update ktc to use zone_n instead of left and right for production
This commit is contained in:
1 parent
ea88e92c99
commit
5b4d9d56d4
9 files changed
+884
-516
No files matched your search
@@ -1,7 +1,7 @@
|
|||||||
# ZenAI KTC Counter — Edge Deployment Guide
|
# ZenAI KTC Counter — Edge Deployment Guide
|
||||||
|
|
||||||
Production deployment for **RK3588** (or compatible RKNN NPU) edge devices running a
|
Production deployment for **RK3588** (or compatible RKNN NPU) edge devices running a
|
||||||
**zone-based sack feeder counter** (left / right feeders):
|
**zone-based sack feeder counter** (1…N named zones):
|
||||||
|
|
||||||
| Component | Script | systemd unit |
|
| Component | Script | systemd unit |
|
||||||
|-----------|--------|--------------|
|
|-----------|--------|--------------|
|
||||||
@@ -10,8 +10,8 @@ Production deployment for **RK3588** (or compatible RKNN NPU) edge devices runni
|
|||||||
|
|
||||||
Both processes share a single `.env` file and read/write the same SQLite database and state JSON.
|
Both processes share a single `.env` file and read/write the same SQLite database and state JSON.
|
||||||
|
|
||||||
**Counting model:** two rectangular zones (left + right). A sack is counted once when its
|
**Counting model:** `ZONE_COUNT` rectangular zones (`zone_1`…`zone_N`). A sack is counted once
|
||||||
centroid **enters** a zone. Left zone → left feeder; right zone → right feeder.
|
after its centroid **dwells** inside a zone long enough (`ZONE_i_DWELL_SEC`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -110,7 +110,7 @@ sudo nano .env
|
|||||||
| `NUM_CLASSES` | Must match the exported model |
|
| `NUM_CLASSES` | Must match the exported model |
|
||||||
| `OBJECT_CLASS_ID` | Class index of the object being counted |
|
| `OBJECT_CLASS_ID` | Class index of the object being counted |
|
||||||
| `CLASS_OBJECT` / `OBJECT_LABEL` | Labels stored in DB (e.g. `karung`) |
|
| `CLASS_OBJECT` / `OBJECT_LABEL` | Labels stored in DB (e.g. `karung`) |
|
||||||
| `ZONE_LEFT_*_FRAC` / `ZONE_RIGHT_*_FRAC` | Feeder zone rectangles (tune per camera) |
|
| `ZONE_COUNT` / `ZONE_i_*` | Named zone rectangles, dwell, cooldown (tune per camera) |
|
||||||
| `SECRET_KEY` | Random string for Flask sessions |
|
| `SECRET_KEY` | Random string for Flask sessions |
|
||||||
|
|
||||||
Ensure `STATE_FILE` and `DB_PATH` both live under `/opt/zenai-ktc-counter/` so data survives reboots (avoid `/tmp` in production).
|
Ensure `STATE_FILE` and `DB_PATH` both live under `/opt/zenai-ktc-counter/` so data survives reboots (avoid `/tmp` in production).
|
||||||
@@ -154,29 +154,28 @@ http://<device-ip>:5000
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. Tuning feeder zones
|
## 5. Tuning counting zones
|
||||||
|
|
||||||
Zones are axis-aligned rectangles. Defaults cover the left and right sides of the frame:
|
Zones are axis-aligned rectangles. Default is a single zone:
|
||||||
|
|
||||||
```ini
|
```ini
|
||||||
ZONE_LEFT_X1_FRAC=0.00
|
ZONE_COUNT=1
|
||||||
ZONE_LEFT_Y1_FRAC=0.20
|
ZONE_1_NAME=feeder
|
||||||
ZONE_LEFT_X2_FRAC=0.35
|
ZONE_1_X1_FRAC=0.10
|
||||||
ZONE_LEFT_Y2_FRAC=0.85
|
ZONE_1_Y1_FRAC=0.10
|
||||||
|
ZONE_1_X2_FRAC=0.90
|
||||||
ZONE_RIGHT_X1_FRAC=0.65
|
ZONE_1_Y2_FRAC=1.00
|
||||||
ZONE_RIGHT_Y1_FRAC=0.20
|
ZONE_1_COOLDOWN_SEC=15
|
||||||
ZONE_RIGHT_X2_FRAC=1.00
|
ZONE_1_DWELL_SEC=2
|
||||||
ZONE_RIGHT_Y2_FRAC=0.85
|
|
||||||
```
|
```
|
||||||
|
|
||||||
For pixel-perfect placement, set absolute coordinates instead (they override fractions):
|
For pixel-perfect placement, set absolute coordinates instead (they override fractions):
|
||||||
|
|
||||||
```ini
|
```ini
|
||||||
ZONE_LEFT_X1=40
|
ZONE_1_X1=40
|
||||||
ZONE_LEFT_Y1=120
|
ZONE_1_Y1=120
|
||||||
ZONE_LEFT_X2=420
|
ZONE_1_X2=420
|
||||||
ZONE_LEFT_Y2=900
|
ZONE_1_Y2=900
|
||||||
```
|
```
|
||||||
|
|
||||||
Enable `DEBUG_TRACKING=true` temporarily for verbose inherit / dup / cooldown logs.
|
Enable `DEBUG_TRACKING=true` temporarily for verbose inherit / dup / cooldown logs.
|
||||||
@@ -184,10 +183,10 @@ Enable `DEBUG_TRACKING=true` temporarily for verbose inherit / dup / cooldown lo
|
|||||||
Each successful count always prints a Frigate-style line to the journal:
|
Each successful count always prints a Frigate-style line to the journal:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
Total karung di kandang_bawah_feeder_kanan: 5
|
Total karung di kandang_bawah_feeder: 5
|
||||||
```
|
```
|
||||||
|
|
||||||
Set `FEEDER_LEFT_NAME` / `FEEDER_RIGHT_NAME` (and `OBJECT_LABEL`) in `.env` to match your site.
|
Set `ZONE_i_NAME` (and `OBJECT_LABEL`) in `.env` to match your site.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -235,7 +234,7 @@ sudo systemctl start zenai-ktc-counter zenai-ktc-dashboard
|
|||||||
| No RTSP frames | Ping camera; test with `ffplay <SOURCE>`; check `OPENCV_FFMPEG_CAPTURE_OPTIONS` |
|
| 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 |
|
| Dashboard shows 0 count | `STATE_FILE` in `.env` must match between counter and dashboard; check file exists |
|
||||||
| Live video blank | `LIVE_STREAM_ENABLED=true`; path matches `LIVE_STREAM_FRAME_PATH` in both processes |
|
| Live video blank | `LIVE_STREAM_ENABLED=true`; path matches `LIVE_STREAM_FRAME_PATH` in both processes |
|
||||||
| Wrong feeder / missed counts | Tune `ZONE_*_FRAC`, `CONF`, ByteTrack thresholds; enable `DEBUG_TRACKING=true` |
|
| Wrong zone / missed counts | Tune `ZONE_i_*_FRAC`, `CONF`, ByteTrack thresholds; enable `DEBUG_TRACKING=true` |
|
||||||
| Service keeps restarting | `journalctl -u zenai-ktc-counter -e`; often missing model, bad RTSP URL, or venv not created |
|
| Service keeps restarting | `journalctl -u zenai-ktc-counter -e`; often missing model, bad RTSP URL, or venv not created |
|
||||||
|
|
||||||
### Manual test (without systemd)
|
### Manual test (without systemd)
|
||||||
|
|||||||
+40
-44
@@ -77,22 +77,10 @@ INHERIT_SEC=1.0
|
|||||||
INHERIT_PX=60
|
INHERIT_PX=60
|
||||||
# To DISABLE the inheritance guard, set INHERIT_PX=-1 (distance check can never match).
|
# To DISABLE the inheritance guard, set INHERIT_PX=-1 (distance check can never match).
|
||||||
|
|
||||||
# --- Feeder cooldown (seconds) ---
|
# Per-zone cooldown / dwell are set on ZONE_i_COOLDOWN_SEC and ZONE_i_DWELL_SEC below.
|
||||||
# After a sack is counted on a feeder, ignore further counts on that same feeder
|
|
||||||
# for this many seconds. Useful when multiple detections/IDs fire for one sack.
|
|
||||||
# 0 = disabled.
|
|
||||||
ZONE_COOLDOWN_LEFT_SEC=3
|
|
||||||
ZONE_COOLDOWN_RIGHT_SEC=3
|
|
||||||
|
|
||||||
# --- Feeder dwell (seconds) ---
|
|
||||||
# Sack must remain continuously inside the feeder zone for this many seconds
|
|
||||||
# before it is counted and a cross snapshot is saved. Leaving the zone resets
|
|
||||||
# the timer. 0 = count on the first frame inside the zone.
|
|
||||||
ZONE_DWELL_LEFT_SEC=2
|
|
||||||
ZONE_DWELL_RIGHT_SEC=2
|
|
||||||
|
|
||||||
# --- Display ---
|
# --- Display ---
|
||||||
# Site name shown on the dashboard header (top-right)
|
# Site name shown on the dashboard header
|
||||||
SITE_NAME=ZenAi
|
SITE_NAME=ZenAi
|
||||||
|
|
||||||
# --- Object class names ---
|
# --- Object class names ---
|
||||||
@@ -104,41 +92,49 @@ OBJECT_LABEL=karung
|
|||||||
CLASS_OBJECT=karung
|
CLASS_OBJECT=karung
|
||||||
# Model class ID for the object being counted (default 0)
|
# Model class ID for the object being counted (default 0)
|
||||||
OBJECT_CLASS_ID=0
|
OBJECT_CLASS_ID=0
|
||||||
# Feeder names printed on each count, e.g.
|
# Feeder / zone display names come from ZONE_i_NAME (see zones below).
|
||||||
# "Total karung di kandang_bawah_feeder_kanan: 5"
|
|
||||||
FEEDER_LEFT_NAME=kandang_bawah_feeder_kiri
|
|
||||||
FEEDER_RIGHT_NAME=kandang_bawah_feeder_kanan
|
|
||||||
# Verbose tracking debug (inherit / dup / cooldown / per-frame dets)
|
# Verbose tracking debug (inherit / dup / cooldown / per-frame dets)
|
||||||
DEBUG_TRACKING=false
|
DEBUG_TRACKING=false
|
||||||
|
|
||||||
# --- Feeder zones (left / right) ---
|
# --- Counting zones (1…N) ---
|
||||||
# Rectangular zones mark the left and right sack feeders.
|
# Rectangular zones. Count once after dwell. Absolute pixels win over fracs.
|
||||||
# Count once when a track centroid ENTERS a zone (outside → inside).
|
ZONE_COUNT=1
|
||||||
# Absolute pixel overrides (ZONE_*_X1/Y1/X2/Y2) win over fractions when set.
|
ZONE_1_NAME=kandang_bawah_feeder
|
||||||
#
|
ZONE_1_X1=
|
||||||
# Left feeder zone (default: left third of the frame)
|
ZONE_1_Y1=
|
||||||
ZONE_LEFT_X1=
|
ZONE_1_X2=
|
||||||
ZONE_LEFT_Y1=
|
ZONE_1_Y2=
|
||||||
ZONE_LEFT_X2=
|
ZONE_1_X1_FRAC=0.10
|
||||||
ZONE_LEFT_Y2=
|
ZONE_1_Y1_FRAC=0.10
|
||||||
ZONE_LEFT_X1_FRAC=0.02
|
ZONE_1_X2_FRAC=0.90
|
||||||
ZONE_LEFT_Y1_FRAC=0.10
|
ZONE_1_Y2_FRAC=1.00
|
||||||
ZONE_LEFT_X2_FRAC=0.440
|
ZONE_1_COOLDOWN_SEC=3
|
||||||
ZONE_LEFT_Y2_FRAC=1.00
|
ZONE_1_DWELL_SEC=2
|
||||||
#
|
|
||||||
# Right feeder zone (full right column)
|
# Example two-zone setup (uncomment + set ZONE_COUNT=2):
|
||||||
ZONE_RIGHT_X1=
|
# ZONE_COUNT=2
|
||||||
ZONE_RIGHT_Y1=
|
# ZONE_1_NAME=kandang_bawah_feeder_kiri
|
||||||
ZONE_RIGHT_X2=
|
# ZONE_1_X1_FRAC=0.02
|
||||||
ZONE_RIGHT_Y2=
|
# ZONE_1_Y1_FRAC=0.10
|
||||||
ZONE_RIGHT_X1_FRAC=0.490
|
# ZONE_1_X2_FRAC=0.440
|
||||||
ZONE_RIGHT_Y1_FRAC=0.10
|
# ZONE_1_Y2_FRAC=1.00
|
||||||
ZONE_RIGHT_X2_FRAC=0.98
|
# ZONE_1_COOLDOWN_SEC=3
|
||||||
ZONE_RIGHT_Y2_FRAC=1.00
|
# ZONE_1_DWELL_SEC=2
|
||||||
|
# ZONE_2_NAME=kandang_bawah_feeder_kanan
|
||||||
|
# ZONE_2_X1=
|
||||||
|
# ZONE_2_Y1=
|
||||||
|
# ZONE_2_X2=
|
||||||
|
# ZONE_2_Y2=
|
||||||
|
# ZONE_2_X1_FRAC=0.490
|
||||||
|
# ZONE_2_Y1_FRAC=0.10
|
||||||
|
# ZONE_2_X2_FRAC=0.98
|
||||||
|
# ZONE_2_Y2_FRAC=1.00
|
||||||
|
# ZONE_2_COOLDOWN_SEC=3
|
||||||
|
# ZONE_2_DWELL_SEC=2
|
||||||
|
|
||||||
# --- Counting day management ---
|
# --- Counting day management ---
|
||||||
# Daily cutoff time (HH:MM) – a new counting day starts after this time and the
|
# Daily cutoff time (HH:MM) – a new counting day starts after this time and the
|
||||||
# previous day's counter_left / counter_right totals are finalized in the database.
|
# previous day's zone 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.
|
# CUTOFF_TIME is an alias used by the dashboard; DAILY_CUTOFF_TIME takes priority in counter_live_rknn.py.
|
||||||
DAILY_CUTOFF_TIME=17:00
|
DAILY_CUTOFF_TIME=17:00
|
||||||
CUTOFF_TIME=17:00
|
CUTOFF_TIME=17:00
|
||||||
@@ -152,7 +148,7 @@ CROSS_CSV=/opt/batch-counter/crossings.csv
|
|||||||
# --- Crossing snapshots ---
|
# --- Crossing snapshots ---
|
||||||
# Save an annotated frame image every time an object enters a feeder zone and the
|
# Save an annotated frame image every time an object enters a feeder zone and the
|
||||||
# counter increases (true/false, default: false). Written to <DIR>/cross/
|
# counter increases (true/false, default: false). Written to <DIR>/cross/
|
||||||
# (filename: <YYYYmmdd_HHMMSS_mmm>_<left|right>_id<track>_f<frame>.jpg)
|
# (filename: <YYYYmmdd_HHMMSS_mmm>_<zone_i>_id<track>_f<frame>.jpg)
|
||||||
SAVE_CROSS_SNAPSHOT=false
|
SAVE_CROSS_SNAPSHOT=false
|
||||||
# Also save one snapshot the first time each object is detected, before it enters
|
# Also save one snapshot the first time each object is detected, before it enters
|
||||||
# a zone (true/false, default: false). Written to <DIR>/detect/ with the same track id so
|
# a zone (true/false, default: false). Written to <DIR>/detect/ with the same track id so
|
||||||
|
|||||||
+214
-51
@@ -2,7 +2,7 @@
|
|||||||
"""
|
"""
|
||||||
Edge production counter dashboard.
|
Edge production counter dashboard.
|
||||||
Reads counter.db + current_counter.json from the counter stack.
|
Reads counter.db + current_counter.json from the counter stack.
|
||||||
Tracks daily left/right feeder sack counts per counting day (no batches).
|
Tracks daily per-zone sack counts per counting day (no batches).
|
||||||
Default port 5000.
|
Default port 5000.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -24,6 +24,8 @@ from werkzeug.serving import WSGIRequestHandler
|
|||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
|
from zones_config import load_zones, zone_meta
|
||||||
|
|
||||||
|
|
||||||
app = Flask(__name__, template_folder="templates")
|
app = Flask(__name__, template_folder="templates")
|
||||||
app.config["SECRET_KEY"] = os.getenv("SECRET_KEY", "change-me-in-production")
|
app.config["SECRET_KEY"] = os.getenv("SECRET_KEY", "change-me-in-production")
|
||||||
@@ -43,11 +45,16 @@ CONTROL_FILE = os.getenv("CONTROL_FILE", f"{_DEFAULT_DIR}/control.json")
|
|||||||
CONTROL_DEFAULT_COUNTING = os.getenv("CONTROL_DEFAULT_COUNTING", "true").lower() == "true"
|
CONTROL_DEFAULT_COUNTING = os.getenv("CONTROL_DEFAULT_COUNTING", "true").lower() == "true"
|
||||||
|
|
||||||
SITE_NAME = os.getenv("SITE_NAME", "LIVE")
|
SITE_NAME = os.getenv("SITE_NAME", "LIVE")
|
||||||
|
CAMERA_NAME = os.getenv("CAMERA_NAME", "CC1")
|
||||||
|
|
||||||
DASHBOARD_PORT = int(os.getenv("DASHBOARD_PORT", "5000"))
|
DASHBOARD_PORT = int(os.getenv("DASHBOARD_PORT", "5000"))
|
||||||
DASHBOARD_HOST = os.getenv("DASHBOARD_HOST", "0.0.0.0")
|
DASHBOARD_HOST = os.getenv("DASHBOARD_HOST", "0.0.0.0")
|
||||||
FLASK_DEBUG = os.getenv("FLASK_DEBUG", "false").lower() == "true"
|
FLASK_DEBUG = os.getenv("FLASK_DEBUG", "false").lower() == "true"
|
||||||
|
|
||||||
|
ZONES = load_zones(camera_name=CAMERA_NAME)
|
||||||
|
ZONES_META = zone_meta(ZONES)
|
||||||
|
ZONE_IDS = [z["id"] for z in ZONES_META]
|
||||||
|
|
||||||
@app.route("/api/live-video")
|
@app.route("/api/live-video")
|
||||||
def api_live_video():
|
def api_live_video():
|
||||||
if not os.path.isfile(LIVE_STREAM_FRAME_PATH):
|
if not os.path.isfile(LIVE_STREAM_FRAME_PATH):
|
||||||
@@ -86,12 +93,17 @@ def api_live_video():
|
|||||||
|
|
||||||
|
|
||||||
_SNAP_RE = re.compile(
|
_SNAP_RE = re.compile(
|
||||||
r"^(?P<ts>\d{8}_\d{6}_\d{3})_(?P<kind>detect|left|right)_id(?P<tid>\d+)_f(?P<frame>\d+)\.jpg$"
|
r"^(?P<ts>\d{8}_\d{6}_\d{3})_(?P<kind>detect|zone_\d+)_id(?P<tid>\d+)_f(?P<frame>\d+)\.jpg$"
|
||||||
)
|
)
|
||||||
|
# Also accept legacy left/right snapshot filenames.
|
||||||
|
_SNAP_RE_LEGACY = re.compile(
|
||||||
|
r"^(?P<ts>\d{8}_\d{6}_\d{3})_(?P<kind>left|right)_id(?P<tid>\d+)_f(?P<frame>\d+)\.jpg$"
|
||||||
|
)
|
||||||
|
_LEGACY_KIND_MAP = {"left": "zone_1", "right": "zone_2"}
|
||||||
|
|
||||||
|
|
||||||
def _parse_snapshot(path, category):
|
def _parse_snapshot(path, category):
|
||||||
m = _SNAP_RE.match(path.name)
|
m = _SNAP_RE.match(path.name) or _SNAP_RE_LEGACY.match(path.name)
|
||||||
if not m:
|
if not m:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
@@ -99,6 +111,7 @@ def _parse_snapshot(path, category):
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
dt = datetime.fromtimestamp(path.stat().st_mtime)
|
dt = datetime.fromtimestamp(path.stat().st_mtime)
|
||||||
kind = m.group("kind")
|
kind = m.group("kind")
|
||||||
|
kind = _LEGACY_KIND_MAP.get(kind, kind)
|
||||||
return {
|
return {
|
||||||
"file": f"{category}/{path.name}",
|
"file": f"{category}/{path.name}",
|
||||||
"category": category,
|
"category": category,
|
||||||
@@ -141,7 +154,8 @@ def api_snapshots():
|
|||||||
items = [i for i in items if i["category"] == "cross"]
|
items = [i for i in items if i["category"] == "cross"]
|
||||||
elif kind == "detect":
|
elif kind == "detect":
|
||||||
items = [i for i in items if i["category"] == "detect"]
|
items = [i for i in items if i["category"] == "detect"]
|
||||||
elif kind in ("left", "right"):
|
else:
|
||||||
|
# zone_N or legacy left/right (already remapped in parse)
|
||||||
items = [i for i in items if i["kind"] == kind]
|
items = [i for i in items if i["kind"] == kind]
|
||||||
if track_id is not None:
|
if track_id is not None:
|
||||||
items = [i for i in items if i["track_id"] == track_id]
|
items = [i for i in items if i["track_id"] == track_id]
|
||||||
@@ -179,8 +193,6 @@ def _ensure_db():
|
|||||||
camera_name TEXT NOT NULL,
|
camera_name TEXT NOT NULL,
|
||||||
object_label TEXT NOT NULL,
|
object_label TEXT NOT NULL,
|
||||||
total_count INTEGER NOT NULL DEFAULT 0,
|
total_count INTEGER NOT NULL DEFAULT 0,
|
||||||
total_left INTEGER NOT NULL DEFAULT 0,
|
|
||||||
total_right INTEGER NOT NULL DEFAULT 0,
|
|
||||||
start_time TEXT,
|
start_time TEXT,
|
||||||
end_time TEXT,
|
end_time TEXT,
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
@@ -188,6 +200,77 @@ def _ensure_db():
|
|||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS daily_zone_counts (
|
||||||
|
counting_date TEXT NOT NULL,
|
||||||
|
camera_name TEXT NOT NULL,
|
||||||
|
object_label TEXT NOT NULL,
|
||||||
|
zone_id TEXT NOT NULL,
|
||||||
|
count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (counting_date, camera_name, object_label, zone_id)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
# Lightweight migrate if legacy columns still exist (counter store does full migrate).
|
||||||
|
cur.execute("PRAGMA table_info(daily_counters)")
|
||||||
|
cols = {row[1] for row in cur.fetchall()}
|
||||||
|
if "total_left" in cols or "total_right" in cols:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT counting_date, camera_name, object_label,
|
||||||
|
COALESCE(total_left, 0), COALESCE(total_right, 0)
|
||||||
|
FROM daily_counters
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
for counting_date, camera_name, object_label, left, right in cur.fetchall():
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO daily_zone_counts
|
||||||
|
(counting_date, camera_name, object_label, zone_id, count)
|
||||||
|
VALUES (?, ?, ?, 'zone_1', ?)
|
||||||
|
ON CONFLICT(counting_date, camera_name, object_label, zone_id)
|
||||||
|
DO UPDATE SET count = excluded.count
|
||||||
|
""",
|
||||||
|
(counting_date, camera_name, object_label, left),
|
||||||
|
)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO daily_zone_counts
|
||||||
|
(counting_date, camera_name, object_label, zone_id, count)
|
||||||
|
VALUES (?, ?, ?, 'zone_2', ?)
|
||||||
|
ON CONFLICT(counting_date, camera_name, object_label, zone_id)
|
||||||
|
DO UPDATE SET count = excluded.count
|
||||||
|
""",
|
||||||
|
(counting_date, camera_name, object_label, right),
|
||||||
|
)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS daily_counters_new (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
counting_date TEXT NOT NULL,
|
||||||
|
camera_name TEXT NOT NULL,
|
||||||
|
object_label TEXT NOT NULL,
|
||||||
|
total_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
start_time TEXT,
|
||||||
|
end_time TEXT,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(counting_date, camera_name, object_label)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT OR IGNORE INTO daily_counters_new
|
||||||
|
(id, counting_date, camera_name, object_label,
|
||||||
|
total_count, start_time, end_time, updated_at)
|
||||||
|
SELECT id, counting_date, camera_name, object_label,
|
||||||
|
total_count, start_time, end_time, updated_at
|
||||||
|
FROM daily_counters
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
cur.execute("DROP TABLE daily_counters")
|
||||||
|
cur.execute("ALTER TABLE daily_counters_new RENAME TO daily_counters")
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
@@ -216,6 +299,7 @@ def index():
|
|||||||
site_name=SITE_NAME,
|
site_name=SITE_NAME,
|
||||||
show_detect=SAVE_DETECT_SNAPSHOT,
|
show_detect=SAVE_DETECT_SNAPSHOT,
|
||||||
control_enabled=CONTROL_ENABLED,
|
control_enabled=CONTROL_ENABLED,
|
||||||
|
zones=ZONES_META,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -263,34 +347,80 @@ def api_control_set():
|
|||||||
|
|
||||||
@app.route("/snapshots")
|
@app.route("/snapshots")
|
||||||
def snapshots_page():
|
def snapshots_page():
|
||||||
return render_template("snapshots.html", site_name=SITE_NAME, show_detect=SAVE_DETECT_SNAPSHOT)
|
return render_template(
|
||||||
|
"snapshots.html",
|
||||||
|
site_name=SITE_NAME,
|
||||||
|
show_detect=SAVE_DETECT_SNAPSHOT,
|
||||||
|
zones=ZONES_META,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _empty_counts():
|
||||||
|
return {zid: 0 for zid in ZONE_IDS}
|
||||||
|
|
||||||
|
|
||||||
def _empty_current():
|
def _empty_current():
|
||||||
return {
|
return {
|
||||||
"counting_date": get_counting_date(),
|
"counting_date": get_counting_date(),
|
||||||
"count": 0,
|
"count": 0,
|
||||||
"count_left": 0,
|
"counts": _empty_counts(),
|
||||||
"count_right": 0,
|
"zones": ZONES_META,
|
||||||
"start_time": None,
|
"start_time": None,
|
||||||
"last_detection_time": None,
|
"last_detection_time": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_counts(raw):
|
||||||
|
counts = _empty_counts()
|
||||||
|
if isinstance(raw, dict):
|
||||||
|
for zid in ZONE_IDS:
|
||||||
|
counts[zid] = int(raw.get(zid, 0) or 0)
|
||||||
|
return counts
|
||||||
|
return counts
|
||||||
|
|
||||||
|
|
||||||
def _load_current_state():
|
def _load_current_state():
|
||||||
"""Load live counter state from current_counter.json."""
|
"""Load live counter state from current_counter.json."""
|
||||||
with open(CURRENT_COUNTER_PATH, "r", encoding="utf-8") as f:
|
with open(CURRENT_COUNTER_PATH, "r", encoding="utf-8") as f:
|
||||||
data = json.load(f)
|
data = json.load(f)
|
||||||
|
counts = data.get("counts")
|
||||||
|
if not isinstance(counts, dict):
|
||||||
|
counts = {
|
||||||
|
"zone_1": int(data.get("count_left", 0) or 0),
|
||||||
|
"zone_2": int(data.get("count_right", 0) or 0),
|
||||||
|
}
|
||||||
|
counts = _normalize_counts(counts)
|
||||||
return {
|
return {
|
||||||
"counting_date": data.get("counting_date") or get_counting_date(),
|
"counting_date": data.get("counting_date") or get_counting_date(),
|
||||||
"count": int(data.get("count", 0) or 0),
|
"count": int(data.get("count", sum(counts.values())) or 0),
|
||||||
"count_left": int(data.get("count_left", 0) or 0),
|
"counts": counts,
|
||||||
"count_right": int(data.get("count_right", 0) or 0),
|
"zones": ZONES_META,
|
||||||
"start_time": data.get("start_time"),
|
"start_time": data.get("start_time"),
|
||||||
"last_detection_time": data.get("last_detection_time"),
|
"last_detection_time": data.get("last_detection_time"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _zone_counts_for_days(conn, dates):
|
||||||
|
"""Return {counting_date: {zone_id: count}} for the given dates."""
|
||||||
|
if not dates:
|
||||||
|
return {}
|
||||||
|
cur = conn.cursor()
|
||||||
|
placeholders = ",".join("?" for _ in dates)
|
||||||
|
cur.execute(
|
||||||
|
f"""
|
||||||
|
SELECT counting_date, zone_id, COALESCE(count, 0)
|
||||||
|
FROM daily_zone_counts
|
||||||
|
WHERE counting_date IN ({placeholders})
|
||||||
|
""",
|
||||||
|
list(dates),
|
||||||
|
)
|
||||||
|
out = {d: _empty_counts() for d in dates}
|
||||||
|
for counting_date, zone_id, count in cur.fetchall():
|
||||||
|
if counting_date in out and zone_id in out[counting_date]:
|
||||||
|
out[counting_date][zone_id] = int(count)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _query_history(date_from=None, date_to=None, days=None, limit=None, offset=0):
|
def _query_history(date_from=None, date_to=None, days=None, limit=None, offset=0):
|
||||||
"""Query daily_counters with optional date range and pagination."""
|
"""Query daily_counters with optional date range and pagination."""
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
@@ -316,7 +446,7 @@ def _query_history(date_from=None, date_to=None, days=None, limit=None, offset=0
|
|||||||
|
|
||||||
sql = f"""
|
sql = f"""
|
||||||
SELECT counting_date, camera_name, object_label,
|
SELECT counting_date, camera_name, object_label,
|
||||||
total_count, total_left, total_right, start_time, end_time, updated_at
|
total_count, start_time, end_time, updated_at
|
||||||
FROM daily_counters
|
FROM daily_counters
|
||||||
{where}
|
{where}
|
||||||
ORDER BY counting_date DESC
|
ORDER BY counting_date DESC
|
||||||
@@ -327,20 +457,23 @@ def _query_history(date_from=None, date_to=None, days=None, limit=None, offset=0
|
|||||||
page_params.extend([limit, offset])
|
page_params.extend([limit, offset])
|
||||||
|
|
||||||
cur.execute(sql, page_params)
|
cur.execute(sql, page_params)
|
||||||
|
day_rows = cur.fetchall()
|
||||||
|
dates = [row["counting_date"] for row in day_rows]
|
||||||
|
zone_map = _zone_counts_for_days(conn, dates)
|
||||||
rows = [
|
rows = [
|
||||||
{
|
{
|
||||||
"date": row["counting_date"],
|
"date": row["counting_date"],
|
||||||
"camera_name": row["camera_name"],
|
"camera_name": row["camera_name"],
|
||||||
"object_label": row["object_label"],
|
"object_label": row["object_label"],
|
||||||
"total_count": row["total_count"],
|
"total_count": row["total_count"],
|
||||||
"total_left": row["total_left"],
|
"counts": zone_map.get(row["counting_date"], _empty_counts()),
|
||||||
"total_right": row["total_right"],
|
"zones": ZONES_META,
|
||||||
"diff": (row["total_left"] or 0) + (row["total_right"] or 0),
|
"diff": sum(zone_map.get(row["counting_date"], _empty_counts()).values()),
|
||||||
"start_time": row["start_time"],
|
"start_time": row["start_time"],
|
||||||
"end_time": row["end_time"],
|
"end_time": row["end_time"],
|
||||||
"updated_at": row["updated_at"],
|
"updated_at": row["updated_at"],
|
||||||
}
|
}
|
||||||
for row in cur.fetchall()
|
for row in day_rows
|
||||||
]
|
]
|
||||||
conn.close()
|
conn.close()
|
||||||
return rows, total
|
return rows, total
|
||||||
@@ -448,6 +581,7 @@ def api_history():
|
|||||||
payload = {
|
payload = {
|
||||||
"success": True,
|
"success": True,
|
||||||
"site_name": SITE_NAME,
|
"site_name": SITE_NAME,
|
||||||
|
"zones": ZONES_META,
|
||||||
"filters": {
|
"filters": {
|
||||||
"days": days,
|
"days": days,
|
||||||
"date_from": date_from,
|
"date_from": date_from,
|
||||||
@@ -543,9 +677,7 @@ def api_summary():
|
|||||||
today = get_counting_date()
|
today = get_counting_date()
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
SELECT COALESCE(total_count, 0) as total_count,
|
SELECT COALESCE(total_count, 0) as total_count
|
||||||
COALESCE(total_left, 0) as total_left,
|
|
||||||
COALESCE(total_right, 0) as total_right
|
|
||||||
FROM daily_counters
|
FROM daily_counters
|
||||||
WHERE counting_date = ?
|
WHERE counting_date = ?
|
||||||
""",
|
""",
|
||||||
@@ -555,9 +687,7 @@ def api_summary():
|
|||||||
yesterday = (datetime.strptime(today, "%Y-%m-%d").date() - timedelta(days=1)).isoformat()
|
yesterday = (datetime.strptime(today, "%Y-%m-%d").date() - timedelta(days=1)).isoformat()
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
SELECT COALESCE(total_count, 0) as total_count,
|
SELECT COALESCE(total_count, 0) as total_count
|
||||||
COALESCE(total_left, 0) as total_left,
|
|
||||||
COALESCE(total_right, 0) as total_right
|
|
||||||
FROM daily_counters
|
FROM daily_counters
|
||||||
WHERE counting_date = ?
|
WHERE counting_date = ?
|
||||||
""",
|
""",
|
||||||
@@ -567,13 +697,23 @@ def api_summary():
|
|||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
SELECT COALESCE(SUM(total_count), 0) as grand_total,
|
SELECT COALESCE(SUM(total_count), 0) as grand_total,
|
||||||
COALESCE(SUM(total_left), 0) as grand_left,
|
|
||||||
COALESCE(SUM(total_right), 0) as grand_right,
|
|
||||||
COUNT(DISTINCT counting_date) as total_days
|
COUNT(DISTINCT counting_date) as total_days
|
||||||
FROM daily_counters
|
FROM daily_counters
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
all_time = cur.fetchone()
|
all_time = cur.fetchone()
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT zone_id, COALESCE(SUM(count), 0)
|
||||||
|
FROM daily_zone_counts
|
||||||
|
GROUP BY zone_id
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
grand_counts = _empty_counts()
|
||||||
|
for zone_id, count in cur.fetchall():
|
||||||
|
if zone_id in grand_counts:
|
||||||
|
grand_counts[zone_id] = int(count)
|
||||||
|
zone_map = _zone_counts_for_days(conn, [today, yesterday])
|
||||||
cur.execute("SELECT ROUND(AVG(total_count), 1) as avg_per_day FROM daily_counters")
|
cur.execute("SELECT ROUND(AVG(total_count), 1) as avg_per_day FROM daily_counters")
|
||||||
avg = cur.fetchone()
|
avg = cur.fetchone()
|
||||||
cur.execute(
|
cur.execute(
|
||||||
@@ -588,22 +728,20 @@ def api_summary():
|
|||||||
conn.close()
|
conn.close()
|
||||||
return jsonify(
|
return jsonify(
|
||||||
{
|
{
|
||||||
|
"zones": ZONES_META,
|
||||||
"today": {
|
"today": {
|
||||||
"date": today,
|
"date": today,
|
||||||
"total_count": today_row["total_count"] if today_row else 0,
|
"total_count": today_row["total_count"] if today_row else 0,
|
||||||
"total_left": today_row["total_left"] if today_row else 0,
|
"counts": zone_map.get(today, _empty_counts()),
|
||||||
"total_right": today_row["total_right"] if today_row else 0,
|
|
||||||
},
|
},
|
||||||
"yesterday": {
|
"yesterday": {
|
||||||
"date": yesterday,
|
"date": yesterday,
|
||||||
"total_count": yesterday_row["total_count"] if yesterday_row else 0,
|
"total_count": yesterday_row["total_count"] if yesterday_row else 0,
|
||||||
"total_left": yesterday_row["total_left"] if yesterday_row else 0,
|
"counts": zone_map.get(yesterday, _empty_counts()),
|
||||||
"total_right": yesterday_row["total_right"] if yesterday_row else 0,
|
|
||||||
},
|
},
|
||||||
"all_time": {
|
"all_time": {
|
||||||
"grand_total": all_time["grand_total"],
|
"grand_total": all_time["grand_total"],
|
||||||
"grand_left": all_time["grand_left"],
|
"counts": grand_counts,
|
||||||
"grand_right": all_time["grand_right"],
|
|
||||||
"total_days": all_time["total_days"],
|
"total_days": all_time["total_days"],
|
||||||
},
|
},
|
||||||
"average_per_day": avg["avg_per_day"] or 0,
|
"average_per_day": avg["avg_per_day"] or 0,
|
||||||
@@ -614,7 +752,15 @@ def api_summary():
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
except sqlite3.OperationalError as e:
|
except sqlite3.OperationalError as e:
|
||||||
return jsonify({"success": False, "error": f"Database unavailable: {e}", "today": {"date": datetime.now().date().isoformat(), "total_count": 0, "total_left": 0, "total_right": 0}, "yesterday": {"date": "", "total_count": 0, "total_left": 0, "total_right": 0}, "all_time": {"grand_total": 0, "grand_left": 0, "grand_right": 0, "total_days": 0}, "average_per_day": 0, "best_day": {"date": None, "count": 0}}), 200
|
empty = {
|
||||||
|
"zones": ZONES_META,
|
||||||
|
"today": {"date": datetime.now().date().isoformat(), "total_count": 0, "counts": _empty_counts()},
|
||||||
|
"yesterday": {"date": "", "total_count": 0, "counts": _empty_counts()},
|
||||||
|
"all_time": {"grand_total": 0, "counts": _empty_counts(), "total_days": 0},
|
||||||
|
"average_per_day": 0,
|
||||||
|
"best_day": {"date": None, "count": 0},
|
||||||
|
}
|
||||||
|
return jsonify({"success": False, "error": f"Database unavailable: {e}", **empty}), 200
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({"success": False, "error": str(e)}), 500
|
return jsonify({"success": False, "error": str(e)}), 500
|
||||||
|
|
||||||
@@ -628,26 +774,28 @@ def api_daily_data():
|
|||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
SELECT counting_date, total_count, total_left, total_right
|
SELECT counting_date, total_count
|
||||||
FROM daily_counters
|
FROM daily_counters
|
||||||
WHERE counting_date >= ?
|
WHERE counting_date >= ?
|
||||||
ORDER BY counting_date ASC
|
ORDER BY counting_date ASC
|
||||||
""",
|
""",
|
||||||
(date_from,),
|
(date_from,),
|
||||||
)
|
)
|
||||||
|
day_rows = cur.fetchall()
|
||||||
|
dates = [row["counting_date"] for row in day_rows]
|
||||||
|
zone_map = _zone_counts_for_days(conn, dates)
|
||||||
daily_data = [
|
daily_data = [
|
||||||
{
|
{
|
||||||
"date": row["counting_date"],
|
"date": row["counting_date"],
|
||||||
"total_count": row["total_count"],
|
"total_count": row["total_count"],
|
||||||
"total_left": row["total_left"],
|
"counts": zone_map.get(row["counting_date"], _empty_counts()),
|
||||||
"total_right": row["total_right"],
|
|
||||||
}
|
}
|
||||||
for row in cur.fetchall()
|
for row in day_rows
|
||||||
]
|
]
|
||||||
conn.close()
|
conn.close()
|
||||||
return jsonify(daily_data)
|
return jsonify({"zones": ZONES_META, "data": daily_data})
|
||||||
except sqlite3.OperationalError:
|
except sqlite3.OperationalError:
|
||||||
return jsonify([]), 200
|
return jsonify({"zones": ZONES_META, "data": []}), 200
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({"success": False, "error": str(e)}), 500
|
return jsonify({"success": False, "error": str(e)}), 500
|
||||||
|
|
||||||
@@ -659,26 +807,28 @@ def api_available_dates():
|
|||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
SELECT counting_date, total_count, total_left, total_right, start_time, end_time
|
SELECT counting_date, total_count, start_time, end_time
|
||||||
FROM daily_counters
|
FROM daily_counters
|
||||||
ORDER BY counting_date DESC
|
ORDER BY counting_date DESC
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
day_rows = cur.fetchall()
|
||||||
|
dates_list = [row["counting_date"] for row in day_rows]
|
||||||
|
zone_map = _zone_counts_for_days(conn, dates_list)
|
||||||
dates = [
|
dates = [
|
||||||
{
|
{
|
||||||
"date": row["counting_date"],
|
"date": row["counting_date"],
|
||||||
"total_count": row["total_count"],
|
"total_count": row["total_count"],
|
||||||
"total_left": row["total_left"],
|
"counts": zone_map.get(row["counting_date"], _empty_counts()),
|
||||||
"total_right": row["total_right"],
|
|
||||||
"start_time": row["start_time"],
|
"start_time": row["start_time"],
|
||||||
"end_time": row["end_time"],
|
"end_time": row["end_time"],
|
||||||
}
|
}
|
||||||
for row in cur.fetchall()
|
for row in day_rows
|
||||||
]
|
]
|
||||||
conn.close()
|
conn.close()
|
||||||
return jsonify(dates)
|
return jsonify({"zones": ZONES_META, "data": dates})
|
||||||
except sqlite3.OperationalError:
|
except sqlite3.OperationalError:
|
||||||
return jsonify([]), 200
|
return jsonify({"zones": ZONES_META, "data": []}), 200
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({"success": False, "error": str(e)}), 500
|
return jsonify({"success": False, "error": str(e)}), 500
|
||||||
|
|
||||||
@@ -729,7 +879,7 @@ def export_daily_xlsx():
|
|||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
SELECT counting_date, total_count, total_left, total_right, start_time, end_time
|
SELECT counting_date, total_count, start_time, end_time
|
||||||
FROM daily_counters
|
FROM daily_counters
|
||||||
WHERE counting_date >= ?
|
WHERE counting_date >= ?
|
||||||
ORDER BY counting_date ASC
|
ORDER BY counting_date ASC
|
||||||
@@ -737,6 +887,8 @@ def export_daily_xlsx():
|
|||||||
(date_from,),
|
(date_from,),
|
||||||
)
|
)
|
||||||
rows = cur.fetchall()
|
rows = cur.fetchall()
|
||||||
|
dates = [row["counting_date"] for row in rows]
|
||||||
|
zone_map = _zone_counts_for_days(conn, dates)
|
||||||
conn.close()
|
conn.close()
|
||||||
except sqlite3.OperationalError as e:
|
except sqlite3.OperationalError as e:
|
||||||
return jsonify({"success": False, "error": f"Database unavailable: {e}"}), 503
|
return jsonify({"success": False, "error": f"Database unavailable: {e}"}), 503
|
||||||
@@ -746,16 +898,27 @@ def export_daily_xlsx():
|
|||||||
wb = Workbook()
|
wb = Workbook()
|
||||||
ws = wb.active
|
ws = wb.active
|
||||||
ws.title = "Daily Counters"
|
ws.title = "Daily Counters"
|
||||||
_style_header(ws, [("A", "Date"), ("B", "Total"), ("C", "Left"), ("D", "Right"), ("E", "Total (L+R)"), ("F", "First Count"), ("G", "Last Count")])
|
headers = [("A", "Date"), ("B", "Total")]
|
||||||
|
# column letters continue past B
|
||||||
|
from openpyxl.utils import get_column_letter
|
||||||
|
col = 3
|
||||||
|
for z in ZONES_META:
|
||||||
|
headers.append((get_column_letter(col), z["name"]))
|
||||||
|
col += 1
|
||||||
|
headers.append((get_column_letter(col), "First Count"))
|
||||||
|
headers.append((get_column_letter(col + 1), "Last Count"))
|
||||||
|
_style_header(ws, headers)
|
||||||
|
|
||||||
for r_idx, row in enumerate(rows, 2):
|
for r_idx, row in enumerate(rows, 2):
|
||||||
|
counts = zone_map.get(row["counting_date"], _empty_counts())
|
||||||
ws.cell(row=r_idx, column=1, value=row["counting_date"])
|
ws.cell(row=r_idx, column=1, value=row["counting_date"])
|
||||||
ws.cell(row=r_idx, column=2, value=row["total_count"])
|
ws.cell(row=r_idx, column=2, value=row["total_count"])
|
||||||
ws.cell(row=r_idx, column=3, value=row["total_left"])
|
c = 3
|
||||||
ws.cell(row=r_idx, column=4, value=row["total_right"])
|
for z in ZONES_META:
|
||||||
ws.cell(row=r_idx, column=5, value=(row["total_left"] or 0) + (row["total_right"] or 0))
|
ws.cell(row=r_idx, column=c, value=counts.get(z["id"], 0))
|
||||||
ws.cell(row=r_idx, column=6, value=row["start_time"])
|
c += 1
|
||||||
ws.cell(row=r_idx, column=7, value=row["end_time"])
|
ws.cell(row=r_idx, column=c, value=row["start_time"])
|
||||||
|
ws.cell(row=r_idx, column=c + 1, value=row["end_time"])
|
||||||
|
|
||||||
_auto_width(ws)
|
_auto_width(ws)
|
||||||
filename = f"{SITE_NAME}_daily_records_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
|
filename = f"{SITE_NAME}_daily_records_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
|
||||||
|
|||||||
+144
-206
@@ -3,8 +3,8 @@ Edge production live counter — RTSP + YOLO RKNN + ByteTrack + zone counting.
|
|||||||
Runs on RK3588 hardware with RKNN model (320×320 input).
|
Runs on RK3588 hardware with RKNN model (320×320 input).
|
||||||
Uses ByteTrack (Kalman filter + two-stage IoU association) for tracking.
|
Uses ByteTrack (Kalman filter + two-stage IoU association) for tracking.
|
||||||
|
|
||||||
Left / right rectangular zones mark the left and right sack feeders.
|
Configurable rectangular zones (ZONE_COUNT + ZONE_i_*) mark sack feeders.
|
||||||
A sack is counted once when its centroid enters a zone.
|
A sack is counted once when its centroid dwells in a zone long enough.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import contextlib
|
import contextlib
|
||||||
@@ -28,6 +28,7 @@ load_dotenv()
|
|||||||
|
|
||||||
from rknnlite.api import RKNNLite
|
from rknnlite.api import RKNNLite
|
||||||
from counter_store import CounterStore
|
from counter_store import CounterStore
|
||||||
|
from zones_config import load_zones, zone_ids as zone_id_list, zones_by_id
|
||||||
|
|
||||||
# --- config (override via env / .env) ---
|
# --- config (override via env / .env) ---
|
||||||
OUTPUT_DIR = os.getenv("OUTPUT_DIR", "/opt/jetson-counter")
|
OUTPUT_DIR = os.getenv("OUTPUT_DIR", "/opt/jetson-counter")
|
||||||
@@ -38,32 +39,11 @@ MODEL_PATH = os.getenv("MODEL_PATH", "/opt/jetson-counter/yolo11n.rknn")
|
|||||||
CAMERA_NAME = os.getenv("CAMERA_NAME", "CC1")
|
CAMERA_NAME = os.getenv("CAMERA_NAME", "CC1")
|
||||||
OBJECT_LABEL = os.getenv("OBJECT_LABEL", "object")
|
OBJECT_LABEL = os.getenv("OBJECT_LABEL", "object")
|
||||||
CLASS_OBJECT = os.getenv("CLASS_OBJECT", "object")
|
CLASS_OBJECT = os.getenv("CLASS_OBJECT", "object")
|
||||||
# Human-readable feeder names used in count logs, e.g.
|
|
||||||
# "Total karung di kandang_bawah_feeder_kanan: 5"
|
|
||||||
FEEDER_LEFT_NAME = os.getenv("FEEDER_LEFT_NAME", f"{CAMERA_NAME}_feeder_kiri")
|
|
||||||
FEEDER_RIGHT_NAME = os.getenv("FEEDER_RIGHT_NAME", f"{CAMERA_NAME}_feeder_kanan")
|
|
||||||
DEBUG_TRACKING = os.getenv("DEBUG_TRACKING", "").lower() == "true"
|
DEBUG_TRACKING = os.getenv("DEBUG_TRACKING", "").lower() == "true"
|
||||||
|
|
||||||
# Left feeder zone (fractions of frame width/height). Absolute pixel overrides
|
ZONES = load_zones(camera_name=CAMERA_NAME)
|
||||||
# win when set (ZONE_LEFT_X1 … ZONE_LEFT_Y2).
|
ZONE_IDS = zone_id_list(ZONES)
|
||||||
ZONE_LEFT_X1 = int(os.getenv("ZONE_LEFT_X1")) if os.getenv("ZONE_LEFT_X1") else None
|
ZONES_BY_ID = zones_by_id(ZONES)
|
||||||
ZONE_LEFT_Y1 = int(os.getenv("ZONE_LEFT_Y1")) if os.getenv("ZONE_LEFT_Y1") else None
|
|
||||||
ZONE_LEFT_X2 = int(os.getenv("ZONE_LEFT_X2")) if os.getenv("ZONE_LEFT_X2") else None
|
|
||||||
ZONE_LEFT_Y2 = int(os.getenv("ZONE_LEFT_Y2")) if os.getenv("ZONE_LEFT_Y2") else None
|
|
||||||
ZONE_LEFT_X1_FRAC = float(os.getenv("ZONE_LEFT_X1_FRAC", "0.00"))
|
|
||||||
ZONE_LEFT_Y1_FRAC = float(os.getenv("ZONE_LEFT_Y1_FRAC", "0.20"))
|
|
||||||
ZONE_LEFT_X2_FRAC = float(os.getenv("ZONE_LEFT_X2_FRAC", "0.35"))
|
|
||||||
ZONE_LEFT_Y2_FRAC = float(os.getenv("ZONE_LEFT_Y2_FRAC", "0.85"))
|
|
||||||
|
|
||||||
# Right feeder zone
|
|
||||||
ZONE_RIGHT_X1 = int(os.getenv("ZONE_RIGHT_X1")) if os.getenv("ZONE_RIGHT_X1") else None
|
|
||||||
ZONE_RIGHT_Y1 = int(os.getenv("ZONE_RIGHT_Y1")) if os.getenv("ZONE_RIGHT_Y1") else None
|
|
||||||
ZONE_RIGHT_X2 = int(os.getenv("ZONE_RIGHT_X2")) if os.getenv("ZONE_RIGHT_X2") else None
|
|
||||||
ZONE_RIGHT_Y2 = int(os.getenv("ZONE_RIGHT_Y2")) if os.getenv("ZONE_RIGHT_Y2") else None
|
|
||||||
ZONE_RIGHT_X1_FRAC = float(os.getenv("ZONE_RIGHT_X1_FRAC", "0.65"))
|
|
||||||
ZONE_RIGHT_Y1_FRAC = float(os.getenv("ZONE_RIGHT_Y1_FRAC", "0.20"))
|
|
||||||
ZONE_RIGHT_X2_FRAC = float(os.getenv("ZONE_RIGHT_X2_FRAC", "1.00"))
|
|
||||||
ZONE_RIGHT_Y2_FRAC = float(os.getenv("ZONE_RIGHT_Y2_FRAC", "0.85"))
|
|
||||||
|
|
||||||
IMGSZ = int(os.getenv("IMGSZ", "320"))
|
IMGSZ = int(os.getenv("IMGSZ", "320"))
|
||||||
HALF = os.getenv("HALF", "false").lower() == "true"
|
HALF = os.getenv("HALF", "false").lower() == "true"
|
||||||
@@ -86,7 +66,7 @@ TRACK_BUFFER = int(os.getenv("TRACK_BUFFER", "30"))
|
|||||||
TRACK_MIN_HITS = int(os.getenv("TRACK_MIN_HITS", "3"))
|
TRACK_MIN_HITS = int(os.getenv("TRACK_MIN_HITS", "3"))
|
||||||
|
|
||||||
# Dedup guard against ID-switch double counts: ignore a second zone entry in the
|
# Dedup guard against ID-switch double counts: ignore a second zone entry in the
|
||||||
# same feeder within DEDUP_FRAMES and DEDUP_PX (centroid distance) of a recent count.
|
# same zone within DEDUP_FRAMES and DEDUP_PX (centroid distance) of a recent count.
|
||||||
DEDUP_FRAMES = int(os.getenv("DEDUP_FRAMES", "15"))
|
DEDUP_FRAMES = int(os.getenv("DEDUP_FRAMES", "15"))
|
||||||
DEDUP_PX = float(os.getenv("DEDUP_PX", "60"))
|
DEDUP_PX = float(os.getenv("DEDUP_PX", "60"))
|
||||||
|
|
||||||
@@ -96,16 +76,6 @@ DEDUP_PX = float(os.getenv("DEDUP_PX", "60"))
|
|||||||
INHERIT_SEC = float(os.getenv("INHERIT_SEC", "1.0"))
|
INHERIT_SEC = float(os.getenv("INHERIT_SEC", "1.0"))
|
||||||
INHERIT_PX = float(os.getenv("INHERIT_PX", "60"))
|
INHERIT_PX = float(os.getenv("INHERIT_PX", "60"))
|
||||||
|
|
||||||
# Per-feeder cooldown: after a count on that feeder, ignore further counts on the
|
|
||||||
# same feeder for N seconds. 0 = disabled.
|
|
||||||
ZONE_COOLDOWN_LEFT_SEC = float(os.getenv("ZONE_COOLDOWN_LEFT_SEC", "0"))
|
|
||||||
ZONE_COOLDOWN_RIGHT_SEC = float(os.getenv("ZONE_COOLDOWN_RIGHT_SEC", "0"))
|
|
||||||
|
|
||||||
# Per-feeder dwell: sack must remain inside the zone for N seconds before it is
|
|
||||||
# counted (and a cross snapshot is saved). 0 = count on first frame in zone.
|
|
||||||
ZONE_DWELL_LEFT_SEC = float(os.getenv("ZONE_DWELL_LEFT_SEC", "2"))
|
|
||||||
ZONE_DWELL_RIGHT_SEC = float(os.getenv("ZONE_DWELL_RIGHT_SEC", "2"))
|
|
||||||
|
|
||||||
DAILY_CUTOFF_TIME = os.getenv("DAILY_CUTOFF_TIME", "20:00")
|
DAILY_CUTOFF_TIME = os.getenv("DAILY_CUTOFF_TIME", "20:00")
|
||||||
|
|
||||||
EXPORT_CSV = os.getenv("EXPORT_CSV", "true").lower() == "true"
|
EXPORT_CSV = os.getenv("EXPORT_CSV", "true").lower() == "true"
|
||||||
@@ -183,8 +153,15 @@ POPUP_LIFETIME = 20
|
|||||||
ZONE_PULSE_FRAMES = 12
|
ZONE_PULSE_FRAMES = 12
|
||||||
COUNT_PULSE_FRAMES = 15
|
COUNT_PULSE_FRAMES = 15
|
||||||
|
|
||||||
C_ZONE_LEFT = (80, 220, 100) # green — left feeder
|
# Zone overlay colors cycle for N zones (BGR).
|
||||||
C_ZONE_RIGHT = (0, 165, 255) # orange — right feeder
|
ZONE_COLORS = [
|
||||||
|
(80, 220, 100), # green
|
||||||
|
(0, 165, 255), # orange
|
||||||
|
(255, 180, 50), # blue-ish
|
||||||
|
(200, 100, 255), # pink
|
||||||
|
(50, 220, 220), # yellow
|
||||||
|
]
|
||||||
|
C_ZONE_DEFAULT = ZONE_COLORS[0]
|
||||||
|
|
||||||
SKELETON = [(0, 1), (4, 3), (1, 2), (3, 2), (2, 6), (2, 5), (2, 7), (7, 8)]
|
SKELETON = [(0, 1), (4, 3), (1, 2), (3, 2), (2, 6), (2, 5), (2, 7), (7, 8)]
|
||||||
SK_COLORS = [
|
SK_COLORS = [
|
||||||
@@ -703,30 +680,6 @@ class RKNNYOLO:
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
|
|
||||||
def _zone_coord(abs_val, frac, span):
|
|
||||||
if abs_val is not None:
|
|
||||||
return int(abs_val)
|
|
||||||
return int(span * frac)
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_zone_left(frame_width, frame_height):
|
|
||||||
"""Return (x1, y1, x2, y2) for the left feeder zone."""
|
|
||||||
x1 = _zone_coord(ZONE_LEFT_X1, ZONE_LEFT_X1_FRAC, frame_width)
|
|
||||||
y1 = _zone_coord(ZONE_LEFT_Y1, ZONE_LEFT_Y1_FRAC, frame_height)
|
|
||||||
x2 = _zone_coord(ZONE_LEFT_X2, ZONE_LEFT_X2_FRAC, frame_width)
|
|
||||||
y2 = _zone_coord(ZONE_LEFT_Y2, ZONE_LEFT_Y2_FRAC, frame_height)
|
|
||||||
return (min(x1, x2), min(y1, y2), max(x1, x2), max(y1, y2))
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_zone_right(frame_width, frame_height):
|
|
||||||
"""Return (x1, y1, x2, y2) for the right feeder zone."""
|
|
||||||
x1 = _zone_coord(ZONE_RIGHT_X1, ZONE_RIGHT_X1_FRAC, frame_width)
|
|
||||||
y1 = _zone_coord(ZONE_RIGHT_Y1, ZONE_RIGHT_Y1_FRAC, frame_height)
|
|
||||||
x2 = _zone_coord(ZONE_RIGHT_X2, ZONE_RIGHT_X2_FRAC, frame_width)
|
|
||||||
y2 = _zone_coord(ZONE_RIGHT_Y2, ZONE_RIGHT_Y2_FRAC, frame_height)
|
|
||||||
return (min(x1, x2), min(y1, y2), max(x1, x2), max(y1, y2))
|
|
||||||
|
|
||||||
|
|
||||||
def point_in_zone(cx, cy, zone):
|
def point_in_zone(cx, cy, zone):
|
||||||
x1, y1, x2, y2 = zone
|
x1, y1, x2, y2 = zone
|
||||||
return x1 <= cx <= x2 and y1 <= cy <= y2
|
return x1 <= cx <= x2 and y1 <= cy <= y2
|
||||||
@@ -742,47 +695,52 @@ def is_duplicate_cross(recent, cx, cy, frame_idx):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def feeder_cooldown_sec(feeder):
|
def zone_color(zone_id):
|
||||||
if feeder == "left":
|
try:
|
||||||
return ZONE_COOLDOWN_LEFT_SEC
|
idx = ZONE_IDS.index(zone_id)
|
||||||
return ZONE_COOLDOWN_RIGHT_SEC
|
except ValueError:
|
||||||
|
idx = 0
|
||||||
|
return ZONE_COLORS[idx % len(ZONE_COLORS)]
|
||||||
|
|
||||||
|
|
||||||
def feeder_dwell_sec(feeder):
|
def resolve_zone_at(cx, cy, rects_by_id):
|
||||||
if feeder == "left":
|
"""Return zone_id or None for the zone containing (cx, cy).
|
||||||
return ZONE_DWELL_LEFT_SEC
|
|
||||||
return ZONE_DWELL_RIGHT_SEC
|
If multiple zones contain the point, pick the nearest rectangle center.
|
||||||
|
"""
|
||||||
|
hits = []
|
||||||
|
for zid, rect in rects_by_id.items():
|
||||||
|
if point_in_zone(cx, cy, rect):
|
||||||
|
x1, y1, x2, y2 = rect
|
||||||
|
dist = abs(cx - (x1 + x2) / 2) + abs(cy - (y1 + y2) / 2)
|
||||||
|
hits.append((dist, zid))
|
||||||
|
if not hits:
|
||||||
|
return None
|
||||||
|
hits.sort(key=lambda t: t[0])
|
||||||
|
return hits[0][1]
|
||||||
|
|
||||||
|
|
||||||
def resolve_feeder_at(cx, cy, zone_left, zone_right):
|
def zone_display_name(zone_id):
|
||||||
"""Return 'left' | 'right' | None for the zone containing (cx, cy)."""
|
z = ZONES_BY_ID.get(zone_id)
|
||||||
now_left = point_in_zone(cx, cy, zone_left)
|
return z.name if z else zone_id
|
||||||
now_right = point_in_zone(cx, cy, zone_right)
|
|
||||||
if now_left and now_right:
|
|
||||||
lx1, ly1, lx2, ly2 = zone_left
|
|
||||||
rx1, ry1, rx2, ry2 = zone_right
|
|
||||||
dl = abs(cx - (lx1 + lx2) / 2) + abs(cy - (ly1 + ly2) / 2)
|
|
||||||
dr = abs(cx - (rx1 + rx2) / 2) + abs(cy - (ry1 + ry2) / 2)
|
|
||||||
return "left" if dl <= dr else "right"
|
|
||||||
if now_left:
|
|
||||||
return "left"
|
|
||||||
if now_right:
|
|
||||||
return "right"
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def feeder_display_name(feeder):
|
def zone_dwell_sec(zone_id):
|
||||||
if feeder == "left":
|
z = ZONES_BY_ID.get(zone_id)
|
||||||
return FEEDER_LEFT_NAME
|
return z.dwell_sec if z else 0.0
|
||||||
return FEEDER_RIGHT_NAME
|
|
||||||
|
|
||||||
|
|
||||||
def feeder_in_cooldown(feeder, mono, last_count_mono):
|
def zone_cooldown_sec(zone_id):
|
||||||
"""True if this feeder is still inside its post-count cooldown window."""
|
z = ZONES_BY_ID.get(zone_id)
|
||||||
cooldown = feeder_cooldown_sec(feeder)
|
return z.cooldown_sec if z else 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def zone_in_cooldown(zone_id, mono, last_count_mono):
|
||||||
|
"""True if this zone is still inside its post-count cooldown window."""
|
||||||
|
cooldown = zone_cooldown_sec(zone_id)
|
||||||
if cooldown <= 0:
|
if cooldown <= 0:
|
||||||
return False
|
return False
|
||||||
last = last_count_mono.get(feeder)
|
last = last_count_mono.get(zone_id)
|
||||||
if last is None:
|
if last is None:
|
||||||
return False
|
return False
|
||||||
return (mono - last) < cooldown
|
return (mono - last) < cooldown
|
||||||
@@ -810,10 +768,10 @@ def _inherit_prev(tracked, new_tid, cx, cy, mono, max_age, max_px):
|
|||||||
|
|
||||||
|
|
||||||
def _default_zone_state():
|
def _default_zone_state():
|
||||||
"""Per-track feeder-zone state.
|
"""Per-track zone state.
|
||||||
|
|
||||||
counted: already contributed one LEFT or RIGHT count (at most one per object).
|
counted: already contributed one zone count (at most one per object).
|
||||||
side: 'left' | 'right' | None after a count.
|
side: zone_id or None after a count.
|
||||||
dwell_feeder / dwell_since: which zone the track is currently dwelling in,
|
dwell_feeder / dwell_since: which zone the track is currently dwelling in,
|
||||||
and when continuous presence in that zone began.
|
and when continuous presence in that zone began.
|
||||||
"""
|
"""
|
||||||
@@ -1066,7 +1024,7 @@ def draw_pill(img, text, x, y, bg, fg=C_TEXT, font_scale=0.45, pad_x=6, pad_y=4)
|
|||||||
cv2.putText(img, text, (x + pad_x, y), font, font_scale, fg, 1, cv2.LINE_AA)
|
cv2.putText(img, text, (x + pad_x, y), font, font_scale, fg, 1, cv2.LINE_AA)
|
||||||
|
|
||||||
|
|
||||||
def draw_elegant_zone(img, zone, pulse_remaining=0, label="ZONE", color=C_ZONE_LEFT):
|
def draw_elegant_zone(img, zone, pulse_remaining=0, label="ZONE", color=C_ZONE_DEFAULT):
|
||||||
x1, y1, x2, y2 = zone
|
x1, y1, x2, y2 = zone
|
||||||
strength = pulse_remaining / max(ZONE_PULSE_FRAMES, 1)
|
strength = pulse_remaining / max(ZONE_PULSE_FRAMES, 1)
|
||||||
thickness = 2 + int(2 * strength)
|
thickness = 2 + int(2 * strength)
|
||||||
@@ -1122,7 +1080,7 @@ def draw_zone_count(img, zone, count, label, color, pulse_remaining=0):
|
|||||||
cv2.putText(img, label, (slx, sly), font, 0.45, C_MUTED, 1, cv2.LINE_AA)
|
cv2.putText(img, label, (slx, sly), font, 0.45, C_MUTED, 1, cv2.LINE_AA)
|
||||||
|
|
||||||
|
|
||||||
def draw_hud(img, w, total, total_left, total_right, elapsed_sec, rate):
|
def draw_hud(img, w, total, counts_by_zone, elapsed_sec, rate):
|
||||||
bar_h = 40
|
bar_h = 40
|
||||||
overlay_rect(img, 0, 0, w, bar_h, C_PANEL, alpha=0.72)
|
overlay_rect(img, 0, 0, w, bar_h, C_PANEL, alpha=0.72)
|
||||||
cv2.line(img, (0, bar_h), (w, bar_h), C_BORDER, 1)
|
cv2.line(img, (0, bar_h), (w, bar_h), C_BORDER, 1)
|
||||||
@@ -1139,36 +1097,28 @@ def draw_hud(img, w, total, total_left, total_right, elapsed_sec, rate):
|
|||||||
1,
|
1,
|
||||||
cv2.LINE_AA,
|
cv2.LINE_AA,
|
||||||
)
|
)
|
||||||
cv2.putText(
|
x = 100
|
||||||
img, "LEFT", (100, 14), cv2.FONT_HERSHEY_SIMPLEX, 0.32, C_MUTED, 1, cv2.LINE_AA
|
for zid in ZONE_IDS:
|
||||||
)
|
label = zid.replace("zone_", "Z")
|
||||||
cv2.putText(
|
color = zone_color(zid)
|
||||||
img,
|
cv2.putText(
|
||||||
str(total_left),
|
img, label, (x, 14), cv2.FONT_HERSHEY_SIMPLEX, 0.32, C_MUTED, 1, cv2.LINE_AA
|
||||||
(100, 32),
|
)
|
||||||
cv2.FONT_HERSHEY_SIMPLEX,
|
cv2.putText(
|
||||||
0.55,
|
img,
|
||||||
C_ZONE_LEFT,
|
str(counts_by_zone.get(zid, 0)),
|
||||||
1,
|
(x, 32),
|
||||||
cv2.LINE_AA,
|
cv2.FONT_HERSHEY_SIMPLEX,
|
||||||
)
|
0.55,
|
||||||
cv2.putText(
|
color,
|
||||||
img, "RIGHT", (180, 14), cv2.FONT_HERSHEY_SIMPLEX, 0.32, C_MUTED, 1, cv2.LINE_AA
|
1,
|
||||||
)
|
cv2.LINE_AA,
|
||||||
cv2.putText(
|
)
|
||||||
img,
|
x += 80
|
||||||
str(total_right),
|
|
||||||
(180, 32),
|
|
||||||
cv2.FONT_HERSHEY_SIMPLEX,
|
|
||||||
0.55,
|
|
||||||
C_ZONE_RIGHT,
|
|
||||||
1,
|
|
||||||
cv2.LINE_AA,
|
|
||||||
)
|
|
||||||
cv2.putText(
|
cv2.putText(
|
||||||
img,
|
img,
|
||||||
"UPTIME",
|
"UPTIME",
|
||||||
(270, 14),
|
(x + 10, 14),
|
||||||
cv2.FONT_HERSHEY_SIMPLEX,
|
cv2.FONT_HERSHEY_SIMPLEX,
|
||||||
0.32,
|
0.32,
|
||||||
C_MUTED,
|
C_MUTED,
|
||||||
@@ -1178,7 +1128,7 @@ def draw_hud(img, w, total, total_left, total_right, elapsed_sec, rate):
|
|||||||
cv2.putText(
|
cv2.putText(
|
||||||
img,
|
img,
|
||||||
f"{elapsed_sec / 3600:.1f}h",
|
f"{elapsed_sec / 3600:.1f}h",
|
||||||
(270, 32),
|
(x + 10, 32),
|
||||||
cv2.FONT_HERSHEY_SIMPLEX,
|
cv2.FONT_HERSHEY_SIMPLEX,
|
||||||
0.45,
|
0.45,
|
||||||
C_TEXT,
|
C_TEXT,
|
||||||
@@ -1186,12 +1136,12 @@ def draw_hud(img, w, total, total_left, total_right, elapsed_sec, rate):
|
|||||||
cv2.LINE_AA,
|
cv2.LINE_AA,
|
||||||
)
|
)
|
||||||
cv2.putText(
|
cv2.putText(
|
||||||
img, "RATE", (360, 14), cv2.FONT_HERSHEY_SIMPLEX, 0.32, C_MUTED, 1, cv2.LINE_AA
|
img, "RATE", (x + 100, 14), cv2.FONT_HERSHEY_SIMPLEX, 0.32, C_MUTED, 1, cv2.LINE_AA
|
||||||
)
|
)
|
||||||
cv2.putText(
|
cv2.putText(
|
||||||
img,
|
img,
|
||||||
f"{rate:.1f}/min",
|
f"{rate:.1f}/min",
|
||||||
(360, 32),
|
(x + 100, 32),
|
||||||
cv2.FONT_HERSHEY_SIMPLEX,
|
cv2.FONT_HERSHEY_SIMPLEX,
|
||||||
0.45,
|
0.45,
|
||||||
C_ACCENT,
|
C_ACCENT,
|
||||||
@@ -1333,6 +1283,7 @@ def run():
|
|||||||
camera_name=CAMERA_NAME,
|
camera_name=CAMERA_NAME,
|
||||||
object_label=OBJECT_LABEL,
|
object_label=OBJECT_LABEL,
|
||||||
cutoff_time=DAILY_CUTOFF_TIME,
|
cutoff_time=DAILY_CUTOFF_TIME,
|
||||||
|
zone_ids=ZONE_IDS,
|
||||||
logger=lambda msg: print(f"[{now_str()}] {msg}"),
|
logger=lambda msg: print(f"[{now_str()}] {msg}"),
|
||||||
)
|
)
|
||||||
store.start_cutoff_watcher()
|
store.start_cutoff_watcher()
|
||||||
@@ -1340,7 +1291,7 @@ def run():
|
|||||||
cross_logger = None
|
cross_logger = None
|
||||||
if EXPORT_CSV:
|
if EXPORT_CSV:
|
||||||
cross_logger = CsvLogger(
|
cross_logger = CsvLogger(
|
||||||
CROSS_CSV, ["counting_date", "frame", "feeder", "object_id"]
|
CROSS_CSV, ["counting_date", "frame", "zone_id", "object_id"]
|
||||||
)
|
)
|
||||||
|
|
||||||
model = RKNNYOLO(
|
model = RKNNYOLO(
|
||||||
@@ -1363,19 +1314,17 @@ def run():
|
|||||||
min_hits=TRACK_MIN_HITS,
|
min_hits=TRACK_MIN_HITS,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Per-track zone state: enter left → LEFT feeder; enter right → RIGHT feeder.
|
# Per-track zone state. Keys survive ID switches via inheritance.
|
||||||
# Keys survive ID switches via inheritance (see _inherit_prev).
|
|
||||||
object_zone_state = {}
|
object_zone_state = {}
|
||||||
|
|
||||||
recent_cross_left = deque()
|
recent_cross = {zid: deque() for zid in ZONE_IDS}
|
||||||
recent_cross_right = deque()
|
last_count_mono = {zid: None for zid in ZONE_IDS}
|
||||||
# Monotonic timestamp of last successful count per feeder (for cooldown).
|
|
||||||
last_count_mono = {"left": None, "right": None}
|
|
||||||
|
|
||||||
detect_snapshot_ids = set()
|
detect_snapshot_ids = set()
|
||||||
|
|
||||||
object_cross_flash = {}
|
object_cross_flash = {}
|
||||||
zone_pulse = count_left_pulse = count_right_pulse = 0
|
zone_pulse = 0
|
||||||
|
count_pulse = {zid: 0 for zid in ZONE_IDS}
|
||||||
popups = []
|
popups = []
|
||||||
|
|
||||||
session_start = time.time()
|
session_start = time.time()
|
||||||
@@ -1386,8 +1335,7 @@ def run():
|
|||||||
video_writer = None
|
video_writer = None
|
||||||
crossing_times = deque()
|
crossing_times = deque()
|
||||||
# Overlay shows daily store totals (resume-safe, resets on counting-day change).
|
# Overlay shows daily store totals (resume-safe, resets on counting-day change).
|
||||||
counter_left = store.current_count_left
|
counter_by_zone = store.current_counts
|
||||||
counter_right = store.current_count_right
|
|
||||||
last_snapshot_cleanup = 0.0
|
last_snapshot_cleanup = 0.0
|
||||||
|
|
||||||
counting_active = True
|
counting_active = True
|
||||||
@@ -1413,16 +1361,19 @@ def run():
|
|||||||
model.release()
|
model.release()
|
||||||
return
|
return
|
||||||
|
|
||||||
zone_left = resolve_zone_left(w, h)
|
zone_rects = {z.id: z.resolve_rect(w, h) for z in ZONES}
|
||||||
zone_right = resolve_zone_right(w, h)
|
zones_desc = " ".join(
|
||||||
print(
|
f"{z.id}({z.name}) {zone_rects[z.id]}" for z in ZONES
|
||||||
f"RKNN+ByteTrack zone counter | {w}x{h} @ {fps}fps | "
|
)
|
||||||
f"LEFT feeder {zone_left} RIGHT feeder {zone_right}"
|
print(f"RKNN+ByteTrack zone counter | {w}x{h} @ {fps}fps | {zones_desc}")
|
||||||
|
cool_desc = " ".join(
|
||||||
|
f"{z.id}={z.cooldown_sec}s" for z in ZONES
|
||||||
|
)
|
||||||
|
dwell_desc = " ".join(
|
||||||
|
f"{z.id}={z.dwell_sec}s" for z in ZONES
|
||||||
)
|
)
|
||||||
print(
|
print(
|
||||||
f"Cooldownoldown: left={ZONE_COOLDOWN_LEFT_SEC}s right={ZONE_COOLDOWN_RIGHT_SEC}s "
|
f"Cooldown: {cool_desc} (0=off) | Dwell: {dwell_desc} (0=immediate)"
|
||||||
f"(0=off) | Dwell: left={ZONE_DWELL_LEFT_SEC}s right={ZONE_DWELL_RIGHT_SEC}s "
|
|
||||||
f"(0=immediate)"
|
|
||||||
)
|
)
|
||||||
print(f"Model: {MODEL_PATH} | imgsz={IMGSZ} | core_mask={CORE_MASK}")
|
print(f"Model: {MODEL_PATH} | imgsz={IMGSZ} | core_mask={CORE_MASK}")
|
||||||
print(
|
print(
|
||||||
@@ -1452,8 +1403,7 @@ def run():
|
|||||||
cap, w, h, fps = connect_stream(SOURCE)
|
cap, w, h, fps = connect_stream(SOURCE)
|
||||||
if cap is None:
|
if cap is None:
|
||||||
break
|
break
|
||||||
zone_left = resolve_zone_left(w, h)
|
zone_rects = {z.id: z.resolve_rect(w, h) for z in ZONES}
|
||||||
zone_right = resolve_zone_right(w, h)
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
now = time.time()
|
now = time.time()
|
||||||
@@ -1534,30 +1484,23 @@ def run():
|
|||||||
)
|
)
|
||||||
zone_str = ""
|
zone_str = ""
|
||||||
if object_zone_state:
|
if object_zone_state:
|
||||||
left_ids = sorted(
|
by_side = {}
|
||||||
tid
|
for tid, st in object_zone_state.items():
|
||||||
for tid, st in object_zone_state.items()
|
side = st.get("side")
|
||||||
if st.get("side") == "left"
|
if side:
|
||||||
)
|
by_side.setdefault(side, []).append(tid)
|
||||||
right_ids = sorted(
|
for side, tids in by_side.items():
|
||||||
tid
|
zone_str += f" {side}_counted: {sorted(tids)}"
|
||||||
for tid, st in object_zone_state.items()
|
|
||||||
if st.get("side") == "right"
|
|
||||||
)
|
|
||||||
if left_ids:
|
|
||||||
zone_str += f" left_counted: {left_ids}"
|
|
||||||
if right_ids:
|
|
||||||
zone_str += f" right_counted: {right_ids}"
|
|
||||||
print(
|
print(
|
||||||
f"[DEBUG F{frame_idx}] dets={len(object_boxes_xyxy)} "
|
f"[DEBUG F{frame_idx}] dets={len(object_boxes_xyxy)} "
|
||||||
f"tracks={len(object_track_map)} "
|
f"tracks={len(object_track_map)} "
|
||||||
f"zone_L={zone_left} zone_R={zone_right}{scores_str}"
|
f"zones={zone_rects}{scores_str}"
|
||||||
f"{tracks_str}{zone_str}",
|
f"{tracks_str}{zone_str}",
|
||||||
flush=True,
|
flush=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
# --- zone dwell counting only on DETECTED objects this frame ---
|
# --- zone dwell counting only on DETECTED objects this frame ---
|
||||||
# A sack must remain inside a feeder zone for ZONE_DWELL_*_SEC before
|
# A sack must remain inside a zone for ZONE_i_DWELL_SEC before
|
||||||
# it is counted (and a cross snapshot is saved). Leaving the zone
|
# it is counted (and a cross snapshot is saved). Leaving the zone
|
||||||
# resets the dwell timer. Cooldown still applies after a successful count.
|
# resets the dwell timer. Cooldown still applies after a successful count.
|
||||||
for di in range(len(object_boxes_xyxy)):
|
for di in range(len(object_boxes_xyxy)):
|
||||||
@@ -1597,7 +1540,7 @@ def run():
|
|||||||
object_zone_state[tid] = _default_zone_state()
|
object_zone_state[tid] = _default_zone_state()
|
||||||
st = object_zone_state[tid]
|
st = object_zone_state[tid]
|
||||||
|
|
||||||
feeder = resolve_feeder_at(cx, cy, zone_left, zone_right)
|
feeder = resolve_zone_at(cx, cy, zone_rects)
|
||||||
|
|
||||||
if st["counted"]:
|
if st["counted"]:
|
||||||
object_tracked[tid] = (cx, cy, mono)
|
object_tracked[tid] = (cx, cy, mono)
|
||||||
@@ -1608,7 +1551,7 @@ def run():
|
|||||||
if st["dwell_feeder"] is not None and DEBUG_TRACKING:
|
if st["dwell_feeder"] is not None and DEBUG_TRACKING:
|
||||||
print(
|
print(
|
||||||
f"[DEBUG F{frame_idx}] DWELL RESET: tid={tid} "
|
f"[DEBUG F{frame_idx}] DWELL RESET: tid={tid} "
|
||||||
f"left feeder={st['dwell_feeder']}",
|
f"left zone={st['dwell_feeder']}",
|
||||||
flush=True,
|
flush=True,
|
||||||
)
|
)
|
||||||
st["dwell_feeder"] = None
|
st["dwell_feeder"] = None
|
||||||
@@ -1622,40 +1565,40 @@ def run():
|
|||||||
if DEBUG_TRACKING:
|
if DEBUG_TRACKING:
|
||||||
print(
|
print(
|
||||||
f"[DEBUG F{frame_idx}] DWELL START: tid={tid} "
|
f"[DEBUG F{frame_idx}] DWELL START: tid={tid} "
|
||||||
f"feeder={feeder} need={feeder_dwell_sec(feeder):.1f}s",
|
f"zone={feeder} need={zone_dwell_sec(feeder):.1f}s",
|
||||||
flush=True,
|
flush=True,
|
||||||
)
|
)
|
||||||
object_tracked[tid] = (cx, cy, mono)
|
object_tracked[tid] = (cx, cy, mono)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
dwell_need = feeder_dwell_sec(feeder)
|
dwell_need = zone_dwell_sec(feeder)
|
||||||
dwell_elapsed = mono - (st["dwell_since"] or mono)
|
dwell_elapsed = mono - (st["dwell_since"] or mono)
|
||||||
if dwell_need > 0 and dwell_elapsed < dwell_need:
|
if dwell_need > 0 and dwell_elapsed < dwell_need:
|
||||||
object_tracked[tid] = (cx, cy, mono)
|
object_tracked[tid] = (cx, cy, mono)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Dwell complete — apply dedup / cooldown, then count + snapshot.
|
# Dwell complete — apply dedup / cooldown, then count + snapshot.
|
||||||
recent = recent_cross_left if feeder == "left" else recent_cross_right
|
recent = recent_cross[feeder]
|
||||||
if is_duplicate_cross(recent, cx, cy, frame_idx):
|
if is_duplicate_cross(recent, cx, cy, frame_idx):
|
||||||
st["counted"] = True
|
st["counted"] = True
|
||||||
st["side"] = feeder
|
st["side"] = feeder
|
||||||
if DEBUG_TRACKING:
|
if DEBUG_TRACKING:
|
||||||
print(
|
print(
|
||||||
f"[DEBUG F{frame_idx}] DUP ZONE IGNORED: tid={tid} "
|
f"[DEBUG F{frame_idx}] DUP ZONE IGNORED: tid={tid} "
|
||||||
f"cx={cx:.1f} cy={cy:.1f} feeder={feeder}",
|
f"cx={cx:.1f} cy={cy:.1f} zone={feeder}",
|
||||||
flush=True,
|
flush=True,
|
||||||
)
|
)
|
||||||
object_tracked[tid] = (cx, cy, mono)
|
object_tracked[tid] = (cx, cy, mono)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if feeder_in_cooldown(feeder, mono, last_count_mono):
|
if zone_in_cooldown(feeder, mono, last_count_mono):
|
||||||
# Keep dwelling; count once cooldown expires if still in zone.
|
# Keep dwelling; count once cooldown expires if still in zone.
|
||||||
if DEBUG_TRACKING:
|
if DEBUG_TRACKING:
|
||||||
cool = feeder_cooldown_sec(feeder)
|
cool = zone_cooldown_sec(feeder)
|
||||||
elapsed = mono - (last_count_mono.get(feeder) or mono)
|
elapsed_c = mono - (last_count_mono.get(feeder) or mono)
|
||||||
print(
|
print(
|
||||||
f"[DEBUG F{frame_idx}] DWELL WAIT COOLDOWN: tid={tid} "
|
f"[DEBUG F{frame_idx}] DWELL WAIT COOLDOWN: tid={tid} "
|
||||||
f"feeder={feeder} elapsed={elapsed:.2f}s / {cool}s",
|
f"zone={feeder} elapsed={elapsed_c:.2f}s / {cool}s",
|
||||||
flush=True,
|
flush=True,
|
||||||
)
|
)
|
||||||
object_tracked[tid] = (cx, cy, mono)
|
object_tracked[tid] = (cx, cy, mono)
|
||||||
@@ -1664,7 +1607,7 @@ def run():
|
|||||||
if DEBUG_TRACKING:
|
if DEBUG_TRACKING:
|
||||||
print(
|
print(
|
||||||
f"[DEBUG F{frame_idx}] DWELL COUNT: tid={tid} "
|
f"[DEBUG F{frame_idx}] DWELL COUNT: tid={tid} "
|
||||||
f"cx={cx:.1f} cy={cy:.1f} feeder={feeder} "
|
f"cx={cx:.1f} cy={cy:.1f} zone={feeder} "
|
||||||
f"dwell={dwell_elapsed:.2f}s",
|
f"dwell={dwell_elapsed:.2f}s",
|
||||||
flush=True,
|
flush=True,
|
||||||
)
|
)
|
||||||
@@ -1674,22 +1617,17 @@ def run():
|
|||||||
st["side"] = feeder
|
st["side"] = feeder
|
||||||
last_count_mono[feeder] = mono
|
last_count_mono[feeder] = mono
|
||||||
_, _, counted = store.record_zone_entry(tid, feeder)
|
_, _, counted = store.record_zone_entry(tid, feeder)
|
||||||
counter_left = store.current_count_left
|
counter_by_zone = store.current_counts
|
||||||
counter_right = store.current_count_right
|
|
||||||
if not counted:
|
if not counted:
|
||||||
# Dedup in the store rejected this event; skip overlay/CSV.
|
# Dedup in the store rejected this event; skip overlay/CSV.
|
||||||
object_tracked[tid] = (cx, cy, mono)
|
object_tracked[tid] = (cx, cy, mono)
|
||||||
continue
|
continue
|
||||||
if feeder == "left":
|
count_pulse[feeder] = COUNT_PULSE_FRAMES
|
||||||
count_left_pulse = COUNT_PULSE_FRAMES
|
feeder_total = counter_by_zone.get(feeder, 0)
|
||||||
feeder_total = counter_left
|
|
||||||
else:
|
|
||||||
count_right_pulse = COUNT_PULSE_FRAMES
|
|
||||||
feeder_total = counter_right
|
|
||||||
# Frigate-style count log, e.g.
|
# Frigate-style count log, e.g.
|
||||||
# "Total karung di kandang_bawah_feeder_kanan: 5"
|
# "Total karung di Cam_zone_1: 5"
|
||||||
print(
|
print(
|
||||||
f"Total {OBJECT_LABEL} di {feeder_display_name(feeder)}: {feeder_total}",
|
f"Total {OBJECT_LABEL} di {zone_display_name(feeder)}: {feeder_total}",
|
||||||
flush=True,
|
flush=True,
|
||||||
)
|
)
|
||||||
if cross_logger:
|
if cross_logger:
|
||||||
@@ -1710,7 +1648,7 @@ def run():
|
|||||||
"x": int(cx) - 12,
|
"x": int(cx) - 12,
|
||||||
"y": int(cy),
|
"y": int(cy),
|
||||||
"born": frame_idx,
|
"born": frame_idx,
|
||||||
"text": f"+1 {feeder.upper()}",
|
"text": f"+1 {feeder}",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
object_tracked[tid] = (cx, cy, mono)
|
object_tracked[tid] = (cx, cy, mono)
|
||||||
@@ -1737,30 +1675,30 @@ def run():
|
|||||||
zone_pulse = ZONE_PULSE_FRAMES
|
zone_pulse = ZONE_PULSE_FRAMES
|
||||||
|
|
||||||
# Keep overlay aligned with daily store (also resets after cutoff).
|
# Keep overlay aligned with daily store (also resets after cutoff).
|
||||||
counter_left = store.current_count_left
|
counter_by_zone = store.current_counts
|
||||||
counter_right = store.current_count_right
|
|
||||||
while crossing_times and mono - crossing_times[0] > RATE_WINDOW_SEC:
|
while crossing_times and mono - crossing_times[0] > RATE_WINDOW_SEC:
|
||||||
crossing_times.popleft()
|
crossing_times.popleft()
|
||||||
rate = (len(crossing_times) / RATE_WINDOW_SEC * 60) if crossing_times else 0.0
|
rate = (len(crossing_times) / RATE_WINDOW_SEC * 60) if crossing_times else 0.0
|
||||||
|
|
||||||
draw_elegant_zone(
|
for z in ZONES:
|
||||||
frame, zone_left, zone_pulse, label="LEFT FEEDER", color=C_ZONE_LEFT
|
rect = zone_rects[z.id]
|
||||||
)
|
color = zone_color(z.id)
|
||||||
draw_elegant_zone(
|
draw_elegant_zone(
|
||||||
frame, zone_right, zone_pulse, label="RIGHT FEEDER", color=C_ZONE_RIGHT
|
frame, rect, zone_pulse, label=z.name.upper(), color=color
|
||||||
)
|
)
|
||||||
draw_zone_count(
|
draw_zone_count(
|
||||||
frame, zone_left, counter_left, "LEFT", C_ZONE_LEFT, count_left_pulse
|
frame,
|
||||||
)
|
rect,
|
||||||
draw_zone_count(
|
counter_by_zone.get(z.id, 0),
|
||||||
frame, zone_right, counter_right, "RIGHT", C_ZONE_RIGHT, count_right_pulse
|
z.id.replace("zone_", "Z"),
|
||||||
)
|
color,
|
||||||
|
count_pulse.get(z.id, 0),
|
||||||
|
)
|
||||||
draw_hud(
|
draw_hud(
|
||||||
frame,
|
frame,
|
||||||
w,
|
w,
|
||||||
counter_left + counter_right,
|
sum(counter_by_zone.values()),
|
||||||
counter_left,
|
counter_by_zone,
|
||||||
counter_right,
|
|
||||||
elapsed,
|
elapsed,
|
||||||
rate,
|
rate,
|
||||||
)
|
)
|
||||||
@@ -1788,8 +1726,8 @@ def run():
|
|||||||
if object_cross_flash[tid] <= 0:
|
if object_cross_flash[tid] <= 0:
|
||||||
del object_cross_flash[tid]
|
del object_cross_flash[tid]
|
||||||
zone_pulse = max(0, zone_pulse - 1)
|
zone_pulse = max(0, zone_pulse - 1)
|
||||||
count_left_pulse = max(0, count_left_pulse - 1)
|
for zid in count_pulse:
|
||||||
count_right_pulse = max(0, count_right_pulse - 1)
|
count_pulse[zid] = max(0, count_pulse[zid] - 1)
|
||||||
|
|
||||||
if video_writer is not None:
|
if video_writer is not None:
|
||||||
video_writer.write(frame)
|
video_writer.write(frame)
|
||||||
|
|||||||
+169
-54
@@ -1,11 +1,9 @@
|
|||||||
"""
|
"""
|
||||||
Production daily counter persistence for edge sack feeder counter.
|
Production daily counter persistence for edge sack zone counter.
|
||||||
Tracks left / right feeder zone entries and the daily total per counting day,
|
Tracks per-zone entries and the daily total per counting day,
|
||||||
delimited by the daily cutoff time. SQLite schema + current_counter.json state.
|
delimited by the daily cutoff time. SQLite schema + current_counter.json state.
|
||||||
|
|
||||||
Feeders:
|
Zone ids are stable keys like zone_1, zone_2, … (from zones_config).
|
||||||
left → left feeder zone
|
|
||||||
right → right feeder zone
|
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
import sqlite3
|
import sqlite3
|
||||||
@@ -24,6 +22,7 @@ class CounterStore:
|
|||||||
object_label='object',
|
object_label='object',
|
||||||
cutoff_time='20:00',
|
cutoff_time='20:00',
|
||||||
carry_ids=50,
|
carry_ids=50,
|
||||||
|
zone_ids=None,
|
||||||
logger=print,
|
logger=print,
|
||||||
):
|
):
|
||||||
self.db_path = db_path
|
self.db_path = db_path
|
||||||
@@ -34,6 +33,10 @@ class CounterStore:
|
|||||||
datetime.strptime(cutoff_time, '%H:%M')
|
datetime.strptime(cutoff_time, '%H:%M')
|
||||||
|
|
||||||
self.carry_ids = int(carry_ids)
|
self.carry_ids = int(carry_ids)
|
||||||
|
self.zone_ids = list(zone_ids) if zone_ids else ['zone_1']
|
||||||
|
if not self.zone_ids:
|
||||||
|
raise ValueError('zone_ids must contain at least one zone id')
|
||||||
|
self._zone_id_set = set(self.zone_ids)
|
||||||
self.log = logger
|
self.log = logger
|
||||||
|
|
||||||
self.state_lock = threading.Lock()
|
self.state_lock = threading.Lock()
|
||||||
@@ -46,6 +49,11 @@ class CounterStore:
|
|||||||
self._init_db()
|
self._init_db()
|
||||||
self.current_state = self._load_state()
|
self.current_state = self._load_state()
|
||||||
|
|
||||||
|
def _table_columns(self, table):
|
||||||
|
cur = self.db.cursor()
|
||||||
|
cur.execute(f'PRAGMA table_info({table})')
|
||||||
|
return {row[1] for row in cur.fetchall()}
|
||||||
|
|
||||||
def _init_db(self):
|
def _init_db(self):
|
||||||
cur = self.db.cursor()
|
cur = self.db.cursor()
|
||||||
cur.execute(
|
cur.execute(
|
||||||
@@ -56,8 +64,6 @@ class CounterStore:
|
|||||||
camera_name TEXT NOT NULL,
|
camera_name TEXT NOT NULL,
|
||||||
object_label TEXT NOT NULL,
|
object_label TEXT NOT NULL,
|
||||||
total_count INTEGER NOT NULL DEFAULT 0,
|
total_count INTEGER NOT NULL DEFAULT 0,
|
||||||
total_left INTEGER NOT NULL DEFAULT 0,
|
|
||||||
total_right INTEGER NOT NULL DEFAULT 0,
|
|
||||||
start_time TEXT,
|
start_time TEXT,
|
||||||
end_time TEXT,
|
end_time TEXT,
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
@@ -65,7 +71,87 @@ class CounterStore:
|
|||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS daily_zone_counts (
|
||||||
|
counting_date TEXT NOT NULL,
|
||||||
|
camera_name TEXT NOT NULL,
|
||||||
|
object_label TEXT NOT NULL,
|
||||||
|
zone_id TEXT NOT NULL,
|
||||||
|
count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (counting_date, camera_name, object_label, zone_id)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
|
self._migrate_left_right_if_needed()
|
||||||
|
|
||||||
|
def _migrate_left_right_if_needed(self):
|
||||||
|
cols = self._table_columns('daily_counters')
|
||||||
|
if 'total_left' not in cols and 'total_right' not in cols:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.log('Migrating daily_counters left/right columns to daily_zone_counts')
|
||||||
|
cur = self.db.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT counting_date, camera_name, object_label,
|
||||||
|
COALESCE(total_left, 0), COALESCE(total_right, 0)
|
||||||
|
FROM daily_counters
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
rows = cur.fetchall()
|
||||||
|
for counting_date, camera_name, object_label, left, right in rows:
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO daily_zone_counts
|
||||||
|
(counting_date, camera_name, object_label, zone_id, count)
|
||||||
|
VALUES (?, ?, ?, 'zone_1', ?)
|
||||||
|
ON CONFLICT(counting_date, camera_name, object_label, zone_id)
|
||||||
|
DO UPDATE SET count = excluded.count
|
||||||
|
""",
|
||||||
|
(counting_date, camera_name, object_label, left),
|
||||||
|
)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO daily_zone_counts
|
||||||
|
(counting_date, camera_name, object_label, zone_id, count)
|
||||||
|
VALUES (?, ?, ?, 'zone_2', ?)
|
||||||
|
ON CONFLICT(counting_date, camera_name, object_label, zone_id)
|
||||||
|
DO UPDATE SET count = excluded.count
|
||||||
|
""",
|
||||||
|
(counting_date, camera_name, object_label, right),
|
||||||
|
)
|
||||||
|
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE daily_counters_new (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
counting_date TEXT NOT NULL,
|
||||||
|
camera_name TEXT NOT NULL,
|
||||||
|
object_label TEXT NOT NULL,
|
||||||
|
total_count INTEGER NOT NULL DEFAULT 0,
|
||||||
|
start_time TEXT,
|
||||||
|
end_time TEXT,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(counting_date, camera_name, object_label)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO daily_counters_new
|
||||||
|
(id, counting_date, camera_name, object_label,
|
||||||
|
total_count, start_time, end_time, updated_at)
|
||||||
|
SELECT id, counting_date, camera_name, object_label,
|
||||||
|
total_count, start_time, end_time, updated_at
|
||||||
|
FROM daily_counters
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
cur.execute('DROP TABLE daily_counters')
|
||||||
|
cur.execute('ALTER TABLE daily_counters_new RENAME TO daily_counters')
|
||||||
|
self.db.commit()
|
||||||
|
self.log(f'Migration complete ({len(rows)} daily row(s))')
|
||||||
|
|
||||||
def get_counting_date(self, dt=None):
|
def get_counting_date(self, dt=None):
|
||||||
if dt is None:
|
if dt is None:
|
||||||
@@ -75,11 +161,28 @@ class CounterStore:
|
|||||||
return dt.date().isoformat()
|
return dt.date().isoformat()
|
||||||
return (dt.date() + timedelta(days=1)).isoformat()
|
return (dt.date() + timedelta(days=1)).isoformat()
|
||||||
|
|
||||||
|
def _empty_counts(self):
|
||||||
|
return {zid: 0 for zid in self.zone_ids}
|
||||||
|
|
||||||
def _normalize_state(self, state):
|
def _normalize_state(self, state):
|
||||||
state.setdefault('count_left', 0)
|
counts = state.get('counts')
|
||||||
state.setdefault('count_right', 0)
|
if not isinstance(counts, dict):
|
||||||
state.setdefault('count', state['count_left'] + state['count_right'])
|
counts = {}
|
||||||
|
if 'count_left' in state or 'count_right' in state:
|
||||||
|
counts['zone_1'] = int(state.get('count_left', 0) or 0)
|
||||||
|
counts['zone_2'] = int(state.get('count_right', 0) or 0)
|
||||||
|
normalized = self._empty_counts()
|
||||||
|
for zid in self.zone_ids:
|
||||||
|
normalized[zid] = int(counts.get(zid, 0) or 0)
|
||||||
|
# Preserve migrated zone_2 even if current config is 1 zone? Spec says
|
||||||
|
# configured ids only — drop unknown keys from active counts.
|
||||||
|
state['counts'] = normalized
|
||||||
|
state['count'] = sum(normalized.values())
|
||||||
|
state.pop('count_left', None)
|
||||||
|
state.pop('count_right', None)
|
||||||
state.setdefault('counted_event_ids', [])
|
state.setdefault('counted_event_ids', [])
|
||||||
|
state.setdefault('start_time', datetime.now().isoformat())
|
||||||
|
state.setdefault('last_detection_time', state.get('start_time'))
|
||||||
return state
|
return state
|
||||||
|
|
||||||
def _load_state(self):
|
def _load_state(self):
|
||||||
@@ -97,10 +200,6 @@ class CounterStore:
|
|||||||
self.state_file.unlink(missing_ok=True)
|
self.state_file.unlink(missing_ok=True)
|
||||||
return None
|
return None
|
||||||
state = self._normalize_state(state)
|
state = self._normalize_state(state)
|
||||||
# Track IDs are process-local and restart from 1 after every process
|
|
||||||
# start. Persisted dedup keys like "5_left" 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 []
|
stale_ids = state.get('counted_event_ids') or []
|
||||||
if stale_ids:
|
if stale_ids:
|
||||||
self.log(
|
self.log(
|
||||||
@@ -110,7 +209,7 @@ class CounterStore:
|
|||||||
state['counted_event_ids'] = []
|
state['counted_event_ids'] = []
|
||||||
self.log(
|
self.log(
|
||||||
f"Resumed {current_date} with total={state['count']} "
|
f"Resumed {current_date} with total={state['count']} "
|
||||||
f"(left={state['count_left']} right={state['count_right']})"
|
f"counts={state['counts']}"
|
||||||
)
|
)
|
||||||
with open(self.state_file, 'w', encoding='utf-8') as f:
|
with open(self.state_file, 'w', encoding='utf-8') as f:
|
||||||
json.dump(state, f, indent=2, ensure_ascii=False)
|
json.dump(state, f, indent=2, ensure_ascii=False)
|
||||||
@@ -134,11 +233,11 @@ class CounterStore:
|
|||||||
carried = self.current_state['counted_event_ids'][-self.carry_ids:]
|
carried = self.current_state['counted_event_ids'][-self.carry_ids:]
|
||||||
except (KeyError, TypeError):
|
except (KeyError, TypeError):
|
||||||
carried = []
|
carried = []
|
||||||
|
counts = self._empty_counts()
|
||||||
self.current_state = {
|
self.current_state = {
|
||||||
'counting_date': counting_date,
|
'counting_date': counting_date,
|
||||||
'count': 0,
|
'count': 0,
|
||||||
'count_left': 0,
|
'counts': counts,
|
||||||
'count_right': 0,
|
|
||||||
'start_time': now,
|
'start_time': now,
|
||||||
'last_detection_time': now,
|
'last_detection_time': now,
|
||||||
'counted_event_ids': carried,
|
'counted_event_ids': carried,
|
||||||
@@ -146,13 +245,15 @@ class CounterStore:
|
|||||||
self.save_state()
|
self.save_state()
|
||||||
self.log(f'Started counting day {counting_date} ({self.object_label})')
|
self.log(f'Started counting day {counting_date} ({self.object_label})')
|
||||||
|
|
||||||
def record_zone_entry(self, track_id, feeder):
|
def record_zone_entry(self, track_id, zone_id):
|
||||||
"""Record a sack entering a feeder zone. feeder: 'left' | 'right'.
|
"""Record a sack entering a zone. zone_id: 'zone_1' | 'zone_2' | …
|
||||||
|
|
||||||
Returns (total_count, day_started, counted).
|
Returns (total_count, day_started, counted).
|
||||||
"""
|
"""
|
||||||
if feeder not in ('left', 'right'):
|
if zone_id not in self._zone_id_set:
|
||||||
raise ValueError(f"feeder must be 'left' or 'right', got {feeder!r}")
|
raise ValueError(
|
||||||
|
f"zone_id must be one of {self.zone_ids}, got {zone_id!r}"
|
||||||
|
)
|
||||||
with self.state_lock:
|
with self.state_lock:
|
||||||
counting_date = self.get_counting_date()
|
counting_date = self.get_counting_date()
|
||||||
day_started = False
|
day_started = False
|
||||||
@@ -161,20 +262,18 @@ class CounterStore:
|
|||||||
day_started = True
|
day_started = True
|
||||||
|
|
||||||
counted = False
|
counted = False
|
||||||
event_key = f"{track_id}_{feeder}"
|
event_key = f"{track_id}_{zone_id}"
|
||||||
if event_key not in self.current_state['counted_event_ids']:
|
if event_key not in self.current_state['counted_event_ids']:
|
||||||
self.current_state['count'] += 1
|
self.current_state['count'] += 1
|
||||||
if feeder == 'left':
|
self.current_state['counts'][zone_id] = (
|
||||||
self.current_state['count_left'] += 1
|
int(self.current_state['counts'].get(zone_id, 0)) + 1
|
||||||
else:
|
)
|
||||||
self.current_state['count_right'] += 1
|
|
||||||
self.current_state['counted_event_ids'].append(event_key)
|
self.current_state['counted_event_ids'].append(event_key)
|
||||||
counted = True
|
counted = True
|
||||||
self.log(
|
self.log(
|
||||||
f'Counted {feeder} feeder (track {track_id}) | {counting_date} '
|
f'Counted {zone_id} (track {track_id}) | {counting_date} '
|
||||||
f'total: {self.current_state["count"]} '
|
f'total: {self.current_state["count"]} '
|
||||||
f'(left={self.current_state["count_left"]} '
|
f'counts={self.current_state["counts"]}'
|
||||||
f'right={self.current_state["count_right"]})'
|
|
||||||
)
|
)
|
||||||
self._persist_day()
|
self._persist_day()
|
||||||
|
|
||||||
@@ -189,22 +288,34 @@ class CounterStore:
|
|||||||
"""
|
"""
|
||||||
INSERT INTO daily_counters
|
INSERT INTO daily_counters
|
||||||
(counting_date, camera_name, object_label,
|
(counting_date, camera_name, object_label,
|
||||||
total_count, total_left, total_right, start_time, end_time)
|
total_count, start_time, end_time)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(counting_date, camera_name, object_label)
|
ON CONFLICT(counting_date, camera_name, object_label)
|
||||||
DO UPDATE SET
|
DO UPDATE SET
|
||||||
total_count = excluded.total_count,
|
total_count = excluded.total_count,
|
||||||
total_left = excluded.total_left,
|
|
||||||
total_right = excluded.total_right,
|
|
||||||
end_time = excluded.end_time,
|
end_time = excluded.end_time,
|
||||||
updated_at = CURRENT_TIMESTAMP
|
updated_at = CURRENT_TIMESTAMP
|
||||||
""",
|
""",
|
||||||
(
|
(
|
||||||
state['counting_date'], self.camera_name, self.object_label,
|
state['counting_date'], self.camera_name, self.object_label,
|
||||||
state['count'], state['count_left'], state['count_right'],
|
state['count'],
|
||||||
state['start_time'], datetime.now().isoformat(),
|
state['start_time'], datetime.now().isoformat(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
for zid, count in state['counts'].items():
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO daily_zone_counts
|
||||||
|
(counting_date, camera_name, object_label, zone_id, count)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(counting_date, camera_name, object_label, zone_id)
|
||||||
|
DO UPDATE SET count = excluded.count
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
state['counting_date'], self.camera_name, self.object_label,
|
||||||
|
zid, int(count),
|
||||||
|
),
|
||||||
|
)
|
||||||
self.db.commit()
|
self.db.commit()
|
||||||
|
|
||||||
def cutoff_watcher_loop(self):
|
def cutoff_watcher_loop(self):
|
||||||
@@ -231,40 +342,44 @@ class CounterStore:
|
|||||||
return self.current_state['count']
|
return self.current_state['count']
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def current_count_left(self):
|
def current_counts(self):
|
||||||
if self.current_state is None:
|
if self.current_state is None:
|
||||||
return 0
|
return self._empty_counts()
|
||||||
return self.current_state.get('count_left', 0)
|
return dict(self.current_state.get('counts') or self._empty_counts())
|
||||||
|
|
||||||
@property
|
def _day_zone_counts(self, counting_date=None):
|
||||||
def current_count_right(self):
|
|
||||||
if self.current_state is None:
|
|
||||||
return 0
|
|
||||||
return self.current_state.get('count_right', 0)
|
|
||||||
|
|
||||||
def _day_totals(self, counting_date=None):
|
|
||||||
if counting_date is None:
|
if counting_date is None:
|
||||||
counting_date = self.get_counting_date()
|
counting_date = self.get_counting_date()
|
||||||
cur = self.db.cursor()
|
cur = self.db.cursor()
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
SELECT COALESCE(total_count, 0), COALESCE(total_left, 0), COALESCE(total_right, 0)
|
SELECT zone_id, COALESCE(count, 0)
|
||||||
FROM daily_counters
|
FROM daily_zone_counts
|
||||||
WHERE counting_date = ? AND camera_name = ? AND object_label = ?
|
WHERE counting_date = ? AND camera_name = ? AND object_label = ?
|
||||||
""",
|
""",
|
||||||
(counting_date, self.camera_name, self.object_label),
|
(counting_date, self.camera_name, self.object_label),
|
||||||
)
|
)
|
||||||
row = cur.fetchone()
|
out = self._empty_counts()
|
||||||
return row if row else (0, 0, 0)
|
for zone_id, count in cur.fetchall():
|
||||||
|
if zone_id in out:
|
||||||
|
out[zone_id] = int(count)
|
||||||
|
return out
|
||||||
|
|
||||||
def display_total(self):
|
def display_total(self):
|
||||||
return self._day_totals()[0]
|
cur = self.db.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT COALESCE(total_count, 0)
|
||||||
|
FROM daily_counters
|
||||||
|
WHERE counting_date = ? AND camera_name = ? AND object_label = ?
|
||||||
|
""",
|
||||||
|
(self.get_counting_date(), self.camera_name, self.object_label),
|
||||||
|
)
|
||||||
|
row = cur.fetchone()
|
||||||
|
return int(row[0]) if row else 0
|
||||||
|
|
||||||
def display_left(self):
|
def display_counts(self):
|
||||||
return self._day_totals()[1]
|
return self._day_zone_counts()
|
||||||
|
|
||||||
def display_right(self):
|
|
||||||
return self._day_totals()[2]
|
|
||||||
|
|
||||||
def shutdown(self):
|
def shutdown(self):
|
||||||
self.shutdown_event.set()
|
self.shutdown_event.set()
|
||||||
|
|||||||
+29
-35
@@ -1,5 +1,5 @@
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
# ZenAI KTC — sack feeder zone counter (ByteTrack + left/right zones)
|
# ZenAI KTC — sack feeder zone counter (ByteTrack + N named zones)
|
||||||
# Shared config for: counter_live_rknn.py + counter_dashboard.py
|
# Shared config for: counter_live_rknn.py + counter_dashboard.py
|
||||||
# Copy to .env on device: cp env.example .env && nano .env
|
# Copy to .env on device: cp env.example .env && nano .env
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -44,31 +44,38 @@ OBJECT_LABEL=karung
|
|||||||
CLASS_OBJECT=karung
|
CLASS_OBJECT=karung
|
||||||
OBJECT_CLASS_ID=0
|
OBJECT_CLASS_ID=0
|
||||||
|
|
||||||
# Feeder names used in journal count logs:
|
|
||||||
# Total karung di kandang_bawah_feeder_kanan: 5
|
|
||||||
FEEDER_LEFT_NAME=kandang_bawah_feeder_kiri
|
|
||||||
FEEDER_RIGHT_NAME=kandang_bawah_feeder_kanan
|
|
||||||
|
|
||||||
# Verbose tracking debug (inherit / dup / cooldown / per-frame dets)
|
# Verbose tracking debug (inherit / dup / cooldown / per-frame dets)
|
||||||
DEBUG_TRACKING=false
|
DEBUG_TRACKING=false
|
||||||
|
|
||||||
# --- Feeder zones (left / right) ---
|
# --- Counting zones (1…N) ---
|
||||||
# Rectangular zones as fractions of frame width/height.
|
# ZONE_COUNT=1 uses only ZONE_1_*. Add ZONE_2_* (etc.) when ZONE_COUNT>=2.
|
||||||
# Absolute pixel overrides: ZONE_LEFT_X1, ZONE_LEFT_Y1, ZONE_LEFT_X2, ZONE_LEFT_Y2
|
# Absolute pixels (ZONE_i_X1…) win over fractions when set.
|
||||||
# (and the matching ZONE_RIGHT_*). Absolute values win when set.
|
# A sack is counted once after dwelling in a zone.
|
||||||
#
|
ZONE_COUNT=1
|
||||||
# A sack is counted once when its centroid ENTERS a zone.
|
ZONE_1_NAME=kandang_bawah_feeder
|
||||||
# LEFT zone → left feeder
|
ZONE_1_X1_FRAC=0.10
|
||||||
# RIGHT zone → right feeder
|
ZONE_1_Y1_FRAC=0.10
|
||||||
ZONE_LEFT_X1_FRAC=0.02
|
ZONE_1_X2_FRAC=0.90
|
||||||
ZONE_LEFT_Y1_FRAC=0.10
|
ZONE_1_Y2_FRAC=1.00
|
||||||
ZONE_LEFT_X2_FRAC=0.440
|
ZONE_1_COOLDOWN_SEC=15
|
||||||
ZONE_LEFT_Y2_FRAC=1.00
|
ZONE_1_DWELL_SEC=2
|
||||||
|
|
||||||
ZONE_RIGHT_X1_FRAC=0.490
|
# Example two-zone setup:
|
||||||
ZONE_RIGHT_Y1_FRAC=0.10
|
# ZONE_COUNT=2
|
||||||
ZONE_RIGHT_X2_FRAC=0.98
|
# ZONE_1_NAME=kandang_bawah_feeder_kiri
|
||||||
ZONE_RIGHT_Y2_FRAC=1.00
|
# ZONE_1_X1_FRAC=0.02
|
||||||
|
# ZONE_1_Y1_FRAC=0.10
|
||||||
|
# ZONE_1_X2_FRAC=0.440
|
||||||
|
# ZONE_1_Y2_FRAC=1.00
|
||||||
|
# ZONE_1_COOLDOWN_SEC=15
|
||||||
|
# ZONE_1_DWELL_SEC=2
|
||||||
|
# ZONE_2_NAME=kandang_bawah_feeder_kanan
|
||||||
|
# ZONE_2_X1_FRAC=0.490
|
||||||
|
# ZONE_2_Y1_FRAC=0.10
|
||||||
|
# ZONE_2_X2_FRAC=0.98
|
||||||
|
# ZONE_2_Y2_FRAC=1.00
|
||||||
|
# ZONE_2_COOLDOWN_SEC=15
|
||||||
|
# ZONE_2_DWELL_SEC=2
|
||||||
|
|
||||||
# --- Counting day management ---
|
# --- Counting day management ---
|
||||||
DAILY_CUTOFF_TIME=17:00
|
DAILY_CUTOFF_TIME=17:00
|
||||||
@@ -95,17 +102,6 @@ DEDUP_PX=60
|
|||||||
INHERIT_SEC=1.0
|
INHERIT_SEC=1.0
|
||||||
INHERIT_PX=60
|
INHERIT_PX=60
|
||||||
|
|
||||||
# --- Feeder cooldown (seconds) ---
|
|
||||||
# After a sack is counted on a feeder, ignore further counts on that same feeder
|
|
||||||
# for this many seconds. 0 = disabled.
|
|
||||||
ZONE_COOLDOWN_LEFT_SEC=15
|
|
||||||
ZONE_COOLDOWN_RIGHT_SEC=15
|
|
||||||
|
|
||||||
# --- Feeder dwell (seconds) ---
|
|
||||||
# Sack must stay inside the zone this long before count + snapshot. 0 = immediate.
|
|
||||||
ZONE_DWELL_LEFT_SEC=2
|
|
||||||
ZONE_DWELL_RIGHT_SEC=2
|
|
||||||
|
|
||||||
# --- Video recording ---
|
# --- Video recording ---
|
||||||
RECORD_VIDEO=false
|
RECORD_VIDEO=false
|
||||||
VIDEO_SEGMENT_SEC=3600
|
VIDEO_SEGMENT_SEC=3600
|
||||||
@@ -128,5 +124,3 @@ SAVE_CROSS_SNAPSHOT=true
|
|||||||
CROSS_SNAPSHOT_DIR=/opt/zenai-ktc-snaps/snapshots
|
CROSS_SNAPSHOT_DIR=/opt/zenai-ktc-snaps/snapshots
|
||||||
CROSS_SNAPSHOT_CLEANUP_SEC=3600
|
CROSS_SNAPSHOT_CLEANUP_SEC=3600
|
||||||
CROSS_SNAPSHOT_MAX_AGE_DAYS=7
|
CROSS_SNAPSHOT_MAX_AGE_DAYS=7
|
||||||
|
|
||||||
|
|
||||||
+115
-102
@@ -597,7 +597,7 @@
|
|||||||
<div class="logo-icon">◆</div>
|
<div class="logo-icon">◆</div>
|
||||||
<div>
|
<div>
|
||||||
<h1>ZenAI KTC Module</h1>
|
<h1>ZenAI KTC Module</h1>
|
||||||
<div class="header-sub">Sack feeder zone counter — YOLO RockChip + ByteTrack</div>
|
<div class="header-sub">Sack zone counter — YOLO RockChip + ByteTrack</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
@@ -621,32 +621,28 @@
|
|||||||
<span class="s-chip live-chip-tag"><span class="ldot"></span>LIVE</span>
|
<span class="s-chip live-chip-tag"><span class="ldot"></span>LIVE</span>
|
||||||
<div class="s-icon primary"><i class="fas fa-calendar-day"></i></div>
|
<div class="s-icon primary"><i class="fas fa-calendar-day"></i></div>
|
||||||
<div class="s-val" id="today-count">--</div>
|
<div class="s-val" id="today-count">--</div>
|
||||||
<div class="s-label">Total Today (Left + Right)</div>
|
<div class="s-label">Total Today</div>
|
||||||
<div class="sub-cards">
|
<div class="sub-cards" id="today-zone-cards">
|
||||||
<div class="sub-card left">
|
{% for z in zones %}
|
||||||
<div class="sc-head"><i class="fas fa-arrow-left"></i> Left</div>
|
<div class="sub-card">
|
||||||
<div class="sc-val" id="today-left">--</div>
|
<div class="sc-head"><i class="fas fa-vector-square"></i> {{ z.name }}</div>
|
||||||
</div>
|
<div class="sc-val" id="today-{{ z.id }}">--</div>
|
||||||
<div class="sub-card right">
|
|
||||||
<div class="sc-head"><i class="fas fa-arrow-right"></i> Right</div>
|
|
||||||
<div class="sc-val" id="today-right">--</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="s-card">
|
<div class="s-card">
|
||||||
<span class="s-chip info">Yesterday</span>
|
<span class="s-chip info">Yesterday</span>
|
||||||
<div class="s-icon warm"><i class="fas fa-history"></i></div>
|
<div class="s-icon warm"><i class="fas fa-history"></i></div>
|
||||||
<div class="s-val" id="yesterday-count">--</div>
|
<div class="s-val" id="yesterday-count">--</div>
|
||||||
<div class="s-label">Total Yesterday (Left + Right)</div>
|
<div class="s-label">Total Yesterday</div>
|
||||||
<div class="sub-cards">
|
<div class="sub-cards" id="yesterday-zone-cards">
|
||||||
<div class="sub-card left">
|
{% for z in zones %}
|
||||||
<div class="sc-head"><i class="fas fa-arrow-left"></i> Left</div>
|
<div class="sub-card">
|
||||||
<div class="sc-val" id="yesterday-left">--</div>
|
<div class="sc-head"><i class="fas fa-vector-square"></i> {{ z.name }}</div>
|
||||||
</div>
|
<div class="sc-val" id="yesterday-{{ z.id }}">--</div>
|
||||||
<div class="sub-card right">
|
|
||||||
<div class="sc-head"><i class="fas fa-arrow-right"></i> Right</div>
|
|
||||||
<div class="sc-val" id="yesterday-right">--</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -654,10 +650,6 @@
|
|||||||
<!-- Hidden (kept for JS refs) -->
|
<!-- Hidden (kept for JS refs) -->
|
||||||
<div class="hide-me">
|
<div class="hide-me">
|
||||||
<span id="live-count">--</span>
|
<span id="live-count">--</span>
|
||||||
<span id="live-left">--</span>
|
|
||||||
<span id="live-right">--</span>
|
|
||||||
<span id="live-last-detection-left">--</span>
|
|
||||||
<span id="live-last-detection-right">--</span>
|
|
||||||
<span id="today-total">--</span>
|
<span id="today-total">--</span>
|
||||||
<span id="yesterday-total">--</span>
|
<span id="yesterday-total">--</span>
|
||||||
<span id="avg-count">--</span>
|
<span id="avg-count">--</span>
|
||||||
@@ -710,37 +702,29 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<span class="section-title">◉ Quick Stats</span>
|
<span class="section-title">◉ Quick Stats</span>
|
||||||
<div style="margin-top:16px;">
|
<div style="margin-top:16px;" id="quick-stats">
|
||||||
<div class="quick-stat">
|
<div class="quick-stat">
|
||||||
<div class="qs-left">
|
<div class="qs-left">
|
||||||
<div class="qs-icon primary"><i class="fas fa-layer-group"></i></div>
|
<div class="qs-icon primary"><i class="fas fa-layer-group"></i></div>
|
||||||
<div>
|
<div>
|
||||||
<div class="qs-label">Grand Total</div>
|
<div class="qs-label">Grand Total</div>
|
||||||
<div class="qs-sub">All time (Left + Right)</div>
|
<div class="qs-sub">All time</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span class="qs-val" id="grand-total">--</span>
|
<span class="qs-val" id="grand-total">--</span>
|
||||||
</div>
|
</div>
|
||||||
|
{% for z in zones %}
|
||||||
<div class="quick-stat">
|
<div class="quick-stat">
|
||||||
<div class="qs-left">
|
<div class="qs-left">
|
||||||
<div class="qs-icon success"><i class="fas fa-arrow-right-to-bracket"></i></div>
|
<div class="qs-icon success"><i class="fas fa-vector-square"></i></div>
|
||||||
<div>
|
<div>
|
||||||
<div class="qs-label">Total Left</div>
|
<div class="qs-label">{{ z.name }}</div>
|
||||||
<div class="qs-sub">All time</div>
|
<div class="qs-sub">All time</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span class="qs-val" id="grand-left">--</span>
|
<span class="qs-val" id="grand-{{ z.id }}">--</span>
|
||||||
</div>
|
|
||||||
<div class="quick-stat">
|
|
||||||
<div class="qs-left">
|
|
||||||
<div class="qs-icon warm"><i class="fas fa-arrow-right-from-bracket"></i></div>
|
|
||||||
<div>
|
|
||||||
<div class="qs-label">Total Right</div>
|
|
||||||
<div class="qs-sub">All time</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span class="qs-val" id="grand-right">--</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="recent-section">
|
<div class="recent-section">
|
||||||
@@ -772,8 +756,9 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th style="padding-left:4px;">Date</th>
|
<th style="padding-left:4px;">Date</th>
|
||||||
<th>Total</th>
|
<th>Total</th>
|
||||||
<th>Left</th>
|
{% for z in zones %}
|
||||||
<th>Right</th>
|
<th>{{ z.name }}</th>
|
||||||
|
{% endfor %}
|
||||||
<th style="padding-right:4px;">Status</th>
|
<th style="padding-right:4px;">Status</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -788,6 +773,15 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
const ZONES = {{ zones | tojson }};
|
||||||
|
const ZONE_COLORS = [
|
||||||
|
['rgba(0, 255, 136, 0.7)', 'rgba(0, 255, 136, 1)'],
|
||||||
|
['rgba(255, 45, 120, 0.7)', 'rgba(255, 45, 120, 1)'],
|
||||||
|
['rgba(0, 180, 255, 0.7)', 'rgba(0, 180, 255, 1)'],
|
||||||
|
['rgba(255, 180, 0, 0.7)', 'rgba(255, 180, 0, 1)'],
|
||||||
|
['rgba(180, 100, 255, 0.7)', 'rgba(180, 100, 255, 1)'],
|
||||||
|
];
|
||||||
|
|
||||||
// --- Theme ---
|
// --- Theme ---
|
||||||
(function() {
|
(function() {
|
||||||
const saved = localStorage.getItem('theme');
|
const saved = localStorage.getItem('theme');
|
||||||
@@ -888,16 +882,19 @@ async function loadCurrentCounter() {
|
|||||||
const res = await fetch('/api/current-counter');
|
const res = await fetch('/api/current-counter');
|
||||||
if (!res.ok) throw new Error('No active counter');
|
if (!res.ok) throw new Error('No active counter');
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
const counts = data.counts || {};
|
||||||
const tleft = data.count_left || 0;
|
const total = data.count != null ? data.count : Object.values(counts).reduce((a, b) => a + (b || 0), 0);
|
||||||
const tright = data.count_right || 0;
|
document.getElementById('today-count').textContent = total.toLocaleString();
|
||||||
document.getElementById('today-count').textContent = (tleft + tright).toLocaleString();
|
ZONES.forEach(z => {
|
||||||
document.getElementById('today-left').textContent = tleft.toLocaleString();
|
const el = document.getElementById('today-' + z.id);
|
||||||
document.getElementById('today-right').textContent = tright.toLocaleString();
|
if (el) el.textContent = (counts[z.id] || 0).toLocaleString();
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
document.getElementById('today-count').textContent = '0';
|
document.getElementById('today-count').textContent = '0';
|
||||||
document.getElementById('today-left').textContent = '0';
|
ZONES.forEach(z => {
|
||||||
document.getElementById('today-right').textContent = '0';
|
const el = document.getElementById('today-' + z.id);
|
||||||
|
if (el) el.textContent = '0';
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -908,9 +905,16 @@ async function loadSummary() {
|
|||||||
|
|
||||||
document.getElementById('current-date').textContent = data.today.date;
|
document.getElementById('current-date').textContent = data.today.date;
|
||||||
|
|
||||||
document.getElementById('yesterday-count').textContent = ((data.yesterday.total_left || 0) + (data.yesterday.total_right || 0)).toLocaleString();
|
const yCounts = (data.yesterday && data.yesterday.counts) || {};
|
||||||
document.getElementById('yesterday-left').textContent = (data.yesterday.total_left || 0).toLocaleString();
|
document.getElementById('yesterday-count').textContent = (
|
||||||
document.getElementById('yesterday-right').textContent = (data.yesterday.total_right || 0).toLocaleString();
|
data.yesterday.total_count != null
|
||||||
|
? data.yesterday.total_count
|
||||||
|
: Object.values(yCounts).reduce((a, b) => a + (b || 0), 0)
|
||||||
|
).toLocaleString();
|
||||||
|
ZONES.forEach(z => {
|
||||||
|
const el = document.getElementById('yesterday-' + z.id);
|
||||||
|
if (el) el.textContent = (yCounts[z.id] || 0).toLocaleString();
|
||||||
|
});
|
||||||
|
|
||||||
document.getElementById('avg-count').textContent = Number(data.average_per_day).toLocaleString();
|
document.getElementById('avg-count').textContent = Number(data.average_per_day).toLocaleString();
|
||||||
document.getElementById('total-days').textContent = `${data.all_time.total_days} days recorded`;
|
document.getElementById('total-days').textContent = `${data.all_time.total_days} days recorded`;
|
||||||
@@ -918,9 +922,12 @@ async function loadSummary() {
|
|||||||
document.getElementById('best-count').textContent = data.best_day.count.toLocaleString();
|
document.getElementById('best-count').textContent = data.best_day.count.toLocaleString();
|
||||||
document.getElementById('best-date').textContent = data.best_day.date || '--';
|
document.getElementById('best-date').textContent = data.best_day.date || '--';
|
||||||
|
|
||||||
document.getElementById('grand-total').textContent = ((data.all_time.grand_left || 0) + (data.all_time.grand_right || 0)).toLocaleString();
|
const gCounts = (data.all_time && data.all_time.counts) || {};
|
||||||
document.getElementById('grand-left').textContent = (data.all_time.grand_left || 0).toLocaleString();
|
document.getElementById('grand-total').textContent = (data.all_time.grand_total || 0).toLocaleString();
|
||||||
document.getElementById('grand-right').textContent = (data.all_time.grand_right || 0).toLocaleString();
|
ZONES.forEach(z => {
|
||||||
|
const el = document.getElementById('grand-' + z.id);
|
||||||
|
if (el) el.textContent = (gCounts[z.id] || 0).toLocaleString();
|
||||||
|
});
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load summary:', err);
|
console.error('Failed to load summary:', err);
|
||||||
@@ -935,15 +942,17 @@ async function loadChartData(days) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/daily-data?days=${days}`);
|
const res = await fetch(`/api/daily-data?days=${days}`);
|
||||||
const data = await res.json();
|
const payload = await res.json();
|
||||||
|
const data = Array.isArray(payload) ? payload : (payload.data || []);
|
||||||
|
const zones = (payload.zones && payload.zones.length) ? payload.zones : ZONES;
|
||||||
|
|
||||||
const labels = data.map(d => {
|
const labels = data.map(d => {
|
||||||
const date = new Date(d.date);
|
const date = new Date(d.date);
|
||||||
return date.toLocaleDateString('en-US', { weekday: 'short', day: 'numeric', month: 'short' });
|
return date.toLocaleDateString('en-US', { weekday: 'short', day: 'numeric', month: 'short' });
|
||||||
});
|
});
|
||||||
const counts = data.map(d => (d.total_left || 0) + (d.total_right || 0));
|
const totals = data.map(d => d.total_count != null
|
||||||
const lefts = data.map(d => d.total_left || 0);
|
? d.total_count
|
||||||
const rights = data.map(d => d.total_right || 0);
|
: Object.values(d.counts || {}).reduce((a, b) => a + (b || 0), 0));
|
||||||
|
|
||||||
if (mainChart) mainChart.destroy();
|
if (mainChart) mainChart.destroy();
|
||||||
|
|
||||||
@@ -953,42 +962,34 @@ async function loadChartData(days) {
|
|||||||
const gridColor = isDark ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)';
|
const gridColor = isDark ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.05)';
|
||||||
const textColor = isDark ? '#6b7394' : '#5a6088';
|
const textColor = isDark ? '#6b7394' : '#5a6088';
|
||||||
|
|
||||||
|
const datasets = zones.map((z, idx) => {
|
||||||
|
const colors = ZONE_COLORS[idx % ZONE_COLORS.length];
|
||||||
|
return {
|
||||||
|
label: z.name,
|
||||||
|
data: data.map(d => (d.counts && d.counts[z.id]) || 0),
|
||||||
|
backgroundColor: colors[0],
|
||||||
|
borderColor: colors[1],
|
||||||
|
borderWidth: 2,
|
||||||
|
borderRadius: 6,
|
||||||
|
yAxisID: 'y'
|
||||||
|
};
|
||||||
|
});
|
||||||
|
datasets.push({
|
||||||
|
label: 'Total',
|
||||||
|
data: totals,
|
||||||
|
type: 'line',
|
||||||
|
borderColor: 'rgba(0, 240, 255, 0.9)',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
borderWidth: 2,
|
||||||
|
pointRadius: 3,
|
||||||
|
yAxisID: 'y'
|
||||||
|
});
|
||||||
|
|
||||||
mainChart = new Chart(ctx, {
|
mainChart = new Chart(ctx, {
|
||||||
type: 'bar',
|
type: 'bar',
|
||||||
data: {
|
data: {
|
||||||
labels: labels,
|
labels: labels,
|
||||||
datasets: [
|
datasets: datasets
|
||||||
{
|
|
||||||
label: 'Left',
|
|
||||||
data: lefts,
|
|
||||||
backgroundColor: 'rgba(0, 255, 136, 0.7)',
|
|
||||||
borderColor: 'rgba(0, 255, 136, 1)',
|
|
||||||
borderWidth: 2,
|
|
||||||
borderRadius: 6,
|
|
||||||
yAxisID: 'y'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Right',
|
|
||||||
data: rights,
|
|
||||||
backgroundColor: 'rgba(255, 45, 120, 0.7)',
|
|
||||||
borderColor: 'rgba(255, 45, 120, 1)',
|
|
||||||
borderWidth: 2,
|
|
||||||
borderRadius: 6,
|
|
||||||
yAxisID: 'y'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Total',
|
|
||||||
data: counts,
|
|
||||||
type: 'line',
|
|
||||||
borderColor: 'rgba(0, 240, 255, 1)',
|
|
||||||
backgroundColor: 'rgba(0, 240, 255, 0.05)',
|
|
||||||
borderWidth: 3,
|
|
||||||
pointRadius: 4,
|
|
||||||
pointBackgroundColor: 'rgba(0, 240, 255, 1)',
|
|
||||||
tension: 0.4,
|
|
||||||
yAxisID: 'y'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
options: {
|
options: {
|
||||||
responsive: true,
|
responsive: true,
|
||||||
@@ -1031,27 +1032,32 @@ async function loadChartData(days) {
|
|||||||
async function loadRecentDays() {
|
async function loadRecentDays() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/available-dates');
|
const res = await fetch('/api/available-dates');
|
||||||
const days = await res.json();
|
const payload = await res.json();
|
||||||
|
const days = Array.isArray(payload) ? payload : (payload.data || []);
|
||||||
|
|
||||||
const container = document.getElementById('recent-days');
|
const container = document.getElementById('recent-days');
|
||||||
if (!days || days.length === 0) {
|
if (!days || days.length === 0) {
|
||||||
container.innerHTML = '<div class="recent-item" style="justify-content:center;color:var(--text-secondary);font-size:10px;">No records yet</div>';
|
container.innerHTML = '<div class="recent-item" style="justify-content:center;color:var(--text-secondary);font-size:10px;">No records yet</div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
container.innerHTML = days.slice(0, 5).map(d => `
|
container.innerHTML = days.slice(0, 5).map(d => {
|
||||||
|
const counts = d.counts || {};
|
||||||
|
const parts = ZONES.map(z => `${z.name} ${(counts[z.id] || 0).toLocaleString()}`).join(' · ');
|
||||||
|
const total = d.total_count != null ? d.total_count : Object.values(counts).reduce((a, b) => a + (b || 0), 0);
|
||||||
|
return `
|
||||||
<div class="recent-item">
|
<div class="recent-item">
|
||||||
<div class="ri-left">
|
<div class="ri-left">
|
||||||
<div class="ri-badge"><i class="fas fa-calendar"></i></div>
|
<div class="ri-badge"><i class="fas fa-calendar"></i></div>
|
||||||
<div>
|
<div>
|
||||||
<div class="ri-date">${d.date}</div>
|
<div class="ri-date">${d.date}</div>
|
||||||
<div class="ri-time">LEFT ${(d.total_left || 0).toLocaleString()} · RIGHT ${(d.total_right || 0).toLocaleString()}</div>
|
<div class="ri-time">${parts}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="text-align:right;">
|
<div style="text-align:right;">
|
||||||
<div class="ri-count">${((d.total_left || 0) + (d.total_right || 0)).toLocaleString()}</div>
|
<div class="ri-count">${total.toLocaleString()}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>`;
|
||||||
`).join('');
|
}).join('');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load recent days:', err);
|
console.error('Failed to load recent days:', err);
|
||||||
}
|
}
|
||||||
@@ -1060,7 +1066,8 @@ async function loadRecentDays() {
|
|||||||
async function loadAllDates() {
|
async function loadAllDates() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/available-dates');
|
const res = await fetch('/api/available-dates');
|
||||||
const dates = await res.json();
|
const payload = await res.json();
|
||||||
|
const dates = Array.isArray(payload) ? payload : (payload.data || []);
|
||||||
renderDailyTable(dates);
|
renderDailyTable(dates);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load dates:', err);
|
console.error('Failed to load dates:', err);
|
||||||
@@ -1075,11 +1082,12 @@ function resetFilter() {
|
|||||||
function renderDailyTable(dates) {
|
function renderDailyTable(dates) {
|
||||||
const tbody = document.getElementById('daily-table-body');
|
const tbody = document.getElementById('daily-table-body');
|
||||||
const currentDate = document.getElementById('current-date').textContent;
|
const currentDate = document.getElementById('current-date').textContent;
|
||||||
|
const colCount = 3 + ZONES.length;
|
||||||
|
|
||||||
if (dates.length === 0) {
|
if (dates.length === 0) {
|
||||||
tbody.innerHTML = `
|
tbody.innerHTML = `
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="5" style="text-align:center;padding:32px;color:var(--text-secondary);">
|
<td colspan="${colCount}" style="text-align:center;padding:32px;color:var(--text-secondary);">
|
||||||
<div style="font-size:32px;opacity:0.2;margin-bottom:8px;">□</div>
|
<div style="font-size:32px;opacity:0.2;margin-bottom:8px;">□</div>
|
||||||
<div style="font-size:10px;letter-spacing:1px;">NO DATA AVAILABLE</div>
|
<div style="font-size:10px;letter-spacing:1px;">NO DATA AVAILABLE</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -1089,6 +1097,11 @@ function renderDailyTable(dates) {
|
|||||||
|
|
||||||
tbody.innerHTML = dates.map(d => {
|
tbody.innerHTML = dates.map(d => {
|
||||||
const isToday = d.date === currentDate;
|
const isToday = d.date === currentDate;
|
||||||
|
const counts = d.counts || {};
|
||||||
|
const total = d.total_count != null ? d.total_count : Object.values(counts).reduce((a, b) => a + (b || 0), 0);
|
||||||
|
const zoneCells = ZONES.map(z =>
|
||||||
|
`<td><span class="badge badge-green">${(counts[z.id] || 0).toLocaleString()}</span></td>`
|
||||||
|
).join('');
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<tr>
|
<tr>
|
||||||
@@ -1101,9 +1114,8 @@ function renderDailyTable(dates) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td><span style="font-family:'Orbitron',sans-serif;font-size:15px;font-weight:700;">${((d.total_left || 0) + (d.total_right || 0)).toLocaleString()}</span></td>
|
<td><span style="font-family:'Orbitron',sans-serif;font-size:15px;font-weight:700;">${total.toLocaleString()}</span></td>
|
||||||
<td><span class="badge badge-green">${d.total_left.toLocaleString()}</span></td>
|
${zoneCells}
|
||||||
<td><span class="badge badge-gray">${d.total_right.toLocaleString()}</span></td>
|
|
||||||
<td style="padding-right:4px;">
|
<td style="padding-right:4px;">
|
||||||
<span class="badge ${isToday ? 'badge-green' : 'badge-gray'}">
|
<span class="badge ${isToday ? 'badge-green' : 'badge-gray'}">
|
||||||
${isToday ? '<span style="width:5px;height:5px;border-radius:50%;background:var(--accent4);display:inline-block;margin-right:5px;animation:blink 1.5s ease-in-out infinite;"></span>Active' : 'Completed'}
|
${isToday ? '<span style="width:5px;height:5px;border-radius:50%;background:var(--accent4);display:inline-block;margin-right:5px;animation:blink 1.5s ease-in-out infinite;"></span>Active' : 'Completed'}
|
||||||
@@ -1119,7 +1131,8 @@ function filterByDate() {
|
|||||||
|
|
||||||
fetch('/api/available-dates')
|
fetch('/api/available-dates')
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(dates => {
|
.then(payload => {
|
||||||
|
const dates = Array.isArray(payload) ? payload : (payload.data || []);
|
||||||
const filtered = dates.filter(d => d.date === date);
|
const filtered = dates.filter(d => d.date === date);
|
||||||
renderDailyTable(filtered);
|
renderDailyTable(filtered);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -207,6 +207,8 @@
|
|||||||
}
|
}
|
||||||
.snap-kind.left { background: rgba(0,255,136,0.12); color: var(--accent4); }
|
.snap-kind.left { background: rgba(0,255,136,0.12); color: var(--accent4); }
|
||||||
.snap-kind.right { background: rgba(255,45,120,0.12); color: var(--accent3); }
|
.snap-kind.right { background: rgba(255,45,120,0.12); color: var(--accent3); }
|
||||||
|
.snap-kind.zone_1 { background: rgba(0,255,136,0.12); color: var(--accent4); }
|
||||||
|
.snap-kind.zone_2 { background: rgba(255,45,120,0.12); color: var(--accent3); }
|
||||||
.snap-kind.detect { background: rgba(0,240,255,0.10); color: var(--accent); }
|
.snap-kind.detect { background: rgba(0,240,255,0.10); color: var(--accent); }
|
||||||
.snap-tid {
|
.snap-tid {
|
||||||
font-family: 'Orbitron', sans-serif; font-size: 11px; font-weight: 700; color: var(--text-primary);
|
font-family: 'Orbitron', sans-serif; font-size: 11px; font-weight: 700; color: var(--text-primary);
|
||||||
@@ -300,8 +302,9 @@
|
|||||||
<div class="toolbar-group">
|
<div class="toolbar-group">
|
||||||
<div class="filters" id="snap-filters">
|
<div class="filters" id="snap-filters">
|
||||||
<button class="active" data-kind="all" onclick="loadSnapshots('all')">ALL</button>
|
<button class="active" data-kind="all" onclick="loadSnapshots('all')">ALL</button>
|
||||||
<button data-kind="left" onclick="loadSnapshots('left')">LEFT</button>
|
{% for z in zones %}
|
||||||
<button data-kind="right" onclick="loadSnapshots('right')">RIGHT</button>
|
<button data-kind="{{ z.id }}" onclick="loadSnapshots('{{ z.id }}')">{{ z.name | upper }}</button>
|
||||||
|
{% endfor %}
|
||||||
{% if show_detect %}<button data-kind="detect" onclick="loadSnapshots('detect')">DETECT</button>{% endif %}
|
{% if show_detect %}<button data-kind="detect" onclick="loadSnapshots('detect')">DETECT</button>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<input type="number" id="snap-tid-filter" placeholder="Track ID" min="0"
|
<input type="number" id="snap-tid-filter" placeholder="Track ID" min="0"
|
||||||
|
|||||||
+147
@@ -0,0 +1,147 @@
|
|||||||
|
"""Load N named counting zones from environment variables.
|
||||||
|
|
||||||
|
Env:
|
||||||
|
ZONE_COUNT (default 1)
|
||||||
|
ZONE_i_NAME
|
||||||
|
ZONE_i_X1..Y2 (optional absolute pixels)
|
||||||
|
ZONE_i_X1_FRAC..Y2_FRAC
|
||||||
|
ZONE_i_COOLDOWN_SEC, ZONE_i_DWELL_SEC
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Mapping, Optional, Sequence
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ZoneDef:
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
x1: Optional[int]
|
||||||
|
y1: Optional[int]
|
||||||
|
x2: Optional[int]
|
||||||
|
y2: Optional[int]
|
||||||
|
x1_frac: float
|
||||||
|
y1_frac: float
|
||||||
|
x2_frac: float
|
||||||
|
y2_frac: float
|
||||||
|
cooldown_sec: float
|
||||||
|
dwell_sec: float
|
||||||
|
|
||||||
|
def resolve_rect(self, frame_width: int, frame_height: int):
|
||||||
|
def coord(abs_val, frac, span):
|
||||||
|
if abs_val is not None:
|
||||||
|
return int(abs_val)
|
||||||
|
return int(span * frac)
|
||||||
|
|
||||||
|
x1 = coord(self.x1, self.x1_frac, frame_width)
|
||||||
|
y1 = coord(self.y1, self.y1_frac, frame_height)
|
||||||
|
x2 = coord(self.x2, self.x2_frac, frame_width)
|
||||||
|
y2 = coord(self.y2, self.y2_frac, frame_height)
|
||||||
|
return (min(x1, x2), min(y1, y2), max(x1, x2), max(y1, y2))
|
||||||
|
|
||||||
|
|
||||||
|
def _env_int(environ: Mapping[str, str], key: str) -> Optional[int]:
|
||||||
|
raw = environ.get(key)
|
||||||
|
if raw is None or str(raw).strip() == "":
|
||||||
|
return None
|
||||||
|
return int(raw)
|
||||||
|
|
||||||
|
|
||||||
|
def _env_float(environ: Mapping[str, str], key: str, default: float) -> float:
|
||||||
|
raw = environ.get(key)
|
||||||
|
if raw is None or str(raw).strip() == "":
|
||||||
|
return float(default)
|
||||||
|
return float(raw)
|
||||||
|
|
||||||
|
|
||||||
|
def load_zones(
|
||||||
|
environ: Optional[Mapping[str, str]] = None,
|
||||||
|
camera_name: Optional[str] = None,
|
||||||
|
) -> list[ZoneDef]:
|
||||||
|
"""Parse ZONE_COUNT + ZONE_i_* into ZoneDef list. Fail fast on invalid config."""
|
||||||
|
env = environ if environ is not None else os.environ
|
||||||
|
cam = camera_name if camera_name is not None else env.get("CAMERA_NAME", "camera")
|
||||||
|
|
||||||
|
raw_count = env.get("ZONE_COUNT", "1")
|
||||||
|
try:
|
||||||
|
zone_count = int(raw_count)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise ValueError(f"ZONE_COUNT must be an integer >= 1, got {raw_count!r}") from exc
|
||||||
|
if zone_count < 1:
|
||||||
|
raise ValueError(f"ZONE_COUNT must be >= 1, got {zone_count}")
|
||||||
|
|
||||||
|
# Default geometry: single zone covers center-ish; multi-zone splits horizontally.
|
||||||
|
defaults_by_count = {
|
||||||
|
1: {
|
||||||
|
1: (0.10, 0.10, 0.90, 1.00),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
zones: list[ZoneDef] = []
|
||||||
|
for i in range(1, zone_count + 1):
|
||||||
|
prefix = f"ZONE_{i}_"
|
||||||
|
zone_id = f"zone_{i}"
|
||||||
|
name = env.get(f"{prefix}NAME") or f"{cam}_zone_{i}"
|
||||||
|
|
||||||
|
if zone_count == 1:
|
||||||
|
dx1, dy1, dx2, dy2 = defaults_by_count[1][1]
|
||||||
|
else:
|
||||||
|
# Even horizontal slices across the frame.
|
||||||
|
slice_w = 1.0 / zone_count
|
||||||
|
gap = 0.02
|
||||||
|
dx1 = (i - 1) * slice_w + (gap if i > 1 else 0.0)
|
||||||
|
dx2 = i * slice_w - (gap if i < zone_count else 0.0)
|
||||||
|
dy1, dy2 = 0.10, 1.00
|
||||||
|
|
||||||
|
# Require at least frac defaults present logically — always have defaults.
|
||||||
|
x1_frac = _env_float(env, f"{prefix}X1_FRAC", dx1)
|
||||||
|
y1_frac = _env_float(env, f"{prefix}Y1_FRAC", dy1)
|
||||||
|
x2_frac = _env_float(env, f"{prefix}X2_FRAC", dx2)
|
||||||
|
y2_frac = _env_float(env, f"{prefix}Y2_FRAC", dy2)
|
||||||
|
|
||||||
|
if not (0.0 <= x1_frac <= 1.0 and 0.0 <= x2_frac <= 1.0):
|
||||||
|
raise ValueError(
|
||||||
|
f"{prefix}X*_FRAC must be in [0,1], got x1={x1_frac} x2={x2_frac}"
|
||||||
|
)
|
||||||
|
if not (0.0 <= y1_frac <= 1.0 and 0.0 <= y2_frac <= 1.0):
|
||||||
|
raise ValueError(
|
||||||
|
f"{prefix}Y*_FRAC must be in [0,1], got y1={y1_frac} y2={y2_frac}"
|
||||||
|
)
|
||||||
|
if x1_frac == x2_frac and _env_int(env, f"{prefix}X1") is None:
|
||||||
|
raise ValueError(f"{prefix} geometry has zero width (X1_FRAC == X2_FRAC)")
|
||||||
|
if y1_frac == y2_frac and _env_int(env, f"{prefix}Y1") is None:
|
||||||
|
raise ValueError(f"{prefix} geometry has zero height (Y1_FRAC == Y2_FRAC)")
|
||||||
|
|
||||||
|
zones.append(
|
||||||
|
ZoneDef(
|
||||||
|
id=zone_id,
|
||||||
|
name=name,
|
||||||
|
x1=_env_int(env, f"{prefix}X1"),
|
||||||
|
y1=_env_int(env, f"{prefix}Y1"),
|
||||||
|
x2=_env_int(env, f"{prefix}X2"),
|
||||||
|
y2=_env_int(env, f"{prefix}Y2"),
|
||||||
|
x1_frac=x1_frac,
|
||||||
|
y1_frac=y1_frac,
|
||||||
|
x2_frac=x2_frac,
|
||||||
|
y2_frac=y2_frac,
|
||||||
|
cooldown_sec=_env_float(env, f"{prefix}COOLDOWN_SEC", 0.0),
|
||||||
|
dwell_sec=_env_float(env, f"{prefix}DWELL_SEC", 2.0),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return zones
|
||||||
|
|
||||||
|
|
||||||
|
def zone_ids(zones: Sequence[ZoneDef]) -> list[str]:
|
||||||
|
return [z.id for z in zones]
|
||||||
|
|
||||||
|
|
||||||
|
def zone_meta(zones: Sequence[ZoneDef]) -> list[dict]:
|
||||||
|
"""Dashboard/API-friendly [{id, name}, ...]."""
|
||||||
|
return [{"id": z.id, "name": z.name} for z in zones]
|
||||||
|
|
||||||
|
|
||||||
|
def zones_by_id(zones: Sequence[ZoneDef]) -> dict[str, ZoneDef]:
|
||||||
|
return {z.id: z for z in zones}
|
||||||
Reference in new issue
Block a user