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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) */}
|
||||
|
||||
Reference in New Issue
Block a user