nntoan209's picture
scorevision: push artifact
bd2227f verified
Raw
History Blame Contribute Delete
17.5 kB
from pathlib import Path
import argparse
import json
import math, cv2, numpy as np, onnxruntime as ort
from numpy import ndarray
from pydantic import BaseModel
class BoundingBox(BaseModel):
x1: int
y1: int
x2: int
y2: int
cls_id: int
conf: float
class TVFrameResult(BaseModel):
frame_id: int
boxes: list[BoundingBox]
keypoints: list[tuple[int, int]]
class Miner:
def __init__(self, path_hf_repo: Path) -> None:
model_path = path_hf_repo / "weights.onnx"
try:
ort.preload_dlls()
except Exception:
pass
sess_options = ort.SessionOptions()
sess_options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
try:
self.session = ort.InferenceSession(
str(model_path),
sess_options=sess_options,
providers=["CUDAExecutionProvider", "CPUExecutionProvider"],
)
except Exception:
self.session = ort.InferenceSession(
str(model_path),
sess_options=sess_options,
providers=["CPUExecutionProvider"],
)
self.input_name = self.session.get_inputs()[0].name
self.output_names = [o.name for o in self.session.get_outputs()]
self.input_shape = self.session.get_inputs()[0].shape
self.input_height = self._safe_dim(self.input_shape[2], 1280)
self.input_width = self._safe_dim(self.input_shape[3], 1280)
self.tile_size = 960
self.overlap = 0.5
self.conf_thres = 0.47
self.wbf_iou = 0.55
self.max_det = 100
self.min_box_area = 14 * 14
self.min_w = 8
self.min_h = 8
self.max_aspect_ratio = 8.0
self.max_box_area_ratio = 0.8
def __repr__(self) -> str:
return (
f"ONNXRuntime(session={type(self.session).__name__}, "
f"providers={self.session.get_providers()})"
)
@staticmethod
def _safe_dim(value, default: int) -> int:
return value if isinstance(value, int) and value > 0 else default
@staticmethod
def _clip_boxes(boxes: np.ndarray, image_size: tuple[int, int]) -> np.ndarray:
w, h = image_size
if len(boxes) == 0:
return boxes
boxes[:, 0] = np.clip(boxes[:, 0], 0, w - 1)
boxes[:, 1] = np.clip(boxes[:, 1], 0, h - 1)
boxes[:, 2] = np.clip(boxes[:, 2], 0, w - 1)
boxes[:, 3] = np.clip(boxes[:, 3], 0, h - 1)
return boxes
@staticmethod
def _xywh_to_xyxy(boxes: np.ndarray) -> np.ndarray:
out = np.empty_like(boxes)
out[:, 0] = boxes[:, 0] - boxes[:, 2] / 2.0
out[:, 1] = boxes[:, 1] - boxes[:, 3] / 2.0
out[:, 2] = boxes[:, 0] + boxes[:, 2] / 2.0
out[:, 3] = boxes[:, 1] + boxes[:, 3] / 2.0
return out
def _slice_image(
self, image: np.ndarray
) -> list[tuple[np.ndarray, tuple[int, int], tuple[int, int]]]:
h, w = image.shape[:2]
t = self.tile_size
st = max(1, int(t * (1.0 - self.overlap)))
xs = []
x = 0
while True:
if x + t >= w:
xs.append(max(0, w - t))
break
xs.append(x)
x += st
ys = []
y = 0
while True:
if y + t >= h:
ys.append(max(0, h - t))
break
ys.append(y)
y += st
xs = list(dict.fromkeys(xs))
ys = list(dict.fromkeys(ys))
out = []
for y0 in ys:
for x0 in xs:
x1 = min(x0 + t, w)
y1 = min(y0 + t, h)
crop = image[y0:y1, x0:x1]
vh, vw = crop.shape[:2]
out.append((crop, (x0, y0), (vw, vh)))
return out
def _preprocess(self, image: np.ndarray) -> np.ndarray:
if image.dtype != np.uint8:
image = image.astype(np.uint8)
h, w = image.shape[:2]
pad_w = max(0, self.input_width - w)
pad_h = max(0, self.input_height - h)
if pad_w > 0 or pad_h > 0:
image = cv2.copyMakeBorder(
image,
0,
pad_h,
0,
pad_w,
cv2.BORDER_CONSTANT,
value=(114, 114, 114),
)
if image.shape[0] != self.input_height or image.shape[1] != self.input_width:
raise ValueError(
f"Bad tile shape after pad: {image.shape[:2]}, "
f"expected ({self.input_height}, {self.input_width})"
)
img = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
img = img.astype(np.float32) / 255.0
img = np.transpose(img, (2, 0, 1))[None, ...]
return np.ascontiguousarray(img, dtype=np.float32)
def _filter_sane_boxes(
self,
boxes: np.ndarray,
scores: np.ndarray,
cls_ids: np.ndarray,
valid_size: tuple[int, int],
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
if len(boxes) == 0:
return boxes, scores, cls_ids
valid_w, valid_h = valid_size
image_area = float(max(1, valid_w * valid_h))
keep = []
for i, box in enumerate(boxes):
x1, y1, x2, y2 = box.tolist()
bw = x2 - x1
bh = y2 - y1
if bw <= 0 or bh <= 0:
continue
if bw < self.min_w or bh < self.min_h:
continue
area = bw * bh
if area < self.min_box_area:
continue
if area > self.max_box_area_ratio * image_area:
continue
ar = max(bw / max(bh, 1e-6), bh / max(bw, 1e-6))
if ar > self.max_aspect_ratio:
continue
keep.append(i)
if not keep:
return (
np.empty((0, 4), dtype=np.float32),
np.empty((0,), dtype=np.float32),
np.empty((0,), dtype=np.int32),
)
keep = np.array(keep, dtype=np.intp)
return boxes[keep], scores[keep], cls_ids[keep]
def _decode_final_dets(
self, preds: np.ndarray, valid_size: tuple[int, int]
) -> tuple[np.ndarray, np.ndarray]:
if preds.ndim == 3 and preds.shape[0] == 1:
preds = preds[0]
if preds.ndim != 2 or preds.shape[1] < 6:
raise ValueError(f"Unexpected final-det output shape: {preds.shape}")
boxes = preds[:, :4].astype(np.float32)
scores = preds[:, 4].astype(np.float32)
cls_ids = preds[:, 5].astype(np.int32)
keep = cls_ids == 0
boxes = boxes[keep]
scores = scores[keep]
cls_ids = cls_ids[keep]
keep = scores >= self.conf_thres
boxes = boxes[keep]
scores = scores[keep]
cls_ids = cls_ids[keep]
if len(boxes) == 0:
return (
np.empty((0, 4), dtype=np.float32),
np.empty((0,), dtype=np.float32),
)
boxes = self._clip_boxes(boxes, valid_size)
boxes, scores, cls_ids = self._filter_sane_boxes(boxes, scores, cls_ids, valid_size)
if len(boxes) == 0:
return (
np.empty((0, 4), dtype=np.float32),
np.empty((0,), dtype=np.float32),
)
return boxes, scores
def _decode_raw_yolo(
self, preds: np.ndarray, valid_size: tuple[int, int]
) -> tuple[np.ndarray, np.ndarray]:
if preds.ndim != 3:
raise ValueError(f"Unexpected raw output shape: {preds.shape}")
if preds.shape[0] != 1:
raise ValueError(f"Unexpected batch dimension in raw output: {preds.shape}")
preds = preds[0]
if preds.shape[0] <= 16 and preds.shape[1] > preds.shape[0]:
preds = preds.T
if preds.ndim != 2 or preds.shape[1] < 5:
raise ValueError(f"Unexpected normalized raw output shape: {preds.shape}")
boxes_xywh = preds[:, :4].astype(np.float32)
tail = preds[:, 4:].astype(np.float32)
if tail.shape[1] == 1:
scores = tail[:, 0]
cls_ids = np.zeros(len(scores), dtype=np.int32)
elif tail.shape[1] == 2:
obj = tail[:, 0]
cls_prob = tail[:, 1]
scores = obj * cls_prob
cls_ids = np.zeros(len(scores), dtype=np.int32)
else:
obj = tail[:, 0]
class_probs = tail[:, 1:]
cls_ids = np.argmax(class_probs, axis=1).astype(np.int32)
cls_scores = class_probs[np.arange(len(class_probs)), cls_ids]
scores = obj * cls_scores
keep = cls_ids == 0
boxes_xywh = boxes_xywh[keep]
scores = scores[keep]
cls_ids = cls_ids[keep]
keep = scores >= self.conf_thres
boxes_xywh = boxes_xywh[keep]
scores = scores[keep]
cls_ids = cls_ids[keep]
if len(boxes_xywh) == 0:
return (
np.empty((0, 4), dtype=np.float32),
np.empty((0,), dtype=np.float32),
)
boxes = self._xywh_to_xyxy(boxes_xywh)
boxes = self._clip_boxes(boxes, valid_size)
boxes, scores, cls_ids = self._filter_sane_boxes(boxes, scores, cls_ids, valid_size)
if len(boxes) == 0:
return (
np.empty((0, 4), dtype=np.float32),
np.empty((0,), dtype=np.float32),
)
return boxes, scores
def _postprocess(
self, output: np.ndarray, valid_size: tuple[int, int]
) -> tuple[np.ndarray, np.ndarray]:
if output.ndim == 2 and output.shape[1] == 6:
return self._decode_final_dets(output, valid_size)
if output.ndim == 3 and output.shape[0] == 1 and output.shape[2] == 6:
return self._decode_final_dets(output, valid_size)
return self._decode_raw_yolo(output, valid_size)
def _predict_tile(self, image: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
if image is None:
raise ValueError("Input image is None")
if not isinstance(image, np.ndarray):
raise TypeError(f"Input is not numpy array: {type(image)}")
if image.ndim != 3:
raise ValueError(f"Expected HWC image, got shape={image.shape}")
if image.shape[2] != 3:
raise ValueError(f"Expected 3 channels, got shape={image.shape}")
valid_w, valid_h = image.shape[1], image.shape[0]
input_tensor = self._preprocess(image)
expected_shape = (1, 3, self.input_height, self.input_width)
if input_tensor.shape != expected_shape:
raise ValueError(
f"Bad input tensor shape={input_tensor.shape}, expected={expected_shape}"
)
outputs = self.session.run(self.output_names, {self.input_name: input_tensor})
det_output = outputs[0]
return self._postprocess(det_output, (valid_w, valid_h))
@staticmethod
def _iou_one(a: np.ndarray, b: np.ndarray) -> float:
xx1 = max(float(a[0]), float(b[0]))
yy1 = max(float(a[1]), float(b[1]))
xx2 = min(float(a[2]), float(b[2]))
yy2 = min(float(a[3]), float(b[3]))
inter = max(0.0, xx2 - xx1) * max(0.0, yy2 - yy1)
area_a = max(0.0, float(a[2] - a[0])) * max(0.0, float(a[3] - a[1]))
area_b = max(0.0, float(b[2] - b[0])) * max(0.0, float(b[3] - b[1]))
return float(inter / (area_a + area_b - inter + 1e-7))
def _wbf(self, boxes: np.ndarray, scores: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
if len(boxes) == 0:
return boxes, scores
order = np.argsort(scores)[::-1]
boxes = boxes[order]
scores = scores[order]
used = np.zeros(len(boxes), dtype=bool)
fused_boxes = []
fused_scores = []
for i in range(len(boxes)):
if used[i]:
continue
cluster = [i]
used[i] = True
changed = True
while changed:
changed = False
for j in range(i + 1, len(boxes)):
if used[j]:
continue
if any(self._iou_one(boxes[j], boxes[k]) >= self.wbf_iou for k in cluster):
used[j] = True
cluster.append(j)
changed = True
cb = boxes[cluster]
cs = scores[cluster]
w = cs / (cs.sum() + 1e-7)
fused_boxes.append((cb * w[:, None]).sum(axis=0))
fused_scores.append(float(cs.max()))
return np.asarray(fused_boxes, dtype=np.float32), np.asarray(fused_scores, dtype=np.float32)
def _merge_boxes_global(
self,
image_shape: tuple[int, int],
tile_boxes: np.ndarray,
tile_scores: np.ndarray,
) -> list[BoundingBox]:
if len(tile_boxes) == 0:
return []
H, W = image_shape[:2]
boxes = tile_boxes.copy()
scores = tile_scores.copy()
keep = np.argsort(scores)[::-1]
boxes = boxes[keep]
scores = scores[keep]
boxes, scores = self._wbf(boxes, scores)
out = []
for b, sc in zip(boxes[: self.max_det], scores[: self.max_det]):
x1 = int(math.floor(b[0]))
y1 = int(math.floor(b[1]))
x2 = int(math.ceil(b[2]))
y2 = int(math.ceil(b[3]))
x1 = max(0, min(x1, W - 1))
y1 = max(0, min(y1, H - 1))
x2 = max(0, min(x2, W - 1))
y2 = max(0, min(y2, H - 1))
if x2 > x1 and y2 > y1:
out.append(
BoundingBox(
x1=x1,
y1=y1,
x2=x2,
y2=y2,
cls_id=0,
conf=float(sc),
)
)
return out
def predict_image(self, image: np.ndarray) -> list[BoundingBox]:
if image is None:
raise ValueError("Input image is None")
if not isinstance(image, np.ndarray):
raise TypeError(f"Input is not numpy array: {type(image)}")
if image.ndim != 3:
raise ValueError(f"Expected HWC image, got shape={image.shape}")
if image.shape[2] != 3:
raise ValueError(f"Expected 3 channels, got shape={image.shape}")
H, W = image.shape[:2]
tiles = self._slice_image(image)
all_boxes = []
all_scores = []
for i, (tile_img, (ox, oy), (vw, vh)) in enumerate(tiles):
try:
b1, s1 = self._predict_tile(tile_img)
flipped = cv2.flip(tile_img, 1)
b2, s2 = self._predict_tile(flipped)
if len(b2):
tw = tile_img.shape[1]
b2 = b2.copy()
b2[:, [0, 2]] = tw - b2[:, [2, 0]]
if len(b1) == 0 and len(b2) == 0:
continue
if len(b1) == 0:
b, s = b2, s2
elif len(b2) == 0:
b, s = b1, s1
else:
b = np.vstack([b1, b2])
s = np.hstack([s1, s2])
except Exception as e:
print(f"⚠️ Tile inference failed at ({ox}, {oy}): {e}")
continue
left_edge = ox == 0
top_edge = oy == 0
right_edge = (ox + vw) >= W
bottom_edge = (oy + vh) >= H
for bb, sc in zip(b, s):
bw = bb[2] - bb[0]
bh = bb[3] - bb[1]
m = max(8, int(min(bw, bh) * 0.2))
if not left_edge and bb[0] < m:
continue
if not top_edge and bb[1] < m:
continue
if not right_edge and bb[2] > (vw - m):
continue
if not bottom_edge and bb[3] > (vh - m):
continue
x1 = max(0, min(W - 1, int(bb[0] + ox)))
y1 = max(0, min(H - 1, int(bb[1] + oy)))
x2 = max(0, min(W - 1, int(bb[2] + ox)))
y2 = max(0, min(H - 1, int(bb[3] + oy)))
if x2 > x1 and y2 > y1:
all_boxes.append([x1, y1, x2, y2])
all_scores.append(float(sc))
if not all_boxes:
return []
boxes = np.asarray(all_boxes, dtype=np.float32)
scores = np.asarray(all_scores, dtype=np.float32)
return self._merge_boxes_global(image.shape, boxes, scores)
def predict_batch(
self,
batch_images: list[ndarray],
offset: int,
n_keypoints: int,
) -> list[TVFrameResult]:
results: list[TVFrameResult] = []
for frame_number_in_batch, image in enumerate(batch_images):
try:
boxes = self.predict_image(image)
except Exception as e:
print(f"⚠️ Inference failed for frame {offset + frame_number_in_batch}: {e}")
boxes = []
results.append(
TVFrameResult(
frame_id=offset + frame_number_in_batch,
boxes=boxes,
keypoints=[(0, 0) for _ in range(max(0, int(n_keypoints)))],
)
)
return results