CoolFace
Datasetpublic

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.

sourceHugging Faceupdated 27d agoView on Hugging Face
10likes169downloads
review-detections.py331 linesDownload Raw Back to root
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