Source code for phenotypic.grid._auto_grid_finder

from __future__ import annotations

import warnings
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from phenotypic._core._image import Image

import pandas as pd
import numpy as np

from phenotypic.abc_ import GridFinder
from phenotypic.tools_.measurement_info_ import BBOX, GRID


[docs] class AutoGridFinder(GridFinder): """ Automatically determines grid row and column edges from detected object centers using a deterministic robust-fit algorithm. Unlike histogram or optimizer-based approaches, this class fits a regular grid model directly to the per-object distance-transform maximum centers (deepest interior point of each object's mask). These centers are anchored in the dense colony body and are unaffected by thin filamentous extensions (e.g., fungal hyphae) that would otherwise pull intensity-weighted centroids off-body and bias the grid fit. Outlier rejection further protects against atypical objects pulling boundaries away from the true positions. Args: nrows: Number of rows in the grid (default 8 for 96-well plates). ncols: Number of columns in the grid (default 12 for 96-well plates). residual_fraction: Outlier threshold as a fraction of pitch. Centers whose fit residual exceeds ``pitch * residual_fraction`` are excluded from the refined fit (default 0.25). tol: Deprecated. Accepted for backward compatibility but ignored. max_iter: Deprecated. Accepted for backward compatibility but ignored. """ _MAX_OBJECTS_PER_CELL: int = 250 def __init__( self, nrows: int = 8, ncols: int = 12, residual_fraction: float = 0.25, *, tol: float | None = None, max_iter: int | None = None, ): super().__init__(nrows=nrows, ncols=ncols) self.residual_fraction: float = residual_fraction if tol is not None: warnings.warn( "The 'tol' parameter is deprecated and has no effect. " "AutoGridFinder now uses a deterministic robust-fit algorithm.", DeprecationWarning, stacklevel=2, ) if max_iter is not None: warnings.warn( "The 'max_iter' parameter is deprecated and has no effect. " "AutoGridFinder now uses a deterministic robust-fit algorithm.", DeprecationWarning, stacklevel=2, ) # ------------------------------------------------------------------ # Static helper methods # ------------------------------------------------------------------ @staticmethod def _extract_axis_centers(info_table: pd.DataFrame, axis: int) -> np.ndarray: """Return sorted weighted centers along *axis* (0=rows, 1=cols).""" if axis == 0: col = str(BBOX.DIST_WEIGHTED_CENTER_RR) elif axis == 1: col = str(BBOX.DIST_WEIGHTED_CENTER_CC) else: raise ValueError(f"axis must be 0 or 1, got {axis}") centers = info_table.loc[:, col].values.astype(float) centers.sort() return centers @staticmethod def _estimate_pitch(centers: np.ndarray, n_expected: int) -> float: """Estimate grid pitch from sorted centers and expected grid count. Uses ``(max - min) / (n_expected - 1)`` which is robust when multiple objects share a grid cell (common in colony imaging where fragments or sub-colonies yield many detections per well). """ if len(centers) < 2: raise ValueError("Need at least 2 centers to estimate pitch") return float((centers[-1] - centers[0]) / max(n_expected - 1, 1)) @staticmethod def _robust_pitch_estimate(centers: np.ndarray, n_expected: int) -> float: """Estimate grid pitch robustly using the mode of successive differences. Falls back to span-based estimation when the mode is outside a plausible range (0.5x–2.0x the span estimate). Args: centers: Sorted 1-D array of object center coordinates. n_expected: Number of expected grid positions along this axis. Returns: Estimated pitch in pixels. """ span_pitch = AutoGridFinder._estimate_pitch(centers, n_expected) if span_pitch <= 0: return span_pitch diffs = np.diff(centers) diffs = diffs[diffs > 0] if len(diffs) < 2: return span_pitch bin_width = span_pitch / 4.0 n_bins = max(int(np.ceil(diffs.max() / bin_width)), 1) counts, bin_edges = np.histogram(diffs, bins=n_bins, range=(0, diffs.max() + bin_width)) mode_bin = np.argmax(counts) mode_pitch = float((bin_edges[mode_bin] + bin_edges[mode_bin + 1]) / 2.0) if mode_pitch < 0.5 * span_pitch or mode_pitch > 2.0 * span_pitch: return span_pitch return mode_pitch @staticmethod def _assign_grid_indices( centers: np.ndarray, pitch: float, anchor: float | None = None, ) -> np.ndarray: """Assign integer grid indices: ``round((c - anchor) / pitch)``. Args: centers: Sorted 1-D array of object center coordinates. pitch: Estimated grid pitch. anchor: Reference coordinate for index 0. When *None*, ``centers[0]`` is used (original behaviour). """ ref = centers[0] if anchor is None else anchor indices = np.rint((centers - ref) / pitch).astype(int) indices -= indices.min() return indices @staticmethod def _aggregate_to_cell_medians( centers: np.ndarray, indices: np.ndarray, ) -> tuple[np.ndarray, np.ndarray]: """Collapse multiple centers per grid index to one median each. Returns: Tuple of (median_centers, unique_indices), one entry per occupied grid slot. """ grouped = pd.Series(centers).groupby(indices).median() return grouped.values, grouped.index.values @staticmethod def _fit_pitch_and_offset( centers: np.ndarray, indices: np.ndarray, ) -> tuple[float, float]: """Closed-form linear fit ``center = pitch * idx + offset``. Returns: Tuple of (pitch, offset). """ idx_mean = indices.mean() ctr_mean = centers.mean() idx_dev = indices - idx_mean denom = float(idx_dev @ idx_dev) if denom == 0.0: return 0.0, ctr_mean pitch = float(idx_dev @ (centers - ctr_mean)) / denom offset = ctr_mean - pitch * idx_mean return pitch, offset @staticmethod def _identify_inliers( centers: np.ndarray, indices: np.ndarray, pitch: float, offset: float, threshold: float, ) -> np.ndarray: """Return boolean mask where ``|residual| <= threshold``.""" predicted = pitch * indices + offset residuals = np.abs(centers - predicted) return residuals <= threshold @staticmethod def _compute_grid_edges( pitch: float, offset: float, n_bins: int, image_dim: int, ) -> np.ndarray: """Compute ``n_bins + 1`` edge coordinates clipped to ``[0, image_dim]``. Edges are placed at ``offset + pitch * i - pitch / 2`` for ``i = 0 .. n_bins``. The offset is clamped so that the first edge is >= 0 and the last edge is <= image_dim, preventing duplicate edges after clipping. When the fitted pitch is too large for the image, the pitch is shrunk to ``image_dim / n_bins`` so the grid fills the available space. """ half = pitch / 2.0 min_offset = half # first edge >= 0 max_offset = image_dim - pitch * n_bins + half # last edge <= image_dim if min_offset > max_offset: # Pitch is too large for the image — shrink to fit pitch = image_dim / n_bins half = pitch / 2.0 offset = half else: offset = float(np.clip(offset, min_offset, max_offset)) edges = offset + pitch * np.arange(n_bins + 1) - half np.clip(edges, 0, image_dim, out=edges) np.round(edges, out=edges) return edges.astype(int) # ------------------------------------------------------------------ # Axis-level orchestrator # ------------------------------------------------------------------ @staticmethod def _uniform_edges(n_expected: int, image_dim: int) -> np.ndarray: """Fallback: uniform spacing centered in image.""" pitch = image_dim / n_expected return AutoGridFinder._compute_grid_edges( pitch, pitch / 2, n_expected, image_dim, ) def _fit_axis_edges_simple( self, centers: np.ndarray, n_expected: int, image_dim: int, ) -> np.ndarray: """Simple pipeline used when the object count is low. Uses span-based pitch estimation and fits all centers directly. """ pitch = self._estimate_pitch(centers, n_expected) if pitch <= 0: return self._uniform_edges(n_expected, image_dim) indices = self._assign_grid_indices(centers, pitch) pitch, offset = self._fit_pitch_and_offset(centers, indices) if pitch <= 0: return self._uniform_edges(n_expected, image_dim) threshold = pitch * self.residual_fraction inlier_mask = self._identify_inliers( centers, indices, pitch, offset, threshold, ) if inlier_mask.sum() >= 2: pitch, offset = self._fit_pitch_and_offset( centers[inlier_mask], indices[inlier_mask], ) if pitch <= 0: return self._uniform_edges(n_expected, image_dim) span = int(indices.max() - indices.min()) + 1 if span < n_expected: image_center = image_dim / 2.0 grid_center_idx = (n_expected - 1) / 2.0 offset = image_center - pitch * grid_center_idx return self._compute_grid_edges(pitch, offset, n_expected, image_dim) def _fit_axis_edges( self, info_table: pd.DataFrame, axis: int, n_expected: int, image_dim: int, ) -> np.ndarray: """Full pipeline: extract centers → fit → reject → refit → edges. Falls back to uniform spacing when fewer than 2 objects are found. Uses the simple pipeline for low object counts and the robust pipeline (median aggregation + robust pitch) for high counts. """ try: centers = self._extract_axis_centers(info_table, axis) except (KeyError, IndexError): centers = np.array([]) if len(centers) < 2: return self._uniform_edges(n_expected, image_dim) # Low-N guard: use simple pipeline when few objects if len(centers) < 2 * n_expected: return self._fit_axis_edges_simple( centers, n_expected, image_dim, ) # High-N guard: skip fitting for pathological object counts if len(centers) > self._MAX_OBJECTS_PER_CELL * n_expected: warnings.warn( f"Detected {len(centers)} objects for {n_expected} expected " f"grid positions (>{self._MAX_OBJECTS_PER_CELL} per cell). " f"Falling back to uniform grid spacing.", stacklevel=2, ) return self._uniform_edges(n_expected, image_dim) # --- Robust pipeline for high object counts --- # Step 1: robust pitch estimate (mode of successive differences) pitch = self._robust_pitch_estimate(centers, n_expected) if pitch <= 0: return self._uniform_edges(n_expected, image_dim) # Step 2: assign grid indices with median anchor anchor = float(np.median(centers)) indices = self._assign_grid_indices(centers, pitch, anchor=anchor) # Step 3: aggregate to one median per grid cell medians, unique_idx = self._aggregate_to_cell_medians( centers, indices, ) # Step 4: fit on aggregated medians (equal weight per cell) pitch, offset = self._fit_pitch_and_offset(medians, unique_idx) if pitch <= 0: return self._uniform_edges(n_expected, image_dim) # Step 5: outlier rejection + refit on cells threshold = pitch * self.residual_fraction inlier_mask = self._identify_inliers( medians, unique_idx, pitch, offset, threshold, ) if inlier_mask.sum() >= 2: pitch, offset = self._fit_pitch_and_offset( medians[inlier_mask], unique_idx[inlier_mask], ) if pitch <= 0: return self._uniform_edges(n_expected, image_dim) # Step 6: symmetry anchoring when detected span < expected span = int(unique_idx.max() - unique_idx.min()) + 1 if span < n_expected: image_center = image_dim / 2.0 grid_center_idx = (n_expected - 1) / 2.0 offset = image_center - pitch * grid_center_idx return self._compute_grid_edges(pitch, offset, n_expected, image_dim) # ------------------------------------------------------------------ # Public interface # ------------------------------------------------------------------
[docs] def get_row_edges(self, image: Image) -> np.ndarray: """Return row edge coordinates for *image*. Args: image: Image with detected objects (``image.objects.info()``). Returns: Integer array of length ``nrows + 1``. """ if image.num_objects == 0: return self._uniform_edges(self.nrows, image.shape[0]) info_table = image.objects.info(include_metadata=False) return self._fit_axis_edges( info_table, axis=0, n_expected=self.nrows, image_dim=image.shape[0], )
[docs] def get_col_edges(self, image: Image) -> np.ndarray: """Return column edge coordinates for *image*. Args: image: Image with detected objects (``image.objects.info()``). Returns: Integer array of length ``ncols + 1``. """ if image.num_objects == 0: return self._uniform_edges(self.ncols, image.shape[1]) info_table = image.objects.info(include_metadata=False) return self._fit_axis_edges( info_table, axis=1, n_expected=self.ncols, image_dim=image.shape[1], )
def _operate(self, image: Image) -> pd.DataFrame: """Compute grid edges and assign each detected object to a grid cell. Args: image: Image with detected objects. Returns: DataFrame with grid assignments (ROW_NUM, COL_NUM, ROW_MAJOR_IDX). """ if image.num_objects == 0: return super()._get_grid_info( image=image, row_edges=self._uniform_edges(self.nrows, image.shape[0]), col_edges=self._uniform_edges(self.ncols, image.shape[1]), ) info_table = image.objects.info(include_metadata=False) row_edges = self._fit_axis_edges( info_table, axis=0, n_expected=self.nrows, image_dim=image.shape[0], ) col_edges = self._fit_axis_edges( info_table, axis=1, n_expected=self.ncols, image_dim=image.shape[1], ) return super()._get_grid_info( image=image, row_edges=row_edges, col_edges=col_edges, info_table=info_table, ) # ------------------------------------------------------------------ # Diagnostic inspect() method # ------------------------------------------------------------------ _OI_NAVY = "#003660" _OI_ORANGE = "#E69F00" _OI_SKY = "#56B4E9" _OI_GREEN = "#009E73" _OI_VERMILION = "#D55E00" _OI_BLUE = "#0072B2" _OI_PURPLE = "#CC79A7" _OI_GREY = "#BBBBBB" @staticmethod def _dashboard_rcparams() -> dict: """Return standard dashboard matplotlib rcParams.""" return { "axes.facecolor": "#ffffff", "figure.facecolor": "#f5f7fa", "axes.edgecolor": "#dde3ed", "axes.grid": True, "grid.color": "#e8ecf2", "grid.linewidth": 0.8, "axes.spines.top": False, "axes.spines.right": False, "axes.titlecolor": "#003660", "axes.titleweight": "600", "axes.titlesize": 11, "axes.labelsize": 9, "axes.labelcolor": "#2e3a4e", "xtick.labelsize": 8, "ytick.labelsize": 8, "xtick.color": "#8892a4", "ytick.color": "#8892a4", "font.family": "sans-serif", "font.sans-serif": ["DM Sans", "Helvetica Neue", "Arial"], "axes.prop_cycle": __import__("matplotlib").cycler(color=[ "#003660", "#E69F00", "#56B4E9", "#009E73", "#0072B2", "#CC79A7", ]), } @staticmethod def _in_jupyter() -> bool: """Detect if running inside a Jupyter notebook.""" try: get_ipython() # type: ignore # noqa: F821 return True except NameError: return False def _run_timed_pipeline( self, image: Image, show_progress: bool = True, ) -> dict: """Run the grid pipeline with per-step timing and optional progress bar. Args: image: Image with detected objects. show_progress: Whether to display a progress bar. Returns: Dict with keys: timings, info_table, row_edges, col_edges, grid_df, pipeline_path. """ import time steps = [ "regionprops", "fit rows", "fit cols", "grid assignment", ] timings: dict[str, float] = {} pbar = None pipeline_path = "uniform (no objects)" if show_progress: if self._in_jupyter(): try: from ipywidgets import IntProgress from IPython.display import display pbar = IntProgress( min=0, max=len(steps), description="Grid inspect:", ) display(pbar) except ImportError: pass if pbar is None: try: from tqdm import tqdm pbar = tqdm(total=len(steps), desc="Grid inspect") except ImportError: pass def _tick(step_name: str, start: float) -> None: timings[step_name] = time.perf_counter() - start if pbar is not None: if hasattr(pbar, "value"): # ipywidgets pbar.value += 1 else: # tqdm pbar.update(1) # Step 1: regionprops t0 = time.perf_counter() if image.num_objects == 0: info_table = pd.DataFrame() else: info_table = image.objects.info(include_metadata=False) _tick("regionprops", t0) # Step 2: fit rows t0 = time.perf_counter() if image.num_objects == 0: row_edges = self._uniform_edges(self.nrows, image.shape[0]) else: n_centers = len(info_table) if n_centers < 2: pipeline_path = "uniform (< 2 objects)" elif n_centers < 2 * self.nrows: pipeline_path = "simple" elif n_centers > self._MAX_OBJECTS_PER_CELL * self.nrows: pipeline_path = "uniform (object count guard)" else: pipeline_path = "robust" row_edges = self._fit_axis_edges( info_table, axis=0, n_expected=self.nrows, image_dim=image.shape[0], ) _tick("fit rows", t0) # Step 3: fit cols t0 = time.perf_counter() if image.num_objects == 0: col_edges = self._uniform_edges(self.ncols, image.shape[1]) else: col_edges = self._fit_axis_edges( info_table, axis=1, n_expected=self.ncols, image_dim=image.shape[1], ) _tick("fit cols", t0) # Step 4: grid assignment t0 = time.perf_counter() grid_df = super()._get_grid_info( image=image, row_edges=row_edges, col_edges=col_edges, info_table=info_table if not info_table.empty else None, ) _tick("grid assignment", t0) if pbar is not None and hasattr(pbar, "close"): pbar.close() return { "timings": timings, "info_table": info_table, "row_edges": row_edges, "col_edges": col_edges, "grid_df": grid_df, "pipeline_path": pipeline_path, } @classmethod def _plot_timing_waterfall(cls, timings: dict[str, float]): """Horizontal bar chart of per-step timing.""" import panel as pn import matplotlib.pyplot as plt with plt.rc_context(cls._dashboard_rcparams()): fig, ax = plt.subplots(figsize=(5, 2.5)) steps = list(timings.keys()) times = [timings[s] for s in steps] total = sum(times) bars = ax.barh(steps, times, color=cls._OI_NAVY, height=0.6) for bar, t in zip(bars, times): ax.text( bar.get_width() + total * 0.02, bar.get_y() + bar.get_height() / 2, f"{t:.3f}s", va="center", fontsize=8, fontfamily="monospace", color="#2e3a4e", ) ax.set_xlabel("Time (s)") ax.set_title(f"Step Timing (total: {total:.3f}s)") ax.invert_yaxis() fig.tight_layout() pane = pn.pane.Matplotlib(fig, tight=True, dpi=100) plt.close(fig) return pane @classmethod def _plot_object_size_dist( cls, info_table: pd.DataFrame, nrows: int, ncols: int, image_shape: tuple[int, ...], ): """Histogram of object bounding box areas with expected cell size.""" import panel as pn import matplotlib.pyplot as plt with plt.rc_context(cls._dashboard_rcparams()): fig, ax = plt.subplots(figsize=(5, 3.5)) if info_table.empty: ax.text( 0.5, 0.5, "No objects detected", ha="center", va="center", fontsize=10, color="#8892a4", transform=ax.transAxes, ) ax.set_title("Object Size Distribution") fig.tight_layout() pane = pn.pane.Matplotlib(fig, tight=True, dpi=100) plt.close(fig) return pane heights = ( info_table[str(BBOX.MAX_RR)].values - info_table[str(BBOX.MIN_RR)].values ) widths = ( info_table[str(BBOX.MAX_CC)].values - info_table[str(BBOX.MIN_CC)].values ) areas = heights * widths expected_cell_area = ( (image_shape[0] / nrows) * (image_shape[1] / ncols) ) oversized_mask = areas > expected_cell_area ax.hist( areas[~oversized_mask], bins=50, color=cls._OI_NAVY, alpha=0.8, label="Normal", ) if oversized_mask.any(): ax.hist( areas[oversized_mask], bins=max(1, oversized_mask.sum() // 2), color=cls._OI_VERMILION, alpha=0.8, label=f"Oversized ({oversized_mask.sum()})", ) ax.axvline( expected_cell_area, ls="--", color=cls._OI_GREY, lw=1.5, label="Expected cell area", ) ax.set_xlabel("Bbox Area (px\u00b2)") ax.set_ylabel("Count") ax.set_title("Object Size Distribution") ax.legend(fontsize=7, framealpha=0.8) fig.tight_layout() pane = pn.pane.Matplotlib(fig, tight=True, dpi=100) plt.close(fig) return pane @classmethod def _plot_center_scatter( cls, info_table: pd.DataFrame, row_edges: np.ndarray, col_edges: np.ndarray, image_shape: tuple[int, ...], ): """Scatter plot of weighted centroids with grid edge overlay.""" import panel as pn import matplotlib.pyplot as plt with plt.rc_context(cls._dashboard_rcparams()): aspect = image_shape[1] / image_shape[0] fig_h = 4.0 fig, ax = plt.subplots(figsize=(fig_h * aspect, fig_h)) if info_table.empty: ax.text( 0.5, 0.5, "No objects detected", ha="center", va="center", fontsize=10, color="#8892a4", transform=ax.transAxes, ) else: cc = info_table[str(BBOX.DIST_WEIGHTED_CENTER_CC)].values rr = info_table[str(BBOX.DIST_WEIGHTED_CENTER_RR)].values ax.scatter( cc, rr, s=4, alpha=0.5, color=cls._OI_NAVY, edgecolors="none", ) for edge in row_edges: ax.axhline(edge, color=cls._OI_VERMILION, lw=0.8, alpha=0.7) for edge in col_edges: ax.axvline(edge, color=cls._OI_VERMILION, lw=0.8, alpha=0.7) ax.set_xlim(0, image_shape[1]) ax.set_ylim(image_shape[0], 0) ax.set_xlabel("Column (px)") ax.set_ylabel("Row (px)") ax.set_title("Centroids with Grid Overlay") fig.tight_layout() pane = pn.pane.Matplotlib(fig, tight=True, dpi=100) plt.close(fig) return pane @classmethod def _build_inspect_summary( cls, result: dict, nrows: int, ncols: int, image_shape: tuple[int, ...], ): """Markdown summary panel with grid diagnostics.""" import panel as pn info_table = result["info_table"] timings = result["timings"] grid_df = result["grid_df"] n_objects = len(info_table) total_time = sum(timings.values()) # Objects per cell stats if not grid_df.empty and str(GRID.ROW_MAJOR_IDX) in grid_df.columns: counts = grid_df[str(GRID.ROW_MAJOR_IDX)].value_counts() min_per_cell = int(counts.min()) if len(counts) > 0 else 0 med_per_cell = float(counts.median()) if len(counts) > 0 else 0 max_per_cell = int(counts.max()) if len(counts) > 0 else 0 occupied = len(counts) else: min_per_cell = med_per_cell = max_per_cell = occupied = 0 # Oversized objects if not info_table.empty: heights = ( info_table[str(BBOX.MAX_RR)].values - info_table[str(BBOX.MIN_RR)].values ) widths = ( info_table[str(BBOX.MAX_CC)].values - info_table[str(BBOX.MIN_CC)].values ) expected_cell_area = ( (image_shape[0] / nrows) * (image_shape[1] / ncols) ) n_oversized = int((heights * widths > expected_cell_area).sum()) else: n_oversized = 0 # Pitch from edges row_edges = result["row_edges"] col_edges = result["col_edges"] row_pitch = float(np.median(np.diff(row_edges))) col_pitch = float(np.median(np.diff(col_edges))) md = ( f"### Summary\n\n" f"| Metric | Value |\n" f"|---|---|\n" f"| Objects | {n_objects} |\n" f"| Grid | {nrows} x {ncols} ({nrows * ncols} cells) |\n" f"| Occupied cells | {occupied} |\n" f"| Obj/cell (min / med / max) | {min_per_cell} / " f"{med_per_cell:.1f} / {max_per_cell} |\n" f"| Oversized objects | {n_oversized} |\n" f"| Row pitch | {row_pitch:.1f} px |\n" f"| Col pitch | {col_pitch:.1f} px |\n" f"| Pipeline path | {result['pipeline_path']} |\n" f"| Total time | {total_time:.3f} s |\n" ) return pn.pane.Markdown( md, styles={"font-family": "'DM Sans', sans-serif"}, )
[docs] def inspect(self, image: Image, show_progress: bool = True): """Interactive diagnostic dashboard for grid fitting. Profiles the grid-fitting pipeline and displays timing breakdown, object size distribution, centroid scatter with grid overlay, and summary statistics. Useful for identifying bottlenecks when ``grid.info()`` is slow (e.g., with filamentous fungi images). Uses an ipywidgets progress bar in Jupyter, tqdm otherwise. Args: image: Image with detected objects (must have objmap). show_progress: Whether to display a progress bar during profiling. Defaults to True. Returns: Panel Column layout with 4 diagnostic panels. Examples: >>> from phenotypic.data import load_synth_yeast_plate >>> from phenotypic.detect import OtsuDetector >>> from phenotypic.grid import AutoGridFinder >>> image = load_synth_yeast_plate() >>> image = OtsuDetector().apply(image) >>> finder = AutoGridFinder(nrows=8, ncols=12) >>> dashboard = finder.inspect(image) """ import panel as pn result = self._run_timed_pipeline(image, show_progress=show_progress) header = pn.pane.Markdown( f"## Grid Fitting Diagnostics -- {image.num_objects} objects, " f"{self.nrows}x{self.ncols} grid", styles={ "font-family": "'DM Sans', sans-serif", "color": self._OI_NAVY, }, ) p1 = self._plot_timing_waterfall(result["timings"]) p2 = self._plot_object_size_dist( result["info_table"], self.nrows, self.ncols, image.shape, ) p3 = self._plot_center_scatter( result["info_table"], result["row_edges"], result["col_edges"], image.shape, ) p4 = self._build_inspect_summary( result, self.nrows, self.ncols, image.shape, ) return pn.Column( header, pn.Row(p1, p4), pn.Row(p3, p2), )
AutoGridFinder.measure.__doc__ = AutoGridFinder._operate.__doc__ AutoGridFinder.__doc__ = GRID.append_rst_to_doc(AutoGridFinder.__doc__)