Chris Leo commited on
scorevision: push artifact
Browse files- 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())
|