fix : live view buffering

This commit is contained in:
Alberto-Audrix committed 2026-07-10 13:54:36 +07:00
1 parent af2854d771
commit 314b598c66
4 files changed
+73 -24

No files matched your search

+4 -2
View File
@@ -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 | | Counter won't start | `journalctl -u zenai-kpc-counter`; verify `MODEL_PATH` exists; RKNN drivers installed |
| No RTSP frames | Ping camera; test with `ffplay <SOURCE>`; check `OPENCV_FFMPEG_CAPTURE_OPTIONS` | | No RTSP frames | Ping camera; test with `ffplay <SOURCE>`; check `OPENCV_FFMPEG_CAPTURE_OPTIONS` |
| Dashboard shows 0 count | `STATE_FILE` in `.env` must match between counter and dashboard; check file exists | | 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 | | 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 | | 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_http_version 1.1;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; 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;
} }
} }
``` ```
+22 -14
View File
@@ -44,32 +44,40 @@ DASHBOARD_PORT = int(os.getenv("DASHBOARD_PORT", "5000"))
DASHBOARD_HOST = os.getenv("DASHBOARD_HOST", "0.0.0.0") DASHBOARD_HOST = os.getenv("DASHBOARD_HOST", "0.0.0.0")
FLASK_DEBUG = os.getenv("FLASK_DEBUG", "false").lower() == "true" 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") @app.route("/api/live-video")
def 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(): def generate():
consecutive_fails = 0
MAX_FAILS = 30
while True: while True:
try: try:
if not os.path.isfile(LIVE_STREAM_FRAME_PATH):
time.sleep(0.5)
continue
with open(LIVE_STREAM_FRAME_PATH, "rb") as f: with open(LIVE_STREAM_FRAME_PATH, "rb") as f:
jpeg = f.read() jpeg = f.read()
consecutive_fails = 0 if not _is_valid_jpeg(jpeg):
time.sleep(0.05)
continue
yield (b"--frame\r\n" yield (b"--frame\r\n"
b"Content-Type: image/jpeg\r\n\r\n" + jpeg + b"\r\n") b"Content-Type: image/jpeg\r\n\r\n" + jpeg + b"\r\n")
except FileNotFoundError: except FileNotFoundError:
consecutive_fails += 1 time.sleep(0.5)
if consecutive_fails >= MAX_FAILS:
return
time.sleep(1.0)
continue continue
except Exception: except Exception:
consecutive_fails += 1 time.sleep(0.25)
if consecutive_fails >= MAX_FAILS:
return
time.sleep(0.5)
continue continue
time.sleep(0.05) time.sleep(0.05)
return Response(generate(), mimetype="multipart/x-mixed-replace; boundary=frame") return Response(generate(), mimetype="multipart/x-mixed-replace; boundary=frame")
+13 -3
View File
@@ -1002,6 +1002,18 @@ def draw_popups(img, popups, frame_idx):
return alive 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): def connect_stream(source, warmup=WARMUP_FRAMES):
attempts = 0 attempts = 0
while not shutdown_requested: while not shutdown_requested:
@@ -1381,12 +1393,10 @@ def run():
if LIVE_STREAM_ENABLED and frame_idx % LIVE_STREAM_EVERY_N == 0: if LIVE_STREAM_ENABLED and frame_idx % LIVE_STREAM_EVERY_N == 0:
try: try:
Path(LIVE_STREAM_FRAME_PATH).parent.mkdir(parents=True, exist_ok=True)
_, jpeg = cv2.imencode( _, jpeg = cv2.imencode(
".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, LIVE_STREAM_QUALITY] ".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, LIVE_STREAM_QUALITY]
) )
with open(LIVE_STREAM_FRAME_PATH, "wb") as f: _write_live_frame_atomic(LIVE_STREAM_FRAME_PATH, jpeg.tobytes())
f.write(jpeg.tobytes())
except Exception: except Exception:
pass pass
+34 -5
View File
@@ -811,6 +811,9 @@ function updateToggleIcon() {
let mainChart = null; let mainChart = null;
let videoActive = false; let videoActive = false;
let videoErrorTimer = null; let videoErrorTimer = null;
let videoWatchdogTimer = null;
let lastVideoFrameAt = 0;
const VIDEO_STALL_MS = 8000;
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
loadSummary(); loadSummary();
@@ -1083,6 +1086,32 @@ document.addEventListener('keydown', (e) => {
// --- Live Video Feed --- // --- 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() { function toggleVideo() {
const btn = document.getElementById('videoToggle'); const btn = document.getElementById('videoToggle');
const img = document.getElementById('live-video-img'); const img = document.getElementById('live-video-img');
@@ -1093,14 +1122,16 @@ function toggleVideo() {
if (videoActive) { if (videoActive) {
resetFilter(); resetFilter();
img.src = '/api/live-video?' + Date.now();
img.style.display = 'block'; img.style.display = 'block';
if (placeholder) placeholder.style.display = 'none'; if (placeholder) placeholder.style.display = 'none';
btn.textContent = 'STREAM ON'; btn.textContent = 'STREAM ON';
btn.classList.add('active'); btn.classList.add('active');
status.innerHTML = '<span style="width:6px;height:6px;border-radius:50%;background:var(--accent4);animation:blink 1.5s ease-in-out infinite;"></span> connecting...'; status.innerHTML = '<span style="width:6px;height:6px;border-radius:50%;background:var(--accent4);animation:blink 1.5s ease-in-out infinite;"></span> connecting...';
status.className = 'video-status waiting'; status.className = 'video-status waiting';
reconnectVideo();
startVideoWatchdog();
} else { } else {
stopVideoWatchdog();
img.src = ''; img.src = '';
img.style.display = 'none'; img.style.display = 'none';
if (placeholder) placeholder.style.display = ''; if (placeholder) placeholder.style.display = '';
@@ -1113,6 +1144,7 @@ function toggleVideo() {
} }
function onVideoLoad() { function onVideoLoad() {
lastVideoFrameAt = Date.now();
const status = document.getElementById('video-status'); const status = document.getElementById('video-status');
status.innerHTML = '<span style="width:6px;height:6px;border-radius:50%;background:var(--accent4);box-shadow:0 0 6px var(--accent4);animation:blink 1.5s ease-in-out infinite;"></span> live'; status.innerHTML = '<span style="width:6px;height:6px;border-radius:50%;background:var(--accent4);box-shadow:0 0 6px var(--accent4);animation:blink 1.5s ease-in-out infinite;"></span> live';
status.className = 'video-status connected'; status.className = 'video-status connected';
@@ -1124,10 +1156,7 @@ function onVideoError() {
if (overlay) overlay.classList.add('visible'); if (overlay) overlay.classList.add('visible');
if (videoErrorTimer) clearTimeout(videoErrorTimer); if (videoErrorTimer) clearTimeout(videoErrorTimer);
videoErrorTimer = setTimeout(() => { videoErrorTimer = setTimeout(() => {
if (videoActive) { reconnectVideo();
const img = document.getElementById('live-video-img');
img.src = '/api/live-video?' + Date.now();
}
}, 2000); }, 2000);
} }