From 115ee449f3e242e18eadfc1e477ce250da67821c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 01:21:56 +0000 Subject: [PATCH 1/6] feat(datasets): unified create/import/add-imagery flow Add a 3-step "New Dataset" wizard (/datasets/new) that combines dataset creation, image upload, and labeled-archive import in one flow, plus a shared "Add Data" panel on the dataset detail page for ongoing ingestion into the open (unlocked) version. - Extract reusable ImageUploader, ArchiveImporter, and DataSourcePanel components from the legacy upload/import pages. - Backend: add dataset_service.latest_open_version() and surface latest_version_id / open_version_id on the dataset detail response so new data lands in the correct editable version. - Repoint dataset list entry points to the new wizard; legacy upload/import/version pages remain for deep links. https://claude.ai/code/session_01LJ7JL1pztkSpkfhEdu8ivg --- backend/src/app/api/datasets.py | 6 + backend/src/app/services/dataset_service.py | 13 + .../components/datasets/ArchiveImporter.tsx | 155 +++++++++++ .../components/datasets/DataSourcePanel.tsx | 62 +++++ .../src/components/datasets/ImageUploader.tsx | 228 +++++++++++++++ frontend/src/pages/datasets/new.tsx | 262 ++++++++++++++++++ 6 files changed, 726 insertions(+) create mode 100644 frontend/src/components/datasets/ArchiveImporter.tsx create mode 100644 frontend/src/components/datasets/DataSourcePanel.tsx create mode 100644 frontend/src/components/datasets/ImageUploader.tsx create mode 100644 frontend/src/pages/datasets/new.tsx diff --git a/backend/src/app/api/datasets.py b/backend/src/app/api/datasets.py index d0c1834..0f5ccc8 100644 --- a/backend/src/app/api/datasets.py +++ b/backend/src/app/api/datasets.py @@ -162,6 +162,10 @@ def get_dataset( classes = json.loads(class_map.classes) except Exception: classes = [] + # The newest version (latest) and the newest editable/unlocked version + # (open) — the UI writes new imagery/imports into the open version. + latest_v = versions[0] if versions else None + open_v = next((v for v in versions if not v.locked), None) return { "id": d.id, "project_id": d.project_id, @@ -169,6 +173,8 @@ def get_dataset( "description": d.description, "task_type": d.task_type, "classes": classes, + "latest_version_id": latest_v.id if latest_v else None, + "open_version_id": open_v.id if open_v else None, "versions": [ { "id": v.id, diff --git a/backend/src/app/services/dataset_service.py b/backend/src/app/services/dataset_service.py index 14ea236..cf681bf 100644 --- a/backend/src/app/services/dataset_service.py +++ b/backend/src/app/services/dataset_service.py @@ -62,3 +62,16 @@ def snapshot_version(db: Session, dataset_id: str, notes: str | None = None) -> db.commit() db.refresh(ver) return ver + + +def latest_open_version(db: Session, dataset_id: str) -> DatasetVersion | None: + """Return the newest editable (unlocked) version, or None if all are locked. + + New imagery and annotations must land in an unlocked version; locked + versions are immutable snapshots. + """ + return db.scalars( + select(DatasetVersion) + .where(DatasetVersion.dataset_id == dataset_id, DatasetVersion.locked.is_(False)) + .order_by(DatasetVersion.version.desc()) + ).first() diff --git a/frontend/src/components/datasets/ArchiveImporter.tsx b/frontend/src/components/datasets/ArchiveImporter.tsx new file mode 100644 index 0000000..1fefe2e --- /dev/null +++ b/frontend/src/components/datasets/ArchiveImporter.tsx @@ -0,0 +1,155 @@ +import React, { useEffect, useState } from 'react'; +import Select from '@/components/ui/Select'; +import Input from '@/components/ui/Input'; +import Button from '@/components/ui/Button'; +import Alert from '@/components/ui/Alert'; +import Spinner from '@/components/ui/Spinner'; +import { apiGet, apiUrl, getToken } from '@/services/api'; + +export interface ImportResult { + dataset_id: string; + version_id: string; + format: string; + asset_count: number; + annotation_count: number; + classes: string[]; + warnings: string[]; +} + +interface ArchiveImporterProps { + datasetId: string; + /** Target an existing open version; omit to let the backend create one. */ + versionId?: string; + onComplete?: (result: ImportResult) => void; +} + +const DEFAULT_FORMATS = ['coco', 'yolo', 'pascal_voc', 'cvat', 'labelme', 'datumaro']; + +/** + * Imports a labeled archive (zip) into a dataset via `/api/datasets/{id}/import`. + * Uses a raw multipart fetch (the api.ts helpers are JSON-only). Extracted from + * the legacy import page so it can be embedded in the wizard and detail tab. + */ +export default function ArchiveImporter({ datasetId, versionId, onComplete }: ArchiveImporterProps) { + const [formats, setFormats] = useState(DEFAULT_FORMATS); + const [fmt, setFmt] = useState('coco'); + const [imageUriBase, setImageUriBase] = useState(''); + const [file, setFile] = useState(null); + const [result, setResult] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + useEffect(() => { + apiGet<{ formats: string[] }>('/api/datasets/formats') + .then((r) => r.formats?.length && setFormats(r.formats)) + .catch(() => {}); + }, []); + + async function onSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!datasetId || !file) { + setError('Pick an archive to import'); + return; + } + setLoading(true); + setError(null); + setResult(null); + try { + const fd = new FormData(); + fd.append('file', file); + fd.append('fmt', fmt); + if (versionId) fd.append('version_id', versionId); + if (imageUriBase) fd.append('image_uri_base', imageUriBase); + const token = getToken(); + const res = await fetch(apiUrl(`/api/datasets/${datasetId}/import`), { + method: 'POST', + body: fd, + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); + if (!res.ok) { + const detail = await res.json().catch(() => null); + throw new Error(detail?.detail || `HTTP ${res.status}`); + } + const json = (await res.json()) as ImportResult; + setResult(json); + onComplete?.(json); + } catch (err) { + setError(err instanceof Error ? err.message : 'Import failed'); + } finally { + setLoading(false); + } + } + + if (result) { + return ( + + Imported {result.asset_count} assets and {result.annotation_count} annotations ( + {result.classes.length} classes) into version{' '} + {result.version_id.slice(0, 8)}. + {result.warnings.length > 0 && ( +
+ {result.warnings.length} warning{result.warnings.length !== 1 ? 's' : ''} +
+ )} +
+ +
+
+ ); + } + + return ( +
+

+ Upload a zip in COCO, YOLO, Pascal VOC, CVAT, LabelMe, or Datumaro format. Images and labels + are added to the open version. +

+
+ + +
+
+ + setImageUriBase(e.target.value)} + placeholder="e.g. datasets//imported/" + /> +
+
+ + setFile(e.target.files?.[0] || null)} + className="text-xs font-mono" + /> +
+ {error && {error}} + +
+ ); +} diff --git a/frontend/src/components/datasets/DataSourcePanel.tsx b/frontend/src/components/datasets/DataSourcePanel.tsx new file mode 100644 index 0000000..f1f835f --- /dev/null +++ b/frontend/src/components/datasets/DataSourcePanel.tsx @@ -0,0 +1,62 @@ +import React, { useState } from 'react'; +import ImageUploader from '@/components/datasets/ImageUploader'; +import ArchiveImporter, { type ImportResult } from '@/components/datasets/ArchiveImporter'; + +type Tab = 'upload' | 'import'; + +interface DataSourcePanelProps { + datasetId: string; + /** Open (unlocked) version that new data should land in. */ + versionId: string; + onUploaded?: (count: number) => void; + onImported?: (result: ImportResult) => void; +} + +/** + * Tabbed data-entry surface: upload new imagery or import a labeled archive. + * Shared by the dataset creation wizard (step 2) and the detail "Data" tab. + */ +export default function DataSourcePanel({ + datasetId, + versionId, + onUploaded, + onImported, +}: DataSourcePanelProps) { + const [tab, setTab] = useState('upload'); + + const tabBtn = (id: Tab, label: string) => { + const active = tab === id; + return ( + + ); + }; + + return ( +
+
+ {tabBtn('upload', 'UPLOAD IMAGES')} + {tabBtn('import', 'IMPORT LABELED ARCHIVE')} +
+
+ {tab === 'upload' ? ( + + ) : ( + + )} +
+
+ ); +} diff --git a/frontend/src/components/datasets/ImageUploader.tsx b/frontend/src/components/datasets/ImageUploader.tsx new file mode 100644 index 0000000..a28dfcc --- /dev/null +++ b/frontend/src/components/datasets/ImageUploader.tsx @@ -0,0 +1,228 @@ +import React, { useRef, useState } from 'react'; +import Button from '@/components/ui/Button'; +import Alert from '@/components/ui/Alert'; +import Spinner from '@/components/ui/Spinner'; +import { apiPost } from '@/services/api'; + +interface UploadEntry { + name: string; + progress: number; + status: 'pending' | 'uploading' | 'done' | 'error'; + error?: string; +} + +interface ImageUploaderProps { + datasetId: string; + versionId: string; + /** Called after a batch finishes with the count of successfully uploaded files. */ + onComplete?: (uploaded: number) => void; +} + +/** + * Drag-and-drop image uploader. Requests a presigned PUT URL per file + * (`/api/ingest/upload-url`), streams the bytes with progress, then registers + * the asset (`/api/ingest/confirm`). Extracted from the legacy upload page so + * the creation wizard and the dataset detail "Data" tab can share it. + */ +export default function ImageUploader({ datasetId, versionId, onComplete }: ImageUploaderProps) { + const [files, setFiles] = useState([]); + const [uploads, setUploads] = useState([]); + const [uploading, setUploading] = useState(false); + const [error, setError] = useState(null); + const [allDone, setAllDone] = useState(false); + const fileInputRef = useRef(null); + + function selectFiles(selected: File[]) { + setFiles(selected); + setUploads([]); + setAllDone(false); + setError(null); + } + + function onDrop(e: React.DragEvent) { + e.preventDefault(); + selectFiles(Array.from(e.dataTransfer.files)); + } + + async function onUpload() { + if (!files.length || !datasetId || !versionId) { + setError('Create a dataset version first before uploading.'); + return; + } + setUploading(true); + setError(null); + setAllDone(false); + + const initial: UploadEntry[] = files.map((f) => ({ + name: f.name, + progress: 0, + status: 'pending', + })); + setUploads(initial); + + let hasError = false; + let uploaded = 0; + for (let i = 0; i < files.length; i++) { + const file = files[i]; + setUploads((prev) => prev.map((u, idx) => (idx === i ? { ...u, status: 'uploading' } : u))); + try { + const { url, objectKey } = await apiPost<{ + url: string; + fields: Record; + objectKey: string; + }>('/api/ingest/upload-url', { + datasetVersionId: versionId, + filename: file.name, + contentType: file.type, + }); + + await new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.upload.onprogress = (e) => { + if (e.lengthComputable) { + const pct = Math.round((e.loaded / e.total) * 100); + setUploads((prev) => prev.map((u, idx) => (idx === i ? { ...u, progress: pct } : u))); + } + }; + xhr.onload = () => + xhr.status < 300 ? resolve() : reject(new Error(`Upload failed: ${xhr.status}`)); + xhr.onerror = () => reject(new Error('Network error during upload')); + xhr.open('PUT', url); + xhr.setRequestHeader('Content-Type', file.type); + xhr.send(file); + }); + + await apiPost('/api/ingest/confirm', { + dataset_id: datasetId, + version_id: versionId, + storage_key: objectKey, + filename: file.name, + content_type: file.type, + }); + + uploaded += 1; + setUploads((prev) => + prev.map((u, idx) => (idx === i ? { ...u, progress: 100, status: 'done' } : u)) + ); + } catch (err) { + hasError = true; + const msg = err instanceof Error ? err.message : 'Upload failed'; + setUploads((prev) => + prev.map((u, idx) => (idx === i ? { ...u, status: 'error', error: msg } : u)) + ); + setError(msg); + } + } + + setUploading(false); + if (!hasError) setAllDone(true); + onComplete?.(uploaded); + } + + const doneCount = uploads.filter((u) => u.status === 'done').length; + + return ( +
+
e.preventDefault()} + onClick={() => fileInputRef.current?.click()} + className="flex flex-col items-center justify-center border border-dashed border-[var(--hud-border-strong)] bg-[var(--hud-inset)] p-8 cursor-pointer hover:border-[var(--hud-accent)] hover:bg-[var(--hud-elevated)] transition-colors group" + > +
+ +
+

