Source code for phenotypic.refine._nearest_neighbor_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, field_validator
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 NearestNeighborMerger(ObjectRefiner):
"""Absorb small colony fragments into their nearest neighboring detection.
For each object whose area is below ``min_size``, finds the single
nearest centroid within ``distance_threshold`` and reassigns the
fragment's label to that neighbor. The merge is one-directional and
non-transitive: large anchor colonies are never themselves merged,
which avoids cascading chains across the plate.
For a comparison of merging strategies, see
:doc:`/explanation/refinement_strategies`.
Best For:
- Absorbing dust or debris fragments that appear near genuine
colonies after thresholding or edge detection.
- Size-selective cleanup where only small artefacts below an area
threshold need merging while large colonies remain independent.
- Plates with scattered micro-colonies or satellite spots that
should count as part of the nearest parent colony.
Consider Also:
- :class:`MergeFragmentChains` when transitive closure is needed
so that chains of nearby fragments (A near B near C) merge into
one detection even if the endpoints are far apart.
- :class:`SmallToLargeMerger` when small fragments should
specifically merge into the largest nearby colony rather than
the geometrically closest one.
- :class:`MaskClosing` for morphological bridging of narrow gaps
without relabeling objects.
Args:
distance_threshold: Maximum centroid-to-centroid distance in pixels
within which a small fragment is eligible to merge with its
nearest neighbor. Fragments beyond this distance remain
independent. Typical range: 10--50. Default: 20.0.
min_size: Area threshold in pixels. Objects with area strictly below
this value are merge candidates; objects at or above serve as
immovable anchors. Set to ``None`` to make all objects eligible
for merging. Typical range: 20--200. Default: 50.
Returns:
Image: Input image with ``objmap`` updated after absorbing small
fragments into their nearest neighbors. ``objmask``, ``rgb``,
``gray``, and ``detect_mat`` are unchanged.
Raises:
ValueError: If ``distance_threshold`` is not positive.
ValueError: If ``min_size`` is provided and not positive.
See Also:
:doc:`/how_to/notebooks/merge_fragmented_detections` for fragment
merging workflows on real plate images.
:doc:`/explanation/refinement_strategies` for choosing the right
merging approach.
"""
distance_threshold: Annotated[float, TuneSpec(10.0, 50.0)] = Field(
default=20.0, gt=0
)
min_size: Annotated[int | None, TuneSpec(20, 200, log=True)] = 50
@field_validator("min_size")
@classmethod
def _validate_min_size(cls, min_size: int | None) -> int | None:
"""Reject a non-positive ``min_size`` when one is provided.
Reproduces the pre-migration ``__init__`` guard verbatim.
"""
if min_size is not None and min_size <= 0:
raise ValueError("min_size must be positive if provided")
return min_size
def _operate(self, image: Image) -> Image:
"""Apply nearest-neighbor distance-based merging to objmap.
Algorithm:
1. Extract labels, centroids, and areas from labeled map
2. Build KDTree from centroids
3. Query k=2 nearest neighbors (first is self, second is actual nearest)
4. For each object:
- If object is large (>= min_size), preserve independently
- If object is small and nearest neighbor within threshold, merge
- Otherwise preserve independently
5. 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)
labels = df["label"].values
centroids = df[["centroid-0", "centroid-1"]].values
areas = df["area"].values
# Build KDTree for spatial queries
tree = cKDTree(centroids)
# Query k=2 nearest neighbors (self + actual nearest)
distances, indices = tree.query(centroids, k=2)
# Build merge map
merge_map = {}
for i, label in enumerate(labels):
# Check size filter: preserve large objects independently
if self.min_size is not None and areas[i] >= self.min_size:
merge_map[label] = label
continue
# Get actual nearest neighbor (second result, first is self)
nearest_idx = indices[i, 1]
nearest_label = labels[nearest_idx]
distance_to_nearest = distances[i, 1]
# Merge to nearest if within threshold
if distance_to_nearest <= self.distance_threshold:
merge_map[label] = nearest_label
else:
merge_map[label] = label
# Apply merge mapping to objmap
remap = np.vectorize(lambda lbl: merge_map.get(lbl, lbl))
merged_objmap = remap(objmap)
# Write result (no relabeling for this approach)
image.objmap[:] = merged_objmap
return image