/ss și /resume afișează acum markerii top-3 buline sub ROI (cercuri pline,
r=7, culoarea clasificată) cu tick vertical roșu pe pick-ul FSM (rightmost).
Caption compact: `N/3 STATE` header + `emoji c1/c2/c3: name ← pick`.
FIRE_{BUY|SELL} afișat ca 3/3 când fire_ts e în ultimele 30s.
/resume face capture ÎNAINTE de clearing state → zero race cu FSM tick
simultan. Capture fail → title marchează "⚠️ captură eșuată", resume-ul
rulează oricum.
config: <version> mutat din caption în /status (acolo are sens pentru
verificare de calibrare, nu la fiecare /ss).
Adaugă find_top_dots în vision.py (top-N variantă a find_rightmost_dot,
tie-break determinist pe y). 5 teste sintetice noi + 4 teste noi pentru
dispatcher resume (screenshot inline, capture-fail, order-of-ops,
parity /ss <-> fire path).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
74 lines
2.3 KiB
Python
74 lines
2.3 KiB
Python
"""Unit tests for vision primitives (synthetic BGR masks, fast, deterministic)."""
|
||
from __future__ import annotations
|
||
|
||
import cv2
|
||
import numpy as np
|
||
|
||
from atm.vision import find_top_dots
|
||
|
||
|
||
BG_RGB = (18, 18, 18) # background in RGB
|
||
|
||
|
||
def _make_frame(h: int = 30, w: int = 100) -> np.ndarray:
|
||
"""Blank BGR frame filled with BG_RGB."""
|
||
bgr_bg = (BG_RGB[2], BG_RGB[1], BG_RGB[0])
|
||
frame = np.zeros((h, w, 3), dtype=np.uint8)
|
||
frame[:, :] = bgr_bg
|
||
return frame
|
||
|
||
|
||
def _paint_dot(frame: np.ndarray, cx: int, cy: int, radius: int = 5,
|
||
bgr: tuple[int, int, int] = (0, 255, 0)) -> None:
|
||
# radius ≥ 5 keeps blob above min_cluster_px after 2× erosion by 3x3 kernel.
|
||
cv2.circle(frame, (cx, cy), radius, bgr, -1)
|
||
|
||
|
||
def test_find_top_dots_happy_three_blobs_sorted_desc():
|
||
frame = _make_frame()
|
||
_paint_dot(frame, 10, 15)
|
||
_paint_dot(frame, 30, 15)
|
||
_paint_dot(frame, 50, 15)
|
||
result = find_top_dots(frame, BG_RGB, n=3)
|
||
assert len(result) == 3
|
||
# Sorted by right edge descending → x=50 first, then 30, then 10.
|
||
xs = [pt[0] for pt in result]
|
||
assert xs[0] > xs[1] > xs[2]
|
||
assert xs[0] >= 48 and xs[2] <= 12 # allow ±2px wobble from centroid
|
||
|
||
|
||
def test_find_top_dots_zero_blobs_returns_empty():
|
||
frame = _make_frame()
|
||
assert find_top_dots(frame, BG_RGB, n=3) == []
|
||
|
||
|
||
def test_find_top_dots_one_blob_n3_returns_one():
|
||
frame = _make_frame()
|
||
_paint_dot(frame, 25, 15)
|
||
result = find_top_dots(frame, BG_RGB, n=3)
|
||
assert len(result) == 1
|
||
cx, _cy = result[0]
|
||
assert 23 <= cx <= 27
|
||
|
||
|
||
def test_find_top_dots_fused_wide_blob_anchors_to_right_edge():
|
||
frame = _make_frame()
|
||
# Paint a wide stripe (width > 12) — simulates fused anti-aliased dots.
|
||
cv2.rectangle(frame, (20, 13), (60, 17), (0, 255, 0), -1)
|
||
result = find_top_dots(frame, BG_RGB, n=1)
|
||
assert len(result) == 1
|
||
cx, _cy = result[0]
|
||
# Anchor should be near right edge (~58 = 60-2), not centroid (~40).
|
||
assert cx >= 55
|
||
|
||
|
||
def test_find_top_dots_tie_break_by_y_ascending():
|
||
frame = _make_frame(h=40)
|
||
# Two dots at same right-edge x=50, different y.
|
||
_paint_dot(frame, 50, 10) # upper — should come first
|
||
_paint_dot(frame, 50, 30) # lower
|
||
result = find_top_dots(frame, BG_RGB, n=2)
|
||
assert len(result) == 2
|
||
# Tie-break: smaller y first.
|
||
assert result[0][1] < result[1][1]
|