""" Step 2 — keypoint selection. `organoid` asks the vision model to invent coordinates from a lookup table ("centred -> (w/2, h/2); upper-left -> (w/4, h/4)"). That is a nine-cell quantisation of the image and it breaks on any concave object: the centroid of a C-shaped or L-shaped fossil can sit in the background, so SAM3 gets a positive point that is not on the specimen at all. This module proposes points *geometrically* from the alpha matte instead, and then hands them to the vision model to review. Two guarantees fall out of that: 1. Every positive point is inside the mask by construction — the anchor is the maximum of the Euclidean distance transform, i.e. the point furthest from any boundary. For a crescent-shaped fossil that lands in the thickest part of the crescent, never in the hollow. 2. Negative points come for free. SAM3 accepts label-0 prompts, and the single biggest failure mode when photographing a specimen on a desk is the model annexing the cast shadow. Seeding negatives into the shadow band suppresses that directly. The vision model's job becomes *review* (accept / move / add / drop), which is what current models are actually reliable at, rather than *estimation*, which they are not. """ from __future__ import annotations from pathlib import Path import cv2 import numpy as np from helpers.photometry import shadow_band from config.settings import CORE_DT_FRACTION, N_NEGATIVE_POINTS, N_POSITIVE_POINTS def _farthest_point_sample(candidates: np.ndarray, seed: tuple[int, int], k: int) -> list[tuple[int, int]]: """Greedy farthest-point sampling — spreads points across the specimen.""" chosen = [np.array(seed, dtype=np.float32)] if len(candidates) == 0: return [tuple(int(v) for v in seed)] for _ in range(max(0, k - 1)): d = np.min( np.linalg.norm(candidates[:, None, :] - np.stack(chosen)[None], axis=2), axis=1, ) chosen.append(candidates[int(np.argmax(d))].astype(np.float32)) return [(int(p[0]), int(p[1])) for p in chosen] def propose_keypoints( image_path: str | Path, alpha_mask_path: str | Path, n_positive: int = N_POSITIVE_POINTS, n_negative: int = N_NEGATIVE_POINTS, ) -> dict: """ Derive positive (inside-fossil) and negative (background/shadow) prompt points. Returns a dict with `positive_points` / `negative_points` as lists of `{"x": int, "y": int, "label": str, "source": "geometric"}`. """ bgr = cv2.imread(str(image_path)) if bgr is None: raise FileNotFoundError(f"Could not read image: {image_path}") alpha = cv2.imread(str(alpha_mask_path), cv2.IMREAD_GRAYSCALE) if alpha is None: raise FileNotFoundError(f"Could not read alpha matte: {alpha_mask_path}") binary = (alpha > 127).astype(np.uint8) * 255 # --- positives: distance-transform anchor + spread --------------------- dt = cv2.distanceTransform(binary, cv2.DIST_L2, 5) if dt.max() <= 0: raise RuntimeError("Alpha matte is empty — nothing to place keypoints on.") anchor_y, anchor_x = np.unravel_index(int(np.argmax(dt)), dt.shape) core = (dt > CORE_DT_FRACTION * dt.max()).astype(np.uint8) ys, xs = np.nonzero(core) candidates = np.stack([xs, ys], axis=1) positives = _farthest_point_sample(candidates, (int(anchor_x), int(anchor_y)), n_positive) # --- negatives: shadow band first, then plain background --------------- h, w = binary.shape ring_kernel = np.ones((61, 61), np.uint8) ring = cv2.bitwise_and(cv2.dilate(binary, ring_kernel), cv2.bitwise_not(binary)) # One shared definition of "shadow" — see helpers/photometry.py. shadow = shadow_band(bgr, binary).astype(np.uint8) * 255 n_shadow = min(n_negative // 2, 3) negatives: list[tuple[int, int]] = [] for mask, want in ((shadow, n_shadow), (ring, n_negative - n_shadow)): yy, xx = np.nonzero(mask) if len(yy) == 0 or want <= 0: continue idx = np.linspace(0, len(yy) - 1, want).astype(int) negatives += [(int(xx[i]), int(yy[i])) for i in idx] # Deduplicate while preserving order. negatives = list(dict.fromkeys(negatives)) shadow_px = int((shadow > 0).sum()) notes = ( f"Anchor placed at the distance-transform maximum " f"(depth {dt.max():.0f}px from the nearest boundary). " f"{len(positives)} positive, {len(negatives)} negative points. " + ( f"A shadow band of {shadow_px} px was detected and seeded with " f"{n_shadow} negative points." if shadow_px > 0 else "No cast shadow detected around the specimen." ) ) return { "status": "success", "image_path": str(image_path), "dimensions": {"width": int(w), "height": int(h)}, "positive_points": [ {"x": x, "y": y, "label": "positive", "source": "geometric"} for x, y in positives ], "negative_points": [ {"x": x, "y": y, "label": "negative", "source": "geometric"} for x, y in negatives ], "notes": notes, }