Image Feature Extraction
MLX
English
data-label-factory
vision
dataset-labeling
object-detection
apple-silicon
gemma
falcon-perception
openrouter
yolo
Instructions to use waltgrace/data-label-factory with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- MLX
How to use waltgrace/data-label-factory with MLX:
# Download the model from the Hub pip install huggingface_hub[hf_xet] huggingface-cli download --local-dir data-label-factory waltgrace/data-label-factory
- Notebooks
- Google Colab
- Kaggle
- Local Apps Settings
- LM Studio
feat: web UI label page, Falcon Perception MLX, API server
Browse files- data_label_factory/serve.py +364 -0
- research/v2-expert-sniper-integration.md +105 -3
- web/app/api/dlf/route.ts +29 -0
- web/app/label/page.tsx +574 -0
data_label_factory/serve.py
ADDED
|
@@ -0,0 +1,364 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
serve.py — REST API server for data-label-factory.
|
| 3 |
+
|
| 4 |
+
The web UI frontend calls this server to run the labeling pipeline.
|
| 5 |
+
Wraps the same logic as the MCP tools but over HTTP.
|
| 6 |
+
|
| 7 |
+
Usage:
|
| 8 |
+
python3 -m data_label_factory.serve --port 8400
|
| 9 |
+
|
| 10 |
+
Endpoints:
|
| 11 |
+
GET /api/providers — list registered providers + status
|
| 12 |
+
POST /api/auto — create project from samples + description
|
| 13 |
+
POST /api/filter — filter a single image
|
| 14 |
+
POST /api/label — label a single image
|
| 15 |
+
POST /api/verify — verify a bbox crop
|
| 16 |
+
POST /api/score — score a COCO annotation file
|
| 17 |
+
POST /api/upload — upload sample images
|
| 18 |
+
GET /api/experiments — list experiments
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
from __future__ import annotations
|
| 22 |
+
|
| 23 |
+
import base64
|
| 24 |
+
import io
|
| 25 |
+
import json
|
| 26 |
+
import os
|
| 27 |
+
import shutil
|
| 28 |
+
import tempfile
|
| 29 |
+
import time
|
| 30 |
+
import traceback
|
| 31 |
+
from datetime import datetime
|
| 32 |
+
from pathlib import Path
|
| 33 |
+
from typing import Any
|
| 34 |
+
|
| 35 |
+
try:
|
| 36 |
+
from fastapi import FastAPI, UploadFile, File, Form, HTTPException
|
| 37 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 38 |
+
from fastapi.responses import JSONResponse, FileResponse
|
| 39 |
+
from fastapi.staticfiles import StaticFiles
|
| 40 |
+
except ImportError:
|
| 41 |
+
raise ImportError("FastAPI required: pip install fastapi uvicorn python-multipart")
|
| 42 |
+
|
| 43 |
+
app = FastAPI(title="data-label-factory", version="0.2.0")
|
| 44 |
+
|
| 45 |
+
app.add_middleware(
|
| 46 |
+
CORSMiddleware,
|
| 47 |
+
allow_origins=["*"],
|
| 48 |
+
allow_methods=["*"],
|
| 49 |
+
allow_headers=["*"],
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
UPLOAD_DIR = Path(os.environ.get("DLF_UPLOAD_DIR", "/tmp/dlf-uploads"))
|
| 53 |
+
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
# ─── Providers ──────────────────────────────────────────────
|
| 57 |
+
|
| 58 |
+
@app.get("/api/providers")
|
| 59 |
+
def get_providers():
|
| 60 |
+
from .providers import list_providers, create_provider
|
| 61 |
+
results = []
|
| 62 |
+
for name in list_providers():
|
| 63 |
+
try:
|
| 64 |
+
p = create_provider(name)
|
| 65 |
+
st = p.status()
|
| 66 |
+
results.append({
|
| 67 |
+
"name": name,
|
| 68 |
+
"alive": st.get("alive", False),
|
| 69 |
+
"capabilities": sorted(p.capabilities),
|
| 70 |
+
"info": str(st.get("info", ""))[:200],
|
| 71 |
+
})
|
| 72 |
+
except Exception as e:
|
| 73 |
+
results.append({"name": name, "alive": False, "error": str(e)})
|
| 74 |
+
return {"providers": results}
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
# ─── Auto project ──────────────────────────────────────────
|
| 78 |
+
|
| 79 |
+
@app.post("/api/auto")
|
| 80 |
+
async def auto_project(
|
| 81 |
+
description: str = Form(...),
|
| 82 |
+
samples: list[UploadFile] = File(default=[]),
|
| 83 |
+
):
|
| 84 |
+
"""Create a project from uploaded samples + description."""
|
| 85 |
+
# Save uploaded files
|
| 86 |
+
session_id = f"session_{int(time.time())}"
|
| 87 |
+
session_dir = UPLOAD_DIR / session_id
|
| 88 |
+
session_dir.mkdir(parents=True, exist_ok=True)
|
| 89 |
+
|
| 90 |
+
saved_paths = []
|
| 91 |
+
for f in samples:
|
| 92 |
+
dest = session_dir / f.filename
|
| 93 |
+
with open(dest, "wb") as out:
|
| 94 |
+
out.write(await f.read())
|
| 95 |
+
saved_paths.append(str(dest))
|
| 96 |
+
|
| 97 |
+
from .auto import auto_project as _auto, detect_content_type
|
| 98 |
+
content_type = detect_content_type(description)
|
| 99 |
+
|
| 100 |
+
try:
|
| 101 |
+
config = _auto(
|
| 102 |
+
samples=str(session_dir) if saved_paths else [],
|
| 103 |
+
description=description,
|
| 104 |
+
output="",
|
| 105 |
+
analyze=False, # skip VLM analysis for speed in web UI
|
| 106 |
+
)
|
| 107 |
+
except Exception as e:
|
| 108 |
+
raise HTTPException(500, str(e))
|
| 109 |
+
|
| 110 |
+
return {
|
| 111 |
+
"session_id": session_id,
|
| 112 |
+
"content_type": content_type,
|
| 113 |
+
"config": config,
|
| 114 |
+
"n_samples": len(saved_paths),
|
| 115 |
+
"sample_dir": str(session_dir),
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
# ─── Filter ────────────────────────────────────────────────
|
| 120 |
+
|
| 121 |
+
@app.post("/api/filter")
|
| 122 |
+
async def filter_image(
|
| 123 |
+
image: UploadFile = File(...),
|
| 124 |
+
prompt: str = Form(default="Does this image show the target object? Answer YES or NO."),
|
| 125 |
+
backend: str = Form(default="qwen"),
|
| 126 |
+
):
|
| 127 |
+
"""Filter a single image via a VLM backend."""
|
| 128 |
+
from .providers import create_provider
|
| 129 |
+
|
| 130 |
+
# Save temp file
|
| 131 |
+
suffix = Path(image.filename).suffix or ".jpg"
|
| 132 |
+
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False, dir=str(UPLOAD_DIR)) as f:
|
| 133 |
+
f.write(await image.read())
|
| 134 |
+
tmp_path = f.name
|
| 135 |
+
|
| 136 |
+
try:
|
| 137 |
+
provider = create_provider(backend)
|
| 138 |
+
result = provider.filter_image(tmp_path, prompt)
|
| 139 |
+
return {
|
| 140 |
+
"verdict": result.verdict,
|
| 141 |
+
"raw_answer": result.raw_answer,
|
| 142 |
+
"elapsed": round(result.elapsed, 2),
|
| 143 |
+
"confidence": result.confidence,
|
| 144 |
+
"backend": backend,
|
| 145 |
+
}
|
| 146 |
+
except Exception as e:
|
| 147 |
+
raise HTTPException(500, str(e))
|
| 148 |
+
finally:
|
| 149 |
+
os.unlink(tmp_path)
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
# ─── Label ──────────���──────────────────────────────────────
|
| 153 |
+
|
| 154 |
+
@app.post("/api/label")
|
| 155 |
+
async def label_image(
|
| 156 |
+
image: UploadFile = File(...),
|
| 157 |
+
queries: str = Form(default="object"),
|
| 158 |
+
backend: str = Form(default="falcon"),
|
| 159 |
+
):
|
| 160 |
+
"""Label a single image — returns COCO-style bboxes."""
|
| 161 |
+
from .providers import create_provider
|
| 162 |
+
from PIL import Image
|
| 163 |
+
|
| 164 |
+
suffix = Path(image.filename).suffix or ".jpg"
|
| 165 |
+
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False, dir=str(UPLOAD_DIR)) as f:
|
| 166 |
+
f.write(await image.read())
|
| 167 |
+
tmp_path = f.name
|
| 168 |
+
|
| 169 |
+
try:
|
| 170 |
+
provider = create_provider(backend)
|
| 171 |
+
im = Image.open(tmp_path)
|
| 172 |
+
iw, ih = im.size
|
| 173 |
+
query_list = [q.strip() for q in queries.split(",")]
|
| 174 |
+
result = provider.label_image(tmp_path, query_list, image_wh=(iw, ih))
|
| 175 |
+
|
| 176 |
+
# Also run metrics on the annotations
|
| 177 |
+
from .metrics import verify_bbox_rules
|
| 178 |
+
scored_anns = []
|
| 179 |
+
for ann in result.annotations:
|
| 180 |
+
vr = verify_bbox_rules(ann["bbox"], (iw, ih), score=ann.get("score", 1.0))
|
| 181 |
+
scored_anns.append({
|
| 182 |
+
**ann,
|
| 183 |
+
"pass_rate": round(vr.pass_rate, 2),
|
| 184 |
+
"failed_rules": vr.failed_rules,
|
| 185 |
+
})
|
| 186 |
+
|
| 187 |
+
return {
|
| 188 |
+
"annotations": scored_anns,
|
| 189 |
+
"elapsed": round(result.elapsed, 2),
|
| 190 |
+
"backend": backend,
|
| 191 |
+
"image_size": [iw, ih],
|
| 192 |
+
"n_detections": len(scored_anns),
|
| 193 |
+
}
|
| 194 |
+
except Exception as e:
|
| 195 |
+
raise HTTPException(500, str(e))
|
| 196 |
+
finally:
|
| 197 |
+
os.unlink(tmp_path)
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
# ─── Verify ────────────────────────────────────────────────
|
| 201 |
+
|
| 202 |
+
@app.post("/api/verify")
|
| 203 |
+
async def verify_bbox(
|
| 204 |
+
image: UploadFile = File(...),
|
| 205 |
+
bbox: str = Form(...), # JSON: [x, y, w, h]
|
| 206 |
+
query: str = Form(default="object"),
|
| 207 |
+
backend: str = Form(default="qwen"),
|
| 208 |
+
):
|
| 209 |
+
"""Verify a single bbox crop."""
|
| 210 |
+
from .providers import create_provider
|
| 211 |
+
|
| 212 |
+
suffix = Path(image.filename).suffix or ".jpg"
|
| 213 |
+
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False, dir=str(UPLOAD_DIR)) as f:
|
| 214 |
+
f.write(await image.read())
|
| 215 |
+
tmp_path = f.name
|
| 216 |
+
|
| 217 |
+
try:
|
| 218 |
+
provider = create_provider(backend)
|
| 219 |
+
bbox_list = json.loads(bbox)
|
| 220 |
+
result = provider.verify_bbox(tmp_path, bbox_list, query)
|
| 221 |
+
return {
|
| 222 |
+
"verdict": result.verdict,
|
| 223 |
+
"raw_answer": result.raw_answer,
|
| 224 |
+
"elapsed": round(result.elapsed, 2),
|
| 225 |
+
"confidence": result.confidence,
|
| 226 |
+
"backend": backend,
|
| 227 |
+
}
|
| 228 |
+
except Exception as e:
|
| 229 |
+
raise HTTPException(500, str(e))
|
| 230 |
+
finally:
|
| 231 |
+
os.unlink(tmp_path)
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
# ─── Score ─────────────────────────────────────────────────
|
| 235 |
+
|
| 236 |
+
@app.post("/api/score")
|
| 237 |
+
async def score_coco(coco_file: UploadFile = File(...)):
|
| 238 |
+
"""Score a COCO annotation file with deterministic metrics."""
|
| 239 |
+
from .metrics import score_coco as _score
|
| 240 |
+
content = await coco_file.read()
|
| 241 |
+
coco = json.loads(content)
|
| 242 |
+
score = _score(coco)
|
| 243 |
+
return {
|
| 244 |
+
"total_images": score.total_images,
|
| 245 |
+
"total_annotations": score.total_annotations,
|
| 246 |
+
"pass_rate": round(score.pass_rate, 4),
|
| 247 |
+
"mean_score": round(score.mean_score, 4),
|
| 248 |
+
"mean_area_ratio": round(score.mean_area_ratio, 4),
|
| 249 |
+
"rule_breakdown": {k: round(v, 4) for k, v in score.rule_breakdown.items()},
|
| 250 |
+
"per_category": score.per_category,
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
# ─── Batch pipeline ───────────────────────────────────────
|
| 255 |
+
|
| 256 |
+
@app.post("/api/pipeline")
|
| 257 |
+
async def run_pipeline(
|
| 258 |
+
description: str = Form(...),
|
| 259 |
+
backend: str = Form(default="gemma"),
|
| 260 |
+
samples: list[UploadFile] = File(default=[]),
|
| 261 |
+
):
|
| 262 |
+
"""Full pipeline: upload samples → auto project → filter → return results."""
|
| 263 |
+
from .providers import create_provider
|
| 264 |
+
from .auto import detect_content_type, CONTENT_PROFILES
|
| 265 |
+
|
| 266 |
+
session_id = f"pipeline_{int(time.time())}"
|
| 267 |
+
session_dir = UPLOAD_DIR / session_id
|
| 268 |
+
session_dir.mkdir(parents=True, exist_ok=True)
|
| 269 |
+
|
| 270 |
+
# Save uploads
|
| 271 |
+
saved = []
|
| 272 |
+
for f in samples:
|
| 273 |
+
dest = session_dir / f.filename
|
| 274 |
+
with open(dest, "wb") as out:
|
| 275 |
+
out.write(await f.read())
|
| 276 |
+
saved.append({"name": f.filename, "path": str(dest)})
|
| 277 |
+
|
| 278 |
+
content_type = detect_content_type(description)
|
| 279 |
+
profile = CONTENT_PROFILES.get(content_type, CONTENT_PROFILES["generic"])
|
| 280 |
+
|
| 281 |
+
# Use the recommended filter backend or override
|
| 282 |
+
filter_backend = backend if backend != "auto" else profile["filter_backend"]
|
| 283 |
+
|
| 284 |
+
try:
|
| 285 |
+
provider = create_provider(filter_backend)
|
| 286 |
+
except Exception as e:
|
| 287 |
+
return {"error": f"Backend {filter_backend} not available: {e}"}
|
| 288 |
+
|
| 289 |
+
prompt = (
|
| 290 |
+
f"Look at this image. Does it show a {description}? "
|
| 291 |
+
"Answer with exactly one word: YES or NO."
|
| 292 |
+
)
|
| 293 |
+
|
| 294 |
+
results = []
|
| 295 |
+
t0 = time.time()
|
| 296 |
+
for item in saved:
|
| 297 |
+
try:
|
| 298 |
+
fr = provider.filter_image(item["path"], prompt)
|
| 299 |
+
results.append({
|
| 300 |
+
"name": item["name"],
|
| 301 |
+
"verdict": fr.verdict,
|
| 302 |
+
"raw_answer": fr.raw_answer,
|
| 303 |
+
"elapsed": round(fr.elapsed, 2),
|
| 304 |
+
"confidence": fr.confidence,
|
| 305 |
+
})
|
| 306 |
+
except Exception as e:
|
| 307 |
+
results.append({
|
| 308 |
+
"name": item["name"],
|
| 309 |
+
"verdict": "ERROR",
|
| 310 |
+
"raw_answer": str(e)[:100],
|
| 311 |
+
"elapsed": 0,
|
| 312 |
+
"confidence": 0,
|
| 313 |
+
})
|
| 314 |
+
|
| 315 |
+
elapsed_total = time.time() - t0
|
| 316 |
+
counts = {}
|
| 317 |
+
for r in results:
|
| 318 |
+
counts[r["verdict"]] = counts.get(r["verdict"], 0) + 1
|
| 319 |
+
|
| 320 |
+
return {
|
| 321 |
+
"session_id": session_id,
|
| 322 |
+
"content_type": content_type,
|
| 323 |
+
"backend": filter_backend,
|
| 324 |
+
"prompt": prompt,
|
| 325 |
+
"n_images": len(saved),
|
| 326 |
+
"elapsed_total": round(elapsed_total, 1),
|
| 327 |
+
"counts": counts,
|
| 328 |
+
"results": results,
|
| 329 |
+
}
|
| 330 |
+
|
| 331 |
+
|
| 332 |
+
# ─── Experiments ───────────────────────────────────────────
|
| 333 |
+
|
| 334 |
+
@app.get("/api/experiments")
|
| 335 |
+
def list_experiments_api():
|
| 336 |
+
from .experiments import list_experiments
|
| 337 |
+
return {"experiments": list_experiments()}
|
| 338 |
+
|
| 339 |
+
|
| 340 |
+
# ─── Health ────────────────────────────────────────────────
|
| 341 |
+
|
| 342 |
+
@app.get("/api/health")
|
| 343 |
+
def health():
|
| 344 |
+
return {"status": "ok", "version": "0.2.0", "timestamp": datetime.now().isoformat()}
|
| 345 |
+
|
| 346 |
+
|
| 347 |
+
# ─── Main ──────────────────────────────────────────────────
|
| 348 |
+
|
| 349 |
+
def main():
|
| 350 |
+
import argparse
|
| 351 |
+
import uvicorn
|
| 352 |
+
|
| 353 |
+
p = argparse.ArgumentParser(prog="data_label_factory.serve")
|
| 354 |
+
p.add_argument("--host", default="0.0.0.0")
|
| 355 |
+
p.add_argument("--port", type=int, default=8400)
|
| 356 |
+
args = p.parse_args()
|
| 357 |
+
|
| 358 |
+
print(f"data-label-factory API server on http://{args.host}:{args.port}")
|
| 359 |
+
print(f" Docs: http://localhost:{args.port}/docs")
|
| 360 |
+
uvicorn.run(app, host=args.host, port=args.port)
|
| 361 |
+
|
| 362 |
+
|
| 363 |
+
if __name__ == "__main__":
|
| 364 |
+
main()
|
research/v2-expert-sniper-integration.md
CHANGED
|
@@ -191,9 +191,111 @@ Optional extras:
|
|
| 191 |
- `[mcp]` — `mcp`
|
| 192 |
- `[flywheel]` — `opencv-python`, `numpy`
|
| 193 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 194 |
## What's Next
|
| 195 |
|
| 196 |
1. **Batch benchmark:** Run all 157 stop-sign images through Qwen vs Expert Sniper vs OpenRouter
|
| 197 |
-
2. **
|
| 198 |
-
3. **
|
| 199 |
-
4. **
|
|
|
|
| 191 |
- `[mcp]` — `mcp`
|
| 192 |
- `[flywheel]` — `opencv-python`, `numpy`
|
| 193 |
|
| 194 |
+
## Falcon Perception on MLX
|
| 195 |
+
|
| 196 |
+
Falcon Perception is now running natively on MLX via `mlx-vlm` 0.4.4.
|
| 197 |
+
|
| 198 |
+
**Setup (Mac Mini M4):**
|
| 199 |
+
```bash
|
| 200 |
+
# Install Miniforge (user-local, no admin needed)
|
| 201 |
+
curl -fsSL https://github.com/conda-forge/miniforge/releases/latest/download/Miniforge3-MacOSX-arm64.sh -o /tmp/miniforge.sh
|
| 202 |
+
bash /tmp/miniforge.sh -b -p ~/miniforge3
|
| 203 |
+
|
| 204 |
+
# Install mlx-vlm (latest, with falcon_perception + gemma4 support)
|
| 205 |
+
~/miniforge3/bin/pip install mlx mlx-vlm python-multipart
|
| 206 |
+
|
| 207 |
+
# Convert Falcon Perception weights to MLX format
|
| 208 |
+
~/miniforge3/bin/python3 -c "
|
| 209 |
+
from mlx_vlm import convert
|
| 210 |
+
convert('tiiuae/Falcon-Perception', '~/models/falcon-perception-mlx', trust_remote_code=True)
|
| 211 |
+
"
|
| 212 |
+
|
| 213 |
+
# Test
|
| 214 |
+
~/miniforge3/bin/python3 -c "
|
| 215 |
+
from mlx_vlm import load
|
| 216 |
+
model, processor = load('~/models/falcon-perception-mlx', trust_remote_code=True)
|
| 217 |
+
# model.generate_perception(processor, image=img, query='object', max_new_tokens=256)
|
| 218 |
+
"
|
| 219 |
+
```
|
| 220 |
+
|
| 221 |
+
**Result:** 18 detections on a stop-sign image (stop signs, signs, poles) in 42.8s.
|
| 222 |
+
|
| 223 |
+
**Important:** Requires Python 3.10+ (the HF model code uses `float | list` syntax).
|
| 224 |
+
The Mac Mini shipped with Python 3.9 — Miniforge provides 3.13 without needing admin.
|
| 225 |
+
|
| 226 |
+
## Web UI — Label Page
|
| 227 |
+
|
| 228 |
+
The web UI at `/label` provides a visual interface to the full pipeline.
|
| 229 |
+
|
| 230 |
+
### Quick Start
|
| 231 |
+
|
| 232 |
+
```bash
|
| 233 |
+
# Terminal 1: Start the Python API server
|
| 234 |
+
cd data-label-factory
|
| 235 |
+
GEMMA_URL=http://192.168.1.244:8500 python3 -m data_label_factory.serve --port 8400
|
| 236 |
+
|
| 237 |
+
# Terminal 2: Start the Next.js web UI
|
| 238 |
+
cd data-label-factory/web
|
| 239 |
+
npm install # first time only
|
| 240 |
+
PORT=3030 npm run dev
|
| 241 |
+
|
| 242 |
+
# Open http://localhost:3030/label
|
| 243 |
+
```
|
| 244 |
+
|
| 245 |
+
### How to Use
|
| 246 |
+
|
| 247 |
+
1. **Check providers** — The status bar at the top shows which backends are alive
|
| 248 |
+
(green = ready, gray = offline). Gemma + Falcon need the Expert Sniper running.
|
| 249 |
+
|
| 250 |
+
2. **Describe your target** — Type what you want to detect in the text field:
|
| 251 |
+
`stop signs`, `fire hydrants`, `trading cards`, etc.
|
| 252 |
+
|
| 253 |
+
3. **Upload images** — Drag and drop images onto the upload area, or click to
|
| 254 |
+
select files. Thumbnails appear in the center column.
|
| 255 |
+
|
| 256 |
+
4. **Pick backends** — Choose a filter backend (Gemma, Qwen, OpenRouter) and a
|
| 257 |
+
label backend (Falcon, WildDet3D, Chandra, Flywheel) from the dropdowns.
|
| 258 |
+
|
| 259 |
+
5. **Filter All** — Click to run YES/NO classification on every image. Results
|
| 260 |
+
appear as color-coded badges (green YES, red NO) with timing. The summary
|
| 261 |
+
bar at the bottom shows counts and a progress bar.
|
| 262 |
+
|
| 263 |
+
6. **Label individual images** — Click the "Label" button on any image to run
|
| 264 |
+
bbox detection. The canvas on the right draws color-coded bounding boxes
|
| 265 |
+
with category labels and confidence scores.
|
| 266 |
+
|
| 267 |
+
7. **Review annotations** — Below the canvas, each detection shows:
|
| 268 |
+
- Category name and confidence percentage
|
| 269 |
+
- Pixel coordinates `[x, y, w, h]`
|
| 270 |
+
- Quality pass rate from deterministic metrics (green = all rules pass)
|
| 271 |
+
|
| 272 |
+
### Architecture
|
| 273 |
+
|
| 274 |
+
```
|
| 275 |
+
Browser (localhost:3030/label)
|
| 276 |
+
↓ fetch
|
| 277 |
+
Next.js API route (/api/dlf)
|
| 278 |
+
↓ proxy
|
| 279 |
+
Python API server (localhost:8400)
|
| 280 |
+
↓ provider registry
|
| 281 |
+
Expert Sniper (192.168.1.244:8500)
|
| 282 |
+
├── Gemma 4 26B (filter/verify)
|
| 283 |
+
└── Falcon Perception (label/bbox)
|
| 284 |
+
```
|
| 285 |
+
|
| 286 |
+
### Environment Variables
|
| 287 |
+
|
| 288 |
+
| Var | Default | Purpose |
|
| 289 |
+
|-----|---------|---------|
|
| 290 |
+
| `GEMMA_URL` | `http://localhost:8500` | Expert Sniper endpoint |
|
| 291 |
+
| `QWEN_URL` | `http://localhost:8291` | Qwen VLM endpoint |
|
| 292 |
+
| `OPENROUTER_API_KEY` | (none) | OpenRouter cloud models |
|
| 293 |
+
| `DLF_API_URL` | `http://localhost:8400` | Python API (for Next.js proxy) |
|
| 294 |
+
| `DLF_UPLOAD_DIR` | `/tmp/dlf-uploads` | Temp upload storage |
|
| 295 |
+
|
| 296 |
## What's Next
|
| 297 |
|
| 298 |
1. **Batch benchmark:** Run all 157 stop-sign images through Qwen vs Expert Sniper vs OpenRouter
|
| 299 |
+
2. **Pipeline v2 integration:** Wire `label-v2` into the full `pipeline` command
|
| 300 |
+
3. **Web UI improvements:** Live progress streaming, batch label, export COCO from UI
|
| 301 |
+
4. **Publish Falcon MLX weights:** Upload converted weights to HuggingFace so others skip the conversion step
|
web/app/api/dlf/route.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from "next/server";
|
| 2 |
+
|
| 3 |
+
const DLF_API = process.env.DLF_API_URL || "http://localhost:8400";
|
| 4 |
+
|
| 5 |
+
export async function GET(req: NextRequest) {
|
| 6 |
+
const path = req.nextUrl.searchParams.get("path") || "/api/health";
|
| 7 |
+
try {
|
| 8 |
+
const res = await fetch(`${DLF_API}${path}`, { cache: "no-store" });
|
| 9 |
+
const data = await res.json();
|
| 10 |
+
return NextResponse.json(data);
|
| 11 |
+
} catch (e: any) {
|
| 12 |
+
return NextResponse.json({ error: e.message }, { status: 502 });
|
| 13 |
+
}
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
export async function POST(req: NextRequest) {
|
| 17 |
+
const path = req.nextUrl.searchParams.get("path") || "/api/filter";
|
| 18 |
+
try {
|
| 19 |
+
const formData = await req.formData();
|
| 20 |
+
const res = await fetch(`${DLF_API}${path}`, {
|
| 21 |
+
method: "POST",
|
| 22 |
+
body: formData,
|
| 23 |
+
});
|
| 24 |
+
const data = await res.json();
|
| 25 |
+
return NextResponse.json(data);
|
| 26 |
+
} catch (e: any) {
|
| 27 |
+
return NextResponse.json({ error: e.message }, { status: 502 });
|
| 28 |
+
}
|
| 29 |
+
}
|
web/app/label/page.tsx
ADDED
|
@@ -0,0 +1,574 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useState, useRef, useCallback, useEffect } from "react";
|
| 4 |
+
|
| 5 |
+
const DLF_API = "/api/dlf";
|
| 6 |
+
|
| 7 |
+
type Provider = {
|
| 8 |
+
name: string;
|
| 9 |
+
alive: boolean;
|
| 10 |
+
capabilities: string[];
|
| 11 |
+
info?: string;
|
| 12 |
+
};
|
| 13 |
+
|
| 14 |
+
type FilterResult = {
|
| 15 |
+
name: string;
|
| 16 |
+
verdict: string;
|
| 17 |
+
raw_answer: string;
|
| 18 |
+
elapsed: number;
|
| 19 |
+
confidence: number;
|
| 20 |
+
};
|
| 21 |
+
|
| 22 |
+
type Annotation = {
|
| 23 |
+
bbox: number[];
|
| 24 |
+
category: string;
|
| 25 |
+
score: number;
|
| 26 |
+
pass_rate?: number;
|
| 27 |
+
failed_rules?: string[];
|
| 28 |
+
source?: string;
|
| 29 |
+
};
|
| 30 |
+
|
| 31 |
+
type LabelResult = {
|
| 32 |
+
annotations: Annotation[];
|
| 33 |
+
elapsed: number;
|
| 34 |
+
backend: string;
|
| 35 |
+
image_size: number[];
|
| 36 |
+
n_detections: number;
|
| 37 |
+
};
|
| 38 |
+
|
| 39 |
+
// ── Colors ──
|
| 40 |
+
const VERDICT_COLORS: Record<string, string> = {
|
| 41 |
+
YES: "bg-emerald-500/20 text-emerald-400 border-emerald-500/40",
|
| 42 |
+
NO: "bg-red-500/20 text-red-400 border-red-500/40",
|
| 43 |
+
UNKNOWN: "bg-yellow-500/20 text-yellow-400 border-yellow-500/40",
|
| 44 |
+
ERROR: "bg-red-800/20 text-red-300 border-red-800/40",
|
| 45 |
+
};
|
| 46 |
+
|
| 47 |
+
const BBOX_COLORS = [
|
| 48 |
+
"#ff4060", "#20c8ff", "#ffc800", "#60ff60",
|
| 49 |
+
"#c860ff", "#ff8000", "#00c8c8", "#ff60ff",
|
| 50 |
+
];
|
| 51 |
+
|
| 52 |
+
export default function LabelPage() {
|
| 53 |
+
const [providers, setProviders] = useState<Provider[]>([]);
|
| 54 |
+
const [files, setFiles] = useState<File[]>([]);
|
| 55 |
+
const [previews, setPreviews] = useState<string[]>([]);
|
| 56 |
+
const [description, setDescription] = useState("");
|
| 57 |
+
const [filterBackend, setFilterBackend] = useState("gemma");
|
| 58 |
+
const [labelBackend, setLabelBackend] = useState("falcon");
|
| 59 |
+
const [filterResults, setFilterResults] = useState<FilterResult[]>([]);
|
| 60 |
+
const [labelResults, setLabelResults] = useState<Map<string, LabelResult>>(new Map());
|
| 61 |
+
const [selectedImage, setSelectedImage] = useState<number | null>(null);
|
| 62 |
+
const [loading, setLoading] = useState(false);
|
| 63 |
+
const [loadingMsg, setLoadingMsg] = useState("");
|
| 64 |
+
const [apiStatus, setApiStatus] = useState<"checking" | "up" | "down">("checking");
|
| 65 |
+
const canvasRef = useRef<HTMLCanvasElement>(null);
|
| 66 |
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
| 67 |
+
|
| 68 |
+
// Check API + providers on mount
|
| 69 |
+
useEffect(() => {
|
| 70 |
+
fetch(`${DLF_API}?path=/api/health`)
|
| 71 |
+
.then((r) => r.json())
|
| 72 |
+
.then((d) => {
|
| 73 |
+
if (d.status === "ok") {
|
| 74 |
+
setApiStatus("up");
|
| 75 |
+
fetch(`${DLF_API}?path=/api/providers`)
|
| 76 |
+
.then((r) => r.json())
|
| 77 |
+
.then((d) => setProviders(d.providers || []));
|
| 78 |
+
} else {
|
| 79 |
+
setApiStatus("down");
|
| 80 |
+
}
|
| 81 |
+
})
|
| 82 |
+
.catch(() => setApiStatus("down"));
|
| 83 |
+
}, []);
|
| 84 |
+
|
| 85 |
+
// Handle file selection
|
| 86 |
+
const onFilesSelected = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
| 87 |
+
const selected = Array.from(e.target.files || []);
|
| 88 |
+
setFiles(selected);
|
| 89 |
+
setPreviews(selected.map((f) => URL.createObjectURL(f)));
|
| 90 |
+
setFilterResults([]);
|
| 91 |
+
setLabelResults(new Map());
|
| 92 |
+
setSelectedImage(null);
|
| 93 |
+
}, []);
|
| 94 |
+
|
| 95 |
+
// Handle drop
|
| 96 |
+
const onDrop = useCallback((e: React.DragEvent) => {
|
| 97 |
+
e.preventDefault();
|
| 98 |
+
const dropped = Array.from(e.dataTransfer.files).filter((f) =>
|
| 99 |
+
f.type.startsWith("image/")
|
| 100 |
+
);
|
| 101 |
+
if (dropped.length) {
|
| 102 |
+
setFiles(dropped);
|
| 103 |
+
setPreviews(dropped.map((f) => URL.createObjectURL(f)));
|
| 104 |
+
setFilterResults([]);
|
| 105 |
+
setLabelResults(new Map());
|
| 106 |
+
setSelectedImage(null);
|
| 107 |
+
}
|
| 108 |
+
}, []);
|
| 109 |
+
|
| 110 |
+
// Run filter on all images
|
| 111 |
+
const runFilter = async () => {
|
| 112 |
+
if (!files.length || !description) return;
|
| 113 |
+
setLoading(true);
|
| 114 |
+
setFilterResults([]);
|
| 115 |
+
|
| 116 |
+
const prompt = `Look at this image. Does it show a ${description}? Answer with exactly one word: YES or NO.`;
|
| 117 |
+
const results: FilterResult[] = [];
|
| 118 |
+
|
| 119 |
+
for (let i = 0; i < files.length; i++) {
|
| 120 |
+
setLoadingMsg(`Filtering ${i + 1}/${files.length}...`);
|
| 121 |
+
const form = new FormData();
|
| 122 |
+
form.append("image", files[i]);
|
| 123 |
+
form.append("prompt", prompt);
|
| 124 |
+
form.append("backend", filterBackend);
|
| 125 |
+
|
| 126 |
+
try {
|
| 127 |
+
const res = await fetch(`${DLF_API}?path=/api/filter`, {
|
| 128 |
+
method: "POST",
|
| 129 |
+
body: form,
|
| 130 |
+
});
|
| 131 |
+
const data = await res.json();
|
| 132 |
+
results.push({ name: files[i].name, ...data });
|
| 133 |
+
} catch (e: any) {
|
| 134 |
+
results.push({
|
| 135 |
+
name: files[i].name,
|
| 136 |
+
verdict: "ERROR",
|
| 137 |
+
raw_answer: e.message,
|
| 138 |
+
elapsed: 0,
|
| 139 |
+
confidence: 0,
|
| 140 |
+
});
|
| 141 |
+
}
|
| 142 |
+
setFilterResults([...results]);
|
| 143 |
+
}
|
| 144 |
+
setLoading(false);
|
| 145 |
+
setLoadingMsg("");
|
| 146 |
+
};
|
| 147 |
+
|
| 148 |
+
// Run label on a single image
|
| 149 |
+
const runLabel = async (idx: number) => {
|
| 150 |
+
if (!files[idx]) return;
|
| 151 |
+
setLoading(true);
|
| 152 |
+
setLoadingMsg(`Labeling ${files[idx].name}...`);
|
| 153 |
+
setSelectedImage(idx);
|
| 154 |
+
|
| 155 |
+
const form = new FormData();
|
| 156 |
+
form.append("image", files[idx]);
|
| 157 |
+
form.append("queries", description);
|
| 158 |
+
form.append("backend", labelBackend);
|
| 159 |
+
|
| 160 |
+
try {
|
| 161 |
+
const res = await fetch(`${DLF_API}?path=/api/label`, {
|
| 162 |
+
method: "POST",
|
| 163 |
+
body: form,
|
| 164 |
+
});
|
| 165 |
+
const data: LabelResult = await res.json();
|
| 166 |
+
setLabelResults((prev) => new Map(prev).set(files[idx].name, data));
|
| 167 |
+
drawAnnotations(idx, data);
|
| 168 |
+
} catch (e: any) {
|
| 169 |
+
console.error(e);
|
| 170 |
+
}
|
| 171 |
+
setLoading(false);
|
| 172 |
+
setLoadingMsg("");
|
| 173 |
+
};
|
| 174 |
+
|
| 175 |
+
// Draw bboxes on canvas
|
| 176 |
+
const drawAnnotations = (idx: number, result: LabelResult) => {
|
| 177 |
+
const canvas = canvasRef.current;
|
| 178 |
+
if (!canvas) return;
|
| 179 |
+
const ctx = canvas.getContext("2d");
|
| 180 |
+
if (!ctx) return;
|
| 181 |
+
|
| 182 |
+
const img = new Image();
|
| 183 |
+
img.onload = () => {
|
| 184 |
+
const scale = Math.min(800 / img.width, 600 / img.height, 1);
|
| 185 |
+
canvas.width = img.width * scale;
|
| 186 |
+
canvas.height = img.height * scale;
|
| 187 |
+
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
| 188 |
+
|
| 189 |
+
result.annotations.forEach((ann, i) => {
|
| 190 |
+
const [x, y, w, h] = ann.bbox;
|
| 191 |
+
const sx = scale;
|
| 192 |
+
const color = BBOX_COLORS[i % BBOX_COLORS.length];
|
| 193 |
+
|
| 194 |
+
// Box
|
| 195 |
+
ctx.strokeStyle = color;
|
| 196 |
+
ctx.lineWidth = 3;
|
| 197 |
+
ctx.strokeRect(x * sx, y * sx, w * sx, h * sx);
|
| 198 |
+
|
| 199 |
+
// Label bg
|
| 200 |
+
const label = `${ann.category} ${(ann.score * 100).toFixed(0)}%`;
|
| 201 |
+
ctx.font = "bold 14px monospace";
|
| 202 |
+
const tw = ctx.measureText(label).width;
|
| 203 |
+
ctx.fillStyle = color;
|
| 204 |
+
ctx.fillRect(x * sx, y * sx - 20, tw + 8, 20);
|
| 205 |
+
|
| 206 |
+
// Label text
|
| 207 |
+
ctx.fillStyle = "#fff";
|
| 208 |
+
ctx.fillText(label, x * sx + 4, y * sx - 5);
|
| 209 |
+
|
| 210 |
+
// Failed rules indicator
|
| 211 |
+
if (ann.failed_rules && ann.failed_rules.length > 0) {
|
| 212 |
+
ctx.strokeStyle = "#ff0000";
|
| 213 |
+
ctx.lineWidth = 1;
|
| 214 |
+
ctx.setLineDash([4, 4]);
|
| 215 |
+
ctx.strokeRect(x * sx - 2, y * sx - 2, w * sx + 4, h * sx + 4);
|
| 216 |
+
ctx.setLineDash([]);
|
| 217 |
+
}
|
| 218 |
+
});
|
| 219 |
+
};
|
| 220 |
+
img.src = previews[idx];
|
| 221 |
+
};
|
| 222 |
+
|
| 223 |
+
// Re-draw when selecting an already-labeled image
|
| 224 |
+
useEffect(() => {
|
| 225 |
+
if (selectedImage !== null && files[selectedImage]) {
|
| 226 |
+
const result = labelResults.get(files[selectedImage].name);
|
| 227 |
+
if (result) drawAnnotations(selectedImage, result);
|
| 228 |
+
}
|
| 229 |
+
}, [selectedImage]);
|
| 230 |
+
|
| 231 |
+
const aliveFilterBackends = providers.filter(
|
| 232 |
+
(p) => p.alive && p.capabilities.includes("filter")
|
| 233 |
+
);
|
| 234 |
+
const aliveLabelBackends = providers.filter(
|
| 235 |
+
(p) => p.alive && p.capabilities.includes("label")
|
| 236 |
+
);
|
| 237 |
+
|
| 238 |
+
const yesCount = filterResults.filter((r) => r.verdict === "YES").length;
|
| 239 |
+
const noCount = filterResults.filter((r) => r.verdict === "NO").length;
|
| 240 |
+
|
| 241 |
+
return (
|
| 242 |
+
<div className="min-h-screen bg-zinc-950 text-zinc-100 p-6">
|
| 243 |
+
<div className="max-w-7xl mx-auto">
|
| 244 |
+
{/* Header */}
|
| 245 |
+
<div className="flex items-center justify-between mb-8">
|
| 246 |
+
<div>
|
| 247 |
+
<h1 className="text-3xl font-bold tracking-tight">
|
| 248 |
+
data-label-factory
|
| 249 |
+
</h1>
|
| 250 |
+
<p className="text-zinc-400 mt-1">
|
| 251 |
+
Upload images, describe your target, pick a model, get labels.
|
| 252 |
+
</p>
|
| 253 |
+
</div>
|
| 254 |
+
<div className="flex items-center gap-3">
|
| 255 |
+
<div
|
| 256 |
+
className={`w-3 h-3 rounded-full ${
|
| 257 |
+
apiStatus === "up"
|
| 258 |
+
? "bg-emerald-500"
|
| 259 |
+
: apiStatus === "down"
|
| 260 |
+
? "bg-red-500"
|
| 261 |
+
: "bg-yellow-500 animate-pulse"
|
| 262 |
+
}`}
|
| 263 |
+
/>
|
| 264 |
+
<span className="text-sm text-zinc-500">
|
| 265 |
+
{apiStatus === "up"
|
| 266 |
+
? "API connected"
|
| 267 |
+
: apiStatus === "down"
|
| 268 |
+
? "API offline — start: python3 -m data_label_factory.serve"
|
| 269 |
+
: "Checking..."}
|
| 270 |
+
</span>
|
| 271 |
+
</div>
|
| 272 |
+
</div>
|
| 273 |
+
|
| 274 |
+
{/* Providers status bar */}
|
| 275 |
+
{providers.length > 0 && (
|
| 276 |
+
<div className="flex gap-2 mb-6 flex-wrap">
|
| 277 |
+
{providers.map((p) => (
|
| 278 |
+
<div
|
| 279 |
+
key={p.name}
|
| 280 |
+
className={`px-3 py-1.5 rounded-lg text-xs font-mono border ${
|
| 281 |
+
p.alive
|
| 282 |
+
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-400"
|
| 283 |
+
: "border-zinc-700 bg-zinc-900 text-zinc-600"
|
| 284 |
+
}`}
|
| 285 |
+
>
|
| 286 |
+
{p.alive ? "\u2713" : "\u2717"} {p.name}{" "}
|
| 287 |
+
<span className="text-zinc-500">
|
| 288 |
+
[{p.capabilities.join(",")}]
|
| 289 |
+
</span>
|
| 290 |
+
</div>
|
| 291 |
+
))}
|
| 292 |
+
</div>
|
| 293 |
+
)}
|
| 294 |
+
|
| 295 |
+
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
| 296 |
+
{/* Left panel — Controls */}
|
| 297 |
+
<div className="space-y-4">
|
| 298 |
+
{/* Description */}
|
| 299 |
+
<div>
|
| 300 |
+
<label className="block text-sm font-medium text-zinc-400 mb-1">
|
| 301 |
+
What are you labeling?
|
| 302 |
+
</label>
|
| 303 |
+
<input
|
| 304 |
+
type="text"
|
| 305 |
+
value={description}
|
| 306 |
+
onChange={(e) => setDescription(e.target.value)}
|
| 307 |
+
placeholder="e.g. stop signs, fire hydrants, trading cards..."
|
| 308 |
+
className="w-full px-4 py-3 rounded-lg bg-zinc-900 border border-zinc-700 text-zinc-100 placeholder:text-zinc-600 focus:border-blue-500 focus:outline-none"
|
| 309 |
+
/>
|
| 310 |
+
</div>
|
| 311 |
+
|
| 312 |
+
{/* Upload */}
|
| 313 |
+
<div
|
| 314 |
+
onDrop={onDrop}
|
| 315 |
+
onDragOver={(e) => e.preventDefault()}
|
| 316 |
+
onClick={() => fileInputRef.current?.click()}
|
| 317 |
+
className="border-2 border-dashed border-zinc-700 rounded-lg p-8 text-center cursor-pointer hover:border-blue-500/50 transition-colors"
|
| 318 |
+
>
|
| 319 |
+
<input
|
| 320 |
+
ref={fileInputRef}
|
| 321 |
+
type="file"
|
| 322 |
+
multiple
|
| 323 |
+
accept="image/*"
|
| 324 |
+
onChange={onFilesSelected}
|
| 325 |
+
className="hidden"
|
| 326 |
+
/>
|
| 327 |
+
<p className="text-zinc-400">
|
| 328 |
+
{files.length
|
| 329 |
+
? `${files.length} images selected`
|
| 330 |
+
: "Drop images here or click to select"}
|
| 331 |
+
</p>
|
| 332 |
+
</div>
|
| 333 |
+
|
| 334 |
+
{/* Backend selectors */}
|
| 335 |
+
<div className="grid grid-cols-2 gap-3">
|
| 336 |
+
<div>
|
| 337 |
+
<label className="block text-xs text-zinc-500 mb-1">
|
| 338 |
+
Filter backend
|
| 339 |
+
</label>
|
| 340 |
+
<select
|
| 341 |
+
value={filterBackend}
|
| 342 |
+
onChange={(e) => setFilterBackend(e.target.value)}
|
| 343 |
+
className="w-full px-3 py-2 rounded bg-zinc-900 border border-zinc-700 text-sm"
|
| 344 |
+
>
|
| 345 |
+
{aliveFilterBackends.length ? (
|
| 346 |
+
aliveFilterBackends.map((p) => (
|
| 347 |
+
<option key={p.name} value={p.name}>
|
| 348 |
+
{p.name}
|
| 349 |
+
</option>
|
| 350 |
+
))
|
| 351 |
+
) : (
|
| 352 |
+
<>
|
| 353 |
+
<option value="qwen">qwen</option>
|
| 354 |
+
<option value="gemma">gemma</option>
|
| 355 |
+
<option value="openrouter">openrouter</option>
|
| 356 |
+
<option value="chandra">chandra</option>
|
| 357 |
+
</>
|
| 358 |
+
)}
|
| 359 |
+
</select>
|
| 360 |
+
</div>
|
| 361 |
+
<div>
|
| 362 |
+
<label className="block text-xs text-zinc-500 mb-1">
|
| 363 |
+
Label backend
|
| 364 |
+
</label>
|
| 365 |
+
<select
|
| 366 |
+
value={labelBackend}
|
| 367 |
+
onChange={(e) => setLabelBackend(e.target.value)}
|
| 368 |
+
className="w-full px-3 py-2 rounded bg-zinc-900 border border-zinc-700 text-sm"
|
| 369 |
+
>
|
| 370 |
+
{aliveLabelBackends.length ? (
|
| 371 |
+
aliveLabelBackends.map((p) => (
|
| 372 |
+
<option key={p.name} value={p.name}>
|
| 373 |
+
{p.name}
|
| 374 |
+
</option>
|
| 375 |
+
))
|
| 376 |
+
) : (
|
| 377 |
+
<>
|
| 378 |
+
<option value="falcon">falcon</option>
|
| 379 |
+
<option value="wilddet3d">wilddet3d</option>
|
| 380 |
+
<option value="chandra">chandra</option>
|
| 381 |
+
<option value="flywheel">flywheel</option>
|
| 382 |
+
</>
|
| 383 |
+
)}
|
| 384 |
+
</select>
|
| 385 |
+
</div>
|
| 386 |
+
</div>
|
| 387 |
+
|
| 388 |
+
{/* Action buttons */}
|
| 389 |
+
<div className="flex gap-3">
|
| 390 |
+
<button
|
| 391 |
+
onClick={runFilter}
|
| 392 |
+
disabled={loading || !files.length || !description}
|
| 393 |
+
className="flex-1 px-4 py-3 bg-blue-600 hover:bg-blue-500 disabled:bg-zinc-800 disabled:text-zinc-600 rounded-lg font-medium transition-colors"
|
| 394 |
+
>
|
| 395 |
+
{loading ? loadingMsg : "Filter All"}
|
| 396 |
+
</button>
|
| 397 |
+
</div>
|
| 398 |
+
|
| 399 |
+
{/* Filter summary */}
|
| 400 |
+
{filterResults.length > 0 && (
|
| 401 |
+
<div className="bg-zinc-900 rounded-lg p-4 border border-zinc-800">
|
| 402 |
+
<div className="flex justify-between text-sm mb-2">
|
| 403 |
+
<span className="text-zinc-400">Filter results</span>
|
| 404 |
+
<span className="text-zinc-500">
|
| 405 |
+
{filterResults.length} images
|
| 406 |
+
</span>
|
| 407 |
+
</div>
|
| 408 |
+
<div className="flex gap-4 text-lg font-mono">
|
| 409 |
+
<span className="text-emerald-400">{yesCount} YES</span>
|
| 410 |
+
<span className="text-red-400">{noCount} NO</span>
|
| 411 |
+
<span className="text-zinc-500">
|
| 412 |
+
{filterResults.length - yesCount - noCount} other
|
| 413 |
+
</span>
|
| 414 |
+
</div>
|
| 415 |
+
<div className="w-full bg-zinc-800 rounded-full h-2 mt-2">
|
| 416 |
+
<div
|
| 417 |
+
className="bg-emerald-500 h-2 rounded-full transition-all"
|
| 418 |
+
style={{
|
| 419 |
+
width: `${(yesCount / Math.max(filterResults.length, 1)) * 100}%`,
|
| 420 |
+
}}
|
| 421 |
+
/>
|
| 422 |
+
</div>
|
| 423 |
+
</div>
|
| 424 |
+
)}
|
| 425 |
+
</div>
|
| 426 |
+
|
| 427 |
+
{/* Center panel — Image grid + filter results */}
|
| 428 |
+
<div className="space-y-2 max-h-[80vh] overflow-y-auto">
|
| 429 |
+
{previews.map((src, i) => {
|
| 430 |
+
const fr = filterResults[i];
|
| 431 |
+
const lr = labelResults.get(files[i]?.name);
|
| 432 |
+
return (
|
| 433 |
+
<div
|
| 434 |
+
key={i}
|
| 435 |
+
onClick={() => {
|
| 436 |
+
setSelectedImage(i);
|
| 437 |
+
if (lr) drawAnnotations(i, lr);
|
| 438 |
+
}}
|
| 439 |
+
className={`flex items-center gap-3 p-2 rounded-lg cursor-pointer transition-colors ${
|
| 440 |
+
selectedImage === i
|
| 441 |
+
? "bg-zinc-800 border border-blue-500/50"
|
| 442 |
+
: "bg-zinc-900/50 border border-zinc-800 hover:bg-zinc-800/50"
|
| 443 |
+
}`}
|
| 444 |
+
>
|
| 445 |
+
<img
|
| 446 |
+
src={src}
|
| 447 |
+
alt=""
|
| 448 |
+
className="w-16 h-16 object-cover rounded"
|
| 449 |
+
/>
|
| 450 |
+
<div className="flex-1 min-w-0">
|
| 451 |
+
<p className="text-sm text-zinc-300 truncate">
|
| 452 |
+
{files[i]?.name}
|
| 453 |
+
</p>
|
| 454 |
+
{fr && (
|
| 455 |
+
<div className="flex items-center gap-2 mt-1">
|
| 456 |
+
<span
|
| 457 |
+
className={`px-2 py-0.5 rounded text-xs font-mono border ${
|
| 458 |
+
VERDICT_COLORS[fr.verdict] || VERDICT_COLORS.UNKNOWN
|
| 459 |
+
}`}
|
| 460 |
+
>
|
| 461 |
+
{fr.verdict}
|
| 462 |
+
</span>
|
| 463 |
+
<span className="text-xs text-zinc-500">
|
| 464 |
+
{fr.elapsed}s
|
| 465 |
+
</span>
|
| 466 |
+
</div>
|
| 467 |
+
)}
|
| 468 |
+
{lr && (
|
| 469 |
+
<p className="text-xs text-zinc-500 mt-0.5">
|
| 470 |
+
{lr.n_detections} detections via {lr.backend}
|
| 471 |
+
</p>
|
| 472 |
+
)}
|
| 473 |
+
</div>
|
| 474 |
+
<button
|
| 475 |
+
onClick={(e) => {
|
| 476 |
+
e.stopPropagation();
|
| 477 |
+
runLabel(i);
|
| 478 |
+
}}
|
| 479 |
+
disabled={loading}
|
| 480 |
+
className="px-3 py-1.5 text-xs bg-zinc-700 hover:bg-zinc-600 disabled:opacity-30 rounded"
|
| 481 |
+
>
|
| 482 |
+
Label
|
| 483 |
+
</button>
|
| 484 |
+
</div>
|
| 485 |
+
);
|
| 486 |
+
})}
|
| 487 |
+
{previews.length === 0 && (
|
| 488 |
+
<div className="text-center text-zinc-600 py-20">
|
| 489 |
+
Upload images to get started
|
| 490 |
+
</div>
|
| 491 |
+
)}
|
| 492 |
+
</div>
|
| 493 |
+
|
| 494 |
+
{/* Right panel — Canvas + annotations */}
|
| 495 |
+
<div className="space-y-4">
|
| 496 |
+
<canvas
|
| 497 |
+
ref={canvasRef}
|
| 498 |
+
className="w-full rounded-lg bg-zinc-900 border border-zinc-800"
|
| 499 |
+
width={800}
|
| 500 |
+
height={600}
|
| 501 |
+
/>
|
| 502 |
+
|
| 503 |
+
{selectedImage !== null && files[selectedImage] && (
|
| 504 |
+
<div>
|
| 505 |
+
{(() => {
|
| 506 |
+
const lr = labelResults.get(files[selectedImage].name);
|
| 507 |
+
if (!lr) return null;
|
| 508 |
+
return (
|
| 509 |
+
<div className="space-y-2">
|
| 510 |
+
<div className="flex justify-between text-sm">
|
| 511 |
+
<span className="text-zinc-400">
|
| 512 |
+
{lr.n_detections} detections
|
| 513 |
+
</span>
|
| 514 |
+
<span className="text-zinc-500">
|
| 515 |
+
{lr.elapsed}s via {lr.backend}
|
| 516 |
+
</span>
|
| 517 |
+
</div>
|
| 518 |
+
{lr.annotations.map((ann, i) => (
|
| 519 |
+
<div
|
| 520 |
+
key={i}
|
| 521 |
+
className="flex items-center gap-2 px-3 py-2 bg-zinc-900 rounded border border-zinc-800 text-sm"
|
| 522 |
+
>
|
| 523 |
+
<div
|
| 524 |
+
className="w-3 h-3 rounded-full flex-shrink-0"
|
| 525 |
+
style={{
|
| 526 |
+
backgroundColor:
|
| 527 |
+
BBOX_COLORS[i % BBOX_COLORS.length],
|
| 528 |
+
}}
|
| 529 |
+
/>
|
| 530 |
+
<span className="font-mono text-zinc-300">
|
| 531 |
+
{ann.category}
|
| 532 |
+
</span>
|
| 533 |
+
<span className="text-zinc-500">
|
| 534 |
+
{(ann.score * 100).toFixed(0)}%
|
| 535 |
+
</span>
|
| 536 |
+
<span className="text-zinc-600 text-xs ml-auto">
|
| 537 |
+
[{ann.bbox.map((v) => Math.round(v)).join(", ")}]
|
| 538 |
+
</span>
|
| 539 |
+
{ann.pass_rate !== undefined && (
|
| 540 |
+
<span
|
| 541 |
+
className={`text-xs px-1.5 py-0.5 rounded ${
|
| 542 |
+
ann.pass_rate >= 1
|
| 543 |
+
? "bg-emerald-500/20 text-emerald-400"
|
| 544 |
+
: "bg-yellow-500/20 text-yellow-400"
|
| 545 |
+
}`}
|
| 546 |
+
>
|
| 547 |
+
{(ann.pass_rate * 100).toFixed(0)}%
|
| 548 |
+
</span>
|
| 549 |
+
)}
|
| 550 |
+
</div>
|
| 551 |
+
))}
|
| 552 |
+
</div>
|
| 553 |
+
);
|
| 554 |
+
})()}
|
| 555 |
+
|
| 556 |
+
{!labelResults.get(files[selectedImage].name) && (
|
| 557 |
+
<div className="text-center text-zinc-600 py-8">
|
| 558 |
+
Click "Label" on an image to see detections
|
| 559 |
+
</div>
|
| 560 |
+
)}
|
| 561 |
+
</div>
|
| 562 |
+
)}
|
| 563 |
+
|
| 564 |
+
{selectedImage === null && (
|
| 565 |
+
<div className="text-center text-zinc-600 py-8">
|
| 566 |
+
Select an image to view / label
|
| 567 |
+
</div>
|
| 568 |
+
)}
|
| 569 |
+
</div>
|
| 570 |
+
</div>
|
| 571 |
+
</div>
|
| 572 |
+
</div>
|
| 573 |
+
);
|
| 574 |
+
}
|