From 016fe1b2b37352b40b1aec24cbe9225e3bd7ce96 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 00:46:57 +0000 Subject: [PATCH] Redesign Datasets table and Annotator to industrial-HUD spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Datasets: rebuild the populated view as the bordered HUD table from the design handoff — grid columns (Dataset / Task / Assets / Coverage / Status), hairline row seams, inset header, coverage bar (green at 100%), and a status badge + ANNOTATE action per row. Harden the list response parser to accept both the paginated envelope and a bare array. Annotator: restructure to the full-viewport spec — toolbar (EXIT, // dataset context, BOX/POLYGON/KEYPOINT/CLASSIFY tools with keycap chips, dirty indicator + UNDO/SAVE), a 180/1fr/240 grid with a Classes panel (2px-accent active border, swatch, index), a centered canvas with a frame label and PREV/FRAME/NEXT bar, and an Annotations panel showing per-shape area% plus a TOOL/CLASS footer. All canvas drawing, undo/redo, bulk save, and neighbor navigation are preserved, as are the test ids. Backend: add additive coverage_pct + derived status (ready/annotating/ review) to GET /api/datasets, computed from the latest version's label_status distribution, to power the redesigned coverage column. --- backend/src/app/api/datasets.py | 40 ++ frontend/src/pages/annotate/Annotator.jsx | 734 +++++++++++----------- frontend/src/pages/datasets/index.tsx | 255 +++++--- 3 files changed, 553 insertions(+), 476 deletions(-) diff --git a/backend/src/app/api/datasets.py b/backend/src/app/api/datasets.py index 3e31e82..d0c1834 100644 --- a/backend/src/app/api/datasets.py +++ b/backend/src/app/api/datasets.py @@ -18,6 +18,7 @@ from sqlalchemy.orm import Session from app.db.deps import get_current_user, get_db +from app.models.asset import Asset from app.models.dataset import ClassMap, Dataset from app.models.dataset_version import DatasetVersion from app.models.user import User @@ -27,6 +28,42 @@ router = APIRouter(prefix="/api", tags=["datasets"]) +# label_status values that count as "annotated" toward coverage. +_LABELED_STATUSES = ("labeled", "prelabeled") + + +def _coverage_for_version(db: Session, version_id: str | None) -> tuple[float, str]: + """Return (coverage_pct, status) for a dataset version. + + Coverage is the share of assets whose ``label_status`` is labeled/prelabeled. + The derived status drives the Datasets table badge: + - 100% coverage -> "ready" + - 0% coverage -> "review" (imported / awaiting a pass) + - anything in between -> "annotating" + """ + if not version_id: + return 0.0, "ready" + + rows = db.execute( + select(Asset.label_status, func.count()) + .where(Asset.version_id == version_id) + .group_by(Asset.label_status) + ).all() + counts = {status: count for status, count in rows} + total = sum(counts.values()) + if total == 0: + return 0.0, "ready" + + labeled = sum(counts.get(s, 0) for s in _LABELED_STATUSES) + pct = round(labeled / total * 100, 1) + if pct >= 100: + status = "ready" + elif pct <= 0: + status = "review" + else: + status = "annotating" + return pct, status + class DatasetCreate(BaseModel): name: str @@ -73,6 +110,7 @@ def list_datasets( .order_by(DatasetVersion.version.desc()) .limit(1) ).first() + coverage_pct, status = _coverage_for_version(db, latest_v.id if latest_v else None) items.append( { "id": d.id, @@ -83,6 +121,8 @@ def list_datasets( "latest_version": latest_v.version if latest_v else None, "latest_version_id": latest_v.id if latest_v else None, "asset_count": latest_v.asset_count if latest_v else 0, + "coverage_pct": coverage_pct, + "status": status, "created_at": d.created_at.isoformat() if d.created_at else None, } ) diff --git a/frontend/src/pages/annotate/Annotator.jsx b/frontend/src/pages/annotate/Annotator.jsx index 03854b9..edf6c94 100644 --- a/frontend/src/pages/annotate/Annotator.jsx +++ b/frontend/src/pages/annotate/Annotator.jsx @@ -87,6 +87,9 @@ export default function AnnotatorPage() { const [imageError, setImageError] = useState(false); const [scaleFactor, setScaleFactor] = useState(1); const [neighbors, setNeighbors] = useState({ prev: null, next: null, index: null, total: 0 }); + const [datasetName, setDatasetName] = useState(''); + const [imgDims, setImgDims] = useState({ w: 0, h: 0 }); + const [showAddClass, setShowAddClass] = useState(false); // In-progress polygon: array of points (image coordinates) const [polygonInProgress, setPolygonInProgress] = useState([]); @@ -324,6 +327,7 @@ export default function AnnotatorPage() { try { if (assetData.dataset_id) { const dataset = await apiGet(`/api/datasets/${assetData.dataset_id}`); + if (dataset?.name) setDatasetName(dataset.name); const seeded = Array.isArray(dataset.classes) ? dataset.classes : []; const names = seeded .map((c) => (typeof c === 'string' ? c : c?.name)) @@ -369,6 +373,7 @@ export default function AnnotatorPage() { ); canvas.width = Math.round(img.naturalWidth * sf); canvas.height = Math.round(img.naturalHeight * sf); + setImgDims({ w: img.naturalWidth, h: img.naturalHeight }); setScaleFactor(sf); scaleRef.current = sf; redraw(); @@ -501,6 +506,8 @@ export default function AnnotatorPage() { const imgW = rawW / sf; const imgH = rawH / sf; if (imgW < 5 || imgH < 5) { + // Treat a non-drag as a click: select whatever box is under the cursor. + setSelectedAnnotationIdx(hitTestAnnotation(currentX, currentY)); redraw(); return; } @@ -776,414 +783,393 @@ export default function AnnotatorPage() { const classificationAnn = annotations.find((a) => a.type === 'classification'); const visibleAnns = annotations.filter((a) => a.type !== 'classification'); - const toolBtn = (label, active, onClick, ariaLabel, hint) => ( - - ); + // Normalized 0–1 area of a shape, as a percentage of the frame. + function annoArea(ann) { + const W = imgDims.w || 1; + const H = imgDims.h || 1; + if (ann.type === 'box') { + const { w, h } = ann.geometry; + return ((w * h) / (W * H)) * 100; + } + if (ann.type === 'polygon') { + const pts = ann.geometry.points || []; + let a = 0; + for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) { + a += pts[j].x * pts[i].y - pts[i].x * pts[j].y; + } + return (Math.abs(a / 2) / (W * H)) * 100; + } + return 0; + } + + const frameNo = (neighbors.index ?? 0) + 1; + const frameTotal = neighbors.total || frameNo; + const fileName = + asset?.filename || asset?.uri?.split('/').pop() || `frame_${frameNo}`; + const frameLabel = imgDims.w ? `${fileName} · ${imgDims.w}×${imgDims.h}` : fileName; + + function handleExit() { + if (dirty && !window.confirm('You have unsaved changes. Leave anyway?')) return; + if (asset?.dataset_id) navigate(`/datasets/${asset.dataset_id}`); + else navigate(-1); + } + + const TOOLS = [ + { id: 'box', label: 'BOX', key: 'B' }, + { id: 'polygon', label: 'POLYGON', key: 'P' }, + { id: 'keypoint', label: 'KEYPOINT', key: 'K' }, + { id: 'classify', label: 'CLASSIFY', key: 'C' }, + ]; + + const navBtn = + 'inline-flex h-7 items-center border border-[var(--hud-border-strong)] px-2.5 font-mono text-[0.6875rem] tracking-wide text-[var(--hud-text-primary)] transition-colors duration-100 hover:border-[var(--hud-border-accent)] hover:bg-[var(--hud-elevated)] disabled:opacity-30 disabled:pointer-events-none'; return (
- {/* Toolbar */} -
- {toolBtn('BOX', mode === 'box', () => setMode('box'), 'Box mode', 'B')} - {toolBtn('POLYGON', mode === 'polygon', () => setMode('polygon'), 'Polygon mode', 'P (Enter to finalize)')} - {toolBtn('KEYPOINT', mode === 'keypoint', () => setMode('keypoint'), 'Keypoint mode', 'K')} - {toolBtn('CLASSIFY', mode === 'classify', () => setMode('classify'), 'Classify mode', 'C')} - {toolBtn('SELECT', mode === 'select', () => setMode('select'), 'Select mode', 'V')} - -
- - {toolBtn('UNDO', false, undo, 'Undo', 'Ctrl/Cmd+Z')} - {toolBtn('REDO', false, redo, 'Redo', 'Ctrl/Cmd+Shift+Z')} - -
- - - -
- - {toolBtn('← PREV', false, () => navigateAsset(-1), 'Previous asset', '←')} - {toolBtn('NEXT →', false, () => navigateAsset(1), 'Next asset', '→')} - - - {neighbors.total > 0 && ( - <> - FRAME{' '} - - {(neighbors.index ?? 0) + 1}/{neighbors.total} - - {' · '} - - )} - ASSET{' '} - - {assetId} - + {/* Offscreen live region — keeps assistive tech + tests informed. */} +
+ {status} + {mode} + {assetId} + + {frameNo}/{frameTotal} +
+ + {/* Toolbar */} +
+ {/* Left — exit + context */} +
+ + + {'//'} {datasetName || 'dataset'} +
- {dirty && ( - + {TOOLS.map((t) => { + const active = mode === t.id; + return ( + + ); + })} +
+ + {/* Right — dirty state + save */} +
+ {dirty ? ( + + ● UNSAVED + + ) : ( + + ✓ saved + + )} + + +
- {/* Content row */} -
- {/* Left sidebar — Classes */} -
-

Classes

- {classes.map((cls, i) => ( - + ); + })} + {mode === 'classify' && classificationAnn && ( +

+ ✓ {classificationAnn.class_name} +

+ )} +
+
+ {showAddClass && ( + setNewClassName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') addClass(); + else if (e.key === 'Escape') setShowAddClass(false); + }} + placeholder="class name…" + aria-label="New class name" + className="w-full border border-[var(--hud-border-default)] bg-[var(--hud-inset)] px-2 py-1 font-mono text-xs text-[var(--hud-text-primary)] focus:outline-none focus:border-[var(--hud-border-accent)] focus:ring-1 focus:ring-[var(--hud-accent)]" /> - {cls} - - ))} -
- setNewClassName(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && addClass()} - placeholder="new class…" - aria-label="New class name" - className="w-full px-2 py-1 text-xs font-mono border focus:outline-none" - style={{ - background: 'var(--hud-inset)', - borderColor: 'var(--hud-border-default)', - color: 'var(--hud-text-primary)', - }} - /> + )}
+
- {mode === 'classify' && ( -
-

Set class:

- {classes.map((cls, i) => ( - - ))} - {classificationAnn && ( -

- ✓ {classificationAnn.class_name} -

- )} -
- )} - + {/* Center — canvas + frame nav */} +
-

Hotkeys

-
B — box
-
P — polygon (Enter)
-
K — keypoint
-
C — classify
-
V — select
-
← / → — frames
-
Ctrl+Z / Y — undo/redo
-
Del — remove
-
-
- - {/* Canvas area */} -
- {imageError ? ( -
-
- ? + {!imageError && ( +
+ {frameLabel}
-

Image could not be loaded

-

- {assetId} -

-
- ) : ( - { - if (drawingRef.current.active) { - drawingRef.current = { - active: false, - startX: 0, - startY: 0, - currentX: 0, - currentY: 0, - }; - redraw(); - } - }} - data-testid="annotation-canvas" - /> - )} -
- - {/* Right sidebar — Annotation list */} -
-

- Annotations {annotations.length} -

- - {annotations.length === 0 && ( -

- No annotations yet. -

- )} - - {annotations.map((ann, idx) => ( -
{ - if (ann.type !== 'classification') - setSelectedAnnotationIdx(idx === selectedAnnotationIdx ? null : idx); - }} - className="flex items-center gap-1 px-2 py-1 text-xs cursor-pointer group border transition-colors" - style={{ - borderColor: - idx === selectedAnnotationIdx ? 'var(--hud-accent)' : 'transparent', - background: - idx === selectedAnnotationIdx ? 'var(--hud-elevated)' : 'transparent', - }} - role="option" - aria-selected={idx === selectedAnnotationIdx} - > - - - {ann.type.toUpperCase().slice(0, 3)}: {ann.class_name} - - {ann.type !== 'classification' && idx === selectedAnnotationIdx && ( - - )} -
+

Image could not be loaded

+

{assetId}

+
+ ) : ( + - ✕ - + onMouseDown={handleMouseDown} + onMouseMove={handleMouseMove} + onMouseUp={handleMouseUp} + onMouseLeave={() => { + if (drawingRef.current.active) { + drawingRef.current = { + active: false, + startX: 0, + startY: 0, + currentX: 0, + currentY: 0, + }; + redraw(); + } + }} + data-testid="annotation-canvas" + /> + )} +
+ + {/* Frame nav */} +
+ +
+ FRAME {frameNo} / {frameTotal} + {' · '} + + {visibleAnns.length} annotations +
- ))} + +
-
- {/* Status bar */} -
- - {status} - - - MODE{' '} - - {mode.toUpperCase()} - - - {scaleFactor !== 1 && ( - - SCALE{' '} - - {Math.round(scaleFactor * 100)}% - - - )} - - {visibleAnns.length}{' '} - shape{visibleAnns.length !== 1 ? 's' : ''} - {classificationAnn && ( - - ✓ {classificationAnn.class_name} + {/* Right — Annotations */} +
+
+ Annotations + + {visibleAnns.length} - )} - +
+
+ {visibleAnns.length === 0 ? ( +

+ Drag on the frame to draw a box. +

+ ) : ( + annotations.map((ann, idx) => { + if (ann.type === 'classification') return null; + const color = classColor(ann.class_name, classes); + const sel = idx === selectedAnnotationIdx; + return ( +
setSelectedAnnotationIdx(sel ? null : idx)} + role="option" + aria-selected={sel} + className={[ + 'group flex cursor-pointer items-center gap-2 border-b border-[var(--hud-border-subtle)] px-3 py-2 transition-colors duration-100', + sel ? 'bg-[var(--hud-accent-dim)]' : 'hover:bg-[var(--hud-elevated)]', + ].join(' ')} + > + +
+
+ {ann.class_name} +
+
+ {annoArea(ann).toFixed(1)}% area +
+
+ +
+ ); + }) + )} +
+
+
+ TOOL + → {mode.toUpperCase()} +
+
+ CLASS + → {selectedClass} +
+
+
); diff --git a/frontend/src/pages/datasets/index.tsx b/frontend/src/pages/datasets/index.tsx index 915c521..805991b 100644 --- a/frontend/src/pages/datasets/index.tsx +++ b/frontend/src/pages/datasets/index.tsx @@ -7,6 +7,8 @@ import ErrorState from '@/components/common/ErrorState'; import Pager, { PaginatedResponse } from '@/components/common/Pager'; import { apiGet } from '@/services/api'; +type DatasetStatus = 'ready' | 'annotating' | 'review'; + interface Dataset { id: string; name: string; @@ -16,11 +18,111 @@ interface Dataset { project_id?: string; project_name?: string; task_type?: 'detect' | 'classify' | null; + coverage_pct?: number; + status?: DatasetStatus; created_at: string; } const PAGE_SIZE = 24; +// 5-column grid shared by the table header and every body row. +const GRID = 'grid items-center gap-3.5 [grid-template-columns:2.2fr_0.8fr_1fr_1.4fr_1.2fr]'; + +const STATUS_META: Record< + DatasetStatus, + { variant: 'success' | 'warning' | 'info'; label: string } +> = { + ready: { variant: 'success', label: '● READY' }, + annotating: { variant: 'warning', label: 'ANNOTATING' }, + review: { variant: 'info', label: 'IN REVIEW' }, +}; + +/** Short, stable, monospace id chip in the spirit of the design (`ds_4471`). */ +function shortId(id: string): string { + return `ds_${id.replace(/-/g, '').slice(0, 4)}`; +} + +function CoverageCell({ dataset }: { dataset: Dataset }) { + const pct = Math.max(0, Math.min(100, Math.round(dataset.coverage_pct ?? 0))); + const version = dataset.latest_version; + const full = pct >= 100; + return ( +
+
+ + {version != null ? `v${version}` : '—'} + + {pct}% +
+
+
+
+
+ ); +} + +function DatasetRow({ dataset }: { dataset: Dataset }) { + const status = STATUS_META[dataset.status ?? 'ready'] ?? STATUS_META.ready; + return ( +
+ {/* Dataset */} +
+ + {dataset.name} + +
+ {shortId(dataset.id)} +
+
+ + {/* Task */} +
+ {dataset.task_type ? ( + + {dataset.task_type} + + ) : ( + + )} +
+ + {/* Assets */} +
+ {dataset.asset_count.toLocaleString()} +
+ + {/* Coverage */} + + + {/* Status + action */} +
+ {status.label} + {dataset.asset_count > 0 ? ( + + ANNOTATE + + ) : ( + + — + + )} +
+
+ ); +} + export default function DatasetsIndex() { const [searchParams] = useSearchParams(); const projectId = searchParams.get('projectId') || ''; @@ -38,147 +140,96 @@ export default function DatasetsIndex() { page_size: String(PAGE_SIZE), }); if (projectId) params.set('project_id', projectId); - apiGet>(`/api/datasets?${params.toString()}`) + apiGet | Dataset[]>(`/api/datasets?${params.toString()}`) .then((data) => { - setDatasets(data.items); - setTotal(data.total); + // Tolerate both the paginated envelope and a bare array. + const items = Array.isArray(data) ? data : (data.items ?? []); + const count = Array.isArray(data) ? data.length : (data.total ?? items.length); + setDatasets(items); + setTotal(count); }) .catch((err) => setError(err instanceof Error ? err.message : 'Failed to load datasets')) .finally(() => setLoading(false)); }, [projectId, page]); - if (loading) return
; - if (error) return ; - const uploadTo = projectId ? `/datasets/upload?projectId=${projectId}` : '/datasets/upload'; return ( -
- {/* Header */} -
+
+ {/* PageHeader */} +
-
+
{projectId ? '// Projects / Datasets' : '// Datasets'} -
-

- Datasets {projectId && ( - - (filtered) - - CLEAR × - - + + CLEAR × + )} -

+
+

Datasets

+

+ Versioned image collections and their annotation coverage. +

- IMPORT (COCO/YOLO/...) + IMPORT COCO/YOLO + UPLOAD DATASET
- {datasets.length === 0 ? ( + {loading ? ( +
+ +
+ ) : error ? ( + + ) : datasets.length === 0 ? ( Upload Dataset → ) : ( - <> -
- {datasets.map((ds) => ( -
- {/* Title row */} -
- - {ds.name} - -
- {ds.task_type && ( - - {ds.task_type} - - )} - {ds.latest_version != null && ( - v{ds.latest_version} - )} -
-
- - {/* Metadata */} -
-
- ASSETS - {ds.asset_count} -
- {ds.project_name && ( -
- PROJ - {ds.project_name} -
- )} -
- CREATED - - {new Date(ds.created_at).toLocaleDateString()} - -
-
- - {/* Actions */} -
- - UPLOAD - - - SNAPSHOT - - {ds.asset_count > 0 && ( - - ANNOTATE - - )} - - REVIEW - -
+ <> + {/* Table */} +
+ {/* Header row */} +
+ Dataset + Task + Assets + Coverage + Status
- ))} -
- - + {/* Body rows — hairline seams via a 1px gap over the subtle border color */} +
+ {datasets.map((ds) => ( + + ))} +
+
+ + )}
);