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>
163 lines
6.3 KiB
Python
163 lines
6.3 KiB
Python
r"""Verify the two proposed fixes for detect_strips on the latest 2-window capture.
|
|
|
|
Fix A: include gray in VIVID_COLORS mask (cheapest change).
|
|
Fix B: use non-background mask (any pixel where diff(bg) > bg_tol).
|
|
|
|
Reuses the most recent raw capture in logs/repro/.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
SRC = ROOT / "src"
|
|
if str(SRC) not in sys.path:
|
|
sys.path.insert(0, str(SRC))
|
|
|
|
import cv2 # noqa: E402
|
|
import numpy as np # noqa: E402
|
|
|
|
from atm.config import Config, ROI # noqa: E402
|
|
from atm.vision import crop_roi # noqa: E402
|
|
|
|
|
|
def _detect_strips_with_palette(
|
|
full_dot_crop, palette, color_names, min_gap_px, min_strip_px,
|
|
):
|
|
"""Same body as layout.detect_strips but with selectable color set."""
|
|
h, w = full_dot_crop.shape[:2]
|
|
mask = np.zeros((h, w), dtype=np.uint8)
|
|
img_f = full_dot_crop.astype(np.float32)
|
|
for name in color_names:
|
|
if name not in palette:
|
|
continue
|
|
rgb, tol = palette[name]
|
|
bgr = np.array([rgb[2], rgb[1], rgb[0]], dtype=np.float32)
|
|
diff = np.linalg.norm(img_f - bgr, axis=2)
|
|
mask |= (diff < tol).astype(np.uint8)
|
|
kw = max(3, min_gap_px // 2)
|
|
kernel = np.ones((1, kw), dtype=np.uint8)
|
|
closed = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel)
|
|
n_labels, _labels, stats, _centroids = cv2.connectedComponentsWithStats(closed, connectivity=8)
|
|
out = []
|
|
for i in range(1, n_labels):
|
|
x = int(stats[i, cv2.CC_STAT_LEFT])
|
|
ww = int(stats[i, cv2.CC_STAT_WIDTH])
|
|
if ww < min_strip_px:
|
|
continue
|
|
out.append(ROI(x=x, y=0, w=ww, h=h))
|
|
out.sort(key=lambda r: r.x)
|
|
return out, mask, closed
|
|
|
|
|
|
def _detect_strips_non_bg(full_dot_crop, bg_rgb, bg_tol, min_gap_px, min_strip_px):
|
|
"""Fix B: any pixel different from background → strip mask."""
|
|
h, w = full_dot_crop.shape[:2]
|
|
bgr_bg = np.array([bg_rgb[2], bg_rgb[1], bg_rgb[0]], dtype=np.float32)
|
|
diff = np.linalg.norm(full_dot_crop.astype(np.float32) - bgr_bg, axis=2)
|
|
mask = (diff > bg_tol).astype(np.uint8)
|
|
kw = max(3, min_gap_px // 2)
|
|
kernel = np.ones((1, kw), dtype=np.uint8)
|
|
closed = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel)
|
|
n_labels, _labels, stats, _centroids = cv2.connectedComponentsWithStats(closed, connectivity=8)
|
|
out = []
|
|
for i in range(1, n_labels):
|
|
x = int(stats[i, cv2.CC_STAT_LEFT])
|
|
ww = int(stats[i, cv2.CC_STAT_WIDTH])
|
|
if ww < min_strip_px:
|
|
continue
|
|
out.append(ROI(x=x, y=0, w=ww, h=h))
|
|
out.sort(key=lambda r: r.x)
|
|
return out, mask, closed
|
|
|
|
|
|
def _annotate(frame, strips_abs, label, out_dir):
|
|
annotated = frame.copy()
|
|
for r in strips_abs:
|
|
cv2.rectangle(annotated, (r.x, r.y), (r.x + r.w, r.y + r.h), (0, 255, 255), 2)
|
|
p = out_dir / f"diag_fix_{label}.png"
|
|
cv2.imwrite(str(p), annotated)
|
|
return p
|
|
|
|
|
|
def main() -> int:
|
|
cfg = Config.load_current(ROOT / "configs")
|
|
raws = sorted(p for p in (ROOT / "logs" / "repro").glob("*_raw.png") if p.name[0].isdigit())
|
|
if not raws:
|
|
raise SystemExit("Run scripts/repro_ss_resume.py first.")
|
|
raw_path = raws[-1]
|
|
print(f"Using: {raw_path}")
|
|
frame = cv2.imread(str(raw_path), cv2.IMREAD_COLOR)
|
|
|
|
palette = {n: (s.rgb, s.tolerance) for n, s in cfg.colors.items() if n != "background"}
|
|
bg_rgb = cfg.colors["background"].rgb
|
|
bg_tol = cfg.colors["background"].tolerance
|
|
full_crop = crop_roi(frame, cfg.dot_roi)
|
|
h, w = full_crop.shape[:2]
|
|
strip_h = cfg.dot_roi.h
|
|
min_strip_px = max(150, strip_h * 8)
|
|
min_gap_px = max(20, int(strip_h * 0.8))
|
|
print(f"params: min_strip_px={min_strip_px} min_gap_px={min_gap_px} crop={w}x{h}")
|
|
|
|
out_dir = ROOT / "logs" / "repro"
|
|
|
|
# Baseline (current code: vivid only, no gray)
|
|
vivid_only = ("turquoise", "yellow", "dark_green", "dark_red", "light_green", "light_red")
|
|
s0, m0, c0 = _detect_strips_with_palette(full_crop, palette, vivid_only, min_gap_px, min_strip_px)
|
|
print(f"\n[BASELINE vivid-only ] strips={len(s0)}")
|
|
for i, r in enumerate(s0):
|
|
print(f" [{i}] x={r.x:>4d} w={r.w:>4d}")
|
|
cv2.imwrite(str(out_dir / "diag_fix_BASELINE_mask_closed.png"), (c0 * 255).astype(np.uint8))
|
|
_annotate(frame, [ROI(x=cfg.dot_roi.x + r.x, y=cfg.dot_roi.y, w=r.w, h=r.h) for r in s0],
|
|
"BASELINE", out_dir)
|
|
|
|
# Fix A: include gray
|
|
fixA_palette = vivid_only + ("gray",)
|
|
sA, mA, cA = _detect_strips_with_palette(full_crop, palette, fixA_palette, min_gap_px, min_strip_px)
|
|
print(f"\n[FIX A vivid+gray ] strips={len(sA)}")
|
|
for i, r in enumerate(sA):
|
|
print(f" [{i}] x={r.x:>4d} w={r.w:>4d}")
|
|
cv2.imwrite(str(out_dir / "diag_fix_A_mask_closed.png"), (cA * 255).astype(np.uint8))
|
|
_annotate(frame, [ROI(x=cfg.dot_roi.x + r.x, y=cfg.dot_roi.y, w=r.w, h=r.h) for r in sA],
|
|
"A_vivid_plus_gray", out_dir)
|
|
|
|
# Fix B: any non-background
|
|
sB, mB, cB = _detect_strips_non_bg(full_crop, bg_rgb, bg_tol, min_gap_px, min_strip_px)
|
|
print(f"\n[FIX B non-bg ] strips={len(sB)}")
|
|
for i, r in enumerate(sB):
|
|
print(f" [{i}] x={r.x:>4d} w={r.w:>4d}")
|
|
cv2.imwrite(str(out_dir / "diag_fix_B_mask_closed.png"), (cB * 255).astype(np.uint8))
|
|
_annotate(frame, [ROI(x=cfg.dot_roi.x + r.x, y=cfg.dot_roi.y, w=r.w, h=r.h) for r in sB],
|
|
"B_non_background", out_dir)
|
|
|
|
# Sanity: where is the divider between the two TS windows?
|
|
# Project non-bg mask onto x; long zero-runs reveal the gap.
|
|
bgr_bg = np.array([bg_rgb[2], bg_rgb[1], bg_rgb[0]], dtype=np.float32)
|
|
diff = np.linalg.norm(full_crop.astype(np.float32) - bgr_bg, axis=2)
|
|
nonbg = (diff > bg_tol).astype(np.uint8)
|
|
col_any = nonbg.any(axis=0).astype(np.uint8)
|
|
# Find longest 0-run
|
|
longest = (0, 0, 0) # (length, x0, x1)
|
|
i = 0
|
|
while i < w:
|
|
if col_any[i] == 1:
|
|
i += 1
|
|
continue
|
|
j = i
|
|
while j < w and col_any[j] == 0:
|
|
j += 1
|
|
run = j - i
|
|
if run > longest[0]:
|
|
longest = (run, i, j - 1)
|
|
i = j
|
|
print(f"\nLongest empty (background-only) horizontal stretch in dot_roi: "
|
|
f"{longest[0]}px at x={longest[1]}..{longest[2]} "
|
|
f"(this is where the window divider sits)")
|
|
print(f"\nWrote diag_fix_*.png to {out_dir}")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|