feat(vision): unify data panels with inline model testers

- Create unified data panel architecture replacing separate BoundaryDataPanel and ColumnClassifierDataPanel
- Add MiniBoundaryTester with SVG overlay showing ground truth vs predicted corners
- Add MiniColumnTester with prediction badge overlay showing digit and correct/wrong status
- Support preloading images in full test page via ?image= query param
- Fix overflow on detail panel to allow scrolling

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock
2026-01-10 14:00:09 -06:00
parent bc95a2f34b
commit ff5e0afc64
21 changed files with 3467 additions and 2090 deletions

View File

@@ -2,17 +2,13 @@
import { css } from '../../../../styled-system/css'
import { useModelType } from '../hooks/useModelType'
import { BoundaryDataPanel } from '../train/components/BoundaryDataPanel'
import { ColumnClassifierDataPanel } from '../train/components/ColumnClassifierDataPanel'
import { UnifiedDataPanel } from '../train/components/data-panel/UnifiedDataPanel'
/**
* Vision Training Data Hub
*
* Main page for /vision-training/[model].
* Renders the appropriate data panel based on the model type.
*
* - boundary-detector: BoundaryDataPanel
* - column-classifier: ColumnClassifierDataPanel
* Renders the unified data panel for the current model type.
*/
export default function VisionTrainingDataPage() {
const modelType = useModelType()
@@ -26,8 +22,7 @@ export default function VisionTrainingDataPage() {
})}
style={{ height: 'calc(100vh - var(--nav-height))' }}
>
{modelType === 'boundary-detector' && <BoundaryDataPanel showHeader />}
{modelType === 'column-classifier' && <ColumnClassifierDataPanel showHeader />}
<UnifiedDataPanel modelType={modelType} />
</div>
)
}

View File

@@ -33,6 +33,7 @@ export default function TestModelPage() {
const modelType = useModelType()
const searchParams = useSearchParams()
const sessionId = searchParams.get('session')
const preloadImageUrl = searchParams.get('image')
const [sessionInfo, setSessionInfo] = useState<SessionInfo | null>(null)
const [sessionLoading, setSessionLoading] = useState(false)
@@ -171,7 +172,7 @@ export default function TestModelPage() {
{modelType === 'column-classifier' && (
<>
{/* Image Upload Tester */}
<ImageUploadTester />
<ImageUploadTester preloadImageUrl={preloadImageUrl} />
{/* Divider */}
<div
@@ -238,7 +239,7 @@ export default function TestModelPage() {
</div>
{/* Image Upload Tester */}
<BoundaryImageTester />
<BoundaryImageTester preloadImageUrl={preloadImageUrl} />
{/* Quick info */}
<div
@@ -271,10 +272,15 @@ export default function TestModelPage() {
)
}
interface ImageUploadTesterProps {
/** Optional URL to preload an image from */
preloadImageUrl?: string | null
}
/**
* Component for uploading and testing individual training images
*/
function ImageUploadTester() {
function ImageUploadTester({ preloadImageUrl }: ImageUploadTesterProps) {
const fileInputRef = useRef<HTMLInputElement>(null)
const [uploadedImage, setUploadedImage] = useState<string | null>(null)
const [imageData, setImageData] = useState<ImageData | null>(null)
@@ -298,6 +304,69 @@ function ImageUploadTester() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []) // Only run once on mount
// Load preloaded image URL if provided
useEffect(() => {
if (!preloadImageUrl) return
const loadPreloadedImage = async () => {
setError(null)
setResult(null)
setIsLoading(true)
try {
// Fetch the image and convert to data URL
const response = await fetch(preloadImageUrl)
const blob = await response.blob()
const dataUrl = await new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result as string)
reader.onerror = reject
reader.readAsDataURL(blob)
})
setUploadedImage(dataUrl)
// Load as image and convert to ImageData
const img = new Image()
img.crossOrigin = 'anonymous'
img.src = dataUrl
await new Promise((resolve, reject) => {
img.onload = resolve
img.onerror = reject
})
// Draw to canvas to get ImageData
const canvas = document.createElement('canvas')
canvas.width = img.naturalWidth
canvas.height = img.naturalHeight
const ctx = canvas.getContext('2d', { willReadFrequently: true })
if (!ctx) throw new Error('Failed to get canvas context')
ctx.drawImage(img, 0, 0)
const imgData = ctx.getImageData(0, 0, canvas.width, canvas.height)
setImageData(imgData)
// Try to extract digit from URL path
const urlParts = preloadImageUrl.split('/')
const digitMatch = urlParts.find((p) => /^[0-9]$/.test(p))
if (digitMatch) {
setExpectedDigit(parseInt(digitMatch, 10))
}
console.log('[ImageUploadTester] Preloaded image:', {
width: imgData.width,
height: imgData.height,
url: preloadImageUrl,
})
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load preloaded image')
} finally {
setIsLoading(false)
}
}
loadPreloadedImage()
}, [preloadImageUrl])
const handleFileSelect = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
@@ -607,10 +676,15 @@ function ImageUploadTester() {
)
}
interface BoundaryImageTesterProps {
/** Optional URL to preload an image from */
preloadImageUrl?: string | null
}
/**
* Component for uploading and testing boundary detection on frame images
*/
function BoundaryImageTester() {
function BoundaryImageTester({ preloadImageUrl }: BoundaryImageTesterProps) {
const fileInputRef = useRef<HTMLInputElement>(null)
const [uploadedImage, setUploadedImage] = useState<string | null>(null)
const [imageElement, setImageElement] = useState<HTMLImageElement | null>(null)
@@ -629,6 +703,52 @@ function BoundaryImageTester() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []) // Only run once on mount
// Load preloaded image URL if provided
useEffect(() => {
if (!preloadImageUrl) return
const loadPreloadedImage = async () => {
setError(null)
setResult(null)
setIsLoading(true)
try {
// Fetch the image and convert to data URL
const response = await fetch(preloadImageUrl)
const blob = await response.blob()
const dataUrl = await new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result as string)
reader.onerror = reject
reader.readAsDataURL(blob)
})
setUploadedImage(dataUrl)
// Load as image element
const img = new Image()
img.crossOrigin = 'anonymous'
img.src = dataUrl
await new Promise((resolve, reject) => {
img.onload = resolve
img.onerror = reject
})
setImageElement(img)
console.log('[BoundaryImageTester] Preloaded image:', {
width: img.naturalWidth,
height: img.naturalHeight,
url: preloadImageUrl,
})
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load preloaded image')
} finally {
setIsLoading(false)
}
}
loadPreloadedImage()
}, [preloadImageUrl])
const handleFileSelect = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return

View File

