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>
174 lines
5.8 KiB
Python
174 lines
5.8 KiB
Python
r"""Reproduce /ss and /resume Telegram screenshot pipelines for offline inspection.
|
|
|
|
Brings the `m2d` window to front (Win32 trick — same as live loop), captures via mss
|
|
using the active config's chart_window_region, then runs the EXACT same annotators
|
|
that /ss and /resume use:
|
|
- _save_inspect_frame → /ss path (top-3 dots per detected strip + caption)
|
|
- _save_annotated_frame → /resume path (cyan rect on dot_roi/sub_roi)
|
|
|
|
Outputs are saved under logs/repro/ alongside a JSON summary of detections.
|
|
|
|
Usage:
|
|
.\.venv\Scripts\python.exe scripts\repro_ss_resume.py
|
|
.\.venv\Scripts\python.exe scripts\repro_ss_resume.py --no-focus
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import sys
|
|
import time
|
|
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 detect_strips # noqa: E402
|
|
from atm.main import ( # noqa: E402
|
|
_focus_window_by_title,
|
|
_format_inspect_caption,
|
|
_save_annotated_frame,
|
|
_save_inspect_frame,
|
|
)
|
|
from atm.vision import crop_roi # noqa: E402
|
|
|
|
|
|
def _capture_via_region(cfg) -> np.ndarray | None:
|
|
import mss # type: ignore[import-untyped]
|
|
|
|
reg = cfg.chart_window_region
|
|
if reg is None:
|
|
# Fallback: grab full primary monitor
|
|
with mss.mss() as sct:
|
|
mon = sct.monitors[1]
|
|
img = sct.grab(mon)
|
|
return cv2.cvtColor(np.array(img), cv2.COLOR_BGRA2BGR)
|
|
with mss.mss() as sct:
|
|
mon = {"top": reg.y, "left": reg.x, "width": reg.w, "height": reg.h}
|
|
img = sct.grab(mon)
|
|
return cv2.cvtColor(np.array(img), cv2.COLOR_BGRA2BGR)
|
|
|
|
|
|
def main() -> int:
|
|
p = argparse.ArgumentParser()
|
|
p.add_argument("--no-focus", action="store_true", help="Skip Win32 focus call.")
|
|
p.add_argument("--delay", type=float, default=0.5, help="Seconds to sleep after focus.")
|
|
p.add_argument(
|
|
"--out",
|
|
type=Path,
|
|
default=ROOT / "logs" / "repro",
|
|
help="Output directory for annotated frames + JSON.",
|
|
)
|
|
args = p.parse_args()
|
|
|
|
cfg = Config.load_current(ROOT / "configs")
|
|
args.out.mkdir(parents=True, exist_ok=True)
|
|
|
|
# 1) Focus
|
|
if not args.no_focus and cfg.window_title:
|
|
title = _focus_window_by_title(cfg.window_title)
|
|
print(f"[focus] needle={cfg.window_title!r} -> {title!r}")
|
|
if args.delay > 0:
|
|
time.sleep(args.delay)
|
|
|
|
# 2) Capture
|
|
frame = _capture_via_region(cfg)
|
|
if frame is None:
|
|
print("[capture] FAILED — no frame")
|
|
return 1
|
|
print(f"[capture] frame shape={frame.shape}")
|
|
|
|
now = time.time()
|
|
ts_str = time.strftime("%Y%m%d_%H%M%S")
|
|
|
|
# Save raw too so we can hand-inspect what mss actually grabbed
|
|
raw_path = args.out / f"{ts_str}_raw.png"
|
|
cv2.imwrite(str(raw_path), frame)
|
|
print(f"[raw] {raw_path}")
|
|
|
|
# 3) Detect strips (same logic as live multi-chart split)
|
|
strip_h = cfg.dot_roi.h
|
|
min_strip_px = max(150, strip_h * 8)
|
|
min_gap_px = max(20, int(strip_h * 0.8))
|
|
palette = {
|
|
name: (spec.rgb, spec.tolerance)
|
|
for name, spec in cfg.colors.items()
|
|
if name != "background"
|
|
}
|
|
full_dot_crop = crop_roi(frame, cfg.dot_roi)
|
|
raw_strips = detect_strips(full_dot_crop, palette, min_gap_px, min_strip_px)
|
|
# Translate back to absolute frame coords
|
|
from atm.config import ROI # noqa: E402
|
|
|
|
strips = [
|
|
ROI(x=cfg.dot_roi.x + r.x, y=cfg.dot_roi.y + r.y, w=r.w, h=r.h)
|
|
for r in raw_strips
|
|
]
|
|
print(f"[strips] dot_roi={cfg.dot_roi} detected={len(strips)} strips")
|
|
for i, s in enumerate(strips):
|
|
print(f" strip[{i}] x={s.x} y={s.y} w={s.w} h={s.h}")
|
|
|
|
# Also dump a copy of the full dot_roi crop for visual sanity-check
|
|
crop_path = args.out / f"{ts_str}_dot_roi_crop.png"
|
|
cv2.imwrite(str(crop_path), full_dot_crop)
|
|
print(f"[crop] {crop_path}")
|
|
|
|
# 4) /ss inspect-annotate (top-3 per strip, FSM-pick markers)
|
|
inspect_path, detections = _save_inspect_frame(
|
|
frame, cfg, args.out, now, audit=None,
|
|
strips=strips if strips else None,
|
|
)
|
|
caption = _format_inspect_caption(detections, cfg)
|
|
print(f"[ss] {inspect_path}")
|
|
print(f"[ss] caption:\n{caption}")
|
|
|
|
# 5) /resume annotate (cyan rect on dot_roi or first strip)
|
|
roi_for_resume = strips[0] if strips else cfg.dot_roi
|
|
resume_path = _save_annotated_frame(
|
|
frame, cfg, args.out, "resume_repro", now, audit=None, roi=roi_for_resume,
|
|
)
|
|
print(f"[resume] {resume_path}")
|
|
|
|
# 6) JSON summary
|
|
summary = {
|
|
"ts": ts_str,
|
|
"frame_shape": list(frame.shape),
|
|
"dot_roi": {"x": cfg.dot_roi.x, "y": cfg.dot_roi.y, "w": cfg.dot_roi.w, "h": cfg.dot_roi.h},
|
|
"strips": [
|
|
{"x": s.x, "y": s.y, "w": s.w, "h": s.h} for s in strips
|
|
],
|
|
"detections": [
|
|
{
|
|
"strip_idx": d["strip_idx"],
|
|
"idx": d["idx"],
|
|
"name": d["name"],
|
|
"rgb": list(d["rgb"]),
|
|
"distance": round(float(d["distance"]), 3),
|
|
"confidence": round(float(d["confidence"]), 3),
|
|
"pos_abs": list(d["pos_abs"]),
|
|
}
|
|
for d in detections
|
|
],
|
|
"files": {
|
|
"raw": str(raw_path),
|
|
"dot_roi_crop": str(crop_path),
|
|
"inspect": str(inspect_path) if inspect_path else None,
|
|
"resume": str(resume_path) if resume_path else None,
|
|
},
|
|
"ss_caption": caption,
|
|
}
|
|
json_path = args.out / f"{ts_str}_summary.json"
|
|
json_path.write_text(json.dumps(summary, indent=2), encoding="utf-8")
|
|
print(f"[json] {json_path}")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|