Merging for deployment update with service #2

Merged
dsutanto merged 5 commits from audrix/zenai-kpc-python:main into main 2026-07-14 08:45:26 +07:00
5 changed files with 196 additions and 146 deletions
Showing only changes of commit 6071d56dc3 - Show all commits

No files matched your search

+116 -28
View File
@@ -1,30 +1,39 @@
# =============================================================================
# DEPRECATED — use env.example instead.
#
# cp env.example .env && nano .env
#
# This file is kept for backward compatibility only. All new deployments should
# use env.example as the single canonical template. See DEPLOY.md.
# Edge RK3588 production counter + dashboard
# Shared config for: counter_live_rknn_bytetrack.py + counter_dashboard.py
# Copy to .env on device: cp config.env.example .env && nano .env
# =============================================================================
# --- Core paths ---
OUTPUT_DIR=/opt/zenai-kpc-counter
DB_PATH=/opt/zenai-kpc-counter/counter.db
STATE_FILE=/opt/zenai-kpc-counter/current_counter.json
# Root output directory (logs, DB, video, CSV)
OUTPUT_DIR=/opt/bytetrack-counter
# SQLite database path for daily counter records & crossing logs
DB_PATH=/opt/bytetrack-counter/bytetrack_counter.db
# JSON file persisting the current active counting day state
STATE_FILE=/tmp/bytetrack_current_counter.json
# --- Input source ---
# RTSP / HTTP live stream, or a local video file path
SOURCE=rtsp://user:pass@192.168.0.100:554/stream1
# FFmpeg capture options passed to cv2.VideoCapture (RTSP low-latency flags)
OPENCV_FFMPEG_CAPTURE_OPTIONS=rtsp_transport;tcp|fflags;nobuffer|flags;low_delay
# --- RKNN model ---
MODEL_PATH=/opt/models/your_model.rknn
# Path to exported .rknn model (YOLO format, e.g. yolo11n.rknn)
MODEL_PATH=/opt/models/yolo9t.rknn
# Input image size for the model (square, e.g. 320 → 320×320)
IMGSZ=320
# Use FP16 inference on NPU (true/false); currently unused in ByteTrack variant
HALF=false
CORE_MASK=1
# NPU core mask: 1=core0, 2=core1, 3=core0+core1, 7=all three
CORE_MASK=7
# Compute device index (reserved; not used at runtime)
DEVICE=0
# --- YOLO decoder ---
NUM_CLASSES=4
# Number of object classes the model outputs
NUM_CLASSES=2
# Apply sigmoid to raw class scores (true/false); set true if model head uses BCE logits
SCORE_SIGMOID=false
# --- Detection ---
@@ -37,55 +46,117 @@ CONF=0.3
NMS_IOU=0.45
# --- ByteTrack tracking ---
# Detections with score >= this get priority matching in the first association stage
TRACK_HIGH_THRESH=0.5
TRACK_LOW_THRESH=0.3
TRACK_MATCH_THRESH=0.7
TRACK_BUFFER=60
# Detections with score between this and TRACK_HIGH_THRESH are matched in the second stage
TRACK_LOW_THRESH=0.1
# IoU threshold for the first-stage association (0–1). Higher = stricter overlap required
TRACK_MATCH_THRESH=0.8
# Frames a track survives without a match before being permanently removed
TRACK_BUFFER=30
# Minimum consecutive (or total) hits needed before a track is considered confirmed
TRACK_MIN_HITS=3
# --- ID-switch counting guards ---
# When a track's ID changes right at the counting line, one physical object can be
# counted twice (two IDs cross) or missed (neither ID sees the full transition).
# These two guards correct for that.
#
# Dedup guard (prevents double counting): after a crossing, a second crossing in
# the SAME direction within DEDUP_FRAMES frames and DEDUP_PX horizontal pixels is
# ignored (treated as the same object under a new ID).
DEDUP_FRAMES=15
DEDUP_PX=60
# To DISABLE the dedup guard, set DEDUP_PX=-1 (distance check can never match).
#
# Inheritance guard (prevents missed counting): when a brand-new track appears, it
# inherits the last position of a recently-seen nearby track (within INHERIT_SEC
# seconds and INHERIT_PX horizontal pixels) so the crossing is still detected
# across the ID switch.
INHERIT_SEC=1.0
INHERIT_PX=60
# To DISABLE the inheritance guard, set INHERIT_PX=-1 (distance check can never match).
# --- Display ---
# Site name shown on the dashboard header (top-right)
SITE_NAME=ZenAi
# --- Object class names ---
# Camera / location identifier shown in HUD and stored in DB
CAMERA_NAME=ZenAi
OBJECT_LABEL=karung
CLASS_OBJECT=karung
# Label used for batch grouping in the database
OBJECT_LABEL=object
# Class name for the counted object (must match model class order)
CLASS_OBJECT=object
# Model class ID for the object being counted (default 0)
OBJECT_CLASS_ID=0
# --- Line crossing ---
# Two horizontal counting lines:
# Line 1 (default ~33%): counts top-to-down (IN)
# Line 2 (default ~66%): counts bottom-to-up (OUT)
# Fixed y-coordinate for line 1/IN (overrides LINE_Y1_FRAC if set)
LINE_Y1=
LINE_Y1_FRAC=0.70
# Fraction of frame height for line 1 (default 0.33)
LINE_Y1_FRAC=0.33
# Fixed y-coordinate for line 2 (overrides LINE_Y2_FRAC if set)
LINE_Y2=
LINE_Y2_FRAC=0.30
# Fraction of frame height for line 2 (default 0.66)
LINE_Y2_FRAC=0.66
# --- Counting day management ---
# Daily cutoff time (HH:MM) – a new counting day starts after this time and the
# previous day's counter_in / counter_out totals are finalized in the database.
# CUTOFF_TIME is an alias used by the dashboard; DAILY_CUTOFF_TIME takes priority in counter_live_rknn.py.
DAILY_CUTOFF_TIME=20:00
CUTOFF_TIME=20:00
# --- CSV export ---
EXPORT_CSV=false
CROSS_CSV=/opt/zenai-kpc-counter/crossings.csv
# Write per-crossing events to a CSV file (true/false)
EXPORT_CSV=true
# Path where the crossing CSV is written
CROSS_CSV=/opt/batch-counter/crossings.csv
# --- Crossing snapshots ---
SAVE_CROSS_SNAPSHOT=true
# Save an annotated frame image every time an object crosses a line and the
# counter increases (true/false, default: false). Written to <DIR>/cross/
# (filename: <YYYYmmdd_HHMMSS_mmm>_<in|out>_id<track>_f<frame>.jpg)
SAVE_CROSS_SNAPSHOT=false
# Also save one snapshot the first time each object is detected, before it crosses
# (true/false, default: false). Written to <DIR>/detect/ with the same track id so
# it can be correlated with the crossing snapshot
# (filename: <YYYYmmdd_HHMMSS_mmm>_detect_id<track>_f<frame>.jpg)
SAVE_DETECT_SNAPSHOT=false
CROSS_SNAPSHOT_DIR=/opt/zenai-kpc-counter/snapshots
# Base directory for snapshots (detect/ and cross/ subfolders are created inside).
# The dashboard reads this same path to display the snapshot gallery, so keep it
# identical for both the counter and the dashboard.
CROSS_SNAPSHOT_DIR=/opt/batch-counter/snapshots
# JPEG quality for snapshots (1-100)
CROSS_SNAPSHOT_QUALITY=85
# Retention: keep at most this many snapshot files (detect + cross combined);
# oldest are deleted first (0 = unlimited)
CROSS_SNAPSHOT_MAX_FILES=1000
CROSS_SNAPSHOT_MAX_AGE_DAYS=3
CROSS_SNAPSHOT_CLEANUP_SEC=3600
# Retention: delete snapshots older than this many days (0 = never by age)
CROSS_SNAPSHOT_MAX_AGE_DAYS=7
# Run the cleanup sweep at most once every N seconds
CROSS_SNAPSHOT_CLEANUP_SEC=60
# --- Rate / performance ---
MOTION_DETECTION_ENABLED=true
# Enable motion detection pre-filter: skip inference on frames with no movement
# (true/false, default: false), saving NPU/CPU load. Motion is measured by the
# fraction of pixels that changed (localized-motion aware), NOT the whole-frame
# average, so an object entering the edge of the frame is detected immediately.
MOTION_DETECTION_ENABLED=false
# Per-pixel intensity change (0-255) for a pixel to count as "moved". Lower = more
# sensitive to subtle movement. Default 25.
MOTION_PIXEL_DELTA=25
# Fraction of frame pixels (0-1) that must change to trigger inference. Lower =
# more sensitive / detects smaller or farther objects sooner. Default 0.002 (0.2%).
MOTION_MIN_AREA_FRAC=0.002
# Heartbeat: always run inference at least every N frames even with no detected
# motion, so a slow or barely-moving object is never missed for long. Default 15.
MOTION_HEARTBEAT_FRAMES=15
# (Deprecated) old whole-frame mean-difference threshold; no longer used.
MOTION_THRESHOLD=5.0
# --- Runtime control (start/stop counting on the fly) ---
@@ -115,24 +186,41 @@ CONTROL_SOCKET_PORT=5090
# Sliding window in seconds for computing the crossing rate (objects/minute)
RATE_WINDOW_SEC=60
# Number of frames to discard at startup to let the stream buffer stabilise
WARMUP_FRAMES=30
# Delay in seconds between stream reconnection attempts
RECONNECT_DELAY_SEC=3
# Maximum reconnection attempts (0 = infinite)
MAX_RECONNECT_ATTEMPTS=0
# Seconds after which a tracked but unseen object is pruned from the active set
TRACKED_PRUNE_SEC=300
# --- Video recording ---
# Save annotated frames to segmented MP4 files (true/false)
RECORD_VIDEO=false
# Duration in seconds of each video segment file
VIDEO_SEGMENT_SEC=3600
# Output video FPS (fallback if source FPS is unknown or ≤ 1)
OUTPUT_FPS=15
# --- Live stream snapshot ---
LIVE_STREAM_ENABLED=true
LIVE_STREAM_FRAME_PATH=/dev/shm/zenai-kpc-counter/live_frame.jpg
# Periodically write the latest annotated frame as JPEG for an external web server
LIVE_STREAM_ENABLED=false
# Path to the shared-memory snapshot file (served by nginx / lighttpd)
LIVE_STREAM_FRAME_PATH=/dev/shm/byetrack-counter/live_frame.jpg
# JPEG quality (1–100)
LIVE_STREAM_QUALITY=75
# Write the snapshot every N frames (lower = more frequent updates)
LIVE_STREAM_EVERY_N=2
# --- Dashboard (counter_dashboard.py) ---
# Flask secret key for session/cookie signing — change in production!
SECRET_KEY=change-me-in-production
# Bind address for the Flask web server
DASHBOARD_HOST=0.0.0.0
# Listen port for the dashboard web UI
DASHBOARD_PORT=5000
# Enable Flask debug mode (true/false) — auto-reloads on code changes; disable in production
FLASK_DEBUG=false
# Fallback name for the active counting-day JSON state file used by the dashboard
CURRENT_COUNTER_PATH=/tmp/bytetrack_current_counter.json
+14 -20
View File
@@ -48,28 +48,16 @@ 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()
if not jpeg or len(jpeg) < 2 or jpeg[:2] != b"\xff\xd8":
@@ -82,10 +70,16 @@ def api_live_video():
yield (b"--frame\r\n"
b"Content-Type: image/jpeg\r\n\r\n" + jpeg + b"\r\n")
except FileNotFoundError:
time.sleep(0.5)
consecutive_fails += 1
if consecutive_fails >= MAX_FAILS:
return
time.sleep(1.0)
continue
except Exception:
time.sleep(0.25)
consecutive_fails += 1
if consecutive_fails >= MAX_FAILS:
return
time.sleep(0.5)
continue
time.sleep(0.05)
return Response(generate(), mimetype="multipart/x-mixed-replace; boundary=frame")
@@ -544,4 +538,4 @@ if __name__ == "__main__":
print(f"Jetson counter dashboard at http://{DASHBOARD_HOST}:{DASHBOARD_PORT}")
print(f"DB: {DB_PATH}")
print(f"State: {CURRENT_COUNTER_PATH}")
app.run(host=DASHBOARD_HOST, port=DASHBOARD_PORT, debug=FLASK_DEBUG)
app.run(host=DASHBOARD_HOST, port=DASHBOARD_PORT, debug=FLASK_DEBUG)
+1 -13
View File
@@ -1108,18 +1108,6 @@ 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:
@@ -1605,4 +1593,4 @@ object_tracked = {}
if __name__ == "__main__":
run()
run()
+59 -50
View File
@@ -1,32 +1,27 @@
# =============================================================================
# ZenAI KPC edge counter + dashboard
# Shared config for: counter_live_rknn.py + counter_dashboard.py
#
# On device:
# cp env.example .env && nano .env
#
# Install path (systemd): /opt/zenai-kpc-python
# Data path: /opt/zenai-kpc-counter
# See DEPLOY.md for full setup instructions.
# =============================================================================
# Edge RK3588 production counter + dashboard
# Shared config for: counter_live_rknn_bytetrack.py + counter_dashboard.py
# Copy to .env on device: cp config.env.example .env && nano .env
# =============================================================================
# --- Core paths ---
# Root output directory (video segments, default snapshot/CSV paths)
OUTPUT_DIR=/opt/zenai-kpc-counter
# SQLite database path for daily counter records
DB_PATH=/opt/zenai-kpc-counter/counter.db
# JSON file for the active counting-day state (counter writes, dashboard reads)
STATE_FILE=/opt/zenai-kpc-counter/current_counter.json
# Root output directory (logs, DB, video, CSV)
OUTPUT_DIR=/opt/zenai-kpc-bt-counter
# SQLite database path for daily counter records & crossing logs
DB_PATH=/tmp/counter.db
# JSON file persisting the current active counting day state
STATE_FILE=/tmp/current_counter.json
# --- Input source ---
# RTSP / HTTP live stream, or a local video file path
SOURCE=rtsp://user:pass@192.168.0.100:554/stream1
#SOURCE=rtsp://user:pass@192.168.0.100:554/stream1
SOURCE=rtsp://10.38.30.64:8554/my_stream
# FFmpeg capture options passed to cv2.VideoCapture (RTSP low-latency flags)
OPENCV_FFMPEG_CAPTURE_OPTIONS=rtsp_transport;tcp|fflags;nobuffer|flags;low_delay
# --- RKNN model ---
# Path to exported .rknn model (YOLO format)
MODEL_PATH=/opt/models/your_model.rknn
# Path to exported .rknn model (YOLO format, e.g. yolo11n.rknn)
MODEL_PATH=/opt/models/zenai_kac_sukawarna_20260702.rknn
# Input image size for the model (square, e.g. 320 → 320×320)
IMGSZ=320
# Use FP16 inference on NPU (true/false); currently unused in ByteTrack variant
@@ -58,24 +53,14 @@ TRACK_BUFFER=60
# Minimum consecutive (or total) hits needed before a track is considered confirmed
TRACK_MIN_HITS=3
# --- ID-switch counting guards ---
# Dedup guard: ignore a second crossing in the same direction within DEDUP_FRAMES
# frames and DEDUP_PX horizontal pixels (set DEDUP_PX=-1 to disable).
DEDUP_FRAMES=15
DEDUP_PX=60
# Inheritance guard: new tracks inherit position from a recently-seen nearby track
# (set INHERIT_PX=-1 to disable).
INHERIT_SEC=1.0
INHERIT_PX=60
# --- Display ---
# Site name shown on the dashboard header
# Site name shown on the dashboard header (top-right)
SITE_NAME=ZenAi
# --- Object class names ---
# Camera / location identifier shown in HUD and stored in DB
CAMERA_NAME=ZenAi
# Label used for grouping in the database
# Label used for batch grouping in the database
OBJECT_LABEL=karung
# Class name for the counted object (must match model class order)
CLASS_OBJECT=karung
@@ -83,59 +68,83 @@ CLASS_OBJECT=karung
OBJECT_CLASS_ID=0
# --- Line crossing ---
# Line 1 (~upper): counts top-to-down (IN). Line 2 (~lower): bottom-to-up (OUT).
# Fixed y-coordinate overrides the fraction if set.
# Two horizontal counting lines:
# Line 1 (default ~33%): counts top-to-down (IN)
# Line 2 (default ~66%): counts bottom-to-up (OUT)
# Fixed y-coordinate for line 1/IN (overrides LINE_Y1_FRAC if set)
LINE_Y1=
# Fraction of frame height for line 1 (default 0.33)
LINE_Y1_FRAC=0.70
# Fixed y-coordinate for line 2 (overrides LINE_Y2_FRAC if set)
LINE_Y2=
# Fraction of frame height for line 2 (default 0.66)
LINE_Y2_FRAC=0.30
# --- Counting day management ---
# New counting day starts after this time (HH:MM).
# Daily cutoff time (HH:MM) – a new counting day starts after this time and the
# previous day's counter_in / counter_out totals are finalized in the database.
# CUTOFF_TIME is an alias used by the dashboard; DAILY_CUTOFF_TIME takes priority in counter_live_rknn.py.
DAILY_CUTOFF_TIME=20:00
CUTOFF_TIME=20:00
# --- CSV export ---
# Write per-crossing events to a CSV file (true/false)
EXPORT_CSV=false
CROSS_CSV=/opt/zenai-kpc-counter/crossings.csv
# --- Crossing snapshots ---
SAVE_CROSS_SNAPSHOT=true
SAVE_DETECT_SNAPSHOT=false
CROSS_SNAPSHOT_DIR=/opt/zenai-kpc-counter/snapshots
CROSS_SNAPSHOT_QUALITY=85
CROSS_SNAPSHOT_MAX_FILES=1000
CROSS_SNAPSHOT_MAX_AGE_DAYS=3
CROSS_SNAPSHOT_CLEANUP_SEC=3600
# Path where the crossing CSV is written
CROSS_CSV=/tmp/crossings.csv
# --- Rate / performance ---
# Enable motion detection pre-filter: skip inference on frames with no movement
# (true/false, default: false). When enabled, frames below MOTION_THRESHOLD are
# skipped, saving NPU/CPU load.
MOTION_DETECTION_ENABLED=true
MOTION_PIXEL_DELTA=25
MOTION_MIN_AREA_FRAC=0.002
MOTION_HEARTBEAT_FRAMES=15
# Mean absolute pixel difference threshold (0–255) to consider a frame as having
# motion. Lower = more sensitive. Default 5.0.
MOTION_THRESHOLD=5.0
# Sliding window in seconds for computing the crossing rate (objects/minute)
RATE_WINDOW_SEC=60
# Number of frames to discard at startup to let the stream buffer stabilise
WARMUP_FRAMES=30
# Delay in seconds between stream reconnection attempts
RECONNECT_DELAY_SEC=3
# Maximum reconnection attempts (0 = infinite)
MAX_RECONNECT_ATTEMPTS=0
# Seconds after which a tracked but unseen object is pruned from the active set
TRACKED_PRUNE_SEC=300
# --- Video recording ---
# Save annotated frames to segmented MP4 files (true/false)
RECORD_VIDEO=false
# Duration in seconds of each video segment file
VIDEO_SEGMENT_SEC=3600
# Output video FPS (fallback if source FPS is unknown or ≤ 1)
OUTPUT_FPS=15
# --- Live stream snapshot ---
# Periodically write the latest annotated frame as JPEG for an external web server
LIVE_STREAM_ENABLED=true
LIVE_STREAM_FRAME_PATH=/dev/shm/zenai-kpc-counter/live_frame.jpg
# Path to the shared-memory snapshot file (served by nginx / lighttpd)
LIVE_STREAM_FRAME_PATH=/dev/shm/byetrack-counter/live_frame.jpg
# JPEG quality (1–100)
LIVE_STREAM_QUALITY=75
# Write the snapshot every N frames (lower = more frequent updates)
LIVE_STREAM_EVERY_N=2
# --- Dashboard (counter_dashboard.py) ---
# Flask secret key for session/cookie signing — change in production!
SECRET_KEY=change-me-in-production
# Bind address for the Flask web server
DASHBOARD_HOST=0.0.0.0
# Listen port for the dashboard web UI
DASHBOARD_PORT=5000
# Enable Flask debug mode (true/false) — auto-reloads on code changes; disable in production
FLASK_DEBUG=false
# Fallback name for the active counting-day JSON state file used by the dashboard
CURRENT_COUNTER_PATH=/tmp/bytetrack_current_counter.json
# Debug: set DEBUG_TRACKING=true to log per-frame tracking details to stdout
# DEBUG_TRACKING=false
SAVE_CROSS_SNAPSHOT=true
CROSS_SNAPSHOT_DIR=/opt/zenai-kpc-snaps/snapshots
CROSS_SNAPSHOT_CLEANUP_SEC=3600
CROSS_SNAPSHOT_MAX_AGE_DAYS=3
DEDUP_PX=-1
+6 -35
View File
@@ -814,9 +814,6 @@ 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();
@@ -1140,32 +1137,6 @@ 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');
@@ -1176,16 +1147,14 @@ 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 = '<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';
reconnectVideo();
startVideoWatchdog();
} else {
stopVideoWatchdog();
img.src = '';
img.style.display = 'none';
if (placeholder) placeholder.style.display = '';
@@ -1198,7 +1167,6 @@ function toggleVideo() {
}
function onVideoLoad() {
lastVideoFrameAt = Date.now();
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.className = 'video-status connected';
@@ -1210,7 +1178,10 @@ function onVideoError() {
if (overlay) overlay.classList.add('visible');
if (videoErrorTimer) clearTimeout(videoErrorTimer);
videoErrorTimer = setTimeout(() => {
reconnectVideo();
if (videoActive) {
const img = document.getElementById('live-video-img');
img.src = '/api/live-video?' + Date.now();
}
}, 2000);
}
@@ -1246,4 +1217,4 @@ function hideVideoError() {
</script>
</body>
</html>
</html>