From 12b70a34315959871e58ad7f4d8569311a0efa63 Mon Sep 17 00:00:00 2001 From: andrew Date: Thu, 17 Sep 2026 12:59:54 +0700 Subject: [PATCH] refactor: consolidate counting config through src/config_loader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - config.yaml gains counting.max_reid_frames (200) + debounce_frames (8); CountingConfig + parsing + pinned-value test added - predict.py load_zones() delegates to read_zone_polygons() (geometry only); legacy zones.json knob reads removed (config.yaml is canonical) - run_prediction wires 5 more live knobs from cfg.counting.* (entry/exit overlap, tolerance_missing_frames, max_reid_frames, debounce_frames) - delete deprecated src/config.py (v3 keys) + tests/test_config.py; src/main.py bridges onto unified config so the deprecated entrypoint keeps working - 8 never-read globals (JARAK_ABSORBSI_GHOST, CAMERA_NOISE_DEADBAND, ...) left untouched — flagged for Phase 2 dead-code cleanup Verified: pytest 21 passed; full pipeline runs on synthetic video (engines load, 30 frames, clean finish). --- config.yaml | 2 ++ predict.py | 59 +++++++++++++++------------------- src/config.py | 64 ------------------------------------- src/config_loader.py | 4 +++ src/main.py | 46 ++++++++++++++++++++++++-- tests/test_config.py | 28 ---------------- tests/test_config_loader.py | 19 +++++++++++ 7 files changed, 94 insertions(+), 128 deletions(-) delete mode 100644 src/config.py delete mode 100644 tests/test_config.py diff --git a/config.yaml b/config.yaml index 8638de9..9abce7b 100644 --- a/config.yaml +++ b/config.yaml @@ -112,6 +112,8 @@ counting: circle_stay_timeout_sec: 10.0 jarak_toleransi_duplikat: 30 tolerance_missing_frames: 1200 + max_reid_frames: 200 + debounce_frames: 8 batch: timeout_seconds: 30.0 diff --git a/predict.py b/predict.py index 975a07c..415cbc1 100644 --- a/predict.py +++ b/predict.py @@ -30,7 +30,6 @@ from src.config_loader import ( load_config, read_zone_polygons, resolve_active_mode, - LEGACY_ZONE_KNOBS, ) # --- SQLite Database & State Configuration --- @@ -649,43 +648,30 @@ DEFAULT_TRUCK = [] def load_zones(): - global ZONA_PALET_REF, ZONA_TRUCK_REF, GARIS_COUNTING_REF, DUPLICATE_CIRCLE_RADIUS_REF - global MIN_VALID_AREA_REF, JARAK_TOLERANSI_DUPLIKAT_REF, MAX_REID_TRANSIT_DISTANCE_REF - global CIRCLE_STAY_TIMEOUT_SEC, INFERENCE_STRIDE, CONFIRM_DELAY_SEC, EXIT_CONFIRM_DELAY_SEC + """Load zone geometry from zones.json (polygons only — knobs live in config.yaml). + + Delegates to src.config_loader.read_zone_polygons: legacy knob keys in + zones.json are ignored with a warning (config.yaml counting.* wins). + ZONA_COUNTING_REF is derived from the truck polygon (bottom quarter), + not from the file's "counting" key — preserved pre-YAML behaviour. + """ + global ZONA_PALET_REF, ZONA_TRUCK_REF, GARIS_COUNTING_REF global ZONA_COUNTING_REF, left_limit_ref, right_limit_ref, EXTERNAL_STREAM_URL_REF if os.path.exists(ZONES_JSON_PATH): try: - with open(ZONES_JSON_PATH, 'r') as f: - data = json.load(f) - ZONA_PALET_REF = np.array(data.get('palet', []), dtype=np.int32) - ZONA_TRUCK_REF = np.array(data.get('truck', []), dtype=np.int32) - ZONA_COUNTING_REF = get_bottom_quarter(ZONA_TRUCK_REF) - left_limit_ref = float(data.get('left_limit', 0.05)) - right_limit_ref = float(data.get('right_limit', 0.95)) - GARIS_COUNTING_REF = ZONA_TRUCK_REF.copy() - DUPLICATE_CIRCLE_RADIUS_REF = data.get('duplicate_circle_radius', 30) - MIN_VALID_AREA_REF = data.get('min_valid_area', 15000) - JARAK_TOLERANSI_DUPLIKAT_REF = data.get('jarak_toleransi_duplikat', 20) - MAX_REID_TRANSIT_DISTANCE_REF = data.get('max_reid_transit_distance', 400) - CIRCLE_STAY_TIMEOUT_SEC = data.get('circle_stay_timeout_sec', 10.0) - INFERENCE_STRIDE = data.get('inference_stride', 2) - CONFIRM_DELAY_SEC = data.get('confirm_delay_sec', 0.5) - EXIT_CONFIRM_DELAY_SEC = data.get('exit_confirm_delay_sec', 6.0) - 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 + zones = read_zone_polygons(ZONES_JSON_PATH) + ZONA_PALET_REF = np.array(zones.get('palet', []), dtype=np.int32) + ZONA_TRUCK_REF = np.array(zones.get('truck', []), dtype=np.int32) + ZONA_COUNTING_REF = get_bottom_quarter(ZONA_TRUCK_REF) + left_limit_ref = float(zones.get('left_limit', 0.05)) + right_limit_ref = float(zones.get('right_limit', 0.95)) + GARIS_COUNTING_REF = ZONA_TRUCK_REF.copy() + EXTERNAL_STREAM_URL_REF = zones.get('external_stream_url', '') or 'http://192.168.192.96:8888/cam/' + print("[INFO] Berhasil memuat koordinat zona dari zones.json (via src.config_loader)") + return except Exception as e: print(f"[WARNING] Gagal memuat zones.json ({e}), menggunakan default.") - + ZONA_PALET_REF = np.array(DEFAULT_PALET, dtype=np.int32) ZONA_TRUCK_REF = np.array(DEFAULT_TRUCK, dtype=np.int32) ZONA_COUNTING_REF = get_bottom_quarter(ZONA_TRUCK_REF) @@ -1247,6 +1233,8 @@ def run_prediction(model_path, source_path, global width, height, CONFIRM_DELAY_SEC, EXIT_CONFIRM_DELAY_SEC global active_batch_info, system_state global DUPLICATE_CIRCLE_RADIUS, MIN_VALID_AREA, JARAK_TOLERANSI_DUPLIKAT, MAX_REID_TRANSIT_DISTANCE + global ENTRY_OVERLAP_THRESHOLD, EXIT_OVERLAP_THRESHOLD, TOLERANSI_FRAME_HILANG + global MAX_REID_FRAMES, DEBOUNCE_FRAMES 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 @@ -1293,6 +1281,11 @@ def run_prediction(model_path, source_path, 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 + ENTRY_OVERLAP_THRESHOLD = cfg.counting.entry_overlap_threshold + EXIT_OVERLAP_THRESHOLD = cfg.counting.exit_overlap_threshold + TOLERANSI_FRAME_HILANG = cfg.counting.tolerance_missing_frames + MAX_REID_FRAMES = cfg.counting.max_reid_frames + DEBOUNCE_FRAMES = cfg.counting.debounce_frames # Legacy batch_mode.json no longer drives the mode — nudge once if stale. _legacy_warn = check_legacy_batch_mode(cfg, BATCH_MODE_FILE) diff --git a/src/config.py b/src/config.py deleted file mode 100644 index bc5022c..0000000 --- a/src/config.py +++ /dev/null @@ -1,64 +0,0 @@ -"""Configuration loader — reads .env and exposes typed settings.""" - -from __future__ import annotations - -import os -from dataclasses import dataclass, field -from pathlib import Path - -from dotenv import load_dotenv - - -@dataclass(frozen=True) -class Config: - """Immutable application configuration.""" - - # Stream sources - local_rtsp: str = "" - jetson_rtsp: str = "" - - # Model paths (canonical production weights in models/; see models/modelREADME.md) - sack_model_path: str = "./models/best.engine" - truck_model_path: str = "./models/truck-detector.engine" - - # Counting line (fractions of frame dimensions) - counting_line_y: float = 0.60 - counting_line_x_start: float = 0.38 - counting_line_x_end: float = 0.72 - - # Detection confidence - sack_conf: float = 0.40 - truck_conf: float = 0.50 - - # Batch management - batch_timeout_seconds: float = 30.0 - - # Output - csv_output_dir: str = "./output" - seed: int = 42 - - -def load_config(env_path: str = ".env") -> Config: - """Load configuration from .env file and environment variables.""" - load_dotenv(env_path) - - return Config( - local_rtsp=os.getenv("LOCAL_RTSP", ""), - jetson_rtsp=os.getenv("JETSON_RTSP", ""), - sack_model_path=os.getenv("MODEL_SACK_PATH", "./models/best.engine"), - truck_model_path=os.getenv( - "MODEL_TRUCK_PATH", "./models/truck-detector.engine" - ), - counting_line_y=float(os.getenv("COUNTING_LINE_Y", "0.60")), - counting_line_x_start=float( - os.getenv("COUNTING_LINE_X_START", "0.38") - ), - counting_line_x_end=float(os.getenv("COUNTING_LINE_X_END", "0.72")), - sack_conf=float(os.getenv("SACK_CONF_THRESHOLD", "0.40")), - truck_conf=float(os.getenv("TRUCK_CONF_THRESHOLD", "0.50")), - batch_timeout_seconds=float( - os.getenv("BATCH_TIMEOUT_SECONDS", "30") - ), - csv_output_dir=os.getenv("CSV_OUTPUT_DIR", "./output"), - seed=int(os.getenv("DATA_SEED", "42")), - ) diff --git a/src/config_loader.py b/src/config_loader.py index 978612f..dda78fa 100644 --- a/src/config_loader.py +++ b/src/config_loader.py @@ -95,6 +95,8 @@ class CountingConfig: circle_stay_timeout_sec: float = 10.0 jarak_toleransi_duplikat: int = 30 tolerance_missing_frames: int = 1200 + max_reid_frames: int = 200 + debounce_frames: int = 8 @dataclass @@ -428,6 +430,8 @@ def load_config(path: str | Path = "config.yaml") -> Config: 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)), + max_reid_frames=int(counting_raw.get("max_reid_frames", 200)), + debounce_frames=int(counting_raw.get("debounce_frames", 8)), ), batch=BatchConfig( timeout_seconds=float(batch_raw.get("timeout_seconds", 30.0)), diff --git a/src/main.py b/src/main.py index 5407d91..ead6bdb 100644 --- a/src/main.py +++ b/src/main.py @@ -13,7 +13,7 @@ import time import cv2 from src.batch import BatchLifecycleManager -from src.config import load_config, Config +from src.config_loader import load_config as _load_unified_config from src.counting import LineCrossCounter from src.dashboard import DashboardOverlay from src.detection import TruckDetector @@ -24,7 +24,47 @@ from src.tracking import ByteTrackTracker from src.truck_roi import TruckROITracker -def build_pipeline(cfg: Config, source_path: str | None = None): +def _legacy_cfg_bridge(env_path: str = ".env"): + """Bridge the deprecated v3 Config API onto the unified config_loader.Config. + + `python -m src.main` is deprecated (use predict.py), but must keep working. + Only the fields actually used below are bridged. + """ + import os + from types import SimpleNamespace + + try: + from dotenv import load_dotenv + load_dotenv(env_path) + except ImportError: + pass + cfg = _load_unified_config("config.yaml") + mode = cfg.get_active_mode() + truck_key = next((e.path for e in mode.engines if "truck" in e.classes), "combined") + sack_key = next((e.path for e in mode.engines if "sack" in e.classes), "combined") + base = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + return SimpleNamespace( + local_rtsp=cfg.stream.rtsp_url, + jetson_rtsp="", + sack_model_path=cfg.engine_path(sack_key, base), + truck_model_path=cfg.engine_path(truck_key, base), + counting_line_y=0.60, + counting_line_x_start=0.38, + counting_line_x_end=0.72, + sack_conf=cfg.detection_params_for("sack").conf, + truck_conf=cfg.detection_params_for("truck").conf, + batch_timeout_seconds=cfg.batch.timeout_seconds, + csv_output_dir="./output", + seed=42, + ) + + +def load_config(env_path: str = ".env"): + """Deprecated shim — use src.config_loader.load_config instead.""" + return _legacy_cfg_bridge(env_path) + + +def build_pipeline(cfg, source_path: str | None = None): """Construct all components from config.""" # ── Stream source ──────────────────────────────────────────── @@ -108,7 +148,7 @@ def _filter_sacks_in_roi(detections, roi): ] -def run(cfg: Config, source_path: str | None = None) -> None: +def run(cfg, source_path: str | None = None) -> None: """Main processing loop — processes EVERY frame for accuracy.""" p = build_pipeline(cfg, source_path) diff --git a/tests/test_config.py b/tests/test_config.py deleted file mode 100644 index 6b9c6fe..0000000 --- a/tests/test_config.py +++ /dev/null @@ -1,28 +0,0 @@ -"""Smoke tests for config loader (src/config.py). Needs python-dotenv only.""" - -from src.config import load_config - -_KEYS = [ - "LOCAL_RTSP", "JETSON_RTSP", "MODEL_SACK_PATH", "MODEL_TRUCK_PATH", - "COUNTING_LINE_Y", "COUNTING_LINE_X_START", "COUNTING_LINE_X_END", - "SACK_CONF_THRESHOLD", "TRUCK_CONF_THRESHOLD", "BATCH_TIMEOUT_SECONDS", - "CSV_OUTPUT_DIR", "DATA_SEED", -] - - -def test_load_from_env_file(tmp_path, monkeypatch): - for k in _KEYS: - monkeypatch.delenv(k, raising=False) - env = tmp_path / "test.env" - env.write_text( - "LOCAL_RTSP=rtsp://cam/1\n" - "MODEL_SACK_PATH=/m/sack.pt\n" - "MODEL_TRUCK_PATH=/m/truck.pt\n" - "SACK_CONF_THRESHOLD=0.55\n" - ) - cfg = load_config(str(env)) - assert cfg.local_rtsp == "rtsp://cam/1" - assert cfg.sack_model_path == "/m/sack.pt" - assert cfg.truck_model_path == "/m/truck.pt" - assert cfg.sack_conf == 0.55 - assert cfg.truck_conf == 0.50 # default preserved diff --git a/tests/test_config_loader.py b/tests/test_config_loader.py index e72ce31..fbe25e5 100644 --- a/tests/test_config_loader.py +++ b/tests/test_config_loader.py @@ -39,6 +39,25 @@ def test_repo_config_loads_and_validates(repo_config_path): assert dp.min_bbox_area >= 0 +def test_counting_knobs_match_production_defaults(repo_config_path): + """All live counting knobs in predict.py must come from config.yaml.""" + cfg = load_config(repo_config_path) + c = cfg.counting + assert c.confirm_delay_sec == 0.5 + assert c.exit_confirm_delay_sec == 6.0 + assert c.entry_overlap_threshold == 0.20 + assert c.exit_overlap_threshold == 0.05 + assert c.camera_noise_deadband == 50 + assert c.duplicate_circle_radius == 30 + assert c.min_valid_area == 15000 + assert c.max_reid_transit_distance == 400 + assert c.circle_stay_timeout_sec == 10.0 + assert c.jarak_toleransi_duplikat == 30 + assert c.tolerance_missing_frames == 1200 + assert c.max_reid_frames == 200 + assert c.debounce_frames == 8 + + 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: