diff --git a/cellpose/gui/gui.py b/cellpose/gui/gui.py index 8aa8677b..ff3ae5b6 100644 --- a/cellpose/gui/gui.py +++ b/cellpose/gui/gui.py @@ -54,8 +54,6 @@ def __init__(self, parent, name, color): """) self.show() - - def levelChanged(self, parent): parent.level_change(self.name) @@ -147,6 +145,15 @@ def make_cmap(cm=0): return cmap def rgb_to_hex(rgb_tuple): + """ + Converts an RGB tuple to a hex color string. + + Args: + rgb_tuple (tuple): The RGB tuple (e.g., (255, 0, 0) for red). + + Returns: + str: The hex color string (e.g., '#ff0000' for red). + """ return '#{:02x}{:02x}{:02x}'.format(rgb_tuple[0], rgb_tuple[1], rgb_tuple[2]) @@ -308,6 +315,9 @@ def __init__(self, image=None, logger=None): self.reset() self.minimap_window_instance = None + # if the view of the image is changed, the method onViewChanged is called + self.p0.sigRangeChanged.connect(self.onViewChanged) + # Custom multi-page tiff image stack self.grayscale_image_stack = [] self.colors_stack = [] @@ -364,8 +374,6 @@ def minimap_window(self): self.minimap_window_instance.deleteLater() self.minimap_window_instance = None - - def color_initialization(self): """ Initializes the color stack by assigning every layer a standard color by repeating colors in the list. @@ -514,6 +522,55 @@ def center_view_on_position(self, normalized_x, normalized_y): self.p0.setXRange(*new_x_range, padding=0) self.p0.setYRange(*new_y_range, padding=0) + def onViewChanged(self): + """ + This function is called whenever the view of the image in the view box is changed. + This includes it being zoomed in or zoomed out, as well as it being moved around. + It normalizes coordinates and dimensions of the view box in relation to the current image. + + Returns: + Normalized coordinates of the view box (normalized_x, normalized_y) + Normalized dimensions of the view box (normalized_width, normalized_height) + """ + + # Access the positional values of the view box p0 in form of a rectangle using viewRect() + view_rect = self.p0.viewRect() + + # Extract the x and y coordinates of the view box + x_coordinates = [view_rect.left(), view_rect.right()] + y_coordinates = [view_rect.top(), view_rect.bottom()] + + # Extract the dimensions of the view box + width = view_rect.width() + height = view_rect.height() + + try: + + # Get the size of the image + img_height = self.img.image.shape[0] + img_width = self.img.image.shape[1] + + # Calculate the normalized coordinates in relation to the image size + normalized_x = tuple( + coordinate / img_width for coordinate in x_coordinates) + normalized_y = tuple( + coordinate / img_height for coordinate in y_coordinates) + + # Calculate the normalized dimensions + normalized_width = width / img_width + normalized_height = height / img_height + + # Set the highlight area in the minimap window + self.minimap_window_instance.set_highlight_area(normalized_x[0], normalized_y[0], normalized_width, normalized_height) + + return normalized_x, normalized_y, normalized_width, normalized_height + + except Exception as e: + + # if an exception of any kind occurs, the specific exception is printed to the console + print(f"An error occurred while changing the view: {e}") + + def generate_multi_channel_ui(self, n): c = 0 # Position der Elemente im Layout @@ -617,25 +674,32 @@ def make_buttons(self): self.autobtn.setChecked(True) self.satBoxG.addWidget(self.autobtn, b0, 1, 1, 8) - + #--- Initialization of Non-Tiff cases ---# c = 0 # position of the elements in the right side menu + # Define color names and labels for non-TIFF images + colornames = ["red", "Chartreuse", "DodgerBlue"] + names = ["red", "green", "blue"] + colors = ["red", "green", "blue"] + + # Initialize sliders list self.sliders = [] # ---Create a list (extendable) of color/on-off buttons ---# - colors = ["red", "green", "blue"] # self.marker_buttons = [self.create_color_button(color, None) for color in colors] # self.on_off_buttons = [self.create_on_off_button(i) for i in range self.] + # Add labels and sliders for non-TIFF images for r in range(3): c += 1 - label = QLabel(f'Marker {r + 1}') # create a label for each marker - # color_button = self.marker_buttons[r] # get the corresponding color button - self.marker_buttons = [self.create_color_button(color, None) for color in colors] - # on_off_button = self.on_off_buttons[r] # get the corresponding on-off button - label.setStyleSheet("color: white") - label.setFont(self.boldmedfont) + # Create a label for each color channel + if r == 0: + label = QLabel('gray/
red') + else: + label = QLabel(names[r] + ":") + label.setStyleSheet(f"color: {colornames[r]}") self.rightBoxLayout.addWidget(label, c, 0, 1, 1) + # self.rightBoxLayout.addWidget(color_button, c, 9, 1, 1) # add the color button to the layout # self.rightBoxLayout.addWidget(on_off_button, c, 10, 1, 1) # add the on-off button to the layout self.sliders.append(Slider(self, colors[r], None)) @@ -1230,7 +1294,7 @@ def get_color_button_style(self, color_name): height: 12px; width: 12px; }} - """ + """ def set_image_opacity(self, image, opacity): @@ -1298,12 +1362,12 @@ def adjust_channel_bounds(self, channel, bounds): def level_change(self, r): - if self.tiff_loaded: - if int(r) < len(self.sliders): + if self.tiff_loaded: # if tiff is loaded, sliders adjust contrast of each layer + if int(r) < len(self.sliders): # make sure the list of sliders already filled r_index = r if self.on_off_buttons[r].isChecked(): - self.adjust_channel_bounds(r_index, self.sliders[r_index].value()) - self.update_plot() + self.adjust_channel_bounds(r_index, self.sliders[r_index].value()) # adjust contrast of layer + self.update_plot() # update plot else: r = ["red", "green", "blue"].index(r) @@ -1504,6 +1568,7 @@ def disable_buttons_removeROIs(self): self.saveSet.setEnabled(False) self.savePNG.setEnabled(False) self.saveFlows.setEnabled(False) + self.saveFeaturesCsv.setEnabled(False) self.saveOutlines.setEnabled(False) self.saveROIs.setEnabled(False) self.minimapWindow.setEnabled(False) @@ -1532,6 +1597,26 @@ def toggle_saving(self): self.saveOutlines.setEnabled(False) self.saveROIs.setEnabled(False) + def toggle_save_features_csv(self): + """ + Toggles the save features csv button based on the image file type. + If the image is a tiff or tif file, the button is enabled. Otherwise, it is disabled. + This method is called after a segmentation has taken place. + + Args: + None + + Returns: + None + """ + + filetype = imghdr.what(self.filename) + + if filetype in ['tiff', 'tif']: + self.saveFeaturesCsv.setEnabled(True) + else: + self.saveFeaturesCsv.setEnabled(False) + def toggle_removals(self): if self.ncells > 0: self.ClearButton.setEnabled(True) diff --git a/cellpose/gui/guiparts.py b/cellpose/gui/guiparts.py index c6ad4389..12223de1 100644 --- a/cellpose/gui/guiparts.py +++ b/cellpose/gui/guiparts.py @@ -451,6 +451,20 @@ def __init__(self, parent=None): # This marks the menu button as checked parent.minimapWindow.setChecked(True) + # Create and add a highlight rectangle to the minimap with initial position [0, 0] and size [100, 100], outlined + # in white with a 3-pixel width. + self.highlight_area = pg.RectROI([0, 0], [100, 100], pen=pg.mkPen('w', width=3), resizable=False, movable=False) + self.highlight_area.hoverEvent = lambda event: None + # Remove all resize handles after initialization + QtCore.QTimer.singleShot(0, lambda: [self.highlight_area.removeHandle(handle) for handle in + self.highlight_area.getHandles()]) + + + # Set the highlight area to cover the entire image by default + self.set_highlight_area(0, 0, 1, 1) + # Add the highlight area to the viewbox + self.viewbox.addItem(self.highlight_area) + def closeEvent(self, event: QEvent): """ Method to uncheck the button in the menu if the window is closed. @@ -501,6 +515,45 @@ def update_minimap(self, parent): else: self.setFixedSize(self.minimapSize, self.minimapSize) + def set_highlight_area(self, normalized_x, normalized_y, normalized_width, normalized_height): + """ + Method to set the highlight area on the minimap. + The position and size of the rectangle are set based on the calculated normalized coordinates from the + onViewChanged method. + + Parameters: + normalized_x (float): Normalized x-coordinate for the position. + normalized_y (float): Normalized y-coordinate for the position. + normalized_width (float): Normalized width for the size. + normalized_height (float): Normalized height for the size. + + Returns: + tuple: The calculated (x, y, width, height) coordinates. + """ + + if self.parent().img.image is not None: + # Retrieve the height and width of the image + img_height = self.parent().img.image.shape[0] + img_width = self.parent().img.image.shape[1] + + # Calculate the position and size of the highlight area based on the normalized coordinates + x = normalized_x * img_width + y = normalized_y * img_height + width = normalized_width * img_width + height = normalized_height * img_height + + # Set the position of the rectangle area on the minimap + # Move the rectangle to the calculated position + self.highlight_area.setPos(x, y) + + # Set the size of the rectangle on the minimap + # Adjust the rectangle's size to the calculated width and height + self.highlight_area.setSize([width, height]) + + else: + print("Error: No image loaded in parent.") + + def sliderValueChanged(self, value): """ Method to change the size of the minimap based on the slider value. diff --git a/cellpose/gui/io.py b/cellpose/gui/io.py index 20718c6d..3ed61b6b 100644 --- a/cellpose/gui/io.py +++ b/cellpose/gui/io.py @@ -175,6 +175,8 @@ def _load_image(parent, filename=None, load_seg=True, load_3D=False): parent.minimap_window_instance = guiparts.MinimapWindow(parent) parent.minimap_window_instance.show() + parent.saveFeaturesCsv.setEnabled(False) + def _initialize_images(parent, image, load_3D=False): """ format image for GUI """ @@ -503,6 +505,8 @@ def _load_masks(parent, filename=None): if parent.ncells > 0: parent.draw_layer() parent.toggle_mask_ops() + # features can only be saved if masks are loaded + parent.toggle_save_features_csv() del masks gc.collect() parent.update_layer() @@ -582,6 +586,8 @@ def _masks_to_gui(parent, masks, outlines=None, colors=None): if parent.ncells > 0: parent.draw_layer() parent.toggle_mask_ops() + # features can only be saved if masks are loaded + parent.toggle_save_features_csv() parent.ismanual = np.zeros(parent.ncells, bool) parent.zdraw = list(-1 * np.ones(parent.ncells, np.int16)) @@ -591,6 +597,7 @@ def _masks_to_gui(parent, masks, outlines=None, colors=None): else: parent.ViewDropDown.setCurrentIndex(0) + def _save_features_csv(parent): """ Saves features to CSV if dataset is 2D. @@ -605,11 +612,18 @@ def _save_features_csv(parent): return filename = parent.filename + base = os.path.splitext(filename)[0] + "_features.csv" + # check if the dataset is 2D (NZ == 1 implies a single z-layer) if parent.NZ == 1: print("GUI_INFO: saving features to CSV file") - save_features_csv(parent.filename) + # this gives us the channels that are currently loaded by converting the images to an array + stacked_images = parent.convert_images_to_array(parent.grayscale_image_stack) + # reduction to only the relevant light intensity channel + channels = stacked_images[:, :, :, 1] + # save the features to a CSV file using method in cellpose.io + save_features_csv(parent.filename, parent.cellpix, channels) else: print("ERROR: cannot save features") diff --git a/cellpose/gui/menus.py b/cellpose/gui/menus.py index 74d9fe0a..5af6702b 100644 --- a/cellpose/gui/menus.py +++ b/cellpose/gui/menus.py @@ -81,12 +81,13 @@ def mainmenu(parent): This adds a new menu item for saving features as a .csv file. The user can activate this function to export specific data directly from the GUI. The function `_save_features_as_csv` from the `io` module is called when the user clicks on the menu item. + It is disabled by default and only activated if an image is segmented and of the type tif/tiff. """ parent.saveFeaturesCsv = QAction("Save Features as .&csv", parent) parent.saveFeaturesCsv.setShortcut("Ctrl+Shift+C") parent.saveFeaturesCsv.triggered.connect(lambda: io._save_features_csv(parent)) file_menu.addAction(parent.saveFeaturesCsv) - parent.saveFeaturesCsv.setEnabled(True) + parent.saveFeaturesCsv.setEnabled(False) """ This creates a new menu item for the minimap that the user can activate. diff --git a/cellpose/io.py b/cellpose/io.py index 115d3c95..2433e7e5 100644 --- a/cellpose/io.py +++ b/cellpose/io.py @@ -69,6 +69,7 @@ def logger_setup(): return logger, log_file + from . import utils, plot, transforms @@ -561,11 +562,17 @@ def masks_flows_to_seg(images, masks, flows, file_names, diams=30., channels=Non np.save(base + "_seg.npy", dat) -def save_features_csv(file_name): +def save_features_csv(file_name, cellpix, channels): """ - Save features to .csv file and remove if it already exists + This method saves the features of a segmentation to .csv file and replaces the old one if it exists. + It is saved in the folder containing the current image. + The features are the average marker intensity for each cell in each channel. + They are calculates in form of a matrix: Rows represent cells, columns represent channels. + Args: file_name (str): Target CSV file name + cellpix (np.ndarray): Mask array where each cell has a unique ID + channels (ist of np.ndarray): List of channel images Returns: None @@ -573,9 +580,55 @@ def save_features_csv(file_name): file_name = os.path.splitext(file_name)[0] + "_cp_features.csv" if os.path.exists(file_name): os.remove(file_name) + + # Get unique cell ids (excluding background which is assumed to be 0) + cell_ids = np.unique(cellpix) + cell_ids = cell_ids[cell_ids != 0] + + # Number of cells and channels + num_cells = len(cell_ids) + num_channels = len(channels) + + # Initialize feature matrix + features = np.zeros((num_cells, num_channels)) + + # Calculate features for each cell and each channel + for i, cell_id in enumerate(cell_ids): + # Create a mask for the current cell + cell_mask = cellpix == cell_id + # Returns a boolean matrix indicating the location of the current cell + + # Count the number of pixels in the current cell + num_pixels = np.sum(cell_mask) + + # Skip the cell if it has no pixels + if num_pixels == 0: + continue + + # Calculate the feature for each channel + for j in range(num_channels): + + # Sum the marker intensities for the current cell in the current channel + marker_intensity_sum = np.sum(channels[j] * cell_mask) + + # Calculate the average marker intensity for the current cell in the current channel + features[i, j] = marker_intensity_sum / num_pixels + + # Create row and column labels + row_labels = [f'cell {i + 1}' for i in range(num_cells)] + column_labels = [f'marker {j + 1}' for j in range(num_channels)] + + # creating a csv file or clearing the existing one with open(file_name, mode='w', newline='') as f: - # creating an empty csv file or clearing the existing one - pass + + writer = csv.writer(f) + + # Write the header + writer.writerow([''] + column_labels) + + # Write the data + for i, row_label in enumerate(row_labels): + writer.writerow([row_label] + list(features[i])) def save_to_png(images, masks, flows, file_names): """ deprecated (runs io.save_masks with png=True)