waltgrace commited on
Commit
68cddd0
·
verified ·
1 Parent(s): e019de9

feat: web UI label page, Falcon Perception MLX, API server

Browse files
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. **Falcon Perception on MLX:** The latest mlx-vlm main branch has `falcon_perception` model support — need MLX-converted weights (current weights are PyTorch)
198
- 3. **Pipeline v2 integration:** Wire `label-v2` into the full `pipeline` command
199
- 4. **Web UI review:** Extend canvas viewer to show provider source per annotation
 
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
+ }