+ Drop images here or click to browse +

+

JPG · PNG · WEBP

+ selectFiles(Array.from(e.target.files || []))} + /> +
+ + {files.length > 0 && uploads.length === 0 && ( +
+ {files.length} file(s) selected +
+ )} + + {uploads.length > 0 && ( +
+ {uploads.map((u, i) => ( +
+
+ {u.name} + + {u.status === 'done' + ? '✓ DONE' + : u.status === 'error' + ? '✗ ERROR' + : u.status === 'uploading' + ? `${u.progress}%` + : 'PENDING'} + +
+
+
+
+ {u.error && ( +

{u.error}

+ )} +
+ ))} +
+ )} + + {error && {error}} + +
+ + {allDone && ( + + ✓ {doneCount} file(s) uploaded + + )} +
+
+ ); +} diff --git a/frontend/src/pages/datasets/new.tsx b/frontend/src/pages/datasets/new.tsx new file mode 100644 index 0000000..7b81811 --- /dev/null +++ b/frontend/src/pages/datasets/new.tsx @@ -0,0 +1,262 @@ +import React, { useEffect, useState } from 'react'; +import { Link, useNavigate, useSearchParams } from 'react-router-dom'; +import Input from '@/components/ui/Input'; +import Button from '@/components/ui/Button'; +import Alert from '@/components/ui/Alert'; +import Select from '@/components/ui/Select'; +import Spinner from '@/components/ui/Spinner'; +import DataSourcePanel from '@/components/datasets/DataSourcePanel'; +import type { ImportResult } from '@/components/datasets/ArchiveImporter'; +import { apiGet, apiPost } from '@/services/api'; + +interface Project { + id: string; + name: string; +} + +const STEPS = ['Define', 'Add data', 'Review'] as const; + +export default function DatasetNew() { + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const [step, setStep] = useState(0); + + // Step 1 — define + const [projects, setProjects] = useState([]); + const [projectId, setProjectId] = useState(searchParams.get('projectId') || ''); + const [name, setName] = useState(''); + const [description, setDescription] = useState(''); + const [taskType, setTaskType] = useState('detect'); + const [classesText, setClassesText] = useState(''); + const [creating, setCreating] = useState(false); + const [error, setError] = useState(null); + + // Created dataset + const [datasetId, setDatasetId] = useState(''); + const [versionId, setVersionId] = useState(''); + + // Step 2/3 — running tallies + const [uploaded, setUploaded] = useState(0); + const [imports, setImports] = useState([]); + + useEffect(() => { + apiGet<{ items: Project[] }>('/api/projects?page=1&page_size=200') + .then((d) => setProjects(d.items || [])) + .catch(() => {}); + }, []); + + async function createDataset() { + if (!projectId) { + setError('Select a project'); + return; + } + if (!name.trim()) { + setError('Enter a dataset name'); + return; + } + setCreating(true); + setError(null); + try { + const classes = classesText + .split(',') + .map((c) => c.trim()) + .filter(Boolean); + const ds = await apiPost<{ id: string; activeVersionId: string }>( + `/api/datasets/${projectId}`, + { + name: name.trim(), + description: description.trim() || undefined, + task_type: taskType, + classes: classes.length ? classes : undefined, + } + ); + setDatasetId(ds.id); + setVersionId(ds.activeVersionId); + setStep(1); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to create dataset'); + } finally { + setCreating(false); + } + } + + const importedAssets = imports.reduce((sum, r) => sum + r.asset_count, 0); + const importedAnnotations = imports.reduce((sum, r) => sum + r.annotation_count, 0); + + return ( +
+ {/* Header */} +
+
+
// Datasets / New
+

Create Dataset

+
+ + ← DATASETS + +
+ + {/* Stepper */} +
+ {STEPS.map((label, i) => { + const active = i === step; + const done = i < step; + return ( + +
+ + {done ? '✓' : i + 1} + + + {label.toUpperCase()} + +
+ {i < STEPS.length - 1 && ( + + )} +
+ ); + })} +
+ + {/* Step 1 — Define */} + {step === 0 && ( +
+
+
+ + +
+
+ + +
+
+
+ + setName(e.target.value)} + onKeyDown={(e: React.KeyboardEvent) => e.key === 'Enter' && createDataset()} + /> +
+
+ + setDescription(e.target.value)} + /> +
+
+ + setClassesText(e.target.value)} + /> +
+ {error && {error}} + +
+ )} + + {/* Step 2 — Add data */} + {step === 1 && datasetId && versionId && ( +
+ setUploaded((n) => n + c)} + onImported={(r) => setImports((prev) => [...prev, r])} + /> +
+ + {uploaded + importedAssets} asset(s) added so far + + +
+
+ )} + + {/* Step 3 — Review */} + {step === 2 && ( +
+
+ Summary +
+ + + +
+
+
+ + + +
+
+ )} +
+ ); +} + +function SummaryRow({ label, value }: { label: string; value: string }) { + return ( +
+ {label} + {value} +
+ ); +} From b35f7aff3de6baa555a967aedbd3cc100f573cac Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 01:41:53 +0000 Subject: [PATCH 2/6] feat(datasets): gold-standard metrics dashboard + wire up new flow Add a detailed dataset metrics view: coverage/label-status, review status, per-class balance with imbalance ratio and unused-class detection, annotations-per-image, bbox area/aspect distributions, image resolution, annotation types, and 30-day labeling velocity. - Backend: asset_service.get_dataset_metrics() aggregates in SQL with a capped Python pass for box-geometry histograms; exposed at GET /api/datasets/{id}/metrics. Unit-tested (3 cases). - Frontend: hand-coded SVG/HUD chart primitives (StatTile, BarChart, Histogram, DonutChart, Sparkline) + /datasets/:id/metrics page. - Wire the unified create flow: routes for /datasets/new and /datasets/:id/metrics, "+ ADD DATA" panel and METRICS link on the dataset detail page, and repointed list CTAs to the new wizard. https://claude.ai/code/session_01LJ7JL1pztkSpkfhEdu8ivg --- backend/src/app/api/assets.py | 19 +- backend/src/app/services/asset_service.py | 233 +++++++++++++++++ backend/tests/unit/test_dataset_metrics.py | 146 +++++++++++ frontend/src/App.jsx | 4 + frontend/src/components/charts/BarChart.tsx | 51 ++++ frontend/src/components/charts/DonutChart.tsx | 101 ++++++++ frontend/src/components/charts/Histogram.tsx | 45 ++++ frontend/src/components/charts/Sparkline.tsx | 55 ++++ frontend/src/components/charts/StatTile.tsx | 31 +++ frontend/src/components/charts/colors.ts | 16 ++ .../components/datasets/ArchiveImporter.tsx | 11 +- .../src/components/datasets/ImageUploader.tsx | 8 +- .../src/pages/datasets/[datasetId]/index.tsx | 98 ++++--- .../pages/datasets/[datasetId]/metrics.tsx | 240 ++++++++++++++++++ frontend/src/pages/datasets/index.tsx | 21 +- 15 files changed, 1033 insertions(+), 46 deletions(-) create mode 100644 backend/tests/unit/test_dataset_metrics.py create mode 100644 frontend/src/components/charts/BarChart.tsx create mode 100644 frontend/src/components/charts/DonutChart.tsx create mode 100644 frontend/src/components/charts/Histogram.tsx create mode 100644 frontend/src/components/charts/Sparkline.tsx create mode 100644 frontend/src/components/charts/StatTile.tsx create mode 100644 frontend/src/components/charts/colors.ts create mode 100644 frontend/src/pages/datasets/[datasetId]/metrics.tsx diff --git a/backend/src/app/api/assets.py b/backend/src/app/api/assets.py index a753a6e..d0b55c9 100644 --- a/backend/src/app/api/assets.py +++ b/backend/src/app/api/assets.py @@ -10,7 +10,13 @@ from app.db.deps import get_current_user, get_db from app.models.user import User from app.services.annotation_service import get_asset_annotations -from app.services.asset_service import confirm_upload, get_asset, get_dataset_stats, list_assets +from app.services.asset_service import ( + confirm_upload, + get_asset, + get_dataset_metrics, + get_dataset_stats, + list_assets, +) router = APIRouter(prefix="/api", tags=["assets"]) @@ -138,6 +144,17 @@ def dataset_stats( return get_dataset_stats(db, dataset_id, version_id=version_id) +@router.get("/datasets/{dataset_id}/metrics") +def dataset_metrics( + dataset_id: str = Path(...), + version_id: str | None = Query(None), + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Detailed dataset health metrics for the metrics dashboard.""" + return get_dataset_metrics(db, dataset_id, version_id=version_id) + + @router.get("/assets/{asset_id}/neighbors") def get_asset_neighbors( asset_id: str = Path(...), diff --git a/backend/src/app/services/asset_service.py b/backend/src/app/services/asset_service.py index fd89f8d..644e767 100644 --- a/backend/src/app/services/asset_service.py +++ b/backend/src/app/services/asset_service.py @@ -1,14 +1,20 @@ from __future__ import annotations import json +from datetime import datetime, timedelta, timezone from sqlalchemy import func, select from sqlalchemy.orm import Session from app.models.annotation import Annotation from app.models.asset import Asset +from app.models.dataset import ClassMap, Dataset from app.models.dataset_version import DatasetVersion +# Cap on how many annotation geometries we parse in Python for the +# size/aspect-ratio histograms. Above this we sample and flag the result. +_GEOMETRY_SAMPLE_CAP = 5000 + def get_asset(db: Session, asset_id: str) -> Asset | None: return db.get(Asset, asset_id) @@ -100,3 +106,230 @@ def get_dataset_stats(db: Session, dataset_id: str, version_id: str | None = Non "class_distribution": class_counts, "annotation_count": sum(class_counts.values()), } + + +def _box_wh(geometry_json: str) -> tuple[float, float] | None: + """Parse a box annotation's geometry JSON into (width, height) in pixels.""" + try: + g = json.loads(geometry_json) + except Exception: + return None + w = g.get("w") + h = g.get("h") + if isinstance(w, (int, float)) and isinstance(h, (int, float)) and w > 0 and h > 0: + return float(w), float(h) + return None + + +def get_dataset_metrics(db: Session, dataset_id: str, version_id: str | None = None) -> dict: + """Gold-standard dataset health metrics. + + Aggregates in SQL where possible; geometry histograms parse a capped sample + of annotation rows in Python. Scopes to a single version when given. + """ + _LABELED = ("labeled", "prelabeled") + + def _scope_assets(q): + q = q.where(Asset.dataset_id == dataset_id) + if version_id: + q = q.where(Asset.version_id == version_id) + return q + + def _scope_anns(q): + q = q.join(Asset, Annotation.asset_id == Asset.id).where(Asset.dataset_id == dataset_id) + if version_id: + q = q.where(Asset.version_id == version_id) + return q + + # --- Asset / workflow counts ------------------------------------------ + status_rows = db.execute( + _scope_assets(select(Asset.label_status, func.count())).group_by(Asset.label_status) + ).all() + status_counts = {s: c for s, c in status_rows} + total_assets = sum(status_counts.values()) + labeled = sum(status_counts.get(s, 0) for s in _LABELED) + + annotated_assets = ( + db.scalar(_scope_anns(select(func.count(func.distinct(Annotation.asset_id))))) or 0 + ) + empty_images = max(total_assets - annotated_assets, 0) + + # --- Review workflow --------------------------------------------------- + review_rows = db.execute( + _scope_anns(select(Annotation.review_status, func.count())).group_by( + Annotation.review_status + ) + ).all() + review_counts = {(s or "unreviewed"): c for s, c in review_rows} + flagged = db.scalar(_scope_anns(select(func.count())).where(Annotation.flagged.is_(True))) or 0 + + # --- Class balance ----------------------------------------------------- + instance_rows = db.execute( + _scope_anns(select(Annotation.class_name, func.count())).group_by(Annotation.class_name) + ).all() + image_rows = db.execute( + _scope_anns( + select(Annotation.class_name, func.count(func.distinct(Annotation.asset_id))) + ).group_by(Annotation.class_name) + ).all() + instance_counts = {(c or "(none)"): n for c, n in instance_rows} + image_counts = {(c or "(none)"): n for c, n in image_rows} + total_annotations = sum(instance_counts.values()) + + nonzero = [n for c, n in instance_counts.items() if c != "(none)" and n > 0] + imbalance_ratio = round(max(nonzero) / min(nonzero), 2) if len(nonzero) >= 1 else None + + # Defined-but-unused classes (declared in the ClassMap, never annotated). + defined_classes: list[str] = [] + ds = db.get(Dataset, dataset_id) + if ds and ds.class_map_id: + cm = db.get(ClassMap, ds.class_map_id) + if cm: + try: + for c in json.loads(cm.classes): + name = c if isinstance(c, str) else c.get("name") + if name: + defined_classes.append(name) + except Exception: + pass + used = {c for c in instance_counts if c != "(none)"} + unused_classes = [c for c in defined_classes if c not in used] + + # --- Annotation type breakdown ---------------------------------------- + type_rows = db.execute( + _scope_anns(select(Annotation.type, func.count())).group_by(Annotation.type) + ).all() + type_counts = {t: n for t, n in type_rows} + + # --- Annotations per image -------------------------------------------- + per_asset_rows = db.execute( + _scope_anns(select(Annotation.asset_id, func.count())).group_by(Annotation.asset_id) + ).all() + per_image_counts = [n for _, n in per_asset_rows] + per_image_hist = {"0": empty_images, "1": 0, "2-5": 0, "6-10": 0, "10+": 0} + for n in per_image_counts: + if n == 1: + per_image_hist["1"] += 1 + elif n <= 5: + per_image_hist["2-5"] += 1 + elif n <= 10: + per_image_hist["6-10"] += 1 + else: + per_image_hist["10+"] += 1 + per_image_mean = round(total_annotations / total_assets, 2) if total_assets else 0.0 + per_image_max = max(per_image_counts) if per_image_counts else 0 + + # --- Box geometry (area + aspect ratio), sampled ---------------------- + geo_rows = db.execute( + _scope_anns(select(Annotation.geometry)) + .where(Annotation.type == "box") + .limit(_GEOMETRY_SAMPLE_CAP + 1) + ).all() + geometry_sampled = len(geo_rows) > _GEOMETRY_SAMPLE_CAP + area_hist = {"small (<32²)": 0, "medium (<96²)": 0, "large (≥96²)": 0} + aspect_hist = {"tall (<0.5)": 0, "square (0.5-2)": 0, "wide (>2)": 0} + for (geom,) in geo_rows[:_GEOMETRY_SAMPLE_CAP]: + wh = _box_wh(geom) + if not wh: + continue + w, h = wh + area = w * h + if area < 32 * 32: + area_hist["small (<32²)"] += 1 + elif area < 96 * 96: + area_hist["medium (<96²)"] += 1 + else: + area_hist["large (≥96²)"] += 1 + ar = w / h + if ar < 0.5: + aspect_hist["tall (<0.5)"] += 1 + elif ar <= 2.0: + aspect_hist["square (0.5-2)"] += 1 + else: + aspect_hist["wide (>2)"] += 1 + + # --- Image resolution -------------------------------------------------- + res_rows = db.execute( + _scope_assets(select(Asset.width, Asset.height)).where(Asset.width.is_not(None)) + ).all() + res_hist = {"<640": 0, "640-1280": 0, "1280-1920": 0, "≥1920": 0} + areas: list[int] = [] + for w, h in res_rows: + if not w or not h: + continue + areas.append(w * h) + m = max(w, h) + if m < 640: + res_hist["<640"] += 1 + elif m < 1280: + res_hist["640-1280"] += 1 + elif m < 1920: + res_hist["1280-1920"] += 1 + else: + res_hist["≥1920"] += 1 + areas.sort() + if areas: + median_area = areas[len(areas) // 2] + resolution = { + "min_pixels": areas[0], + "max_pixels": areas[-1], + "median_pixels": median_area, + "histogram": res_hist, + "with_dimensions": len(areas), + } + else: + resolution = { + "min_pixels": None, + "max_pixels": None, + "median_pixels": None, + "histogram": res_hist, + "with_dimensions": 0, + } + + # --- Labeling velocity (last 30 days) --------------------------------- + since = datetime.now(timezone.utc) - timedelta(days=30) + vel_rows = db.execute( + _scope_anns(select(func.date(Annotation.created_at), func.count())) + .where(Annotation.created_at >= since) + .group_by(func.date(Annotation.created_at)) + ).all() + velocity = [{"date": str(d), "count": n} for d, n in vel_rows if d is not None] + velocity.sort(key=lambda r: r["date"]) + + coverage_pct = round(labeled / total_assets * 100, 1) if total_assets else 0.0 + + return { + "total_assets": total_assets, + "total_annotations": total_annotations, + "coverage_pct": coverage_pct, + "labeled": labeled, + "empty_images": empty_images, + "label_status_distribution": status_counts, + "review": { + "unreviewed": review_counts.get("unreviewed", 0), + "approved": review_counts.get("approved", 0), + "rejected": review_counts.get("rejected", 0), + "flagged": flagged, + }, + "class_balance": { + "instances": instance_counts, + "images": image_counts, + "imbalance_ratio": imbalance_ratio, + "defined_classes": defined_classes, + "unused_classes": unused_classes, + }, + "annotation_types": type_counts, + "per_image": { + "histogram": per_image_hist, + "mean": per_image_mean, + "max": per_image_max, + }, + "box_geometry": { + "area_histogram": area_hist, + "aspect_histogram": aspect_hist, + "sampled": geometry_sampled, + }, + "resolution": resolution, + "velocity": velocity, + "split": None, # train/val/test split not modeled yet + } diff --git a/backend/tests/unit/test_dataset_metrics.py b/backend/tests/unit/test_dataset_metrics.py new file mode 100644 index 0000000..f7b7063 --- /dev/null +++ b/backend/tests/unit/test_dataset_metrics.py @@ -0,0 +1,146 @@ +"""Unit tests for asset_service.get_dataset_metrics using SQLite in-memory DB.""" + +from __future__ import annotations + +import json + +import pytest +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from app.db.base import Base + + +@pytest.fixture +def db(): + engine = create_engine("sqlite+pysqlite:///:memory:", connect_args={"check_same_thread": False}) + Base.metadata.create_all(engine) + Session = sessionmaker(bind=engine, autoflush=False, autocommit=False) + session = Session() + yield session + session.close() + Base.metadata.drop_all(engine) + + +def _seed_dataset(db, dataset_id="ds-1"): + from app.models.dataset import Dataset + from app.models.dataset_version import DatasetVersion + from app.models.project import Project + from app.models.workspace import Workspace + + db.add(Workspace(id="ws-1", name="WS", created_by="user-1")) + db.add(Project(id="proj-1", workspace_id="ws-1", name="P", slug="p")) + ds = Dataset(id=dataset_id, project_id="proj-1", name="DS") + db.add(ds) + ver = DatasetVersion(id="ver-1", dataset_id=dataset_id, version=1) + db.add(ver) + db.commit() + return ds, ver + + +_ASSET_SEQ = [0] + + +def _seed_asset(db, ds, ver, *, label_status="unlabeled", width=None, height=None): + from app.models.asset import Asset + + _ASSET_SEQ[0] += 1 + aid = f"asset-{_ASSET_SEQ[0]}" + a = Asset( + id=aid, + dataset_id=ds.id, + version_id=ver.id, + uri=f"datasets/v1/{aid}.jpg", + mime_type="image/jpeg", + label_status=label_status, + width=width, + height=height, + ) + db.add(a) + db.commit() + return a + + +_ANN_SEQ = [0] + + +def _seed_annotation(db, asset, *, class_name="cat", geometry=None, ann_type="box"): + from app.models.annotation import Annotation + + _ANN_SEQ[0] += 1 + geom = geometry or json.dumps({"x": 0, "y": 0, "w": 10, "h": 10}) + ann = Annotation( + id=f"ann-{_ANN_SEQ[0]}", + asset_id=asset.id, + type=ann_type, + geometry=geom, + class_name=class_name, + author_id="user-1", + ) + db.add(ann) + db.commit() + return ann + + +def test_get_dataset_metrics(db): + from app.services import asset_service + + ds, ver = _seed_dataset(db) + # a1: labeled, 3 small cat boxes, with dimensions + a1 = _seed_asset(db, ds, ver, label_status="labeled", width=800, height=600) + for _ in range(3): + _seed_annotation(db, a1, class_name="cat") + # a2: labeled, 1 large dog box + a2 = _seed_asset(db, ds, ver, label_status="labeled", width=1920, height=1080) + _seed_annotation( + db, a2, class_name="dog", geometry=json.dumps({"x": 0, "y": 0, "w": 200, "h": 200}) + ) + # a3: empty image (no annotations) + _seed_asset(db, ds, ver, label_status="unlabeled", width=400, height=300) + + m = asset_service.get_dataset_metrics(db, ds.id) + + assert m["total_assets"] == 3 + assert m["total_annotations"] == 4 + assert m["empty_images"] == 1 + assert m["class_balance"]["instances"]["cat"] == 3 + assert m["class_balance"]["instances"]["dog"] == 1 + assert m["class_balance"]["images"]["cat"] == 1 + assert m["class_balance"]["imbalance_ratio"] == 3.0 + assert m["per_image"]["histogram"]["0"] == 1 + assert m["per_image"]["histogram"]["1"] == 1 + assert m["per_image"]["histogram"]["2-5"] == 1 + assert m["per_image"]["max"] == 3 + assert m["box_geometry"]["area_histogram"]["small (<32²)"] == 3 + assert m["box_geometry"]["area_histogram"]["large (≥96²)"] == 1 + assert m["box_geometry"]["sampled"] is False + assert m["resolution"]["with_dimensions"] == 3 + assert m["resolution"]["histogram"]["≥1920"] == 1 + + +def test_get_dataset_metrics_empty(db): + from app.services import asset_service + + ds, _ = _seed_dataset(db) + m = asset_service.get_dataset_metrics(db, ds.id) + assert m["total_assets"] == 0 + assert m["total_annotations"] == 0 + assert m["coverage_pct"] == 0.0 + assert m["class_balance"]["imbalance_ratio"] is None + + +def test_get_dataset_metrics_unused_classes(db): + from app.models.dataset import ClassMap + from app.services import asset_service + + ds, ver = _seed_dataset(db) + cm = ClassMap(id="cm-1", project_id="proj-1", classes=json.dumps(["cat", "dog", "bird"])) + db.add(cm) + ds.class_map_id = "cm-1" + db.add(ds) + db.commit() + a1 = _seed_asset(db, ds, ver, label_status="labeled", width=640, height=480) + _seed_annotation(db, a1, class_name="cat") + + m = asset_service.get_dataset_metrics(db, ds.id) + assert set(m["class_balance"]["unused_classes"]) == {"dog", "bird"} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 339f330..1a063f7 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -12,7 +12,9 @@ import ProjectDashboard from "./pages/projects/[projectId]/index"; import DatasetUpload from "./pages/datasets/upload"; import DatasetVersion from "./pages/datasets/version"; import DatasetsIndex from "./pages/datasets/index"; +import DatasetNew from "./pages/datasets/new"; import DatasetDetail from "./pages/datasets/[datasetId]/index"; +import DatasetMetrics from "./pages/datasets/[datasetId]/metrics"; import DatasetAnnotateGateway from "./pages/datasets/[datasetId]/annotate"; import DatasetReviewQueue from "./pages/datasets/[datasetId]/review"; import ExperimentsIndex from "./pages/experiments/index"; @@ -228,9 +230,11 @@ export default function App() { } /> {/* Datasets */} } /> + } /> } /> } /> } /> + } /> } /> } /> {/* Experiments */} diff --git a/frontend/src/components/charts/BarChart.tsx b/frontend/src/components/charts/BarChart.tsx new file mode 100644 index 0000000..9eafc81 --- /dev/null +++ b/frontend/src/components/charts/BarChart.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import { colorAt } from '@/components/charts/colors'; + +export interface BarDatum { + label: string; + value: number; + /** Override the auto color (e.g. to flag an imbalanced/empty class). */ + color?: string; +} + +interface BarChartProps { + data: BarDatum[]; + /** Optional unit suffix for the value labels. */ + unit?: string; + maxBars?: number; +} + +/** Horizontal bar chart — used for per-class instance/image counts. */ +export default function BarChart({ data, unit = '', maxBars = 30 }: BarChartProps) { + const rows = [...data].sort((a, b) => b.value - a.value).slice(0, maxBars); + const max = rows.reduce((m, d) => Math.max(m, d.value), 0) || 1; + + if (rows.length === 0) { + return

