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) => (
-
);
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) => (
+
+ ))}
+
+
+
+ >
)}
);