Source code for phenotypic.refine._small_to_large_merger
from __future__ import annotations
from typing import TYPE_CHECKING, Annotated
if TYPE_CHECKING:
from phenotypic._core._image import Image
import numpy as np
from pydantic import Field
from scipy.spatial import cKDTree
from skimage.measure import regionprops_table
import pandas as pd
from ..abc_ import ObjectRefiner
from ..sdk_.typing_ import TuneSpec
[docs]
class SmallToLargeMerger(ObjectRefiner):
"""Merge small colony fragments into their nearest large colony using size-partitioned merging.
Partitions detected objects into small fragments (below ``size_threshold``)
and large anchor colonies (at or above), then absorbs each fragment into
the nearest anchor within ``distance_threshold`` pixels. Large colonies
are never merged with each other, preventing false consolidation of
distinct colonies.
For a comparison of fragment merging strategies, see
:doc:`/explanation/refinement_strategies`.
Best For:
- Fragmented detections from heterogeneous colony pigmentation or
uneven illumination that produce satellite regions around a main
colony body.
- Post-watershed over-segmentation where one colony splits into a
large core and small peripheral fragments.
- Plates with lighting gradients that generate small spurious
detections adjacent to genuine large colonies.
- Scenarios where small isolated debris should be absorbed into
nearby real colonies rather than discarded outright.
Consider Also:
- :class:`MergeFragmentChains` when large-to-large merging is also
needed, not only small-to-large absorption.
- :class:`NearestNeighborMerger` for nearest-neighbor merging
without any size-based partitioning.
- :class:`SmallObjectRemover` when small fragments should be
discarded entirely rather than absorbed into a parent colony.
Args:
distance_threshold: Maximum centroid-to-centroid distance in pixels
within which a small fragment is absorbed into the nearest large
colony. Set smaller than the minimum expected spacing between
distinct large colonies to avoid cross-colony merges. Typical
range: 10--50. Default: 30.0.
size_threshold: Pixel area boundary separating merge candidates
(below threshold) from immovable anchor colonies (at or above).
Typical range: 50--200. Scale upward for high-resolution scans
where colony areas are larger. Default: 100.
Returns:
Image: Input image with ``objmap`` updated so that small fragments
within range are relabeled to match their nearest large colony.
Raises:
ValueError: If ``distance_threshold`` or ``size_threshold`` is not
positive.
See Also:
:doc:`/how_to/notebooks/merge_fragmented_detections` for fragment
merging workflows on real plate images.
:doc:`/explanation/refinement_strategies` for a comparison of
merging strategies.
"""
distance_threshold: Annotated[float, TuneSpec(10.0, 50.0)] = Field(
default=30.0, gt=0
)
size_threshold: Annotated[int, TuneSpec(50, 200, log=True)] = Field(
default=100, gt=0
)
def _operate(self, image: Image) -> Image:
"""Apply small-to-large hierarchical merging to objmap.
Algorithm:
1. Extract label, centroid, area for all objects
2. Partition into small (< threshold) and large (>= threshold)
3. If no large objects exist, return unchanged
4. Build KDTree from large centroids only
5. Query nearest large colony for each small object
6. Merge small -> large if distance <= threshold
7. Apply merge mapping to objmap
Args:
image: Image object with populated objmap from prior detection.
Returns:
Image object with merged objmap and unchanged RGB/gray/detect_mat.
"""
objmap = image.objmap[:]
# Edge cases: empty or single object
if objmap.max() == 0:
return image
if objmap.max() == 1:
return image
# Extract properties
props = regionprops_table(
label_image=objmap, properties=["label", "centroid", "area"]
)
df = pd.DataFrame(props)
# Partition into small and large
small_df = df[df["area"] < self.size_threshold]
large_df = df[df["area"] >= self.size_threshold]
# Edge case: no large objects to merge into
if len(large_df) == 0:
return image
# Edge case: no small objects to merge
if len(small_df) == 0:
return image
# Extract large object properties
large_centroids = large_df[["centroid-0", "centroid-1"]].values
large_labels = large_df["label"].values
# Build KDTree from large centroids only
tree = cKDTree(large_centroids)
# Extract small object properties
small_centroids = small_df[["centroid-0", "centroid-1"]].values
small_labels = small_df["label"].values
# Query nearest large colony for each small object
distances, indices = tree.query(small_centroids, k=1)
# Initialize merge map: large objects map to themselves
merge_map = {lbl: lbl for lbl in large_labels}
# Add small objects: merge to nearest large if within threshold
for i, small_label in enumerate(small_labels):
nearest_large_label = large_labels[indices[i]]
distance_to_nearest = distances[i]
if distance_to_nearest <= self.distance_threshold:
merge_map[small_label] = nearest_large_label
else:
merge_map[small_label] = small_label # Too far, keep independent
# Apply merge mapping to objmap
remap = np.vectorize(lambda lbl: merge_map.get(lbl, lbl))
merged_objmap = remap(objmap)
# Write result
image.objmap[:] = merged_objmap
return image