File size: 16,696 Bytes
d31e060
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
"""High-level YOLO26n detection for uint8 NumPy image frames."""

from __future__ import annotations

import threading
from collections.abc import Callable
from dataclasses import dataclass
from pathlib import Path
from time import perf_counter
from types import TracebackType
from typing import Literal, Protocol, Self, cast

import cv2
import numpy as np
from numpy.typing import NDArray

from .errors import Yolo26nError
from .yolo26n import Yolo26nTensorSession

__all__ = [
    "Detection",
    "DetectionResult",
    "DetectorConfig",
    "DetectorTimings",
    "PixelFormat",
    "Yolo26Detector",
]

PixelFormat = Literal["BGR", "RGB", "BGRX"]
_BOX_COORDINATE_CHANNELS = 4
_LETTERBOX_VALUE = 114
_MAX_NMS_CANDIDATES = 30_000
_CLASS_BOX_OFFSET = 7680
type _FloatArray = NDArray[np.float32]
type _IndexArray = NDArray[np.int64]
type _Uint8Array = NDArray[np.uint8]


class _TensorSession(Protocol):
    input_shape: tuple[int, int, int, int]
    output_shape: tuple[int, int, int]
    class_names: tuple[str, ...]

    @property
    def closed(self) -> bool: ...

    def execute(self, tensor: _FloatArray) -> _FloatArray: ...

    def _prepare_image_without_resize(
        self,
        frame: _Uint8Array,
        *,
        pixel_format: str,
        output: _FloatArray,
    ) -> None: ...

    def close(self) -> None: ...


@dataclass(frozen=True, slots=True)
class DetectorConfig:
    """Postprocessing settings for one detector instance."""

    confidence: float = 0.25
    iou: float = 0.45
    max_detections: int = 300
    multi_label: bool = True

    def __post_init__(self) -> None:
        if not 0.0 <= self.confidence <= 1.0:
            raise ValueError("confidence must be between 0 and 1")
        if not 0.0 <= self.iou <= 1.0:
            raise ValueError("iou must be between 0 and 1")
        if self.max_detections <= 0:
            raise ValueError("max_detections must be positive")


@dataclass(frozen=True, slots=True)
class Detection:
    """One detection in source-image pixel coordinates (x1, y1, x2, y2)."""

    box: tuple[float, float, float, float]
    confidence: float
    class_id: int
    class_name: str


@dataclass(frozen=True, slots=True)
class DetectorTimings:
    """Wall-clock time spent in each detector stage."""

    preprocess_ms: float
    inference_ms: float
    postprocess_ms: float

    @property
    def total_ms(self) -> float:
        return self.preprocess_ms + self.inference_ms + self.postprocess_ms


@dataclass(frozen=True, slots=True)
class DetectionResult:
    """Detections and timing information for one source frame."""

    detections: tuple[Detection, ...]
    source_shape: tuple[int, int]
    timings: DetectorTimings


