feat: unified config.yaml with extensible model presets (A-D data-driven, E/F ready)
ci / smoke (push) Canceled after 0s
ci / smoke (push) Canceled after 0s
- config.yaml canonical for stream/models/counting/batch/output/camera - models.modes hold engines+class filters only; conf/iou/min_bbox in detection_params - predict.py derives tracker roles structurally (no per-mode branching) - dashboard mode switch validates + persists atomically to config.yaml - .env keeps secrets/deployment only; zones.json geometry; tracker.yaml hyperparams - batch_mode.json keeps manual/auto batch mode; legacy model_mode ignored w/ warning - src/detection+tracking gain iou param (default 0.7 = no behavior change)
This commit is contained in:
1 parent
630f4bc29e
commit
2d9c66cbb6
14 files changed
+1254
-199
No files matched your search
+18
-8
@@ -1,24 +1,34 @@
|
|||||||
# Core variables for Karung Counter
|
# .env — SECRETS & deployment-specific values ONLY (never commit real credentials).
|
||||||
|
# Canonical config lives in config.yaml (paths, modes, counting knobs, batch, camera).
|
||||||
|
# Zone polygons live in zones.json. Tracker hyperparams live in cfg/tracker.yaml.
|
||||||
|
#
|
||||||
|
# Legacy keys (MODEL_PATH, MODEL_MODE, BATCH_MODE_FILE, *_PATH overrides) are
|
||||||
|
# still honoured when set, but deprecated — prefer config.yaml.
|
||||||
|
|
||||||
|
# --- Output paths (override config.yaml output.* when set; legacy) ---
|
||||||
OUTPUT_DIR=/opt/jetson-counter
|
OUTPUT_DIR=/opt/jetson-counter
|
||||||
DB_PATH=/opt/jetson-counter/jetson_counter.db
|
DB_PATH=/opt/jetson-counter/jetson_counter.db
|
||||||
STATE_FILE=/opt/jetson-counter/current_batch.json
|
STATE_FILE=/opt/jetson-counter/current_batch.json
|
||||||
BATCH_MODE_FILE=/opt/jetson-counter/batch_mode.json
|
|
||||||
LIVE_STREAM_FRAME_PATH=/dev/shm/jetson-counter/live_frame.jpg
|
LIVE_STREAM_FRAME_PATH=/dev/shm/jetson-counter/live_frame.jpg
|
||||||
|
|
||||||
|
# --- Identity / batch day ---
|
||||||
CAMERA_NAME=CC1
|
CAMERA_NAME=CC1
|
||||||
OBJECT_LABEL=karung-pakan
|
OBJECT_LABEL=karung-pakan
|
||||||
DAILY_CUTOFF_TIME=06:00
|
DAILY_CUTOFF_TIME=06:00
|
||||||
|
BATCH_MERGE_THRESHOLD_SECONDS=300
|
||||||
|
|
||||||
# Dashboard variables
|
# --- Dashboard (canonical home for these; read from env, NOT config.yaml) ---
|
||||||
SECRET_KEY=change-me-in-production
|
SECRET_KEY=change-me-in-production
|
||||||
DASHBOARD_HOST=0.0.0.0
|
DASHBOARD_HOST=0.0.0.0
|
||||||
DASHBOARD_PORT=5000
|
DASHBOARD_PORT=5000
|
||||||
OFFICE_PORT=5721
|
OFFICE_PORT=5721
|
||||||
FLASK_DEBUG=false
|
FLASK_DEBUG=false
|
||||||
|
SITE_NAME=LIVE
|
||||||
|
|
||||||
# Stream URL
|
# --- Stream (canonical home; read from env, NOT config.yaml) ---
|
||||||
RTSP_URL=rtsp://user:pass@192.168.192.209:8554/camera_stream_640
|
RTSP_URL=rtsp://user:pass@192.168.192.209:8554/camera_stream_640
|
||||||
|
|
||||||
# Model pipeline mode: A=combined only; B=v4 truck + yolo11n sack+box;
|
# --- Deprecated: model pipeline mode now lives in config.yaml models.active_mode.
|
||||||
# C=A + yolo11n box-only (default); D=v4 truck + best sack-only + yolo11n box-only.
|
# Dashboard switches persist there (atomic write, comments preserved) and apply
|
||||||
# Dashboard switches persist here and apply on next service restart.
|
# on next `karung-counter` restart. Only set this to temporarily override.
|
||||||
MODEL_MODE=C
|
# MODEL_MODE=C
|
||||||
@@ -12,6 +12,6 @@ jobs:
|
|||||||
- uses: actions/setup-python@v5
|
- uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: "3.10"
|
python-version: "3.10"
|
||||||
- run: pip install numpy python-dotenv pytest
|
- run: pip install numpy python-dotenv pytest pyyaml
|
||||||
- run: python -m pytest tests/ -q
|
- run: python -m pytest tests/ -q
|
||||||
- run: python -m compileall -q src/ counter_dashboard.py
|
- run: python -m compileall -q src/ counter_dashboard.py
|
||||||
@@ -9,12 +9,14 @@ Indonesian (`karung`=sack, `truk`=truck); YOLO class names are English
|
|||||||
## Which pipeline to touch
|
## Which pipeline to touch
|
||||||
|
|
||||||
- `predict.py` = **production AND dev CLI** (runs as `karung-counter.service` with
|
- `predict.py` = **production AND dev CLI** (runs as `karung-counter.service` with
|
||||||
zero args). Dev flags: `--source VID --env .env --model X --output-dir D
|
zero args). Dev flags: `--source VID --env .env --config YAML --model X --output-dir D
|
||||||
--output-json F --sack-conf C --truck-conf C --box-conf C --box-model P
|
--output-json F --sack-conf C --truck-conf C --box-conf C --box-model P
|
||||||
--model-mode A|B|C|D --batch-timeout S --max-frames N --no-dashboard --no-db`.
|
--model-mode M --batch-timeout S --max-frames N --no-dashboard --no-db`.
|
||||||
Zero flags = systemd behaviour (`MODEL_MODE` env, default C).
|
Zero flags = systemd behaviour (`config.yaml` + `.env`).
|
||||||
Model modes: A=combined only; B=v4 truck + yolo11n sack+box;
|
Model modes are DATA in `config.yaml` `models.modes` (engines + class filters
|
||||||
C=A + yolo11n box-only (default); D=v4 truck + best sack-only + yolo11n box-only.
|
only; conf/iou/min_bbox in `models.detection_params`):
|
||||||
|
A=combined only; B=v4 truck + yolo11n sack+box; C=A + yolo11n box-only (default);
|
||||||
|
D=v4 truck + best sack-only + yolo11n box-only. New modes need no code change.
|
||||||
All modes load `.engine` files (2-3 coexist, ~24 MB peak); never mix load
|
All modes load `.engine` files (2-3 coexist, ~24 MB peak); never mix load
|
||||||
order assumptions — PyTorch `.pt` must load before TensorRT `.engine`.
|
order assumptions — PyTorch `.pt` must load before TensorRT `.engine`.
|
||||||
- `src/` = shared library (detection/tracking/counting/batch). `python -m src.main`
|
- `src/` = shared library (detection/tracking/counting/batch). `python -m src.main`
|
||||||
@@ -25,10 +27,14 @@ Indonesian (`karung`=sack, `truk`=truck); YOLO class names are English
|
|||||||
|
|
||||||
## Gotchas
|
## Gotchas
|
||||||
|
|
||||||
- **Two env-key dialects**: production `.env` uses `RTSP_URL`, `DB_PATH`, `MODEL_PATH`,
|
- **Unified config**: `config.yaml` is canonical (stream/models/counting/batch/
|
||||||
… (`predict.py`, `counter_dashboard.py`); `src/config.py` reads different keys
|
output/camera via `src/config_loader.py`); `.env` holds secrets + deployment
|
||||||
(`LOCAL_RTSP`, `MODEL_SACK_PATH`, `MODEL_TRUCK_PATH`, …). Check which loader your
|
only (`RTSP_URL`, dashboard host/ports/secret/site); `zones.json` holds
|
||||||
entry point uses before adding config.
|
geometry (polygons + left/right limits — knob keys there are ignored, warned);
|
||||||
|
`cfg/tracker.yaml` holds tracker hyperparams. `src/config.py` (v3 keys like
|
||||||
|
`LOCAL_RTSP`) is deprecated — don't add keys there. Dashboard mode switches
|
||||||
|
write `config.yaml` `models.active_mode` (atomic, manual restart to apply);
|
||||||
|
`batch_mode.json` keeps only manual/auto batch mode.
|
||||||
- **Counting filters by class name, not ID**: `SackDetector`/`BoxDetector`/
|
- **Counting filters by class name, not ID**: `SackDetector`/`BoxDetector`/
|
||||||
`TruckDetector` filter via `BaseDetector(class_filter)` (`src/detection.py`);
|
`TruckDetector` filter via `BaseDetector(class_filter)` (`src/detection.py`);
|
||||||
tracker keeps `("sack", "truck", "box")` (`src/tracking.py`); counting uses
|
tracker keeps `("sack", "truck", "box")` (`src/tracking.py`); counting uses
|
||||||
|
|||||||
+133
@@ -0,0 +1,133 @@
|
|||||||
|
# config.yaml — canonical configuration for the production pipeline
|
||||||
|
# (predict.py + counter_dashboard.py). Single source of truth.
|
||||||
|
#
|
||||||
|
# Secrets & deployment-specific values (RTSP_URL, dashboard host/ports,
|
||||||
|
# SECRET_KEY, SITE_NAME) stay in .env — they are NOT duplicated here.
|
||||||
|
# Zone polygons stay in zones.json. Tracker hyperparams stay in cfg/tracker.yaml.
|
||||||
|
#
|
||||||
|
# Model modes: add new entries under models.modes (E, F, ...) without code changes.
|
||||||
|
# A mode only selects engines + class filters; per-class conf/iou/min_bbox_area
|
||||||
|
# live in models.detection_params and apply to ALL modes.
|
||||||
|
|
||||||
|
stream:
|
||||||
|
resolution: [1280, 720]
|
||||||
|
inference_stride: 2
|
||||||
|
|
||||||
|
models:
|
||||||
|
active_mode: "C" # A|B|C|D (+ future E, F, ...). Dashboard switch writes here (manual restart to apply).
|
||||||
|
|
||||||
|
# Engine file paths (relative to repo root). Mode `engines` entries reference these keys.
|
||||||
|
paths:
|
||||||
|
combined: "models/v4-best.engine"
|
||||||
|
truck_only: "models/v4-best.engine"
|
||||||
|
sack_only: "models/best.engine"
|
||||||
|
box: "models/yolo11n-bbox-100ep-sack+box-20260909-best.engine"
|
||||||
|
truck_detector: "models/truck-detector.engine"
|
||||||
|
|
||||||
|
# Mode presets: engines to load + class filters. NOTHING else per mode.
|
||||||
|
# Each engine declares the classes it contributes, so tracker roles
|
||||||
|
# (shared vs dedicated, separate truck model) derive structurally —
|
||||||
|
# new modes need zero Python changes.
|
||||||
|
modes:
|
||||||
|
A:
|
||||||
|
description: "Combined v4 sack+truck only (legacy, no box counting)"
|
||||||
|
engines:
|
||||||
|
- path: combined
|
||||||
|
classes: [truck, sack]
|
||||||
|
class_filters:
|
||||||
|
truck: ["truck"]
|
||||||
|
sack: ["sack"]
|
||||||
|
box: []
|
||||||
|
B:
|
||||||
|
description: "v4 truck-only + yolo11n sack+box (shared tracker, shared ID space)"
|
||||||
|
engines:
|
||||||
|
- path: truck_only
|
||||||
|
classes: [truck]
|
||||||
|
- path: box
|
||||||
|
classes: [sack, box]
|
||||||
|
class_filters:
|
||||||
|
truck: ["truck"]
|
||||||
|
sack: ["sack"]
|
||||||
|
box: ["box"]
|
||||||
|
C:
|
||||||
|
description: "Combined v4 sack+truck + yolo11n box-only (dedicated tracker)"
|
||||||
|
engines:
|
||||||
|
- path: combined
|
||||||
|
classes: [truck, sack]
|
||||||
|
- path: box
|
||||||
|
classes: [box]
|
||||||
|
class_filters:
|
||||||
|
truck: ["truck"]
|
||||||
|
sack: ["sack"]
|
||||||
|
box: ["box"]
|
||||||
|
D:
|
||||||
|
description: "v4 truck-only + best sack-only + yolo11n box-only (3 engines)"
|
||||||
|
engines:
|
||||||
|
- path: truck_only
|
||||||
|
classes: [truck]
|
||||||
|
- path: sack_only
|
||||||
|
classes: [sack]
|
||||||
|
- path: box
|
||||||
|
classes: [box]
|
||||||
|
class_filters:
|
||||||
|
truck: ["truck"]
|
||||||
|
sack: ["sack"]
|
||||||
|
box: ["box"]
|
||||||
|
|
||||||
|
# Shared per-class detection params (ALL modes). iou default = Ultralytics default.
|
||||||
|
# min_bbox_area is a permissive guardrail in px^2 @1280x720 (existing
|
||||||
|
# perspective min_valid_area filter still applies on top).
|
||||||
|
detection_params:
|
||||||
|
truck:
|
||||||
|
conf: 0.35
|
||||||
|
iou: 0.7
|
||||||
|
min_bbox_area: 5000
|
||||||
|
sack:
|
||||||
|
conf: 0.35
|
||||||
|
iou: 0.7
|
||||||
|
min_bbox_area: 1500
|
||||||
|
box:
|
||||||
|
conf: 0.35
|
||||||
|
iou: 0.7
|
||||||
|
min_bbox_area: 1500
|
||||||
|
|
||||||
|
# Polygons live in zones.json (kept separate: site calibration cadence).
|
||||||
|
zones:
|
||||||
|
config_file: "zones.json"
|
||||||
|
|
||||||
|
# Tracker hyperparams live in cfg/tracker.yaml (kept separate: ML tuning cadence).
|
||||||
|
tracker:
|
||||||
|
config_file: "cfg/tracker.yaml"
|
||||||
|
|
||||||
|
counting:
|
||||||
|
confirm_delay_sec: 0.5
|
||||||
|
exit_confirm_delay_sec: 6.0
|
||||||
|
entry_overlap_threshold: 0.20
|
||||||
|
exit_overlap_threshold: 0.05
|
||||||
|
camera_noise_deadband: 50
|
||||||
|
# Effective values below match zones.json (which previously overrode predict.py defaults).
|
||||||
|
duplicate_circle_radius: 30
|
||||||
|
min_valid_area: 15000
|
||||||
|
max_reid_transit_distance: 400
|
||||||
|
circle_stay_timeout_sec: 10.0
|
||||||
|
jarak_toleransi_duplikat: 30
|
||||||
|
tolerance_missing_frames: 1200
|
||||||
|
|
||||||
|
batch:
|
||||||
|
timeout_seconds: 30.0
|
||||||
|
merge_threshold_seconds: 300
|
||||||
|
daily_cutoff_time: "06:00"
|
||||||
|
|
||||||
|
output:
|
||||||
|
dir: "/opt/jetson-counter"
|
||||||
|
db_name: "jetson_counter.db"
|
||||||
|
state_file: "current_batch.json"
|
||||||
|
# batch_mode_file is DEPRECATED: dashboard now persists the mode switch to
|
||||||
|
# models.active_mode in this file. Key kept so old deployments can be detected.
|
||||||
|
batch_mode_file: "batch_mode.json"
|
||||||
|
live_frame_path: "/dev/shm/jetson-counter/live_frame.jpg"
|
||||||
|
live_status_path: "/dev/shm/jetson-counter/live_status.json"
|
||||||
|
|
||||||
|
camera:
|
||||||
|
name: "CC1"
|
||||||
|
object_label: "karung-pakan"
|
||||||
+75
-31
@@ -21,9 +21,26 @@ from werkzeug.serving import WSGIRequestHandler
|
|||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
|
# Unified config (config.yaml canonical; .env supplies secrets/deployment-only
|
||||||
|
# values: RTSP_URL, dashboard host/ports/secret/site). Env vars still override
|
||||||
|
# file paths when explicitly set (backward compatible with old deployments).
|
||||||
|
from src.config_loader import load_config, set_active_mode
|
||||||
|
|
||||||
|
_CONFIG_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "config.yaml")
|
||||||
|
CFG = load_config(_CONFIG_PATH)
|
||||||
|
|
||||||
|
|
||||||
|
def _env_or(cfg_value, *env_keys):
|
||||||
|
"""Backward compat: explicit env var wins over config.yaml for file paths."""
|
||||||
|
for k in env_keys:
|
||||||
|
v = os.getenv(k)
|
||||||
|
if v:
|
||||||
|
return v
|
||||||
|
return cfg_value
|
||||||
|
|
||||||
|
|
||||||
app = Flask(__name__, template_folder="templates")
|
app = Flask(__name__, template_folder="templates")
|
||||||
app.config["SECRET_KEY"] = os.getenv("SECRET_KEY", "change-me-in-production")
|
app.config["SECRET_KEY"] = CFG.dashboard.secret_key
|
||||||
|
|
||||||
if os.name == "nt":
|
if os.name == "nt":
|
||||||
_DEFAULT_DIR = "d:/Belajar/menghitung karung"
|
_DEFAULT_DIR = "d:/Belajar/menghitung karung"
|
||||||
@@ -31,20 +48,25 @@ if os.name == "nt":
|
|||||||
CURRENT_BATCH_PATH = f"{_DEFAULT_DIR}/current_batch.json"
|
CURRENT_BATCH_PATH = f"{_DEFAULT_DIR}/current_batch.json"
|
||||||
BATCH_MODE_PATH = f"{_DEFAULT_DIR}/batch_mode.json"
|
BATCH_MODE_PATH = f"{_DEFAULT_DIR}/batch_mode.json"
|
||||||
LIVE_STREAM_FRAME_PATH = f"{_DEFAULT_DIR}/live_frame.jpg"
|
LIVE_STREAM_FRAME_PATH = f"{_DEFAULT_DIR}/live_frame.jpg"
|
||||||
|
LIVE_STATUS_FILE = f"{_DEFAULT_DIR}/live_status.json"
|
||||||
else:
|
else:
|
||||||
_DEFAULT_DIR = "/opt/jetson-counter"
|
_DEFAULT_DIR = CFG.output.dir
|
||||||
DB_PATH = os.getenv("DB_PATH", f"{_DEFAULT_DIR}/jetson_counter.db")
|
DB_PATH = _env_or(os.path.join(_DEFAULT_DIR, CFG.output.db_name), "DB_PATH")
|
||||||
CURRENT_BATCH_PATH = os.getenv("STATE_FILE", os.getenv("CURRENT_BATCH_PATH", f"{_DEFAULT_DIR}/current_batch.json"))
|
CURRENT_BATCH_PATH = _env_or(
|
||||||
BATCH_MODE_PATH = os.getenv("BATCH_MODE_FILE", f"{_DEFAULT_DIR}/batch_mode.json")
|
os.path.join(_DEFAULT_DIR, CFG.output.state_file),
|
||||||
LIVE_STREAM_FRAME_PATH = os.getenv("LIVE_STREAM_FRAME_PATH", "/dev/shm/jetson-counter/live_frame.jpg")
|
"STATE_FILE", "CURRENT_BATCH_PATH")
|
||||||
|
BATCH_MODE_PATH = _env_or(
|
||||||
|
os.path.join(_DEFAULT_DIR, CFG.output.batch_mode_file), "BATCH_MODE_FILE")
|
||||||
|
LIVE_STREAM_FRAME_PATH = _env_or(CFG.output.live_frame_path, "LIVE_STREAM_FRAME_PATH")
|
||||||
|
LIVE_STATUS_FILE = _env_or(CFG.output.live_status_path, "LIVE_STATUS_FILE")
|
||||||
|
|
||||||
CUTOFF_TIME = os.getenv("CUTOFF_TIME", os.getenv("DAILY_CUTOFF_TIME", "20:00"))
|
CUTOFF_TIME = _env_or(CFG.batch.daily_cutoff_time, "CUTOFF_TIME", "DAILY_CUTOFF_TIME")
|
||||||
|
|
||||||
SITE_NAME = os.getenv("SITE_NAME", "LIVE")
|
SITE_NAME = CFG.dashboard.site_name
|
||||||
|
|
||||||
DASHBOARD_PORT = int(os.getenv("DASHBOARD_PORT", "5000"))
|
DASHBOARD_PORT = CFG.dashboard.port
|
||||||
DASHBOARD_HOST = os.getenv("DASHBOARD_HOST", "0.0.0.0")
|
DASHBOARD_HOST = CFG.dashboard.host
|
||||||
FLASK_DEBUG = os.getenv("FLASK_DEBUG", "false").lower() == "true"
|
FLASK_DEBUG = CFG.dashboard.debug
|
||||||
|
|
||||||
@app.route("/api/live-video")
|
@app.route("/api/live-video")
|
||||||
def api_live_video():
|
def api_live_video():
|
||||||
@@ -160,9 +182,9 @@ def get_counting_date(dt=None, cutoff_str=CUTOFF_TIME):
|
|||||||
return dt.date().isoformat()
|
return dt.date().isoformat()
|
||||||
|
|
||||||
|
|
||||||
CAMERA_NAME = os.getenv("CAMERA_NAME", "CC1")
|
CAMERA_NAME = _env_or(CFG.camera.name, "CAMERA_NAME")
|
||||||
OBJECT_LABEL = os.getenv("OBJECT_LABEL", "karung-pakan")
|
OBJECT_LABEL = _env_or(CFG.camera.object_label, "OBJECT_LABEL")
|
||||||
OFFICE_PORT = int(os.getenv("OFFICE_PORT", "5721"))
|
OFFICE_PORT = CFG.dashboard.office_port
|
||||||
|
|
||||||
def is_office_request():
|
def is_office_request():
|
||||||
"""Check if request comes from office port."""
|
"""Check if request comes from office port."""
|
||||||
@@ -359,25 +381,35 @@ def api_batch_stop():
|
|||||||
return jsonify({"success": False, "error": str(e)}), 500
|
return jsonify({"success": False, "error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
MODEL_MODE_CHOICES = ("A", "B", "C", "D")
|
# Model modes are DATA in config.yaml models.modes — derived here so future
|
||||||
|
# modes (E, F, ...) appear automatically. batch_mode.json keeps ONLY the
|
||||||
|
# manual/auto batch "mode"; model_mode lives in config.yaml now.
|
||||||
|
MODEL_MODE_CHOICES = tuple(CFG.models.modes.keys())
|
||||||
MODEL_MODE_DESCRIPTIONS = {
|
MODEL_MODE_DESCRIPTIONS = {
|
||||||
"A": "Combined v4 sack+truck only (legacy)",
|
m: preset.description for m, preset in CFG.models.modes.items()
|
||||||
"B": "v4 truck-only + yolo11n sack+box",
|
|
||||||
"C": "Combined v4 sack+truck + yolo11n box-only (default production)",
|
|
||||||
"D": "v4 truck + best.pt sack-only + yolo11n box-only",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _active_model_mode() -> str:
|
||||||
|
"""Canonical model mode: config.yaml models.active_mode (validated)."""
|
||||||
|
try:
|
||||||
|
return load_config(_CONFIG_PATH).models.active_mode
|
||||||
|
except Exception:
|
||||||
|
return CFG.models.active_mode
|
||||||
|
|
||||||
|
|
||||||
def _read_batch_mode_file():
|
def _read_batch_mode_file():
|
||||||
data = {"mode": "manual", "model_mode": "C"}
|
data = {"mode": "manual", "model_mode": _active_model_mode()}
|
||||||
if os.path.exists(BATCH_MODE_PATH):
|
if os.path.exists(BATCH_MODE_PATH):
|
||||||
try:
|
try:
|
||||||
with open(BATCH_MODE_PATH, "r") as f:
|
with open(BATCH_MODE_PATH, "r") as f:
|
||||||
stored = json.load(f)
|
stored = json.load(f)
|
||||||
data["mode"] = stored.get("mode", "manual")
|
data["mode"] = stored.get("mode", "manual")
|
||||||
data["model_mode"] = (stored.get("model_mode") or "C").upper()
|
# Legacy model_mode in batch_mode.json is IGNORED (config.yaml wins).
|
||||||
if data["model_mode"] not in MODEL_MODE_CHOICES:
|
legacy = (stored.get("model_mode") or "").upper()
|
||||||
data["model_mode"] = "C"
|
if legacy and legacy != data["model_mode"]:
|
||||||
|
print(f"[WARN] batch_mode.json model_mode={legacy!r} diabaikan — "
|
||||||
|
f"config.yaml active_mode={data['model_mode']!r} yang berlaku.")
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
return data
|
return data
|
||||||
@@ -395,20 +427,32 @@ def api_batch_mode():
|
|||||||
return jsonify({"success": False, "error": "Invalid mode. Use 'auto' or 'manual'"}), 400
|
return jsonify({"success": False, "error": "Invalid mode. Use 'auto' or 'manual'"}), 400
|
||||||
stored["mode"] = mode
|
stored["mode"] = mode
|
||||||
if "model_mode" in req_data:
|
if "model_mode" in req_data:
|
||||||
mmode = str(req_data.get("model_mode", "C")).upper()
|
mmode = str(req_data.get("model_mode", "")).upper()
|
||||||
if mmode not in MODEL_MODE_CHOICES:
|
try:
|
||||||
return jsonify({"success": False, "error": "Invalid model_mode. Use A/B/C/D"}), 400
|
# Validates against config.yaml models.modes, then persists
|
||||||
|
# atomically (tmp+replace, comments preserved). Manual
|
||||||
|
# `karung-counter` restart still required to apply.
|
||||||
|
set_active_mode(_CONFIG_PATH, mmode)
|
||||||
|
except (ValueError, RuntimeError) as ve:
|
||||||
|
return jsonify({
|
||||||
|
"success": False,
|
||||||
|
"error": f"Invalid model_mode. Use one of {sorted(MODEL_MODE_CHOICES)}: {ve}",
|
||||||
|
}), 400
|
||||||
stored["model_mode"] = mmode
|
stored["model_mode"] = mmode
|
||||||
stored["updated_at"] = datetime.now().isoformat()
|
stored["updated_at"] = datetime.now().isoformat()
|
||||||
|
|
||||||
os.makedirs(os.path.dirname(BATCH_MODE_PATH), exist_ok=True)
|
# batch_mode.json keeps ONLY the manual/auto batch mode now.
|
||||||
with open(BATCH_MODE_PATH, "w", encoding="utf-8") as f:
|
batch_state = {"mode": stored["mode"], "updated_at": stored["updated_at"]}
|
||||||
json.dump(stored, f, indent=2)
|
os.makedirs(os.path.dirname(BATCH_MODE_PATH) or ".", exist_ok=True)
|
||||||
|
tmp_path = BATCH_MODE_PATH + ".tmp"
|
||||||
|
with open(tmp_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(batch_state, f, indent=2)
|
||||||
|
os.replace(tmp_path, BATCH_MODE_PATH)
|
||||||
|
|
||||||
return jsonify({"success": True, "mode": stored["mode"],
|
return jsonify({"success": True, "mode": stored["mode"],
|
||||||
"model_mode": stored["model_mode"],
|
"model_mode": stored["model_mode"],
|
||||||
"message": f"Batch mode={stored['mode']}, model_mode={stored['model_mode']} "
|
"message": f"Batch mode={stored['mode']}, model_mode={stored['model_mode']} "
|
||||||
f"(model_mode applies on next service restart)"})
|
f"(model_mode applies on next karung-counter restart)"})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({"success": False, "error": str(e)}), 500
|
return jsonify({"success": False, "error": str(e)}), 500
|
||||||
|
|
||||||
@@ -432,7 +476,7 @@ def api_model_modes():
|
|||||||
@app.route("/api/current-batch")
|
@app.route("/api/current-batch")
|
||||||
def api_current_batch():
|
def api_current_batch():
|
||||||
fps_val = 0.0
|
fps_val = 0.0
|
||||||
status_file = os.getenv('LIVE_STATUS_FILE', '/dev/shm/jetson-counter/live_status.json' if os.name != 'nt' else 'd:/Belajar/menghitung karung/live_status.json')
|
status_file = LIVE_STATUS_FILE
|
||||||
try:
|
try:
|
||||||
if os.path.exists(status_file):
|
if os.path.exists(status_file):
|
||||||
with open(status_file, "r") as sf:
|
with open(status_file, "r") as sf:
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ files_to_sync = [
|
|||||||
("templates/base.html", "/home/jetson/karung/templates/base.html"),
|
("templates/base.html", "/home/jetson/karung/templates/base.html"),
|
||||||
("counter_dashboard.py", "/home/jetson/karung/counter_dashboard.py"),
|
("counter_dashboard.py", "/home/jetson/karung/counter_dashboard.py"),
|
||||||
("predict.py", "/home/jetson/karung/predict.py"),
|
("predict.py", "/home/jetson/karung/predict.py"),
|
||||||
|
("config.yaml", "/home/jetson/karung/config.yaml"),
|
||||||
(".env", "/home/jetson/karung/.env"),
|
(".env", "/home/jetson/karung/.env"),
|
||||||
] + [
|
] + [
|
||||||
(f"models/{f}", f"/home/jetson/karung/models/{f}") for f in MODEL_ENGINES
|
(f"models/{f}", f"/home/jetson/karung/models/{f}") for f in MODEL_ENGINES
|
||||||
|
|||||||
+31
-4
@@ -1,9 +1,36 @@
|
|||||||
# Configuration
|
# Configuration
|
||||||
|
|
||||||
|
Canonical source: **`config.yaml`** (repo root) — stream, models, counting knobs,
|
||||||
|
batch, output paths, camera. Loaded once at startup via `src/config_loader.py`
|
||||||
|
(stdlib dataclasses + pyyaml, no heavy deps). Secrets & deployment-only values
|
||||||
|
stay in `.env`. Zone polygons stay in `zones.json`. Tracker hyperparams stay in
|
||||||
|
`cfg/tracker.yaml`.
|
||||||
|
|
||||||
|
```
|
||||||
|
config.yaml canonical: stream/models/counting/batch/output/camera
|
||||||
|
.env secrets + deployment: RTSP_URL, dashboard host/ports/secret/site
|
||||||
|
zones.json geometry: palet/truck/counting polygons + left/right limits
|
||||||
|
cfg/tracker.yaml tracker hyperparams (FastTrack/ByteTrack tuning)
|
||||||
|
```
|
||||||
|
|
||||||
|
Model modes are **data** (`config.yaml` → `models.modes`): each preset declares
|
||||||
|
only `engines` (path key + contributed classes) and `class_filters`.
|
||||||
|
Per-class `conf`/`iou`/`min_bbox_area` live in `models.detection_params` and
|
||||||
|
apply to ALL modes. Adding mode E/F/... is a YAML-only change — `predict.py`
|
||||||
|
derives tracker roles structurally, and the dashboard `/api/model-modes`
|
||||||
|
endpoint lists them automatically.
|
||||||
|
|
||||||
|
Mode switch: dashboard `POST /api/batch/mode {"model_mode": "X"}` validates
|
||||||
|
against `config.yaml` and persists atomically (tmp+replace, comments preserved)
|
||||||
|
to `models.active_mode`. **Manual `karung-counter` restart still required**
|
||||||
|
(models load once at startup). `batch_mode.json` keeps only the manual/auto
|
||||||
|
batch `mode`; its legacy `model_mode` key is ignored (warned). `MODEL_MODE`
|
||||||
|
env var still overrides for one run but is deprecated (warned).
|
||||||
|
|
||||||
Three layers: environment file → zone polygons → tracker/counter tuning.
|
Three layers: environment file → zone polygons → tracker/counter tuning.
|
||||||
Template: `.env.example`. Production values live in `.env` (git-ignored).
|
Template: `.env.example`. Production values live in `.env` (git-ignored).
|
||||||
|
|
||||||
## 1. `.env` (production keys — `predict.py` / `counter_dashboard.py`)
|
## 1. `.env` (secrets & deployment — `predict.py` / `counter_dashboard.py`)
|
||||||
|
|
||||||
| Key | Default | Meaning |
|
| Key | Default | Meaning |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -19,9 +46,9 @@ Template: `.env.example`. Production values live in `.env` (git-ignored).
|
|||||||
| `DASHBOARD_HOST` / `DASHBOARD_PORT` | `0.0.0.0` / `5000` | Dashboard bind |
|
| `DASHBOARD_HOST` / `DASHBOARD_PORT` | `0.0.0.0` / `5000` | Dashboard bind |
|
||||||
| `OFFICE_PORT` | `5721` | Second dashboard port |
|
| `OFFICE_PORT` | `5721` | Second dashboard port |
|
||||||
| `FLASK_DEBUG` | `false` | Flask debug |
|
| `FLASK_DEBUG` | `false` | Flask debug |
|
||||||
| `RTSP_URL` | — | Camera stream URL |
|
| `RTSP_URL` | — | Camera stream URL (env-only, never in YAML) |
|
||||||
| `MODEL_PATH` | auto (`models/v4-best.engine` > `.pt` > `v4-best (1).pt` > `models/model_karung_truk.engine` > `.pt`) | Override combined-model weights (`predict.py`) |
|
| `MODEL_PATH` | — (deprecated) | Single-file v4 override, folded into `models.paths` |
|
||||||
| `MODEL_MODE` | `C` | Model pipeline mode A/B/C/D (see `models/modelREADME.md`); also settable via `--model-mode` or dashboard (applies on restart) |
|
| `MODEL_MODE` | — (deprecated) | One-run override of `models.active_mode` (warned) |
|
||||||
| `BATCH_MERGE_THRESHOLD_SECONDS` | `300` | Merge window for adjacent batches |
|
| `BATCH_MERGE_THRESHOLD_SECONDS` | `300` | Merge window for adjacent batches |
|
||||||
|
|
||||||
On Windows dev machines these resolve to `d:/Belajar/menghitung karung/...`.
|
On Windows dev machines these resolve to `d:/Belajar/menghitung karung/...`.
|
||||||
|
|||||||
@@ -18,7 +18,13 @@ Class names below are read directly from each checkpoint (`YOLO(path).names`).
|
|||||||
| `best.{pt,onnx,engine}` | `{0: sack}` | seg | Sack-only specialist (Mode D sack) |
|
| `best.{pt,onnx,engine}` | `{0: sack}` | seg | Sack-only specialist (Mode D sack) |
|
||||||
| `karung-dimuat-detection-di-feedmill-yolo26n-seg-200e.{pt,onnx,engine}` | `{0: person, 1: sack}` | **seg** | Person-exclusion seg model (legacy `predict_new.py`) |
|
| `karung-dimuat-detection-di-feedmill-yolo26n-seg-200e.{pt,onnx,engine}` | `{0: person, 1: sack}` | **seg** | Person-exclusion seg model (legacy `predict_new.py`) |
|
||||||
|
|
||||||
## Model Modes (`predict.py --model-mode`, default **C**)
|
## Model Modes (`predict.py --model-mode` / `config.yaml models.active_mode`, default **C**)
|
||||||
|
|
||||||
|
Modes are DATA in `config.yaml` `models.modes` — each preset declares engines
|
||||||
|
(path key + contributed classes) and class filters only. Per-class
|
||||||
|
conf/iou/min_bbox live in `models.detection_params` (shared across modes).
|
||||||
|
Add E/F/... in YAML with no code change; `predict.py` derives tracker roles
|
||||||
|
structurally and the dashboard lists new modes automatically.
|
||||||
|
|
||||||
Counting filters by **class name, not ID** (`BaseDetector(class_filter)` in
|
Counting filters by **class name, not ID** (`BaseDetector(class_filter)` in
|
||||||
`src/detection.py`; tracker allow-list in `src/tracking.py`; dual counters in
|
`src/detection.py`; tracker allow-list in `src/tracking.py`; dual counters in
|
||||||
|
|||||||
+223
-138
@@ -24,6 +24,14 @@ from src.truck_roi import TruckROITracker
|
|||||||
from src.counting import LineCrossCounter, MultiClassLineCounter
|
from src.counting import LineCrossCounter, MultiClassLineCounter
|
||||||
from src.batch import BatchLifecycleManager, BatchRecord
|
from src.batch import BatchLifecycleManager, BatchRecord
|
||||||
from src.dashboard import DashboardOverlay
|
from src.dashboard import DashboardOverlay
|
||||||
|
from src.config_loader import (
|
||||||
|
Config,
|
||||||
|
check_legacy_batch_mode,
|
||||||
|
load_config,
|
||||||
|
read_zone_polygons,
|
||||||
|
resolve_active_mode,
|
||||||
|
LEGACY_ZONE_KNOBS,
|
||||||
|
)
|
||||||
|
|
||||||
# --- SQLite Database & State Configuration ---
|
# --- SQLite Database & State Configuration ---
|
||||||
if os.name == 'nt':
|
if os.name == 'nt':
|
||||||
@@ -75,10 +83,12 @@ def parse_args():
|
|||||||
p.add_argument("--box-model", type=str, default=None,
|
p.add_argument("--box-model", type=str, default=None,
|
||||||
help="Override yolo11n sack+box model path (.pt/.engine)")
|
help="Override yolo11n sack+box model path (.pt/.engine)")
|
||||||
p.add_argument("--model-mode", type=str, default=None,
|
p.add_argument("--model-mode", type=str, default=None,
|
||||||
choices=["A", "B", "C", "D"],
|
help="Model pipeline mode (default: config.yaml models.active_mode). "
|
||||||
help="Model pipeline mode (default: MODEL_MODE env or C). "
|
"A=combined only; B=v4 truck + yolo11n sack+box; "
|
||||||
"A=combined only; B=v4 truck + yolo11n sack+box; "
|
"C=A + yolo11n box-only; D=v4 truck + best sack + yolo11n box. "
|
||||||
"C=A + yolo11n box-only; D=v4 truck + best sack + yolo11n box")
|
"New modes from config.yaml need no code change.")
|
||||||
|
p.add_argument("--config", type=str, default=None,
|
||||||
|
help="Path to config.yaml (default: config.yaml next to predict.py)")
|
||||||
p.add_argument("--batch-timeout", type=float, default=None,
|
p.add_argument("--batch-timeout", type=float, default=None,
|
||||||
help="Override sack-idle + truck-gone timeouts (seconds)")
|
help="Override sack-idle + truck-gone timeouts (seconds)")
|
||||||
p.add_argument("--max-frames", type=int, default=None,
|
p.add_argument("--max-frames", type=int, default=None,
|
||||||
@@ -443,15 +453,22 @@ all_counted_sacks_map = {}
|
|||||||
last_seen_near_person_frame = {}
|
last_seen_near_person_frame = {}
|
||||||
blocked_due_to_duplicate = {}
|
blocked_due_to_duplicate = {}
|
||||||
|
|
||||||
# --- Model Paths ---
|
# --- Model paths & config ---
|
||||||
# All weights live in models/ (see models/modelREADME.md for mode/filter matrix).
|
# All weights live in models/ (see models/modelREADME.md for mode/filter matrix
|
||||||
# Model gabungan karung + truk terbaru (v4-best TensorRT / PyTorch)
|
# and config.yaml models.* for the canonical paths + per-mode presets).
|
||||||
_BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
_BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
_MODELS_DIR = os.path.join(_BASE_DIR, "models")
|
_MODELS_DIR = os.path.join(_BASE_DIR, "models")
|
||||||
_env_model = os.getenv("MODEL_PATH")
|
_DEFAULT_CONFIG_PATH = os.path.join(_BASE_DIR, "config.yaml")
|
||||||
if _env_model and os.path.exists(_env_model):
|
|
||||||
COMBINED_MODEL_PATH = _env_model
|
# Active Config object (set in __main__/run_prediction before the pipeline starts).
|
||||||
else:
|
_CFG: Config | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _legacy_combined_fallback() -> str:
|
||||||
|
"""Pre-YAML combined-model auto-pick (kept for --model/MODEL_PATH handling)."""
|
||||||
|
_env_model = os.getenv("MODEL_PATH")
|
||||||
|
if _env_model and os.path.exists(_env_model):
|
||||||
|
return _env_model
|
||||||
_candidates = [
|
_candidates = [
|
||||||
os.path.join(_MODELS_DIR, "v4-best.engine"),
|
os.path.join(_MODELS_DIR, "v4-best.engine"),
|
||||||
os.path.join(_MODELS_DIR, "v4-best.pt"),
|
os.path.join(_MODELS_DIR, "v4-best.pt"),
|
||||||
@@ -459,123 +476,134 @@ else:
|
|||||||
os.path.join(_MODELS_DIR, "model_karung_truk.engine"),
|
os.path.join(_MODELS_DIR, "model_karung_truk.engine"),
|
||||||
os.path.join(_MODELS_DIR, "model_karung_truk.pt"),
|
os.path.join(_MODELS_DIR, "model_karung_truk.pt"),
|
||||||
]
|
]
|
||||||
COMBINED_MODEL_PATH = next((p for p in _candidates if os.path.exists(p)), os.path.join(_MODELS_DIR, "v4-best.pt"))
|
return next((p for p in _candidates if os.path.exists(p)),
|
||||||
|
os.path.join(_MODELS_DIR, "v4-best.pt"))
|
||||||
|
|
||||||
|
|
||||||
def _pick_box_model(explicit: str | None) -> str:
|
def apply_cli_overrides_to_config(cfg: Config, args) -> Config:
|
||||||
"""Resolve yolo11n sack+box weights: explicit > .engine > .pt."""
|
"""Fold legacy CLI/env model overrides into the Config object (in place).
|
||||||
if explicit:
|
|
||||||
return explicit
|
|
||||||
for cand in (
|
|
||||||
os.path.join(_MODELS_DIR, "yolo11n-bbox-100ep-sack+box-20260909-best.engine"),
|
|
||||||
os.path.join(_MODELS_DIR, "yolo11n-bbox-100ep-sack+box-20260909-best.pt"),
|
|
||||||
):
|
|
||||||
if os.path.exists(cand):
|
|
||||||
return cand
|
|
||||||
return os.path.join(_MODELS_DIR, "yolo11n-bbox-100ep-sack+box-20260909-best.engine")
|
|
||||||
|
|
||||||
|
--model/--box-model/MODEL_PATH keep their pre-YAML meaning:
|
||||||
def _pick_sack_only_model() -> str:
|
a single v4 file used wherever a v4 engine is needed.
|
||||||
"""Resolve best sack-only weights: .engine > .pt."""
|
--sack-conf/--truck-conf/--box-conf override detection_params conf.
|
||||||
for cand in (
|
"""
|
||||||
os.path.join(_MODELS_DIR, "best.engine"),
|
v4_override = args.model or os.getenv("MODEL_PATH")
|
||||||
os.path.join(_MODELS_DIR, "best.pt"),
|
if v4_override:
|
||||||
):
|
if not os.path.exists(v4_override):
|
||||||
if os.path.exists(cand):
|
print(f"[WARN] --model/MODEL_PATH {v4_override} tidak ditemukan, "
|
||||||
return cand
|
f"dipakai langsung (YOLO bisa resolve).")
|
||||||
return os.path.join(_MODELS_DIR, "best.engine")
|
for key in ("combined", "truck_only"):
|
||||||
|
if key in cfg.models.paths:
|
||||||
|
cfg.models.paths[key] = v4_override
|
||||||
|
if getattr(args, "box_model", None):
|
||||||
|
cfg.models.paths["box"] = args.box_model
|
||||||
|
for cls_name, val in (("sack", args.sack_conf), ("truck", args.truck_conf),
|
||||||
|
("box", args.box_conf)):
|
||||||
|
if val is not None and cls_name in cfg.models.detection_params:
|
||||||
|
cfg.models.detection_params[cls_name].conf = float(val)
|
||||||
|
if getattr(args, "batch_timeout", None) is not None:
|
||||||
|
cfg.batch.timeout_seconds = float(args.batch_timeout)
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
# --- Model pipeline modes -------------------------------------------------
|
# --- Model pipeline modes -------------------------------------------------
|
||||||
# A: combined v4 sack+truck only (legacy production).
|
# Modes are DATA in config.yaml models.modes (engines + class filters only).
|
||||||
# B: v4 truck-only + yolo11n sack+box.
|
# A=combined only; B=v4 truck + yolo11n sack+box; C=A + yolo11n box-only (default);
|
||||||
# C: A + yolo11n box-only (default production).
|
# D=v4 truck + best sack-only + yolo11n box-only. New modes need no code change.
|
||||||
# D: v4 truck + best.pt sack-only + yolo11n box-only.
|
|
||||||
# All engines verified to coexist (~16-24 MB peak of 7.6 GB).
|
# All engines verified to coexist (~16-24 MB peak of 7.6 GB).
|
||||||
MODEL_MODES = ("A", "B", "C", "D")
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_model_mode(explicit: str | None) -> str:
|
def resolve_model_mode(explicit: str | None) -> str:
|
||||||
"""Precedence: --model-mode > MODEL_MODE env > batch_mode.json model_mode > C.
|
"""Precedence: --model-mode > MODEL_MODE env (deprecated) > config.yaml.
|
||||||
|
|
||||||
Dashboard-driven switches persist to batch_mode.json and take effect
|
Dashboard switches persist to config.yaml models.active_mode and take effect
|
||||||
on next service restart (models are loaded once at startup).
|
on next service restart (models are loaded once at startup).
|
||||||
|
batch_mode.json is legacy and no longer consulted.
|
||||||
"""
|
"""
|
||||||
mode = (explicit or os.getenv("MODEL_MODE") or "").upper()
|
cfg = _CFG or load_config(_DEFAULT_CONFIG_PATH)
|
||||||
if not mode:
|
return resolve_active_mode(explicit, cfg)
|
||||||
try:
|
|
||||||
_bm = os.path.join(
|
|
||||||
os.getenv("OUTPUT_DIR", "/opt/jetson-counter") if os.name != "nt"
|
|
||||||
else "d:/Belajar/menghitung karung",
|
|
||||||
"batch_mode.json",
|
|
||||||
)
|
|
||||||
_bm = os.getenv("BATCH_MODE_FILE", _bm)
|
|
||||||
with open(_bm, "r", encoding="utf-8") as f:
|
|
||||||
mode = (json.load(f).get("model_mode") or "").upper()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
mode = mode or "C"
|
|
||||||
if mode not in MODEL_MODES:
|
|
||||||
print(f"[WARN] MODEL_MODE '{mode}' tidak dikenal, pakai C.")
|
|
||||||
return "C"
|
|
||||||
return mode
|
|
||||||
|
|
||||||
|
|
||||||
def build_model_pipeline(mode, combined_path, box_model_path, sack_only_path,
|
def _bbox_area_ok(d, min_area: float) -> bool:
|
||||||
sack_conf, truck_conf, box_conf, device):
|
"""Permissive per-class guardrail from config.yaml detection_params."""
|
||||||
"""Instantiate YOLO handles + detectors/trackers for a model mode.
|
x1, y1, x2, y2 = d.bbox
|
||||||
|
return (x2 - x1) * (y2 - y1) >= min_area
|
||||||
|
|
||||||
Returns dict with keys: mode, truck_model, sack_model, box_model,
|
|
||||||
truck_detector, tracker, box_tracker (None when sack tracker covers boxes).
|
def build_model_pipeline(mode, cfg, device, base_dir=None):
|
||||||
|
"""Instantiate YOLO handles + detectors/trackers for a mode preset.
|
||||||
|
|
||||||
|
Roles derive structurally from the preset's per-engine `classes`
|
||||||
|
(config.yaml models.modes) — no per-mode-letter branching, so new
|
||||||
|
modes (E, F, ...) work without code changes:
|
||||||
|
- tracker (primary): first engine covering `sack`
|
||||||
|
- truck_detector: first engine covering `truck`
|
||||||
|
(separate_truck_model = truck engine is not the primary)
|
||||||
|
- box_tracker: dedicated iff a non-primary engine covers `box`
|
||||||
|
(shared with the primary tracker otherwise, e.g. mode B)
|
||||||
|
|
||||||
|
Returns dict with keys: mode, truck_detector, tracker, box_tracker
|
||||||
|
(None when sack tracker covers boxes or no box engine), separate_truck_model,
|
||||||
|
class_filters, min_areas.
|
||||||
"""
|
"""
|
||||||
print(f"[INFO] Model mode: {mode}")
|
base_dir = base_dir or _BASE_DIR
|
||||||
|
preset = cfg.models.modes[mode]
|
||||||
|
filters = {k: tuple(v) for k, v in preset.class_filters.items()}
|
||||||
|
dp_truck = cfg.detection_params_for("truck")
|
||||||
|
dp_sack = cfg.detection_params_for("sack")
|
||||||
|
dp_box = cfg.detection_params_for("box")
|
||||||
|
print(f"[INFO] Model mode: {mode} ({preset.description})")
|
||||||
dummy = np.zeros((720, 1280, 3), dtype=np.uint8)
|
dummy = np.zeros((720, 1280, 3), dtype=np.uint8)
|
||||||
|
|
||||||
def _load(path, label):
|
handles: dict[str, object] = {}
|
||||||
print(f"[INFO] Memuat {label}: {path}")
|
|
||||||
m = YOLO(path)
|
def _load(path_key: str):
|
||||||
_ = m(dummy, imgsz=640, device=device, verbose=False) # warm-up CUDA/TRT ctx
|
if path_key not in handles:
|
||||||
return m
|
path = cfg.engine_path(path_key, base_dir)
|
||||||
|
print(f"[INFO] Memuat {path_key}: {path}")
|
||||||
|
m = YOLO(path)
|
||||||
|
_ = m(dummy, imgsz=640, device=device, verbose=False) # warm-up CUDA/TRT ctx
|
||||||
|
handles[path_key] = m
|
||||||
|
return handles[path_key]
|
||||||
|
|
||||||
|
primary_key = next((e.path for e in preset.engines if "sack" in e.classes), None)
|
||||||
|
truck_key = next((e.path for e in preset.engines if "truck" in e.classes), None)
|
||||||
|
box_keys = [e.path for e in preset.engines if "box" in e.classes]
|
||||||
|
|
||||||
|
if primary_key is None:
|
||||||
|
raise ValueError(
|
||||||
|
f"Mode {mode!r} has no engine covering class 'sack' — "
|
||||||
|
f"at least one engines[].classes must include sack."
|
||||||
|
)
|
||||||
|
|
||||||
|
tracker = ByteTrackTracker(_load(primary_key), dp_sack.conf, dp_sack.iou)
|
||||||
|
truck_detector = (
|
||||||
|
TruckDetector(_load(truck_key), dp_truck.conf,
|
||||||
|
class_filter=filters.get("truck") or ("truck",),
|
||||||
|
iou=dp_truck.iou)
|
||||||
|
if truck_key is not None else None
|
||||||
|
)
|
||||||
|
separate_truck_model = truck_key is not None and truck_key != primary_key
|
||||||
|
|
||||||
|
box_tracker = None
|
||||||
|
if box_keys and filters.get("box"):
|
||||||
|
dedicated = next((k for k in box_keys if k != primary_key), None)
|
||||||
|
if dedicated is not None:
|
||||||
|
box_tracker = ByteTrackTracker(_load(dedicated), dp_box.conf, dp_box.iou)
|
||||||
|
# else: boxes share the primary tracker (single ID space, e.g. mode B)
|
||||||
|
|
||||||
if mode == "A":
|
|
||||||
shared = _load(combined_path, "model gabungan sack+truck")
|
|
||||||
return {
|
|
||||||
"mode": mode,
|
|
||||||
"truck_detector": TruckDetector(shared, truck_conf),
|
|
||||||
"tracker": ByteTrackTracker(shared, sack_conf),
|
|
||||||
"box_tracker": None,
|
|
||||||
"separate_truck_model": False,
|
|
||||||
}
|
|
||||||
if mode == "B":
|
|
||||||
v4 = _load(combined_path, "model v4 (truck-only)")
|
|
||||||
yb = _load(box_model_path, "model yolo11n (sack+box)")
|
|
||||||
return {
|
|
||||||
"mode": mode,
|
|
||||||
"truck_detector": TruckDetector(v4, truck_conf, class_filter=("truck",)),
|
|
||||||
"tracker": ByteTrackTracker(yb, sack_conf),
|
|
||||||
"box_tracker": None, # boxes share the yolo11n tracker
|
|
||||||
"separate_truck_model": True,
|
|
||||||
}
|
|
||||||
if mode == "C":
|
|
||||||
shared = _load(combined_path, "model gabungan sack+truck")
|
|
||||||
yb = _load(box_model_path, "model yolo11n (box-only)")
|
|
||||||
return {
|
|
||||||
"mode": mode,
|
|
||||||
"truck_detector": TruckDetector(shared, truck_conf),
|
|
||||||
"tracker": ByteTrackTracker(shared, sack_conf),
|
|
||||||
"box_tracker": ByteTrackTracker(yb, box_conf),
|
|
||||||
"separate_truck_model": False,
|
|
||||||
}
|
|
||||||
# mode == "D"
|
|
||||||
v4 = _load(combined_path, "model v4 (truck-only)")
|
|
||||||
sb = _load(sack_only_path, "model best (sack-only)")
|
|
||||||
yb = _load(box_model_path, "model yolo11n (box-only)")
|
|
||||||
return {
|
return {
|
||||||
"mode": mode,
|
"mode": mode,
|
||||||
"truck_detector": TruckDetector(v4, truck_conf, class_filter=("truck",)),
|
"truck_detector": truck_detector,
|
||||||
"tracker": ByteTrackTracker(sb, sack_conf),
|
"tracker": tracker,
|
||||||
"box_tracker": ByteTrackTracker(yb, box_conf),
|
"box_tracker": box_tracker,
|
||||||
"separate_truck_model": True,
|
"separate_truck_model": separate_truck_model,
|
||||||
|
"class_filters": filters,
|
||||||
|
"min_areas": {
|
||||||
|
"truck": dp_truck.min_bbox_area,
|
||||||
|
"sack": dp_sack.min_bbox_area,
|
||||||
|
"box": dp_box.min_bbox_area,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
# =====================================================================
|
# =====================================================================
|
||||||
@@ -646,6 +674,13 @@ def load_zones():
|
|||||||
EXTERNAL_STREAM_URL_REF = data.get('external_stream_url', 'http://192.168.192.96:8888/cam/')
|
EXTERNAL_STREAM_URL_REF = data.get('external_stream_url', 'http://192.168.192.96:8888/cam/')
|
||||||
if not EXTERNAL_STREAM_URL_REF:
|
if not EXTERNAL_STREAM_URL_REF:
|
||||||
EXTERNAL_STREAM_URL_REF = 'http://192.168.192.96:8888/cam/'
|
EXTERNAL_STREAM_URL_REF = 'http://192.168.192.96:8888/cam/'
|
||||||
|
# Knob keys below are LEGACY: config.yaml counting.* is canonical and
|
||||||
|
# run_prediction overrides these globals from it. Kept reading here
|
||||||
|
# only so import-time defaults stay sane; warn once to guide migration.
|
||||||
|
_legacy_knobs = sorted(LEGACY_ZONE_KNOBS & set(data.keys()))
|
||||||
|
if _legacy_knobs:
|
||||||
|
print(f"[WARN] zones.json knob keys {_legacy_knobs} diabaikan — "
|
||||||
|
f"pakai config.yaml (counting.*) sebagai gantinya.")
|
||||||
print("[INFO] Berhasil memuat koordinat zona dan parameter kalibrasi dari zones.json")
|
print("[INFO] Berhasil memuat koordinat zona dan parameter kalibrasi dari zones.json")
|
||||||
return
|
return
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -1200,9 +1235,10 @@ def _filter_sacks_in_roi(detections, roi):
|
|||||||
|
|
||||||
def run_prediction(model_path, source_path,
|
def run_prediction(model_path, source_path,
|
||||||
output_json_path="hasil_perhitungan.json", max_frames=None,
|
output_json_path="hasil_perhitungan.json", max_frames=None,
|
||||||
inference_stride=2, sack_conf=0.35, truck_conf=0.35,
|
inference_stride=None, sack_conf=None, truck_conf=None,
|
||||||
box_conf=0.35, box_model_path=None, model_mode=None,
|
box_conf=None, box_model_path=None, model_mode=None,
|
||||||
output_dir=None, batch_timeout=None):
|
output_dir=None, batch_timeout=None,
|
||||||
|
config_path=None, cfg=None):
|
||||||
global prev_active_track_ids, lost_tracks, metrics, track_positions, counted_at_frame
|
global prev_active_track_ids, lost_tracks, metrics, track_positions, counted_at_frame
|
||||||
global track_confirmed_state, already_counted, is_locked, has_crossed_line, exit_crossed_line, track_areas
|
global track_confirmed_state, already_counted, is_locked, has_crossed_line, exit_crossed_line, track_areas
|
||||||
global pending_enter_since, pending_exit_since, track_started_in_truck, outside_truck_frames
|
global pending_enter_since, pending_exit_since, track_started_in_truck, outside_truck_frames
|
||||||
@@ -1212,25 +1248,65 @@ def run_prediction(model_path, source_path,
|
|||||||
global active_batch_info, system_state
|
global active_batch_info, system_state
|
||||||
global DUPLICATE_CIRCLE_RADIUS, MIN_VALID_AREA, JARAK_TOLERANSI_DUPLIKAT, MAX_REID_TRANSIT_DISTANCE
|
global DUPLICATE_CIRCLE_RADIUS, MIN_VALID_AREA, JARAK_TOLERANSI_DUPLIKAT, MAX_REID_TRANSIT_DISTANCE
|
||||||
global DB_PATH, STATE_FILE, BATCH_MODE_FILE, LIVE_STREAM_FRAME_PATH
|
global DB_PATH, STATE_FILE, BATCH_MODE_FILE, LIVE_STREAM_FRAME_PATH
|
||||||
|
global CAMERA_NAME, OBJECT_LABEL, DAILY_CUTOFF_TIME, BATCH_MERGE_THRESHOLD_SECONDS
|
||||||
|
global CIRCLE_STAY_TIMEOUT_SEC, _CFG
|
||||||
|
|
||||||
mode = resolve_model_mode(model_mode)
|
# --- Unified config (config.yaml canonical, .env for secrets/deployment) ---
|
||||||
if box_model_path is None:
|
global _CFG
|
||||||
box_model_path = _pick_box_model(None)
|
_CFG = cfg or load_config(config_path or _DEFAULT_CONFIG_PATH)
|
||||||
|
cfg = _CFG
|
||||||
|
|
||||||
|
# Legacy path overrides keep their pre-YAML meaning (folded into cfg).
|
||||||
|
if model_path and model_path != _legacy_combined_fallback():
|
||||||
|
for _k in ("combined", "truck_only"):
|
||||||
|
if _k in cfg.models.paths:
|
||||||
|
cfg.models.paths[_k] = model_path
|
||||||
|
if box_model_path:
|
||||||
|
cfg.models.paths["box"] = box_model_path
|
||||||
|
for _cls, _v in (("sack", sack_conf), ("truck", truck_conf), ("box", box_conf)):
|
||||||
|
if _v is not None and _cls in cfg.models.detection_params:
|
||||||
|
cfg.models.detection_params[_cls].conf = float(_v)
|
||||||
|
|
||||||
|
# --- Path / identity / timing globals from config (output_dir wins) ---
|
||||||
if output_dir:
|
if output_dir:
|
||||||
# Cross-platform: plain join, keep .env layout when output_dir is None
|
# Cross-platform: plain join, keep config layout when output_dir is None
|
||||||
DB_PATH = os.path.join(output_dir, "jetson_counter.db")
|
DB_PATH = os.path.join(output_dir, "jetson_counter.db")
|
||||||
STATE_FILE = os.path.join(output_dir, "current_batch.json")
|
STATE_FILE = os.path.join(output_dir, "current_batch.json")
|
||||||
BATCH_MODE_FILE = os.path.join(output_dir, "batch_mode.json")
|
BATCH_MODE_FILE = os.path.join(output_dir, "batch_mode.json")
|
||||||
LIVE_STREAM_FRAME_PATH = os.path.join(output_dir, "live_frame.jpg")
|
LIVE_STREAM_FRAME_PATH = os.path.join(output_dir, "live_frame.jpg")
|
||||||
print(f"[INFO] Output dir override: {output_dir}")
|
print(f"[INFO] Output dir override: {output_dir}")
|
||||||
|
else:
|
||||||
|
DB_PATH = os.path.join(cfg.output.dir, cfg.output.db_name)
|
||||||
|
STATE_FILE = os.path.join(cfg.output.dir, cfg.output.state_file)
|
||||||
|
BATCH_MODE_FILE = os.path.join(cfg.output.dir, cfg.output.batch_mode_file)
|
||||||
|
LIVE_STREAM_FRAME_PATH = cfg.output.live_frame_path
|
||||||
|
CAMERA_NAME = cfg.camera.name
|
||||||
|
OBJECT_LABEL = cfg.camera.object_label
|
||||||
|
DAILY_CUTOFF_TIME = cfg.batch.daily_cutoff_time
|
||||||
|
BATCH_MERGE_THRESHOLD_SECONDS = cfg.batch.merge_threshold_seconds
|
||||||
|
|
||||||
|
# --- Counting-knob globals from config (values match legacy zones.json) ---
|
||||||
|
CONFIRM_DELAY_SEC = cfg.counting.confirm_delay_sec
|
||||||
|
EXIT_CONFIRM_DELAY_SEC = cfg.counting.exit_confirm_delay_sec
|
||||||
|
DUPLICATE_CIRCLE_RADIUS = cfg.counting.duplicate_circle_radius
|
||||||
|
MIN_VALID_AREA = cfg.counting.min_valid_area
|
||||||
|
JARAK_TOLERANSI_DUPLIKAT = cfg.counting.jarak_toleransi_duplikat
|
||||||
|
MAX_REID_TRANSIT_DISTANCE = cfg.counting.max_reid_transit_distance
|
||||||
|
CIRCLE_STAY_TIMEOUT_SEC = cfg.counting.circle_stay_timeout_sec
|
||||||
|
|
||||||
|
# Legacy batch_mode.json no longer drives the mode — nudge once if stale.
|
||||||
|
_legacy_warn = check_legacy_batch_mode(cfg, BATCH_MODE_FILE)
|
||||||
|
if _legacy_warn:
|
||||||
|
print(f"[WARN] {_legacy_warn}")
|
||||||
|
|
||||||
|
mode = resolve_model_mode(model_mode)
|
||||||
|
|
||||||
# 1. Silencing YOLO logs
|
# 1. Silencing YOLO logs
|
||||||
from ultralytics.utils import LOGGER
|
from ultralytics.utils import LOGGER
|
||||||
import logging
|
import logging
|
||||||
LOGGER.setLevel(logging.WARNING)
|
LOGGER.setLevel(logging.WARNING)
|
||||||
|
|
||||||
INFERENCE_STRIDE = inference_stride
|
INFERENCE_STRIDE = inference_stride if inference_stride is not None else cfg.stream.inference_stride
|
||||||
|
|
||||||
saver = None
|
saver = None
|
||||||
saver_thread = None
|
saver_thread = None
|
||||||
@@ -1265,17 +1341,15 @@ def run_prediction(model_path, source_path,
|
|||||||
|
|
||||||
print(f"[INFO] Resolusi Asli: {int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))}x{int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))} @ {fps:.1f} FPS (Diresize ke 1280x720 untuk koordinat tetap)")
|
print(f"[INFO] Resolusi Asli: {int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))}x{int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))} @ {fps:.1f} FPS (Diresize ke 1280x720 untuk koordinat tetap)")
|
||||||
|
|
||||||
# Initialize components per model mode (engines verified to coexist)
|
# Initialize components per mode preset (engines verified to coexist)
|
||||||
pipe = build_model_pipeline(
|
pipe = build_model_pipeline(mode, cfg, device, _BASE_DIR)
|
||||||
mode, model_path, box_model_path, _pick_sack_only_model(),
|
|
||||||
sack_conf, truck_conf, box_conf, device,
|
|
||||||
)
|
|
||||||
print("[INFO] Warm-up model selesai.")
|
print("[INFO] Warm-up model selesai.")
|
||||||
|
|
||||||
truck_detector = pipe["truck_detector"]
|
truck_detector = pipe["truck_detector"]
|
||||||
tracker = pipe["tracker"]
|
tracker = pipe["tracker"]
|
||||||
box_tracker = pipe["box_tracker"]
|
box_tracker = pipe["box_tracker"]
|
||||||
separate_truck_model = pipe["separate_truck_model"]
|
separate_truck_model = pipe["separate_truck_model"]
|
||||||
|
min_areas = pipe["min_areas"]
|
||||||
stabilizer = BboxStabilizer(
|
stabilizer = BboxStabilizer(
|
||||||
ema_alpha=0.35,
|
ema_alpha=0.35,
|
||||||
max_hold_frames=10,
|
max_hold_frames=10,
|
||||||
@@ -1334,10 +1408,10 @@ def run_prediction(model_path, source_path,
|
|||||||
confidence=1.0
|
confidence=1.0
|
||||||
)
|
)
|
||||||
|
|
||||||
DUPLICATE_CIRCLE_RADIUS = DUPLICATE_CIRCLE_RADIUS_REF
|
# NOTE: counting-knob globals (DUPLICATE_CIRCLE_RADIUS, MIN_VALID_AREA,
|
||||||
MIN_VALID_AREA = MIN_VALID_AREA_REF
|
# JARAK_TOLERANSI_DUPLIKAT, MAX_REID_TRANSIT_DISTANCE, ...) were set from
|
||||||
JARAK_TOLERANSI_DUPLIKAT = JARAK_TOLERANSI_DUPLIKAT_REF
|
# config.yaml at the top of run_prediction — intentionally NOT reset to
|
||||||
MAX_REID_TRANSIT_DISTANCE = MAX_REID_TRANSIT_DISTANCE_REF
|
# zones.json REFs here (config.yaml is canonical now).
|
||||||
|
|
||||||
counter = MultiClassLineCounter(
|
counter = MultiClassLineCounter(
|
||||||
line_y=static_line_y,
|
line_y=static_line_y,
|
||||||
@@ -1346,7 +1420,7 @@ def run_prediction(model_path, source_path,
|
|||||||
margin=20,
|
margin=20,
|
||||||
dedup_radius=float(DUPLICATE_CIRCLE_RADIUS),
|
dedup_radius=float(DUPLICATE_CIRCLE_RADIUS),
|
||||||
)
|
)
|
||||||
_idle_timeout = batch_timeout if batch_timeout is not None else 30.0
|
_idle_timeout = batch_timeout if batch_timeout is not None else cfg.batch.timeout_seconds
|
||||||
batch_mgr = BatchLifecycleManager(
|
batch_mgr = BatchLifecycleManager(
|
||||||
stabilize_seconds=0.0, # Start batch instantly when triggered by crossing
|
stabilize_seconds=0.0, # Start batch instantly when triggered by crossing
|
||||||
stabilize_threshold_px=9999.0, # Disable displacement threshold check
|
stabilize_threshold_px=9999.0, # Disable displacement threshold check
|
||||||
@@ -1426,11 +1500,19 @@ def run_prediction(model_path, source_path,
|
|||||||
# Filter sack (+box, modes B-D) detections (confidence >= 0.50).
|
# Filter sack (+box, modes B-D) detections (confidence >= 0.50).
|
||||||
# Counter splits by class_name downstream; MultiClassLineCounter
|
# Counter splits by class_name downstream; MultiClassLineCounter
|
||||||
# ignores anything that is not sack/box.
|
# ignores anything that is not sack/box.
|
||||||
raw_tracked_sacks = [d for d in raw_tracked_all if d.class_name in ("sack", "box") and d.confidence >= 0.50]
|
# min_bbox_area guardrails come from config.yaml detection_params.
|
||||||
|
raw_tracked_sacks = [
|
||||||
|
d for d in raw_tracked_all
|
||||||
|
if d.class_name in ("sack", "box") and d.confidence >= 0.50
|
||||||
|
and _bbox_area_ok(d, min_areas.get(d.class_name, 0))
|
||||||
|
]
|
||||||
|
|
||||||
# Truck candidates: from shared tracker (modes A/C) and/or the
|
# Truck candidates: from shared tracker (modes A/C) and/or the
|
||||||
# separate v4 truck model (modes B/D, every 5th frame, cached).
|
# separate v4 truck model (modes B/D, every 5th frame, cached).
|
||||||
truck_candidates = [d for d in raw_tracked_all if d.class_name == "truck"]
|
truck_candidates = [
|
||||||
|
d for d in raw_tracked_all
|
||||||
|
if d.class_name == "truck" and _bbox_area_ok(d, min_areas.get("truck", 0))
|
||||||
|
]
|
||||||
if separate_truck_model:
|
if separate_truck_model:
|
||||||
if frame_idx % 5 == 0 or 'last_detected_trucks' not in locals():
|
if frame_idx % 5 == 0 or 'last_detected_trucks' not in locals():
|
||||||
last_detected_trucks = truck_detector.detect(frame)
|
last_detected_trucks = truck_detector.detect(frame)
|
||||||
@@ -1465,7 +1547,9 @@ def run_prediction(model_path, source_path,
|
|||||||
last_raw_tracked_boxes = raw_tracked_boxes
|
last_raw_tracked_boxes = raw_tracked_boxes
|
||||||
else:
|
else:
|
||||||
raw_tracked_boxes = last_raw_tracked_boxes
|
raw_tracked_boxes = last_raw_tracked_boxes
|
||||||
raw_boxes = [d for d in raw_tracked_boxes if d.class_name == "box"]
|
raw_boxes = [d for d in raw_tracked_boxes
|
||||||
|
if d.class_name == "box"
|
||||||
|
and _bbox_area_ok(d, min_areas.get("box", 0))]
|
||||||
stable_boxes = box_stabilizer.update(raw_boxes)
|
stable_boxes = box_stabilizer.update(raw_boxes)
|
||||||
stable_boxes = [
|
stable_boxes = [
|
||||||
d for d in stable_boxes
|
d for d in stable_boxes
|
||||||
@@ -1777,35 +1861,36 @@ if __name__ == "__main__":
|
|||||||
NO_DASHBOARD = args.no_dashboard
|
NO_DASHBOARD = args.no_dashboard
|
||||||
NO_DB = args.no_db
|
NO_DB = args.no_db
|
||||||
|
|
||||||
MODEL_FILE = args.model or COMBINED_MODEL_PATH
|
# Unified config first (config.yaml canonical; .env supplies secrets/RTSP).
|
||||||
if args.model and not os.path.exists(args.model):
|
_CFG = load_config(args.config or _DEFAULT_CONFIG_PATH)
|
||||||
print(f"[WARN] --model {args.model} tidak ditemukan, dipakai langsung (YOLO bisa resolve).")
|
_CFG = apply_cli_overrides_to_config(_CFG, args)
|
||||||
|
|
||||||
if args.source:
|
if args.source:
|
||||||
SOURCE_INPUT = args.source
|
SOURCE_INPUT = args.source
|
||||||
else:
|
else:
|
||||||
_env_url = os.getenv("RTSP_URL")
|
_env_url = _CFG.stream.rtsp_url
|
||||||
SOURCE_INPUT = _env_url if _env_url else ("anomali.mp4" if (os.path.exists("anomali.mp4") and os.name == 'nt') else "rtsp://192.168.192.96:8554/cam")
|
SOURCE_INPUT = _env_url if _env_url else ("anomali.mp4" if (os.path.exists("anomali.mp4") and os.name == 'nt') else "rtsp://192.168.192.96:8554/cam")
|
||||||
OUTPUT_JSON = args.output_json or "hasil_perhitungan.json"
|
OUTPUT_JSON = args.output_json or "hasil_perhitungan.json"
|
||||||
|
|
||||||
print(f"[INFO] source={SOURCE_INPUT} model={MODEL_FILE} "
|
print(f"[INFO] config={args.config or _DEFAULT_CONFIG_PATH} source={SOURCE_INPUT} "
|
||||||
f"sack_conf={args.sack_conf or 0.35} truck_conf={args.truck_conf or 0.35} "
|
f"model_mode={resolve_model_mode(args.model_mode)} "
|
||||||
f"box_conf={args.box_conf or 0.35} model_mode={args.model_mode or os.getenv('MODEL_MODE', 'C')} "
|
|
||||||
f"no_dashboard={NO_DASHBOARD} no_db={NO_DB}")
|
f"no_dashboard={NO_DASHBOARD} no_db={NO_DB}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
run_prediction(
|
run_prediction(
|
||||||
model_path=MODEL_FILE,
|
model_path=args.model,
|
||||||
source_path=SOURCE_INPUT,
|
source_path=SOURCE_INPUT,
|
||||||
output_json_path=OUTPUT_JSON,
|
output_json_path=OUTPUT_JSON,
|
||||||
max_frames=args.max_frames,
|
max_frames=args.max_frames,
|
||||||
sack_conf=args.sack_conf or 0.35,
|
sack_conf=args.sack_conf,
|
||||||
truck_conf=args.truck_conf or 0.35,
|
truck_conf=args.truck_conf,
|
||||||
box_conf=args.box_conf or 0.35,
|
box_conf=args.box_conf,
|
||||||
box_model_path=args.box_model,
|
box_model_path=args.box_model,
|
||||||
model_mode=args.model_mode,
|
model_mode=args.model_mode,
|
||||||
output_dir=args.output_dir,
|
output_dir=args.output_dir,
|
||||||
batch_timeout=args.batch_timeout,
|
batch_timeout=args.batch_timeout,
|
||||||
|
config_path=args.config or _DEFAULT_CONFIG_PATH,
|
||||||
|
cfg=_CFG,
|
||||||
)
|
)
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
print("\n" + "=" * 50)
|
print("\n" + "=" * 50)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ numpy
|
|||||||
shapely
|
shapely
|
||||||
flask
|
flask
|
||||||
python-dotenv
|
python-dotenv
|
||||||
|
pyyaml # also pulled by ultralytics; used directly by src/config_loader.py
|
||||||
openpyxl
|
openpyxl
|
||||||
paramiko # deploy_to_jetson.py only
|
paramiko # deploy_to_jetson.py only
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,609 @@
|
|||||||
|
"""Unified config loader — single source of truth for the production pipeline.
|
||||||
|
|
||||||
|
Reads `config.yaml` (canonical) + `.env` (secrets & deployment-only values:
|
||||||
|
RTSP_URL, dashboard host/ports, SECRET_KEY, SITE_NAME).
|
||||||
|
|
||||||
|
Design rules (see docs/configuration.md):
|
||||||
|
- `config.yaml` owns stream/models/zones-knobs/counting/batch/output/camera.
|
||||||
|
- `.env` owns RTSP_URL + dashboard host/ports/secret/site (never in YAML).
|
||||||
|
- `zones.json` owns zone polygons only (knob keys there are ignored, warned).
|
||||||
|
- `cfg/tracker.yaml` owns tracker hyperparams (referenced, not parsed here).
|
||||||
|
- Model modes are data: `models.modes` maps mode id -> engines + class filters.
|
||||||
|
Adding mode E/F/... is a YAML-only change (no Python edits).
|
||||||
|
- No heavy dependencies: stdlib dataclasses + pyyaml only (pyyaml already
|
||||||
|
comes with ultralytics). Import-safe for CI smoke tests.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import warnings
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
try:
|
||||||
|
import yaml
|
||||||
|
except ImportError: # pragma: no cover
|
||||||
|
yaml = None # type: ignore
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Dataclasses (one per config.yaml section)
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class StreamConfig:
|
||||||
|
rtsp_url: str = "" # always from .env RTSP_URL, never YAML
|
||||||
|
resolution: Tuple[int, int] = (1280, 720)
|
||||||
|
inference_stride: int = 2
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ModeEngine:
|
||||||
|
"""One engine in a mode preset: which file + which classes it contributes."""
|
||||||
|
|
||||||
|
path: str = "" # key into ModelsConfig.paths
|
||||||
|
classes: List[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ModelMode:
|
||||||
|
"""A mode preset: ONLY engine selection + class filters (nothing else)."""
|
||||||
|
|
||||||
|
description: str = ""
|
||||||
|
engines: List[ModeEngine] = field(default_factory=list)
|
||||||
|
class_filters: Dict[str, List[str]] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DetectionParams:
|
||||||
|
conf: float = 0.35
|
||||||
|
iou: float = 0.7 # Ultralytics default; matches pre-YAML behaviour
|
||||||
|
min_bbox_area: int = 1500 # px^2 @1280x720; permissive guardrail
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ModelsConfig:
|
||||||
|
active_mode: str = "C"
|
||||||
|
paths: Dict[str, str] = field(default_factory=dict)
|
||||||
|
modes: Dict[str, ModelMode] = field(default_factory=dict)
|
||||||
|
detection_params: Dict[str, DetectionParams] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ZonesConfig:
|
||||||
|
config_file: str = "zones.json"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TrackerConfig:
|
||||||
|
config_file: str = "cfg/tracker.yaml"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CountingConfig:
|
||||||
|
confirm_delay_sec: float = 0.5
|
||||||
|
exit_confirm_delay_sec: float = 6.0
|
||||||
|
entry_overlap_threshold: float = 0.20
|
||||||
|
exit_overlap_threshold: float = 0.05
|
||||||
|
camera_noise_deadband: int = 50
|
||||||
|
duplicate_circle_radius: int = 30
|
||||||
|
min_valid_area: int = 15000
|
||||||
|
max_reid_transit_distance: int = 400
|
||||||
|
circle_stay_timeout_sec: float = 10.0
|
||||||
|
jarak_toleransi_duplikat: int = 30
|
||||||
|
tolerance_missing_frames: int = 1200
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BatchConfig:
|
||||||
|
timeout_seconds: float = 30.0
|
||||||
|
merge_threshold_seconds: int = 300
|
||||||
|
daily_cutoff_time: str = "06:00"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class OutputConfig:
|
||||||
|
dir: str = "/opt/jetson-counter"
|
||||||
|
db_name: str = "jetson_counter.db"
|
||||||
|
state_file: str = "current_batch.json"
|
||||||
|
batch_mode_file: str = "batch_mode.json" # DEPRECATED (mode now in config.yaml)
|
||||||
|
live_frame_path: str = "/dev/shm/jetson-counter/live_frame.jpg"
|
||||||
|
live_status_path: str = "/dev/shm/jetson-counter/live_status.json"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DashboardConfig:
|
||||||
|
"""Deployment-only values — always from .env, never YAML."""
|
||||||
|
|
||||||
|
host: str = "0.0.0.0"
|
||||||
|
port: int = 5000
|
||||||
|
office_port: int = 5721
|
||||||
|
secret_key: str = "change-me-in-production"
|
||||||
|
site_name: str = "LIVE"
|
||||||
|
debug: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CameraConfig:
|
||||||
|
name: str = "CC1"
|
||||||
|
object_label: str = "karung-pakan"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Config:
|
||||||
|
stream: StreamConfig = field(default_factory=StreamConfig)
|
||||||
|
models: ModelsConfig = field(default_factory=ModelsConfig)
|
||||||
|
zones: ZonesConfig = field(default_factory=ZonesConfig)
|
||||||
|
tracker: TrackerConfig = field(default_factory=TrackerConfig)
|
||||||
|
counting: CountingConfig = field(default_factory=CountingConfig)
|
||||||
|
batch: BatchConfig = field(default_factory=BatchConfig)
|
||||||
|
output: OutputConfig = field(default_factory=OutputConfig)
|
||||||
|
dashboard: DashboardConfig = field(default_factory=DashboardConfig)
|
||||||
|
camera: CameraConfig = field(default_factory=CameraConfig)
|
||||||
|
|
||||||
|
# -- mode helpers ------------------------------------------------------ #
|
||||||
|
def get_active_mode(self) -> ModelMode:
|
||||||
|
try:
|
||||||
|
return self.models.modes[self.models.active_mode]
|
||||||
|
except KeyError:
|
||||||
|
raise ValueError(
|
||||||
|
f"config.yaml models.active_mode={self.models.active_mode!r} "
|
||||||
|
f"not defined in models.modes (available: {sorted(self.models.modes)})"
|
||||||
|
)
|
||||||
|
|
||||||
|
def detection_params_for(self, class_name: str) -> DetectionParams:
|
||||||
|
try:
|
||||||
|
return self.models.detection_params[class_name]
|
||||||
|
except KeyError:
|
||||||
|
raise ValueError(
|
||||||
|
f"No detection_params for class {class_name!r} "
|
||||||
|
f"(available: {sorted(self.models.detection_params)})"
|
||||||
|
)
|
||||||
|
|
||||||
|
def engine_path(self, key: str, base_dir: str | Path = ".") -> str:
|
||||||
|
"""Resolve a mode engine key via models.paths (relative to base_dir).
|
||||||
|
|
||||||
|
Sibling fallback (dev convenience, mirrors pre-YAML behaviour):
|
||||||
|
missing `.engine` -> existing `.pt` next to it, with a WARNING.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
rel = self.models.paths[key]
|
||||||
|
except KeyError:
|
||||||
|
raise ValueError(
|
||||||
|
f"Engine key {key!r} not defined in models.paths "
|
||||||
|
f"(available: {sorted(self.models.paths)})"
|
||||||
|
)
|
||||||
|
p = Path(base_dir) / rel
|
||||||
|
if not p.exists() and p.suffix == ".engine":
|
||||||
|
pt_sibling = p.with_suffix(".pt")
|
||||||
|
if pt_sibling.exists():
|
||||||
|
warnings.warn(
|
||||||
|
f"Engine {p} missing — falling back to {pt_sibling} "
|
||||||
|
f"(export the .engine for production).",
|
||||||
|
UserWarning,
|
||||||
|
stacklevel=2,
|
||||||
|
)
|
||||||
|
return str(pt_sibling)
|
||||||
|
return str(p)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Parsing
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
def _require_yaml() -> None:
|
||||||
|
if yaml is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
"pyyaml is required by src/config_loader.py "
|
||||||
|
"(pip install pyyaml — already a dependency of ultralytics)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_detection_params(raw: Dict[str, Any]) -> Dict[str, DetectionParams]:
|
||||||
|
out: Dict[str, DetectionParams] = {}
|
||||||
|
for cls_name, vals in (raw or {}).items():
|
||||||
|
vals = vals or {}
|
||||||
|
out[cls_name] = DetectionParams(
|
||||||
|
conf=float(vals.get("conf", 0.35)),
|
||||||
|
iou=float(vals.get("iou", 0.7)),
|
||||||
|
min_bbox_area=int(vals.get("min_bbox_area", 1500)),
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_models(raw: Dict[str, Any]) -> ModelsConfig:
|
||||||
|
raw = raw or {}
|
||||||
|
modes: Dict[str, ModelMode] = {}
|
||||||
|
for mode_id, m in (raw.get("modes") or {}).items():
|
||||||
|
m = m or {}
|
||||||
|
engines: List[ModeEngine] = []
|
||||||
|
for e in m.get("engines") or []:
|
||||||
|
if isinstance(e, str): # short form: bare path key, classes unknown
|
||||||
|
engines.append(ModeEngine(path=e, classes=[]))
|
||||||
|
else:
|
||||||
|
engines.append(ModeEngine(
|
||||||
|
path=str(e.get("path", "")),
|
||||||
|
classes=list(e.get("classes") or []),
|
||||||
|
))
|
||||||
|
modes[str(mode_id).upper()] = ModelMode(
|
||||||
|
description=str(m.get("description", "")),
|
||||||
|
engines=engines,
|
||||||
|
class_filters={k: list(v or []) for k, v in (m.get("class_filters") or {}).items()},
|
||||||
|
)
|
||||||
|
return ModelsConfig(
|
||||||
|
active_mode=str(raw.get("active_mode", "C")).upper(),
|
||||||
|
paths={k: str(v) for k, v in (raw.get("paths") or {}).items()},
|
||||||
|
modes=modes,
|
||||||
|
detection_params=_parse_detection_params(raw.get("detection_params")),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _dashboard_from_env() -> DashboardConfig:
|
||||||
|
return DashboardConfig(
|
||||||
|
host=os.getenv("DASHBOARD_HOST", "0.0.0.0"),
|
||||||
|
port=int(os.getenv("DASHBOARD_PORT", "5000")),
|
||||||
|
office_port=int(os.getenv("OFFICE_PORT", "5721")),
|
||||||
|
secret_key=os.getenv("SECRET_KEY", "change-me-in-production"),
|
||||||
|
site_name=os.getenv("SITE_NAME", "LIVE"),
|
||||||
|
debug=os.getenv("FLASK_DEBUG", "false").lower() == "true",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _default_models() -> ModelsConfig:
|
||||||
|
"""Legacy models section (modes A-D + paths + params) for the .env-backup path.
|
||||||
|
|
||||||
|
Mirrors config.yaml defaults so a missing config.yaml behaves identically.
|
||||||
|
"""
|
||||||
|
paths = {
|
||||||
|
"combined": "models/v4-best.engine",
|
||||||
|
"truck_only": "models/v4-best.engine",
|
||||||
|
"sack_only": "models/best.engine",
|
||||||
|
"box": "models/yolo11n-bbox-100ep-sack+box-20260909-best.engine",
|
||||||
|
"truck_detector": "models/truck-detector.engine",
|
||||||
|
}
|
||||||
|
params = {
|
||||||
|
"truck": DetectionParams(conf=0.35, iou=0.7, min_bbox_area=5000),
|
||||||
|
"sack": DetectionParams(conf=0.35, iou=0.7, min_bbox_area=1500),
|
||||||
|
"box": DetectionParams(conf=0.35, iou=0.7, min_bbox_area=1500),
|
||||||
|
}
|
||||||
|
truck_f = {"truck": ["truck"]}
|
||||||
|
sack_f = {"sack": ["sack"]}
|
||||||
|
box_f = {"box": ["box"]}
|
||||||
|
modes = {
|
||||||
|
"A": ModelMode(
|
||||||
|
description="Combined v4 sack+truck only (legacy, no box counting)",
|
||||||
|
engines=[ModeEngine(path="combined", classes=["truck", "sack"])],
|
||||||
|
class_filters={**truck_f, **sack_f, "box": []},
|
||||||
|
),
|
||||||
|
"B": ModelMode(
|
||||||
|
description="v4 truck-only + yolo11n sack+box (shared tracker)",
|
||||||
|
engines=[
|
||||||
|
ModeEngine(path="truck_only", classes=["truck"]),
|
||||||
|
ModeEngine(path="box", classes=["sack", "box"]),
|
||||||
|
],
|
||||||
|
class_filters={**truck_f, **sack_f, **box_f},
|
||||||
|
),
|
||||||
|
"C": ModelMode(
|
||||||
|
description="Combined v4 sack+truck + yolo11n box-only (dedicated tracker)",
|
||||||
|
engines=[
|
||||||
|
ModeEngine(path="combined", classes=["truck", "sack"]),
|
||||||
|
ModeEngine(path="box", classes=["box"]),
|
||||||
|
],
|
||||||
|
class_filters={**truck_f, **sack_f, **box_f},
|
||||||
|
),
|
||||||
|
"D": ModelMode(
|
||||||
|
description="v4 truck-only + best sack-only + yolo11n box-only",
|
||||||
|
engines=[
|
||||||
|
ModeEngine(path="truck_only", classes=["truck"]),
|
||||||
|
ModeEngine(path="sack_only", classes=["sack"]),
|
||||||
|
ModeEngine(path="box", classes=["box"]),
|
||||||
|
],
|
||||||
|
class_filters={**truck_f, **sack_f, **box_f},
|
||||||
|
),
|
||||||
|
}
|
||||||
|
return ModelsConfig(active_mode="C", paths=paths, modes=modes,
|
||||||
|
detection_params=params)
|
||||||
|
|
||||||
|
|
||||||
|
def _defaults_for_platform() -> Config:
|
||||||
|
"""Fallback Config when config.yaml is missing: .env values + code defaults.
|
||||||
|
|
||||||
|
Mirrors the pre-YAML behaviour of predict.py / counter_dashboard.py so
|
||||||
|
nothing breaks (emits WARNING to nudge migration to config.yaml).
|
||||||
|
"""
|
||||||
|
if os.name == "nt":
|
||||||
|
out_dir = "d:/Belajar/menghitung karung"
|
||||||
|
live_frame = f"{out_dir}/live_frame.jpg"
|
||||||
|
live_status = f"{out_dir}/live_status.json"
|
||||||
|
else:
|
||||||
|
out_dir = os.getenv("OUTPUT_DIR", "/opt/jetson-counter")
|
||||||
|
live_frame = os.getenv("LIVE_STREAM_FRAME_PATH", "/dev/shm/jetson-counter/live_frame.jpg")
|
||||||
|
live_status = os.getenv(
|
||||||
|
"LIVE_STATUS_FILE", "/dev/shm/jetson-counter/live_status.json"
|
||||||
|
)
|
||||||
|
cfg = Config(
|
||||||
|
stream=StreamConfig(
|
||||||
|
rtsp_url=os.getenv("RTSP_URL", ""),
|
||||||
|
resolution=(1280, 720),
|
||||||
|
inference_stride=2,
|
||||||
|
),
|
||||||
|
models=_default_models(),
|
||||||
|
output=OutputConfig(
|
||||||
|
dir=out_dir,
|
||||||
|
db_name="jetson_counter.db",
|
||||||
|
state_file="current_batch.json",
|
||||||
|
batch_mode_file="batch_mode.json",
|
||||||
|
live_frame_path=live_frame,
|
||||||
|
live_status_path=live_status,
|
||||||
|
),
|
||||||
|
camera=CameraConfig(
|
||||||
|
name=os.getenv("CAMERA_NAME", "CC1"),
|
||||||
|
object_label=os.getenv("OBJECT_LABEL", "karung-pakan"),
|
||||||
|
),
|
||||||
|
batch=BatchConfig(
|
||||||
|
timeout_seconds=30.0,
|
||||||
|
merge_threshold_seconds=int(os.getenv("BATCH_MERGE_THRESHOLD_SECONDS", "300")),
|
||||||
|
daily_cutoff_time=os.getenv("DAILY_CUTOFF_TIME", "06:00"),
|
||||||
|
),
|
||||||
|
dashboard=_dashboard_from_env(),
|
||||||
|
)
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
def load_config(path: str | Path = "config.yaml") -> Config:
|
||||||
|
"""Load Config from YAML + .env. Missing file -> .env/defaults + WARNING."""
|
||||||
|
_require_yaml()
|
||||||
|
p = Path(path)
|
||||||
|
if not p.exists():
|
||||||
|
warnings.warn(
|
||||||
|
f"config file {p} not found — falling back to .env + built-in defaults. "
|
||||||
|
f"Create {p} (see config.yaml in repo) to silence this warning.",
|
||||||
|
UserWarning,
|
||||||
|
stacklevel=2,
|
||||||
|
)
|
||||||
|
return _defaults_for_platform()
|
||||||
|
|
||||||
|
with open(p, "r", encoding="utf-8") as f:
|
||||||
|
raw = yaml.safe_load(f) or {}
|
||||||
|
|
||||||
|
stream_raw = raw.get("stream") or {}
|
||||||
|
zones_raw = raw.get("zones") or {}
|
||||||
|
tracker_raw = raw.get("tracker") or {}
|
||||||
|
counting_raw = raw.get("counting") or {}
|
||||||
|
batch_raw = raw.get("batch") or {}
|
||||||
|
output_raw = raw.get("output") or {}
|
||||||
|
camera_raw = raw.get("camera") or {}
|
||||||
|
|
||||||
|
res = stream_raw.get("resolution", [1280, 720])
|
||||||
|
|
||||||
|
# Output paths: explicit legacy env vars win (pre-YAML behaviour),
|
||||||
|
# otherwise config.yaml. dir+name split reproduces the resolved absolute
|
||||||
|
# path via os.path.join (all production files share one dir in practice).
|
||||||
|
if os.name == "nt":
|
||||||
|
out_dir = os.getenv("OUTPUT_DIR", "d:/Belajar/menghitung karung")
|
||||||
|
_db = os.getenv("DB_PATH", f"{out_dir}/jetson_counter.db")
|
||||||
|
_st = os.getenv("STATE_FILE", f"{out_dir}/current_batch.json")
|
||||||
|
_bm = os.getenv("BATCH_MODE_FILE", f"{out_dir}/batch_mode.json")
|
||||||
|
_lf = os.getenv("LIVE_STREAM_FRAME_PATH", f"{out_dir}/live_frame.jpg")
|
||||||
|
_ls = os.getenv("LIVE_STATUS_FILE", f"{out_dir}/live_status.json")
|
||||||
|
else:
|
||||||
|
out_dir = os.getenv("OUTPUT_DIR", str(output_raw.get("dir", "/opt/jetson-counter")))
|
||||||
|
_db = os.getenv("DB_PATH", os.path.join(out_dir, str(output_raw.get("db_name", "jetson_counter.db"))))
|
||||||
|
_st = os.getenv("STATE_FILE", os.path.join(out_dir, str(output_raw.get("state_file", "current_batch.json"))))
|
||||||
|
_bm = os.getenv("BATCH_MODE_FILE", os.path.join(out_dir, str(output_raw.get("batch_mode_file", "batch_mode.json"))))
|
||||||
|
_lf = os.getenv("LIVE_STREAM_FRAME_PATH", str(output_raw.get(
|
||||||
|
"live_frame_path", "/dev/shm/jetson-counter/live_frame.jpg")))
|
||||||
|
_ls = os.getenv("LIVE_STATUS_FILE", str(output_raw.get(
|
||||||
|
"live_status_path", "/dev/shm/jetson-counter/live_status.json")))
|
||||||
|
output = OutputConfig(
|
||||||
|
dir=os.path.dirname(_db) if os.getenv("DB_PATH") else out_dir,
|
||||||
|
db_name=os.path.basename(_db),
|
||||||
|
state_file=os.path.basename(_st),
|
||||||
|
batch_mode_file=os.path.basename(_bm),
|
||||||
|
live_frame_path=_lf,
|
||||||
|
live_status_path=_ls,
|
||||||
|
)
|
||||||
|
|
||||||
|
cfg = Config(
|
||||||
|
stream=StreamConfig(
|
||||||
|
rtsp_url=os.getenv("RTSP_URL", ""),
|
||||||
|
resolution=(int(res[0]), int(res[1])),
|
||||||
|
inference_stride=int(stream_raw.get("inference_stride", 2)),
|
||||||
|
),
|
||||||
|
models=_parse_models(raw.get("models")),
|
||||||
|
zones=ZonesConfig(config_file=str(zones_raw.get("config_file", "zones.json"))),
|
||||||
|
tracker=TrackerConfig(config_file=str(tracker_raw.get("config_file", "cfg/tracker.yaml"))),
|
||||||
|
counting=CountingConfig(
|
||||||
|
confirm_delay_sec=float(counting_raw.get("confirm_delay_sec", 0.5)),
|
||||||
|
exit_confirm_delay_sec=float(counting_raw.get("exit_confirm_delay_sec", 6.0)),
|
||||||
|
entry_overlap_threshold=float(counting_raw.get("entry_overlap_threshold", 0.20)),
|
||||||
|
exit_overlap_threshold=float(counting_raw.get("exit_overlap_threshold", 0.05)),
|
||||||
|
camera_noise_deadband=int(counting_raw.get("camera_noise_deadband", 50)),
|
||||||
|
duplicate_circle_radius=int(counting_raw.get("duplicate_circle_radius", 30)),
|
||||||
|
min_valid_area=int(counting_raw.get("min_valid_area", 15000)),
|
||||||
|
max_reid_transit_distance=int(counting_raw.get("max_reid_transit_distance", 400)),
|
||||||
|
circle_stay_timeout_sec=float(counting_raw.get("circle_stay_timeout_sec", 10.0)),
|
||||||
|
jarak_toleransi_duplikat=int(counting_raw.get("jarak_toleransi_duplikat", 30)),
|
||||||
|
tolerance_missing_frames=int(counting_raw.get("tolerance_missing_frames", 1200)),
|
||||||
|
),
|
||||||
|
batch=BatchConfig(
|
||||||
|
timeout_seconds=float(batch_raw.get("timeout_seconds", 30.0)),
|
||||||
|
merge_threshold_seconds=int(os.getenv(
|
||||||
|
"BATCH_MERGE_THRESHOLD_SECONDS",
|
||||||
|
batch_raw.get("merge_threshold_seconds", 300))),
|
||||||
|
daily_cutoff_time=str(os.getenv(
|
||||||
|
"DAILY_CUTOFF_TIME",
|
||||||
|
batch_raw.get("daily_cutoff_time", "06:00"))),
|
||||||
|
),
|
||||||
|
output=output,
|
||||||
|
dashboard=_dashboard_from_env(),
|
||||||
|
camera=CameraConfig(
|
||||||
|
name=str(camera_raw.get("name", os.getenv("CAMERA_NAME", "CC1"))),
|
||||||
|
object_label=str(camera_raw.get("object_label", os.getenv("OBJECT_LABEL", "karung-pakan"))),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
validate_config(cfg)
|
||||||
|
return cfg
|
||||||
|
|
||||||
|
|
||||||
|
def validate_config(cfg: Config) -> None:
|
||||||
|
"""Raise ValueError on inconsistent mode/paths/params (fail fast at startup)."""
|
||||||
|
if not cfg.models.modes:
|
||||||
|
raise ValueError("config.yaml models.modes is empty — define at least one mode")
|
||||||
|
if cfg.models.active_mode not in cfg.models.modes:
|
||||||
|
raise ValueError(
|
||||||
|
f"models.active_mode={cfg.models.active_mode!r} not in models.modes "
|
||||||
|
f"(available: {sorted(cfg.models.modes)})"
|
||||||
|
)
|
||||||
|
for mode_id, mode in cfg.models.modes.items():
|
||||||
|
for e in mode.engines:
|
||||||
|
if e.path not in cfg.models.paths:
|
||||||
|
raise ValueError(
|
||||||
|
f"Mode {mode_id!r} references engine {e.path!r} "
|
||||||
|
f"missing from models.paths (available: {sorted(cfg.models.paths)})"
|
||||||
|
)
|
||||||
|
for cls_name, dp in cfg.models.detection_params.items():
|
||||||
|
if not (0.0 < dp.conf <= 1.0):
|
||||||
|
raise ValueError(f"detection_params.{cls_name}.conf={dp.conf} must be in (0, 1]")
|
||||||
|
if not (0.0 < dp.iou <= 1.0):
|
||||||
|
raise ValueError(f"detection_params.{cls_name}.iou={dp.iou} must be in (0, 1]")
|
||||||
|
if dp.min_bbox_area < 0:
|
||||||
|
raise ValueError(
|
||||||
|
f"detection_params.{cls_name}.min_bbox_area={dp.min_bbox_area} must be >= 0"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# Mode resolution (precedence) + legacy migration helpers
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
def resolve_active_mode(explicit: Optional[str], cfg: Config) -> str:
|
||||||
|
"""Precedence: explicit --model-mode > MODEL_MODE env (deprecated) > config.yaml.
|
||||||
|
|
||||||
|
Returns the validated (uppercased) mode id. Unknown ids fall back to
|
||||||
|
config.yaml active_mode with a WARNING (never crash production).
|
||||||
|
"""
|
||||||
|
if explicit:
|
||||||
|
mode = explicit.upper()
|
||||||
|
else:
|
||||||
|
env_mode = os.getenv("MODEL_MODE", "")
|
||||||
|
if env_mode:
|
||||||
|
warnings.warn(
|
||||||
|
"MODEL_MODE env var is deprecated — set models.active_mode in "
|
||||||
|
"config.yaml instead. Env value still honoured for this run.",
|
||||||
|
DeprecationWarning,
|
||||||
|
stacklevel=2,
|
||||||
|
)
|
||||||
|
mode = env_mode.upper()
|
||||||
|
else:
|
||||||
|
mode = cfg.models.active_mode
|
||||||
|
if mode not in cfg.models.modes:
|
||||||
|
warnings.warn(
|
||||||
|
f"Model mode {mode!r} not defined in config.yaml models.modes — "
|
||||||
|
f"falling back to {cfg.models.active_mode!r}.",
|
||||||
|
UserWarning,
|
||||||
|
stacklevel=2,
|
||||||
|
)
|
||||||
|
return cfg.models.active_mode
|
||||||
|
return mode
|
||||||
|
|
||||||
|
|
||||||
|
def set_active_mode(path: str | Path, mode: str) -> str:
|
||||||
|
"""Persist a mode switch to config.yaml (atomic tmp+replace, comments preserved).
|
||||||
|
|
||||||
|
Uses a targeted line edit (not yaml.dump) so human comments/formatting survive.
|
||||||
|
Validates the mode exists before touching the file. Manual service restart
|
||||||
|
still required to apply (models load once at startup).
|
||||||
|
"""
|
||||||
|
mode = mode.upper()
|
||||||
|
cfg = load_config(path) # validates file + all modes
|
||||||
|
if mode not in cfg.models.modes:
|
||||||
|
raise ValueError(
|
||||||
|
f"Invalid model mode {mode!r} (available: {sorted(cfg.models.modes)})"
|
||||||
|
)
|
||||||
|
p = Path(path)
|
||||||
|
text = p.read_text(encoding="utf-8")
|
||||||
|
new_text, n = re.subn(
|
||||||
|
r'^(\s*active_mode\s*:\s*)["\']?\w+["\']?',
|
||||||
|
lambda m: f'{m.group(1)}"{mode}"',
|
||||||
|
text,
|
||||||
|
count=1,
|
||||||
|
flags=re.M,
|
||||||
|
)
|
||||||
|
if n != 1:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Could not locate a single 'active_mode:' line in {p} — file left untouched"
|
||||||
|
)
|
||||||
|
tmp = p.with_suffix(p.suffix + ".tmp")
|
||||||
|
tmp.write_text(new_text, encoding="utf-8")
|
||||||
|
os.replace(tmp, p)
|
||||||
|
return mode
|
||||||
|
|
||||||
|
|
||||||
|
def check_legacy_batch_mode(cfg: Config, batch_mode_path: str | Path) -> Optional[str]:
|
||||||
|
"""One-time migration nudge: warn if legacy batch_mode.json disagrees with config.yaml.
|
||||||
|
|
||||||
|
Returns the warning message (or None). Caller decides how to surface it.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(batch_mode_path, "r", encoding="utf-8") as f:
|
||||||
|
stored = json.load(f)
|
||||||
|
legacy = (stored.get("model_mode") or "").upper()
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return None
|
||||||
|
if legacy and legacy != cfg.models.active_mode:
|
||||||
|
return (
|
||||||
|
f"Legacy {batch_mode_path} pins model_mode={legacy!r} but config.yaml "
|
||||||
|
f"active_mode={cfg.models.active_mode!r} wins. Delete {batch_mode_path} "
|
||||||
|
f"(or align it) to silence this warning."
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# zones.json polygons (polygons only — knob keys there are ignored)
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
|
||||||
|
# Knob keys that used to live in zones.json and are now owned by config.yaml.
|
||||||
|
# If present, they are IGNORED (config.yaml wins) — warn once to guide migration.
|
||||||
|
# NOTE: palet/truck/counting polygons + left_limit/right_limit + external_stream_url
|
||||||
|
# stay in zones.json (geometry ownership); they are NOT in this set.
|
||||||
|
LEGACY_ZONE_KNOBS = {
|
||||||
|
"duplicate_circle_radius", "min_valid_area",
|
||||||
|
"jarak_toleransi_duplikat", "max_reid_transit_distance",
|
||||||
|
"circle_stay_timeout_sec", "inference_stride",
|
||||||
|
"confirm_delay_sec", "exit_confirm_delay_sec",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def read_zone_polygons(path: str | Path) -> Dict[str, Any]:
|
||||||
|
"""Read palet/truck/counting polygons from zones.json.
|
||||||
|
|
||||||
|
Returns {"palet": [...], "truck": [...], "counting": [...]}.
|
||||||
|
Warns if legacy knob keys are present (they are ignored — config.yaml owns them).
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
|
||||||
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
ignored = sorted(LEGACY_ZONE_KNOBS & set(data.keys()))
|
||||||
|
if ignored:
|
||||||
|
warnings.warn(
|
||||||
|
f"{path} contains legacy knob keys {ignored} — IGNORED, "
|
||||||
|
f"config.yaml owns these values now. Keep only palet/truck/counting polygons.",
|
||||||
|
UserWarning,
|
||||||
|
stacklevel=2,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"palet": data.get("palet", []),
|
||||||
|
"truck": data.get("truck", []),
|
||||||
|
"counting": data.get("counting", []),
|
||||||
|
"left_limit": float(data.get("left_limit", 0.27578)),
|
||||||
|
"right_limit": float(data.get("right_limit", 0.72578)),
|
||||||
|
"external_stream_url": data.get("external_stream_url", ""),
|
||||||
|
}
|
||||||
+9
-6
@@ -23,14 +23,16 @@ class BaseDetector:
|
|||||||
model_path: str | YOLO,
|
model_path: str | YOLO,
|
||||||
conf: float = 0.35,
|
conf: float = 0.35,
|
||||||
class_filter: tuple[str, ...] | list[str] | None = None,
|
class_filter: tuple[str, ...] | list[str] | None = None,
|
||||||
|
iou: float = 0.7,
|
||||||
) -> None:
|
) -> None:
|
||||||
self._model = model_path if isinstance(model_path, YOLO) else YOLO(model_path)
|
self._model = model_path if isinstance(model_path, YOLO) else YOLO(model_path)
|
||||||
self._conf = conf
|
self._conf = conf
|
||||||
self._class_filter = set(class_filter) if class_filter else None
|
self._class_filter = set(class_filter) if class_filter else None
|
||||||
|
self._iou = iou
|
||||||
|
|
||||||
def detect(self, frame: np.ndarray) -> list[Detection]:
|
def detect(self, frame: np.ndarray) -> list[Detection]:
|
||||||
results = self._model.predict(
|
results = self._model.predict(
|
||||||
frame, conf=self._conf, verbose=False
|
frame, conf=self._conf, iou=self._iou, verbose=False
|
||||||
)
|
)
|
||||||
return self._parse(results[0])
|
return self._parse(results[0])
|
||||||
|
|
||||||
@@ -69,8 +71,8 @@ class BaseDetector:
|
|||||||
class SackDetector(BaseDetector):
|
class SackDetector(BaseDetector):
|
||||||
"""Detects sacks (drops persons/boxes/trucks from multi-class models)."""
|
"""Detects sacks (drops persons/boxes/trucks from multi-class models)."""
|
||||||
|
|
||||||
def __init__(self, model_path: str | YOLO, conf: float = 0.35) -> None:
|
def __init__(self, model_path: str | YOLO, conf: float = 0.35, iou: float = 0.7) -> None:
|
||||||
super().__init__(model_path, conf, class_filter=("sack",))
|
super().__init__(model_path, conf, class_filter=("sack",), iou=iou)
|
||||||
|
|
||||||
|
|
||||||
class TruckDetector(BaseDetector):
|
class TruckDetector(BaseDetector):
|
||||||
@@ -81,12 +83,13 @@ class TruckDetector(BaseDetector):
|
|||||||
model_path: str | YOLO,
|
model_path: str | YOLO,
|
||||||
conf: float = 0.35,
|
conf: float = 0.35,
|
||||||
class_filter: tuple[str, ...] | list[str] | None = None,
|
class_filter: tuple[str, ...] | list[str] | None = None,
|
||||||
|
iou: float = 0.7,
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(model_path, conf, class_filter=class_filter)
|
super().__init__(model_path, conf, class_filter=class_filter, iou=iou)
|
||||||
|
|
||||||
|
|
||||||
class BoxDetector(BaseDetector):
|
class BoxDetector(BaseDetector):
|
||||||
"""Detects boxes (drops sacks/persons from the sack+box model)."""
|
"""Detects boxes (drops sacks/persons from the sack+box model)."""
|
||||||
|
|
||||||
def __init__(self, model_path: str | YOLO, conf: float = 0.35) -> None:
|
def __init__(self, model_path: str | YOLO, conf: float = 0.35, iou: float = 0.7) -> None:
|
||||||
super().__init__(model_path, conf, class_filter=("box",))
|
super().__init__(model_path, conf, class_filter=("box",), iou=iou)
|
||||||
+3
-1
@@ -28,7 +28,7 @@ _TRACKER_CFG = os.path.join(
|
|||||||
class ByteTrackTracker:
|
class ByteTrackTracker:
|
||||||
"""Tracks sacks across frames using FastTrack (occlusion-aware)."""
|
"""Tracks sacks across frames using FastTrack (occlusion-aware)."""
|
||||||
|
|
||||||
def __init__(self, model_path: str | YOLO, conf: float = 0.35) -> None:
|
def __init__(self, model_path: str | YOLO, conf: float = 0.35, iou: float = 0.7) -> None:
|
||||||
if isinstance(model_path, YOLO):
|
if isinstance(model_path, YOLO):
|
||||||
self._model = model_path
|
self._model = model_path
|
||||||
self._model_path = getattr(model_path, "ckpt_path", str(model_path))
|
self._model_path = getattr(model_path, "ckpt_path", str(model_path))
|
||||||
@@ -36,6 +36,7 @@ class ByteTrackTracker:
|
|||||||
self._model = YOLO(model_path)
|
self._model = YOLO(model_path)
|
||||||
self._model_path = model_path
|
self._model_path = model_path
|
||||||
self._conf = conf
|
self._conf = conf
|
||||||
|
self._iou = iou
|
||||||
self._tracker_cfg = _TRACKER_CFG if os.path.exists(_TRACKER_CFG) else "bytetrack.yaml"
|
self._tracker_cfg = _TRACKER_CFG if os.path.exists(_TRACKER_CFG) else "bytetrack.yaml"
|
||||||
|
|
||||||
def update(
|
def update(
|
||||||
@@ -45,6 +46,7 @@ class ByteTrackTracker:
|
|||||||
results = self._model.track(
|
results = self._model.track(
|
||||||
frame,
|
frame,
|
||||||
conf=self._conf,
|
conf=self._conf,
|
||||||
|
iou=self._iou,
|
||||||
persist=True,
|
persist=True,
|
||||||
tracker=self._tracker_cfg,
|
tracker=self._tracker_cfg,
|
||||||
verbose=False,
|
verbose=False,
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
"""Smoke tests for src/config_loader.py — pure-Python, no cv2/ultralytics needed."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from src.config_loader import (
|
||||||
|
check_legacy_batch_mode,
|
||||||
|
load_config,
|
||||||
|
read_zone_polygons,
|
||||||
|
resolve_active_mode,
|
||||||
|
set_active_mode,
|
||||||
|
validate_config,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def repo_config_path():
|
||||||
|
p = os.path.join(os.path.dirname(os.path.dirname(__file__)), "config.yaml")
|
||||||
|
assert os.path.exists(p), "repo config.yaml missing"
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def test_repo_config_loads_and_validates(repo_config_path):
|
||||||
|
cfg = load_config(repo_config_path)
|
||||||
|
assert cfg.models.active_mode == "C"
|
||||||
|
assert set(cfg.models.modes) >= {"A", "B", "C", "D"}
|
||||||
|
for mode_id, mode in cfg.models.modes.items():
|
||||||
|
assert mode.engines, f"mode {mode_id} has no engines"
|
||||||
|
for e in mode.engines:
|
||||||
|
assert e.path in cfg.models.paths, f"mode {mode_id}: unknown engine {e.path}"
|
||||||
|
assert e.classes, f"mode {mode_id}: engine {e.path} declares no classes"
|
||||||
|
for cls_name in ("truck", "sack", "box"):
|
||||||
|
dp = cfg.detection_params_for(cls_name)
|
||||||
|
assert 0.0 < dp.conf <= 1.0
|
||||||
|
assert 0.0 < dp.iou <= 1.0
|
||||||
|
assert dp.min_bbox_area >= 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_mode_preset_only_filters_and_engines(repo_config_path):
|
||||||
|
"""Modes must not carry per-class conf/iou — those live in detection_params."""
|
||||||
|
with open(repo_config_path) as f:
|
||||||
|
raw = yaml.safe_load(f)
|
||||||
|
for mode_id, mode in raw["models"]["modes"].items():
|
||||||
|
assert "conf" not in mode, f"mode {mode_id}: conf belongs in detection_params"
|
||||||
|
assert "iou" not in mode, f"mode {mode_id}: iou belongs in detection_params"
|
||||||
|
assert "min_bbox_area" not in mode, f"mode {mode_id}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_active_mode_precedence(repo_config_path, monkeypatch):
|
||||||
|
cfg = load_config(repo_config_path)
|
||||||
|
monkeypatch.delenv("MODEL_MODE", raising=False)
|
||||||
|
assert resolve_active_mode(None, cfg) == "C"
|
||||||
|
assert resolve_active_mode("d", cfg) == "D"
|
||||||
|
with pytest.warns(UserWarning): # unknown -> fallback to active
|
||||||
|
assert resolve_active_mode("Z", cfg) == "C"
|
||||||
|
monkeypatch.setenv("MODEL_MODE", "B")
|
||||||
|
with pytest.warns(DeprecationWarning): # env still honoured, deprecated
|
||||||
|
assert resolve_active_mode(None, cfg) == "B"
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_file_falls_back_with_warning(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.delenv("MODEL_MODE", raising=False)
|
||||||
|
with pytest.warns(UserWarning, match="not found"):
|
||||||
|
cfg = load_config(tmp_path / "nope.yaml")
|
||||||
|
assert cfg.models.active_mode == "C"
|
||||||
|
assert cfg.stream.inference_stride == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_active_mode_roundtrip_and_validation(tmp_path, repo_config_path):
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
dst = tmp_path / "config.yaml"
|
||||||
|
shutil.copy(repo_config_path, dst)
|
||||||
|
assert set_active_mode(dst, "d") == "D"
|
||||||
|
assert load_config(dst).models.active_mode == "D"
|
||||||
|
text = dst.read_text()
|
||||||
|
assert 'active_mode: "D"' in text
|
||||||
|
assert "# config.yaml" in text # comments preserved (no yaml.dump reformat)
|
||||||
|
with pytest.raises(ValueError, match="Invalid model mode"):
|
||||||
|
set_active_mode(dst, "Z")
|
||||||
|
assert load_config(dst).models.active_mode == "D" # untouched on failure
|
||||||
|
|
||||||
|
|
||||||
|
def test_future_mode_extensible_without_code(tmp_path, repo_config_path):
|
||||||
|
"""Adding mode E is a YAML-only change: loader accepts it, no code edits."""
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
dst = tmp_path / "config.yaml"
|
||||||
|
shutil.copy(repo_config_path, dst)
|
||||||
|
with open(dst) as f:
|
||||||
|
raw = yaml.safe_load(f)
|
||||||
|
raw["models"]["modes"]["E"] = {
|
||||||
|
"description": "hypothetical future preset",
|
||||||
|
"engines": [{"path": "combined", "classes": ["truck", "sack"]}],
|
||||||
|
"class_filters": {"truck": ["truck"], "sack": ["sack"], "box": []},
|
||||||
|
}
|
||||||
|
with open(dst, "w") as f:
|
||||||
|
yaml.safe_dump(raw, f)
|
||||||
|
cfg = load_config(dst)
|
||||||
|
assert resolve_active_mode("E", cfg) == "E"
|
||||||
|
assert set_active_mode(dst, "E") == "E"
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_batch_mode_check(tmp_path, repo_config_path):
|
||||||
|
cfg = load_config(repo_config_path)
|
||||||
|
legacy = tmp_path / "batch_mode.json"
|
||||||
|
legacy.write_text(json.dumps({"mode": "auto", "model_mode": "B"}))
|
||||||
|
msg = check_legacy_batch_mode(cfg, legacy)
|
||||||
|
assert msg is not None and "'B'" in msg and "'C'" in msg
|
||||||
|
legacy.write_text(json.dumps({"mode": "auto", "model_mode": "C"}))
|
||||||
|
assert check_legacy_batch_mode(cfg, legacy) is None
|
||||||
|
assert check_legacy_batch_mode(cfg, tmp_path / "missing.json") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_zone_polygons_and_legacy_knob_warning(tmp_path):
|
||||||
|
zf = tmp_path / "zones.json"
|
||||||
|
zf.write_text(json.dumps({
|
||||||
|
"palet": [[0, 0]], "truck": [[1, 1]], "counting": [[2, 2]],
|
||||||
|
"left_limit": 0.27, "right_limit": 0.72,
|
||||||
|
"duplicate_circle_radius": 30, # legacy knob -> ignored + warned
|
||||||
|
}))
|
||||||
|
with pytest.warns(UserWarning, match="legacy knob"):
|
||||||
|
zones = read_zone_polygons(zf)
|
||||||
|
assert zones["palet"] == [[0, 0]]
|
||||||
|
assert zones["left_limit"] == 0.27 # geometry stays in zones.json
|
||||||
Reference in new issue
Block a user