Source code for phenotypic.grid._centered_auto_grid_finder

from __future__ import annotations

import warnings
from typing import TYPE_CHECKING, Annotated, ClassVar

if TYPE_CHECKING:
    from phenotypic._core._image import Image

import numpy as np
import pandas as pd

from phenotypic.abc_ import GridFinder
from phenotypic.schema import BBOX
from phenotypic.sdk_.typing_ import TuneSpec


[docs] class CenteredAutoGridFinderFallbackWarning(UserWarning): """Warning category for fallbacks and bounded-ambiguous fits in :class:`CenteredAutoGridFinder` (degenerate comb-response, ICP failure, bound contradiction, low colony count). Filter in batch runs:: import warnings from phenotypic.grid import CenteredAutoGridFinderFallbackWarning warnings.filterwarnings("ignore", category=CenteredAutoGridFinderFallbackWarning) """
[docs] class CenteredAutoGridFinder(GridFinder): """Center-anchored grid finder for sparse arrayed plates. Fits a regular axis-aligned grid (single isotropic pitch + center) to detected colony centers by their *periodicity* rather than their *span*, so it survives empty edge/interior rows that break span-based fitting. Assumes the plate is roughly centered in the (de-rotated) frame. See the design spec for the algorithm. Args: nrows: Number of grid rows (default 8 — 96-well plate). ncols: Number of grid columns (default 12 — 96-well plate). residual_fraction: ICP robust-trim threshold as a fraction of pitch (default 0.25). n_pitch_samples: Comb-response scan resolution (default 512). response_floor: Fundamental-selection threshold as a fraction of the peak comb-response (default 0.8). max_iter: ICP iteration cap per multi-start candidate (default 6). min_fit_objects: Below this colony count the fit is treated as bounded-ambiguous (default 6). warn: Emit :class:`CenteredAutoGridFinderFallbackWarning` (default False). Notes: nrows/ncols must match the physical plate; a mismatch produces a wrong grid silently (no internal guard). For multiple colonies per well use a downstream refiner (KeepNearestCenter / KeepSectionLargest / MergeWithinSection); this finder assigns faithfully, many-to-one. A fitted grid may have one outer edge clipped to the image boundary, but placements that would collapse an entire cell are rejected before assignment. Examples: Default 96-well fit on the bundled synthetic plate: >>> from phenotypic.data import load_synth_yeast_plate >>> from phenotypic.detect import OtsuDetector >>> from phenotypic.grid import CenteredAutoGridFinder >>> image = OtsuDetector().apply(load_synth_yeast_plate()) >>> finder = CenteredAutoGridFinder(nrows=8, ncols=12) >>> grid_df = finder.measure(image) >>> len(finder.get_row_edges(image)) == 9 True >>> len(finder.get_col_edges(image)) == 13 True """ SPAN_PCT_LOW: ClassVar[float] = 5.0 SPAN_PCT_HIGH: ClassVar[float] = 95.0 ABSOLUTE_FLOOR: ClassVar[float] = 0.6 # pooled comb response (max 2.0) below which "no periodicity" DET_EPS: ClassVar[float] = 1e-6 # Sub-pixel residual tie tolerance: a later multi-start candidate must beat the # incumbent by more than this (px) to replace it, so translation-invariant ties # resolve to the nearest-image-center candidate (tried first) rather than to # floating-point noise. _RESIDUAL_TIE_TOL: ClassVar[float] = 1e-6 nrows: Annotated[int, TuneSpec(tunable=False)] = 8 ncols: Annotated[int, TuneSpec(tunable=False)] = 12 residual_fraction: Annotated[float, TuneSpec(0.1, 0.5)] = 0.25 n_pitch_samples: Annotated[int, TuneSpec(tunable=False)] = 512 response_floor: Annotated[float, TuneSpec(0.5, 0.95)] = 0.8 max_iter: Annotated[int, TuneSpec(tunable=False)] = 6 min_fit_objects: Annotated[int, TuneSpec(tunable=False)] = 6 warn: bool = False # ---- helpers (filled in by later tasks) ---- def _uniform_edges(self, n: int, image_dim: int) -> np.ndarray: """Evenly spaced edges spanning the full axis (length n+1).""" return np.linspace(0, image_dim, n + 1) def _compute_bounds(self, x: np.ndarray, y: np.ndarray, H: int, W: int) -> tuple[float, float]: """Object-derived pitch floor (percentile span) + image-derived ceiling (outermost cell centers fit the frame). NEVER uses image_dim/n as a floor.""" x_span = np.percentile(x, self.SPAN_PCT_HIGH) - np.percentile(x, self.SPAN_PCT_LOW) y_span = np.percentile(y, self.SPAN_PCT_HIGH) - np.percentile(y, self.SPAN_PCT_LOW) p_min = max(x_span / max(self.ncols - 1, 1), y_span / max(self.nrows - 1, 1)) p_max = min(H / max(self.nrows - 1, 1), W / max(self.ncols - 1, 1)) return float(p_min), float(p_max) @staticmethod def _comb_mag(coords: np.ndarray, p: float) -> float: return float(np.abs(np.exp(1j * 2.0 * np.pi * coords / p).mean())) def _estimate_pitch(self, x: np.ndarray, y: np.ndarray, p_min: float, p_max: float) -> tuple[float, bool]: """Pooled comb-response over [p_min, p_max]; pick the FUNDAMENTAL (largest p among strict local maxima >= response_floor*peak). Returns (pitch, ok).""" if not (p_max > p_min > 0): return float(p_max), False ps = np.linspace(p_min, p_max, self.n_pitch_samples) Rr = np.array([self._comb_mag(x, p) + self._comb_mag(y, p) for p in ps]) peak = float(Rr.max()) if peak < self.ABSOLUTE_FLOOR: return float(ps[int(np.argmax(Rr))]), False # Local maxima above the relative floor; choose the largest p (fundamental). # Boundary samples count as candidates so a true pitch landing exactly on the # p_min floor (e.g. the outermost columns fully span the frame, making the # percentile span == (C-1)*p) is recoverable — otherwise the peak at index 0 # would be excluded by a strict-interior-only check. The ABSOLUTE_FLOOR guard # above still rejects genuinely non-periodic layouts. n = len(ps) floor_val = self.response_floor * peak idx = [] for i in range(n): left = Rr[i] > Rr[i - 1] if i > 0 else True right = Rr[i] > Rr[i + 1] if i < n - 1 else True if left and right and Rr[i] >= floor_val: idx.append(i) if not idx: return float(ps[int(np.argmax(Rr))]), False p0 = float(ps[max(idx)]) return p0, True @staticmethod def _phase(coords: np.ndarray, p: float) -> float: return float(np.angle(np.exp(1j * 2.0 * np.pi * coords / p).mean())) def _center_candidates(self, coords: np.ndarray, p: float, n_cells: int, axis_len: int) -> list[float]: """Integer placements of the grid center consistent with the comb phase, kept if within the FULL in-frame offset box, ordered nearest-image-center first.""" base = (self._phase(coords, p) / (2.0 * np.pi)) * p # cell-center phase, in (-p/2, p/2] grid_extent = (n_cells - 1) * p half = (axis_len - grid_extent) / 2.0 + p # full in-frame offset + 1 pitch slack img_c = axis_len / 2.0 cands = [] for m in range(-n_cells, n_cells + 1): c = base + (n_cells - 1) / 2.0 * p + m * p if abs(c - img_c) <= half: cands.append(float(c)) return sorted(cands, key=lambda c: abs(c - img_c)) @staticmethod def _bounded_solution( solution: np.ndarray, x: np.ndarray, y: np.ndarray, a: np.ndarray, b: np.ndarray, p_min: float, p_max: float, ) -> tuple[float, float, float] | None: """Apply pitch bounds to a lattice solve and re-optimize its center.""" cx, cy, p = (float(value) for value in solution) if not np.all(np.isfinite([cx, cy, p])): return None bounded_p = float(np.clip(p, p_min, p_max)) if bounded_p != p: cx = float(np.mean(x - a * bounded_p)) cy = float(np.mean(y - b * bounded_p)) return cx, cy, bounded_p def _icp_refine( self, x: np.ndarray, y: np.ndarray, cx: float, cy: float, p: float, p_min: float, p_max: float, ) -> tuple[float, float, float, float] | None: """Closed-form assign->solve ICP from one seed. Returns (cx,cy,p,mean_residual) or None if the design matrix is singular (cannot constrain pitch). The fitted pitch is constrained to ``[p_min, p_max]`` after every solve. When a bound is active, the grid center is re-optimized for that fixed pitch. """ if not (np.isfinite(p_min) and np.isfinite(p_max) and 0 < p_min <= p_max): return None R, C, N = self.nrows, self.ncols, len(x) a = b = None p = float(np.clip(p, p_min, p_max)) for _ in range(self.max_iter): jx = np.clip(np.round((x - cx) / p + (C - 1) / 2.0), 0, C - 1) iy = np.clip(np.round((y - cy) / p + (R - 1) / 2.0), 0, R - 1) a = jx - (C - 1) / 2.0 b = iy - (R - 1) / 2.0 A = np.array([[N, 0.0, a.sum()], [0.0, N, b.sum()], [a.sum(), b.sum(), (a * a + b * b).sum()]]) if abs(np.linalg.det(A)) < self.DET_EPS: return None rhs = np.array([x.sum(), y.sum(), (a * x + b * y).sum()]) bounded = self._bounded_solution( np.linalg.solve(A, rhs), x, y, a, b, p_min, p_max ) if bounded is None: return None cx, cy, p = bounded # one-pass robust trim then re-solve on inliers res = np.hypot(x - (cx + a * p), y - (cy + b * p)) inl = res <= self.residual_fraction * p if 3 <= inl.sum() < N: ai, bi, xi, yi, ni = a[inl], b[inl], x[inl], y[inl], int(inl.sum()) A2 = np.array([[ni, 0.0, ai.sum()], [0.0, ni, bi.sum()], [ai.sum(), bi.sum(), (ai * ai + bi * bi).sum()]]) if abs(np.linalg.det(A2)) >= self.DET_EPS: bounded = self._bounded_solution( np.linalg.solve( A2, np.array([ xi.sum(), yi.sum(), (ai * xi + bi * yi).sum(), ]), ), xi, yi, ai, bi, p_min, p_max, ) if bounded is None: return None cx, cy, p = bounded if a is None or b is None: # max_iter <= 0: the loop never ran, so nothing was fitted. return None res = np.hypot(x - (cx + a * p), y - (cy + b * p)) return float(cx), float(cy), float(p), float(res.mean()) def _multi_start_refine( self, x: np.ndarray, y: np.ndarray, p0: float, p_min: float, p_max: float, cx_cands: list[float], cy_cands: list[float], H: int, W: int, ) -> tuple[tuple[float, float, float, float] | None, bool]: """Run ICP from every candidate and keep the best feasible registration. Residual is the primary score. Fits tied within ``_RESIDUAL_TIE_TOL`` explicitly prefer the grid center nearest the image center. Candidates whose clipped edges collapse a cell are discarded before scoring. The returned boolean records whether ICP produced any result, including infeasible ones. """ best: tuple[float, float, float, float] | None = None best_center_distance = np.inf saw_refined_candidate = False for cx0 in cx_cands: for cy0 in cy_cands: out = self._icp_refine(x, y, cx0, cy0, p0, p_min, p_max) if out is None: continue saw_refined_candidate = True cx, cy, p, residual = out if not np.all(np.isfinite(out)) or not (p_min <= p <= p_max): continue row_edges = self._axis_edges(cy, p, self.nrows, H) col_edges = self._axis_edges(cx, p, self.ncols, W - 1) if not ( self._axis_edges_are_valid(row_edges, self.nrows, H) and self._axis_edges_are_valid(col_edges, self.ncols, W - 1) ): continue center_distance = float( np.hypot(cx - (W - 1) / 2.0, cy - H / 2.0) ) residual_tied = ( best is not None and abs(residual - best[3]) <= self._RESIDUAL_TIE_TOL ) if ( best is None or residual < best[3] - self._RESIDUAL_TIE_TOL or (residual_tied and center_distance < best_center_distance) ): best = out best_center_distance = center_distance return best, saw_refined_candidate # ---- centers -> edges ---- def _axis_edges(self, center: float, p: float, n_cells: int, image_dim: int) -> np.ndarray: """Return clipped cell midlines without silently repairing collapsed cells. A single outer edge may clip to the frame while the partition remains valid. Callers use :meth:`_axis_edges_are_valid` to reject placements where two or more clipped edges coincide. """ first_center = center - (n_cells - 1) / 2.0 * p edges = first_center - p / 2.0 + np.arange(n_cells + 1) * p return np.clip(edges, 0, image_dim) @staticmethod def _axis_edges_are_valid( edges: np.ndarray, n_cells: int, image_dim: int, ) -> bool: """Return whether edges form a finite, bounded, strict cell partition.""" edges = np.asarray(edges) return bool( edges.ndim == 1 and len(edges) == n_cells + 1 and np.all(np.isfinite(edges)) and np.all(edges >= 0) and np.all(edges <= image_dim) and np.all(np.diff(edges) > 0) ) @staticmethod def _extract_centers(image: "Image"): info = image.objects.info(include_metadata=False) x = info[str(BBOX.DIST_WEIGHTED_CENTER_CC)].to_numpy(dtype=float) y = info[str(BBOX.DIST_WEIGHTED_CENTER_RR)].to_numpy(dtype=float) return x, y, info def _warn(self, msg: str) -> None: if self.warn: warnings.warn(f"CenteredAutoGridFinder {msg}", CenteredAutoGridFinderFallbackWarning, stacklevel=2) def _centered_uniform(self, p: float, H: int, W: int): """Return a strict image-centered grid, safely bounding degenerate pitches.""" centers_fit_max = min( H / max(self.nrows - 1, 1), (W - 1) / max(self.ncols - 1, 1), ) full_cells_pitch = min( H / max(self.nrows, 1), (W - 1) / max(self.ncols, 1), ) if np.isfinite(p) and p > 0: safe_p = min(float(p), centers_fit_max) else: safe_p = full_cells_pitch row_edges = self._axis_edges(H / 2.0, safe_p, self.nrows, H) col_edges = self._axis_edges((W - 1) / 2.0, safe_p, self.ncols, W - 1) if ( self._axis_edges_are_valid(row_edges, self.nrows, H) and self._axis_edges_are_valid(col_edges, self.ncols, W - 1) ): return row_edges, col_edges row_edges = self._axis_edges(H / 2.0, full_cells_pitch, self.nrows, H) col_edges = self._axis_edges( (W - 1) / 2.0, full_cells_pitch, self.ncols, W - 1 ) if ( self._axis_edges_are_valid(row_edges, self.nrows, H) and self._axis_edges_are_valid(col_edges, self.ncols, W - 1) ): return row_edges, col_edges # Pathological dimensions or floating-point resolution: preserve the edge # contract independently on each axis rather than returning collapsed bins. return ( self._uniform_edges(self.nrows, H), self._uniform_edges(self.ncols, W - 1), ) def _finalize_grid_edges( self, row_edges: np.ndarray, col_edges: np.ndarray, fallback_pitch: float, H: int, W: int, ) -> tuple[np.ndarray, np.ndarray]: """Enforce the strict-edge postcondition before delegating to ``pd.cut``.""" if ( self._axis_edges_are_valid(row_edges, self.nrows, H) and self._axis_edges_are_valid(col_edges, self.ncols, W - 1) ): return row_edges, col_edges self._warn( "[invalid-geometry] fitted edges do not form a strict partition; " "centered uniform fallback." ) return self._centered_uniform(fallback_pitch, H, W) def _fit_grid_from_centers(self, x: np.ndarray, y: np.ndarray, H: int, W: int): """Full pipeline on raw center arrays -> (row_edges, col_edges), with the sparse-tail fallback ladder. Returns axis-aligned edge arrays.""" N = len(x) # N in {0,1}: no inferable pitch -> centered grid at the max-fitting pitch if N < 2: self._warn(f"[few-objects] N={N}; centered uniform grid at max pitch.") p_max = min(H / max(self.nrows - 1, 1), W / max(self.ncols - 1, 1)) return self._centered_uniform(p_max, H, W) p_min, p_max = self._compute_bounds(x, y, H, W) if p_min >= p_max: self._warn(f"[bound-inversion] p_min={p_min:.1f} >= p_max={p_max:.1f}; " "centered uniform grid at p_max.") return self._centered_uniform(p_max, H, W) p0, ok = self._estimate_pitch(x, y, p_min, p_max) if not ok: self._warn("[degenerate-response] no clear periodicity; centered uniform at p_min.") return self._centered_uniform(p_min, H, W) if N <= self.min_fit_objects: self._warn(f"[few-objects] N={N}: bounded-ambiguous fit (best-effort, not confident).") cx_c = self._center_candidates(x, p0, self.ncols, W - 1) cy_c = self._center_candidates(y, p0, self.nrows, H) best, saw_refined_candidate = self._multi_start_refine( x, y, p0, p_min, p_max, cx_c, cy_c, H, W ) if best is None: if saw_refined_candidate: self._warn( "[invalid-geometry] ICP registrations collapse one or more " "grid cells; centered uniform at comb pitch." ) else: self._warn( "[icp-failed] no registration could be refined; " "centered uniform at comb pitch." ) return self._centered_uniform(p0, H, W) if best[3] > self.residual_fraction * best[2]: self._warn("[icp-failed] no acceptable registration; centered uniform at comb pitch.") return self._centered_uniform(p0, H, W) cx, cy, p, _res = best return self._finalize_grid_edges( self._axis_edges(cy, p, self.nrows, H), self._axis_edges(cx, p, self.ncols, W - 1), p0, H, W, ) # ---- GridFinder overrides ----
[docs] def get_row_edges(self, image: "Image") -> np.ndarray: return self._fit_grid(image)[0]
[docs] def get_col_edges(self, image: "Image") -> np.ndarray: return self._fit_grid(image)[1]
def _fit_grid(self, image: "Image"): """(row_edges, col_edges) for *image*, applying the fallback ladder (Task 7).""" x, y, _ = self._extract_centers(image) return self._fit_grid_from_centers(x, y, image.shape[0], image.shape[1]) def _operate(self, image: "Image") -> pd.DataFrame: x, y, info = self._extract_centers(image) row_edges, col_edges = self._fit_grid_from_centers(x, y, image.shape[0], image.shape[1]) return super()._get_grid_info(image=image, row_edges=row_edges, col_edges=col_edges, info_table=info)