Source code for phenotypic.refine._nearest_neighbor_merger
from __future__ import annotations
from typing import TYPE_CHECKING, Optional
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 NearestNeighborMerger(ObjectRefiner):
"""Merge small fragments into their nearest neighboring colony.
Each object below ``min_size`` is absorbed into its single closest
neighbor within ``distance_threshold``. Unlike transitive merging, this
is one-directional and conservative — it cleans up debris without
cascading merges across the plate.
Args:
distance_threshold: Maximum centroid distance (pixels) for merging.
Objects beyond this distance remain independent. Typical range:
15--40. Default: 25.
min_size: Only objects with area below this threshold are merge
candidates. Larger objects serve as anchor targets. ``None``
merges all objects (rarely desired). Default: 50.
Returns:
Image: Input image with ``objmask`` and ``objmap`` updated after
merging small objects into their nearest neighbors.
Best For:
- Absorbing dust or noise fragments near real colonies.
- Cleaning up small detection artifacts without risking cascading merges.
- Size-selective cleanup where only fragments below a threshold merge.
Consider Also:
- :class:`TransitiveDistanceMerger` for chained merging of all nearby
objects regardless of size.
- :class:`SmallToLargeMerger` for merging small objects into the
largest nearby colony.
- :class:`MaskCloser` for bridging narrow gaps morphologically.
See Also:
:doc:`/how_to/notebooks/merge_fragmented_detections` for fragment
merging strategies.
:doc:`/explanation/refinement_strategies` for choosing the right
refinement approach.
"""
[docs]
def __init__(self, distance_threshold: float = 20.0, min_size: Optional[int] = 50):
"""Initialize the merger.
Args:
distance_threshold (float): Maximum distance to nearest neighbor for
merging. Objects farther than this remain independent.
min_size (int | None): Minimum area to preserve independently.
Objects smaller than this merge to nearest neighbor if within
distance_threshold. Larger objects remain untouched.
Raises:
ValueError: If distance_threshold is not positive or if min_size is
provided and not positive.
"""
if distance_threshold <= 0:
raise ValueError("distance_threshold must be positive")
if min_size is not None and min_size <= 0:
raise ValueError("min_size must be positive if provided")
self.distance_threshold = distance_threshold
self.min_size = 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