wechat_ai/wechat_vision/ffmpeg_realtime_detect.py

899 lines
32 KiB
Python

import argparse
import json
import signal
import subprocess
import sys
import threading
import time
import webbrowser
from datetime import datetime, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from io import BytesIO
from pathlib import Path
import numpy as np
import onnxruntime as ort
from PIL import Image, ImageDraw
BASE_DIR = Path(__file__).resolve().parent
MODEL_PATH = BASE_DIR / "best.onnx"
DEBUG_DIR = BASE_DIR / "ouptsw" / "monitor"
VISUALIZE_DIR = BASE_DIR / "ouptsw" / "realtime_view"
CLASS_NAMES = {
0: "input_box",
}
IOU_THRESHOLD = 0.5
class StreamState:
def __init__(self):
self.condition = threading.Condition()
self.jpeg = None
self.event = None
self.sequence = 0
def update(self, jpeg, event):
with self.condition:
self.jpeg = jpeg
self.event = event
self.sequence += 1
self.condition.notify_all()
def snapshot(self):
with self.condition:
return self.jpeg, self.event, self.sequence
def parse_args():
parser = argparse.ArgumentParser(
description="Realtime FFmpeg screen capture + ONNX detection demo."
)
parser.add_argument("--model", default=str(MODEL_PATH), help="ONNX model path")
parser.add_argument(
"--input",
default="3:none",
help="FFmpeg avfoundation input, e.g. '3:none'. Use --list-devices to inspect.",
)
parser.add_argument("--fps", type=float, default=2.0, help="capture FPS")
parser.add_argument("--confidence", type=float, default=0.1, help="minimum score")
parser.add_argument("--target-class", type=int, default=0, help="target class id")
parser.add_argument(
"--max-frames",
type=int,
default=0,
help="stop after N frames; 0 means run until interrupted",
)
parser.add_argument(
"--emit-empty",
action="store_true",
help="emit no_detection events when target is not found",
)
parser.add_argument(
"--debug-dir",
default="",
help="save annotated frames into this directory",
)
parser.add_argument(
"--debug-every",
type=int,
default=0,
help="save one debug image every N frames; 0 disables debug images",
)
parser.add_argument(
"--visualize-dir",
default="",
help="write latest.jpg/latest.json/index.html for browser visualization",
)
parser.add_argument(
"--visualize-every",
type=int,
default=1,
help="update visualization every N frames when --visualize-dir is enabled",
)
parser.add_argument(
"--open-visualizer",
action="store_true",
help="open the visualization HTML in the default browser",
)
parser.add_argument(
"--serve",
action="store_true",
help="serve a realtime MJPEG stream and dashboard over HTTP",
)
parser.add_argument("--host", default="127.0.0.1", help="HTTP server host")
parser.add_argument("--port", type=int, default=8765, help="HTTP server port")
parser.add_argument(
"--display",
action="store_true",
help="open an ffplay window with the annotated realtime video",
)
parser.add_argument(
"--display-title",
default="WeChat Vision ONNX Preview",
help="ffplay window title when --display is enabled",
)
parser.add_argument(
"--display-width",
type=int,
default=720,
help="ffplay window width when --display is enabled",
)
parser.add_argument(
"--display-height",
type=int,
default=450,
help="ffplay window height when --display is enabled",
)
parser.add_argument(
"--display-left",
type=int,
default=0,
help="ffplay window left position when --display is enabled",
)
parser.add_argument(
"--display-top",
type=int,
default=0,
help="ffplay window top position when --display is enabled",
)
parser.add_argument(
"--list-devices",
action="store_true",
help="list FFmpeg avfoundation devices and exit",
)
return parser.parse_args()
def log_error(message):
print(message, file=sys.stderr, flush=True)
def utc_now():
return datetime.now(timezone.utc).isoformat()
def input_shape(input_meta):
return [dim if isinstance(dim, int) else 1 for dim in input_meta.shape]
def preprocess(image, shape):
_, _, height, width = shape
resized = image.convert("RGB").resize((width, height))
array = np.asarray(resized, dtype=np.float32) / 255.0
return np.transpose(array, (2, 0, 1))[None]
def iou(box, boxes):
x1 = np.maximum(box[0], boxes[:, 0])
y1 = np.maximum(box[1], boxes[:, 1])
x2 = np.minimum(box[2], boxes[:, 2])
y2 = np.minimum(box[3], boxes[:, 3])
intersection = np.maximum(0, x2 - x1) * np.maximum(0, y2 - y1)
box_area = np.maximum(0, box[2] - box[0]) * np.maximum(0, box[3] - box[1])
boxes_area = np.maximum(0, boxes[:, 2] - boxes[:, 0]) * np.maximum(
0,
boxes[:, 3] - boxes[:, 1],
)
return intersection / np.maximum(box_area + boxes_area - intersection, 1e-6)
def nms(detections, confidence):
detections = detections[detections[:, 4] >= confidence]
if len(detections) == 0:
return detections
detections = detections[np.argsort(detections[:, 4])[::-1]]
kept = []
while len(detections) > 0:
best = detections[0]
kept.append(best)
if len(detections) == 1:
break
detections = detections[1:][iou(best[:4], detections[1:, :4]) < IOU_THRESHOLD]
return np.array(kept)
def scale_detections(detections, original_size, shape):
if len(detections) == 0:
return detections
_, _, input_height, input_width = shape
original_width, original_height = original_size
scaled = detections.copy()
scaled[:, [0, 2]] *= original_width / input_width
scaled[:, [1, 3]] *= original_height / input_height
scaled[:, [0, 2]] = np.clip(scaled[:, [0, 2]], 0, original_width)
scaled[:, [1, 3]] = np.clip(scaled[:, [1, 3]], 0, original_height)
return scaled
def detect(session, input_meta, image, target_class, confidence):
shape = input_shape(input_meta)
tensor = preprocess(image, shape)
raw = session.run(None, {input_meta.name: tensor})[0][0]
detections = raw[raw[:, 5].astype(int) == target_class]
detections = nms(detections, confidence)
return scale_detections(detections, image.size, shape)
def emit(event):
print(json.dumps(event, ensure_ascii=False, separators=(",", ":")), flush=True)
def write_json_atomic(path, data):
path.parent.mkdir(parents=True, exist_ok=True)
temp_path = path.with_suffix(path.suffix + ".tmp")
temp_path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
temp_path.replace(path)
def save_image_atomic(image, path):
path.parent.mkdir(parents=True, exist_ok=True)
temp_path = path.with_suffix(path.suffix + ".tmp")
image.save(temp_path, format="JPEG", quality=85)
temp_path.replace(path)
def image_to_jpeg_bytes(image):
buffer = BytesIO()
image.save(buffer, format="JPEG", quality=85)
return buffer.getvalue()
def detection_event(detection, frame_index, frame_size, class_name):
x1, y1, x2, y2, score, class_id = detection
cx = (x1 + x2) / 2
cy = (y1 + y2) / 2
return {
"type": "detection",
"timestamp": utc_now(),
"frame_index": frame_index,
"class_id": int(class_id),
"class_name": class_name,
"confidence": round(float(score), 6),
"bbox_screen": [round(float(x1), 2), round(float(y1), 2), round(float(x2), 2), round(float(y2), 2)],
"center_screen": [round(float(cx), 2), round(float(cy), 2)],
"frame_size": [frame_size[0], frame_size[1]],
}
def no_detection_event(frame_index, frame_size, target_class, class_name):
return {
"type": "no_detection",
"timestamp": utc_now(),
"frame_index": frame_index,
"class_id": target_class,
"class_name": class_name,
"frame_size": [frame_size[0], frame_size[1]],
}
def save_debug_image(image, detections, path, class_name):
debug = draw_detections(image, detections, class_name)
path.parent.mkdir(parents=True, exist_ok=True)
debug.save(path)
def draw_detections(image, detections, class_name):
debug = image.convert("RGB")
draw = ImageDraw.Draw(debug)
for detection in detections:
x1, y1, x2, y2, score, _ = detection
cx = (x1 + x2) / 2
cy = (y1 + y2) / 2
draw.rectangle((x1, y1, x2, y2), outline="red", width=3)
draw.ellipse((cx - 5, cy - 5, cx + 5, cy + 5), fill="yellow", outline="red")
draw.text(
(x1, max(0, y1 - 14)),
f"{class_name} ({cx:.1f}, {cy:.1f}) {score:.2f}",
fill="yellow",
)
return debug
def draw_frame_info(image, event):
draw = ImageDraw.Draw(image)
frame_index = event.get("frame_index", "-")
timestamp = event.get("timestamp", "")
text = f"frame={frame_index} {timestamp}"
draw.rectangle((12, 12, 620, 52), fill=(0, 0, 0))
draw.text((22, 22), text, fill=(80, 255, 120))
return image
def write_visualizer_html(path):
path.parent.mkdir(parents=True, exist_ok=True)
html = """<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>WeChat Vision Realtime</title>
<style>
:root { color-scheme: dark; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
body { margin: 0; background: #09090b; color: #f4f4f5; }
.wrap { display: grid; grid-template-columns: minmax(0, 1fr) 360px; gap: 16px; padding: 16px; }
.stage { background: #18181b; border: 1px solid #27272a; border-radius: 14px; overflow: hidden; min-height: 60vh; }
img { display: block; width: 100%; height: auto; }
aside { background: #18181b; border: 1px solid #27272a; border-radius: 14px; padding: 16px; }
h1 { margin: 0 0 12px; font-size: 18px; }
.item { padding: 10px 0; border-top: 1px solid #27272a; }
.label { color: #a1a1aa; font-size: 12px; margin-bottom: 4px; }
.value { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; word-break: break-all; }
.ok { color: #86efac; }
.miss { color: #fca5a5; }
@media (max-width: 900px) { .wrap { grid-template-columns: 1fr; } }
</style>
</head>
<body>
<div class="wrap">
<main class="stage"><img id="frame" alt="latest detection frame" /></main>
<aside>
<h1>WeChat Vision Realtime</h1>
<div class="item"><div class="label">Status</div><div id="status" class="value">loading</div></div>
<div class="item"><div class="label">Frame</div><div id="frameIndex" class="value">-</div></div>
<div class="item"><div class="label">Class</div><div id="className" class="value">-</div></div>
<div class="item"><div class="label">Confidence</div><div id="confidence" class="value">-</div></div>
<div class="item"><div class="label">Center Screen</div><div id="center" class="value">-</div></div>
<div class="item"><div class="label">BBox Screen</div><div id="bbox" class="value">-</div></div>
<div class="item"><div class="label">Frame Size</div><div id="size" class="value">-</div></div>
<div class="item"><div class="label">Timestamp</div><div id="time" class="value">-</div></div>
</aside>
</div>
<script>
async function refresh() {
const cacheBust = Date.now();
document.getElementById('frame').src = 'latest.jpg?t=' + cacheBust;
try {
const response = await fetch('latest.json?t=' + cacheBust);
const data = await response.json();
const hit = data.event && data.event.type === 'detection';
const status = document.getElementById('status');
status.textContent = hit ? 'detection' : 'no detection';
status.className = 'value ' + (hit ? 'ok' : 'miss');
document.getElementById('frameIndex').textContent = data.frame_index ?? '-';
document.getElementById('className').textContent = data.class_name ?? '-';
document.getElementById('confidence').textContent = data.confidence ?? '-';
document.getElementById('center').textContent = JSON.stringify(data.center_screen ?? '-');
document.getElementById('bbox').textContent = JSON.stringify(data.bbox_screen ?? '-');
document.getElementById('size').textContent = JSON.stringify(data.frame_size ?? '-');
document.getElementById('time').textContent = data.timestamp ?? '-';
} catch (error) {
document.getElementById('status').textContent = 'waiting for latest.json';
}
}
refresh();
setInterval(refresh, 500);
</script>
</body>
</html>
"""
path.write_text(html, encoding="utf-8")
def dashboard_html(frame_url="frame.jpg", json_url="latest.json"):
return f"""<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>WeChat Vision Live Stream</title>
<style>
:root {{ color-scheme: dark; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }}
body {{ margin: 0; background: #09090b; color: #f4f4f5; }}
.wrap {{ display: grid; grid-template-columns: minmax(0, 1fr) 360px; gap: 16px; padding: 16px; }}
.stage {{ background: #18181b; border: 1px solid #27272a; border-radius: 14px; overflow: hidden; min-height: 60vh; position: relative; }}
canvas {{ display: block; width: 100%; height: auto; }}
.overlay {{ position: absolute; left: 12px; top: 12px; padding: 6px 10px; border-radius: 999px; background: rgba(9, 9, 11, 0.78); color: #86efac; font: 12px ui-monospace, SFMono-Regular, Menlo, monospace; }}
aside {{ background: #18181b; border: 1px solid #27272a; border-radius: 14px; padding: 16px; }}
h1 {{ margin: 0 0 12px; font-size: 18px; }}
.item {{ padding: 10px 0; border-top: 1px solid #27272a; }}
.label {{ color: #a1a1aa; font-size: 12px; margin-bottom: 4px; }}
.value {{ font-family: ui-monospace, SFMono-Regular, Menlo, monospace; word-break: break-all; }}
.pill {{ display: inline-flex; align-items: center; gap: 8px; padding: 6px 10px; border-radius: 999px; background: #052e16; color: #86efac; font-size: 12px; margin-bottom: 10px; }}
.dot {{ width: 8px; height: 8px; border-radius: 50%; background: #22c55e; box-shadow: 0 0 14px #22c55e; }}
.ok {{ color: #86efac; }}
.miss {{ color: #fca5a5; }}
@media (max-width: 900px) {{ .wrap {{ grid-template-columns: 1fr; }} }}
</style>
</head>
<body>
<div class="wrap">
<main class="stage">
<canvas id="preview"></canvas>
<div id="overlay" class="overlay">waiting for frames</div>
</main>
<aside>
<h1>WeChat Vision Live</h1>
<div class="pill"><span class="dot"></span><span>Canvas realtime preview</span></div>
<div class="item"><div class="label">Frame URL</div><div class="value">/{frame_url}</div></div>
<div class="item"><div class="label">Sequence</div><div id="sequence" class="value">-</div></div>
<div class="item"><div class="label">Metadata FPS</div><div id="metaFps" class="value">-</div></div>
<div class="item"><div class="label">Status</div><div id="status" class="value">loading</div></div>
<div class="item"><div class="label">Frame</div><div id="frameIndex" class="value">-</div></div>
<div class="item"><div class="label">Class</div><div id="className" class="value">-</div></div>
<div class="item"><div class="label">Confidence</div><div id="confidence" class="value">-</div></div>
<div class="item"><div class="label">Center Screen</div><div id="center" class="value">-</div></div>
<div class="item"><div class="label">BBox Screen</div><div id="bbox" class="value">-</div></div>
<div class="item"><div class="label">Frame Size</div><div id="size" class="value">-</div></div>
<div class="item"><div class="label">Timestamp</div><div id="time" class="value">-</div></div>
</aside>
</div>
<script>
const canvas = document.getElementById('preview');
const context = canvas.getContext('2d');
let previousSequence = null;
let previousTime = null;
let drawnSequence = null;
async function drawFrame(sequence) {{
const response = await fetch('{frame_url}?seq=' + sequence + '&t=' + Date.now(), {{ cache: 'no-store' }});
if (!response.ok) return;
const blob = await response.blob();
const bitmap = await createImageBitmap(blob);
if (canvas.width !== bitmap.width || canvas.height !== bitmap.height) {{
canvas.width = bitmap.width;
canvas.height = bitmap.height;
}}
context.drawImage(bitmap, 0, 0);
bitmap.close();
drawnSequence = sequence;
}}
async function refreshMeta() {{
try {{
const response = await fetch('{json_url}?t=' + Date.now(), {{ cache: 'no-store' }});
const data = await response.json();
const event = data.event || data;
const now = performance.now();
if (previousSequence !== null && previousTime !== null && data.sequence !== undefined) {{
const elapsed = Math.max((now - previousTime) / 1000, 0.001);
const fps = (data.sequence - previousSequence) / elapsed;
document.getElementById('metaFps').textContent = fps.toFixed(1);
}}
previousSequence = data.sequence;
previousTime = now;
const hit = event.type === 'detection';
const status = document.getElementById('status');
status.textContent = hit ? 'detection' : event.type || 'waiting';
status.className = 'value ' + (hit ? 'ok' : 'miss');
document.getElementById('sequence').textContent = data.sequence ?? '-';
document.getElementById('frameIndex').textContent = event.frame_index ?? '-';
document.getElementById('className').textContent = event.class_name ?? '-';
document.getElementById('confidence').textContent = event.confidence ?? '-';
document.getElementById('center').textContent = JSON.stringify(event.center_screen ?? '-');
document.getElementById('bbox').textContent = JSON.stringify(event.bbox_screen ?? '-');
document.getElementById('size').textContent = JSON.stringify(event.frame_size ?? '-');
document.getElementById('time').textContent = event.timestamp ?? '-';
document.getElementById('overlay').textContent = `seq=${{data.sequence ?? '-'}} frame=${{event.frame_index ?? '-'}} ${{hit ? 'detection' : event.type || 'waiting'}}`;
if (data.sequence !== undefined && data.sequence > 0 && data.sequence !== drawnSequence) {{
await drawFrame(data.sequence);
}}
}} catch (error) {{
document.getElementById('status').textContent = 'waiting for preview';
}}
}}
refreshMeta();
setInterval(refreshMeta, 200);
</script>
</body>
</html>
"""
def make_stream_handler(state):
class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, format, *args):
return
def do_HEAD(self):
if self.path == "/" or self.path.startswith("/index.html"):
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Cache-Control", "no-store")
self.end_headers()
return
if self.path.startswith("/latest.json"):
self.send_response(200)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Cache-Control", "no-store")
self.end_headers()
return
if self.path.startswith("/frame.jpg"):
self.send_response(200)
self.send_header("Content-Type", "image/jpeg")
self.send_header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
self.end_headers()
return
if self.path.startswith("/stream.mjpg"):
self.send_response(200)
self.send_header("Cache-Control", "no-cache, private")
self.send_header("Pragma", "no-cache")
self.send_header("Content-Type", "multipart/x-mixed-replace; boundary=frame")
self.end_headers()
return
self.send_error(404)
def do_GET(self):
if self.path == "/" or self.path.startswith("/index.html"):
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(dashboard_html().encode("utf-8"))
return
if self.path.startswith("/latest.json"):
_, event, sequence = state.snapshot()
payload = {
"sequence": sequence,
"updated_at": utc_now(),
"event": event or {"type": "waiting", "timestamp": utc_now()},
}
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Cache-Control", "no-store")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
return
if self.path.startswith("/frame.jpg"):
jpeg, _, sequence = state.snapshot()
if not jpeg:
self.send_response(503)
self.send_header("Cache-Control", "no-store")
self.end_headers()
return
self.send_response(200)
self.send_header("Content-Type", "image/jpeg")
self.send_header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
self.send_header("Pragma", "no-cache")
self.send_header("X-Frame-Sequence", str(sequence))
self.send_header("Content-Length", str(len(jpeg)))
self.end_headers()
self.wfile.write(jpeg)
return
if self.path.startswith("/stream.mjpg"):
self.send_response(200)
self.send_header("Age", "0")
self.send_header("Cache-Control", "no-cache, private")
self.send_header("Pragma", "no-cache")
self.send_header("Content-Type", "multipart/x-mixed-replace; boundary=frame")
self.end_headers()
last_sequence = -1
while True:
with state.condition:
state.condition.wait_for(lambda: state.sequence != last_sequence, timeout=5)
jpeg = state.jpeg
last_sequence = state.sequence
if not jpeg:
continue
try:
self.wfile.write(b"--frame\r\n")
self.wfile.write(b"Content-Type: image/jpeg\r\n")
self.wfile.write(f"Content-Length: {len(jpeg)}\r\n\r\n".encode("ascii"))
self.wfile.write(jpeg)
self.wfile.write(b"\r\n")
except (BrokenPipeError, ConnectionResetError):
break
return
self.send_error(404)
return Handler
def start_stream_server(host, port, state):
server = ThreadingHTTPServer((host, port), make_stream_handler(state))
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
return server
def update_visualizer(image, detections, event, output_dir, class_name):
annotated = draw_detections(image, detections, class_name)
annotated = draw_frame_info(annotated, event)
save_image_atomic(annotated, output_dir / "latest.jpg")
payload = {
"updated_at": utc_now(),
"event": event,
"frame_index": event.get("frame_index"),
"class_id": event.get("class_id"),
"class_name": event.get("class_name"),
"confidence": event.get("confidence"),
"bbox_screen": event.get("bbox_screen"),
"center_screen": event.get("center_screen"),
"frame_size": event.get("frame_size"),
"timestamp": event.get("timestamp"),
}
write_json_atomic(output_dir / "latest.json", payload)
def ffmpeg_command(args):
return [
"ffmpeg",
"-hide_banner",
"-loglevel",
"error",
"-f",
"avfoundation",
"-framerate",
str(args.fps),
"-i",
args.input,
"-f",
"image2pipe",
"-vcodec",
"mjpeg",
"-",
]
def ffplay_command(args):
return [
"ffplay",
"-hide_banner",
"-loglevel",
"error",
"-fflags",
"nobuffer",
"-flags",
"low_delay",
"-framedrop",
"-sync",
"ext",
"-window_title",
args.display_title,
"-left",
str(args.display_left),
"-top",
str(args.display_top),
"-x",
str(args.display_width),
"-y",
str(args.display_height),
"-f",
"mjpeg",
"-i",
"pipe:0",
]
def start_ffplay(args):
return subprocess.Popen(
ffplay_command(args),
stdin=subprocess.PIPE,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
bufsize=0,
)
def write_display_frame(display_process, jpeg):
if display_process is None or display_process.stdin is None:
return False
if display_process.poll() is not None:
return False
try:
display_process.stdin.write(jpeg)
display_process.stdin.flush()
return True
except (BrokenPipeError, OSError):
return False
def list_devices():
command = [
"ffmpeg",
"-hide_banner",
"-f",
"avfoundation",
"-list_devices",
"true",
"-i",
"",
]
subprocess.run(command, check=False)
def read_jpeg_frames(stream):
buffer = bytearray()
while True:
chunk = stream.read(65536)
if not chunk:
break
buffer.extend(chunk)
while True:
start = buffer.find(b"\xff\xd8")
if start < 0:
if len(buffer) > 1024 * 1024:
del buffer[:-2]
break
end = buffer.find(b"\xff\xd9", start + 2)
if end < 0:
if start > 0:
del buffer[:start]
break
frame = bytes(buffer[start : end + 2])
del buffer[: end + 2]
yield frame
def run_monitor(args):
model_path = Path(args.model)
class_name = CLASS_NAMES.get(args.target_class, f"class_{args.target_class}")
visualize_dir = Path(args.visualize_dir) if args.visualize_dir else None
stream_state = StreamState() if args.serve else None
stream_server = None
display_process = None
if stream_state:
stream_server = start_stream_server(args.host, args.port, stream_state)
url = f"http://{args.host}:{args.port}/"
emit({"type": "stream_server_ready", "url": url})
if args.open_visualizer:
webbrowser.open(url)
if visualize_dir:
write_visualizer_html(visualize_dir / "index.html")
emit({"type": "visualizer_ready", "path": str(visualize_dir / "index.html")})
if args.open_visualizer and not args.serve:
webbrowser.open((visualize_dir / "index.html").resolve().as_uri())
if args.display:
display_process = start_ffplay(args)
emit({"type": "display_started", "title": args.display_title})
session = ort.InferenceSession(str(model_path))
input_meta = session.get_inputs()[0]
emit(
{
"type": "monitor_started",
"timestamp": utc_now(),
"model": str(model_path),
"providers": session.get_providers(),
"input": args.input,
"fps": args.fps,
"target_class": args.target_class,
"target_name": class_name,
"confidence": args.confidence,
}
)
process = subprocess.Popen(
ffmpeg_command(args),
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
bufsize=0,
)
if process.stdout is None:
raise RuntimeError("ffmpeg stdout pipe is not available")
frame_index = 0
try:
for frame_bytes in read_jpeg_frames(process.stdout):
frame_index += 1
image = Image.open(BytesIO(frame_bytes)).convert("RGB")
detections = detect(
session,
input_meta,
image,
args.target_class,
args.confidence,
)
if len(detections) > 0:
best = detections[np.argmax(detections[:, 4])]
event = detection_event(best, frame_index, image.size, class_name)
emit(event)
elif args.emit_empty:
event = no_detection_event(frame_index, image.size, args.target_class, class_name)
emit(event)
else:
event = no_detection_event(frame_index, image.size, args.target_class, class_name)
if args.debug_dir and args.debug_every > 0 and frame_index % args.debug_every == 0:
save_debug_image(
image,
detections,
Path(args.debug_dir) / f"frame_{frame_index:06d}.jpg",
class_name,
)
if (
visualize_dir
and args.visualize_every > 0
and frame_index % args.visualize_every == 0
):
update_visualizer(image, detections, event, visualize_dir, class_name)
annotated = None
if stream_state:
annotated = draw_detections(image, detections, class_name)
stream_state.update(image_to_jpeg_bytes(annotated), event)
if display_process:
if annotated is None:
annotated = draw_detections(image, detections, class_name)
annotated = draw_frame_info(annotated, event)
if not write_display_frame(display_process, image_to_jpeg_bytes(annotated)):
emit({"type": "display_closed", "timestamp": utc_now()})
break
if args.max_frames > 0 and frame_index >= args.max_frames:
break
finally:
process.terminate()
try:
process.wait(timeout=2)
except subprocess.TimeoutExpired:
process.kill()
stderr = b""
if process.stderr is not None:
try:
stderr = process.stderr.read()
except Exception:
stderr = b""
if frame_index == 0 and stderr:
log_error(stderr.decode(errors="replace").strip())
if display_process:
if display_process.stdin:
try:
display_process.stdin.close()
except Exception:
pass
display_process.terminate()
try:
display_process.wait(timeout=2)
except subprocess.TimeoutExpired:
display_process.kill()
emit(
{
"type": "monitor_stopped",
"timestamp": utc_now(),
"frames": frame_index,
}
)
if stream_server:
stream_server.shutdown()
def main():
def stop_handler(signum, frame):
raise KeyboardInterrupt
signal.signal(signal.SIGTERM, stop_handler)
signal.signal(signal.SIGINT, stop_handler)
args = parse_args()
if args.list_devices:
list_devices()
return
if args.debug_every > 0 and not args.debug_dir:
args.debug_dir = str(DEBUG_DIR)
if args.open_visualizer and not args.visualize_dir and not args.serve:
args.visualize_dir = str(VISUALIZE_DIR)
try:
run_monitor(args)
except KeyboardInterrupt:
emit({"type": "monitor_interrupted", "timestamp": utc_now()})
if __name__ == "__main__":
main()