diff --git a/src/atm/calibrate.py b/src/atm/calibrate.py index 7c859fb..e2ff423 100644 --- a/src/atm/calibrate.py +++ b/src/atm/calibrate.py @@ -616,7 +616,7 @@ class _CalibrationWizard: elif name.startswith("color_"): cname = name.split("_", 1)[1] 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} elif name == "chart_roi_tl": self._tmp["chart_tl"] = (ox, oy) @@ -659,14 +659,31 @@ class _CalibrationWizard: # -------- helpers -------- 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.""" - box = (max(0, x - 3), max(0, y - 3), x + 4, y + 4) + """Sample RGB around (x,y), snapping to the most-saturated pixel in a + ±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()) n = len(pixels) or 1 - r = sum(p[0] for p in pixels) // n - g = sum(p[1] for p in pixels) // n - b = sum(p[2] for p in pixels) // n - return (r, g, b) + return ( + sum(p[0] for p in pixels) // n, + sum(p[1] for p in pixels) // n, + sum(p[2] for p in pixels) // n, + ) def _save(self) -> None: from tkinter import messagebox diff --git a/src/atm/vision.py b/src/atm/vision.py index 1a40f0c..fa9f378 100644 --- a/src/atm/vision.py +++ b/src/atm/vision.py @@ -103,12 +103,28 @@ def find_rightmost_dot( diff = np.linalg.norm(roi_img.astype(np.float32) - bgr_bg, axis=2) mask = diff > bg_tol # True = non-background _h, w = mask.shape + col_counts = mask.sum(axis=0) + + right_col = None for x in range(w - 1, -1, -1): - col = mask[:, x] - if col.sum() >= min_cluster_px: - ys = np.where(col)[0] - return (x, int(ys.mean())) - return None + if col_counts[x] >= min_cluster_px: + right_col = x + break + if right_col is 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]: diff --git a/tests/test_dryrun.py b/tests/test_dryrun.py index 8b7521a..29e335c 100644 --- a/tests/test_dryrun.py +++ b/tests/test_dryrun.py @@ -19,17 +19,17 @@ from atm.dryrun import ConfusionMatrix, DryrunResult, dryrun # 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) _SAMPLED_RGB: dict[str, tuple[int, int, int]] = { - "turquoise": (0, 97, 97), # true (0, 200, 200) - "yellow": (124, 124, 0), # true (255, 255, 0) - "dark_green": (0, 48, 0), # true (0, 100, 0) - "dark_red": (68, 0, 0), # true (139, 0, 0) - "light_green": (70, 116, 70), # true (144, 238, 144) - "light_red": (124, 89, 94), # true (255, 182, 193) - "gray": (62, 62, 62), # true (128, 128, 128) + "turquoise": (0, 146, 146), # true (0, 200, 200) + "yellow": (187, 187, 0), # true (255, 255, 0) + "dark_green": (0, 73, 0), # true (0, 100, 0) + "dark_red": (102, 0, 0), # true (139, 0, 0) + "light_green": (105, 174, 105), # true (144, 238, 144) + "light_red": (187, 133, 141), # true (255, 182, 193) + "gray": (94, 94, 94), # true (128, 128, 128) } # True RGB values used when painting frames (before sampling dilution)