stfkramer commited on
Commit
f4ef9cd
·
verified ·
1 Parent(s): c8558fd

scorevision: push artifact

Browse files
Files changed (1) hide show
  1. miner.py +325 -0
miner.py ADDED
@@ -0,0 +1,325 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+
3
+ from ultralytics import YOLO
4
+ from numpy import ndarray
5
+ from pydantic import BaseModel
6
+ import numpy as np
7
+ import cv2
8
+
9
+
10
+ def calculate_iou(box1: dict, box2: dict) -> float:
11
+ x1 = max(box1["x1"], box2["x1"])
12
+ y1 = max(box1["y1"], box2["y1"])
13
+ x2 = min(box1["x2"], box2["x2"])
14
+ y2 = min(box1["y2"], box2["y2"])
15
+
16
+ intersection_width = max(0.0, x2 - x1)
17
+ intersection_height = max(0.0, y2 - y1)
18
+ intersection_area = intersection_width * intersection_height
19
+
20
+ box1_area = max(0.0, box1["x2"] - box1["x1"]) * max(
21
+ 0.0, box1["y2"] - box1["y1"]
22
+ )
23
+ box2_area = max(0.0, box2["x2"] - box2["x1"]) * max(
24
+ 0.0, box2["y2"] - box2["y1"]
25
+ )
26
+
27
+ union_area = box1_area + box2_area - intersection_area
28
+
29
+ if union_area <= 0:
30
+ return 0.0
31
+
32
+ return intersection_area / union_area
33
+
34
+
35
+ def fuse_box_cluster(cluster: list[dict]) -> dict:
36
+ """
37
+ Merge box coordinates using confidence-weighted averaging.
38
+
39
+ The merged confidence is the highest confidence in the cluster.
40
+ """
41
+ weights = np.array(
42
+ [max(box["conf"], 1e-6) for box in cluster],
43
+ dtype=np.float32,
44
+ )
45
+
46
+ coordinates = np.array(
47
+ [
48
+ [box["x1"], box["y1"], box["x2"], box["y2"]]
49
+ for box in cluster
50
+ ],
51
+ dtype=np.float32,
52
+ )
53
+
54
+ fused_coordinates = np.average(
55
+ coordinates,
56
+ axis=0,
57
+ weights=weights,
58
+ )
59
+
60
+ best_box = max(cluster, key=lambda box: box["conf"])
61
+
62
+ return {
63
+ "x1": int(round(fused_coordinates[0])),
64
+ "y1": int(round(fused_coordinates[1])),
65
+ "x2": int(round(fused_coordinates[2])),
66
+ "y2": int(round(fused_coordinates[3])),
67
+ "cls_id": int(best_box["cls_id"]),
68
+ "conf": float(max(box["conf"] for box in cluster)),
69
+ }
70
+
71
+
72
+ def merge_same_class_boxes(
73
+ detections: list[dict],
74
+ iou_threshold: float = 0.65,
75
+ ) -> list[dict]:
76
+ """
77
+ Merge overlapping boxes only when they have the same class ID.
78
+ """
79
+ merged_detections = []
80
+
81
+ class_ids = sorted({box["cls_id"] for box in detections})
82
+
83
+ for class_id in class_ids:
84
+ remaining = [
85
+ box.copy()
86
+ for box in detections
87
+ if box["cls_id"] == class_id
88
+ ]
89
+
90
+ remaining.sort(key=lambda box: box["conf"], reverse=True)
91
+
92
+ while remaining:
93
+ cluster = [remaining.pop(0)]
94
+
95
+ # Recalculate the fused box as new members join the cluster.
96
+ changed = True
97
+
98
+ while changed:
99
+ changed = False
100
+ current_fused_box = fuse_box_cluster(cluster)
101
+ not_merged = []
102
+
103
+ for candidate in remaining:
104
+ iou = calculate_iou(current_fused_box, candidate)
105
+
106
+ if iou >= iou_threshold:
107
+ cluster.append(candidate)
108
+ changed = True
109
+ else:
110
+ not_merged.append(candidate)
111
+
112
+ remaining = not_merged
113
+
114
+ merged_detections.append(fuse_box_cluster(cluster))
115
+
116
+ merged_detections.sort(
117
+ key=lambda box: box["conf"],
118
+ reverse=True,
119
+ )
120
+
121
+ return merged_detections
122
+
123
+
124
+ def reference_model(image: np.ndarray, model: YOLO, confidence_threshold: float = 0.1, is_flipped: bool = False) -> list[dict]:
125
+ results = model.predict(
126
+ source=image,
127
+ conf=confidence_threshold,
128
+ verbose=False,
129
+ )
130
+
131
+ result = results[0]
132
+ detections = []
133
+
134
+ if result.boxes is not None:
135
+ coordinates = result.boxes.xyxy.cpu().tolist()
136
+ confidences = result.boxes.conf.cpu().tolist()
137
+ class_ids = result.boxes.cls.int().cpu().tolist()
138
+
139
+ for coordinates_xyxy, confidence, class_id in zip(
140
+ coordinates,
141
+ confidences,
142
+ class_ids,
143
+ ):
144
+ x1, y1, x2, y2 = coordinates_xyxy
145
+
146
+ if is_flipped:
147
+ # Flip the x-coordinates for the horizontally flipped image
148
+ x1, x2 = image.shape[1] - x2, image.shape[1] - x1
149
+ detection = {
150
+ "x1": float(x1),
151
+ "y1": float(y1),
152
+ "x2": float(x2),
153
+ "y2": float(y2),
154
+ "cls_id": int(class_id),
155
+ "conf": float(confidence),
156
+ }
157
+ detections.append(detection)
158
+
159
+ return detections
160
+
161
+
162
+ class BoundingBox(BaseModel):
163
+ x1: int
164
+ y1: int
165
+ x2: int
166
+ y2: int
167
+ cls_id: int
168
+ conf: float
169
+
170
+
171
+ class Polygon(BaseModel):
172
+ cls_id: int
173
+ conf: float
174
+ points: list[tuple[int, int]]
175
+
176
+
177
+ class TVFrameResult(BaseModel):
178
+ frame_id: int
179
+ boxes: list[BoundingBox] | None = None
180
+ polygons: list[Polygon] | None = None
181
+ keypoints: list[tuple[int, int]] | None = None
182
+
183
+
184
+ class Miner:
185
+ """
186
+ This class is responsible for:
187
+ - Loading ML models.
188
+ - Running batched predictions on images.
189
+ - Parsing ML model outputs into structured results (TVFrameResult).
190
+
191
+ This class can be modified, but it must have the following to be compatible with the chute:
192
+ - be named `Miner`
193
+ - have a `predict_batch` function with the inputs and outputs specified
194
+ - be stored in a file called `miner.py` which lives in the root of the HFHub repo
195
+ """
196
+
197
+ def __init__(self, path_hf_repo: Path) -> None:
198
+ """
199
+ Loads all ML models from the repository.
200
+ -----(Adjust as needed)----
201
+
202
+ Args:
203
+ path_hf_repo (Path):
204
+ Path to the downloaded HuggingFace Hub repository
205
+
206
+ Returns:
207
+ None
208
+ """
209
+ self.bbox_model = YOLO(path_hf_repo / "weights.onnx")
210
+ print(f"✅ BBox Model Loaded")
211
+
212
+ self.model_class_names = ["balaclava", "bat", "glove", "graffiti", "hoodie", "spray paint"]
213
+ self.subnet_class_names = ["balaclava", "hoodie", "glove", "bat", "spray paint", "graffiti"]
214
+
215
+ self._class_id_map = np.fromiter(
216
+ map(self.subnet_class_names.index, self.model_class_names), dtype=np.int32,
217
+ count=len(self.model_class_names),
218
+ )
219
+
220
+ self._conf_thres_array = np.array(
221
+ [0.44, 0.65, 0.55, 0.25, 0.45, 0.26], dtype=np.float32,
222
+ )
223
+ self._bonus_array = np.array(
224
+ [0.23, 0.3, 0.45, 0.14, 0.45, 0.41], dtype=np.float32,
225
+ )
226
+
227
+ def __repr__(self) -> str:
228
+ """
229
+ Information about miner returned in the health endpoint
230
+ to inspect the loaded ML models (and their types)
231
+ -----(Adjust as needed)----
232
+ """
233
+ return f"Model: {type(self.bbox_model).__name__}"
234
+
235
+ def predict_crime(self, image: np.ndarray, model: YOLO, confidence_threshold: float = 0.1) -> list[dict]:
236
+ flipped = cv2.flip(image, 1) # Flip the image horizontally
237
+
238
+ original_detections = reference_model(
239
+ image=image,
240
+ model=model,
241
+ confidence_threshold=confidence_threshold,
242
+ is_flipped=False,
243
+ )
244
+ flipped_detections = reference_model(
245
+ image=flipped,
246
+ model=model,
247
+ confidence_threshold=confidence_threshold,
248
+ is_flipped=True,
249
+ )
250
+
251
+ detections = original_detections + flipped_detections
252
+
253
+ merged_boxes = merge_same_class_boxes(
254
+ detections,
255
+ iou_threshold=0.65,
256
+ )
257
+
258
+ return merged_boxes
259
+
260
+ def predict_batch(
261
+ self,
262
+ batch_images: list[ndarray],
263
+ offset: int,
264
+ n_keypoints: int,
265
+ ) -> list[TVFrameResult]:
266
+ """
267
+ Miner prediction for a batch of images.
268
+ Handles the orchestration of ML models and any preprocessing and postprocessing
269
+ -----(Adjust as needed)----
270
+
271
+ Args:
272
+ batch_images (list[np.ndarray]):
273
+ A list of images (as NumPy arrays) to process in this batch.
274
+ offset (int):
275
+ The frame number corresponding to the first image in the batch.
276
+ Used to correctly index frames in the output results.
277
+ n_keypoints (int):
278
+ The number of keypoints expected for each frame in this challenge type.
279
+
280
+ Returns:
281
+ list[TVFrameResult]:
282
+ A list of predictions for each image in the batch
283
+ """
284
+
285
+ bboxes: dict[int, list[BoundingBox]] = {}
286
+
287
+ for frame_number_in_batch, image in enumerate(batch_images):
288
+ merged_boxes = self.predict_crime(
289
+ image=image,
290
+ model=self.bbox_model,
291
+ confidence_threshold=0.1,
292
+ )
293
+
294
+ boxes = []
295
+ for box in merged_boxes:
296
+ x1, y1, x2, y2, model_cls_id, model_conf = box.values()
297
+ sn_cls_id = self._class_id_map[int(model_cls_id)]
298
+
299
+ if model_conf < self._conf_thres_array[sn_cls_id]:
300
+ continue
301
+
302
+ bossted_conf = min(model_conf + self._bonus_array[sn_cls_id], 0.99)
303
+ boxes.append(
304
+ BoundingBox(
305
+ x1=int(x1),
306
+ y1=int(y1),
307
+ x2=int(x2),
308
+ y2=int(y2),
309
+ cls_id=int(sn_cls_id),
310
+ conf=float(bossted_conf),
311
+ )
312
+ )
313
+ bboxes[offset + frame_number_in_batch] = boxes
314
+ print("✅ BBoxes predicted")
315
+
316
+ results: list[TVFrameResult] = []
317
+ for frame_number in range(offset, offset + len(batch_images)):
318
+ results.append(
319
+ TVFrameResult(
320
+ frame_id=frame_number,
321
+ boxes=bboxes.get(frame_number, []),
322
+ )
323
+ )
324
+ print("✅ Combined results as TVFrameResult")
325
+ return results