refactor: consolidate counting config through src/config_loader

- 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).
This commit is contained in:
andrew committed 2026-09-17 12:59:54 +07:00
1 parent 0467e0ce68
commit 12b70a3431
7 files changed
+90 -124

No files matched your search

+2
View File
@@ -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
+22 -29
View File
@@ -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,39 +648,26 @@ 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)
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(data.get('left_limit', 0.05))
right_limit_ref = float(data.get('right_limit', 0.95))
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()
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")
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.")
@@ -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)
-64
View File
@@ -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")),
)
+4
View File
@@ -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)),
+43 -3
View File
@@ -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)
-28
View File
@@ -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
+19
View File
@@ -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: