From 1f1a35fc24f50d753a7f086362e2da51235353fd Mon Sep 17 00:00:00 2001 From: David Day Date: Wed, 18 Dec 2024 02:49:06 -0800 Subject: [PATCH] feat: Show filename when importing with PartSeg in napari (#1226) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hi, Thank you for developing this fantastic tool! It has been incredibly valuable for our research. While working with PartSeg in Napari, we noticed that the filename does not appear in the layer names, making distinguishing between multiple imported files challenging. To address this, I’ve updated the code in this pull request to add the filename as a prefix to the layer names. I’m open to feedback on this approach and would love your thoughts or suggestions for further improvement! Best, David ## Summary by Sourcery New Features: - Add filename as a prefix to layer names when importing with PartSeg in Napari. ## Summary by CodeRabbit - **New Features** - Introduced a new method for adjusting color values, enhancing color processing capabilities. - Added conditional functionality for adding color based on the version of the application. - Improved layer naming conventions by incorporating file name prefixes for better clarity. - Added a new property for selecting layer naming formats in settings. - Introduced a new enumeration for layer naming formats with multiple options. - Added new test cases to validate layer naming format functionality. - **Bug Fixes** - Enhanced handling of alpha channels in color strings to ensure accurate color representation. --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Grzegorz Bokota --- package/PartSeg/plugins/napari_io/loader.py | 16 +++++++++++-- .../plugins/napari_widgets/_settings.py | 22 ++++++++++++++++++ package/PartSegCore/universal_const.py | 23 +++++++++++++++++++ .../tests/test_PartSeg/test_napari_widgets.py | 11 +++++++++ package/tests/test_PartSegCore/test_io.py | 14 +++++++++++ 5 files changed, 84 insertions(+), 2 deletions(-) diff --git a/package/PartSeg/plugins/napari_io/loader.py b/package/PartSeg/plugins/napari_io/loader.py index 158068697..3b2da536e 100644 --- a/package/PartSeg/plugins/napari_io/loader.py +++ b/package/PartSeg/plugins/napari_io/loader.py @@ -1,3 +1,4 @@ +import os import typing from importlib.metadata import version @@ -9,6 +10,7 @@ from PartSegCore.analysis import ProjectTuple from PartSegCore.io_utils import LoadBase, WrongFileTypeException from PartSegCore.mask.io_functions import MaskProjectTuple +from PartSegCore.universal_const import format_layer_name from PartSegImage import Image @@ -54,12 +56,20 @@ def add_color(image: Image, idx: int) -> dict: # noqa: ARG001 def _image_to_layers(project_info, scale, translate): + settings = get_settings() + filename = os.path.basename(project_info.file_path) res_layers = [] if project_info.image.name == "ROI" and project_info.image.channels == 1: res_layers.append( ( project_info.image.get_channel(0), - {"scale": scale, "name": project_info.image.channel_names[0], "translate": translate}, + { + "scale": scale, + "name": format_layer_name( + settings.layer_naming_format, filename, project_info.image.channel_names[0] + ), + "translate": translate, + }, "labels", ) ) @@ -69,7 +79,9 @@ def _image_to_layers(project_info, scale, translate): project_info.image.get_channel(i), { "scale": scale, - "name": project_info.image.channel_names[i], + "name": format_layer_name( + settings.layer_naming_format, filename, project_info.image.channel_names[i] + ), "blending": "additive", "translate": translate, "metadata": project_info.image.metadata, diff --git a/package/PartSeg/plugins/napari_widgets/_settings.py b/package/PartSeg/plugins/napari_widgets/_settings.py index d83807455..2781916d1 100644 --- a/package/PartSeg/plugins/napari_widgets/_settings.py +++ b/package/PartSeg/plugins/napari_widgets/_settings.py @@ -7,6 +7,7 @@ from PartSeg.common_backend import napari_get_settings from PartSegCore import Units from PartSegCore.json_hooks import PartSegEncoder +from PartSegCore.universal_const import LayerNamingFormat _SETTINGS = None @@ -29,6 +30,14 @@ def io_units(self) -> Units: def io_units(self, value: Units): self.set("io_units", value) + @property + def layer_naming_format(self) -> LayerNamingFormat: + return self.get("layer_naming_format", LayerNamingFormat.channel_only) + + @layer_naming_format.setter + def layer_naming_format(self, value: LayerNamingFormat): + self.set("layer_naming_format", value) + def get_settings() -> PartSegNapariSettings: global _SETTINGS # noqa: PLW0603 # pylint: disable=global-statement @@ -51,6 +60,12 @@ def __init__(self): self.units_select.changed.connect(self.units_selection_changed) self.settings.connect_("io_units", self.units_changed) self.append(self.units_select) + self.layer_naming_select = create_widget( + self.settings.layer_naming_format, annotation=LayerNamingFormat, label="Format for Layer Name" + ) + self.layer_naming_select.changed.connect(self.layer_naming_format_selection_changed) + self.settings.connect_("layer_naming_format", self.layer_naming_format_changed) + self.append(self.layer_naming_select) def units_selection_changed(self, value): self.settings.io_units = value @@ -58,3 +73,10 @@ def units_selection_changed(self, value): def units_changed(self): self.units_select.value = self.settings.io_units + + def layer_naming_format_selection_changed(self, value): + self.settings.layer_naming_format = value + self.settings.dump() + + def layer_naming_format_changed(self): + self.layer_naming_select.value = self.settings.layer_naming_format diff --git a/package/PartSegCore/universal_const.py b/package/PartSegCore/universal_const.py index f5c56728f..c2d914355 100644 --- a/package/PartSegCore/universal_const.py +++ b/package/PartSegCore/universal_const.py @@ -17,3 +17,26 @@ def __str__(self): _UNITS_LIST = ["mm", "µm", "nm", "pm"] UNIT_SCALE = [10**3, 10**6, 10**9, 10**12] + + +@register_class() +class LayerNamingFormat(Enum): + channel_only = 0 + filename_only = 1 + filename_channel = 2 + channel_filename = 3 + + def __str__(self): + return self.name.replace("_", " ") + + +def format_layer_name(layer_format: LayerNamingFormat, file_name: str, channel_name: str) -> str: + if layer_format == LayerNamingFormat.channel_only: + return channel_name + if layer_format == LayerNamingFormat.filename_only: + return file_name + if layer_format == LayerNamingFormat.filename_channel: + return f"{file_name} | {channel_name}" + if layer_format == LayerNamingFormat.channel_filename: + return f"{channel_name} | {file_name}" + raise ValueError("Unknown format") # pragma: no cover diff --git a/package/tests/test_PartSeg/test_napari_widgets.py b/package/tests/test_PartSeg/test_napari_widgets.py index 32f37125c..c0fe4e084 100644 --- a/package/tests/test_PartSeg/test_napari_widgets.py +++ b/package/tests/test_PartSeg/test_napari_widgets.py @@ -57,6 +57,7 @@ from PartSegCore.segmentation.noise_filtering import NoiseFilterSelection from PartSegCore.segmentation.threshold import DoubleThresholdSelection, ThresholdSelection from PartSegCore.segmentation.watershed import WatershedSelection +from PartSegCore.universal_const import LayerNamingFormat NAPARI_GE_5_0 = parse_version(version("napari")) >= parse_version("0.5.0a1") NAPARI_GE_4_19 = parse_version(version("napari")) >= parse_version("0.4.19a1") @@ -655,3 +656,13 @@ def test_change_units(self, qtbot): assert s.io_units == Units.nm s.io_units = Units.mm assert w.units_select.value == Units.mm + + def test_change_layer_name_format(self, qtbot): + s = _settings.get_settings() + s.layer_naming_format = LayerNamingFormat.channel_only + w = _settings.SettingsEditor() + qtbot.addWidget(w.native) + w.layer_naming_select.value = LayerNamingFormat.channel_filename + assert s.layer_naming_format == LayerNamingFormat.channel_filename + s.layer_naming_format = LayerNamingFormat.channel_only + assert w.layer_naming_select.value == LayerNamingFormat.channel_only diff --git a/package/tests/test_PartSegCore/test_io.py b/package/tests/test_PartSegCore/test_io.py index d57fd8788..e42d51cff 100644 --- a/package/tests/test_PartSegCore/test_io.py +++ b/package/tests/test_PartSegCore/test_io.py @@ -62,6 +62,7 @@ from PartSegCore.segmentation.noise_filtering import DimensionType from PartSegCore.segmentation.segmentation_algorithm import ThresholdAlgorithm from PartSegCore.segmentation.threshold import RangeThresholdSelection +from PartSegCore.universal_const import LayerNamingFormat, format_layer_name from PartSegCore.utils import ProfileDict, check_loaded_dict from PartSegImage import Image @@ -911,6 +912,19 @@ def test_load(self, tmp_path): assert res.roi_info.roi.shape == (1, 1, 100, 100) +@pytest.mark.parametrize( + ("value", "expected"), + [ + (LayerNamingFormat.channel_only, "b"), + (LayerNamingFormat.filename_only, "a"), + (LayerNamingFormat.filename_channel, "a | b"), + (LayerNamingFormat.channel_filename, "b | a"), + ], +) +def test_format_layer_name(value, expected): + assert format_layer_name(value, "a", "b") == expected + + UPDATE_NAME_JSON = """ {"problematic set": { "__MeasurementProfile__": true,