Spaces:
Sleeping
Sleeping
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
|