From 91180f01dfe29c7f985d71d4eb31d020fd0657b3 Mon Sep 17 00:00:00 2001 From: Anwai Archit Date: Thu, 15 Oct 2026 16:47:57 +0200 Subject: [PATCH 1/6] Add optional classical features to the pixel and object classifiers [skip ci] --- micro_sam/_cli.py | 11 +- micro_sam/classical_features.py | 224 ++++++++++++++++++ micro_sam/object_classification.py | 30 ++- micro_sam/pixel_classification.py | 37 ++- micro_sam/sam_annotator/_annotator.py | 66 +++++- micro_sam/sam_annotator/_tooltips.py | 6 +- micro_sam/sam_annotator/_widgets.py | 72 ++++++ micro_sam/sam_annotator/object_classifier.py | 9 +- micro_sam/sam_annotator/pixel_classifier.py | 5 +- test/test_classical_features.py | 84 +++++++ .../test_classifier_ndim.py | 26 ++ 11 files changed, 539 insertions(+), 31 deletions(-) create mode 100644 micro_sam/classical_features.py create mode 100644 test/test_classical_features.py diff --git a/micro_sam/_cli.py b/micro_sam/_cli.py index 58b9ca294..50689f8b5 100644 --- a/micro_sam/_cli.py +++ b/micro_sam/_cli.py @@ -699,7 +699,7 @@ def _classifier_model_spec(rf_path): def _classifier_predictor(rf_path, model_type, checkpoint_path, tile_shape, halo, ndim, device): - """Build the predictor and optional upsampler for classifier inference, defaulting to the training config.""" + """Build the predictor, upsampler and classical features for classifier inference from the training config.""" from .util import _get_sam_model spec = _classifier_model_spec(rf_path) @@ -724,7 +724,7 @@ def _classifier_predictor(rf_path, model_type, checkpoint_path, tile_shape, halo from .pixel_classification import get_anyup_upsampler upsampler = get_anyup_upsampler(device=device) - return predictor, upsampler, model_type, tile_shape, halo + return predictor, upsampler, spec.get("classical_features"), model_type, tile_shape, halo def _classifier_common_options(f): @@ -773,7 +773,7 @@ def inference_pixel_classification( from .pixel_classification import run_prediction_with_pixel_classifier tile_shape, halo = _parse_shape(tile_shape), _parse_shape(halo) - predictor, upsampler, model_type, tile_shape, halo = _classifier_predictor( + predictor, upsampler, classical_features, model_type, tile_shape, halo = _classifier_predictor( rf_path, model_type, checkpoint_path, tile_shape, halo, ndim, device, ) @@ -784,7 +784,7 @@ def inference_pixel_classification( predictions = run_prediction_with_pixel_classifier( images=input_paths, predictor=predictor, rf_path=rf_path, image_key=key, ndim=ndim, - upsampler=upsampler, model_type=model_type, + upsampler=upsampler, model_type=model_type, classical_features=classical_features, ) for prediction, output_fpath in zip(predictions, output_paths): imageio.imwrite(output_fpath, prediction, compression="zlib") @@ -811,7 +811,7 @@ def inference_object_classification( from .object_classification import run_prediction_with_object_classifier tile_shape, halo = _parse_shape(tile_shape), _parse_shape(halo) - predictor, upsampler, model_type, tile_shape, halo = _classifier_predictor( + predictor, upsampler, classical_features, model_type, tile_shape, halo = _classifier_predictor( rf_path, model_type, checkpoint_path, tile_shape, halo, ndim, device, ) @@ -828,6 +828,7 @@ def inference_object_classification( predictions = run_prediction_with_object_classifier( images=input_paths, segmentations=segmentation_paths, predictor=predictor, rf_path=rf_path, image_key=key, segmentation_key=segmentation_key, ndim=ndim, upsampler=upsampler, model_type=model_type, + classical_features=classical_features, ) for prediction, output_fpath in zip(predictions, output_paths): imageio.imwrite(output_fpath, prediction, compression="zlib") diff --git a/micro_sam/classical_features.py b/micro_sam/classical_features.py new file mode 100644 index 000000000..69506ad61 --- /dev/null +++ b/micro_sam/classical_features.py @@ -0,0 +1,224 @@ +"""Classical (hand-crafted) image features to add on top of the foundation model features. + +The pixel features are an ilastik-like multi-scale filter bank, computed with `bioimage_cpp` on the +image resized to the pixel classification feature grid. The object features are morphology and +intensity statistics, computed with `bioimage_py` on the full resolution segmentation, with `scipy` and +`skimage` only for the statistics these packages do not provide. +""" + +import warnings +from multiprocessing import cpu_count +from typing import Optional, Sequence, Tuple + +import numpy as np +import pandas as pd +from scipy import ndimage +from skimage.measure import regionprops_table + +import bioimage_cpp.filters as bic_filters +from bioimage_cpp.transformation import resample + +from bioimage_py.morphology import morphology, regionprops + + +PIXEL_FEATURES = ( + "gaussian_smoothing", + "laplacian_of_gaussian", + "gaussian_gradient_magnitude", + "difference_of_gaussians", + "structure_tensor_eigenvalues", + "hessian_of_gaussian_eigenvalues", +) +"""The supported classical pixel features. Each one is computed per channel and per sigma.""" + +OBJECT_FEATURES = ( + "size", + "intensity_mean", + "intensity_std", + "intensity_min", + "intensity_max", + "extent", + "equivalent_diameter", + "axis_major_length", + "axis_minor_length", + "eccentricity", + "solidity", +) +"""The supported classical object features. The intensity features are computed per channel.""" + +DEFAULT_SIGMAS = (0.5, 1.0, 2.0, 4.0) +"""The default filter scales, in pixels of the pixel classification feature grid.""" + +# The ratio between the two scales of the difference of Gaussians. +DOG_RATIO = 1.6 + +INTENSITY_FEATURES = { + "intensity_mean": ndimage.mean, + "intensity_std": ndimage.standard_deviation, + "intensity_min": ndimage.minimum, + "intensity_max": ndimage.maximum, +} +SHAPE_FEATURES = ("extent", "equivalent_diameter", "axis_major_length", "axis_minor_length", "eccentricity") + + +def _validate(features: Sequence[str], supported: Sequence[str], kind: str) -> list: + features = list(features) + if not features: + raise ValueError(f"Expect at least one classical {kind} feature, choose from {list(supported)}.") + invalid = [name for name in features if name not in supported] + if invalid: + raise ValueError(f"Invalid classical {kind} features {invalid}, choose from {list(supported)}.") + # Use the canonical order, so that the feature columns do not depend on the order of the selection. + return [name for name in supported if name in features] + + +def _to_channels(image: np.ndarray, ndim: int) -> np.ndarray: + """Bring the image to float32 with a trailing channel axis, normalized to [0, 1] per channel.""" + image = np.asarray(image) + if image.ndim == ndim: + image = image[..., None] + elif image.ndim != ndim + 1: + raise ValueError(f"Expect an image with {ndim} spatial dimensions and optional channels, got {image.shape}.") + image = image.astype("float32") + axes = tuple(range(ndim)) + minval, maxval = image.min(axis=axes), image.max(axis=axes) + return (image - minval) / np.maximum(maxval - minval, 1e-7) + + +def _resize_plane(plane: np.ndarray, target_hw: Tuple[int, int]) -> np.ndarray: + # Anti-aliased resize with pixel-center alignment, matching 'skimage.transform.resize'. + scale = [s / t for s, t in zip(plane.shape, target_hw)] + matrix = np.array([[scale[0], 0.0, (scale[0] - 1) / 2], [0.0, scale[1], (scale[1] - 1) / 2]]) + return resample( + np.ascontiguousarray(plane), matrix, bounding_box=tuple(slice(0, t) for t in target_hw) + ) + + +def _filter_plane(plane: np.ndarray, features: Sequence[str], sigmas: Sequence[float]) -> list: + responses = [] + for sigma in sigmas: + for name in features: + if name == "gaussian_smoothing": + responses.append(bic_filters.gaussian_smoothing(plane, sigma)) + elif name == "laplacian_of_gaussian": + responses.append(bic_filters.laplacian_of_gaussian(plane, sigma)) + elif name == "gaussian_gradient_magnitude": + responses.append(bic_filters.gaussian_gradient_magnitude(plane, sigma)) + elif name == "difference_of_gaussians": + smoothed = bic_filters.gaussian_smoothing(plane, sigma) + responses.append(smoothed - bic_filters.gaussian_smoothing(plane, DOG_RATIO * sigma)) + elif name == "structure_tensor_eigenvalues": + eigenvalues = bic_filters.structure_tensor_eigenvalues(plane, sigma, sigma) + responses.extend(np.moveaxis(eigenvalues, -1, 0)) + else: + eigenvalues = bic_filters.hessian_of_gaussian_eigenvalues(plane, sigma) + responses.extend(np.moveaxis(eigenvalues, -1, 0)) + return responses + + +def compute_classical_pixel_features( + image: np.ndarray, + grid_shape: Tuple[int, ...], + features: Sequence[str] = PIXEL_FEATURES, + sigmas: Sequence[float] = DEFAULT_SIGMAS, +) -> np.ndarray: + """Compute classical filter features on the pixel classification feature grid. + + The image is normalized to [0, 1] per channel and resized to the grid, then the filters are applied + per channel, per sigma and, for 3d data, per slice. The result is aligned row-wise with the features + of `pixel_classification.compute_pixel_features`. + + Args: + image: The image, of shape (H, W) or (Z, H, W), with an optional trailing channel axis. + grid_shape: The feature grid shape, (gh, gw) for 2d or (Z, gh, gw) for 3d. + features: The classical pixel features to compute, a subset of `PIXEL_FEATURES`. + sigmas: The filter scales, in pixels of the feature grid. + + Returns: + The per-pixel features, of shape (N, F) flattened over the grid in row-major order. + """ + features = _validate(features, PIXEL_FEATURES, "pixel") + image = _to_channels(image, len(grid_shape)) + if len(grid_shape) == 2: + image = image[None] + grid_shape = (1,) + tuple(grid_shape) + if image.shape[0] != grid_shape[0]: + raise ValueError(f"The image has {image.shape[0]} slices, but the feature grid has {grid_shape[0]}.") + + planes = [] + for z in range(grid_shape[0]): + responses = [] + for c in range(image.shape[-1]): + plane = _resize_plane(image[z, ..., c], grid_shape[1:]) + responses.extend(_filter_plane(plane, features, sigmas)) + planes.append(np.stack(responses, axis=-1)) + return np.stack(planes).reshape(-1, planes[0].shape[-1]).astype("float32") + + +def compute_classical_object_features( + image: np.ndarray, + segmentation: np.ndarray, + seg_ids: Optional[np.ndarray] = None, + features: Sequence[str] = OBJECT_FEATURES, +) -> np.ndarray: + """Compute classical morphology and intensity features per object. + + Args: + image: The image, with the spatial shape of the segmentation and an optional trailing channel axis. + segmentation: The segmentation, of shape (H, W) or (Z, H, W). The background id 0 is ignored. + seg_ids: The ids of the objects to compute the features for, e.g. the ids returned by + `object_classification.compute_object_features`. By default all objects in the segmentation. + features: The classical object features to compute, a subset of `OBJECT_FEATURES`. + + Returns: + The object features, of shape (len(seg_ids), F), in the order of `seg_ids`. + """ + features = _validate(features, OBJECT_FEATURES, "object") + segmentation = np.asarray(segmentation) + if segmentation.dtype not in (np.uint32, np.uint64, np.int32, np.int64): + segmentation = segmentation.astype("uint32") + image = _to_channels(image, segmentation.ndim) + if image.shape[:-1] != segmentation.shape: + raise ValueError(f"The image shape {image.shape} does not match the segmentation shape {segmentation.shape}.") + + if seg_ids is None: + seg_ids = np.unique(segmentation) + seg_ids = seg_ids[seg_ids != 0] + seg_ids = np.asarray(seg_ids) + + columns = {} + shape_features = [name for name in features if name in SHAPE_FEATURES] + if "size" in features or shape_features: + table = morphology(segmentation) + if "size" in features: + columns["size"] = table.set_index("label")["size"] + if shape_features: + props = regionprops(segmentation, table, num_workers=cpu_count()).set_index("label") + major, minor = props["axis_major_length"], props["axis_minor_length"] + eccentricity = np.sqrt(1.0 - (minor / major.where(major > 0)) ** 2).fillna(0.0) + shape_columns = { + "extent": props["extent"], "equivalent_diameter": props["equivalent_diameter_area"], + "axis_major_length": major, "axis_minor_length": minor, "eccentricity": eccentricity, + } + columns.update({name: shape_columns[name] for name in shape_features}) + + if "solidity" in features: + # Flat or tiny objects have no convex hull, we treat them as fully convex. + with warnings.catch_warnings(), np.errstate(divide="ignore", invalid="ignore"): + warnings.simplefilter("ignore") + props = regionprops_table(segmentation, properties=("label", "solidity")) + solidity = np.where(np.isfinite(props["solidity"]), props["solidity"], 1.0) + columns["solidity"] = pd.Series(solidity, index=props["label"]) + + ordered = {} + for name in features: + if name in INTENSITY_FEATURES: + for c in range(image.shape[-1]): + values = INTENSITY_FEATURES[name](image[..., c], labels=segmentation, index=seg_ids) + ordered[f"{name}_{c}"] = pd.Series(np.asarray(values), index=seg_ids) + else: + ordered[name] = columns[name] + + # Objects that are missing from the segmentation get zero features. + result = pd.DataFrame(ordered).reindex(seg_ids).fillna(0.0) + return result.to_numpy(dtype="float32") diff --git a/micro_sam/object_classification.py b/micro_sam/object_classification.py index b928e576f..579445847 100644 --- a/micro_sam/object_classification.py +++ b/micro_sam/object_classification.py @@ -17,6 +17,7 @@ from . import util from .v1.util import precompute_image_embeddings +from .classical_features import compute_classical_object_features def _anyup_object_resize(embeds_chw, image_region, target_hw, upsampler): @@ -161,6 +162,7 @@ def compute_object_features( verbose: bool = True, image: Optional[np.ndarray] = None, upsampler=None, + classical_features: Optional[Sequence[str]] = None, ) -> Tuple[np.ndarray, np.ndarray]: """Compute object features based on SAM embeddings. @@ -169,9 +171,11 @@ def compute_object_features( segmentation: The segmentation for which to compute the features. resize_embedding_shape: Shape for intermediate resizing of the embeddings. verbose: Whether to print a progressbar for the computation. - image: The original image, required when `upsampler` is given (AnyUp uses it as guidance). + image: The original image, required when `upsampler` or `classical_features` is given. upsampler: An optional AnyUp model (see `pixel_classification.get_anyup_upsampler`). When given, the embedding is upsampled with AnyUp using the image instead of plain interpolation. + classical_features: The classical object features to append to the embedding features, a subset + of `classical_features.OBJECT_FEATURES`. By default no classical features are used. Returns: The segmentation ids. @@ -179,7 +183,22 @@ def compute_object_features( """ if upsampler is not None and image is None: raise ValueError("An 'image' is required when an AnyUp 'upsampler' is given.") + if classical_features is not None and image is None: + raise ValueError("An 'image' is required when 'classical_features' are given.") + seg_ids, features = _compute_embedding_object_features( + image_embeddings, segmentation, resize_embedding_shape, verbose, + image=image if upsampler is not None else None, upsampler=upsampler, + ) + if classical_features is not None: + classical = compute_classical_object_features(image, segmentation, seg_ids, classical_features) + features = np.concatenate([features, classical], axis=1) + return seg_ids, features + + +def _compute_embedding_object_features( + image_embeddings, segmentation, resize_embedding_shape, verbose, image=None, upsampler=None +): is_tiled = image_embeddings["input_size"] is None is_3d = segmentation.ndim == 3 @@ -317,6 +336,7 @@ def run_prediction_with_object_classifier( ndim: Optional[int] = None, upsampler=None, model_type: Optional[str] = None, + classical_features: Optional[Sequence[str]] = None, ) -> List[np.ndarray]: """Run prediction with a pretrained object classifier on a series of images. @@ -332,6 +352,9 @@ def run_prediction_with_object_classifier( upsampler: An optional AnyUp model (see `pixel_classification.get_anyup_upsampler`) to upsample the embeddings with the image as guidance instead of plain interpolation. Use the same setting that the classifier was trained with. + model_type: The model family of `predictor`, used to pick the matching embedding function. + classical_features: The classical object features to append to the embedding features. + Use the same features that the classifier was trained with. Returns: The predictions. @@ -351,9 +374,10 @@ def run_prediction_with_object_classifier( if isinstance(segmentation, (str, os.PathLike)): segmentation = util.load_image_data(segmentation, key=segmentation_key) embeddings = compute_embeddings(predictor, image, verbose=False, ndim=ndim) + use_image = upsampler is not None or classical_features is not None seg_ids, features = compute_object_features( - embeddings, segmentation, verbose=False, image=image if upsampler is not None else None, - upsampler=upsampler, + embeddings, segmentation, verbose=False, image=image if use_image else None, + upsampler=upsampler, classical_features=classical_features, ) prediction = rf.predict(features) if project_prediction: diff --git a/micro_sam/pixel_classification.py b/micro_sam/pixel_classification.py index 137052cc4..111e8c583 100644 --- a/micro_sam/pixel_classification.py +++ b/micro_sam/pixel_classification.py @@ -21,6 +21,7 @@ from . import util from .v1.util import precompute_image_embeddings +from .classical_features import compute_classical_pixel_features # Default in-plane grid size (longest side) for the per-pixel feature grid. # Non-tiled images use 'grid_size'. Tiled images use the larger 'max_grid_size', since tiling @@ -221,6 +222,7 @@ def compute_pixel_features( image: Optional[np.ndarray] = None, upsampler=None, verbose: bool = True, + classical_features: Optional[Sequence[str]] = None, ) -> Tuple[np.ndarray, Tuple[int, ...]]: """Compute per-pixel features from SAM embeddings for pixel classification. @@ -233,10 +235,12 @@ def compute_pixel_features( image_shape: The spatial shape of the image, (H, W) for 2d or (Z, H, W) for 3d. grid_size: In-plane grid size (longest side) for non-tiled images. max_grid_size: In-plane grid size (longest side) for tiled images. - image: The original image, required when `upsampler` is given (AnyUp uses it as guidance). + image: The original image, required when `upsampler` or `classical_features` is given. upsampler: An optional AnyUp model (see `get_anyup_upsampler`). When given, the embedding is upsampled with AnyUp using the image instead of plain interpolation. verbose: Whether to print a progressbar for the computation. + classical_features: The classical filter features to append to the embedding features, a subset + of `classical_features.PIXEL_FEATURES`. By default no classical features are used. Returns: The per-pixel features, of shape (N, C) flattened over the grid in row-major order. @@ -244,6 +248,8 @@ def compute_pixel_features( """ if upsampler is not None and image is None: raise ValueError("An 'image' is required when an AnyUp 'upsampler' is given.") + if classical_features is not None and image is None: + raise ValueError("An 'image' is required when 'classical_features' are given.") is_tiled = image_embeddings["input_size"] is None is_3d = len(image_shape) == 3 @@ -299,7 +305,11 @@ def compute_pixel_features( if anyup_pbar is not None: anyup_pbar.close() - return feature_image.reshape(-1, feature_image.shape[-1]), grid_shape + features = feature_image.reshape(-1, feature_image.shape[-1]) + if classical_features is not None: + classical = compute_classical_pixel_features(image, grid_shape, classical_features) + features = np.concatenate([features, classical], axis=1) + return features, grid_shape def accumulate_pixel_labels(annotation: np.ndarray, grid_shape: Tuple[int, ...]) -> np.ndarray: @@ -435,6 +445,8 @@ def run_training_with_pixel_classifier( n_components: Optional[int] = None, random_state: Optional[int] = 0, upsampler=None, + classical_features: Optional[Sequence[str]] = None, + model_type: Optional[str] = None, **rf_kwargs, ): """Train a pixel classifier on a series of images and (sparse) annotations. @@ -461,6 +473,10 @@ def run_training_with_pixel_classifier( to leave it unseeded, in which case predictions vary slightly between runs. upsampler: An optional AnyUp model (see `get_anyup_upsampler`) to upsample the embeddings with the image as guidance instead of plain interpolation. + classical_features: The classical filter features to append to the embedding features, a subset + of `classical_features.PIXEL_FEATURES`. By default no classical features are used. + model_type: The model family of `predictor`, used to pick the matching embedding function + (SAM1 / SAM2 / VFM). If not given, the SAM1 embedding function is used. rf_kwargs: Additional keyword arguments for the `RandomForestClassifier`. Returns: @@ -471,6 +487,8 @@ def run_training_with_pixel_classifier( f"Expect the same number of images and annotations, got {len(images)}, {len(annotations)}." ) + compute_embeddings = util.get_embedding_function(model_type) if model_type is not None \ + else precompute_image_embeddings all_features, all_labels = [], [] for image, annotation in tqdm( zip(images, annotations), total=len(images), desc="Compute features for pixel classifier training" @@ -482,10 +500,11 @@ def run_training_with_pixel_classifier( this_ndim = ndim if ndim is not None else (image.ndim - 1 if image.shape[-1] == 3 else image.ndim) image_shape = image.shape[:this_ndim] - embeddings = precompute_image_embeddings(predictor, image, verbose=False, ndim=this_ndim) + embeddings = compute_embeddings(predictor, image, verbose=False, ndim=this_ndim) + use_image = upsampler is not None or classical_features is not None features, grid_shape = compute_pixel_features( - embeddings, image_shape, image=image if upsampler is not None else None, - upsampler=upsampler, verbose=False, + embeddings, image_shape, image=image if use_image else None, + upsampler=upsampler, verbose=False, classical_features=classical_features, ) labels = accumulate_pixel_labels(annotation, grid_shape) @@ -521,6 +540,7 @@ def run_prediction_with_pixel_classifier( ndim: Optional[int] = None, upsampler=None, model_type: Optional[str] = None, + classical_features: Optional[Sequence[str]] = None, ) -> List[np.ndarray]: """Run prediction with a pretrained pixel classifier on a series of images. @@ -535,6 +555,8 @@ def run_prediction_with_pixel_classifier( the classifier was trained with. model_type: The model family of `predictor`, used to pick the matching embedding function (SAM1 / SAM2 / VFM). If not given, the SAM1 embedding function is used. + classical_features: The classical filter features to append to the embedding features. + Use the same features that the classifier was trained with. Returns: The pixel level predictions. @@ -551,9 +573,10 @@ def run_prediction_with_pixel_classifier( this_ndim = ndim if ndim is not None else (image.ndim - 1 if image.shape[-1] == 3 else image.ndim) image_shape = image.shape[:this_ndim] embeddings = compute_embeddings(predictor, image, verbose=False, ndim=this_ndim) + use_image = upsampler is not None or classical_features is not None features, grid_shape = compute_pixel_features( - embeddings, image_shape, image=image if upsampler is not None else None, - upsampler=upsampler, verbose=False, + embeddings, image_shape, image=image if use_image else None, + upsampler=upsampler, verbose=False, classical_features=classical_features, ) prediction = rf.predict(features) predictions.append(project_prediction_to_image(prediction, grid_shape, image_shape)) diff --git a/micro_sam/sam_annotator/_annotator.py b/micro_sam/sam_annotator/_annotator.py index 56cd47ae1..e845088f0 100644 --- a/micro_sam/sam_annotator/_annotator.py +++ b/micro_sam/sam_annotator/_annotator.py @@ -418,6 +418,7 @@ class _ClassifierBase(QtWidgets.QScrollArea): label_widget_title = "Label names:" max_components = 256 # PCA upper bound (256 pixel channels, 257 object features) tool_key = None # "pixel" | "object", selects the tool-specific tooltips + classical_feature_choices = () # the classical features this tool supports supports_apply_to_volume = True # if False the tool always runs over the full image/volume # @@ -472,17 +473,20 @@ def _get_cached_upsampler(self): state.anyup_upsampler = get_anyup_upsampler(device=device) return state.anyup_upsampler - def _resolve_anyup(self): - # Returns (image, upsampler, ok). image/upsampler are None unless AnyUp is enabled. - image, upsampler = None, None + def _resolve_feature_inputs(self): + # Returns (image, upsampler, classical_features, ok). The image is only needed for AnyUp or + # the classical features, and is None otherwise. + upsampler = None if self._get_use_anyup(): try: upsampler = self._get_cached_upsampler() except ImportError as e: widgets._generate_message("error", str(e)) - return None, None, False - image = self._viewer.layers["image"].data - return image, upsampler, True + return None, None, None, False + classical_features = self._get_classical_features() + use_image = upsampler is not None or classical_features is not None + image = self._viewer.layers["image"].data if use_image else None + return image, upsampler, classical_features, True def accumulate_batch_features(self): """Add the current image's labeled features to the running batch training set. @@ -717,6 +721,27 @@ def _create_classifier_io_widget(self): checkbox_row.native.layout().setContentsMargins(0, 0, 0, 0) checkbox_row.native.layout().addStretch(1) + # Optional classical features, appended to the embedding features. Checking it reveals a + # dropdown to select the features. Both change the features, so they clear the cached ones. + use_classical = CheckBox(value=False, text="Add classical features") + use_classical.native.setToolTip(get_tooltip("classification", f"use_classical_features_{self.tool_key}")) + classical_tooltip = get_tooltip("classification", f"classical_features_{self.tool_key}") + classical_features = widgets.CheckableComboBox( + self.classical_feature_choices, labels=[name.replace("_", " ") for name in self.classical_feature_choices], + ) + classical_features.setToolTip(classical_tooltip) + size_policy = getattr(QtWidgets.QSizePolicy, "Policy", QtWidgets.QSizePolicy) + classical_features.setSizePolicy(size_policy.Expanding, size_policy.Fixed) + classical_label = Label(value="classical features:") + classical_label.native.setToolTip(classical_tooltip) + classical_row = Container(layout="horizontal", widgets=[classical_label], labels=False) + classical_row.native.layout().addWidget(classical_features) + classical_row.native.layout().setContentsMargins(0, 0, 0, 0) + classical_row.visible = False + use_classical.changed.connect(lambda checked: setattr(classical_row, "visible", checked)) + use_classical.changed.connect(self._invalidate_features) + classical_features.selection_changed.connect(self._invalidate_features) + top_features_tooltip = get_tooltip("classification", f"top_features_{self.tool_key}") top_features = SpinBox(value=10, min=1, max=self.max_components, step=1) top_features.native.setToolTip(top_features_tooltip) @@ -739,7 +764,6 @@ def _create_classifier_io_widget(self): random_seed_tooltip = get_tooltip("classification", "random_seed") random_seed.native.setToolTip(random_seed_tooltip) # Let the dropdown expand to fill the row width (no trailing stretch), matching the path widgets. - size_policy = getattr(QtWidgets.QSizePolicy, "Policy", QtWidgets.QSizePolicy) random_seed.native.setSizePolicy(size_policy.Expanding, size_policy.Fixed) random_seed_label = Label(value="random seed:") random_seed_label.native.setToolTip(random_seed_tooltip) @@ -754,20 +778,31 @@ def get_n_components(): def get_use_anyup(): return bool(use_anyup.value) + def get_classical_features(): + # None switches the classical features off, also when the checkbox is on but nothing is selected. + selected = classical_features.selected() + return selected if use_classical.value and selected else None + def get_random_state(): return 0 if random_seed.value == "fixed" else None - def set_options(use_top_features_val, n_top_features, use_anyup_val, random_seed_val=None): + def set_options( + use_top_features_val, n_top_features, use_anyup_val, random_seed_val=None, classical_features_val=None + ): # Restore the option controls from a loaded classifier's spec. use_top_features.value = bool(use_top_features_val) if n_top_features: top_features.value = int(n_top_features) use_anyup.value = bool(use_anyup_val) + use_classical.value = classical_features_val is not None + if classical_features_val is not None: + classical_features.set_selected(classical_features_val) if random_seed_val is not None: random_seed.value = random_seed_val self._get_n_components = get_n_components self._get_use_anyup = get_use_anyup + self._get_classical_features = get_classical_features self._get_random_state = get_random_state self._set_options = set_options @@ -787,7 +822,10 @@ def set_options(use_top_features_val, n_top_features, use_anyup_val, random_seed export_button.native.setToolTip(get_tooltip("classification", "export_button")) export_button.clicked.connect(lambda: self._save_rf(export_dir.value)) - rows = [checkbox_row, top_features_row, random_seed_row, load_path, load_button, export_dir, export_button] + rows = [ + checkbox_row, top_features_row, use_classical, classical_row, random_seed_row, + load_path, load_button, export_dir, export_button, + ] return Container(widgets=rows, labels=False) def _create_widgets(self): @@ -916,6 +954,10 @@ def _run_train_and_predict(self, apply_to_volume=True): previous_features, previous_labels = state.previous_features, state.previous_labels if (labels == 0).all() and (previous_labels is None): return widgets._generate_message("error", "You have not provided any annotations.") + if previous_features is not None and previous_features.shape[1] != features.shape[1]: + return widgets._generate_message( + "error", "The features of the previous images do not match the current feature settings." + ) n_components = self._get_n_components() random_state = self._get_random_state() @@ -1019,6 +1061,7 @@ def _classifier_spec(self, rf): tiling_on = getattr(ew, "tiling", "no") == "yes" n_components = self._get_n_components() use_anyup = bool(self._get_use_anyup()) + classical_features = self._get_classical_features() random_seed = "random" if self._get_random_state() is None else "fixed" # The GUI sets 'ew.model_type' only after it computes embeddings. Use the predictor's # model_type (always set) instead, so a CLI-launched session still records the model. @@ -1035,6 +1078,7 @@ def _classifier_spec(self, rf): "use_top_features": n_components > 0, "n_top_features": n_components if n_components > 0 else None, "upsampling": "anyup" if use_anyup else "interpolation", + "classical_features": classical_features, "random_seed": random_seed, "ndim": getattr(self, "_ndim", None), "class_ids": [int(c) for c in rf.classes_], @@ -1072,10 +1116,10 @@ def _restore_from_spec(self, spec): device=None, tile_shape=spec.get("tile_shape"), halo=spec.get("halo"), ) - # Top-feature selection, AnyUp/interpolation upsampling and the random-seed mode. + # Top-feature selection, AnyUp/interpolation upsampling, the random-seed mode and the classical features. self._set_options( spec.get("use_top_features", False), spec.get("n_top_features"), - spec.get("upsampling") == "anyup", spec.get("random_seed"), + spec.get("upsampling") == "anyup", spec.get("random_seed"), spec.get("classical_features"), ) # Class names, restored into the label widget. diff --git a/micro_sam/sam_annotator/_tooltips.py b/micro_sam/sam_annotator/_tooltips.py index 5da21dfc1..df07daf65 100644 --- a/micro_sam/sam_annotator/_tooltips.py +++ b/micro_sam/sam_annotator/_tooltips.py @@ -119,7 +119,7 @@ "commit_button": "Commit the current segmentation to the committed-objects layer. Shortcut: C.", }, "classification": { - "settings": "Optional classifier settings: PCA feature reduction, AnyUp upsampling, the random seed, and loading or exporting a trained classifier.", # noqa + "settings": "Optional classifier settings: PCA feature reduction, AnyUp upsampling, classical features, the random seed, and loading or exporting a trained classifier.", # noqa "forward_classifier_state": "Carry the classifier across images in the batch. The tool stacks the annotated features from earlier images with the current image. It trains a fresh random forest on the combined set. Then it applies the forest to the next image automatically, even without new annotations. Uncheck to classify each image on its own.", # noqa "segmentation": "Select the segmentation (labels) layer whose objects will be classified.", "train_button": "Train the random forest on all current annotations and predict on the image. Shortcut: Shift + T.", # noqa @@ -129,6 +129,10 @@ "use_top_features_object": "Reduce the object features to their most informative components via PCA before training. When unchecked, all features are used and no PCA is applied.", # noqa "top_features_pixel": "Number of top PCA components to reduce the embedding to, between 1 and 256 (the SAM/SAM2 image embedding has 256 channels for all model sizes).", # noqa "top_features_object": "Number of top PCA components to reduce the object features to, between 1 and 257 (object area plus the 256 per-channel embedding means).", # noqa + "use_classical_features_pixel": "Add classical filter features (as in ilastik) to the SAM/SAM2 embedding features. They are computed at several scales on the image resized to the feature grid, which can help for intensity- and texture-driven classes.", # noqa + "use_classical_features_object": "Add classical object features (size, intensity statistics and shape descriptors) to the SAM/SAM2 embedding features. They are computed on the full-resolution image and segmentation.", # noqa + "classical_features_pixel": "Select the classical filters to add. Each filter is computed per image channel at the scales 0.5, 1, 2 and 4 (in pixels of the feature grid).", # noqa + "classical_features_object": "Select the classical object features to add. The intensity statistics are computed per image channel.", # noqa "use_anyup": "Use AnyUp to upsample the embedding with the original image as guidance, for sharper features near object boundaries. When unchecked, plain interpolation is used.", # noqa "random_seed": "'fixed' trains with a fixed random seed so the prediction is reproducible across runs. 'random' leaves the random forest unseeded, so results change slightly each time you train.", # noqa "load_path": "Path to a stored classifier (.joblib) to load and apply to the current image.", diff --git a/micro_sam/sam_annotator/_widgets.py b/micro_sam/sam_annotator/_widgets.py index d59fc47a8..d8c88f977 100644 --- a/micro_sam/sam_annotator/_widgets.py +++ b/micro_sam/sam_annotator/_widgets.py @@ -21,6 +21,7 @@ from qtpy import QtWidgets from qtpy.QtCore import QObject, Signal, Qt +from qtpy.QtGui import QStandardItem, QStandardItemModel from magicgui import magic_factory from magicgui.widgets import ComboBox, Container, create_widget @@ -71,6 +72,77 @@ def _make_collapsible(widget, title, tooltip=None): return parent_widget +class CheckableComboBox(QtWidgets.QComboBox): + """A dropdown with a checkbox per item, to select several items at once. + + Args: + items: The item names. + labels: Optional display text per item. By default the item names are shown. + parent: The parent widget. + """ + selection_changed = Signal() + + def __init__(self, items, labels=None, parent=None): + super().__init__(parent) + self._items = list(items) + labels = self._items if labels is None else list(labels) + self._model = QStandardItemModel(self) + for label in labels: + item = QStandardItem(label) + # The click handler toggles the state, so the item itself is not user-checkable. + item.setFlags(Qt.ItemIsEnabled) + item.setData(Qt.Checked, Qt.CheckStateRole) + self._model.appendRow(item) + self.setModel(self._model) + self.view().pressed.connect(self._toggle) + self._keep_open = False + + def _toggle(self, index): + item = self._model.itemFromIndex(index) + checked = item.data(Qt.CheckStateRole) == Qt.Checked + item.setData(Qt.Unchecked if checked else Qt.Checked, Qt.CheckStateRole) + self._keep_open = True + self.update() + self.selection_changed.emit() + + def hidePopup(self): + # Keep the popup open while the user toggles items. + if self._keep_open: + self._keep_open = False + return + super().hidePopup() + + def selected(self): + """Return the names of the checked items.""" + return [ + name for row, name in enumerate(self._items) + if self._model.item(row).data(Qt.CheckStateRole) == Qt.Checked + ] + + def set_selected(self, names): + """Check exactly the items in `names`.""" + for row, name in enumerate(self._items): + self._model.item(row).setData(Qt.Checked if name in names else Qt.Unchecked, Qt.CheckStateRole) + self.update() + self.selection_changed.emit() + + def paintEvent(self, event): + # Show a summary of the selection instead of the current item. + n_selected = len(self.selected()) + if n_selected == len(self._items): + text = "all features" + elif n_selected == 0: + text = "no features" + else: + text = f"{n_selected} of {len(self._items)} features" + painter = QtWidgets.QStylePainter(self) + option = QtWidgets.QStyleOptionComboBox() + self.initStyleOption(option) + option.currentText = text + painter.drawComplexControl(QtWidgets.QStyle.CC_ComboBox, option) + painter.drawControl(QtWidgets.QStyle.CE_ComboBoxLabel, option) + + # Base class for a widget with convenience functionality for adding parameters. class _WidgetBase(QtWidgets.QWidget): def __init__(self, parent=None): diff --git a/micro_sam/sam_annotator/object_classifier.py b/micro_sam/sam_annotator/object_classifier.py index 89390da2d..94733f2fe 100644 --- a/micro_sam/sam_annotator/object_classifier.py +++ b/micro_sam/sam_annotator/object_classifier.py @@ -26,11 +26,12 @@ from ._annotator import _ClassifierBase from .util import _sync_embedding_widget from ._batch_classification import ClassificationBatchTask +from ..classical_features import OBJECT_FEATURES from ..object_classification import compute_object_features, project_prediction_to_segmentation # Object features are the object area plus the per-channel mean of the 256-channel SAM/SAM2 image # embedding, i.e. 257 features. PCA can reduce to at most this many components. -OBJECT_FEATURES = 257 +N_OBJECT_FEATURES = 257 INTERNAL_LABEL_LAYER_NAMES = {"annotations", "prediction"} @@ -91,8 +92,9 @@ class ObjectClassifier(_ClassifierBase): features_attr = "object_features" aux_attr = "seg_ids" label_widget_title = "Object label names:" - max_components = OBJECT_FEATURES + max_components = N_OBJECT_FEATURES tool_key = "object" + classical_feature_choices = OBJECT_FEATURES supports_apply_to_volume = False # object classification always runs over the full image/volume def _get_selected_segmentation_layer(self): @@ -112,11 +114,12 @@ def _compute_features(self): segmentation_layer = self._get_selected_segmentation_layer() if segmentation_layer is None: return None, None - image, upsampler, ok = self._resolve_anyup() + image, upsampler, classical_features, ok = self._resolve_feature_inputs() if not ok: return None, None seg_ids, features = compute_object_features( state.image_embeddings, segmentation_layer.data, image=image, upsampler=upsampler, + classical_features=classical_features, ) state.seg_ids, state.object_features = seg_ids, features return state.object_features, state.seg_ids diff --git a/micro_sam/sam_annotator/pixel_classifier.py b/micro_sam/sam_annotator/pixel_classifier.py index 9dce10f53..157e7e103 100644 --- a/micro_sam/sam_annotator/pixel_classifier.py +++ b/micro_sam/sam_annotator/pixel_classifier.py @@ -16,6 +16,7 @@ from ._annotator import _ClassifierBase from .util import _sync_embedding_widget from ._batch_classification import ClassificationBatchTask +from ..classical_features import PIXEL_FEATURES from ..pixel_classification import ( accumulate_pixel_labels, compute_pixel_features, project_prediction_to_image, train_pixel_classifier, ) @@ -34,6 +35,7 @@ class PixelClassifier(_ClassifierBase): label_widget_title = "Pixel label names:" max_components = EMBEDDING_CHANNELS tool_key = "pixel" + classical_feature_choices = PIXEL_FEATURES def _compute_features(self): # Returns (features, grid_shape) for the current image, computing and caching them if needed. @@ -41,11 +43,12 @@ def _compute_features(self): if state.pixel_features is None: if widgets._validate_embeddings(self._viewer): return None, None - image, upsampler, ok = self._resolve_anyup() + image, upsampler, classical_features, ok = self._resolve_feature_inputs() if not ok: return None, None features, grid_shape = compute_pixel_features( state.image_embeddings, state.image_shape, image=image, upsampler=upsampler, + classical_features=classical_features, ) state.pixel_features, state.pixel_grid_shape = features, grid_shape return state.pixel_features, state.pixel_grid_shape diff --git a/test/test_classical_features.py b/test/test_classical_features.py new file mode 100644 index 000000000..b67e59f67 --- /dev/null +++ b/test/test_classical_features.py @@ -0,0 +1,84 @@ +import unittest + +import numpy as np +from skimage.data import coins +from skimage.filters import threshold_otsu +from skimage.measure import label, regionprops_table + +from micro_sam.classical_features import ( + OBJECT_FEATURES, PIXEL_FEATURES, compute_classical_object_features, compute_classical_pixel_features, +) + + +class TestClassicalFeatures(unittest.TestCase): + def setUp(self): + self.image = coins() + self.segmentation = label(self.image > threshold_otsu(self.image)) + + def test_pixel_features_2d(self): + grid_shape = (75, 96) + features = compute_classical_pixel_features(self.image, grid_shape) + # 6 filters per sigma, two of them with 2 eigenvalues, at 4 sigmas. + self.assertEqual(features.shape, (75 * 96, 4 * 8)) + self.assertTrue(np.isfinite(features).all()) + + # The features are independent of the order of the selection. + subset = compute_classical_pixel_features(self.image, grid_shape, ["gaussian_smoothing"]) + reordered = compute_classical_pixel_features( + self.image, grid_shape, ["hessian_of_gaussian_eigenvalues", "gaussian_smoothing"] + ) + self.assertEqual(subset.shape, (75 * 96, 4)) + np.testing.assert_allclose(reordered[:, 0], subset[:, 0]) + + def test_pixel_features_channels_and_3d(self): + rgb = np.stack(3 * [self.image], axis=-1) + features = compute_classical_pixel_features(rgb, (75, 96), ["gaussian_gradient_magnitude"]) + self.assertEqual(features.shape, (75 * 96, 3 * 4)) + + volume = np.stack(2 * [self.image]) + features = compute_classical_pixel_features(volume, (2, 75, 96), ["difference_of_gaussians"]) + self.assertEqual(features.shape, (2 * 75 * 96, 4)) + + with self.assertRaises(ValueError): + compute_classical_pixel_features(volume, (3, 75, 96), ["difference_of_gaussians"]) + + def test_invalid_features(self): + with self.assertRaises(ValueError): + compute_classical_pixel_features(self.image, (75, 96), ["size"]) + with self.assertRaises(ValueError): + compute_classical_object_features(self.image, self.segmentation, features=[]) + + def test_object_features_match_skimage(self): + seg_ids = np.unique(self.segmentation)[1:][::-1] + features = compute_classical_object_features(self.image, self.segmentation, seg_ids) + self.assertEqual(features.shape, (len(seg_ids), len(OBJECT_FEATURES))) + + normalized = self.image.astype("float32") + normalized = (normalized - normalized.min()) / (normalized.max() - normalized.min()) + expected = regionprops_table( + self.segmentation, normalized, properties=( + "label", "area", "intensity_mean", "intensity_std", "intensity_min", "intensity_max", "extent", + "equivalent_diameter_area", "axis_major_length", "axis_minor_length", "eccentricity", "solidity", + ) + ) + order = [list(expected["label"]).index(seg_id) for seg_id in seg_ids] + for i, name in enumerate(list(expected)[1:]): + np.testing.assert_allclose(features[:, i], np.asarray(expected[name])[order], rtol=1e-4, atol=1e-5) + + def test_object_features_3d_and_missing_ids(self): + volume = np.stack(3 * [self.image]) + segmentation = np.stack(3 * [self.segmentation]) + seg_ids = np.array([1, 2, 10_000]) + features = compute_classical_object_features(volume, segmentation, seg_ids, ["size", "solidity"]) + self.assertEqual(features.shape, (3, 2)) + self.assertTrue(np.isfinite(features).all()) + self.assertEqual(features[0, 0], 3 * (self.segmentation == 1).sum()) + np.testing.assert_array_equal(features[2], 0) + + def test_supported_features(self): + self.assertEqual(len(set(PIXEL_FEATURES)), len(PIXEL_FEATURES)) + self.assertEqual(len(set(OBJECT_FEATURES)), len(OBJECT_FEATURES)) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_sam_annotator/test_classifier_ndim.py b/test/test_sam_annotator/test_classifier_ndim.py index c6df8d39d..e97b80451 100644 --- a/test/test_sam_annotator/test_classifier_ndim.py +++ b/test/test_sam_annotator/test_classifier_ndim.py @@ -55,3 +55,29 @@ def test_classifier_recreates_label_layers_for_3d(make_napari_viewer_proxy, clas assert len(layer.scale) == 3 viewer.close() + + +@pytest.mark.gui +@pytest.mark.skipif(platform.system() in ("Windows",), reason="Gui test is not working on windows.") +@pytest.mark.parametrize("classifier_cls", [PixelClassifier, ObjectClassifier]) +def test_classifier_classical_features_option(make_napari_viewer_proxy, classifier_cls): + viewer = make_napari_viewer_proxy() + annotator = classifier_cls(viewer) + state = AnnotatorState() + choices = list(classifier_cls.classical_feature_choices) + + # Off by default, which switches the classical features off in the backend. + assert annotator._get_classical_features() is None + + # Restoring a spec enables the option with the stored selection and clears the cached features. + setattr(state, annotator.features_attr, np.zeros((1, 1))) + annotator._set_options(False, None, False, "fixed", choices[:2]) + assert annotator._get_classical_features() == choices[:2] + assert getattr(state, annotator.features_attr) is None + + # An empty selection is treated as switched off. + annotator._set_options(False, None, False, "fixed", []) + assert annotator._get_classical_features() is None + annotator._set_options(False, None, False, "fixed", None) + assert annotator._get_classical_features() is None + viewer.close() From 09a2386fafff114beee1a2fefb7b317741c31e96 Mon Sep 17 00:00:00 2001 From: Anwai Archit Date: Thu, 15 Oct 2026 17:09:20 +0200 Subject: [PATCH 2/6] Place the classical feature dropdown next to its checkbox [skip ci] --- micro_sam/sam_annotator/_annotator.py | 11 +++++------ micro_sam/sam_annotator/_widgets.py | 4 ++++ 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/micro_sam/sam_annotator/_annotator.py b/micro_sam/sam_annotator/_annotator.py index e845088f0..d4d122fe5 100644 --- a/micro_sam/sam_annotator/_annotator.py +++ b/micro_sam/sam_annotator/_annotator.py @@ -732,13 +732,12 @@ def _create_classifier_io_widget(self): classical_features.setToolTip(classical_tooltip) size_policy = getattr(QtWidgets.QSizePolicy, "Policy", QtWidgets.QSizePolicy) classical_features.setSizePolicy(size_policy.Expanding, size_policy.Fixed) - classical_label = Label(value="classical features:") - classical_label.native.setToolTip(classical_tooltip) - classical_row = Container(layout="horizontal", widgets=[classical_label], labels=False) + classical_features.setVisible(False) + classical_row = Container(layout="horizontal", widgets=[use_classical], labels=False) classical_row.native.layout().addWidget(classical_features) classical_row.native.layout().setContentsMargins(0, 0, 0, 0) - classical_row.visible = False - use_classical.changed.connect(lambda checked: setattr(classical_row, "visible", checked)) + classical_row.native.layout().addStretch(1) + use_classical.changed.connect(lambda checked: classical_features.setVisible(checked)) use_classical.changed.connect(self._invalidate_features) classical_features.selection_changed.connect(self._invalidate_features) @@ -823,7 +822,7 @@ def set_options( export_button.clicked.connect(lambda: self._save_rf(export_dir.value)) rows = [ - checkbox_row, top_features_row, use_classical, classical_row, random_seed_row, + checkbox_row, top_features_row, classical_row, random_seed_row, load_path, load_button, export_dir, export_button, ] return Container(widgets=rows, labels=False) diff --git a/micro_sam/sam_annotator/_widgets.py b/micro_sam/sam_annotator/_widgets.py index d8c88f977..997496483 100644 --- a/micro_sam/sam_annotator/_widgets.py +++ b/micro_sam/sam_annotator/_widgets.py @@ -96,6 +96,10 @@ def __init__(self, items, labels=None, parent=None): self.setModel(self._model) self.view().pressed.connect(self._toggle) self._keep_open = False + # The closed box only shows a short summary, so the long item names must not widen the panel. + self.setSizeAdjustPolicy(QtWidgets.QComboBox.AdjustToMinimumContentsLengthWithIcon) + self.setMinimumContentsLength(12) + self.view().setMinimumWidth(self.view().sizeHintForColumn(0) + 40) def _toggle(self, index): item = self._model.itemFromIndex(index) From f643cea74f14096ac567f1863952be6e1405e00c Mon Sep 17 00:00:00 2001 From: Anwai Archit Date: Fri, 16 Oct 2026 10:35:32 +0200 Subject: [PATCH 3/6] Raise the bioimage-py pin to 0.2.2 --- environment.yaml | 2 +- setup.cfg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/environment.yaml b/environment.yaml index b2202ac90..a1d6b62e1 100644 --- a/environment.yaml +++ b/environment.yaml @@ -3,7 +3,7 @@ channels: - conda-forge dependencies: - bioimage-cpp >=0.7 - - bioimage-py >=0.2.1 + - bioimage-py >=0.2.2 - bioimageio.core >=0.11 - click - imagecodecs diff --git a/setup.cfg b/setup.cfg index 4a939fc8a..3f4cbcfae 100644 --- a/setup.cfg +++ b/setup.cfg @@ -37,7 +37,7 @@ package_dir = install_requires = anyup @ git+https://github.com/wimmerth/anyup bioimage-cpp>=0.7 - bioimage-py>=0.2.1 + bioimage-py>=0.2.2 # model_export passes FileDescr for covers and documentation, which needs spec >=0.5.11. bioimageio.core>=0.11 click From 6c260e63b31f5f320c7301a1595b8545d53a5aaf Mon Sep 17 00:00:00 2001 From: Anwai Archit Date: Fri, 16 Oct 2026 10:39:05 +0200 Subject: [PATCH 4/6] Silence the regionprops progress output in the classical features --- micro_sam/classical_features.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/micro_sam/classical_features.py b/micro_sam/classical_features.py index 69506ad61..0fc9c76b2 100644 --- a/micro_sam/classical_features.py +++ b/micro_sam/classical_features.py @@ -193,7 +193,7 @@ def compute_classical_object_features( if "size" in features: columns["size"] = table.set_index("label")["size"] if shape_features: - props = regionprops(segmentation, table, num_workers=cpu_count()).set_index("label") + props = regionprops(segmentation, table, num_workers=cpu_count(), verbose=False).set_index("label") major, minor = props["axis_major_length"], props["axis_minor_length"] eccentricity = np.sqrt(1.0 - (minor / major.where(major > 0)) ** 2).fillna(0.0) shape_columns = { From 1f5acf7a3e61ec769dc126e4f25f35a9d62ad865 Mon Sep 17 00:00:00 2001 From: Anwai Archit Date: Fri, 16 Oct 2026 10:49:54 +0200 Subject: [PATCH 5/6] Cancel older CI runs on a PR when a new commit is pushed --- .github/workflows/test-pip-installation.yaml | 5 +++++ .github/workflows/test.yaml | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/.github/workflows/test-pip-installation.yaml b/.github/workflows/test-pip-installation.yaml index bb1b280b5..8cdb8dbaf 100644 --- a/.github/workflows/test-pip-installation.yaml +++ b/.github/workflows/test-pip-installation.yaml @@ -10,6 +10,11 @@ on: pull_request: # run CI on commits to any open PR workflow_dispatch: # can manually trigger CI from GitHub actions tab +# Cancel older runs of this workflow on the same PR when a new commit is pushed. +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: test-pip: name: pip-install ubuntu-latest ${{ matrix.python-version }} diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index ff2f5c132..8dab7925e 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -10,6 +10,11 @@ on: pull_request: # run CI on commits to any open PR workflow_dispatch: # can manually trigger CI from GitHub actions tab +# Cancel older runs of this workflow on the same PR when a new commit is pushed. +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: test: name: ${{ matrix.os }} ${{ matrix.python-version }} From a6f6c00d69f704d423f98610c105c63965f773d3 Mon Sep 17 00:00:00 2001 From: Anwai Archit Date: Fri, 16 Oct 2026 10:52:18 +0200 Subject: [PATCH 6/6] Limit the CI cancellation to runs on the same PR --- .github/workflows/test-pip-installation.yaml | 4 ++-- .github/workflows/test.yaml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test-pip-installation.yaml b/.github/workflows/test-pip-installation.yaml index 8cdb8dbaf..9b984b37c 100644 --- a/.github/workflows/test-pip-installation.yaml +++ b/.github/workflows/test-pip-installation.yaml @@ -10,9 +10,9 @@ on: pull_request: # run CI on commits to any open PR workflow_dispatch: # can manually trigger CI from GitHub actions tab -# Cancel older runs of this workflow on the same PR when a new commit is pushed. +# Cancel older runs of this workflow on the same PR; other runs get their own group and always run. concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 8dab7925e..147fa80e3 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -10,9 +10,9 @@ on: pull_request: # run CI on commits to any open PR workflow_dispatch: # can manually trigger CI from GitHub actions tab -# Cancel older runs of this workflow on the same PR when a new commit is pushed. +# Cancel older runs of this workflow on the same PR; other runs get their own group and always run. concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: