thomaskk2024 commited on
Commit
dff39cc
·
verified ·
1 Parent(s): b4d09c6

Upload folder using huggingface_hub

Browse files
Files changed (4) hide show
  1. chute_config.yml +18 -0
  2. miner.py +598 -0
  3. my_chute.py +446 -0
  4. person-detection.onnx +3 -0
chute_config.yml ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Image:
2
+ from_base: parachutes/python:3.12
3
+ run_command:
4
+ - pip install --upgrade setuptools wheel
5
+ - pip install 'numpy>=1.23' 'onnxruntime-gpu[cuda,cudnn]>=1.16' 'opencv-python>=4.7' 'pillow>=9.5' 'huggingface_hub>=0.19.4' 'pydantic>=2.0' 'pyyaml>=6.0' 'aiohttp>=3.9' 'torch<2.6'
6
+
7
+ NodeSelector:
8
+ gpu_count: 1
9
+ min_vram_gb_per_gpu: 24
10
+ min_memory_gb: 32
11
+ min_cpu_count: 32
12
+
13
+ Chute:
14
+ timeout_seconds: 900
15
+ concurrency: 4
16
+ max_instances: 5
17
+ scaling_threshold: 0.5
18
+ shutdown_after_seconds: 288000
miner.py ADDED
@@ -0,0 +1,598 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+ import math
3
+
4
+ import cv2
5
+ import numpy as np
6
+ import onnxruntime as ort
7
+ from numpy import ndarray
8
+ from pydantic import BaseModel
9
+
10
+
11
+ class BoundingBox(BaseModel):
12
+ x1: int
13
+ y1: int
14
+ x2: int
15
+ y2: int
16
+ cls_id: int
17
+ conf: float
18
+
19
+
20
+ class TVFrameResult(BaseModel):
21
+ frame_id: int
22
+ boxes: list[BoundingBox]
23
+ keypoints: list[tuple[int, int]]
24
+
25
+
26
+ class Miner:
27
+ def __init__(self, path_hf_repo: Path) -> None:
28
+ model_path = path_hf_repo / "person-detection.onnx"
29
+ self.class_names = ["person"]
30
+ print("ORT version:", ort.__version__)
31
+
32
+ try:
33
+ ort.preload_dlls()
34
+ print("✅ onnxruntime.preload_dlls() success")
35
+ except Exception as e:
36
+ print(f"⚠️ preload_dlls failed: {e}")
37
+
38
+ print("ORT available providers BEFORE session:", ort.get_available_providers())
39
+
40
+ sess_options = ort.SessionOptions()
41
+ sess_options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
42
+
43
+ try:
44
+ self.session = ort.InferenceSession(
45
+ str(model_path),
46
+ sess_options=sess_options,
47
+ providers=["CUDAExecutionProvider", "CPUExecutionProvider"],
48
+ )
49
+ print("✅ Created ORT session with preferred CUDA provider list")
50
+ except Exception as e:
51
+ print(f"⚠️ CUDA session creation failed, falling back to CPU: {e}")
52
+ self.session = ort.InferenceSession(
53
+ str(model_path),
54
+ sess_options=sess_options,
55
+ providers=["CPUExecutionProvider"],
56
+ )
57
+
58
+ print("ORT session providers:", self.session.get_providers())
59
+
60
+ for inp in self.session.get_inputs():
61
+ print("INPUT:", inp.name, inp.shape, inp.type)
62
+
63
+ for out in self.session.get_outputs():
64
+ print("OUTPUT:", out.name, out.shape, out.type)
65
+
66
+ self.input_name = self.session.get_inputs()[0].name
67
+ self.output_names = [output.name for output in self.session.get_outputs()]
68
+ self.input_shape = self.session.get_inputs()[0].shape
69
+
70
+ self.input_height = self._safe_dim(self.input_shape[2], default=1280)
71
+ self.input_width = self._safe_dim(self.input_shape[3], default=1280)
72
+
73
+ # ---------- Scoring-oriented thresholds ----------
74
+ # Low threshold for candidate generation
75
+ self.conf_thres = 0.08
76
+
77
+ # High-confidence boxes can survive without TTA confirmation
78
+ self.conf_high = 0.18
79
+
80
+ # NMS threshold
81
+ self.iou_thres = 0.45
82
+
83
+ # TTA confirmation IoU
84
+ self.tta_match_iou = 0.55
85
+
86
+ self.max_det = 300
87
+ self.use_tta = True
88
+
89
+ # Box sanity filters
90
+ self.min_box_area = 16 * 16
91
+ self.min_w = 6
92
+ self.min_h = 6
93
+ self.max_aspect_ratio = 6.0
94
+ self.max_box_area_ratio = 0.95
95
+
96
+ print(f"✅ ONNX model loaded from: {model_path}")
97
+ print(f"✅ ONNX providers: {self.session.get_providers()}")
98
+ print(f"✅ ONNX input: name={self.input_name}, shape={self.input_shape}")
99
+
100
+ def __repr__(self) -> str:
101
+ return (
102
+ f"ONNXRuntime(session={type(self.session).__name__}, "
103
+ f"providers={self.session.get_providers()})"
104
+ )
105
+
106
+ @staticmethod
107
+ def _safe_dim(value, default: int) -> int:
108
+ return value if isinstance(value, int) and value > 0 else default
109
+
110
+ def _letterbox(
111
+ self,
112
+ image: ndarray,
113
+ new_shape: tuple[int, int],
114
+ color=(114, 114, 114),
115
+ ) -> tuple[ndarray, float, tuple[float, float]]:
116
+ h, w = image.shape[:2]
117
+ new_w, new_h = new_shape
118
+
119
+ ratio = min(new_w / w, new_h / h)
120
+ resized_w = int(round(w * ratio))
121
+ resized_h = int(round(h * ratio))
122
+
123
+ if (resized_w, resized_h) != (w, h):
124
+ interp = cv2.INTER_CUBIC if ratio > 1.0 else cv2.INTER_LINEAR
125
+ image = cv2.resize(image, (resized_w, resized_h), interpolation=interp)
126
+
127
+ dw = new_w - resized_w
128
+ dh = new_h - resized_h
129
+ dw /= 2.0
130
+ dh /= 2.0
131
+
132
+ left = int(round(dw - 0.1))
133
+ right = int(round(dw + 0.1))
134
+ top = int(round(dh - 0.1))
135
+ bottom = int(round(dh + 0.1))
136
+
137
+ padded = cv2.copyMakeBorder(
138
+ image,
139
+ top,
140
+ bottom,
141
+ left,
142
+ right,
143
+ borderType=cv2.BORDER_CONSTANT,
144
+ value=color,
145
+ )
146
+ return padded, ratio, (dw, dh)
147
+
148
+ def _preprocess(
149
+ self, image: ndarray
150
+ ) -> tuple[np.ndarray, float, tuple[float, float], tuple[int, int]]:
151
+ orig_h, orig_w = image.shape[:2]
152
+
153
+ img, ratio, pad = self._letterbox(
154
+ image, (self.input_width, self.input_height)
155
+ )
156
+ img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
157
+ img = img.astype(np.float32) / 255.0
158
+ img = np.transpose(img, (2, 0, 1))[None, ...]
159
+ img = np.ascontiguousarray(img, dtype=np.float32)
160
+
161
+ return img, ratio, pad, (orig_w, orig_h)
162
+
163
+ @staticmethod
164
+ def _clip_boxes(boxes: np.ndarray, image_size: tuple[int, int]) -> np.ndarray:
165
+ w, h = image_size
166
+ boxes[:, 0] = np.clip(boxes[:, 0], 0, w - 1)
167
+ boxes[:, 1] = np.clip(boxes[:, 1], 0, h - 1)
168
+ boxes[:, 2] = np.clip(boxes[:, 2], 0, w - 1)
169
+ boxes[:, 3] = np.clip(boxes[:, 3], 0, h - 1)
170
+ return boxes
171
+
172
+ @staticmethod
173
+ def _xywh_to_xyxy(boxes: np.ndarray) -> np.ndarray:
174
+ out = np.empty_like(boxes)
175
+ out[:, 0] = boxes[:, 0] - boxes[:, 2] / 2.0
176
+ out[:, 1] = boxes[:, 1] - boxes[:, 3] / 2.0
177
+ out[:, 2] = boxes[:, 0] + boxes[:, 2] / 2.0
178
+ out[:, 3] = boxes[:, 1] + boxes[:, 3] / 2.0
179
+ return out
180
+
181
+ @staticmethod
182
+ def _hard_nms(
183
+ boxes: np.ndarray,
184
+ scores: np.ndarray,
185
+ iou_thresh: float,
186
+ ) -> np.ndarray:
187
+ if len(boxes) == 0:
188
+ return np.array([], dtype=np.intp)
189
+
190
+ boxes = np.asarray(boxes, dtype=np.float32)
191
+ scores = np.asarray(scores, dtype=np.float32)
192
+ order = np.argsort(scores)[::-1]
193
+ keep = []
194
+
195
+ while len(order) > 0:
196
+ i = order[0]
197
+ keep.append(i)
198
+ if len(order) == 1:
199
+ break
200
+
201
+ rest = order[1:]
202
+
203
+ xx1 = np.maximum(boxes[i, 0], boxes[rest, 0])
204
+ yy1 = np.maximum(boxes[i, 1], boxes[rest, 1])
205
+ xx2 = np.minimum(boxes[i, 2], boxes[rest, 2])
206
+ yy2 = np.minimum(boxes[i, 3], boxes[rest, 3])
207
+
208
+ inter = np.maximum(0.0, xx2 - xx1) * np.maximum(0.0, yy2 - yy1)
209
+
210
+ area_i = np.maximum(0.0, (boxes[i, 2] - boxes[i, 0])) * np.maximum(0.0, (boxes[i, 3] - boxes[i, 1]))
211
+ area_r = np.maximum(0.0, (boxes[rest, 2] - boxes[rest, 0])) * np.maximum(0.0, (boxes[rest, 3] - boxes[rest, 1]))
212
+
213
+ iou = inter / (area_i + area_r - inter + 1e-7)
214
+ order = rest[iou <= iou_thresh]
215
+
216
+ return np.array(keep, dtype=np.intp)
217
+
218
+ @staticmethod
219
+ def _box_iou_one_to_many(box: np.ndarray, boxes: np.ndarray) -> np.ndarray:
220
+ xx1 = np.maximum(box[0], boxes[:, 0])
221
+ yy1 = np.maximum(box[1], boxes[:, 1])
222
+ xx2 = np.minimum(box[2], boxes[:, 2])
223
+ yy2 = np.minimum(box[3], boxes[:, 3])
224
+
225
+ inter = np.maximum(0.0, xx2 - xx1) * np.maximum(0.0, yy2 - yy1)
226
+
227
+ area_a = max(0.0, (box[2] - box[0]) * (box[3] - box[1]))
228
+ area_b = np.maximum(0.0, boxes[:, 2] - boxes[:, 0]) * np.maximum(0.0, boxes[:, 3] - boxes[:, 1])
229
+
230
+ return inter / (area_a + area_b - inter + 1e-7)
231
+
232
+ def _filter_sane_boxes(
233
+ self,
234
+ boxes: np.ndarray,
235
+ scores: np.ndarray,
236
+ cls_ids: np.ndarray,
237
+ orig_size: tuple[int, int],
238
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
239
+ if len(boxes) == 0:
240
+ return boxes, scores, cls_ids
241
+
242
+ orig_w, orig_h = orig_size
243
+ image_area = float(orig_w * orig_h)
244
+
245
+ keep = []
246
+ for i, box in enumerate(boxes):
247
+ x1, y1, x2, y2 = box.tolist()
248
+ bw = x2 - x1
249
+ bh = y2 - y1
250
+
251
+ if bw <= 0 or bh <= 0:
252
+ continue
253
+ if bw < self.min_w or bh < self.min_h:
254
+ continue
255
+
256
+ area = bw * bh
257
+ if area < self.min_box_area:
258
+ continue
259
+ if area > self.max_box_area_ratio * image_area:
260
+ continue
261
+
262
+ ar = max(bw / max(bh, 1e-6), bh / max(bw, 1e-6))
263
+ if ar > self.max_aspect_ratio:
264
+ continue
265
+
266
+ keep.append(i)
267
+
268
+ if not keep:
269
+ return (
270
+ np.empty((0, 4), dtype=np.float32),
271
+ np.empty((0,), dtype=np.float32),
272
+ np.empty((0,), dtype=np.int32),
273
+ )
274
+
275
+ keep = np.array(keep, dtype=np.intp)
276
+ return boxes[keep], scores[keep], cls_ids[keep]
277
+
278
+ def _decode_final_dets(
279
+ self,
280
+ preds: np.ndarray,
281
+ ratio: float,
282
+ pad: tuple[float, float],
283
+ orig_size: tuple[int, int],
284
+ ) -> list[BoundingBox]:
285
+ if preds.ndim == 3 and preds.shape[0] == 1:
286
+ preds = preds[0]
287
+
288
+ if preds.ndim != 2 or preds.shape[1] < 6:
289
+ raise ValueError(f"Unexpected ONNX final-det output shape: {preds.shape}")
290
+
291
+ boxes = preds[:, :4].astype(np.float32)
292
+ scores = preds[:, 4].astype(np.float32)
293
+ cls_ids = preds[:, 5].astype(np.int32)
294
+
295
+ # person only
296
+ keep = cls_ids == 0
297
+ boxes = boxes[keep]
298
+ scores = scores[keep]
299
+ cls_ids = cls_ids[keep]
300
+
301
+ # candidate threshold
302
+ keep = scores >= self.conf_thres
303
+ boxes = boxes[keep]
304
+ scores = scores[keep]
305
+ cls_ids = cls_ids[keep]
306
+
307
+ if len(boxes) == 0:
308
+ return []
309
+
310
+ pad_w, pad_h = pad
311
+ orig_w, orig_h = orig_size
312
+
313
+ boxes[:, [0, 2]] -= pad_w
314
+ boxes[:, [1, 3]] -= pad_h
315
+ boxes /= ratio
316
+ boxes = self._clip_boxes(boxes, (orig_w, orig_h))
317
+
318
+ boxes, scores, cls_ids = self._filter_sane_boxes(boxes, scores, cls_ids, orig_size)
319
+ if len(boxes) == 0:
320
+ return []
321
+
322
+ keep_idx = self._hard_nms(boxes, scores, self.iou_thres)
323
+ keep_idx = keep_idx[: self.max_det]
324
+
325
+ boxes = boxes[keep_idx]
326
+ scores = scores[keep_idx]
327
+ cls_ids = cls_ids[keep_idx]
328
+
329
+ return [
330
+ BoundingBox(
331
+ x1=int(math.floor(box[0])),
332
+ y1=int(math.floor(box[1])),
333
+ x2=int(math.ceil(box[2])),
334
+ y2=int(math.ceil(box[3])),
335
+ cls_id=int(cls_id),
336
+ conf=float(conf),
337
+ )
338
+ for box, conf, cls_id in zip(boxes, scores, cls_ids)
339
+ if box[2] > box[0] and box[3] > box[1]
340
+ ]
341
+
342
+ def _decode_raw_yolo(
343
+ self,
344
+ preds: np.ndarray,
345
+ ratio: float,
346
+ pad: tuple[float, float],
347
+ orig_size: tuple[int, int],
348
+ ) -> list[BoundingBox]:
349
+ if preds.ndim != 3:
350
+ raise ValueError(f"Unexpected raw ONNX output shape: {preds.shape}")
351
+ if preds.shape[0] != 1:
352
+ raise ValueError(f"Unexpected batch dimension in raw output: {preds.shape}")
353
+
354
+ preds = preds[0]
355
+
356
+ # Normalize to [N, C]
357
+ if preds.shape[0] <= 16 and preds.shape[1] > preds.shape[0]:
358
+ preds = preds.T
359
+
360
+ if preds.ndim != 2 or preds.shape[1] < 5:
361
+ raise ValueError(f"Unexpected normalized raw output shape: {preds.shape}")
362
+
363
+ boxes_xywh = preds[:, :4].astype(np.float32)
364
+ tail = preds[:, 4:].astype(np.float32)
365
+
366
+ # Supports:
367
+ # [x,y,w,h,score] single-class
368
+ # [x,y,w,h,obj,cls] YOLO standard single-class
369
+ # [x,y,w,h,obj,cls1,cls2,...] multi-class
370
+ if tail.shape[1] == 1:
371
+ scores = tail[:, 0]
372
+ cls_ids = np.zeros(len(scores), dtype=np.int32)
373
+ elif tail.shape[1] == 2:
374
+ obj = tail[:, 0]
375
+ cls_prob = tail[:, 1]
376
+ scores = obj * cls_prob
377
+ cls_ids = np.zeros(len(scores), dtype=np.int32)
378
+ else:
379
+ obj = tail[:, 0]
380
+ class_probs = tail[:, 1:]
381
+ cls_ids = np.argmax(class_probs, axis=1).astype(np.int32)
382
+ cls_scores = class_probs[np.arange(len(class_probs)), cls_ids]
383
+ scores = obj * cls_scores
384
+
385
+ keep = cls_ids == 0
386
+ boxes_xywh = boxes_xywh[keep]
387
+ scores = scores[keep]
388
+ cls_ids = cls_ids[keep]
389
+
390
+ keep = scores >= self.conf_thres
391
+ boxes_xywh = boxes_xywh[keep]
392
+ scores = scores[keep]
393
+ cls_ids = cls_ids[keep]
394
+
395
+ if len(boxes_xywh) == 0:
396
+ return []
397
+
398
+ boxes = self._xywh_to_xyxy(boxes_xywh)
399
+
400
+ pad_w, pad_h = pad
401
+ orig_w, orig_h = orig_size
402
+
403
+ boxes[:, [0, 2]] -= pad_w
404
+ boxes[:, [1, 3]] -= pad_h
405
+ boxes /= ratio
406
+ boxes = self._clip_boxes(boxes, (orig_w, orig_h))
407
+
408
+ boxes, scores, cls_ids = self._filter_sane_boxes(boxes, scores, cls_ids, orig_size)
409
+ if len(boxes) == 0:
410
+ return []
411
+
412
+ keep_idx = self._hard_nms(boxes, scores, self.iou_thres)
413
+ keep_idx = keep_idx[: self.max_det]
414
+
415
+ boxes = boxes[keep_idx]
416
+ scores = scores[keep_idx]
417
+ cls_ids = cls_ids[keep_idx]
418
+
419
+ return [
420
+ BoundingBox(
421
+ x1=int(math.floor(box[0])),
422
+ y1=int(math.floor(box[1])),
423
+ x2=int(math.ceil(box[2])),
424
+ y2=int(math.ceil(box[3])),
425
+ cls_id=int(cls_id),
426
+ conf=float(conf),
427
+ )
428
+ for box, conf, cls_id in zip(boxes, scores, cls_ids)
429
+ if box[2] > box[0] and box[3] > box[1]
430
+ ]
431
+
432
+ def _postprocess(
433
+ self,
434
+ output: np.ndarray,
435
+ ratio: float,
436
+ pad: tuple[float, float],
437
+ orig_size: tuple[int, int],
438
+ ) -> list[BoundingBox]:
439
+ if output.ndim == 2 and output.shape[1] >= 6:
440
+ return self._decode_final_dets(output, ratio, pad, orig_size)
441
+
442
+ if output.ndim == 3 and output.shape[0] == 1 and output.shape[2] >= 6:
443
+ return self._decode_final_dets(output, ratio, pad, orig_size)
444
+
445
+ return self._decode_raw_yolo(output, ratio, pad, orig_size)
446
+
447
+ def _predict_single(self, image: np.ndarray) -> list[BoundingBox]:
448
+ if image is None:
449
+ raise ValueError("Input image is None")
450
+ if not isinstance(image, np.ndarray):
451
+ raise TypeError(f"Input is not numpy array: {type(image)}")
452
+ if image.ndim != 3:
453
+ raise ValueError(f"Expected HWC image, got shape={image.shape}")
454
+ if image.shape[0] <= 0 or image.shape[1] <= 0:
455
+ raise ValueError(f"Invalid image shape={image.shape}")
456
+ if image.shape[2] != 3:
457
+ raise ValueError(f"Expected 3 channels, got shape={image.shape}")
458
+
459
+ if image.dtype != np.uint8:
460
+ image = image.astype(np.uint8)
461
+
462
+ input_tensor, ratio, pad, orig_size = self._preprocess(image)
463
+
464
+ expected_shape = (1, 3, self.input_height, self.input_width)
465
+ if input_tensor.shape != expected_shape:
466
+ raise ValueError(
467
+ f"Bad input tensor shape={input_tensor.shape}, expected={expected_shape}"
468
+ )
469
+
470
+ outputs = self.session.run(self.output_names, {self.input_name: input_tensor})
471
+ det_output = outputs[0]
472
+ return self._postprocess(det_output, ratio, pad, orig_size)
473
+
474
+ def _merge_tta_consensus(
475
+ self,
476
+ boxes_orig: list[BoundingBox],
477
+ boxes_flip: list[BoundingBox],
478
+ ) -> list[BoundingBox]:
479
+ """
480
+ Keep:
481
+ - any box with conf >= conf_high
482
+ - low/medium-conf boxes only if confirmed across TTA views
483
+ Then run final hard NMS.
484
+ """
485
+ if not boxes_orig and not boxes_flip:
486
+ return []
487
+
488
+ coords_o = np.array([[b.x1, b.y1, b.x2, b.y2] for b in boxes_orig], dtype=np.float32) if boxes_orig else np.empty((0, 4), dtype=np.float32)
489
+ scores_o = np.array([b.conf for b in boxes_orig], dtype=np.float32) if boxes_orig else np.empty((0,), dtype=np.float32)
490
+
491
+ coords_f = np.array([[b.x1, b.y1, b.x2, b.y2] for b in boxes_flip], dtype=np.float32) if boxes_flip else np.empty((0, 4), dtype=np.float32)
492
+ scores_f = np.array([b.conf for b in boxes_flip], dtype=np.float32) if boxes_flip else np.empty((0,), dtype=np.float32)
493
+
494
+ accepted_boxes = []
495
+ accepted_scores = []
496
+
497
+ # Original view candidates
498
+ for i in range(len(coords_o)):
499
+ score = scores_o[i]
500
+ if score >= self.conf_high:
501
+ accepted_boxes.append(coords_o[i])
502
+ accepted_scores.append(score)
503
+ elif len(coords_f) > 0:
504
+ ious = self._box_iou_one_to_many(coords_o[i], coords_f)
505
+ j = int(np.argmax(ious))
506
+ if ious[j] >= self.tta_match_iou:
507
+ fused_score = max(score, scores_f[j])
508
+ accepted_boxes.append(coords_o[i])
509
+ accepted_scores.append(fused_score)
510
+
511
+ # Flipped-view high-confidence boxes that original missed
512
+ for i in range(len(coords_f)):
513
+ score = scores_f[i]
514
+ if score < self.conf_high:
515
+ continue
516
+
517
+ if len(coords_o) == 0:
518
+ accepted_boxes.append(coords_f[i])
519
+ accepted_scores.append(score)
520
+ continue
521
+
522
+ ious = self._box_iou_one_to_many(coords_f[i], coords_o)
523
+ if np.max(ious) < self.tta_match_iou:
524
+ accepted_boxes.append(coords_f[i])
525
+ accepted_scores.append(score)
526
+
527
+ if not accepted_boxes:
528
+ return []
529
+
530
+ boxes = np.array(accepted_boxes, dtype=np.float32)
531
+ scores = np.array(accepted_scores, dtype=np.float32)
532
+
533
+ keep = self._hard_nms(boxes, scores, self.iou_thres)
534
+ keep = keep[: self.max_det]
535
+
536
+ out = []
537
+ for idx in keep:
538
+ x1, y1, x2, y2 = boxes[idx].tolist()
539
+ out.append(
540
+ BoundingBox(
541
+ x1=int(math.floor(x1)),
542
+ y1=int(math.floor(y1)),
543
+ x2=int(math.ceil(x2)),
544
+ y2=int(math.ceil(y2)),
545
+ cls_id=0,
546
+ conf=float(scores[idx]),
547
+ )
548
+ )
549
+ return out
550
+
551
+ def _predict_tta(self, image: np.ndarray) -> list[BoundingBox]:
552
+ boxes_orig = self._predict_single(image)
553
+
554
+ flipped = cv2.flip(image, 1)
555
+ boxes_flip_raw = self._predict_single(flipped)
556
+
557
+ w = image.shape[1]
558
+ boxes_flip = [
559
+ BoundingBox(
560
+ x1=w - b.x2,
561
+ y1=b.y1,
562
+ x2=w - b.x1,
563
+ y2=b.y2,
564
+ cls_id=b.cls_id,
565
+ conf=b.conf,
566
+ )
567
+ for b in boxes_flip_raw
568
+ ]
569
+
570
+ return self._merge_tta_consensus(boxes_orig, boxes_flip)
571
+
572
+ def predict_batch(
573
+ self,
574
+ batch_images: list[ndarray],
575
+ offset: int,
576
+ n_keypoints: int,
577
+ ) -> list[TVFrameResult]:
578
+ results: list[TVFrameResult] = []
579
+
580
+ for frame_number_in_batch, image in enumerate(batch_images):
581
+ try:
582
+ if self.use_tta:
583
+ boxes = self._predict_tta(image)
584
+ else:
585
+ boxes = self._predict_single(image)
586
+ except Exception as e:
587
+ print(f"⚠️ Inference failed for frame {offset + frame_number_in_batch}: {e}")
588
+ boxes = []
589
+
590
+ results.append(
591
+ TVFrameResult(
592
+ frame_id=offset + frame_number_in_batch,
593
+ boxes=boxes,
594
+ keypoints=[(0, 0) for _ in range(max(0, int(n_keypoints)))],
595
+ )
596
+ )
597
+
598
+ return results
my_chute.py ADDED
@@ -0,0 +1,446 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+
3
+ import sys
4
+ from importlib.machinery import PathFinder
5
+ from importlib.util import module_from_spec, spec_from_file_location
6
+ from inspect import signature
7
+ from pathlib import Path
8
+ from site import getsitepackages, getusersitepackages
9
+ from sysconfig import get_paths
10
+ from tempfile import NamedTemporaryFile
11
+ from typing import Any, Generator
12
+ from urllib.parse import quote
13
+
14
+ import torch
15
+ from aiohttp import ClientSession
16
+ from chutes.chute import Chute, NodeSelector
17
+ from chutes.image import Image
18
+ from base64 import b64decode
19
+ from cv2 import CAP_PROP_FRAME_COUNT, VideoCapture, imdecode, IMREAD_COLOR
20
+ from huggingface_hub import snapshot_download
21
+ from numpy import ndarray, frombuffer, uint8
22
+ from pydantic import BaseModel
23
+ from yaml import safe_load
24
+
25
+ ##============VARIABLES=======================
26
+ HF_REPO_NAME = "YRD526/ScoreVision"
27
+ HF_REPO_REVISION = "main"
28
+ CHUTES_USERNAME = "mango202"
29
+ CHUTE_NAME = "person-detection-mango2"
30
+ FILENAME = "miner.py"
31
+ CLASSNAME = "Miner"
32
+ CHUTE_CONFIG_FILENAME = "chute_config.yml"
33
+ MAX_LOADED_MODEL_SIZE_GB = 5.0
34
+
35
+
36
+ ##=========DATA CLASSES================
37
+ class TVFrame(BaseModel):
38
+ frame_id: int
39
+ url: str | None = None
40
+ data: str | None = None
41
+
42
+
43
+ class TVPredictInput(BaseModel):
44
+ url: str | None = None
45
+ frames: list[TVFrame] | None = None
46
+ meta: dict[str, Any] = {}
47
+
48
+
49
+ class TVPredictOutput(BaseModel):
50
+ success: bool
51
+ predictions: dict[str, list[dict]] | None = None
52
+ error: str | None = None
53
+
54
+
55
+ ##=========UTILITY FUNCTIONS=================
56
+ def get_whitelisted_paths() -> set[Path]:
57
+ STDLIB_ROOT = Path(get_paths()["stdlib"]).resolve()
58
+ SITE_PACKAGES_ROOTS = {Path(p).resolve() for p in getsitepackages()}
59
+ SITE_PACKAGES_ROOTS.add(Path(getusersitepackages()).resolve())
60
+
61
+ return {STDLIB_ROOT, *SITE_PACKAGES_ROOTS}
62
+
63
+ def post_check_files_against_whitelist(*, before: set[str], miner_path: Path) -> None:
64
+ miner_path = miner_path.resolve()
65
+ WHITELIST = get_whitelisted_paths()
66
+ after = set(sys.modules.keys()) - before
67
+ offenders = []
68
+ for module_loaded_name in sorted(after):
69
+ module_loaded = sys.modules.get(module_loaded_name)
70
+ if module_loaded is None:
71
+ continue
72
+ module_loaded_file = getattr(module_loaded, "__file__", None)
73
+ if not module_loaded_file:
74
+ continue
75
+ module_loaded_filepath = Path(module_loaded_file).resolve()
76
+ if module_loaded_filepath == miner_path:
77
+ continue
78
+ if any(module_loaded_filepath.is_relative_to(root) for root in WHITELIST):
79
+ continue
80
+ offenders.append(module_loaded_name)
81
+
82
+ if offenders:
83
+ raise ImportError(
84
+ f"Miner loaded modules outside the whitelist: {', '.join(offenders)}"
85
+ )
86
+
87
+
88
+ def verify_cuda_version() -> str:
89
+ min_version = (12, 0)
90
+
91
+ if not torch.cuda.is_available():
92
+ return "❌ CUDA is not available. Make sure GPU runtime is attached correctly"
93
+
94
+ device_count = torch.cuda.device_count()
95
+ if device_count < 1:
96
+ return "❌ No CUDA devices detected"
97
+
98
+ cuda_version_str = torch.version.cuda
99
+ if cuda_version_str is None:
100
+ return "❌ PyTorch was not compiled with CUDA support"
101
+
102
+ try:
103
+ cuda_version = tuple(map(int, cuda_version_str.split(".")))
104
+ except Exception as e:
105
+ return f"❌ Unable to parse CUDA version string: {e}"
106
+
107
+ if cuda_version < min_version:
108
+ return f"❌ CUDA version {cuda_version_str} is too old. Requires >= {min_version[0]}.{min_version[1]}"
109
+
110
+ return f"CUDA {cuda_version_str} detected with {device_count} device(s)"
111
+
112
+
113
+ def load_chute_config_from_hf_repo(config_path: Path) -> dict:
114
+ try:
115
+ if not config_path.exists():
116
+ raise ValueError("No config file found in repo")
117
+
118
+ with config_path.open() as configuration_file:
119
+ config = safe_load(configuration_file)
120
+ print("✅ Loaded Chutes Configuration")
121
+ return config or {}
122
+ except Exception as e:
123
+ print(f"⚠️ Failed to load Chutes Configuration. Using defaults: {e}")
124
+ return {}
125
+
126
+
127
+ def safe_instantiate(cls, config: dict):
128
+ params = {k: v for k, v in config.items() if k in signature(cls).parameters}
129
+ print(
130
+ f"The following parameters will be applied when instantiating {cls.__name__}: {params}"
131
+ )
132
+ obj = cls(**params)
133
+ for function_name, value in config.items():
134
+ if not hasattr(obj, function_name) or not callable(getattr(obj, function_name)):
135
+ continue
136
+ print(f"applying .{function_name}({value})")
137
+ method = getattr(obj, function_name)
138
+ if isinstance(value, list):
139
+ for v in value:
140
+ if isinstance(v, (tuple, list)):
141
+ method(*v)
142
+ else:
143
+ method(v)
144
+ else:
145
+ method(value)
146
+ return obj
147
+
148
+
149
+ def load_chute(
150
+ hf_repo: str,
151
+ hf_revision: str,
152
+ config_filename: str,
153
+ chutes_username: str,
154
+ chute_name: str,
155
+ ) -> Chute:
156
+ hf_repo_path = Path(snapshot_download(hf_repo, revision=hf_revision))
157
+ print("✅ Huggingface Hub repo downloaded")
158
+
159
+ config = load_chute_config_from_hf_repo(config_path=hf_repo_path / config_filename)
160
+ print("✅ Config file loaded")
161
+
162
+ image_config = config.get("Image", {})
163
+ image_config.update(
164
+ {
165
+ "username": chutes_username,
166
+ "name": chute_name,
167
+ "tag":"latest",
168
+ "readme": "README.md",
169
+ }
170
+ )
171
+ chute_image = safe_instantiate(cls=Image, config=image_config)
172
+ print("✅ Image instantiated")
173
+
174
+ machine_specs = safe_instantiate(
175
+ cls=NodeSelector, config=config.get("NodeSelector", {})
176
+ )
177
+ print("✅ NodeSelector instantiated")
178
+
179
+ chute_config = config.get("Chute", {})
180
+ chute_config.update(
181
+ {
182
+ "username": chutes_username,
183
+ "name": chute_name,
184
+ "image": chute_image,
185
+ "node_selector": machine_specs,
186
+ "allow_external_egress":False,
187
+ "readme": "README.md",
188
+ }
189
+ )
190
+ chute = safe_instantiate(cls=Chute, config=chute_config)
191
+ print("✅ Chute instantiated")
192
+ return chute
193
+
194
+
195
+ def load_miner_from_hf_repo(path_hf_repo: Path, filename: str, classname: str):
196
+ path_hf_repo = path_hf_repo.resolve()
197
+ path_miner = path_hf_repo / filename
198
+ if not path_miner.is_file():
199
+ raise FileNotFoundError(
200
+ f"❌ Required file '{filename}' not in repo {path_hf_repo}"
201
+ )
202
+
203
+ WHITELIST = get_whitelisted_paths()
204
+
205
+ class HFRepoImportBlocker(PathFinder):
206
+ @classmethod
207
+ def find_spec(cls, fullname, path=None, target=None):
208
+ spec = super().find_spec(fullname, path, target)
209
+ if spec is None or spec.origin is None:
210
+ return spec
211
+ origin_path = Path(spec.origin).resolve()
212
+ if any(origin_path.is_relative_to(root) for root in WHITELIST):
213
+ return spec
214
+ raise ImportError(
215
+ f"❌ Import blocked: '{fullname}' (file: {origin_path}) is not in the whitelist. All code related to the miner MUST be inside {filename}"
216
+ )
217
+
218
+ try:
219
+ before_exec = set(sys.modules.keys())
220
+ sys.meta_path.insert(0, HFRepoImportBlocker)
221
+ spec = spec_from_file_location("miner", path_miner)
222
+ module = module_from_spec(spec)
223
+ sys.modules["miner"] = module
224
+ spec.loader.exec_module(module)
225
+ miner_object = getattr(module, classname)
226
+ miner = miner_object(path_hf_repo)
227
+ post_check_files_against_whitelist(before=before_exec, miner_path=path_miner)
228
+ except Exception as e:
229
+ raise e
230
+ finally:
231
+ sys.meta_path.remove(HFRepoImportBlocker)
232
+ return miner
233
+
234
+
235
+ def object_size_in_bytes(current_obj: Any, seen_ids: set[int], limit: int) -> int:
236
+ obj_id = id(current_obj)
237
+ if obj_id in seen_ids or limit <= 0:
238
+ return 0
239
+ seen_ids.add(obj_id)
240
+ total_size = 0
241
+ if isinstance(current_obj, torch.Tensor):
242
+ return current_obj.numel() * current_obj.element_size()
243
+ if isinstance(current_obj, ndarray):
244
+ return current_obj.nbytes
245
+ try:
246
+ total_size += sys.getsizeof(current_obj)
247
+ except TypeError:
248
+ pass
249
+ if isinstance(current_obj, dict):
250
+ for key, value in current_obj.items():
251
+ total_size += object_size_in_bytes(key, seen_ids, limit - 1)
252
+ total_size += object_size_in_bytes(value, seen_ids, limit - 1)
253
+ elif isinstance(current_obj, (list, tuple, set)):
254
+ try:
255
+ for item in current_obj:
256
+ total_size += object_size_in_bytes(item, seen_ids, limit - 1)
257
+ except TypeError:
258
+ pass
259
+ elif hasattr(current_obj, "__dict__"):
260
+ for value in current_obj.__dict__.values():
261
+ total_size += object_size_in_bytes(value, seen_ids, limit - 1)
262
+ return total_size
263
+
264
+
265
+ def get_miner_size_gb(miner) -> float:
266
+ total_size_bytes = object_size_in_bytes(
267
+ current_obj=miner, seen_ids=set(), limit=100
268
+ )
269
+ return total_size_bytes / (1024**3)
270
+
271
+
272
+ def get_video_frames_in_batches(
273
+ video: VideoCapture, batch_size: int
274
+ ) -> Generator[list[ndarray], None, None]:
275
+ batch = []
276
+ while True:
277
+ ok, frame = video.read()
278
+ if not ok:
279
+ if batch:
280
+ yield batch
281
+ break
282
+
283
+ batch.append(frame)
284
+
285
+ if len(batch) >= batch_size:
286
+ yield batch
287
+ batch = []
288
+
289
+
290
+ ##=========ENDPOINTS================
291
+ chute = load_chute(
292
+ hf_repo=HF_REPO_NAME,
293
+ hf_revision=HF_REPO_REVISION,
294
+ chutes_username=CHUTES_USERNAME,
295
+ chute_name=CHUTE_NAME,
296
+ config_filename=CHUTE_CONFIG_FILENAME,
297
+ )
298
+
299
+
300
+ @chute.on_startup()
301
+ async def load_model(self) -> None:
302
+ try:
303
+ self.cuda_status = verify_cuda_version()
304
+ except Exception as e:
305
+ self.cuda_status = f"❌ Failed to check cuda status: {e}"
306
+ print(self.cuda_status)
307
+
308
+ self.miner_size = None
309
+ try:
310
+ hf_repo_path = Path(snapshot_download(HF_REPO_NAME, revision=HF_REPO_REVISION))
311
+ print("✅ Huggingface Hub repo downloaded")
312
+
313
+ self.miner = load_miner_from_hf_repo(
314
+ path_hf_repo=hf_repo_path, filename=FILENAME, classname=CLASSNAME
315
+ )
316
+ print(f"✅ Miner loaded {self.miner}")
317
+ self.miner_size = get_miner_size_gb(self.miner)
318
+ if self.miner_size > MAX_LOADED_MODEL_SIZE_GB:
319
+ raise ValueError(
320
+ f"Loaded Miner memory footprint {self.miner_size:.2f} GB exceeds the allowed limit of {MAX_LOADED_MODEL_SIZE_GB:.2f} GB. "
321
+ )
322
+ self.status = "healthy"
323
+ except Exception as e:
324
+ self.status = f"❌ Failed to load miner: {e}"
325
+ self.miner = None
326
+ print(self.status)
327
+
328
+
329
+ @chute.cord(public_api_path="/health")
330
+ async def health(self, *args, **kwargs) -> dict[str, Any]:
331
+ return {
332
+ "status": self.status,
333
+ "model_loaded": str(self.miner),
334
+ "memory_gb": self.miner_size,
335
+ "cuda": self.cuda_status,
336
+ }
337
+
338
+
339
+ @chute.cord(
340
+ public_api_path="/predict",
341
+ )
342
+ async def predict(self, data: TVPredictInput) -> dict:
343
+ try:
344
+ if self.miner is None:
345
+ raise ValueError("Models were not properly loaded!")
346
+
347
+ metadata = data.meta
348
+ batch_size = metadata.get("batch_size", 64)
349
+ n_keypoints = metadata.get("n_keypoints", 32)
350
+
351
+ frame_results = []
352
+
353
+ if data.frames:
354
+ frame_entries = [(f.frame_id, f.url, f.data) for f in data.frames]
355
+ async with ClientSession() as session:
356
+ loaded_frames: list[tuple[int, ndarray]] = []
357
+ for frame_id, frame_url, frame_data in frame_entries:
358
+ if frame_data:
359
+ try:
360
+ content = b64decode(frame_data)
361
+ except Exception as e:
362
+ raise ValueError(f"Failed to decode frame {frame_id}: {e}")
363
+ elif frame_url:
364
+ url = f"https://proxy.chutes.ai/misc/proxy?url={quote(frame_url, safe='')}"
365
+ async with session.get(url) as response:
366
+ response.raise_for_status()
367
+ content = await response.read()
368
+ else:
369
+ raise ValueError(f"Frame {frame_id} missing url or data")
370
+
371
+ arr = frombuffer(content, dtype=uint8)
372
+ img = imdecode(arr, IMREAD_COLOR)
373
+ if img is None:
374
+ raise ValueError(f"Failed to decode frame {frame_id}")
375
+ loaded_frames.append((frame_id, img))
376
+
377
+ for i in range(0, len(loaded_frames), batch_size):
378
+ batch = loaded_frames[i : i + batch_size]
379
+ batch_images = [img for _, img in batch]
380
+ batch_frame_ids = [fid for fid, _ in batch]
381
+ print(f"Predicting Batch: {i // batch_size + 1}")
382
+ batch_frame_results = self.miner.predict_batch(
383
+ batch_images=batch_images,
384
+ offset=0,
385
+ n_keypoints=n_keypoints,
386
+ )
387
+ for frame_id, frame_result in zip(
388
+ batch_frame_ids, batch_frame_results, strict=True
389
+ ):
390
+ fr = frame_result.model_dump()
391
+ fr["frame_id"] = frame_id
392
+ frame_results.append(fr)
393
+ else:
394
+ if not data.url:
395
+ raise ValueError("Missing both video url and frames payload")
396
+
397
+ url = f"https://proxy.chutes.ai/misc/proxy?url={quote(data.url, safe='')}"
398
+ async with ClientSession() as session:
399
+ async with session.get(url) as response:
400
+ response.raise_for_status()
401
+ content = await response.read()
402
+ print("✅ Challenge video downloaded. Saving video to temporary file.")
403
+
404
+ with NamedTemporaryFile(prefix="sv_video_", suffix=".mp4") as f:
405
+ f.write(content)
406
+ cap = VideoCapture(f.name)
407
+ if not cap.isOpened():
408
+ raise ValueError(
409
+ f"Problem accessing downloaded video {f.name}"
410
+ )
411
+
412
+ n_frames = int(cap.get(CAP_PROP_FRAME_COUNT))
413
+ print(
414
+ f"Processing video with {n_frames} frames in batches of {batch_size}"
415
+ )
416
+
417
+ for batch_number, images in enumerate(
418
+ get_video_frames_in_batches(
419
+ video=cap, batch_size=batch_size
420
+ )
421
+ ):
422
+ frame_number = batch_size * batch_number
423
+ print(f"Predicting Batch: {batch_number + 1}")
424
+ batch_frame_results = self.miner.predict_batch(
425
+ batch_images=images,
426
+ offset=frame_number,
427
+ n_keypoints=n_keypoints,
428
+ )
429
+ frame_results.extend(
430
+ [
431
+ frame_result.model_dump()
432
+ for frame_result in batch_frame_results
433
+ ]
434
+ )
435
+
436
+ cap.release()
437
+ print("✅ Frame Predictions Completed")
438
+
439
+ result = TVPredictOutput(success=True, predictions={"frames": frame_results})
440
+
441
+ except Exception as e:
442
+ result = TVPredictOutput(
443
+ success=False,
444
+ error=f"❌ There was a problem during prediction: {e}",
445
+ )
446
+ return result.model_dump(mode="json")
person-detection.onnx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:be6e4b7e468f655482d0f73509954721601eacb68d376d8191a14bdb7f3d3105
3
+ size 19404973