Chris Leo commited on
Commit
b47deb0
·
verified ·
1 Parent(s): 2588d4e

scorevision: push artifact

Browse files
Files changed (1) hide show
  1. training/build_dataset.py +860 -0
training/build_dataset.py ADDED
@@ -0,0 +1,860 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ End-to-end dataset builder for Detect-crime.
3
+
4
+ Sources (each toggleable via flags; pipeline tolerates missing sources):
5
+ 1. manako latestAnnotatedChallenge (--manako): king-labeled in-domain frames.
6
+ 2. Roboflow Universe (--roboflow PER_CLASS_SLUGS): per-class detection datasets.
7
+ 3. COCO 2017 'baseball bat' subset (--coco-bat): high-quality bat boxes.
8
+ 4. Extra dir (--extra-dir): pre-collected images you've curated locally.
9
+
10
+ Each Roboflow / COCO source is mapped to our 6-class target via an explicit class table.
11
+ Empty-label images are dropped unless --keep-empty (we keep them as negative-bg supervision
12
+ during silver pretrain).
13
+
14
+ Pipeline:
15
+ - Pull each enabled source into a working dir.
16
+ - Auto-relabel: re-emit YOLO label files with cls ids remapped to our target order.
17
+ For sources that come *unlabeled* (e.g. manako frames without preds), run the king's
18
+ ONNX over them to get silver labels.
19
+ - Manako frames go to val (real-domain eval); rest is 90/10 train/val.
20
+
21
+ Usage:
22
+ python build_dataset.py \
23
+ --out ../data \
24
+ --king-onnx /root/turbovision_crime/king_models/Detect-crime/weights.onnx \
25
+ --manako --manako-polls 30 \
26
+ --roboflow balaclava=brainster/balaclava-detection-v3 \
27
+ --roboflow glove=ppe-detection/gloves-v1 \
28
+ --coco-bat /path/to/coco/annotations/instances_train2017.json /path/to/coco/train2017 \
29
+ --extra-dir ../data_extra --keep-empty
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import argparse
35
+ import hashlib
36
+ import io
37
+ import json
38
+ import os
39
+ import random
40
+ import shutil
41
+ import sys
42
+ import time
43
+ from pathlib import Path
44
+ from urllib.parse import quote
45
+ from urllib.request import Request, urlopen
46
+
47
+ import numpy as np
48
+
49
+
50
+ TARGET_CLASS_NAMES = ["balaclava", "bat", "glove", "graffiti", "hoodie", "spray paint"]
51
+ CLASS_TO_ID = {name: i for i, name in enumerate(TARGET_CLASS_NAMES)}
52
+ ELEMENT_ID = "manak0/Detect-crime"
53
+ CONSOLE_API = "https://console.scorevision.io/api/v2"
54
+
55
+ # The current king (`alfred8995/crime001`) emits class ids in their own order:
56
+ # 0 balaclava, 1 hoodie, 2 glove, 3 bat, 4 spray paint, 5 graffiti
57
+ # When we use their ONNX as a silver labeler OR ingest manako API predictions, we
58
+ # remap from alfred's order -> our target (manak0-baseline) order.
59
+ KING_CLASS_ORDER = ["balaclava", "hoodie", "glove", "bat", "spray paint", "graffiti"]
60
+ KING_TO_TARGET_REMAP = np.array(
61
+ [TARGET_CLASS_NAMES.index(n) for n in KING_CLASS_ORDER], dtype=np.int32
62
+ )
63
+ # Sanity: KING_TO_TARGET_REMAP == [0, 4, 2, 1, 5, 3]
64
+
65
+
66
+ # --------------------------------------------------------------------- utils
67
+
68
+ def http_get(url: str, timeout: int = 60, retries: int = 4, headers: dict | None = None) -> bytes:
69
+ last = None
70
+ base_headers = {"User-Agent": "build_dataset/1.0"}
71
+ if headers:
72
+ base_headers.update(headers)
73
+ for i in range(retries):
74
+ try:
75
+ req = Request(url, headers=base_headers)
76
+ with urlopen(req, timeout=timeout) as r:
77
+ return r.read()
78
+ except Exception as e:
79
+ last = e
80
+ if i < retries - 1:
81
+ time.sleep(1.5 * (i + 1))
82
+ raise last # type: ignore
83
+
84
+
85
+ def sha16(b: bytes) -> str:
86
+ return hashlib.sha256(b).hexdigest()[:16]
87
+
88
+
89
+ def image_size(buf: bytes) -> tuple[int, int]:
90
+ if buf[:8] == b"\x89PNG\r\n\x1a\n":
91
+ return int.from_bytes(buf[16:20], "big"), int.from_bytes(buf[20:24], "big")
92
+ if buf[:2] == b"\xff\xd8":
93
+ from PIL import Image
94
+ return Image.open(io.BytesIO(buf)).size
95
+ from PIL import Image
96
+ return Image.open(io.BytesIO(buf)).size
97
+
98
+
99
+ # --------------------------------------------------------------------- sources
100
+
101
+ def fetch_manako(work: Path, log, polls: int = 1, delay_s: int = 180) -> list[tuple[Path, str, dict | None]]:
102
+ """Pull rotating latestAnnotatedChallenge frames + king predictions."""
103
+ out: list[tuple[Path, str, dict | None]] = []
104
+ img_dir = work / "manako_images"
105
+ img_dir.mkdir(parents=True, exist_ok=True)
106
+ seen_paths: set[str] = set()
107
+
108
+ for attempt in range(max(1, polls)):
109
+ try:
110
+ raw = http_get(f"{CONSOLE_API}/elements/{quote(ELEMENT_ID, safe='')}?lookback_days=30")
111
+ except Exception as e:
112
+ log(f"[manako] API call failed (attempt {attempt + 1}/{polls}): {e}")
113
+ if attempt < polls - 1:
114
+ time.sleep(delay_s)
115
+ continue
116
+ elt = json.loads(raw)
117
+ lac = elt.get("latestAnnotatedChallenge")
118
+ if not lac:
119
+ log(f"[manako] no latestAnnotatedChallenge (attempt {attempt + 1}/{polls})")
120
+ if attempt < polls - 1:
121
+ time.sleep(delay_s)
122
+ continue
123
+
124
+ frames = (lac.get("response") or {}).get("frames") or []
125
+ preds = ((lac.get("response") or {}).get("predictions") or {}).get("frames") or []
126
+ preds_by_id = {p.get("frame_id"): p for p in preds}
127
+
128
+ new_count = 0
129
+ for f in frames:
130
+ url = f.get("url")
131
+ fid = f.get("frame_id")
132
+ if not url:
133
+ continue
134
+ try:
135
+ data = http_get(url)
136
+ except Exception as e:
137
+ log(f"[manako] image fetch failed {url}: {e}")
138
+ continue
139
+ digest = sha16(data)
140
+ path = img_dir / f"manako_ch{lac.get('challenge_id')}_f{fid}_{digest}.png"
141
+ if str(path) in seen_paths:
142
+ continue
143
+ seen_paths.add(str(path))
144
+ path.write_bytes(data)
145
+ out.append((path, "manako", preds_by_id.get(fid)))
146
+ new_count += 1
147
+
148
+ log(f"[manako] poll {attempt + 1}/{polls}: +{new_count} (total {len(out)}, "
149
+ f"challenge_id={lac.get('challenge_id')})")
150
+ if attempt < polls - 1:
151
+ time.sleep(delay_s)
152
+
153
+ log(f"[manako] pulled {len(out)} unique image(s) across {polls} poll(s)")
154
+ return out
155
+
156
+
157
+ def fetch_roboflow(slug: str, target_class: str, work: Path, log,
158
+ api_key: str | None = None) -> list[tuple[Path, str, list[str]]]:
159
+ """Download a Roboflow Universe dataset in YOLO format and remap to target class id.
160
+
161
+ `slug` is the workspace/project[/version] path on universe.roboflow.com.
162
+ Without API key, only public/free datasets work; many crime-relevant ones are public.
163
+ """
164
+ target_id = CLASS_TO_ID.get(target_class)
165
+ if target_id is None:
166
+ log(f"[roboflow] unknown target class: {target_class}")
167
+ return []
168
+
169
+ out_dir = work / "roboflow" / slug.replace("/", "_")
170
+ out_dir.mkdir(parents=True, exist_ok=True)
171
+ out: list[tuple[Path, str, list[str]]] = []
172
+
173
+ try:
174
+ from roboflow import Roboflow # type: ignore
175
+ except ImportError:
176
+ log("[roboflow] roboflow SDK not installed; pip install roboflow")
177
+ return []
178
+
179
+ if api_key is None:
180
+ api_key = os.environ.get("ROBOFLOW_API_KEY", "")
181
+ if not api_key:
182
+ log(f"[roboflow] no ROBOFLOW_API_KEY set; skipping {slug}")
183
+ return []
184
+
185
+ parts = slug.split("/")
186
+ if len(parts) < 2:
187
+ log(f"[roboflow] malformed slug: {slug}")
188
+ return []
189
+ ws, project = parts[0], parts[1]
190
+ version = int(parts[2]) if len(parts) >= 3 and parts[2].isdigit() else 1
191
+
192
+ rf = Roboflow(api_key=api_key)
193
+ ds = rf.workspace(ws).project(project).version(version).download("yolov8", location=str(out_dir))
194
+ log(f"[roboflow] downloaded {slug} -> {ds.location}")
195
+
196
+ # Roboflow YOLO export: {ds.location}/{train,valid,test}/images/*, labels/*.txt with their own data.yaml
197
+ rf_yaml = Path(ds.location) / "data.yaml"
198
+ if not rf_yaml.exists():
199
+ log(f"[roboflow] no data.yaml in {ds.location}; skipping")
200
+ return []
201
+
202
+ # Parse names from data.yaml (we accept either yaml or simple key:value).
203
+ try:
204
+ import yaml
205
+ rf_cfg = yaml.safe_load(rf_yaml.read_text())
206
+ except Exception as e:
207
+ log(f"[roboflow] yaml parse failed for {rf_yaml}: {e}")
208
+ return []
209
+ rf_names = rf_cfg.get("names") or []
210
+ if isinstance(rf_names, dict):
211
+ rf_names = [rf_names[k] for k in sorted(rf_names.keys())]
212
+
213
+ for split in ("train", "valid", "test"):
214
+ img_dir = Path(ds.location) / split / "images"
215
+ lbl_dir = Path(ds.location) / split / "labels"
216
+ if not img_dir.exists() or not lbl_dir.exists():
217
+ continue
218
+ for img_path in img_dir.iterdir():
219
+ if not img_path.is_file():
220
+ continue
221
+ lbl_path = lbl_dir / (img_path.stem + ".txt")
222
+ if not lbl_path.exists():
223
+ continue
224
+ try:
225
+ rows = lbl_path.read_text().strip().splitlines()
226
+ except Exception:
227
+ continue
228
+ mapped: list[str] = []
229
+ for r in rows:
230
+ parts = r.split()
231
+ if len(parts) != 5:
232
+ continue
233
+ src_cls = int(parts[0])
234
+ # Single-class roboflow datasets: any positive box -> target class
235
+ if len(rf_names) == 1 or src_cls >= len(rf_names):
236
+ mapped.append(f"{target_id} {parts[1]} {parts[2]} {parts[3]} {parts[4]}")
237
+ continue
238
+ # Multi-class: keep only boxes whose name matches the target (loose match).
239
+ src_name = str(rf_names[src_cls]).lower().replace("_", " ")
240
+ if target_class.lower() in src_name or src_name in target_class.lower():
241
+ mapped.append(f"{target_id} {parts[1]} {parts[2]} {parts[3]} {parts[4]}")
242
+ if not mapped:
243
+ continue
244
+ out.append((img_path, f"roboflow_{slug.replace('/', '_')}", mapped))
245
+
246
+ log(f"[roboflow] {slug}: kept {len(out)} labeled images")
247
+ return out
248
+
249
+
250
+ def fetch_coco_bat(coco_ann_json: Path, coco_img_dir: Path, log) -> list[tuple[Path, str, list[str]]]:
251
+ """Extract baseball-bat images from a COCO 2017 annotations file."""
252
+ target_id = CLASS_TO_ID["bat"]
253
+ out: list[tuple[Path, str, list[str]]] = []
254
+ if not coco_ann_json.exists() or not coco_img_dir.exists():
255
+ log(f"[coco-bat] paths missing: {coco_ann_json}, {coco_img_dir}")
256
+ return out
257
+
258
+ log(f"[coco-bat] reading {coco_ann_json} (this is large; ~30s)...")
259
+ with coco_ann_json.open() as f:
260
+ coco = json.load(f)
261
+
262
+ # COCO category id 39 = "baseball bat" in the 80-class set.
263
+ bat_ids = {c["id"] for c in coco.get("categories", []) if c.get("name", "").lower() == "baseball bat"}
264
+ if not bat_ids:
265
+ log("[coco-bat] no 'baseball bat' category found; aborting")
266
+ return out
267
+
268
+ # Group annotations by image_id.
269
+ images = {im["id"]: im for im in coco.get("images", [])}
270
+ by_img: dict[int, list[dict]] = {}
271
+ for ann in coco.get("annotations", []):
272
+ if ann.get("category_id") in bat_ids:
273
+ by_img.setdefault(ann["image_id"], []).append(ann)
274
+
275
+ for img_id, anns in by_img.items():
276
+ info = images.get(img_id)
277
+ if not info:
278
+ continue
279
+ fname = info["file_name"]
280
+ w, h = info["width"], info["height"]
281
+ img_path = coco_img_dir / fname
282
+ if not img_path.exists():
283
+ continue
284
+ rows = []
285
+ for a in anns:
286
+ x, y, bw, bh = a["bbox"] # COCO xywh top-left
287
+ cx = (x + bw / 2.0) / w
288
+ cy = (y + bh / 2.0) / h
289
+ rows.append(f"{target_id} {cx:.6f} {cy:.6f} {bw / w:.6f} {bh / h:.6f}")
290
+ if rows:
291
+ out.append((img_path, "coco_bat", rows))
292
+ log(f"[coco-bat] kept {len(out)} bat-positive images")
293
+ return out
294
+
295
+
296
+ def fetch_hf_parquet_coco(repo_id: str, filename: str, target_class: str,
297
+ work: Path, log,
298
+ limit: int = 0) -> list[tuple[bytes, str, list[str]]]:
299
+ """Pull a HuggingFace `datasets`-style parquet whose rows have
300
+ `image: {bytes}`, `width`, `height`, `objects: [{bbox: [x,y,w,h], ...}]`
301
+ (graffiti dataset format). Maps every box to TARGET_CLASS_NAMES[target_class].
302
+
303
+ Returns list of (image_bytes, source_tag, yolo_label_rows).
304
+ """
305
+ target_id = CLASS_TO_ID.get(target_class)
306
+ if target_id is None:
307
+ log(f"[hf-parquet-coco] unknown target class: {target_class}")
308
+ return []
309
+ try:
310
+ from huggingface_hub import hf_hub_download
311
+ import pyarrow.parquet as pq
312
+ except ImportError:
313
+ log("[hf-parquet-coco] missing deps; pip install huggingface_hub pyarrow")
314
+ return []
315
+ try:
316
+ local = hf_hub_download(
317
+ repo_id=repo_id, filename=filename,
318
+ repo_type="dataset", cache_dir=str(work / "_hf_cache"),
319
+ )
320
+ except Exception as e:
321
+ log(f"[hf-parquet-coco] download failed {repo_id}/{filename}: {e}")
322
+ return []
323
+ log(f"[hf-parquet-coco] downloaded {repo_id}/{filename}")
324
+ out: list[tuple[bytes, str, list[str]]] = []
325
+ table = pq.read_table(local, columns=["image", "width", "height", "objects"])
326
+ rows = table.to_pylist()
327
+ for row in rows:
328
+ img_dict = row.get("image") or {}
329
+ data = img_dict.get("bytes") if isinstance(img_dict, dict) else None
330
+ if not data:
331
+ continue
332
+ w = int(row.get("width") or 0)
333
+ h = int(row.get("height") or 0)
334
+ if w <= 0 or h <= 0:
335
+ try:
336
+ w, h = image_size(data)
337
+ except Exception:
338
+ continue
339
+ labels: list[str] = []
340
+ for obj in row.get("objects") or []:
341
+ bbox = obj.get("bbox") or []
342
+ if len(bbox) != 4:
343
+ continue
344
+ bx, by, bw, bh = (float(bbox[0]), float(bbox[1]), float(bbox[2]), float(bbox[3]))
345
+ if bw <= 1 or bh <= 1:
346
+ continue
347
+ cx = (bx + bw / 2.0) / w
348
+ cy = (by + bh / 2.0) / h
349
+ labels.append(f"{target_id} {cx:.6f} {cy:.6f} {bw / w:.6f} {bh / h:.6f}")
350
+ if labels:
351
+ out.append((data, f"hf_{repo_id.replace('/', '_')}", labels))
352
+ if limit and len(out) >= limit:
353
+ break
354
+ log(f"[hf-parquet-coco] {repo_id}: kept {len(out)} labeled images")
355
+ return out
356
+
357
+
358
+ def fetch_hf_parquet_cls(repo_id: str, filename: str, target_class: str,
359
+ work: Path, log,
360
+ limit: int = 0) -> list[tuple[bytes, str, None]]:
361
+ """Pull a HuggingFace classification-synset parquet (rows have just
362
+ `image: {bytes}` with optional `label`). Boxes will be filled in later by
363
+ the king's ONNX silver labeler — this fn just extracts the image bytes.
364
+
365
+ `target_class` is purely a tag for downstream filtering; the king labels
366
+ every detection regardless.
367
+ """
368
+ try:
369
+ from huggingface_hub import hf_hub_download
370
+ import pyarrow.parquet as pq
371
+ except ImportError:
372
+ log("[hf-parquet-cls] missing deps; pip install huggingface_hub pyarrow")
373
+ return []
374
+ try:
375
+ local = hf_hub_download(
376
+ repo_id=repo_id, filename=filename,
377
+ repo_type="dataset", cache_dir=str(work / "_hf_cache"),
378
+ )
379
+ except Exception as e:
380
+ log(f"[hf-parquet-cls] download failed {repo_id}/{filename}: {e}")
381
+ return []
382
+ log(f"[hf-parquet-cls] downloaded {repo_id}/{filename}")
383
+ out: list[tuple[bytes, str, None]] = []
384
+ table = pq.read_table(local, columns=["image"])
385
+ for row in table.to_pylist():
386
+ img_dict = row.get("image") or {}
387
+ data = img_dict.get("bytes") if isinstance(img_dict, dict) else None
388
+ if not data:
389
+ continue
390
+ out.append((data, f"hf_{repo_id.replace('/', '_')}_{target_class}", None))
391
+ if limit and len(out) >= limit:
392
+ break
393
+ log(f"[hf-parquet-cls] {repo_id}: extracted {len(out)} images (king will label)")
394
+ return out
395
+
396
+
397
+ def fetch_extra(extra_dir: Path | None, log) -> list[tuple[Path, str, None]]:
398
+ if extra_dir is None or not extra_dir.exists():
399
+ return []
400
+ out: list[tuple[Path, str, None]] = []
401
+ for ext in ("*.jpg", "*.jpeg", "*.png", "*.bmp", "*.webp"):
402
+ for p in extra_dir.rglob(ext):
403
+ out.append((p, "extra", None))
404
+ log(f"[extra] picked up {len(out)} images from {extra_dir}")
405
+ return out
406
+
407
+
408
+ # --------------------------------------------------------------------- king labeler
409
+
410
+ class KingLabeler:
411
+ """Run the king's ONNX over an unlabeled image to produce YOLO-format silver labels.
412
+
413
+ Mirrors alfred8995/crime001's preprocessing: letterbox to native input H/W, RGB / 255,
414
+ NCHW float32. Decodes both [N,6] / [1,N,6] (NMS-baked) and raw [1, C, N] formats with
415
+ proper letterbox-reverse back to original image coords.
416
+ """
417
+
418
+ def __init__(self, onnx_path: Path, conf_floor: float = 0.15, intra_threads: int = 0) -> None:
419
+ import onnxruntime as ort
420
+ sess_options = ort.SessionOptions()
421
+ sess_options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
422
+ if intra_threads > 0:
423
+ sess_options.intra_op_num_threads = int(intra_threads)
424
+ try:
425
+ self.session = ort.InferenceSession(
426
+ str(onnx_path), sess_options=sess_options,
427
+ providers=["CUDAExecutionProvider", "CPUExecutionProvider"],
428
+ )
429
+ except Exception:
430
+ self.session = ort.InferenceSession(
431
+ str(onnx_path), sess_options=sess_options,
432
+ providers=["CPUExecutionProvider"],
433
+ )
434
+ self.input_name = self.session.get_inputs()[0].name
435
+ self.output_names = [o.name for o in self.session.get_outputs()]
436
+ in_shape = self.session.get_inputs()[0].shape
437
+ self.in_h = int(in_shape[2]) if isinstance(in_shape[2], int) and in_shape[2] > 0 else 640
438
+ self.in_w = int(in_shape[3]) if isinstance(in_shape[3], int) and in_shape[3] > 0 else 640
439
+ self.conf_floor = float(conf_floor)
440
+ self.iou_thresh = 0.45
441
+
442
+ @staticmethod
443
+ def _letterbox(image: np.ndarray, new_size: tuple[int, int],
444
+ color: tuple[int, int, int] = (114, 114, 114)
445
+ ) -> tuple[np.ndarray, float, tuple[float, float]]:
446
+ import cv2
447
+ h, w = image.shape[:2]
448
+ new_w, new_h = new_size
449
+ ratio = min(new_w / w, new_h / h)
450
+ rw, rh = int(round(w * ratio)), int(round(h * ratio))
451
+ if (rw, rh) != (w, h):
452
+ interp = cv2.INTER_CUBIC if ratio > 1.0 else cv2.INTER_LINEAR
453
+ image = cv2.resize(image, (rw, rh), interpolation=interp)
454
+ dw = (new_w - rw) / 2.0
455
+ dh = (new_h - rh) / 2.0
456
+ top, bottom = int(round(dh - 0.1)), int(round(dh + 0.1))
457
+ left, right = int(round(dw - 0.1)), int(round(dw + 0.1))
458
+ padded = cv2.copyMakeBorder(image, top, bottom, left, right,
459
+ borderType=cv2.BORDER_CONSTANT, value=color)
460
+ return padded, ratio, (dw, dh)
461
+
462
+ def label(self, image_bytes: bytes) -> list[str]:
463
+ import cv2
464
+ arr = np.frombuffer(image_bytes, dtype=np.uint8)
465
+ bgr = cv2.imdecode(arr, cv2.IMREAD_COLOR)
466
+ if bgr is None:
467
+ return []
468
+ orig_h, orig_w = bgr.shape[:2]
469
+
470
+ padded, ratio, (dw, dh) = self._letterbox(bgr, (self.in_w, self.in_h))
471
+ rgb = cv2.cvtColor(padded, cv2.COLOR_BGR2RGB).astype(np.float32) / 255.0
472
+ x = np.transpose(rgb, (2, 0, 1))[None, ...]
473
+ x = np.ascontiguousarray(x, dtype=np.float32)
474
+
475
+ out = self.session.run(self.output_names, {self.input_name: x})[0]
476
+ return self._decode(out, ratio, dw, dh, orig_w, orig_h)
477
+
478
+ def _decode(self, output: np.ndarray, ratio: float, dw: float, dh: float,
479
+ orig_w: int, orig_h: int) -> list[str]:
480
+ """Decode + reverse-letterbox to original image coords."""
481
+ # NMS-baked: [N, 6] or [1, N, 6]
482
+ if output.ndim == 3 and output.shape[0] == 1 and output.shape[2] == 6:
483
+ preds = output[0]
484
+ elif output.ndim == 2 and output.shape[1] == 6:
485
+ preds = output
486
+ else:
487
+ return self._decode_raw(output, ratio, dw, dh, orig_w, orig_h)
488
+
489
+ boxes = preds[:, :4].astype(np.float32, copy=True)
490
+ scores = preds[:, 4].astype(np.float32)
491
+ cls = preds[:, 5].astype(np.int32)
492
+
493
+ keep = (scores >= self.conf_floor) & (boxes[:, 2] > boxes[:, 0]) & (boxes[:, 3] > boxes[:, 1])
494
+ boxes, scores, cls = boxes[keep], scores[keep], cls[keep]
495
+ if len(boxes) == 0:
496
+ return []
497
+
498
+ # reverse letterbox (input-letterboxed coords -> orig image coords)
499
+ boxes[:, [0, 2]] -= dw
500
+ boxes[:, [1, 3]] -= dh
501
+ boxes /= ratio
502
+ boxes[:, 0] = np.clip(boxes[:, 0], 0, orig_w - 1)
503
+ boxes[:, 1] = np.clip(boxes[:, 1], 0, orig_h - 1)
504
+ boxes[:, 2] = np.clip(boxes[:, 2], 0, orig_w - 1)
505
+ boxes[:, 3] = np.clip(boxes[:, 3], 0, orig_h - 1)
506
+
507
+ return self._to_yolo_rows(boxes, cls, orig_w, orig_h)
508
+
509
+ def _decode_raw(self, output: np.ndarray, ratio: float, dw: float, dh: float,
510
+ orig_w: int, orig_h: int) -> list[str]:
511
+ """Fallback for raw [1, C, N] / [1, N, C] YOLO output (e.g. manak0 baseline)."""
512
+ if output.ndim != 3 or output.shape[0] != 1:
513
+ return []
514
+ p = output[0]
515
+ if p.shape[0] <= 16 and p.shape[1] > p.shape[0]:
516
+ p = p.T
517
+ if p.shape[1] < 5:
518
+ return []
519
+ boxes_xywh = p[:, :4].astype(np.float32)
520
+ cls_part = p[:, 4:].astype(np.float32)
521
+ cls = np.argmax(cls_part, axis=1).astype(np.int32)
522
+ scores = cls_part[np.arange(len(cls_part)), cls]
523
+ keep = scores >= self.conf_floor
524
+ if not np.any(keep):
525
+ return []
526
+ boxes_xywh = boxes_xywh[keep]
527
+ scores = scores[keep]
528
+ cls = cls[keep]
529
+ # xywh (input-letterboxed coords) -> xyxy (input coords)
530
+ xyxy = np.empty((len(boxes_xywh), 4), dtype=np.float32)
531
+ xyxy[:, 0] = boxes_xywh[:, 0] - boxes_xywh[:, 2] / 2.0
532
+ xyxy[:, 1] = boxes_xywh[:, 1] - boxes_xywh[:, 3] / 2.0
533
+ xyxy[:, 2] = boxes_xywh[:, 0] + boxes_xywh[:, 2] / 2.0
534
+ xyxy[:, 3] = boxes_xywh[:, 1] + boxes_xywh[:, 3] / 2.0
535
+ xyxy, scores, cls = self._nms(xyxy, scores, cls, self.iou_thresh)
536
+ if len(xyxy) == 0:
537
+ return []
538
+ # reverse letterbox
539
+ xyxy[:, [0, 2]] -= dw
540
+ xyxy[:, [1, 3]] -= dh
541
+ xyxy /= ratio
542
+ xyxy[:, 0] = np.clip(xyxy[:, 0], 0, orig_w - 1)
543
+ xyxy[:, 1] = np.clip(xyxy[:, 1], 0, orig_h - 1)
544
+ xyxy[:, 2] = np.clip(xyxy[:, 2], 0, orig_w - 1)
545
+ xyxy[:, 3] = np.clip(xyxy[:, 3], 0, orig_h - 1)
546
+ return self._to_yolo_rows(xyxy, cls, orig_w, orig_h)
547
+
548
+ @staticmethod
549
+ def _nms(boxes: np.ndarray, scores: np.ndarray, cls: np.ndarray,
550
+ iou_thresh: float) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
551
+ if len(boxes) == 0:
552
+ return boxes, scores, cls
553
+ order = np.argsort(-scores)
554
+ boxes = boxes[order]; scores = scores[order]; cls = cls[order]
555
+ keep = []
556
+ suppressed = np.zeros(len(boxes), dtype=bool)
557
+ for i in range(len(boxes)):
558
+ if suppressed[i]:
559
+ continue
560
+ keep.append(i)
561
+ xa = np.maximum(boxes[i, 0], boxes[i + 1:, 0])
562
+ ya = np.maximum(boxes[i, 1], boxes[i + 1:, 1])
563
+ xb = np.minimum(boxes[i, 2], boxes[i + 1:, 2])
564
+ yb = np.minimum(boxes[i, 3], boxes[i + 1:, 3])
565
+ inter = np.maximum(0.0, xb - xa) * np.maximum(0.0, yb - ya)
566
+ area_i = (boxes[i, 2] - boxes[i, 0]) * (boxes[i, 3] - boxes[i, 1])
567
+ area_r = (boxes[i + 1:, 2] - boxes[i + 1:, 0]) * (boxes[i + 1:, 3] - boxes[i + 1:, 1])
568
+ ious = inter / (area_i + area_r - inter + 1e-7)
569
+ for k, ok in enumerate(ious >= iou_thresh):
570
+ if ok:
571
+ suppressed[i + 1 + k] = True
572
+ return boxes[keep], scores[keep], cls[keep]
573
+
574
+ @staticmethod
575
+ def _to_yolo_rows(boxes: np.ndarray, cls: np.ndarray, w: int, h: int) -> list[str]:
576
+ # Remap king's class order -> our target order before emitting.
577
+ rows = []
578
+ for (x1, y1, x2, y2), c in zip(boxes, cls):
579
+ if c < 0 or c >= len(KING_TO_TARGET_REMAP):
580
+ continue
581
+ tgt = int(KING_TO_TARGET_REMAP[int(c)])
582
+ bw = float(x2 - x1)
583
+ bh = float(y2 - y1)
584
+ if bw <= 1 or bh <= 1:
585
+ continue
586
+ cx = (float(x1) + float(x2)) / 2.0 / w
587
+ cy = (float(y1) + float(y2)) / 2.0 / h
588
+ rows.append(f"{tgt} {cx:.6f} {cy:.6f} {bw / w:.6f} {bh / h:.6f}")
589
+ return rows
590
+
591
+
592
+ def king_preds_to_yolo(preds: dict, img_w: int, img_h: int) -> list[str]:
593
+ """Manako preds use absolute pixel xyxy + cls_id (in alfred8995's class order).
594
+ Remap to our target order before emitting YOLO rows.
595
+ """
596
+ rows: list[str] = []
597
+ for box in preds.get("boxes") or []:
598
+ try:
599
+ x1, y1, x2, y2 = float(box["x1"]), float(box["y1"]), float(box["x2"]), float(box["y2"])
600
+ c = int(box["cls_id"])
601
+ except Exception:
602
+ continue
603
+ if c < 0 or c >= len(KING_TO_TARGET_REMAP):
604
+ continue
605
+ tgt = int(KING_TO_TARGET_REMAP[c])
606
+ bw = x2 - x1
607
+ bh = y2 - y1
608
+ if bw <= 1 or bh <= 1:
609
+ continue
610
+ cx = (x1 + x2) / 2.0 / img_w
611
+ cy = (y1 + y2) / 2.0 / img_h
612
+ rows.append(f"{tgt} {cx:.6f} {cy:.6f} {bw / img_w:.6f} {bh / img_h:.6f}")
613
+ return rows
614
+
615
+
616
+ # --------------------------------------------------------------------- writer
617
+
618
+ def write_yolo_pair(out_root: Path, split: str, name: str, image_bytes: bytes,
619
+ label_lines: list[str], ext: str) -> None:
620
+ img_dir = out_root / "images" / split
621
+ lbl_dir = out_root / "labels" / split
622
+ img_dir.mkdir(parents=True, exist_ok=True)
623
+ lbl_dir.mkdir(parents=True, exist_ok=True)
624
+ (img_dir / f"{name}.{ext}").write_bytes(image_bytes)
625
+ (lbl_dir / f"{name}.txt").write_text("\n".join(label_lines), encoding="utf-8")
626
+
627
+
628
+ def write_data_yaml(out_root: Path) -> Path:
629
+ data_yaml = out_root / "data.yaml"
630
+ data_yaml.write_text(
631
+ "# YOLO dataset for manak0/Detect-crime\n"
632
+ f"path: {out_root.resolve()}\n"
633
+ "train: images/train\n"
634
+ "val: images/val\n"
635
+ "names:\n"
636
+ + "".join(f" {i}: {n}\n" for i, n in enumerate(TARGET_CLASS_NAMES)),
637
+ encoding="utf-8",
638
+ )
639
+ return data_yaml
640
+
641
+
642
+ # --------------------------------------------------------------------- main
643
+
644
+ def main() -> int:
645
+ ap = argparse.ArgumentParser()
646
+ ap.add_argument("--out", required=True, help="dataset output root (YOLO format)")
647
+ ap.add_argument("--king-onnx", required=True,
648
+ help="path to king's ONNX (used as silver labeler for unlabeled frames)")
649
+ ap.add_argument("--manako", action="store_true", help="poll manako latestAnnotatedChallenge")
650
+ ap.add_argument("--manako-polls", type=int, default=1)
651
+ ap.add_argument("--manako-poll-delay", type=int, default=180)
652
+ ap.add_argument("--roboflow", action="append", default=[],
653
+ help="repeatable: TARGETCLASS=workspace/project[/version]; "
654
+ "e.g. balaclava=brainster/balaclava-detection-v3")
655
+ ap.add_argument("--coco-bat", nargs=2, metavar=("ANN_JSON", "IMG_DIR"),
656
+ help="paths to COCO instances_train2017.json and train2017/")
657
+ ap.add_argument("--extra-dir", type=Path, default=None,
658
+ help="optional dir of pre-collected images (auto-labeled by king)")
659
+ ap.add_argument("--hf-parquet-coco", action="append", default=[],
660
+ help="repeatable: TARGETCLASS=repo_id::filename (COCO-bbox parquet); "
661
+ "e.g. graffiti=junaidfayazlone/GraffitiDetection::Train-00000-of-00001-graffiti.parquet")
662
+ ap.add_argument("--hf-parquet-cls", action="append", default=[],
663
+ help="repeatable: TARGETCLASS=repo_id::filename (classification parquet, "
664
+ "king-labeled); e.g. balaclava=mlnomad/imnet1k_ski_mask::data/train-00000-of-00001.parquet")
665
+ ap.add_argument("--hf-limit", type=int, default=0,
666
+ help="cap rows pulled from each HF parquet (0 = all)")
667
+ ap.add_argument("--min-conf", type=float, default=0.15,
668
+ help="confidence floor for the silver labeler (lower = more labels, more noise)")
669
+ ap.add_argument("--workdir", type=Path, default=Path("/tmp/crime_dataset_work"))
670
+ ap.add_argument("--val-frac", type=float, default=0.10)
671
+ ap.add_argument("--seed", type=int, default=42)
672
+ ap.add_argument("--limit", type=int, default=0,
673
+ help="cap images per source (0 = no cap)")
674
+ ap.add_argument("--intra-threads", type=int, default=0)
675
+ ap.add_argument("--keep-empty", action="store_true",
676
+ help="keep images with no detected boxes (negative-bg supervision)")
677
+ args = ap.parse_args()
678
+
679
+ out_root = Path(args.out).resolve()
680
+ work = args.workdir.resolve()
681
+ work.mkdir(parents=True, exist_ok=True)
682
+
683
+ def log(msg: str) -> None:
684
+ print(msg, file=sys.stderr, flush=True)
685
+
686
+ # Sources collected as (path_or_bytes, src_tag, labels_or_preds_or_None).
687
+ # labels can be: list[str] YOLO rows (already mapped), dict (king preds), or None (needs labeling).
688
+ sources: list[tuple[Path, str, list[str] | dict | None]] = []
689
+
690
+ if args.manako:
691
+ for img_path, src, preds in fetch_manako(work, log,
692
+ polls=args.manako_polls,
693
+ delay_s=args.manako_poll_delay):
694
+ sources.append((img_path, src, preds))
695
+
696
+ for spec in args.roboflow:
697
+ if "=" not in spec:
698
+ log(f"[roboflow] malformed --roboflow {spec}; expected TARGETCLASS=slug")
699
+ continue
700
+ target, slug = spec.split("=", 1)
701
+ for img_path, src, labels in fetch_roboflow(slug.strip(), target.strip(), work, log):
702
+ sources.append((img_path, src, labels))
703
+
704
+ if args.coco_bat:
705
+ coco_ann, coco_img = Path(args.coco_bat[0]), Path(args.coco_bat[1])
706
+ for img_path, src, labels in fetch_coco_bat(coco_ann, coco_img, log):
707
+ sources.append((img_path, src, labels))
708
+
709
+ # HF parquet sources land as (bytes, src, labels). Persist to disk under work
710
+ # so the rest of the pipeline (which expects file paths) can pick them up.
711
+ hf_dir = work / "hf_images"
712
+ hf_dir.mkdir(parents=True, exist_ok=True)
713
+
714
+ for spec in args.hf_parquet_coco:
715
+ if "=" not in spec or "::" not in spec:
716
+ log(f"[hf-parquet-coco] malformed {spec}; expected TARGETCLASS=repo::filename")
717
+ continue
718
+ target, rest = spec.split("=", 1)
719
+ repo_id, filename = rest.split("::", 1)
720
+ for data, src, labels in fetch_hf_parquet_coco(repo_id.strip(), filename.strip(),
721
+ target.strip(), work, log,
722
+ limit=args.hf_limit):
723
+ digest = sha16(data)
724
+ ext = "jpg" if data[:2] == b"\xff\xd8" else "png"
725
+ p = hf_dir / f"{src}_{digest}.{ext}"
726
+ if not p.exists():
727
+ p.write_bytes(data)
728
+ sources.append((p, src, labels))
729
+
730
+ for spec in args.hf_parquet_cls:
731
+ if "=" not in spec or "::" not in spec:
732
+ log(f"[hf-parquet-cls] malformed {spec}; expected TARGETCLASS=repo::filename")
733
+ continue
734
+ target, rest = spec.split("=", 1)
735
+ repo_id, filename = rest.split("::", 1)
736
+ for data, src, _ in fetch_hf_parquet_cls(repo_id.strip(), filename.strip(),
737
+ target.strip(), work, log,
738
+ limit=args.hf_limit):
739
+ digest = sha16(data)
740
+ ext = "jpg" if data[:2] == b"\xff\xd8" else "png"
741
+ p = hf_dir / f"{src}_{digest}.{ext}"
742
+ if not p.exists():
743
+ p.write_bytes(data)
744
+ sources.append((p, src, None))
745
+
746
+ if args.extra_dir:
747
+ for img_path, src, _ in fetch_extra(args.extra_dir, log):
748
+ sources.append((img_path, src, None))
749
+
750
+ if not sources:
751
+ log("[!] no sources; pass at least --manako / --roboflow / --coco-bat / --extra-dir")
752
+ return 1
753
+
754
+ if args.limit:
755
+ sources = sources[: args.limit * 8] # rough cap; we'll dedupe below
756
+ log(f"[build] total candidate sources: {len(sources)}")
757
+
758
+ # Auto-labeler for sources that come unlabeled.
759
+ labeler = KingLabeler(Path(args.king_onnx), conf_floor=args.min_conf,
760
+ intra_threads=args.intra_threads)
761
+ log(f"[label] king onnx input={labeler.in_w}x{labeler.in_h} "
762
+ f"providers={labeler.session.get_providers()}")
763
+
764
+ samples: list[tuple[str, bytes, list[str], str]] = []
765
+ seen_hashes: set[str] = set()
766
+ n_labeled = 0
767
+ for i, (path, src, payload) in enumerate(sources):
768
+ try:
769
+ data = path.read_bytes()
770
+ except Exception as e:
771
+ log(f"[skip] read failed {path}: {e}")
772
+ continue
773
+ digest = sha16(data)
774
+ if digest in seen_hashes:
775
+ continue
776
+ seen_hashes.add(digest)
777
+ ext = "jpg" if data[:2] == b"\xff\xd8" else "png"
778
+
779
+ labels: list[str]
780
+ if isinstance(payload, list):
781
+ labels = payload # already remapped YOLO rows
782
+ elif isinstance(payload, dict):
783
+ try:
784
+ w, h = image_size(data)
785
+ labels = king_preds_to_yolo(payload, w, h)
786
+ except Exception as e:
787
+ log(f"[label] manako preds parse failed {path}: {e}; falling back to ONNX")
788
+ labels = labeler.label(data)
789
+ else:
790
+ try:
791
+ labels = labeler.label(data)
792
+ except Exception as e:
793
+ log(f"[label] ONNX labeling failed {path}: {e}")
794
+ continue
795
+
796
+ if not labels and not args.keep_empty:
797
+ continue
798
+ if labels:
799
+ n_labeled += 1
800
+ name = f"{src}_{digest}"
801
+ samples.append((name, data, labels, src))
802
+ if (i + 1) % 200 == 0:
803
+ log(f" labeled {i + 1}/{len(sources)} (kept {len(samples)})")
804
+
805
+ log(f"[build] labeled {n_labeled}/{len(sources)} unique images (others were empty)")
806
+
807
+ if not samples:
808
+ log("[!] zero labeled samples; lower --min-conf and retry")
809
+ return 1
810
+
811
+ rng = random.Random(args.seed)
812
+ manako_samples = [s for s in samples if s[3] == "manako"]
813
+ other_samples = [s for s in samples if s[3] != "manako"]
814
+ rng.shuffle(other_samples)
815
+
816
+ n_val_target = max(1, int(round(len(samples) * args.val_frac)))
817
+ val: list = list(manako_samples)
818
+ if len(val) < n_val_target:
819
+ val.extend(other_samples[: n_val_target - len(val)])
820
+ train = other_samples[n_val_target - len(manako_samples):]
821
+ else:
822
+ train = other_samples
823
+ log(f"[split] train={len(train)} val={len(val)} (manako-in-val={len(manako_samples)})")
824
+
825
+ if out_root.exists():
826
+ for sub in ("images/train", "images/val", "labels/train", "labels/val"):
827
+ d = out_root / sub
828
+ if d.exists():
829
+ shutil.rmtree(d)
830
+ out_root.mkdir(parents=True, exist_ok=True)
831
+
832
+ for name, data, labels, _src in train:
833
+ ext = "jpg" if data[:2] == b"\xff\xd8" else "png"
834
+ write_yolo_pair(out_root, "train", name, data, labels, ext)
835
+ for name, data, labels, _src in val:
836
+ ext = "jpg" if data[:2] == b"\xff\xd8" else "png"
837
+ write_yolo_pair(out_root, "val", name, data, labels, ext)
838
+
839
+ yaml_path = write_data_yaml(out_root)
840
+ log(f"[done] wrote dataset to {out_root}")
841
+ log(f" data.yaml: {yaml_path}")
842
+ log(f" train images: {len(train)}, val images: {len(val)}")
843
+
844
+ cls_train = [0] * len(TARGET_CLASS_NAMES)
845
+ cls_val = [0] * len(TARGET_CLASS_NAMES)
846
+ for _n, _b, labs, _s in train:
847
+ for line in labs:
848
+ cls_train[int(line.split()[0])] += 1
849
+ for _n, _b, labs, _s in val:
850
+ for line in labs:
851
+ cls_val[int(line.split()[0])] += 1
852
+ log("[stats] per-class instance counts")
853
+ for i, n in enumerate(TARGET_CLASS_NAMES):
854
+ log(f" {i} {n:13s} train={cls_train[i]:6d} val={cls_val[i]:6d}")
855
+
856
+ return 0
857
+
858
+
859
+ if __name__ == "__main__":
860
+ raise SystemExit(main())