@@ -1,82 +1,17 @@
/**
* Model Registry for Vision Training
*
* This registry maps model types to their specific panel components.
* Each model type can have custom Data, Train, Test, and Sessions panels.
*
* Architecture: Shared Shell + Swappable Panels
* - VisionTrainingShell handles the common UI (model selector, tabs)
* - Panels are looked up from this registry based on selected model type
* This registry defines model types and their metadata.
* Pages use the path-based architecture and render components directly.
*/
import type { ComponentType } from 'react'
import type { ModelType } from './train/components/wizard/types'
// Panel prop interfaces (minimal, each panel manages its own state)
export interface DataPanelProps {
onDataChanged?: () => void
}
export type TrainPanelProps = {}
export interface TestPanelProps {
sessionId?: string
}
export interface SessionsPanelProps {
modelType: ModelType
onSessionSelect?: (id: string) => void
}
// Registry entry for a model type
export interface ModelRegistryEntry {
label: string
description: string
DataPanel: ComponentType<DataPanelProps>
TrainPanel: ComponentType<TrainPanelProps>
TestPanel: ComponentType<TestPanelProps>
SessionsPanel: ComponentType<SessionsPanelProps>
}
// Placeholder component type for initial setup
type PlaceholderComponent = ComponentType<Record<string, unknown>>
// Placeholder - will be replaced with actual imports
// These are defined lazily to avoid circular imports
let _boundaryDataPanel: PlaceholderComponent | null = null
let _columnClassifierDataPanel: PlaceholderComponent | null = null
let _boundaryTrainPanel: PlaceholderComponent | null = null
let _columnClassifierTrainPanel: PlaceholderComponent | null = null
let _boundaryTestPanel: PlaceholderComponent | null = null
let _columnClassifierTestPanel: PlaceholderComponent | null = null
let _sharedSessionsPanel: PlaceholderComponent | null = null
// Registry initialization function - called once panels are available
export function registerPanels(panels: {
BoundaryDataPanel: PlaceholderComponent
ColumnClassifierDataPanel: PlaceholderComponent
BoundaryTrainPanel: PlaceholderComponent
ColumnClassifierTrainPanel: PlaceholderComponent
BoundaryTestPanel: PlaceholderComponent
ColumnClassifierTestPanel: PlaceholderComponent
SharedSessionsPanel: PlaceholderComponent
}) {
_boundaryDataPanel = panels.BoundaryDataPanel
_columnClassifierDataPanel = panels.ColumnClassifierDataPanel
_boundaryTrainPanel = panels.BoundaryTrainPanel
_columnClassifierTrainPanel = panels.ColumnClassifierTrainPanel
_boundaryTestPanel = panels.BoundaryTestPanel
_columnClassifierTestPanel = panels.ColumnClassifierTestPanel
_sharedSessionsPanel = panels.SharedSessionsPanel
}
// Fallback placeholder component
function PlaceholderPanel({ name }: { name: string }) {
return (
<div style={{ padding: '2rem', textAlign: 'center', color: '#888' }}>
<p>Panel not yet registered: {name}</p>
</div>
)
icon: string
}
// Helper to get model registry entry
@@ -86,27 +21,13 @@ export function getModelEntry(modelType: ModelType): ModelRegistryEntry {
return {
label: 'Boundary Detector',
description: 'Detects abacus boundaries in camera frames',
DataPanel: _boundaryDataPanel ?? (() => <PlaceholderPanel name="BoundaryDataPanel" />),
TrainPanel: _boundaryTrainPanel ?? (() => <PlaceholderPanel name="BoundaryTrainPanel" />),
TestPanel: _boundaryTestPanel ?? (() => <PlaceholderPanel name="BoundaryTestPanel" />),
SessionsPanel:
_sharedSessionsPanel ?? (() => <PlaceholderPanel name="SharedSessionsPanel" />),
icon: '🎯',
}
case 'column-classifier':
return {
label: 'Column Classifier',
description: 'Classifies abacus column values (0-9)',
DataPanel:
_columnClassifierDataPanel ??
(() => <PlaceholderPanel name="ColumnClassifierDataPanel" />),
TrainPanel:
_columnClassifierTrainPanel ??
(() => <PlaceholderPanel name="ColumnClassifierTrainPanel" />),
TestPanel:
_columnClassifierTestPanel ??
(() => <PlaceholderPanel name="ColumnClassifierTestPanel" />),
SessionsPanel:
_sharedSessionsPanel ?? (() => <PlaceholderPanel name="SharedSessionsPanel" />),
icon: '🔢',
}
default: {
const exhaustiveCheck: never = modelType

View File

@@ -3,7 +3,7 @@
import * as Dialog from '@radix-ui/react-dialog'
import { css } from '../../../../../styled-system/css'
import { Z_INDEX } from '@/constants/zIndex'
import { BoundaryDataPanel } from './BoundaryDataPanel'
import { UnifiedDataPanel } from './data-panel/UnifiedDataPanel'
interface BoundaryDataHubModalProps {
isOpen: boolean
@@ -14,7 +14,7 @@ interface BoundaryDataHubModalProps {
/**
* Boundary Data Hub Modal
*
* Modal wrapper for BoundaryDataPanel.
* Modal wrapper for boundary detector data panel.
* Provides quick access to boundary detector training data management.
*/
export function BoundaryDataHubModal({
@@ -41,8 +41,9 @@ export function BoundaryDataHubModal({
left: '50%',
transform: 'translate(-50%, -50%)',
width: '95vw',
maxWidth: '1000px',
maxHeight: '90vh',
maxWidth: '1200px',
height: '90vh',
maxHeight: '800px',
bg: 'gray.900',
borderRadius: 'xl',
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.5)',
@@ -53,48 +54,34 @@ export function BoundaryDataHubModal({
})}
style={{ zIndex: Z_INDEX.MODAL + 1 }}
>
{/* Header */}
<div
{/* Accessible title (visually hidden) */}
<Dialog.Title className={css({ srOnly: true })}>Boundary Training Data</Dialog.Title>
<Dialog.Description className={css({ srOnly: true })}>
Manage boundary detector training samples
</Dialog.Description>
{/* Close button */}
<Dialog.Close
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
p: 4,
borderBottom: '1px solid',
borderColor: 'gray.800',
position: 'absolute',
top: 2,
right: 2,
p: 2,
bg: 'transparent',
border: 'none',
color: 'gray.400',
cursor: 'pointer',
borderRadius: 'md',
zIndex: 10,
_hover: { color: 'gray.200', bg: 'gray.800' },
})}
>
<div className={css({ display: 'flex', alignItems: 'center', gap: 3 })}>
<span className={css({ fontSize: 'xl' })}>🎯</span>
<div>
<Dialog.Title
className={css({ fontSize: 'lg', fontWeight: 'bold', color: 'gray.100' })}
>
Boundary Training Data
</Dialog.Title>
<Dialog.Description className={css({ fontSize: 'sm', color: 'gray.400' })}>
Manage boundary detector training samples
</Dialog.Description>
</div>
</div>
<Dialog.Close
className={css({
p: 2,
bg: 'transparent',
border: 'none',
color: 'gray.400',
cursor: 'pointer',
borderRadius: 'md',
_hover: { color: 'gray.200', bg: 'gray.800' },
})}
>
</Dialog.Close>
</div>
</Dialog.Close>
{/* Panel content */}
<div className={css({ flex: 1, overflow: 'hidden' })}>
<BoundaryDataPanel onDataChanged={onDataChanged} />
<UnifiedDataPanel modelType="boundary-detector" onDataChanged={onDataChanged} />
</div>
</Dialog.Content>
</Dialog.Portal>

View File

@@ -1,646 +0,0 @@
'use client'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { css } from '../../../../../styled-system/css'
import type { DataPanelProps } from '../../registry'
import { NumeralSelector } from './NumeralSelector'
import { DigitImageBrowser, type TrainingImageMeta } from './DigitImageBrowser'
import { DigitCapturePanel } from './DigitCapturePanel'
import { SyncHistoryIndicator } from './SyncHistoryIndicator'
import { isColumnClassifierSamples, type SamplesData, type DataQuality } from './wizard/types'
interface SyncStatus {
available: boolean
remote?: { host: string; totalImages: number }
local?: { totalImages: number }
needsSync?: boolean
newOnRemote?: number
newOnLocal?: number
excludedByDeletion?: number
error?: string
}
interface SyncProgress {
phase: 'idle' | 'connecting' | 'syncing' | 'complete' | 'error'
message: string
filesTransferred?: number
bytesTransferred?: number
}
type MobileTab = 'browse' | 'capture'
interface ColumnClassifierDataPanelProps extends DataPanelProps {
/** Show the header with title and image count (default: true for modal, false for shell) */
showHeader?: boolean
/** Optional samples data (if not provided, panel will fetch its own) */
samples?: SamplesData | null
/** Optional sync status (if not provided, panel will check itself) */
syncStatus?: SyncStatus | null
/** Optional sync progress (if not provided, panel manages own sync) */
syncProgress?: SyncProgress
/** Optional sync start handler */
onStartSync?: () => void
/** Optional sync cancel handler */
onCancelSync?: () => void
}
/**
* Column Classifier Data Panel
*
* Standalone panel for managing column classifier training data.
* Can be used inside a modal or directly in the shell.
*
* Features:
* - Numeral selector bar at top showing counts/health
* - Split view: image browser (left) + capture panel (right)
* - Mobile: tab toggle between browse and capture
* - Optional sync with NAS
*/
export function ColumnClassifierDataPanel({
onDataChanged,
showHeader = false,
samples: samplesProp,
syncStatus: syncStatusProp,
syncProgress: syncProgressProp,
onStartSync: onStartSyncProp,
onCancelSync: onCancelSyncProp,
}: ColumnClassifierDataPanelProps) {
// Selected digit (0-9)
const [selectedDigit, setSelectedDigit] = useState(0)
// Mobile tab state
const [mobileTab, setMobileTab] = useState<MobileTab>('capture')
// Images for the selected digit
const [images, setImages] = useState<TrainingImageMeta[]>([])
const [imagesLoading, setImagesLoading] = useState(false)
// Self-managed samples state (used when not provided as prop)
const [selfSamples, setSelfSamples] = useState<SamplesData | null>(null)
const samples = samplesProp !== undefined ? samplesProp : selfSamples
// Self-managed sync state (used when not provided as props)
const [selfSyncStatus, setSelfSyncStatus] = useState<SyncStatus | null>(null)
const [selfSyncProgress, setSelfSyncProgress] = useState<SyncProgress>({
phase: 'idle',
message: '',
})
const syncStatus = syncStatusProp !== undefined ? syncStatusProp : selfSyncStatus
const syncProgress = syncProgressProp ?? selfSyncProgress
// Counter to trigger sync history refresh after sync completes
const [syncHistoryRefreshTrigger, setSyncHistoryRefreshTrigger] = useState(0)
// Fetch samples if not provided
useEffect(() => {
if (samplesProp !== undefined) return
const fetchSamples = async () => {
try {
const response = await fetch('/api/vision-training/samples?modelType=column-classifier')
if (response.ok) {
const data = await response.json()
setSelfSamples(data)
}
} catch (error) {
console.error('[ColumnClassifierDataPanel] Failed to fetch samples:', error)
}
}
fetchSamples()
}, [samplesProp])
// Fetch sync status if not provided
useEffect(() => {
if (syncStatusProp !== undefined) return
const fetchSyncStatus = async () => {
try {
const response = await fetch('/api/vision-training/sync/status')
if (response.ok) {
const data = await response.json()
setSelfSyncStatus(data)
}
} catch (error) {
console.error('[ColumnClassifierDataPanel] Failed to fetch sync status:', error)
}
}
fetchSyncStatus()
}, [syncStatusProp])
// Self-managed sync handlers
const handleStartSync = useCallback(async () => {
if (onStartSyncProp) {
onStartSyncProp()
return
}
setSelfSyncProgress({ phase: 'connecting', message: 'Connecting...' })
try {
const response = await fetch('/api/vision-training/sync', { method: 'POST' })
if (response.ok) {
setSelfSyncProgress({ phase: 'complete', message: 'Sync complete!' })
onDataChanged?.()
} else {
setSelfSyncProgress({ phase: 'error', message: 'Sync failed' })
}
} catch (error) {
setSelfSyncProgress({
phase: 'error',
message: error instanceof Error ? error.message : 'Sync failed',
})
}
// Refresh sync history after sync attempt (success or failure)
setSyncHistoryRefreshTrigger((prev) => prev + 1)
}, [onStartSyncProp, onDataChanged])
const handleCancelSync = useCallback(() => {
if (onCancelSyncProp) {
onCancelSyncProp()
return
}
setSelfSyncProgress({ phase: 'idle', message: '' })
}, [onCancelSyncProp])
// Digit counts from samples or computed from loaded images
// (only available for column classifier samples)
const digitCounts = useMemo(() => {
if (samples && isColumnClassifierSamples(samples)) {
const counts: Record<number, number> = {}
for (let i = 0; i <= 9; i++) {
counts[i] = samples.digits[i]?.count || 0
}
return counts
}
return { 0: 0, 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0, 7: 0, 8: 0, 9: 0 }
}, [samples])
// Total image count
const totalImages = useMemo(() => {
return Object.values(digitCounts).reduce((sum, count) => sum + count, 0)
}, [digitCounts])
// Data quality from samples
const dataQuality: DataQuality = samples?.dataQuality || 'none'
// Load images for selected digit
// showLoading: set to false when refreshing after capture (to avoid flash of loading state)
const loadImages = useCallback(
async (showLoading = true) => {
if (showLoading) {
setImagesLoading(true)
}
try {
const response = await fetch(`/api/vision-training/images?digit=${selectedDigit}`)
if (response.ok) {
const data = await response.json()
setImages(data.images || [])
}
} catch (error) {
console.error('Failed to load images:', error)
} finally {
if (showLoading) {
setImagesLoading(false)
}
}
},
[selectedDigit]
)
// Load images when digit changes
useEffect(() => {
loadImages()
}, [selectedDigit, loadImages])
// Handle capture success - refresh data without showing loading state
const handleCaptureSuccess = useCallback(
(_capturedCount: number) => {
onDataChanged?.()
// Don't show loading state - keeps existing images visible while new ones are fetched
loadImages(false)
// Re-fetch samples to update counts
if (samplesProp === undefined) {
fetch('/api/vision-training/samples?modelType=column-classifier')
.then((res) => res.json())
.then((data) => setSelfSamples(data))
.catch(() => {})
}
},
[onDataChanged, loadImages, samplesProp]
)
// Handle image deletion
const handleDeleteImage = useCallback(
async (image: TrainingImageMeta) => {
// Optimistically remove from state
setImages((prev) => prev.filter((img) => img.filename !== image.filename))
try {
const response = await fetch('/api/vision-training/images', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
filenames: [{ digit: image.digit, filename: image.filename }],
confirm: true,
}),
})
if (response.ok) {
onDataChanged?.()
loadImages(false)
} else {
loadImages(false)
}
} catch (error) {
console.error('Failed to delete image:', error)
loadImages(false)
}
},
[onDataChanged, loadImages]
)
// Handle bulk image deletion
const handleBulkDeleteImages = useCallback(
async (imagesToDelete: TrainingImageMeta[]) => {
// Optimistically remove deleted images from state
const deletedFilenames = new Set(imagesToDelete.map((img) => img.filename))
setImages((prev) => prev.filter((img) => !deletedFilenames.has(img.filename)))
try {
const response = await fetch('/api/vision-training/images', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
filenames: imagesToDelete.map((img) => ({
digit: img.digit,
filename: img.filename,
})),
confirm: true,
}),
})
if (response.ok) {
onDataChanged?.()
// Refresh in background without loading state
loadImages(false)
} else {
// Revert on failure
loadImages(false)
}
} catch (error) {
console.error('Failed to bulk delete images:', error)
// Revert on failure
loadImages(false)
}
},
[onDataChanged, loadImages]
)
// Handle image reclassification
const handleReclassifyImage = useCallback(
async (image: TrainingImageMeta, newDigit: number) => {
// Optimistically remove from current view (it's moving to another digit)
setImages((prev) => prev.filter((img) => img.filename !== image.filename))
try {
const response = await fetch(
`/api/vision-training/images/${image.digit}/${image.filename}`,
{
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ newDigit }),
}
)
if (response.ok) {
onDataChanged?.()
loadImages(false)
} else {
loadImages(false)
}
} catch (error) {
console.error('Failed to reclassify image:', error)
loadImages(false)
}
},
[onDataChanged, loadImages]
)
// Handle bulk image reclassification
const handleBulkReclassifyImages = useCallback(
async (imagesToMove: TrainingImageMeta[], newDigit: number) => {
// Optimistically remove reclassified images from current view (they're moving to another digit)
const movedFilenames = new Set(imagesToMove.map((img) => img.filename))
setImages((prev) => prev.filter((img) => !movedFilenames.has(img.filename)))
try {
const response = await fetch('/api/vision-training/images', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
images: imagesToMove.map((img) => ({
digit: img.digit,
filename: img.filename,
})),
newDigit,
}),
})
if (response.ok) {
onDataChanged?.()
// Refresh in background without loading state
loadImages(false)
} else {
// Revert on failure
loadImages(false)
}
} catch (error) {
console.error('Failed to bulk reclassify images:', error)
// Revert on failure
loadImages(false)
}
},
[onDataChanged, loadImages]
)
const isSyncing = syncProgress.phase === 'connecting' || syncProgress.phase === 'syncing'
const hasNewOnRemote = (syncStatus?.newOnRemote ?? 0) > 0
return (
<div
data-component="column-classifier-data-panel"
className={css({
display: 'flex',
flexDirection: 'column',
height: '100%',
bg: 'gray.900',
})}
>
{/* Optional header */}
{showHeader && (
<div
data-element="panel-header"
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
px: { base: 3, lg: 5 },
py: 3,
borderBottom: '1px solid',
borderColor: 'gray.800',
bg: 'gray.850',
})}
>
<div
data-element="header-title-group"
className={css({ display: 'flex', alignItems: 'center', gap: 3 })}
>
<span data-element="header-icon" className={css({ fontSize: 'xl' })}>
🎯
</span>
<div data-element="header-text">
<h2
data-element="header-title"
className={css({
fontSize: 'lg',
fontWeight: 'bold',
color: 'gray.100',
})}
>
Training Data Hub
</h2>
<div
data-element="header-subtitle"
className={css({ fontSize: 'sm', color: 'gray.500' })}
>
{totalImages.toLocaleString()} images {dataQuality} quality
</div>
</div>
</div>
<div
data-element="header-actions"
className={css({ display: 'flex', alignItems: 'center', gap: 3 })}
>
{/* Sync history indicator */}
{syncStatus?.available && (
<SyncHistoryIndicator
modelType="column-classifier"
refreshTrigger={syncHistoryRefreshTrigger}
/>
)}
{/* Sync button */}
{syncStatus?.available && (
<button
type="button"
data-action="sync"
data-status={isSyncing ? 'syncing' : hasNewOnRemote ? 'has-new' : 'in-sync'}
onClick={isSyncing ? handleCancelSync : handleStartSync}
disabled={!hasNewOnRemote && !isSyncing}
className={css({
display: 'flex',
alignItems: 'center',
gap: 2,
px: 3,
py: 2,
bg: isSyncing ? 'blue.800' : hasNewOnRemote ? 'blue.600' : 'gray.700',
color: hasNewOnRemote || isSyncing ? 'white' : 'gray.500',
borderRadius: 'lg',
border: 'none',
cursor: hasNewOnRemote || isSyncing ? 'pointer' : 'not-allowed',
fontSize: 'sm',
fontWeight: 'medium',
_hover: hasNewOnRemote ? { bg: 'blue.500' } : {},
})}
>
{isSyncing ? (
<>
<span
className={css({
animation: 'spin 1s linear infinite',
})}
>
🔄
</span>
<span
className={css({
display: { base: 'none', md: 'inline' },
})}
>
{syncProgress.message}
</span>
</>
) : hasNewOnRemote ? (
<>
<span></span>
<span
className={css({
display: { base: 'none', md: 'inline' },
})}
>
Sync {syncStatus.newOnRemote} new
</span>
</>
) : (
<>
<span></span>
<span
className={css({
display: { base: 'none', md: 'inline' },
})}
>
In sync
</span>
</>
)}
</button>
)}
</div>
</div>
)}
{/* Numeral selector bar */}
<div
data-element="numeral-selector-bar"
className={css({
px: { base: 2, lg: 5 },
py: 3,
borderBottom: '1px solid',
borderColor: 'gray.800',
bg: 'gray.875',
overflowX: 'auto',
})}
>
<NumeralSelector
digitCounts={digitCounts}
selectedDigit={selectedDigit}
onSelectDigit={setSelectedDigit}
compact={false}
/>
</div>
{/* Main content - split view */}
<div
data-element="main-content"
className={css({
flex: 1,
display: 'flex',
flexDirection: { base: 'column', lg: 'row' },
minHeight: 0,
overflow: 'hidden',
})}
>
{/* Mobile tab toggle */}
<div
data-element="mobile-tab-bar"
className={css({
display: { base: 'flex', lg: 'none' },
borderBottom: '1px solid',
borderColor: 'gray.800',
})}
>
<button
type="button"
data-action="select-capture-tab"
data-active={mobileTab === 'capture'}
onClick={() => setMobileTab('capture')}
className={css({
flex: 1,
py: 3,
bg: 'transparent',
color: mobileTab === 'capture' ? 'blue.400' : 'gray.500',
borderBottom: '2px solid',
borderColor: mobileTab === 'capture' ? 'blue.400' : 'transparent',
border: 'none',
cursor: 'pointer',
fontWeight: 'medium',
fontSize: 'sm',
})}
>
📸 Capture
</button>
<button
type="button"
data-action="select-browse-tab"
data-active={mobileTab === 'browse'}
onClick={() => setMobileTab('browse')}
className={css({
flex: 1,
py: 3,
bg: 'transparent',
color: mobileTab === 'browse' ? 'purple.400' : 'gray.500',
borderBottom: '2px solid',
borderColor: mobileTab === 'browse' ? 'purple.400' : 'transparent',
border: 'none',
cursor: 'pointer',
fontWeight: 'medium',
fontSize: 'sm',
})}
>
🖼 Browse ({digitCounts[selectedDigit] || 0})
</button>
</div>
{/* Left: Image Browser (desktop always, mobile when tab selected) */}
{/* This is the ONLY scrollable section - takes remaining space */}
<div
data-element="browse-panel"
data-visible={mobileTab === 'browse'}
className={css({
display: {
base: mobileTab === 'browse' ? 'flex' : 'none',
lg: 'flex',
},
flexDirection: 'column',
flex: 1,
minWidth: 0,
borderRight: { lg: '1px solid' },
borderColor: { lg: 'gray.800' },
bg: 'gray.850',
overflow: 'hidden',
})}
>
{/* Scrollable image grid */}
<div
data-element="browse-scroll-area"
className={css({ flex: 1, overflow: 'auto', p: 3 })}
>
<DigitImageBrowser
digit={selectedDigit}
images={images}
loading={imagesLoading}
onDeleteImage={handleDeleteImage}
onBulkDeleteImages={handleBulkDeleteImages}
onReclassifyImage={handleReclassifyImage}
onBulkReclassifyImages={handleBulkReclassifyImages}
/>
</div>
</div>
{/* Right: Capture Panel (desktop always, mobile when tab selected) */}
{/* No scrolling - camera takes full height, sized to content */}
<div
data-element="capture-panel"
data-visible={mobileTab === 'capture'}
className={css({
display: {
base: mobileTab === 'capture' ? 'flex' : 'none',
lg: 'flex',
},
flexDirection: 'column',
flexShrink: 0,
width: { lg: 'auto' },
maxWidth: { lg: '50%' },
minHeight: 0,
overflow: 'hidden',
bg: 'gray.900',
})}
>
<DigitCapturePanel
digit={selectedDigit}
onCaptureSuccess={handleCaptureSuccess}
columnCount={4}
/>
</div>
</div>
</div>
)
}

