feat: config-authoritative conf gates + cross-model IoU sack suppression
ci / smoke (push) Canceled after 0s

This commit is contained in:
andrew committed 2026-09-25 14:06:04 +07:00
1 parent 1e4dc4201c
commit a12056a5ad
7 files changed
+80 -7

No files matched your search

+5 -2
View File
@@ -79,11 +79,11 @@ models:
# perspective min_valid_area filter still applies on top).
detection_params:
truck:
conf: 0.35
conf: 0.45 # live gate was hardcoded 0.45; config authoritative now
iou: 0.7
min_bbox_area: 5000
sack:
conf: 0.3 # default 0.35
conf: 0.4 # live gate used to hardcode 0.50; 0.40 compromise (config is authoritative)
iou: 0.7
min_bbox_area: 1500
box:
@@ -107,6 +107,9 @@ counting:
camera_noise_deadband: 50
# Effective values below match zones.json (which previously overrode predict.py defaults).
duplicate_circle_radius: 30
# Drop sack detections overlapping a box detection (cross-model IoU) — v4 has
# no box class, so white boxes get labeled "sack"; box model always wins.
cross_class_iou: 0.4
min_valid_area: 15000
max_reid_transit_distance: 400
circle_stay_timeout_sec: 10.0
+5 -1
View File
@@ -16,7 +16,11 @@ 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`
apply to ALL modes. `detection_params.*.conf` is the **authoritative detection
floor** — `predict.py` no longer hardcodes a second gate (old 0.50 sack / 0.45
truck overrides are gone). `counting.cross_class_iou` (default 0.4) drops sack
detections that overlap a box detection (v4 has no box class); `<= 0` disables.
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.
+18 -4
View File
@@ -21,7 +21,7 @@ from src.detection import SackDetector, TruckDetector, BoxDetector
from src.tracking import ByteTrackTracker
from src.stabilizer import BboxStabilizer
from src.truck_roi import TruckROITracker
from src.counting import LineCrossCounter, MultiClassLineCounter
from src.counting import LineCrossCounter, MultiClassLineCounter, drop_sacks_overlapping_boxes
from src.batch import BatchLifecycleManager, BatchRecord
from src.dashboard import DashboardOverlay
from src.config_loader import (
@@ -454,6 +454,7 @@ MIN_DY_COUNTING_ZONE = -3
# --- Parameter Lingkaran Duplikat Statis ---
DUPLICATE_CIRCLE_RADIUS_REF = 35
DUPLICATE_CIRCLE_RADIUS = 35
CROSS_CLASS_IOU = 0.4 # drop sack dets overlapping a box det (cross-model)
SHOW_ALL_BBOXES = False
counted_sack_positions = []
CIRCLE_STAY_TIMEOUT_SEC = 10.0
@@ -632,6 +633,11 @@ def build_model_pipeline(mode, cfg, device, base_dir=None):
"sack": dp_sack.min_bbox_area,
"box": dp_box.min_bbox_area,
},
"confs": {
"truck": dp_truck.conf,
"sack": dp_sack.conf,
"box": dp_box.conf,
},
}
# =====================================================================
@@ -1264,6 +1270,7 @@ def run_prediction(model_path, source_path,
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 CROSS_CLASS_IOU
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
@@ -1315,6 +1322,7 @@ def run_prediction(model_path, source_path,
TOLERANSI_FRAME_HILANG = cfg.counting.tolerance_missing_frames
MAX_REID_FRAMES = cfg.counting.max_reid_frames
DEBOUNCE_FRAMES = cfg.counting.debounce_frames
CROSS_CLASS_IOU = cfg.counting.cross_class_iou
# Legacy batch_mode.json no longer drives the mode — nudge once if stale.
_legacy_warn = check_legacy_batch_mode(cfg, BATCH_MODE_FILE)
@@ -1372,6 +1380,7 @@ def run_prediction(model_path, source_path,
box_tracker = pipe["box_tracker"]
separate_truck_model = pipe["separate_truck_model"]
min_areas = pipe["min_areas"]
confs = pipe["confs"]
stabilizer = BboxStabilizer(
ema_alpha=0.35,
max_hold_frames=10,
@@ -1519,13 +1528,14 @@ def run_prediction(model_path, source_path,
else:
raw_tracked_all = last_raw_tracked_all
# Filter sack (+box, modes B-D) detections (confidence >= 0.50).
# Filter sack (+box, modes B-D) detections. Per-class conf floor
# comes from config.yaml detection_params (authoritative).
# Counter splits by class_name downstream; MultiClassLineCounter
# ignores anything that is not sack/box.
# 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
if d.class_name in ("sack", "box") and d.confidence >= confs.get(d.class_name, 0.50)
and _bbox_area_ok(d, min_areas.get(d.class_name, 0))
]
@@ -1543,7 +1553,7 @@ def run_prediction(model_path, source_path,
# Filter truck detections: Wajib 100% berada di dalam detection_polygon & ambil maksimal 1 bbox terbaik
valid_trucks = []
for d in truck_candidates:
if d.class_name == "truck" and d.confidence >= 0.45:
if d.class_name == "truck" and d.confidence >= confs.get("truck", 0.45):
x1, y1, x2, y2 = d.bbox
# Bounding box truk 100% harus berada di dalam detection_polygon
truck_bbox_poly = box(x1, y1, x2, y2)
@@ -1589,6 +1599,10 @@ def run_prediction(model_path, source_path,
]
# Combined list for ROI filter / counting / viz (counter splits by class).
# Cross-model suppression: v4 has no box class (white boxes labeled
# "sack"); box model always wins. cross_class_iou <= 0 disables.
if stable_boxes and CROSS_CLASS_IOU > 0:
stable = drop_sacks_overlapping_boxes(stable, stable_boxes, CROSS_CLASS_IOU)
stable_all = stable + stable_boxes
# Count sacks currently visible in the bottom 85% of truck area (for batch start/end condition)
+2
View File
@@ -90,6 +90,7 @@ class CountingConfig:
exit_overlap_threshold: float = 0.05
camera_noise_deadband: int = 50
duplicate_circle_radius: int = 30
cross_class_iou: float = 0.4
min_valid_area: int = 15000
max_reid_transit_distance: int = 400
circle_stay_timeout_sec: float = 10.0
@@ -467,6 +468,7 @@ def load_config(path: str | Path = "config.yaml") -> Config:
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)),
cross_class_iou=float(counting_raw.get("cross_class_iou", 0.4)),
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)),
+33
View File
@@ -337,3 +337,36 @@ class MultiClassLineCounter:
def reset(self) -> None:
self._sack.reset()
self._box.reset()
def _bbox_iou(a, b) -> float:
ax1, ay1, ax2, ay2 = a
bx1, by1, bx2, by2 = b
ix1, iy1 = max(ax1, bx1), max(ay1, by1)
ix2, iy2 = min(ax2, bx2), min(ay2, by2)
iw, ih = max(0.0, ix2 - ix1), max(0.0, iy2 - iy1)
inter = iw * ih
if inter <= 0.0:
return 0.0
union = (ax2 - ax1) * (ay2 - ay1) + (bx2 - bx1) * (by2 - by1) - inter
return inter / union if union > 0.0 else 0.0
def drop_sacks_overlapping_boxes(
sacks: list[Detection],
boxes: list[Detection],
iou_thr: float = 0.4,
) -> list[Detection]:
"""Cross-model suppression: drop `sack` detections overlapping a `box`.
Combined engine (v4) has no `box` class, so white boxes on a truck get
labeled `sack`; the box-only model always wins. `iou_thr <= 0` disables.
"""
if iou_thr <= 0 or not sacks or not boxes:
return list(sacks)
box_bbs = [b.bbox for b in boxes]
return [
s for s in sacks
if s.class_name != "sack"
or all(_bbox_iou(s.bbox, bb) < iou_thr for bb in box_bbs)
]
+1
View File
@@ -84,6 +84,7 @@ def test_counting_knobs_match_production_defaults(repo_config_path):
assert c.exit_overlap_threshold == 0.05
assert c.camera_noise_deadband == 50
assert c.duplicate_circle_radius == 30
assert c.cross_class_iou == 0.4
assert c.min_valid_area == 15000
assert c.max_reid_transit_distance == 400
assert c.circle_stay_timeout_sec == 10.0
+16
View File
@@ -96,3 +96,19 @@ def test_multiclass_geometry_sync_and_reset():
assert c.loading_count == 1
c.reset()
assert (c.loading_count, c.box_loading_count, c.box_net_count) == (0, 0, 0)
def test_drop_sacks_overlapping_boxes():
from src.counting import drop_sacks_overlapping_boxes
box = _det(9, y1=100, cx=500.0, class_name="box")
# identical footprint -> IoU 1.0 >= 0.4 -> sack dropped
assert drop_sacks_overlapping_boxes([_det(1, y1=100, cx=500.0)], [box]) == []
# partial overlap (cx 515) -> IoU ~0.45 -> dropped
assert drop_sacks_overlapping_boxes([_det(2, y1=100, cx=515.0)], [box]) == []
# no overlap -> kept
kept = drop_sacks_overlapping_boxes([_det(3, y1=100, cx=700.0)], [box])
assert len(kept) == 1 and kept[0].track_id == 3
# no boxes / disabled -> kept
assert len(drop_sacks_overlapping_boxes([_det(4, y1=100)], [], 0.4)) == 1
assert len(drop_sacks_overlapping_boxes([_det(5, y1=100)], [box], 0.0)) == 1
# box dets in sack list never dropped
assert len(drop_sacks_overlapping_boxes([_det(6, y1=100, class_name="box")], [box])) == 1