class Yolo26Detector:
    """Convert image frames into labelled detections in source coordinates.

    The detector applies letterboxing, normalization, native inference,
    non-maximum suppression, and coordinate scaling. Each result also reports
    time spent preprocessing, executing the runtime, and postprocessing. One
    input tensor is reused between calls, so ``detect`` and ``close`` are
    deliberately non-reentrant; use separate processes for concurrent
    isolation. The runtime and model paths are keyword-only so callers cannot
    silently reverse two path-like arguments.
    """

    def __init__(
        self,
        *,
        runtime: str | Path,
        model: str | Path,
        config: DetectorConfig | None = None,
    ) -> None:
        self.config = config or DetectorConfig()
        self._session: _TensorSession = Yolo26nTensorSession(runtime=runtime, model=model)
        self._lifecycle_lock = threading.Lock()
        try:
            class_names = tuple(self._session.class_names)
            input_shape = tuple(self._session.input_shape)
            output_shape = tuple(self._session.output_shape)
            if (
                len(input_shape) != 4
                or input_shape[:2] != (1, 3)
                or input_shape[2] <= 0
                or input_shape[2] != input_shape[3]
            ):
                raise ValueError(
                    f"detector requires a square [1, 3, S, S] input, got {input_shape}"
                )
            if (
                len(output_shape) != 3
                or output_shape[0] != 1
                or output_shape[1] <= _BOX_COORDINATE_CHANNELS
                or output_shape[2] <= 0
            ):
                raise ValueError(
                    "detector requires output shape [1, 4 + classes, positions], "
                    f"got {output_shape}"
                )
            output_class_count = output_shape[1] - _BOX_COORDINATE_CHANNELS
            if len(class_names) != output_class_count or any(
                not isinstance(name, str) or not name for name in class_names
            ):
                raise ValueError(
                    "detector class names must be non-empty strings matching the "
                    f"{output_class_count} output classes; got {len(class_names)}"
                )
        except BaseException:
            self._session.close()
            raise
        self.class_names = class_names
        self.input_shape = input_shape
        self.output_shape = output_shape
        self._input_hw = (input_shape[2], input_shape[3])
        self._input_tensor = np.empty(input_shape, dtype=np.float32)

    @property
    def closed(self) -> bool:
        return self._session.closed

    def detect(
        self,
        frame: _Uint8Array,
        *,
        pixel_format: PixelFormat,
        on_frame_consumed: Callable[[], None] | None = None,
    ) -> DetectionResult:
        """Detect objects in one uint8 HWC BGR, RGB, or BGRX frame.

        Pixel-order conversion, letterboxing, normalization, native execution,
        non-maximum suppression, and box scaling are all included. Frames whose
        longest side already matches the square model input take the native
        no-resize path: centered value-114 padding, channel conversion,
        HWC-to-CHW conversion, and FP32 normalization are fused into the reused
        input tensor. Frames requiring interpolation use centered letterboxing.
        If provided, ``on_frame_consumed`` is called exactly once after the
        input tensor no longer refers to the source frame and before inference.
        """
        source_shape = self._validate_frame(frame, pixel_format)
        if not self._lifecycle_lock.acquire(blocking=False):
            raise Yolo26nError("concurrent detection or close is not supported")

        try:
            if self.closed:
                raise Yolo26nError("detector is closed")

            preprocess_started_at = perf_counter()
            self._prepare_input(frame, source_shape, pixel_format)
            preprocess_ms = (perf_counter() - preprocess_started_at) * 1000
            if on_frame_consumed is not None:
                on_frame_consumed()

            inference_started_at = perf_counter()
            raw = self._run_inference()
            inference_ms = (perf_counter() - inference_started_at) * 1000

            postprocess_started_at = perf_counter()
            detections = self._postprocess(raw, source_shape)
            postprocess_ms = (perf_counter() - postprocess_started_at) * 1000
            return DetectionResult(
                detections=detections,
                source_shape=source_shape,
                timings=DetectorTimings(
                    preprocess_ms=preprocess_ms,
                    inference_ms=inference_ms,
                    postprocess_ms=postprocess_ms,
                ),
            )
        finally:
            self._lifecycle_lock.release()

    @staticmethod
    def _validate_frame(
        frame: _Uint8Array,
        pixel_format: PixelFormat,
    ) -> tuple[int, int]:
        if not isinstance(frame, np.ndarray):
            raise TypeError("frame must be a numpy.ndarray")
        if frame.dtype != np.uint8:
            raise TypeError("frame must have dtype uint8")
        if pixel_format not in ("BGR", "RGB", "BGRX"):
            raise ValueError("pixel_format must be 'BGR', 'RGB', or 'BGRX'")
        expected_channels = 4 if pixel_format == "BGRX" else 3
        if frame.ndim != 3 or frame.shape[2] != expected_channels:
            raise ValueError(
                f"{pixel_format} frame must have shape [height, width, {expected_channels}]"
            )
        if frame.shape[0] == 0 or frame.shape[1] == 0:
            raise ValueError("frame dimensions must be positive")
        return (int(frame.shape[0]), int(frame.shape[1]))

    def _prepare_input(
        self,
        frame: _Uint8Array,
        source_shape: tuple[int, int],
        pixel_format: PixelFormat,
    ) -> None:
        if max(source_shape) == self._input_hw[0]:
            self._session._prepare_image_without_resize(
                frame,
                pixel_format=pixel_format,
                output=self._input_tensor,
            )
            return

        three_channel_frame = frame[..., :3] if pixel_format == "BGRX" else frame
        image = _letterbox(three_channel_frame, self._input_hw)
        image_rgb = image[..., ::-1] if pixel_format in ("BGR", "BGRX") else image
        np.copyto(
            self._input_tensor[0],
            image_rgb.transpose(2, 0, 1),
            casting="unsafe",
        )
        np.divide(self._input_tensor, np.float32(255.0), out=self._input_tensor)

    def _run_inference(self) -> _FloatArray:
        return self._session.execute(self._input_tensor)

    def _postprocess(
        self,
        raw: _FloatArray,
        source_shape: tuple[int, int],
    ) -> tuple[Detection, ...]:
        processed = _non_max_suppression(
            raw[0],
            confidence=self.config.confidence,
            iou=self.config.iou,
            max_detections=self.config.max_detections,
            multi_label=self.config.multi_label,
        )
        _scale_boxes(processed[:, :4], self._input_hw, source_shape)
        detections = []
        for row in processed:
            class_id = int(row[5])
            if not 0 <= class_id < len(self.class_names):
                raise Yolo26nError(f"postprocessing returned unknown class ID {class_id}")
            x1, y1, x2, y2 = (float(value) for value in row[:4])
            if x2 <= x1 or y2 <= y1:
                continue
            detections.append(
                Detection(
                    box=(x1, y1, x2, y2),
                    confidence=float(row[4]),
                    class_id=class_id,
                    class_name=self.class_names[class_id],
                )
            )
        return tuple(detections)

    def close(self) -> None:
        """Release the detector's tensor session; repeated calls are safe."""
        with self._lifecycle_lock:
            self._session.close()

    def __enter__(self) -> Self:
        if self.closed:
            raise Yolo26nError("detector is closed")
        return self

    def __exit__(
        self,
        _exc_type: type[BaseException] | None,
        _exc_value: BaseException | None,
        _traceback: TracebackType | None,
    ) -> None:
        self.close()


