fix(vision): find_rightmost_dot returns cluster centre, not edge

Also: calibrate._sample_rgb now snaps to the most-saturated pixel within 15px
of the click, so rough clicks still pick up the dot's pure colour. Default
dot-colour tolerance bumped 30→60 to absorb anti-aliasing.

Test fixture _SAMPLED_RGB recomputed for the new 36/49 dilution (was 24/49
when sampling at the trailing edge).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-04-16 06:59:47 +00:00
parent 0a4f9793e9
commit 0f430dae21
3 changed files with 53 additions and 20 deletions

View File

@@ -616,7 +616,7 @@ class _CalibrationWizard:
elif name.startswith("color_"): elif name.startswith("color_"):
cname = name.split("_", 1)[1] cname = name.split("_", 1)[1]
rgb = self._sample_rgb(ox, oy) rgb = self._sample_rgb(ox, oy)
tol = 15.0 if cname == "background" else 30.0 tol = 25.0 if cname == "background" else 60.0
self._data["colors"][cname] = {"rgb": list(rgb), "tolerance": tol} self._data["colors"][cname] = {"rgb": list(rgb), "tolerance": tol}
elif name == "chart_roi_tl": elif name == "chart_roi_tl":
self._tmp["chart_tl"] = (ox, oy) self._tmp["chart_tl"] = (ox, oy)
@@ -659,14 +659,31 @@ class _CalibrationWizard:
# -------- helpers -------- # -------- helpers --------
def _sample_rgb(self, x: int, y: int) -> tuple[int, int, int]: def _sample_rgb(self, x: int, y: int) -> tuple[int, int, int]:
"""Average RGB of a 7x7 patch centered on (x,y) in the original image.""" """Sample RGB around (x,y), snapping to the most-saturated pixel in a
box = (max(0, x - 3), max(0, y - 3), x + 4, y + 4) ±15px radius so rough clicks still pick the dot's pure colour."""
import numpy as np
arr = np.array(self._pil_img)
h, w = arr.shape[:2]
r = 15
x0, x1 = max(0, x - r), min(w, x + r + 1)
y0, y1 = max(0, y - r), min(h, y + r + 1)
patch = arr[y0:y1, x0:x1].astype(np.int32)
# Saturation proxy = max-min per pixel across RGB channels
sat = patch.max(axis=2) - patch.min(axis=2)
if sat.max() > 30:
iy, ix = np.unravel_index(int(np.argmax(sat)), sat.shape)
cx, cy = x0 + int(ix), y0 + int(iy)
else:
cx, cy = x, y
# 5x5 mean at the (snapped) centre.
box = (max(0, cx - 2), max(0, cy - 2), cx + 3, cy + 3)
pixels = list(self._pil_img.crop(box).getdata()) pixels = list(self._pil_img.crop(box).getdata())
n = len(pixels) or 1 n = len(pixels) or 1
r = sum(p[0] for p in pixels) // n return (
g = sum(p[1] for p in pixels) // n sum(p[0] for p in pixels) // n,
b = sum(p[2] for p in pixels) // n sum(p[1] for p in pixels) // n,
return (r, g, b) sum(p[2] for p in pixels) // n,
)
def _save(self) -> None: def _save(self) -> None:
from tkinter import messagebox from tkinter import messagebox

View File

@@ -103,12 +103,28 @@ def find_rightmost_dot(
diff = np.linalg.norm(roi_img.astype(np.float32) - bgr_bg, axis=2) diff = np.linalg.norm(roi_img.astype(np.float32) - bgr_bg, axis=2)
mask = diff > bg_tol # True = non-background mask = diff > bg_tol # True = non-background
_h, w = mask.shape _h, w = mask.shape
col_counts = mask.sum(axis=0)
right_col = None
for x in range(w - 1, -1, -1): for x in range(w - 1, -1, -1):
col = mask[:, x] if col_counts[x] >= min_cluster_px:
if col.sum() >= min_cluster_px: right_col = x
ys = np.where(col)[0] break
return (x, int(ys.mean())) if right_col is None:
return None return None
# Walk LEFT until a gap to find the cluster's left edge.
left_col = right_col
for x in range(right_col - 1, -1, -1):
if col_counts[x] < min_cluster_px:
break
left_col = x
cx = (left_col + right_col) // 2
cluster = mask[:, left_col:right_col + 1]
ys = np.where(cluster.any(axis=1))[0]
cy = int(ys.mean()) if ys.size > 0 else 0
return (cx, cy)
def pixel_rgb(roi_img: np.ndarray, x: int, y: int, box: int = 3) -> tuple[int, int, int]: def pixel_rgb(roi_img: np.ndarray, x: int, y: int, box: int = 3) -> tuple[int, int, int]:

View File

@@ -19,17 +19,17 @@ from atm.dryrun import ConfusionMatrix, DryrunResult, dryrun
# so classify_pixel returns the correct label. # so classify_pixel returns the correct label.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
_SCALE = 24 / 49 # fraction of dot pixels in the 7x7 sample box _SCALE = 36 / 49 # fraction of dot pixels in the 7x7 sample box (centre-based)
# True BGR paint values → sampled RGB ≈ int(true_RGB * _SCALE) # True BGR paint values → sampled RGB ≈ int(true_RGB * _SCALE)
_SAMPLED_RGB: dict[str, tuple[int, int, int]] = { _SAMPLED_RGB: dict[str, tuple[int, int, int]] = {
"turquoise": (0, 97, 97), # true (0, 200, 200) "turquoise": (0, 146, 146), # true (0, 200, 200)
"yellow": (124, 124, 0), # true (255, 255, 0) "yellow": (187, 187, 0), # true (255, 255, 0)
"dark_green": (0, 48, 0), # true (0, 100, 0) "dark_green": (0, 73, 0), # true (0, 100, 0)
"dark_red": (68, 0, 0), # true (139, 0, 0) "dark_red": (102, 0, 0), # true (139, 0, 0)
"light_green": (70, 116, 70), # true (144, 238, 144) "light_green": (105, 174, 105), # true (144, 238, 144)
"light_red": (124, 89, 94), # true (255, 182, 193) "light_red": (187, 133, 141), # true (255, 182, 193)
"gray": (62, 62, 62), # true (128, 128, 128) "gray": (94, 94, 94), # true (128, 128, 128)
} }
# True RGB values used when painting frames (before sampling dilution) # True RGB values used when painting frames (before sampling dilution)