Spaces:
Sleeping
Sleeping
| """ | |
| Detection post-processing β decode YOLOv8 output tensor and apply NMS. | |
| YOLOv8 ONNX output shape: ``[1, 84, num_anchors]`` (transposed from v5/v7) | |
| * Row 0-3 : cx, cy, w, h (normalised to input_size) | |
| * Row 4-83: class scores (no objectness score in v8) | |
| """ | |
| from __future__ import annotations | |
| from dataclasses import dataclass, field | |
| from typing import Optional | |
| import cv2 | |
| import numpy as np | |
| from src.detection.class_labels import get_color, get_label | |
| class Detection: | |
| """A single detected object.""" | |
| bbox: tuple[int, int, int, int] # x1, y1, x2, y2 (pixel coords in original frame) | |
| conf: float # detection confidence | |
| class_id: int | |
| label: str = field(init=False) | |
| color: tuple[int, int, int] = field(init=False) | |
| def __post_init__(self): | |
| self.label = get_label(self.class_id) | |
| self.color = get_color(self.class_id) | |
| def x1(self) -> int: return self.bbox[0] | |
| def y1(self) -> int: return self.bbox[1] | |
| def x2(self) -> int: return self.bbox[2] | |
| def y2(self) -> int: return self.bbox[3] | |
| def width(self) -> int: return self.x2 - self.x1 | |
| def height(self) -> int: return self.y2 - self.y1 | |
| def center(self) -> tuple[int, int]: | |
| return (self.x1 + self.x2) // 2, (self.y1 + self.y2) // 2 | |
| def tlwh(self) -> tuple[int, int, int, int]: | |
| """top-left x, top-left y, width, height""" | |
| return self.x1, self.y1, self.width, self.height | |
| def xyxy(self) -> tuple[int, int, int, int]: | |
| return self.bbox | |
| def area(self) -> int: | |
| return max(0, self.width) * max(0, self.height) | |
| def iou(self, other: "Detection") -> float: | |
| """IoU with another Detection.""" | |
| ix1 = max(self.x1, other.x1) | |
| iy1 = max(self.y1, other.y1) | |
| ix2 = min(self.x2, other.x2) | |
| iy2 = min(self.y2, other.y2) | |
| inter = max(0, ix2 - ix1) * max(0, iy2 - iy1) | |
| union = self.area + other.area - inter | |
| return inter / (union + 1e-6) | |
| def crop(self, frame: np.ndarray, pad: float = 0.05) -> np.ndarray: | |
| """Crop the ROI from *frame* with optional percentage padding.""" | |
| h, w = frame.shape[:2] | |
| pw = int(self.width * pad) | |
| ph = int(self.height * pad) | |
| x1 = max(0, self.x1 - pw) | |
| y1 = max(0, self.y1 - ph) | |
| x2 = min(w, self.x2 + pw) | |
| y2 = min(h, self.y2 + ph) | |
| return frame[y1:y2, x1:x2].copy() | |
| def __repr__(self) -> str: | |
| return ( | |
| f"Detection({self.label!r}, conf={self.conf:.2f}, " | |
| f"bbox={self.bbox})" | |
| ) | |
| # ββ Postprocessing ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def decode_yolov8( | |
| output: np.ndarray, | |
| orig_shape: tuple[int, int], | |
| input_size: int = 640, | |
| conf_threshold: float = 0.40, | |
| nms_threshold: float = 0.45, | |
| class_filter: Optional[list[int]] = None, | |
| ) -> list[Detection]: | |
| """ | |
| Decode a raw YOLOv8 ONNX output into ``Detection`` objects. | |
| Args: | |
| output: Raw net.forward() output, shape ``[1, 84, N]`` or ``[84, N]``. | |
| orig_shape: Original frame shape ``(height, width)``. | |
| input_size: Square input dimension used during inference. | |
| conf_threshold: Minimum confidence to keep a detection. | |
| nms_threshold: IoU threshold for NMS. | |
| class_filter: If non-empty, only keep detections of listed class IDs. | |
| Returns: | |
| List of ``Detection`` objects in original image coordinates. | |
| """ | |
| # Ensure shape is [84, N] | |
| pred = output | |
| if pred.ndim == 3: | |
| pred = pred[0] # [84, N] | |
| if pred.shape[0] != 84: | |
| pred = pred.T # some exports give [N, 84] | |
| orig_h, orig_w = orig_shape | |
| scale_x = orig_w / input_size | |
| scale_y = orig_h / input_size | |
| boxes_xywh = pred[:4].T # [N, 4] cx,cy,w,h (normalised to input_size) | |
| class_scores = pred[4:].T # [N, 80] | |
| class_ids = np.argmax(class_scores, axis=1) | |
| confs = class_scores[np.arange(len(class_ids)), class_ids] | |
| # Confidence threshold | |
| mask = confs >= conf_threshold | |
| if class_filter: | |
| mask &= np.isin(class_ids, class_filter) | |
| boxes_xywh = boxes_xywh[mask] | |
| confs = confs[mask] | |
| class_ids = class_ids[mask] | |
| if len(boxes_xywh) == 0: | |
| return [] | |
| # Convert cx,cy,w,h β x1,y1,w,h for cv2.dnn.NMSBoxes | |
| cx, cy, bw, bh = boxes_xywh[:, 0], boxes_xywh[:, 1], boxes_xywh[:, 2], boxes_xywh[:, 3] | |
| x1 = (cx - bw / 2) * scale_x | |
| y1 = (cy - bh / 2) * scale_y | |
| w_px = bw * scale_x | |
| h_px = bh * scale_y | |
| # Clip to frame | |
| x1 = np.clip(x1, 0, orig_w).astype(int) | |
| y1 = np.clip(y1, 0, orig_h).astype(int) | |
| w_px = np.clip(w_px, 1, orig_w - x1).astype(int) | |
| h_px = np.clip(h_px, 1, orig_h - y1).astype(int) | |
| # NMS per class | |
| boxes_list = [[int(x), int(y), int(w), int(h)] for x, y, w, h in zip(x1, y1, w_px, h_px)] | |
| indices = cv2.dnn.NMSBoxes( | |
| boxes_list, | |
| confs.tolist(), | |
| conf_threshold, | |
| nms_threshold, | |
| ) | |
| if isinstance(indices, np.ndarray): | |
| indices = indices.flatten().tolist() | |
| detections: list[Detection] = [] | |
| for i in indices: | |
| x, y, w, h = boxes_list[i] | |
| detections.append( | |
| Detection( | |
| bbox=(x, y, x + w, y + h), | |
| conf=float(confs[i]), | |
| class_id=int(class_ids[i]), | |
| ) | |
| ) | |
| return detections | |