View File

@@ -28,10 +28,7 @@ interface SyncHistoryIndicatorProps {
* Shows "Last sync: X ago" with expandable dropdown showing recent syncs.
* Unobtrusive by default, informative on interaction.
*/
export function SyncHistoryIndicator({
modelType,
refreshTrigger = 0,
}: SyncHistoryIndicatorProps) {
export function SyncHistoryIndicator({ modelType, refreshTrigger = 0 }: SyncHistoryIndicatorProps) {
const [history, setHistory] = useState<SyncHistoryEntry[]>([])
const [loading, setLoading] = useState(true)
const [expanded, setExpanded] = useState(false)
@@ -110,9 +107,7 @@ export function SyncHistoryIndicator({
})}
>
<span>{isSuccess ? '✓' : '⚠'}</span>
<span>
{isSuccess ? `Last sync: ${timeAgo}` : `Sync failed ${timeAgo}`}
</span>
<span>{isSuccess ? `Last sync: ${timeAgo}` : `Sync failed ${timeAgo}`}</span>
<span
className={css({
fontSize: '10px',

View File

@@ -3,53 +3,24 @@
import * as Dialog from '@radix-ui/react-dialog'
import { css } from '../../../../../styled-system/css'
import { Z_INDEX } from '@/constants/zIndex'
import type { SamplesData } from './wizard/types'
import { ColumnClassifierDataPanel } from './ColumnClassifierDataPanel'
interface SyncStatus {
available: boolean
remote?: { host: string; totalImages: number }
local?: { totalImages: number }
needsSync?: boolean
newOnRemote?: number
newOnLocal?: number
excludedByDeletion?: number
error?: string
}
interface SyncProgress {
phase: 'idle' | 'connecting' | 'syncing' | 'complete' | 'error'
message: string
filesTransferred?: number
bytesTransferred?: number
}
import { UnifiedDataPanel } from './data-panel/UnifiedDataPanel'
interface TrainingDataHubModalProps {
isOpen: boolean
onClose: () => void
samples: SamplesData | null
onDataChanged: () => void
syncStatus: SyncStatus | null
syncProgress: SyncProgress
onStartSync: () => void
onCancelSync: () => void
}
/**
* Training Data Hub Modal
*
* Modal wrapper for ColumnClassifierDataPanel.
* Modal wrapper for column classifier data panel.
* Provides quick access to column classifier training data management.
*/
export function TrainingDataHubModal({
isOpen,
onClose,
samples,
onDataChanged,
syncStatus,
syncProgress,
onStartSync,
onCancelSync,
}: TrainingDataHubModalProps) {
return (
<Dialog.Root open={isOpen} onOpenChange={(open) => !open && onClose()}>
@@ -90,82 +61,28 @@ export function TrainingDataHubModal({
Manage and capture training data for the digit classifier
</Dialog.Description>
{/* Header */}
<div
data-element="modal-header"
{/* Close button */}
<Dialog.Close
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
px: { base: 3, lg: 5 },
py: 3,
borderBottom: '1px solid',
borderColor: 'gray.800',
bg: 'gray.850',
position: 'absolute',
top: 2,
right: 2,
p: 2,
bg: 'transparent',
border: 'none',
color: 'gray.400',
cursor: 'pointer',
borderRadius: 'md',
zIndex: 10,
_hover: { color: 'gray.200', bg: 'gray.800' },
})}
>
<div
data-element="header-title-group"
className={css({ display: 'flex', alignItems: 'center', gap: 3 })}
>
<span data-element="header-icon" className={css({ fontSize: 'xl' })}>
🎯
</span>
<div data-element="header-text">
<h2
data-element="header-title"
className={css({
fontSize: 'lg',
fontWeight: 'bold',
color: 'gray.100',
})}
>
Training Data Hub
</h2>
<div
data-element="header-subtitle"
className={css({ fontSize: 'sm', color: 'gray.500' })}
>
Manage and capture training data for the digit classifier
</div>
</div>
</div>
{/* Close button */}
<Dialog.Close asChild>
<button
type="button"
data-action="close-modal"
className={css({
width: '36px',
height: '36px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bg: 'transparent',
color: 'gray.500',
borderRadius: 'lg',
border: 'none',
cursor: 'pointer',
fontSize: 'lg',
_hover: { bg: 'gray.800', color: 'gray.300' },
})}
>
</button>
</Dialog.Close>
</div>
</Dialog.Close>
{/* Panel content */}
<div className={css({ flex: 1, overflow: 'hidden' })}>
<ColumnClassifierDataPanel
onDataChanged={onDataChanged}
samples={samples}
syncStatus={syncStatus}
syncProgress={syncProgress}
onStartSync={onStartSync}
onCancelSync={onCancelSync}
/>
<UnifiedDataPanel modelType="column-classifier" onDataChanged={onDataChanged} />
</div>
</Dialog.Content>
</Dialog.Portal>

View File

@@ -0,0 +1,654 @@
'use client'
import { useEffect, useState } from 'react'
import { css } from '../../../../../../styled-system/css'
import type { BoundaryDataItem } from './types'
import { MiniBoundaryTester } from './MiniBoundaryTester'
// Pipeline preview types
interface PipelineStepVariant {
image_base64: string
label: string
description: string
}
interface PipelineStep {
step: number
name: string
title: string
description: string
image_base64?: string
variants?: PipelineStepVariant[]
error?: string
note?: string
original_size?: string
target_size?: string
}
interface PipelinePreview {
steps: PipelineStep[]
}
export interface BoundaryDetailContentProps {
/** The selected boundary frame */
item: BoundaryDataItem
/** Handler to delete the item */
onDelete: () => void
/** Whether delete is in progress */
isDeleting: boolean
}
/**
* Detail content for boundary detector frames.
* Shows preview with corners, marker masking, pipeline preview, and metadata.
*/
export function BoundaryDetailContent({ item, onDelete, isDeleting }: BoundaryDetailContentProps) {
// Marker masking preview state
const [maskedImageUrl, setMaskedImageUrl] = useState<string | null>(null)
const [maskingInProgress, setMaskingInProgress] = useState(false)
const [maskingError, setMaskingError] = useState<string | null>(null)
const [maskRegions, setMaskRegions] = useState<
Array<{ x1: number; y1: number; x2: number; y2: number }>
>([])
const [showMaskedPreview, setShowMaskedPreview] = useState(true)
// Pipeline preview state
const [pipelinePreview, setPipelinePreview] = useState<PipelinePreview | null>(null)
const [pipelineLoading, setPipelineLoading] = useState(false)
const [pipelineError, setPipelineError] = useState<string | null>(null)
const [showPipelinePreview, setShowPipelinePreview] = useState(false)
// Fetch masked preview when item changes
useEffect(() => {
if (!showMaskedPreview) {
setMaskedImageUrl(null)
setMaskRegions([])
setMaskingError(null)
return
}
const fetchMaskedPreview = async () => {
setMaskingInProgress(true)
setMaskingError(null)
setMaskedImageUrl(null)
setMaskRegions([])
try {
const imageResponse = await fetch(item.imagePath)
const imageBlob = await imageResponse.blob()
const base64 = await new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => {
const result = reader.result as string
const base64Data = result.replace(/^data:image\/[a-z]+;base64,/, '')
resolve(base64Data)
}
reader.onerror = reject
reader.readAsDataURL(imageBlob)
})
const response = await fetch('/api/vision-training/preview-masked', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
imageData: base64,
corners: item.corners,
method: 'noise',
}),
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || 'Failed to generate masked preview')
}
const result = await response.json()
setMaskedImageUrl(`data:image/png;base64,${result.maskedImageData}`)
setMaskRegions(result.maskRegions || [])
} catch (error) {
console.error('[BoundaryDetailContent] Masking error:', error)
setMaskingError(error instanceof Error ? error.message : 'Unknown error')
} finally {
setMaskingInProgress(false)
}
}
fetchMaskedPreview()
}, [item, showMaskedPreview])
// Fetch pipeline preview when enabled
useEffect(() => {
if (!showPipelinePreview) {
setPipelinePreview(null)
setPipelineError(null)
return
}
const fetchPipelinePreview = async () => {
setPipelineLoading(true)
setPipelineError(null)
setPipelinePreview(null)
try {
const imageResponse = await fetch(item.imagePath)
const imageBlob = await imageResponse.blob()
const base64 = await new Promise<string>((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => {
const result = reader.result as string
const base64Data = result.replace(/^data:image\/[a-z]+;base64,/, '')
resolve(base64Data)
}
reader.onerror = reject
reader.readAsDataURL(imageBlob)
})
const response = await fetch('/api/vision-training/preview-augmentation', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
imageData: base64,
corners: item.corners,
}),
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || 'Failed to generate pipeline preview')
}
const result = await response.json()
setPipelinePreview(result.pipeline)
} catch (error) {
console.error('[BoundaryDetailContent] Pipeline preview error:', error)
setPipelineError(error instanceof Error ? error.message : 'Unknown error')
} finally {
setPipelineLoading(false)
}
}
fetchPipelinePreview()
}, [item, showPipelinePreview])
return (
<div
data-component="boundary-detail-content"
className={css({
display: 'flex',
flexDirection: 'column',
gap: 4,
height: '100%',
overflow: 'auto',
})}
>
{/* Preview with corners */}
<div
className={css({
position: 'relative',
borderRadius: 'md',
overflow: 'hidden',
})}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={item.imagePath}
alt="Selected frame"
className={css({ width: '100%', display: 'block' })}
/>
<svg
viewBox="0 0 100 100"
preserveAspectRatio="none"
className={css({
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
pointerEvents: 'none',
})}
>
<polygon
points={`${item.corners.topLeft.x * 100},${item.corners.topLeft.y * 100} ${item.corners.topRight.x * 100},${item.corners.topRight.y * 100} ${item.corners.bottomRight.x * 100},${item.corners.bottomRight.y * 100} ${item.corners.bottomLeft.x * 100},${item.corners.bottomLeft.y * 100}`}
fill="rgba(147, 51, 234, 0.1)"
stroke="rgba(147, 51, 234, 0.9)"
strokeWidth="1"
/>
<circle
cx={item.corners.topLeft.x * 100}
cy={item.corners.topLeft.y * 100}
r="2"
fill="#22c55e"
/>
<circle
cx={item.corners.topRight.x * 100}
cy={item.corners.topRight.y * 100}
r="2"
fill="#22c55e"
/>
<circle
cx={item.corners.bottomRight.x * 100}
cy={item.corners.bottomRight.y * 100}
r="2"
fill="#22c55e"
/>
<circle
cx={item.corners.bottomLeft.x * 100}
cy={item.corners.bottomLeft.y * 100}
r="2"
fill="#22c55e"
/>
</svg>
</div>
{/* Masked Preview Section */}
<div
className={css({
p: 2,
bg: 'gray.900',
borderRadius: 'md',
border: '1px solid',
borderColor: 'orange.700/50',
})}
>
<div
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
mb: showMaskedPreview ? 2 : 0,
})}
>
<div className={css({ display: 'flex', alignItems: 'center', gap: 2 })}>
<span className={css({ fontSize: 'sm' })}>🎭</span>
<span className={css({ fontSize: 'sm', fontWeight: 'medium', color: 'orange.300' })}>
Marker Masking Preview
</span>
</div>
<button
type="button"
onClick={() => setShowMaskedPreview(!showMaskedPreview)}
className={css({
fontSize: 'xs',
color: 'gray.400',
bg: 'transparent',
border: 'none',
cursor: 'pointer',
_hover: { color: 'gray.200' },
})}
>
{showMaskedPreview ? 'Hide' : 'Show'}
</button>
</div>
{showMaskedPreview && (
<>
<div
className={css({
position: 'relative',
borderRadius: 'md',
overflow: 'hidden',
bg: 'gray.800',
minHeight: '100px',
})}
>
{maskingInProgress ? (
<div
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100px',
color: 'gray.400',
})}
>
<span className={css({ animation: 'spin 1s linear infinite' })}></span>
<span className={css({ ml: 2, fontSize: 'sm' })}>
Generating masked preview...
</span>
</div>
) : maskingError ? (
<div
className={css({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
height: '100px',
color: 'red.400',
fontSize: 'sm',
textAlign: 'center',
p: 2,
})}
>
<span> {maskingError}</span>
</div>
) : maskedImageUrl ? (
<>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={maskedImageUrl}
alt="Marker-masked preview"
className={css({ width: '100%', display: 'block' })}
/>
<svg
viewBox="0 0 100 100"
preserveAspectRatio="none"
className={css({
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
pointerEvents: 'none',
})}
>
<polygon
points={`${item.corners.topLeft.x * 100},${item.corners.topLeft.y * 100} ${item.corners.topRight.x * 100},${item.corners.topRight.y * 100} ${item.corners.bottomRight.x * 100},${item.corners.bottomRight.y * 100} ${item.corners.bottomLeft.x * 100},${item.corners.bottomLeft.y * 100}`}
fill="rgba(249, 115, 22, 0.1)"
stroke="rgba(249, 115, 22, 0.9)"
strokeWidth="1"
/>
</svg>
</>
) : (
<div
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100px',
color: 'gray.500',
fontSize: 'sm',
})}
>
No masked preview
</div>
)}
</div>
{maskedImageUrl && maskRegions.length > 0 && (
<div className={css({ mt: 2, fontSize: 'xs', color: 'gray.400' })}>
{maskRegions.length} marker region{maskRegions.length !== 1 ? 's' : ''} masked
</div>
)}
<div className={css({ mt: 2, fontSize: 'xs', color: 'gray.500' })}>
Training with masked images forces the model to learn frame edges, not markers.
</div>
</>
)}
</div>
{/* Pipeline Preview Section */}
<div
className={css({
p: 2,
bg: 'gray.900',
borderRadius: 'md',
border: '1px solid',
borderColor: 'blue.700/50',
})}
>
<div
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
mb: showPipelinePreview ? 2 : 0,
})}
>
<div className={css({ display: 'flex', alignItems: 'center', gap: 2 })}>
<span className={css({ fontSize: 'sm' })}>🔬</span>
<span className={css({ fontSize: 'sm', fontWeight: 'medium', color: 'blue.300' })}>
Preprocessing Pipeline
</span>
</div>
<button
type="button"
onClick={() => setShowPipelinePreview(!showPipelinePreview)}
className={css({
fontSize: 'xs',
color: 'gray.400',
bg: 'transparent',
border: 'none',
cursor: 'pointer',
_hover: { color: 'gray.200' },
})}
>
{showPipelinePreview ? 'Hide' : 'Show'}
</button>
</div>
{showPipelinePreview && (
<>
{pipelineLoading ? (
<div
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100px',
color: 'gray.400',
})}
>
<span className={css({ animation: 'spin 1s linear infinite' })}></span>
<span className={css({ ml: 2, fontSize: 'sm' })}>
Generating pipeline preview...
</span>
</div>
) : pipelineError ? (
<div
className={css({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
height: '100px',
color: 'red.400',
fontSize: 'sm',
textAlign: 'center',
p: 2,
})}
>
<span> {pipelineError}</span>
</div>
) : pipelinePreview ? (
<div className={css({ display: 'flex', flexDirection: 'column', gap: 3 })}>
{pipelinePreview.steps.map((step) => (
<div
key={step.step}
className={css({
p: 2,
bg: 'gray.800',
borderRadius: 'md',
border: '1px solid',
borderColor: 'gray.700',
})}
>
<div className={css({ display: 'flex', alignItems: 'center', gap: 2, mb: 2 })}>
<span
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '20px',
height: '20px',
bg: 'blue.600',
color: 'white',
borderRadius: 'full',
fontSize: 'xs',
fontWeight: 'bold',
})}
>
{step.step}
</span>
<span
className={css({ fontSize: 'sm', fontWeight: 'medium', color: 'gray.100' })}
>
{step.title}
</span>
</div>
<div className={css({ fontSize: 'xs', color: 'gray.400', mb: 2 })}>
{step.description}
</div>
{step.image_base64 && (
<div
className={css({
borderRadius: 'sm',
overflow: 'hidden',
border: '1px solid',
borderColor: 'gray.600',
})}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={`data:image/jpeg;base64,${step.image_base64}`}
alt={step.title}
className={css({ width: '100%', display: 'block' })}
/>
</div>
)}
{step.variants && (
<div
className={css({
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gap: 1,
})}
>
{step.variants.map((variant, i) => (
<div
key={i}
className={css({
position: 'relative',
borderRadius: 'sm',
overflow: 'hidden',
border: variant.label === 'Original' ? '2px solid' : '1px solid',
borderColor: variant.label === 'Original' ? 'green.500' : 'gray.600',
})}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={`data:image/jpeg;base64,${variant.image_base64}`}
alt={variant.label}
className={css({
width: '100%',
aspectRatio: '1',
objectFit: 'cover',
})}
/>
<div
className={css({
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
bg: 'black/80',
fontSize: '8px',
color: 'gray.200',
textAlign: 'center',
py: '2px',
px: 1,
})}
title={variant.description}
>
{variant.label}
</div>
</div>
))}
</div>
)}
{step.note && (
<div
className={css({
mt: 2,
fontSize: 'xs',
color: 'gray.500',
fontStyle: 'italic',
})}
>
Note: {step.note}
</div>
)}
{step.error && (
<div className={css({ mt: 2, fontSize: 'xs', color: 'red.400' })}>
Error: {step.error}
</div>
)}
</div>
))}
<div className={css({ fontSize: 'xs', color: 'gray.500' })}>
This shows the exact preprocessing pipeline used during training.
</div>
</div>
) : (
<div
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '60px',
color: 'gray.500',
fontSize: 'sm',
})}
>
Click "Show" to load pipeline preview
</div>
)}
</>
)}
</div>
{/* Model Tester */}
<MiniBoundaryTester imagePath={item.imagePath} groundTruthCorners={item.corners} />
{/* Metadata */}
<div className={css({ fontSize: 'sm' })}>
<div className={css({ color: 'gray.400', mb: 1 })}>Captured</div>
<div className={css({ color: 'gray.200' })}>
{item.capturedAt ? new Date(item.capturedAt).toLocaleString() : 'Unknown'}
</div>
</div>
<div className={css({ fontSize: 'sm' })}>
<div className={css({ color: 'gray.400', mb: 1 })}>Resolution</div>
<div className={css({ color: 'gray.200' })}>
{item.frameWidth} × {item.frameHeight}
</div>
</div>
<div className={css({ fontSize: 'sm' })}>
<div className={css({ color: 'gray.400', mb: 1 })}>Device</div>
<div className={css({ color: 'gray.200', fontFamily: 'mono', fontSize: 'xs' })}>
{item.deviceId}
</div>
</div>
{/* Delete button */}
<button
type="button"
onClick={onDelete}
disabled={isDeleting}
className={css({
mt: 'auto',
py: 2,
bg: 'red.600/20',
color: 'red.400',
border: '1px solid',
borderColor: 'red.600/50',
borderRadius: 'md',
cursor: 'pointer',
fontWeight: 'medium',
_hover: { bg: 'red.600/30', borderColor: 'red.500' },
_disabled: { opacity: 0.5, cursor: 'not-allowed' },
})}
>
{isDeleting ? 'Deleting...' : '🗑️ Delete Frame'}
</button>
</div>
)
}

