Files
atm/scripts/diag_strip_detection.py
Marius Mutu c950a5a699 feat(multi-chart): refactor _run_multi_tick + fix alert spam pe oscilație strip
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>
2026-05-05 17:59:18 +03:00

134 lines
5.2 KiB
Python

r"""Diagnose why detect_strips finds 1 strip when there are 2 TS windows.
Reuses the most recent raw capture under logs/repro/. For each strip detected,
prints the connected-components vivid mask, gaps, and the contiguous run lengths
so we can see exactly where the threshold is killing the second strip.
"""
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 # noqa: E402
from atm.layout import VIVID_COLORS, detect_strips # noqa: E402
from atm.vision import crop_roi, find_top_dots, classify_pixel, pixel_rgb # noqa: E402
def _latest_raw() -> Path:
candidates = sorted((ROOT / "logs" / "repro").glob("*_raw.png"))
if not candidates:
raise SystemExit("No *_raw.png in logs/repro — run scripts/repro_ss_resume.py first.")
return candidates[-1]
def main() -> int:
cfg = Config.load_current(ROOT / "configs")
raw_path = _latest_raw()
print(f"Using raw frame: {raw_path}")
frame = cv2.imread(str(raw_path), cv2.IMREAD_COLOR)
if frame is None:
raise SystemExit(f"Could not read {raw_path}")
palette = {
name: (spec.rgb, spec.tolerance)
for name, spec in cfg.colors.items()
if name != "background"
}
full_crop = crop_roi(frame, cfg.dot_roi)
h, w = full_crop.shape[:2]
print(f"dot_roi crop shape: {h}x{w} (cfg.dot_roi={cfg.dot_roi})")
# 1) Build the vivid mask used by detect_strips
mask = np.zeros((h, w), dtype=np.uint8)
img_f = full_crop.astype(np.float32)
per_color_pixels = {}
for name in VIVID_COLORS:
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)
m = (diff < tol).astype(np.uint8)
per_color_pixels[name] = int(m.sum())
mask |= m
print("\nVIVID_COLORS pixel counts in dot_roi crop (pre-morphology):")
for n, c in per_color_pixels.items():
print(f" {n:12s} {c:>7d} px")
print(f" total mask: {int(mask.sum()):>7d} px")
# 2) Column projection: which x columns have any vivid pixel?
col_has = mask.any(axis=0).astype(np.uint8)
runs = []
i = 0
while i < w:
if col_has[i] == 0:
i += 1
continue
j = i
while j < w and col_has[j] == 1:
j += 1
runs.append((i, j - 1, j - i))
i = j
print(f"\nContiguous vivid-column runs (raw, no morphology): {len(runs)}")
for x0, x1, run_w in runs[:25]:
print(f" x={x0:>4d}..{x1:>4d} width={run_w}")
if len(runs) > 25:
print(f" ... +{len(runs) - 25} more")
# 3) Apply same morphology + connected components as detect_strips
strip_h = cfg.dot_roi.h
min_strip_px = max(150, strip_h * 8)
min_gap_px = max(20, int(strip_h * 0.8))
kw = max(3, min_gap_px // 2)
print(f"\ndetect_strips params: min_strip_px={min_strip_px} min_gap_px={min_gap_px} morphology kw={kw}")
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)
print(f"\nConnected components after CLOSE (kw={kw}): n={n_labels - 1} (excluding bg)")
for i in range(1, n_labels):
x = int(stats[i, cv2.CC_STAT_LEFT])
y = int(stats[i, cv2.CC_STAT_TOP])
ww = int(stats[i, cv2.CC_STAT_WIDTH])
hh = int(stats[i, cv2.CC_STAT_HEIGHT])
passes = "PASS" if ww >= min_strip_px else "drop"
print(f" [{i:>2d}] x={x:>4d} y={y:>3d} w={ww:>4d} h={hh:>3d} -> {passes}")
strips = detect_strips(full_crop, palette, min_gap_px, min_strip_px)
print(f"\ndetect_strips() final result: {len(strips)} strip(s)")
for i, s in enumerate(strips):
print(f" strip[{i}] x={s.x} y={s.y} w={s.w} h={s.h}")
# 4) Run find_top_dots on the FULL dot_roi (single-chart fallback path used
# by /ss when ctx.charts < 2). This is what users see when the layout
# detector hasn't promoted the layout to multi-chart yet.
bg_rgb = cfg.colors["background"].rgb if "background" in cfg.colors else (18, 18, 18)
bg_tol = cfg.colors["background"].tolerance if "background" in cfg.colors else 15.0
full_dots = find_top_dots(full_crop, bg_rgb, bg_tol, n=3)
print(f"\nfind_top_dots on FULL dot_roi (n=3, single-chart fallback path):")
for i, (cx, cy) in enumerate(full_dots):
rgb = pixel_rgb(full_crop, cx, cy)
m = classify_pixel(rgb, palette)
print(f" c{i + 1}: pos=({cx + cfg.dot_roi.x},{cy + cfg.dot_roi.y}) rgb={rgb} -> {m.name} d={m.distance:.1f}")
# 5) Save the binary mask + closed mask for visual inspection
out_dir = ROOT / "logs" / "repro"
cv2.imwrite(str(out_dir / "diag_mask_raw.png"), (mask * 255).astype(np.uint8))
cv2.imwrite(str(out_dir / "diag_mask_closed.png"), (closed * 255).astype(np.uint8))
print(f"\nWrote diag_mask_raw.png + diag_mask_closed.png to {out_dir}")
return 0
if __name__ == "__main__":
raise SystemExit(main())