diff --git a/DEPLOY.md b/DEPLOY.md index fbf24cb..b12d1f4 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -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://: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 `; 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) diff --git a/config.env.example b/config.env.example index 26450a6..e5d09c3 100644 --- a/config.env.example +++ b/config.env.example @@ -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 /cross/ -# (filename: __id_f.jpg) +# (filename: __id_f.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 /detect/ with the same track id so diff --git a/counter_dashboard.py b/counter_dashboard.py index 357d077..79482e6 100644 --- a/counter_dashboard.py +++ b/counter_dashboard.py @@ -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\d{8}_\d{6}_\d{3})_(?Pdetect|left|right)_id(?P\d+)_f(?P\d+)\.jpg$" + r"^(?P\d{8}_\d{6}_\d{3})_(?Pdetect|zone_\d+)_id(?P\d+)_f(?P\d+)\.jpg$" ) +# Also accept legacy left/right snapshot filenames. +_SNAP_RE_LEGACY = re.compile( + r"^(?P\d{8}_\d{6}_\d{3})_(?Pleft|right)_id(?P\d+)_f(?P\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" diff --git a/counter_live_rknn.py b/counter_live_rknn.py index c824a2f..b370dc9 100644 --- a/counter_live_rknn.py +++ b/counter_live_rknn.py @@ -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). + + 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): - """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" - return None +def zone_display_name(zone_id): + z = ZONES_BY_ID.get(zone_id) + return z.name if z else zone_id -def feeder_display_name(feeder): - if feeder == "left": - return FEEDER_LEFT_NAME - return FEEDER_RIGHT_NAME +def zone_dwell_sec(zone_id): + z = ZONES_BY_ID.get(zone_id) + return z.dwell_sec if z else 0.0 -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_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, ) - cv2.putText( - img, "LEFT", (100, 14), cv2.FONT_HERSHEY_SIMPLEX, 0.32, C_MUTED, 1, cv2.LINE_AA - ) - cv2.putText( - img, - str(total_left), - (100, 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, - 1, - cv2.LINE_AA, - ) + x = 100 + for zid in ZONE_IDS: + label = zid.replace("zone_", "Z") + color = zone_color(zid) + cv2.putText( + img, label, (x, 14), cv2.FONT_HERSHEY_SIMPLEX, 0.32, C_MUTED, 1, cv2.LINE_AA + ) + cv2.putText( + img, + str(counts_by_zone.get(zid, 0)), + (x, 32), + cv2.FONT_HERSHEY_SIMPLEX, + 0.55, + 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 - 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 - ) - 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 - ) + for z in ZONES: + rect = zone_rects[z.id] + color = zone_color(z.id) + draw_elegant_zone( + frame, rect, zone_pulse, label=z.name.upper(), color=color + ) + draw_zone_count( + 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) diff --git a/counter_store.py b/counter_store.py index 320705a..277733f 100644 --- a/counter_store.py +++ b/counter_store.py @@ -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() diff --git a/env.example b/env.example index ddc9b93..46dac1b 100644 --- a/env.example +++ b/env.example @@ -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 - - diff --git a/templates/dashboard.html b/templates/dashboard.html index 9e54ef0..e017924 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -597,7 +597,7 @@
◆

ZenAI KTC Module

-
Sack feeder zone counter — YOLO RockChip + ByteTrack
+
Sack zone counter — YOLO RockChip + ByteTrack
@@ -621,32 +621,28 @@ LIVE
--
-
Total Today (Left + Right)
-
-
-
Left
-
--
-
-
-
Right
-
--
+
Total Today
+
+ {% for z in zones %} +
+
{{ z.name }}
+
--
+ {% endfor %}
Yesterday
--
-
Total Yesterday (Left + Right)
-
-
-
Left
-
--
-
-
-
Right
-
--
+
Total Yesterday
+
+ {% for z in zones %} +
+
{{ z.name }}
+
--
+ {% endfor %}
@@ -654,10 +650,6 @@
-- - -- - -- - -- - -- -- -- -- @@ -710,37 +702,29 @@
◉ Quick Stats -
+
Grand Total
-
All time (Left + Right)
+
All time
--
+ {% for z in zones %}
-
+
-
Total Left
+
{{ z.name }}
All time
- -- -
-
-
-
-
-
Total Right
-
All time
-
-
- -- + --
+ {% endfor %}
@@ -772,8 +756,9 @@ Date Total - Left - Right + {% for z in zones %} + {{ z.name }} + {% endfor %} Status @@ -788,6 +773,15 @@