feat: unified config.yaml with extensible model presets (A-D data-driven, E/F ready)
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:
andrew committed 2026-09-17 11:44:45 +07:00
1 parent 630f4bc29e
commit 2d9c66cbb6
14 files changed
+1254 -199

No files matched your search

+18 -8
View File
@@ -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
+1 -1
View File
@@ -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
+15 -9
View File
@@ -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
View File
@@ -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
View File
@@ -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:
+1
View File
@@ -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
View File
@@ -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/...`.
+7 -1
View File
@@ -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
View File
@@ -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)
+1
View File
@@ -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
+609
View File
@@ -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
View File
@@ -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
View File
@@ -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,
+128
View File
@@ -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