calibrare
This commit is contained in:
@@ -13,5 +13,15 @@
|
|||||||
"path": "logs/fires/20260417_210441_ss.png",
|
"path": "logs/fires/20260417_210441_ss.png",
|
||||||
"expected": "light_red",
|
"expected": "light_red",
|
||||||
"note": "fire phase (missed live alert)"
|
"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"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -122,8 +122,19 @@ def find_rightmost_dot(
|
|||||||
best_idx = i
|
best_idx = i
|
||||||
if best_idx is None:
|
if best_idx is None:
|
||||||
return None
|
return None
|
||||||
cx, cy = centroids[best_idx]
|
# When erosion fails to sever anti-aliased bridges between adjacent dots
|
||||||
return (int(cx), int(cy))
|
# (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]:
|
def pixel_rgb(roi_img: np.ndarray, x: int, y: int, box: int = 3) -> tuple[int, int, int]:
|
||||||
|
|||||||
@@ -196,3 +196,111 @@ def test_rolling_window() -> None:
|
|||||||
|
|
||||||
assert len(det.rolling) <= 20
|
assert len(det.rolling) <= 20
|
||||||
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"
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user