File size: 6,984 Bytes
bab4aa8
 
 
 
 
 
 
 
 
 
 
 
1bccbb6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bab4aa8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1bccbb6
bab4aa8
1bccbb6
 
 
bab4aa8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
import io
import numpy as np
import onnxruntime as ort
from PIL import Image
from huggingface_hub import hf_hub_download
from ..config import (
    YOLO_MODEL_ID,
    YOLO_MODEL_FILENAME,
    INTRA_OP_NUM_THREADS,
    INTER_OP_NUM_THREADS,
)

_yolo_session = None


def _get_yolo_session() -> ort.InferenceSession:
    """Lazy-load the YOLO ONNX session on first use instead of at import time."""
    global _yolo_session
    if _yolo_session is not None:
        return _yolo_session

    print(f"[object_detector] Downloading YOLO model '{YOLO_MODEL_ID}/{YOLO_MODEL_FILENAME}'...")
    model_file = hf_hub_download(repo_id=YOLO_MODEL_ID, filename=YOLO_MODEL_FILENAME)

    ort_options = ort.SessionOptions()
    ort_options.intra_op_num_threads = INTRA_OP_NUM_THREADS
    ort_options.inter_op_num_threads = INTER_OP_NUM_THREADS
    _yolo_session = ort.InferenceSession(
        model_file, sess_options=ort_options, providers=["CPUExecutionProvider"]
    )
    print("[object_detector] YOLO model loaded successfully!")
    return _yolo_session


def _letterbox(
    img: Image.Image, new_shape: tuple[int, int] = (640, 640)
) -> tuple[Image.Image, float, tuple[int, int]]:
    """Resize image with letterboxing (preserve aspect ratio, pad with gray)."""
    w, h = img.size
    r = min(new_shape[0] / h, new_shape[1] / w)
    new_unpad = (int(round(w * r)), int(round(h * r)))
    dw = (new_shape[1] - new_unpad[0]) / 2
    dh = (new_shape[0] - new_unpad[1]) / 2

    resized = img.resize(new_unpad, Image.Resampling.BILINEAR)

    top, bottom = int(round(dh - 0.1)), int(round(dh + 0.1))
    left, right = int(round(dw - 0.1)), int(round(dw + 0.1))
    padded = Image.new("RGB", (new_shape[1], new_shape[0]), (114, 114, 114))
    padded.paste(resized, (left, top))
    return padded, r, (dw, dh)


def _nms(boxes: np.ndarray, scores: np.ndarray, iou_threshold: float) -> np.ndarray:
    """Non-Maximum Suppression. Returns indices of kept boxes."""
    if len(boxes) == 0:
        return np.array([], dtype=int)

    order = scores.argsort()[::-1]
    keep = []

    while len(order) > 0:
        i = order[0]
        keep.append(i)
        if len(order) == 1:
            break

        xx1 = np.maximum(boxes[i, 0], boxes[order[1:], 0])
        yy1 = np.maximum(boxes[i, 1], boxes[order[1:], 1])
        xx2 = np.minimum(boxes[i, 2], boxes[order[1:], 2])
        yy2 = np.minimum(boxes[i, 3], boxes[order[1:], 3])

        inter = np.maximum(0, xx2 - xx1) * np.maximum(0, yy2 - yy1)
        area_i = (boxes[i, 2] - boxes[i, 0]) * (boxes[i, 3] - boxes[i, 1])
        area_rest = (boxes[order[1:], 2] - boxes[order[1:], 0]) * (
            boxes[order[1:], 3] - boxes[order[1:], 1]
        )
        iou = inter / (area_i + area_rest - inter + 1e-7)

        inds = np.where(iou <= iou_threshold)[0]
        order = order[inds + 1]

    return np.array(keep, dtype=int)


def _preprocess(contents: bytes) -> tuple[np.ndarray, float, tuple[int, int], tuple[int, int]]:
    """Decode image, letterbox to 640x640, return NCHW float32 tensor + metadata."""
    image = Image.open(io.BytesIO(contents)).convert("RGB")
    orig_w, orig_h = image.size

    img, ratio, (dw, dh) = _letterbox(image)
    arr = np.array(img, dtype=np.float32) / 255.0
    arr = arr.transpose(2, 0, 1)  # HWC -> CHW
    arr = np.expand_dims(arr, 0)  # add batch dim -> NCHW
    return arr, ratio, (dw, dh), (orig_w, orig_h)


