165 lines
5.8 KiB
Python
165 lines
5.8 KiB
Python
#!/usr/bin/env python3
|
|
"""Live scene detection latency benchmark (M4).
|
|
|
|
Measures time from a triggered visual change on the sender to a new JPEG
|
|
appearing in the receiver's frames/ directory.
|
|
|
|
Usage (run on receiver, sender accessible via SSH):
|
|
python ctrl/bench_delay.py --session-dir data/sessions/CURRENT --sender mariano@sender
|
|
python ctrl/bench_delay.py --frames-dir data/sessions/CURRENT/frames --sender mariano@sender
|
|
|
|
How it works:
|
|
1. Records the current frame count in frames/index.json
|
|
2. SSH to sender, triggers a visual change (xdotool workspace switch)
|
|
3. Polls frames/index.json for a new entry (or watches via mtime)
|
|
4. Measures wall-clock difference = scene detection latency
|
|
|
|
For repeated measurements, use --repeat N with --interval S between triggers.
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import logging
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
|
|
log = logging.getLogger("bench_delay")
|
|
|
|
|
|
def get_frame_count(frames_dir: Path) -> int:
|
|
index = frames_dir / "index.json"
|
|
if not index.exists():
|
|
return 0
|
|
try:
|
|
return len(json.loads(index.read_text()))
|
|
except (json.JSONDecodeError, ValueError):
|
|
return 0
|
|
|
|
|
|
def get_latest_frame_mtime(frames_dir: Path) -> float:
|
|
index = frames_dir / "index.json"
|
|
if not index.exists():
|
|
return 0.0
|
|
return index.stat().st_mtime
|
|
|
|
|
|
def trigger_scene_change(sender: str, method: str = "workspace") -> float:
|
|
"""Trigger a visual change on the sender. Returns wall-clock time of trigger."""
|
|
if method == "workspace":
|
|
# xdotool switch workspace — causes a full-screen visual change
|
|
cmd = ["ssh", sender, "DISPLAY=:0 xdotool key super+Right"]
|
|
elif method == "color":
|
|
# Flash a fullscreen color using xterm (more dramatic change)
|
|
cmd = ["ssh", sender,
|
|
"DISPLAY=:0 bash -c 'xterm -fullscreen -bg red -e sleep 0.5 &'"]
|
|
else:
|
|
log.error("Unknown trigger method: %s", method)
|
|
sys.exit(1)
|
|
|
|
t = time.monotonic()
|
|
wall = time.time()
|
|
try:
|
|
subprocess.run(cmd, timeout=5, capture_output=True)
|
|
except subprocess.TimeoutExpired:
|
|
log.warning("SSH trigger timed out")
|
|
return wall
|
|
|
|
|
|
def wait_for_new_frame(frames_dir: Path, initial_count: int,
|
|
timeout: float = 15.0, poll_interval: float = 0.1) -> float | None:
|
|
"""Wait for a new frame to appear. Returns wall-clock time when detected, or None."""
|
|
deadline = time.monotonic() + timeout
|
|
while time.monotonic() < deadline:
|
|
count = get_frame_count(frames_dir)
|
|
if count > initial_count:
|
|
return time.time()
|
|
time.sleep(poll_interval)
|
|
return None
|
|
|
|
|
|
def run_measurement(frames_dir: Path, sender: str, method: str) -> dict:
|
|
initial_count = get_frame_count(frames_dir)
|
|
trigger_wall = trigger_scene_change(sender, method)
|
|
detected_wall = wait_for_new_frame(frames_dir, initial_count)
|
|
|
|
if detected_wall is None:
|
|
return {"trigger_wall": trigger_wall, "latency_s": None, "timed_out": True}
|
|
|
|
latency = detected_wall - trigger_wall
|
|
return {
|
|
"trigger_wall": trigger_wall,
|
|
"detected_wall": detected_wall,
|
|
"latency_s": round(latency, 3),
|
|
"timed_out": False,
|
|
}
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description="Scene detection latency benchmark")
|
|
parser.add_argument("--frames-dir", type=Path, help="Path to frames/ directory")
|
|
parser.add_argument("--session-dir", type=Path, help="Path to session directory")
|
|
parser.add_argument("--sender", required=True, help="SSH target for sender (user@host)")
|
|
parser.add_argument("--method", default="workspace", choices=["workspace", "color"],
|
|
help="How to trigger visual change")
|
|
parser.add_argument("--repeat", type=int, default=3, help="Number of measurements")
|
|
parser.add_argument("--interval", type=float, default=5.0, help="Seconds between triggers")
|
|
parser.add_argument("--json", action="store_true", help="Output JSON")
|
|
args = parser.parse_args()
|
|
|
|
logging.basicConfig(
|
|
level=logging.INFO,
|
|
format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
|
|
datefmt="%H:%M:%S",
|
|
)
|
|
|
|
if args.session_dir:
|
|
frames_dir = args.session_dir / "frames"
|
|
elif args.frames_dir:
|
|
frames_dir = args.frames_dir
|
|
else:
|
|
parser.error("Provide --frames-dir or --session-dir")
|
|
return
|
|
|
|
if not frames_dir.exists():
|
|
log.error("Frames dir not found: %s", frames_dir)
|
|
sys.exit(1)
|
|
|
|
results = []
|
|
for i in range(args.repeat):
|
|
if i > 0:
|
|
time.sleep(args.interval)
|
|
log.info("Trigger %d/%d...", i + 1, args.repeat)
|
|
r = run_measurement(frames_dir, args.sender, args.method)
|
|
if r["timed_out"]:
|
|
log.warning("TIMEOUT (no frame in 15s)")
|
|
else:
|
|
log.info(" latency: %ss", r["latency_s"])
|
|
results.append(r)
|
|
|
|
latencies = [r["latency_s"] for r in results if r["latency_s"] is not None]
|
|
|
|
if args.json:
|
|
print(json.dumps({"measurements": results, "summary": {
|
|
"count": len(latencies),
|
|
"avg_s": round(sum(latencies) / len(latencies), 3) if latencies else None,
|
|
"min_s": round(min(latencies), 3) if latencies else None,
|
|
"max_s": round(max(latencies), 3) if latencies else None,
|
|
"timeouts": sum(1 for r in results if r["timed_out"]),
|
|
}}, indent=2))
|
|
else:
|
|
log.info("M4 Scene detection latency:")
|
|
if latencies:
|
|
log.info(" avg: %.1fs", sum(latencies) / len(latencies))
|
|
log.info(" min: %.1fs", min(latencies))
|
|
log.info(" max: %.1fs", max(latencies))
|
|
timeouts = sum(1 for r in results if r["timed_out"])
|
|
if timeouts:
|
|
log.warning(" timeouts: %d/%d", timeouts, len(results))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|