No data

; + } + + return ( +
+ {rows.map((d, i) => ( +
+ + {d.label} + +
+
+
+ + {d.value} + {unit} + +
+ ))} +
+ ); +} diff --git a/frontend/src/components/charts/DonutChart.tsx b/frontend/src/components/charts/DonutChart.tsx new file mode 100644 index 0000000..475ce9c --- /dev/null +++ b/frontend/src/components/charts/DonutChart.tsx @@ -0,0 +1,101 @@ +import React from 'react'; + +export interface DonutSegment { + label: string; + value: number; + color: string; +} + +interface DonutChartProps { + segments: DonutSegment[]; + size?: number; + centerLabel?: string; + centerValue?: string | number; +} + +/** Donut chart built from stacked stroke-dasharray arcs. */ +export default function DonutChart({ + segments, + size = 140, + centerLabel, + centerValue, +}: DonutChartProps) { + const total = segments.reduce((s, seg) => s + seg.value, 0); + const stroke = 16; + const radius = (size - stroke) / 2; + const circ = 2 * Math.PI * radius; + + let offset = 0; + const arcs = segments + .filter((s) => s.value > 0) + .map((seg) => { + const frac = total > 0 ? seg.value / total : 0; + const dash = frac * circ; + const arc = ( + + ); + offset += dash; + return arc; + }); + + return ( +
+ + + {arcs} + {(centerValue !== undefined || centerLabel) && ( + <> + + {centerValue} + + {centerLabel && ( + + {centerLabel.toUpperCase()} + + )} + + )} + +
+ {segments.map((seg) => ( +
+ + {seg.label} + {seg.value} +
+ ))} +
+
+ ); +} diff --git a/frontend/src/components/charts/Histogram.tsx b/frontend/src/components/charts/Histogram.tsx new file mode 100644 index 0000000..6c5414f --- /dev/null +++ b/frontend/src/components/charts/Histogram.tsx @@ -0,0 +1,45 @@ +import React from 'react'; + +export interface HistBucket { + label: string; + value: number; +} + +interface HistogramProps { + data: HistBucket[]; + color?: string; + height?: number; +} + +/** Vertical bucketed histogram — per-image counts, bbox area, resolution. */ +export default function Histogram({ + data, + color = 'var(--hud-accent)', + height = 120, +}: HistogramProps) { + const max = data.reduce((m, d) => Math.max(m, d.value), 0) || 1; + if (data.length === 0) { + return

No data

; + } + + return ( +
+ {data.map((d) => ( +
+ {d.value} +
0 ? 2 : 0)}px`, + background: color, + minHeight: d.value > 0 ? 2 : 0, + }} + /> + + {d.label} + +
+ ))} +
+ ); +} diff --git a/frontend/src/components/charts/Sparkline.tsx b/frontend/src/components/charts/Sparkline.tsx new file mode 100644 index 0000000..cff2c92 --- /dev/null +++ b/frontend/src/components/charts/Sparkline.tsx @@ -0,0 +1,55 @@ +import React from 'react'; + +interface SparklinePoint { + date: string; + count: number; +} + +interface SparklineProps { + data: SparklinePoint[]; + height?: number; +} + +/** Compact line chart for labeling velocity over time. */ +export default function Sparkline({ data, height = 120 }: SparklineProps) { + if (data.length === 0) { + return

No recent activity

; + } + const w = 500; + const h = height; + const pad = 8; + const max = data.reduce((m, d) => Math.max(m, d.count), 0) || 1; + const n = data.length; + const x = (i: number) => (n === 1 ? w / 2 : pad + (i / (n - 1)) * (w - 2 * pad)); + const y = (v: number) => h - pad - (v / max) * (h - 2 * pad); + const points = data.map((d, i) => `${x(i)},${y(d.count)}`).join(' '); + const total = data.reduce((s, d) => s + d.count, 0); + + return ( +
+ + + {data.map((d, i) => ( + + ))} + +
+ {data[0].date} + {total} annotations / 30d + {data[data.length - 1].date} +
+
+ ); +} diff --git a/frontend/src/components/charts/StatTile.tsx b/frontend/src/components/charts/StatTile.tsx new file mode 100644 index 0000000..5b104b9 --- /dev/null +++ b/frontend/src/components/charts/StatTile.tsx @@ -0,0 +1,31 @@ +import React from 'react'; + +interface StatTileProps { + label: string; + value: string | number; + sub?: string; + tone?: 'default' | 'accent' | 'success' | 'warning' | 'danger'; +} + +const TONE: Record, string> = { + default: 'var(--hud-text-data)', + accent: 'var(--hud-text-accent)', + success: 'var(--hud-success-text)', + warning: 'var(--hud-warning-text)', + danger: 'var(--hud-danger-text)', +}; + +/** Big-number metric tile in the HUD summary-chip style. */ +export default function StatTile({ label, value, sub, tone = 'default' }: StatTileProps) { + return ( +
+
{label}
+
+ {value} +
+ {sub && ( +
{sub}
+ )} +
+ ); +} diff --git a/frontend/src/components/charts/colors.ts b/frontend/src/components/charts/colors.ts new file mode 100644 index 0000000..55b7ddb --- /dev/null +++ b/frontend/src/components/charts/colors.ts @@ -0,0 +1,16 @@ +// Shared HUD-muted chart palette (OKLCH), matching the annotator class colors +// and the experiments metric chart so visuals stay consistent across the app. +export const CHART_COLORS = [ + 'oklch(0.72 0.10 82)', // amber / accent + 'oklch(0.60 0.10 155)', // green + 'oklch(0.68 0.16 20)', // red + 'oklch(0.72 0.08 230)', // blue + 'oklch(0.70 0.10 75)', // gold + 'oklch(0.65 0.10 200)', // teal + 'oklch(0.62 0.10 100)', // olive + 'oklch(0.58 0.12 310)', // violet + 'oklch(0.68 0.10 40)', // orange + 'oklch(0.60 0.08 180)', // cyan +]; + +export const colorAt = (i: number): string => CHART_COLORS[i % CHART_COLORS.length]; diff --git a/frontend/src/components/datasets/ArchiveImporter.tsx b/frontend/src/components/datasets/ArchiveImporter.tsx index 1fefe2e..6f1097b 100644 --- a/frontend/src/components/datasets/ArchiveImporter.tsx +++ b/frontend/src/components/datasets/ArchiveImporter.tsx @@ -4,7 +4,8 @@ import Input from '@/components/ui/Input'; import Button from '@/components/ui/Button'; import Alert from '@/components/ui/Alert'; import Spinner from '@/components/ui/Spinner'; -import { apiGet, apiUrl, getToken } from '@/services/api'; +import { apiGet, apiUrl } from '@/services/api'; +import { getStoredToken } from '@/services/token-store'; export interface ImportResult { dataset_id: string; @@ -30,7 +31,11 @@ const DEFAULT_FORMATS = ['coco', 'yolo', 'pascal_voc', 'cvat', 'labelme', 'datum * Uses a raw multipart fetch (the api.ts helpers are JSON-only). Extracted from * the legacy import page so it can be embedded in the wizard and detail tab. */ -export default function ArchiveImporter({ datasetId, versionId, onComplete }: ArchiveImporterProps) { +export default function ArchiveImporter({ + datasetId, + versionId, + onComplete, +}: ArchiveImporterProps) { const [formats, setFormats] = useState(DEFAULT_FORMATS); const [fmt, setFmt] = useState('coco'); const [imageUriBase, setImageUriBase] = useState(''); @@ -60,7 +65,7 @@ export default function ArchiveImporter({ datasetId, versionId, onComplete }: Ar fd.append('fmt', fmt); if (versionId) fd.append('version_id', versionId); if (imageUriBase) fd.append('image_uri_base', imageUriBase); - const token = getToken(); + const token = getStoredToken(); const res = await fetch(apiUrl(`/api/datasets/${datasetId}/import`), { method: 'POST', body: fd, diff --git a/frontend/src/components/datasets/ImageUploader.tsx b/frontend/src/components/datasets/ImageUploader.tsx index a28dfcc..e7c3232 100644 --- a/frontend/src/components/datasets/ImageUploader.tsx +++ b/frontend/src/components/datasets/ImageUploader.tsx @@ -102,13 +102,13 @@ export default function ImageUploader({ datasetId, versionId, onComplete }: Imag uploaded += 1; setUploads((prev) => - prev.map((u, idx) => (idx === i ? { ...u, progress: 100, status: 'done' } : u)) + prev.map((u, idx) => (idx === i ? { ...u, progress: 100, status: 'done' } : u)), ); } catch (err) { hasError = true; const msg = err instanceof Error ? err.message : 'Upload failed'; setUploads((prev) => - prev.map((u, idx) => (idx === i ? { ...u, status: 'error', error: msg } : u)) + prev.map((u, idx) => (idx === i ? { ...u, status: 'error', error: msg } : u)), ); setError(msg); } @@ -197,7 +197,9 @@ export default function ImageUploader({ datasetId, versionId, onComplete }: Imag />
{u.error && ( -

{u.error}

+

+ {u.error} +

)}
))} diff --git a/frontend/src/pages/datasets/[datasetId]/index.tsx b/frontend/src/pages/datasets/[datasetId]/index.tsx index aecb00a..7feac76 100644 --- a/frontend/src/pages/datasets/[datasetId]/index.tsx +++ b/frontend/src/pages/datasets/[datasetId]/index.tsx @@ -4,6 +4,7 @@ import Badge from '@/components/ui/Badge'; import Button from '@/components/ui/Button'; import Loading from '@/components/common/Loading'; import ErrorState from '@/components/common/ErrorState'; +import DataSourcePanel from '@/components/datasets/DataSourcePanel'; import { apiGet, apiPost } from '@/services/api'; interface DatasetVersion { @@ -22,6 +23,7 @@ interface DatasetDetail { project_id?: string; classes: Array; versions: DatasetVersion[]; + open_version_id?: string | null; created_at?: string; } @@ -32,6 +34,7 @@ export default function DatasetDetail() { const [error, setError] = useState(null); const [snapshotLoading, setSnapshotLoading] = useState(false); const [snapshotMsg, setSnapshotMsg] = useState(null); + const [showData, setShowData] = useState(false); function reload() { if (!datasetId) return; @@ -59,21 +62,26 @@ export default function DatasetDetail() { } } - if (loading) return
; + if (loading) + return ( +
+ +
+ ); if (error) return ; if (!dataset) return ; const latestVersion = dataset.versions[0]; - const classNames = dataset.classes.map((c) => - typeof c === 'string' ? c : c.name - ); + const classNames = dataset.classes.map((c) => (typeof c === 'string' ? c : c.name)); return (
{/* Header */}
@@ -81,15 +89,27 @@ export default function DatasetDetail() {

{dataset.name} - {latestVersion && ( - v{latestVersion.version} - )} + {latestVersion && v{latestVersion.version}}

{dataset.description && ( -

{dataset.description}

+

+ {dataset.description} +

)}
+ + + METRICS + {latestVersion && ( <> + {showData && ( +
+
Add Data
+ {dataset.open_version_id ? ( + reload()} + onImported={() => reload()} + /> + ) : ( +
+ No unlocked version available to add data to. +
+ )} +
+ )} +
{/* Version History */}
@@ -135,21 +173,22 @@ export default function DatasetDetail() { ) : (
{dataset.versions.map((v, idx) => ( -
+
v{v.version} - {idx === 0 && ( - LATEST - )} - {v.locked && ( - LOCKED - )} + {idx === 0 && LATEST} + {v.locked && LOCKED}
{v.notes && ( -

{v.notes}

+

+ {v.notes} +

)} {v.created_at && (

@@ -223,10 +262,17 @@ export default function DatasetDetail() { { label: 'ID', value: {dataset.id} }, { label: 'Versions', value: dataset.versions.length }, { label: 'Total Assets', value: latestVersion?.asset_count ?? 0 }, - ...(dataset.created_at ? [{ label: 'Created', value: new Date(dataset.created_at).toLocaleDateString() }] : []), + ...(dataset.created_at + ? [{ label: 'Created', value: new Date(dataset.created_at).toLocaleDateString() }] + : []), ].map(({ label, value }) => ( -

- {label} +
+ + {label} + {value}
))} @@ -277,13 +323,7 @@ interface AssetSummary { label_status: string; } -function FrameExtractionCard({ - datasetId, - versionId, -}: { - datasetId: string; - versionId: string; -}) { +function FrameExtractionCard({ datasetId, versionId }: { datasetId: string; versionId: string }) { const [videos, setVideos] = useState([]); const [running, setRunning] = useState(null); const [error, setError] = useState(null); @@ -338,8 +378,8 @@ function FrameExtractionCard({
Video → Frames

- Extract one frame every N seconds - and persist as new image assets. + Extract one frame every N seconds and + persist as new image assets.