Source code for phenotypic.refine._small_to_large_merger
from __future__ import annotations
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from phenotypic._core._image import Image
import numpy as np
from scipy.spatial import cKDTree
from skimage.measure import regionprops_table
import pandas as pd
from ..abc_ import ObjectRefiner
[docs]
class SmallToLargeMerger(ObjectRefiner):
"""Merge small colony fragments into their nearest large colony using hierarchical size-based merging.
Partitions objects into small (below size threshold) and large (at or
above), then absorbs each small fragment into the nearest large neighbor
within the distance threshold. Large colonies serve as stable anchors
and never merge with each other, preventing false consolidation of
distinct colonies.
Args:
distance_threshold: Maximum centroid-to-centroid distance in pixels
for merging a small fragment into a large colony. Typical
range: 10--50. Should be smaller than the minimum distance
between distinct large colonies. Default: 30.0.
size_threshold: Pixel area separating small fragments from large
anchor colonies. Objects below this are merge candidates;
objects at or above are preserved as anchors. Typical range:
50--200. Default: 100.
Returns:
Image: Input image with ``objmap`` updated so that small fragments
are relabeled to their nearest large colony.
Raises:
ValueError: If ``distance_threshold`` or ``size_threshold`` is not
positive.
Best For:
- Fragmented detections from heterogeneous pigmentation or uneven
illumination where satellites cluster around a main colony.
- Post-watershed over-segmentation where one colony splits into a
large core plus small peripheral regions.
- Removing small debris near real colonies without merging distinct
large colonies.
- Plates with severe lighting gradients that produce satellite
fragments around main detections.
Consider Also:
- :class:`TransitiveDistanceMerger` when all nearby objects should
merge regardless of size, including large-to-large merging.
- :class:`NearestNeighborMerger` for simple nearest-neighbor
merging without size partitioning.
- :class:`SmallObjectRemover` when small fragments should be
discarded entirely rather than absorbed.
See Also:
:doc:`/how_to/notebooks/merge_fragmented_detections` for fragment
merging workflows.
:doc:`/explanation/refinement_strategies` for a comparison of
merging strategies.
"""
[docs]
def __init__(self, distance_threshold: float = 30.0, size_threshold: int = 100):
"""Initialize the merger.
Args:
distance_threshold (float): Maximum distance from small fragment to
large colony for merging (pixels).
size_threshold (int): Minimum area for an object to be considered a
"large" anchor colony. Smaller objects are candidates for merging.
Raises:
ValueError: If distance_threshold or size_threshold are not positive.
"""
if distance_threshold <= 0:
raise ValueError("distance_threshold must be positive")
if size_threshold <= 0:
raise ValueError("size_threshold must be positive")
self.distance_threshold = distance_threshold
self.size_threshold = size_threshold
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