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,133 @@
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())

162
scripts/diag_strip_fixes.py Normal file
View File

@@ -0,0 +1,162 @@
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())

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())

173
scripts/repro_ss_resume.py Normal file
View File

@@ -0,0 +1,173 @@
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())