From bed79fcc357285ee7e220b6ba6c39c650108c38a Mon Sep 17 00:00:00 2001 From: Marius Mutu Date: Tue, 21 Apr 2026 07:25:38 +0300 Subject: [PATCH] calibrare --- samples/calibration_labels.json | 10 +++ src/atm/vision.py | 15 ++++- tests/test_detector.py | 108 ++++++++++++++++++++++++++++++++ 3 files changed, 131 insertions(+), 2 deletions(-) diff --git a/samples/calibration_labels.json b/samples/calibration_labels.json index 031a0df..f770610 100644 --- a/samples/calibration_labels.json +++ b/samples/calibration_labels.json @@ -13,5 +13,15 @@ "path": "logs/fires/20260417_210441_ss.png", "expected": "light_red", "note": "fire phase (missed live alert)" + }, + { + "path": "logs/fires/20260420_210649_ss.png", + "expected": "dark_red", + "note": "fused-blob regression: rightmost dark_red missed because erosion failed to separate adjacent fused dots; centroid landed on interior gray dot" + }, + { + "path": "logs/fires/20260420_200603_poll.png", + "expected": "dark_green", + "note": "fused-blob regression: rightmost dark_green missed for the same reason as the 21:06:49 dark_red sample" } ] diff --git a/src/atm/vision.py b/src/atm/vision.py index 6821062..9a46287 100644 --- a/src/atm/vision.py +++ b/src/atm/vision.py @@ -122,8 +122,19 @@ def find_rightmost_dot( best_idx = i if best_idx is None: return None - cx, cy = centroids[best_idx] - return (int(cx), int(cy)) + # When erosion fails to sever anti-aliased bridges between adjacent dots + # (common on long, dense dot rows), the "rightmost" component spans + # several fused dots and its centroid lands on an interior dot — wrong + # colour. Detect fused blobs by width and anchor to the right edge + # instead; small isolated dots still use the centroid. + comp_w = int(stats[best_idx, cv2.CC_STAT_WIDTH]) + right_edge = int(stats[best_idx, cv2.CC_STAT_LEFT]) + comp_w - 1 + if comp_w > 12: + cx = max(right_edge - 2, 0) + else: + cx = int(centroids[best_idx][0]) + cy = int(centroids[best_idx][1]) + 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_detector.py b/tests/test_detector.py index fc375e8..4cfb81e 100644 --- a/tests/test_detector.py +++ b/tests/test_detector.py @@ -196,3 +196,111 @@ def test_rolling_window() -> None: assert len(det.rolling) <= 20 assert len(det.rolling) == 20 + + +# --------------------------------------------------------------------------- +# Fused-blob regression: anti-aliased bridges merge adjacent dots into one +# connected component. The rightmost component's centroid then lands on an +# interior dot (wrong colour). find_rightmost_dot must anchor to the right +# edge for wide blobs so the truly-rightmost dot is sampled. +# See vision.find_rightmost_dot and logs/fires/20260420_210649_ss.png. +# --------------------------------------------------------------------------- + +def _make_fused_stripe_frame( + gray_segments: int, + tail_bgr: tuple[int, int, int], + seg_w: int = 13, + stripe_h: int = 13, +) -> np.ndarray: + """Continuous multi-colour stripe: N gray segments + one tail-colour segment. + + Survives 2-iter erosion as a single component — exactly the failure mode on + real screenshots where anti-aliased bridges fuse the whole dot row into one + component. Centroid lands on an interior gray segment; the right edge lies + inside the tail colour. + """ + frame = np.full((100, 300, 3), BG_VAL, dtype=np.uint8) + y0 = DOT_ROI.y + (DOT_ROI.h - stripe_h) // 2 + x0 = DOT_ROI.x + 40 + gray_bgr = (128, 128, 128) + for i in range(gray_segments): + xs = x0 + i * seg_w + frame[y0:y0 + stripe_h, xs:xs + seg_w] = gray_bgr + xs = x0 + gray_segments * seg_w + frame[y0:y0 + stripe_h, xs:xs + seg_w] = tail_bgr + return frame + + +@pytest.mark.parametrize( + ("screenshot", "expected"), + [ + ("logs/fires/20260420_210649_ss.png", "dark_red"), + ("logs/fires/20260420_200603_poll.png", "dark_green"), + ], +) +def test_real_screenshot_rightmost_dot(screenshot: str, expected: str) -> None: + """Regression on live-capture frames where fused blobs hid the rightmost dot. + + 2026-04-20 live session missed both a dark_red (21:06:49) and a dark_green + (20:06:03) because find_rightmost_dot returned the centroid of a multi-dot + fused component. Skips cleanly if the sample PNG is not checked out locally + (logs/fires/ is gitignored). + """ + import cv2 + from pathlib import Path + + from atm.config import ROI + from atm.vision import classify_pixel, crop_roi, find_rightmost_dot, pixel_rgb + + path = Path(screenshot) + if not path.exists(): + pytest.skip(f"sample not available: {path}") + + frame = cv2.imread(str(path)) + assert frame is not None + + # Matches configs/2026-04-18-1220.toml dot_roi — the live config that missed + # these alerts. + roi = ROI(x=0, y=712, w=1796, h=35) + crop = crop_roi(frame, roi) + + dot = find_rightmost_dot(crop, bg_rgb=(0, 0, 0), bg_tol=25.0) + assert dot is not None, "rightmost dot must be found" + + rgb = pixel_rgb(crop, *dot) + palette = { + "turquoise": ((0, 153, 153), 60.0), + "yellow": ((153, 153, 0), 60.0), + "dark_green": ((0, 122, 0), 60.0), + "dark_red": ((128, 0, 0), 60.0), + "light_green": ((0, 171, 0), 60.0), + "light_red": ((171, 0, 0), 60.0), + "gray": ((128, 128, 128), 60.0), + } + match = classify_pixel(rgb, palette) + assert match.name == expected, ( + f"{path.name}: expected {expected}, got {match.name} at {dot} RGB={rgb}" + ) + + +def test_fused_blob_samples_rightmost_dot() -> None: + """Fused multi-colour stripe must classify the rightmost colour, not the + centroid colour. Pre-fix the centroid fell on an interior gray segment + on real screenshots (2026-04-20 dark_red/dark_green misses).""" + dark_red_bgr = (0, 0, 100) # BGR for dark_red RGB=(100,0,0) + frame = _make_fused_stripe_frame(gray_segments=7, tail_bgr=dark_red_bgr) + + cfg = _make_cfg() + from atm.config import ColorSpec + cfg.colors["gray"] = ColorSpec(rgb=(128, 128, 128), tolerance=30.0) + cfg.colors["dark_red"] = ColorSpec(rgb=(100, 0, 0), tolerance=30.0) + + det = Detector(cfg, capture=lambda: frame) + r = det.step(0.0) + + assert r.dot_found is True + assert r.match is not None + assert r.match.name == "dark_red", ( + f"expected dark_red (rightmost segment), got {r.match.name} at " + f"{r.dot_pos_abs} RGB={r.rgb} — centroid regression" + )