Spaces:
Sleeping
Sleeping
File size: 5,961 Bytes
876cc3e c20f9af 876cc3e c20f9af | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 | """
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)."
),
} |