Bug critic: _strips_match(tol=10) trip pe pulsații naturale de lățime ~18px între ticks (ex. 792↔810px). Fiecare trip → _commit_layout_change → reset FSM + alert Telegram + scheduler stop. Logul 2026-05-04.jsonl arăta 576 evenimente layout_change/zi, plus prime alerts repetate la dark_red/dark_green (FSM resetat înghite lockout-ul) și sincronizare cross-chart pe ambele FSM-uri simultan. Fix: - main.py:1511 — gate doar pe count change (len(new) != len(current)); count stabil → silent update sub_roi indiferent de jitter - main.py:1438 — silent=True pe alert layout_change (Telegram fără sunet) - 2 teste regresie noi: width oscillation 792↔810 + silent assertion - 2 teste async reparate: bootstrap _detect_strips_for_ctx pentru ScriptedDetector (regresie după ce _run_multi_tick a devenit unica cale de detecție) Plus refactor multi-chart pre-existent: layout.py modul nou, _detect_strips_for_ctx, ChartState per-chart FSM/Detector, ROI per-strip pe screenshots, scripts/diag_*. Verificat: 292 passed, 2 skipped în 10s. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
363 lines
12 KiB
Python
363 lines
12 KiB
Python
"""Tests for src/atm/detector.py."""
|
|
from __future__ import annotations
|
|
|
|
import numpy as np
|
|
import pytest
|
|
|
|
from atm.config import (
|
|
CanaryRegion,
|
|
ColorSpec,
|
|
Config,
|
|
DiscordCfg,
|
|
ROI,
|
|
TelegramCfg,
|
|
YAxisCalib,
|
|
)
|
|
from atm.detector import DetectionResult, Detector
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Constants
|
|
# ---------------------------------------------------------------------------
|
|
DOT_ROI = ROI(x=10, y=10, w=280, h=80)
|
|
BG_VAL = 18 # background pixel value (18, 18, 18)
|
|
|
|
# BGR values (OpenCV convention: B, G, R)
|
|
# turquoise RGB=(0,255,255) → BGR=(255,255,0)
|
|
# yellow RGB=(255,255,0) → BGR=(0,255,255)
|
|
TURQUOISE_BGR = (255, 255, 0)
|
|
YELLOW_BGR = (0, 255, 255)
|
|
# A purple-ish colour far from every palette entry (RGB=(100,150,50))
|
|
UNKNOWN_BGR = (50, 150, 100)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _make_frame(*dot_specs: tuple[tuple[int, int, int], int, int]) -> np.ndarray:
|
|
"""Create a (100, 300, 3) uint8 BGR frame filled with background.
|
|
|
|
Each spec is (bgr_color, roi_x_start, roi_x_end) and paints a
|
|
full-height stripe inside DOT_ROI. roi_x_end=280 reaches the right
|
|
boundary so pixel_rgb sampling stays within the dot.
|
|
"""
|
|
frame = np.full((100, 300, 3), BG_VAL, dtype=np.uint8)
|
|
for bgr, x0, x1 in dot_specs:
|
|
fx0 = DOT_ROI.x + x0
|
|
fx1 = DOT_ROI.x + x1
|
|
fy0 = DOT_ROI.y
|
|
fy1 = DOT_ROI.y + DOT_ROI.h
|
|
frame[fy0:fy1, fx0:fx1] = bgr
|
|
return frame
|
|
|
|
|
|
def _make_cfg(debounce_depth: int = 1) -> Config:
|
|
colors = {
|
|
"turquoise": ColorSpec(rgb=(0, 255, 255), tolerance=30.0),
|
|
"yellow": ColorSpec(rgb=(255, 255, 0), tolerance=30.0),
|
|
"dark_green": ColorSpec(rgb=(0, 100, 0), tolerance=30.0),
|
|
"dark_red": ColorSpec(rgb=(100, 0, 0), tolerance=30.0),
|
|
"light_green": ColorSpec(rgb=(0, 255, 0), tolerance=30.0),
|
|
"light_red": ColorSpec(rgb=(255, 0, 0), tolerance=30.0),
|
|
"gray": ColorSpec(rgb=(128, 128, 128), tolerance=30.0),
|
|
}
|
|
return Config(
|
|
window_title="test",
|
|
dot_roi=DOT_ROI,
|
|
chart_roi=ROI(x=0, y=0, w=600, h=400),
|
|
colors=colors,
|
|
y_axis=YAxisCalib(p1_y=0, p1_price=100.0, p2_y=400, p2_price=80.0),
|
|
canary=CanaryRegion(
|
|
roi=ROI(x=0, y=0, w=50, h=50),
|
|
baseline_phash="0" * 64,
|
|
drift_threshold=8,
|
|
),
|
|
discord=DiscordCfg(webhook_url="http://example.com/hook"),
|
|
telegram=TelegramCfg(bot_token="tok", chat_id="123"),
|
|
debounce_depth=debounce_depth,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_empty_roi_no_dot() -> None:
|
|
"""All-background frame → dot not found."""
|
|
frame = np.full((100, 300, 3), BG_VAL, dtype=np.uint8)
|
|
cfg = _make_cfg()
|
|
det = Detector(cfg, capture=lambda: frame)
|
|
|
|
r = det.step(0.0)
|
|
|
|
assert r.window_found is True
|
|
assert r.dot_found is False
|
|
assert r.rgb is None
|
|
assert r.match is None
|
|
assert r.accepted is False
|
|
|
|
|
|
def test_rightmost_cluster() -> None:
|
|
"""Two dots at different x positions → detector returns rightmost colour."""
|
|
# turquoise on the left, yellow extending to the right ROI edge
|
|
frame = _make_frame(
|
|
(TURQUOISE_BGR, 50, 100), # roi_x [50, 100)
|
|
(YELLOW_BGR, 200, 280), # roi_x [200, 280) → right edge
|
|
)
|
|
cfg = _make_cfg()
|
|
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 == "yellow"
|
|
|
|
|
|
def test_debounce_depth_1() -> None:
|
|
"""depth=1: single valid frame → accepted=True."""
|
|
frame = _make_frame((YELLOW_BGR, 200, 280))
|
|
cfg = _make_cfg(debounce_depth=1)
|
|
det = Detector(cfg, capture=lambda: frame)
|
|
|
|
r = det.step(0.0)
|
|
|
|
assert r.accepted is True
|
|
assert r.color == "yellow"
|
|
|
|
|
|
def test_debounce_depth_2() -> None:
|
|
"""depth=2: first frame → accepted=False; second same → accepted=True."""
|
|
frame = _make_frame((YELLOW_BGR, 200, 280))
|
|
cfg = _make_cfg(debounce_depth=2)
|
|
det = Detector(cfg, capture=lambda: frame)
|
|
|
|
r1 = det.step(0.0)
|
|
r2 = det.step(1.0)
|
|
|
|
assert r1.accepted is False
|
|
assert r2.accepted is True
|
|
assert r2.color == "yellow"
|
|
|
|
|
|
def test_debounce_reset_on_change() -> None:
|
|
"""depth=2: A then B → neither accepted."""
|
|
frame_a = _make_frame((TURQUOISE_BGR, 200, 280))
|
|
frame_b = _make_frame((YELLOW_BGR, 200, 280))
|
|
cfg = _make_cfg(debounce_depth=2)
|
|
frames = iter([frame_a, frame_b])
|
|
det = Detector(cfg, capture=lambda: next(frames))
|
|
|
|
r1 = det.step(0.0)
|
|
r2 = det.step(1.0)
|
|
|
|
assert r1.accepted is False
|
|
assert r2.accepted is False
|
|
|
|
|
|
def test_unknown_not_accepted() -> None:
|
|
"""Colour outside every palette tolerance → UNKNOWN, accepted=False."""
|
|
frame = _make_frame((UNKNOWN_BGR, 200, 280))
|
|
cfg = _make_cfg(debounce_depth=1)
|
|
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 == "UNKNOWN"
|
|
assert r.accepted is False
|
|
assert r.color is None
|
|
|
|
|
|
def test_window_lost() -> None:
|
|
"""capture() returns None → window_found=False, safe defaults."""
|
|
cfg = _make_cfg()
|
|
det = Detector(cfg, capture=lambda: None)
|
|
|
|
r = det.step(0.0)
|
|
|
|
assert r.window_found is False
|
|
assert r.dot_found is False
|
|
assert r.rgb is None
|
|
assert r.match is None
|
|
assert r.accepted is False
|
|
assert r.color is None
|
|
|
|
|
|
def test_rolling_window() -> None:
|
|
"""Rolling window never exceeds 20 entries."""
|
|
frame = _make_frame((YELLOW_BGR, 200, 280))
|
|
cfg = _make_cfg()
|
|
det = Detector(cfg, capture=lambda: frame)
|
|
|
|
for i in range(25):
|
|
det.step(float(i))
|
|
|
|
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_dot_roi_override_uses_sub_roi() -> None:
|
|
"""dot_roi_override must be used instead of cfg.dot_roi for crop + offset.
|
|
|
|
Paint a yellow dot inside the override ROI but **outside** cfg.dot_roi.
|
|
The default DOT_ROI is (10,10,280,80); we override with an ROI placed
|
|
well to the right (x=200, w=80) so the painted dot only intersects the
|
|
override. If the detector still cropped from cfg.dot_roi the yellow dot
|
|
would land at the rightmost edge of the larger ROI as well — so we use
|
|
a frame that has nothing in the cfg.dot_roi region except inside the
|
|
override window, and assert dot_pos_abs falls inside the override.
|
|
"""
|
|
override = ROI(x=200, y=20, w=80, h=60)
|
|
# Background-only frame, then paint yellow only inside the override
|
|
frame = np.full((100, 300, 3), BG_VAL, dtype=np.uint8)
|
|
fy0, fy1 = override.y, override.y + override.h
|
|
fx0, fx1 = override.x + 50, override.x + override.w # right edge of override
|
|
frame[fy0:fy1, fx0:fx1] = YELLOW_BGR
|
|
|
|
cfg = _make_cfg(debounce_depth=1)
|
|
det = Detector(cfg, capture=lambda: frame, dot_roi_override=override)
|
|
|
|
r = det.step(0.0)
|
|
|
|
assert r.dot_found is True
|
|
assert r.match is not None
|
|
assert r.match.name == "yellow"
|
|
assert r.dot_pos_abs is not None
|
|
abs_x, abs_y = r.dot_pos_abs
|
|
assert override.x <= abs_x < override.x + override.w
|
|
assert override.y <= abs_y < override.y + override.h
|
|
|
|
|
|
def test_dot_pos_abs_with_offset() -> None:
|
|
"""dot_pos_abs must include the override ROI's (x, y) offset."""
|
|
override = ROI(x=100, y=20, w=50, h=40)
|
|
frame = np.full((100, 300, 3), BG_VAL, dtype=np.uint8)
|
|
# Paint a single full-height yellow stripe at roi-local x in [40, 50)
|
|
# so find_rightmost_dot lands somewhere inside that stripe.
|
|
fy0, fy1 = override.y, override.y + override.h
|
|
fx0, fx1 = override.x + 40, override.x + 50
|
|
frame[fy0:fy1, fx0:fx1] = YELLOW_BGR
|
|
|
|
cfg = _make_cfg(debounce_depth=1)
|
|
det = Detector(cfg, capture=lambda: frame, dot_roi_override=override)
|
|
|
|
r = det.step(0.0)
|
|
|
|
assert r.dot_found is True
|
|
assert r.dot_pos_abs is not None
|
|
abs_x, abs_y = r.dot_pos_abs
|
|
# Painted stripe: roi-local x in [40,50), y in [0, h). Absolute coords
|
|
# must be offset by override.(x, y).
|
|
assert override.x + 40 <= abs_x < override.x + 50
|
|
assert override.y <= abs_y < override.y + override.h
|
|
|
|
|
|
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"
|
|
)
|