From b61b24a1b86cc0a219e0766d1c46acc4d07cfe6c Mon Sep 17 00:00:00 2001 From: James Ryan <95660545+jamesyan-git@users.noreply.github.com> Date: Thu, 12 Oct 2023 12:22:32 +1100 Subject: [PATCH] Add widget to generate shape layer and colour by features (#201) Making a widget to allow users to select layer to skeletonise and colour by features --------- Co-authored-by: Juan Nunez-Iglesias --- .github/workflows/test.yml | 8 +- doc/examples/skeletonize_horse.py | 19 +++++ setup.cfg | 3 + src/skan/napari.yaml | 18 +++-- src/skan/napari_skan.py | 109 ++++++++++++++++++++++++---- src/skan/test/test_napari_plugin.py | 63 ++++++++++++++++ 6 files changed, 197 insertions(+), 23 deletions(-) create mode 100644 doc/examples/skeletonize_horse.py create mode 100644 src/skan/test/test_napari_plugin.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b81c4dd9..98524fd3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -35,6 +35,9 @@ jobs: with: python-version: ${{ matrix.python }} + # these libraries enable testing on Qt on linux + - uses: tlambert03/setup-qt-libs@v1 + - name: Install dependencies run: | python -m pip install --upgrade pip @@ -42,8 +45,9 @@ jobs: python -m pip install .[all,testing] - name: Test - run: | - pytest --pyargs skan --doctest-modules + uses: aganders3/headless-gui@v1 + with: + run: pytest --pyargs skan --doctest-modules -s -v - name: Coverage if: runner.os == 'Linux' && matrix.python == '3.10' diff --git a/doc/examples/skeletonize_horse.py b/doc/examples/skeletonize_horse.py new file mode 100644 index 00000000..c4d39ea7 --- /dev/null +++ b/doc/examples/skeletonize_horse.py @@ -0,0 +1,19 @@ +import napari +from skimage import data, morphology +from skan import Skeleton +from skan.napari_skan import labels_to_skeleton_shapes, SkeletonizeMethod +import numpy as np + +viewer = napari.Viewer() +horse = np.logical_not(data.horse().astype(bool)) + +labels_layer = viewer.add_labels(horse) + +ldt = labels_to_skeleton_shapes(labels_layer, SkeletonizeMethod.zhang) +(skel_layer,) = viewer._add_layer_from_data(*ldt) + +dw, widget = viewer.window.add_plugin_dock_widget( + 'skan', 'Color Skeleton Widget' + ) + +napari.run() diff --git a/setup.cfg b/setup.cfg index 4f4acdec..099f13c1 100644 --- a/setup.cfg +++ b/setup.cfg @@ -48,6 +48,7 @@ python_requires = >=3.8 include_package_data = True install_requires = imageio>=2.10.1 + magicgui>=0.7.3 matplotlib>=3.4 networkx>=2.7 numba>=0.53 @@ -71,8 +72,10 @@ all = testing = coverage hypothesis + napari[pyqt5]!=0.4.18 pytest pytest-cov + pytest-qt seaborn<1.0 tifffile docs = diff --git a/src/skan/napari.yaml b/src/skan/napari.yaml index 3de66053..55e617d1 100644 --- a/src/skan/napari.yaml +++ b/src/skan/napari.yaml @@ -2,11 +2,17 @@ name: skan schema_version: 0.1.0 contributions: commands: - - id: skan.skeletonize_labels - title: Skeletonize labels... - python_name: skan.napari_skan:skeletonize_labels - + - id: skan.skeletonize + title: Make Skeleton + python_name: skan.napari_skan:labels_to_skeleton_shapes + - id: skan.color_widget + title: Color Widget + python_name: skan.napari_skan:color_by_feature widgets: - - command: skan.skeletonize_labels - display_name: Skeletonize labels... + - command: skan.skeletonize + display_name: Skeleton Widget autogenerate: true + - command: skan.color_widget + display_name: Color Skeleton Widget + + diff --git a/src/skan/napari_skan.py b/src/skan/napari_skan.py index 1386157f..0714cf32 100644 --- a/src/skan/napari_skan.py +++ b/src/skan/napari_skan.py @@ -1,29 +1,108 @@ +from magicgui import magic_factory import numpy as np from enum import Enum from skimage.morphology import skeletonize +from skan import summarize, Skeleton + +CAT_COLOR = "tab10" +CONTINUOUS_COLOR = "viridis" + class SkeletonizeMethod(Enum): - Zhang = "zhang" - Lee = "lee" + """Use enum for method choice for easier use with magicgui.""" + zhang = "zhang" + lee = "lee" -def skeletonize_labels(labels: "napari.types.LabelsData", method: SkeletonizeMethod) -> "napari.types.LabelsData": - """Takes a labels layer and a skimage skeletonize method and generates a skeleton representation +def labels_to_skeleton_shapes( + labels: "napari.layers.Labels", choice: SkeletonizeMethod + ) -> "napari.types.LayerDataTuple": + """Skeletonize a labels layer using given method and export as Shapes. Parameters ---------- - labels : napari.types.LabelsData - A labels layer containing data to skeletonize - method : SkeletonizeMethod - Enum denoting the chosen skeletonize method method + labels : napari.layers.Labels + Labels layer containing data to skeletonize + choice : SkeletonizeMethod + Enum corresponding to skeletonization method Returns ------- - napari.types.LabelsData - Labels layer depecting the extracted skeleton + napari.types.LayerDataTuple + Shapes layer data with skeleton + """ + binary_labels = (labels.data > 0).astype(np.uint8) + binary_skeleton = skeletonize(binary_labels, method=choice.value) + + skeleton = Skeleton(binary_skeleton) + + all_paths = [skeleton.path_coordinates(i) for i in range(skeleton.n_paths)] + + # option to have main_path = True (or something) changing header + paths_table = summarize(skeleton) + layer_kwargs = { + 'shape_type': 'path', + 'edge_colormap': 'tab10', + 'features': paths_table, + 'metadata': {'skeleton': skeleton}, + } + + return all_paths, layer_kwargs, 'shapes' + + +def _populate_feature_choices(color_by_feature_widget): + """Update feature names combobox when source layer is changed. + + This runs on widget initialization and on every change of Shapes layer + thereafter. + + Parameters + ---------- + color_by_feature_widget : function that takes widget as input + Function that takes in the widget and modifies the choices in-place. + """ + color_by_feature_widget.shapes_layer.changed.connect( + lambda _: _update_feature_names(color_by_feature_widget) + ) + _update_feature_names(color_by_feature_widget) + + +def _update_feature_names(color_by_feature_widget): + """Search for a shapes layer with appropriate metadata for skeletons + + Parameters + ---------- + color_by_feature_widget : magicgui Widget + widget that contains reference to shapes layers + """ + shapes_layer = color_by_feature_widget.shapes_layer.value + + def get_choices(features_combo): + """Closure to use the current shapes layer to update given combobox.""" + return shapes_layer.features.columns + + color_by_feature_widget.feature_name.choices = get_choices + + +@magic_factory( + widget_init=_populate_feature_choices, + feature_name={"widget_type": "ComboBox"} + ) +def color_by_feature(shapes_layer: "napari.layers.Shapes", feature_name): + """Check the currently selected feature and update edge colors + + TODO: allow selecting a colormap. + + Parameters + ---------- + shapes_layer : napari.layers.Shapes + A napari Shapes layer. + feature_name : String + A string corresponding to a feature column present in the Shapes layer. """ - binary_labels = (labels > 0).astype(np.uint8) - # skeletonize returns a binary array, so we can just multiply it with the - # labels to get appropriate colors - skeletonized = skeletonize(binary_labels, method=method.value) * labels - return skeletonized + current_column_type = shapes_layer.features[feature_name].dtype + if current_column_type == "float64": + shapes_layer.edge_colormap = CONTINUOUS_COLOR + else: + shapes_layer.edge_colormap = CAT_COLOR + shapes_layer.edge_color = feature_name diff --git a/src/skan/test/test_napari_plugin.py b/src/skan/test/test_napari_plugin.py new file mode 100644 index 00000000..39a0129d --- /dev/null +++ b/src/skan/test/test_napari_plugin.py @@ -0,0 +1,63 @@ +from skan.napari_skan import labels_to_skeleton_shapes, _update_feature_names +from skimage import data, morphology +from napari.layers import Labels +import numpy as np +from skan.napari_skan import SkeletonizeMethod +from skan.csr import Skeleton +import pandas as pd +import napari + + +def make_trivial_labels_layer(): + label_data = np.zeros(shape=(10, 10), dtype=int) + label_data[5, 1:9] = 1 + labels_layer = Labels(label_data) + return labels_layer + + +def test_get_skeleton_simple(): + labels_layer = make_trivial_labels_layer() + skeleton_type = SkeletonizeMethod.zhang + shapes_data, layer_kwargs, _ = labels_to_skeleton_shapes( + labels_layer, skeleton_type + ) + + assert type(layer_kwargs["metadata"]["skeleton"]) is Skeleton + np.testing.assert_array_equal( + shapes_data[0], + [[5, 1], [5, 2], [5, 3], [5, 4], [5, 5], [5, 6], [5, 7], [5, 8]] + ) + assert len(shapes_data) == 1 + assert 'features' in layer_kwargs + assert type(layer_kwargs['features']) is pd.DataFrame + + +def test_get_skeleton_horse(): + horse = np.logical_not(data.horse().astype(bool)) + labels_layer = Labels(horse) + skeleton_type = SkeletonizeMethod.zhang + shapes_data, layer_kwargs, _ = labels_to_skeleton_shapes( + labels_layer, skeleton_type + ) + assert len(shapes_data) == 24 # 24 line segments in the horse skeleton + assert 'features' in layer_kwargs + assert type(layer_kwargs["features"]) is pd.DataFrame + + +def test_gui(make_napari_viewer): + viewer = make_napari_viewer() + horse = np.logical_not(data.horse().astype(bool)) + + labels_layer = viewer.add_labels(horse) + + ldt = labels_to_skeleton_shapes(labels_layer, SkeletonizeMethod.zhang) + (skel_layer,) = viewer._add_layer_from_data(*ldt) + + dw, widget = viewer.window.add_plugin_dock_widget( + 'skan', 'Color Skeleton Widget' + ) + widget.feature_name.value = "euclidean-distance" + widget() + layer = viewer.layers[-1] + assert layer.edge_colormap.name == 'viridis' + assert len(layer.data) == 24