Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6639c4f7ee | ||
|
|
47e9381269 | ||
|
|
706f9261fb | ||
|
|
b52e876eed | ||
|
|
c80984ba80 | ||
|
|
3c30ded269 | ||
|
|
41c4a558b1 | ||
|
|
cb0b8ac1d9 | ||
|
|
2b52ce6e6d | ||
|
|
783b785b4f | ||
|
|
25f3baddc9 | ||
|
|
1920ff183b | ||
|
|
a72d35776e | ||
|
|
3af0b8188f | ||
|
|
6071d56dc3 | ||
|
|
d9c04d36c0 | ||
|
|
314b598c66 | ||
|
|
af2854d771 |
No files matched your search
@@ -0,0 +1,258 @@
|
||||
# ZenAI KPC Counter — Edge Deployment Guide
|
||||
|
||||
Production deployment for **RK3588** (or compatible RKNN NPU) edge devices running:
|
||||
|
||||
| Component | Script | systemd unit |
|
||||
|-----------|--------|--------------|
|
||||
| Status webhook (IN/OUT/OFF gate) | `status_webhook.py` | `zenai-kpc-status-webhook.service` |
|
||||
| RTSP counter (RKNN + ByteTrack) | `counter_live_rknn.py` | `zenai-kpc-counter.service` |
|
||||
| Web dashboard (Flask) | `counter_dashboard.py` | `zenai-kpc-dashboard.service` |
|
||||
|
||||
All three processes share a single `.env` file. The counter and dashboard read/write the same SQLite database and state JSON. The webhook writes `status_webhook_state.json`, which the counter polls when `STATUS_WEBHOOK_ENABLED=true`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Prerequisites
|
||||
|
||||
### Hardware & OS
|
||||
|
||||
- RK3588 board (or Jetson/RK device with RKNN Lite runtime)
|
||||
- Linux with systemd
|
||||
- Network access to the RTSP camera stream
|
||||
|
||||
### System packages
|
||||
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install -y python3 python3-venv python3-pip ffmpeg libgl1 openssl
|
||||
```
|
||||
|
||||
`ffmpeg` is required for low-latency RTSP capture via OpenCV and for session MP4 recording (`RECORD_VIDEO=true`). `libgl1` is often needed for `opencv-python` on headless systems.
|
||||
|
||||
### RKNN model
|
||||
|
||||
Export or copy your `.rknn` model to the device, e.g.:
|
||||
|
||||
```text
|
||||
/opt/models/your_model.rknn
|
||||
```
|
||||
|
||||
Set `MODEL_PATH` in `.env` to match. The model class count must match `NUM_CLASSES`, and `OBJECT_CLASS_ID` must point at the class you count.
|
||||
|
||||
---
|
||||
|
||||
## 2. Directory layout
|
||||
|
||||
Default paths used by the service files and `env.example`:
|
||||
|
||||
```text
|
||||
/opt/zenai-kpc-python/ # application code (this repo)
|
||||
├── counter_live_rknn.py
|
||||
├── counter_dashboard.py
|
||||
├── counter_store.py
|
||||
├── status_webhook.py
|
||||
├── templates/
|
||||
├── venv/ # Python virtual environment (created during install)
|
||||
├── .env # runtime config (not in git)
|
||||
├── env.example # template — copy to .env
|
||||
└── DEPLOY.md
|
||||
|
||||
/opt/zenai-kpc-counter/ # persistent runtime data (created automatically)
|
||||
├── counter.db # SQLite daily records
|
||||
├── current_counter.json # live counting-day state
|
||||
├── snapshots/ # crossing/detect JPEGs (if enabled)
|
||||
└── crossings.csv # optional per-event CSV
|
||||
|
||||
/opt/models/ # RKNN models (deploy separately)
|
||||
/dev/shm/zenai-kpc-counter/ # live JPEG frame for dashboard video (tmpfs)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Install application
|
||||
|
||||
### 3.1 Copy code to the device
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /opt/zenai-kpc-python
|
||||
sudo rsync -av --exclude venv --exclude .env --exclude __pycache__ \
|
||||
./ /opt/zenai-kpc-python/
|
||||
# Or: sudo git clone <repo-url> /opt/zenai-kpc-python
|
||||
```
|
||||
|
||||
### 3.2 Create virtual environment and install dependencies
|
||||
|
||||
```bash
|
||||
cd /opt/zenai-kpc-python
|
||||
sudo python3 -m venv venv
|
||||
sudo ./venv/bin/pip install --upgrade pip
|
||||
sudo ./venv/bin/pip install -r requirements.txt
|
||||
```
|
||||
|
||||
> `rknn-toolkit-lite2` is platform-specific. Install on the target ARM device, not on a Windows dev machine.
|
||||
|
||||
### 3.3 Create runtime config
|
||||
|
||||
```bash
|
||||
cd /opt/zenai-kpc-python
|
||||
sudo cp env.example .env
|
||||
sudo nano .env
|
||||
```
|
||||
|
||||
**Minimum values to edit before starting:**
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `SOURCE` | RTSP URL or local video file path |
|
||||
| `MODEL_PATH` | Path to your `.rknn` model on device |
|
||||
| `NUM_CLASSES` | Must match the exported model |
|
||||
| `OBJECT_CLASS_ID` | Class index of the object being counted |
|
||||
| `CLASS_OBJECT` / `OBJECT_LABEL` | Labels stored in DB (e.g. `karung`) |
|
||||
| `LINE_Y1_FRAC` / `LINE_Y2_FRAC` | Counting line positions (tune per camera) |
|
||||
| `SECRET_KEY` | Random string for Flask sessions |
|
||||
|
||||
Ensure `STATE_FILE` and `DB_PATH` both live under `/opt/zenai-kpc-counter/` so data survives reboots (avoid `/tmp` in production).
|
||||
|
||||
### 3.4 Create data directories (optional — app creates most paths automatically)
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /opt/zenai-kpc-counter /opt/models /dev/shm/zenai-kpc-counter
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Install systemd services
|
||||
|
||||
```bash
|
||||
cd /opt/zenai-kpc-python
|
||||
sudo cp zenai-kpc-status-webhook.service zenai-kpc-counter.service zenai-kpc-dashboard.service /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable zenai-kpc-status-webhook zenai-kpc-counter zenai-kpc-dashboard
|
||||
sudo systemctl start zenai-kpc-status-webhook
|
||||
sudo systemctl start zenai-kpc-counter
|
||||
sudo systemctl start zenai-kpc-dashboard
|
||||
```
|
||||
|
||||
The dashboard unit starts **after** the counter unit (`After=zenai-kpc-counter.service`). Enable the webhook so it comes up on boot with the other two.
|
||||
|
||||
### Verify
|
||||
|
||||
```bash
|
||||
systemctl status zenai-kpc-status-webhook
|
||||
systemctl status zenai-kpc-counter
|
||||
systemctl status zenai-kpc-dashboard
|
||||
journalctl -u zenai-kpc-status-webhook -f
|
||||
```
|
||||
|
||||
Open the dashboard in a browser:
|
||||
|
||||
```text
|
||||
http://<device-ip>:5000
|
||||
```
|
||||
|
||||
(Port is set by `DASHBOARD_PORT` in `.env`, default `5000`.)
|
||||
|
||||
Status webhook endpoints (fixed ports):
|
||||
|
||||
```text
|
||||
http://<device-ip>:8002/?lokasi=IN|OUT&status=ON|OFF
|
||||
https://<device-ip>:8443/?lokasi=IN|OUT&status=ON|OFF
|
||||
http://<device-ip>:8002/status
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Operations
|
||||
|
||||
### Restart after config change
|
||||
|
||||
```bash
|
||||
sudo systemctl restart zenai-kpc-status-webhook
|
||||
sudo systemctl restart zenai-kpc-counter
|
||||
sudo systemctl restart zenai-kpc-dashboard
|
||||
```
|
||||
|
||||
### View logs
|
||||
|
||||
```bash
|
||||
journalctl -u zenai-kpc-status-webhook -n 100 --no-pager
|
||||
journalctl -u zenai-kpc-counter -n 100 --no-pager
|
||||
journalctl -u zenai-kpc-dashboard -n 100 --no-pager
|
||||
```
|
||||
|
||||
### Stop services
|
||||
|
||||
```bash
|
||||
sudo systemctl stop zenai-kpc-dashboard zenai-kpc-counter zenai-kpc-status-webhook
|
||||
```
|
||||
|
||||
The counter handles `SIGTERM` gracefully — it finishes the current frame, persists state to SQLite, then exits.
|
||||
|
||||
### Update application code
|
||||
|
||||
```bash
|
||||
cd /opt/zenai-kpc-python
|
||||
sudo systemctl stop zenai-kpc-dashboard zenai-kpc-counter zenai-kpc-status-webhook
|
||||
# rsync or git pull new code
|
||||
sudo ./venv/bin/pip install -r requirements.txt # if dependencies changed
|
||||
sudo systemctl start zenai-kpc-status-webhook zenai-kpc-counter zenai-kpc-dashboard
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Troubleshooting
|
||||
|
||||
| Symptom | Things to check |
|
||||
|---------|-----------------|
|
||||
| Counter won't start | `journalctl -u zenai-kpc-counter`; verify `MODEL_PATH` exists; RKNN drivers installed |
|
||||
| Status webhook won't start | `journalctl -u zenai-kpc-status-webhook`; ports 8002/8443 free; `openssl` installed if certs are missing |
|
||||
| No RTSP frames | Ping camera; test with `ffplay <SOURCE>`; check `OPENCV_FFMPEG_CAPTURE_OPTIONS` |
|
||||
| Dashboard shows 0 count | `STATE_FILE` in `.env` must match between counter and dashboard; check file exists |
|
||||
| Counting stays OFF | Check `STATUS_WEBHOOK_ENABLED` and `GET http://<device-ip>:8002/status`; `STATUS_WEBHOOK_FILE` must match the webhook state file |
|
||||
| Live video blank | `LIVE_STREAM_ENABLED=true`; path matches `LIVE_STREAM_FRAME_PATH` in both processes; if using nginx, set `proxy_read_timeout` (see §7) |
|
||||
| Wrong counts | Tune `LINE_Y1_FRAC`/`LINE_Y2_FRAC`, `CONF`, ByteTrack thresholds; enable `DEBUG_TRACKING=true` temporarily |
|
||||
| Service keeps restarting | `journalctl -u zenai-kpc-counter -e`; often missing model, bad RTSP URL, or venv not created |
|
||||
|
||||
### Manual test (without systemd)
|
||||
|
||||
```bash
|
||||
cd /opt/zenai-kpc-python
|
||||
source venv/bin/activate
|
||||
python status_webhook.py # terminal 1
|
||||
python counter_live_rknn.py # terminal 2
|
||||
python counter_dashboard.py # terminal 3
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Optional: reverse proxy
|
||||
|
||||
For HTTPS or port 80 access, put nginx in front of the dashboard:
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name counter.example.com;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:5000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_buffering off; # required for /api/live-video MJPEG stream
|
||||
proxy_read_timeout 3600s; # keep long-lived MJPEG connections open
|
||||
proxy_send_timeout 3600s;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The live JPEG at `LIVE_STREAM_FRAME_PATH` can also be served statically by nginx if you prefer not to use the Flask MJPEG endpoint.
|
||||
|
||||
---
|
||||
|
||||
## 8. Security notes
|
||||
|
||||
- Change `SECRET_KEY` from the default before exposing the dashboard on a network.
|
||||
- Services currently run as `root` for simplicity on edge devices. For hardened deployments, create a dedicated user, chown `/opt/zenai-kpc-counter`, and update the `User=` / `Group=` lines in the service files.
|
||||
- Do not commit `.env` — it may contain RTSP credentials.
|
||||
- Set `FLASK_DEBUG=false` in production.
|
||||
+37
-7
@@ -92,9 +92,11 @@ CLASS_OBJECT=object
|
||||
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)
|
||||
# Two horizontal counting lines with sequence logic:
|
||||
# Line 1 (IN): top→down counts IN (OUT→IN sequence, or IN-only)
|
||||
# Line 2 (OUT): bottom→up counts OUT (IN→OUT sequence, or OUT-only)
|
||||
# Passing the opposite line first only arms; the destination line still counts
|
||||
# even if the opposite line was never crossed.
|
||||
# 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)
|
||||
@@ -184,6 +186,19 @@ CONTROL_SOCKET_HOST=127.0.0.1
|
||||
# Control socket TCP port.
|
||||
CONTROL_SOCKET_PORT=5090
|
||||
|
||||
# --- Status webhook gate (count only when status is NYALA) ---
|
||||
# When true, counting runs only while the webhook state file reports NYALA
|
||||
# (or STATUS_WEBHOOK_ACTIVE_VALUE). Any other/missing status pauses counting.
|
||||
STATUS_WEBHOOK_ENABLED=false
|
||||
# JSON file written by the external status webhook.
|
||||
# Example active: {"status": "IN / OUT"}
|
||||
# Example inactive: {"status": "OFF"}
|
||||
STATUS_WEBHOOK_FILE=/opt/zenai-kpc-python/status_webhook_state
|
||||
# Status value that allows counting (case-insensitive match).
|
||||
STATUS_WEBHOOK_ACTIVE_VALUE=IN, OUT
|
||||
# Expected inactive status from the webhook (shown on overlay when paused).
|
||||
STATUS_WEBHOOK_INACTIVE_VALUE=OFF
|
||||
|
||||
# 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
|
||||
@@ -192,13 +207,28 @@ WARMUP_FRAMES=30
|
||||
RECONNECT_DELAY_SEC=3
|
||||
# Maximum reconnection attempts (0 = infinite)
|
||||
MAX_RECONNECT_ATTEMPTS=0
|
||||
# Max stream-error log lines per outage (Cannot open / Stream dropped).
|
||||
# After this, further errors are silent until the stream recovers; then the
|
||||
# budget resets for the next outage. 0 = unlimited.
|
||||
STREAM_ERROR_LOG_MAX=3
|
||||
# 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
|
||||
# Session mode (default): ByteTrack-style — encode pending MP4 in SHM_DIR, start on
|
||||
# webhook OFF→IN/OUT (or control resume), stop on IN/OUT→OFF, then background-copy to
|
||||
# VIDEO_OUTPUT_DIR/<counting_date>/. Point VIDEO_OUTPUT_DIR at NFS for remote store.
|
||||
# segment mode: legacy continuous dumps under OUTPUT_DIR every VIDEO_SEGMENT_SEC.
|
||||
RECORD_VIDEO=true
|
||||
RECORD_MODE=session
|
||||
SHM_DIR=/dev/shm/zenai-kpc-counter
|
||||
VIDEO_OUTPUT_DIR=/opt/bytetrack-counter
|
||||
RECORD_END_DELAY=1
|
||||
RECORD_DISCARD_EMPTY=true
|
||||
RECORD_RETENTION_DAYS=3
|
||||
RECORD_CRF=18
|
||||
RECORD_PRESET=slow
|
||||
# Legacy segment length (RECORD_MODE=segment only)
|
||||
VIDEO_SEGMENT_SEC=3600
|
||||
# Output video FPS (fallback if source FPS is unknown or ≤ 1)
|
||||
OUTPUT_FPS=15
|
||||
@@ -207,7 +237,7 @@ OUTPUT_FPS=15
|
||||
# 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
|
||||
LIVE_STREAM_FRAME_PATH=/dev/shm/zenai-kpc-counter/live_frame.jpg
|
||||
# JPEG quality (1–100)
|
||||
LIVE_STREAM_QUALITY=75
|
||||
# Write the snapshot every N frames (lower = more frequent updates)
|
||||
|
||||
+310
-62
@@ -43,6 +43,8 @@ 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")
|
||||
CAMERA_NAME = os.getenv("CAMERA_NAME", "CC1")
|
||||
OBJECT_LABEL = os.getenv("OBJECT_LABEL", "object")
|
||||
|
||||
DASHBOARD_PORT = int(os.getenv("DASHBOARD_PORT", "5000"))
|
||||
DASHBOARD_HOST = os.getenv("DASHBOARD_HOST", "0.0.0.0")
|
||||
@@ -266,46 +268,319 @@ def snapshots_page():
|
||||
return render_template("snapshots.html", site_name=SITE_NAME, show_detect=SAVE_DETECT_SNAPSHOT)
|
||||
|
||||
|
||||
@app.route("/api/current-counter")
|
||||
def api_current_counter():
|
||||
try:
|
||||
with open(CURRENT_COUNTER_PATH, "r") as f:
|
||||
def _empty_current():
|
||||
return {
|
||||
"counting_date": get_counting_date(),
|
||||
"count": 0,
|
||||
"count_in": 0,
|
||||
"count_out": 0,
|
||||
"start_time": None,
|
||||
"last_detection_time": None,
|
||||
}
|
||||
|
||||
|
||||
def _load_current_state():
|
||||
"""Load live counter state from current_counter.json."""
|
||||
with open(CURRENT_COUNTER_PATH, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
return {
|
||||
"counting_date": data.get("counting_date") or get_counting_date(),
|
||||
"count": int(data.get("count", 0) or 0),
|
||||
"count_in": int(data.get("count_in", 0) or 0),
|
||||
"count_out": int(data.get("count_out", 0) or 0),
|
||||
"start_time": data.get("start_time"),
|
||||
"last_detection_time": data.get("last_detection_time"),
|
||||
}
|
||||
|
||||
|
||||
def _zero_day_row(date_str, camera_name=None, object_label=None):
|
||||
"""Stub row for a counting day with no activity."""
|
||||
return {
|
||||
"date": date_str,
|
||||
"camera_name": camera_name or CAMERA_NAME,
|
||||
"object_label": object_label or OBJECT_LABEL,
|
||||
"total_count": 0,
|
||||
"total_in": 0,
|
||||
"total_out": 0,
|
||||
"diff": 0,
|
||||
"start_time": None,
|
||||
"end_time": None,
|
||||
"updated_at": None,
|
||||
}
|
||||
|
||||
|
||||
def _fill_date_gaps(rows, date_from=None, date_to=None, descending=True):
|
||||
"""Ensure every date in [date_from, date_to] is present; missing days are 0."""
|
||||
by_date = {r["date"]: r for r in rows}
|
||||
if date_to is None:
|
||||
date_to = get_counting_date()
|
||||
if date_from is None:
|
||||
if not by_date:
|
||||
return rows
|
||||
date_from = min(by_date.keys())
|
||||
|
||||
sample = next(iter(by_date.values()), None)
|
||||
cam = sample["camera_name"] if sample else CAMERA_NAME
|
||||
lab = sample["object_label"] if sample else OBJECT_LABEL
|
||||
|
||||
filled = []
|
||||
d = datetime.strptime(date_from, "%Y-%m-%d").date()
|
||||
end = datetime.strptime(date_to, "%Y-%m-%d").date()
|
||||
if d > end:
|
||||
return rows
|
||||
|
||||
while d <= end:
|
||||
key = d.isoformat()
|
||||
if key in by_date:
|
||||
filled.append(by_date[key])
|
||||
else:
|
||||
filled.append(_zero_day_row(key, cam, lab))
|
||||
d += timedelta(days=1)
|
||||
|
||||
if descending:
|
||||
filled.reverse()
|
||||
return filled
|
||||
|
||||
|
||||
def _query_history(date_from=None, date_to=None, days=None, limit=None, offset=0):
|
||||
"""Query daily_counters with optional date range and pagination.
|
||||
|
||||
Missing dates in the requested range are filled with zero totals so the API
|
||||
always returns a continuous series.
|
||||
"""
|
||||
conn = get_db()
|
||||
cur = conn.cursor()
|
||||
|
||||
clauses = []
|
||||
params = []
|
||||
|
||||
if days is not None and date_from is None and date_to is None:
|
||||
date_from = (datetime.now() - timedelta(days=days)).date().isoformat()
|
||||
|
||||
if date_from:
|
||||
clauses.append("counting_date >= ?")
|
||||
params.append(date_from)
|
||||
if date_to:
|
||||
clauses.append("counting_date <= ?")
|
||||
params.append(date_to)
|
||||
|
||||
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
|
||||
|
||||
sql = f"""
|
||||
SELECT counting_date, camera_name, object_label,
|
||||
total_count, total_in, total_out, start_time, end_time, updated_at
|
||||
FROM daily_counters
|
||||
{where}
|
||||
ORDER BY counting_date ASC
|
||||
"""
|
||||
cur.execute(sql, params)
|
||||
rows = [
|
||||
{
|
||||
"date": row["counting_date"],
|
||||
"camera_name": row["camera_name"],
|
||||
"object_label": row["object_label"],
|
||||
"total_count": row["total_count"],
|
||||
"total_in": row["total_in"],
|
||||
"total_out": row["total_out"],
|
||||
"diff": (row["total_in"] or 0) - (row["total_out"] or 0),
|
||||
"start_time": row["start_time"],
|
||||
"end_time": row["end_time"],
|
||||
"updated_at": row["updated_at"],
|
||||
}
|
||||
for row in cur.fetchall()
|
||||
]
|
||||
conn.close()
|
||||
|
||||
fill_to = date_to or get_counting_date()
|
||||
rows = _fill_date_gaps(rows, date_from=date_from, date_to=fill_to, descending=True)
|
||||
total = len(rows)
|
||||
if limit is not None:
|
||||
rows = rows[offset: offset + limit]
|
||||
return rows, total
|
||||
|
||||
|
||||
@app.route("/api/current")
|
||||
@app.route("/api/current-counter")
|
||||
def api_current():
|
||||
"""Current counting-day totals from live state file."""
|
||||
try:
|
||||
state = _load_current_state()
|
||||
return jsonify(
|
||||
{
|
||||
"success": True,
|
||||
"counting_date": data.get("counting_date"),
|
||||
"count": data.get("count", 0),
|
||||
"count_in": data.get("count_in", 0),
|
||||
"count_out": data.get("count_out", 0),
|
||||
"start_time": data.get("start_time"),
|
||||
"last_detection_time": data.get("last_detection_time"),
|
||||
"active": True,
|
||||
"site_name": SITE_NAME,
|
||||
"counting": _read_counting_flag() if CONTROL_ENABLED else True,
|
||||
**state,
|
||||
}
|
||||
)
|
||||
except FileNotFoundError:
|
||||
return jsonify(
|
||||
{
|
||||
"success": False,
|
||||
"error": "No active counter",
|
||||
"count": 0,
|
||||
"count_in": 0,
|
||||
"count_out": 0,
|
||||
"counting_date": None,
|
||||
"success": True,
|
||||
"site_name": SITE_NAME,
|
||||
"counting": _read_counting_flag() if CONTROL_ENABLED else True,
|
||||
"active": False,
|
||||
"error": "No active counter — state file missing",
|
||||
**_empty_current(),
|
||||
}
|
||||
), 200
|
||||
except Exception as e:
|
||||
return jsonify(
|
||||
{
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"count": 0,
|
||||
"count_in": 0,
|
||||
"count_out": 0,
|
||||
"counting_date": None,
|
||||
"error": f"Failed to read current counter from {CURRENT_COUNTER_PATH}: {e}",
|
||||
"site_name": SITE_NAME,
|
||||
**_empty_current(),
|
||||
}
|
||||
), 500
|
||||
|
||||
|
||||
@app.route("/api/history")
|
||||
def api_history():
|
||||
"""
|
||||
Historical daily counters.
|
||||
|
||||
Query params:
|
||||
days – last N calendar days (default 30; ignored if date_from/date_to set)
|
||||
date_from – inclusive YYYY-MM-DD
|
||||
date_to – inclusive YYYY-MM-DD
|
||||
limit – page size (default: all matching)
|
||||
offset – page offset (default 0)
|
||||
"""
|
||||
try:
|
||||
days = request.args.get("days", type=int)
|
||||
date_from = request.args.get("date_from")
|
||||
date_to = request.args.get("date_to")
|
||||
limit = request.args.get("limit", type=int)
|
||||
offset = request.args.get("offset", 0, type=int)
|
||||
|
||||
if date_from:
|
||||
try:
|
||||
datetime.strptime(date_from, "%Y-%m-%d")
|
||||
except ValueError:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": {
|
||||
"code": "VALIDATION_ERROR",
|
||||
"message": f"Invalid date_from '{date_from}'. Use YYYY-MM-DD.",
|
||||
},
|
||||
}), 400
|
||||
if date_to:
|
||||
try:
|
||||
datetime.strptime(date_to, "%Y-%m-%d")
|
||||
except ValueError:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": {
|
||||
"code": "VALIDATION_ERROR",
|
||||
"message": f"Invalid date_to '{date_to}'. Use YYYY-MM-DD.",
|
||||
},
|
||||
}), 400
|
||||
|
||||
if days is None and date_from is None and date_to is None:
|
||||
days = 30
|
||||
if offset < 0:
|
||||
offset = 0
|
||||
if limit is not None and limit < 1:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": {
|
||||
"code": "VALIDATION_ERROR",
|
||||
"message": "limit must be a positive integer",
|
||||
},
|
||||
}), 400
|
||||
|
||||
rows, total = _query_history(
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
days=days,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
payload = {
|
||||
"success": True,
|
||||
"site_name": SITE_NAME,
|
||||
"filters": {
|
||||
"days": days,
|
||||
"date_from": date_from,
|
||||
"date_to": date_to,
|
||||
},
|
||||
"count": len(rows),
|
||||
"data": rows,
|
||||
}
|
||||
if limit is not None:
|
||||
payload["pagination"] = {
|
||||
"offset": offset,
|
||||
"limit": limit,
|
||||
"total": total,
|
||||
"has_next": offset + limit < total,
|
||||
"has_prev": offset > 0,
|
||||
}
|
||||
else:
|
||||
payload["total"] = total
|
||||
return jsonify(payload)
|
||||
except sqlite3.OperationalError as e:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": {
|
||||
"code": "DATABASE_UNAVAILABLE",
|
||||
"message": f"Database unavailable at {DB_PATH}: {e}",
|
||||
},
|
||||
"data": [],
|
||||
"count": 0,
|
||||
"total": 0,
|
||||
}), 200
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": {
|
||||
"code": "INTERNAL_ERROR",
|
||||
"message": str(e),
|
||||
},
|
||||
}), 500
|
||||
|
||||
|
||||
@app.route("/api/history/<counting_date>")
|
||||
def api_history_day(counting_date):
|
||||
"""Single counting-day record by YYYY-MM-DD."""
|
||||
try:
|
||||
datetime.strptime(counting_date, "%Y-%m-%d")
|
||||
except ValueError:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": {
|
||||
"code": "VALIDATION_ERROR",
|
||||
"message": f"Invalid counting_date '{counting_date}'. Use YYYY-MM-DD.",
|
||||
},
|
||||
}), 400
|
||||
|
||||
try:
|
||||
rows, _ = _query_history(date_from=counting_date, date_to=counting_date)
|
||||
data = rows[0] if rows else _zero_day_row(counting_date)
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"site_name": SITE_NAME,
|
||||
"data": data,
|
||||
})
|
||||
except sqlite3.OperationalError as e:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": {
|
||||
"code": "DATABASE_UNAVAILABLE",
|
||||
"message": f"Database unavailable at {DB_PATH}: {e}",
|
||||
},
|
||||
}), 503
|
||||
except Exception as e:
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"error": {
|
||||
"code": "INTERNAL_ERROR",
|
||||
"message": str(e),
|
||||
},
|
||||
}), 500
|
||||
|
||||
|
||||
@app.route("/api/summary")
|
||||
def api_summary():
|
||||
try:
|
||||
@@ -395,27 +670,19 @@ def api_daily_data():
|
||||
try:
|
||||
days = request.args.get("days", 30, type=int)
|
||||
date_from = (datetime.now() - timedelta(days=days)).date().isoformat()
|
||||
conn = get_db()
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT counting_date, total_count, total_in, total_out
|
||||
FROM daily_counters
|
||||
WHERE counting_date >= ?
|
||||
ORDER BY counting_date ASC
|
||||
""",
|
||||
(date_from,),
|
||||
)
|
||||
date_to = get_counting_date()
|
||||
rows, _ = _query_history(date_from=date_from, date_to=date_to)
|
||||
# ASC for charts
|
||||
rows = list(reversed(rows))
|
||||
daily_data = [
|
||||
{
|
||||
"date": row["counting_date"],
|
||||
"date": row["date"],
|
||||
"total_count": row["total_count"],
|
||||
"total_in": row["total_in"],
|
||||
"total_out": row["total_out"],
|
||||
}
|
||||
for row in cur.fetchall()
|
||||
for row in rows
|
||||
]
|
||||
conn.close()
|
||||
return jsonify(daily_data)
|
||||
except sqlite3.OperationalError:
|
||||
return jsonify([]), 200
|
||||
@@ -426,27 +693,18 @@ def api_daily_data():
|
||||
@app.route("/api/available-dates")
|
||||
def api_available_dates():
|
||||
try:
|
||||
conn = get_db()
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT counting_date, total_count, total_in, total_out, start_time, end_time
|
||||
FROM daily_counters
|
||||
ORDER BY counting_date DESC
|
||||
"""
|
||||
)
|
||||
rows, _ = _query_history()
|
||||
dates = [
|
||||
{
|
||||
"date": row["counting_date"],
|
||||
"date": row["date"],
|
||||
"total_count": row["total_count"],
|
||||
"total_in": row["total_in"],
|
||||
"total_out": row["total_out"],
|
||||
"start_time": row["start_time"],
|
||||
"end_time": row["end_time"],
|
||||
}
|
||||
for row in cur.fetchall()
|
||||
for row in rows
|
||||
]
|
||||
conn.close()
|
||||
return jsonify(dates)
|
||||
except sqlite3.OperationalError:
|
||||
return jsonify([]), 200
|
||||
@@ -496,19 +754,9 @@ def export_daily_xlsx():
|
||||
try:
|
||||
days = request.args.get("days", 30, type=int)
|
||||
date_from = (datetime.now() - timedelta(days=days)).date().isoformat()
|
||||
conn = get_db()
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT counting_date, total_count, total_in, total_out, start_time, end_time
|
||||
FROM daily_counters
|
||||
WHERE counting_date >= ?
|
||||
ORDER BY counting_date ASC
|
||||
""",
|
||||
(date_from,),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
conn.close()
|
||||
date_to = get_counting_date()
|
||||
rows, _ = _query_history(date_from=date_from, date_to=date_to)
|
||||
rows = list(reversed(rows)) # ASC for export
|
||||
except sqlite3.OperationalError as e:
|
||||
return jsonify({"success": False, "error": f"Database unavailable: {e}"}), 503
|
||||
except Exception as e:
|
||||
@@ -520,11 +768,11 @@ def export_daily_xlsx():
|
||||
_style_header(ws, [("A", "Date"), ("B", "Total"), ("C", "In"), ("D", "Out"), ("E", "Diff (In-Out)"), ("F", "First Count"), ("G", "Last Count")])
|
||||
|
||||
for r_idx, row in enumerate(rows, 2):
|
||||
ws.cell(row=r_idx, column=1, value=row["counting_date"])
|
||||
ws.cell(row=r_idx, column=1, value=row["date"])
|
||||
ws.cell(row=r_idx, column=2, value=row["total_count"])
|
||||
ws.cell(row=r_idx, column=3, value=row["total_in"])
|
||||
ws.cell(row=r_idx, column=4, value=row["total_out"])
|
||||
ws.cell(row=r_idx, column=5, value=(row["total_in"] or 0) - (row["total_out"] or 0))
|
||||
ws.cell(row=r_idx, column=5, value=row["diff"])
|
||||
ws.cell(row=r_idx, column=6, value=row["start_time"])
|
||||
ws.cell(row=r_idx, column=7, value=row["end_time"])
|
||||
|
||||
|
||||
+351
-80
@@ -4,13 +4,16 @@ Runs on RK3588 hardware with RKNN model (320×320 input).
|
||||
Uses ByteTrack (Kalman filter + two-stage IoU association) for tracking.
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import numpy as np
|
||||
import cv2
|
||||
import csv
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import signal
|
||||
import socket
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from collections import deque
|
||||
@@ -23,6 +26,7 @@ load_dotenv()
|
||||
|
||||
from rknnlite.api import RKNNLite
|
||||
from counter_store import CounterStore
|
||||
from video_session_writer import VideoSessionWriter
|
||||
|
||||
# --- config (override via env / .env) ---
|
||||
OUTPUT_DIR = os.getenv("OUTPUT_DIR", "/opt/jetson-counter")
|
||||
@@ -94,14 +98,29 @@ RATE_WINDOW_SEC = int(os.getenv("RATE_WINDOW_SEC", "60"))
|
||||
WARMUP_FRAMES = int(os.getenv("WARMUP_FRAMES", "30"))
|
||||
RECONNECT_DELAY_SEC = int(os.getenv("RECONNECT_DELAY_SEC", "3"))
|
||||
MAX_RECONNECT_ATTEMPTS = int(os.getenv("MAX_RECONNECT_ATTEMPTS", "0"))
|
||||
# Max stream-error prints per outage (Cannot open / Stream dropped). After this,
|
||||
# further errors are silent until the stream recovers, then the budget resets.
|
||||
# 0 = unlimited (old behavior).
|
||||
STREAM_ERROR_LOG_MAX = int(os.getenv("STREAM_ERROR_LOG_MAX", "3"))
|
||||
TRACKED_PRUNE_SEC = int(os.getenv("TRACKED_PRUNE_SEC", "300"))
|
||||
RECORD_VIDEO = os.getenv("RECORD_VIDEO", "false").lower() == "true"
|
||||
VIDEO_SEGMENT_SEC = int(os.getenv("VIDEO_SEGMENT_SEC", "3600"))
|
||||
VIDEO_SEGMENT_SEC = int(os.getenv("VIDEO_SEGMENT_SEC", "3600")) # legacy segment mode only
|
||||
OUTPUT_FPS = int(os.getenv("OUTPUT_FPS", "15"))
|
||||
# ByteTrack-style session recording: encode in SHM, move to VIDEO_OUTPUT_DIR on session end.
|
||||
SHM_DIR = os.getenv("SHM_DIR", "/dev/shm/zenai-kpc-counter")
|
||||
VIDEO_OUTPUT_DIR = os.getenv("VIDEO_OUTPUT_DIR", OUTPUT_DIR)
|
||||
RECORD_END_DELAY = float(os.getenv("RECORD_END_DELAY", "1"))
|
||||
RECORD_DISCARD_EMPTY = os.getenv("RECORD_DISCARD_EMPTY", "true").lower() == "true"
|
||||
RECORD_RETENTION_DAYS = int(os.getenv("RECORD_RETENTION_DAYS", "3"))
|
||||
_crf_raw = os.getenv("RECORD_CRF", "").strip()
|
||||
RECORD_CRF = int(_crf_raw) if _crf_raw else -1
|
||||
RECORD_PRESET = os.getenv("RECORD_PRESET", "").strip()
|
||||
# segment = old hourly dump to OUTPUT_DIR; session = SHM + webhook/control lifecycle
|
||||
RECORD_MODE = os.getenv("RECORD_MODE", "session").strip().lower()
|
||||
|
||||
LIVE_STREAM_ENABLED = os.getenv("LIVE_STREAM_ENABLED", "false").lower() == "true"
|
||||
LIVE_STREAM_FRAME_PATH = os.getenv(
|
||||
"LIVE_STREAM_FRAME_PATH", "/dev/shm/jetson-counter/live_frame.jpg"
|
||||
"LIVE_STREAM_FRAME_PATH", f"{SHM_DIR}/live_frame.jpg"
|
||||
)
|
||||
LIVE_STREAM_QUALITY = int(os.getenv("LIVE_STREAM_QUALITY", "75"))
|
||||
LIVE_STREAM_EVERY_N = int(os.getenv("LIVE_STREAM_EVERY_N", "2"))
|
||||
@@ -139,6 +158,20 @@ CONTROL_SOCKET_ENABLED = os.getenv("CONTROL_SOCKET_ENABLED", "false").lower() ==
|
||||
CONTROL_SOCKET_HOST = os.getenv("CONTROL_SOCKET_HOST", "127.0.0.1")
|
||||
CONTROL_SOCKET_PORT = int(os.getenv("CONTROL_SOCKET_PORT", "5090"))
|
||||
|
||||
# --- Status webhook gate (direction-locked when IN or OUT) ---
|
||||
# When enabled, counting runs only while status is IN or OUT, and only that
|
||||
# direction is counted (IN → in crossings, OUT → out crossings). OFF pauses all.
|
||||
STATUS_WEBHOOK_ENABLED = os.getenv("STATUS_WEBHOOK_ENABLED", "false").lower() == "true"
|
||||
STATUS_WEBHOOK_FILE = os.getenv(
|
||||
"STATUS_WEBHOOK_FILE",
|
||||
"/opt/zenai-kpc-python/status_webhook_state.json",
|
||||
)
|
||||
_active_raw = os.getenv("STATUS_WEBHOOK_ACTIVE_VALUES", "IN,OUT")
|
||||
STATUS_WEBHOOK_ACTIVE_VALUES = frozenset(
|
||||
part.strip().upper() for part in _active_raw.split(",") if part.strip()
|
||||
)
|
||||
STATUS_WEBHOOK_INACTIVE_VALUE = os.getenv("STATUS_WEBHOOK_INACTIVE_VALUE", "OFF").strip().upper()
|
||||
|
||||
CROSS_FLASH_FRAMES = 12
|
||||
POPUP_LIFETIME = 20
|
||||
LINE_PULSE_FRAMES = 12
|
||||
@@ -441,7 +474,6 @@ class ByteTracker:
|
||||
cost_mat = 1.0 - iou_mat
|
||||
matches = _greedy_match(cost_mat, threshold=1.0 - self.match_thresh)
|
||||
|
||||
matched_det_idx = set()
|
||||
for di, ti in matches:
|
||||
det_global = int(high_idx[di])
|
||||
orig_idx = int(remain_orig_idx[det_global])
|
||||
@@ -451,30 +483,6 @@ class ByteTracker:
|
||||
det_to_track[orig_idx] = track_pool[ti].track_id
|
||||
tracked_map[track_pool[ti].track_id] = (track_pool[ti].get_cx(), track_pool[ti].get_cy())
|
||||
match_pairs_high.append((det_global, ti))
|
||||
matched_det_idx.add(di)
|
||||
|
||||
# --- second pass: any unmatched high-score detection tries to
|
||||
# pair with ANY track that overlaps above threshold, even one
|
||||
# that already has a match. This prevents a close/overlapping
|
||||
# object from spawning a spurious new ID when the greedy 1:1
|
||||
# match paired its detection elsewhere. ---
|
||||
for di in range(len(high_dets)):
|
||||
if di in matched_det_idx:
|
||||
continue
|
||||
best_ti = None
|
||||
best_iou = 0.0
|
||||
for ti in range(num_tracks):
|
||||
if iou_mat[di, ti] > best_iou and iou_mat[di, ti] >= self.match_thresh:
|
||||
best_iou = iou_mat[di, ti]
|
||||
best_ti = ti
|
||||
if best_ti is not None:
|
||||
det_global = int(high_idx[di])
|
||||
orig_idx = int(remain_orig_idx[det_global])
|
||||
track_pool[best_ti].update(dets[det_global])
|
||||
track_pool[best_ti].hit_streak = max(1, track_pool[best_ti].hit_streak)
|
||||
matched_track_idx.add(best_ti)
|
||||
det_to_track[orig_idx] = track_pool[best_ti].track_id
|
||||
tracked_map[track_pool[best_ti].track_id] = (track_pool[best_ti].get_cx(), track_pool[best_ti].get_cy())
|
||||
|
||||
unmatched_tracks = [
|
||||
t for t in range(num_tracks) if t not in matched_track_idx
|
||||
@@ -719,8 +727,9 @@ def is_duplicate_cross(recent, cx, frame_idx):
|
||||
|
||||
|
||||
def _inherit_prev(tracked, new_tid, cx, cy, mono, max_age, max_px):
|
||||
"""Find a recently-seen nearby track and return its (cx, cy, mono) so a new
|
||||
track ID can inherit the trajectory across an ID switch. Returns None if none.
|
||||
"""Find a recently-seen nearby track for ID-switch continuation.
|
||||
|
||||
Returns (cx, cy, mono, source_tid) or None.
|
||||
|
||||
The predecessor must be close in BOTH x and y (same physical object at the same
|
||||
spot). Matching on x only would let a new track inherit a far-away y, seeding a
|
||||
@@ -737,10 +746,27 @@ def _inherit_prev(tracked, new_tid, cx, cy, mono, max_age, max_px):
|
||||
dist = abs(tcx - cx)
|
||||
if dist <= best_dist:
|
||||
best_dist = dist
|
||||
best = (tcx, tcy, ts)
|
||||
best = (tcx, tcy, ts, tid)
|
||||
return best
|
||||
|
||||
|
||||
def _default_cross_state():
|
||||
"""Per-track line-crossing state for sequence counting.
|
||||
|
||||
seen_out / seen_in: ever registered a pass of that line (arm or count).
|
||||
counted: already contributed one IN or OUT (at most one per physical object).
|
||||
"""
|
||||
return {"seen_out": False, "seen_in": False, "counted": False}
|
||||
|
||||
|
||||
def _copy_cross_state(src):
|
||||
return {
|
||||
"seen_out": bool(src.get("seen_out", False)),
|
||||
"seen_in": bool(src.get("seen_in", False)),
|
||||
"counted": bool(src.get("counted", False)),
|
||||
}
|
||||
|
||||
|
||||
def now_str():
|
||||
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
@@ -757,6 +783,21 @@ def read_counting_flag(default=True):
|
||||
return default
|
||||
|
||||
|
||||
def read_status_webhook():
|
||||
"""Return normalized status from webhook file, or inactive value on error."""
|
||||
try:
|
||||
with open(STATUS_WEBHOOK_FILE, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
status = str(data.get("status", "")).strip().upper()
|
||||
return status or STATUS_WEBHOOK_INACTIVE_VALUE
|
||||
except Exception:
|
||||
return STATUS_WEBHOOK_INACTIVE_VALUE
|
||||
|
||||
|
||||
def status_webhook_allows_counting(status):
|
||||
return status in STATUS_WEBHOOK_ACTIVE_VALUES
|
||||
|
||||
|
||||
def write_control_file(counting):
|
||||
"""Create/update the control file atomically (used to seed defaults)."""
|
||||
try:
|
||||
@@ -832,9 +873,36 @@ def start_control_socket():
|
||||
return srv
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _quiet_opencv_stderr():
|
||||
"""Silence OpenCV/FFmpeg stderr (DESCRIBE failed, cap.cpp WARN, etc.)."""
|
||||
saved_fd = None
|
||||
devnull_fd = None
|
||||
try:
|
||||
stderr_fd = sys.stderr.fileno()
|
||||
saved_fd = os.dup(stderr_fd)
|
||||
devnull_fd = os.open(os.devnull, os.O_WRONLY)
|
||||
os.dup2(devnull_fd, stderr_fd)
|
||||
except (AttributeError, OSError, ValueError):
|
||||
yield
|
||||
return
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
os.dup2(saved_fd, stderr_fd)
|
||||
os.close(saved_fd)
|
||||
os.close(devnull_fd)
|
||||
|
||||
|
||||
def open_capture(source):
|
||||
if source.lower().startswith(("rtsp://", "http://")):
|
||||
os.environ["OPENCV_FFMPEG_CAPTURE_OPTIONS"] = RTSP_FFMPEG_OPTIONS
|
||||
# When STREAM_ERROR_LOG_MAX > 0, native OpenCV/FFmpeg stderr is suppressed;
|
||||
# stream_error_log emits at most that many user-facing messages per outage.
|
||||
if STREAM_ERROR_LOG_MAX > 0:
|
||||
with _quiet_opencv_stderr():
|
||||
cap = cv2.VideoCapture(source, cv2.CAP_FFMPEG)
|
||||
else:
|
||||
cap = cv2.VideoCapture(source, cv2.CAP_FFMPEG)
|
||||
cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)
|
||||
return cap
|
||||
@@ -1133,6 +1201,46 @@ def draw_popups(img, popups, frame_idx):
|
||||
return alive
|
||||
|
||||
|
||||
class StreamErrorLog:
|
||||
"""Rate-limit stream error prints to STREAM_ERROR_LOG_MAX per outage.
|
||||
|
||||
After the cap, messages are suppressed until note_up() when the stream
|
||||
is healthy again; the next drop starts a fresh budget.
|
||||
"""
|
||||
|
||||
def __init__(self, max_logs=STREAM_ERROR_LOG_MAX):
|
||||
self.max_logs = int(max_logs)
|
||||
self._emitted = 0
|
||||
self._suppressed = 0
|
||||
|
||||
def emit(self, msg: str) -> None:
|
||||
if self.max_logs <= 0:
|
||||
print(msg)
|
||||
return
|
||||
if self._emitted < self.max_logs:
|
||||
self._emitted += 1
|
||||
print(msg)
|
||||
if self._emitted >= self.max_logs:
|
||||
print(
|
||||
f"[{now_str()}] Stream errors capped at {self.max_logs} "
|
||||
f"this outage — further messages suppressed until stream recovers"
|
||||
)
|
||||
else:
|
||||
self._suppressed += 1
|
||||
|
||||
def note_up(self) -> None:
|
||||
if self._suppressed > 0:
|
||||
print(
|
||||
f"[{now_str()}] Stream recovered "
|
||||
f"(suppressed {self._suppressed} error log(s) during outage)"
|
||||
)
|
||||
self._emitted = 0
|
||||
self._suppressed = 0
|
||||
|
||||
|
||||
stream_error_log = StreamErrorLog()
|
||||
|
||||
|
||||
def connect_stream(source, warmup=WARMUP_FRAMES):
|
||||
attempts = 0
|
||||
while not shutdown_requested:
|
||||
@@ -1143,7 +1251,9 @@ def connect_stream(source, warmup=WARMUP_FRAMES):
|
||||
raise RuntimeError(
|
||||
f"Cannot open source after {attempts} attempts: {source}"
|
||||
)
|
||||
print(f"Cannot open source, retry in {RECONNECT_DELAY_SEC}s...")
|
||||
stream_error_log.emit(
|
||||
f"Cannot open source, retry in {RECONNECT_DELAY_SEC}s..."
|
||||
)
|
||||
time.sleep(RECONNECT_DELAY_SEC)
|
||||
continue
|
||||
if warmup > 0 and source.lower().startswith(("rtsp://", "http://")):
|
||||
@@ -1153,6 +1263,7 @@ def connect_stream(source, warmup=WARMUP_FRAMES):
|
||||
fps = cap.get(cv2.CAP_PROP_FPS)
|
||||
if not fps or fps <= 1:
|
||||
fps = OUTPUT_FPS
|
||||
stream_error_log.note_up()
|
||||
return cap, w, h, fps
|
||||
return None, 0, 0, OUTPUT_FPS
|
||||
|
||||
@@ -1201,8 +1312,9 @@ def run():
|
||||
min_hits=TRACK_MIN_HITS,
|
||||
)
|
||||
|
||||
object_line1_crossed = set()
|
||||
object_line2_crossed = set()
|
||||
# Per-track sequence state: OUT→IN / IN-only → IN; IN→OUT / OUT-only → OUT.
|
||||
# Keys survive ID switches via inheritance (see _inherit_prev).
|
||||
object_cross_state = {}
|
||||
|
||||
recent_cross_in = deque()
|
||||
recent_cross_out = deque()
|
||||
@@ -1221,11 +1333,14 @@ def run():
|
||||
frames_since_infer = 0
|
||||
video_writer = None
|
||||
crossing_times = deque()
|
||||
counter_in = 0
|
||||
counter_out = 0
|
||||
# Overlay shows daily store totals (resume-safe, resets on counting-day change).
|
||||
counter_in = store.current_count_in
|
||||
counter_out = store.current_count_out
|
||||
last_snapshot_cleanup = 0.0
|
||||
|
||||
counting_active = True
|
||||
status_nyala_active = True
|
||||
status_webhook_value = STATUS_WEBHOOK_INACTIVE_VALUE
|
||||
last_control_poll = 0.0
|
||||
control_socket = None
|
||||
if CONTROL_ENABLED:
|
||||
@@ -1241,6 +1356,13 @@ def run():
|
||||
control_socket = start_control_socket()
|
||||
except Exception as exc:
|
||||
print(f"[{now_str()}] Failed to start control socket: {exc}")
|
||||
if STATUS_WEBHOOK_ENABLED:
|
||||
status_webhook_value = read_status_webhook()
|
||||
status_nyala_active = status_webhook_allows_counting(status_webhook_value)
|
||||
print(
|
||||
f"Status webhook enabled | file={STATUS_WEBHOOK_FILE} | "
|
||||
f"status={status_webhook_value}"
|
||||
)
|
||||
|
||||
cap, w, h, fps = connect_stream(SOURCE)
|
||||
if cap is None:
|
||||
@@ -1251,7 +1373,9 @@ def run():
|
||||
line_y1 = resolve_line_y1(h)
|
||||
line_y2 = resolve_line_y2(h)
|
||||
print(
|
||||
f"RKNN+ByteTrack counter | {w}x{h} @ {fps}fps | line1 y={line_y1} (in v) line2 y={line_y2} (out ^)"
|
||||
f"RKNN+ByteTrack counter | {w}x{h} @ {fps}fps | "
|
||||
f"line1 y={line_y1} (IN ↓) line2 y={line_y2} (OUT ↑) | "
|
||||
f"seq: OUT→IN/IN-only→in, IN→OUT/OUT-only→out"
|
||||
)
|
||||
print(f"Model: {MODEL_PATH} | imgsz={IMGSZ} | core_mask={CORE_MASK}")
|
||||
print(
|
||||
@@ -1262,7 +1386,36 @@ def run():
|
||||
print(f"State: {STATE_FILE}")
|
||||
|
||||
if RECORD_VIDEO:
|
||||
if RECORD_MODE == "segment":
|
||||
video_writer = VideoSegmentWriter(OUTPUT_DIR, w, h, fps, VIDEO_SEGMENT_SEC)
|
||||
print(
|
||||
f"Recording (segment): {OUTPUT_DIR} every {VIDEO_SEGMENT_SEC}s"
|
||||
)
|
||||
else:
|
||||
video_writer = VideoSessionWriter(
|
||||
shm_dir=SHM_DIR,
|
||||
video_output_dir=VIDEO_OUTPUT_DIR,
|
||||
w=w,
|
||||
h=h,
|
||||
fps=fps if fps and fps > 1 else OUTPUT_FPS,
|
||||
end_delay_sec=RECORD_END_DELAY,
|
||||
retention_days=RECORD_RETENTION_DAYS,
|
||||
crf=RECORD_CRF,
|
||||
preset=RECORD_PRESET,
|
||||
discard_empty=RECORD_DISCARD_EMPTY,
|
||||
)
|
||||
print(
|
||||
f"Recording (session): SHM={SHM_DIR} → out={VIDEO_OUTPUT_DIR} | "
|
||||
f"end_delay={RECORD_END_DELAY}s discard_empty={RECORD_DISCARD_EMPTY} | "
|
||||
f"ffmpeg={'yes' if shutil.which('ffmpeg') else 'NO'}"
|
||||
)
|
||||
# If webhook already active at startup, begin a session immediately.
|
||||
if STATUS_WEBHOOK_ENABLED and status_nyala_active:
|
||||
video_writer.note_session_start(
|
||||
status_webhook_value, store.get_counting_date()
|
||||
)
|
||||
elif not STATUS_WEBHOOK_ENABLED and counting_active:
|
||||
video_writer.note_session_start("SESSION", store.get_counting_date())
|
||||
|
||||
reconnect_count = 0
|
||||
|
||||
@@ -1272,8 +1425,9 @@ def run():
|
||||
if not IS_LIVE:
|
||||
break
|
||||
reconnect_count += 1
|
||||
print(
|
||||
f"Stream dropped (attempt {reconnect_count}), reconnecting in {RECONNECT_DELAY_SEC}s..."
|
||||
stream_error_log.emit(
|
||||
f"Stream dropped (attempt {reconnect_count}), "
|
||||
f"reconnecting in {RECONNECT_DELAY_SEC}s..."
|
||||
)
|
||||
cap.release()
|
||||
time.sleep(RECONNECT_DELAY_SEC)
|
||||
@@ -1291,15 +1445,53 @@ def run():
|
||||
cross_events_frame = []
|
||||
detect_events_frame = []
|
||||
|
||||
if CONTROL_ENABLED and (now - last_control_poll) >= CONTROL_POLL_SEC:
|
||||
if (CONTROL_ENABLED or STATUS_WEBHOOK_ENABLED) and (
|
||||
now - last_control_poll
|
||||
) >= CONTROL_POLL_SEC:
|
||||
last_control_poll = now
|
||||
if CONTROL_ENABLED:
|
||||
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")
|
||||
print(
|
||||
f"[{now_str()}] Counting "
|
||||
f"{'RESUMED' if counting_active else 'PAUSED'} via control file"
|
||||
)
|
||||
if (
|
||||
isinstance(video_writer, VideoSessionWriter)
|
||||
and not STATUS_WEBHOOK_ENABLED
|
||||
):
|
||||
if counting_active:
|
||||
video_writer.note_session_start(
|
||||
"SESSION", store.get_counting_date()
|
||||
)
|
||||
else:
|
||||
video_writer.note_session_end()
|
||||
if STATUS_WEBHOOK_ENABLED:
|
||||
new_status = read_status_webhook()
|
||||
if new_status != status_webhook_value:
|
||||
prev_status = status_webhook_value
|
||||
status_webhook_value = new_status
|
||||
status_nyala_active = status_webhook_allows_counting(
|
||||
status_webhook_value
|
||||
)
|
||||
print(f"[{now_str()}] Status webhook {status_webhook_value}")
|
||||
if isinstance(video_writer, VideoSessionWriter):
|
||||
was_active = status_webhook_allows_counting(prev_status)
|
||||
if status_nyala_active and not was_active:
|
||||
video_writer.note_session_start(
|
||||
status_webhook_value, store.get_counting_date()
|
||||
)
|
||||
elif was_active and not status_nyala_active:
|
||||
video_writer.note_session_end()
|
||||
|
||||
# Raw frames only (before overlay). Session writer encodes here every frame.
|
||||
if isinstance(video_writer, VideoSessionWriter):
|
||||
video_writer.feed_frame(frame)
|
||||
|
||||
counting_allowed = counting_active and status_nyala_active
|
||||
skip_inference = False
|
||||
if not counting_active:
|
||||
if not counting_allowed:
|
||||
skip_inference = True
|
||||
elif MOTION_DETECTION_ENABLED:
|
||||
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
|
||||
@@ -1361,21 +1553,23 @@ def run():
|
||||
if object_det_to_track
|
||||
else ""
|
||||
)
|
||||
crossing_str1 = (
|
||||
f" line1_crossed: {sorted(object_line1_crossed)}"
|
||||
if object_line1_crossed
|
||||
else ""
|
||||
crossing_str = ""
|
||||
if object_cross_state:
|
||||
line1_ids = sorted(
|
||||
tid for tid, st in object_cross_state.items() if st.get("seen_in")
|
||||
)
|
||||
crossing_str2 = (
|
||||
f" line2_crossed: {sorted(object_line2_crossed)}"
|
||||
if object_line2_crossed
|
||||
else ""
|
||||
line2_ids = sorted(
|
||||
tid for tid, st in object_cross_state.items() if st.get("seen_out")
|
||||
)
|
||||
if line1_ids:
|
||||
crossing_str += f" line1_crossed: {line1_ids}"
|
||||
if line2_ids:
|
||||
crossing_str += f" line2_crossed: {line2_ids}"
|
||||
print(
|
||||
f"[DEBUG F{frame_idx}] dets={len(object_boxes_xyxy)} "
|
||||
f"tracks={len(object_track_map)} "
|
||||
f"line_y1={line_y1} line_y2={line_y2}{scores_str}"
|
||||
f"{tracks_str}{crossing_str1}{crossing_str2}"
|
||||
f"{tracks_str}{crossing_str}"
|
||||
)
|
||||
|
||||
# --- crossing detection only on DETECTED objects this frame ---
|
||||
@@ -1392,36 +1586,98 @@ def run():
|
||||
detect_snapshot_ids.add(tid)
|
||||
detect_events_frame.append(tid)
|
||||
|
||||
inherited_this_frame = False
|
||||
if tid not in object_tracked:
|
||||
inherited = _inherit_prev(
|
||||
object_tracked, tid, cx, cy, mono, INHERIT_SEC, INHERIT_PX
|
||||
)
|
||||
if inherited is not None:
|
||||
object_tracked[tid] = inherited
|
||||
object_tracked[tid] = inherited[:3]
|
||||
src_tid = inherited[3]
|
||||
if src_tid in object_cross_state:
|
||||
object_cross_state[tid] = _copy_cross_state(
|
||||
object_cross_state[src_tid]
|
||||
)
|
||||
inherited_this_frame = True
|
||||
if os.getenv("DEBUG_TRACKING", "").lower() == "true":
|
||||
src_st = object_cross_state.get(tid, {})
|
||||
print(
|
||||
f"[DEBUG F{frame_idx}] INHERIT prev for new tid={tid} "
|
||||
f"from ({inherited[0]:.1f},{inherited[1]:.1f})"
|
||||
f"from tid={src_tid} ({inherited[0]:.1f},{inherited[1]:.1f})"
|
||||
f" seen_in={src_st.get('seen_in', False)}"
|
||||
f" seen_out={src_st.get('seen_out', False)}"
|
||||
f" counted={src_st.get('counted', False)}"
|
||||
)
|
||||
|
||||
if tid not in object_cross_state:
|
||||
object_cross_state[tid] = _default_cross_state()
|
||||
st = object_cross_state[tid]
|
||||
|
||||
if tid in object_tracked:
|
||||
prev_cy = object_tracked[tid][1]
|
||||
crossed1 = (
|
||||
crossed_top_down(prev_cy, cy, line_y1)
|
||||
and tid not in object_line1_crossed
|
||||
# Line 1 = IN, line 2 = OUT.
|
||||
# Arm (no count): OUT top→down, IN bottom→up.
|
||||
# Count: IN top→down → IN (OUT→IN or IN-only)
|
||||
# OUT bottom→up → OUT (IN→OUT or OUT-only)
|
||||
out_down = (
|
||||
crossed_top_down(prev_cy, cy, line_y2) and not st["seen_out"]
|
||||
)
|
||||
crossed2 = (
|
||||
crossed_bottom_up(prev_cy, cy, line_y2)
|
||||
and tid not in object_line2_crossed
|
||||
in_up = (
|
||||
crossed_bottom_up(prev_cy, cy, line_y1) and not st["seen_in"]
|
||||
)
|
||||
if crossed1 or crossed2:
|
||||
direction = "in" if crossed1 else "out"
|
||||
recent = recent_cross_in if crossed1 else recent_cross_out
|
||||
in_down = (
|
||||
crossed_top_down(prev_cy, cy, line_y1) and not st["seen_in"]
|
||||
)
|
||||
out_up = (
|
||||
crossed_bottom_up(prev_cy, cy, line_y2) and not st["seen_out"]
|
||||
)
|
||||
|
||||
if out_down or in_up or in_down or out_up:
|
||||
if out_down:
|
||||
st["seen_out"] = True
|
||||
if in_up:
|
||||
st["seen_in"] = True
|
||||
if in_down:
|
||||
st["seen_in"] = True
|
||||
if out_up:
|
||||
st["seen_out"] = True
|
||||
|
||||
# Prefer decisive motion if both count triggers fire in one jump.
|
||||
count_in = in_down and not st["counted"]
|
||||
count_out = out_up and not st["counted"]
|
||||
if STATUS_WEBHOOK_ENABLED:
|
||||
# Webhook IN → only IN counts; OUT → only OUT counts.
|
||||
if status_webhook_value == "IN":
|
||||
count_out = False
|
||||
elif status_webhook_value == "OUT":
|
||||
count_in = False
|
||||
if count_in and count_out:
|
||||
if cy >= prev_cy:
|
||||
count_out = False
|
||||
else:
|
||||
count_in = False
|
||||
direction = "in" if count_in else ("out" if count_out else None)
|
||||
|
||||
if direction is None:
|
||||
# Arm-only pass (OUT↓ or IN↑). No count yet.
|
||||
if os.getenv("DEBUG_TRACKING", "").lower() == "true":
|
||||
arm = []
|
||||
if out_down:
|
||||
arm.append("out_down")
|
||||
if in_up:
|
||||
arm.append("in_up")
|
||||
print(
|
||||
f"[DEBUG F{frame_idx}] CROSS ARM: tid={tid} "
|
||||
f"prev_cy={prev_cy:.1f} -> cy={cy:.1f} "
|
||||
f"arm={'+'.join(arm)} "
|
||||
f"seen_in={st['seen_in']} seen_out={st['seen_out']}"
|
||||
)
|
||||
object_tracked[tid] = (cx, cy, mono)
|
||||
continue
|
||||
|
||||
recent = recent_cross_in if direction == "in" else recent_cross_out
|
||||
if is_duplicate_cross(recent, cx, frame_idx):
|
||||
if crossed1:
|
||||
object_line1_crossed.add(tid)
|
||||
if crossed2:
|
||||
object_line2_crossed.add(tid)
|
||||
st["counted"] = True
|
||||
if os.getenv("DEBUG_TRACKING", "").lower() == "true":
|
||||
print(
|
||||
f"[DEBUG F{frame_idx}] DUP CROSS IGNORED: tid={tid} "
|
||||
@@ -1429,23 +1685,29 @@ def run():
|
||||
)
|
||||
object_tracked[tid] = (cx, cy, mono)
|
||||
continue
|
||||
|
||||
if os.getenv("DEBUG_TRACKING", "").lower() == "true":
|
||||
line_label = "1" if crossed1 else "2"
|
||||
line_y_val = line_y1 if crossed1 else line_y2
|
||||
line_n = 1 if direction == "in" else 2
|
||||
line_y = line_y1 if direction == "in" else line_y2
|
||||
print(
|
||||
f"[DEBUG F{frame_idx}] CROSS DETECTED: tid={tid} "
|
||||
f"prev_cy={prev_cy:.1f} -> cy={cy:.1f} line={line_label} y={line_y_val} dir={direction}"
|
||||
f"prev_cy={prev_cy:.1f} -> cy={cy:.1f} "
|
||||
f"line={line_n} y={line_y} dir={direction}"
|
||||
)
|
||||
|
||||
recent.append((frame_idx, cx))
|
||||
if crossed1:
|
||||
object_line1_crossed.add(tid)
|
||||
counter_in += 1
|
||||
st["counted"] = True
|
||||
_, _, counted = store.record_object_crossing(tid, direction)
|
||||
counter_in = store.current_count_in
|
||||
counter_out = store.current_count_out
|
||||
if not counted:
|
||||
# Dedup in the store rejected this event; skip overlay/CSV.
|
||||
object_tracked[tid] = (cx, cy, mono)
|
||||
continue
|
||||
if direction == "in":
|
||||
count_in_pulse = COUNT_PULSE_FRAMES
|
||||
if crossed2:
|
||||
object_line2_crossed.add(tid)
|
||||
counter_out += 1
|
||||
else:
|
||||
count_out_pulse = COUNT_PULSE_FRAMES
|
||||
store.record_object_crossing(tid, direction)
|
||||
if cross_logger:
|
||||
cross_logger.write_row(
|
||||
[
|
||||
@@ -1458,6 +1720,8 @@ def run():
|
||||
object_crossed_frame = True
|
||||
cross_events_frame.append((tid, direction))
|
||||
crossing_times.append(mono)
|
||||
if isinstance(video_writer, VideoSessionWriter):
|
||||
video_writer.note_crossing()
|
||||
object_cross_flash1[tid] = CROSS_FLASH_FRAMES
|
||||
object_cross_flash2[tid] = CROSS_FLASH_FRAMES
|
||||
popups.append(
|
||||
@@ -1465,7 +1729,7 @@ def run():
|
||||
"x": int(cx) - 12,
|
||||
"y": int(cy),
|
||||
"born": frame_idx,
|
||||
"text": f"+1" if not (crossed1 and crossed2) else "x2",
|
||||
"text": "+1",
|
||||
}
|
||||
)
|
||||
object_tracked[tid] = (cx, cy, mono)
|
||||
@@ -1494,7 +1758,9 @@ def run():
|
||||
if object_crossed_frame:
|
||||
line_pulse = LINE_PULSE_FRAMES
|
||||
|
||||
display_total = store.display_total()
|
||||
# Keep overlay aligned with daily store (also resets after cutoff).
|
||||
counter_in = store.current_count_in
|
||||
counter_out = store.current_count_out
|
||||
while crossing_times and mono - crossing_times[0] > RATE_WINDOW_SEC:
|
||||
crossing_times.popleft()
|
||||
rate = (len(crossing_times) / RATE_WINDOW_SEC * 60) if crossing_times else 0.0
|
||||
@@ -1523,7 +1789,10 @@ def run():
|
||||
)
|
||||
popups = draw_popups(frame, popups, frame_idx)
|
||||
|
||||
if CONTROL_ENABLED and not counting_active:
|
||||
if (CONTROL_ENABLED or STATUS_WEBHOOK_ENABLED) and not counting_allowed:
|
||||
if STATUS_WEBHOOK_ENABLED and not status_nyala_active:
|
||||
badge = status_webhook_value
|
||||
else:
|
||||
badge = "COUNTING PAUSED"
|
||||
(bw, bh), _ = cv2.getTextSize(badge, cv2.FONT_HERSHEY_SIMPLEX, 0.6, 2)
|
||||
bx = w // 2 - bw // 2
|
||||
@@ -1542,7 +1811,7 @@ def run():
|
||||
count_in_pulse = max(0, count_in_pulse - 1)
|
||||
count_out_pulse = max(0, count_out_pulse - 1)
|
||||
|
||||
if video_writer is not None:
|
||||
if isinstance(video_writer, VideoSegmentWriter):
|
||||
video_writer.write(frame)
|
||||
|
||||
if LIVE_STREAM_ENABLED and frame_idx % LIVE_STREAM_EVERY_N == 0:
|
||||
@@ -1598,7 +1867,9 @@ def run():
|
||||
prune_stale_tracks(object_tracked, mono)
|
||||
|
||||
cap.release()
|
||||
if video_writer is not None:
|
||||
if isinstance(video_writer, VideoSessionWriter):
|
||||
video_writer.shutdown()
|
||||
elif video_writer is not None:
|
||||
video_writer.release()
|
||||
if cross_logger:
|
||||
cross_logger.close()
|
||||
|
||||
+87
-4
@@ -41,6 +41,12 @@ class CounterStore:
|
||||
self.db = sqlite3.connect(db_path, check_same_thread=False)
|
||||
self._init_db()
|
||||
self.current_state = self._load_state()
|
||||
with self.state_lock:
|
||||
self._fill_missing_days()
|
||||
counting_date = self.get_counting_date()
|
||||
if self.current_state is None:
|
||||
self._start_new_day(counting_date)
|
||||
self._persist_day()
|
||||
|
||||
def _init_db(self):
|
||||
cur = self.db.cursor()
|
||||
@@ -88,11 +94,23 @@ class CounterStore:
|
||||
state.setdefault('count_in', 0)
|
||||
state.setdefault('count_out', 0)
|
||||
state.setdefault('count', state['count_in'] + state['count_out'])
|
||||
state.setdefault('counted_event_ids', [])
|
||||
# Track IDs are process-local and restart from 1 after every process
|
||||
# start. Persisted dedup keys like "5_in" would silently block new
|
||||
# counts that reuse those IDs, so clear them on resume while keeping
|
||||
# the day's totals.
|
||||
stale_ids = state.get('counted_event_ids') or []
|
||||
if stale_ids:
|
||||
self.log(
|
||||
f"Cleared {len(stale_ids)} persisted track dedup keys "
|
||||
f"(track IDs reset on restart)"
|
||||
)
|
||||
state['counted_event_ids'] = []
|
||||
self.log(
|
||||
f"Resumed {current_date} with total={state['count']} "
|
||||
f"(in={state['count_in']} out={state['count_out']})"
|
||||
)
|
||||
with open(self.state_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(state, f, indent=2, ensure_ascii=False)
|
||||
return state
|
||||
except Exception as exc:
|
||||
self.log(f'Failed to load state file: {exc}')
|
||||
@@ -125,8 +143,65 @@ class CounterStore:
|
||||
self.save_state()
|
||||
self.log(f'Started counting day {counting_date} ({self.object_label})')
|
||||
|
||||
def _ensure_day_row(self, counting_date, start_time=None, end_time=None):
|
||||
"""Insert a zero row for counting_date if it does not already exist."""
|
||||
now = datetime.now().isoformat()
|
||||
cur = self.db.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT OR IGNORE INTO daily_counters
|
||||
(counting_date, camera_name, object_label,
|
||||
total_count, total_in, total_out, start_time, end_time)
|
||||
VALUES (?, ?, ?, 0, 0, 0, ?, ?)
|
||||
""",
|
||||
(
|
||||
counting_date,
|
||||
self.camera_name,
|
||||
self.object_label,
|
||||
start_time or now,
|
||||
end_time or now,
|
||||
),
|
||||
)
|
||||
self.db.commit()
|
||||
|
||||
def _fill_missing_days(self):
|
||||
"""Backfill any missing counting dates from first DB row through today as 0."""
|
||||
today = self.get_counting_date()
|
||||
cur = self.db.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT counting_date FROM daily_counters
|
||||
WHERE camera_name = ? AND object_label = ?
|
||||
ORDER BY counting_date ASC
|
||||
""",
|
||||
(self.camera_name, self.object_label),
|
||||
)
|
||||
existing = {row[0] for row in cur.fetchall()}
|
||||
if not existing:
|
||||
self._ensure_day_row(today)
|
||||
return
|
||||
|
||||
start = datetime.strptime(min(existing), '%Y-%m-%d').date()
|
||||
end = datetime.strptime(today, '%Y-%m-%d').date()
|
||||
filled = 0
|
||||
d = start
|
||||
while d <= end:
|
||||
key = d.isoformat()
|
||||
if key not in existing:
|
||||
self._ensure_day_row(key)
|
||||
filled += 1
|
||||
d += timedelta(days=1)
|
||||
if filled:
|
||||
self.log(
|
||||
f'Backfilled {filled} zero-activity day(s) '
|
||||
f'{start.isoformat()}..{end.isoformat()}'
|
||||
)
|
||||
|
||||
def record_object_crossing(self, track_id, direction):
|
||||
"""Record an object crossing a counting line. direction: 'in' | 'out'."""
|
||||
"""Record an object crossing a counting line. direction: 'in' | 'out'.
|
||||
|
||||
Returns (total_count, day_started, counted).
|
||||
"""
|
||||
with self.state_lock:
|
||||
counting_date = self.get_counting_date()
|
||||
day_started = False
|
||||
@@ -134,6 +209,7 @@ class CounterStore:
|
||||
self._start_new_day(counting_date)
|
||||
day_started = True
|
||||
|
||||
counted = False
|
||||
event_key = f"{track_id}_{direction}"
|
||||
if event_key not in self.current_state['counted_event_ids']:
|
||||
self.current_state['count'] += 1
|
||||
@@ -142,6 +218,7 @@ class CounterStore:
|
||||
else:
|
||||
self.current_state['count_out'] += 1
|
||||
self.current_state['counted_event_ids'].append(event_key)
|
||||
counted = True
|
||||
self.log(
|
||||
f'Counted {direction} (track {track_id}) | {counting_date} '
|
||||
f'total: {self.current_state["count"]} '
|
||||
@@ -151,7 +228,7 @@ class CounterStore:
|
||||
|
||||
self.current_state['last_detection_time'] = datetime.now().isoformat()
|
||||
self.save_state()
|
||||
return self.current_state['count'], day_started
|
||||
return self.current_state['count'], day_started, counted
|
||||
|
||||
def _persist_day(self):
|
||||
state = self.current_state
|
||||
@@ -182,13 +259,19 @@ class CounterStore:
|
||||
while not self.shutdown_event.is_set():
|
||||
time.sleep(60)
|
||||
with self.state_lock:
|
||||
self._fill_missing_days()
|
||||
counting_date = self.get_counting_date()
|
||||
if self.current_state is None:
|
||||
self._start_new_day(counting_date)
|
||||
self._persist_day()
|
||||
continue
|
||||
if self.current_state['counting_date'] != self.get_counting_date():
|
||||
if self.current_state['counting_date'] != counting_date:
|
||||
self.log('Daily cutoff reached - finalizing day totals')
|
||||
self._persist_day()
|
||||
self.current_state = None
|
||||
self.save_state()
|
||||
self._start_new_day(counting_date)
|
||||
self._persist_day()
|
||||
|
||||
def start_cutoff_watcher(self):
|
||||
t = threading.Thread(target=self.cutoff_watcher_loop, daemon=True)
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
Jul 14 22:15:51 frigate-karung-masuk-tuang-dev python[2390681]: , 5806, 5808, 5809, 5810, 5811, 5812, 5813, 5814, 5815, 5816, 5817, 5818, 5819, 5820, 5821, 5822, 5824, 5825, 5826, 5827, 5828, 5829, 5830, 5831, 5832, 5833, 5834, 5835, 5837, 5838, 5839, 5840, 5843, 5844, 5845, 5846, 5847, 5848, 5849, 5854, 5855, 5856, 5857, 5858, 5860, 5861, 5862, 5863, 5864, 5865, 5866, 5867, 5868, 5870, 5871, 5872, 5873, 5874, 5876, 5877, 5878, 5879, 5880, 5881, 5882, 5883, 5884, 5885, 5886, 5887, 5888, 5889, 5892, 5893, 5894, 5895, 5896, 5897, 5899, 5900, 5901, 5902, 5903, 5904, 5907, 5908, 5909, 5910, 5911, 5912, 5913, 5914, 5915, 5916, 5917, 5918, 5919, 5920, 5921, 5922, 5923, 5925, 5926, 5927, 5928, 5929, 5930, 5931, 5932, 5935, 5936, 5937, 5938, 5939, 5942, 5943, 5944, 5945, 5946, 5947, 5948, 5949, 5950, 5951, 5953, 5954, 5955, 5956, 5957, 5958, 5959, 5961, 5962, 5963, 5964, 5965, 5966, 5967, 5968, 5969, 5970, 5971, 5972, 5974, 5975, 5976, 5977, 5978, 5979, 5980, 5982, 5983, 5985, 5986, 5987, 5988, 5989, 5990, 5991, 5992, 5993, 5995, 5996, 5997, 5998, 5999, 6000, 6001, 6002, 6003, 6004, 6005, 6007, 6008, 6009, 6010, 6011, 6012, 6013, 6014, 6015, 6020, 6021, 6022, 6023, 6024, 6025, 6026, 6030, 6031, 6032, 6033, 6034, 6035, 6036, 6037, 6038, 6042, 6043, 6044, 6045, 6046, 6047, 6048, 6050, 6051, 6052, 6056, 6057, 6058, 6059, 6060, 6061, 6062, 6063, 6064, 6065, 6068, 6069, 6070, 6071, 6072, 6073, 6074, 6075, 6076, 6080, 6081, 6082, 6083, 6084, 6085, 6087, 6088, 6089, 6090, 6091, 6092, 6093, 6094, 6095, 6096, 6097, 6098, 6099, 6100, 6102, 6103, 6104, 6105, 6106, 6107, 6108, 6115, 6116, 6117, 6118, 6119, 6120, 6121, 6122, 6123, 6124, 6131, 6132, 6133, 6134, 6135, 6136, 6137, 6138, 6139, 6140, 6141, 6142, 6143, 6144, 6145, 6146, 6147, 6148, 6149, 6150, 6151, 6152, 6154, 6155, 6156, 6157, 6158, 6159, 6160, 6161, 6162, 6163, 6164, 6165, 6166, 6168, 6169, 6170, 6171, 6172, 6173, 6175, 6176, 6177, 6178, 6179, 6180, 6181, 6183, 6184, 6185, 6186, 6190, 6191, 6192, 6193, 6194, 6195, 6196, 6197, 6198, 6199, 6200, 6201, 6202, 6203, 6204, 6205, 6206, 6207, 6208, 6209, 6210, 6212, 6213, 6214, 6215, 6216, 6217, 6218, 6219, 6220, 6227, 6228, 6229, 6230, 6231, 6232, 6233, 6234, 6235, 6236, 6237, 6239, 6240, 6241, 6242, 6243, 6244, 6245, 6246, 6247, 6250, 6251, 6252, 6253, 6254, 6255, 6256, 6257, 6258, 6259, 6260, 6261, 6267, 6268, 6269, 6270, 6271, 6272, 6273, 6274, 6275, 6276, 6277, 6278, 6280, 6281, 6282, 6283, 6284, 6285, 6286, 6287, 6288, 6290, 6291, 6292, 6293, 6294, 6295, 6296, 6297, 6298, 6303, 6304, 6305, 6306, 6307, 6308, 6309, 6310, 6311, 6312, 6313, 6314, 6315, 6316, 6317, 6318, 6319, 6320, 6321, 6322, 6323, 6324, 6325, 6327, 6328, 6329, 6330, 6331, 6332, 6333, 6334, 6335, 6336, 6338, 6339, 6340, 6341, 6342, 6344, 6345, 6346, 6347, 6348, 6349, 6350, 6351, 6352, 6353, 6355, 6356, 6357, 6358, 6359, 6360, 6361, 6366, 6367, 6368, 6369, 6370, 6371, 6372, 6373, 6374, 6375, 6376, 6377, 6378, 6379, 6380, 6381, 6382, 6383, 6384, 6385, 6386, 6387, 6388, 6389, 6390, 6391, 6392, 6393, 6394, 6395, 6396, 6397, 6398, 6401, 6402, 6403, 6404, 6405, 6406, 6407, 6408, 6409, 6410, 6411, 6412, 6415, 6416, 6417, 6418, 6419, 6420, 6421, 6422, 6428, 6429, 6430, 6431, 6440, 6441, 6442, 6443, 6444, 6445, 6446, 6447, 6450, 6451, 6452, 6453, 6454, 6455, 6456, 6457, 6458, 6459, 6460, 6461, 6464, 6465, 6466, 6467, 6468, 6469, 6470, 6471, 6472, 6475, 6476, 6477, 6478, 6479, 6480, 6481, 6482, 6483, 6484, 6485, 6486, 6487, 6488, 6489, 6490, 6491, 6492, 6493, 6494, 6495, 6496, 6498, 6499, 6500, 6502, 6503, 6504, 6505, 6506, 6507, 6508, 6509, 6510, 6511, 6512, 6513, 6514, 6515, 6516, 6517, 6518, 6519, 6520, 6521, 6522, 6523, 6528, 6529, 6530, 6531, 6532, 6533, 6534, 6535, 6538, 6539, 6540, 6541, 6542, 6546, 6547, 6548, 6549, 6550, 6551, 6552, 6553, 6554, 6555, 6556, 6557, 6558, 6559, 6560, 6561, 6562, 6563, 6564, 6565, 6566, 6567, 6568, 6569, 6570, 6571, 6572, 6573, 6574, 6575, 6576, 6577, 6580, 6581, 6582, 6583, 6584, 6585, 6586, 6587, 6588, 6589, 6590, 6591, 6592, 6593, 6594, 6595, 6596, 6597, 6598, 6599, 6600, 6601, 6602, 6603, 6605, 6606, 6607, 6608, 6609, 6610, 6611, 6614, 6615, 6616, 6617, 6619, 6620, 6621, 6622, 6623, 6624, 6625, 6626, 6627, 6628, 6630, 6631, 6632, 6633, 6634, 6636, 6642, 6643, 6644, 6645, 6646, 6647, 6648, 6649, 6650, 6652, 6653, 6654, 6655, 6656, 6657, 6658, 6659, 6660, 6661, 6662, 6663, 6664, 6668, 6669, 6670, 6671, 6672, 6673, 6674, 6676, 6677, 6678, 6679, 6680, 6681, 6682, 6683, 6684, 6685, 6686, 6687, 6688, 6694, 6695, 6696, 6697, 6698, 6699, 6700, 6701, 6702, 6703, 6704, 6705, 6706, 6707, 6708, 6709, 6710, 6711, 6712, 6713, 6714, 6722, 6723, 6724, 6725, 6726, 6727, 6728, 6729, 6730, 6731, 6732, 6733, 6738, 6739, 6740, 6741, 6742, 6743, 6744, 6745, 6746, 6747, 6748, 6749, 6750, 6754, 6755, 6756, 6757, 6758, 6759, 6760, 6761, 6762, 6763, 6764, 6765, 6766, 6767, 6768, 6769, 6772, 6773, 6774, 6777, 6778, 6779, 6780, 6781, 6782, 6788, 6789, 6790, 6791, 6792, 6793, 6794, 6795, 6797, 6798, 6799, 6800, 6801, 6802, 6803, 6804, 6805, 6806, 6Line truncated
|
||||
+91
-90
@@ -1,150 +1,151 @@
|
||||
# =============================================================================
|
||||
# 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
|
||||
# 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.
|
||||
# =============================================================================
|
||||
|
||||
# --- Core paths ---
|
||||
# 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
|
||||
OUTPUT_DIR=/opt/zenai-kpc-counter
|
||||
DB_PATH=/opt/zenai-kpc-counter/counter.db
|
||||
STATE_FILE=/opt/zenai-kpc-counter/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://10.38.30.64:8554/my_stream
|
||||
# FFmpeg capture options passed to cv2.VideoCapture (RTSP low-latency flags)
|
||||
SOURCE=rtsp://192.168.192.250:8554/my_stream_1
|
||||
OPENCV_FFMPEG_CAPTURE_OPTIONS=rtsp_transport;tcp|fflags;nobuffer|flags;low_delay
|
||||
|
||||
# --- RKNN model ---
|
||||
# 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)
|
||||
MODEL_PATH=/opt/models/zenai_kac_sukawarna_20260716.rknn
|
||||
IMGSZ=320
|
||||
# Use FP16 inference on NPU (true/false); currently unused in ByteTrack variant
|
||||
HALF=false
|
||||
# NPU core mask: 1=core0, 2=core1, 3=core0+core1, 7=all three
|
||||
CORE_MASK=1
|
||||
# Compute device index (reserved; not used at runtime)
|
||||
CORE_MASK=7
|
||||
DEVICE=0
|
||||
|
||||
# --- YOLO decoder ---
|
||||
# Number of object classes the model outputs
|
||||
NUM_CLASSES=4
|
||||
# Apply sigmoid to raw class scores (true/false); set true if model head uses BCE logits
|
||||
NUM_CLASSES=1
|
||||
SCORE_SIGMOID=false
|
||||
|
||||
# --- Detection ---
|
||||
# Confidence threshold – detections below this are discarded before NMS
|
||||
CONF=0.5
|
||||
CONF=0.6
|
||||
NMS_IOU=0.9
|
||||
|
||||
# --- ByteTrack tracking ---
|
||||
# Detections with score >= this get priority matching in the first association stage
|
||||
TRACK_HIGH_THRESH=0.5
|
||||
# Detections with score between this and TRACK_HIGH_THRESH are matched in the second stage
|
||||
TRACK_LOW_THRESH=0.3
|
||||
# IoU threshold for the first-stage association (0–1). Higher = stricter overlap required
|
||||
TRACK_MATCH_THRESH=0.7
|
||||
# Frames a track survives without a match before being permanently removed
|
||||
TRACK_HIGH_THRESH=0.6
|
||||
TRACK_LOW_THRESH=0.4
|
||||
TRACK_MATCH_THRESH=0.8
|
||||
TRACK_BUFFER=60
|
||||
# Minimum consecutive (or total) hits needed before a track is considered confirmed
|
||||
TRACK_MIN_HITS=3
|
||||
|
||||
# --- ID-switch counting guards ---
|
||||
DEDUP_FRAMES=15
|
||||
DEDUP_PX=60
|
||||
INHERIT_SEC=1.0
|
||||
INHERIT_PX=60
|
||||
INHERIT_CROSS_MAX_DY=40
|
||||
|
||||
# --- 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
|
||||
# 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
|
||||
# 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=
|
||||
# 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 ---
|
||||
# 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
|
||||
DAILY_CUTOFF_TIME=00:00
|
||||
CUTOFF_TIME=00:00
|
||||
|
||||
# --- CSV export ---
|
||||
# Write per-crossing events to a CSV file (true/false)
|
||||
EXPORT_CSV=false
|
||||
# Path where the crossing CSV is written
|
||||
CROSS_CSV=/tmp/crossings.csv
|
||||
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
|
||||
|
||||
# --- 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
|
||||
# Mean absolute pixel difference threshold (0–255) to consider a frame as having
|
||||
# motion. Lower = more sensitive. Default 5.0.
|
||||
MOTION_PIXEL_DELTA=25
|
||||
MOTION_MIN_AREA_FRAC=0.002
|
||||
MOTION_HEARTBEAT_FRAMES=15
|
||||
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)
|
||||
# --- Video recording (ByteTrack-style session) ---
|
||||
# When true, record raw frames for each counting session
|
||||
RECORD_VIDEO=false
|
||||
# Duration in seconds of each video segment file
|
||||
# session = encode in SHM, start on webhook IN/OUT (or control ON), stop on OFF
|
||||
# segment = legacy continuous hourly files under OUTPUT_DIR
|
||||
RECORD_MODE=session
|
||||
# Pending MP4 while session is active (tmpfs / RAM)
|
||||
SHM_DIR=/dev/shm/zenai-kpc-counter
|
||||
# Finished MP4 destination (use NFS mount here if desired). Defaults to OUTPUT_DIR.
|
||||
VIDEO_OUTPUT_DIR=/opt/zenai-kpc-counter
|
||||
# Extra seconds to keep recording after session end (IN/OUT → OFF)
|
||||
RECORD_END_DELAY=1
|
||||
# Delete pending clip if no object was counted during the session
|
||||
RECORD_DISCARD_EMPTY=true
|
||||
# Delete day folders under VIDEO_OUTPUT_DIR older than this many days (0 = off)
|
||||
RECORD_RETENTION_DAYS=3
|
||||
# H.264 quality via OpenCV FFmpeg writer (empty = default). Lower CRF = better.
|
||||
RECORD_CRF=18
|
||||
RECORD_PRESET=slow
|
||||
# Legacy segment length (only when RECORD_MODE=segment)
|
||||
VIDEO_SEGMENT_SEC=3600
|
||||
# Output video FPS (fallback if source FPS is unknown or ≤ 1)
|
||||
OUTPUT_FPS=15
|
||||
|
||||
# --- Runtime control (start/stop counting on the fly) ---
|
||||
CONTROL_ENABLED=false
|
||||
CONTROL_FILE=/opt/zenai-kpc-counter/control.json
|
||||
CONTROL_DEFAULT_COUNTING=true
|
||||
CONTROL_POLL_SEC=1.0
|
||||
CONTROL_SOCKET_ENABLED=false
|
||||
CONTROL_SOCKET_HOST=127.0.0.1
|
||||
CONTROL_SOCKET_PORT=5090
|
||||
|
||||
# --- Status webhook gate (count only when status is IN or OUT) ---
|
||||
STATUS_WEBHOOK_ENABLED=true
|
||||
STATUS_WEBHOOK_FILE=/opt/zenai-kpc-python/status_webhook_state.json
|
||||
STATUS_WEBHOOK_ACTIVE_VALUES=IN,OUT
|
||||
STATUS_WEBHOOK_INACTIVE_VALUE=OFF
|
||||
|
||||
|
||||
# Sliding window in seconds for computing the crossing rate (objects/minute)
|
||||
RATE_WINDOW_SEC=60
|
||||
WARMUP_FRAMES=30
|
||||
RECONNECT_DELAY_SEC=3
|
||||
MAX_RECONNECT_ATTEMPTS=0
|
||||
# Max stream-error logs per outage; resets when stream recovers. 0 = unlimited.
|
||||
STREAM_ERROR_LOG_MAX=3
|
||||
TRACKED_PRUNE_SEC=300
|
||||
|
||||
# --- Live stream snapshot ---
|
||||
# Periodically write the latest annotated frame as JPEG for an external web server
|
||||
LIVE_STREAM_ENABLED=true
|
||||
# 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_FRAME_PATH=/dev/shm/zenai-kpc-counter/live_frame.jpg
|
||||
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
|
||||
DASHBOARD_PORT=7000
|
||||
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
|
||||
|
||||
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
|
||||
# Debug: set DEBUG_TRACKING=true to log per-frame tracking details to stdout
|
||||
DEBUG_TRACKING=true
|
||||
@@ -1,3 +1,7 @@
|
||||
# System packages (apt — not installable via pip):
|
||||
# sudo apt install -y python3 python3-venv python3-pip ffmpeg libgl1
|
||||
# ffmpeg: RTSP capture via OpenCV + session MP4 recording (VideoWriter)
|
||||
|
||||
numpy<2
|
||||
rknn-toolkit-lite2
|
||||
opencv-python
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Standalone status webhook (stdlib only, HTTP + HTTPS).
|
||||
|
||||
Receives:
|
||||
GET /status -> {"status": "IN"|"OUT"|"OFF"}
|
||||
GET /?lokasi=IN|OUT&status=ON|OFF -> success | fail
|
||||
|
||||
State stored in JSON as IN | OUT | OFF.
|
||||
|
||||
Success (200) only when a valid transition applies; otherwise 400 and JSON unchanged:
|
||||
- lokasi=IN, status=ON + current OFF -> IN
|
||||
- lokasi=OUT, status=ON + current OFF -> OUT
|
||||
- lokasi=IN, status=OFF + current IN -> OFF
|
||||
- lokasi=OUT, status=OFF + current OUT -> OFF
|
||||
|
||||
Fixed ports: HTTP 8002, HTTPS 8443 (TLS cannot share a port with plain HTTP).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import ssl
|
||||
import subprocess
|
||||
import threading
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
# Fixed ports — always the same (do not override via env).
|
||||
HTTP_PORT = 8002
|
||||
HTTPS_PORT = 8443
|
||||
BASE_DIR = Path(os.path.dirname(os.path.abspath(__file__)))
|
||||
STATE_FILE = Path(
|
||||
os.getenv("STATUS_WEBHOOK_STATE", str(BASE_DIR / "status_webhook_state.json"))
|
||||
)
|
||||
CERT_FILE = Path(os.getenv("STATUS_WEBHOOK_CERT", str(BASE_DIR / "status_webhook_cert.pem")))
|
||||
KEY_FILE = Path(os.getenv("STATUS_WEBHOOK_KEY", str(BASE_DIR / "status_webhook_key.pem")))
|
||||
ALLOWED_LOKASI = frozenset({"IN", "OUT"})
|
||||
ALLOWED_STATUS = frozenset({"ON", "OFF"})
|
||||
ALLOWED_STORED = frozenset({"IN", "OUT", "OFF"})
|
||||
|
||||
_lock = threading.Lock()
|
||||
|
||||
|
||||
def normalize_lokasi(value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
value = value.strip().upper()
|
||||
return value if value in ALLOWED_LOKASI else None
|
||||
|
||||
|
||||
def normalize_request_status(value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
value = value.strip().upper()
|
||||
return value if value in ALLOWED_STATUS else None
|
||||
|
||||
|
||||
def normalize_stored(value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
value = str(value).strip().upper()
|
||||
return value if value in ALLOWED_STORED else None
|
||||
|
||||
|
||||
def load_status() -> str | None:
|
||||
try:
|
||||
data = json.loads(STATE_FILE.read_text(encoding="utf-8"))
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
print(f"status_webhook: failed to read state from {STATE_FILE}: {exc}")
|
||||
return None
|
||||
return normalize_stored(data.get("status"))
|
||||
|
||||
|
||||
def is_run_status(status: str | None = None) -> bool:
|
||||
"""True when counter should run (IN or OUT)."""
|
||||
if status is None:
|
||||
status = load_status()
|
||||
return normalize_stored(status) in ("IN", "OUT")
|
||||
|
||||
|
||||
def save_status(status: str) -> None:
|
||||
STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = STATE_FILE.with_suffix(".tmp")
|
||||
payload = {"status": status}
|
||||
tmp.write_text(json.dumps(payload) + "\n", encoding="utf-8")
|
||||
tmp.replace(STATE_FILE)
|
||||
|
||||
|
||||
def apply_transition(
|
||||
lokasi: str, status: str, current: str | None
|
||||
) -> tuple[str | None, bool]:
|
||||
"""Return (new_status, ok). ok True only for a valid transition that stores."""
|
||||
# Treat missing/empty state as OFF so first ON can start a session.
|
||||
effective = current if current is not None else "OFF"
|
||||
|
||||
if status == "ON":
|
||||
if effective == "OFF":
|
||||
return lokasi, True
|
||||
return None, False
|
||||
|
||||
# status == "OFF"
|
||||
if effective == lokasi:
|
||||
return "OFF", True
|
||||
return None, False
|
||||
|
||||
|
||||
def ensure_tls_certs() -> None:
|
||||
if CERT_FILE.is_file() and KEY_FILE.is_file():
|
||||
return
|
||||
CERT_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
print(
|
||||
f"status_webhook: generating self-signed TLS cert at {CERT_FILE} "
|
||||
f"(set STATUS_WEBHOOK_CERT / STATUS_WEBHOOK_KEY to use your own)"
|
||||
)
|
||||
subprocess.run(
|
||||
[
|
||||
"openssl",
|
||||
"req",
|
||||
"-x509",
|
||||
"-newkey",
|
||||
"rsa:2048",
|
||||
"-keyout",
|
||||
str(KEY_FILE),
|
||||
"-out",
|
||||
str(CERT_FILE),
|
||||
"-days",
|
||||
"365",
|
||||
"-nodes",
|
||||
"-subj",
|
||||
"/CN=status-webhook",
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
|
||||
class StatusHandler(BaseHTTPRequestHandler):
|
||||
def log_message(self, fmt: str, *args) -> None:
|
||||
print(f"status_webhook: {self.address_string()} - {fmt % args}")
|
||||
|
||||
def do_GET(self) -> None:
|
||||
parsed = urlparse(self.path)
|
||||
path = parsed.path.rstrip("/") or "/"
|
||||
if path == "/status":
|
||||
current = load_status() or "OFF"
|
||||
self._send_json(200, {"status": current})
|
||||
return
|
||||
if path not in ("/", ""):
|
||||
self._send_text(404, "Not Found")
|
||||
return
|
||||
|
||||
params = parse_qs(parsed.query)
|
||||
lokasi_values = params.get("lokasi", [])
|
||||
status_values = params.get("status", [])
|
||||
if (
|
||||
not lokasi_values
|
||||
or lokasi_values[0] == ""
|
||||
or not status_values
|
||||
or status_values[0] == ""
|
||||
):
|
||||
self._send_text(400, "fail")
|
||||
return
|
||||
|
||||
lokasi = normalize_lokasi(lokasi_values[0])
|
||||
status = normalize_request_status(status_values[0])
|
||||
if lokasi is None or status is None:
|
||||
self._send_text(400, "fail")
|
||||
return
|
||||
|
||||
with _lock:
|
||||
current = load_status()
|
||||
new_status, ok = apply_transition(lokasi, status, current)
|
||||
if not ok or new_status is None:
|
||||
self._send_text(400, "fail")
|
||||
return
|
||||
save_status(new_status)
|
||||
|
||||
self._send_text(200, "success")
|
||||
|
||||
def _send_text(self, code: int, body: str) -> None:
|
||||
raw = body.encode("utf-8")
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", "text/plain; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(raw)))
|
||||
self.end_headers()
|
||||
self.wfile.write(raw)
|
||||
|
||||
def _send_json(self, code: int, payload: dict) -> None:
|
||||
raw = json.dumps(payload).encode("utf-8")
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(raw)))
|
||||
self.end_headers()
|
||||
self.wfile.write(raw)
|
||||
|
||||
|
||||
def make_http_server(port: int = HTTP_PORT) -> ThreadingHTTPServer:
|
||||
return ThreadingHTTPServer(("0.0.0.0", port), StatusHandler)
|
||||
|
||||
|
||||
def make_https_server(port: int = HTTPS_PORT) -> ThreadingHTTPServer:
|
||||
ensure_tls_certs()
|
||||
server = ThreadingHTTPServer(("0.0.0.0", port), StatusHandler)
|
||||
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||
ctx.load_cert_chain(certfile=str(CERT_FILE), keyfile=str(KEY_FILE))
|
||||
server.socket = ctx.wrap_socket(server.socket, server_side=True)
|
||||
return server
|
||||
|
||||
|
||||
def _try_make_server(kind: str, factory) -> ThreadingHTTPServer | None:
|
||||
try:
|
||||
return factory()
|
||||
except OSError as exc:
|
||||
print(f"status_webhook: {kind} port already in use ({exc}); skipping bind")
|
||||
return None
|
||||
|
||||
|
||||
def start_background() -> tuple[ThreadingHTTPServer | None, ThreadingHTTPServer | None]:
|
||||
"""Start HTTP + HTTPS servers on daemon threads. Safe if ports are taken."""
|
||||
http_server = _try_make_server("HTTP", make_http_server)
|
||||
https_server = _try_make_server("HTTPS", make_https_server)
|
||||
|
||||
if http_server is not None:
|
||||
threading.Thread(
|
||||
target=http_server.serve_forever,
|
||||
name="status-webhook-http",
|
||||
daemon=True,
|
||||
).start()
|
||||
print(
|
||||
f"status_webhook HTTP http://0.0.0.0:{HTTP_PORT}/"
|
||||
f"?lokasi=IN|OUT&status=ON|OFF"
|
||||
)
|
||||
|
||||
if https_server is not None:
|
||||
threading.Thread(
|
||||
target=https_server.serve_forever,
|
||||
name="status-webhook-https",
|
||||
daemon=True,
|
||||
).start()
|
||||
print(
|
||||
f"status_webhook HTTPS https://0.0.0.0:{HTTPS_PORT}/"
|
||||
f"?lokasi=IN|OUT&status=ON|OFF"
|
||||
)
|
||||
|
||||
print(f"status_webhook state file: {STATE_FILE}")
|
||||
return http_server, https_server
|
||||
|
||||
|
||||
def stop_servers(
|
||||
http_server: ThreadingHTTPServer | None,
|
||||
https_server: ThreadingHTTPServer | None,
|
||||
) -> None:
|
||||
for server in (http_server, https_server):
|
||||
if server is None:
|
||||
continue
|
||||
try:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
except Exception as exc:
|
||||
print(f"status_webhook: shutdown error: {exc}")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
http_server, https_server = start_background()
|
||||
if http_server is None and https_server is None:
|
||||
raise SystemExit("status_webhook: neither HTTP nor HTTPS could bind")
|
||||
|
||||
stop = threading.Event()
|
||||
|
||||
def _handle_stop(_signum, _frame) -> None:
|
||||
stop.set()
|
||||
|
||||
signal.signal(signal.SIGTERM, _handle_stop)
|
||||
signal.signal(signal.SIGINT, _handle_stop)
|
||||
|
||||
try:
|
||||
# serve_forever already running on threads; park main until stop.
|
||||
while not stop.is_set():
|
||||
stop.wait(3600)
|
||||
finally:
|
||||
print("status_webhook stopped")
|
||||
stop_servers(http_server, https_server)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,19 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIDEzCCAfugAwIBAgIUFz+BTq4D+gxdrVY45TcZBoN8PnYwDQYJKoZIhvcNAQEL
|
||||
BQAwGTEXMBUGA1UEAwwOc3RhdHVzLXdlYmhvb2swHhcNMjYwODA2MDQ0NjE4WhcN
|
||||
MjcwODA2MDQ0NjE4WjAZMRcwFQYDVQQDDA5zdGF0dXMtd2ViaG9vazCCASIwDQYJ
|
||||
KoZIhvcNAQEBBQADggEPADCCAQoCggEBALyKLPWaDeepc9n2edmHniHtsRkUGjl3
|
||||
5SmkWcSbMfYUO3MeRif1HysnFd6VpZP9xP3GuxpMl6/Zpabcs5t1hKeQjIa9rl2z
|
||||
OYr9ly4Lcwyl1UYwqy7yjNOtPrsNUNoWQkSuX7pHSdxDgh7Oa6KbKqYCsljjuT6z
|
||||
ZBSptFINMJpP0n1/BEVYclk10nRqQ67YLngBCYBoSHtgvSSYlRDOUYmXja++Vlu4
|
||||
tsJyguAb+YuZ+79Addsc+7uuO622IvUEcO55pzf4dBdenkxDEoPW7fppikh301Ck
|
||||
k+wyCPv3jbDR/QOiH5K7YtdovFQuw+BAVRH/ddarBZwSaywhVCf7TXMCAwEAAaNT
|
||||
MFEwHQYDVR0OBBYEFPZtrVQMH8TCxTNzRA9LtiBdFL//MB8GA1UdIwQYMBaAFPZt
|
||||
rVQMH8TCxTNzRA9LtiBdFL//MA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQEL
|
||||
BQADggEBABqAXwBSS3JcpBRGmIYwulOC2LUPi/qr58j9VvNFTLlRwUQkmXSXbcW/
|
||||
n/cPjJxg0RG9xihCxUCClbXuC76ZKeTyfmp3kB0mWRJ7lRonf674/35/EG5+xXcM
|
||||
2Vs6zkVrrcbebuGHzAv8z0GfcFbyN5iecHzpOD64CDRkxNb6hVf2u8Q7oC9TB9r6
|
||||
PHrErjrT8pYs15LbZlB7925PtWZB5z8x695qYpO1c0y6IBsbVsXm9lVxww/BldK9
|
||||
nJSIetKGSp1xoHgGsaiW1oNMkdpML0f9sk3CVzO4e3MG+dWJ9YCzTE+78YniN1Wr
|
||||
U4dXZz9jT9eMxiav/KxrA3JVThmU8Qo=
|
||||
-----END CERTIFICATE-----
|
||||
@@ -0,0 +1,28 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC8iiz1mg3nqXPZ
|
||||
9nnZh54h7bEZFBo5d+UppFnEmzH2FDtzHkYn9R8rJxXelaWT/cT9xrsaTJev2aWm
|
||||
3LObdYSnkIyGva5dszmK/ZcuC3MMpdVGMKsu8ozTrT67DVDaFkJErl+6R0ncQ4Ie
|
||||
zmuimyqmArJY47k+s2QUqbRSDTCaT9J9fwRFWHJZNdJ0akOu2C54AQmAaEh7YL0k
|
||||
mJUQzlGJl42vvlZbuLbCcoLgG/mLmfu/QHXbHPu7rjuttiL1BHDueac3+HQXXp5M
|
||||
QxKD1u36aYpId9NQpJPsMgj7942w0f0Doh+Su2LXaLxULsPgQFUR/3XWqwWcEmss
|
||||
IVQn+01zAgMBAAECggEAC96vIe9G/NTARHKuDTHqlLxAMBIB7KhNtydvt18F8DYp
|
||||
3/+B7zYRdkgJqm/FcuHBKzD9ypQT4LBVK4ItlJX7egkxr7H1blTARK3efLmfzqYK
|
||||
HVcnD9eZYiJAFsqp0nEgTu6jfDjMv59Ia+QXBq+6KaV10P7VRMtKe7qLbbcC3lQY
|
||||
bzpwQr691t0NUy7xXbqjyx/akuEOAGZPYDkovdBu3eQrCzjGSosOKmwxZHUIUyDI
|
||||
CYlIiKViR5qVvftioPgEHE/lM9e/2JWxQZB5vZlpjsWut5RNJihoZeWxKBNWxEqj
|
||||
75zPiUa1KHAke4AiDKqxL37/1Dd4HWF/nz0PMPv6gQKBgQDtd4RNriSgruq2olh6
|
||||
EAOlg70t1Qv8GOxrmOQTRm1nXa4N6Mb8eOpB7BH8twyRUwV5ftoIlG4GX2k/0rHT
|
||||
3o/+C8BVUG8+t65K2fku8o2mVowjshWSgEKyEG/mZ4jGUlJYwi4rZM9opz/hhY8u
|
||||
+0+yOOBmOuPzRXAL7eKQSjEWUwKBgQDLQR3c3IHWCGxaeNTTIj4KxKLOeGHT7Qt5
|
||||
t9ktmKtTwMHbJWksQ0QNAWoXLr30bxyQgJjtd3TTi93nl/ALT956zp7rLFj0017O
|
||||
1tGwEKgDgKuMO5wolnWUCMw+w071jdBpLC/hb6zJuLQHITrY+IAYOgZooWe/w9vD
|
||||
lk6/BivIYQKBgQDZrQQXXPlwXccD8V9fTMy67T7+A1xAE+ysWPNBA/8HkKUbVPUK
|
||||
vCAom6iFWppnoI3VKEXfNYiByPYmrhGaYFroCoec7OV8vU1EifjUYz0bbBx8ICOM
|
||||
Lox0w4J/1wpWmWGowR8nYfqKOT3ikdaFv5L3kRGKRJNuDYm/NanIkGncxwKBgFAC
|
||||
VAUK8DkWm8CJbA2onw+SFBx+mtPXrfq9+knOnTKc4DKp6Vq5J+KOufpiNfgwfOgN
|
||||
FyXzLhPQLQvrbVymlgd1qm0cye+l/N4jBevuwpSOY/kRxgjcIXCiffP+4egbaPzd
|
||||
ngN5+GR3xrY/yHB8ccAXp0osrzB3oty9IEZl4XpBAoGAMEXHcPfb7u7maXm4w6bR
|
||||
BGGlSmxoUqueS252NSfuBIpeBpDdqJS8+euh2s7mAZOrBUlw+XTzC6HbyPoThLl/
|
||||
dPyagyL48dwjZMn65s6T8Ia5uzDzSfjREKI2yFkAQqArF7JbBY/AwHkcko8o/zXc
|
||||
F1TYtACipQQyFRK6FYmjK+M=
|
||||
-----END PRIVATE KEY-----
|
||||
@@ -0,0 +1 @@
|
||||
{"status": "OFF"}
|
||||
@@ -0,0 +1,420 @@
|
||||
"""
|
||||
Per-session video recorder (ByteTrack-counter style).
|
||||
|
||||
Encode pending MP4 into SHM (tmpfs), then background-copy to VIDEO_OUTPUT_DIR
|
||||
when the session ends. Triggers: webhook OFF→IN/OUT start, IN/OUT→OFF stop.
|
||||
|
||||
Uses ffmpeg subprocess when available (reliable on RK3588); falls back to OpenCV
|
||||
VideoWriter with multiple fourcc values.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
|
||||
_CODEC_CANDIDATES = ("avc1", "mp4v", "H264", "X264")
|
||||
|
||||
|
||||
class _FrameWriter:
|
||||
def write(self, frame) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
def close(self) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
def is_open(self) -> bool:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class _OpenCVFrameWriter(_FrameWriter):
|
||||
def __init__(self, path: str, w: int, h: int, fps: float, crf: int, preset: str):
|
||||
self._writer = None
|
||||
self._codec = None
|
||||
if crf > 0 or preset:
|
||||
opts = []
|
||||
if crf > 0:
|
||||
opts.append(f"crf;{crf}")
|
||||
if preset:
|
||||
opts.append(f"preset;{preset}")
|
||||
os.environ["OPENCV_FFMPEG_WRITE_OPTIONS"] = "|".join(opts)
|
||||
|
||||
for codec in _CODEC_CANDIDATES:
|
||||
writer = cv2.VideoWriter(
|
||||
path, cv2.VideoWriter_fourcc(*codec), fps, (int(w), int(h))
|
||||
)
|
||||
if writer.isOpened():
|
||||
self._writer = writer
|
||||
self._codec = codec
|
||||
print(f"[RECORD] OpenCV writer codec={codec} fps={fps} size={w}x{h}")
|
||||
return
|
||||
|
||||
if self._writer is not None:
|
||||
self._writer.release()
|
||||
|
||||
def is_open(self) -> bool:
|
||||
return self._writer is not None and self._writer.isOpened()
|
||||
|
||||
def write(self, frame) -> None:
|
||||
if self.is_open():
|
||||
self._writer.write(frame)
|
||||
|
||||
def close(self) -> None:
|
||||
if self._writer is not None:
|
||||
try:
|
||||
self._writer.release()
|
||||
except Exception:
|
||||
pass
|
||||
self._writer = None
|
||||
|
||||
|
||||
class _FfmpegPipeWriter(_FrameWriter):
|
||||
"""Pipe raw BGR frames to ffmpeg (needs even width/height for libx264)."""
|
||||
|
||||
def __init__(self, path: str, w: int, h: int, fps: float, crf: int, preset: str):
|
||||
self._proc = None
|
||||
self._w = int(w) - (int(w) % 2)
|
||||
self._h = int(h) - (int(h) % 2)
|
||||
self._use_crop = self._w != int(w) or self._h != int(h)
|
||||
if self._w < 2 or self._h < 2:
|
||||
return
|
||||
|
||||
preset_v = preset or "medium"
|
||||
crf_v = str(crf) if crf > 0 else "23"
|
||||
cmd = [
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-f",
|
||||
"rawvideo",
|
||||
"-vcodec",
|
||||
"rawvideo",
|
||||
"-s",
|
||||
f"{self._w}x{self._h}",
|
||||
"-pix_fmt",
|
||||
"bgr24",
|
||||
"-r",
|
||||
str(fps),
|
||||
"-i",
|
||||
"-",
|
||||
"-an",
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-preset",
|
||||
preset_v,
|
||||
"-crf",
|
||||
crf_v,
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
str(path),
|
||||
]
|
||||
try:
|
||||
self._proc = subprocess.Popen(
|
||||
cmd,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
print(
|
||||
f"[RECORD] ffmpeg pipe libx264 fps={fps} size={self._w}x{self._h} → {path}"
|
||||
)
|
||||
except FileNotFoundError:
|
||||
print("[RECORD] ffmpeg not found in PATH")
|
||||
except Exception as exc:
|
||||
print(f"[RECORD] ffmpeg pipe failed to start: {exc}")
|
||||
|
||||
def is_open(self) -> bool:
|
||||
return self._proc is not None and self._proc.poll() is None
|
||||
|
||||
def write(self, frame) -> None:
|
||||
if not self.is_open():
|
||||
return
|
||||
if self._use_crop:
|
||||
frame = frame[: self._h, : self._w]
|
||||
try:
|
||||
self._proc.stdin.write(frame.tobytes())
|
||||
except (BrokenPipeError, OSError) as exc:
|
||||
print(f"[RECORD] ffmpeg pipe write error: {exc}")
|
||||
self.close()
|
||||
|
||||
def close(self) -> None:
|
||||
if self._proc is None:
|
||||
return
|
||||
try:
|
||||
if self._proc.stdin:
|
||||
self._proc.stdin.close()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self._proc.wait(timeout=30)
|
||||
except subprocess.TimeoutExpired:
|
||||
self._proc.kill()
|
||||
if self._proc.returncode not in (0, None):
|
||||
err = ""
|
||||
try:
|
||||
err = (self._proc.stderr.read() if self._proc.stderr else b"").decode(
|
||||
"utf-8", errors="replace"
|
||||
).strip()
|
||||
except Exception:
|
||||
pass
|
||||
if err:
|
||||
print(f"[RECORD] ffmpeg exit {self._proc.returncode}: {err[:500]}")
|
||||
self._proc = None
|
||||
|
||||
|
||||
def _open_frame_writer(path: str, w: int, h: int, fps: float, crf: int, preset: str):
|
||||
if shutil.which("ffmpeg"):
|
||||
writer = _FfmpegPipeWriter(path, w, h, fps, crf, preset)
|
||||
if writer.is_open():
|
||||
return writer
|
||||
writer.close()
|
||||
|
||||
writer = _OpenCVFrameWriter(path, w, h, fps, crf, preset)
|
||||
if writer.is_open():
|
||||
return writer
|
||||
writer.close()
|
||||
print(f"[RECORD] Failed to open any writer for {path}")
|
||||
return None
|
||||
|
||||
|
||||
def _copy_and_clean(src: str, dst: str) -> None:
|
||||
try:
|
||||
src_p = Path(src)
|
||||
if not src_p.is_file() or src_p.stat().st_size == 0:
|
||||
print(f"[RECORD] Skip move — missing or empty: {src}")
|
||||
src_p.unlink(missing_ok=True)
|
||||
return
|
||||
Path(dst).parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(src, dst)
|
||||
src_p.unlink(missing_ok=True)
|
||||
print(f"[RECORD] Moved: {src} -> {dst}")
|
||||
except Exception as exc:
|
||||
print(f"[RECORD] Move failed {src} -> {dst}: {exc}")
|
||||
|
||||
|
||||
class VideoSessionWriter:
|
||||
"""IDLE → RECORDING → STOPPING → save/discard → IDLE."""
|
||||
|
||||
IDLE = "IDLE"
|
||||
RECORDING = "RECORDING"
|
||||
STOPPING = "STOPPING"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
shm_dir: str,
|
||||
video_output_dir: str,
|
||||
w: int,
|
||||
h: int,
|
||||
fps: float,
|
||||
end_delay_sec: float = 1.0,
|
||||
retention_days: int = 3,
|
||||
crf: int = -1,
|
||||
preset: str = "",
|
||||
discard_empty: bool = True,
|
||||
):
|
||||
self.shm_dir = Path(shm_dir)
|
||||
self.video_output_dir = Path(video_output_dir)
|
||||
self.w = int(w)
|
||||
self.h = int(h)
|
||||
self.fps = float(fps) if fps and fps > 1 else 15.0
|
||||
self.end_delay_sec = float(end_delay_sec)
|
||||
self.retention_days = int(retention_days)
|
||||
self.crf = int(crf)
|
||||
self.preset = preset or ""
|
||||
self.discard_empty = bool(discard_empty)
|
||||
|
||||
self.state = self.IDLE
|
||||
self.state_start = 0.0
|
||||
self.writer: _FrameWriter | None = None
|
||||
self.current_path = None
|
||||
self.direction = "SESSION"
|
||||
self.counting_date = datetime.now().strftime("%Y-%m-%d")
|
||||
self.had_crossing = False
|
||||
self._frames_written = 0
|
||||
self._move_threads: list[threading.Thread] = []
|
||||
|
||||
self.shm_dir.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
self.video_output_dir.mkdir(parents=True, exist_ok=True)
|
||||
except OSError as exc:
|
||||
print(f"[RECORD] WARNING: cannot create VIDEO_OUTPUT_DIR {video_output_dir}: {exc}")
|
||||
|
||||
for stale in self.shm_dir.glob("pending_*.mp4"):
|
||||
try:
|
||||
stale.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
self.cleanup_retention()
|
||||
|
||||
def note_session_start(self, direction: str, counting_date: str | None = None) -> None:
|
||||
direction = (direction or "SESSION").strip().upper()
|
||||
if direction not in ("IN", "OUT"):
|
||||
direction = "SESSION"
|
||||
if counting_date:
|
||||
self.counting_date = counting_date
|
||||
|
||||
if self.state != self.IDLE:
|
||||
print(f"[RECORD] Session start ignored (state={self.state})")
|
||||
return
|
||||
|
||||
if not self._start_recording(direction):
|
||||
print("[RECORD] Session start failed — no encoder opened")
|
||||
return
|
||||
|
||||
self.state = self.RECORDING
|
||||
self.state_start = time.monotonic()
|
||||
self.had_crossing = False
|
||||
self._frames_written = 0
|
||||
print(f"[RECORD] Session active ({direction})")
|
||||
|
||||
def note_crossing(self) -> None:
|
||||
if self.state in (self.RECORDING, self.STOPPING):
|
||||
self.had_crossing = True
|
||||
|
||||
def note_session_end(self) -> None:
|
||||
if self.state != self.RECORDING:
|
||||
return
|
||||
|
||||
if self.discard_empty and not self.had_crossing:
|
||||
print("[RECORD] Session end — discarding (no crossings)")
|
||||
self._stop_and_delete()
|
||||
self.state = self.IDLE
|
||||
return
|
||||
|
||||
self.state = self.STOPPING
|
||||
self.state_start = time.monotonic()
|
||||
print(f"[RECORD] Session stopping (+{self.end_delay_sec}s tail)")
|
||||
|
||||
def feed_frame(self, raw) -> None:
|
||||
now = time.monotonic()
|
||||
|
||||
if self.state == self.STOPPING:
|
||||
if self.writer is not None and self.writer.is_open():
|
||||
self.writer.write(raw)
|
||||
self._frames_written += 1
|
||||
if now - self.state_start >= self.end_delay_sec:
|
||||
self._stop_and_move()
|
||||
self.state = self.IDLE
|
||||
return
|
||||
|
||||
if self.state == self.RECORDING and self.writer is not None and self.writer.is_open():
|
||||
self.writer.write(raw)
|
||||
self._frames_written += 1
|
||||
|
||||
def cleanup_retention(self) -> None:
|
||||
if self.retention_days <= 0:
|
||||
return
|
||||
cutoff = (datetime.now() - timedelta(days=self.retention_days)).strftime("%Y-%m-%d")
|
||||
try:
|
||||
for entry in self.video_output_dir.iterdir():
|
||||
if entry.is_dir() and entry.name < cutoff:
|
||||
shutil.rmtree(entry, ignore_errors=True)
|
||||
print(f"[RECORD] Retention removed: {entry}")
|
||||
except OSError as exc:
|
||||
print(f"[RECORD] Retention scan failed: {exc}")
|
||||
|
||||
def shutdown(self) -> None:
|
||||
prev = self.state
|
||||
self._close_writer()
|
||||
|
||||
self.state = self.IDLE
|
||||
path = self.current_path
|
||||
self.current_path = None
|
||||
|
||||
if not path:
|
||||
return
|
||||
|
||||
keep = prev in (self.RECORDING, self.STOPPING) and (
|
||||
not self.discard_empty or self.had_crossing
|
||||
)
|
||||
if keep:
|
||||
dst = self._final_path()
|
||||
self._spawn_move(str(path), str(dst))
|
||||
else:
|
||||
try:
|
||||
Path(path).unlink(missing_ok=True)
|
||||
print(f"[RECORD] Discarding on shutdown: {path}")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
for t in self._move_threads:
|
||||
t.join(timeout=30.0)
|
||||
self._move_threads.clear()
|
||||
|
||||
def _start_recording(self, direction: str) -> bool:
|
||||
self._close_writer()
|
||||
|
||||
self.direction = direction
|
||||
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
path = self.shm_dir / f"pending_{direction}_{ts}.mp4"
|
||||
self.current_path = path
|
||||
|
||||
self.writer = _open_frame_writer(
|
||||
str(path), self.w, self.h, self.fps, self.crf, self.preset
|
||||
)
|
||||
if self.writer is not None and self.writer.is_open():
|
||||
return True
|
||||
|
||||
self._close_writer()
|
||||
self.current_path = None
|
||||
return False
|
||||
|
||||
def _close_writer(self) -> None:
|
||||
if self.writer is not None:
|
||||
try:
|
||||
self.writer.close()
|
||||
except Exception:
|
||||
pass
|
||||
self.writer = None
|
||||
|
||||
def _stop_and_delete(self) -> None:
|
||||
path = self.current_path
|
||||
self._close_writer()
|
||||
self.current_path = None
|
||||
if path:
|
||||
try:
|
||||
Path(path).unlink(missing_ok=True)
|
||||
print(f"[RECORD] Discarded: {path} ({self._frames_written} frames)")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def _stop_and_move(self) -> None:
|
||||
path = self.current_path
|
||||
self._close_writer()
|
||||
self.current_path = None
|
||||
if not path:
|
||||
return
|
||||
size = Path(path).stat().st_size if Path(path).is_file() else 0
|
||||
print(f"[RECORD] Stopped: {path} ({self._frames_written} frames, {size} bytes)")
|
||||
if size == 0:
|
||||
Path(path).unlink(missing_ok=True)
|
||||
print(f"[RECORD] Skip move — empty file removed: {path}")
|
||||
return
|
||||
dst = self._final_path()
|
||||
self._spawn_move(str(path), str(dst))
|
||||
|
||||
def _final_path(self) -> Path:
|
||||
day = self.counting_date or datetime.now().strftime("%Y-%m-%d")
|
||||
date_part = day.replace("-", "")
|
||||
ts = datetime.now().strftime("%H%M%S")
|
||||
name = f"{self.direction}_{date_part}_{ts}.mp4"
|
||||
return self.video_output_dir / day / name
|
||||
|
||||
def _spawn_move(self, src: str, dst: str) -> None:
|
||||
t = threading.Thread(
|
||||
target=_copy_and_clean,
|
||||
args=(src, dst),
|
||||
name="record-move",
|
||||
daemon=True,
|
||||
)
|
||||
t.start()
|
||||
self._move_threads.append(t)
|
||||
@@ -0,0 +1,32 @@
|
||||
[Unit]
|
||||
Description=ZenAI KPC Edge Counter (RTSP + RKNN)
|
||||
Documentation=file:///opt/zenai-kpc-python/DEPLOY.md
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
Group=root
|
||||
|
||||
WorkingDirectory=/opt/zenai-kpc-python
|
||||
EnvironmentFile=/opt/zenai-kpc-python/.env
|
||||
Environment=PYTHONNOUSERSITE=1
|
||||
Environment=PATH=/opt/zenai-kpc-python/venv/bin:/usr/local/bin:/usr/bin:/bin
|
||||
|
||||
ExecStart=/opt/zenai-kpc-python/venv/bin/python counter_live_rknn.py
|
||||
|
||||
TimeoutStopSec=30
|
||||
KillSignal=SIGTERM
|
||||
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
StartLimitInterval=120s
|
||||
StartLimitBurst=5
|
||||
|
||||
NoNewPrivileges=true
|
||||
ProtectHome=true
|
||||
PrivateTmp=false
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,32 @@
|
||||
[Unit]
|
||||
Description=ZenAI KPC Dashboard (Flask)
|
||||
Documentation=file:///opt/zenai-kpc-python/DEPLOY.md
|
||||
After=network-online.target zenai-kpc-counter.service
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
Group=root
|
||||
|
||||
WorkingDirectory=/opt/zenai-kpc-python
|
||||
EnvironmentFile=/opt/zenai-kpc-python/.env
|
||||
Environment=PATH=/opt/zenai-kpc-python/venv/bin:/usr/local/bin:/usr/bin:/bin
|
||||
Environment=FLASK_DEBUG=false
|
||||
|
||||
ExecStart=/opt/zenai-kpc-python/venv/bin/python counter_dashboard.py
|
||||
|
||||
TimeoutStopSec=15
|
||||
KillSignal=SIGTERM
|
||||
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
StartLimitInterval=60s
|
||||
StartLimitBurst=3
|
||||
|
||||
NoNewPrivileges=true
|
||||
ProtectHome=true
|
||||
PrivateTmp=false
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -0,0 +1,32 @@
|
||||
[Unit]
|
||||
Description=ZenAI KPC Status Webhook (HTTP + HTTPS)
|
||||
Documentation=file:///opt/zenai-kpc-python/DEPLOY.md
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=root
|
||||
Group=root
|
||||
|
||||
WorkingDirectory=/opt/zenai-kpc-python
|
||||
EnvironmentFile=/opt/zenai-kpc-python/.env
|
||||
Environment=PYTHONNOUSERSITE=1
|
||||
Environment=PATH=/opt/zenai-kpc-python/venv/bin:/usr/local/bin:/usr/bin:/bin
|
||||
|
||||
ExecStart=/opt/zenai-kpc-python/venv/bin/python status_webhook.py
|
||||
|
||||
TimeoutStopSec=15
|
||||
KillSignal=SIGTERM
|
||||
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
StartLimitInterval=60s
|
||||
StartLimitBurst=3
|
||||
|
||||
NoNewPrivileges=true
|
||||
ProtectHome=true
|
||||
PrivateTmp=false
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
Reference in new issue
Block a user