Merge branch 'dsutanto-main' to main

This commit is contained in:
Alberto-Audrix committed 2026-07-12 15:08:56 +07:00
commit d9c04d36c0
5 files changed
+478 -6

No files matched your search

+179
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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()
+54
View File
@@ -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');