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>
This commit is contained in:
2026-05-05 17:59:18 +03:00
parent 8a1be979fe
commit c950a5a699
14 changed files with 1952 additions and 255 deletions

View File

@@ -0,0 +1,124 @@
r"""Inspect ATM strip pixels without relying on shell pipelines.
Usage:
.\.venv\Scripts\python.exe scripts\inspect_image_pixels.py 6033117943853423831.jpg
.\.venv\Scripts\python.exe scripts\inspect_image_pixels.py image.jpg --point 1780 725
The script intentionally parses only the config fields needed for pixel inspection,
so it does not require Discord/Telegram secrets to be valid.
"""
from __future__ import annotations
import argparse
import json
import sys
import tomllib
from pathlib import Path
import cv2
ROOT = Path(__file__).resolve().parents[1]
SRC = ROOT / "src"
if str(SRC) not in sys.path:
sys.path.insert(0, str(SRC))
from atm.config import ROI # noqa: E402
from atm.vision import classify_pixel, crop_roi, find_top_dots, pixel_rgb # noqa: E402
def _load_probe_config(path: Path) -> dict:
data = tomllib.loads(path.read_text(encoding="utf-8"))
colors = {
name: (tuple(int(c) for c in spec["rgb"]), float(spec["tolerance"]))
for name, spec in data["colors"].items()
}
background = colors.pop("background", ((18, 18, 18), 15.0))
return {
"dot_roi": ROI(**data["dot_roi"]),
"colors": colors,
"background_rgb": background[0],
"background_tol": background[1],
}
def _as_jsonable_match(match) -> dict:
return {
"name": match.name,
"distance": round(float(match.distance), 3),
"confidence": round(float(match.confidence), 3),
}
def main() -> int:
parser = argparse.ArgumentParser(description="Inspect ATM strip pixels in a JPG/PNG frame.")
parser.add_argument("image", type=Path, help="Frame image path.")
parser.add_argument(
"--config",
type=Path,
default=ROOT / "configs" / "2026-04-21-recalib.toml",
help="ATM TOML config path.",
)
parser.add_argument("--top", type=int, default=3, help="Number of rightmost dots to report.")
parser.add_argument(
"--point",
nargs=2,
type=int,
metavar=("X", "Y"),
help="Optional absolute pixel coordinate to sample.",
)
parser.add_argument("--box", type=int, default=3, help="Sampling radius for mean RGB.")
args = parser.parse_args()
frame = cv2.imread(str(args.image), cv2.IMREAD_COLOR)
if frame is None:
raise SystemExit(f"Could not read image: {args.image}")
probe = _load_probe_config(args.config)
roi = probe["dot_roi"]
roi_img = crop_roi(frame, roi)
dots = find_top_dots(
roi_img,
bg_rgb=probe["background_rgb"],
bg_tol=probe["background_tol"],
n=args.top,
)
result = {
"image": str(args.image),
"image_size": {"w": int(frame.shape[1]), "h": int(frame.shape[0])},
"config": str(args.config),
"dot_roi": {"x": roi.x, "y": roi.y, "w": roi.w, "h": roi.h},
"dots": [],
}
for x, y in dots:
rgb = pixel_rgb(roi_img, x, y, box=args.box)
match = classify_pixel(rgb, probe["colors"])
result["dots"].append(
{
"roi_xy": [int(x), int(y)],
"abs_xy": [int(roi.x + x), int(roi.y + y)],
"rgb": list(rgb),
"match": _as_jsonable_match(match),
}
)
if args.point:
px, py = args.point
if not (roi.x <= px < roi.x + roi.w and roi.y <= py < roi.y + roi.h):
raise SystemExit(f"Point {px},{py} is outside dot_roi")
rx, ry = px - roi.x, py - roi.y
rgb = pixel_rgb(roi_img, rx, ry, box=args.box)
result["point"] = {
"roi_xy": [rx, ry],
"abs_xy": [px, py],
"rgb": list(rgb),
"match": _as_jsonable_match(classify_pixel(rgb, probe["colors"])),
}
print(json.dumps(result, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())