meaculpitt commited on
Commit
3e48203
Β·
verified Β·
1 Parent(s): 5abbca1

scorevision: push artifact

Browse files
Files changed (3) hide show
  1. __pycache__/miner.cpython-312.pyc +0 -0
  2. miner.py +8 -8
  3. miner.py.bak_v319 +2041 -0
__pycache__/miner.cpython-312.pyc CHANGED
Binary files a/__pycache__/miner.cpython-312.pyc and b/__pycache__/miner.cpython-312.pyc differ
 
miner.py CHANGED
@@ -1,5 +1,5 @@
1
  """
2
- Score Vision SN44 β€” Unified miner v3.19 (2026-04-04). YOLO12s + TRT + element detect.
3
  Dual-model: vehicle (YOLO11m INT8 1280, 1-pass) + person (YOLO12s FP16 960 end2end, TRT).
4
  Pose model: YOLOv8n-pose FP16 640 for false-positive filtering + keypoint box refinement.
5
  Vehicle weights loaded from secondary HF repo (meaculpitt/ScoreVision-Vehicle).
@@ -7,7 +7,7 @@ Person weights loaded from primary HF repo (template downloads automatically).
7
 
8
  Vehicle model (vehicle_weights.onnx):
9
  Trained classes: 0=car, 1=bus, 2=truck, 3=motorcycle
10
- Output: 1=car, 2=truck, 3=motorcycle. Bus (cls_id=4) SUPPRESSED β€” not scored by validator.
11
  Per-class confidence thresholds: car 0.45, truck 0.45, motorcycle 0.35.
12
  Per-class aspect ratio bounds for FP filtering.
13
  Single-pass (v3.19) β€” flip TTA removed for RTF improvement.
@@ -199,8 +199,8 @@ if not _lat_logger.handlers:
199
  logger = logging.getLogger(__name__)
200
 
201
  # ── Vehicle config ──────────────────────────────────────────────────────────
202
- VEH_MODEL_TO_OUT: dict[int, int] = {0: 1, 1: 4, 2: 2, 3: 3} # bus→4 (avoid person cls_id=0 collision)
203
- VEH_SKIP_CLS = {4} # Bus: not scored by validator, just generates FP. Skip entirely.
204
  VEH_NUM_CLASSES = 4
205
  VEH_CONF_THRES = 0.30 # Low decode threshold for TTA (final filter is per-class)
206
  VEH_TTA_CONF = 0.20 # TTA flip pass decode threshold
@@ -212,7 +212,7 @@ VEH_CLASS_CONF: dict[int, float] = {
212
  1: 0.45, # car β€” most FP-prone class (75% of training data, overconfident)
213
  2: 0.45, # truck β€” moderate raise
214
  3: 0.45, # motorcycle β€” raised from 0.35 to reduce FP (small targets, easy to miss)
215
- 4: 1.0, # bus β€” effectively suppressed (not scored anyway)
216
  }
217
 
218
  # ── Per-class vehicle aspect ratio bounds (min_ratio, max_ratio) ───────────
@@ -221,7 +221,7 @@ VEH_CLASS_ASPECT: dict[int, float] = {
221
  1: 5.0, # car β€” rarely > 5:1 from any angle
222
  2: 6.0, # truck β€” can be elongated
223
  3: 4.5, # motorcycle β€” compact, rarely very elongated
224
- 4: 8.0, # bus (filtered anyway)
225
  }
226
 
227
  # ── Per-class minimum area (pixels) ───────────────────────────────────────
@@ -229,7 +229,7 @@ VEH_CLASS_MIN_AREA: dict[int, int] = {
229
  1: 196, # car β€” 14x14 min
230
  2: 256, # truck β€” 16x16 min (should be at least medium-sized)
231
  3: 100, # motorcycle β€” 10x10 min (can be very small in distance)
232
- 4: 400, # bus β€” 20x20 min
233
  }
234
 
235
  # ── Vehicle box sanity filters (global fallbacks) ─────────────────────────
@@ -744,7 +744,7 @@ class Miner:
744
  2. Remap classes, per-class NMS
745
  3. Per-class confidence filter (higher thresholds reduce FP)
746
  4. Per-class aspect ratio filter
747
- 5. Skip bus (cls_id=4, not scored by validator)
748
  """
749
  oh, ow = image_bgr.shape[:2]
750
 
 
1
  """
2
+ Score Vision SN44 β€” Unified miner v3.20 (2026-04-04). YOLO12s + TRT + bus fix.
3
  Dual-model: vehicle (YOLO11m INT8 1280, 1-pass) + person (YOLO12s FP16 960 end2end, TRT).
4
  Pose model: YOLOv8n-pose FP16 640 for false-positive filtering + keypoint box refinement.
5
  Vehicle weights loaded from secondary HF repo (meaculpitt/ScoreVision-Vehicle).
 
7
 
8
  Vehicle model (vehicle_weights.onnx):
9
  Trained classes: 0=car, 1=bus, 2=truck, 3=motorcycle
10
+ Output: 0=bus, 1=car, 2=truck, 3=motorcycle. All classes scored (v3.20 bus fix).
11
  Per-class confidence thresholds: car 0.45, truck 0.45, motorcycle 0.35.
12
  Per-class aspect ratio bounds for FP filtering.
13
  Single-pass (v3.19) β€” flip TTA removed for RTF improvement.
 
199
  logger = logging.getLogger(__name__)
200
 
201
  # ── Vehicle config ──────────────────────────────────────────────────────────
202
+ VEH_MODEL_TO_OUT: dict[int, int] = {0: 1, 1: 0, 2: 2, 3: 3} # bus→0 (validator expects bus at idx 0)
203
+ VEH_SKIP_CLS = set() # v3.20: bus now scored (cls_id=0). Element detection prevents collision.
204
  VEH_NUM_CLASSES = 4
205
  VEH_CONF_THRES = 0.30 # Low decode threshold for TTA (final filter is per-class)
206
  VEH_TTA_CONF = 0.20 # TTA flip pass decode threshold
 
212
  1: 0.45, # car β€” most FP-prone class (75% of training data, overconfident)
213
  2: 0.45, # truck β€” moderate raise
214
  3: 0.45, # motorcycle β€” raised from 0.35 to reduce FP (small targets, easy to miss)
215
+ 0: 0.45, # bus β€” same threshold as car/truck (v3.20: unsuppressed)
216
  }
217
 
218
  # ── Per-class vehicle aspect ratio bounds (min_ratio, max_ratio) ───────────
 
221
  1: 5.0, # car β€” rarely > 5:1 from any angle
222
  2: 6.0, # truck β€” can be elongated
223
  3: 4.5, # motorcycle β€” compact, rarely very elongated
224
+ 0: 8.0, # bus β€” elongated body
225
  }
226
 
227
  # ── Per-class minimum area (pixels) ───────────────────────────────────────
 
229
  1: 196, # car β€” 14x14 min
230
  2: 256, # truck β€” 16x16 min (should be at least medium-sized)
231
  3: 100, # motorcycle β€” 10x10 min (can be very small in distance)
232
+ 0: 400, # bus β€” 20x20 min
233
  }
234
 
235
  # ── Vehicle box sanity filters (global fallbacks) ─────────────────────────
 
744
  2. Remap classes, per-class NMS
745
  3. Per-class confidence filter (higher thresholds reduce FP)
746
  4. Per-class aspect ratio filter
747
+ 5. All 4 classes scored (v3.20: bus unsuppressed, cls_id=0)
748
  """
749
  oh, ow = image_bgr.shape[:2]
750
 