View File

@@ -0,0 +1,69 @@
'use client'
import { css } from '../../../../../../styled-system/css'
import type { BoundaryDataItem } from './types'
export interface BoundaryGridItemProps {
/** The boundary frame data */
item: BoundaryDataItem
/** Whether this item is selected */
isSelected: boolean
/** Click handler */
onClick: () => void
}
/**
* Grid item for boundary detector frames.
* Shows the image with corner overlay.
* Uses uniform 4:3 aspect ratio for grid consistency,
* with objectFit: contain to preserve image proportions.
*/
export function BoundaryGridItem({ item, isSelected, onClick }: BoundaryGridItemProps) {
return (
<button
type="button"
data-item-id={item.id}
onClick={onClick}
className={css({
position: 'relative',
aspectRatio: '4/3',
bg: 'gray.900',
border: '2px solid',
borderColor: isSelected ? 'purple.500' : 'gray.700',
borderRadius: 'lg',
overflow: 'hidden',
cursor: 'pointer',
_hover: { borderColor: 'purple.400' },
})}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={item.imagePath}
alt={`Frame ${item.id}`}
className={css({
width: '100%',
height: '100%',
objectFit: 'contain',
})}
/>
{/* Corner indicator - simple badge instead of overlay */}
<div
className={css({
position: 'absolute',
bottom: 1,
right: 1,
px: 1.5,
py: 0.5,
bg: 'purple.600/80',
color: 'white',
fontSize: 'xs',
fontWeight: 'bold',
borderRadius: 'sm',
})}
>
4pt
</div>
</button>
)
}

View File

