diff --git a/synaptic_reconstruction/napari.yaml b/synaptic_reconstruction/napari.yaml index 540c3af6..b36c72fc 100644 --- a/synaptic_reconstruction/napari.yaml +++ b/synaptic_reconstruction/napari.yaml @@ -1,5 +1,5 @@ name: synaptic_reconstruction -display_name: Synaptic Reconstruction +display_name: SynapseNet # see https://napari.org/stable/plugins/manifest.html for valid categories categories: ["Image Processing", "Annotation"] contributions: @@ -16,6 +16,9 @@ contributions: - id: synaptic_reconstruction.morphology python_name: synaptic_reconstruction.tools.morphology_widget:MorphologyWidget title: Morphology Analysis + - id: synaptic_reconstruction.vesicle_pooling + python_name: synaptic_reconstruction.tools.vesicle_pool_widget:VesiclePoolWidget + title: Vesicle Pooling readers: - command: synaptic_reconstruction.file_reader @@ -32,3 +35,5 @@ contributions: display_name: Distance Measurement - command: synaptic_reconstruction.morphology display_name: Morphology Analysis + - command: synaptic_reconstruction.vesicle_pooling + display_name: Vesicle Pooling diff --git a/synaptic_reconstruction/tools/base_widget.py b/synaptic_reconstruction/tools/base_widget.py index d2fcdcf1..ca39e8a5 100644 --- a/synaptic_reconstruction/tools/base_widget.py +++ b/synaptic_reconstruction/tools/base_widget.py @@ -1,13 +1,20 @@ +import os from pathlib import Path import napari import qtpy.QtWidgets as QtWidgets +from napari.utils.notifications import show_info from qtpy.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QLabel, QSpinBox, QComboBox, QCheckBox ) from superqt import QCollapsible +try: + from napari_skimage_regionprops import add_table +except ImportError: + add_table = None + class BaseWidget(QWidget): def __init__(self): @@ -31,12 +38,14 @@ def _create_layer_selector(self, selector_name, layer_type="Image"): layer_filter = napari.layers.Image elif layer_type == "Labels": layer_filter = napari.layers.Labels + elif layer_type == "Shapes": + layer_filter = napari.layers.Shapes else: raise ValueError("layer_type must be either 'Image' or 'Labels'.") selector_widget = QtWidgets.QWidget() image_selector = QtWidgets.QComboBox() - layer_label = QtWidgets.QLabel(f"{selector_name} Layer:") + layer_label = QtWidgets.QLabel(f"{selector_name}:") # Populate initial options self._update_selector(selector=image_selector, layer_filter=layer_filter) @@ -58,9 +67,23 @@ def _create_layer_selector(self, selector_name, layer_type="Image"): def _update_selector(self, selector, layer_filter): """Update a single selector with the current image layers in the viewer.""" selector.clear() - image_layers = [layer.name for layer in self.viewer.layers if isinstance(layer, layer_filter)] # if isinstance(layer, napari.layers.Image) + image_layers = [layer.name for layer in self.viewer.layers if isinstance(layer, layer_filter)] selector.addItems(image_layers) + def _get_layer_selector_layer(self, selector_name): + """Return the layer currently selected in a given selector.""" + if selector_name in self.layer_selectors: + selector_widget = self.layer_selectors[selector_name] + + # Retrieve the QComboBox from the QWidget's layout + image_selector = selector_widget.layout().itemAt(1).widget() + + if isinstance(image_selector, QComboBox): + selected_layer_name = image_selector.currentText() + if selected_layer_name in self.viewer.layers: + return self.viewer.layers[selected_layer_name] + return None # Return None if layer not found + def _get_layer_selector_data(self, selector_name, return_metadata=False): """Return the data for the layer currently selected in a given selector.""" if selector_name in self.layer_selectors: @@ -172,7 +195,7 @@ def _add_shape_param(self, names, values, min_val, max_val, step=1, title=None, title=title[1] if title is not None else title, tooltip=tooltip ) layout.addLayout(y_layout) - + if len(names) == 3: z_layout = QVBoxLayout() z_param, _ = self._add_int_param( @@ -262,3 +285,43 @@ def _get_file_path(self, name, textbox, tooltip=None): else: # Handle the case where the selected path is not a file print("Invalid file selected. Please try again.") + + def _handle_resolution(self, metadata, voxel_size_param, ndim): + # Get the resolution / voxel size from the layer metadata if available. + resolution = metadata.get("voxel_size", None) + if resolution is not None: + resolution = [resolution[ax] for ax in ("zyx" if ndim == 3 else "yx")] + + # If user input was given then override resolution from metadata. + if voxel_size_param.value() != 0.0: # Changed from default. + resolution = ndim * [voxel_size_param.value()] + + assert len(resolution) == ndim + return resolution + + def _save_table(self, save_path, data): + ext = os.path.splitext(save_path)[1] + if ext == "": # No file extension given, By default we save to CSV. + file_path = f"{save_path}.csv" + data.to_csv(file_path, index=False) + elif ext == ".csv": # Extension was specified as csv + file_path = save_path + data.to_csv(file_path, index=False) + elif ext == ".xlsx": # We also support excel. + file_path = save_path + data.to_excel(file_path, index=False) + else: + raise ValueError("Invalid extension for table: {ext}. We support .csv or .xlsx.") + return file_path + + def _add_properties_and_table(self, layer, table_data, save_path=""): + if layer.properties: + layer.properties = layer.properties.update(table_data) + else: + layer.properties = table_data + if add_table is not None: + add_table(layer, self.viewer) + # Save table to file if save path is provided. + if save_path != "": + file_path = self._save_table(self.save_path.text(), table_data) + show_info(f"INFO: Added table and saved file to {file_path}.") diff --git a/synaptic_reconstruction/tools/distance_measure_widget.py b/synaptic_reconstruction/tools/distance_measure_widget.py index 65833a00..65e54a5d 100644 --- a/synaptic_reconstruction/tools/distance_measure_widget.py +++ b/synaptic_reconstruction/tools/distance_measure_widget.py @@ -1,5 +1,3 @@ -import os - import napari import napari.layers import numpy as np @@ -11,27 +9,6 @@ from .base_widget import BaseWidget from .. import distance_measurements -try: - from napari_skimage_regionprops import add_table -except ImportError: - add_table = None - - -def _save_distance_table(save_path, data): - ext = os.path.splitext(save_path)[1] - if ext == "": # No file extension given, By default we save to CSV. - file_path = f"{save_path}.csv" - data.to_csv(file_path, index=False) - elif ext == ".csv": # Extension was specified as csv - file_path = save_path - data.to_csv(file_path, index=False) - elif ext == ".xlsx": # We also support excel. - file_path = save_path - data.to_excel(file_path, index=False) - else: - raise ValueError("Invalid extension for table: {ext}. We support .csv or .xlsx.") - return file_path - class DistanceMeasureWidget(BaseWidget): def __init__(self): @@ -68,16 +45,16 @@ def __init__(self): def _to_table_data(self, distances, seg_ids, endpoints1=None, endpoints2=None): assert len(distances) == len(seg_ids), f"{distances.shape}, {seg_ids.shape}" if seg_ids.ndim == 2: - table_data = {"label1": seg_ids[:, 0], "label2": seg_ids[:, 1], "distance": distances} + table_data = {"label_id1": seg_ids[:, 0], "label_id2": seg_ids[:, 1], "distance": distances} else: - table_data = {"label": seg_ids, "distance": distances} + table_data = {"label_id": seg_ids, "distance": distances} if endpoints1 is not None: axis_names = "zyx" if endpoints1.shape[1] == 3 else "yx" table_data.update({f"begin-{ax}": endpoints1[:, i] for i, ax in enumerate(axis_names)}) table_data.update({f"end-{ax}": endpoints2[:, i] for i, ax in enumerate(axis_names)}) return pd.DataFrame(table_data) - def _add_lines_and_table(self, lines, properties, table_data, name): + def _add_lines_and_table(self, lines, table_data, name): line_layer = self.viewer.add_shapes( lines, name=name, @@ -85,30 +62,16 @@ def _add_lines_and_table(self, lines, properties, table_data, name): edge_width=2, edge_color="red", blending="additive", - properties=properties, ) - if add_table is not None: - add_table(line_layer, self.viewer) - - if self.save_path.text() != "": - file_path = _save_distance_table(self.save_path.text(), table_data) - - if self.save_path.text() != "": - show_info(f"Added distance lines and saved file to {file_path}.") - else: - show_info("Added distance lines.") + self._add_properties_and_table(line_layer, table_data, self.save_path.text()) def on_measure_seg_to_object(self): segmentation = self._get_layer_selector_data(self.image_selector_name1) object_data = self._get_layer_selector_data(self.image_selector_name2) - # get metadata from layer if available + + # Get the resolution / voxel size. metadata = self._get_layer_selector_data(self.image_selector_name1, return_metadata=True) - resolution = metadata.get("voxel_size", None) - if resolution is not None: - resolution = [v for v in resolution.values()] - # if user input is present override metadata - if self.voxel_size_param.value() != 0.0: # changed from default - resolution = segmentation.ndim * [self.voxel_size_param.value()] + resolution = self._handle_resolution(metadata, self.voxel_size_param, segmentation.ndim) (distances, endpoints1, @@ -117,28 +80,23 @@ def on_measure_seg_to_object(self): segmentation=segmentation, segmented_object=object_data, distance_type="boundary", resolution=resolution ) - lines, properties = distance_measurements.create_object_distance_lines( + lines, _ = distance_measurements.create_object_distance_lines( distances=distances, endpoints1=endpoints1, endpoints2=endpoints2, seg_ids=seg_ids, ) table_data = self._to_table_data(distances, seg_ids, endpoints1, endpoints2) - self._add_lines_and_table(lines, properties, table_data, name="distances") + self._add_lines_and_table(lines, table_data, name="distances") def on_measure_pairwise(self): segmentation = self._get_layer_selector_data(self.image_selector_name1) if segmentation is None: show_info("Please choose a segmentation.") return - # get metadata from layer if available + metadata = self._get_layer_selector_data(self.image_selector_name1, return_metadata=True) - resolution = metadata.get("voxel_size", None) - if resolution is not None: - resolution = [v for v in resolution.values()] - # if user input is present override metadata - if self.voxel_size_param.value() != 0.0: # changed from default - resolution = segmentation.ndim * [self.voxel_size_param.value()] + resolution = self._handle_resolution(metadata, self.voxel_size_param, segmentation.ndim) (distances, endpoints1, @@ -153,7 +111,7 @@ def on_measure_pairwise(self): distances=properties["distance"], seg_ids=np.concatenate([properties["id_a"][:, None], properties["id_b"][:, None]], axis=1) ) - self._add_lines_and_table(lines, properties, table_data, name="pairwise-distances") + self._add_lines_and_table(lines, table_data, name="pairwise-distances") def _create_settings_widget(self): setting_values = QWidget() diff --git a/synaptic_reconstruction/tools/morphology_widget.py b/synaptic_reconstruction/tools/morphology_widget.py index 1d446da6..97c2b626 100644 --- a/synaptic_reconstruction/tools/morphology_widget.py +++ b/synaptic_reconstruction/tools/morphology_widget.py @@ -1,5 +1,3 @@ -import os - import napari import napari.layers import napari.viewer @@ -13,12 +11,6 @@ from .base_widget import BaseWidget from synaptic_reconstruction.imod.to_imod import convert_segmentation_to_spheres from synaptic_reconstruction.morphology import compute_object_morphology -from synaptic_reconstruction.tools.util import _save_table - -try: - from napari_skimage_regionprops import add_table -except ImportError: - add_table = None class MorphologyWidget(BaseWidget): @@ -69,7 +61,7 @@ def _create_shapes_layer(self, table_data, name="Shapes Layer"): if 'z' not in table_data.columns else table_data[['x', 'y', 'z']].to_numpy() ) - radii = table_data['radii'].to_numpy() + radii = table_data['radius'].to_numpy() if coords.shape[1] == 2: # For 2D data, create circular outlines using trigonometric functions @@ -127,47 +119,19 @@ def _to_table_data(self, coords, radii, props): assert len(coords) == len(radii), f"Shape mismatch: {coords.shape}, {radii.shape}" # Define columns based on dimension (2D or 3D) - col_names = ['x', 'y'] if coords.shape[1] == 2 else ['x', 'y', 'z'] + col_names = ["x", "y"] if coords.shape[1] == 2 else ["x", "y", "z"] table_data = { - 'label_id': [prop.label for prop in props], + "label_id": [prop.label for prop in props], **{col: coords[:, i] for i, col in enumerate(col_names)}, - 'radii': radii, - 'intensity_max': [prop.intensity_max for prop in props], - 'intensity_mean': [prop.intensity_mean for prop in props], - 'intensity_min': [prop.intensity_min for prop in props], - 'intensity_std': [prop.intensity_std for prop in props], + "radius": radii, + "intensity_max": [prop.intensity_max for prop in props], + "intensity_mean": [prop.intensity_mean for prop in props], + "intensity_min": [prop.intensity_min for prop in props], + "intensity_std": [prop.intensity_std for prop in props], } return pd.DataFrame(table_data) - def _add_table(self, coords, radii, props, name="Shapes Layer"): - """ - Add a Shapes layer and table data to the Napari viewer. - - Args: - viewer (napari.Viewer): The Napari viewer instance. - coords (np.ndarray): Array of 2D or 3D coordinates. - radii (np.ndarray): Array of radii corresponding to the coordinates. - props (list): List of properties containing intensity statistics. - name (str): Name of the Shapes layer. - save_path (str): Path to save the table data, if provided. - """ - # Create table data - table_data = self._to_table_data(coords, radii, props) - - # Add the shapes layer - layer = self._create_shapes_layer(table_data, name) - - if add_table is not None: - add_table(layer, self.viewer) - - # Save the table to a file if a save path is provided - if self.save_path.text(): - table_data.to_csv(self.save_path, index=False) - print(f"INFO: Added table and saved file to {self.save_path}.") - else: - print("INFO: Table added to viewer.") - def on_measure_vesicle_morphology(self): segmentation = self._get_layer_selector_data(self.image_selector_name1) image = self._get_layer_selector_data(self.image_selector_name) @@ -178,26 +142,26 @@ def on_measure_vesicle_morphology(self): show_info("INFO: Please choose an image.") return - # get metadata from layer if available - metadata = self._get_layer_selector_data(self.image_selector_name1, return_metadata=True) - resolution = metadata.get("voxel_size", None) - if resolution is not None: - resolution = [v for v in resolution.values()] - # if user input is present override metadata - if self.voxel_size_param.value() != 0.0: # changed from default - resolution = segmentation.ndim * [self.voxel_size_param.value()] + # Get the resolution / voxel size. + metadata = self._get_layer_selector_data(self.image_selector_name, return_metadata=True) + resolution = self._handle_resolution(metadata, self.voxel_size_param, segmentation.ndim) + # Compute the mophology parameter. props = regionprops(label_image=segmentation, intensity_image=image) - coords, radii = convert_segmentation_to_spheres( segmentation=segmentation, resolution=resolution, props=props, ) - self._add_table(coords, radii, props, name="Vesicles") + + # Create table data and add the properties and table to the layer. + table_data = self._to_table_data(coords, radii, props) + layer = self._get_layer_selector_layer(self.image_selector_name1) + self._add_properties_and_table(layer, table_data, self.save_path.text()) def on_measure_structure_morphology(self): - """add the structure measurements to the segmentation layer (via properties) + """ + Add the structure measurements to the segmentation layer (via properties) and visualize the properties table """ segmentation = self._get_layer_selector_data(self.image_selector_name1) @@ -205,34 +169,17 @@ def on_measure_structure_morphology(self): show_info("INFO: Please choose a segmentation.") return # get metadata from layer if available - metadata = self._get_layer_selector_data(self.image_selector_name1, return_metadata=True) + metadata = self._get_layer_selector_data(self.image_selector_name, return_metadata=True) resolution = metadata.get("voxel_size", None) if resolution is not None: resolution = [v for v in resolution.values()] morphology = compute_object_morphology( - object_=segmentation, structure_name=self.image_selector_name1, - resolution=resolution - ) - - self._add_table_structure(morphology) - - def _add_table_structure(self, morphology): - # add properties to segmentation layer - segmentation_selector_widget = self.layer_selectors[self.image_selector_name1] - image_selector = segmentation_selector_widget.layout().itemAt(1).widget() - selected_layer_name = image_selector.currentText() - self.viewer.layers[selected_layer_name].properties = morphology - - # Add a table layer to the Napari viewer - if add_table is not None: - add_table(self.viewer.layers[selected_layer_name], self.viewer) - - # Save table to file if save path is provided - if self.save_path.text() != "": - file_path = _save_table(self.save_path.text(), self._to_table_data_structure(morphology)) - show_info(f"INFO: Added table and saved file to {file_path}.") - else: - print("INFO: Table added to viewer.") + object_=segmentation, structure_name=self.image_selector_name1, resolution=resolution + ) + + # Add the properties to the layer and add/save the table. + layer = self._get_layer_selector_layer(self.image_selector_name1) + self._add_properties_and_table(layer, morphology, self.save_path.text()) def _create_settings_widget(self): setting_values = QWidget() diff --git a/synaptic_reconstruction/tools/util.py b/synaptic_reconstruction/tools/util.py index edb51a1d..0cedf0b5 100644 --- a/synaptic_reconstruction/tools/util.py +++ b/synaptic_reconstruction/tools/util.py @@ -12,22 +12,6 @@ from ..inference.vesicles import segment_vesicles -def _save_table(save_path, data): - ext = os.path.splitext(save_path)[1] - if ext == "": # No file extension given, By default we save to CSV. - file_path = f"{save_path}.csv" - data.to_csv(file_path, index=False) - elif ext == ".csv": # Extension was specified as csv - file_path = save_path - data.to_csv(file_path, index=False) - elif ext == ".xlsx": # We also support excel. - file_path = save_path - data.to_excel(file_path, index=False) - else: - raise ValueError("Invalid extension for table: {ext}. We support .csv or .xlsx.") - return file_path - - def load_custom_model(model_path: str, device: Optional[Union[str, torch.device]] = None) -> torch.nn.Module: model_path = _clean_filepath(model_path) if device is None: diff --git a/synaptic_reconstruction/tools/vesicle_pool_widget.py b/synaptic_reconstruction/tools/vesicle_pool_widget.py new file mode 100644 index 00000000..c557ee20 --- /dev/null +++ b/synaptic_reconstruction/tools/vesicle_pool_widget.py @@ -0,0 +1,227 @@ +import napari +import napari.layers +import napari.viewer +import numpy as np +import pandas as pd + +from napari.utils.notifications import show_info +from qtpy.QtWidgets import QWidget, QVBoxLayout, QPushButton + +from .base_widget import BaseWidget + +try: + from napari_skimage_regionprops import add_table +except ImportError: + add_table = None + +# This will fail if we have more than 8 pools. +COLORMAP = ["red", "blue", "yellow", "cyan", "purple", "magenta", "orange", "green"] + + +class VesiclePoolWidget(BaseWidget): + def __init__(self): + super().__init__() + + self.viewer = napari.current_viewer() + layout = QVBoxLayout() + + self.image_selector_name = "Distances to Structure" + self.image_selector_name1 = "Vesicles Segmentation" + # Create the image selection dropdown. + self.image_selector_widget = self._create_layer_selector(self.image_selector_name, layer_type="Shapes") + self.segmentation1_selector_widget = self._create_layer_selector(self.image_selector_name1, layer_type="Labels") + + # Create new layer name. + self.pool_layer_name_param, pool_layer_name_layout = self._add_string_param( + name="Output Layer Name", value="", + ) + + # Create pool name. + self.pool_name_param, pool_name_layout = self._add_string_param( + name="Vesicle Pool", value="", + ) + + # Create query string + self.query_param, query_layout = self._add_string_param( + name="Criterion", value="", + tooltip="Enter a comma separated query string (e.g., 'radius > 15, distance > 250') " + "Possible filters: radius, distance, area, intensity_max, intensity_mean, intensity_min, intensity_std" + ) + + # Create advanced settings. + self.settings = self._create_settings_widget() + + # Create and connect buttons. + self.measure_button1 = QPushButton("Create Vesicle Pool") + self.measure_button1.clicked.connect(self.on_pool_vesicles) + + # Add the widgets to the layout. + layout.addWidget(self.image_selector_widget) + layout.addWidget(self.segmentation1_selector_widget) + layout.addLayout(query_layout) + layout.addLayout(pool_layer_name_layout) + layout.addLayout(pool_name_layout) + layout.addWidget(self.measure_button1) + + self.setLayout(layout) + + def on_pool_vesicles(self): + distances_layer = self._get_layer_selector_layer(self.image_selector_name) + distances = distances_layer.properties + segmentation = self._get_layer_selector_data(self.image_selector_name1) + morphology_layer = self._get_layer_selector_layer(self.image_selector_name1) + morphology = morphology_layer.properties + + if segmentation is None: + show_info("INFO: Please choose a segmentation.") + return + if self.query_param.text() == "": + show_info("INFO: Please enter a query string.") + return + query = self.query_param.text() + + if self.pool_layer_name_param.text() == "": + show_info("INFO: Please enter a new layer name.") + return + pool_layer_name = self.pool_layer_name_param.text() + if self.pool_name_param.text() == "": + show_info("INFO: Please enter a pooled group name.") + return + pool_name = self.pool_name_param.text() + + if distances is None: + show_info("INFO: Distances layer could not be found or has no values.") + return + + self._compute_vesicle_pool(segmentation, distances, morphology, pool_layer_name, pool_name, query) + + def _compute_vesicle_pool(self, segmentation, distances, morphology, pool_layer_name, pool_name, query): + """ + Compute a vesicle pool based on the provided query parameters. + + Args: + segmentation (array): Segmentation data (e.g., labeled regions). + distances (dict): Properties from the distances layer. + morphology (dict): Properties from the morphology layer. + pool_layer_name (str): Name for the new layer to be created. + pool_name (str): Name for the pooled group to be assigned. + query (dict): Query parameters. + """ + + distance_ids = distances.get("label_id", []) + morphology_ids = morphology.get("label_id", []) + + # Ensure that IDs are identical. + if set(distance_ids) != set(morphology_ids): + show_info("ERROR: The IDs in distances and morphology are not identical.") + return + + # Create a merged dataframe from the dataframes which are relevant for the criterion. + # TODO: select the dataframes more dynamically depending on the criterion defined by the user. + distances = pd.DataFrame(distances) + morphology = pd.DataFrame(morphology) + merged_df = morphology.merge(distances, left_on="label_id", right_on="label_id", suffixes=("_morph", "_dist")) + + # Assign the vesicles to the current pool by filtering the mergeddataframe based on the query. + filtered_df = self._parse_query(query, merged_df) + pool_vesicle_ids = filtered_df.label_id.values.tolist() + + # Check if this layer was already created in a previous pool assignment. + if pool_layer_name in self.viewer.layers: + # If yes then load the previous pool assignments and merge them with the new pool assignments + pool_layer = self.viewer.layers[pool_layer_name] + pool_properties = pd.DataFrame.from_dict(pool_layer.properties) + + pool_names = pd.unique(pool_properties.pool).tolist() + if pool_name in pool_names: + # This pool has already been assigned and we changed the criterion. + # Its old assignment has to be over-written, remove the rows for this pool. + pool_properties = pool_properties[pool_properties.pool != pool_name] + + # Combine the vesicle ids corresponding to the previous assignment with the + # assignment for the new / current pool. + old_pool_ids = pool_properties.label_id.values.tolist() + pool_assignments = sorted(pool_vesicle_ids + old_pool_ids) + + # Get a map for each vesicle id to its pool. + id_to_pool_name = {ves_id: pool_name for ves_id in pool_vesicle_ids} + id_to_pool_name.update({k: v for k, v in zip(old_pool_ids, pool_properties.pool.values)}) + + # Get the pool values. + # This is the list of pool names, corresponding to the selected ids in pool_assignments. + pool_values = [id_to_pool_name[ves_id] for ves_id in pool_assignments] + + else: + # Otherwise, this is the first pool assignment. + pool_assignments = pool_vesicle_ids + pool_values = [pool_name] * len(pool_assignments) + + # Create the filtered segmentation. + vesicle_pools = segmentation.copy() + vesicle_pools[~np.isin(vesicle_pools, pool_assignments)] = 0 + + # Create the pool properties. + pool_properties = merged_df[merged_df.label_id.isin(pool_assignments)] + pool_properties.insert(1, "pool", pool_values) + + # Create the colormap to group the pools in the layer rendering. + # This can lead to color switches: if a new pool gets added which starts with + # a letter that's earlier in the alphabet the color will switch. + # To avoid this the user has to specify the pool color (not yet implemented, see next todo). + pool_names = np.unique(pool_values).tolist() + # TODO: add setting so that users can over-ride the color for a pool. + # TODO: provide a default color (how?) to avoid the warning + pool_colors = {pname: COLORMAP[pool_names.index(pname)] for pname in pool_names} + vesicle_colors = { + label_id: pool_colors[pname] for label_id, pname + in zip(pool_properties.label_id.values, pool_properties.pool.values) + } + + # TODO print some messages + # Add or replace the pool layer and properties. + if pool_layer_name in self.viewer.layers: + # message about added or over-ridden pool, including number of vesicles in pool + pool_layer = self.viewer.layers[pool_layer_name] + pool_layer.data = vesicle_pools + pool_layer.color_map = vesicle_colors + else: + # message about new pool, including number of vesicles in pool + pool_layer = self.viewer.add_labels(vesicle_pools, name=pool_layer_name, colormap=vesicle_colors) + + # TODO add the save path + self._add_properties_and_table(pool_layer, pool_properties, save_path="") + pool_layer.refresh() + + def _parse_query(self, query: str, data: pd.DataFrame) -> pd.DataFrame: + """Parse and apply a query string to filter data. + + Args: + query: Comma-separated query string (e.g., "radius > 15, distance > 250"). + data: DataFrame containing the data to filter. + + Returns: + Filtered DataFrame. + """ + filters = query.split(",") # Split the query into individual conditions + filters = [f.strip() for f in filters] # Remove extra spaces + for condition in filters: + try: + # Apply each condition to filter the DataFrame + data = data.query(condition) + except Exception as e: + print(f"Failed to apply condition '{condition}': {e}") + continue + return data + + def _create_settings_widget(self): + setting_values = QWidget() + setting_values.setLayout(QVBoxLayout()) + + self.save_path, layout = self._add_path_param(name="Save Table", select_type="file", value="") + setting_values.layout().addLayout(layout) + + self.voxel_size_param, layout = self._add_float_param("voxel_size", 0.0, min_val=0.0, max_val=100.0) + setting_values.layout().addLayout(layout) + + settings = self._make_collapsible(widget=setting_values, title="Advanced Settings") + return settings diff --git a/synaptic_reconstruction/tools/volume_reader.py b/synaptic_reconstruction/tools/volume_reader.py index a36facaa..961dfa5b 100644 --- a/synaptic_reconstruction/tools/volume_reader.py +++ b/synaptic_reconstruction/tools/volume_reader.py @@ -1,6 +1,6 @@ import os -from typing import Callable, List, Optional, Sequence, Union +from typing import Callable, Dict, List, Optional, Sequence, Union import mrcfile from napari.types import LayerData @@ -74,14 +74,16 @@ def read_image_volume(path: PathOrPaths) -> List[LayerData]: return -def read_voxel_size(input_path: str) -> dict | None: +def read_voxel_size(input_path: str) -> Dict[str, float] | None: """Read voxel size from mrc/rec file and store it in layer_attributes. The original unit of voxel size is Angstrom and we convert it to nanometers by dividing it by ten. Args: - input_path (str): path to mrc/rec file - layer_attributes (dict): napari layer attributes to store voxel size to + input_path: Path to mrc/rec file. + + Returns: + Mapping from the axis name to voxel size. None if the voxel size could not be read. """ new_voxel_size = None with mrcfile.open(input_path, permissive=True) as mrc: