diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e925735..d7595ff 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,41 +1,27 @@ repos: - - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.6.0 - hooks: - - id: check-docstring-first - - id: end-of-file-fixer - - id: trailing-whitespace - exclude: ^.napari-hub/* - - repo: https://github.com/PyCQA/isort - rev: 5.13.2 - hooks: - - id: isort - - repo: https://github.com/asottile/pyupgrade - rev: v3.17.0 - hooks: - - id: pyupgrade - args: [--py38-plus, --keep-runtime-typing] - - repo: https://github.com/myint/autoflake - rev: v2.3.1 - hooks: - - id: autoflake - args: ["--in-place", "--remove-all-unused-imports"] - - repo: https://github.com/psf/black - rev: 24.8.0 - hooks: - - id: black - - repo: https://github.com/PyCQA/flake8 - rev: 7.1.1 - hooks: - - id: flake8 - additional_dependencies: [flake8-typing-imports>=1.9.0] - - repo: https://github.com/tlambert03/napari-plugin-checks - rev: v0.3.0 - hooks: - - id: napari-plugin-checks - # https://mypy.readthedocs.io/en/stable/introduction.html - # you may wish to add this as well! - # - repo: https://github.com/pre-commit/mirrors-mypy - # rev: v0.910-1 - # hooks: - # - id: mypy +- repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.12.2 + hooks: + - id: ruff-format + exclude: examples + - id: ruff + +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + # .py files are skipped cause already checked by other hooks + hooks: + - id: check-yaml + - id: check-toml + - id: check-merge-conflict + exclude: .*\.py + - id: end-of-file-fixer + exclude: .*\.py + - id: trailing-whitespace + # trailing whitespace has meaning in markdown https://www.markdownguide.org/hacks/#indent-tab + exclude: .*\.py|.*\.md + - id: mixed-line-ending + exclude: .*\.py +- repo: https://github.com/python-jsonschema/check-jsonschema + rev: 0.33.2 + hooks: + - id: check-github-workflows diff --git a/pyproject.toml b/pyproject.toml index 3c16eeb..f89f293 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,9 +6,82 @@ build-backend = "setuptools.build_meta" [tool.setuptools_scm] write_to = "src/PartSeg_smfish/_version.py" -[tool.black] +[tool.ruff] line-length = 79 +exclude = [ + ".bzr", + ".direnv", + ".eggs", + ".git", + ".mypy_cache", + ".pants.d", + ".ruff_cache", + ".svn", + ".tox", + ".venv", + "__pypackages__", + "_build", + "buck-out", + "build", + "dist", + "node_modules", + "venv", + "*vendored*", + "*_vendor*", +] -[tool.isort] -profile = "black" -line_length = 79 +fix = true + +[tool.ruff.format] +quote-style = "double" + +[tool.ruff.lint] +select = [ + "E", "F", "W", #flake8 + "UP", # pyupgrade + "I", # isort + "YTT", #flake8-2020 + "TC", # flake8-type-checing + "BLE", # flake8-blind-exception + "B", # flake8-bugbear + "A", # flake8-builtins + "C4", # flake8-comprehensions + "ISC", # flake8-implicit-str-concat + "G", # flake8-logging-format + "PIE", # flake8-pie + "COM", # flake8-commas + "SIM", # flake8-simplify + "INP", # flake8-no-pep420 + "PYI", # flake8-pyi + "Q", # flake8-quotes + "RSE", # flake8-raise + "RET", # flake8-return + "TID", # flake8-tidy-imports # replace absolutify import + "TRY", # tryceratops + "ICN", # flake8-import-conventions + "RUF", # ruff specyfic rules + "NPY201", # checks compatibility with numpy version 2.0 + "ASYNC", # flake8-async + "EXE", # flake8-executable + "FA", # flake8-future-annotations + "LOG", # flake8-logging + "SLOT", # flake8-slots + "PT", # flake8-pytest-style + "T20", # flake8-print +] +ignore = [ + "COM812", # conflict with formatter +] + +[tool.ruff.lint.pyupgrade] +keep-runtime-typing = true + + +[tool.ruff.lint.flake8-quotes] +docstring-quotes = "double" +inline-quotes = "double" +multiline-quotes = "double" + +[tool.ruff.lint.isort] +known-first-party=['PartSeg_smfish'] +combine-as-imports = true diff --git a/src/PartSeg_smfish/__init__.py b/src/PartSeg_smfish/__init__.py index 74e43ad..9057a97 100644 --- a/src/PartSeg_smfish/__init__.py +++ b/src/PartSeg_smfish/__init__.py @@ -9,8 +9,7 @@ def register(): - from PartSegCore.register import RegisterEnum - from PartSegCore.register import register as register_fun + from PartSegCore.register import RegisterEnum, register as register_fun from . import measurement, segmentation diff --git a/src/PartSeg_smfish/_tests/test_segmentation.py b/src/PartSeg_smfish/_tests/test_segmentation.py index 480700c..1d7ab3c 100644 --- a/src/PartSeg_smfish/_tests/test_segmentation.py +++ b/src/PartSeg_smfish/_tests/test_segmentation.py @@ -9,7 +9,7 @@ ) -@pytest.mark.parametrize("mask", (True, False)) +@pytest.mark.parametrize("mask", [True, False]) def test_gauss_estimate(mask): param = GaussBackgroundEstimateParameters( background_estimate_radius=5, @@ -22,7 +22,7 @@ def test_gauss_estimate(mask): GaussBackgroundEstimate.spot_estimate(data, mask, (10, 10), param) -@pytest.mark.parametrize("mask", (True, False)) +@pytest.mark.parametrize("mask", [True, False]) def test_laplacian_estimate(mask): param = LaplacianBackgroundEstimateParameters( laplacian_radius=1.3, diff --git a/src/PartSeg_smfish/copy_labels.py b/src/PartSeg_smfish/copy_labels.py index 6508ba8..6796963 100644 --- a/src/PartSeg_smfish/copy_labels.py +++ b/src/PartSeg_smfish/copy_labels.py @@ -44,7 +44,7 @@ def __init__(self, napari_viewer: Viewer): self.setLayout(layout) self.viewer.layers.selection.events.active.connect( - self.activate_widget + self.activate_widget, ) self.copy_btn.clicked.connect(self.copy_action) self.check_all_btn.clicked.connect(self._check_all) @@ -116,8 +116,21 @@ def copy_action(self): checked = {layer.selected_label} z_position = self.viewer.dims.current_step[layer.dtype.ndim - 3] for component_num in checked: - mask = layer.data[leading_zeros + (z_position,)] == component_num + mask = ( + layer.data[ + ( + *leading_zeros, + z_position, + ) + ] + == component_num + ) start = max(0, self.lower.value()) end = min(layer.data.shape[1] - 1, self.upper.value()) + 1 for i in range(start, end): - layer.data[leading_zeros + (i,)][mask] = component_num + layer.data[ + ( + *leading_zeros, + i, + ) + ][mask] = component_num diff --git a/src/PartSeg_smfish/segmentation.py b/src/PartSeg_smfish/segmentation.py index 3e26fcd..cbb767a 100644 --- a/src/PartSeg_smfish/segmentation.py +++ b/src/PartSeg_smfish/segmentation.py @@ -1,7 +1,7 @@ import operator from abc import ABC, abstractmethod from copy import deepcopy -from typing import Callable, List, Tuple, Union +from typing import Callable, Union import numpy as np import SimpleITK @@ -38,8 +38,7 @@ ThresholdSelection, ) from PartSegCore.utils import BaseModel -from PartSegImage import Channel -from PartSegImage import Image as PSImage +from PartSegImage import Channel, Image as PSImage from pydantic import Field @@ -52,13 +51,20 @@ def spot_estimate(cls, array, mask, spacing, parameters): class GaussBackgroundEstimateParameters(BaseModel): background_estimate_radius: float = Field( - 5, description="Radius of background gauss filter", ge=0, le=20 + 5, + description="Radius of background gauss filter", + ge=0, + le=20, ) foreground_estimate_radius: float = Field( - 2.5, description="Radius of foreground gauss filter", ge=0, le=20 + 2.5, + description="Radius of foreground gauss filter", + ge=0, + le=20, ) estimate_mask: bool = Field( - True, description="Estimate background outside mask" + True, + description="Estimate background outside mask", ) @@ -105,12 +111,12 @@ class LaplacianBackgroundEstimateParameters(BaseModel): le=20, ) estimate_mask: bool = Field( - True, description="Estimate background outside mask" + True, + description="Estimate background outside mask", ) class LaplacianBackgroundEstimate(SpotDetect): - __argument_class__ = LaplacianBackgroundEstimateParameters @classmethod @@ -126,7 +132,9 @@ def spot_estimate( return _laplacian_estimate(array, parameters.laplacian_radius) mask = mask if mask is not None else array > 0 return _laplacian_estimate_mask( - array, mask, parameters.laplacian_radius + array, + mask, + parameters.laplacian_radius, ) @classmethod @@ -149,13 +157,18 @@ class SpotExtractionSelection( class SMSegmentationBaseParameters(BaseModel): channel_nuc: Channel = Field(0, title="Nucleus channel") noise_filtering_nucleus: NoiseFilterSelection = Field( - NoiseFilterSelection.get_default(), title="Filter nucleus" + NoiseFilterSelection.get_default(), + title="Filter nucleus", ) nucleus_threshold: ThresholdSelection = Field( - ThresholdSelection.get_default(), title="Nucleus threshold" + ThresholdSelection.get_default(), + title="Nucleus threshold", ) minimum_nucleus_size: int = Field( - 500, title="Minimum nucleus size (px)", ge=0, le=10**6 + 500, + title="Minimum nucleus size (px)", + ge=0, + le=10**6, ) leave_the_biggest: bool = Field( True, @@ -164,13 +177,18 @@ class SMSegmentationBaseParameters(BaseModel): ) channel_molecule: Channel = Field(1, title="Channel molecule") molecule_threshold: ThresholdSelection = Field( - ThresholdSelection.get_default(), title="Molecule threshold" + ThresholdSelection.get_default(), + title="Molecule threshold", ) minimum_molecule_size: int = Field( - 5, title="Minimum molecule size (px)", ge=0, le=10**6 + 5, + title="Minimum molecule size (px)", + ge=0, + le=10**6, ) spot_method: SpotExtractionSelection = Field( - SpotExtractionSelection.get_default(), title="Spot method" + SpotExtractionSelection.get_default(), + title="Spot method", ) @@ -195,7 +213,8 @@ def get_name(cls) -> str: return "sm-fish spot segmentation" def calculation_run( - self, report_fun: Callable[[str, int], None] + self, + report_fun: Callable[[str, int], None], ) -> SegmentationResult: channel_nuc = self.get_channel(self.new_parameters.channel_nuc) noise_filtering_parameters = ( @@ -218,19 +237,21 @@ def calculation_run( operator.ge, ) nucleus_connect = SimpleITK.ConnectedComponent( - SimpleITK.GetImageFromArray(nucleus_mask), True + SimpleITK.GetImageFromArray(nucleus_mask), + True, ) nucleus_segmentation = SimpleITK.GetArrayFromImage( SimpleITK.RelabelComponent( - nucleus_connect, self.new_parameters.minimum_nucleus_size - ) + nucleus_connect, + self.new_parameters.minimum_nucleus_size, + ), ) nucleus_segmentation = convex_fill(nucleus_segmentation) if self.new_parameters.leave_the_biggest: nucleus_segmentation[nucleus_segmentation > 1] = 0 channel_molecule = self.get_channel( - self.new_parameters.channel_molecule + self.new_parameters.channel_molecule, ) background_estimate: SpotDetect = SpotExtractionSelection[ self.new_parameters.spot_method.name @@ -259,25 +280,27 @@ def calculation_run( operator.ge, ) nucleus_connect = SimpleITK.ConnectedComponent( - SimpleITK.GetImageFromArray(molecule_mask), True + SimpleITK.GetImageFromArray(molecule_mask), + True, ) molecule_segmentation = SimpleITK.GetArrayFromImage( SimpleITK.RelabelComponent( - nucleus_connect, self.new_parameters.minimum_molecule_size - ) + nucleus_connect, + self.new_parameters.minimum_molecule_size, + ), ) sizes = np.bincount(molecule_segmentation.flat) elements = np.unique(molecule_segmentation[molecule_segmentation > 0]) cellular_components = set( - np.unique(molecule_segmentation[nucleus_segmentation == 0]) + np.unique(molecule_segmentation[nucleus_segmentation == 0]), ) if 0 in cellular_components: cellular_components.remove(0) nucleus_components = set( - np.unique(molecule_segmentation[nucleus_segmentation > 0]) + np.unique(molecule_segmentation[nucleus_segmentation > 0]), ) if 0 in nucleus_components: nucleus_components.remove(0) @@ -285,9 +308,9 @@ def calculation_run( cellular_components = cellular_components - mixed_components nucleus_components = nucleus_components - mixed_components label_types = ( - {i: "Nucleus" for i in nucleus_components} - | {i: "Cytoplasm" for i in cellular_components} - | {i: "Mixed" for i in mixed_components} + dict.fromkeys(nucleus_components, "Nucleus") + | dict.fromkeys(cellular_components, "Cytoplasm") + | dict.fromkeys(mixed_components, "Mixed") ) self._spots_count = { "Nucleus": len(nucleus_components), @@ -316,19 +339,24 @@ def calculation_run( parameters=self.get_segmentation_profile(), additional_layers={ "nucleus segmentation": AdditionalLayerDescription( - data=nucleus_segmentation, layer_type="labels" + data=nucleus_segmentation, + layer_type="labels", ), "roi segmentation": AdditionalLayerDescription( - data=molecule_segmentation, layer_type="labels" + data=molecule_segmentation, + layer_type="labels", ), "estimated signal": AdditionalLayerDescription( - data=estimated, layer_type="image" + data=estimated, + layer_type="image", ), "channel molecule": AdditionalLayerDescription( - data=channel_molecule, layer_type="image" + data=channel_molecule, + layer_type="image", ), "position": AdditionalLayerDescription( - data=position_array, layer_type="labels" + data=position_array, + layer_type="labels", ), }, roi_annotation=annotation, @@ -373,7 +401,7 @@ def gauss_background_estimate( ) if clip_bellow_0: resp[resp < 0] = 0 - resp = resp.reshape((1,) + resp.shape) + resp = resp.reshape((1, *resp.shape)) # return it + some layer properties return LayerDataTuple( ( @@ -383,14 +411,14 @@ def gauss_background_estimate( "scale": image.scale, "name": "Signal estimate", }, - ) + ), ) def _gauss_background_estimate_mask( channel: np.ndarray, mask: np.ndarray, - scale: Union[List[float], Tuple[Union[float, int]]], + scale: Union[list[float], tuple[Union[float, int]]], background_gauss_radius: float, foreground_gauss_radius: float, ) -> np.ndarray: @@ -400,7 +428,10 @@ def _gauss_background_estimate_mask( data = gaussian(data, 15, False) data[mask > 0] = channel[mask > 0] resp = _gauss_background_estimate( - data, scale, background_gauss_radius, foreground_gauss_radius + data, + scale, + background_gauss_radius, + foreground_gauss_radius, ) resp[mask == 0] = 0 return resp @@ -408,7 +439,7 @@ def _gauss_background_estimate_mask( def _gauss_background_estimate( channel: np.ndarray, - scale: Union[List[float], Tuple[Union[float, int]]], + scale: Union[list[float], tuple[Union[float, int]]], background_gauss_radius: float, foreground_gauss_radius: float, ) -> np.ndarray: @@ -433,7 +464,10 @@ def _gauss_background_estimate( def laplacian_estimate( - image: Image, mask: Labels, radius=1.30, clip_bellow_0=True + image: Image, + mask: Labels, + radius=1.30, + clip_bellow_0=True, ) -> LayerDataTuple: mask = ( mask.data @@ -452,12 +486,14 @@ def laplacian_estimate( "scale": image.scale, "name": "Laplacian estimate", }, - ) + ), ) def _laplacian_estimate_mask( - channel: np.ndarray, mask: np.ndarray, radius=1.30 + channel: np.ndarray, + mask: np.ndarray, + radius=1.30, ) -> np.ndarray: data = channel.astype(np.float64) mean_background = np.mean(data[mask > 0]) @@ -470,19 +506,25 @@ def _laplacian_estimate_mask( def _laplacian_estimate(channel: np.ndarray, radius=1.30) -> np.ndarray: return -SimpleITK.GetArrayFromImage( SimpleITK.LaplacianRecursiveGaussian( - SimpleITK.GetImageFromArray(channel), radius - ) + SimpleITK.GetImageFromArray(channel), + radius, + ), ) def laplacian_check( - image: Image, mask: Labels, radius=1.0, threshold=10.0, min_size=50 + image: Image, + mask: Labels, + radius=1.0, + threshold=10.0, + min_size=50, ) -> LayerDataTuple: data = image.data[0] laplaced = -SimpleITK.GetArrayFromImage( SimpleITK.LaplacianRecursiveGaussian( - SimpleITK.GetImageFromArray(data), radius - ) + SimpleITK.GetImageFromArray(data), + radius, + ), ) labeling = SimpleITK.GetArrayFromImage( @@ -496,22 +538,30 @@ def laplacian_check( SimpleITK.GetImageFromArray(mask.data[0]), ), min_size, - ) + ), ) - labeling = labeling.reshape((1,) + data.shape) + labeling = labeling.reshape((1, *data.shape)) return LayerDataTuple( - (labeling, {"scale": image.scale, "name": "Signal estimate"}) + (labeling, {"scale": image.scale, "name": "Signal estimate"}), ) class LayerRangeThresholdFlowParameters( - CellFromNucleusFlow.__argument_class__ + CellFromNucleusFlow.__argument_class__, ): lower_layer: int = Field( - 0, title="Lower layer", ge=-1, le=1000, position=0 + 0, + title="Lower layer", + ge=-1, + le=1000, + position=0, ) upper_layer: int = Field( - -1, title="Lower layer", ge=-1, le=1000, position=1 + -1, + title="Lower layer", + ge=-1, + le=1000, + position=1, ) @@ -537,7 +587,8 @@ def get_steps_num(): return CellFromNucleusFlow.get_steps_num() + 2 def calculation_run( - self, report_fun: Callable[[str, int], None] + self, + report_fun: Callable[[str, int], None], ) -> ROIExtractionResult: count = [0] @@ -555,7 +606,8 @@ def report_fun_wrap(text, num): slice_arr[self.image.stack_pos] = slice(lower_layer, upper_layer) slice_arr.pop(self.image.channel_pos) image: PSImage = self.image.substitute(mask=self.mask).cut_image( - slice_arr, frame=0 + slice_arr, + frame=0, ) new_data = np.max(image.get_data(), axis=image.stack_pos) @@ -582,16 +634,24 @@ def report_fun_wrap(text, num): report_fun("Copy layers", count[0] + 1) res_roi = np.zeros( - self.image.get_channel(0).shape, dtype=partial_res.roi.dtype + self.image.get_channel(0).shape, + dtype=partial_res.roi.dtype, ) base_index = (slice(None),) * (self.image.stack_pos) for i in range(lower_layer, upper_layer): - res_roi[base_index + (i,)] = partial_res.roi + res_roi[ + ( + *base_index, + i, + ) + ] = partial_res.roi report_fun("Prepare result", count[0] + 2) additional_layer = { "maximum_projection": AdditionalLayerDescription( - new_data, "image", "maximum projection" + new_data, + "image", + "maximum projection", ), **partial_res.additional_layers, } @@ -626,12 +686,12 @@ def maximum_projection( "scale": image.scale, "name": "Maximum projection", }, - ) + ), ) class ThresholdFlowAlgorithmParametersWithDilation( - ThresholdFlowAlgorithmParameters + ThresholdFlowAlgorithmParameters, ): dilation_radius: int = Field( 0, @@ -655,7 +715,8 @@ def get_steps_num(cls) -> int: return ThresholdFlowAlgorithm.get_steps_num() + 1 def calculation_run( - self, report_fun: Callable[[str, int], None] + self, + report_fun: Callable[[str, int], None], ) -> ROIExtractionResult: res = super().calculation_run(report_fun) if self.new_parameters.dilation_radius == 0: @@ -677,7 +738,9 @@ def calculation_run( ) res2.additional_layers["dilated roi"] = AdditionalLayerDescription( - res.roi, "labels", "Dilated ROI" + res.roi, + "labels", + "Dilated ROI", ) report_fun("Calculation done", self.get_steps_num()) return res2 diff --git a/src/PartSeg_smfish/verify_points.py b/src/PartSeg_smfish/verify_points.py index c6bf100..295d8d2 100644 --- a/src/PartSeg_smfish/verify_points.py +++ b/src/PartSeg_smfish/verify_points.py @@ -1,5 +1,4 @@ from itertools import product -from typing import List import numpy as np from magicgui import magic_factory @@ -8,7 +7,7 @@ from scipy.spatial.distance import cdist -def group_points(points: np.ndarray, max_dist=1) -> List[List[np.ndarray]]: +def group_points(points: np.ndarray, max_dist=1) -> list[list[np.ndarray]]: points = np.copy(points) points[:, -3] = np.round(points[:, -3]) sort = np.argsort(points[:, -3]) @@ -51,9 +50,10 @@ def _shift_array(points_to_roi: int, ndim: int) -> np.ndarray: base = tuple([0] * (ndim - 2)) return np.array( [ - base + (x, y) + (*base, x, y) for x, y in product( - range(-points_to_roi, points_to_roi + 1), repeat=2 + range(-points_to_roi, points_to_roi + 1), + repeat=2, ) if x**2 + y**2 <= points_to_roi**2 ], @@ -63,12 +63,12 @@ def _shift_array(points_to_roi: int, ndim: int) -> np.ndarray: class MatchResults: def __init__(self, points_grouped, labels): - self.points_grouped: List[List[np.ndarray]] = points_grouped - self.matched_points: List[bool] = [False for _ in self.points_grouped] + self.points_grouped: list[list[np.ndarray]] = points_grouped + self.matched_points: list[bool] = [False for _ in self.points_grouped] if 0 in labels: labels.remove(0) self.labels_preserve: np.ndarray = np.arange( - (max(labels) if labels else 0) + 1 + (max(labels) if labels else 0) + 1, ) self.labels = labels self.ignored = 0 @@ -126,7 +126,7 @@ def verify_segmentation( points_to_roi: int = 1, ignore_single_points: bool = True, info: str = "", -) -> List[LayerDataTuple]: +) -> list[LayerDataTuple]: match_result = verify_sm_segmentation( segmentation.data, points.data, @@ -141,13 +141,14 @@ def verify_segmentation( f" {len(match_result.matched_points)}" f"\nconsumed {all_labels - len(match_result.labels)} of" f" {all_labels} segmentation components" - + f"\nignored {match_result.ignored}" + f"\nignored {match_result.ignored}" if ignore_single_points else "" ) res = [] for ok, points_group in zip( - match_result.matched_points, match_result.points_grouped + match_result.matched_points, + match_result.points_grouped, ): if not ok: res.extend(points_group) @@ -181,7 +182,7 @@ def find_single_points( points_res = [x[0] for x in points_grouped if len(x) == 1] find_single_points.info.value = ( f"Single points count: {len(points_res)} of {len(points_grouped)}," - f" ratio {len(points_res)/len(points_grouped)}" + f" ratio {len(points_res) / len(points_grouped)}" ) points_res = np.array(points_res) if points_res else None return LayerDataTuple( @@ -193,5 +194,5 @@ def find_single_points( "face_color": "green", }, "points", - ) + ), )