Skip to content

Commit

Permalink
Merge pull request #49 from fractal-napari-plugins-collection/41_napa…
Browse files Browse the repository at this point in the history
…ri_050_support

Add napari 0.5.0 support & update prediction layer display
  • Loading branch information
jluethi authored Jul 26, 2024
2 parents 5aa40dc + 3470d44 commit 1734442
Show file tree
Hide file tree
Showing 8 changed files with 112 additions and 56 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/test_and_deploy.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ An interactive classifier plugin that allows the user to assign objects in a lab
## Usage
<p align="center"><img src="https://github.com/fractal-napari-plugins-collection/napari-feature-classifier/assets/18033446/1ebf0890-1a7b-4e4b-a21c-88ca8f1dd800" /></p>

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:
Expand Down
4 changes: 2 additions & 2 deletions setup.cfg
Original file line number Diff line number Diff line change
@@ -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 = [email protected]
url = https://github.com/fractal-napari-plugins-collection/napari-feature-classifier
Expand Down Expand Up @@ -37,7 +37,7 @@ package_dir =
# add your package requirements here
install_requires =
numpy < 2.0
napari < 0.4.19
napari
matplotlib
magicgui
pandas
Expand Down
2 changes: 1 addition & 1 deletion src/napari_feature_classifier/__init__.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
"""Init"""
__version__ = "0.1.2"
__version__ = "0.2.0"
63 changes: 53 additions & 10 deletions src/napari_feature_classifier/annotator_widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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,
Expand Down Expand Up @@ -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[
Expand Down Expand Up @@ -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)
Expand All @@ -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.
"""
Expand All @@ -317,6 +333,33 @@ 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.
Expand Down
51 changes: 32 additions & 19 deletions src/napari_feature_classifier/classifier_widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import logging
import pickle

from packaging import version
from pathlib import Path
from typing import Optional

Expand All @@ -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,
Expand Down Expand Up @@ -256,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
Expand Down Expand Up @@ -294,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):
"""
Expand Down Expand Up @@ -396,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):
Expand Down Expand Up @@ -427,19 +430,29 @@ 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):
"""
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 = []
Expand Down
40 changes: 20 additions & 20 deletions src/napari_feature_classifier/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
):
"""
Expand All @@ -85,25 +85,25 @@ 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.
Expand Down
4 changes: 2 additions & 2 deletions tox.ini
Original file line number Diff line number Diff line change
@@ -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 =
Expand Down

0 comments on commit 1734442

Please sign in to comment.