def _letterbox(image: _Uint8Array, target_shape: tuple[int, int]) -> _Uint8Array:
    """Resize without distortion, then center the image on a square canvas."""
    source_height, source_width = image.shape[:2]
    target_height, target_width = target_shape
    scale = min(target_height / source_height, target_width / source_width)
    resized_width = round(source_width * scale)
    resized_height = round(source_height * scale)
    if (source_width, source_height) != (resized_width, resized_height):
        image = cast(
            _Uint8Array,
            cv2.resize(
                image,
                (resized_width, resized_height),
                interpolation=cv2.INTER_LINEAR,
            ),
        )

    horizontal_padding = (target_width - resized_width) / 2
    vertical_padding = (target_height - resized_height) / 2
    left = round(horizontal_padding - 0.1)
    right = round(horizontal_padding + 0.1)
    top = round(vertical_padding - 0.1)
    bottom = round(vertical_padding + 0.1)
    return cast(
        _Uint8Array,
        cv2.copyMakeBorder(
            image,
            top,
            bottom,
            left,
            right,
            cv2.BORDER_CONSTANT,
            value=(_LETTERBOX_VALUE,) * 3,
        ),
    )


def _xywh_to_xyxy(boxes: _FloatArray) -> _FloatArray:
    converted = np.empty_like(boxes)
    half_size = boxes[:, 2:] / np.float32(2.0)
    converted[:, :2] = boxes[:, :2] - half_size
    converted[:, 2:] = boxes[:, :2] + half_size
    return converted


