""" Point validation — the agent's feedback loop. The agent chooses the keypoints. This module does not choose any. It only *measures* what the agent chose and reports back, so the agent can revise before paying for a SAM3 pass: * is the point inside the image at all? * what is under it — specimen, backdrop, or cast shadow? (measured from the step-1 alpha matte and from helpers/photometry.py, not guessed) * how far is it from the nearest boundary? A positive point 3 px from the edge is technically inside and practically useless. The verdicts are advisory. `agrees_with_step1: false` is a disagreement between the agent and the background remover, and the agent is allowed to win — it can see the photograph and the matte cannot. What it is not allowed to do is emit a point off-canvas, or claim a positive point on a region it also called negative. """ from __future__ import annotations from pathlib import Path import cv2 import numpy as np from PIL import Image from helpers.images import load_rgb from helpers.photometry import shadow_band # A positive point closer than this (as a fraction of the image's shorter side) # to the mask boundary is flagged as precarious. _EDGE_MARGIN_FRAC = 0.01 def validate_points( image_path: str | Path, alpha_mask_path: str | Path | None, positive_points: list[dict], negative_points: list[dict], ) -> dict: """ Measure the agent's chosen points against the image. Chooses nothing. Returns {ok, dimensions, positives: [...verdicts], negatives: [...], errors: [...], warnings: [...], summary}. Only `errors` are blocking. Warnings are for the agent to weigh. """ rgb_img = load_rgb(image_path) w, h = rgb_img.size rgb = np.array(rgb_img) if alpha_mask_path and Path(alpha_mask_path).exists(): alpha = np.array(Image.open(alpha_mask_path).convert("L").resize((w, h))) fg = alpha > 127 else: fg = np.zeros((h, w), bool) shadow = shadow_band(cv2.cvtColor(rgb, cv2.COLOR_RGB2BGR), fg) # Distance from every pixel to the nearest non-specimen pixel. depth = cv2.distanceTransform(fg.astype(np.uint8), cv2.DIST_L2, 5) margin = max(2.0, _EDGE_MARGIN_FRAC * min(w, h)) errors: list[str] = [] warnings: list[str] = [] def _verdict(p: dict, want_inside: bool, tag: str) -> dict: x, y = int(p["x"]), int(p["y"]) if not (0 <= x < w and 0 <= y < h): errors.append(f"{tag} ({x},{y}) is outside the image (bounds 0..{w-1}, 0..{h-1}).") return {"x": x, "y": y, "in_bounds": False} inside = bool(fg[y, x]) in_shadow = bool(shadow[y, x]) d = float(depth[y, x]) region = "specimen" if inside else ("cast_shadow" if in_shadow else "backdrop") v = { "x": x, "y": y, "in_bounds": True, "region_per_step1": region, "px_from_boundary": round(d, 1), "rgb_under_point": [int(c) for c in rgb[y, x]], "agrees_with_step1": inside == want_inside, } if want_inside and not inside: warnings.append( f"{tag} ({x},{y}) is a POSITIVE point, but step 1 calls that pixel " f"{region} (RGB {v['rgb_under_point']}). Three possibilities, and only " f"you can tell which: (a) step 1 clipped the specimen there — common, " f"keep the point; (b) this is ROCK MATRIX the fossil is embedded in and " f"you are correctly telling SAM3 the whole piece is one object — keep " f"the point; or (c) the point is genuinely off in the backdrop and " f"should move. Do NOT reflexively delete it: on an embedded specimen, " f"points out on the matrix are how the matrix stays in the mask." ) if want_inside and inside and d < margin: warnings.append( f"{tag} ({x},{y}) is only {d:.0f}px from the specimen edge. SAM3 " f"prompts near a boundary are ambiguous; move it toward the interior." ) if (not want_inside) and inside: warnings.append( f"{tag} ({x},{y}) is a NEGATIVE point but sits {d:.0f}px INSIDE the " f"specimen per step 1. A negative on the fossil punches a hole in the " f"mask. Move it or drop it." ) return v pos = [_verdict(p, True, f"P{i+1}") for i, p in enumerate(positive_points)] neg = [_verdict(p, False, f"N{i+1}") for i, p in enumerate(negative_points)] if not positive_points: errors.append("No positive points. SAM3 needs at least one point on the specimen.") seen: dict[tuple[int, int], str] = {} for label, pts in (("positive", positive_points), ("negative", negative_points)): for p in pts: key = (int(p["x"]), int(p["y"])) if key in seen and seen[key] != label: errors.append(f"({key[0]},{key[1]}) is listed as both positive and negative.") seen[key] = label shadow_px = int(shadow.sum()) if shadow_px > 0.005 * w * h and not any( n.get("region_per_step1") == "cast_shadow" for n in neg ): warnings.append( f"A cast shadow of ~{shadow_px} px was detected and NONE of your negative " f"points are in it. The shadow is the single most common thing SAM3 " f"wrongly annexes into the specimen. Put a negative point in it." ) return { "ok": not errors, "dimensions": {"width": w, "height": h}, "positives": pos, "negatives": neg, "errors": errors, "warnings": warnings, "shadow_pixels_detected": shadow_px, "summary": ( f"{len(pos)} positive, {len(neg)} negative. " f"{len(errors)} blocking error(s), {len(warnings)} warning(s)." ), }