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. 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)) def _icp_refine(self, x: np.ndarray, y: np.ndarray, cx: float, cy: float, p: float): """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).""" R, C, N = self.nrows, self.ncols, len(x) a = b = None 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()]) cx, cy, p = np.linalg.solve(A, rhs) # 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: cx, cy, p = np.linalg.solve(A2, np.array([xi.sum(), yi.sum(), (ai * xi + bi * yi).sum()])) 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, cx_cands: list[float], cy_cands: list[float]): """Run ICP from every (cx,cy) candidate; keep the lowest-residual result. Candidates are supplied nearest-image-center first (the ordering prior). A later candidate replaces the current best only if it is *meaningfully* lower (by ``_RESIDUAL_TIE_TOL`` px), so a row/column-translation-invariant sparse layout — where several integer placements all fit with ~0 residual and differ only by floating-point noise — resolves to the placement nearest the image center rather than to whichever tie computed a marginally smaller float. """ best = None for cx0 in cx_cands: for cy0 in cy_cands: out = self._icp_refine(x, y, cx0, cy0, p0) if out is None: continue if best is None or out[3] < best[3] - self._RESIDUAL_TIE_TOL: best = out return best # ---- centers -> edges ---- def _axis_edges(self, center: float, p: float, n_cells: int, image_dim: int) -> np.ndarray: """n+1 edges = cell-center midlines with outer edges at +/- p/2, clipped to [0, image_dim].""" 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 _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): """Centered uniform grid at pitch p (image-centered).""" re = self._axis_edges(H / 2.0, p, self.nrows, H) ce = self._axis_edges(W / 2.0, p, self.ncols, W) return re, ce 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) cy_c = self._center_candidates(y, p0, self.nrows, H) best = self._multi_start_refine(x, y, p0, cx_c, cy_c) if best is None or 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._axis_edges(cy, p, self.nrows, H), self._axis_edges(cx, p, self.ncols, 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)