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');