data-label-factory / web /app /page.tsx
waltgrace's picture
Initial release: data-label-factory v0.1.0
4cda727 verified
"use client";
import { useEffect, useState, useCallback, useMemo, useRef } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Toaster } from "@/components/ui/sonner";
import { toast } from "sonner";
import { BboxOverlay, colorForQuery, type AnnotatedBbox } from "@/components/BboxOverlay";
import type { ImageReview } from "@/lib/types";
type LoadedImage = ImageReview & { url: string };
export default function Home() {
const [images, setImages] = useState<LoadedImage[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [bucketFilter, setBucketFilter] = useState<string>("all");
const [selectedIdx, setSelectedIdx] = useState<number>(0);
const [activeBbox, setActiveBbox] = useState<number | null>(null);
const cardRef = useRef<HTMLDivElement>(null);
useEffect(() => {
fetch("/api/labels")
.then((r) => r.json())
.then((data) => {
if (data.error) setError(data.error);
else setImages(data.images ?? []);
})
.catch((e) => setError(String(e)))
.finally(() => setLoading(false));
}, []);
const filtered = useMemo(() => {
if (bucketFilter === "all") return images;
return images.filter((i) => i.bucket === bucketFilter);
}, [images, bucketFilter]);
const current = filtered[selectedIdx];
const annotated: AnnotatedBbox[] = useMemo(() => {
if (!current) return [];
return current.bboxes.map((b, idx) => ({ ...b, idx }));
}, [current]);
const queryStats = useMemo(() => {
if (!current) return [] as { query: string; count: number; approved: number; rejected: number }[];
const m = new Map<string, { query: string; count: number; approved: number; rejected: number }>();
for (const b of current.bboxes) {
const e = m.get(b.query) ?? { query: b.query, count: 0, approved: 0, rejected: 0 };
e.count++;
if (b.verdict === "approved") e.approved++;
if (b.verdict === "rejected") e.rejected++;
m.set(b.query, e);
}
return Array.from(m.values()).sort((a, b) => b.count - a.count);
}, [current]);
const totalReviewed = images.filter((i) => i.image_verdict).length;
const totalApproved = images.filter((i) => i.image_verdict === "approved").length;
const totalRejected = images.filter((i) => i.image_verdict === "rejected").length;
const bucketCounts = useMemo(() => {
const m = new Map<string, number>();
for (const i of images) m.set(i.bucket, (m.get(i.bucket) ?? 0) + 1);
return m;
}, [images]);
const saveReview = useCallback(async (img: LoadedImage) => {
try {
const res = await fetch("/api/labels", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(img),
});
const data = await res.json();
if (data.ok) toast.success(`Saved (${data.total_reviewed} reviewed)`);
else toast.error(data.error ?? "save failed");
} catch (e) {
toast.error(String(e));
}
}, []);
const setImageVerdict = useCallback(
(verdict: "approved" | "rejected" | "unsure") => {
if (!current) return;
const updated = { ...current, image_verdict: verdict };
setImages((prev) => prev.map((p) => (p.image_path === current.image_path ? updated : p)));
saveReview(updated);
setSelectedIdx((i) => Math.min(i + 1, filtered.length - 1));
setActiveBbox(null);
},
[current, filtered.length, saveReview],
);
const setBboxVerdict = useCallback(
(idx: number, verdict: "approved" | "rejected" | "unsure") => {
if (!current) return;
const newBboxes = current.bboxes.map((b, i) => (i === idx ? { ...b, verdict } : b));
const updated = { ...current, bboxes: newBboxes };
setImages((prev) => prev.map((p) => (p.image_path === current.image_path ? updated : p)));
saveReview(updated);
},
[current, saveReview],
);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.target instanceof HTMLInputElement) return;
if (e.key === "ArrowRight" || e.key === "j") {
setSelectedIdx((i) => Math.min(i + 1, filtered.length - 1));
setActiveBbox(null);
} else if (e.key === "ArrowLeft" || e.key === "k") {
setSelectedIdx((i) => Math.max(i - 1, 0));
setActiveBbox(null);
} else if (e.key === "y") {
setImageVerdict("approved");
} else if (e.key === "n") {
setImageVerdict("rejected");
} else if (e.key === "u") {
setImageVerdict("unsure");
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [filtered.length, setImageVerdict]);
if (loading) {
return (
<main className="min-h-screen bg-zinc-950 text-zinc-100 p-8">
<div className="text-2xl">Loading labels from R2…</div>
</main>
);
}
if (error) {
return (
<main className="min-h-screen bg-zinc-950 text-zinc-100 p-8">
<h1 className="text-3xl font-bold mb-4">drone-falcon factory</h1>
<Card className="bg-red-950 border-red-800">
<CardContent className="pt-6">
<div className="text-red-300">Error: {error}</div>
<div className="text-zinc-400 mt-2 text-sm">
The labeling pod is probably still running. Sync labels/partial.json to R2 to see them here.
</div>
</CardContent>
</Card>
</main>
);
}
const buckets = ["all", ...Array.from(bucketCounts.keys()).sort()];
return (
<main className="min-h-screen bg-zinc-950 text-zinc-100">
<Toaster theme="dark" position="bottom-right" />
<header className="border-b border-zinc-800 px-6 py-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">drone-falcon · review factory</h1>
<p className="text-sm text-zinc-400">human verification of Falcon Perception bboxes</p>
</div>
<div className="flex items-center gap-3 text-sm">
<Badge variant="outline" className="border-zinc-700 text-zinc-300">{images.length} labeled</Badge>
<Badge variant="outline" className="border-emerald-700 text-emerald-400">{totalApproved} approved</Badge>
<Badge variant="outline" className="border-red-700 text-red-400">{totalRejected} rejected</Badge>
<Badge variant="outline" className="border-zinc-700 text-zinc-300">{totalReviewed}/{images.length} reviewed</Badge>
</div>
</div>
</header>
<div className="border-b border-zinc-800 px-6 py-2">
<Tabs value={bucketFilter} onValueChange={(v) => { setBucketFilter(v); setSelectedIdx(0); }}>
<TabsList className="bg-zinc-900">
{buckets.map((b) => (
<TabsTrigger key={b} value={b} className="data-[state=active]:bg-zinc-800">
{b === "all" ? `All (${images.length})` : `${b} (${bucketCounts.get(b) ?? 0})`}
</TabsTrigger>
))}
</TabsList>
</Tabs>
</div>
<div className="grid grid-cols-12 gap-4 p-4">
<div className="col-span-2 max-h-[calc(100vh-180px)] overflow-y-auto pr-2 space-y-2">
{filtered.map((img, idx) => (
<button
key={img.image_path}
onClick={() => { setSelectedIdx(idx); setActiveBbox(null); }}
className={`block w-full overflow-hidden rounded border-2 transition-all ${
idx === selectedIdx ? "border-blue-500" : "border-zinc-800 hover:border-zinc-600"
}`}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={img.url} alt="" className="w-full h-20 object-cover" />
<div className="bg-zinc-900 px-1 py-0.5 text-[10px] text-zinc-400 flex justify-between">
<span>{img.bboxes.length} dets</span>
<span>
{img.image_verdict === "approved" && "✓"}
{img.image_verdict === "rejected" && "✗"}
</span>
</div>
</button>
))}
</div>
<div className="col-span-7" ref={cardRef}>
{current ? (
<Card className="bg-zinc-900 border-zinc-800">
<CardHeader className="pb-2">
<CardTitle className="text-base font-mono text-zinc-300 truncate">{current.image_path}</CardTitle>
<div className="text-xs text-zinc-500">
{current.width}×{current.height} · {current.bboxes.length} bboxes · {selectedIdx + 1}/{filtered.length}
</div>
</CardHeader>
<CardContent>
<BboxOverlay
src={current.url}
width={current.width}
height={current.height}
bboxes={annotated}
activeIdx={activeBbox}
onBboxClick={(idx) => setActiveBbox(idx)}
/>
<div className="mt-4 flex gap-2">
<Button onClick={() => setImageVerdict("approved")} className="bg-emerald-700 hover:bg-emerald-600">
✓ Approve image (Y)
</Button>
<Button onClick={() => setImageVerdict("rejected")} variant="destructive">
✗ Reject image (N)
</Button>
<Button onClick={() => setImageVerdict("unsure")} variant="outline" className="border-zinc-700">
? Unsure (U)
</Button>
<div className="flex-1" />
<Button onClick={() => { setSelectedIdx((i) => Math.max(i - 1, 0)); setActiveBbox(null); }} variant="outline" className="border-zinc-700">←</Button>
<Button onClick={() => { setSelectedIdx((i) => Math.min(i + 1, filtered.length - 1)); setActiveBbox(null); }} variant="outline" className="border-zinc-700">→</Button>
</div>
<div className="mt-2 text-xs text-zinc-500">
Shortcuts: <kbd>Y</kbd> approve · <kbd>N</kbd> reject · <kbd>U</kbd> unsure · <kbd></kbd> <kbd></kbd> navigate · click a bbox to select
</div>
</CardContent>
</Card>
) : (
<Card className="bg-zinc-900 border-zinc-800">
<CardContent className="pt-6 text-zinc-400">No images in this bucket</CardContent>
</Card>
)}
</div>
<div className="col-span-3 max-h-[calc(100vh-180px)] overflow-y-auto">
<Card className="bg-zinc-900 border-zinc-800">
<CardHeader className="pb-2">
<CardTitle className="text-sm">Per-query bboxes</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{queryStats.map((qs) => (
<div key={qs.query} className="space-y-1">
<div className="flex items-center justify-between text-xs">
<div className="flex items-center gap-2">
<span className="h-3 w-3 rounded" style={{ backgroundColor: colorForQuery(qs.query) }} />
<span className="font-medium">{qs.query}</span>
</div>
<span className="text-zinc-500">
{qs.count}
{qs.approved > 0 && <span className="text-emerald-500"> ✓{qs.approved}</span>}
{qs.rejected > 0 && <span className="text-red-500"> ✗{qs.rejected}</span>}
</span>
</div>
</div>
))}
{activeBbox !== null && current && (() => {
const bbox = current.bboxes[activeBbox];
return (
<div className="border-t border-zinc-800 pt-3 mt-3">
<div className="text-xs font-bold mb-2">Selected bbox #{activeBbox + 1}</div>
<div className="text-xs text-zinc-400 mb-1">Falcon: {bbox.query}</div>
{bbox.vlm_verdict && (
<div className="text-xs mb-2">
<span className="text-zinc-500">Qwen: </span>
<span className={
bbox.vlm_verdict === "YES" ? "text-emerald-400" :
bbox.vlm_verdict === "NO" ? "text-red-400" :
"text-amber-400"
}>
{bbox.vlm_verdict}
</span>
{bbox.vlm_reasoning && (
<div className="text-zinc-400 italic mt-1 text-[11px]">
&ldquo;{bbox.vlm_reasoning}&rdquo;
</div>
)}
</div>
)}
<div className="flex gap-1 mt-2">
<Button size="sm" className="bg-emerald-700 hover:bg-emerald-600 text-xs h-7" onClick={() => setBboxVerdict(activeBbox, "approved")}>✓</Button>
<Button size="sm" variant="destructive" className="text-xs h-7" onClick={() => setBboxVerdict(activeBbox, "rejected")}>✗</Button>
<Button size="sm" variant="outline" className="text-xs h-7 border-zinc-700" onClick={() => setBboxVerdict(activeBbox, "unsure")}>?</Button>
</div>
</div>
);
})()}
</CardContent>
</Card>
</div>
</div>
</main>
);
}