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
DB_PATH=/opt/jetson-counter/jetson_counter.db
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
# --- Identity / batch day ---
CAMERA_NAME=CC1
OBJECT_LABEL=karung-pakan
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
DASHBOARD_HOST=0.0.0.0
DASHBOARD_PORT=5000
OFFICE_PORT=5721
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
# Model pipeline mode: A=combined only; B=v4 truck + yolo11n sack+box;
# C=A + yolo11n box-only (default); D=v4 truck + best sack-only + yolo11n box-only.
# Dashboard switches persist here and apply on next service restart.
MODEL_MODE=C
# --- Deprecated: model pipeline mode now lives in config.yaml models.active_mode.
# Dashboard switches persist there (atomic write, comments preserved) and apply
# on next `karung-counter` restart. Only set this to temporarily override.
# MODEL_MODE=C
+1 -1
View File
@@ -12,6 +12,6 @@ jobs:
- uses: actions/setup-python@v5
with:
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 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
- `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
--model-mode A|B|C|D --batch-timeout S --max-frames N --no-dashboard --no-db`.
Zero flags = systemd behaviour (`MODEL_MODE` env, default C).
Model modes: A=combined only; B=v4 truck + yolo11n sack+box;
C=A + yolo11n box-only (default); D=v4 truck + best sack-only + yolo11n box-only.
--model-mode M --batch-timeout S --max-frames N --no-dashboard --no-db`.
Zero flags = systemd behaviour (`config.yaml` + `.env`).
Model modes are DATA in `config.yaml` `models.modes` (engines + class filters
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
order assumptions — PyTorch `.pt` must load before TensorRT `.engine`.
- `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
- **Two env-key dialects**: production `.env` uses `RTSP_URL`, `DB_PATH`, `MODEL_PATH`,
… (`predict.py`, `counter_dashboard.py`); `src/config.py` reads different keys
(`LOCAL_RTSP`, `MODEL_SACK_PATH`, `MODEL_TRUCK_PATH`, …). Check which loader your
entry point uses before adding config.
- **Unified config**: `config.yaml` is canonical (stream/models/counting/batch/
output/camera via `src/config_loader.py`); `.env` holds secrets + deployment
only (`RTSP_URL`, dashboard host/ports/secret/site); `zones.json` holds
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`/
`TruckDetector` filter via `BaseDetector(class_filter)` (`src/detection.py`);
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
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.config["SECRET_KEY"] = os.getenv("SECRET_KEY", "change-me-in-production")
app.config["SECRET_KEY"] = CFG.dashboard.secret_key
if os.name == "nt":
_DEFAULT_DIR = "d:/Belajar/menghitung karung"
@@ -31,20 +48,25 @@ if os.name == "nt":
CURRENT_BATCH_PATH = f"{_DEFAULT_DIR}/current_batch.json"
BATCH_MODE_PATH = f"{_DEFAULT_DIR}/batch_mode.json"
LIVE_STREAM_FRAME_PATH = f"{_DEFAULT_DIR}/live_frame.jpg"
LIVE_STATUS_FILE = f"{_DEFAULT_DIR}/live_status.json"
else:
_DEFAULT_DIR = "/opt/jetson-counter"
DB_PATH = os.getenv("DB_PATH", f"{_DEFAULT_DIR}/jetson_counter.db")
CURRENT_BATCH_PATH = os.getenv("STATE_FILE", os.getenv("CURRENT_BATCH_PATH", f"{_DEFAULT_DIR}/current_batch.json"))
BATCH_MODE_PATH = os.getenv("BATCH_MODE_FILE", f"{_DEFAULT_DIR}/batch_mode.json")
LIVE_STREAM_FRAME_PATH = os.getenv("LIVE_STREAM_FRAME_PATH", "/dev/shm/jetson-counter/live_frame.jpg")
_DEFAULT_DIR = CFG.output.dir
DB_PATH = _env_or(os.path.join(_DEFAULT_DIR, CFG.output.db_name), "DB_PATH")
CURRENT_BATCH_PATH = _env_or(
os.path.join(_DEFAULT_DIR, CFG.output.state_file),
"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_HOST = os.getenv("DASHBOARD_HOST", "0.0.0.0")
FLASK_DEBUG = os.getenv("FLASK_DEBUG", "false").lower() == "true"
DASHBOARD_PORT = CFG.dashboard.port
DASHBOARD_HOST = CFG.dashboard.host
FLASK_DEBUG = CFG.dashboard.debug
@app.route("/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()
CAMERA_NAME = os.getenv("CAMERA_NAME", "CC1")
OBJECT_LABEL = os.getenv("OBJECT_LABEL", "karung-pakan")
OFFICE_PORT = int(os.getenv("OFFICE_PORT", "5721"))
CAMERA_NAME = _env_or(CFG.camera.name, "CAMERA_NAME")
OBJECT_LABEL = _env_or(CFG.camera.object_label, "OBJECT_LABEL")
OFFICE_PORT = CFG.dashboard.office_port
def is_office_request():
"""Check if request comes from office port."""
@@ -359,25 +381,35 @@ def api_batch_stop():
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 = {
"A": "Combined v4 sack+truck only (legacy)",
"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",
m: preset.description for m, preset in CFG.models.modes.items()
}
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():
data = {"mode": "manual", "model_mode": "C"}
data = {"mode": "manual", "model_mode": _active_model_mode()}
if os.path.exists(BATCH_MODE_PATH):
try:
with open(BATCH_MODE_PATH, "r") as f:
stored = json.load(f)
data["mode"] = stored.get("mode", "manual")
data["model_mode"] = (stored.get("model_mode") or "C").upper()
if data["model_mode"] not in MODEL_MODE_CHOICES:
data["model_mode"] = "C"
# Legacy model_mode in batch_mode.json is IGNORED (config.yaml wins).
legacy = (stored.get("model_mode") or "").upper()
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:
pass
return data
@@ -395,20 +427,32 @@ def api_batch_mode():
return jsonify({"success": False, "error": "Invalid mode. Use 'auto' or 'manual'"}), 400
stored["mode"] = mode
if "model_mode" in req_data:
mmode = str(req_data.get("model_mode", "C")).upper()
if mmode not in MODEL_MODE_CHOICES:
return jsonify({"success": False, "error": "Invalid model_mode. Use A/B/C/D"}), 400
mmode = str(req_data.get("model_mode", "")).upper()
try:
# 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["updated_at"] = datetime.now().isoformat()
os.makedirs(os.path.dirname(BATCH_MODE_PATH), exist_ok=True)
with open(BATCH_MODE_PATH, "w", encoding="utf-8") as f:
json.dump(stored, f, indent=2)
# batch_mode.json keeps ONLY the manual/auto batch mode now.
batch_state = {"mode": stored["mode"], "updated_at": stored["updated_at"]}
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"],
"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:
return jsonify({"success": False, "error": str(e)}), 500
@@ -432,7 +476,7 @@ def api_model_modes():
@app.route("/api/current-batch")
def api_current_batch():
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:
if os.path.exists(status_file):
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"),
("counter_dashboard.py", "/home/jetson/karung/counter_dashboard.py"),
("predict.py", "/home/jetson/karung/predict.py"),
("config.yaml", "/home/jetson/karung/config.yaml"),
(".env", "/home/jetson/karung/.env"),
] + [
(f"models/{f}", f"/home/jetson/karung/models/{f}") for f in MODEL_ENGINES
+31 -4
View File
@@ -1,9 +1,36 @@
# 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.
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 |
|---|---|---|
@@ -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 |
| `OFFICE_PORT` | `5721` | Second dashboard port |
| `FLASK_DEBUG` | `false` | Flask debug |
| `RTSP_URL` | — | Camera stream URL |
| `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_MODE` | `C` | Model pipeline mode A/B/C/D (see `models/modelREADME.md`); also settable via `--model-mode` or dashboard (applies on restart) |
| `RTSP_URL` | — | Camera stream URL (env-only, never in YAML) |
| `MODEL_PATH` | — (deprecated) | Single-file v4 override, folded into `models.paths` |
| `MODEL_MODE` | — (deprecated) | One-run override of `models.active_mode` (warned) |
| `BATCH_MERGE_THRESHOLD_SECONDS` | `300` | Merge window for adjacent batches |
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) |
| `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
`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.batch import BatchLifecycleManager, BatchRecord
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 ---
if os.name == 'nt':
@@ -75,10 +83,12 @@ def parse_args():
p.add_argument("--box-model", type=str, default=None,
help="Override yolo11n sack+box model path (.pt/.engine)")
p.add_argument("--model-mode", type=str, default=None,
choices=["A", "B", "C", "D"],
help="Model pipeline mode (default: MODEL_MODE env or C). "
"A=combined only; B=v4 truck + yolo11n sack+box; "
"C=A + yolo11n box-only; D=v4 truck + best sack + yolo11n box")
help="Model pipeline mode (default: config.yaml models.active_mode). "
"A=combined only; B=v4 truck + yolo11n sack+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,
help="Override sack-idle + truck-gone timeouts (seconds)")
p.add_argument("--max-frames", type=int, default=None,
@@ -443,15 +453,22 @@ all_counted_sacks_map = {}
last_seen_near_person_frame = {}
blocked_due_to_duplicate = {}
# --- Model Paths ---
# All weights live in models/ (see models/modelREADME.md for mode/filter matrix).
# Model gabungan karung + truk terbaru (v4-best TensorRT / PyTorch)
# --- Model paths & config ---
# All weights live in models/ (see models/modelREADME.md for mode/filter matrix
# and config.yaml models.* for the canonical paths + per-mode presets).
_BASE_DIR = os.path.dirname(os.path.abspath(__file__))
_MODELS_DIR = os.path.join(_BASE_DIR, "models")
_env_model = os.getenv("MODEL_PATH")
if _env_model and os.path.exists(_env_model):
COMBINED_MODEL_PATH = _env_model
else:
_DEFAULT_CONFIG_PATH = os.path.join(_BASE_DIR, "config.yaml")
# Active Config object (set in __main__/run_prediction before the pipeline starts).
_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 = [
os.path.join(_MODELS_DIR, "v4-best.engine"),
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.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:
"""Resolve yolo11n sack+box weights: explicit > .engine > .pt."""
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")
def apply_cli_overrides_to_config(cfg: Config, args) -> Config:
"""Fold legacy CLI/env model overrides into the Config object (in place).
def _pick_sack_only_model() -> str:
"""Resolve best sack-only weights: .engine > .pt."""
for cand in (
os.path.join(_MODELS_DIR, "best.engine"),
os.path.join(_MODELS_DIR, "best.pt"),
):
if os.path.exists(cand):
return cand
return os.path.join(_MODELS_DIR, "best.engine")
--model/--box-model/MODEL_PATH keep their pre-YAML meaning:
a single v4 file used wherever a v4 engine is needed.
--sack-conf/--truck-conf/--box-conf override detection_params conf.
"""
v4_override = args.model or os.getenv("MODEL_PATH")
if v4_override:
if not os.path.exists(v4_override):
print(f"[WARN] --model/MODEL_PATH {v4_override} tidak ditemukan, "
f"dipakai langsung (YOLO bisa resolve).")
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 -------------------------------------------------
# A: combined v4 sack+truck only (legacy production).
# B: v4 truck-only + yolo11n sack+box.
# C: A + yolo11n box-only (default production).
# D: v4 truck + best.pt sack-only + yolo11n box-only.
# Modes are DATA in config.yaml models.modes (engines + class filters only).
# 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 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:
"""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).
batch_mode.json is legacy and no longer consulted.
"""
mode = (explicit or os.getenv("MODEL_MODE") or "").upper()
if not mode:
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
cfg = _CFG or load_config(_DEFAULT_CONFIG_PATH)
return resolve_active_mode(explicit, cfg)
def build_model_pipeline(mode, combined_path, box_model_path, sack_only_path,
sack_conf, truck_conf, box_conf, device):
"""Instantiate YOLO handles + detectors/trackers for a model mode.
def _bbox_area_ok(d, min_area: float) -> bool:
"""Permissive per-class guardrail from config.yaml detection_params."""
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)
def _load(path, label):
print(f"[INFO] Memuat {label}: {path}")
m = YOLO(path)
_ = m(dummy, imgsz=640, device=device, verbose=False) # warm-up CUDA/TRT ctx
return m
handles: dict[str, object] = {}
def _load(path_key: str):
if path_key not in handles:
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 {
"mode": mode,
"truck_detector": TruckDetector(v4, truck_conf, class_filter=("truck",)),
"tracker": ByteTrackTracker(sb, sack_conf),
"box_tracker": ByteTrackTracker(yb, box_conf),
"separate_truck_model": True,
"truck_detector": truck_detector,
"tracker": tracker,
"box_tracker": box_tracker,
"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/')
if not EXTERNAL_STREAM_URL_REF:
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")
return
except Exception as e:
@@ -1200,9 +1235,10 @@ def _filter_sacks_in_roi(detections, roi):
def run_prediction(model_path, source_path,
output_json_path="hasil_perhitungan.json", max_frames=None,
inference_stride=2, sack_conf=0.35, truck_conf=0.35,
box_conf=0.35, box_model_path=None, model_mode=None,
output_dir=None, batch_timeout=None):
inference_stride=None, sack_conf=None, truck_conf=None,
box_conf=None, box_model_path=None, model_mode=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 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
@@ -1212,25 +1248,65 @@ def run_prediction(model_path, source_path,
global active_batch_info, system_state
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 CAMERA_NAME, OBJECT_LABEL, DAILY_CUTOFF_TIME, BATCH_MERGE_THRESHOLD_SECONDS
global CIRCLE_STAY_TIMEOUT_SEC, _CFG
mode = resolve_model_mode(model_mode)
if box_model_path is None:
box_model_path = _pick_box_model(None)
# --- Unified config (config.yaml canonical, .env for secrets/deployment) ---
global _CFG
_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:
# 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")
STATE_FILE = os.path.join(output_dir, "current_batch.json")
BATCH_MODE_FILE = os.path.join(output_dir, "batch_mode.json")
LIVE_STREAM_FRAME_PATH = os.path.join(output_dir, "live_frame.jpg")
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
from ultralytics.utils import LOGGER
import logging
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_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)")
# Initialize components per model mode (engines verified to coexist)
pipe = build_model_pipeline(
mode, model_path, box_model_path, _pick_sack_only_model(),
sack_conf, truck_conf, box_conf, device,
)
# Initialize components per mode preset (engines verified to coexist)
pipe = build_model_pipeline(mode, cfg, device, _BASE_DIR)
print("[INFO] Warm-up model selesai.")
truck_detector = pipe["truck_detector"]
tracker = pipe["tracker"]
box_tracker = pipe["box_tracker"]
separate_truck_model = pipe["separate_truck_model"]
min_areas = pipe["min_areas"]
stabilizer = BboxStabilizer(
ema_alpha=0.35,
max_hold_frames=10,
@@ -1334,10 +1408,10 @@ def run_prediction(model_path, source_path,
confidence=1.0
)
DUPLICATE_CIRCLE_RADIUS = DUPLICATE_CIRCLE_RADIUS_REF
MIN_VALID_AREA = MIN_VALID_AREA_REF
JARAK_TOLERANSI_DUPLIKAT = JARAK_TOLERANSI_DUPLIKAT_REF
MAX_REID_TRANSIT_DISTANCE = MAX_REID_TRANSIT_DISTANCE_REF
# NOTE: counting-knob globals (DUPLICATE_CIRCLE_RADIUS, MIN_VALID_AREA,
# JARAK_TOLERANSI_DUPLIKAT, MAX_REID_TRANSIT_DISTANCE, ...) were set from
# config.yaml at the top of run_prediction — intentionally NOT reset to
# zones.json REFs here (config.yaml is canonical now).
counter = MultiClassLineCounter(
line_y=static_line_y,
@@ -1346,7 +1420,7 @@ def run_prediction(model_path, source_path,
margin=20,
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(
stabilize_seconds=0.0, # Start batch instantly when triggered by crossing
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).
# Counter splits by class_name downstream; MultiClassLineCounter
# 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
# 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 frame_idx % 5 == 0 or 'last_detected_trucks' not in locals():
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
else:
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 = [
d for d in stable_boxes
@@ -1777,35 +1861,36 @@ if __name__ == "__main__":
NO_DASHBOARD = args.no_dashboard
NO_DB = args.no_db
MODEL_FILE = args.model or COMBINED_MODEL_PATH
if args.model and not os.path.exists(args.model):
print(f"[WARN] --model {args.model} tidak ditemukan, dipakai langsung (YOLO bisa resolve).")
# Unified config first (config.yaml canonical; .env supplies secrets/RTSP).
_CFG = load_config(args.config or _DEFAULT_CONFIG_PATH)
_CFG = apply_cli_overrides_to_config(_CFG, args)
if args.source:
SOURCE_INPUT = args.source
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")
OUTPUT_JSON = args.output_json or "hasil_perhitungan.json"
print(f"[INFO] source={SOURCE_INPUT} model={MODEL_FILE} "
f"sack_conf={args.sack_conf or 0.35} truck_conf={args.truck_conf or 0.35} "
f"box_conf={args.box_conf or 0.35} model_mode={args.model_mode or os.getenv('MODEL_MODE', 'C')} "
print(f"[INFO] config={args.config or _DEFAULT_CONFIG_PATH} source={SOURCE_INPUT} "
f"model_mode={resolve_model_mode(args.model_mode)} "
f"no_dashboard={NO_DASHBOARD} no_db={NO_DB}")
try:
run_prediction(
model_path=MODEL_FILE,
model_path=args.model,
source_path=SOURCE_INPUT,
output_json_path=OUTPUT_JSON,
max_frames=args.max_frames,
sack_conf=args.sack_conf or 0.35,
truck_conf=args.truck_conf or 0.35,
box_conf=args.box_conf or 0.35,
sack_conf=args.sack_conf,
truck_conf=args.truck_conf,
box_conf=args.box_conf,
box_model_path=args.box_model,
model_mode=args.model_mode,
output_dir=args.output_dir,
batch_timeout=args.batch_timeout,
config_path=args.config or _DEFAULT_CONFIG_PATH,
cfg=_CFG,
)
except KeyboardInterrupt:
print("\n" + "=" * 50)
+1
View File
@@ -7,6 +7,7 @@ numpy
shapely
flask
python-dotenv
pyyaml # also pulled by ultralytics; used directly by src/config_loader.py
openpyxl
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,
conf: float = 0.35,
class_filter: tuple[str, ...] | list[str] | None = None,
iou: float = 0.7,
) -> None:
self._model = model_path if isinstance(model_path, YOLO) else YOLO(model_path)
self._conf = conf
self._class_filter = set(class_filter) if class_filter else None
self._iou = iou
def detect(self, frame: np.ndarray) -> list[Detection]:
results = self._model.predict(
frame, conf=self._conf, verbose=False
frame, conf=self._conf, iou=self._iou, verbose=False
)
return self._parse(results[0])
@@ -69,8 +71,8 @@ class BaseDetector:
class SackDetector(BaseDetector):
"""Detects sacks (drops persons/boxes/trucks from multi-class models)."""
def __init__(self, model_path: str | YOLO, conf: float = 0.35) -> None:
super().__init__(model_path, conf, class_filter=("sack",))
def __init__(self, model_path: str | YOLO, conf: float = 0.35, iou: float = 0.7) -> None:
super().__init__(model_path, conf, class_filter=("sack",), iou=iou)
class TruckDetector(BaseDetector):
@@ -81,12 +83,13 @@ class TruckDetector(BaseDetector):
model_path: str | YOLO,
conf: float = 0.35,
class_filter: tuple[str, ...] | list[str] | None = None,
iou: float = 0.7,
) -> None:
super().__init__(model_path, conf, class_filter=class_filter)
super().__init__(model_path, conf, class_filter=class_filter, iou=iou)
class BoxDetector(BaseDetector):
"""Detects boxes (drops sacks/persons from the sack+box model)."""
def __init__(self, model_path: str | YOLO, conf: float = 0.35) -> None:
super().__init__(model_path, conf, class_filter=("box",))
def __init__(self, model_path: str | YOLO, conf: float = 0.35, iou: float = 0.7) -> None:
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:
"""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):
self._model = 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_path = model_path
self._conf = conf
self._iou = iou
self._tracker_cfg = _TRACKER_CFG if os.path.exists(_TRACKER_CFG) else "bytetrack.yaml"
def update(
@@ -45,6 +46,7 @@ class ByteTrackTracker:
results = self._model.track(
frame,
conf=self._conf,
iou=self._iou,
persist=True,
tracker=self._tracker_cfg,
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