def create_reactive_image_grid(n_row, n_col, image_list, image_data, image_path): """ Get an HTML element corresponding to the responsive image grid. Args: n_rows = int, current number of rows in the grid (Input: indicates resizing) n_cols = int, current number of columns in the grid (Input: indicates resizing) image_list = list, containing a list of file paths where the valid images for the chosen directory are stored image_data = dict, with keys 'position' (for visible grid locations) and 'keep' (whether to keep / remove the image) (State) Note: each keys contains a list, of lists of ints, a sequence of data about each completed image group image_path = list, of 1 str, the filepath where the images in image-container were loaded from Returns: html.Div element (containing the grid of images) that can update the responsive-image-grid element """ image_path = image_path[0] # If it doesn't already exist, add an entry (dict) for this image path into the data dictionary if image_path not in image_data: image_data[image_path] = {'position': [], 'keep': [], 'filename': []} # Reduce the image_list by removing the masked images (so they can no longer appear in the image grid / image zoom) flat_mask = utils.create_flat_mask(image_data[image_path]['position'], len(image_list)) image_list = [ img_src for i, img_src in enumerate(image_list) if not flat_mask[i] ] return utils.create_image_grid(n_row, n_col, image_list)
def activate_deactivate_cells(n_rows, n_cols, n_left, n_right, n_up, n_down, n_row1, n_row2, n_row3, n_row4, n_row5, n_row6, n_row7, n_row8, n_row9, n_row1000, n_keep, n_delete, image_list, image_data, image_path, *args): """ Global callback function for toggling classes. There are three toggle modes: 1) Pressing a grid cell will toggle its state 2) Pressing a directional button will force the "last-clicked" focus (only) to shift in the direction stated 3) Resizing the grid will cause the top-left only to be in last-click focus 4) Mark a cell as keep or delete (must already have "grouped-on" class) Note: some of these operations respond to key presses (e.g. directional buttons), which click hidden buttons. Args: n_rows = int, current number of rows in the grid (indicates resizing) n_cols = int, current number of columns in the grid (indicates resizing) n_left = int, number of clicks on the 'move-left' button (indicates shifting) n_right = int, number of clicks on the 'move-right' button (indicates shifting) n_up = int, number of clicks on the 'move-up' button (indicates shifting) n_down = int, number of clicks on the 'move-down' button (indicates shifting) n_row1,..9,1000 = int, number of clicks on the 'select row *' button (indicates shortcut to select many rows) n_keep = int, number of clicks on the 'keep-button' button n_delete = int, number of clicks on the 'delete-button' button image_list = list, of str, specifying where the image files are stored image_data = dict, of dict of lists of ints, a sequence of metadata about completed image groups image_path = str, the filepath where the images in image-container were loaded from *args = positional arguments split into about two equal halves (actually length 2 x N_GRID + 1): 0) args[:N_GRID] are Inputs (activated by the grid-Buttons) 1) args[N_GRID:-1] are States (indicating state of the grid-Tds) Both are in row-major order (for i in rows: for j in cols: ... ) 2) args[-1] is a tuple representing the last clicked cell Returns: a list of new classNames for all the grid cells plus - one extra element for the Image that was last clicked (zoomed image) - one extra element representing the cell that was last clicked (in focus) """ # Unpack the single-element list image_path = image_path[0] if image_path not in image_data: image_data[image_path] = {'position': [], 'keep': [], 'filename': []} # Reduce the image_list by removing the masked images (so they can no longer appear in the image grid / image zoom) flat_mask = utils.create_flat_mask(image_data[image_path]['position'], len(image_list)) image_list = [img for i, img in enumerate(image_list) if not flat_mask[i]] # Find the button that triggered this callback (if any) context = dash.callback_context if not context.triggered: return utils.resize_grid_pressed(image_list=image_list, rows_max=ROWS_MAX, cols_max=COLS_MAX, empty_image=EMPTY_IMAGE, zoom_img_style=config.IMG_STYLE_ZOOM) else: button_id = context.triggered[0]['prop_id'].split('.')[0] # Reset the grid # Note: image-container is not really a button, but fired when confirm-load-directory is pressed (we need the list # inside image-container in order to populate the grid) if button_id in [ 'choose-grid-size', 'image-container', 'image-meta-data', 'loaded-image-path' ]: return utils.resize_grid_pressed(image_list=image_list, rows_max=ROWS_MAX, cols_max=COLS_MAX, empty_image=EMPTY_IMAGE, zoom_img_style=config.IMG_STYLE_ZOOM) # Toggle the state of this button (as it was pressed) elif 'grid-button-' in button_id: current_classes, zoomed_img, cell_last_clicked = utils.image_cell_pressed( button_id, n_cols, COLS_MAX, ROWS_MAX * COLS_MAX, image_list, EMPTY_IMAGE, config.IMG_STYLE_ZOOM, *args) return current_classes + [zoomed_img, cell_last_clicked] # Toggle the grouping state of all cells in the first rows of the grid elif 'select-row-upto-' in button_id: n_rows = int( re.findall('select-row-upto-([0-9]+)-button', button_id)[0]) current_classes, zoomed_img, cell_last_clicked = utils.toggle_group_in_first_n_rows( n_rows, n_cols, ROWS_MAX, COLS_MAX, image_list, EMPTY_IMAGE, config.IMG_STYLE_ZOOM, *args) return current_classes + [zoomed_img, cell_last_clicked] # Harder case: move focus in a particular direction elif 'move-' in button_id: current_classes, zoomed_img, cell_last_clicked = utils.direction_key_pressed( button_id, n_rows, n_cols, COLS_MAX, ROWS_MAX * COLS_MAX, image_list, EMPTY_IMAGE, config.IMG_STYLE_ZOOM, *args) return current_classes + [zoomed_img, cell_last_clicked] elif button_id in ['keep-button', 'delete-button']: current_classes, zoomed_img, cell_last_clicked = utils.keep_delete_pressed( button_id, n_cols, COLS_MAX, ROWS_MAX * COLS_MAX, image_list, EMPTY_IMAGE, config.IMG_STYLE_ZOOM, *args) return current_classes + [zoomed_img, cell_last_clicked] else: raise ValueError('Unrecognized button ID: %s' % str(button_id))
def complete_or_undo_image_group(n_group, n_undo, n_rows, n_cols, image_list, image_data, image_path, n_images, *args): """ Updates the image_mask by appending / deleting relevant info to / from it. This happens when either 'Complete group' or Undo' button is clicked. We also delete (resp. recreate) the unwanted files when a valid completion (resp. undo) is made (although those files are always backed up in the IMAGE_BACKUP_PATH) and send (resp. delete)the meta data to the specified database: see DATABASE_NAME and DATABASE_TABLE (in config.py). Args: n_group = int, number of times the complete-group button is clicked (Input) n_undo = int, number of times the undo-button is clicked (Input) n_rows = int, current number of rows in the grid (Input: indicates resizing) n_cols = int, current number of columns in the grid (Input: indicates resizing) image_list = list, containing a list of file paths where the valid images for the chosen directory are stored image_data = dict, with keys 'position' (for visible grid locations) and 'keep' (whether to keep / remove the image) (State) Note: each keys contains a list, of lists of ints, a sequence of data about each completed image group image_path = str, the filepath where the images in image-container were loaded from n_images = int, number of images originally loaded in the given directory *args = positional arguments are States (given by the grid-Tds for knowing the class names) Returns: 0) updated version of the image mask (if any new group was legitimately completed) 1) Percentage of images completed so far """ # Find the button that triggered this callback (if any) # Note: also prevent this button from firing when the app first loads (causing the first image to be classified) context = dash.callback_context button_id = context.triggered[0]['prop_id'].split('.')[0] if button_id == 'complete-group': mode = 'complete' elif button_id == 'undo-button': mode = 'undo' else: PreventUpdate return image_data, [0] # Unpack the single-element list image_path = image_path[0] if image_path not in image_data: image_data[image_path] = {'position': [], 'keep': [], 'filename': []} # The image_list (from image-container) contains ALL images in this directory, whereas as the list positions below # will refer to the reduced masked list. In order to obtain consistent filenames, we need to apply the previous version # of the mask to the image_list (version prior to this completion). all_img_filenames = [src.split('/')[-1] for src in image_list] prev_mask = utils.create_flat_mask(image_data[image_path]['position'], len(all_img_filenames)) assert len(all_img_filenames) == len( prev_mask ), "Mask should correspond 1-to-1 with filenames in image-container" unmasked_img_filenames = [ fname for i, fname in enumerate(all_img_filenames) if not prev_mask[i] ] if mode == 'complete': # Extract the image group and their meta data (filename and keep / delete) # Note: Need to adjust for the disconnect between the visible grid size (n_rows * n_cols) and the virtual grid size # (ROWS_MAX * COLS_MAX) focus_position = None focus_filename = None focus_date_taken = None grouped_cell_positions = [] grouped_cell_keeps = [] grouped_filenames = [] grouped_date_taken = [] delete_filenames = [] for i in range(n_rows): for j in range(n_cols): # Get the class list (str) for this cell my_class = args[j + i * COLS_MAX] # Position on the visible grid (mapped to list index) list_pos = j + i * n_rows # As the number of unmasked images shrinks (when the user completes a group, those images disappear), the # list position will eventually run out of the valid indices. As there's no valid metadata in this region # we skip over it if list_pos >= len(unmasked_img_filenames): continue image_filename = unmasked_img_filenames[list_pos] # Check if selected to be in the group, add position if on if 'grouped-on' in my_class: grouped_cell_positions.append(list_pos) grouped_filenames.append(image_filename) grouped_date_taken.append( utils.get_image_taken_date(image_path, image_filename, default_date=None)) if 'focus' in my_class: focus_position = list_pos focus_filename = image_filename focus_date_taken = utils.get_image_taken_date( image_path, image_filename, default_date=None) # Check for keep / delete status # Note: important not to append if keep/delete status not yet specified if 'keep' in my_class: grouped_cell_keeps.append(True) elif 'delete' in my_class: grouped_cell_keeps.append(False) delete_filenames.append(image_filename) else: pass # Check 1: some data has been collected since last click (no point appending empty lists) # Check 2: list lengths match, i.e. for each cell in the group, the keep / delete status has been declared # If either check fails, do nothing # TODO: flag something (warning?) to user if list lengths do not match # TODO: if check 2 fails, it currently junks the data - possible to hold onto it? if len(grouped_cell_positions) > 0 and len( grouped_cell_positions) == len(grouped_cell_keeps): image_data[image_path]['position'].append(grouped_cell_positions) image_data[image_path]['keep'].append(grouped_cell_keeps) image_data[image_path]['filename'].append(grouped_filenames) if not program_args.demo: utils.record_grouped_data( image_data=image_data, image_path=image_path, filename_list=grouped_filenames, keep_list=grouped_cell_keeps, date_taken_list=grouped_date_taken, image_backup_path=IMAGE_BACKUP_PATH, meta_data_fpath=config.META_DATA_FPATH, database_uri=config.DATABASE_URI, database_table=config.DATABASE_TABLE) # This is a small trick for quickly saving (keeping) the focussed image (provided none have been grouped) elif len( grouped_cell_positions ) == 0 and focus_position is not None and focus_filename is not None: image_data[image_path]['position'].append([focus_position]) image_data[image_path]['keep'].append([True]) image_data[image_path]['filename'].append([focus_filename]) if not program_args.demo: utils.record_grouped_data( image_data=image_data, image_path=image_path, filename_list=[focus_filename], keep_list=[True], date_taken_list=[focus_date_taken], image_backup_path=IMAGE_BACKUP_PATH, meta_data_fpath=config.META_DATA_FPATH, database_uri=config.DATABASE_URI, database_table=config.DATABASE_TABLE) else: raise PreventUpdate # Note: n_images is a single-entry list pct_complete = utils.calc_percentage_complete( image_data[image_path]['position'], n_images[0]) return image_data, pct_complete elif mode == 'undo': # Remove the last entry from each list in the metadata (corresponding to the last group) try: _ = image_data[image_path]['position'].pop() _ = image_data[image_path]['keep'].pop() filenames_undo = image_data[image_path]['filename'].pop() if not program_args.demo: utils.undo_last_group( image_data=image_data, image_path=image_path, filename_list=filenames_undo, image_backup_path=IMAGE_BACKUP_PATH, meta_data_fpath=config.META_DATA_FPATH, database_uri=config.DATABASE_URI, database_table=config.DATABASE_TABLE, ) # In case the lists are already empty except IndexError: pass # Note: n_images is a single-entry list pct_complete = utils.calc_percentage_complete( image_data[image_path]['position'], n_images[0]) return image_data, pct_complete else: raise ValueError(f'Unknown mode: {mode}')
def activate_deactivate_cells(n_rows, n_cols, n_left, n_right, n_up, n_down, n_keep, n_delete, image_list, image_data, image_path, *args): """ Global callback function for toggling classes. There are three toggle modes: 1) Pressing a grid cell will toggle its state 2) Pressing a directional button will force the "last-clicked" focus (only) to shift in the direction stated 3) Resizing the grid will cause the top-left only to be in last-click focus 4) Mark a cell as keep or delete (must already have "grouped-on" class) Note: some of these operations respond to key presses (e.g. directional buttons), which click hidden buttons. Args: n_rows = int, current number of rows in the grid (indicates resizing) n_cols = int, current number of columns in the grid (indicates resizing) n_left = int, number of clicks on the 'move-left' button (indicates shifting) n_right = int, number of clicks on the 'move-right' button (indicates shifting) n_up = int, number of clicks on the 'move-up' button (indicates shifting) n_down = int, number of clicks on the 'move-down' button (indicates shifting) n_keep = int, number of clicks on the 'keep-button' button n_delete = int, number of clicks on the 'delete-button' button image_list = list, of str, specifying where the image files are stored image_data = dict, of dict of lists of ints, a sequence of metadata about completed image groups image_path = str, the filepath where the images in image-container were loaded from *args = positional arguments split into two equal halves (i.e. of length 2 x N_GRID): 0) args[:N_GRID] are Inputs (activated by the grid-Buttons) 1) args[N_GRID:] are States (indicating state of the grid-Tds) Both are in row-major order (for i in rows: for j in cols: ... ) Returns: a list of new classNames for all the grid cells (plus one extra element for the Image that was last clicked) Note: args split into two halves: args[:N_GRID] are Inputs (Buttons) args[N_GRID:] are States (Tds) """ # Unpack the single-element list image_path = image_path[0] if image_path not in image_data: image_data[image_path] = {'position': [], 'keep': [], 'filename': []} # Reduce the image_list by removing the masked images (so they can no longer appear in the image grid / image zoom) flat_mask = utils.create_flat_mask(image_data[image_path]['position'], len(image_list)) image_list = [img for i, img in enumerate(image_list) if not flat_mask[i]] # Find the button that triggered this callback (if any) context = dash.callback_context if not context.triggered: class_names = [ 'grouped-off focus' if i + j == 0 else 'grouped-off' for i in range(ROWS_MAX) for j in range(COLS_MAX) ] zoomed_img = html.Img(src=image_list[0], style=config.IMG_STYLE_ZOOM) return class_names + [zoomed_img] else: button_id = context.triggered[0]['prop_id'].split('.')[0] # Reset the grid # Note: image-container is not really a button, but fired when confirm-load-directory is pressed (we need the list # inside image-container in order to populate the grid) if button_id in [ 'choose-grid-size', 'image-container', 'image-meta-data', 'loaded-image-path' ]: return utils.resize_grid_pressed(image_list) # Toggle the state of this button (as it was pressed) elif 'grid-button-' in button_id: return utils.image_cell_pressed(button_id, n_cols, image_list, *args) # Harder case: move focus in a particular direction elif 'move-' in button_id: return utils.direction_key_pressed(button_id, n_rows, n_cols, image_list, *args) elif button_id in ['keep-button', 'delete-button']: return utils.keep_delete_pressed(button_id, n_rows, n_cols, image_list, *args) else: raise ValueError('Unrecognized button ID: %s' % str(button_id))