@@ -0,0 +1,212 @@
'use client'
import { useState } from 'react'
import { css } from '../../../../../../styled-system/css'
import type { ColumnDataItem } from './types'
import { MiniColumnTester } from './MiniColumnTester'
export interface ColumnDetailContentProps {
/** The selected column image */
item: ColumnDataItem
/** Handler to delete the item */
onDelete: () => void
/** Whether delete is in progress */
isDeleting: boolean
/** Handler to reclassify the item to a different digit */
onReclassify: (newDigit: number) => void
/** Whether reclassification is in progress */
isReclassifying: boolean
}
/**
* Detail content for column classifier images.
* Shows preview, digit info, reclassify controls, and metadata.
*/
export function ColumnDetailContent({
item,
onDelete,
isDeleting,
onReclassify,
isReclassifying,
}: ColumnDetailContentProps) {
const [selectedNewDigit, setSelectedNewDigit] = useState<number | null>(null)
const handleReclassify = () => {
if (selectedNewDigit !== null && selectedNewDigit !== item.digit) {
onReclassify(selectedNewDigit)
setSelectedNewDigit(null)
}
}
return (
<div
data-component="column-detail-content"
className={css({
display: 'flex',
flexDirection: 'column',
gap: 4,
height: '100%',
overflow: 'auto',
})}
>
{/* Large preview */}
<div
className={css({
position: 'relative',
borderRadius: 'md',
overflow: 'hidden',
bg: 'gray.800',
})}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={item.imagePath}
alt={`Digit ${item.digit}`}
className={css({ width: '100%', display: 'block' })}
/>
{/* Current digit badge */}
<div
className={css({
position: 'absolute',
top: 2,
right: 2,
px: 3,
py: 1,
bg: 'blue.600',
color: 'white',
fontSize: 'lg',
fontWeight: 'bold',
borderRadius: 'md',
})}
>
{item.digit}
</div>
</div>
{/* Reclassify section */}
<div
className={css({
p: 3,
bg: 'gray.900',
borderRadius: 'md',
border: '1px solid',
borderColor: 'gray.700',
})}
>
<div className={css({ fontSize: 'sm', fontWeight: 'medium', color: 'gray.300', mb: 2 })}>
Reclassify to different digit
</div>
{/* Digit selector */}
<div className={css({ display: 'flex', gap: 1, mb: 3 })}>
{[0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map((digit) => (
<button
key={digit}
type="button"
onClick={() => setSelectedNewDigit(digit === item.digit ? null : digit)}
disabled={digit === item.digit}
className={css({
flex: 1,
py: 2,
border: '2px solid',
borderColor:
digit === item.digit
? 'gray.600'
: selectedNewDigit === digit
? 'green.500'
: 'gray.700',
borderRadius: 'md',
bg: selectedNewDigit === digit ? 'green.900/30' : 'gray.800',
color:
digit === item.digit
? 'gray.600'
: selectedNewDigit === digit
? 'green.300'
: 'gray.300',
fontSize: 'sm',
fontWeight: 'bold',
cursor: digit === item.digit ? 'not-allowed' : 'pointer',
_hover: digit !== item.digit ? { borderColor: 'green.400' } : {},
})}
>
{digit}
</button>
))}
</div>
{/* Apply button */}
<button
type="button"
onClick={handleReclassify}
disabled={selectedNewDigit === null || isReclassifying}
className={css({
width: '100%',
py: 2,
bg: selectedNewDigit !== null ? 'green.600' : 'gray.700',
color: selectedNewDigit !== null ? 'white' : 'gray.500',
border: 'none',
borderRadius: 'md',
fontWeight: 'medium',
cursor: selectedNewDigit !== null ? 'pointer' : 'not-allowed',
_hover: selectedNewDigit !== null ? { bg: 'green.500' } : {},
_disabled: { opacity: 0.5, cursor: 'not-allowed' },
})}
>
{isReclassifying
? 'Moving...'
: selectedNewDigit !== null
? `Move to digit ${selectedNewDigit}`
: 'Select a digit to move'}
</button>
</div>
{/* Model Tester */}
<MiniColumnTester imagePath={item.imagePath} groundTruthDigit={item.digit} />
{/* Metadata */}
<div className={css({ fontSize: 'sm' })}>
<div className={css({ color: 'gray.400', mb: 1 })}>Filename</div>
<div className={css({ color: 'gray.200', fontFamily: 'mono', fontSize: 'xs' })}>
{item.filename}
</div>
</div>
<div className={css({ fontSize: 'sm' })}>
<div className={css({ color: 'gray.400', mb: 1 })}>Captured</div>
<div className={css({ color: 'gray.200' })}>
{item.capturedAt ? new Date(item.capturedAt).toLocaleString() : 'Unknown'}
</div>
</div>
<div className={css({ fontSize: 'sm' })}>
<div className={css({ color: 'gray.400', mb: 1 })}>Device</div>
<div className={css({ color: 'gray.200', fontFamily: 'mono', fontSize: 'xs' })}>
{item.deviceId}
</div>
</div>
{/* Delete button */}
<button
type="button"
onClick={onDelete}
disabled={isDeleting}
className={css({
mt: 'auto',
py: 2,
bg: 'red.600/20',
color: 'red.400',
border: '1px solid',
borderColor: 'red.600/50',
borderRadius: 'md',
cursor: 'pointer',
fontWeight: 'medium',
_hover: { bg: 'red.600/30', borderColor: 'red.500' },
_disabled: { opacity: 0.5, cursor: 'not-allowed' },
})}
>
{isDeleting ? 'Deleting...' : '🗑️ Delete Image'}
</button>
</div>
)
}

View File

@@ -0,0 +1,69 @@
'use client'
import { css } from '../../../../../../styled-system/css'
import type { ColumnDataItem } from './types'
export interface ColumnGridItemProps {
/** The column image data */
item: ColumnDataItem
/** Whether this item is selected */
isSelected: boolean
/** Click handler */
onClick: () => void
}
/**
* Grid item for column classifier images.
* Shows the image with digit badge.
* Uses uniform square aspect ratio for grid consistency,
* with objectFit: contain to preserve image proportions.
*/
export function ColumnGridItem({ item, isSelected, onClick }: ColumnGridItemProps) {
return (
<button
type="button"
data-item-id={item.id}
onClick={onClick}
className={css({
position: 'relative',
aspectRatio: '1',
bg: 'gray.900',
border: '2px solid',
borderColor: isSelected ? 'blue.500' : 'gray.700',
borderRadius: 'lg',
overflow: 'hidden',
cursor: 'pointer',
_hover: { borderColor: 'blue.400' },
})}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={item.imagePath}
alt={`Digit ${item.digit}`}
className={css({
width: '100%',
height: '100%',
objectFit: 'contain',
})}
/>
{/* Digit badge */}
<div
className={css({
position: 'absolute',
bottom: 1,
right: 1,
px: 1.5,
py: 0.5,
bg: 'black/70',
color: 'white',
fontSize: 'xs',
fontWeight: 'bold',
borderRadius: 'sm',
})}
>
{item.digit}
</div>
</button>
)
}

View File

@@ -0,0 +1,48 @@
'use client'
import { css } from '../../../../../../styled-system/css'
import type { ModelType } from '../wizard/types'
import { BoundaryDataCapture } from '../BoundaryDataCapture'
import { DigitCapturePanel } from '../DigitCapturePanel'
export interface DataPanelCapturePanelProps {
/** Model type */
modelType: ModelType
/** Handler called when new samples are captured */
onCaptureComplete: () => void
/** Selected digit for column classifier (required when modelType is column-classifier) */
selectedDigit?: number
}
/**
* Shared capture panel that renders model-specific capture UI.
*/
export function DataPanelCapturePanel({
modelType,
onCaptureComplete,
selectedDigit = 0,
}: DataPanelCapturePanelProps) {
return (
<div
data-component="data-panel-capture"
className={css({
display: 'flex',
flexDirection: 'column',
height: '100%',
bg: 'gray.900',
})}
>
{modelType === 'boundary-detector' && (
<BoundaryDataCapture onSamplesCollected={onCaptureComplete} />
)}
{modelType === 'column-classifier' && (
<DigitCapturePanel
digit={selectedDigit}
onCaptureSuccess={onCaptureComplete}
columnCount={4}
/>
)}
</div>
)
}

View File

@@ -0,0 +1,108 @@
'use client'
import { css } from '../../../../../../styled-system/css'
import type { ModelType } from '../wizard/types'
import type { AnyDataItem, BoundaryDataItem, ColumnDataItem } from './types'
import { isBoundaryDataItem, isColumnDataItem } from './types'
import { BoundaryDetailContent } from './BoundaryDetailContent'
import { ColumnDetailContent } from './ColumnDetailContent'
export interface DataPanelDetailPanelProps {
/** Model type */
modelType: ModelType
/** Selected item (or null if nothing selected) */
selectedItem: AnyDataItem | null
/** Handler to close the detail panel */
onClose: () => void
/** Handler to delete the selected item */
onDelete: (item: AnyDataItem) => void
/** Whether delete is in progress */
isDeleting: boolean
/** Handler to reclassify (column classifier only) */
onReclassify?: (item: ColumnDataItem, newDigit: number) => void
/** Whether reclassification is in progress */
isReclassifying?: boolean
}
/**
* Shared detail panel that renders model-specific content.
* Shows when an item is selected from the grid.
*/
export function DataPanelDetailPanel({
modelType,
selectedItem,
onClose,
onDelete,
isDeleting,
onReclassify,
isReclassifying = false,
}: DataPanelDetailPanelProps) {
if (!selectedItem) return null
return (
<div
data-component="data-panel-detail"
className={css({
width: '320px',
flexShrink: 0,
bg: 'gray.800',
borderRadius: 'lg',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
})}
>
{/* Header with close button */}
<div
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
p: 3,
borderBottom: '1px solid',
borderColor: 'gray.700',
})}
>
<div className={css({ fontWeight: 'medium', color: 'gray.200' })}>
{modelType === 'boundary-detector' ? 'Frame Details' : 'Image Details'}
</div>
<button
type="button"
onClick={onClose}
className={css({
p: 1,
bg: 'transparent',
border: 'none',
color: 'gray.400',
cursor: 'pointer',
borderRadius: 'md',
_hover: { bg: 'gray.700', color: 'gray.200' },
})}
>
</button>
</div>
{/* Content area */}
<div className={css({ flex: 1, p: 4, overflow: 'auto' })}>
{modelType === 'boundary-detector' && isBoundaryDataItem(selectedItem) && (
<BoundaryDetailContent
item={selectedItem}
onDelete={() => onDelete(selectedItem)}
isDeleting={isDeleting}
/>
)}
{modelType === 'column-classifier' && isColumnDataItem(selectedItem) && (
<ColumnDetailContent
item={selectedItem}
onDelete={() => onDelete(selectedItem)}
isDeleting={isDeleting}
onReclassify={(newDigit) => onReclassify?.(selectedItem, newDigit)}
isReclassifying={isReclassifying}
/>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,396 @@
'use client'
import { useCallback, useMemo } from 'react'
import { css } from '../../../../../../styled-system/css'
import { TimelineRangeSelector } from '@/components/vision/TimelineRangeSelector'
import {
type DataPanelItem,
type DataPanelFilters as FilterState,
type CaptureTypeFilter,
type TimeRangeMode,
getDefaultFilters,
applyFilters,
isPassiveDevice,
} from './types'
export interface DataPanelFiltersProps<T extends DataPanelItem> {
/** All items to filter */
items: T[]
/** Current filter values */
filters: FilterState
/** Callback when filters change */
onFiltersChange: (filters: FilterState) => void
/** Whether filters panel is expanded */
isExpanded: boolean
/** Toggle expansion */
onToggleExpanded: () => void
/** Label for items (e.g., "frames", "images") */
itemLabel?: string
}
/**
* Shared filter component for data panels.
* Works with any DataPanelItem type.
*/
export function DataPanelFilters<T extends DataPanelItem>({
items,
filters,
onFiltersChange,
isExpanded,
onToggleExpanded,
itemLabel = 'items',
}: DataPanelFiltersProps<T>) {
// Extract unique values for dropdowns
const { devices, sessions, players, passiveCount, explicitCount } = useMemo(() => {
const deviceSet = new Set<string>()
const sessionSet = new Set<string>()
const playerSet = new Set<string>()
let passive = 0
let explicit = 0
for (const item of items) {
deviceSet.add(item.deviceId)
if (item.sessionId) sessionSet.add(item.sessionId)
if (item.playerId) playerSet.add(item.playerId)
if (isPassiveDevice(item.deviceId)) {
passive++
} else {
explicit++
}
}
return {
devices: Array.from(deviceSet).sort(),
sessions: Array.from(sessionSet).sort(),
players: Array.from(playerSet).sort(),
passiveCount: passive,
explicitCount: explicit,
}
}, [items])
// Convert items to timeline format
const timelineImages = useMemo(() => {
return items
.filter((item) => item.capturedAt)
.map((item) => ({
timestamp: new Date(item.capturedAt).getTime(),
sessionId: item.sessionId || item.deviceId,
}))
}, [items])
// Count active filters
const activeFilterCount = useMemo(() => {
let count = 0
if (filters.captureType !== 'all') count++
if (filters.deviceId) count++
if (filters.sessionId) count++
if (filters.playerId) count++
if (filters.timeRangeMode !== 'all') count++
return count
}, [filters])
const filteredCount = useMemo(() => applyFilters(items, filters).length, [items, filters])
const handleTimeRangeChange = useCallback(
(before?: number, after?: number) => {
onFiltersChange({
...filters,
beforeTimestamp: before,
afterTimestamp: after,
})
},
[filters, onFiltersChange]
)
const handleReset = useCallback(() => {
onFiltersChange(getDefaultFilters())
}, [onFiltersChange])
return (
<div data-component="data-panel-filters">
{/* Collapsed header */}
<button
type="button"
onClick={onToggleExpanded}
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
width: '100%',
p: 3,
bg: 'gray.800',
border: '1px solid',
borderColor: activeFilterCount > 0 ? 'purple.600' : 'gray.700',
borderRadius: isExpanded ? 'lg lg 0 0' : 'lg',
cursor: 'pointer',
color: 'gray.100',
_hover: { bg: 'gray.750' },
})}
>
<div className={css({ display: 'flex', alignItems: 'center', gap: 2 })}>
<span>🔍</span>
<span className={css({ fontWeight: 'medium' })}>Filters</span>
{activeFilterCount > 0 && (
<span
className={css({
px: 2,
py: 0.5,
bg: 'purple.600',
borderRadius: 'full',
fontSize: 'xs',
fontWeight: 'bold',
})}
>
{activeFilterCount}
</span>
)}
</div>
<div className={css({ display: 'flex', alignItems: 'center', gap: 2 })}>
<span className={css({ fontSize: 'sm', color: 'gray.400' })}>
{filteredCount} / {items.length} {itemLabel}
</span>
<span
className={css({
transform: isExpanded ? 'rotate(180deg)' : 'rotate(0deg)',
transition: 'transform 0.2s',
})}
>
</span>
</div>
</button>
{/* Expanded panel */}
{isExpanded && (
<div
className={css({
p: 4,
bg: 'gray.850',
border: '1px solid',
borderTop: 'none',
borderColor: activeFilterCount > 0 ? 'purple.600' : 'gray.700',
borderRadius: '0 0 lg lg',
})}
>
{/* Quick filters row */}
<div className={css({ display: 'flex', flexWrap: 'wrap', gap: 3, mb: 4 })}>
{/* Capture type toggle */}
<div className={css({ display: 'flex', flexDirection: 'column', gap: 1 })}>
<label className={css({ fontSize: 'xs', color: 'gray.500' })}>Capture Type</label>
<div
className={css({
display: 'flex',
bg: 'gray.800',
borderRadius: 'md',
p: '2px',
})}
>
{[
{ value: 'all', label: `All (${items.length})` },
{ value: 'passive', label: `Passive (${passiveCount})` },
{ value: 'explicit', label: `Explicit (${explicitCount})` },
].map((opt) => (
<button
key={opt.value}
type="button"
onClick={() =>
onFiltersChange({
...filters,
captureType: opt.value as CaptureTypeFilter,
})
}
className={css({
px: 2,
py: 1,
borderRadius: 'sm',
border: 'none',
fontSize: 'xs',
fontWeight: 'medium',
cursor: 'pointer',
bg: filters.captureType === opt.value ? 'purple.600' : 'transparent',
color: filters.captureType === opt.value ? 'white' : 'gray.400',
_hover: { color: 'white' },
})}
>
{opt.label}
</button>
))}
</div>
</div>
{/* Device dropdown */}
{devices.length > 1 && (
<div className={css({ display: 'flex', flexDirection: 'column', gap: 1 })}>
<label className={css({ fontSize: 'xs', color: 'gray.500' })}>Device</label>
<select
value={filters.deviceId}
onChange={(e) => onFiltersChange({ ...filters, deviceId: e.target.value })}
className={css({
px: 2,
py: 1.5,
bg: 'gray.800',
border: '1px solid',
borderColor: filters.deviceId ? 'purple.500' : 'gray.700',
borderRadius: 'md',
color: 'gray.200',
fontSize: 'sm',
cursor: 'pointer',
minWidth: '140px',
})}
>
<option value="">All devices</option>
{devices.map((d) => (
<option key={d} value={d}>
{d}
</option>
))}
</select>
</div>
)}
{/* Session dropdown */}
{sessions.length > 0 && (
<div className={css({ display: 'flex', flexDirection: 'column', gap: 1 })}>
<label className={css({ fontSize: 'xs', color: 'gray.500' })}>Session</label>
<select
value={filters.sessionId}
onChange={(e) => onFiltersChange({ ...filters, sessionId: e.target.value })}
className={css({
px: 2,
py: 1.5,
bg: 'gray.800',
border: '1px solid',
borderColor: filters.sessionId ? 'purple.500' : 'gray.700',
borderRadius: 'md',
color: 'gray.200',
fontSize: 'sm',
cursor: 'pointer',
minWidth: '140px',
})}
>
<option value="">All sessions</option>
{sessions.map((s) => (
<option key={s} value={s}>
{s.slice(0, 8)}...
</option>
))}
</select>
</div>
)}
{/* Player dropdown */}
{players.length > 0 && (
<div className={css({ display: 'flex', flexDirection: 'column', gap: 1 })}>
<label className={css({ fontSize: 'xs', color: 'gray.500' })}>Player</label>
<select
value={filters.playerId}
onChange={(e) => onFiltersChange({ ...filters, playerId: e.target.value })}
className={css({
px: 2,
py: 1.5,
bg: 'gray.800',
border: '1px solid',
borderColor: filters.playerId ? 'purple.500' : 'gray.700',
borderRadius: 'md',
color: 'gray.200',
fontSize: 'sm',
cursor: 'pointer',
minWidth: '140px',
})}
>
<option value="">All players</option>
{players.map((p) => (
<option key={p} value={p}>
{p}
</option>
))}
</select>
</div>
)}
{/* Reset button */}
{activeFilterCount > 0 && (
<div className={css({ display: 'flex', alignItems: 'flex-end' })}>
<button
type="button"
onClick={handleReset}
className={css({
px: 3,
py: 1.5,
bg: 'gray.700',
border: 'none',
borderRadius: 'md',
color: 'gray.300',
fontSize: 'sm',
cursor: 'pointer',
_hover: { bg: 'gray.600' },
})}
>
Reset
</button>
</div>
)}
</div>
{/* Time range filter */}
<div className={css({ borderTop: '1px solid', borderColor: 'gray.700', pt: 4 })}>
<div className={css({ display: 'flex', alignItems: 'center', gap: 3, mb: 3 })}>
<label className={css({ fontSize: 'sm', color: 'gray.400' })}>Time Range</label>
<div
className={css({
display: 'flex',
bg: 'gray.800',
borderRadius: 'md',
p: '2px',
})}
>
{[
{ value: 'all', label: 'All Time' },
{ value: 'before', label: 'Before' },
{ value: 'after', label: 'After' },
{ value: 'between', label: 'Between' },
].map((opt) => (
<button
key={opt.value}
type="button"
onClick={() =>
onFiltersChange({
...filters,
timeRangeMode: opt.value as TimeRangeMode,
})
}
className={css({
px: 2,
py: 1,
borderRadius: 'sm',
border: 'none',
fontSize: 'xs',
fontWeight: 'medium',
cursor: 'pointer',
bg: filters.timeRangeMode === opt.value ? 'purple.600' : 'transparent',
color: filters.timeRangeMode === opt.value ? 'white' : 'gray.400',
_hover: { color: 'white' },
})}
>
{opt.label}
</button>
))}
</div>
</div>
{/* Timeline selector */}
{filters.timeRangeMode !== 'all' && timelineImages.length > 0 && (
<TimelineRangeSelector
images={timelineImages}
mode={filters.timeRangeMode}
beforeTimestamp={filters.beforeTimestamp}
afterTimestamp={filters.afterTimestamp}
onChange={handleTimeRangeChange}
/>
)}
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,179 @@
'use client'
import { css } from '../../../../../../styled-system/css'
import type { ModelType } from '../wizard/types'
import type { SyncStatus, SyncProgress } from './types'
import { SyncHistoryIndicator } from '../SyncHistoryIndicator'
export interface DataPanelHeaderProps {
/** Model type */
modelType: ModelType
/** Total item count */
totalCount: number
/** Data quality label */
dataQuality: string
/** Sync status (optional) */
syncStatus?: SyncStatus | null
/** Sync progress (optional) */
syncProgress?: SyncProgress
/** Handler to start sync */
onStartSync?: () => void
/** Handler to cancel sync */
onCancelSync?: () => void
/** Trigger for sync history refresh */
syncHistoryRefreshTrigger?: number
}
const MODEL_CONFIG = {
'boundary-detector': {
icon: '🎯',
title: 'Boundary Training Data',
itemLabel: 'frames',
},
'column-classifier': {
icon: '🔢',
title: 'Column Classifier Data',
itemLabel: 'images',
},
} as const
/**
* Shared header component for data panels.
* Shows model info, count, quality, and optional sync controls.
*/
export function DataPanelHeader({
modelType,
totalCount,
dataQuality,
syncStatus,
syncProgress,
onStartSync,
onCancelSync,
syncHistoryRefreshTrigger = 0,
}: DataPanelHeaderProps) {
const config = MODEL_CONFIG[modelType]
const isSyncing = syncProgress?.phase === 'connecting' || syncProgress?.phase === 'syncing'
const hasNewOnRemote = (syncStatus?.newOnRemote ?? 0) > 0
return (
<div
data-element="data-panel-header"
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
px: { base: 3, lg: 5 },
py: 3,
borderBottom: '1px solid',
borderColor: 'gray.800',
bg: 'gray.850',
})}
>
{/* Title group */}
<div
data-element="header-title-group"
className={css({ display: 'flex', alignItems: 'center', gap: 3 })}
>
<span data-element="header-icon" className={css({ fontSize: 'xl' })}>
{config.icon}
</span>
<div data-element="header-text">
<h2
data-element="header-title"
className={css({
fontSize: 'lg',
fontWeight: 'bold',
color: 'gray.100',
})}
>
{config.title}
</h2>
<div
data-element="header-subtitle"
className={css({ fontSize: 'sm', color: 'gray.500' })}
>
{totalCount.toLocaleString()} {config.itemLabel} {dataQuality} quality
</div>
</div>
</div>
{/* Actions */}
<div
data-element="header-actions"
className={css({ display: 'flex', alignItems: 'center', gap: 3 })}
>
{/* Sync history indicator */}
{syncStatus?.available && (
<SyncHistoryIndicator modelType={modelType} refreshTrigger={syncHistoryRefreshTrigger} />
)}
{/* Sync button */}
{syncStatus?.available && onStartSync && onCancelSync && (
<button
type="button"
data-action="sync"
data-status={isSyncing ? 'syncing' : hasNewOnRemote ? 'has-new' : 'in-sync'}
onClick={isSyncing ? onCancelSync : onStartSync}
disabled={!hasNewOnRemote && !isSyncing}
className={css({
display: 'flex',
alignItems: 'center',
gap: 2,
px: 3,
py: 2,
bg: isSyncing ? 'blue.800' : hasNewOnRemote ? 'blue.600' : 'gray.700',
color: hasNewOnRemote || isSyncing ? 'white' : 'gray.500',
borderRadius: 'lg',
border: 'none',
cursor: hasNewOnRemote || isSyncing ? 'pointer' : 'not-allowed',
fontSize: 'sm',
fontWeight: 'medium',
_hover: hasNewOnRemote ? { bg: 'blue.500' } : {},
})}
>
{isSyncing ? (
<>
<span
className={css({
animation: 'spin 1s linear infinite',
})}
>
🔄
</span>
<span
className={css({
display: { base: 'none', md: 'inline' },
})}
>
{syncProgress?.message}
</span>
</>
) : hasNewOnRemote ? (
<>
<span></span>
<span
className={css({
display: { base: 'none', md: 'inline' },
})}
>
Sync {syncStatus.newOnRemote} new
</span>
</>
) : (
<>
<span></span>
<span
className={css({
display: { base: 'none', md: 'inline' },
})}
>
In sync
</span>
</>
)}
</button>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,359 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import Link from 'next/link'
import { css } from '../../../../../../styled-system/css'
import { useBoundaryDetector } from '@/hooks/useBoundaryDetector'
import type { QuadCorners } from '@/types/vision'
export interface MiniBoundaryTesterProps {
/** URL of the image to test */
imagePath: string
/** Ground truth corners from the annotation */
groundTruthCorners: QuadCorners
}
/**
* Mini boundary detector tester for the detail panel.
* Runs inference on the selected training image and shows results with visual overlay.
*/
export function MiniBoundaryTester({ imagePath, groundTruthCorners }: MiniBoundaryTesterProps) {
const [result, setResult] = useState<{
corners: QuadCorners
confidence: number
} | null>(null)
const [error, setError] = useState<string | null>(null)
const [isRunning, setIsRunning] = useState(false)
const [hasRun, setHasRun] = useState(false)
const imageRef = useRef<HTMLImageElement | null>(null)
const detector = useBoundaryDetector({ enabled: true })
// Reset state when image changes
useEffect(() => {
setResult(null)
setError(null)
setHasRun(false)
}, [imagePath])
const runInference = useCallback(async () => {
if (!imageRef.current || isRunning) return
setIsRunning(true)
setError(null)
try {
// Ensure model is loaded
if (!detector.isReady) {
await detector.preload()
}
const detectionResult = await detector.detectFromImage(imageRef.current)
if (!detectionResult) {
throw new Error('Detection failed - model may not be available')
}
setResult({
corners: detectionResult.corners,
confidence: detectionResult.confidence,
})
setHasRun(true)
} catch (err) {
setError(err instanceof Error ? err.message : 'Inference failed')
} finally {
setIsRunning(false)
}
}, [detector, isRunning])
// Calculate corner error (average distance between predicted and ground truth)
const calculateError = useCallback((): number | null => {
if (!result) return null
const corners = ['topLeft', 'topRight', 'bottomLeft', 'bottomRight'] as const
let totalError = 0
for (const corner of corners) {
const dx = result.corners[corner].x - groundTruthCorners[corner].x
const dy = result.corners[corner].y - groundTruthCorners[corner].y
totalError += Math.sqrt(dx * dx + dy * dy)
}
return totalError / 4
}, [result, groundTruthCorners])
const avgError = calculateError()
// Get confidence color
const getConfidenceColor = (conf: number) => {
if (conf >= 0.8) return 'green.400'
if (conf >= 0.5) return 'yellow.400'
return 'red.400'
}
// Get error color (in normalized units, ~0.02 is very good, ~0.1 is bad)
const getErrorColor = (err: number) => {
if (err <= 0.02) return 'green.400'
if (err <= 0.05) return 'yellow.400'
return 'red.400'
}
return (
<div
data-component="mini-boundary-tester"
className={css({
p: 3,
bg: 'gray.900',
borderRadius: 'md',
border: '1px solid',
borderColor: 'purple.700/50',
})}
>
<div
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
mb: 3,
})}
>
<div className={css({ display: 'flex', alignItems: 'center', gap: 2 })}>
<span className={css({ fontSize: 'sm' })}>🧪</span>
<span className={css({ fontSize: 'sm', fontWeight: 'medium', color: 'purple.300' })}>
Model Tester
</span>
</div>
<span className={css({ fontSize: 'xs', color: 'gray.500' })}>
{detector.isReady ? (
<span className={css({ color: 'green.400' })}>Ready</span>
) : detector.isLoading ? (
<span className={css({ color: 'yellow.400' })}>Loading...</span>
) : detector.isUnavailable ? (
<span className={css({ color: 'red.400' })}>Unavailable</span>
) : (
<span className={css({ color: 'gray.400' })}>Not loaded</span>
)}
</span>
</div>
{/* Visual preview with corner overlay */}
<div
className={css({
position: 'relative',
width: '100%',
aspectRatio: '4/3',
bg: 'gray.800',
borderRadius: 'md',
overflow: 'hidden',
mb: 3,
})}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
ref={imageRef}
src={imagePath}
alt="Frame for inference"
crossOrigin="anonymous"
className={css({
width: '100%',
height: '100%',
objectFit: 'contain',
})}
/>
{/* Corner overlay SVG */}
{result && (
<svg
viewBox="0 0 100 100"
preserveAspectRatio="none"
className={css({
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
pointerEvents: 'none',
})}
>
{/* Ground truth quadrilateral (green, dashed) */}
<polygon
points={`${groundTruthCorners.topLeft.x * 100},${groundTruthCorners.topLeft.y * 100} ${groundTruthCorners.topRight.x * 100},${groundTruthCorners.topRight.y * 100} ${groundTruthCorners.bottomRight.x * 100},${groundTruthCorners.bottomRight.y * 100} ${groundTruthCorners.bottomLeft.x * 100},${groundTruthCorners.bottomLeft.y * 100}`}
fill="none"
stroke="rgba(34, 197, 94, 0.8)"
strokeWidth="0.5"
strokeDasharray="2,1"
/>
{/* Predicted quadrilateral (purple, solid) */}
<polygon
points={`${result.corners.topLeft.x * 100},${result.corners.topLeft.y * 100} ${result.corners.topRight.x * 100},${result.corners.topRight.y * 100} ${result.corners.bottomRight.x * 100},${result.corners.bottomRight.y * 100} ${result.corners.bottomLeft.x * 100},${result.corners.bottomLeft.y * 100}`}
fill="rgba(147, 51, 234, 0.15)"
stroke="rgba(147, 51, 234, 0.9)"
strokeWidth="0.5"
/>
{/* Ground truth corner markers (green squares) */}
{(['topLeft', 'topRight', 'bottomLeft', 'bottomRight'] as const).map((corner) => (
<rect
key={`gt-${corner}`}
x={groundTruthCorners[corner].x * 100 - 1}
y={groundTruthCorners[corner].y * 100 - 1}
width="2"
height="2"
fill="#22c55e"
stroke="white"
strokeWidth="0.3"
/>
))}
{/* Predicted corner markers (purple circles) */}
{(['topLeft', 'topRight', 'bottomLeft', 'bottomRight'] as const).map((corner) => (
<circle
key={`pred-${corner}`}
cx={result.corners[corner].x * 100}
cy={result.corners[corner].y * 100}
r="1.5"
fill="#a855f7"
stroke="white"
strokeWidth="0.3"
/>
))}
{/* Error lines connecting ground truth to predicted */}
{(['topLeft', 'topRight', 'bottomLeft', 'bottomRight'] as const).map((corner) => (
<line
key={`error-${corner}`}
x1={groundTruthCorners[corner].x * 100}
y1={groundTruthCorners[corner].y * 100}
x2={result.corners[corner].x * 100}
y2={result.corners[corner].y * 100}
stroke="rgba(239, 68, 68, 0.8)"
strokeWidth="0.3"
/>
))}
</svg>
)}
{/* Legend overlay */}
{result && (
<div
className={css({
position: 'absolute',
bottom: 1,
left: 1,
display: 'flex',
gap: 2,
px: 1.5,
py: 0.5,
bg: 'black/70',
borderRadius: 'sm',
fontSize: '10px',
})}
>
<span className={css({ color: 'green.400' })}> Ground Truth</span>
<span className={css({ color: 'purple.400' })}> Predicted</span>
</div>
)}
</div>
{/* Run button */}
<button
type="button"
onClick={runInference}
disabled={isRunning || detector.isLoading || detector.isUnavailable}
className={css({
w: '100%',
py: 2,
bg: hasRun ? 'gray.700' : 'purple.600',
color: 'white',
borderRadius: 'md',
border: 'none',
cursor: 'pointer',
fontWeight: 'medium',
fontSize: 'sm',
_hover: { bg: hasRun ? 'gray.600' : 'purple.500' },
_disabled: { opacity: 0.5, cursor: 'not-allowed' },
})}
>
{isRunning ? 'Running...' : hasRun ? 'Run Again' : 'Test Model'}
</button>
{/* Error display */}
{error && (
<div
className={css({
mt: 2,
p: 2,
bg: 'red.900/30',
border: '1px solid',
borderColor: 'red.700',
borderRadius: 'md',
color: 'red.300',
fontSize: 'xs',
})}
>
{error}
</div>
)}
{/* Results summary */}
{result && (
<div
className={css({
mt: 3,
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: 2,
})}
>
<div className={css({ p: 2, bg: 'gray.800', borderRadius: 'md', textAlign: 'center' })}>
<div className={css({ fontSize: 'xs', color: 'gray.400', mb: 1 })}>Confidence</div>
<div
className={css({
fontSize: 'lg',
fontWeight: 'bold',
fontFamily: 'mono',
color: getConfidenceColor(result.confidence),
})}
>
{(result.confidence * 100).toFixed(1)}%
</div>
</div>
<div className={css({ p: 2, bg: 'gray.800', borderRadius: 'md', textAlign: 'center' })}>
<div className={css({ fontSize: 'xs', color: 'gray.400', mb: 1 })}>Avg Error</div>
<div
className={css({
fontSize: 'lg',
fontWeight: 'bold',
fontFamily: 'mono',
color: avgError !== null ? getErrorColor(avgError) : 'gray.400',
})}
>
{avgError !== null ? `${(avgError * 100).toFixed(1)}%` : '-'}
</div>
</div>
</div>
)}
{/* Full screen link */}
<Link
href={`/vision-training/boundary-detector/test?image=${encodeURIComponent(imagePath)}`}
className={css({
display: 'block',
mt: 3,
py: 2,
textAlign: 'center',
bg: 'gray.800',
color: 'purple.300',
borderRadius: 'md',
border: '1px solid',
borderColor: 'gray.700',
textDecoration: 'none',
fontSize: 'sm',
_hover: { bg: 'gray.700', borderColor: 'purple.600' },
})}
>
Open Full Tester
</Link>
</div>
)
}

View File

@@ -0,0 +1,335 @@
'use client'
import { useCallback, useEffect, useRef, useState } from 'react'
import Link from 'next/link'
import { css } from '../../../../../../styled-system/css'
import { useColumnClassifier } from '@/hooks/useColumnClassifier'
export interface MiniColumnTesterProps {
/** URL of the image to test */
imagePath: string
/** Ground truth digit (for comparison) */
groundTruthDigit: number
}
/**
* Mini column classifier tester for the detail panel.
* Runs inference on the selected training image and shows results with visual overlay.
*/
export function MiniColumnTester({ imagePath, groundTruthDigit }: MiniColumnTesterProps) {
const [result, setResult] = useState<{
digit: number
heaven: number
earth: number
heavenConf: number
earthConf: number
} | null>(null)
const [error, setError] = useState<string | null>(null)
const [isRunning, setIsRunning] = useState(false)
const [hasRun, setHasRun] = useState(false)
const imageRef = useRef<HTMLImageElement | null>(null)
const canvasRef = useRef<HTMLCanvasElement | null>(null)
const classifier = useColumnClassifier()
// Reset state when image changes
useEffect(() => {
setResult(null)
setError(null)
setHasRun(false)
}, [imagePath])
// Create canvas for ImageData extraction
useEffect(() => {
if (!canvasRef.current) {
canvasRef.current = document.createElement('canvas')
}
}, [])
const runInference = useCallback(async () => {
if (!imageRef.current || !canvasRef.current || isRunning) return
if (!imageRef.current.complete) {
setError('Image not loaded yet')
return
}
setIsRunning(true)
setError(null)
try {
// Ensure model is loaded
if (!classifier.isModelLoaded) {
await classifier.preload()
}
// Extract ImageData from image
const canvas = canvasRef.current
const img = imageRef.current
canvas.width = img.naturalWidth
canvas.height = img.naturalHeight
const ctx = canvas.getContext('2d')
if (!ctx) throw new Error('Failed to get canvas context')
ctx.drawImage(img, 0, 0)
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
// Run classification
const classResult = await classifier.classifyColumn(imageData)
if (!classResult) {
throw new Error('Classification failed - model may not be available')
}
setResult({
digit: classResult.digit,
heaven: classResult.beadPosition.heaven,
earth: classResult.beadPosition.earth,
heavenConf: classResult.beadPosition.heavenConfidence,
earthConf: classResult.beadPosition.earthConfidence,
})
setHasRun(true)
} catch (err) {
setError(err instanceof Error ? err.message : 'Inference failed')
} finally {
setIsRunning(false)
}
}, [classifier, isRunning])
const isCorrect = result ? result.digit === groundTruthDigit : null
return (
<div
data-component="mini-column-tester"
className={css({
p: 3,
bg: 'gray.900',
borderRadius: 'md',
border: '1px solid',
borderColor: 'blue.700/50',
})}
>
<div
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
mb: 3,
})}
>
<div className={css({ display: 'flex', alignItems: 'center', gap: 2 })}>
<span className={css({ fontSize: 'sm' })}>🧪</span>
<span className={css({ fontSize: 'sm', fontWeight: 'medium', color: 'blue.300' })}>
Model Tester
</span>
</div>
<span className={css({ fontSize: 'xs', color: 'gray.500' })}>
{classifier.isModelLoaded ? (
<span className={css({ color: 'green.400' })}>Ready</span>
) : classifier.isLoading ? (
<span className={css({ color: 'yellow.400' })}>Loading...</span>
) : classifier.isModelUnavailable ? (
<span className={css({ color: 'red.400' })}>Unavailable</span>
) : (
<span className={css({ color: 'gray.400' })}>Not loaded</span>
)}
</span>
</div>
{/* Visual preview with prediction overlay */}
<div
className={css({
position: 'relative',
display: 'flex',
justifyContent: 'center',
bg: 'gray.800',
borderRadius: 'md',
overflow: 'hidden',
mb: 3,
p: 2,
})}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
ref={imageRef}
src={imagePath}
alt="Column for inference"
crossOrigin="anonymous"
className={css({
maxHeight: '200px',
objectFit: 'contain',
})}
/>
{/* Prediction overlay badge */}
{result && (
<div
className={css({
position: 'absolute',
top: 2,
right: 2,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 1,
})}
>
{/* Predicted digit */}
<div
className={css({
width: '48px',
height: '48px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bg: isCorrect ? 'green.600' : 'red.600',
color: 'white',
fontSize: '2xl',
fontWeight: 'bold',
fontFamily: 'mono',
borderRadius: 'lg',
border: '2px solid',
borderColor: isCorrect ? 'green.400' : 'red.400',
boxShadow: 'lg',
})}
>
{result.digit}
</div>
{/* Status indicator */}
<div
className={css({
px: 2,
py: 0.5,
bg: isCorrect ? 'green.900/80' : 'red.900/80',
color: isCorrect ? 'green.300' : 'red.300',
fontSize: 'xs',
fontWeight: 'medium',
borderRadius: 'sm',
})}
>
{isCorrect ? '✓ Correct' : '✗ Wrong'}
</div>
</div>
)}
{/* Ground truth badge */}
<div
className={css({
position: 'absolute',
bottom: 2,
left: 2,
px: 2,
py: 1,
bg: 'black/70',
color: 'gray.300',
fontSize: 'xs',
borderRadius: 'sm',
})}
>
Expected: <span className={css({ fontWeight: 'bold', color: 'white' })}>{groundTruthDigit}</span>
</div>
</div>
{/* Run button */}
<button
type="button"
onClick={runInference}
disabled={isRunning || classifier.isLoading || classifier.isModelUnavailable}
className={css({
w: '100%',
py: 2,
bg: hasRun ? 'gray.700' : 'blue.600',
color: 'white',
borderRadius: 'md',
border: 'none',
cursor: 'pointer',
fontWeight: 'medium',
fontSize: 'sm',
_hover: { bg: hasRun ? 'gray.600' : 'blue.500' },
_disabled: { opacity: 0.5, cursor: 'not-allowed' },
})}
>
{isRunning ? 'Running...' : hasRun ? 'Run Again' : 'Test Model'}
</button>
{/* Error display */}
{error && (
<div
className={css({
mt: 2,
p: 2,
bg: 'red.900/30',
border: '1px solid',
borderColor: 'red.700',
borderRadius: 'md',
color: 'red.300',
fontSize: 'xs',
})}
>
{error}
</div>
)}
{/* Bead positions detail */}
{result && (
<div
className={css({
mt: 3,
p: 2,
bg: 'gray.800',
borderRadius: 'md',
})}
>
<div className={css({ fontSize: 'xs', color: 'gray.400', mb: 2 })}>Bead Positions:</div>
<div
className={css({
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: 2,
fontSize: 'sm',
})}
>
<div>
<span className={css({ color: 'gray.500' })}>Heaven:</span>{' '}
<span className={css({ fontFamily: 'mono', color: 'gray.200' })}>{result.heaven}</span>
<span className={css({ color: 'gray.500', fontSize: 'xs', ml: 1 })}>
({(result.heavenConf * 100).toFixed(0)}%)
</span>
</div>
<div>
<span className={css({ color: 'gray.500' })}>Earth:</span>{' '}
<span className={css({ fontFamily: 'mono', color: 'gray.200' })}>{result.earth}</span>
<span className={css({ color: 'gray.500', fontSize: 'xs', ml: 1 })}>
({(result.earthConf * 100).toFixed(0)}%)
</span>
</div>
</div>
<div className={css({ mt: 2, fontSize: 'xs', color: 'gray.500', textAlign: 'center' })}>
{result.heaven} × 5 + {result.earth} = {result.digit}
</div>
</div>
)}
{/* Full screen link */}
<Link
href={`/vision-training/column-classifier/test?image=${encodeURIComponent(imagePath)}`}
className={css({
display: 'block',
mt: 3,
py: 2,
textAlign: 'center',
bg: 'gray.800',
color: 'blue.300',
borderRadius: 'md',
border: '1px solid',
borderColor: 'gray.700',
textDecoration: 'none',
fontSize: 'sm',
_hover: { bg: 'gray.700', borderColor: 'blue.600' },
})}
>
Open Full Tester
</Link>
</div>
)
}

