update ktc to use zone_n instead of left and right for production

This commit is contained in:
Alberto-Audrix committed 2026-08-27 15:44:10 +07:00
1 parent ea88e92c99
commit 5b4d9d56d4
9 files changed
+871 -503

No files matched your search

+21 -22
View File
@@ -1,7 +1,7 @@
# ZenAI KTC Counter — Edge Deployment Guide
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 |
|-----------|--------|--------------|
@@ -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.
**Counting model:** two rectangular zones (left + right). A sack is counted once when its
centroid **enters** a zone. Left zone → left feeder; right zone → right feeder.
**Counting model:** `ZONE_COUNT` rectangular zones (`zone_1`…`zone_N`). A sack is counted once
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 |
| `OBJECT_CLASS_ID` | Class index of the object being counted |
| `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 |
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
ZONE_LEFT_X1_FRAC=0.00
ZONE_LEFT_Y1_FRAC=0.20
ZONE_LEFT_X2_FRAC=0.35
ZONE_LEFT_Y2_FRAC=0.85
ZONE_RIGHT_X1_FRAC=0.65
ZONE_RIGHT_Y1_FRAC=0.20
ZONE_RIGHT_X2_FRAC=1.00
ZONE_RIGHT_Y2_FRAC=0.85
ZONE_COUNT=1
ZONE_1_NAME=feeder
ZONE_1_X1_FRAC=0.10
ZONE_1_Y1_FRAC=0.10
ZONE_1_X2_FRAC=0.90
ZONE_1_Y2_FRAC=1.00
ZONE_1_COOLDOWN_SEC=15
ZONE_1_DWELL_SEC=2
```
For pixel-perfect placement, set absolute coordinates instead (they override fractions):
```ini
ZONE_LEFT_X1=40
ZONE_LEFT_Y1=120
ZONE_LEFT_X2=420
ZONE_LEFT_Y2=900
ZONE_1_X1=40
ZONE_1_Y1=120
ZONE_1_X2=420
ZONE_1_Y2=900
```
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:
```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` |
| 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 |
| 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 |
### Manual test (without systemd)
+40 -44
View File
@@ -77,22 +77,10 @@ INHERIT_SEC=1.0
INHERIT_PX=60
# To DISABLE the inheritance guard, set INHERIT_PX=-1 (distance check can never match).
# --- Feeder cooldown (seconds) ---
# 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
# Per-zone cooldown / dwell are set on ZONE_i_COOLDOWN_SEC and ZONE_i_DWELL_SEC below.
# --- Display ---
# Site name shown on the dashboard header (top-right)
# Site name shown on the dashboard header
SITE_NAME=ZenAi
# --- Object class names ---
@@ -104,41 +92,49 @@ OBJECT_LABEL=karung
CLASS_OBJECT=karung
# Model class ID for the object being counted (default 0)
OBJECT_CLASS_ID=0
# Feeder names printed on each count, e.g.
# "Total karung di kandang_bawah_feeder_kanan: 5"
FEEDER_LEFT_NAME=kandang_bawah_feeder_kiri
FEEDER_RIGHT_NAME=kandang_bawah_feeder_kanan
# Feeder / zone display names come from ZONE_i_NAME (see zones below).
# Verbose tracking debug (inherit / dup / cooldown / per-frame dets)
DEBUG_TRACKING=false
# --- Feeder zones (left / right) ---
# Rectangular zones mark the left and right sack feeders.
# Count once when a track centroid ENTERS a zone (outside → inside).
# Absolute pixel overrides (ZONE_*_X1/Y1/X2/Y2) win over fractions when set.
#
# Left feeder zone (default: left third of the frame)
ZONE_LEFT_X1=
ZONE_LEFT_Y1=
ZONE_LEFT_X2=
ZONE_LEFT_Y2=
ZONE_LEFT_X1_FRAC=0.02
ZONE_LEFT_Y1_FRAC=0.10
ZONE_LEFT_X2_FRAC=0.440
ZONE_LEFT_Y2_FRAC=1.00
#
# Right feeder zone (full right column)
ZONE_RIGHT_X1=
ZONE_RIGHT_Y1=
ZONE_RIGHT_X2=
ZONE_RIGHT_Y2=
ZONE_RIGHT_X1_FRAC=0.490
ZONE_RIGHT_Y1_FRAC=0.10
ZONE_RIGHT_X2_FRAC=0.98
ZONE_RIGHT_Y2_FRAC=1.00
# --- Counting zones (1…N) ---
# Rectangular zones. Count once after dwell. Absolute pixels win over fracs.
ZONE_COUNT=1
ZONE_1_NAME=kandang_bawah_feeder
ZONE_1_X1=
ZONE_1_Y1=
ZONE_1_X2=
ZONE_1_Y2=
ZONE_1_X1_FRAC=0.10
ZONE_1_Y1_FRAC=0.10
ZONE_1_X2_FRAC=0.90
ZONE_1_Y2_FRAC=1.00
ZONE_1_COOLDOWN_SEC=3
ZONE_1_DWELL_SEC=2
# Example two-zone setup (uncomment + set ZONE_COUNT=2):
# ZONE_COUNT=2
# ZONE_1_NAME=kandang_bawah_feeder_kiri
# 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=3
# 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 ---
# 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.
DAILY_CUTOFF_TIME=17:00
CUTOFF_TIME=17:00
@@ -152,7 +148,7 @@ CROSS_CSV=/opt/batch-counter/crossings.csv
# --- Crossing snapshots ---
# 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/
# (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
# 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
+214 -51
View File
@@ -2,7 +2,7 @@
"""
Edge production counter dashboard.
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.
"""
@@ -24,6 +24,8 @@ from werkzeug.serving import WSGIRequestHandler
from dotenv import load_dotenv
load_dotenv()
from zones_config import load_zones, zone_meta
app = Flask(__name__, template_folder="templates")
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"
SITE_NAME = os.getenv("SITE_NAME", "LIVE")
CAMERA_NAME = os.getenv("CAMERA_NAME", "CC1")
DASHBOARD_PORT = int(os.getenv("DASHBOARD_PORT", "5000"))
DASHBOARD_HOST = os.getenv("DASHBOARD_HOST", "0.0.0.0")
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")
def api_live_video():
if not os.path.isfile(LIVE_STREAM_FRAME_PATH):
@@ -86,12 +93,17 @@ def api_live_video():
_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):
m = _SNAP_RE.match(path.name)
m = _SNAP_RE.match(path.name) or _SNAP_RE_LEGACY.match(path.name)
if not m:
return None
try:
@@ -99,6 +111,7 @@ def _parse_snapshot(path, category):
except ValueError:
dt = datetime.fromtimestamp(path.stat().st_mtime)
kind = m.group("kind")
kind = _LEGACY_KIND_MAP.get(kind, kind)
return {
"file": f"{category}/{path.name}",
"category": category,
@@ -141,7 +154,8 @@ def api_snapshots():
items = [i for i in items if i["category"] == "cross"]
elif kind == "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]
if track_id is not None:
items = [i for i in items if i["track_id"] == track_id]
@@ -179,8 +193,6 @@ def _ensure_db():
camera_name TEXT NOT NULL,
object_label TEXT NOT NULL,
total_count INTEGER NOT NULL DEFAULT 0,
total_left INTEGER NOT NULL DEFAULT 0,
total_right INTEGER NOT NULL DEFAULT 0,
start_time TEXT,
end_time TEXT,
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.close()
@@ -216,6 +299,7 @@ def index():
site_name=SITE_NAME,
show_detect=SAVE_DETECT_SNAPSHOT,
control_enabled=CONTROL_ENABLED,
zones=ZONES_META,
)
@@ -263,34 +347,80 @@ def api_control_set():
@app.route("/snapshots")
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():
return {
"counting_date": get_counting_date(),
"count": 0,
"count_left": 0,
"count_right": 0,
"counts": _empty_counts(),
"zones": ZONES_META,
"start_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():
"""Load live counter state from current_counter.json."""
with open(CURRENT_COUNTER_PATH, "r", encoding="utf-8") as 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 {
"counting_date": data.get("counting_date") or get_counting_date(),
"count": int(data.get("count", 0) or 0),
"count_left": int(data.get("count_left", 0) or 0),
"count_right": int(data.get("count_right", 0) or 0),
"count": int(data.get("count", sum(counts.values())) or 0),
"counts": counts,
"zones": ZONES_META,
"start_time": data.get("start_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):
"""Query daily_counters with optional date range and pagination."""
conn = get_db()
@@ -316,7 +446,7 @@ def _query_history(date_from=None, date_to=None, days=None, limit=None, offset=0
sql = f"""
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
{where}
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])
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 = [
{
"date": row["counting_date"],
"camera_name": row["camera_name"],
"object_label": row["object_label"],
"total_count": row["total_count"],
"total_left": row["total_left"],
"total_right": row["total_right"],
"diff": (row["total_left"] or 0) + (row["total_right"] or 0),
"counts": zone_map.get(row["counting_date"], _empty_counts()),
"zones": ZONES_META,
"diff": sum(zone_map.get(row["counting_date"], _empty_counts()).values()),
"start_time": row["start_time"],
"end_time": row["end_time"],
"updated_at": row["updated_at"],
}
for row in cur.fetchall()
for row in day_rows
]
conn.close()
return rows, total
@@ -448,6 +581,7 @@ def api_history():
payload = {
"success": True,
"site_name": SITE_NAME,
"zones": ZONES_META,
"filters": {
"days": days,
"date_from": date_from,
@@ -543,9 +677,7 @@ def api_summary():
today = get_counting_date()
cur.execute(
"""
SELECT COALESCE(total_count, 0) as total_count,
COALESCE(total_left, 0) as total_left,
COALESCE(total_right, 0) as total_right
SELECT COALESCE(total_count, 0) as total_count
FROM daily_counters
WHERE counting_date = ?
""",
@@ -555,9 +687,7 @@ def api_summary():
yesterday = (datetime.strptime(today, "%Y-%m-%d").date() - timedelta(days=1)).isoformat()
cur.execute(
"""
SELECT COALESCE(total_count, 0) as total_count,
COALESCE(total_left, 0) as total_left,
COALESCE(total_right, 0) as total_right
SELECT COALESCE(total_count, 0) as total_count
FROM daily_counters
WHERE counting_date = ?
""",
@@ -567,13 +697,23 @@ def api_summary():
cur.execute(
"""
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
FROM daily_counters
"""
)
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")
avg = cur.fetchone()
cur.execute(
@@ -588,22 +728,20 @@ def api_summary():
conn.close()
return jsonify(
{
"zones": ZONES_META,
"today": {
"date": today,
"total_count": today_row["total_count"] if today_row else 0,
"total_left": today_row["total_left"] if today_row else 0,
"total_right": today_row["total_right"] if today_row else 0,
"counts": zone_map.get(today, _empty_counts()),
},
"yesterday": {
"date": yesterday,
"total_count": yesterday_row["total_count"] if yesterday_row else 0,
"total_left": yesterday_row["total_left"] if yesterday_row else 0,
"total_right": yesterday_row["total_right"] if yesterday_row else 0,
"counts": zone_map.get(yesterday, _empty_counts()),
},
"all_time": {
"grand_total": all_time["grand_total"],
"grand_left": all_time["grand_left"],
"grand_right": all_time["grand_right"],
"counts": grand_counts,
"total_days": all_time["total_days"],
},
"average_per_day": avg["avg_per_day"] or 0,
@@ -614,7 +752,15 @@ def api_summary():
}
)
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:
return jsonify({"success": False, "error": str(e)}), 500
@@ -628,26 +774,28 @@ def api_daily_data():
cur = conn.cursor()
cur.execute(
"""
SELECT counting_date, total_count, total_left, total_right
SELECT counting_date, total_count
FROM daily_counters
WHERE counting_date >= ?
ORDER BY counting_date ASC
""",
(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 = [
{
"date": row["counting_date"],
"total_count": row["total_count"],
"total_left": row["total_left"],
"total_right": row["total_right"],
"counts": zone_map.get(row["counting_date"], _empty_counts()),
}
for row in cur.fetchall()
for row in day_rows
]
conn.close()
return jsonify(daily_data)
return jsonify({"zones": ZONES_META, "data": daily_data})
except sqlite3.OperationalError:
return jsonify([]), 200
return jsonify({"zones": ZONES_META, "data": []}), 200
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500
@@ -659,26 +807,28 @@ def api_available_dates():
cur = conn.cursor()
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
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 = [
{
"date": row["counting_date"],
"total_count": row["total_count"],
"total_left": row["total_left"],
"total_right": row["total_right"],
"counts": zone_map.get(row["counting_date"], _empty_counts()),
"start_time": row["start_time"],
"end_time": row["end_time"],
}
for row in cur.fetchall()
for row in day_rows
]
conn.close()
return jsonify(dates)
return jsonify({"zones": ZONES_META, "data": dates})
except sqlite3.OperationalError:
return jsonify([]), 200
return jsonify({"zones": ZONES_META, "data": []}), 200
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500
@@ -729,7 +879,7 @@ def export_daily_xlsx():
cur = conn.cursor()
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
WHERE counting_date >= ?
ORDER BY counting_date ASC
@@ -737,6 +887,8 @@ def export_daily_xlsx():
(date_from,),
)
rows = cur.fetchall()
dates = [row["counting_date"] for row in rows]
zone_map = _zone_counts_for_days(conn, dates)
conn.close()
except sqlite3.OperationalError as e:
return jsonify({"success": False, "error": f"Database unavailable: {e}"}), 503
@@ -746,16 +898,27 @@ def export_daily_xlsx():
wb = Workbook()
ws = wb.active
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):
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=2, value=row["total_count"])
ws.cell(row=r_idx, column=3, value=row["total_left"])
ws.cell(row=r_idx, column=4, value=row["total_right"])
ws.cell(row=r_idx, column=5, value=(row["total_left"] or 0) + (row["total_right"] or 0))
ws.cell(row=r_idx, column=6, value=row["start_time"])
ws.cell(row=r_idx, column=7, value=row["end_time"])
c = 3
for z in ZONES_META:
ws.cell(row=r_idx, column=c, value=counts.get(z["id"], 0))
c += 1
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)
filename = f"{SITE_NAME}_daily_records_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
+131 -193
View File
@@ -3,8 +3,8 @@ Edge production live counter — RTSP + YOLO RKNN + ByteTrack + zone counting.
Runs on RK3588 hardware with RKNN model (320×320 input).
Uses ByteTrack (Kalman filter + two-stage IoU association) for tracking.
Left / right rectangular zones mark the left and right sack feeders.
A sack is counted once when its centroid enters a zone.
Configurable rectangular zones (ZONE_COUNT + ZONE_i_*) mark sack feeders.
A sack is counted once when its centroid dwells in a zone long enough.
"""
import contextlib
@@ -28,6 +28,7 @@ load_dotenv()
from rknnlite.api import RKNNLite
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) ---
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")
OBJECT_LABEL = os.getenv("OBJECT_LABEL", "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"
# Left feeder zone (fractions of frame width/height). Absolute pixel overrides
# win when set (ZONE_LEFT_X1 … ZONE_LEFT_Y2).
ZONE_LEFT_X1 = int(os.getenv("ZONE_LEFT_X1")) if os.getenv("ZONE_LEFT_X1") else None
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"))
ZONES = load_zones(camera_name=CAMERA_NAME)
ZONE_IDS = zone_id_list(ZONES)
ZONES_BY_ID = zones_by_id(ZONES)
IMGSZ = int(os.getenv("IMGSZ", "320"))
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"))
# 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_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_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")
EXPORT_CSV = os.getenv("EXPORT_CSV", "true").lower() == "true"
@@ -183,8 +153,15 @@ POPUP_LIFETIME = 20
ZONE_PULSE_FRAMES = 12
COUNT_PULSE_FRAMES = 15
C_ZONE_LEFT = (80, 220, 100) # green — left feeder
C_ZONE_RIGHT = (0, 165, 255) # orange — right feeder
# Zone overlay colors cycle for N zones (BGR).
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)]
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):
x1, y1, x2, y2 = zone
return x1 <= cx <= x2 and y1 <= cy <= y2
@@ -742,47 +695,52 @@ def is_duplicate_cross(recent, cx, cy, frame_idx):
return False
def feeder_cooldown_sec(feeder):
if feeder == "left":
return ZONE_COOLDOWN_LEFT_SEC
return ZONE_COOLDOWN_RIGHT_SEC
def zone_color(zone_id):
try:
idx = ZONE_IDS.index(zone_id)
except ValueError:
idx = 0
return ZONE_COLORS[idx % len(ZONE_COLORS)]
def feeder_dwell_sec(feeder):
if feeder == "left":
return ZONE_DWELL_LEFT_SEC
return ZONE_DWELL_RIGHT_SEC
def resolve_zone_at(cx, cy, rects_by_id):
"""Return zone_id or None for the zone containing (cx, cy).
def resolve_feeder_at(cx, cy, zone_left, zone_right):
"""Return 'left' | 'right' | None for the zone containing (cx, cy)."""
now_left = point_in_zone(cx, cy, zone_left)
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"
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 feeder_display_name(feeder):
if feeder == "left":
return FEEDER_LEFT_NAME
return FEEDER_RIGHT_NAME
def zone_display_name(zone_id):
z = ZONES_BY_ID.get(zone_id)
return z.name if z else zone_id
def feeder_in_cooldown(feeder, mono, last_count_mono):
"""True if this feeder is still inside its post-count cooldown window."""
cooldown = feeder_cooldown_sec(feeder)
def zone_dwell_sec(zone_id):
z = ZONES_BY_ID.get(zone_id)
return z.dwell_sec if z else 0.0
def zone_cooldown_sec(zone_id):
z = ZONES_BY_ID.get(zone_id)
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:
return False
last = last_count_mono.get(feeder)
last = last_count_mono.get(zone_id)
if last is None:
return False
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():
"""Per-track feeder-zone state.
"""Per-track zone state.
counted: already contributed one LEFT or RIGHT count (at most one per object).
side: 'left' | 'right' | None after a count.
counted: already contributed one zone count (at most one per object).
side: zone_id or None after a count.
dwell_feeder / dwell_since: which zone the track is currently dwelling in,
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)
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
strength = pulse_remaining / max(ZONE_PULSE_FRAMES, 1)
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)
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
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)
@@ -1139,36 +1097,28 @@ def draw_hud(img, w, total, total_left, total_right, elapsed_sec, rate):
1,
cv2.LINE_AA,
)
x = 100
for zid in ZONE_IDS:
label = zid.replace("zone_", "Z")
color = zone_color(zid)
cv2.putText(
img, "LEFT", (100, 14), cv2.FONT_HERSHEY_SIMPLEX, 0.32, C_MUTED, 1, cv2.LINE_AA
img, label, (x, 14), cv2.FONT_HERSHEY_SIMPLEX, 0.32, C_MUTED, 1, cv2.LINE_AA
)
cv2.putText(
img,
str(total_left),
(100, 32),
str(counts_by_zone.get(zid, 0)),
(x, 32),
cv2.FONT_HERSHEY_SIMPLEX,
0.55,
C_ZONE_LEFT,
1,
cv2.LINE_AA,
)
cv2.putText(
img, "RIGHT", (180, 14), cv2.FONT_HERSHEY_SIMPLEX, 0.32, C_MUTED, 1, cv2.LINE_AA
)
cv2.putText(
img,
str(total_right),
(180, 32),
cv2.FONT_HERSHEY_SIMPLEX,
0.55,
C_ZONE_RIGHT,
color,
1,
cv2.LINE_AA,
)
x += 80
cv2.putText(
img,
"UPTIME",
(270, 14),
(x + 10, 14),
cv2.FONT_HERSHEY_SIMPLEX,
0.32,
C_MUTED,
@@ -1178,7 +1128,7 @@ def draw_hud(img, w, total, total_left, total_right, elapsed_sec, rate):
cv2.putText(
img,
f"{elapsed_sec / 3600:.1f}h",
(270, 32),
(x + 10, 32),
cv2.FONT_HERSHEY_SIMPLEX,
0.45,
C_TEXT,
@@ -1186,12 +1136,12 @@ def draw_hud(img, w, total, total_left, total_right, elapsed_sec, rate):
cv2.LINE_AA,
)
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(
img,
f"{rate:.1f}/min",
(360, 32),
(x + 100, 32),
cv2.FONT_HERSHEY_SIMPLEX,
0.45,
C_ACCENT,
@@ -1333,6 +1283,7 @@ def run():
camera_name=CAMERA_NAME,
object_label=OBJECT_LABEL,
cutoff_time=DAILY_CUTOFF_TIME,
zone_ids=ZONE_IDS,
logger=lambda msg: print(f"[{now_str()}] {msg}"),
)
store.start_cutoff_watcher()
@@ -1340,7 +1291,7 @@ def run():
cross_logger = None
if EXPORT_CSV:
cross_logger = CsvLogger(
CROSS_CSV, ["counting_date", "frame", "feeder", "object_id"]
CROSS_CSV, ["counting_date", "frame", "zone_id", "object_id"]
)
model = RKNNYOLO(
@@ -1363,19 +1314,17 @@ def run():
min_hits=TRACK_MIN_HITS,
)
# Per-track zone state: enter left → LEFT feeder; enter right → RIGHT feeder.
# Keys survive ID switches via inheritance (see _inherit_prev).
# Per-track zone state. Keys survive ID switches via inheritance.
object_zone_state = {}
recent_cross_left = deque()
recent_cross_right = deque()
# Monotonic timestamp of last successful count per feeder (for cooldown).
last_count_mono = {"left": None, "right": None}
recent_cross = {zid: deque() for zid in ZONE_IDS}
last_count_mono = {zid: None for zid in ZONE_IDS}
detect_snapshot_ids = set()
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 = []
session_start = time.time()
@@ -1386,8 +1335,7 @@ def run():
video_writer = None
crossing_times = deque()
# Overlay shows daily store totals (resume-safe, resets on counting-day change).
counter_left = store.current_count_left
counter_right = store.current_count_right
counter_by_zone = store.current_counts
last_snapshot_cleanup = 0.0
counting_active = True
@@ -1413,16 +1361,19 @@ def run():
model.release()
return
zone_left = resolve_zone_left(w, h)
zone_right = resolve_zone_right(w, h)
print(
f"RKNN+ByteTrack zone counter | {w}x{h} @ {fps}fps | "
f"LEFT feeder {zone_left} RIGHT feeder {zone_right}"
zone_rects = {z.id: z.resolve_rect(w, h) for z in ZONES}
zones_desc = " ".join(
f"{z.id}({z.name}) {zone_rects[z.id]}" for z in ZONES
)
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(
f"Cooldownoldown: left={ZONE_COOLDOWN_LEFT_SEC}s right={ZONE_COOLDOWN_RIGHT_SEC}s "
f"(0=off) | Dwell: left={ZONE_DWELL_LEFT_SEC}s right={ZONE_DWELL_RIGHT_SEC}s "
f"(0=immediate)"
f"Cooldown: {cool_desc} (0=off) | Dwell: {dwell_desc} (0=immediate)"
)
print(f"Model: {MODEL_PATH} | imgsz={IMGSZ} | core_mask={CORE_MASK}")
print(
@@ -1452,8 +1403,7 @@ def run():
cap, w, h, fps = connect_stream(SOURCE)
if cap is None:
break
zone_left = resolve_zone_left(w, h)
zone_right = resolve_zone_right(w, h)
zone_rects = {z.id: z.resolve_rect(w, h) for z in ZONES}
continue
now = time.time()
@@ -1534,30 +1484,23 @@ def run():
)
zone_str = ""
if object_zone_state:
left_ids = sorted(
tid
for tid, st in object_zone_state.items()
if st.get("side") == "left"
)
right_ids = sorted(
tid
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}"
by_side = {}
for tid, st in object_zone_state.items():
side = st.get("side")
if side:
by_side.setdefault(side, []).append(tid)
for side, tids in by_side.items():
zone_str += f" {side}_counted: {sorted(tids)}"
print(
f"[DEBUG F{frame_idx}] dets={len(object_boxes_xyxy)} "
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}",
flush=True,
)
# --- 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
# resets the dwell timer. Cooldown still applies after a successful count.
for di in range(len(object_boxes_xyxy)):
@@ -1597,7 +1540,7 @@ def run():
object_zone_state[tid] = _default_zone_state()
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"]:
object_tracked[tid] = (cx, cy, mono)
@@ -1608,7 +1551,7 @@ def run():
if st["dwell_feeder"] is not None and DEBUG_TRACKING:
print(
f"[DEBUG F{frame_idx}] DWELL RESET: tid={tid} "
f"left feeder={st['dwell_feeder']}",
f"left zone={st['dwell_feeder']}",
flush=True,
)
st["dwell_feeder"] = None
@@ -1622,40 +1565,40 @@ def run():
if DEBUG_TRACKING:
print(
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,
)
object_tracked[tid] = (cx, cy, mono)
continue
dwell_need = feeder_dwell_sec(feeder)
dwell_need = zone_dwell_sec(feeder)
dwell_elapsed = mono - (st["dwell_since"] or mono)
if dwell_need > 0 and dwell_elapsed < dwell_need:
object_tracked[tid] = (cx, cy, mono)
continue
# 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):
st["counted"] = True
st["side"] = feeder
if DEBUG_TRACKING:
print(
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,
)
object_tracked[tid] = (cx, cy, mono)
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.
if DEBUG_TRACKING:
cool = feeder_cooldown_sec(feeder)
elapsed = mono - (last_count_mono.get(feeder) or mono)
cool = zone_cooldown_sec(feeder)
elapsed_c = mono - (last_count_mono.get(feeder) or mono)
print(
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,
)
object_tracked[tid] = (cx, cy, mono)
@@ -1664,7 +1607,7 @@ def run():
if DEBUG_TRACKING:
print(
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",
flush=True,
)
@@ -1674,22 +1617,17 @@ def run():
st["side"] = feeder
last_count_mono[feeder] = mono
_, _, counted = store.record_zone_entry(tid, feeder)
counter_left = store.current_count_left
counter_right = store.current_count_right
counter_by_zone = store.current_counts
if not counted:
# Dedup in the store rejected this event; skip overlay/CSV.
object_tracked[tid] = (cx, cy, mono)
continue
if feeder == "left":
count_left_pulse = COUNT_PULSE_FRAMES
feeder_total = counter_left
else:
count_right_pulse = COUNT_PULSE_FRAMES
feeder_total = counter_right
count_pulse[feeder] = COUNT_PULSE_FRAMES
feeder_total = counter_by_zone.get(feeder, 0)
# Frigate-style count log, e.g.
# "Total karung di kandang_bawah_feeder_kanan: 5"
# "Total karung di Cam_zone_1: 5"
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,
)
if cross_logger:
@@ -1710,7 +1648,7 @@ def run():
"x": int(cx) - 12,
"y": int(cy),
"born": frame_idx,
"text": f"+1 {feeder.upper()}",
"text": f"+1 {feeder}",
}
)
object_tracked[tid] = (cx, cy, mono)
@@ -1737,30 +1675,30 @@ def run():
zone_pulse = ZONE_PULSE_FRAMES
# Keep overlay aligned with daily store (also resets after cutoff).
counter_left = store.current_count_left
counter_right = store.current_count_right
counter_by_zone = store.current_counts
while crossing_times and mono - crossing_times[0] > RATE_WINDOW_SEC:
crossing_times.popleft()
rate = (len(crossing_times) / RATE_WINDOW_SEC * 60) if crossing_times else 0.0
for z in ZONES:
rect = zone_rects[z.id]
color = zone_color(z.id)
draw_elegant_zone(
frame, zone_left, zone_pulse, label="LEFT FEEDER", color=C_ZONE_LEFT
)
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(
frame, zone_left, counter_left, "LEFT", C_ZONE_LEFT, count_left_pulse
)
draw_zone_count(
frame, zone_right, counter_right, "RIGHT", C_ZONE_RIGHT, count_right_pulse
frame,
rect,
counter_by_zone.get(z.id, 0),
z.id.replace("zone_", "Z"),
color,
count_pulse.get(z.id, 0),
)
draw_hud(
frame,
w,
counter_left + counter_right,
counter_left,
counter_right,
sum(counter_by_zone.values()),
counter_by_zone,
elapsed,
rate,
)
@@ -1788,8 +1726,8 @@ def run():
if object_cross_flash[tid] <= 0:
del object_cross_flash[tid]
zone_pulse = max(0, zone_pulse - 1)
count_left_pulse = max(0, count_left_pulse - 1)
count_right_pulse = max(0, count_right_pulse - 1)
for zid in count_pulse:
count_pulse[zid] = max(0, count_pulse[zid] - 1)
if video_writer is not None:
video_writer.write(frame)
+169 -54
View File
@@ -1,11 +1,9 @@
"""
Production daily counter persistence for edge sack feeder counter.
Tracks left / right feeder zone entries and the daily total per counting day,
Production daily counter persistence for edge sack zone counter.
Tracks per-zone entries and the daily total per counting day,
delimited by the daily cutoff time. SQLite schema + current_counter.json state.
Feeders:
left → left feeder zone
right → right feeder zone
Zone ids are stable keys like zone_1, zone_2, … (from zones_config).
"""
import json
import sqlite3
@@ -24,6 +22,7 @@ class CounterStore:
object_label='object',
cutoff_time='20:00',
carry_ids=50,
zone_ids=None,
logger=print,
):
self.db_path = db_path
@@ -34,6 +33,10 @@ class CounterStore:
datetime.strptime(cutoff_time, '%H:%M')
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.state_lock = threading.Lock()
@@ -46,6 +49,11 @@ class CounterStore:
self._init_db()
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):
cur = self.db.cursor()
cur.execute(
@@ -56,8 +64,6 @@ class CounterStore:
camera_name TEXT NOT NULL,
object_label TEXT NOT NULL,
total_count INTEGER NOT NULL DEFAULT 0,
total_left INTEGER NOT NULL DEFAULT 0,
total_right INTEGER NOT NULL DEFAULT 0,
start_time TEXT,
end_time TEXT,
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._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):
if dt is None:
@@ -75,11 +161,28 @@ class CounterStore:
return dt.date().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):
state.setdefault('count_left', 0)
state.setdefault('count_right', 0)
state.setdefault('count', state['count_left'] + state['count_right'])
counts = state.get('counts')
if not isinstance(counts, dict):
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('start_time', datetime.now().isoformat())
state.setdefault('last_detection_time', state.get('start_time'))
return state
def _load_state(self):
@@ -97,10 +200,6 @@ class CounterStore:
self.state_file.unlink(missing_ok=True)
return None
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 []
if stale_ids:
self.log(
@@ -110,7 +209,7 @@ class CounterStore:
state['counted_event_ids'] = []
self.log(
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:
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:]
except (KeyError, TypeError):
carried = []
counts = self._empty_counts()
self.current_state = {
'counting_date': counting_date,
'count': 0,
'count_left': 0,
'count_right': 0,
'counts': counts,
'start_time': now,
'last_detection_time': now,
'counted_event_ids': carried,
@@ -146,13 +245,15 @@ class CounterStore:
self.save_state()
self.log(f'Started counting day {counting_date} ({self.object_label})')
def record_zone_entry(self, track_id, feeder):
"""Record a sack entering a feeder zone. feeder: 'left' | 'right'.
def record_zone_entry(self, track_id, zone_id):
"""Record a sack entering a zone. zone_id: 'zone_1' | 'zone_2' | …
Returns (total_count, day_started, counted).
"""
if feeder not in ('left', 'right'):
raise ValueError(f"feeder must be 'left' or 'right', got {feeder!r}")
if zone_id not in self._zone_id_set:
raise ValueError(
f"zone_id must be one of {self.zone_ids}, got {zone_id!r}"
)
with self.state_lock:
counting_date = self.get_counting_date()
day_started = False
@@ -161,20 +262,18 @@ class CounterStore:
day_started = True
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']:
self.current_state['count'] += 1
if feeder == 'left':
self.current_state['count_left'] += 1
else:
self.current_state['count_right'] += 1
self.current_state['counts'][zone_id] = (
int(self.current_state['counts'].get(zone_id, 0)) + 1
)
self.current_state['counted_event_ids'].append(event_key)
counted = True
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'(left={self.current_state["count_left"]} '
f'right={self.current_state["count_right"]})'
f'counts={self.current_state["counts"]}'
)
self._persist_day()
@@ -189,22 +288,34 @@ class CounterStore:
"""
INSERT INTO daily_counters
(counting_date, camera_name, object_label,
total_count, total_left, total_right, start_time, end_time)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
total_count, start_time, end_time)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(counting_date, camera_name, object_label)
DO UPDATE SET
total_count = excluded.total_count,
total_left = excluded.total_left,
total_right = excluded.total_right,
end_time = excluded.end_time,
updated_at = CURRENT_TIMESTAMP
""",
(
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(),
),
)
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()
def cutoff_watcher_loop(self):
@@ -231,40 +342,44 @@ class CounterStore:
return self.current_state['count']
@property
def current_count_left(self):
def current_counts(self):
if self.current_state is None:
return 0
return self.current_state.get('count_left', 0)
return self._empty_counts()
return dict(self.current_state.get('counts') or self._empty_counts())
@property
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):
def _day_zone_counts(self, counting_date=None):
if counting_date is None:
counting_date = self.get_counting_date()
cur = self.db.cursor()
cur.execute(
"""
SELECT COALESCE(total_count, 0), COALESCE(total_left, 0), COALESCE(total_right, 0)
FROM daily_counters
SELECT zone_id, COALESCE(count, 0)
FROM daily_zone_counts
WHERE counting_date = ? AND camera_name = ? AND object_label = ?
""",
(counting_date, self.camera_name, self.object_label),
)
row = cur.fetchone()
return row if row else (0, 0, 0)
out = self._empty_counts()
for zone_id, count in cur.fetchall():
if zone_id in out:
out[zone_id] = int(count)
return out
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):
return self._day_totals()[1]
def display_right(self):
return self._day_totals()[2]
def display_counts(self):
return self._day_zone_counts()
def shutdown(self):
self.shutdown_event.set()
+29 -35
View File
@@ -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
# Copy to .env on device: cp env.example .env && nano .env
# =============================================================================
@@ -44,31 +44,38 @@ OBJECT_LABEL=karung
CLASS_OBJECT=karung
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)
DEBUG_TRACKING=false
# --- Feeder zones (left / right) ---
# Rectangular zones as fractions of frame width/height.
# Absolute pixel overrides: ZONE_LEFT_X1, ZONE_LEFT_Y1, ZONE_LEFT_X2, ZONE_LEFT_Y2
# (and the matching ZONE_RIGHT_*). Absolute values win when set.
#
# A sack is counted once when its centroid ENTERS a zone.
# LEFT zone → left feeder
# RIGHT zone → right feeder
ZONE_LEFT_X1_FRAC=0.02
ZONE_LEFT_Y1_FRAC=0.10
ZONE_LEFT_X2_FRAC=0.440
ZONE_LEFT_Y2_FRAC=1.00
# --- Counting zones (1…N) ---
# ZONE_COUNT=1 uses only ZONE_1_*. Add ZONE_2_* (etc.) when ZONE_COUNT>=2.
# Absolute pixels (ZONE_i_X1…) win over fractions when set.
# A sack is counted once after dwelling in a zone.
ZONE_COUNT=1
ZONE_1_NAME=kandang_bawah_feeder
ZONE_1_X1_FRAC=0.10
ZONE_1_Y1_FRAC=0.10
ZONE_1_X2_FRAC=0.90
ZONE_1_Y2_FRAC=1.00
ZONE_1_COOLDOWN_SEC=15
ZONE_1_DWELL_SEC=2
ZONE_RIGHT_X1_FRAC=0.490
ZONE_RIGHT_Y1_FRAC=0.10
ZONE_RIGHT_X2_FRAC=0.98
ZONE_RIGHT_Y2_FRAC=1.00
# Example two-zone setup:
# ZONE_COUNT=2
# ZONE_1_NAME=kandang_bawah_feeder_kiri
# 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 ---
DAILY_CUTOFF_TIME=17:00
@@ -95,17 +102,6 @@ DEDUP_PX=60
INHERIT_SEC=1.0
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 ---
RECORD_VIDEO=false
VIDEO_SEGMENT_SEC=3600
@@ -128,5 +124,3 @@ SAVE_CROSS_SNAPSHOT=true
CROSS_SNAPSHOT_DIR=/opt/zenai-ktc-snaps/snapshots
CROSS_SNAPSHOT_CLEANUP_SEC=3600
CROSS_SNAPSHOT_MAX_AGE_DAYS=7
+115 -102
View File
@@ -597,7 +597,7 @@
<div class="logo-icon">&#9670;</div>
<div>
<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 class="header-right">
@@ -621,32 +621,28 @@
<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-val" id="today-count">--</div>
<div class="s-label">Total Today (Left + Right)</div>
<div class="sub-cards">
<div class="sub-card left">
<div class="sc-head"><i class="fas fa-arrow-left"></i> Left</div>
<div class="sc-val" id="today-left">--</div>
</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 class="s-label">Total Today</div>
<div class="sub-cards" id="today-zone-cards">
{% for z in zones %}
<div class="sub-card">
<div class="sc-head"><i class="fas fa-vector-square"></i> {{ z.name }}</div>
<div class="sc-val" id="today-{{ z.id }}">--</div>
</div>
{% endfor %}
</div>
</div>
<div class="s-card">
<span class="s-chip info">Yesterday</span>
<div class="s-icon warm"><i class="fas fa-history"></i></div>
<div class="s-val" id="yesterday-count">--</div>
<div class="s-label">Total Yesterday (Left + Right)</div>
<div class="sub-cards">
<div class="sub-card left">
<div class="sc-head"><i class="fas fa-arrow-left"></i> Left</div>
<div class="sc-val" id="yesterday-left">--</div>
</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 class="s-label">Total Yesterday</div>
<div class="sub-cards" id="yesterday-zone-cards">
{% for z in zones %}
<div class="sub-card">
<div class="sc-head"><i class="fas fa-vector-square"></i> {{ z.name }}</div>
<div class="sc-val" id="yesterday-{{ z.id }}">--</div>
</div>
{% endfor %}
</div>
</div>
</div>
@@ -654,10 +650,6 @@
<!-- Hidden (kept for JS refs) -->
<div class="hide-me">
<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="yesterday-total">--</span>
<span id="avg-count">--</span>
@@ -710,37 +702,29 @@
</div>
<div class="panel">
<span class="section-title">&#9673; Quick Stats</span>
<div style="margin-top:16px;">
<div style="margin-top:16px;" id="quick-stats">
<div class="quick-stat">
<div class="qs-left">
<div class="qs-icon primary"><i class="fas fa-layer-group"></i></div>
<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>
<span class="qs-val" id="grand-total">--</span>
</div>
{% for z in zones %}
<div class="quick-stat">
<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 class="qs-label">Total Left</div>
<div class="qs-label">{{ z.name }}</div>
<div class="qs-sub">All time</div>
</div>
</div>
<span class="qs-val" id="grand-left">--</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>
<span class="qs-val" id="grand-{{ z.id }}">--</span>
</div>
{% endfor %}
</div>
<div class="recent-section">
@@ -772,8 +756,9 @@
<tr>
<th style="padding-left:4px;">Date</th>
<th>Total</th>
<th>Left</th>
<th>Right</th>
{% for z in zones %}
<th>{{ z.name }}</th>
{% endfor %}
<th style="padding-right:4px;">Status</th>
</tr>
</thead>
@@ -788,6 +773,15 @@
</div>
<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 ---
(function() {
const saved = localStorage.getItem('theme');
@@ -888,16 +882,19 @@ async function loadCurrentCounter() {
const res = await fetch('/api/current-counter');
if (!res.ok) throw new Error('No active counter');
const data = await res.json();
const tleft = data.count_left || 0;
const tright = data.count_right || 0;
document.getElementById('today-count').textContent = (tleft + tright).toLocaleString();
document.getElementById('today-left').textContent = tleft.toLocaleString();
document.getElementById('today-right').textContent = tright.toLocaleString();
const counts = data.counts || {};
const total = data.count != null ? data.count : Object.values(counts).reduce((a, b) => a + (b || 0), 0);
document.getElementById('today-count').textContent = total.toLocaleString();
ZONES.forEach(z => {
const el = document.getElementById('today-' + z.id);
if (el) el.textContent = (counts[z.id] || 0).toLocaleString();
});
} catch (err) {
document.getElementById('today-count').textContent = '0';
document.getElementById('today-left').textContent = '0';
document.getElementById('today-right').textContent = '0';
ZONES.forEach(z => {
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('yesterday-count').textContent = ((data.yesterday.total_left || 0) + (data.yesterday.total_right || 0)).toLocaleString();
document.getElementById('yesterday-left').textContent = (data.yesterday.total_left || 0).toLocaleString();
document.getElementById('yesterday-right').textContent = (data.yesterday.total_right || 0).toLocaleString();
const yCounts = (data.yesterday && data.yesterday.counts) || {};
document.getElementById('yesterday-count').textContent = (
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('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-date').textContent = data.best_day.date || '--';
document.getElementById('grand-total').textContent = ((data.all_time.grand_left || 0) + (data.all_time.grand_right || 0)).toLocaleString();
document.getElementById('grand-left').textContent = (data.all_time.grand_left || 0).toLocaleString();
document.getElementById('grand-right').textContent = (data.all_time.grand_right || 0).toLocaleString();
const gCounts = (data.all_time && data.all_time.counts) || {};
document.getElementById('grand-total').textContent = (data.all_time.grand_total || 0).toLocaleString();
ZONES.forEach(z => {
const el = document.getElementById('grand-' + z.id);
if (el) el.textContent = (gCounts[z.id] || 0).toLocaleString();
});
} catch (err) {
console.error('Failed to load summary:', err);
@@ -935,15 +942,17 @@ async function loadChartData(days) {
try {
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 date = new Date(d.date);
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 lefts = data.map(d => d.total_left || 0);
const rights = data.map(d => d.total_right || 0);
const totals = data.map(d => d.total_count != null
? d.total_count
: Object.values(d.counts || {}).reduce((a, b) => a + (b || 0), 0));
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 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, {
type: 'bar',
data: {
labels: labels,
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'
}
]
datasets: datasets
},
options: {
responsive: true,
@@ -1031,27 +1032,32 @@ async function loadChartData(days) {
async function loadRecentDays() {
try {
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');
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>';
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="ri-left">
<div class="ri-badge"><i class="fas fa-calendar"></i></div>
<div>
<div class="ri-date">${d.date}</div>
<div class="ri-time">LEFT ${(d.total_left || 0).toLocaleString()} &middot; RIGHT ${(d.total_right || 0).toLocaleString()}</div>
<div class="ri-time">${parts}</div>
</div>
</div>
<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>
`).join('');
</div>`;
}).join('');
} catch (err) {
console.error('Failed to load recent days:', err);
}
@@ -1060,7 +1066,8 @@ async function loadRecentDays() {
async function loadAllDates() {
try {
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);
} catch (err) {
console.error('Failed to load dates:', err);
@@ -1075,11 +1082,12 @@ function resetFilter() {
function renderDailyTable(dates) {
const tbody = document.getElementById('daily-table-body');
const currentDate = document.getElementById('current-date').textContent;
const colCount = 3 + ZONES.length;
if (dates.length === 0) {
tbody.innerHTML = `
<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;">&#9633;</div>
<div style="font-size:10px;letter-spacing:1px;">NO DATA AVAILABLE</div>
</td>
@@ -1089,6 +1097,11 @@ function renderDailyTable(dates) {
tbody.innerHTML = dates.map(d => {
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 `
<tr>
@@ -1101,9 +1114,8 @@ function renderDailyTable(dates) {
</div>
</div>
</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 class="badge badge-green">${d.total_left.toLocaleString()}</span></td>
<td><span class="badge badge-gray">${d.total_right.toLocaleString()}</span></td>
<td><span style="font-family:'Orbitron',sans-serif;font-size:15px;font-weight:700;">${total.toLocaleString()}</span></td>
${zoneCells}
<td style="padding-right:4px;">
<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'}
@@ -1119,7 +1131,8 @@ function filterByDate() {
fetch('/api/available-dates')
.then(r => r.json())
.then(dates => {
.then(payload => {
const dates = Array.isArray(payload) ? payload : (payload.data || []);
const filtered = dates.filter(d => d.date === date);
renderDailyTable(filtered);
});
+5 -2
View File
@@ -207,6 +207,8 @@
}
.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.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-tid {
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="filters" id="snap-filters">
<button class="active" data-kind="all" onclick="loadSnapshots('all')">ALL</button>
<button data-kind="left" onclick="loadSnapshots('left')">LEFT</button>
<button data-kind="right" onclick="loadSnapshots('right')">RIGHT</button>
{% for z in zones %}
<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 %}
</div>
<input type="number" id="snap-tid-filter" placeholder="Track ID" min="0"
+147
View File
@@ -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}