From bcea708137e914664ef09c916105cda3f7ce8312 Mon Sep 17 00:00:00 2001 From: jluethi Date: Fri, 26 Jul 2024 19:51:28 +0200 Subject: [PATCH 1/4] Add support for napari>=0.4.19 colormap handling --- .github/workflows/test_and_deploy.yaml | 2 +- setup.cfg | 2 +- .../annotator_widget.py | 64 ++++++++++++++++--- .../classifier_widget.py | 28 +++++--- src/napari_feature_classifier/utils.py | 39 ++++++----- tox.ini | 4 +- 6 files changed, 97 insertions(+), 42 deletions(-) diff --git a/.github/workflows/test_and_deploy.yaml b/.github/workflows/test_and_deploy.yaml index 1091ab1..b3df05d 100644 --- a/.github/workflows/test_and_deploy.yaml +++ b/.github/workflows/test_and_deploy.yaml @@ -23,7 +23,7 @@ jobs: strategy: matrix: platform: [ubuntu-latest, windows-latest, macos-latest] - python-version: ['3.9', '3.10'] + python-version: ['3.9', '3.10', '3.11'] steps: - uses: actions/checkout@v3 diff --git a/setup.cfg b/setup.cfg index 9f9e71c..98dbccc 100644 --- a/setup.cfg +++ b/setup.cfg @@ -37,7 +37,7 @@ package_dir = # add your package requirements here install_requires = numpy < 2.0 - napari < 0.4.19 + napari matplotlib magicgui pandas diff --git a/src/napari_feature_classifier/annotator_widget.py b/src/napari_feature_classifier/annotator_widget.py index 84469b0..a367ce3 100644 --- a/src/napari_feature_classifier/annotator_widget.py +++ b/src/napari_feature_classifier/annotator_widget.py @@ -2,6 +2,7 @@ import warnings from enum import Enum from functools import partial +from packaging import version from pathlib import Path from typing import Optional, Sequence, cast @@ -24,7 +25,8 @@ # pylint: disable=R0801 from napari_feature_classifier.utils import ( get_colormap, - reset_display_colormaps, + reset_display_colormaps_legacy, + reset_display_colormaps_modern, get_valid_label_layers, get_selected_or_valid_label_layer, napari_info, @@ -245,7 +247,11 @@ def toggle_label(self, labels_layer, event): ] = np.NaN # Update only the single color value that changed - self.update_single_color(labels_layer, label) + napari_version = version.parse(napari.__version__) + if napari_version >= version.parse("0.4.19"): + self.update_single_color_slow(labels_layer, label) + else: + self.update_single_color_legacy(labels_layer, label) def set_class_n(self, event, n: int): # pylint: disable=C0103 self._class_selector.value = self.ClassSelection[ @@ -276,13 +282,23 @@ def _init_annotation(self, label_layer: napari.layers.Labels): self._annotations_layer.scale = label_layer.scale self._annotations_layer.translate = label_layer.translate - reset_display_colormaps( - label_layer, - feature_col="annotations", - display_layer=self._annotations_layer, - label_column=self._label_column, - cmap=self.cmap, - ) + napari_version = version.parse(napari.__version__) + if napari_version >= version.parse("0.4.19"): + reset_display_colormaps_modern( + label_layer, + feature_col="annotations", + display_layer=self._annotations_layer, + label_column=self._label_column, + cmap=self.cmap, + ) + else: + reset_display_colormaps_legacy( + label_layer, + feature_col="annotations", + display_layer=self._annotations_layer, + label_column=self._label_column, + cmap=self.cmap, + ) label_layer.mouse_drag_callbacks.append(self.toggle_label) # keybindings for the available classes (0 = deselect) @@ -300,7 +316,7 @@ def _update_save_destination(self, label_layer: napari.layers.Labels): base_path = Path(self._save_destination.value).parent self._save_destination.value = base_path / f"{label_layer.name}_annotation.csv" - def update_single_color(self, label_layer, label): + def update_single_color_legacy(self, label_layer, label): """ Update the color of a single object in the annotations layer. """ @@ -317,6 +333,34 @@ def update_single_color(self, label_layer, label): self._annotations_layer.opacity = 1.0 self._annotations_layer.color_mode = "direct" + def update_single_color_slow(self, label_layer, label): + """ + Update the color of a single object in the annotations layer. + + napari >= 0.4.19 does not have a direct API to only update a single + color. It always validates & updates the whole colormap. + Therefore, this update mode scales badly with the number of unique + labels. + See details in https://github.com/napari/napari/issues/6732 + + """ + color = self.cmap( + float( + label_layer.features.loc[ + label_layer.features[self._label_column] == label, + "annotations", + ].iloc[0] + ) + / len(self.cmap.colors) + ) + from napari.utils.colormaps import DirectLabelColormap + colordict = self._annotations_layer.colormap.color_dict + colordict[label] = color + self._annotations_layer.colormap = DirectLabelColormap( + color_dict=colordict + ) + self._annotations_layer.opacity = 1.0 + def _on_save_clicked(self): """ Save annotations to a csv file. diff --git a/src/napari_feature_classifier/classifier_widget.py b/src/napari_feature_classifier/classifier_widget.py index 7a48e96..628077f 100644 --- a/src/napari_feature_classifier/classifier_widget.py +++ b/src/napari_feature_classifier/classifier_widget.py @@ -2,6 +2,7 @@ import logging import pickle +from packaging import version from pathlib import Path from typing import Optional @@ -27,7 +28,8 @@ from napari_feature_classifier.classifier import Classifier from napari_feature_classifier.utils import ( get_colormap, - reset_display_colormaps, + reset_display_colormaps_modern, + reset_display_colormaps_legacy, get_valid_label_layers, get_selected_or_valid_label_layer, napari_info, @@ -427,13 +429,23 @@ def _init_prediction_layer(self, label_layer: napari.layers.Labels): self._prediction_layer.translate = label_layer.translate # Update the colormap of the prediction layer - reset_display_colormaps( - label_layer, - feature_col="prediction", - display_layer=self._prediction_layer, - label_column=self._label_column, - cmap=get_colormap(), - ) + napari_version = version.parse(napari.__version__) + if napari_version >= version.parse("0.4.19"): + reset_display_colormaps_modern( + label_layer, + feature_col="prediction", + display_layer=self._prediction_layer, + label_column=self._label_column, + cmap=get_colormap(), + ) + else: + reset_display_colormaps_legacy( + label_layer, + feature_col="prediction", + display_layer=self._prediction_layer, + label_column=self._label_column, + cmap=get_colormap(), + ) def hide_prediction_layer(self, labels_layer, event): """ diff --git a/src/napari_feature_classifier/utils.py b/src/napari_feature_classifier/utils.py index 4c47e80..846c3f5 100644 --- a/src/napari_feature_classifier/utils.py +++ b/src/napari_feature_classifier/utils.py @@ -70,7 +70,7 @@ def get_colormap(matplotlib_colormap="Set1"): return cmap -def reset_display_colormaps( +def reset_display_colormaps_legacy( label_layer, feature_col, display_layer, label_column, cmap ): """ @@ -85,25 +85,24 @@ def reset_display_colormaps( display_layer.color_mode = "direct" -# # Check if it runs in napari -# # This currently triggers an exception. -# # Find a new way to ensure the warning is also shown in the napari -# # interface # if _ipython_has_eventloop(): -# NapariQtNotification(message, 'INFO').show() - - -# def napari_warn(message): -# # Wrapper function to ensure a message o -# warnings.warn(message) -# show_info(message) -# print('test') -# # This currently triggers an exception. -# # Find a new way to ensure the warning is also shown in the napari -# # interface -# if _ipython_has_eventloop(): -# pass -# # NapariQtNotification(message, 'WARNING').show() -# +def reset_display_colormaps_modern( + label_layer, feature_col, display_layer, label_column, cmap +): + """ + Reset the colormap based on the annotations in + label_layer.features['annotation'] and sends the updated colormap + to the annotation label layer + + Modern version to support napari >= 0.4.19 + """ + from napari.utils.colormaps import DirectLabelColormap + colors = cmap(label_layer.features[feature_col].astype(float) / len(cmap.colors)) + colordict = dict(zip(label_layer.features[label_column], colors)) + colordict[None] = [0, 0, 0, 0] + display_layer.colormap = DirectLabelColormap(color_dict=colordict) + display_layer.opacity = 1.0 + + def napari_info(message): """ Info message wrapper. diff --git a/tox.ini b/tox.ini index e8c1fae..254cc9a 100644 --- a/tox.ini +++ b/tox.ini @@ -1,13 +1,13 @@ # For more information about tox, see https://tox.readthedocs.io/en/latest/ [tox] -envlist = py{38,39,310}-{linux,macos,windows} +envlist = py{39,310,311}-{linux,macos,windows} isolated_build=true [gh-actions] python = - 3.8: py38 3.9: py39 3.10: py310 + 3.11: py311 [gh-actions:env] PLATFORM = From 9832f650d8fcefb40c2e44e4aeeb670789f1b33e Mon Sep 17 00:00:00 2001 From: jluethi Date: Fri, 26 Jul 2024 19:52:40 +0200 Subject: [PATCH 2/4] precommit cleanup --- src/napari_feature_classifier/annotator_widget.py | 9 ++++----- src/napari_feature_classifier/utils.py | 1 + 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/napari_feature_classifier/annotator_widget.py b/src/napari_feature_classifier/annotator_widget.py index a367ce3..9776475 100644 --- a/src/napari_feature_classifier/annotator_widget.py +++ b/src/napari_feature_classifier/annotator_widget.py @@ -337,9 +337,9 @@ def update_single_color_slow(self, label_layer, label): """ Update the color of a single object in the annotations layer. - napari >= 0.4.19 does not have a direct API to only update a single + napari >= 0.4.19 does not have a direct API to only update a single color. It always validates & updates the whole colormap. - Therefore, this update mode scales badly with the number of unique + Therefore, this update mode scales badly with the number of unique labels. See details in https://github.com/napari/napari/issues/6732 @@ -354,11 +354,10 @@ def update_single_color_slow(self, label_layer, label): / len(self.cmap.colors) ) from napari.utils.colormaps import DirectLabelColormap + colordict = self._annotations_layer.colormap.color_dict colordict[label] = color - self._annotations_layer.colormap = DirectLabelColormap( - color_dict=colordict - ) + self._annotations_layer.colormap = DirectLabelColormap(color_dict=colordict) self._annotations_layer.opacity = 1.0 def _on_save_clicked(self): diff --git a/src/napari_feature_classifier/utils.py b/src/napari_feature_classifier/utils.py index 846c3f5..6e2aa89 100644 --- a/src/napari_feature_classifier/utils.py +++ b/src/napari_feature_classifier/utils.py @@ -96,6 +96,7 @@ def reset_display_colormaps_modern( Modern version to support napari >= 0.4.19 """ from napari.utils.colormaps import DirectLabelColormap + colors = cmap(label_layer.features[feature_col].astype(float) / len(cmap.colors)) colordict = dict(zip(label_layer.features[label_column], colors)) colordict[None] = [0, 0, 0, 0] From 4c9e12288fbee4fa04908f97c52e3b0ec89392ad Mon Sep 17 00:00:00 2001 From: jluethi Date: Fri, 26 Jul 2024 20:10:34 +0200 Subject: [PATCH 3/4] Switch from hiding prediction layer during annotation to making predictions a contour --- .../classifier_widget.py | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/src/napari_feature_classifier/classifier_widget.py b/src/napari_feature_classifier/classifier_widget.py index 628077f..0052318 100644 --- a/src/napari_feature_classifier/classifier_widget.py +++ b/src/napari_feature_classifier/classifier_widget.py @@ -258,6 +258,7 @@ def __init__( name="Predictions", translate=self._last_selected_label_layer.translate, ) + self._prediction_layer.contour = 2 # Set the label selection to a valid label layer => Running into proxy bug self._viewer.layers.selection.active = self._last_selected_label_layer @@ -296,9 +297,9 @@ def __init__( self._init_prediction_layer(self._last_selected_label_layer) # Whenever the label layer is clicked, hide the prediction layer # (e.g. new annotations are made) - self._last_selected_label_layer.mouse_drag_callbacks.append( - self.hide_prediction_layer - ) + # self._last_selected_label_layer.mouse_drag_callbacks.append( + # self.hide_prediction_layer + # ) def run(self): """ @@ -398,9 +399,9 @@ def selection_changed(self): ): self._last_selected_label_layer = self._viewer.layers.selection.active self._init_prediction_layer(self._viewer.layers.selection.active) - self._last_selected_label_layer.mouse_drag_callbacks.append( - self.hide_prediction_layer - ) + # self._last_selected_label_layer.mouse_drag_callbacks.append( + # self.hide_prediction_layer + # ) self._update_export_destination(self._last_selected_label_layer) def _init_prediction_layer(self, label_layer: napari.layers.Labels): @@ -447,11 +448,11 @@ def _init_prediction_layer(self, label_layer: napari.layers.Labels): cmap=get_colormap(), ) - def hide_prediction_layer(self, labels_layer, event): - """ - Hide the prediction layer - """ - self._prediction_layer.visible = False + # def hide_prediction_layer(self, labels_layer, event): + # """ + # Hide the prediction layer + # """ + # self._prediction_layer.visible = False def get_relevant_label_layers(self): relevant_label_layers = [] From 3470d44cfe22b8a123d945bd2764790a1bde2dd0 Mon Sep 17 00:00:00 2001 From: jluethi Date: Fri, 26 Jul 2024 20:19:45 +0200 Subject: [PATCH 4/4] Update README & version to 0.2.0 --- README.md | 2 +- setup.cfg | 2 +- src/napari_feature_classifier/__init__.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 8d22163..ff08187 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ An interactive classifier plugin that allows the user to assign objects in a lab ## Usage

