class Model: def __init__(self): logg = logging.getLogger(f"c.{__class__.__name__}.init") logg.info("Start init") self._out_fold_not_set = "Not set not known" self.output_folder = Observable(self._out_fold_not_set) self.input_folders = Observable({}) # dict to send photo_info -> only active photos self.photo_info_list_active = Observable({}) # remember all PhotoInfo loaded self._photo_info_list_all = {} # dict to send (selection_info, status) -> keep in de-selected one self.selection_list = Observable({}) # full path of the current_photo_prim self.current_photo_prim = Observable("") # index in self._active_photo_list of current photo self._index_prim = 0 # full path of the current_photo_echo self.current_photo_echo = Observable("") self._index_echo = 0 # set of valid photo extensions to load in _active_photo_list self._is_photo_ext = set((".jpg", ".jpeg", ".JPG", ".png")) # thumbnail size for ThumbButtons self._thumb_size = 50 # how much to move the image from keyboard self._mov_delta = 200 # ImageTk that holds the cropped picture self.cropped_prim = Observable(None) self.cropped_echo = Observable(None) # cache dimension for the Holder self._cropper_cache_dim = 10 self._loaded_croppers = Holder(self._cropper_cache_dim) self._widget_wid = -1 self._widget_hei = -1 # dict of metadata {name:value} self.metadata_prim = Observable({}) self.metadata_echo = Observable(None) self._load_named_metadata() # setup layout info self._layout_tot = 6 self._layout_is_double = (1, 5) self.layout_current = Observable(0) self._old_single_layout = 0 # double layout to jump to when swapDoubleLayout is called self._basic_double_layout = 1 def setOutputFolder(self, output_folder_full): logg = logging.getLogger(f"c.{__class__.__name__}.setOutputFolder") logg.info(f"Setting output_folder to '{output_folder_full}'") logui = logging.getLogger("UI") logui.info(f"Setting output folder to '{output_folder_full}'") # create the folder if it doesn't exist if not isdir(output_folder_full): logui.info(f"Not a folder '{output_folder_full}', creating it") makedirs(output_folder_full) self.output_folder.set(output_folder_full) def addInputFolder(self, input_folder_full): logg = logging.getLogger(f"c.{__class__.__name__}.addInputFolder") logg.info(f"Adding new input_folder '{input_folder_full}'") logui = logging.getLogger("UI") # check for folder existence if not isdir(input_folder_full): logui.error(f"Not a valid folder: {input_folder_full}") return 1 logui.info(f"Selected new input folder: '{input_folder_full}'") old_folders = self.input_folders.get() if input_folder_full in old_folders: logui.warn("Selected folder already in input list.") return 0 old_folders[input_folder_full] = True self.input_folders.set(old_folders) self.updatePhotoInfoList() def toggleInputFolder(self, state): logg = logging.getLogger(f"c.{__class__.__name__}.toggleInputFolder") # logg.setLevel("TRACE") logg.info("Setting new input_folder state") state = {x: state[x].get() for x in state} logg.trace(f"state {state}") if sum((state[x] for x in state)) > 0: # at least one still toggled self.input_folders.set(state) else: # no folders toggled, revert to previous state logui = logging.getLogger("UI") logui.warn("At least one input folder has to be selected.") self.input_folders._docallbacks() self.updatePhotoInfoList() def saveSelection(self): logg = logging.getLogger(f"c.{__class__.__name__}.saveSelection") # logg.setLevel("TRACE") logg.info("Saving selected pics") logui = logging.getLogger("UI") # check that output_folder is set output_folder = self.output_folder.get() if output_folder == self._out_fold_not_set: logui.warn( "Set the output folder before saving the selection list") return # get current selection_list, {pic: [PhotoInfo, is_selected] } selection_list = self.selection_list.get() logg.trace(selection_list) # keep only selected active_selection = tuple(p for p in selection_list if selection_list[p][1]) if len(active_selection) == 0: logui.warn("No active pic in selection list") return elif len(active_selection) == 1: s = "" else: s = "s" logui.info(f"Saving {len(active_selection)} pic{s} in {output_folder}") out_fol_content = set(listdir(output_folder)) for pic in active_selection: base_pic = basename(pic) if base_pic in out_fol_content: logui.warn(f"{base_pic} already in output folder, skipping it") # MAYBE copy it with changed name else: copy2(pic, output_folder) def setLayout(self, lay_num): logg = logging.getLogger(f"c.{__class__.__name__}.setLayout") logg.info(f"Setting layout_current to '{lay_num}'") self.layout_current.set(lay_num) # if the new layout is not double, reset the values for meta_echo if not self.layout_current.get() in self._layout_is_double: self.metadata_echo.set(None) def cycleLayout(self): logg = logging.getLogger(f"c.{__class__.__name__}.cycleLayout") # logg.setLevel("TRACE") logg.info("Cycling layout") old_layout = self.layout_current.get() new_layout = (old_layout + 1) % self._layout_tot self.setLayout(new_layout) # if the new layout is double and the old is not, sync echo and prim indexes if new_layout in self._layout_is_double and ( old_layout not in self._layout_is_double): self.moveIndexEcho("sync") def swapDoubleLayout(self): """Go from a single to a double layout and back Save the current one if it's single, and go back to that """ logg = logging.getLogger(f"c.{__class__.__name__}.swapDoubleLayout") # logg.setLevel("TRACE") logg.info("Swapping layout") # the layout is double: go back to saved single layout if self.layout_current.get() in self._layout_is_double: self.setLayout(self._old_single_layout) # the layout is single: save it and go to double else: self._old_single_layout = self.layout_current.get() self.setLayout(self._basic_double_layout) # also sync echo to prim self.moveIndexEcho("sync") def updatePhotoInfoList(self): """Update photo_info_list_active, load new photos and relative info photo_info_list_active is actually a dict of PhotoInfo objects Has to load the thumbnail and metadata """ logg = logging.getLogger(f"c.{__class__.__name__}.updatePhotoInfoList") # logg.setLevel("TRACE") logg.info("Update photo_info_list_active") # list of filenames of active photos: ideally parallel to # photo_info_list_active.keys() but dict order can't be trusted so we # keep track here of the index # TODO the list passed to view to sort in special way # TODO sort list according to metadata # hopefully loading them will be fast, all will be needed to sort self._active_photo_list = [] input_folders = self.input_folders.get() for folder in input_folders: # the folder is not toggled, skip it if input_folders[folder] is False: continue for photo in listdir(folder): photo_full = join(folder, photo) if self._is_photo(photo_full): self._active_photo_list.append(photo_full) new_photo_info_active = {} for photo_full in self._active_photo_list: # load new photos in _photo_info_list_all if photo_full not in self._photo_info_list_all: self._photo_info_list_all[photo_full] = PhotoInfo( photo_full, self._thumb_size) # collect the active PhotoInfo object in the new dict new_photo_info_active[photo_full] = self._photo_info_list_all[ photo_full] logg.info( f"photo_info_list_active has now {len(new_photo_info_active)} items" ) logui = logging.getLogger("UI") logui.info( f"There are now {len(new_photo_info_active)} images in the active list." ) self.photo_info_list_active.set(new_photo_info_active) current_photo_prim = self.current_photo_prim.get() if current_photo_prim in self._active_photo_list: # the photo is still in list: if needed update the index self._index_prim = self._active_photo_list.index( current_photo_prim) else: # the photo is not in list anymore: reset to 0 self._index_prim = 0 self._update_photo_prim(self._active_photo_list[0]) # reset the echo index to follow prim self._index_echo = self._index_prim # reload the echo image self._update_photo_echo(self._active_photo_list[self._index_echo]) def _is_photo(self, photo_full): _, photo_ext = splitext(photo_full) return photo_ext in self._is_photo_ext def setIndexPrim(self, index_prim): logg = logging.getLogger(f"c.{__class__.__name__}.setIndexPrim") logg.info(f"Setting index_prim to {index_prim}") self._index_prim = index_prim self._update_photo_prim(self._active_photo_list[index_prim]) def moveIndexPrim(self, direction): logg = logging.getLogger(f"c.{__class__.__name__}.moveIndexPrim") logg.info(f"Moving index prim {direction}") if direction == "forward": new_index_prim = self._index_prim + 1 elif direction == "backward": new_index_prim = self._index_prim - 1 new_index_prim = new_index_prim % len( self.photo_info_list_active.get()) self.setIndexPrim(new_index_prim) def seekIndexPrim(self, pic): logg = logging.getLogger(f"c.{__class__.__name__}.seekIndexPrim") logg.info("Seeking index prim") # MAYBE the pic is not in _active_photo_list... very weird, add guards? self._index_prim = self._active_photo_list.index(pic) self._update_photo_prim(pic) def _update_photo_prim(self, pic_prim): """Change what is needed for a new pic_prim - current_photo_prim - cropped_prim - prim metadata """ logg = logging.getLogger(f"c.{__class__.__name__}._update_photo_prim") # logg.setLevel("TRACE") logg.info(f"Updating photo prim, index {self._index_prim}") self.current_photo_prim.set(pic_prim) # resets zoom level and pos for the new photo; can only be done AFTER # mainloop starts, during initialization Model.doResize has not been # called yet, and the widget dimensions are still undefined; # the first time reset_image will be called by the Configure event later if self._widget_wid != -1: crop_prim = self._loaded_croppers.get_cropper(pic_prim) crop_prim.reset_image(self._widget_wid, self._widget_hei) self.cropped_prim.set(crop_prim.image_res) # if the layout is double, copy the new zoom level to echo pic if self.layout_current.get() in self._layout_is_double: self._cloneParams() # get the metadata for the image metadata_exif_prim = self._photo_info_list_all[pic_prim].get_metadata() metadata_named_prim = self._parse_metadata(metadata_exif_prim) self.metadata_prim.set(metadata_named_prim) def setIndexEcho(self, index_echo): logg = logging.getLogger(f"c.{__class__.__name__}.setIndexEcho") logg.info(f"Setting index_echo to {index_echo}") self._index_echo = index_echo self._update_photo_echo(self._active_photo_list[index_echo]) def moveIndexEcho(self, direction): logg = logging.getLogger(f"c.{__class__.__name__}.moveIndexEcho") logg.info(f"Moving index echo {direction}") if not self.layout_current.get() in self._layout_is_double: # TODO move index prim in this case logg.warn("Current layout is not double, can't move index echo") return if direction == "forward": new_index_echo = self._index_echo + 1 elif direction == "backward": new_index_echo = self._index_echo - 1 elif direction == "sync": new_index_echo = self._index_prim new_index_echo = new_index_echo % len( self.photo_info_list_active.get()) self.setIndexEcho(new_index_echo) def _update_photo_echo(self, pic_echo): """Change what is needed for a new pic_echo in echo frame - current_photo_echo - cropped_echo If _index_echo == _index_prim do not recompute image crop """ logg = logging.getLogger(f"c.{__class__.__name__}._update_photo_echo") logg.info(f"Updating photo echo, index {self._index_echo}") self.current_photo_echo.set(pic_echo) if self._widget_wid != -1: self._cloneParams() if self.layout_current.get() in self._layout_is_double: # get the metadata for the image metadata_exif_echo = self._photo_info_list_all[ pic_echo].get_metadata() metadata_named_echo = self._parse_metadata(metadata_exif_echo) self.metadata_echo.set(metadata_named_echo) else: self.metadata_echo.set(None) def likePressed(self, which_frame): """Update selection_list accordingly""" logg = logging.getLogger(f"c.{__class__.__name__}.likePressed") # logg.setLevel("TRACE") logg.info(f"Like pressed on {which_frame}") # if the layout is not double consider the event from prim cur_lay_is_double = self.layout_current.get() in self._layout_is_double if (not cur_lay_is_double) and which_frame == "echo": which_frame = "prim" if which_frame == "prim": new_pic = self.current_photo_prim.get() elif which_frame == "echo": new_pic = self.current_photo_echo.get() else: logg.error(f"Unrecognized frame {which_frame}") old_selection_list = self.selection_list.get() if new_pic in old_selection_list: # if it was in selection_list already, toggle is_selected old_selection_list[new_pic][1] = not old_selection_list[new_pic][1] else: # add to dict (PhotoInfo, is_selected) old_selection_list[new_pic] = [ self._photo_info_list_all[new_pic], True ] self.selection_list.set(old_selection_list) def toggleSelectionPic(self, pic): logg = logging.getLogger(f"c.{__class__.__name__}.toggleSelectionPic") # logg.setLevel("TRACE") logg.info(f"Toggling selection_list {pic}") old_selection_list = self.selection_list.get() old_selection_list[pic][1] = not old_selection_list[pic][1] self.selection_list.set(old_selection_list) def doResize(self, widget_wid, widget_hei): """Triggered by a configure event in the Label Also saves Label dimension, so that when the photo is changed, the new crop can be computed """ logg = logging.getLogger(f"c.{__class__.__name__}.doResize") # logg.setLevel("TRACE") logg.info("Do resize") self._widget_wid = widget_wid self._widget_hei = widget_hei # get the current_photo_prim full name pic_prim = self.current_photo_prim.get() # reset the image with the new widget dimension: # get the cropper for the image crop_prim = self._loaded_croppers.get_cropper(pic_prim) # reset the image zoom/pos crop_prim.reset_image(self._widget_wid, self._widget_hei) # update the Observable self.cropped_prim.set(crop_prim.image_res) if self.layout_current.get() in self._layout_is_double: # clone params to echo self._cloneParams() def zoomImage(self, direction, rel_x=-1, rel_y=-1): logg = logging.getLogger(f"c.{__class__.__name__}.zoomImage") # logg.setLevel("TRACE") logg.trace(f"Zooming in direction {direction}") # get current prim pic pic_prim = self.current_photo_prim.get() # get the cropper for the image crop_prim = self._loaded_croppers.get_cropper(pic_prim) # zoom the image crop_prim.zoom_image(direction, rel_x, rel_y) # update the Observable self.cropped_prim.set(crop_prim.image_res) if self.layout_current.get() in self._layout_is_double: self._cloneParams() def moveImageDirection(self, direction): """Move image in the specified direction of self._mov_delta""" logg = logging.getLogger(f"c.{__class__.__name__}.moveImageDirection") # logg.setLevel("TRACE") logg.trace(f"Moving in direction {direction}") if direction == "right": self._moveImage(self._mov_delta, 0) elif direction == "left": self._moveImage(-self._mov_delta, 0) elif direction == "up": self._moveImage(0, -self._mov_delta) elif direction == "down": self._moveImage(0, self._mov_delta) def moveImageMouse(self, mouse_x, mouse_y): """Move the image to follow the mouse""" logg = logging.getLogger(f"c.{__class__.__name__}.moveImageMouse") # logg.setLevel("TRACE") logg.trace("Moving mouse") delta_x = self._old_mouse_x - mouse_x delta_y = self._old_mouse_y - mouse_y self._old_mouse_x = mouse_x self._old_mouse_y = mouse_y self._moveImage(delta_x, delta_y) def saveMousePos(self, mouse_x, mouse_y): """Save the current mouse position""" self._old_mouse_x = mouse_x self._old_mouse_y = mouse_y def _moveImage(self, delta_x, delta_y): """Actually move image of specified delta""" logg = logging.getLogger(f"c.{__class__.__name__}._moveImage") # logg.setLevel("TRACE") logg.trace(f"Moving delta {delta_x} {delta_y}") # get current prim pic pic_prim = self.current_photo_prim.get() # get the cropper for the image crop_prim = self._loaded_croppers.get_cropper(pic_prim) # move the image crop_prim.move_image(delta_x, delta_y) # update the Observable self.cropped_prim.set(crop_prim.image_res) # if double, move echo as well if self.layout_current.get() in self._layout_is_double: self._cloneParams() def _cloneParams(self): """Clone current prim params to echo image""" # MAYBE the check for doubleness of the layout can be done here # cloning only makes sense if it's double after all logg = logging.getLogger(f"c.{__class__.__name__}._cloneParams") # logg.setLevel("TRACE") logg.trace("Cloning params") # get current prim pic pic_prim = self.current_photo_prim.get() # get the cropper for the image crop_prim = self._loaded_croppers.get_cropper(pic_prim) # get current echo pic pic_echo = self.current_photo_echo.get() # get the cropper for the image crop_echo = self._loaded_croppers.get_cropper(pic_echo) # get params from prim params = crop_prim.get_params() # copy them in echo crop_echo.load_params(params) # update echo observable self.cropped_echo.set(crop_echo.image_res) def _load_named_metadata(self): """Populate two dicts that map a readable name in the metadata field""" self.name2exif = {} self.name2exif["Date taken"] = "Image DateTime" # "EXIF DateTimeOriginal", # "EXIF DateTimeDigitized", self.name2exif["Exposure time"] = "EXIF ExposureTime" self.name2exif["Aperture"] = "EXIF FNumber" # self.name2exif["Program"] = "EXIF ExposureProgram" self.name2exif["ISO"] = "EXIF ISOSpeedRatings" self.name2exif["Width"] = "PILWidth" self.name2exif["Height"] = "PILHeight" def _parse_metadata(self, metadata_exif): """Parse raw EXIF metadata into named ones""" logg = logging.getLogger(f"c.{__class__.__name__}._parse_metadata") # logg.setLevel("TRACE") logg.info("Parsing metadata") metadata_named = {} # translate the names from EXIF to readable, set default values for name in self.name2exif: exif_name = self.name2exif[name] if exif_name in metadata_exif: if name == "Aperture": logg.trace(f"{str(metadata_exif[exif_name])}") fra = Fraction(str(metadata_exif[exif_name])) metadata_named[name] = fra.numerator / fra.denominator else: metadata_named[name] = metadata_exif[exif_name] else: metadata_named[name] = "-" logg.trace(f"{name}: {metadata_named[name]}") return metadata_named