""" Thin wrapper around Ultralytics YOLO11. Why YOLO here at all, when the VLM can already "see" the image? -> VLMs are known to hallucinate counts ("about 3 people") because they're generating text, not measuring pixels. YOLO gives us *pixel-grounded*, confidence-scored counts and boxes. We feed these counts into the VLM prompt as ground truth context, and use the boxes for drawing on the annotated image. This is a "detector-grounded VLM" pattern — cheap to build, and meaningfully reduces hallucination vs a VLM-only pipeline. """ from ultralytics import YOLO from .config import YOLO_MODEL_ID, RELEVANT_CLASSES, YOLO_CONF_THRESHOLD class DisasterObjectDetector: def __init__(self, model_id: str = YOLO_MODEL_ID): self.model = YOLO(model_id) def detect(self, image_path: str): """Run detection, return list of dicts: {class, conf, box=[x1,y1,x2,y2]}""" results = self.model.predict( source=image_path, conf=YOLO_CONF_THRESHOLD, verbose=False, )[0] detections = [] names = results.names for box in results.boxes: cls_id = int(box.cls.item()) cls_name = names[cls_id] if cls_name not in RELEVANT_CLASSES: continue conf = float(box.conf.item()) x1, y1, x2, y2 = [float(v) for v in box.xyxy[0].tolist()] detections.append({ "class": cls_name, "conf": conf, "box": [x1, y1, x2, y2], }) return detections @staticmethod def summarize(detections): """Turn a list of detections into {'people': 2, 'cars': 1, ...} counts.""" counts = {} for d in detections: label = RELEVANT_CLASSES.get(d["class"], d["class"]) counts[label] = counts.get(label, 0) + 1 return counts @staticmethod def to_context_string(counts: dict) -> str: """Human-readable string injected into the VLM prompt as grounding context.""" if not counts: return "No standard objects (people/vehicles) were confidently detected by the object detector." parts = [f"{v} {k}" for k, v in counts.items()] return "Object detector confidently found: " + ", ".join(parts) + \ ". Treat these counts as reliable ground truth; do not contradict them."