From 8a20f5f6a2f87b4d6b5290a72a678f8da708ca16 Mon Sep 17 00:00:00 2001 From: Roy Hoitink Date: Tue, 7 Nov 2023 14:36:19 +0100 Subject: [PATCH] Major code overhaul, moved code to separate class --- src/napari_trackpy/__init__.py | 4 +- src/napari_trackpy/_widget.py | 399 ++++++++++++++++++++------------- src/napari_trackpy/napari.yaml | 2 +- 3 files changed, 245 insertions(+), 160 deletions(-) diff --git a/src/napari_trackpy/__init__.py b/src/napari_trackpy/__init__.py index 8905242..5ec5763 100644 --- a/src/napari_trackpy/__init__.py +++ b/src/napari_trackpy/__init__.py @@ -2,8 +2,8 @@ from ._version import version as __version__ except ImportError: __version__ = "unknown" -from ._widget import xyz_particle_tracking_settings_widget +from ._widget import XYZWidget __all__ = [ - "xyz_particle_tracking_settings_widget", + "XYZWidget", ] diff --git a/src/napari_trackpy/_widget.py b/src/napari_trackpy/_widget.py index dbade15..6b47c0c 100644 --- a/src/napari_trackpy/_widget.py +++ b/src/napari_trackpy/_widget.py @@ -1,174 +1,259 @@ -""" -This module is an example of a barebones QWidget plugin for napari - -It implements the Widget specification. -see: https://napari.org/stable/plugins/guides.html?#widgets - -Replace code below according to your needs. -""" from time import time from typing import TYPE_CHECKING import matplotlib.pyplot as plt import numpy as np import trackpy as tp -from magicgui import magic_factory -from magicgui.tqdm import tqdm from matplotlib.backends.backend_qt5agg import FigureCanvas from napari.utils import notifications from superqt.utils import thread_worker if TYPE_CHECKING: import napari + import napari.layers + import napari.viewer -fig_added = False -fig, ax = None, None - +from typing import cast -@magic_factory( - min_mass={"widget_type": "SpinBox", "max": int(1e8)}, +from magicgui.widgets import ( + ComboBox, + Container, + FloatSlider, + FloatSpinBox, + PushButton, + create_widget, ) -def xyz_particle_tracking_settings_widget( - viewer: "napari.viewer.Viewer", - img_layer: "napari.layers.Image", - feature_size_xy_µm: float = 0.3, - feature_size_z_µm: float = 0.3, - min_separation_xy_µm: float = 0.3, - min_separation_z_µm: float = 0.3, - min_mass=int(1e5), -): - if img_layer is None: - notifications.show_error("No image selected") - return - - if "aicsimage" not in img_layer.metadata: - notifications.show_error( - "Data not loaded via aicsimageio plugin, cannot extract metadata" - ) - return - - global fig_added, fig, ax - if not fig_added: - fig, ax = plt.subplots(1, 1) - xyz_particle_tracking_settings_widget.native.layout().addWidget( - FigureCanvas(fig) - ) - fig_added = True - - with tqdm() as pbar: - results = do_particle_tracking( - img_layer, - feature_size_xy_μm, # noqa F821 - feature_size_z_μm, # noqa F821 - min_separation_xy_μm, # noqa F821 - min_separation_z_μm, # noqa F821 - min_mass, - ) - results.returned.connect( - lambda x: add_points_to_viewer(viewer, img_layer, x) - ) - results.returned.connect(lambda x: show_mass_histogram(ax, x)) - results.finished.connect(lambda: pbar.progressbar.hide()) - results.start() - - -def add_points_to_viewer(viewer, img_layer, output): - coords, pixel_sizes = output - # @todo: fix size of points - viewer.add_points( - np.array(coords[["z", "y", "x"]]), - properties={"mass": coords["mass"]}, - scale=pixel_sizes, - edge_color="red", - face_color="transparent", - name=f"{img_layer.name}_coords", - out_of_slice_display=True, - ) - - -def show_mass_histogram(axis, output): - coords, pixel_sizes = output - axis.cla() - axis.hist(coords["mass"], "auto") - axis.set_xlabel("mass (a.u.)") - axis.set_ylabel("occurence") - axis.figure.tight_layout() - axis.figure.canvas.draw() - - -@thread_worker -def do_particle_tracking( - img_layer: "napari.layers.Image", - feature_size_xy_µm: float, - feature_size_z_µm: float, - min_separation_xy_µm: float, - min_separation_z_µm: float, - min_mass, -): - img = img_layer.metadata["aicsimage"] - - # tracking code implementation based on `sp8_xyz_tracking_lif.py` by Maarten Bransen - stack = np.squeeze( - img_layer.data_raw - ) # squeeze out dimensions with length 1 - nz, ny, nx = stack.shape - pixel_sizes = np.array( - [getattr(img.physical_pixel_sizes, dim) for dim in ["Z", "Y", "X"]] - ) - - # convert feature size and min_separation to pixel units - - feature_sizes = np.array( - [ - np.ceil(feature_size_z_µm / np.abs(pixel_sizes[0])) // 2 * 2 + 1, - np.ceil(feature_size_xy_µm / pixel_sizes[1]) // 2 * 2 + 1, - np.ceil(feature_size_xy_µm / pixel_sizes[2]) // 2 * 2 + 1, - ] - ) - min_separations = np.array( - [ - np.ceil(min_separation_z_µm / np.abs(pixel_sizes[0])) // 2 * 2 + 1, - np.ceil(min_separation_xy_µm / pixel_sizes[1]) // 2 * 2 + 1, - np.ceil(min_separation_xy_µm / pixel_sizes[2]) // 2 * 2 + 1, + +class XYZWidget(Container): + def __init__(self, viewer: napari.viewer.Viewer): + self.viewer = viewer + self.img_layer = None + self.img_layer_name = cast( + ComboBox, + create_widget(annotation=napari.layers.Image, label="Image"), + ) + self.feature_size_xy_µm = FloatSpinBox( + value=0.3, + min=0.0, + max=1e3, + step=0.05, + label="Feature size xy (µm)", + ) + self.feature_size_z_µm = FloatSpinBox( + value=0.5, min=0.0, max=1e3, step=0.05, label="Feature size z (µm)" + ) + self.min_separation_xy_µm = FloatSpinBox( + value=0.3, + min=0.0, + max=1e3, + step=0.05, + label="Min. separation xy(µm)", + ) + self.min_separation_z_µm = FloatSpinBox( + value=0.3, + min=0.0, + max=1e3, + step=0.05, + label="Max. separation z(µm)", + ) + self.min_mass = FloatSlider( + value=1e2, min=0, max=1e9, label="Min. mass" + ) + self.max_mass = FloatSlider( + value=1e8, min=1, max=1e9, label="Max. mass" + ) + + self.run_btn = PushButton(label="Run") + self.reset_btn = PushButton(enabled=False, label="Reset") + self.save_params_btn = PushButton( + enabled=False, label="Save tracking parameters" + ) + self.save_tracking_btn = PushButton( + enabled=False, label="Save coordinates" + ) + self.run_btn = PushButton(label="Run") + self.run_btn.clicked.connect(self._on_run_clicked) + self.reset_btn.clicked.connect(self.reset) + self.img_layer_name.changed.connect(self._on_label_layer_changed) + + self.last_added_points_layer = None + self.fig, self.ax = None, None + + super().__init__( + widgets=[ + self.img_layer_name, + self.feature_size_xy_µm, + self.feature_size_z_μm, + self.min_separation_xy_μm, + self.min_separation_z_μm, + self.min_mass, + self.max_mass, + self.run_btn, + self.reset_btn, + self.save_params_btn, + self.save_tracking_btn, + ] + ) + + @thread_worker + def do_particle_tracking(self): + img = self.img_layer.metadata["aicsimage"] + + # tracking code implementation based on `sp8_xyz_tracking_lif.py` by Maarten Bransen + stack = np.squeeze( + self.img_layer.data_raw + ) # squeeze out dimensions with length 1 + nz, ny, nx = stack.shape + self.pixel_sizes = np.array( + [getattr(img.physical_pixel_sizes, dim) for dim in ["Z", "Y", "X"]] + ) + + # convert feature size and min_separation to pixel units + + feature_sizes = np.array( + [ + np.ceil( + self.feature_size_z_µm.value / np.abs(self.pixel_sizes[0]) + ) + // 2 + * 2 + + 1, + np.ceil(self.feature_size_xy_µm.value / self.pixel_sizes[1]) + // 2 + * 2 + + 1, + np.ceil(self.feature_size_xy_µm.value / self.pixel_sizes[2]) + // 2 + * 2 + + 1, + ] + ) + + min_separations = np.array( + [ + np.ceil( + self.min_separation_z_µm.value + / np.abs(self.pixel_sizes[0]) + ) + // 2 + * 2 + + 1, + np.ceil(self.min_separation_xy_µm.value / self.pixel_sizes[1]) + // 2 + * 2 + + 1, + np.ceil(self.min_separation_xy_µm.value / self.pixel_sizes[2]) + // 2 + * 2 + + 1, + ] + ) + + # disallow equal sizes in all dimensions + if ( + feature_sizes[2] == feature_sizes[1] + and feature_sizes[2] == feature_sizes[0] + ): + feature_sizes[0] += 2 + notifications.show_warning( + f"Increasing z-size to {feature_sizes[0] * np.abs(self.pixel_sizes[0])}" + ) + + t = time() + + # trackpy particle tracking with set parameters + coords = tp.locate( + stack, + diameter=feature_sizes, + minmass=self.min_mass.value, + separation=min_separations, + characterize=False, + ) + coords = coords.dropna(subset=["x", "y", "z", "mass"]) + coords = coords.loc[(coords["mass"] < self.max_mass.value)] + coords = coords.loc[ + ( + (coords["x"] >= feature_sizes[2] / 2) + & (coords["x"] <= nx - feature_sizes[2] / 2) + & (coords["y"] >= feature_sizes[1] / 2) + & (coords["y"] <= ny - feature_sizes[1] / 2) + & (coords["z"] >= feature_sizes[0] / 2) + & (coords["z"] <= nz - feature_sizes[0] / 2) + ) ] - ) - - # disallow equal sizes in all dimensions - if ( - feature_sizes[2] == feature_sizes[1] - and feature_sizes[2] == feature_sizes[0] - ): - feature_sizes[0] += 2 - notifications.show_warning( - f"Increasing z-size to {feature_sizes[0] * np.abs(pixel_sizes[0])}" - ) - - t = time() - - # trackpy particle tracking with set parameters - coords = tp.locate( - stack, - diameter=feature_sizes, - minmass=min_mass, - separation=min_separations, - characterize=False, - ) - coords = coords.dropna(subset=["x", "y", "z", "mass"]) - # coords = coords.loc[(coords['mass']= feature_sizes[2] / 2) - & (coords["x"] <= nx - feature_sizes[2] / 2) - & (coords["y"] >= feature_sizes[1] / 2) - & (coords["y"] <= ny - feature_sizes[1] / 2) - & (coords["z"] >= feature_sizes[0] / 2) - & (coords["z"] <= nz - feature_sizes[0] / 2) - ) - ] - - notifications.show_info( - f"{np.shape(coords)[0]} features found, took {time()-t:.2f} s" - ) - - return (coords, pixel_sizes) + + self.coords = coords.copy() + del coords + + notifications.show_info( + f"{np.shape(self.coords)[0]} features found, took {time()-t:.2f} s" + ) + + return (self.coords, self.pixel_sizes) + + def _on_label_layer_changed(self, new_value: napari.layers.Image): + # print(self.img_layer_name, type(self.img_layer_name), type(new_value)) + self.img_layer = new_value + # set your internal annotation layer here. + + def _on_run_clicked(self): + if self.img_layer is None: + notifications.show_error("No image selected") + return + + if "aicsimage" not in self.img_layer.metadata: + notifications.show_error( + "Data not loaded via aicsimageio plugin, cannot extract metadata" + ) + return + + if self.fig is None: + self.fig, self.ax = plt.subplots(1, 1) + self.native.layout().addWidget(FigureCanvas(self.fig)) + + self.run_btn.enabled = False + tracking_thread = self.do_particle_tracking() + tracking_thread.finished.connect(lambda: self.process_tracking()) + # tracking_thread.finished.connect(lambda: ) + tracking_thread.start() + + def process_tracking(self): + self.add_points_to_viewer() + self.show_mass_histogram() + self.run_btn.enabled = True + self.reset_btn.enabled = True + # self.save_params_btn.enabled = True + # self.save_tracking_btn.enabled = True + + def add_points_to_viewer(self): + # @todo: fix size of points + self.last_added_points_layer = self.viewer.add_points( + np.array(self.coords[["z", "y", "x"]]), + properties={"mass": self.coords["mass"]}, + scale=self.pixel_sizes, + edge_color="red", + face_color="transparent", + name=f"{self.img_layer.name}_coords", + out_of_slice_display=True, + ) + + def show_mass_histogram(self): + self.reset_histogram() + self.ax.hist(self.coords["mass"], "auto") + self.ax.figure.tight_layout() + self.ax.figure.canvas.draw() + + def reset_histogram(self): + self.ax.cla() + self.ax.set_xlabel("mass (a.u.)") + self.ax.set_ylabel("occurence") + self.ax.figure.tight_layout() + self.ax.figure.canvas.draw() + + def reset(self): + self.reset_histogram() + self.coords = None + self.pixel_sizes = None + if self.last_added_points_layer is not None: + self.viewer.layers.remove(self.last_added_points_layer.name) diff --git a/src/napari_trackpy/napari.yaml b/src/napari_trackpy/napari.yaml index 2189ba4..c240387 100644 --- a/src/napari_trackpy/napari.yaml +++ b/src/napari_trackpy/napari.yaml @@ -3,7 +3,7 @@ display_name: Particle tracking contributions: commands: - id: napari-trackpy.xyz_particle_tracking_settings_widget - python_name: napari_trackpy:xyz_particle_tracking_settings_widget + python_name: napari_trackpy._widget:XYZWidget title: XYZ particle tracking widgets: - command: napari-trackpy.xyz_particle_tracking_settings_widget