def _postprocess(
    output: np.ndarray,
    conf_threshold: float,
    iou_threshold: float,
    ratio: float,
    pad: tuple[int, int],
    orig_size: tuple[int, int],
    max_detections: int,
) -> list[dict]:
    """Parse YOLO output tensor, apply NMS, return list of detections."""
    # output shape: (1, 84, 8400) -> transpose to (8400, 84)
    preds = output[0].T  # (8400, 84)

    boxes_xywh = preds[:, :4]
    class_scores = preds[:, 4:]
    max_scores = class_scores.max(axis=1)
    class_ids = class_scores.argmax(axis=1)

    # Filter by confidence
    mask = max_scores > conf_threshold
    boxes_xywh = boxes_xywh[mask]
    max_scores = max_scores[mask]
    class_ids = class_ids[mask]

    if len(max_scores) == 0:
        return []

    # Convert xywh -> xyxy
    x1 = boxes_xywh[:, 0] - boxes_xywh[:, 2] / 2
    y1 = boxes_xywh[:, 1] - boxes_xywh[:, 3] / 2
    x2 = boxes_xywh[:, 0] + boxes_xywh[:, 2] / 2
    y2 = boxes_xywh[:, 1] + boxes_xywh[:, 3] / 2
    boxes_xyxy = np.stack([x1, y1, x2, y2], axis=1)

    # NMS
    keep = _nms(boxes_xyxy, max_scores, iou_threshold)

    # Take top max_detections
    if len(keep) > max_detections:
        keep = keep[:max_detections]

    # Scale back to original image coordinates (undo letterbox padding + ratio)
    dw, dh = pad
    orig_w, orig_h = orig_size
    detections = []
    for idx in keep:
        bx1, by1, bx2, by2 = boxes_xyxy[idx]
        # Undo letterbox padding
        bx1 = (bx1 - dw) / ratio
        by1 = (by1 - dh) / ratio
        bx2 = (bx2 - dw) / ratio
        by2 = (by2 - dh) / ratio
        # Clamp to original image bounds
        bx1 = max(0, min(bx1, orig_w))
        by1 = max(0, min(by1, orig_h))
        bx2 = max(0, min(bx2, orig_w))
        by2 = max(0, min(by2, orig_h))
        detections.append(
            {
                "bbox": [float(bx1), float(by1), float(bx2), float(by2)],
                "confidence": float(max_scores[idx]),
                "class_id": int(class_ids[idx]),
            }
        )

    return detections


def detect_objects(
    contents: bytes,
    conf_threshold: float = 0.25,
    iou_threshold: float = 0.45,
    max_detections: int = 3,
) -> list[dict]:
    """Run YOLO detection on image bytes. Returns list of detections sorted by confidence."""
    session = _get_yolo_session()
    pixel_values, ratio, pad, orig_size = _preprocess(contents)
    input_name = session.get_inputs()[0].name
    output_name = session.get_outputs()[0].name
    raw_output = session.run([output_name], {input_name: pixel_values})[0]
    detections = _postprocess(
        raw_output, conf_threshold, iou_threshold, ratio, pad, orig_size, max_detections
    )
    return detections


def crop_objects(
    contents: bytes, detections: list[dict], padding_ratio: float = 0.10
) -> list[bytes]:
    """Crop detected bounding box regions from the original image. Returns list of JPEG bytes."""
    image = Image.open(io.BytesIO(contents)).convert("RGB")
    orig_w, orig_h = image.size
    crops = []

    for det in detections:
        x1, y1, x2, y2 = det["bbox"]
        bw, bh = x2 - x1, y2 - y1
        pad_x, pad_y = bw * padding_ratio, bh * padding_ratio

        cx1 = max(0, int(x1 - pad_x))
        cy1 = max(0, int(y1 - pad_y))
        cx2 = min(orig_w, int(x2 + pad_x))
        cy2 = min(orig_h, int(y2 + pad_y))

        if cx2 <= cx1 or cy2 <= cy1:
            continue

        crop = image.crop((cx1, cy1, cx2, cy2))
        buf = io.BytesIO()
        crop.save(buf, format="JPEG", quality=95)
        crops.append(buf.getvalue())

    return crops