def _candidate_detections(
    prediction: _FloatArray,
    confidence: float,
    multi_label: bool,
) -> _FloatArray:
    class_scores = prediction[_BOX_COORDINATE_CHANNELS:].T
    candidate_positions = np.max(class_scores, axis=1) > confidence
    if not np.any(candidate_positions):
        return np.empty((0, 6), dtype=np.float32)

    boxes = _xywh_to_xyxy(prediction[:_BOX_COORDINATE_CHANNELS].T[candidate_positions])
    scores = class_scores[candidate_positions]
    if multi_label and scores.shape[1] > 1:
        box_indices, class_ids = np.nonzero(scores > confidence)
        return np.column_stack(
            (boxes[box_indices], scores[box_indices, class_ids], class_ids.astype(np.float32))
        ).astype(np.float32, copy=False)

    class_ids = np.argmax(scores, axis=1)
    confidences = scores[np.arange(len(scores)), class_ids]
    accepted = confidences > confidence
    return np.column_stack(
        (boxes[accepted], confidences[accepted], class_ids[accepted].astype(np.float32))
    ).astype(np.float32, copy=False)


def _nms_indices(
    boxes: _FloatArray,
    scores: _FloatArray,
    iou: float,
) -> _IndexArray:
    """Return descending-confidence indices after class-aware box suppression."""
    if not len(boxes):
        return np.empty(0, dtype=np.int64)
    x1, y1, x2, y2 = boxes.T
    areas = (x2 - x1) * (y2 - y1)
    # Reversing a stable ascending order makes exact score ties deterministic:
    # the later model candidate takes precedence.
    order = np.argsort(scores, kind="stable")[::-1]
    kept: list[int] = []
    while len(order):
        current = int(order[0])
        kept.append(current)
        if len(order) == 1:
            break
        remaining = order[1:]
        intersection_width = np.maximum(
            np.minimum(x2[current], x2[remaining]) - np.maximum(x1[current], x1[remaining]),
            np.float32(0.0),
        )
        intersection_height = np.maximum(
            np.minimum(y2[current], y2[remaining]) - np.maximum(y1[current], y1[remaining]),
            np.float32(0.0),
        )
        intersection = intersection_width * intersection_height
        overlap = intersection / (areas[current] + areas[remaining] - intersection)
        order = remaining[overlap <= iou]
    return np.asarray(kept, dtype=np.int64)


def _non_max_suppression(
    prediction: _FloatArray,
    *,
    confidence: float,
    iou: float,
    max_detections: int,
    multi_label: bool,
) -> _FloatArray:
    detections = _candidate_detections(prediction, confidence, multi_label)
    if not len(detections):
        return detections
    if len(detections) > _MAX_NMS_CANDIDATES:
        highest_confidence = np.argsort(detections[:, 4], kind="stable")[::-1][:_MAX_NMS_CANDIDATES]
        detections = detections[highest_confidence]

    # Offsetting coordinates by class prevents boxes from different classes
    # suppressing one another while retaining a single vectorized NMS pass.
    class_offsets = detections[:, 5:6] * np.float32(_CLASS_BOX_OFFSET)
    boxes = detections[:, :4] + class_offsets
    kept = _nms_indices(boxes, detections[:, 4], iou)[:max_detections]
    return detections[kept]


def _scale_boxes(
    boxes: _FloatArray,
    model_shape: tuple[int, int],
    source_shape: tuple[int, int],
) -> None:
    """Map letterboxed model coordinates back to source-image pixels."""
    if not len(boxes):
        return
    model_height, model_width = model_shape
    source_height, source_width = source_shape
    scale = min(model_height / source_height, model_width / source_width)
    horizontal_padding = round((model_width - round(source_width * scale)) / 2 - 0.1)
    vertical_padding = round((model_height - round(source_height * scale)) / 2 - 0.1)
    boxes[:, (0, 2)] -= horizontal_padding
    boxes[:, (1, 3)] -= vertical_padding
    boxes /= np.float32(scale)
    boxes[:, (0, 2)] = boxes[:, (0, 2)].clip(0, source_width)
    boxes[:, (1, 3)] = boxes[:, (1, 3)].clip(0, source_height)