View File

@@ -0,0 +1,663 @@
'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { css } from '../../../../../../styled-system/css'
import type { ModelType } from '../wizard/types'
import type { QuadCorners } from '@/types/vision'
import { BOUNDARY_SAMPLE_CHANNEL } from '@/lib/vision/saveBoundarySample'
import { NumeralSelector } from '../NumeralSelector'
import {
type AnyDataItem,
type BoundaryDataItem,
type ColumnDataItem,
type DataPanelFilters as FilterState,
type SyncStatus,
type SyncProgress,
getDefaultFilters,
applyFilters,
isBoundaryDataItem,
isColumnDataItem,
} from './types'
import { DataPanelHeader } from './DataPanelHeader'
import { DataPanelFilters } from './DataPanelFilters'
import { DataPanelDetailPanel } from './DataPanelDetailPanel'
import { DataPanelCapturePanel } from './DataPanelCapturePanel'
import { BoundaryGridItem } from './BoundaryGridItem'
import { ColumnGridItem } from './ColumnGridItem'
type MobileTab = 'browse' | 'capture'
type RightPanelMode = 'capture' | 'detail'
export interface UnifiedDataPanelProps {
/** Model type determines which UI elements to show */
modelType: ModelType
/** Callback when data changes (for parent refresh) */
onDataChanged?: () => void
}
/**
* Unified Data Panel
*
* A shared component for managing training data for both model types.
* Provides consistent layout with model-specific content.
*
* Layout:
* - Header: Model name, count, quality, sync button
* - Numeral selector (column-classifier only)
* - Filter bar (collapsible)
* - Split view: Browse grid (left) + Capture/Detail panel (right)
* - Mobile: Tab toggle between browse and capture/detail
*/
export function UnifiedDataPanel({ modelType, onDataChanged }: UnifiedDataPanelProps) {
// === State ===
// Items
const [items, setItems] = useState<AnyDataItem[]>([])
const [loading, setLoading] = useState(true)
// Selection
const [selectedItem, setSelectedItem] = useState<AnyDataItem | null>(null)
const [selectedDigit, setSelectedDigit] = useState(0) // column-classifier only
// Filters
const [filters, setFilters] = useState<FilterState>(getDefaultFilters)
const [filtersExpanded, setFiltersExpanded] = useState(false)
// UI state
const [mobileTab, setMobileTab] = useState<MobileTab>('capture')
const [rightPanelMode, setRightPanelMode] = useState<RightPanelMode>('capture')
// Actions
const [deleting, setDeleting] = useState<string | null>(null)
const [reclassifying, setReclassifying] = useState(false)
// Sync state
const [syncStatus, setSyncStatus] = useState<SyncStatus | null>(null)
const [syncProgress, setSyncProgress] = useState<SyncProgress>({ phase: 'idle', message: '' })
const [syncHistoryRefreshTrigger, setSyncHistoryRefreshTrigger] = useState(0)
// Live capture tracking (boundary only)
const [newSamplesCount, setNewSamplesCount] = useState(0)
const lastRefreshTimeRef = useRef<number>(Date.now())
// === Computed values ===
// Apply filters to items
const filteredItems = useMemo(() => {
let result = applyFilters(items, filters)
// For column classifier, also filter by selected digit
if (modelType === 'column-classifier') {
result = result.filter((item) => isColumnDataItem(item) && item.digit === selectedDigit)
}
return result
}, [items, filters, modelType, selectedDigit])
// Digit counts for column classifier
const digitCounts = useMemo(() => {
if (modelType !== 'column-classifier') return {}
const counts: Record<number, number> = {}
for (let i = 0; i <= 9; i++) {
counts[i] = items.filter((item) => isColumnDataItem(item) && item.digit === i).length
}
return counts
}, [items, modelType])
// Total count and quality
const totalCount = items.length
const dataQuality = useMemo(() => {
if (totalCount === 0) return 'none'
if (totalCount < 50) return 'insufficient'
if (totalCount < 200) return 'minimal'
if (totalCount < 500) return 'good'
return 'excellent'
}, [totalCount])
// Item label for filters
const itemLabel = modelType === 'boundary-detector' ? 'frames' : 'images'
// === Data fetching ===
const loadItems = useCallback(async () => {
setLoading(true)
try {
if (modelType === 'boundary-detector') {
const response = await fetch('/api/vision-training/boundary-samples?list=true')
if (response.ok) {
const data = await response.json()
const frames: BoundaryDataItem[] = (data.frames || []).map(
(f: {
baseName: string
deviceId: string
imagePath: string
capturedAt: string
corners: QuadCorners
frameWidth: number
frameHeight: number
sessionId: string | null
playerId: string | null
}) => ({
type: 'boundary' as const,
id: f.baseName,
baseName: f.baseName,
deviceId: f.deviceId,
imagePath: f.imagePath,
capturedAt: f.capturedAt,
corners: f.corners,
frameWidth: f.frameWidth,
frameHeight: f.frameHeight,
sessionId: f.sessionId,
playerId: f.playerId,
})
)
setItems(frames)
setNewSamplesCount(0)
lastRefreshTimeRef.current = Date.now()
}
} else {
// Column classifier - fetch all digits
const allImages: ColumnDataItem[] = []
for (let digit = 0; digit <= 9; digit++) {
const response = await fetch(`/api/vision-training/images?digit=${digit}`)
if (response.ok) {
const data = await response.json()
const images: ColumnDataItem[] = (data.images || []).map(
(img: {
filename: string
digit: number
timestamp?: number
playerId?: string
sessionId?: string
}) => ({
type: 'column' as const,
id: `${digit}-${img.filename}`,
filename: img.filename,
digit: img.digit,
imagePath: `/api/vision-training/images/${digit}/${img.filename}`,
// Convert Unix timestamp to ISO string for timeline
capturedAt: img.timestamp ? new Date(img.timestamp).toISOString() : '',
deviceId: img.playerId || 'unknown',
sessionId: img.sessionId || null,
playerId: img.playerId || null,
})
)
allImages.push(...images)
}
}
setItems(allImages)
}
} catch (error) {
console.error('[UnifiedDataPanel] Failed to load items:', error)
} finally {
setLoading(false)
}
}, [modelType])
// Load items on mount
useEffect(() => {
loadItems()
}, [loadItems])
// Fetch sync status for column classifier
useEffect(() => {
if (modelType !== 'column-classifier') return
const fetchSyncStatus = async () => {
try {
const response = await fetch('/api/vision-training/sync/status')
if (response.ok) {
const data = await response.json()
setSyncStatus(data)
}
} catch (error) {
console.error('[UnifiedDataPanel] Failed to fetch sync status:', error)
}
}
fetchSyncStatus()
}, [modelType])
// Listen for cross-tab notifications (boundary only)
useEffect(() => {
if (modelType !== 'boundary-detector') return
if (typeof BroadcastChannel === 'undefined') return
const channel = new BroadcastChannel(BOUNDARY_SAMPLE_CHANNEL)
const handleMessage = (event: MessageEvent) => {
if (event.data?.type === 'sample-saved') {
setNewSamplesCount((prev) => prev + 1)
loadItems()
}
}
channel.addEventListener('message', handleMessage)
return () => {
channel.removeEventListener('message', handleMessage)
channel.close()
}
}, [modelType, loadItems])
// === Handlers ===
const handleSelectItem = useCallback((item: AnyDataItem) => {
setSelectedItem(item)
setRightPanelMode('detail')
}, [])
const handleCloseDetail = useCallback(() => {
setSelectedItem(null)
setRightPanelMode('capture')
}, [])
const handleDelete = useCallback(
async (item: AnyDataItem) => {
if (!confirm('Delete this item? This cannot be undone.')) return
setDeleting(item.id)
try {
if (isBoundaryDataItem(item)) {
const response = await fetch(
`/api/vision-training/boundary-samples?deviceId=${item.deviceId}&baseName=${item.baseName}`,
{ method: 'DELETE' }
)
if (response.ok) {
setItems((prev) => prev.filter((i) => i.id !== item.id))
if (selectedItem?.id === item.id) {
handleCloseDetail()
}
onDataChanged?.()
} else {
alert('Failed to delete frame')
}
} else if (isColumnDataItem(item)) {
const response = await fetch('/api/vision-training/images', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
filenames: [{ digit: item.digit, filename: item.filename }],
confirm: true,
}),
})
if (response.ok) {
setItems((prev) => prev.filter((i) => i.id !== item.id))
if (selectedItem?.id === item.id) {
handleCloseDetail()
}
onDataChanged?.()
} else {
alert('Failed to delete image')
}
}
} catch (error) {
console.error('[UnifiedDataPanel] Delete error:', error)
alert('Failed to delete item')
} finally {
setDeleting(null)
}
},
[selectedItem, handleCloseDetail, onDataChanged]
)
const handleReclassify = useCallback(
async (item: ColumnDataItem, newDigit: number) => {
setReclassifying(true)
try {
const response = await fetch(`/api/vision-training/images/${item.digit}/${item.filename}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ newDigit }),
})
if (response.ok) {
// Remove from current view and close detail
setItems((prev) => prev.filter((i) => i.id !== item.id))
handleCloseDetail()
onDataChanged?.()
// Reload to get updated counts
loadItems()
} else {
alert('Failed to reclassify image')
}
} catch (error) {
console.error('[UnifiedDataPanel] Reclassify error:', error)
alert('Failed to reclassify image')
} finally {
setReclassifying(false)
}
},
[handleCloseDetail, onDataChanged, loadItems]
)
const handleCaptureComplete = useCallback(() => {
loadItems()
onDataChanged?.()
}, [loadItems, onDataChanged])
const handleStartSync = useCallback(async () => {
setSyncProgress({ phase: 'connecting', message: 'Connecting...' })
try {
const response = await fetch('/api/vision-training/sync', { method: 'POST' })
if (response.ok) {
setSyncProgress({ phase: 'complete', message: 'Sync complete!' })
loadItems()
onDataChanged?.()
} else {
setSyncProgress({ phase: 'error', message: 'Sync failed' })
}
} catch (error) {
setSyncProgress({
phase: 'error',
message: error instanceof Error ? error.message : 'Sync failed',
})
}
setSyncHistoryRefreshTrigger((prev) => prev + 1)
}, [loadItems, onDataChanged])
const handleCancelSync = useCallback(() => {
setSyncProgress({ phase: 'idle', message: '' })
}, [])
// === Render ===
return (
<div
data-component="unified-data-panel"
className={css({
display: 'flex',
flexDirection: 'column',
height: '100%',
bg: 'gray.900',
})}
>
{/* Header */}
<DataPanelHeader
modelType={modelType}
totalCount={totalCount}
dataQuality={dataQuality}
syncStatus={syncStatus}
syncProgress={syncProgress}
onStartSync={handleStartSync}
onCancelSync={handleCancelSync}
syncHistoryRefreshTrigger={syncHistoryRefreshTrigger}
/>
{/* Numeral selector (column-classifier only) */}
{modelType === 'column-classifier' && (
<div
data-element="numeral-selector-bar"
className={css({
px: { base: 2, lg: 4 },
py: 3,
borderBottom: '1px solid',
borderColor: 'gray.800',
bg: 'gray.875',
overflowX: 'auto',
})}
>
<NumeralSelector
digitCounts={digitCounts}
selectedDigit={selectedDigit}
onSelectDigit={setSelectedDigit}
compact={false}
/>
</div>
)}
{/* Filter bar */}
<div className={css({ px: { base: 2, lg: 4 }, py: 3 })}>
<DataPanelFilters
items={items}
filters={filters}
onFiltersChange={setFilters}
isExpanded={filtersExpanded}
onToggleExpanded={() => setFiltersExpanded(!filtersExpanded)}
itemLabel={itemLabel}
/>
</div>
{/* Live capture indicator (boundary only) */}
{modelType === 'boundary-detector' && newSamplesCount > 0 && (
<div
data-element="live-capture-indicator"
className={css({
display: 'flex',
alignItems: 'center',
gap: 2,
px: 4,
py: 2,
mx: 4,
mb: 2,
bg: 'green.900/30',
border: '1px solid',
borderColor: 'green.700/50',
borderRadius: 'lg',
})}
>
<span
className={css({
width: '10px',
height: '10px',
borderRadius: 'full',
bg: 'green.400',
animation: 'pulse 1.5s ease-in-out infinite',
})}
/>
<span className={css({ color: 'green.300', fontWeight: 'medium', fontSize: 'sm' })}>
Live Capture Active
</span>
<span className={css({ color: 'green.400', fontSize: 'sm' })}>
+{newSamplesCount} new frame{newSamplesCount !== 1 ? 's' : ''} captured
</span>
</div>
)}
{/* Mobile tab bar */}
<div
data-element="mobile-tab-bar"
className={css({
display: { base: 'flex', lg: 'none' },
borderBottom: '1px solid',
borderColor: 'gray.800',
})}
>
<button
type="button"
onClick={() => setMobileTab('browse')}
className={css({
flex: 1,
py: 3,
bg: 'transparent',
color: mobileTab === 'browse' ? 'purple.400' : 'gray.500',
borderBottom: '2px solid',
borderColor: mobileTab === 'browse' ? 'purple.400' : 'transparent',
border: 'none',
cursor: 'pointer',
fontWeight: 'medium',
fontSize: 'sm',
})}
>
🖼 Browse ({filteredItems.length})
</button>
<button
type="button"
onClick={() => setMobileTab('capture')}
className={css({
flex: 1,
py: 3,
bg: 'transparent',
color: mobileTab === 'capture' ? 'green.400' : 'gray.500',
borderBottom: '2px solid',
borderColor: mobileTab === 'capture' ? 'green.400' : 'transparent',
border: 'none',
cursor: 'pointer',
fontWeight: 'medium',
fontSize: 'sm',
})}
>
📸 {rightPanelMode === 'detail' ? 'Details' : 'Capture'}
</button>
</div>
{/* Main content - split view */}
<div
data-element="main-content"
className={css({
flex: 1,
display: 'flex',
flexDirection: { base: 'column', lg: 'row' },
minHeight: 0,
overflow: 'hidden',
gap: 4,
p: 4,
})}
>
{/* Browse panel */}
<div
data-element="browse-panel"
className={css({
display: {
base: mobileTab === 'browse' ? 'flex' : 'none',
lg: 'flex',
},
flexDirection: 'column',
flex: 1,
minWidth: 0,
bg: 'gray.850',
borderRadius: 'lg',
overflow: 'hidden',
})}
>
{loading ? (
<div
className={css({
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
})}
>
<div className={css({ textAlign: 'center' })}>
<div className={css({ fontSize: '2xl', animation: 'spin 1s linear infinite' })}>
</div>
<div className={css({ color: 'gray.400', mt: 2 })}>Loading {itemLabel}...</div>
</div>
</div>
) : filteredItems.length === 0 ? (
<div
className={css({
flex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
})}
>
<div className={css({ textAlign: 'center', p: 4 })}>
<div className={css({ fontSize: '3xl', mb: 3 })}>📷</div>
<div className={css({ color: 'gray.300', mb: 2 })}>
{items.length === 0
? `No ${itemLabel} captured yet`
: `No ${itemLabel} match filters`}
</div>
{items.length > 0 && (
<button
type="button"
onClick={() => setFilters(getDefaultFilters())}
className={css({
mt: 2,
px: 4,
py: 2,
bg: 'gray.700',
color: 'gray.200',
border: 'none',
borderRadius: 'md',
cursor: 'pointer',
_hover: { bg: 'gray.600' },
})}
>
Clear Filters
</button>
)}
</div>
</div>
) : (
<div
data-element="grid-scroll-container"
className={css({
flex: 1,
overflow: 'auto',
minHeight: 0,
})}
>
<div
data-element="grid"
className={css({
p: 3,
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(120px, 1fr))',
gap: 2,
alignContent: 'start',
})}
>
{filteredItems.map((item) =>
isBoundaryDataItem(item) ? (
<BoundaryGridItem
key={item.id}
item={item}
isSelected={selectedItem?.id === item.id}
onClick={() => handleSelectItem(item)}
/>
) : isColumnDataItem(item) ? (
<ColumnGridItem
key={item.id}
item={item}
isSelected={selectedItem?.id === item.id}
onClick={() => handleSelectItem(item)}
/>
) : null
)}
</div>
</div>
)}
</div>
{/* Right panel: Capture or Detail */}
<div
data-element="right-panel"
className={css({
display: {
base: mobileTab === 'capture' ? 'flex' : 'none',
lg: 'flex',
},
flexDirection: 'column',
width: { lg: rightPanelMode === 'detail' ? 'auto' : '400px' },
maxWidth: { lg: '50%' },
flexShrink: 0,
minHeight: 0,
overflow: 'auto',
})}
>
{rightPanelMode === 'detail' && selectedItem ? (
<DataPanelDetailPanel
modelType={modelType}
selectedItem={selectedItem}
onClose={handleCloseDetail}
onDelete={handleDelete}
isDeleting={deleting === selectedItem.id}
onReclassify={handleReclassify}
isReclassifying={reclassifying}
/>
) : (
<DataPanelCapturePanel
modelType={modelType}
onCaptureComplete={handleCaptureComplete}
selectedDigit={selectedDigit}
/>
)}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,196 @@
import type { QuadCorners } from '@/types/vision'
import type { ModelType } from '../wizard/types'
/**
* Base interface for all data panel items.
* Both boundary frames and column images conform to this shape.
*/
export interface DataPanelItem {
/** Unique identifier for the item */
id: string
/** Path to the image file */
imagePath: string
/** When the item was captured */
capturedAt: string
/** Device that captured the item */
deviceId: string
/** Session ID if captured during a session */
sessionId: string | null
/** Player ID if associated with a player */
playerId: string | null
}
/**
* Boundary detector data item.
* Represents a frame captured for boundary detector training.
*/
export interface BoundaryDataItem extends DataPanelItem {
type: 'boundary'
/** Corner coordinates (normalized 0-1) */
corners: QuadCorners
/** Original frame width in pixels */
frameWidth: number
/** Original frame height in pixels */
frameHeight: number
/** Base name of the file (without extension) */
baseName: string
}
/**
* Column classifier data item.
* Represents an image captured for column classifier training.
*/
export interface ColumnDataItem extends DataPanelItem {
type: 'column'
/** Digit value (0-9) */
digit: number
/** Filename of the image */
filename: string
}
/**
* Union type for all data panel items
*/
export type AnyDataItem = BoundaryDataItem | ColumnDataItem
/**
* Type guard for boundary data items
*/
export function isBoundaryDataItem(item: AnyDataItem): item is BoundaryDataItem {
return item.type === 'boundary'
}
/**
* Type guard for column data items
*/
export function isColumnDataItem(item: AnyDataItem): item is ColumnDataItem {
return item.type === 'column'
}
/**
* Filter configuration for data panel items.
* Shared between both model types.
*/
export type CaptureTypeFilter = 'all' | 'passive' | 'explicit'
export type TimeRangeMode = 'all' | 'before' | 'after' | 'between'
export interface DataPanelFilters {
/** Filter by capture type */
captureType: CaptureTypeFilter
/** Filter by device ID (empty = all) */
deviceId: string
/** Filter by session ID (empty = all) */
sessionId: string
/** Filter by player ID (empty = all) */
playerId: string
/** Time range filter mode */
timeRangeMode: TimeRangeMode
/** Before timestamp (for time filtering) */
beforeTimestamp?: number
/** After timestamp (for time filtering) */
afterTimestamp?: number
}
/**
* Default filter values
*/
export function getDefaultFilters(): DataPanelFilters {
return {
captureType: 'all',
deviceId: '',
sessionId: '',
playerId: '',
timeRangeMode: 'all',
}
}
/**
* Determines if a device ID represents passive capture
*/
export function isPassiveDevice(deviceId: string): boolean {
return deviceId.startsWith('passive-')
}
/**
* Apply filters to a list of items.
* Works with any DataPanelItem.
*/
export function applyFilters<T extends DataPanelItem>(items: T[], filters: DataPanelFilters): T[] {
return items.filter((item) => {
// Capture type filter
if (filters.captureType !== 'all') {
const isPassive = isPassiveDevice(item.deviceId)
if (filters.captureType === 'passive' && !isPassive) return false
if (filters.captureType === 'explicit' && isPassive) return false
}
// Device filter
if (filters.deviceId && item.deviceId !== filters.deviceId) {
return false
}
// Session filter
if (filters.sessionId && item.sessionId !== filters.sessionId) {
return false
}
// Player filter
if (filters.playerId && item.playerId !== filters.playerId) {
return false
}
// Time range filter
if (filters.timeRangeMode !== 'all' && item.capturedAt) {
const itemTime = new Date(item.capturedAt).getTime()
if (filters.timeRangeMode === 'before' && filters.beforeTimestamp !== undefined) {
if (itemTime >= filters.beforeTimestamp) return false
} else if (filters.timeRangeMode === 'after' && filters.afterTimestamp !== undefined) {
if (itemTime <= filters.afterTimestamp) return false
} else if (
filters.timeRangeMode === 'between' &&
filters.afterTimestamp !== undefined &&
filters.beforeTimestamp !== undefined
) {
if (itemTime <= filters.afterTimestamp || itemTime >= filters.beforeTimestamp) {
return false
}
}
}
return true
})
}
/**
* Props for the unified data panel
*/
export interface UnifiedDataPanelProps {
/** Model type determines which UI elements to show */
modelType: ModelType
/** Callback when data changes (for parent refresh) */
onDataChanged?: () => void
}
/**
* Sync status for NAS sync
*/
export interface SyncStatus {
available: boolean
remote?: { host: string; totalImages: number }
local?: { totalImages: number }
needsSync?: boolean
newOnRemote?: number
newOnLocal?: number
excludedByDeletion?: number
error?: string
}
/**
* Sync progress state
*/
export interface SyncProgress {
phase: 'idle' | 'connecting' | 'syncing' | 'complete' | 'error'
message: string
filesTransferred?: number
bytesTransferred?: number
}

View File

@@ -946,12 +946,7 @@ export function DataCard({
<TrainingDataHubModal
isOpen={hubModalOpen}
onClose={() => setHubModalOpen(false)}
samples={samples}
onDataChanged={() => onSyncComplete?.()}
syncStatus={syncStatus}
syncProgress={syncProgress}
onStartSync={startSync}
onCancelSync={cancelSync}
/>
{/* Boundary Data Hub Modal (Boundary Detector) */}