miner.py.bak_v319 ADDED
@@ -0,0 +1,2041 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Score Vision SN44 β€” Unified miner v3.19 (2026-04-04). YOLO12s + TRT + element detect.
3
+ Dual-model: vehicle (YOLO11m INT8 1280, 1-pass) + person (YOLO12s FP16 960 end2end, TRT).
4
+ Pose model: YOLOv8n-pose FP16 640 for false-positive filtering + keypoint box refinement.
5
+ Vehicle weights loaded from secondary HF repo (meaculpitt/ScoreVision-Vehicle).
6
+ Person weights loaded from primary HF repo (template downloads automatically).
7
+
8
+ Vehicle model (vehicle_weights.onnx):
9
+ Trained classes: 0=car, 1=bus, 2=truck, 3=motorcycle
10
+ Output: 1=car, 2=truck, 3=motorcycle. Bus (cls_id=4) SUPPRESSED β€” not scored by validator.
11
+ Per-class confidence thresholds: car 0.45, truck 0.45, motorcycle 0.35.
12
+ Per-class aspect ratio bounds for FP filtering.
13
+ Single-pass (v3.19) β€” flip TTA removed for RTF improvement.
14
+
15
+ Person model (person_weights.onnx):
16
+ YOLO12s FP16 960px end2end [1,300,6]. Single class: 0=person.
17
+ Background TRT build: starts on CUDA immediately, builds TRT FP16 engine in background
18
+ thread (~18min on fresh node), swaps to TRT atomically when ready. Cached thereafter.
19
+ SAHI-style tiling: full + 2 adaptive tiles + flip TTA, max-conf NMS merge.
20
+
21
+ Pose model (pose_weights.onnx):
22
+ YOLOv8n-pose FP16 640px [1,56,8400]. 17 COCO keypoints.
23
+ Runs once on full image after person detection.
24
+ Anatomical keypoint scoring: weighted per-keypoint sum (head 0.38, upper 0.32, lower 0.30).
25
+ 1. Head keypoints visible β†’ never suppress, always refine box.
26
+ 2. Score >= 0.15 β†’ keep + refine. Score > 0 β†’ keep as-is. Score == 0 + large + low-conf β†’ suppress.
27
+ 3. Box refinement: blend detected box with tight keypoint bbox for better fit.
28
+ Face detector (optional): if face_session loaded, face inside box β†’ never suppress.
29
+
30
+ Both vehicle + person models run on every image. All detections merged.
31
+ Vehicle eval uses cls_id 1-3. Person eval uses cls_id 0 only.
32
+ """
33
+
34
+ import os
35
+ import ctypes
36
+ import glob as _glob
37
+ import logging as _logging
38
+
39
+ _cuda_log = _logging.getLogger(__name__)
40
+
41
+ def _preload_cuda_libs():
42
+ """Pre-load CUDA + TensorRT libs from pip packages so ORT GPU/TRT providers work.
43
+
44
+ Search order for TRT libs (libnvinfer.so, libnvonnxparser.so):
45
+ 1. sys.path entries containing tensorrt_libs/ subdirectory
46
+ 2. site.getsitepackages() + user site-packages for tensorrt_libs/ or tensorrt/
47
+ 3. ctypes.util.find_library('nvinfer') as system-wide fallback
48
+ If not found, logs clearly and skips TRT β€” never attempts pip operations.
49
+ """
50
+ try:
51
+ import ctypes.util as _ctypes_util
52
+ lib_dirs = []
53
+ loaded = set()
54
+
55
+ # ── CUDA libs from nvidia pip packages ──
56
+ for mod_name in ['nvidia.cudnn', 'nvidia.cublas', 'nvidia.cuda_runtime',
57
+ 'nvidia.cufft', 'nvidia.curand', 'nvidia.cusolver',
58
+ 'nvidia.cusparse', 'nvidia.nvjitlink']:
59
+ try:
60
+ mod = __import__(mod_name, fromlist=['__file__'])
61
+ lib_dir = os.path.join(os.path.dirname(mod.__file__), 'lib')
62
+ if os.path.isdir(lib_dir) and lib_dir not in lib_dirs:
63
+ lib_dirs.append(lib_dir)
64
+ except ImportError:
65
+ pass
66
+
67
+ # ── TensorRT libs β€” multi-strategy search ──
68
+ import sys as _sys
69
+ _trt_dir = None
70
+
71
+ # Strategy 1: sys.path (covers standard pip installs)
72
+ for p in _sys.path:
73
+ for subdir in ('tensorrt_libs', 'tensorrt'):
74
+ candidate = os.path.join(p, subdir)
75
+ if os.path.isdir(candidate) and _glob.glob(os.path.join(candidate, 'libnvinfer*')):
76
+ _trt_dir = candidate
77
+ break
78
+ if _trt_dir:
79
+ break
80
+
81
+ # Strategy 2: site-packages directories (covers user installs, venvs)
82
+ if not _trt_dir:
83
+ import site
84
+ search_dirs = list(site.getsitepackages()) if hasattr(site, 'getsitepackages') else []
85
+ user_site = getattr(site, 'getusersitepackages', lambda: None)()
86
+ if user_site:
87
+ search_dirs.append(user_site)
88
+ # Also check common paths not always in site
89
+ search_dirs.extend([
90
+ '/usr/local/lib/python3.12/dist-packages',
91
+ os.path.expanduser('~/.local/lib/python3.12/site-packages'),
92
+ '/home/miner/.local/lib/python3.12/site-packages',
93
+ ])
94
+ for sp in search_dirs:
95
+ for subdir in ('tensorrt_libs', 'tensorrt'):
96
+ candidate = os.path.join(sp, subdir)
97
+ if os.path.isdir(candidate) and _glob.glob(os.path.join(candidate, 'libnvinfer*')):
98
+ _trt_dir = candidate
99
+ break
100
+ if _trt_dir:
101
+ break
102
+
103
+ # Strategy 3: ctypes.util.find_library (system-wide LD search)
104
+ if not _trt_dir:
105
+ nvinfer_path = _ctypes_util.find_library('nvinfer')
106
+ if nvinfer_path:
107
+ _cuda_log.info('TRT found via system library: %s', nvinfer_path)
108
+ try:
109
+ ctypes.CDLL(nvinfer_path, mode=ctypes.RTLD_GLOBAL)
110
+ loaded.add('nvinfer')
111
+ except OSError as e:
112
+ _cuda_log.warning('Failed to load system nvinfer: %s', e)
113
+
114
+ if _trt_dir:
115
+ if _trt_dir not in lib_dirs:
116
+ lib_dirs.append(_trt_dir)
117
+ _cuda_log.info('TRT libs directory: %s', _trt_dir)
118
+ elif 'nvinfer' not in loaded:
119
+ _cuda_log.info('TensorRT libs not found β€” TRT EP will be unavailable (CUDA EP still works)')
120
+
121
+ if not lib_dirs and not loaded:
122
+ _cuda_log.warning('No CUDA or TRT libs found to preload')
123
+ return
124
+
125
+ # Set LD_LIBRARY_PATH for any child processes / dlopen fallbacks
126
+ existing = os.environ.get('LD_LIBRARY_PATH', '')
127
+ os.environ['LD_LIBRARY_PATH'] = ':'.join(lib_dirs + ([existing] if existing else []))
128
+
129
+ # Load CUDA libs (glob all .so in nvidia dirs)
130
+ for lib_dir in lib_dirs:
131
+ if 'tensorrt' in lib_dir:
132
+ continue # TRT libs loaded selectively below
133
+ for so in sorted(_glob.glob(os.path.join(lib_dir, 'lib*.so*'))):
134
+ try:
135
+ ctypes.CDLL(so, mode=ctypes.RTLD_GLOBAL)
136
+ except OSError:
137
+ pass
138
+
139
+ # Load TRT libs selectively (only the essentials, not builder resources)
140
+ if _trt_dir:
141
+ for lib_name in ['libnvinfer.so', 'libnvinfer_plugin.so', 'libnvonnxparser.so']:
142
+ matches = _glob.glob(os.path.join(_trt_dir, lib_name + '*'))
143
+ if matches:
144
+ try:
145
+ ctypes.CDLL(matches[0], mode=ctypes.RTLD_GLOBAL)
146
+ loaded.add(lib_name.split('.')[0])
147
+ except OSError as e:
148
+ _cuda_log.warning('Failed to load %s: %s', lib_name, e)
149
+ else:
150
+ _cuda_log.info('%s not found in %s', lib_name, _trt_dir)
151
+
152
+ if loaded:
153
+ _cuda_log.info('Preloaded libs: %s', ', '.join(sorted(loaded)))
154
+ except Exception as e:
155
+ _cuda_log.warning('CUDA/TRT preload error: %s', e)
156
+
157
+ _preload_cuda_libs()
158
+
159
+
160
+
161
+ from pathlib import Path
162
+ import math
163
+ import time
164
+ import logging
165
+
166
+ import cv2
167
+ import numpy as np
168
+ import onnxruntime as ort
169
+ from numpy import ndarray
170
+ from pydantic import BaseModel
171
+
172
+ import json
173
+ import threading
174
+ from datetime import datetime, timezone
175
+ from concurrent.futures import ThreadPoolExecutor, as_completed
176
+ import inspect
177
+
178
+ # ── Latency logger (per-request timing) ─────────────────────────────────
179
+ import logging as _lat_logging
180
+ _lat_logger = _lat_logging.getLogger("sv_latency")
181
+ _lat_logger.setLevel(_lat_logging.INFO)
182
+ _lat_logger.propagate = False
183
+ if not _lat_logger.handlers:
184
+ try:
185
+ import tempfile as _lat_tempfile
186
+ # Try /home/miner first (Lium), fall back to /tmp (Chutes cloud)
187
+ for _lat_path in ["/home/miner/latency.log", _lat_tempfile.gettempdir() + "/latency.log"]:
188
+ try:
189
+ _lat_fh = _lat_logging.FileHandler(_lat_path)
190
+ _lat_fh.setFormatter(_lat_logging.Formatter(
191
+ "%(asctime)s.%(msecs)03d %(message)s", datefmt="%Y-%m-%d %H:%M:%S"))
192
+ _lat_logger.addHandler(_lat_fh)
193
+ break
194
+ except (OSError, PermissionError):
195
+ continue
196
+ except Exception:
197
+ pass # No file logging β€” latency still logged via main logger
198
+
199
+ logger = logging.getLogger(__name__)
200
+
201
+ # ── Vehicle config ──────────────────────────────────────────────────────────
202
+ VEH_MODEL_TO_OUT: dict[int, int] = {0: 1, 1: 4, 2: 2, 3: 3} # bus→4 (avoid person cls_id=0 collision)
203
+ VEH_SKIP_CLS = {4} # Bus: not scored by validator, just generates FP. Skip entirely.
204
+ VEH_NUM_CLASSES = 4
205
+ VEH_CONF_THRES = 0.30 # Low decode threshold for TTA (final filter is per-class)
206
+ VEH_TTA_CONF = 0.20 # TTA flip pass decode threshold
207
+ VEH_NMS_IOU = 0.50
208
+
209
+ # ── Per-class vehicle confidence thresholds (output cls_id) ────────────────
210
+ # Raising from uniform 0.35: reduces FP (avg 4.1 FFPI β†’ target <2.0)
211
+ VEH_CLASS_CONF: dict[int, float] = {
212
+ 1: 0.45, # car β€” most FP-prone class (75% of training data, overconfident)
213
+ 2: 0.45, # truck β€” moderate raise
214
+ 3: 0.45, # motorcycle β€” raised from 0.35 to reduce FP (small targets, easy to miss)
215
+ 4: 1.0, # bus β€” effectively suppressed (not scored anyway)
216
+ }
217
+
218
+ # ── Per-class vehicle aspect ratio bounds (min_ratio, max_ratio) ───────────
219
+ # ratio = max(w,h) / min(w,h). Generous bounds to avoid suppressing valid detections.
220
+ VEH_CLASS_ASPECT: dict[int, float] = {
221
+ 1: 5.0, # car β€” rarely > 5:1 from any angle
222
+ 2: 6.0, # truck β€” can be elongated
223
+ 3: 4.5, # motorcycle β€” compact, rarely very elongated
224
+ 4: 8.0, # bus (filtered anyway)
225
+ }
226
+
227
+ # ── Per-class minimum area (pixels) ───────────────────────────────────────
228
+ VEH_CLASS_MIN_AREA: dict[int, int] = {
229
+ 1: 196, # car β€” 14x14 min
230
+ 2: 256, # truck β€” 16x16 min (should be at least medium-sized)
231
+ 3: 100, # motorcycle β€” 10x10 min (can be very small in distance)
232
+ 4: 400, # bus β€” 20x20 min
233
+ }
234
+
235
+ # ── Vehicle box sanity filters (global fallbacks) ─────────────────────────
236
+ VEH_MIN_WH = 8
237
+ VEH_MIN_AREA = 100
238
+ VEH_MAX_ASPECT = 8.0
239
+ VEH_MAX_AREA_RATIO = 0.95
240
+ VEH_MAX_DET = 40
241
+
242
+ # ── Vehicle parts confirmation config ────────────────────────────────────
243
+ # Cross-validates vehicle detections using person detections, OpenCV analysis,
244
+ # and optional license plate detector. Small/distant vehicles exempt.
245
+ VEH_PARTS_ENABLED = True # Master switch for parts confirmation
246
+ VEH_PARTS_SMALL_AREA = 0.004 # Below this area ratio: exempt from suppression
247
+ VEH_PARTS_FP_CONF = 0.50 # Below this conf + large + unconfirmed β†’ suppress
248
+ VEH_PARTS_FP_CONF_STRICT = 0.55 # Stricter threshold when plate model loaded but no plate
249
+ VEH_PARTS_FP_AREA = 0.03 # Above this area ratio β†’ eligible for FP suppression
250
+ # Confidence boosts for confirmed parts (additive)
251
+ VEH_PARTS_BOOST_DRIVER = 0.08 # Person in driver/passenger region
252
+ VEH_PARTS_BOOST_RIDER = 0.10 # Person on motorcycle (overlap + optional lean)
253
+ VEH_PARTS_BOOST_HL = 0.05 # Headlight pair detected
254
+ VEH_PARTS_BOOST_PLATE = 0.12 # License plate detected (Phase 2)
255
+ VEH_PARTS_BOOST_WINDOW = 0.06 # Bus window pattern on truck
256
+ # Headlight detection thresholds
257
+ VEH_PARTS_HL_MIN_PX = 60 # Min vehicle width (px) for headlight check
258
+ VEH_PARTS_HL_BRIGHT = 200 # Grayscale threshold for bright spots
259
+ VEH_PARTS_HL_MIN_BLOB = 15 # Min contour area for headlight candidate
260
+ # Window pattern detection (bus/coach)
261
+ VEH_PARTS_WINDOW_MIN_PX = 100 # Min vehicle width for window pattern check
262
+ VEH_PARTS_WINDOW_MIN_PEAKS = 3 # Min periodic edge peaks for window confirmation
263
+ # Motorcycle rider pose
264
+ VEH_PARTS_RIDER_LEAN_DEG = 15.0 # Min torso lean from vertical (degrees) for rider pose
265
+ # Plate detection thresholds
266
+ VEH_PARTS_PLATE_MIN_PX = 120 # only check plates on medium+ vehicles # Min vehicle width for plate detection
267
+ VEH_PARTS_PLATE_CONF = 0.35 # Min plate detection confidence
268
+
269
+ # ── Person config (TTA consensus) ───────────────────────────────────────────
270
+ PER_CONF_LOW = 0.55
271
+ PER_CONF_HIGH = 0.58
272
+ PER_CONSENSUS_IOU = 0.50
273
+ PER_RTF_BUDGET = 8.0
274
+
275
+ # ── Person box sanity filters ──────────────────────────────────────────────
276
+ PER_MIN_WH = 8
277
+ PER_MIN_AREA = 14 * 14
278
+ PER_MAX_ASPECT = 6.0
279
+ PER_MAX_AREA_RATIO = 0.80
280
+
281
+ # ── Person tiling config (SAHI-inspired) ────────────────────────────────────
282
+ PER_TILE_OVERLAP = 0.20 # 20% overlap between tiles
283
+ PER_TILE_MIN_DIM_RATIO = 1.15 # tile when image dim > model_dim * this (~1104px for 960 model)
284
+ PER_TILE_CONF = 0.55 # raised from 0.40 to match PER_CONF_LOW
285
+ PER_NMS_IOU = 0.50 # NMS IoU for merging across passes (max-conf wins)
286
+ PER_MAX_DET = 15 # hard cap on person detections per image
287
+
288
+ # ── Frame quality gating (Laplacian variance) ───────────────────────────────
289
+ PER_BLUR_THRESHOLD = 50.0 # Laplacian variance below this = severely blurry
290
+ PER_BLUR_CONF_PENALTY = 0.85 # multiply confs by this for blurry frames (reduce FP)
291
+
292
+ # ── Adaptive CLAHE config ───────────────────────────────────────────────────
293
+ PER_CLAHE_CLIP = 2.0 # mild CLAHE (was 12.0, too aggressive)
294
+ PER_CLAHE_CONTRAST_THRESH = 40.0 # only apply CLAHE when L-channel std < this
295
+
296
+ # ── Perspective scaling confidence penalty ─────────────────────────────────
297
+ PERSP_DEVIATION_THRESH = 3.0 # ratio >3x or <1/3x triggers penalty
298
+ PERSP_CONF_PENALTY = 0.85 # multiply conf by this for perspective violations
299
+ PERSP_MIN_DETECTIONS = 3 # need β‰₯3 detections to estimate model
300
+ PERSP_MIN_Y_SPREAD = 0.15 # min y-spread as fraction of image height
301
+
302
+ # ── Pose FP filter + box refinement config ────────────────────────────��─────
303
+ POSE_CONF_THRESH = 0.25 # Minimum confidence for pose detection
304
+ POSE_NMS_IOU = 0.65 # NMS IoU threshold for pose detections
305
+ POSE_MATCH_IOU = 0.30 # IoU threshold to match pose to person box
306
+ POSE_KP_CONF = 0.3 # Keypoint visibility threshold
307
+ POSE_FP_MAX_CONF = 0.65 # Max conf below which unmatched large boxes are suppressed
308
+ POSE_FP_MIN_AREA = 0.04 # Min area ratio (of image) for FP suppression to apply
309
+ POSE_REFINE_BLEND = 0.25 # Blend factor for keypoint box refinement (0=original, 1=keypoint)
310
+ POSE_KP_PAD = 0.10 # Padding around keypoint tight bbox
311
+
312
+ # ── Anatomical keypoint scoring ─────────────────────────────────────────────
313
+ # COCO keypoints: 0=nose 1=l_eye 2=r_eye 3=l_ear 4=r_ear
314
+ # 5=l_shoulder 6=r_shoulder 7=l_elbow 8=r_elbow 9=l_wrist 10=r_wrist
315
+ # 11=l_hip 12=r_hip 13=l_knee 14=r_knee 15=l_ankle 16=r_ankle
316
+ POSE_HEAD_KP = [0, 1, 2, 3, 4] # nose + eyes + ears
317
+ POSE_UPPER_KP = [5, 6, 7, 8, 9, 10] # shoulders + elbows + wrists
318
+ POSE_LOWER_KP = [11, 12, 13, 14, 15, 16] # hips + knees + ankles
319
+ # Per-keypoint weights (head > upper > lower). Sum of all = 1.0.
320
+ POSE_KP_WEIGHTS = np.array([
321
+ 0.12, # 0 nose β€” strongest single indicator
322
+ 0.08, # 1 left_eye
323
+ 0.08, # 2 right_eye
324
+ 0.05, # 3 left_ear
325
+ 0.05, # 4 right_ear
326
+ 0.07, # 5 left_shoulder
327
+ 0.07, # 6 right_shoulder
328
+ 0.05, # 7 left_elbow
329
+ 0.05, # 8 right_elbow
330
+ 0.04, # 9 left_wrist
331
+ 0.04, # 10 right_wrist
332
+ 0.05, # 11 left_hip
333
+ 0.05, # 12 right_hip
334
+ 0.04, # 13 left_knee
335
+ 0.04, # 14 right_knee
336
+ 0.03, # 15 left_ankle
337
+ 0.04, # 16 right_ankle
338
+ ], dtype=np.float32) # sums to 1.0
339
+ POSE_ANAT_REFINE_THRESH = 0.15 # Score above which we refine box with keypoints
340
+ POSE_ANAT_SUPPRESS_THRESH = 0.0 # Score at or below which suppression is considered
341
+
342
+ # ── TensorRT engine cache config ────────────────────────────────────────────
343
+ TRT_CACHE_PATH = "/tmp/trt_engine_cache"
344
+ TRT_FP16 = True
345
+ TRT_WORKSPACE_GB = 4
346
+
347
+ # ── Shared ──────────────────────────────────────────────────────────────────
348
+ WBF_SKIP_THR = 0.0001
349
+
350
+ # ── Speed config ────────────────────────────────────────────────────────────
351
+ ENABLE_TTA = False
352
+ ENABLE_PARALLEL = True
353
+
354
+ # ── Secondary HF repo for vehicle weights ───────────────────────────────────
355
+ VEHICLE_HF_REPO = "meaculpitt/ScoreVision-Vehicle"
356
+
357
+
358
+ def _wbf_multi(boxes_list, scores_list, labels_list, iou_thr=0.55, skip_thr=0.0001):
359
+ """Weighted Boxes Fusion (multi-class). Boxes in [0,1] normalized coords."""
360
+ if not boxes_list:
361
+ return np.empty((0, 4)), np.empty(0), np.empty(0)
362
+
363
+ all_b, all_s, all_l = [], [], []
364
+ for bx, sc, lb in zip(boxes_list, scores_list, labels_list):
365
+ for i in range(len(bx)):
366
+ if sc[i] < skip_thr:
367
+ continue
368
+ all_b.append(bx[i])
369
+ all_s.append(sc[i])
370
+ all_l.append(int(lb[i]))
371
+
372
+ if not all_b:
373
+ return np.empty((0, 4)), np.empty(0), np.empty(0)
374
+
375
+ all_b = np.array(all_b)
376
+ all_s = np.array(all_s)
377
+ all_l = np.array(all_l, dtype=int)
378
+
379
+ fused_b, fused_s, fused_l = [], [], []
380
+ for cls in np.unique(all_l):
381
+ m = all_l == cls
382
+ cb, cs = all_b[m], all_s[m]
383
+ order = cs.argsort()[::-1]
384
+ cb, cs = cb[order], cs[order]
385
+
386
+ clusters, cboxes = [], []
387
+ for i in range(len(cb)):
388
+ matched, best_iou = -1, iou_thr
389
+ for ci, cbox in enumerate(cboxes):
390
+ xx1 = max(cb[i, 0], cbox[0])
391
+ yy1 = max(cb[i, 1], cbox[1])
392
+ xx2 = min(cb[i, 2], cbox[2])
393
+ yy2 = min(cb[i, 3], cbox[3])
394
+ inter = max(0, xx2 - xx1) * max(0, yy2 - yy1)
395
+ a1 = (cb[i, 2] - cb[i, 0]) * (cb[i, 3] - cb[i, 1])
396
+ a2 = (cbox[2] - cbox[0]) * (cbox[3] - cbox[1])
397
+ iou = inter / (a1 + a2 - inter + 1e-9)
398
+ if iou > best_iou:
399
+ best_iou = iou
400
+ matched = ci
401
+ if matched >= 0:
402
+ clusters[matched].append(i)
403
+ idxs = clusters[matched]
404
+ w = cs[idxs]
405
+ cboxes[matched] = (cb[idxs] * w[:, None]).sum(0) / w.sum()
406
+ else:
407
+ clusters.append([i])
408
+ cboxes.append(cb[i].copy())
409
+
410
+ for ci, idxs in enumerate(clusters):
411
+ fused_b.append(cboxes[ci])
412
+ fused_s.append(cs[idxs].mean())
413
+ fused_l.append(cls)
414
+
415
+ if not fused_b:
416
+ return np.empty((0, 4)), np.empty(0), np.empty(0)
417
+ return np.array(fused_b), np.array(fused_s), np.array(fused_l)
418
+
419
+
420
+ def _wbf_single(boxes_list, scores_list, iou_thr=0.45, skip_thr=0.0001):
421
+ """Weighted Boxes Fusion (single-class). Boxes in [0,1] normalized coords."""
422
+ if not boxes_list:
423
+ return np.empty((0, 4)), np.empty(0)
424
+
425
+ all_b, all_s = [], []
426
+ for bx, sc in zip(boxes_list, scores_list):
427
+ for i in range(len(bx)):
428
+ if sc[i] < skip_thr:
429
+ continue
430
+ all_b.append(bx[i])
431
+ all_s.append(sc[i])
432
+
433
+ if not all_b:
434
+ return np.empty((0, 4)), np.empty(0)
435
+
436
+ all_b = np.array(all_b)
437
+ all_s = np.array(all_s)
438
+ order = all_s.argsort()[::-1]
439
+ all_b, all_s = all_b[order], all_s[order]
440
+
441
+ clusters, cboxes = [], []
442
+ for i in range(len(all_b)):
443
+ matched, best_iou = -1, iou_thr
444
+ for ci, cbox in enumerate(cboxes):
445
+ xx1 = max(all_b[i, 0], cbox[0])
446
+ yy1 = max(all_b[i, 1], cbox[1])
447
+ xx2 = min(all_b[i, 2], cbox[2])
448
+ yy2 = min(all_b[i, 3], cbox[3])
449
+ inter = max(0, xx2 - xx1) * max(0, yy2 - yy1)
450
+ a1 = (all_b[i, 2] - all_b[i, 0]) * (all_b[i, 3] - all_b[i, 1])
451
+ a2 = (cbox[2] - cbox[0]) * (cbox[3] - cbox[1])
452
+ iou = inter / (a1 + a2 - inter + 1e-9)
453
+ if iou > best_iou:
454
+ best_iou = iou
455
+ matched = ci
456
+ if matched >= 0:
457
+ clusters[matched].append(i)
458
+ idxs = clusters[matched]
459
+ w = all_s[idxs]
460
+ cboxes[matched] = (all_b[idxs] * w[:, None]).sum(0) / w.sum()
461
+ else:
462
+ clusters.append([i])
463
+ cboxes.append(all_b[i].copy())
464
+
465
+ fused_b, fused_s = [], []
466
+ for ci, idxs in enumerate(clusters):
467
+ fused_b.append(cboxes[ci])
468
+ fused_s.append(all_s[idxs].mean())
469
+
470
+ if not fused_b:
471
+ return np.empty((0, 4)), np.empty(0)
472
+ return np.array(fused_b), np.array(fused_s)
473
+
474
+
475
+ def _nms_per_class_boost(boxes, scores, labels, iou_thr=0.50):
476
+ """Per-class hard NMS with max-score cluster boosting.
477
+ Surviving box keeps its coordinates but gets the max confidence
478
+ among all boxes in its overlap cluster."""
479
+ if len(boxes) == 0:
480
+ return np.empty((0, 4)), np.empty(0), np.empty(0, dtype=int)
481
+
482
+ out_b, out_s, out_l = [], [], []
483
+ for cls in np.unique(labels):
484
+ m = labels == cls
485
+ cb, cs = boxes[m], scores[m]
486
+ order = cs.argsort()[::-1]
487
+ cb, cs = cb[order], cs[order]
488
+
489
+ suppressed = set()
490
+ for i in range(len(cb)):
491
+ if i in suppressed:
492
+ continue
493
+ max_score = float(cs[i])
494
+ for j in range(i + 1, len(cb)):
495
+ if j in suppressed:
496
+ continue
497
+ xx1 = max(cb[i, 0], cb[j, 0])
498
+ yy1 = max(cb[i, 1], cb[j, 1])
499
+ xx2 = min(cb[i, 2], cb[j, 2])
500
+ yy2 = min(cb[i, 3], cb[j, 3])
501
+ inter = max(0, xx2 - xx1) * max(0, yy2 - yy1)
502
+ a1 = (cb[i, 2] - cb[i, 0]) * (cb[i, 3] - cb[i, 1])
503
+ a2 = (cb[j, 2] - cb[j, 0]) * (cb[j, 3] - cb[j, 1])
504
+ iou = inter / (a1 + a2 - inter + 1e-9)
505
+ if iou >= iou_thr:
506
+ max_score = max(max_score, float(cs[j]))
507
+ suppressed.add(j)
508
+ out_b.append(cb[i])
509
+ out_s.append(max_score)
510
+ out_l.append(cls)
511
+
512
+ if not out_b:
513
+ return np.empty((0, 4)), np.empty(0), np.empty(0, dtype=int)
514
+ return np.array(out_b), np.array(out_s), np.array(out_l, dtype=int)
515
+
516
+
517
+ class BoundingBox(BaseModel):
518
+ x1: int
519
+ y1: int
520
+ x2: int
521
+ y2: int
522
+ cls_id: int
523
+ conf: float
524
+
525
+
526
+ class TVFrameResult(BaseModel):
527
+ frame_id: int
528
+ boxes: list[BoundingBox]
529
+ keypoints: list[tuple[int, int]]
530
+
531
+
532
+ class Miner:
533
+ def __init__(self, path_hf_repo: Path) -> None:
534
+ self.path_hf_repo = path_hf_repo
535
+
536
+ # Vehicle model β€” download from secondary HF repo with safety guard
537
+ t0 = time.monotonic()
538
+ veh_path = None # Path to secondary repo snapshot (also used for plate model)
539
+ try:
540
+ from huggingface_hub import snapshot_download as _sd
541
+ veh_path = Path(_sd(VEHICLE_HF_REPO))
542
+ veh_weights = str(veh_path / "vehicle_weights.onnx")
543
+ logger.info(f"[init] Vehicle weights from {VEHICLE_HF_REPO} in {time.monotonic()-t0:.1f}s")
544
+ except Exception as e:
545
+ # Fallback: try loading from primary repo (backward compat)
546
+ logger.warning(f"[init] Vehicle secondary repo failed ({e}), trying primary repo")
547
+ veh_weights = str(path_hf_repo / "vehicle_weights.onnx")
548
+ if not Path(veh_weights).exists():
549
+ raise FileNotFoundError(f"vehicle_weights.onnx not found in primary or secondary repo") from e
550
+
551
+ self.veh_session = ort.InferenceSession(
552
+ veh_weights,
553
+ providers=["CUDAExecutionProvider", "CPUExecutionProvider"],
554
+ )
555
+ self.veh_input_name = self.veh_session.get_inputs()[0].name
556
+ veh_shape = self.veh_session.get_inputs()[0].shape
557
+ self.veh_h = int(veh_shape[2])
558
+ self.veh_w = int(veh_shape[3])
559
+
560
+ # Person model β€” CUDA immediately, TRT engine builds in background
561
+ per_onnx = str(path_hf_repo / "person_weights.onnx")
562
+ self.per_session = ort.InferenceSession(
563
+ per_onnx,
564
+ providers=["CUDAExecutionProvider", "CPUExecutionProvider"],
565
+ )
566
+ self.per_input_name = self.per_session.get_inputs()[0].name
567
+ per_shape = self.per_session.get_inputs()[0].shape
568
+ self.per_h = int(per_shape[2])
569
+ self.per_w = int(per_shape[3])
570
+ self._trt_ready = False
571
+ logger.info("[init] Person model: CUDA (TRT build starting in background)")
572
+
573
+ # Launch background TRT engine build
574
+ os.makedirs(TRT_CACHE_PATH, exist_ok=True)
575
+ threading.Thread(
576
+ target=self._build_trt_engine,
577
+ args=(per_onnx,),
578
+ daemon=True,
579
+ name="trt-builder",
580
+ ).start()
581
+
582
+ # Pose model β€” for FP filtering + box refinement
583
+ pose_path = path_hf_repo / "pose_weights.onnx"
584
+ if pose_path.exists():
585
+ self.pose_session = ort.InferenceSession(
586
+ str(pose_path),
587
+ providers=["CUDAExecutionProvider", "CPUExecutionProvider"],
588
+ )
589
+ self.pose_input_name = self.pose_session.get_inputs()[0].name
590
+ pose_shape = self.pose_session.get_inputs()[0].shape
591
+ self.pose_h = int(pose_shape[2])
592
+ self.pose_w = int(pose_shape[3])
593
+ logger.info(f"[init] Pose model loaded: {self.pose_h}x{self.pose_w}")
594
+ else:
595
+ self.pose_session = None
596
+ logger.info("[init] No pose model found, FP filter disabled")
597
+
598
+ # Face detector (SCRFD-500M) β€” confirms person boxes, prevents FP suppression
599
+ face_path = path_hf_repo / "face_weights.onnx"
600
+ if face_path.exists():
601
+ self.face_session = ort.InferenceSession(
602
+ str(face_path),
603
+ providers=["CUDAExecutionProvider", "CPUExecutionProvider"],
604
+ )
605
+ self.face_input_name = self.face_session.get_inputs()[0].name
606
+ logger.info("[init] Face model (SCRFD-500M) loaded")
607
+ else:
608
+ self.face_session = None
609
+ logger.info("[init] No face model found")
610
+
611
+ # License plate detector β€” loaded from secondary HF repo alongside vehicle weights
612
+ plate_path = veh_path / "plate_weights.onnx" if veh_path else None
613
+ if plate_path and plate_path.exists():
614
+ self.plate_session = ort.InferenceSession(
615
+ str(plate_path),
616
+ providers=["CUDAExecutionProvider", "CPUExecutionProvider"],
617
+ )
618
+ self.plate_input_name = self.plate_session.get_inputs()[0].name
619
+ plate_shape = self.plate_session.get_inputs()[0].shape
620
+ self.plate_h = int(plate_shape[2]) if isinstance(plate_shape[2], int) else 640
621
+ self.plate_w = int(plate_shape[3]) if isinstance(plate_shape[3], int) else 640
622
+ logger.info(f"[init] Plate model loaded: {self.plate_h}x{self.plate_w}")
623
+ else:
624
+ self.plate_session = None
625
+ logger.info("[init] No plate model found, plate confirmation disabled")
626
+
627
+ # Pose cache β€” populated by _pose_filter_refine, read by vehicle parts
628
+ self._cached_pose_data = None
629
+
630
+ # Thread pool for parallel inference
631
+ self._executor = ThreadPoolExecutor(max_workers=2)
632
+
633
+ # Log provider info
634
+ veh_prov = self.veh_session.get_providers()
635
+ per_prov = self.per_session.get_providers()
636
+ logger.info(f"Vehicle ORT providers: {veh_prov}")
637
+ logger.info(f"Person ORT providers: {per_prov} (TRT building in background)")
638
+ logger.info(f"TTA={ENABLE_TTA} PARALLEL={ENABLE_PARALLEL}")
639
+
640
+ def _build_trt_engine(self, per_onnx):
641
+ """Build TRT FP16 engine in background, swap person session when ready.
642
+
643
+ On fresh nodes: ~18 min to compile. Cached engine loads in <1s.
644
+ During build, inference uses CUDAExecutionProvider (passes RTF at ~78ms).
645
+ After build, atomically swaps to TRT session (~29ms pipeline).
646
+ """
647
+ try:
648
+ trt_opts = {
649
+ "trt_fp16_enable": str(TRT_FP16).lower(),
650
+ "trt_max_workspace_size": str(TRT_WORKSPACE_GB << 30),
651
+ "trt_engine_cache_enable": "true",
652
+ "trt_engine_cache_path": TRT_CACHE_PATH,
653
+ }
654
+ t0 = time.monotonic()
655
+ logger.info("[trt-build] Creating TRT session (may take ~18min on fresh node)...")
656
+ trt_session = ort.InferenceSession(
657
+ per_onnx,
658
+ providers=[
659
+ ("TensorrtExecutionProvider", trt_opts),
660
+ "CUDAExecutionProvider",
661
+ "CPUExecutionProvider",
662
+ ],
663
+ )
664
+
665
+ provs = trt_session.get_providers()
666
+ if "TensorrtExecutionProvider" not in provs:
667
+ logger.warning("[trt-build] TRT provider not active (%s), keeping CUDA", provs)
668
+ return
669
+
670
+ # Run dummy inference to fully materialize the engine
671
+ inp_name = trt_session.get_inputs()[0].name
672
+ inp_shape = trt_session.get_inputs()[0].shape
673
+ dummy = np.zeros((1, 3, int(inp_shape[2]), int(inp_shape[3])), dtype=np.float32)
674
+ trt_session.run(None, {inp_name: dummy})
675
+
676
+ dt = time.monotonic() - t0
677
+ logger.info("[trt-build] TRT engine ready in %.1fs β€” swapping person session", dt)
678
+
679
+ # Atomic swap β€” Python GIL makes single attribute assignment safe.
680
+ # Any in-flight inference holds a reference to the old session, which
681
+ # stays alive until that inference completes.
682
+ self.per_session = trt_session
683
+ self._trt_ready = True
684
+
685
+ logger.info("[trt-build] Person model now using TensorRT FP16")
686
+ except Exception as e:
687
+ logger.warning("[trt-build] TRT build failed (%s), keeping CUDA", e)
688
+
689
+ def __repr__(self) -> str:
690
+ trt_status = "TRT" if self._trt_ready else "CUDA (TRT building)"
691
+ return f"Unified Miner v3.16 β€” person={trt_status}, background TRT engine build"
692
+
693
+ # ── Vehicle preprocessing (letterbox) ───────────────────────────────────
694
+
695
+ def _veh_letterbox(self, img):
696
+ h, w = img.shape[:2]
697
+ r = min(self.veh_h / h, self.veh_w / w)
698
+ nw, nh = int(round(w * r)), int(round(h * r))
699
+ img_r = cv2.resize(img, (nw, nh), interpolation=cv2.INTER_LINEAR)
700
+ dw, dh = self.veh_w - nw, self.veh_h - nh
701
+ pl, pt = dw // 2, dh // 2
702
+ img_p = cv2.copyMakeBorder(
703
+ img_r, pt, dh - pt, pl, dw - pl,
704
+ cv2.BORDER_CONSTANT, value=(114, 114, 114),
705
+ )
706
+ return img_p, r, pl, pt
707
+
708
+ def _veh_preprocess(self, image_bgr):
709
+ img_p, ratio, pl, pt = self._veh_letterbox(image_bgr)
710
+ rgb = cv2.cvtColor(img_p, cv2.COLOR_BGR2RGB)
711
+ inp = rgb.astype(np.float32) / 255.0
712
+ inp = np.ascontiguousarray(inp.transpose(2, 0, 1)[np.newaxis])
713
+ return inp, ratio, pl, pt
714
+
715
+ def _veh_decode(self, raw, ratio, pl, pt, ow, oh, conf_thresh):
716
+ pred = raw[0]
717
+ if pred.shape[0] < pred.shape[1]:
718
+ pred = pred.T
719
+ cls_scores = pred[:, 4:]
720
+ cls_ids = np.argmax(cls_scores, axis=1)
721
+ confs = np.max(cls_scores, axis=1)
722
+ mask = confs >= conf_thresh
723
+ if not mask.any():
724
+ return np.empty((0, 4)), np.empty(0), np.empty(0, dtype=int)
725
+ bx, confs, cls_ids = pred[mask, :4], confs[mask], cls_ids[mask]
726
+ cx, cy, bw, bh = bx[:, 0], bx[:, 1], bx[:, 2], bx[:, 3]
727
+ x1 = np.clip((cx - bw / 2 - pl) / ratio, 0, ow)
728
+ y1 = np.clip((cy - bh / 2 - pt) / ratio, 0, oh)
729
+ x2 = np.clip((cx + bw / 2 - pl) / ratio, 0, ow)
730
+ y2 = np.clip((cy + bh / 2 - pt) / ratio, 0, oh)
731
+ return np.stack([x1, y1, x2, y2], axis=1), confs, cls_ids
732
+
733
+ def _veh_run_pass(self, image_bgr, conf_thresh):
734
+ oh, ow = image_bgr.shape[:2]
735
+ inp, ratio, pl, pt = self._veh_preprocess(image_bgr)
736
+ raw = self.veh_session.run(None, {self.veh_input_name: inp})[0]
737
+ return self._veh_decode(raw, ratio, pl, pt, ow, oh, conf_thresh)
738
+
739
+ def _infer_vehicle(self, image_bgr):
740
+ """Vehicle detection: single-pass 1280px, per-class NMS + confidence + aspect filter.
741
+
742
+ Pipeline (v3.19 β€” TTA removed for RTF, saves ~9ms/frame Γ— 274 frames):
743
+ 1. Single pass at VEH_CONF_THRES
744
+ 2. Remap classes, per-class NMS
745
+ 3. Per-class confidence filter (higher thresholds reduce FP)
746
+ 4. Per-class aspect ratio filter
747
+ 5. Skip bus (cls_id=4, not scored by validator)
748
+ """
749
+ oh, ow = image_bgr.shape[:2]
750
+
751
+ # Single pass β€” flip TTA removed in v3.19 (RTF 0.89β†’0.65 for 274 frames)
752
+ boxes, confs, cls_ids = self._veh_run_pass(image_bgr, VEH_CONF_THRES)
753
+
754
+ if len(boxes) == 0:
755
+ return []
756
+
757
+ # Remap model classes to output classes
758
+ out_cls = np.array([VEH_MODEL_TO_OUT[int(c)] for c in cls_ids])
759
+
760
+ # Per-class hard NMS with max-score cluster boosting
761
+ boxes, confs, out_cls = _nms_per_class_boost(
762
+ boxes, confs, out_cls, iou_thr=VEH_NMS_IOU)
763
+
764
+ if len(boxes) == 0:
765
+ return []
766
+
767
+ # Per-class confidence filter + aspect ratio filter + bus suppression
768
+ img_area = float(oh * ow)
769
+ sane = []
770
+ for i in range(len(boxes)):
771
+ cls = int(out_cls[i])
772
+
773
+ # Skip bus entirely (not scored by validator, just generates FP)
774
+ if cls in VEH_SKIP_CLS:
775
+ continue
776
+
777
+ # Per-class confidence threshold
778
+ min_conf = VEH_CLASS_CONF.get(cls, VEH_CONF_THRES)
779
+ if confs[i] < min_conf:
780
+ continue
781
+
782
+ bw = boxes[i, 2] - boxes[i, 0]
783
+ bh = boxes[i, 3] - boxes[i, 1]
784
+
785
+ # Minimum dimension
786
+ if bw < VEH_MIN_WH or bh < VEH_MIN_WH:
787
+ continue
788
+
789
+ area = bw * bh
790
+
791
+ # Per-class minimum area
792
+ min_area = VEH_CLASS_MIN_AREA.get(cls, VEH_MIN_AREA)
793
+ if area < min_area:
794
+ continue
795
+
796
+ # Per-class aspect ratio filter
797
+ aspect = max(bw, bh) / max(min(bw, bh), 1e-6)
798
+ max_aspect = VEH_CLASS_ASPECT.get(cls, VEH_MAX_ASPECT)
799
+ if aspect > max_aspect:
800
+ continue
801
+
802
+ # Max area ratio (covers entire image β€” likely FP)
803
+ if area / img_area > VEH_MAX_AREA_RATIO:
804
+ continue
805
+
806
+ sane.append(i)
807
+
808
+ if not sane:
809
+ return []
810
+ boxes, confs, out_cls = boxes[sane], confs[sane], out_cls[sane]
811
+
812
+ # Limit max detections
813
+ if len(boxes) > VEH_MAX_DET:
814
+ top_k = np.argsort(confs)[::-1][:VEH_MAX_DET]
815
+ boxes, confs, out_cls = boxes[top_k], confs[top_k], out_cls[top_k]
816
+
817
+ out = []
818
+ for i in range(len(boxes)):
819
+ b = boxes[i]
820
+ out.append(BoundingBox(
821
+ x1=max(0, min(ow, math.floor(b[0]))),
822
+ y1=max(0, min(oh, math.floor(b[1]))),
823
+ x2=max(0, min(ow, math.ceil(b[2]))),
824
+ y2=max(0, min(oh, math.ceil(b[3]))),
825
+ cls_id=int(out_cls[i]),
826
+ conf=max(0.0, min(1.0, float(confs[i]))),
827
+ ))
828
+ return out
829
+
830
+ # ── Vehicle parts confirmation ───────────────────────────────────────
831
+
832
+ @staticmethod
833
+ def _veh_check_driver(vb, person_boxes):
834
+ """Check if any person detection overlaps the driver/passenger region.
835
+
836
+ Driver region: upper 55% height, center 70% width of vehicle box.
837
+ A person's center inside this region β†’ vehicle confirmed.
838
+ """
839
+ if not person_boxes:
840
+ return False
841
+ vw = vb.x2 - vb.x1
842
+ vh = vb.y2 - vb.y1
843
+ dr_x1 = vb.x1 + vw * 0.15
844
+ dr_y1 = vb.y1
845
+ dr_x2 = vb.x2 - vw * 0.15
846
+ dr_y2 = vb.y1 + vh * 0.55
847
+ for pb in person_boxes:
848
+ pcx = (pb.x1 + pb.x2) / 2
849
+ pcy = (pb.y1 + pb.y2) / 2
850
+ if dr_x1 <= pcx <= dr_x2 and dr_y1 <= pcy <= dr_y2:
851
+ return True
852
+ return False
853
+
854
+ def _veh_check_rider(self, moto_box, person_boxes):
855
+ """Check if motorcycle has a rider, optionally with forward-lean pose.
856
+
857
+ Returns (has_overlap, has_lean_pose).
858
+ Uses cached pose keypoints from person pipeline to check torso angle.
859
+ Motorcycle riders lean forward (torso > 15Β° from vertical).
860
+ """
861
+ if not person_boxes:
862
+ return False, False
863
+ mw = moto_box.x2 - moto_box.x1
864
+ mh = moto_box.y2 - moto_box.y1
865
+ mx = mw * 0.1
866
+ my = mh * 0.1
867
+ has_overlap = False
868
+ for pb in person_boxes:
869
+ pcx = (pb.x1 + pb.x2) / 2
870
+ pcy = (pb.y1 + pb.y2) / 2
871
+ if (moto_box.x1 - mx <= pcx <= moto_box.x2 + mx and
872
+ moto_box.y1 - my <= pcy <= moto_box.y2 + my):
873
+ has_overlap = True
874
+ break
875
+ if not has_overlap:
876
+ return False, False
877
+
878
+ # Check forward-lean pose using cached pose data
879
+ if self._cached_pose_data is None:
880
+ return True, False
881
+ pose_boxes, pose_kps = self._cached_pose_data
882
+ if len(pose_boxes) == 0:
883
+ return True, False
884
+
885
+ for j in range(len(pose_boxes)):
886
+ pb = pose_boxes[j]
887
+ pcx = (pb[0] + pb[2]) / 2
888
+ pcy = (pb[1] + pb[3]) / 2
889
+ if not (moto_box.x1 - mx <= pcx <= moto_box.x2 + mx and
890
+ moto_box.y1 - my <= pcy <= moto_box.y2 + my):
891
+ continue
892
+ kps = pose_kps[j]
893
+ # Need at least one shoulder + one hip visible
894
+ l_sh, r_sh = kps[5], kps[6]
895
+ l_hip, r_hip = kps[11], kps[12]
896
+ sh_vis = [k[:2] for k in [l_sh, r_sh] if k[2] >= POSE_KP_CONF]
897
+ hip_vis = [k[:2] for k in [l_hip, r_hip] if k[2] >= POSE_KP_CONF]
898
+ if not sh_vis or not hip_vis:
899
+ continue
900
+ sh_mid = np.mean(sh_vis, axis=0)
901
+ hip_mid = np.mean(hip_vis, axis=0)
902
+ dx = sh_mid[0] - hip_mid[0]
903
+ dy = hip_mid[1] - sh_mid[1] # positive = shoulder above hip
904
+ if dy <= 0:
905
+ continue
906
+ angle = math.degrees(math.atan2(abs(dx), dy))
907
+ if angle >= VEH_PARTS_RIDER_LEAN_DEG:
908
+ return True, True
909
+ return True, False
910
+
911
+ def _veh_check_headlights(self, vb, image_bgr):
912
+ """Detect bright symmetric pair in lower portion of vehicle box.
913
+
914
+ Requires two bright blobs at similar y, on opposite sides of center,
915
+ with similar area. Only checks vehicles wider than VEH_PARTS_HL_MIN_PX.
916
+ """
917
+ bw = vb.x2 - vb.x1
918
+ bh = vb.y2 - vb.y1
919
+ if bw < VEH_PARTS_HL_MIN_PX or bh < 30:
920
+ return False
921
+
922
+ oh, ow = image_bgr.shape[:2]
923
+ y1 = max(0, min(oh, int(vb.y1 + bh * 0.65)))
924
+ y2 = max(0, min(oh, int(vb.y2)))
925
+ x1 = max(0, min(ow, int(vb.x1)))
926
+ x2 = max(0, min(ow, int(vb.x2)))
927
+ if y2 - y1 < 5 or x2 - x1 < 10:
928
+ return False
929
+
930
+ roi = image_bgr[y1:y2, x1:x2]
931
+ gray = cv2.cvtColor(roi, cv2.COLOR_BGR2GRAY)
932
+ _, bright = cv2.threshold(gray, VEH_PARTS_HL_BRIGHT, 255, cv2.THRESH_BINARY)
933
+ contours, _ = cv2.findContours(bright, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
934
+
935
+ blobs = []
936
+ for c in contours:
937
+ area = cv2.contourArea(c)
938
+ if area < VEH_PARTS_HL_MIN_BLOB:
939
+ continue
940
+ M = cv2.moments(c)
941
+ if M["m00"] < 1:
942
+ continue
943
+ blobs.append((M["m10"] / M["m00"], M["m01"] / M["m00"], area))
944
+
945
+ if len(blobs) < 2:
946
+ return False
947
+
948
+ roi_mid = (x2 - x1) / 2.0
949
+ roi_h = y2 - y1
950
+ for i in range(len(blobs)):
951
+ for j in range(i + 1, len(blobs)):
952
+ b1, b2 = blobs[i], blobs[j]
953
+ if abs(b1[1] - b2[1]) > roi_h * 0.4:
954
+ continue
955
+ if max(b1[2], b2[2]) / max(min(b1[2], b2[2]), 1) > 3.0:
956
+ continue
957
+ if (b1[0] - roi_mid) * (b2[0] - roi_mid) < 0:
958
+ return True
959
+ return False
960
+
961
+ def _veh_check_windows(self, vb, image_bgr):
962
+ """Detect repeated window pattern (bus/coach signature) using vertical edge periodicity.
963
+
964
+ Extracts middle horizontal band, applies vertical Sobel, projects vertically,
965
+ and checks for 3+ regularly-spaced peaks (window frame edges).
966
+ Only for large vehicles (truck cls_id=2).
967
+ """
968
+ bw = vb.x2 - vb.x1
969
+ bh = vb.y2 - vb.y1
970
+ if bw < VEH_PARTS_WINDOW_MIN_PX or bh < 40:
971
+ return False
972
+
973
+ oh, ow = image_bgr.shape[:2]
974
+ # Middle 40% of height (window band on a bus/coach)
975
+ y1 = max(0, min(oh, int(vb.y1 + bh * 0.30)))
976
+ y2 = max(0, min(oh, int(vb.y1 + bh * 0.70)))
977
+ x1 = max(0, min(ow, int(vb.x1)))
978
+ x2 = max(0, min(ow, int(vb.x2)))
979
+ if y2 - y1 < 10 or x2 - x1 < 30:
980
+ return False
981
+
982
+ roi = image_bgr[y1:y2, x1:x2]
983
+ gray = cv2.cvtColor(roi, cv2.COLOR_BGR2GRAY)
984
+
985
+ # Vertical edge detection (window frames are vertical edges)
986
+ sobel_v = cv2.Sobel(gray, cv2.CV_64F, 1, 0, ksize=3)
987
+ abs_sobel = np.abs(sobel_v)
988
+
989
+ # Project vertically: mean per column
990
+ projection = abs_sobel.mean(axis=0)
991
+ if len(projection) < 10:
992
+ return False
993
+
994
+ # Smooth projection
995
+ ks = max(3, int(len(projection) * 0.02) | 1)
996
+ projection = np.convolve(projection, np.ones(ks) / ks, mode='same')
997
+
998
+ # Find peaks above mean + 1 std
999
+ thresh = projection.mean() + projection.std()
1000
+ peaks = []
1001
+ in_peak = False
1002
+ pk_start = 0
1003
+ for i in range(len(projection)):
1004
+ if projection[i] > thresh:
1005
+ if not in_peak:
1006
+ pk_start = i
1007
+ in_peak = True
1008
+ else:
1009
+ if in_peak:
1010
+ peaks.append((pk_start + i) // 2)
1011
+ in_peak = False
1012
+ if in_peak:
1013
+ peaks.append((pk_start + len(projection) - 1) // 2)
1014
+
1015
+ if len(peaks) < VEH_PARTS_WINDOW_MIN_PEAKS:
1016
+ return False
1017
+
1018
+ # Check regular spacing: gaps within 40% of median
1019
+ gaps = [peaks[i + 1] - peaks[i] for i in range(len(peaks) - 1)]
1020
+ if not gaps:
1021
+ return False
1022
+ med = sorted(gaps)[len(gaps) // 2]
1023
+ if med < 5:
1024
+ return False
1025
+ regular = sum(1 for g in gaps if abs(g - med) / max(med, 1) < 0.4)
1026
+ return regular >= len(gaps) * 0.6
1027
+
1028
+ def _veh_check_plate(self, vb, image_bgr):
1029
+ """Run license plate detector on a vehicle crop. Returns True if plate found."""
1030
+ if self.plate_session is None:
1031
+ return False
1032
+ bw = vb.x2 - vb.x1
1033
+ if bw < VEH_PARTS_PLATE_MIN_PX:
1034
+ return False
1035
+
1036
+ oh, ow = image_bgr.shape[:2]
1037
+ # Crop vehicle region with 5% padding
1038
+ pad_x = int(bw * 0.05)
1039
+ pad_y = int((vb.y2 - vb.y1) * 0.05)
1040
+ cx1 = max(0, int(vb.x1) - pad_x)
1041
+ cy1 = max(0, int(vb.y1) - pad_y)
1042
+ cx2 = min(ow, int(vb.x2) + pad_x)
1043
+ cy2 = min(oh, int(vb.y2) + pad_y)
1044
+ crop = image_bgr[cy1:cy2, cx1:cx2]
1045
+ if crop.size == 0:
1046
+ return False
1047
+
1048
+ # Letterbox to plate model input
1049
+ ch, cw = crop.shape[:2]
1050
+ r = min(self.plate_h / ch, self.plate_w / cw)
1051
+ nw, nh = int(round(cw * r)), int(round(ch * r))
1052
+ img_r = cv2.resize(crop, (nw, nh), interpolation=cv2.INTER_LINEAR)
1053
+ dw, dh = self.plate_w - nw, self.plate_h - nh
1054
+ pl, pt = dw // 2, dh // 2
1055
+ img_p = cv2.copyMakeBorder(
1056
+ img_r, pt, dh - pt, pl, dw - pl,
1057
+ cv2.BORDER_CONSTANT, value=(114, 114, 114),
1058
+ )
1059
+ rgb = cv2.cvtColor(img_p, cv2.COLOR_BGR2RGB)
1060
+ inp = rgb.astype(np.float32) / 255.0
1061
+ inp = np.ascontiguousarray(inp.transpose(2, 0, 1)[np.newaxis])
1062
+
1063
+ raw = self.plate_session.run(None, {self.plate_input_name: inp})[0]
1064
+ pred = raw[0] if raw.ndim == 3 else raw
1065
+
1066
+ # Handle both [N,6] end2end (post-NMS) and [N, 5+nc] raw formats
1067
+ if pred.shape[0] < pred.shape[1]:
1068
+ pred = pred.T # transpose [5+nc, N] -> [N, 5+nc]
1069
+ if pred.shape[1] < 5:
1070
+ return False
1071
+ # End2end post-NMS: few detections (< 500), col4=conf already final
1072
+ if pred.shape[0] < 500 and pred.shape[1] == 6:
1073
+ confs = pred[:, 4]
1074
+ elif pred.shape[1] == 5:
1075
+ confs = pred[:, 4] # single objectness score
1076
+ else:
1077
+ # Raw: x,y,w,h,objectness,cls_scores... β†’ conf = obj * max(cls)
1078
+ confs = pred[:, 4] * np.max(pred[:, 5:], axis=1)
1079
+ return bool((confs >= VEH_PARTS_PLATE_CONF).any())
1080
+
1081
+ def _vehicle_parts_confirm(self, vehicle_boxes, person_boxes, image_bgr):
1082
+ """Parts-based confidence scoring for vehicle detections.
1083
+
1084
+ Scoring hierarchy (confidence boosts are additive):
1085
+ 1. License plate detected β†’ +0.12 (strong, never suppress)
1086
+ 2. Person (driver/rider) inside vehicle β†’ +0.08-0.10
1087
+ 3. Headlight pair detected β†’ +0.05
1088
+ 4. Bus window pattern on truck β†’ +0.06
1089
+ 5. No parts but small/distant or high-conf β†’ keep original
1090
+ 6. Large + low-conf + no parts β†’ suppress as FP
1091
+
1092
+ Small/distant vehicles (area < 0.4% of image) are always exempt.
1093
+ Bus (cls_id=4) suppressed in _infer_vehicle β€” window check applies to trucks.
1094
+ """
1095
+ if not vehicle_boxes or not VEH_PARTS_ENABLED:
1096
+ return vehicle_boxes
1097
+
1098
+ oh, ow = image_bgr.shape[:2]
1099
+ img_area = float(oh * ow)
1100
+ has_plate_model = self.plate_session is not None
1101
+ # Skip plate checks on crowded scenes (aerial/drone, plates invisible)
1102
+ skip_plate = len(vehicle_boxes) > 20
1103
+
1104
+ result = []
1105
+ n_driver = 0
1106
+ n_rider = 0
1107
+ n_rider_lean = 0
1108
+ n_headlight = 0
1109
+ n_window = 0
1110
+ n_plate = 0
1111
+ n_suppressed = 0
1112
+
1113
+ for vb in vehicle_boxes:
1114
+ bw = vb.x2 - vb.x1
1115
+ bh = vb.y2 - vb.y1
1116
+ area_ratio = (bw * bh) / img_area
1117
+
1118
+ # Small/distant: exempt from parts check
1119
+ if area_ratio < VEH_PARTS_SMALL_AREA:
1120
+ result.append(vb)
1121
+ continue
1122
+
1123
+ boost = 0.0
1124
+ confirmed = False
1125
+
1126
+ # Check 1: License plate (strongest signal)
1127
+ if has_plate_model and not skip_plate and bw >= VEH_PARTS_PLATE_MIN_PX:
1128
+ try:
1129
+ if self._veh_check_plate(vb, image_bgr):
1130
+ boost += VEH_PARTS_BOOST_PLATE
1131
+ confirmed = True
1132
+ n_plate += 1
1133
+ except Exception:
1134
+ pass
1135
+
1136
+ # Check 2: Driver/passenger inside car or truck
1137
+ if vb.cls_id in (1, 2):
1138
+ if self._veh_check_driver(vb, person_boxes):
1139
+ boost += VEH_PARTS_BOOST_DRIVER
1140
+ confirmed = True
1141
+ n_driver += 1
1142
+
1143
+ # Check 3: Motorcycle rider (overlap + optional lean pose)
1144
+ if vb.cls_id == 3:
1145
+ has_overlap, has_lean = self._veh_check_rider(vb, person_boxes)
1146
+ if has_overlap:
1147
+ boost += VEH_PARTS_BOOST_RIDER
1148
+ if has_lean:
1149
+ boost += 0.05 # Extra for confirmed lean pose
1150
+ n_rider_lean += 1
1151
+ confirmed = True
1152
+ n_rider += 1
1153
+
1154
+ # Check 4: Headlight pair
1155
+ if bw >= VEH_PARTS_HL_MIN_PX:
1156
+ try:
1157
+ if self._veh_check_headlights(vb, image_bgr):
1158
+ boost += VEH_PARTS_BOOST_HL
1159
+ confirmed = True
1160
+ n_headlight += 1
1161
+ except Exception:
1162
+ pass
1163
+
1164
+ # Check 5: Window pattern (large trucks that might be buses)
1165
+ if vb.cls_id == 2 and bw >= VEH_PARTS_WINDOW_MIN_PX:
1166
+ try:
1167
+ if self._veh_check_windows(vb, image_bgr):
1168
+ boost += VEH_PARTS_BOOST_WINDOW
1169
+ n_window += 1
1170
+ except Exception:
1171
+ pass
1172
+
1173
+ # Apply boost and decide
1174
+ new_conf = min(1.0, vb.conf + boost)
1175
+
1176
+ if confirmed:
1177
+ result.append(BoundingBox(
1178
+ x1=vb.x1, y1=vb.y1, x2=vb.x2, y2=vb.y2,
1179
+ cls_id=vb.cls_id, conf=new_conf,
1180
+ ))
1181
+ elif area_ratio > VEH_PARTS_FP_AREA:
1182
+ # Large vehicle β€” use stricter threshold if plate model loaded
1183
+ fp_thresh = VEH_PARTS_FP_CONF_STRICT if (has_plate_model and not skip_plate) else VEH_PARTS_FP_CONF
1184
+ if vb.conf < fp_thresh:
1185
+ n_suppressed += 1
1186
+ else:
1187
+ result.append(vb)
1188
+ else:
1189
+ result.append(vb)
1190
+
1191
+ if n_driver or n_rider or n_headlight or n_window or n_plate or n_suppressed:
1192
+ logger.info(f"[veh-parts] plate={n_plate} driver={n_driver} rider={n_rider}"
1193
+ f"(lean={n_rider_lean}) hl={n_headlight} win={n_window} "
1194
+ f"suppress={n_suppressed}, kept {len(result)}/{len(vehicle_boxes)}")
1195
+ return result
1196
+
1197
+ # ── Person preprocessing (letterbox) ──────────────────────────────────
1198
+
1199
+ def _per_letterbox(self, img):
1200
+ h, w = img.shape[:2]
1201
+ r = min(self.per_h / h, self.per_w / w)
1202
+ nw, nh = int(round(w * r)), int(round(h * r))
1203
+ interp = cv2.INTER_CUBIC if r > 1.0 else cv2.INTER_LINEAR
1204
+ img_r = cv2.resize(img, (nw, nh), interpolation=interp)
1205
+ dw, dh = self.per_w - nw, self.per_h - nh
1206
+ pl, pt = dw // 2, dh // 2
1207
+ img_p = cv2.copyMakeBorder(
1208
+ img_r, pt, dh - pt, pl, dw - pl,
1209
+ cv2.BORDER_CONSTANT, value=(114, 114, 114),
1210
+ )
1211
+ return img_p, r, pl, pt
1212
+
1213
+ def _per_preprocess(self, image_bgr):
1214
+ img_p, ratio, pl, pt = self._per_letterbox(image_bgr)
1215
+ rgb = cv2.cvtColor(img_p, cv2.COLOR_BGR2RGB)
1216
+ inp = rgb.astype(np.float32) / 255.0
1217
+ inp = np.ascontiguousarray(inp.transpose(2, 0, 1)[np.newaxis])
1218
+ return inp, ratio, pl, pt
1219
+
1220
+ def _per_enhance(self, img_bgr):
1221
+ """Adaptive CLAHE: only apply to low-contrast frames, mild clip=2.0."""
1222
+ lab = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2LAB)
1223
+ l, a, b = cv2.split(lab)
1224
+ if float(l.std()) < PER_CLAHE_CONTRAST_THRESH:
1225
+ clahe = cv2.createCLAHE(clipLimit=PER_CLAHE_CLIP, tileGridSize=(8, 8))
1226
+ l = clahe.apply(l)
1227
+ return cv2.cvtColor(cv2.merge([l, a, b]), cv2.COLOR_LAB2BGR)
1228
+ return img_bgr # skip CLAHE on normal-contrast images
1229
+
1230
+ @staticmethod
1231
+ def _frame_blur_score(img_bgr):
1232
+ """Laplacian variance blur metric. Lower = blurrier."""
1233
+ gray = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2GRAY)
1234
+ return cv2.Laplacian(gray, cv2.CV_64F).var()
1235
+
1236
+ @staticmethod
1237
+ def _perspective_penalty(boxes, confs, image_h):
1238
+ """Apply confidence penalty to perspective-anomalous person detections.
1239
+
1240
+ Model: expected_height(y) = alpha * (y_foot - y_vp), where y_vp = image_h / 3.
1241
+ Alpha is estimated from the median height/distance ratio across detections.
1242
+ Detections deviating >3x from expected get conf *= 0.85.
1243
+ Fails open (returns confs unchanged) when model can't be estimated.
1244
+ """
1245
+ n = len(boxes)
1246
+ if n < PERSP_MIN_DETECTIONS:
1247
+ return confs
1248
+
1249
+ y_vp = image_h / 3.0
1250
+ y_feet = boxes[:, 3]
1251
+ heights = boxes[:, 3] - boxes[:, 1]
1252
+
1253
+ valid = y_feet > (y_vp + 10)
1254
+ if valid.sum() < PERSP_MIN_DETECTIONS:
1255
+ return confs
1256
+
1257
+ valid_y = y_feet[valid]
1258
+ valid_h = heights[valid]
1259
+
1260
+ y_spread = (valid_y.max() - valid_y.min()) / image_h
1261
+ if y_spread < PERSP_MIN_Y_SPREAD:
1262
+ return confs
1263
+
1264
+ alpha = float(np.median(valid_h / (valid_y - y_vp)))
1265
+ if alpha <= 0.01:
1266
+ return confs
1267
+
1268
+ new_confs = confs.copy()
1269
+ for i in range(n):
1270
+ if y_feet[i] <= y_vp:
1271
+ continue
1272
+ expected_h = alpha * (y_feet[i] - y_vp)
1273
+ if expected_h <= 0:
1274
+ continue
1275
+ ratio = heights[i] / expected_h
1276
+ if ratio > PERSP_DEVIATION_THRESH or ratio < (1.0 / PERSP_DEVIATION_THRESH):
1277
+ new_confs[i] *= PERSP_CONF_PENALTY
1278
+
1279
+ return new_confs
1280
+
1281
+ def _per_decode(self, raw, ratio, pl, pt, oh, ow, conf_thresh):
1282
+ pred = raw[0]
1283
+ if pred.ndim != 2:
1284
+ return np.empty((0, 4)), np.empty(0)
1285
+
1286
+ # Auto-detect output format
1287
+ if pred.shape[-1] == 6 and pred.shape[0] > pred.shape[1]:
1288
+ # YOLO26 end2end: [N, 6] = [x1, y1, x2, y2, conf, class_id]
1289
+ confs = pred[:, 4]
1290
+ keep = confs >= conf_thresh
1291
+ boxes, confs = pred[keep, :4], confs[keep]
1292
+ if len(boxes) == 0:
1293
+ return np.empty((0, 4)), np.empty(0)
1294
+ boxes[:, 0] = np.floor((boxes[:, 0] - pl) / ratio)
1295
+ boxes[:, 1] = np.floor((boxes[:, 1] - pt) / ratio)
1296
+ boxes[:, 2] = np.ceil((boxes[:, 2] - pl) / ratio)
1297
+ boxes[:, 3] = np.ceil((boxes[:, 3] - pt) / ratio)
1298
+ boxes = np.clip(boxes, 0, [[ow, oh, ow, oh]])
1299
+ return boxes, confs
1300
+
1301
+ # YOLO11 raw format: [5+nc, N] or [N, 5+nc]
1302
+ if pred.shape[0] < pred.shape[1]:
1303
+ pred = pred.T
1304
+ if pred.shape[1] < 5:
1305
+ return np.empty((0, 4)), np.empty(0)
1306
+ cls_scores = pred[:, 4:]
1307
+ confs = np.max(cls_scores, axis=1)
1308
+ keep = confs >= conf_thresh
1309
+ boxes, confs = pred[keep, :4], confs[keep]
1310
+ if len(boxes) == 0:
1311
+ return np.empty((0, 4)), np.empty(0)
1312
+ cx, cy, bw, bh = boxes[:, 0], boxes[:, 1], boxes[:, 2], boxes[:, 3]
1313
+ x1 = np.clip(np.floor((cx - bw / 2 - pl) / ratio), 0, ow)
1314
+ y1 = np.clip(np.floor((cy - bh / 2 - pt) / ratio), 0, oh)
1315
+ x2 = np.clip(np.ceil((cx + bw / 2 - pl) / ratio), 0, ow)
1316
+ y2 = np.clip(np.ceil((cy + bh / 2 - pt) / ratio), 0, oh)
1317
+ return np.stack([x1, y1, x2, y2], axis=1), confs
1318
+
1319
+ def _per_run_pass(self, image_bgr, conf_thresh):
1320
+ oh, ow = image_bgr.shape[:2]
1321
+ inp, ratio, pl, pt = self._per_preprocess(image_bgr)
1322
+ raw = self.per_session.run(None, {self.per_input_name: inp})[0]
1323
+ return self._per_decode(raw, ratio, pl, pt, oh, ow, conf_thresh)
1324
+
1325
+ def _generate_tiles(self, h, w):
1326
+ """SAHI-inspired tile generation.
1327
+
1328
+ Smart 2-tile split: horizontal for landscape, vertical for portrait.
1329
+ Edge-aware: for landscape, split in upper portion to avoid cutting
1330
+ through people standing in bottom third.
1331
+ Returns: [(x1,y1,x2,y2), ...] β€” always starts with full image.
1332
+ """
1333
+ tiles = [(0, 0, w, h)] # full image always first
1334
+
1335
+ # Only tile if image significantly exceeds model input
1336
+ if max(h, w) <= max(self.per_h, self.per_w) * PER_TILE_MIN_DIM_RATIO:
1337
+ return tiles
1338
+
1339
+ overlap_px_x = int(w * PER_TILE_OVERLAP)
1340
+ overlap_px_y = int(h * PER_TILE_OVERLAP)
1341
+
1342
+ if w >= h:
1343
+ # Landscape: 2 horizontal tiles (left + right)
1344
+ mid = w // 2
1345
+ tiles.append((0, 0, mid + overlap_px_x, h))
1346
+ tiles.append((mid - overlap_px_x, 0, w, h))
1347
+ else:
1348
+ # Portrait: 2 vertical tiles (top + bottom)
1349
+ # Edge-aware: bias split toward upper portion (people stand at bottom)
1350
+ mid = int(h * 0.45) # split at 45% height, not 50%
1351
+ tiles.append((0, 0, w, mid + overlap_px_y))
1352
+ tiles.append((0, mid - overlap_px_y, w, h))
1353
+
1354
+ return tiles
1355
+
1356
+ def _per_run_tile(self, image_bgr, tile_region, conf_thresh):
1357
+ """Run person model on a tile crop, return boxes in original coords."""
1358
+ x1t, y1t, x2t, y2t = tile_region
1359
+ crop = image_bgr[y1t:y2t, x1t:x2t]
1360
+ boxes, confs = self._per_run_pass(crop, conf_thresh)
1361
+ if len(boxes) == 0:
1362
+ return np.empty((0, 4)), np.empty(0)
1363
+ # Shift back to original image coordinates
1364
+ boxes[:, 0] += x1t
1365
+ boxes[:, 1] += y1t
1366
+ boxes[:, 2] += x1t
1367
+ boxes[:, 3] += y1t
1368
+ return boxes, confs
1369
+
1370
+ @staticmethod
1371
+ def _nms_max_conf(boxes, scores, iou_thr):
1372
+ """NMS that keeps max confidence when boxes overlap.
1373
+
1374
+ Unlike WBF which averages scores (diluting strong detections),
1375
+ this preserves sharp confidence values β€” critical for FP scoring.
1376
+ """
1377
+ if len(boxes) == 0:
1378
+ return np.empty((0, 4)), np.empty(0)
1379
+
1380
+ # Sort by confidence descending
1381
+ order = np.argsort(-scores)
1382
+ boxes = boxes[order]
1383
+ scores = scores[order]
1384
+
1385
+ keep_b, keep_s = [], []
1386
+ suppressed = set()
1387
+
1388
+ for i in range(len(boxes)):
1389
+ if i in suppressed:
1390
+ continue
1391
+ keep_b.append(boxes[i])
1392
+ keep_s.append(scores[i])
1393
+
1394
+ # Suppress lower-conf overlapping boxes
1395
+ for j in range(i + 1, len(boxes)):
1396
+ if j in suppressed:
1397
+ continue
1398
+ xx1 = max(boxes[i, 0], boxes[j, 0])
1399
+ yy1 = max(boxes[i, 1], boxes[j, 1])
1400
+ xx2 = min(boxes[i, 2], boxes[j, 2])
1401
+ yy2 = min(boxes[i, 3], boxes[j, 3])
1402
+ inter = max(0, xx2 - xx1) * max(0, yy2 - yy1)
1403
+ a1 = (boxes[i, 2] - boxes[i, 0]) * (boxes[i, 3] - boxes[i, 1])
1404
+ a2 = (boxes[j, 2] - boxes[j, 0]) * (boxes[j, 3] - boxes[j, 1])
1405
+ iou = inter / (a1 + a2 - inter + 1e-9)
1406
+ if iou >= iou_thr:
1407
+ suppressed.add(j)
1408
+
1409
+ return np.array(keep_b), np.array(keep_s)
1410
+
1411
+ # ── Pose FP filter + box refinement ──────────────────────────────────
1412
+
1413
+ def _pose_run(self, image_bgr):
1414
+ """Run pose model on full image, return (boxes [N,4], confs [N], keypoints [N,17,3]) in original coords."""
1415
+ if self.pose_session is None:
1416
+ return np.empty((0, 4)), np.empty(0), np.empty((0, 17, 3))
1417
+
1418
+ oh, ow = image_bgr.shape[:2]
1419
+
1420
+ # Letterbox to pose model input size
1421
+ r = min(self.pose_h / oh, self.pose_w / ow)
1422
+ nw, nh = int(round(ow * r)), int(round(oh * r))
1423
+ img_r = cv2.resize(image_bgr, (nw, nh), interpolation=cv2.INTER_LINEAR)
1424
+ dw, dh = self.pose_w - nw, self.pose_h - nh
1425
+ pl, pt = dw // 2, dh // 2
1426
+ img_p = cv2.copyMakeBorder(
1427
+ img_r, pt, dh - pt, pl, dw - pl,
1428
+ cv2.BORDER_CONSTANT, value=(114, 114, 114),
1429
+ )
1430
+
1431
+ rgb = cv2.cvtColor(img_p, cv2.COLOR_BGR2RGB)
1432
+ inp = rgb.astype(np.float32) / 255.0
1433
+ inp = np.ascontiguousarray(inp.transpose(2, 0, 1)[np.newaxis])
1434
+
1435
+ raw = self.pose_session.run(None, {self.pose_input_name: inp})[0]
1436
+
1437
+ # raw shape: [1, 56, 8400] -> transpose to [8400, 56]
1438
+ pred = raw[0] if raw.ndim == 3 else raw
1439
+ if pred.shape[0] < pred.shape[1]:
1440
+ pred = pred.T
1441
+
1442
+ # Decode: cols 0-3=xywh, col 4=conf, cols 5-55=17*3 keypoints
1443
+ confs = pred[:, 4]
1444
+ keep = confs >= POSE_CONF_THRESH
1445
+ if not keep.any():
1446
+ return np.empty((0, 4)), np.empty(0), np.empty((0, 17, 3))
1447
+
1448
+ pred = pred[keep]
1449
+ confs = pred[:, 4]
1450
+
1451
+ # Convert xywh to x1y1x2y2 in original coords
1452
+ cx, cy, bw, bh = pred[:, 0], pred[:, 1], pred[:, 2], pred[:, 3]
1453
+ x1 = np.clip((cx - bw / 2 - pl) / r, 0, ow)
1454
+ y1 = np.clip((cy - bh / 2 - pt) / r, 0, oh)
1455
+ x2 = np.clip((cx + bw / 2 - pl) / r, 0, ow)
1456
+ y2 = np.clip((cy + bh / 2 - pt) / r, 0, oh)
1457
+ boxes = np.stack([x1, y1, x2, y2], axis=1)
1458
+
1459
+ # Decode keypoints: [N, 51] -> [N, 17, 3]
1460
+ kp_raw = pred[:, 5:].reshape(-1, 17, 3).copy()
1461
+ kp_raw[:, :, 0] = (kp_raw[:, :, 0] - pl) / r # x
1462
+ kp_raw[:, :, 1] = (kp_raw[:, :, 1] - pt) / r # y
1463
+ kp_raw[:, :, 0] = np.clip(kp_raw[:, :, 0], 0, ow)
1464
+ kp_raw[:, :, 1] = np.clip(kp_raw[:, :, 1], 0, oh)
1465
+
1466
+ # NMS on pose detections
1467
+ order = np.argsort(-confs)
1468
+ boxes = boxes[order]
1469
+ confs = confs[order]
1470
+ kp_raw = kp_raw[order]
1471
+
1472
+ keep_idx = []
1473
+ suppressed = set()
1474
+ for i in range(len(boxes)):
1475
+ if i in suppressed:
1476
+ continue
1477
+ keep_idx.append(i)
1478
+ for j in range(i + 1, len(boxes)):
1479
+ if j in suppressed:
1480
+ continue
1481
+ xx1 = max(boxes[i, 0], boxes[j, 0])
1482
+ yy1 = max(boxes[i, 1], boxes[j, 1])
1483
+ xx2 = min(boxes[i, 2], boxes[j, 2])
1484
+ yy2 = min(boxes[i, 3], boxes[j, 3])
1485
+ inter = max(0, xx2 - xx1) * max(0, yy2 - yy1)
1486
+ a1 = (boxes[i, 2] - boxes[i, 0]) * (boxes[i, 3] - boxes[i, 1])
1487
+ a2 = (boxes[j, 2] - boxes[j, 0]) * (boxes[j, 3] - boxes[j, 1])
1488
+ iou_val = inter / (a1 + a2 - inter + 1e-9)
1489
+ if iou_val >= POSE_NMS_IOU:
1490
+ suppressed.add(j)
1491
+
1492
+ if not keep_idx:
1493
+ return np.empty((0, 4)), np.empty(0), np.empty((0, 17, 3))
1494
+ keep_idx = np.array(keep_idx)
1495
+ return boxes[keep_idx], confs[keep_idx], kp_raw[keep_idx]
1496
+
1497
+ _FACE_SIZE = 640
1498
+ _FACE_STRIDES = (8, 16, 32)
1499
+ _FACE_NUM_ANCHORS = 2
1500
+ _FACE_THRESH = 0.5
1501
+ _FACE_NMS_THRESH = 0.4
1502
+
1503
+ def _face_run(self, image_bgr):
1504
+ """Run SCRFD-500M face detector. Returns (face_boxes [N,4], face_confs [N])."""
1505
+ if self.face_session is None:
1506
+ return np.empty((0, 4)), np.empty(0)
1507
+
1508
+ oh, ow = image_bgr.shape[:2]
1509
+ sz = self._FACE_SIZE
1510
+
1511
+ # Letterbox resize preserving aspect ratio (top-left aligned)
1512
+ scale = min(sz / oh, sz / ow)
1513
+ nw, nh = int(round(ow * scale)), int(round(oh * scale))
1514
+ resized = cv2.resize(image_bgr, (nw, nh), interpolation=cv2.INTER_LINEAR)
1515
+ det_img = np.zeros((sz, sz, 3), dtype=np.uint8)
1516
+ det_img[:nh, :nw, :] = resized
1517
+
1518
+ # Preprocess: BGR→RGB, (pixel - 127.5) / 128.0
1519
+ blob = cv2.dnn.blobFromImage(
1520
+ det_img, 1.0 / 128.0, (sz, sz), (127.5, 127.5, 127.5), swapRB=True,
1521
+ )
1522
+
1523
+ outputs = self.face_session.run(None, {self.face_input_name: blob})
1524
+
1525
+ # Decode 3 stride levels: outputs[0:3]=scores, [3:6]=bboxes, [6:9]=kps
1526
+ all_scores, all_boxes = [], []
1527
+ for idx, stride in enumerate(self._FACE_STRIDES):
1528
+ scores = outputs[idx][:, 0] # (N,)
1529
+ bbox_d = outputs[idx + 3] # (N, 4) distances
1530
+ keep = scores >= self._FACE_THRESH
1531
+ if not keep.any():
1532
+ continue
1533
+ scores = scores[keep]
1534
+ bbox_d = bbox_d[keep]
1535
+
1536
+ # Generate anchor centers for kept positions
1537
+ fh, fw = sz // stride, sz // stride
1538
+ grid_y, grid_x = np.mgrid[:fh, :fw]
1539
+ centers = np.stack([grid_x, grid_y], axis=-1).astype(np.float32).reshape(-1, 2)
1540
+ centers = np.tile(centers, (1, self._FACE_NUM_ANCHORS)).reshape(-1, 2) * stride
1541
+ centers = centers[keep]
1542
+
1543
+ # distance β†’ bbox: [x1, y1, x2, y2]
1544
+ x1 = centers[:, 0] - bbox_d[:, 0] * stride
1545
+ y1 = centers[:, 1] - bbox_d[:, 1] * stride
1546
+ x2 = centers[:, 0] + bbox_d[:, 2] * stride
1547
+ y2 = centers[:, 1] + bbox_d[:, 3] * stride
1548
+ boxes = np.stack([x1, y1, x2, y2], axis=-1) / scale
1549
+
1550
+ all_scores.append(scores)
1551
+ all_boxes.append(boxes)
1552
+
1553
+ if not all_scores:
1554
+ return np.empty((0, 4)), np.empty(0)
1555
+
1556
+ scores = np.concatenate(all_scores)
1557
+ boxes = np.concatenate(all_boxes)
1558
+
1559
+ # NMS
1560
+ order = scores.argsort()[::-1]
1561
+ scores, boxes = scores[order], boxes[order]
1562
+ keep = []
1563
+ x1, y1, x2, y2 = boxes[:, 0], boxes[:, 1], boxes[:, 2], boxes[:, 3]
1564
+ areas = (x2 - x1) * (y2 - y1)
1565
+ suppressed = np.zeros(len(scores), dtype=bool)
1566
+ for i in range(len(scores)):
1567
+ if suppressed[i]:
1568
+ continue
1569
+ keep.append(i)
1570
+ xx1 = np.maximum(x1[i], x1[i + 1:])
1571
+ yy1 = np.maximum(y1[i], y1[i + 1:])
1572
+ xx2 = np.minimum(x2[i], x2[i + 1:])
1573
+ yy2 = np.minimum(y2[i], y2[i + 1:])
1574
+ inter = np.maximum(0, xx2 - xx1) * np.maximum(0, yy2 - yy1)
1575
+ ovr = inter / (areas[i] + areas[i + 1:] - inter + 1e-6)
1576
+ suppressed[i + 1:] |= ovr > self._FACE_NMS_THRESH
1577
+
1578
+ return boxes[keep], scores[keep]
1579
+
1580
+ @staticmethod
1581
+ def _anatomical_score(kps, kp_conf_thresh=POSE_KP_CONF):
1582
+ """Compute weighted anatomical score from keypoints [17, 3].
1583
+
1584
+ Returns (score, has_head, n_visible):
1585
+ score: weighted sum of visible keypoints (0.0-1.0)
1586
+ has_head: True if any head keypoint (nose/eyes/ears) is visible
1587
+ n_visible: number of visible keypoints
1588
+ """
1589
+ visible = kps[:, 2] >= kp_conf_thresh
1590
+ n_visible = int(visible.sum())
1591
+ score = float((visible.astype(np.float32) * POSE_KP_WEIGHTS).sum())
1592
+ has_head = bool(visible[POSE_HEAD_KP].any())
1593
+ return score, has_head, n_visible
1594
+
1595
+ def _refine_box_with_keypoints(self, pb, kps, ow, oh):
1596
+ """Blend person box with tight keypoint bbox."""
1597
+ visible = kps[:, 2] >= POSE_KP_CONF
1598
+ if not visible.any():
1599
+ return pb
1600
+ vis_kps = kps[visible]
1601
+ kp_x1 = float(vis_kps[:, 0].min())
1602
+ kp_y1 = float(vis_kps[:, 1].min())
1603
+ kp_x2 = float(vis_kps[:, 0].max())
1604
+ kp_y2 = float(vis_kps[:, 1].max())
1605
+
1606
+ # Pad around keypoint bbox
1607
+ kp_w = kp_x2 - kp_x1
1608
+ kp_h = kp_y2 - kp_y1
1609
+ pad_x = kp_w * POSE_KP_PAD
1610
+ pad_y = kp_h * POSE_KP_PAD
1611
+ kp_x1 = max(0, kp_x1 - pad_x)
1612
+ kp_y1 = max(0, kp_y1 - pad_y)
1613
+ kp_x2 = min(ow, kp_x2 + pad_x)
1614
+ kp_y2 = min(oh, kp_y2 + pad_y)
1615
+
1616
+ a = POSE_REFINE_BLEND
1617
+ return BoundingBox(
1618
+ x1=max(0, min(ow, int(pb.x1 * (1 - a) + kp_x1 * a))),
1619
+ y1=max(0, min(oh, int(pb.y1 * (1 - a) + kp_y1 * a))),
1620
+ x2=max(0, min(ow, int(pb.x2 * (1 - a) + kp_x2 * a))),
1621
+ y2=max(0, min(oh, int(pb.y2 * (1 - a) + kp_y2 * a))),
1622
+ cls_id=0,
1623
+ conf=pb.conf,
1624
+ )
1625
+
1626
+ def _pose_filter_refine(self, person_boxes, image_bgr):
1627
+ """Filter FP detections and refine boxes using anatomical keypoint scoring.
1628
+
1629
+ Anatomical scoring: weighted sum of visible keypoints where head/face
1630
+ keypoints (nose, eyes, ears) contribute most, upper body (shoulders,
1631
+ elbows, wrists) next, lower body (hips, knees, ankles) least.
1632
+
1633
+ Decision logic:
1634
+ 1. Run pose model once on full image.
1635
+ 2. Run face detector (if available) for additional confirmation.
1636
+ 3. Match each person detection to best-overlapping pose detection.
1637
+ 4. For matched boxes:
1638
+ a. Head keypoints visible OR face detected β†’ KEEP + refine (never suppress)
1639
+ b. Anatomical score >= REFINE threshold β†’ KEEP + refine
1640
+ c. Anatomical score > 0 β†’ KEEP as-is (partially visible person)
1641
+ d. Anatomical score == 0 + large + low-conf β†’ SUPPRESS (FP candidate)
1642
+ 5. For unmatched boxes:
1643
+ a. Face detected inside box β†’ KEEP
1644
+ b. Large + low-conf β†’ SUPPRESS
1645
+ c. Small or high-conf β†’ KEEP (SAHI-detected or confident)
1646
+ """
1647
+ if not person_boxes or self.pose_session is None:
1648
+ return person_boxes
1649
+
1650
+ oh, ow = image_bgr.shape[:2]
1651
+ img_area = float(oh * ow)
1652
+
1653
+ # Run pose model
1654
+ t_pose = time.monotonic()
1655
+ pose_boxes, pose_confs, pose_kps = self._pose_run(image_bgr)
1656
+ dt_pose = (time.monotonic() - t_pose) * 1000
1657
+
1658
+ # Cache pose data for motorcycle rider check in vehicle parts confirmation
1659
+ self._cached_pose_data = (pose_boxes, pose_kps)
1660
+
1661
+ # Run face detector if available
1662
+ face_boxes = np.empty((0, 4))
1663
+ if self.face_session is not None:
1664
+ t_face = time.monotonic()
1665
+ face_boxes, _ = self._face_run(image_bgr)
1666
+ dt_face = (time.monotonic() - t_face) * 1000
1667
+ logger.info(f"[pose] {len(pose_boxes)} pose, {len(face_boxes)} faces "
1668
+ f"in {dt_pose:.0f}+{dt_face:.0f}ms")
1669
+ else:
1670
+ logger.info(f"[pose] {len(pose_boxes)} pose detections in {dt_pose:.0f}ms")
1671
+
1672
+ # Helper: check if any face detection is inside a person box
1673
+ def has_face_inside(pb):
1674
+ if len(face_boxes) == 0:
1675
+ return False
1676
+ for fb in face_boxes:
1677
+ # Face center must be inside person box
1678
+ fcx = (fb[0] + fb[2]) / 2
1679
+ fcy = (fb[1] + fb[3]) / 2
1680
+ if pb.x1 <= fcx <= pb.x2 and pb.y1 <= fcy <= pb.y2:
1681
+ return True
1682
+ return False
1683
+
1684
+ if len(pose_boxes) == 0:
1685
+ # No pose detections β€” use face detector or size/conf heuristic
1686
+ result = []
1687
+ n_suppressed = 0
1688
+ for pb in person_boxes:
1689
+ if has_face_inside(pb):
1690
+ result.append(pb)
1691
+ continue
1692
+ bw = pb.x2 - pb.x1
1693
+ bh = pb.y2 - pb.y1
1694
+ area_ratio = (bw * bh) / img_area
1695
+ if area_ratio > POSE_FP_MIN_AREA and pb.conf < POSE_FP_MAX_CONF:
1696
+ n_suppressed += 1
1697
+ continue
1698
+ result.append(pb)
1699
+ if n_suppressed:
1700
+ logger.info(f"[pose] Suppressed {n_suppressed} FP (no pose detections)")
1701
+ return result
1702
+
1703
+ # Match person detections to pose detections via IoU
1704
+ result = []
1705
+ n_refined = 0
1706
+ n_suppressed = 0
1707
+ n_face_saved = 0
1708
+
1709
+ for pb in person_boxes:
1710
+ pb_arr = np.array([pb.x1, pb.y1, pb.x2, pb.y2], dtype=float)
1711
+ best_iou = 0.0
1712
+ best_idx = -1
1713
+
1714
+ for j in range(len(pose_boxes)):
1715
+ xx1 = max(pb_arr[0], pose_boxes[j, 0])
1716
+ yy1 = max(pb_arr[1], pose_boxes[j, 1])
1717
+ xx2 = min(pb_arr[2], pose_boxes[j, 2])
1718
+ yy2 = min(pb_arr[3], pose_boxes[j, 3])
1719
+ inter = max(0, xx2 - xx1) * max(0, yy2 - yy1)
1720
+ a1 = (pb_arr[2] - pb_arr[0]) * (pb_arr[3] - pb_arr[1])
1721
+ a2 = (pose_boxes[j, 2] - pose_boxes[j, 0]) * (pose_boxes[j, 3] - pose_boxes[j, 1])
1722
+ iou_val = inter / (a1 + a2 - inter + 1e-9)
1723
+ if iou_val > best_iou:
1724
+ best_iou = iou_val
1725
+ best_idx = j
1726
+
1727
+ if best_iou >= POSE_MATCH_IOU and best_idx >= 0:
1728
+ # Matched to a pose detection β€” compute anatomical score
1729
+ kps = pose_kps[best_idx] # [17, 3]
1730
+ anat_score, has_head, n_vis = self._anatomical_score(kps)
1731
+
1732
+ if has_head or has_face_inside(pb):
1733
+ # Head/face visible β†’ definitely a person, refine box
1734
+ result.append(self._refine_box_with_keypoints(pb, kps, ow, oh))
1735
+ n_refined += 1
1736
+ elif anat_score >= POSE_ANAT_REFINE_THRESH:
1737
+ # Good anatomical score β†’ person confirmed, refine
1738
+ result.append(self._refine_box_with_keypoints(pb, kps, ow, oh))
1739
+ n_refined += 1
1740
+ elif anat_score > POSE_ANAT_SUPPRESS_THRESH:
1741
+ # Some keypoints visible but low score β€” keep as-is
1742
+ result.append(pb)
1743
+ else:
1744
+ # Matched to pose bbox but ZERO keypoints visible
1745
+ # Only suppress if also large and low confidence
1746
+ bw = pb.x2 - pb.x1
1747
+ bh = pb.y2 - pb.y1
1748
+ area_ratio = (bw * bh) / img_area
1749
+ if area_ratio > POSE_FP_MIN_AREA and pb.conf < POSE_FP_MAX_CONF:
1750
+ n_suppressed += 1
1751
+ continue
1752
+ result.append(pb)
1753
+ else:
1754
+ # Not matched to any pose detection
1755
+ if has_face_inside(pb):
1756
+ # Face detector confirms a person
1757
+ result.append(pb)
1758
+ n_face_saved += 1
1759
+ continue
1760
+
1761
+ bw = pb.x2 - pb.x1
1762
+ bh = pb.y2 - pb.y1
1763
+ area_ratio = (bw * bh) / img_area
1764
+
1765
+ if area_ratio > POSE_FP_MIN_AREA and pb.conf < POSE_FP_MAX_CONF:
1766
+ # Large unmatched low-conf box β€” likely FP
1767
+ n_suppressed += 1
1768
+ continue
1769
+ else:
1770
+ # Small box or high conf β€” keep
1771
+ result.append(pb)
1772
+
1773
+ if n_refined or n_suppressed or n_face_saved:
1774
+ logger.info(f"[pose] Refined {n_refined}, suppressed {n_suppressed} FP, "
1775
+ f"face-saved {n_face_saved}, "
1776
+ f"kept {len(result)}/{len(person_boxes)}")
1777
+ return result
1778
+
1779
+ # ── Person inference with SAHI tiling ────────────────────────────────
1780
+
1781
+ def _infer_person(self, image_bgr):
1782
+ """Person detection with SAHI-inspired tiled inference + dynamic NMS.
1783
+
1784
+ Pipeline (2-pass, optimized for RTF):
1785
+ 1. Full-image pass at native 960px
1786
+ 2. Flip TTA pass
1787
+ 3. Dynamic NMS merge (adapts IoU threshold to scene density)
1788
+ 4. Sanity filters + PER_MAX_DET cap
1789
+ 5. Pose FP filter + box refinement (if time allows)
1790
+ """
1791
+ oh, ow = image_bgr.shape[:2]
1792
+ t_start = time.monotonic()
1793
+
1794
+ # Frame quality gating β€” detect severely blurry frames
1795
+ blur_score = self._frame_blur_score(image_bgr)
1796
+ is_blurry = blur_score < PER_BLUR_THRESHOLD
1797
+
1798
+ # Collect all boxes in original pixel coords
1799
+ all_boxes = [] # list of [N, 4] arrays
1800
+ all_confs = [] # list of [N] arrays
1801
+
1802
+ # Pass 1: full image at native 960px
1803
+ boxes_full, confs_full = self._per_run_pass(image_bgr, PER_CONF_LOW)
1804
+ if len(boxes_full) > 0:
1805
+ all_boxes.append(boxes_full)
1806
+ all_confs.append(confs_full)
1807
+
1808
+ elapsed_pass1 = time.monotonic() - t_start
1809
+
1810
+ # Pass 2: flip TTA (always run β€” only 2 passes total for RTF safety)
1811
+ flipped = cv2.flip(image_bgr, 1)
1812
+ boxes_flip, confs_flip = self._per_run_pass(flipped, PER_CONF_LOW)
1813
+ if len(boxes_flip) > 0:
1814
+ boxes_flip[:, 0], boxes_flip[:, 2] = (
1815
+ ow - boxes_flip[:, 2], ow - boxes_flip[:, 0])
1816
+ all_boxes.append(boxes_flip)
1817
+ all_confs.append(confs_flip)
1818
+
1819
+ # Pass 3: CLAHE enhanced pass (low-contrast frames only, time-gated)
1820
+ if time.monotonic() - t_start < PER_RTF_BUDGET * 0.5:
1821
+ enhanced = self._per_enhance(image_bgr)
1822
+ if enhanced is not image_bgr: # CLAHE was applied (low contrast)
1823
+ boxes_enh, confs_enh = self._per_run_pass(enhanced, PER_CONF_LOW)
1824
+ if len(boxes_enh) > 0:
1825
+ all_boxes.append(boxes_enh)
1826
+ all_confs.append(confs_enh)
1827
+
1828
+ if not all_boxes:
1829
+ return []
1830
+
1831
+ # Dynamic NMS: adapt IoU threshold to scene density
1832
+ merged_b = np.concatenate(all_boxes)
1833
+ merged_s = np.concatenate(all_confs)
1834
+ n_raw = len(merged_s)
1835
+ nms_iou = 0.60 if n_raw > 30 else (0.40 if n_raw < 10 else PER_NMS_IOU)
1836
+ merged_b, merged_s = self._nms_max_conf(merged_b, merged_s, nms_iou)
1837
+
1838
+ # Hard cap on max detections (FP protection)
1839
+ if len(merged_s) > PER_MAX_DET:
1840
+ top_idx = np.argsort(merged_s)[-PER_MAX_DET:]
1841
+ merged_b = merged_b[top_idx]
1842
+ merged_s = merged_s[top_idx]
1843
+
1844
+ if len(merged_b) == 0:
1845
+ return []
1846
+
1847
+ # Blur confidence penalty β€” reduce FP on severely blurry frames
1848
+ if is_blurry:
1849
+ merged_s = merged_s * PER_BLUR_CONF_PENALTY
1850
+
1851
+ # Perspective scaling penalty β€” reduce conf for size-anomalous detections
1852
+ merged_s = self._perspective_penalty(merged_b, merged_s, oh)
1853
+
1854
+ # Sanity filters
1855
+ img_area = float(oh * ow)
1856
+ out = []
1857
+ for i in range(len(merged_b)):
1858
+ bw = merged_b[i, 2] - merged_b[i, 0]
1859
+ bh = merged_b[i, 3] - merged_b[i, 1]
1860
+ if bw < PER_MIN_WH or bh < PER_MIN_WH:
1861
+ continue
1862
+ area = bw * bh
1863
+ if area < PER_MIN_AREA:
1864
+ continue
1865
+ if max(bw, bh) / max(min(bw, bh), 1e-6) > PER_MAX_ASPECT:
1866
+ continue
1867
+ if area / img_area > PER_MAX_AREA_RATIO:
1868
+ continue
1869
+ b = merged_b[i]
1870
+ out.append(BoundingBox(
1871
+ x1=max(0, min(ow, int(b[0]))),
1872
+ y1=max(0, min(oh, int(b[1]))),
1873
+ x2=max(0, min(ow, int(b[2]))),
1874
+ y2=max(0, min(oh, int(b[3]))),
1875
+ cls_id=0,
1876
+ conf=max(0.0, min(1.0, float(merged_s[i]))),
1877
+ ))
1878
+
1879
+ # Pose FP filter + box refinement (only if time budget allows)
1880
+ if time.monotonic() - t_start < PER_RTF_BUDGET * 0.85:
1881
+ out = self._pose_filter_refine(out, image_bgr)
1882
+
1883
+ return out
1884
+
1885
+ # ── Element detection (stack frame inspection) ──────────────────────────
1886
+ _CHALLENGE_TYPE_MAP = {2: 'person', 12: 'vehicle'}
1887
+
1888
+ def _detect_element_hint(self) -> str:
1889
+ """Detect whether this request is for person or vehicle.
1890
+
1891
+ Reads challenge_type_id from the chute template predict() metadata
1892
+ via stack frame inspection. Returns 'person', 'vehicle', or 'both'.
1893
+ """
1894
+ frame = None
1895
+ try:
1896
+ frame = inspect.currentframe()
1897
+ for _ in range(10):
1898
+ frame = frame.f_back
1899
+ if frame is None:
1900
+ break
1901
+ meta = frame.f_locals.get('metadata')
1902
+ if isinstance(meta, dict) and 'challenge_type_id' in meta:
1903
+ ct_id = meta['challenge_type_id']
1904
+ return self._CHALLENGE_TYPE_MAP.get(ct_id, 'both')
1905
+ except Exception:
1906
+ pass
1907
+ finally:
1908
+ del frame
1909
+ return 'both'
1910
+
1911
+ # ── Unified inference ───────────────────────────────────────────────────
1912
+
1913
+ def _infer_single(self, image_bgr: ndarray, element_hint: str = 'both') -> list[BoundingBox]:
1914
+ self._cached_pose_data = None # reset before each frame
1915
+
1916
+ if element_hint == 'person':
1917
+ return self._infer_person(image_bgr)
1918
+
1919
+ if element_hint == 'vehicle':
1920
+ # Skip _vehicle_parts_confirm β€” it needs person_boxes for driver/rider
1921
+ # confirmation which aren't available in vehicle-only mode. Without person
1922
+ # detections, large vehicles with conf < 0.55 get falsely suppressed.
1923
+ return self._infer_vehicle(image_bgr)
1924
+
1925
+ # Fallback: run both (original behavior)
1926
+ if ENABLE_PARALLEL:
1927
+ veh_future = self._executor.submit(self._infer_vehicle, image_bgr)
1928
+ per_future = self._executor.submit(self._infer_person, image_bgr)
1929
+ vehicle_boxes = veh_future.result()
1930
+ person_boxes = per_future.result()
1931
+ else:
1932
+ vehicle_boxes = self._infer_vehicle(image_bgr)
1933
+ person_boxes = self._infer_person(image_bgr)
1934
+
1935
+ # Vehicle parts confirmation: cross-reference with person detections
1936
+ vehicle_boxes = self._vehicle_parts_confirm(
1937
+ vehicle_boxes, person_boxes, image_bgr)
1938
+
1939
+ return vehicle_boxes + person_boxes
1940
+
1941
+ # -- Replay buffer -------------------------------------------------------
1942
+ REPLAY_DIR = Path("/home/miner/replay_buffer")
1943
+ REPLAY_MAX = 100
1944
+
1945
+ def _replay_save(self, batch_images, results):
1946
+ try:
1947
+ ts = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S_%f")
1948
+ query_dir = self.REPLAY_DIR / ts
1949
+ query_dir.mkdir(parents=True, exist_ok=True)
1950
+
1951
+ for i, img in enumerate(batch_images):
1952
+ cv2.imwrite(str(query_dir / f"img_{i:03d}.jpg"), img,
1953
+ [cv2.IMWRITE_JPEG_QUALITY, 95])
1954
+
1955
+ preds = []
1956
+ for r in results:
1957
+ preds.append({
1958
+ "frame_id": r.frame_id,
1959
+ "boxes": [b.model_dump() for b in r.boxes],
1960
+ })
1961
+ meta = {
1962
+ "timestamp": ts,
1963
+ "num_images": len(batch_images),
1964
+ "image_shapes": [list(img.shape) for img in batch_images],
1965
+ "predictions": preds,
1966
+ }
1967
+ (query_dir / "meta.json").write_text(json.dumps(meta, indent=2))
1968
+ self._replay_prune()
1969
+ except Exception:
1970
+ pass
1971
+
1972
+ def _replay_prune(self):
1973
+ try:
1974
+ dirs = sorted(
1975
+ [d for d in self.REPLAY_DIR.iterdir() if d.is_dir()],
1976
+ key=lambda d: d.name,
1977
+ )
1978
+ if len(dirs) > self.REPLAY_MAX:
1979
+ import shutil
1980
+ for old in dirs[: len(dirs) - self.REPLAY_MAX]:
1981
+ shutil.rmtree(old, ignore_errors=True)
1982
+ except Exception:
1983
+ pass
1984
+
1985
+ def predict_batch(
1986
+ self,
1987
+ batch_images: list[ndarray],
1988
+ offset: int,
1989
+ n_keypoints: int,
1990
+ ) -> list[TVFrameResult]:
1991
+ t_start = time.perf_counter()
1992
+
1993
+ # Detect element type from caller metadata
1994
+ element_hint = self._detect_element_hint()
1995
+ t_setup = time.perf_counter()
1996
+ dt_setup = (t_setup - t_start) * 1000
1997
+
1998
+ _lat_logger.info(
1999
+ "REQUEST batch=%d hint=%s setup=%.1fms",
2000
+ len(batch_images), element_hint, dt_setup,
2001
+ )
2002
+
2003
+ results: list[TVFrameResult] = []
2004
+ for idx, image in enumerate(batch_images):
2005
+ t_img = time.perf_counter()
2006
+ boxes = self._infer_single(image, element_hint=element_hint)
2007
+ t_post = time.perf_counter()
2008
+ dt_infer = (t_post - t_img) * 1000
2009
+
2010
+ keypoints = [(0, 0) for _ in range(max(0, int(n_keypoints)))]
2011
+ results.append(TVFrameResult(
2012
+ frame_id=offset + idx, boxes=boxes, keypoints=keypoints,
2013
+ ))
2014
+ dt_post = (time.perf_counter() - t_post) * 1000
2015
+
2016
+ if idx < 3 or idx == len(batch_images) - 1:
2017
+ _lat_logger.info(
2018
+ " IMG %d/%d boxes=%d infer=%.1fms post=%.1fms shape=%s",
2019
+ idx, len(batch_images), len(boxes), dt_infer, dt_post,
2020
+ image.shape,
2021
+ )
2022
+
2023
+ t_done = time.perf_counter()
2024
+ dt_total = (t_done - t_start) * 1000
2025
+ total_boxes = sum(len(r.boxes) for r in results)
2026
+
2027
+ _lat_logger.info(
2028
+ "DONE batch=%d boxes=%d total=%.1fms setup=%.1fms hint=%s",
2029
+ len(batch_images), total_boxes, dt_total, dt_setup, element_hint,
2030
+ )
2031
+ logger.info(f"[miner] predict_batch: {len(batch_images)} images, "
2032
+ f"{total_boxes} total boxes, {dt_total:.0f}ms (hint={element_hint})")
2033
+
2034
+ threading.Thread(
2035
+ target=self._replay_save,
2036
+ args=(batch_images, results),
2037
+ daemon=True,
2038
+ ).start()
2039
+
2040
+ return results
2041
+ # Miner v3.19 β€” 1-pass vehicle + CLAHE pass + parts_confirm fix β€” element detection + per-step timing β€” background TRT engine build + CUDA-first fallback 20260402