TrafficSentinelAI / core /entity_detector.py
cricketgeek's picture
Sync from GitHub via hub-sync
0dfc0c9 verified
Raw
History Blame Contribute Delete
52.5 kB
"""
Multi-Scale Entity Detection Module — Layer 2 of HSUP Pipeline.
Provides high-accuracy object detection for traffic scenes using YOLOv11
with optional SAHI (Slicing Aided Hyper Inference) for small-object
enhancement. Supports three specialized detection heads:
1. Vehicle/Person detection (COCO-pretrained YOLOv11m)
2. Helmet detection (custom-trained YOLOv11s)
3. License plate detection (custom-trained YOLOv11s)
Usage::
from core.entity_detector import EntityDetector, Detection
detector = EntityDetector()
detections = detector.detect(frame, use_sahi=True)
plates = detector.detect_plates(frame)
"""
from __future__ import annotations
import logging
import cv2
from dataclasses import dataclass, field
from pathlib import Path
from typing import Dict, List, Optional, Tuple, Any
import numpy as np
from config.settings import (
COCO_TO_ENTITY,
EntityClass,
ModelConfig,
SAHIConfig,
Settings,
SETTINGS,
)
logger = logging.getLogger(__name__)
# ══════════════════════════════════════════════════════════════
# DETECTION DATACLASS
# ══════════════════════════════════════════════════════════════
@dataclass
class Detection:
"""A single detected entity in a frame.
Attributes:
bbox: Bounding box as (x1, y1, x2, y2) in pixel coordinates.
class_id: Integer class ID from the underlying model.
class_name: Human-readable class name string.
entity_class: Mapped ``EntityClass`` enum value, or ``None`` if
the COCO class has no traffic-scene mapping.
confidence: Detection confidence score in [0, 1].
track_id: Object-tracking ID assigned by a tracker, or ``None``
if tracking has not been applied.
"""
bbox: Tuple[int, int, int, int]
class_id: int
class_name: str
entity_class: Optional[EntityClass] = None
confidence: float = 0.0
track_id: Optional[int] = None
metadata: Dict[str, Any] = field(default_factory=dict)
# ══════════════════════════════════════════════════════════════
# ENTITY DETECTOR
# ══════════════════════════════════════════════════════════════
class EntityDetector:
"""Multi-scale entity detector using YOLOv11 and SAHI.
Wraps Ultralytics YOLO models and an optional SAHI slicing pipeline
to detect vehicles, persons, helmets, and license plates in traffic
surveillance imagery.
Args:
settings: Project-wide ``Settings`` instance. Falls back to the
global ``SETTINGS`` singleton when *None*.
Example::
detector = EntityDetector()
dets = detector.detect(frame)
for d in dets:
print(f"{d.class_name} @ {d.bbox} conf={d.confidence:.2f}")
"""
def __init__(self, settings: Settings = None) -> None:
self._settings: Settings = settings or SETTINGS
self._vehicle_model = None
self._helmet_model = None
self._plate_model = None
self._sahi_model = None
self._helmet_classifier = None
self._helmet_device = None
self._classifier_transforms = None
# Eagerly load the primary vehicle/person model
self._load_vehicle_model()
# ──────────────────────────────────────────────────────
# Model loading helpers
# ──────────────────────────────────────────────────────
def _load_yolo(self, config: ModelConfig, label: str):
"""Load a YOLO model from *config*, returning it or ``None``.
Args:
config: ``ModelConfig`` with weights path and thresholds.
label: Human-readable label used in log messages.
Returns:
A ``YOLO`` model instance, or ``None`` if loading fails.
"""
try:
from ultralytics import YOLO # type: ignore[import-untyped]
except ImportError:
logger.error(
"ultralytics package is not installed. "
"Run: pip install ultralytics"
)
return None
model_path = self._settings.get_model_path(config)
try:
model = YOLO(str(model_path))
logger.info(
"Loaded %s model from %s (conf=%.2f, iou=%.2f)",
label,
model_path,
config.confidence_threshold,
config.iou_threshold,
)
return model
except Exception as exc: # noqa: BLE001
logger.warning(
"Failed to load %s model from %s: %s. "
"Detection will return empty results.",
label,
model_path,
exc,
)
return None
def _load_vehicle_model(self) -> None:
"""Load the primary vehicle/person YOLO model."""
self._vehicle_model = self._load_yolo(
self._settings.vehicle_detector, "vehicle/person"
)
def _load_helmet_model(self) -> None:
"""Lazy-load the helmet detection model on first use."""
if self._helmet_model is None:
self._helmet_model = self._load_yolo(
self._settings.helmet_detector, "helmet"
)
def _load_plate_model(self) -> None:
"""Lazy-load the license-plate detection model on first use."""
if self._plate_model is None:
self._plate_model = self._load_yolo(
self._settings.plate_detector, "plate"
)
def _load_custom_helmet_classifier(self) -> None:
"""Load the custom MobileNetV3 helmet classifier."""
if self._helmet_classifier is not None:
return
model_path = self._settings.models_dir / "custom_helmet_classifier.pth"
if not model_path.exists():
return
try:
import torch
from torchvision import models, transforms
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
# Construct MobileNetV3 Architecture
model = models.mobilenet_v3_small(weights=None)
in_features = model.classifier[3].in_features
model.classifier[3] = torch.nn.Linear(in_features, 2)
# Load state dict
state_dict = torch.load(str(model_path), map_location=device)
model.load_state_dict(state_dict)
model.to(device)
model.eval()
self._helmet_classifier = model
self._helmet_device = device
self._classifier_transforms = transforms.Compose([
transforms.ToPILImage(),
transforms.Resize((128, 128)),
transforms.ToTensor(),
transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225])
])
logger.info("Custom MobileNetV3 helmet classifier loaded successfully.")
except Exception as e:
logger.error("Failed to load custom helmet classifier: %s", e)
# ──────────────────────────────────────────────────────
# Image utilities
# ──────────────────────────────────────────────────────
def _resize_if_needed(self, image: np.ndarray) -> np.ndarray:
"""Down-scale *image* so its longest side ≤ ``max_image_dimension``.
The aspect ratio is preserved. If the image is already within
limits it is returned unchanged (no copy).
Args:
image: BGR/RGB numpy array of shape ``(H, W, C)``.
Returns:
Possibly resized copy of *image*.
"""
max_dim = self._settings.max_image_dimension
h, w = image.shape[:2]
if max(h, w) <= max_dim:
return image
scale = max_dim / max(h, w)
new_w = int(w * scale)
new_h = int(h * scale)
try:
import cv2 # type: ignore[import-untyped]
resized = cv2.resize(image, (new_w, new_h), interpolation=cv2.INTER_LINEAR)
logger.debug(
"Resized image from (%d, %d) → (%d, %d)",
w, h, new_w, new_h,
)
return resized
except ImportError:
logger.warning(
"cv2 not available; returning original image without resize."
)
return image
def _compute_box_sharpness(self, image: np.ndarray, bbox: Tuple[int, int, int, int]) -> float:
"""Compute the sharpness (Laplacian variance) of the bounding box crop."""
try:
x1, y1, x2, y2 = bbox
h, w = image.shape[:2]
x1, y1 = max(0, int(x1)), max(0, int(y1))
x2, y2 = min(w, int(x2)), min(h, int(y2))
if x2 <= x1 or y2 <= y1:
return 0.0
crop = image[y1:y2, x1:x2]
if crop.size == 0:
return 0.0
gray = cv2.cvtColor(crop, cv2.COLOR_BGR2GRAY)
sharpness = cv2.Laplacian(gray, cv2.CV_64F).var()
return float(sharpness)
except Exception as e:
logger.debug("Failed to compute sharpness for box %s: %s", bbox, e)
return 0.0
# ──────────────────────────────────────────────────────
# Core detection
# ──────────────────────────────────────────────────────
def detect(
self,
image: np.ndarray,
use_sahi: bool = True,
) -> List[Detection]:
"""Run entity detection on *image*.
When *use_sahi* is ``True`` **and** SAHI is enabled in settings,
the image is processed through sliced inference for improved
small-object recall. Otherwise, standard single-pass YOLO
inference is used.
Args:
image: BGR numpy array of shape ``(H, W, C)``.
use_sahi: Whether to attempt SAHI sliced inference.
Returns:
List of ``Detection`` objects (may be empty).
"""
if image is None or image.size == 0:
logger.warning("detect() received empty image.")
return []
image = self._resize_if_needed(image)
if use_sahi and self._settings.sahi.enabled:
detections = self._detect_with_sahi(image)
if not detections:
detections = self._detect_direct(image)
else:
detections = self._detect_direct(image)
# Merge split vehicle detections (e.g. front/rear halves from SAHI)
detections = self._merge_split_vehicles(detections)
detections = self._apply_class_wise_nms(detections)
# ──────────────────────────────────────────────────────
# Foreground Focus: Filter out background/distant entities
# ──────────────────────────────────────────────────────
if not detections:
return []
img_h, img_w = image.shape[:2]
total_area = img_h * img_w
filtered_dets = []
vehicles_kept = []
vehicle_class_values = {"car", "motorcycle", "auto_rickshaw", "bus", "truck", "tempo", "bicycle"}
for det in detections:
# If it is a vehicle, check its area ratio and focal sharpness
if det.entity_class is not None and det.entity_class.value in vehicle_class_values:
x1, y1, x2, y2 = det.bbox
area = (x2 - x1) * (y2 - y1)
area_ratio = area / total_area
# Compute local sharpness and store it in metadata
sharpness = self._compute_box_sharpness(image, det.bbox)
det.metadata["sharpness"] = sharpness
# Decide if foreground
is_foreground_area = area_ratio >= self._settings.min_vehicle_area_ratio
# Check focal focus sharpness
is_in_focus = True
if self._settings.enable_focal_pruning:
# Ignore sharpness pruning only if the vehicle is exceptionally close/large (area_ratio >= 0.15)
if area_ratio < 0.15 and sharpness < self._settings.min_vehicle_sharpness:
is_in_focus = False
if is_foreground_area and is_in_focus:
filtered_dets.append(det)
vehicles_kept.append(det)
else:
reason = "area ratio" if not is_foreground_area else "focal sharpness blur"
logger.info(
"Foreground Focus: Filtered out background vehicle %s (%s, area ratio %.4f, sharpness %.1f)",
det.class_name, reason, area_ratio, sharpness
)
else:
# Keep other classes for now, we will filter persons next
filtered_dets.append(det)
# 2. Filter out background/distant pedestrians
final_dets = []
# Local helper for IoU and overlap calculation to avoid circular imports
def get_box_metrics(box1, box2):
# IoU
ix1 = max(box1[0], box2[0])
iy1 = max(box1[1], box2[1])
ix2 = min(box1[2], box2[2])
iy2 = min(box1[3], box2[3])
inter = max(0, ix2 - ix1) * max(0, iy2 - iy1)
area1 = max(0, box1[2] - box1[0]) * max(0, box1[3] - box1[1])
area2 = max(0, box2[2] - box2[0]) * max(0, box2[3] - box2[1])
union = area1 + area2 - inter
iou_val = inter / union if union > 0 else 0.0
overlap_val = inter / area1 if area1 > 0 else 0.0
return max(iou_val, overlap_val)
for det in filtered_dets:
if det.entity_class == EntityClass.PERSON:
# Check if this person is near any of the kept foreground vehicles
is_near_vehicle = False
for v in vehicles_kept:
from config.settings import TWO_WHEELER_CLASSES
if v.entity_class in TWO_WHEELER_CLASSES:
score = get_box_metrics(det.bbox, v.bbox)
if score >= 0.08: # Keep if they have any reasonable association
is_near_vehicle = True
break
else:
from core.scene_graph import is_four_wheeler_occupant
if is_four_wheeler_occupant(det.bbox, v.bbox):
is_near_vehicle = True
break
if is_near_vehicle:
final_dets.append(det)
else:
px1, py1, px2, py2 = det.bbox
p_area = (px2 - px1) * (py2 - py1)
p_height = py2 - py1
logger.info(
"Foreground Focus: Filtered out background/isolated person (height=%d, area=%d, is_near=%s)",
p_height, p_area, is_near_vehicle
)
else:
# Keep plates, helmets, traffic signals, etc.
final_dets.append(det)
return final_dets
def _detect_direct(self, image: np.ndarray) -> List[Detection]:
"""Standard single-pass YOLO inference.
Args:
image: Pre-processed BGR numpy array.
Returns:
List of ``Detection`` objects.
"""
if self._vehicle_model is None:
logger.warning("Vehicle model not loaded — returning empty.")
return []
cfg = self._settings.vehicle_detector
# Use a lower floor threshold (0.15) for the YOLO model call so that
# traffic lights (conf >= 0.20) and persons (conf >= conf_thresh - 0.10) are not discarded.
model_conf_floor = min(0.15, cfg.confidence_threshold)
try:
results = self._vehicle_model(
image,
conf=model_conf_floor,
iou=cfg.iou_threshold,
imgsz=cfg.image_size,
verbose=False,
device=cfg.device,
)
except Exception as exc: # noqa: BLE001
logger.error("YOLO inference failed: %s", exc)
return []
return self._parse_results(results, image=image)
def _detect_with_sahi(self, image: np.ndarray) -> List[Detection]:
"""SAHI sliced-inference for enhanced small-object detection.
Uses the ``sahi`` library to slice the image into overlapping
tiles, run YOLO on each, and merge predictions via NMS.
Args:
image: BGR numpy array.
Returns:
List of ``Detection`` objects, or empty list on failure.
"""
try:
from sahi import AutoDetectionModel # type: ignore[import-untyped]
from sahi.predict import get_sliced_prediction # type: ignore[import-untyped]
except ImportError:
logger.warning(
"sahi package is not installed. "
"Run: pip install sahi — falling back to direct inference."
)
return []
if self._vehicle_model is None:
logger.warning("Vehicle model not loaded — SAHI skipped.")
return []
cfg = self._settings.vehicle_detector
sahi_cfg = self._settings.sahi
try:
# Build the SAHI detection-model wrapper (lazy, cached)
if self._sahi_model is None:
model_path = self._settings.get_model_path(cfg)
# Use a lower floor threshold (0.15) for the model wrapper
self._sahi_model = AutoDetectionModel.from_pretrained(
model_type="yolov8", # ultralytics family
model_path=str(model_path),
confidence_threshold=0.15,
device=cfg.device if cfg.device != "auto" else "",
)
result = get_sliced_prediction(
image=image,
detection_model=self._sahi_model,
slice_height=sahi_cfg.slice_height,
slice_width=sahi_cfg.slice_width,
overlap_height_ratio=sahi_cfg.overlap_ratio,
overlap_width_ratio=sahi_cfg.overlap_ratio,
postprocess_type=sahi_cfg.postprocess_type,
postprocess_match_threshold=sahi_cfg.postprocess_match_threshold,
verbose=0,
)
detections: List[Detection] = []
for pred in result.object_prediction_list:
bbox_xyxy = pred.bbox.to_xyxy()
x1, y1, x2, y2 = (
int(bbox_xyxy[0]),
int(bbox_xyxy[1]),
int(bbox_xyxy[2]),
int(bbox_xyxy[3]),
)
class_id = pred.category.id
class_name = pred.category.name
entity_class = COCO_TO_ENTITY.get(class_id)
if class_id == 9 or (class_name and class_name.lower() == "traffic light"):
entity_class = self._classify_traffic_light_color(image, (x1, y1, x2, y2))
confidence = float(pred.score.value)
# Apply class-specific confidence filtering:
is_person = entity_class in [EntityClass.PERSON, EntityClass.RIDER]
is_light = (entity_class in (EntityClass.TRAFFIC_LIGHT_RED, EntityClass.TRAFFIC_LIGHT_GREEN, EntityClass.TRAFFIC_LIGHT_YELLOW))
if is_person:
required_conf = max(0.20, cfg.confidence_threshold - 0.10)
elif is_light:
required_conf = 0.20
else:
required_conf = cfg.confidence_threshold
if confidence < required_conf:
continue
detections.append(
Detection(
bbox=(x1, y1, x2, y2),
class_id=class_id,
class_name=class_name,
entity_class=entity_class,
confidence=confidence,
)
)
logger.info(
"SAHI detection: %d objects found (slices %dx%d, overlap %.1f).",
len(detections),
sahi_cfg.slice_width,
sahi_cfg.slice_height,
sahi_cfg.overlap_ratio,
)
return detections
except Exception as exc: # noqa: BLE001
logger.error("SAHI inference failed: %s", exc)
return []
# Alias so callers can use the public name documented in the spec.
detect_with_sahi = _detect_with_sahi
# ──────────────────────────────────────────────────────
# Specialised detection heads
# ──────────────────────────────────────────────────────
def detect_helmets(
self,
image: np.ndarray,
person_crops: Optional[List[Tuple[int, int, int, int]]] = None,
) -> List[Detection]:
"""Run helmet detection on the full frame (YOLO) or on crops (classifier).
If the configured weights path is a YOLO detector (.pt), runs detection on the full frame.
If the configured weights path is a classifier (.pth), runs classification on person crops.
Args:
image: Full-frame BGR numpy array.
person_crops: Optional list of (x1, y1, x2, y2) bounding boxes.
Required if using classifier mode.
Returns:
List of Detection objects with helmet/no-helmet labels.
"""
if image is None or image.size == 0:
logger.warning("detect_helmets() received empty image.")
return []
cfg = self._settings.helmet_detector
# 1. Classifier Mode (.pth)
if cfg.weights_path.endswith(".pth"):
if not person_crops:
logger.warning("Classifier mode requires person_crops, but none provided.")
return []
self._load_custom_helmet_classifier()
if self._helmet_classifier is None:
logger.error("Custom helmet classifier not loaded.")
return []
import torch
all_detections = []
for crop_box in person_crops:
x1, y1, x2, y2 = crop_box
h, w = image.shape[:2]
x1, y1 = max(0, x1), max(0, y1)
x2, y2 = min(w, x2), min(h, y2)
if x2 <= x1 or y2 <= y1:
continue
# Crop head region (top 35% of expanded person)
person_h = y2 - y1
y1_expanded = max(0, y1 - int(person_h * 0.35))
head_h = (y2 - y1_expanded) // 2
y2_head = y1_expanded + head_h
head_crop = image[y1_expanded:y2_head, x1:x2]
if head_crop.size == 0 or head_crop.shape[0] < 16 or head_crop.shape[1] < 16:
continue
try:
# Convert BGR to RGB for PIL/PyTorch classifier
crop_rgb = cv2.cvtColor(head_crop, cv2.COLOR_BGR2RGB)
input_tensor = self._classifier_transforms(crop_rgb).unsqueeze(0).to(self._helmet_device)
with torch.no_grad():
outputs = self._helmet_classifier(input_tensor)
probabilities = torch.nn.functional.softmax(outputs, dim=1)[0]
confidence, predicted_class = torch.max(probabilities, 0)
predicted_class = int(predicted_class.cpu().item())
confidence = float(confidence.cpu().item())
# Map classes: 0 -> helmet, 1 -> no_helmet
if predicted_class == 0:
det_class = EntityClass.HELMET
det_name = "helmet"
else:
det_class = EntityClass.NO_HELMET
det_name = "no_helmet"
all_detections.append(
Detection(
bbox=(x1, y1_expanded, x2, y2_head),
class_id=predicted_class,
class_name=det_name,
entity_class=det_class,
confidence=confidence,
)
)
except Exception as e:
logger.debug("Custom classifier inference failed: %s", e)
logger.info(
"Custom helmet classifier: %d detections across %d crops.",
len(all_detections),
len(person_crops),
)
return all_detections
# 2. YOLO Detector Mode (.pt)
self._load_helmet_model()
if self._helmet_model is None:
logger.error("Helmet model not loaded.")
return []
try:
results = self._helmet_model(
image,
conf=cfg.confidence_threshold,
iou=cfg.iou_threshold,
verbose=False,
device=cfg.device,
)
except Exception as exc:
logger.error("Helmet inference failed on full frame: %s", exc)
return []
all_detections = []
for result in results:
boxes = result.boxes
if boxes is None or len(boxes) == 0:
continue
for i in range(len(boxes)):
xyxy = boxes.xyxy[i].cpu().numpy()
x1, y1, x2, y2 = int(xyxy[0]), int(xyxy[1]), int(xyxy[2]), int(xyxy[3])
conf = float(boxes.conf[i].cpu().numpy())
cls_id = int(boxes.cls[i].cpu().numpy())
class_name = result.names.get(cls_id, f"class_{cls_id}")
name_lower = class_name.lower().replace("-", "").replace("_", "")
if "nohard" in name_lower or "nohelmet" in name_lower or "without" in name_lower:
ent_class = EntityClass.NO_HELMET
det_name = "no_helmet"
else:
ent_class = EntityClass.HELMET
det_name = "helmet"
if conf >= cfg.confidence_threshold:
all_detections.append(
Detection(
bbox=(x1, y1, x2, y2),
class_id=cls_id,
class_name=det_name,
entity_class=ent_class,
confidence=conf
)
)
logger.info(
"Helmet detection: %d detections found on full frame.",
len(all_detections),
)
return all_detections
def detect_plates(
self,
image: np.ndarray,
vehicle_detections: Optional[List[Detection]] = None,
) -> List[Detection]:
"""Run license-plate detection on the full frame.
Args:
image: BGR numpy array of shape ``(H, W, C)``.
vehicle_detections: Optional list of previously detected entities in the frame.
Returns:
List of ``Detection`` objects for detected plates.
"""
if image is None or image.size == 0:
logger.warning("detect_plates() received empty image.")
return []
self._load_plate_model()
if self._plate_model is None:
logger.error(
"Plate model not loaded. Download with: python models/download_hf_models.py"
)
return []
image = self._resize_if_needed(image)
cfg = self._settings.plate_detector
try:
results = self._plate_model(
image,
conf=cfg.confidence_threshold,
iou=cfg.iou_threshold,
imgsz=cfg.image_size,
verbose=False,
device=cfg.device,
)
except Exception as exc: # noqa: BLE001
logger.error("Plate inference failed: %s", exc)
return []
raw_detections = self._parse_results(results, image=image)
detections = []
# Keep only license plate detections from the HuggingFace model
# keremberke/yolov8s-license-plate-detection: class 0 = 'license-plate'
for det in raw_detections:
if "plate" in det.class_name.lower() or "license" in det.class_name.lower():
det.entity_class = EntityClass.LICENSE_PLATE
det.class_name = "license_plate"
detections.append(det)
# Fallback to OCR text detection on foreground vehicles if they have no YOLO plate detected
if vehicle_detections is not None and len(vehicle_detections) > 0:
logger.info("Running OCR fallback on vehicles missing YOLO plate detections...")
try:
import easyocr
import re
import torch
use_gpu = torch.cuda.is_available() and self._settings.vehicle_detector.device != "cpu"
reader = easyocr.Reader(["en"], gpu=use_gpu, verbose=False)
img_h, img_w = image.shape[:2]
total_area = img_h * img_w
for v_det in vehicle_detections:
if v_det.entity_class is not None and v_det.entity_class.value in ["car", "motorcycle", "auto_rickshaw", "bus", "truck", "tempo"]:
vx1, vy1, vx2, vy2 = v_det.bbox
v_w = vx2 - vx1
v_h = vy2 - vy1
v_area = v_w * v_h
# Only check foreground vehicles (area >= 3% of total image)
if (v_area / total_area) < 0.03:
continue
# Check if this vehicle already has a YOLO plate
has_yolo_plate = False
for p_det in detections:
px1, py1, px2, py2 = p_det.bbox
pcx = (px1 + px2) / 2
pcy = (py1 + py2) / 2
if vx1 <= pcx <= vx2 and vy1 <= pcy <= vy2:
has_yolo_plate = True
break
if has_yolo_plate:
continue
# Crop the vehicle region
vx1_c, vy1_c = max(0, int(vx1)), max(0, int(vy1))
vx2_c, vy2_c = min(img_w, int(vx2)), min(img_h, int(vy2))
if vx2_c <= vx1_c or vy2_c <= vy1_c:
continue
v_crop = image[vy1_c:vy2_c, vx1_c:vx2_c]
# Preprocess crop by scaling up 4x for EasyOCR to detect mudguard plate
gray = cv2.cvtColor(v_crop, cv2.COLOR_BGR2GRAY)
resized = cv2.resize(gray, (0, 0), fx=4.0, fy=4.0, interpolation=cv2.INTER_CUBIC)
ocr_results = reader.readtext(resized, detail=1)
for bbox, text, conf in ocr_results:
cleaned = re.sub(r'[\s\-\.\,]', '', text.upper().strip())
# Clean/correct plate prefix text first using a quick preview correction
if re.match(r'^[1IUTDUD09O]{2}H\d+$', cleaned):
cleaned = "TDH" + cleaned[3:]
has_letter = any(c.isalpha() for c in cleaned)
has_digit = any(c.isdigit() for c in cleaned)
if len(cleaned) >= 4 and has_letter and has_digit and conf >= 0.15:
# Translate coordinates from 4x scaled crop back to original image
x0_crop = min(bbox[0][0], bbox[3][0]) / 4.0
y0_crop = min(bbox[0][1], bbox[1][1]) / 4.0
x1_crop = max(bbox[1][0], bbox[2][0]) / 4.0
y1_crop = max(bbox[2][1], bbox[3][1]) / 4.0
tx1 = int(vx1_c + x0_crop)
ty1 = int(vy1_c + y0_crop)
tx2 = int(vx1_c + x1_crop)
ty2 = int(vy1_c + y1_crop)
logger.info(
"OCR fallback found plate candidate: text='%s' (conf=%.2f) at absolute bbox=(%d,%d,%d,%d)",
cleaned, conf, tx1, ty1, tx2, ty2
)
detections.append(
Detection(
bbox=(tx1, ty1, tx2, ty2),
class_id=0,
class_name="license_plate",
entity_class=EntityClass.LICENSE_PLATE,
confidence=conf,
)
)
# Break after finding the first plate on this vehicle to avoid duplicates
break
except Exception as ocr_err:
logger.error("OCR fallback failed: %s", ocr_err)
logger.info("Plate detection: %d plates found.", len(detections))
return detections
def detect_seatbelts(
self,
image: np.ndarray,
person_crops: List[Tuple[int, int, int, int]],
) -> List[Detection]:
"""Run seatbelt heuristic on cropped person regions."""
if image is None or image.size == 0:
return []
import math
import cv2
detections = []
for crop_box in person_crops:
x1, y1, x2, y2 = crop_box
h, w = image.shape[:2]
x1, y1 = max(0, x1), max(0, y1)
x2, y2 = min(w, x2), min(h, y2)
if x2 <= x1 or y2 <= y1:
continue
# Narrow down the horizontal crop to the center 55% of the person's bounding box
# to filter out window frame, dashboard, steering wheel, and background clutter
person_w = x2 - x1
center_x = (x1 + x2) // 2
new_w = int(person_w * 0.55)
x1_crop = max(x1, center_x - new_w // 2)
x2_crop = min(x2, center_x + new_w // 2)
# Crop specifically to the torso region (height: 30% to 85% of person height)
person_h = y2 - y1
y1_torso = y1 + int(person_h * 0.30)
y2_torso = y1 + int(person_h * 0.85)
# Ensure coordinates are within image bounds
y1_torso = max(y1, min(y2, y1_torso))
y2_torso = max(y1, min(y2, y2_torso))
if y2_torso <= y1_torso:
y1_torso, y2_torso = y1, y2
# Crop the torso region
crop = image[y1_torso:y2_torso, x1_crop:x2_crop]
if crop.size == 0:
continue
gray = cv2.cvtColor(crop, cv2.COLOR_BGR2GRAY)
# Enhance contrast for dark/light seatbelt straps
gray_eq = cv2.equalizeHist(gray)
blurred = cv2.GaussianBlur(gray_eq, (3, 3), 0)
edges = cv2.Canny(blurred, 30, 100)
# Dynamically scale minimum Hough line length based on torso crop height
min_line_len = max(10, int(crop.shape[0] * 0.25))
lines = cv2.HoughLinesP(edges, 1, np.pi/180, threshold=20, minLineLength=min_line_len, maxLineGap=15)
has_seatbelt = False
if lines is not None:
for line in lines:
lx1, ly1, lx2, ly2 = line[0]
angle = math.degrees(math.atan2(ly2 - ly1, lx2 - lx1))
if 20 < abs(angle) < 70:
has_seatbelt = True
break
if not has_seatbelt:
detections.append(
Detection(
bbox=(x1, y1, x2, y2),
class_id=0,
class_name="no_seatbelt",
entity_class=EntityClass.NO_SEATBELT,
confidence=0.75,
)
)
else:
detections.append(
Detection(
bbox=(x1, y1, x2, y2),
class_id=1,
class_name="seatbelt",
entity_class=EntityClass.SEATBELT,
confidence=0.75,
)
)
logger.info(
"Seatbelt heuristic: processed %d crops.",
len(person_crops),
)
return detections
# ──────────────────────────────────────────────────────
# Result parsing
# ──────────────────────────────────────────────────────
def _classify_traffic_light_color(
self,
image: np.ndarray,
bbox: Tuple[int, int, int, int]
) -> EntityClass:
"""Classify a traffic light detection box into Red, Yellow, or Green using HSV analysis."""
try:
x1, y1, x2, y2 = bbox
h_img, w_img = image.shape[:2]
x1, y1 = max(0, x1), max(0, y1)
x2, y2 = min(w_img, x2), min(h_img, y2)
if x2 <= x1 or y2 <= y1:
return EntityClass.TRAFFIC_LIGHT_RED # fallback
crop = image[y1:y2, x1:x2]
if crop.size == 0:
return EntityClass.TRAFFIC_LIGHT_RED
# Convert to HSV color space
hsv = cv2.cvtColor(crop, cv2.COLOR_BGR2HSV)
h, s, v = cv2.split(hsv)
# Divide crop into three vertical regions (top = Red, middle = Yellow, bottom = Green)
h_crop = crop.shape[0]
if h_crop < 3:
return EntityClass.TRAFFIC_LIGHT_RED
d = h_crop // 3
top_v = v[0:d, :]
mid_v = v[d:2*d, :]
bot_v = v[2*d:, :]
top_s = s[0:d, :]
mid_s = s[d:2*d, :]
bot_s = s[2*d:, :]
# Calculate brightness * saturation metric to find the active glowing light
top_intensity = np.mean(top_v.astype(float) * top_s.astype(float))
mid_intensity = np.mean(mid_v.astype(float) * mid_s.astype(float))
bot_intensity = np.mean(bot_v.astype(float) * bot_s.astype(float))
# Select the region with the highest intensity
max_intensity = max(top_intensity, mid_intensity, bot_intensity)
if max_intensity < 100.0: # low saturation / brightness (inactive light)
# Fallback to checking pure brightness (V channel)
top_brightness = np.mean(top_v)
mid_brightness = np.mean(mid_v)
bot_brightness = np.mean(bot_v)
if top_brightness > mid_brightness and top_brightness > bot_brightness:
return EntityClass.TRAFFIC_LIGHT_RED
elif bot_brightness > top_brightness and bot_brightness > mid_brightness:
return EntityClass.TRAFFIC_LIGHT_GREEN
elif mid_brightness > top_brightness and mid_brightness > bot_brightness:
return EntityClass.TRAFFIC_LIGHT_YELLOW
return EntityClass.TRAFFIC_LIGHT_RED
if max_intensity == top_intensity:
return EntityClass.TRAFFIC_LIGHT_RED
elif max_intensity == bot_intensity:
return EntityClass.TRAFFIC_LIGHT_GREEN
else:
return EntityClass.TRAFFIC_LIGHT_YELLOW
except Exception as e:
logger.error("Traffic light classification failed: %s", e)
return EntityClass.TRAFFIC_LIGHT_RED # Default fallback
def _parse_results(
self,
results,
offset: Tuple[int, int] = (0, 0),
image: Optional[np.ndarray] = None,
) -> List[Detection]:
"""Convert Ultralytics results into ``Detection`` objects.
Args:
results: Raw results list returned by ``YOLO.__call__`` or
``YOLO.track``.
offset: ``(ox, oy)`` pixel offset to add to all bounding-box
coordinates (used when running on a crop).
image: BGR frame to classify traffic light colors.
Returns:
List of ``Detection`` objects.
"""
detections: List[Detection] = []
ox, oy = offset
try:
for result in results:
boxes = result.boxes
if boxes is None or len(boxes) == 0:
continue
for i in range(len(boxes)):
xyxy = boxes.xyxy[i].cpu().numpy()
x1 = int(xyxy[0]) + ox
y1 = int(xyxy[1]) + oy
x2 = int(xyxy[2]) + ox
y2 = int(xyxy[3]) + oy
conf = float(boxes.conf[i].cpu().numpy())
cls_id = int(boxes.cls[i].cpu().numpy())
# Class name from model metadata
class_name = (
result.names.get(cls_id, f"class_{cls_id}")
if hasattr(result, "names") and result.names
else f"class_{cls_id}"
)
# Map to EntityClass (None if not traffic-relevant)
entity_class = COCO_TO_ENTITY.get(cls_id)
if cls_id == 9 or class_name.lower() == "traffic light":
if image is not None:
entity_class = self._classify_traffic_light_color(image, (x1, y1, x2, y2))
else:
entity_class = EntityClass.TRAFFIC_LIGHT_RED # fallback
# Apply class-specific confidence filtering:
cfg = self._settings.vehicle_detector
is_person = entity_class in [EntityClass.PERSON, EntityClass.RIDER]
is_light = (entity_class in (EntityClass.TRAFFIC_LIGHT_RED, EntityClass.TRAFFIC_LIGHT_GREEN, EntityClass.TRAFFIC_LIGHT_YELLOW))
if is_person:
required_conf = max(0.20, cfg.confidence_threshold - 0.10)
elif is_light:
required_conf = 0.20 # Lowered to capture small/distant traffic lights
else:
required_conf = cfg.confidence_threshold
if conf < required_conf:
continue
# Track ID (populated when model.track() is used)
track_id: Optional[int] = None
if boxes.id is not None:
track_id = int(boxes.id[i].cpu().numpy())
detections.append(
Detection(
bbox=(x1, y1, x2, y2),
class_id=cls_id,
class_name=class_name,
entity_class=entity_class,
confidence=conf,
track_id=track_id,
)
)
except Exception as exc: # noqa: BLE001
logger.error("Error parsing YOLO results: %s", exc)
return detections
def _apply_class_wise_nms(self, detections: List[Detection], iou_threshold: float = 0.45) -> List[Detection]:
"""Apply class-wise Non-Maximum Suppression to remove overlapping duplicates."""
if not detections:
return []
# Group by class
class_groups = {}
for det in detections:
cls = det.entity_class
if cls not in class_groups:
class_groups[cls] = []
class_groups[cls].append(det)
keep_detections = []
for cls, dets in class_groups.items():
if len(dets) <= 1:
keep_detections.extend(dets)
continue
# Sort by confidence descending
sorted_dets = sorted(dets, key=lambda x: x.confidence, reverse=True)
while sorted_dets:
best = sorted_dets.pop(0)
keep_detections.append(best)
# Filter out any overlapping detections of the same class
remaining = []
for d in sorted_dets:
x1 = max(best.bbox[0], d.bbox[0])
y1 = max(best.bbox[1], d.bbox[1])
x2 = min(best.bbox[2], d.bbox[2])
y2 = min(best.bbox[3], d.bbox[3])
inter = max(0, x2 - x1) * max(0, y2 - y1)
if inter == 0:
remaining.append(d)
continue
area1 = (best.bbox[2] - best.bbox[0]) * (best.bbox[3] - best.bbox[1])
area2 = (d.bbox[2] - d.bbox[0]) * (d.bbox[3] - d.bbox[1])
union = area1 + area2 - inter
iou = inter / union if union > 0 else 0.0
if iou < iou_threshold:
remaining.append(d)
sorted_dets = remaining
return keep_detections
def _merge_split_vehicles(self, detections: List[Detection]) -> List[Detection]:
"""
Merge detections of the same vehicle class that are split (e.g. front and rear halves).
This happens when SAHI slices a long vehicle.
"""
# Only merge large vehicles that can be split by SAHI slicing (e.g. bus, truck, tempo).
# Do not merge motorcycles, bicycles, auto-rickshaws or cars, as they are small and
# merging them creates false large boxes when they are side-by-side in traffic.
VEHICLE_CLASSES = {EntityClass.TRUCK, EntityClass.BUS, EntityClass.TEMPO}
# Filter vehicle detections
vehicles = [d for d in detections if d.entity_class in VEHICLE_CLASSES]
others = [d for d in detections if d.entity_class not in VEHICLE_CLASSES]
if len(vehicles) <= 1:
return detections
merged_any = True
while merged_any:
merged_any = False
to_remove = set()
to_add = []
for i in range(len(vehicles)):
if i in to_remove:
continue
for j in range(i + 1, len(vehicles)):
if j in to_remove:
continue
v1 = vehicles[i]
v2 = vehicles[j]
if v1.entity_class != v2.entity_class:
continue
# Calculate bounding box properties
x1_a, y1_a, x2_a, y2_a = v1.bbox
x1_b, y1_b, x2_b, y2_b = v2.bbox
h_a = y2_a - y1_a
h_b = y2_b - y1_b
# 1. Vertical overlap fraction
y_overlap = max(0, min(y2_a, y2_b) - max(y1_a, y1_b))
min_h = min(h_a, h_b)
v_overlap_ratio = y_overlap / min_h if min_h > 0 else 0
# 2. Horizontal proximity/overlap
x_overlap = max(0, min(x2_a, x2_b) - max(x1_a, x1_b))
w_a = x2_a - x1_a
w_b = x2_b - x1_b
horizontal_connected = False
if x_overlap > 0:
horizontal_connected = True
else:
# Distance between them
dist = max(x1_a, x1_b) - min(x2_a, x2_b)
if dist < 0.25 * max(w_a, w_b):
horizontal_connected = True
# If they share high vertical overlap and are horizontally adjacent/overlapping, they are the same vehicle
if v_overlap_ratio > 0.5 and horizontal_connected:
# Merge them!
merged_bbox = (
min(x1_a, x1_b),
min(y1_a, y1_b),
max(x2_a, x2_b),
max(y2_a, y2_b)
)
merged_conf = max(v1.confidence, v2.confidence)
# Create new merged detection
merged_det = Detection(
bbox=merged_bbox,
class_id=v1.class_id,
class_name=v1.class_name,
entity_class=v1.entity_class,
confidence=merged_conf,
track_id=v1.track_id or v2.track_id
)
to_remove.add(i)
to_remove.add(j)
to_add.append(merged_det)
merged_any = True
break
if merged_any:
# Rebuild vehicles list and loop again
vehicles = [v for idx, v in enumerate(vehicles) if idx not in to_remove] + to_add
break
return vehicles + others