-To use the napari-feature-classifier, you need to have a label image and corresponding measurements: as a csv file, loaded to layer.features or in an [OME-Zarr Anndata table loaded with another plugin](https://github.com/jluethi/napari-ome-zarr-roi-loader). Your feature measurements need to contain a `label` column that matches the label objects in the label image. +To use the napari-feature-classifier, you need to have a label image and corresponding measurements: as a csv file, loaded to layer.features or in an [OME-Zarr Anndata table loaded with another plugin](https://github.com/fractal-napari-plugins-collection/napari-ome-zarr-navigator). Your feature measurements need to contain a `label` column that matches the label objects in the label image. These interactive classification workflows are well suited to visually define cell types, find mitotic cells in images, do quality control by automatically detecting missegmented cells and other tasks where a user can easily assign objects to groups. #### Prepare the label layer: diff --git a/setup.cfg b/setup.cfg index 98dbccc..a7fa121 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = napari-feature-classifier -version = 0.1.2 +version = 0.2.0 author = Joel Luethi and Max Hess author_email = joel.luethi@uzh.ch url = https://github.com/fractal-napari-plugins-collection/napari-feature-classifier diff --git a/src/napari_feature_classifier/__init__.py b/src/napari_feature_classifier/__init__.py index f6aee3b..6a3cf34 100644 --- a/src/napari_feature_classifier/__init__.py +++ b/src/napari_feature_classifier/__init__.py @@ -1,2 +1,2 @@ """Init""" -__version__ = "0.1.2" +__version__ = "0.2.0"