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)."
        ),
    }