"""Small, locally trainable colony-candidate detector for the PetriCount demo. It deliberately uses only the annotated AGAR images that ship with this project. The model ranks multiscale bright/dark blob candidates; it is a baseline for UI integration, not a validated microbiology measurement method. """ from __future__ import annotations from dataclasses import dataclass from pathlib import Path from typing import Iterable import numpy as np from PIL import Image from scipy.ndimage import gaussian_filter, maximum_filter MAX_SIDE = 720 @dataclass class PreparedImage: rgb: np.ndarray gray: np.ndarray scale: float original_size: tuple[int, int] def prepare_image(path_or_file) -> PreparedImage: image = Image.open(path_or_file).convert("RGB") original_size = image.size scale = min(1.0, MAX_SIDE / max(image.size)) if scale < 1: image = image.resize((round(image.width * scale), round(image.height * scale)), Image.Resampling.LANCZOS) rgb = np.asarray(image, dtype=np.float32) / 255.0 gray = rgb[..., 0] * 0.2126 + rgb[..., 1] * 0.7152 + rgb[..., 2] * 0.0722 return PreparedImage(rgb, gray, scale, original_size) def _near_edge(x: int, y: int, width: int, height: int, margin: int = 13) -> bool: return x < margin or y < margin or x >= width - margin or y >= height - margin def candidate_points(prepared: PreparedImage) -> list[tuple[int, int]]: """Find potential round colonies as local extrema at several blob scales.""" gray = prepared.gray height, width = gray.shape candidates: list[tuple[float, int, int]] = [] # Colonies can be lighter or darker than the surrounding agar. for sigma in (1.5, 2.5, 4.0, 6.0): local = gaussian_filter(gray, sigma) - gaussian_filter(gray, sigma * 3.2) radius = max(4, round(sigma * 2.5)) for response in (local, -local): maxima = response == maximum_filter(response, size=radius * 2 + 1) threshold = np.percentile(response, 97.2) ys, xs = np.where(maxima & (response >= threshold)) for y, x in zip(ys, xs): if _near_edge(int(x), int(y), width, height): continue # Suppress the metal/background outside the central dish region. if np.hypot(x - width / 2, y - height / 2) > min(width, height) * 0.48: continue candidates.append((float(response[y, x]), int(x), int(y))) candidates.sort(reverse=True) selected: list[tuple[int, int]] = [] for _, x, y in candidates: if all((x - px) ** 2 + (y - py) ** 2 > 13 ** 2 for px, py in selected): selected.append((x, y)) if len(selected) >= 450: break # Diffuse colonies often have no sharp local maximum. Add a coarse candidate # grid over visually changing parts of the dish and let the learned ranker # decide whether each patch is a colony or background. local_change = np.abs(gaussian_filter(gray, 2) - gaussian_filter(gray, 15)) change_threshold = np.percentile(local_change, 58) for y in range(14, height - 14, 16): for x in range(14, width - 14, 16): if np.hypot(x - width / 2, y - height / 2) > min(width, height) * 0.46: continue if local_change[y, x] < change_threshold: continue if all((x - px) ** 2 + (y - py) ** 2 > 8 ** 2 for px, py in selected): selected.append((x, y)) return selected def _patch(array: np.ndarray, x: int, y: int, radius: int) -> np.ndarray: return array[max(0, y - radius):y + radius + 1, max(0, x - radius):x + radius + 1] def features_for_points(prepared: PreparedImage, points: Iterable[tuple[int, int]]) -> np.ndarray: rows = [] smooth_small = gaussian_filter(prepared.gray, 2) smooth_large = gaussian_filter(prepared.gray, 12) contrast = smooth_small - smooth_large gradient_y, gradient_x = np.gradient(prepared.gray) gradient = np.hypot(gradient_x, gradient_y) for x, y in points: inner_gray = _patch(prepared.gray, x, y, 4) outer_gray = _patch(prepared.gray, x, y, 12) inner_rgb = _patch(prepared.rgb, x, y, 4).reshape(-1, 3) outer_rgb = _patch(prepared.rgb, x, y, 12) inner_gradient = _patch(gradient, x, y, 8) rows.append([ *inner_rgb.mean(axis=0), *inner_rgb.std(axis=0), float(inner_gray.mean()), float(inner_gray.std()), float(outer_gray.mean()), float(outer_gray.std()), float(contrast[y, x]), float(abs(contrast[y, x])), float(inner_gradient.mean()), float(inner_gradient.std()), x / prepared.gray.shape[1], y / prepared.gray.shape[0], ]) return np.asarray(rows, dtype=np.float32) def label_centers(labels: list[dict], scale: float) -> list[tuple[int, int, float]]: return [((item["x"] + item["width"] / 2) * scale, (item["y"] + item["height"] / 2) * scale, max(item["width"], item["height"]) * scale / 2) for item in labels] def label_candidates(points: list[tuple[int, int]], labels: list[dict], scale: float) -> np.ndarray: centers = label_centers(labels, scale) labels_for_points = [] for x, y in points: labels_for_points.append(any((x - cx) ** 2 + (y - cy) ** 2 <= max(7, radius * .78) ** 2 for cx, cy, radius in centers)) return np.asarray(labels_for_points, dtype=np.int8) def add_annotation_centers(points: list[tuple[int, int]], labels: list[dict], scale: float) -> list[tuple[int, int]]: result = list(points) for center_x, center_y, _ in label_centers(labels, scale): point = (round(center_x), round(center_y)) if all((point[0] - px) ** 2 + (point[1] - py) ** 2 > 5 ** 2 for px, py in result): result.append(point) return result