diff --git a/DEPLOY.md b/DEPLOY.md index 044133d..289bb12 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -193,7 +193,7 @@ sudo systemctl start zenai-kpc-counter zenai-kpc-dashboard | Counter won't start | `journalctl -u zenai-kpc-counter`; verify `MODEL_PATH` exists; RKNN drivers installed | | No RTSP frames | Ping camera; test with `ffplay `; check `OPENCV_FFMPEG_CAPTURE_OPTIONS` | | Dashboard shows 0 count | `STATE_FILE` in `.env` must match between counter and dashboard; check file exists | -| Live video blank | `LIVE_STREAM_ENABLED=true`; path matches `LIVE_STREAM_FRAME_PATH` in both processes | +| Live video blank | `LIVE_STREAM_ENABLED=true`; path matches `LIVE_STREAM_FRAME_PATH` in both processes; if using nginx, set `proxy_read_timeout` (see ยง7) | | Wrong counts | Tune `LINE_Y1_FRAC`/`LINE_Y2_FRAC`, `CONF`, ByteTrack thresholds; enable `DEBUG_TRACKING=true` temporarily | | Service keeps restarting | `journalctl -u zenai-kpc-counter -e`; often missing model, bad RTSP URL, or venv not created | @@ -222,7 +222,9 @@ server { proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; - proxy_buffering off; # needed for /api/live-video MJPEG stream + proxy_buffering off; # required for /api/live-video MJPEG stream + proxy_read_timeout 3600s; # keep long-lived MJPEG connections open + proxy_send_timeout 3600s; } } ``` diff --git a/counter_dashboard.py b/counter_dashboard.py index 0ceef5b..c5e3820 100644 --- a/counter_dashboard.py +++ b/counter_dashboard.py @@ -44,32 +44,40 @@ DASHBOARD_PORT = int(os.getenv("DASHBOARD_PORT", "5000")) DASHBOARD_HOST = os.getenv("DASHBOARD_HOST", "0.0.0.0") FLASK_DEBUG = os.getenv("FLASK_DEBUG", "false").lower() == "true" +_JPEG_SOI = b"\xff\xd8" +_JPEG_EOI = b"\xff\xd9" +_MIN_JPEG_BYTES = 128 + + +def _is_valid_jpeg(data): + return ( + data + and len(data) >= _MIN_JPEG_BYTES + and data.startswith(_JPEG_SOI) + and data.endswith(_JPEG_EOI) + ) + + @app.route("/api/live-video") def api_live_video(): - if not os.path.isfile(LIVE_STREAM_FRAME_PATH): - return jsonify({"success": False, "error": "Live stream frame not available yet"}), 503 - def generate(): - consecutive_fails = 0 - MAX_FAILS = 30 while True: try: + if not os.path.isfile(LIVE_STREAM_FRAME_PATH): + time.sleep(0.5) + continue with open(LIVE_STREAM_FRAME_PATH, "rb") as f: jpeg = f.read() - consecutive_fails = 0 + if not _is_valid_jpeg(jpeg): + time.sleep(0.05) + continue yield (b"--frame\r\n" b"Content-Type: image/jpeg\r\n\r\n" + jpeg + b"\r\n") except FileNotFoundError: - consecutive_fails += 1 - if consecutive_fails >= MAX_FAILS: - return - time.sleep(1.0) + time.sleep(0.5) continue except Exception: - consecutive_fails += 1 - if consecutive_fails >= MAX_FAILS: - return - time.sleep(0.5) + time.sleep(0.25) continue time.sleep(0.05) return Response(generate(), mimetype="multipart/x-mixed-replace; boundary=frame") diff --git a/counter_live_rknn.py b/counter_live_rknn.py index 4847a97..aaeee1d 100644 --- a/counter_live_rknn.py +++ b/counter_live_rknn.py @@ -1002,6 +1002,18 @@ def draw_popups(img, popups, frame_idx): return alive +def _write_live_frame_atomic(path, jpeg_bytes): + """Write JPEG atomically so dashboard readers never see a partial file.""" + dest = Path(path) + dest.parent.mkdir(parents=True, exist_ok=True) + tmp = dest.with_suffix(dest.suffix + ".tmp") + with open(tmp, "wb") as f: + f.write(jpeg_bytes) + f.flush() + os.fsync(f.fileno()) + os.replace(tmp, dest) + + def connect_stream(source, warmup=WARMUP_FRAMES): attempts = 0 while not shutdown_requested: @@ -1381,12 +1393,10 @@ def run(): if LIVE_STREAM_ENABLED and frame_idx % LIVE_STREAM_EVERY_N == 0: try: - Path(LIVE_STREAM_FRAME_PATH).parent.mkdir(parents=True, exist_ok=True) _, jpeg = cv2.imencode( ".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, LIVE_STREAM_QUALITY] ) - with open(LIVE_STREAM_FRAME_PATH, "wb") as f: - f.write(jpeg.tobytes()) + _write_live_frame_atomic(LIVE_STREAM_FRAME_PATH, jpeg.tobytes()) except Exception: pass diff --git a/templates/dashboard.html b/templates/dashboard.html index 40648a4..9ae0755 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -811,6 +811,9 @@ function updateToggleIcon() { let mainChart = null; let videoActive = false; let videoErrorTimer = null; +let videoWatchdogTimer = null; +let lastVideoFrameAt = 0; +const VIDEO_STALL_MS = 8000; document.addEventListener('DOMContentLoaded', () => { loadSummary(); @@ -1083,6 +1086,32 @@ document.addEventListener('keydown', (e) => { // --- Live Video Feed --- +function reconnectVideo() { + if (!videoActive) return; + const img = document.getElementById('live-video-img'); + if (!img) return; + lastVideoFrameAt = Date.now(); + img.src = '/api/live-video?' + Date.now(); +} + +function startVideoWatchdog() { + stopVideoWatchdog(); + lastVideoFrameAt = Date.now(); + videoWatchdogTimer = setInterval(() => { + if (!videoActive) return; + if (Date.now() - lastVideoFrameAt > VIDEO_STALL_MS) { + reconnectVideo(); + } + }, 2000); +} + +function stopVideoWatchdog() { + if (videoWatchdogTimer) { + clearInterval(videoWatchdogTimer); + videoWatchdogTimer = null; + } +} + function toggleVideo() { const btn = document.getElementById('videoToggle'); const img = document.getElementById('live-video-img'); @@ -1093,14 +1122,16 @@ function toggleVideo() { if (videoActive) { resetFilter(); - img.src = '/api/live-video?' + Date.now(); img.style.display = 'block'; if (placeholder) placeholder.style.display = 'none'; btn.textContent = 'STREAM ON'; btn.classList.add('active'); status.innerHTML = ' connecting...'; status.className = 'video-status waiting'; + reconnectVideo(); + startVideoWatchdog(); } else { + stopVideoWatchdog(); img.src = ''; img.style.display = 'none'; if (placeholder) placeholder.style.display = ''; @@ -1113,6 +1144,7 @@ function toggleVideo() { } function onVideoLoad() { + lastVideoFrameAt = Date.now(); const status = document.getElementById('video-status'); status.innerHTML = ' live'; status.className = 'video-status connected'; @@ -1124,10 +1156,7 @@ function onVideoError() { if (overlay) overlay.classList.add('visible'); if (videoErrorTimer) clearTimeout(videoErrorTimer); videoErrorTimer = setTimeout(() => { - if (videoActive) { - const img = document.getElementById('live-video-img'); - img.src = '/api/live-video?' + Date.now(); - } + reconnectVideo(); }, 2000); }