diff --git a/CONTROL.md b/CONTROL.md new file mode 100644 index 0000000..53e7f6e --- /dev/null +++ b/CONTROL.md @@ -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. diff --git a/config.env.example b/config.env.example index 536c25d..b386692 100644 --- a/config.env.example +++ b/config.env.example @@ -158,6 +158,32 @@ MOTION_MIN_AREA_FRAC=0.002 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) --- +# 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 # Number of frames to discard at startup to let the stream buffer stabilise diff --git a/counter_dashboard.py b/counter_dashboard.py index 298381d..0bbb1e9 100644 --- a/counter_dashboard.py +++ b/counter_dashboard.py @@ -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")) @@ -207,7 +211,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") diff --git a/counter_live_rknn.py b/counter_live_rknn.py index 57aadb8..f4ba27e 100644 --- a/counter_live_rknn.py +++ b/counter_live_rknn.py @@ -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 @@ -121,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 @@ -702,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 @@ -1095,6 +1200,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() @@ -1144,8 +1266,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) @@ -1367,6 +1498,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, ): @@ -1438,6 +1577,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() diff --git a/templates/dashboard.html b/templates/dashboard.html index 40648a4..d1304e3 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -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 @@ {{ site_name }} SNAPSHOTS + {% if control_enabled %}{% endif %} @@ -827,8 +830,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');