diff --git a/.env.example b/.env.example index 05be92b..67bc236 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 56b95e2..e91972b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/AGENTS.md b/AGENTS.md index 55f5bf9..083dc76 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..8638de9 --- /dev/null +++ b/config.yaml @@ -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" diff --git a/counter_dashboard.py b/counter_dashboard.py index 11e8700..fff7ed1 100644 --- a/counter_dashboard.py +++ b/counter_dashboard.py @@ -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: diff --git a/deploy_to_jetson.py b/deploy_to_jetson.py index f53f961..d27fcac 100644 --- a/deploy_to_jetson.py +++ b/deploy_to_jetson.py @@ -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 diff --git a/docs/configuration.md b/docs/configuration.md index ee863d4..9916a6b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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/...`. diff --git a/models/modelREADME.md b/models/modelREADME.md index 36f1da0..0acc3e4 100644 --- a/models/modelREADME.md +++ b/models/modelREADME.md @@ -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 diff --git a/predict.py b/predict.py index 4afd6ab..975a07c 100644 --- a/predict.py +++ b/predict.py @@ -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) diff --git a/requirements.txt b/requirements.txt index 09f1669..07052df 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/src/config_loader.py b/src/config_loader.py new file mode 100644 index 0000000..978612f --- /dev/null +++ b/src/config_loader.py @@ -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", ""), + } diff --git a/src/detection.py b/src/detection.py index fa0b11e..23b27fa 100644 --- a/src/detection.py +++ b/src/detection.py @@ -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) diff --git a/src/tracking.py b/src/tracking.py index c65cb3f..09a73d1 100644 --- a/src/tracking.py +++ b/src/tracking.py @@ -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, diff --git a/tests/test_config_loader.py b/tests/test_config_loader.py new file mode 100644 index 0000000..e72ce31 --- /dev/null +++ b/tests/test_config_loader.py @@ -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