uv-scripts/object-detection
Object Detection Dataset Scripts 8 scripts to create, convert, review, validate, inspect, diff, and sample object detection datasets on the Hub. Supports 6 bbox formats — no setup required. Start from nothing: falcon-perception.py generates a first-pass detection dataset for any class you can name, zero-shot, with no labelling and no training. The other six then convert, check, and measure it. This repository is inspired by panlabel Quick Start Convert bounding… See the full description on the dataset page: https://huggingface.co/datasets/uv-scripts/object-detection.
10169
1#!/usr/bin/env -S uv run --script2# /// script3# requires-python = ">=3.10"4# dependencies = [5# "gradio>=6,<7",6# "fastapi",7# "datasets>=4.5.0",8# "pillow",9# ]10# ///11"""Human triage for a detection dataset -> an accept/reject verdict per image or per box.12 13A minimal keyboard-first review UI for datasets in THIS DIRECTORY'S schema14(yolo-normalized `objects.bbox` + `image`, `image_id`, `width`, `height` -- what15falcon-perception.py pushes; run convert-hf-dataset.py first for anything else).16Zero-shot teacher labels are suggestions, not ground truth: this is where a17human turns them into something you can quote. Runs locally, opens your18browser, journals every decision, pushes the reviewed rows back to the Hub.19 20 # quick first pass: accept/reject whole images (A / R keys) on a random sample21 uv run review-detections.py you/plates-illustrations --limit 200 \22 --out you/plates-illustrations-reviewed23 24 # detail pass: click boxes to reject them individually25 uv run review-detections.py you/plates-illustrations --mode boxes \26 --out you/plates-illustrations-reviewed27 28Modes:29 quick whole-image verdict. A=accept R=reject M=accept-but-teacher-missed-something30 F=finish arrows=skip/back. Defaults to RANDOM order, so the summary's31 acceptance rate is an unbiased sample statistic you can quote.32 boxes click a box to toggle it rejected; A keeps the rest, R rejects the whole33 image (all boxes). Defaults to rectangularity-ASCENDING order (irregular34 instances first) -- best use of effort, but a biased sample: the summary35 says so and its rate should not be quoted.36 37Two numbers come out, measuring two different things: the ACCEPTANCE rate (are38the boxes that were drawn correct?) and the MISSED rate (how often did the39teacher skip an instance? -- the M key). Quote them separately; neither implies40the other.41 42The journal (default ./review-<dataset>-<split>.jsonl) is appended per decision43and tolerates a torn final line; re-running resumes at the first undecided image.44--out pushes decided rows with a `review` column ({verdict, missed, box_keep,45mode}) alongside the original schema.46"""47 48import argparse49import io50import json51import os52import random53import signal54import threading55 56from fastapi.responses import HTMLResponse, Response57 58DISPLAY_W = 98059 60PAGE = """<!doctype html>61<title>review-detections</title>62<style>63 body { margin:0; background:#181818; color:#ddd; font:14px system-ui; }64 #bar { padding:8px 14px; display:flex; gap:18px; align-items:center; }65 #bar b { color:#fff; } #keys { color:#888; margin-left:auto; }66 #stage { position:relative; margin:0 auto; width:max-content; }67 #img { display:block; }68 .box { position:absolute; border:3px solid #ffd200; cursor:pointer; }69 .box.rej { border-color:#f33; border-style:dashed; }70 #flash { position:fixed; inset:0; display:none; align-items:center; justify-content:center;71 font-size:80px; pointer-events:none; }72 #err { display:none; padding:6px 14px; background:#611; color:#fbb; }73</style>74<div id=bar><b id=pos></b><span id=stats></span><span id=verdict></span><span id=keys></span></div>75<div id=err></div>76<div id=stage><img id=img><div id=boxes></div></div>77<div id=flash></div>78<script>79const MODE = "__MODE__"; // substituted by the server80document.getElementById("keys").textContent =81 MODE === "quick" ? "A accept · R reject · M missed · ←/→ move · F finish"82 : "click box = reject it · A accept rest · R reject all · M missed · ←/→ · F finish";83let idx = 0, meta = null, rejected = new Set();84 85async function load(i) {86 const r = await fetch(`/meta/${i}`);87 if (!r.ok) return;88 meta = await r.json();89 idx = meta.idx; rejected = new Set(meta.rejected_boxes);90 document.getElementById("img").src = `/img/${idx}`;91 document.getElementById("pos").textContent = `${idx + 1} / ${meta.total}`;92 document.getElementById("stats").textContent = meta.stats;93 document.getElementById("verdict").textContent = meta.verdict ? `decided: ${meta.verdict}` : "";94 const holder = document.getElementById("boxes");95 holder.innerHTML = "";96 meta.boxes.forEach(([x0, y0, x1, y1], j) => {97 const d = document.createElement("div");98 d.className = "box" + (rejected.has(j) ? " rej" : "");99 Object.assign(d.style, {left: x0 + "px", top: y0 + "px",100 width: (x1 - x0) + "px", height: (y1 - y0) + "px"});101 if (MODE === "boxes") d.onclick = () => { rejected.has(j) ? rejected.delete(j) : rejected.add(j);102 d.classList.toggle("rej"); };103 holder.appendChild(d);104 });105}106function flash(t, c) {107 const f = document.getElementById("flash");108 f.textContent = t; f.style.color = c; f.style.display = "flex";109 setTimeout(() => f.style.display = "none", 180);110}111async function decide(verdict, missed) {112 if (!meta) return;113 const box_keep = verdict === "reject" ? meta.boxes.map(() => false)114 : meta.boxes.map((_, j) => !rejected.has(j));115 const r = await fetch("/decide", {method: "POST", headers: {"Content-Type": "application/json"},116 body: JSON.stringify({idx, verdict, missed, box_keep, mode: MODE})});117 if (!r.ok) { // do NOT advance on failure -- the journal write did not happen118 const e = document.getElementById("err");119 e.textContent = `decision NOT saved (server error ${r.status}) — fix the problem and retry`;120 e.style.display = "block";121 return;122 }123 document.getElementById("err").style.display = "none";124 flash(verdict === "accept" ? (missed ? "+?" : "✓") : "✗",125 verdict === "accept" ? (missed ? "#fa3" : "#3c3") : "#f33");126 load(idx + 1);127}128document.addEventListener("keydown", (e) => {129 if (e.key === "ArrowRight") load(idx + 1);130 else if (e.key === "ArrowLeft") load(idx - 1);131 else if (e.key === "a" || e.key === "A") decide("accept", false);132 else if (e.key === "r" || e.key === "R") decide("reject", false);133 else if (e.key === "m" || e.key === "M") decide("accept", true);134 else if (e.key === "f" || e.key === "F") {135 fetch("/finish", {method: "POST"});136 document.getElementById("keys").textContent = "finished — see the terminal; you can close this tab";137 }138});139fetch("/start").then(r => r.json()).then(d => load(d.start));140</script>141"""142 143 144def to_display_boxes(objects, width, height, scale):145 out = []146 for cx, cy, w, h in objects["bbox"]:147 x0 = (cx - w / 2) * width * scale148 y0 = (cy - h / 2) * height * scale149 out.append([round(x0), round(y0), round(x0 + w * width * scale), round(y0 + h * height * scale)])150 return out151 152 153def main():154 p = argparse.ArgumentParser()155 p.add_argument("dataset")156 p.add_argument("--split", default="train")157 p.add_argument("--mode", default="quick", choices=["quick", "boxes"])158 p.add_argument("--order", default=None, choices=["random", "rect"],159 help="default: random in quick mode (unbiased rate), rect in boxes mode")160 p.add_argument("--limit", type=int, default=None)161 p.add_argument("--seed", type=int, default=42)162 p.add_argument("--journal", default=None,163 help="default: ./review-<dataset>-<split>.jsonl (scoped so runs don't mix)")164 p.add_argument("--out", default=None, help="Hub repo id for the reviewed dataset")165 p.add_argument("--private", action="store_true")166 p.add_argument("--port", type=int, default=7860)167 args = p.parse_args()168 order = args.order or ("random" if args.mode == "quick" else "rect")169 journal_path = args.journal or f"./review-{args.dataset.replace('/', '--')}-{args.split}.jsonl"170 171 from datasets import Sequence, Value, load_dataset172 173 ds = load_dataset(args.dataset, split=args.split)174 175 missing = [c for c in ("image", "image_id", "width", "height", "objects") if c not in ds.column_names]176 if missing:177 raise SystemExit(f"dataset is missing column(s) {missing} -- this tool reads the schema "178 "falcon-perception.py pushes; see the docstring.")179 180 # a lightweight view for sorting and sniffing that never decodes the image column181 meta_rows = ds.select_columns(["objects"])[:]["objects"]182 for objects in meta_rows[: min(50, len(meta_rows))]:183 if any(not (0 <= v <= 1.5) for box in objects["bbox"] for v in box):184 raise SystemExit("objects.bbox does not look yolo-normalized (values outside [0,1]) -- "185 "run convert-hf-dataset.py --to yolo first.")186 187 ids = list(range(len(ds)))188 if order == "random":189 random.Random(args.seed).shuffle(ids)190 elif "rectangularity" not in meta_rows[0]:191 print("no rectangularity column -- falling back to random order", flush=True)192 random.Random(args.seed).shuffle(ids)193 else:194 ids.sort(key=lambda i: min(meta_rows[i]["rectangularity"]) if meta_rows[i]["rectangularity"] else 2.0)195 if args.limit:196 ids = ids[: args.limit]197 198 # decisions are keyed by DATASET ROW INDEX -- image_id repeats across199 # concatenated per-class runs, so it cannot key a decision200 decisions = {}201 if os.path.exists(journal_path):202 with open(journal_path) as f:203 for line in f:204 line = line.strip()205 if not line:206 continue207 try:208 rec = json.loads(line)209 except json.JSONDecodeError: # torn final line from a crash mid-append210 print("journal: skipped one torn line (crash recovery)", flush=True)211 continue212 decisions[rec["row"]] = rec213 print(f"resumed {len(decisions)} decisions from {journal_path}", flush=True)214 215 img_cache = {}216 217 def render(i):218 if i not in img_cache:219 im = ds[ids[i]]["image"].convert("RGB")220 scale = min(DISPLAY_W / im.width, 1.0)221 if scale < 1.0:222 im = im.resize((round(im.width * scale), round(im.height * scale)))223 buf = io.BytesIO()224 im.save(buf, format="JPEG", quality=88)225 img_cache[i] = (buf.getvalue(), scale)226 if len(img_cache) > 32:227 img_cache.pop(next(iter(img_cache)))228 return img_cache[i]229 230 def stats_line():231 n = len(decisions)232 if not n:233 return ""234 acc = sum(1 for d in decisions.values() if d["verdict"] == "accept")235 mis = sum(1 for d in decisions.values() if d["missed"])236 return f"{n} decided · {acc / n:.0%} accepted · {mis} missed-flagged"237 238 import gradio as gr239 240 app = gr.Server(title="review-detections")241 done = threading.Event()242 243 @app.get("/", response_class=HTMLResponse)244 def page() -> str:245 return PAGE.replace("__MODE__", args.mode)246 247 @app.get("/start")248 def start() -> dict:249 first = next((i for i in range(len(ids)) if ids[i] not in decisions), 0)250 return {"start": first}251 252 @app.get("/img/{i}")253 def img(i: int) -> Response:254 if not 0 <= i < len(ids):255 return Response(status_code=404)256 return Response(content=render(i)[0], media_type="image/jpeg")257 258 @app.get("/meta/{i}")259 def meta(i: int) -> Response:260 if not 0 <= i < len(ids):261 return Response(status_code=404)262 row = ds[ids[i]]263 _, scale = render(i)264 prior = decisions.get(ids[i])265 payload = {266 "idx": i, "total": len(ids),267 "boxes": to_display_boxes(row["objects"], row["width"], row["height"], scale),268 "verdict": prior["verdict"] if prior else None,269 "rejected_boxes": [j for j, k in enumerate(prior["box_keep"]) if not k] if prior else [],270 "stats": stats_line(),271 }272 return Response(content=json.dumps(payload), media_type="application/json")273 274 @app.post("/decide")275 def decide(body: dict) -> dict:276 if not 0 <= body.get("idx", -1) < len(ids):277 return Response(status_code=400)278 row_idx = ids[body["idx"]]279 rec = {280 "row": row_idx, "image_id": ds[row_idx]["image_id"],281 "dataset": args.dataset, "split": args.split,282 "mode": body["mode"], "order": order, "verdict": body["verdict"],283 "missed": bool(body.get("missed")), "box_keep": [bool(b) for b in body.get("box_keep", [])],284 }285 with open(journal_path, "a") as f: # journal FIRST -- only report saved if it is286 f.write(json.dumps(rec) + "\n")287 f.flush()288 os.fsync(f.fileno())289 decisions[row_idx] = rec290 return {"n": len(decisions)}291 292 @app.post("/finish")293 def finish() -> dict:294 done.set()295 return {"ok": True}296 297 print(f"open http://127.0.0.1:{args.port}/ (F in the browser, or Ctrl-C here, to finish)", flush=True)298 app.launch(server_port=args.port, inbrowser=True, quiet=True, prevent_thread_lock=True)299 signal.signal(signal.SIGINT, lambda *_: done.set()) # gradio installs its own handler; override AFTER launch300 done.wait() # review happens in the browser301 signal.signal(signal.SIGINT, signal.default_int_handler) # Ctrl-C must work again (e.g. to abort the push)302 303 n = len(decisions)304 if not n:305 print("no decisions made", flush=True)306 return307 acc = sum(1 for d in decisions.values() if d["verdict"] == "accept")308 mis = sum(1 for d in decisions.values() if d["missed"])309 quotable = order == "random" and all(d["order"] == "random" for d in decisions.values())310 print(f"\n{n} decided · {acc} accepted ({acc / n:.0%}) · {mis} with missed instances ({mis / n:.0%})",311 flush=True)312 print("acceptance rate is " + ("an unbiased random-order sample -- quotable"313 if quotable else "from a non-random or mixed-order queue -- NOT quotable"),314 flush=True)315 316 if args.out:317 rows = sorted(decisions)318 reviewed = ds.select(rows)319 feats = reviewed.features.copy()320 feats["review"] = {"verdict": Value("string"), "missed": Value("bool"),321 "mode": Value("string"), "box_keep": Sequence(Value("bool"))}322 reviewed = reviewed.map(323 lambda r, i: {"review": {k: decisions[rows[i]][k] for k in ("verdict", "missed", "mode", "box_keep")}},324 with_indices=True, features=feats,325 )326 reviewed.push_to_hub(args.out, private=args.private)327 print(f"{len(reviewed)} reviewed rows -> {args.out}", flush=True)328 329 330main()331 