forked from dsutanto/zenai-kpc-python
Merge branch 'dsutanto-main' to main
This commit is contained in:
commit
d9c04d36c0
5 files changed
+478
-6
No files matched your search
+179
@@ -0,0 +1,179 @@
|
||||
# Runtime Control — Start/Stop Counting
|
||||
|
||||
The counter can pause and resume object detection & counting **on the fly**,
|
||||
without restarting the process. There are three interchangeable ways to control
|
||||
it, and they all converge on a single source of truth: the **control file**.
|
||||
|
||||
- **Control file** — a small JSON file the counter polls.
|
||||
- **TCP control socket** — line commands over the network that update the file.
|
||||
- **Dashboard button** — a COUNTING ON/OFF toggle that writes the file via its API.
|
||||
|
||||
When counting is **OFF**, the counter skips inference entirely (no detection, no
|
||||
counting, lower CPU/NPU load), the video/live stream keeps running, and a
|
||||
`COUNTING PAUSED` badge is drawn on the frame. When **ON**, normal counting
|
||||
resumes.
|
||||
|
||||
---
|
||||
|
||||
## 1. Enable runtime control
|
||||
|
||||
Runtime control is opt-in. In your `.env`:
|
||||
|
||||
```ini
|
||||
# Master switch — required for ALL control methods (file, socket, dashboard).
|
||||
CONTROL_ENABLED=true
|
||||
|
||||
# Shared control file. MUST be identical for the counter and the dashboard.
|
||||
CONTROL_FILE=/opt/bytetrack-counter/control.json
|
||||
|
||||
# Counting state on startup / when the control file does not exist yet.
|
||||
CONTROL_DEFAULT_COUNTING=true
|
||||
|
||||
# How often (seconds) the counter re-reads the control file.
|
||||
CONTROL_POLL_SEC=1.0
|
||||
```
|
||||
|
||||
When `CONTROL_ENABLED=false`, the counter always counts, the control file is
|
||||
ignored, and the dashboard hides the toggle button.
|
||||
|
||||
> Changes take effect within `CONTROL_POLL_SEC` seconds (default 1s), because the
|
||||
> counter re-reads the file on a timer.
|
||||
|
||||
---
|
||||
|
||||
## 2. Control file
|
||||
|
||||
### Format
|
||||
|
||||
```json
|
||||
{ "counting": true }
|
||||
```
|
||||
|
||||
- `"counting": true` → counting **ON**
|
||||
- `"counting": false` → counting **OFF** (paused)
|
||||
|
||||
The counter creates this file on startup (seeded from `CONTROL_DEFAULT_COUNTING`)
|
||||
if it does not exist. All writers (counter, dashboard, socket) write it
|
||||
**atomically** (temp file + rename), so readers never see a half-written file.
|
||||
|
||||
### Toggle by editing the file
|
||||
|
||||
Pause counting:
|
||||
|
||||
```bash
|
||||
printf '{"counting": false}\n' > /opt/bytetrack-counter/control.json
|
||||
```
|
||||
|
||||
Resume counting:
|
||||
|
||||
```bash
|
||||
printf '{"counting": true}\n' > /opt/bytetrack-counter/control.json
|
||||
```
|
||||
|
||||
Check current state:
|
||||
|
||||
```bash
|
||||
cat /opt/bytetrack-counter/control.json
|
||||
```
|
||||
|
||||
> Use the exact path from your `CONTROL_FILE` setting. If you write it by hand,
|
||||
> keep it valid JSON — an unreadable file falls back to `CONTROL_DEFAULT_COUNTING`.
|
||||
|
||||
---
|
||||
|
||||
## 3. TCP control socket
|
||||
|
||||
The socket lets you toggle counting over the network. It updates the same control
|
||||
file, so changes still apply within `CONTROL_POLL_SEC`.
|
||||
|
||||
### Enable
|
||||
|
||||
```ini
|
||||
# Requires CONTROL_ENABLED=true as well.
|
||||
CONTROL_SOCKET_ENABLED=true
|
||||
|
||||
# 127.0.0.1 = local only. Use 0.0.0.0 to allow remote clients.
|
||||
CONTROL_SOCKET_HOST=127.0.0.1
|
||||
|
||||
# TCP port.
|
||||
CONTROL_SOCKET_PORT=5090
|
||||
```
|
||||
|
||||
### Commands
|
||||
|
||||
Newline-terminated, case-insensitive. One connection can send multiple commands.
|
||||
|
||||
| Command | Effect | Reply |
|
||||
|----------------------------|-------------------------------|-----------------------|
|
||||
| `START` / `RESUME` / `ON` | Counting ON | `OK counting=on` |
|
||||
| `STOP` / `PAUSE` / `OFF` | Counting OFF | `OK counting=off` |
|
||||
| `TOGGLE` | Flip current state | `OK counting=on/off` |
|
||||
| `STATUS` / `GET` | Report state (no change) | `OK counting=on/off` |
|
||||
| *(anything else)* | — | `ERR unknown command` |
|
||||
|
||||
### Examples
|
||||
|
||||
Using `nc` (netcat):
|
||||
|
||||
```bash
|
||||
printf 'STOP\n' | nc 127.0.0.1 5090
|
||||
printf 'START\n' | nc 127.0.0.1 5090
|
||||
printf 'TOGGLE\n' | nc 127.0.0.1 5090
|
||||
printf 'STATUS\n' | nc 127.0.0.1 5090
|
||||
```
|
||||
|
||||
Using bash `/dev/tcp` (no netcat needed):
|
||||
|
||||
```bash
|
||||
exec 3<>/dev/tcp/127.0.0.1/5090
|
||||
printf 'STATUS\n' >&3
|
||||
head -n1 <&3
|
||||
exec 3>&-
|
||||
```
|
||||
|
||||
Python client:
|
||||
|
||||
```python
|
||||
import socket
|
||||
|
||||
def control(cmd, host="127.0.0.1", port=5090):
|
||||
with socket.create_connection((host, port), timeout=2) as s:
|
||||
s.sendall((cmd + "\n").encode())
|
||||
return s.recv(256).decode().strip()
|
||||
|
||||
print(control("STATUS")) # OK counting=on
|
||||
print(control("STOP")) # OK counting=off
|
||||
```
|
||||
|
||||
> **Security:** the socket has **no authentication**. Keep `CONTROL_SOCKET_HOST`
|
||||
> on `127.0.0.1`, or restrict access with a firewall / trusted network if you
|
||||
> bind to `0.0.0.0`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Dashboard button
|
||||
|
||||
When `CONTROL_ENABLED=true`, the dashboard header shows a **COUNTING ON/OFF**
|
||||
button (green when on, red when off). Clicking it flips the state immediately.
|
||||
|
||||
The dashboard must point at the **same** `CONTROL_FILE` as the counter (set it in
|
||||
the dashboard's environment too). The dashboard exposes:
|
||||
|
||||
- `GET /api/control` → `{ "enabled": true, "counting": true }`
|
||||
- `POST /api/control` with body `{ "counting": false }` → writes the control file
|
||||
(returns `403` if `CONTROL_ENABLED=false`)
|
||||
|
||||
---
|
||||
|
||||
## Notes & behavior
|
||||
|
||||
- **Single source of truth:** the socket and dashboard both write the control
|
||||
file; the counter reacts only to the file. This avoids race conditions between
|
||||
control methods.
|
||||
- **Latency:** expect up to `CONTROL_POLL_SEC` (default 1s) between issuing a
|
||||
command and the counter reacting.
|
||||
- **Live stream keeps running** while paused, so you still see the camera feed
|
||||
with the `COUNTING PAUSED` overlay.
|
||||
- **Shared path requirement:** counter and dashboard must use the same
|
||||
`CONTROL_FILE`. If they run on different machines, use the TCP socket (or a
|
||||
shared network path) instead.
|
||||
+34
-1
@@ -28,7 +28,13 @@ NUM_CLASSES=4
|
||||
SCORE_SIGMOID=false
|
||||
|
||||
# --- Detection ---
|
||||
CONF=0.5
|
||||
# Confidence threshold – detections below this are discarded before NMS
|
||||
CONF=0.3
|
||||
# NMS IoU threshold (0-1): boxes overlapping the top box by more than this are
|
||||
# suppressed. RAISE it (e.g. 0.6-0.7) if two close/overlapping objects are being
|
||||
# merged into one and their separate boxes get suppressed. Lower = more aggressive
|
||||
# merging. Default 0.45.
|
||||
NMS_IOU=0.45
|
||||
|
||||
# --- ByteTrack tracking ---
|
||||
TRACK_HIGH_THRESH=0.5
|
||||
@@ -81,6 +87,33 @@ MOTION_PIXEL_DELTA=25
|
||||
MOTION_MIN_AREA_FRAC=0.002
|
||||
MOTION_HEARTBEAT_FRAMES=15
|
||||
MOTION_THRESHOLD=5.0
|
||||
|
||||
# --- Runtime control (start/stop counting on the fly) ---
|
||||
# When true, the counter watches a JSON control file and pauses/resumes object
|
||||
# detection & counting based on its "counting" flag. The dashboard shows a
|
||||
# COUNTING ON/OFF toggle button that writes this file. When false, the counter
|
||||
# always counts and the dashboard hides the toggle. (true/false, default: false)
|
||||
CONTROL_ENABLED=false
|
||||
# Path to the shared control file. MUST be identical for the counter and the
|
||||
# dashboard so the toggle takes effect. Contents: {"counting": true|false}
|
||||
CONTROL_FILE=/opt/bytetrack-counter/control.json
|
||||
# Counting state to assume on startup / when the control file does not exist yet.
|
||||
CONTROL_DEFAULT_COUNTING=true
|
||||
# How often (seconds) the counter re-reads the control file. Default 1.0.
|
||||
CONTROL_POLL_SEC=1.0
|
||||
# Optional TCP control socket (requires CONTROL_ENABLED=true). Lets you toggle
|
||||
# counting over the network with line commands. It updates the same control file,
|
||||
# so changes apply within CONTROL_POLL_SEC. (true/false, default: false)
|
||||
# Commands (newline-terminated): START|RESUME|ON, STOP|PAUSE|OFF, TOGGLE, STATUS
|
||||
# e.g. printf 'STOP\n' | nc 127.0.0.1 5090
|
||||
CONTROL_SOCKET_ENABLED=false
|
||||
# Bind address for the control socket. Use 127.0.0.1 for local-only, 0.0.0.0 to
|
||||
# allow remote clients (no auth — protect with firewall / trusted network).
|
||||
CONTROL_SOCKET_HOST=127.0.0.1
|
||||
# Control socket TCP port.
|
||||
CONTROL_SOCKET_PORT=5090
|
||||
|
||||
# Sliding window in seconds for computing the crossing rate (objects/minute)
|
||||
RATE_WINDOW_SEC=60
|
||||
WARMUP_FRAMES=30
|
||||
RECONNECT_DELAY_SEC=3
|
||||
|
||||
+57
-2
@@ -38,6 +38,10 @@ LIVE_STREAM_FRAME_PATH = os.getenv("LIVE_STREAM_FRAME_PATH", "/dev/shm/jetson-co
|
||||
CROSS_SNAPSHOT_DIR = os.getenv("CROSS_SNAPSHOT_DIR", f"{_DEFAULT_DIR}/snapshots")
|
||||
SAVE_DETECT_SNAPSHOT = os.getenv("SAVE_DETECT_SNAPSHOT", "false").lower() == "true"
|
||||
|
||||
CONTROL_ENABLED = os.getenv("CONTROL_ENABLED", "false").lower() == "true"
|
||||
CONTROL_FILE = os.getenv("CONTROL_FILE", f"{_DEFAULT_DIR}/control.json")
|
||||
CONTROL_DEFAULT_COUNTING = os.getenv("CONTROL_DEFAULT_COUNTING", "true").lower() == "true"
|
||||
|
||||
SITE_NAME = os.getenv("SITE_NAME", "LIVE")
|
||||
|
||||
DASHBOARD_PORT = int(os.getenv("DASHBOARD_PORT", "5000"))
|
||||
@@ -68,9 +72,13 @@ def api_live_video():
|
||||
continue
|
||||
with open(LIVE_STREAM_FRAME_PATH, "rb") as f:
|
||||
jpeg = f.read()
|
||||
if not _is_valid_jpeg(jpeg):
|
||||
if not jpeg or len(jpeg) < 2 or jpeg[:2] != b"\xff\xd8":
|
||||
consecutive_fails += 1
|
||||
if consecutive_fails >= MAX_FAILS:
|
||||
return
|
||||
time.sleep(0.05)
|
||||
continue
|
||||
consecutive_fails = 0
|
||||
yield (b"--frame\r\n"
|
||||
b"Content-Type: image/jpeg\r\n\r\n" + jpeg + b"\r\n")
|
||||
except FileNotFoundError:
|
||||
@@ -209,7 +217,54 @@ def get_counting_date(dt=None, cutoff_str=CUTOFF_TIME):
|
||||
|
||||
@app.route("/")
|
||||
def index():
|
||||
return render_template("dashboard.html", site_name=SITE_NAME, show_detect=SAVE_DETECT_SNAPSHOT)
|
||||
return render_template(
|
||||
"dashboard.html",
|
||||
site_name=SITE_NAME,
|
||||
show_detect=SAVE_DETECT_SNAPSHOT,
|
||||
control_enabled=CONTROL_ENABLED,
|
||||
)
|
||||
|
||||
|
||||
def _read_counting_flag():
|
||||
try:
|
||||
with open(CONTROL_FILE, "r", encoding="utf-8") as f:
|
||||
return bool(json.load(f).get("counting", CONTROL_DEFAULT_COUNTING))
|
||||
except FileNotFoundError:
|
||||
return CONTROL_DEFAULT_COUNTING
|
||||
except Exception:
|
||||
return CONTROL_DEFAULT_COUNTING
|
||||
|
||||
|
||||
def _write_counting_flag(counting):
|
||||
os.makedirs(os.path.dirname(CONTROL_FILE) or ".", exist_ok=True)
|
||||
tmp = f"{CONTROL_FILE}.tmp"
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
json.dump({"counting": bool(counting)}, f)
|
||||
os.replace(tmp, CONTROL_FILE)
|
||||
|
||||
|
||||
@app.route("/api/control", methods=["GET"])
|
||||
def api_control_get():
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"enabled": CONTROL_ENABLED,
|
||||
"counting": _read_counting_flag(),
|
||||
})
|
||||
|
||||
|
||||
@app.route("/api/control", methods=["POST"])
|
||||
def api_control_set():
|
||||
if not CONTROL_ENABLED:
|
||||
return jsonify({"success": False, "error": "Runtime control is disabled (set CONTROL_ENABLED=true)"}), 403
|
||||
data = request.get_json(silent=True) or {}
|
||||
if "counting" not in data:
|
||||
return jsonify({"success": False, "error": "Missing 'counting' field"}), 400
|
||||
try:
|
||||
counting = bool(data["counting"])
|
||||
_write_counting_flag(counting)
|
||||
return jsonify({"success": True, "counting": counting})
|
||||
except Exception as e:
|
||||
return jsonify({"success": False, "error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route("/snapshots")
|
||||
|
||||
+154
-3
@@ -7,8 +7,11 @@ Uses ByteTrack (Kalman filter + two-stage IoU association) for tracking.
|
||||
import numpy as np
|
||||
import cv2
|
||||
import csv
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
from collections import deque
|
||||
from datetime import datetime
|
||||
@@ -39,6 +42,7 @@ LINE_Y2_FRAC = float(os.getenv("LINE_Y2_FRAC", "0.66"))
|
||||
IMGSZ = int(os.getenv("IMGSZ", "320"))
|
||||
HALF = os.getenv("HALF", "false").lower() == "true"
|
||||
CONF = float(os.getenv("CONF", "0.3"))
|
||||
NMS_IOU = float(os.getenv("NMS_IOU", "0.45"))
|
||||
DEVICE = int(os.getenv("DEVICE", "0"))
|
||||
|
||||
# RKNN NPU core mask
|
||||
@@ -120,6 +124,21 @@ MOTION_MIN_AREA_FRAC = float(os.getenv("MOTION_MIN_AREA_FRAC", "0.002"))
|
||||
# slow/stationary object is never missed for long.
|
||||
MOTION_HEARTBEAT_FRAMES = int(os.getenv("MOTION_HEARTBEAT_FRAMES", "15"))
|
||||
|
||||
# --- Runtime control (toggle counting on/off on the fly) ---
|
||||
# When enabled, the process watches a small JSON control file and honors its
|
||||
# "counting" flag. Set false to always count (ignore the control file).
|
||||
CONTROL_ENABLED = os.getenv("CONTROL_ENABLED", "false").lower() == "true"
|
||||
CONTROL_FILE = os.getenv("CONTROL_FILE", f"{OUTPUT_DIR}/control.json")
|
||||
# Whether counting is active on startup when no control file exists yet.
|
||||
CONTROL_DEFAULT_COUNTING = os.getenv("CONTROL_DEFAULT_COUNTING", "true").lower() == "true"
|
||||
# Re-read the control file at most every N seconds.
|
||||
CONTROL_POLL_SEC = float(os.getenv("CONTROL_POLL_SEC", "1.0"))
|
||||
# Optional TCP control socket. When enabled, the counter listens for line-based
|
||||
# commands so counting can be toggled over the network (in addition to the file).
|
||||
CONTROL_SOCKET_ENABLED = os.getenv("CONTROL_SOCKET_ENABLED", "false").lower() == "true"
|
||||
CONTROL_SOCKET_HOST = os.getenv("CONTROL_SOCKET_HOST", "127.0.0.1")
|
||||
CONTROL_SOCKET_PORT = int(os.getenv("CONTROL_SOCKET_PORT", "5090"))
|
||||
|
||||
CROSS_FLASH_FRAMES = 12
|
||||
POPUP_LIFETIME = 20
|
||||
LINE_PULSE_FRAMES = 12
|
||||
@@ -701,6 +720,93 @@ def now_str():
|
||||
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
def read_counting_flag(default=True):
|
||||
"""Read the 'counting' flag from the control file. Returns default on any error."""
|
||||
try:
|
||||
with open(CONTROL_FILE, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
return bool(data.get("counting", default))
|
||||
except FileNotFoundError:
|
||||
return default
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def write_control_file(counting):
|
||||
"""Create/update the control file atomically (used to seed defaults)."""
|
||||
try:
|
||||
Path(CONTROL_FILE).parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = f"{CONTROL_FILE}.tmp"
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
json.dump({"counting": bool(counting)}, f)
|
||||
os.replace(tmp, CONTROL_FILE)
|
||||
except Exception as exc:
|
||||
print(f"[{now_str()}] Failed to write control file: {exc}")
|
||||
|
||||
|
||||
def start_control_socket():
|
||||
"""Start a TCP server for runtime control. Commands (newline-terminated):
|
||||
START | RESUME | ON -> counting on
|
||||
STOP | PAUSE | OFF -> counting off
|
||||
TOGGLE -> flip
|
||||
STATUS | GET -> report current state
|
||||
It writes the shared control file, so the main loop's file-poll applies it.
|
||||
Returns the server socket (call .close() to stop)."""
|
||||
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
srv.bind((CONTROL_SOCKET_HOST, CONTROL_SOCKET_PORT))
|
||||
srv.listen(5)
|
||||
|
||||
def handle(conn, addr):
|
||||
with conn:
|
||||
conn.settimeout(30)
|
||||
try:
|
||||
buf = b""
|
||||
while not shutdown_requested:
|
||||
try:
|
||||
chunk = conn.recv(256)
|
||||
except socket.timeout:
|
||||
break
|
||||
if not chunk:
|
||||
break
|
||||
buf += chunk
|
||||
while b"\n" in buf:
|
||||
line, buf = buf.split(b"\n", 1)
|
||||
cmd = line.decode("utf-8", "ignore").strip().upper()
|
||||
if not cmd:
|
||||
continue
|
||||
current = read_counting_flag(CONTROL_DEFAULT_COUNTING)
|
||||
if cmd in ("START", "RESUME", "ON"):
|
||||
write_control_file(True)
|
||||
resp = "OK counting=on"
|
||||
elif cmd in ("STOP", "PAUSE", "OFF"):
|
||||
write_control_file(False)
|
||||
resp = "OK counting=off"
|
||||
elif cmd == "TOGGLE":
|
||||
write_control_file(not current)
|
||||
resp = f"OK counting={'off' if current else 'on'}"
|
||||
elif cmd in ("STATUS", "GET"):
|
||||
resp = f"OK counting={'on' if current else 'off'}"
|
||||
else:
|
||||
resp = "ERR unknown command"
|
||||
conn.sendall((resp + "\n").encode("utf-8"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def loop():
|
||||
print(f"Control socket listening on {CONTROL_SOCKET_HOST}:{CONTROL_SOCKET_PORT}")
|
||||
while not shutdown_requested:
|
||||
try:
|
||||
conn, addr = srv.accept()
|
||||
except OSError:
|
||||
break
|
||||
t = threading.Thread(target=handle, args=(conn, addr), daemon=True)
|
||||
t.start()
|
||||
|
||||
threading.Thread(target=loop, daemon=True).start()
|
||||
return srv
|
||||
|
||||
|
||||
def open_capture(source):
|
||||
if source.lower().startswith(("rtsp://", "http://")):
|
||||
os.environ["OPENCV_FFMPEG_CAPTURE_OPTIONS"] = RTSP_FFMPEG_OPTIONS
|
||||
@@ -1067,6 +1173,7 @@ def run():
|
||||
core_mask=CORE_MASK,
|
||||
imgsz=IMGSZ,
|
||||
conf=CONF,
|
||||
iou=NMS_IOU,
|
||||
num_classes=NUM_CLASSES,
|
||||
score_sigmoid=SCORE_SIGMOID,
|
||||
)
|
||||
@@ -1105,6 +1212,23 @@ def run():
|
||||
counter_out = 0
|
||||
last_snapshot_cleanup = 0.0
|
||||
|
||||
counting_active = True
|
||||
last_control_poll = 0.0
|
||||
control_socket = None
|
||||
if CONTROL_ENABLED:
|
||||
if not Path(CONTROL_FILE).exists():
|
||||
write_control_file(CONTROL_DEFAULT_COUNTING)
|
||||
counting_active = read_counting_flag(CONTROL_DEFAULT_COUNTING)
|
||||
print(
|
||||
f"Runtime control enabled | file={CONTROL_FILE} | "
|
||||
f"counting={'ON' if counting_active else 'OFF'}"
|
||||
)
|
||||
if CONTROL_SOCKET_ENABLED:
|
||||
try:
|
||||
control_socket = start_control_socket()
|
||||
except Exception as exc:
|
||||
print(f"[{now_str()}] Failed to start control socket: {exc}")
|
||||
|
||||
cap, w, h, fps = connect_stream(SOURCE)
|
||||
if cap is None:
|
||||
store.shutdown()
|
||||
@@ -1154,8 +1278,17 @@ def run():
|
||||
cross_events_frame = []
|
||||
detect_events_frame = []
|
||||
|
||||
if CONTROL_ENABLED and (now - last_control_poll) >= CONTROL_POLL_SEC:
|
||||
last_control_poll = now
|
||||
new_flag = read_counting_flag(CONTROL_DEFAULT_COUNTING)
|
||||
if new_flag != counting_active:
|
||||
counting_active = new_flag
|
||||
print(f"[{now_str()}] Counting {'RESUMED' if counting_active else 'PAUSED'} via control file")
|
||||
|
||||
skip_inference = False
|
||||
if MOTION_DETECTION_ENABLED:
|
||||
if not counting_active:
|
||||
skip_inference = True
|
||||
elif MOTION_DETECTION_ENABLED:
|
||||
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
|
||||
if prev_gray is not None:
|
||||
diff = cv2.absdiff(gray, prev_gray)
|
||||
@@ -1377,6 +1510,14 @@ def run():
|
||||
)
|
||||
popups = draw_popups(frame, popups, frame_idx)
|
||||
|
||||
if CONTROL_ENABLED and not counting_active:
|
||||
badge = "COUNTING PAUSED"
|
||||
(bw, bh), _ = cv2.getTextSize(badge, cv2.FONT_HERSHEY_SIMPLEX, 0.6, 2)
|
||||
bx = w // 2 - bw // 2
|
||||
overlay_rect(frame, bx - 14, 48, bx + bw + 14, 48 + bh + 18, C_PANEL, alpha=0.75)
|
||||
cv2.rectangle(frame, (bx - 14, 48), (bx + bw + 14, 48 + bh + 18), C_ACCENT, 2)
|
||||
cv2.putText(frame, badge, (bx, 48 + bh + 6), cv2.FONT_HERSHEY_SIMPLEX, 0.6, C_ACCENT, 2, cv2.LINE_AA)
|
||||
|
||||
for flash_store in (
|
||||
object_cross_flash1, object_cross_flash2,
|
||||
):
|
||||
@@ -1393,10 +1534,15 @@ def run():
|
||||
|
||||
if LIVE_STREAM_ENABLED and frame_idx % LIVE_STREAM_EVERY_N == 0:
|
||||
try:
|
||||
_, jpeg = cv2.imencode(
|
||||
Path(LIVE_STREAM_FRAME_PATH).parent.mkdir(parents=True, exist_ok=True)
|
||||
ok, jpeg = cv2.imencode(
|
||||
".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, LIVE_STREAM_QUALITY]
|
||||
)
|
||||
_write_live_frame_atomic(LIVE_STREAM_FRAME_PATH, jpeg.tobytes())
|
||||
if ok:
|
||||
tmp_path = f"{LIVE_STREAM_FRAME_PATH}.tmp"
|
||||
with open(tmp_path, "wb") as f:
|
||||
f.write(jpeg.tobytes())
|
||||
os.replace(tmp_path, LIVE_STREAM_FRAME_PATH)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -1443,6 +1589,11 @@ def run():
|
||||
video_writer.release()
|
||||
if cross_logger:
|
||||
cross_logger.close()
|
||||
if control_socket is not None:
|
||||
try:
|
||||
control_socket.close()
|
||||
except Exception:
|
||||
pass
|
||||
model.release()
|
||||
store.shutdown()
|
||||
|
||||
|
||||
@@ -159,6 +159,8 @@
|
||||
}
|
||||
.nav-link:hover { border-color: var(--accent); color: var(--text-primary); background: var(--cell-bg); }
|
||||
.nav-link.active { border-color: var(--accent); color: var(--accent); }
|
||||
.nav-link.counting-on { border-color: rgba(0,255,136,0.5); color: var(--accent4); }
|
||||
.nav-link.counting-off { border-color: rgba(255,45,120,0.5); color: var(--accent3); }
|
||||
|
||||
/* Section title */
|
||||
.section-title {
|
||||
@@ -608,6 +610,7 @@
|
||||
<span>{{ site_name }}</span>
|
||||
</div>
|
||||
<a class="nav-link" href="/snapshots" title="Snapshots"><i class="fas fa-images"></i> SNAPSHOTS</a>
|
||||
{% if control_enabled %}<button class="nav-link" id="countingToggle" onclick="toggleCounting()" title="Start/stop counting"><i class="fas fa-power-off"></i> <span id="counting-label">--</span></button>{% endif %}
|
||||
<button class="theme-toggle" id="themeToggle" onclick="toggleTheme()" title="Toggle theme">☀️</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -830,8 +833,59 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
setInterval(() => {
|
||||
loadCurrentCounter();
|
||||
}, 2000);
|
||||
|
||||
if (document.getElementById('countingToggle')) {
|
||||
loadControlState();
|
||||
setInterval(loadControlState, 5000);
|
||||
}
|
||||
});
|
||||
|
||||
let countingActive = null;
|
||||
|
||||
async function loadControlState() {
|
||||
try {
|
||||
const res = await fetch('/api/control');
|
||||
const data = await res.json();
|
||||
countingActive = !!data.counting;
|
||||
renderCountingToggle();
|
||||
} catch (err) { /* ignore */ }
|
||||
}
|
||||
|
||||
function renderCountingToggle() {
|
||||
const btn = document.getElementById('countingToggle');
|
||||
const label = document.getElementById('counting-label');
|
||||
if (!btn || !label) return;
|
||||
if (countingActive) {
|
||||
label.textContent = 'COUNTING ON';
|
||||
btn.classList.add('counting-on');
|
||||
btn.classList.remove('counting-off');
|
||||
} else {
|
||||
label.textContent = 'COUNTING OFF';
|
||||
btn.classList.add('counting-off');
|
||||
btn.classList.remove('counting-on');
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleCounting() {
|
||||
const next = !countingActive;
|
||||
try {
|
||||
const res = await fetch('/api/control', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ counting: next }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
countingActive = !!data.counting;
|
||||
renderCountingToggle();
|
||||
} else {
|
||||
alert('Control error: ' + (data.error || 'unknown'));
|
||||
}
|
||||
} catch (err) {
|
||||
alert('Control request failed: ' + err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCurrentCounter() {
|
||||
try {
|
||||
const res = await fetch('/api/current-counter');
|
||||
|
||||
Reference in new issue
Block a user