aigis-captioning / detector.py
omarkashif's picture
Initial AiGiS safety captioning space
5a1cbfa
Raw History Blame Contribute Delete
5.68 kB
"""
Wrap a trained YOLO model into structured, VLM-ready detections.
This is the DETERMINISTIC half of the pipeline. It:
1. runs YOLO on an image
2. returns clean structured detections (class, confidence, box)
3. does simple, auditable PPE-compliance reasoning:
"a head is detected but no helmet overlaps it" -> possible missing helmet
The VLM never decides compliance on its own — it is handed these facts. This keeps
the safety-critical judgement in code that can be logged and reviewed, in line with
the AiGiS design principle.
"""
from __future__ import annotations
from dataclasses import dataclass, asdict, field
from pathlib import Path
import yaml
from ultralytics import YOLO
@dataclass
class Detection:
cls: str
confidence: float
box_xywhn: list[float] # normalized [cx, cy, w, h] in [0,1]
def to_dict(self) -> dict:
return asdict(self)
@dataclass
class ComplianceFinding:
body_part: str
expected_ppe: str
status: str # "present" | "missing" | "uncertain"
detail: str
def to_dict(self) -> dict:
return asdict(self)
@dataclass
class DetectionResult:
image: str
detections: list[Detection] = field(default_factory=list)
compliance: list[ComplianceFinding] = field(default_factory=list)
def to_dict(self) -> dict:
return {
"image": self.image,
"detections": [d.to_dict() for d in self.detections],
"compliance": [c.to_dict() for c in self.compliance],
}
def _xywhn_to_corners(b: list[float]) -> tuple[float, float, float, float]:
cx, cy, w, h = b
return cx - w / 2, cy - h / 2, cx + w / 2, cy + h / 2
def _iou(a: list[float], b: list[float]) -> float:
ax1, ay1, ax2, ay2 = _xywhn_to_corners(a)
bx1, by1, bx2, by2 = _xywhn_to_corners(b)
ix1, iy1 = max(ax1, bx1), max(ay1, by1)
ix2, iy2 = min(ax2, bx2), min(ay2, by2)
iw, ih = max(0.0, ix2 - ix1), max(0.0, iy2 - iy1)
inter = iw * ih
if inter == 0:
return 0.0
area_a = (ax2 - ax1) * (ay2 - ay1)
area_b = (bx2 - bx1) * (by2 - by1)
return inter / (area_a + area_b - inter)
class SafetyDetector:
def __init__(self, weights: str, data_yaml: str, conf: float = 0.25):
self.model = YOLO(weights)
self.conf = conf
with open(data_yaml) as f:
cfg = yaml.safe_load(f)
# body_part -> expected PPE class name
self.ppe_pairs: dict[str, str] = cfg.get("ppe_pairs", {})
def detect(self, image_path: str) -> DetectionResult:
_, det_result = self._run(image_path)
return det_result
def detect_and_annotate(self, image, name: str | None = None):
"""
Same as detect(), but also returns a BGR numpy array with the detection
boxes drawn on it (via Ultralytics' own .plot()). Runs YOLO only once,
so callers can get both the JSON and the annotated image from one call.
`image` can be a file path OR an in-memory BGR array (e.g. from
cv2.imdecode on an uploaded file) — Ultralytics accepts both.
"""
result, det_result = self._run(image, name=name)
annotated = result.plot()
return det_result, annotated
def _run(self, image, name: str | None = None):
result = self.model(image, conf=self.conf, verbose=False)[0]
names = result.names
dets: list[Detection] = []
for box in result.boxes:
cls_name = names[int(box.cls)]
dets.append(
Detection(
cls=cls_name,
confidence=round(float(box.conf), 3),
box_xywhn=[round(float(x), 4) for x in box.xywhn[0].tolist()],
)
)
compliance = self._assess_compliance(dets)
if name is None:
name = str(Path(image).name) if isinstance(image, (str, Path)) else "image"
det_result = DetectionResult(image=name, detections=dets, compliance=compliance)
return result, det_result
def _assess_compliance(self, dets: list[Detection]) -> list[ComplianceFinding]:
"""
For each body part that should be protected, check whether the expected
PPE was detected overlapping it. Purely rule-based and explainable.
"""
findings: list[ComplianceFinding] = []
by_cls: dict[str, list[Detection]] = {}
for d in dets:
by_cls.setdefault(d.cls, []).append(d)
for body_part, expected in self.ppe_pairs.items():
parts = by_cls.get(body_part, [])
if not parts:
continue # body part not visible -> nothing to assess
ppe_items = by_cls.get(expected, [])
for part in parts:
overlap = max(
(_iou(part.box_xywhn, p.box_xywhn) for p in ppe_items),
default=0.0,
)
if overlap >= 0.10:
status, detail = "present", (
f"{expected} overlaps detected {body_part} (IoU {overlap:.2f})"
)
elif ppe_items:
status, detail = "uncertain", (
f"{expected} present in image but not clearly over this "
f"{body_part} (max IoU {overlap:.2f})"
)
else:
status, detail = "missing", (
f"{body_part} detected but no {expected} found in image"
)
findings.append(
ComplianceFinding(body_part, expected, status, detail)
)
return findings