Spaces:
Running
Running
| <html lang="vi"> | |
| <head> | |
| <meta charset="UTF-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| <title>AI Camera Hub - Lumi | AI Model Error Analysis Dashboard</title> | |
| <script src="https://cdn.tailwindcss.com"></script> | |
| <script src="https://unpkg.com/react@18/umd/react.production.min.js"></script> | |
| <script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script> | |
| <script src="https://unpkg.com/@babel/standalone/babel.min.js"></script> | |
| <link rel="preconnect" href="https://fonts.googleapis.com" /> | |
| <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet" /> | |
| <script> | |
| tailwind.config = { | |
| darkMode: 'class', | |
| theme: { | |
| extend: { | |
| fontFamily: { sans: ['Inter', 'system-ui', 'sans-serif'] }, | |
| colors: { | |
| dark: { 900: '#060a13', 800: '#0a0e17', 700: '#111827', 600: '#1a2236', 500: '#1f2937' }, | |
| accent: { blue: '#3b82f6', violet: '#8b5cf6', amber: '#f59e0b', rose: '#f43f5e', emerald: '#10b981' } | |
| } | |
| } | |
| } | |
| }; | |
| </script> | |
| <style> | |
| * { margin: 0; padding: 0; box-sizing: border-box; } | |
| body { background: #060a13; font-family: 'Inter', sans-serif; } | |
| ::-webkit-scrollbar { width: 6px; } | |
| ::-webkit-scrollbar-track { background: #0a0e17; } | |
| ::-webkit-scrollbar-thumb { background: #374151; border-radius: 3px; } | |
| ::-webkit-scrollbar-thumb:hover { background: #4b5563; } | |
| @keyframes fadeIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } } | |
| @keyframes slideUp { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } } | |
| @keyframes pulse-glow { 0%, 100% { box-shadow: 0 0 8px rgba(59,130,246,0.3); } 50% { box-shadow: 0 0 20px rgba(59,130,246,0.6); } } | |
| @keyframes shimmer { 0% { background-position: -200% 0; } 100% { background-position: 200% 0; } } | |
| .animate-fade-in { animation: fadeIn 0.3s ease-out; } | |
| .animate-slide-up { animation: slideUp 0.4s ease-out; } | |
| .glow-selected { animation: pulse-glow 2s ease-in-out infinite; } | |
| .toggle-cell { | |
| transition: all 0.2s ease; | |
| position: relative; | |
| overflow: hidden; | |
| } | |
| .toggle-cell::before { | |
| content: ''; | |
| position: absolute; | |
| inset: 0; | |
| background: linear-gradient(135deg, rgba(59,130,246,0.1), rgba(139,92,246,0.1)); | |
| opacity: 0; | |
| transition: opacity 0.2s; | |
| } | |
| .toggle-cell:hover::before { opacity: 1; } | |
| .toggle-cell.active::before { opacity: 1; } | |
| .toggle-cell.active { | |
| border-color: rgba(59,130,246,0.5) ; | |
| background: linear-gradient(135deg, rgba(59,130,246,0.15), rgba(139,92,246,0.15)) ; | |
| } | |
| .toggle-cell.active-v3 { | |
| border-color: rgba(59,130,246,0.6) ; | |
| background: linear-gradient(135deg, rgba(59,130,246,0.2), rgba(59,130,246,0.08)) ; | |
| box-shadow: 0 0 12px rgba(59,130,246,0.25); | |
| } | |
| .toggle-cell.active-v4 { | |
| border-color: rgba(139,92,246,0.6) ; | |
| background: linear-gradient(135deg, rgba(139,92,246,0.2), rgba(139,92,246,0.08)) ; | |
| box-shadow: 0 0 12px rgba(139,92,246,0.25); | |
| } | |
| .toggle-cell.active-both { | |
| border-color: rgba(245,158,11,0.6) ; | |
| background: linear-gradient(135deg, rgba(245,158,11,0.2), rgba(245,158,11,0.08)) ; | |
| box-shadow: 0 0 12px rgba(245,158,11,0.25); | |
| } | |
| .img-card { | |
| transition: all 0.3s ease; | |
| } | |
| .img-card:hover { transform: translateY(-2px); box-shadow: 0 8px 30px rgba(0,0,0,0.4); } | |
| .img-card:hover .zoom-hint { opacity: 1; } | |
| .progress-fill { | |
| background: linear-gradient(90deg, #3b82f6, #8b5cf6, #3b82f6); | |
| background-size: 200% 100%; | |
| animation: shimmer 3s linear infinite; | |
| } | |
| .stat-card { | |
| backdrop-filter: blur(12px); | |
| -webkit-backdrop-filter: blur(12px); | |
| } | |
| .zoom-overlay { | |
| backdrop-filter: blur(8px); | |
| -webkit-backdrop-filter: blur(8px); | |
| } | |
| .key-hint { | |
| font-size: 10px; | |
| line-height: 1; | |
| min-width: 18px; | |
| height: 18px; | |
| display: inline-flex; | |
| align-items: center; | |
| justify-content: center; | |
| border-radius: 4px; | |
| background: rgba(255,255,255,0.06); | |
| border: 1px solid rgba(255,255,255,0.1); | |
| color: rgba(255,255,255,0.4); | |
| font-weight: 500; | |
| } | |
| @media (max-width: 768px) { | |
| .image-grid { flex-direction: column ; } | |
| } | |
| </style> | |
| </head> | |
| <body class="dark min-h-screen text-gray-100"> | |
| <div id="root"></div> | |
| <script type="text/babel"> | |
| const { useState, useEffect, useCallback, useMemo, useRef } = React; | |
| // ========== CONFIG ========== | |
| const API_BASE = 'http://localhost:3001/api'; | |
| const DEMO_IMAGE_COUNT = 12; | |
| // ========== DEMO DATA ========== | |
| function generateDemoImages() { | |
| const names = []; | |
| for (let i = 1; i <= DEMO_IMAGE_COUNT; i++) { | |
| names.push({ | |
| name: `frame_${String(i).padStart(3, '0')}.jpg`, | |
| hasV3: true, | |
| hasV4: true, | |
| isDemo: true | |
| }); | |
| } | |
| return names; | |
| } | |
| function getDemoImageUrl(name, version) { | |
| const num = parseInt(name.replace(/\D/g, '')); | |
| const seed = version === 'v3' ? num : num + 100; | |
| return `http://static.photos/technology/640x360/${seed}`; | |
| } | |
| // ========== MAIN APP ========== | |
| function App() { | |
| const [images, setImages] = useState([]); | |
| const [currentIndex, setCurrentIndex] = useState(0); | |
| const [annotations, setAnnotations] = useState({}); | |
| const [loading, setLoading] = useState(true); | |
| const [isDemo, setIsDemo] = useState(false); | |
| const [zoomSrc, setZoomSrc] = useState(null); | |
| const [zoomLabel, setZoomLabel] = useState(''); | |
| const [showHelp, setShowHelp] = useState(false); | |
| const [imgErrors, setImgErrors] = useState({}); | |
| const [isSaving, setIsSaving] = useState(false); | |
| const lastSaveRef = useRef(null); | |
| const totalImages = images.length; | |
| const currentImage = images[currentIndex] || null; | |
| // Current annotation for this image | |
| const currentAnnotation = useMemo(() => { | |
| if (!currentImage) return { false_positive: [], false_negative: [] }; | |
| return annotations[currentImage.name] || { false_positive: [], false_negative: [] }; | |
| }, [currentImage, annotations]); | |
| // ========== FETCH DATA ========== | |
| useEffect(() => { | |
| async function init() { | |
| try { | |
| const controller = new AbortController(); | |
| const timeout = setTimeout(() => controller.abort(), 3000); | |
| const [imgRes, annRes] = await Promise.all([ | |
| fetch(`${API_BASE}/images`, { signal: controller.signal }), | |
| fetch(`${API_BASE}/annotations`, { signal: controller.signal }) | |
| ]); | |
| clearTimeout(timeout); | |
| const imgData = await imgRes.json(); | |
| const annData = await annRes.json(); | |
| if (imgData.images && imgData.images.length > 0) { | |
| setImages(imgData.images); | |
| setAnnotations(annData || {}); | |
| setIsDemo(false); | |
| } else { | |
| throw new Error('No images found'); | |
| } | |
| } catch (err) { | |
| console.warn('Backend unavailable, switching to demo mode:', err.message); | |
| setImages(generateDemoImages()); | |
| const saved = localStorage.getItem('demo_annotations'); | |
| setAnnotations(saved ? JSON.parse(saved) : {}); | |
| setIsDemo(true); | |
| } | |
| setLoading(false); | |
| } | |
| init(); | |
| }, []); | |
| // ========== SAVE ANNOTATION ========== | |
| const saveAnnotation = useCallback(async (newAnnotations) => { | |
| setAnnotations(newAnnotations); | |
| setIsSaving(true); | |
| if (isDemo) { | |
| localStorage.setItem('demo_annotations', JSON.stringify(newAnnotations)); | |
| setTimeout(() => setIsSaving(false), 300); | |
| return; | |
| } | |
| try { | |
| await fetch(`${API_BASE}/annotations`, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify(newAnnotations) | |
| }); | |
| } catch (err) { | |
| console.error('Save failed, cached locally:', err); | |
| localStorage.setItem('cached_annotations', JSON.stringify(newAnnotations)); | |
| } | |
| setTimeout(() => setIsSaving(false), 400); | |
| }, [isDemo]); | |
| // ========== TOGGLE ERROR ========== | |
| const toggleError = useCallback((errorType, model) => { | |
| if (!currentImage) return; | |
| const current = currentAnnotation[errorType] || []; | |
| let updated; | |
| if (current.includes(model)) { | |
| updated = current.filter(m => m !== model); | |
| } else { | |
| updated = [...current, model]; | |
| } | |
| const newAnnotations = { | |
| ...annotations, | |
| [currentImage.name]: { | |
| ...currentAnnotation, | |
| [errorType]: updated | |
| } | |
| }; | |
| // If all arrays are empty, remove the entry | |
| const ann = newAnnotations[currentImage.name]; | |
| if (ann.false_positive.length === 0 && ann.false_negative.length === 0) { | |
| delete newAnnotations[currentImage.name]; | |
| } | |
| saveAnnotation(newAnnotations); | |
| }, [currentImage, currentAnnotation, annotations, saveAnnotation]); | |
| // ========== CLEAR CURRENT ========== | |
| const clearCurrent = useCallback(() => { | |
| if (!currentImage) return; | |
| const newAnnotations = { ...annotations }; | |
| delete newAnnotations[currentImage.name]; | |
| saveAnnotation(newAnnotations); | |
| }, [currentImage, annotations, saveAnnotation]); | |
| // ========== NAVIGATION ========== | |
| const goNext = useCallback(() => { | |
| setCurrentIndex(i => Math.min(totalImages - 1, i + 1)); | |
| }, [totalImages]); | |
| const goPrev = useCallback(() => { | |
| setCurrentIndex(i => Math.max(0, i - 1)); | |
| }, []); | |
| // ========== KEYBOARD SHORTCUTS ========== | |
| useEffect(() => { | |
| function handleKeyDown(e) { | |
| if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; | |
| const key = e.key.toLowerCase(); | |
| switch (key) { | |
| case 'a': case 'arrowleft': goPrev(); break; | |
| case 'd': case 'arrowright': goNext(); break; | |
| case '1': toggleError('false_positive', 'v3'); break; | |
| case '2': toggleError('false_positive', 'v4'); break; | |
| case '3': toggleError('false_positive', 'both'); break; | |
| case '4': toggleError('false_negative', 'v3'); break; | |
| case '5': toggleError('false_negative', 'v4'); break; | |
| case '6': toggleError('false_negative', 'both'); break; | |
| case 'w': toggleError('false_positive', e.shiftKey ? 'v4' : 'v3'); break; | |
| case 's': toggleError('false_negative', e.shiftKey ? 'v4' : 'v3'); break; | |
| case 'q': clearCurrent(); break; | |
| case 'escape': | |
| setZoomSrc(null); | |
| setShowHelp(false); | |
| break; | |
| case '?': setShowHelp(h => !h); break; | |
| } | |
| } | |
| window.addEventListener('keydown', handleKeyDown); | |
| return () => window.removeEventListener('keydown', handleKeyDown); | |
| }, [goNext, goPrev, toggleError, clearCurrent]); | |
| // ========== STATS ========== | |
| const stats = useMemo(() => { | |
| const s = { v3_fp: 0, v3_fn: 0, v4_fp: 0, v4_fn: 0, both_fp: 0, both_fn: 0, annotated: 0 }; | |
| Object.values(annotations).forEach(ann => { | |
| const hasAny = (ann.false_positive?.length > 0) || (ann.false_negative?.length > 0); | |
| if (hasAny) s.annotated++; | |
| if (ann.false_positive?.includes('v3')) s.v3_fp++; | |
| if (ann.false_positive?.includes('v4')) s.v4_fp++; | |
| if (ann.false_positive?.includes('both')) s.both_fp++; | |
| if (ann.false_negative?.includes('v3')) s.v3_fn++; | |
| if (ann.false_negative?.includes('v4')) s.v4_fn++; | |
| if (ann.false_negative?.includes('both')) s.both_fn++; | |
| }); | |
| s.v3_total = s.v3_fp + s.v3_fn; | |
| s.v4_total = s.v4_fp + s.v4_fn; | |
| s.both_total = s.both_fp + s.both_fn; | |
| s.total_all = s.v3_total + s.v4_total + s.both_total; | |
| return s; | |
| }, [annotations]); | |
| // ========== IMAGE URL BUILDER ========== | |
| const getImageUrl = useCallback((img, version) => { | |
| if (!img) return ''; | |
| if (img.isDemo) return getDemoImageUrl(img.name, version); | |
| return `${API_BASE.replace('/api', '')}/images/${version}/${encodeURIComponent(img.name)}`; | |
| }, []); | |
| // ========== RENDER: LOADING ========== | |
| if (loading) { | |
| return ( | |
| <div className="min-h-screen flex items-center justify-center"> | |
| <div className="text-center animate-fade-in"> | |
| <div className="w-16 h-16 border-4 border-blue-500/30 border-t-blue-500 rounded-full animate-spin mx-auto mb-4"></div> | |
| <p className="text-gray-400 text-lg">Loading images...</p> | |
| <p className="text-gray-600 text-sm mt-2">Connecting to backend server</p> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| // ========== RENDER: EMPTY ========== | |
| if (totalImages === 0) { | |
| return ( | |
| <div className="min-h-screen flex items-center justify-center"> | |
| <div className="text-center max-w-md animate-fade-in"> | |
| <div className="text-6xl mb-4">📂</div> | |
| <h2 className="text-xl font-semibold text-gray-200 mb-2">No Images Found</h2> | |
| <p className="text-gray-500 text-sm leading-relaxed"> | |
| Place images in <code className="text-blue-400 bg-blue-500/10 px-1.5 py-0.5 rounded">save_images_v3/</code> and{' '} | |
| <code className="text-violet-400 bg-violet-500/10 px-1.5 py-0.5 rounded">save_images_v4/</code> folders, then restart the server. | |
| </p> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| // ========== GRID CELL CONFIG ========== | |
| const gridCells = [ | |
| { errorType: 'false_positive', model: 'v3', label: 'FP · v3', key: '1', activeClass: 'active-v3' }, | |
| { errorType: 'false_positive', model: 'v4', label: 'FP · v4', key: '2', activeClass: 'active-v4' }, | |
| { errorType: 'false_positive', model: 'both', label: 'FP · Both', key: '3', activeClass: 'active-both' }, | |
| { errorType: 'false_negative', model: 'v3', label: 'FN · v3', key: '4', activeClass: 'active-v3' }, | |
| { errorType: 'false_negative', model: 'v4', label: 'FN · v4', key: '5', activeClass: 'active-v4' }, | |
| { errorType: 'false_negative', model: 'both', label: 'FN · Both', key: '6', activeClass: 'active-both' }, | |
| ]; | |
| // ========== RENDER ========== | |
| return ( | |
| <div className="min-h-screen flex flex-col"> | |
| {/* ===== HEADER ===== */} | |
| <header className="border-b border-gray-800/60 bg-dark-800/80 backdrop-blur-sm sticky top-0 z-30"> | |
| <div className="max-w-7xl mx-auto px-4 py-3 flex items-center justify-between"> | |
| <div className="flex items-center gap-3"> | |
| <div className="w-8 h-8 rounded-lg bg-gradient-to-br from-blue-500 to-violet-600 flex items-center justify-center text-white font-bold text-sm">AI</div> | |
| <div> | |
| <h1 className="text-sm font-semibold text-gray-100 leading-tight">AI Camera Hub — Lumi</h1> | |
| <p className="text-xs text-gray-500">AI Model Error Analysis Dashboard</p> | |
| </div> | |
| </div> | |
| <div className="flex items-center gap-2"> | |
| {isDemo && ( | |
| <span className="text-[10px] font-medium bg-amber-500/15 text-amber-400 px-2 py-1 rounded-md border border-amber-500/20"> | |
| DEMO MODE | |
| </span> | |
| )} | |
| {isSaving && ( | |
| <span className="text-[10px] font-medium bg-emerald-500/15 text-emerald-400 px-2 py-1 rounded-md border border-emerald-500/20 animate-fade-in"> | |
| ✓ Saved | |
| </span> | |
| )} | |
| <button | |
| onClick={() => setShowHelp(true)} | |
| className="w-8 h-8 rounded-lg bg-gray-800/60 border border-gray-700/40 flex items-center justify-center text-gray-400 hover:text-gray-200 hover:bg-gray-700/60 transition-all text-sm" | |
| title="Keyboard shortcuts" | |
| > | |
| ? | |
| </button> | |
| </div> | |
| </div> | |
| </header> | |
| <main className="flex-1 flex flex-col max-w-7xl mx-auto w-full px-4 py-4 gap-4"> | |
| {/* ===== IMAGE PANEL ===== */} | |
| <div className="flex gap-4 image-grid" style={{ flex: '1 1 auto', minHeight: 0 }}> | |
| {/* AIv3 Image */} | |
| <div className="flex-1 min-w-0"> | |
| <div className="mb-2 flex items-center gap-2"> | |
| <span className="inline-flex items-center gap-1.5 text-xs font-semibold text-blue-400 bg-blue-500/10 px-2.5 py-1 rounded-md border border-blue-500/20"> | |
| <span className="w-2 h-2 rounded-full bg-blue-500"></span> | |
| AIv3 | |
| </span> | |
| {currentImage && ( | |
| <span className="text-[11px] text-gray-500 truncate">{currentImage.name}</span> | |
| )} | |
| </div> | |
| <div | |
| className="img-card relative rounded-xl overflow-hidden bg-dark-700 border border-gray-800/60 cursor-zoom-in group" | |
| style={{ minHeight: '200px' }} | |
| onClick={() => { | |
| if (currentImage) { | |
| setZoomSrc(getImageUrl(currentImage, 'v3')); | |
| setZoomLabel('AIv3 — ' + currentImage.name); | |
| } | |
| }} | |
| > | |
| {currentImage && currentImage.hasV3 ? ( | |
| <img | |
| src={getImageUrl(currentImage, 'v3')} | |
| alt="AIv3" | |
| className="w-full h-full object-contain max-h-[42vh]" | |
| onError={(e) => { e.target.style.display = 'none'; }} | |
| /> | |
| ) : ( | |
| <div className="flex items-center justify-center h-48 text-gray-600 text-sm"> | |
| <span>Image not found in v3 folder</span> | |
| </div> | |
| )} | |
| <div className="zoom-hint absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-all flex items-center justify-center opacity-0 pointer-events-none"> | |
| <span className="bg-black/50 text-white text-xs px-3 py-1.5 rounded-lg">🔍 Click to zoom</span> | |
| </div> | |
| </div> | |
| </div> | |
| {/* AIv4 Image */} | |
| <div className="flex-1 min-w-0"> | |
| <div className="mb-2 flex items-center gap-2"> | |
| <span className="inline-flex items-center gap-1.5 text-xs font-semibold text-violet-400 bg-violet-500/10 px-2.5 py-1 rounded-md border border-violet-500/20"> | |
| <span className="w-2 h-2 rounded-full bg-violet-500"></span> | |
| AIv4 | |
| </span> | |
| {currentImage && ( | |
| <span className="text-[11px] text-gray-500 truncate">{currentImage.name}</span> | |
| )} | |
| </div> | |
| <div | |
| className="img-card relative rounded-xl overflow-hidden bg-dark-700 border border-gray-800/60 cursor-zoom-in group" | |
| style={{ minHeight: '200px' }} | |
| onClick={() => { | |
| if (currentImage) { | |
| setZoomSrc(getImageUrl(currentImage, 'v4')); | |
| setZoomLabel('AIv4 — ' + currentImage.name); | |
| } | |
| }} | |
| > | |
| {currentImage && currentImage.hasV4 ? ( | |
| <img | |
| src={getImageUrl(currentImage, 'v4')} | |
| alt="AIv4" | |
| className="w-full h-full object-contain max-h-[42vh]" | |
| onError={(e) => { e.target.style.display = 'none'; }} | |
| /> | |
| ) : ( | |
| <div className="flex items-center justify-center h-48 text-gray-600 text-sm"> | |
| <span>Image not found in v4 folder</span> | |
| </div> | |
| )} | |
| <div className="zoom-hint absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-all flex items-center justify-center opacity-0 pointer-events-none"> | |
| <span className="bg-black/50 text-white text-xs px-3 py-1.5 rounded-lg">🔍 Click to zoom</span> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| {/* ===== NAVIGATION + PROGRESS ===== */} | |
| <div className="flex items-center gap-4 animate-slide-up"> | |
| <button | |
| onClick={goPrev} | |
| disabled={currentIndex === 0} | |
| className="flex items-center gap-1.5 px-3 py-2 rounded-lg bg-gray-800/60 border border-gray-700/40 text-gray-300 hover:bg-gray-700/60 hover:text-white transition-all disabled:opacity-30 disabled:cursor-not-allowed text-sm font-medium" | |
| > | |
| <span>‹</span> Prev <span className="key-hint ml-1">A</span> | |
| </button> | |
| <div className="flex-1 flex flex-col gap-1.5"> | |
| <div className="flex items-center justify-between text-sm"> | |
| <span className="text-gray-400 font-medium"> | |
| Image <span className="text-white font-semibold">{currentIndex + 1}</span> / {totalImages} | |
| </span> | |
| <span className="text-gray-500 text-xs"> | |
| {stats.annotated}/{totalImages} annotated ({totalImages > 0 ? Math.round(stats.annotated / totalImages * 100) : 0}%) | |
| </span> | |
| </div> | |
| <div className="w-full h-1.5 bg-gray-800/80 rounded-full overflow-hidden"> | |
| <div | |
| className="h-full progress-fill rounded-full transition-all duration-300" | |
| style={{ width: `${totalImages > 0 ? (stats.annotated / totalImages * 100) : 0}%` }} | |
| /> | |
| </div> | |
| </div> | |
| <button | |
| onClick={goNext} | |
| disabled={currentIndex === totalImages - 1} | |
| className="flex items-center gap-1.5 px-3 py-2 rounded-lg bg-gray-800/60 border border-gray-700/40 text-gray-300 hover:bg-gray-700/60 hover:text-white transition-all disabled:opacity-30 disabled:cursor-not-allowed text-sm font-medium" | |
| > | |
| <span className="key-hint mr-1">D</span> Next <span>›</span> | |
| </button> | |
| </div> | |
| {/* ===== ERROR GRID ===== */} | |
| <div className="bg-dark-700/50 border border-gray-800/60 rounded-xl p-4 animate-slide-up"> | |
| <div className="flex items-center justify-between mb-3"> | |
| <h3 className="text-xs font-semibold text-gray-400 uppercase tracking-wider">Error Annotation</h3> | |
| <button | |
| onClick={clearCurrent} | |
| className="text-[11px] text-gray-500 hover:text-rose-400 transition-colors flex items-center gap-1" | |
| title="Clear all errors for this image (Q)" | |
| > | |
| <span>✕</span> Clear <span className="key-hint ml-0.5">Q</span> | |
| </button> | |
| </div> | |
| {/* Column headers */} | |
| <div className="grid gap-2" style={{ gridTemplateColumns: '140px 1fr 1fr 1fr' }}> | |
| <div></div> | |
| <div className="text-center text-xs font-semibold text-blue-400 pb-1">AIv3</div> | |
| <div className="text-center text-xs font-semibold text-violet-400 pb-1">AIv4</div> | |
| <div className="text-center text-xs font-semibold text-amber-400 pb-1">Cả 2</div> | |
| {/* FP Row */} | |
| <div className="flex items-center gap-2"> | |
| <span className="text-xs text-rose-400 font-medium">Phát hiện nhầm</span> | |
| <span className="text-[10px] text-gray-600">(FP)</span> | |
| </div> | |
| {gridCells.slice(0, 3).map(cell => { | |
| const isActive = (currentAnnotation[cell.errorType] || []).includes(cell.model); | |
| return ( | |
| <button | |
| key={`${cell.errorType}-${cell.model}`} | |
| onClick={() => toggleError(cell.errorType, cell.model)} | |
| className={`toggle-cell rounded-lg border p-3 text-center transition-all ${isActive ? cell.activeClass : 'border-gray-700/40 bg-gray-800/30 hover:bg-gray-800/60'}`} | |
| > | |
| <div className={`text-sm font-semibold ${isActive ? 'text-white' : 'text-gray-500'}`}> | |
| {isActive ? '✓' : '—'} | |
| </div> | |
| <div className="key-hint mx-auto mt-1">{cell.key}</div> | |
| </button> | |
| ); | |
| })} | |
| {/* FN Row */} | |
| <div className="flex items-center gap-2"> | |
| <span className="text-xs text-orange-400 font-medium">Không phát hiện</span> | |
| <span className="text-[10px] text-gray-600">(FN)</span> | |
| </div> | |
| {gridCells.slice(3, 6).map(cell => { | |
| const isActive = (currentAnnotation[cell.errorType] || []).includes(cell.model); | |
| return ( | |
| <button | |
| key={`${cell.errorType}-${cell.model}`} | |
| onClick={() => toggleError(cell.errorType, cell.model)} | |
| className={`toggle-cell rounded-lg border p-3 text-center transition-all ${isActive ? cell.activeClass : 'border-gray-700/40 bg-gray-800/30 hover:bg-gray-800/60'}`} | |
| > | |
| <div className={`text-sm font-semibold ${isActive ? 'text-white' : 'text-gray-500'}`}> | |
| {isActive ? '✓' : '—'} | |
| </div> | |
| <div className="key-hint mx-auto mt-1">{cell.key}</div> | |
| </button> | |
| ); | |
| })} | |
| </div> | |
| {/* Quick toggles */} | |
| <div className="mt-3 pt-3 border-t border-gray-800/40 flex items-center gap-2 text-[11px] text-gray-500"> | |
| <span>Quick:</span> | |
| <span className="flex items-center gap-1"><span className="key-hint">W</span> FP v3</span> | |
| <span className="flex items-center gap-1"><span className="key-hint">⇧W</span> FP v4</span> | |
| <span className="flex items-center gap-1"><span className="key-hint">S</span> FN v3</span> | |
| <span className="flex items-center gap-1"><span className="key-hint">⇧S</span> FN v4</span> | |
| </div> | |
| </div> | |
| {/* ===== STATISTICS ===== */} | |
| <div className="grid grid-cols-2 md:grid-cols-4 gap-3 animate-slide-up"> | |
| {/* AIv3 Stats */} | |
| <div className="stat-card bg-dark-700/50 border border-blue-500/10 rounded-xl p-4"> | |
| <div className="flex items-center gap-2 mb-2"> | |
| <span className="w-2 h-2 rounded-full bg-blue-500"></span> | |
| <span className="text-xs font-semibold text-blue-400">AIv3 Errors</span> | |
| </div> | |
| <div className="text-2xl font-bold text-white mb-1">{stats.v3_total}</div> | |
| <div className="flex gap-3 text-[11px]"> | |
| <span className="text-rose-400">FP: {stats.v3_fp}</span> | |
| <span className="text-orange-400">FN: {stats.v3_fn}</span> | |
| </div> | |
| </div> | |
| {/* AIv4 Stats */} | |
| <div className="stat-card bg-dark-700/50 border border-violet-500/10 rounded-xl p-4"> | |
| <div className="flex items-center gap-2 mb-2"> | |
| <span className="w-2 h-2 rounded-full bg-violet-500"></span> | |
| <span className="text-xs font-semibold text-violet-400">AIv4 Errors</span> | |
| </div> | |
| <div className="text-2xl font-bold text-white mb-1">{stats.v4_total}</div> | |
| <div className="flex gap-3 text-[11px]"> | |
| <span className="text-rose-400">FP: {stats.v4_fp}</span> | |
| <span className="text-orange-400">FN: {stats.v4_fn}</span> | |
| </div> | |
| </div> | |
| {/* Both Stats */} | |
| <div className="stat-card bg-dark-700/50 border border-amber-500/10 rounded-xl p-4"> | |
| <div className="flex items-center gap-2 mb-2"> | |
| <span className="w-2 h-2 rounded-full bg-amber-500"></span> | |
| <span className="text-xs font-semibold text-amber-400">Cả 2 Errors</span> | |
| </div> | |
| <div className="text-2xl font-bold text-white mb-1">{stats.both_total}</div> | |
| <div className="flex gap-3 text-[11px]"> | |
| <span className="text-rose-400">FP: {stats.both_fp}</span> | |
| <span className="text-orange-400">FN: {stats.both_fn}</span> | |
| </div> | |
| </div> | |
| {/* Progress Stats */} | |
| <div className="stat-card bg-dark-700/50 border border-emerald-500/10 rounded-xl p-4"> | |
| <div className="flex items-center gap-2 mb-2"> | |
| <span className="w-2 h-2 rounded-full bg-emerald-500"></span> | |
| <span className="text-xs font-semibold text-emerald-400">Progress</span> | |
| </div> | |
| <div className="text-2xl font-bold text-white mb-1"> | |
| {totalImages > 0 ? Math.round(stats.annotated / totalImages * 100) : 0}% | |
| </div> | |
| <div className="text-[11px] text-gray-500"> | |
| {stats.annotated} / {totalImages} images | |
| </div> | |
| </div> | |
| </div> | |
| {/* ===== BREAKDOWN TABLE ===== */} | |
| {stats.total_all > 0 && ( | |
| <div className="bg-dark-700/50 border border-gray-800/60 rounded-xl p-4 animate-slide-up"> | |
| <h3 className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3">Error Breakdown</h3> | |
| <div className="overflow-x-auto"> | |
| <table className="w-full text-sm"> | |
| <thead> | |
| <tr className="text-gray-500 text-xs"> | |
| <th className="text-left pb-2 font-medium">Type</th> | |
| <th className="text-center pb-2 font-medium text-blue-400">AIv3</th> | |
| <th className="text-center pb-2 font-medium text-violet-400">AIv4</th> | |
| <th className="text-center pb-2 font-medium text-amber-400">Cả 2</th> | |
| <th className="text-center pb-2 font-medium text-gray-400">Total</th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| <tr className="border-t border-gray-800/40"> | |
| <td className="py-2 text-rose-400 text-xs font-medium">False Positive</td> | |
| <td className="py-2 text-center text-white font-semibold">{stats.v3_fp}</td> | |
| <td className="py-2 text-center text-white font-semibold">{stats.v4_fp}</td> | |
| <td className="py-2 text-center text-white font-semibold">{stats.both_fp}</td> | |
| <td className="py-2 text-center text-gray-300 font-semibold">{stats.v3_fp + stats.v4_fp + stats.both_fp}</td> | |
| </tr> | |
| <tr className="border-t border-gray-800/40"> | |
| <td className="py-2 text-orange-400 text-xs font-medium">False Negative</td> | |
| <td className="py-2 text-center text-white font-semibold">{stats.v3_fn}</td> | |
| <td className="py-2 text-center text-white font-semibold">{stats.v4_fn}</td> | |
| <td className="py-2 text-center text-white font-semibold">{stats.both_fn}</td> | |
| <td className="py-2 text-center text-gray-300 font-semibold">{stats.v3_fn + stats.v4_fn + stats.both_fn}</td> | |
| </tr> | |
| <tr className="border-t border-gray-700/60 font-semibold"> | |
| <td className="py-2 text-gray-300 text-xs font-medium">Total</td> | |
| <td className="py-2 text-center text-blue-400">{stats.v3_total}</td> | |
| <td className="py-2 text-center text-violet-400">{stats.v4_total}</td> | |
| <td className="py-2 text-center text-amber-400">{stats.both_total}</td> | |
| <td className="py-2 text-center text-white">{stats.total_all}</td> | |
| </tr> | |
| </tbody> | |
| </table> | |
| </div> | |
| </div> | |
| )} | |
| </main> | |
| {/* ===== ZOOM MODAL ===== */} | |
| {zoomSrc && ( | |
| <div | |
| className="zoom-overlay fixed inset-0 z-50 bg-black/80 flex items-center justify-center p-4 animate-fade-in" | |
| onClick={() => setZoomSrc(null)} | |
| > | |
| <div className="relative max-w-6xl max-h-[90vh] w-full" onClick={e => e.stopPropagation()}> | |
| <div className="absolute top-0 left-0 right-0 flex items-center justify-between p-3 z-10"> | |
| <span className="text-sm text-gray-300 bg-black/60 px-3 py-1 rounded-lg">{zoomLabel}</span> | |
| <button | |
| onClick={() => setZoomSrc(null)} | |
| className="w-8 h-8 rounded-lg bg-black/60 text-gray-300 hover:text-white flex items-center justify-center text-lg transition-colors" | |
| > | |
| ✕ | |
| </button> | |
| </div> | |
| <img | |
| src={zoomSrc} | |
| alt="Zoomed" | |
| className="w-full h-full object-contain max-h-[90vh] rounded-lg" | |
| /> | |
| </div> | |
| </div> | |
| )} | |
| {/* ===== HELP MODAL ===== */} | |
| {showHelp && ( | |
| <div | |
| className="fixed inset-0 z-50 bg-black/60 flex items-center justify-center p-4 animate-fade-in" | |
| onClick={() => setShowHelp(false)} | |
| > | |
| <div | |
| className="bg-dark-700 border border-gray-700/60 rounded-2xl p-6 max-w-md w-full animate-slide-up" | |
| onClick={e => e.stopPropagation()} | |
| > | |
| <div className="flex items-center justify-between mb-4"> | |
| <h2 className="text-lg font-semibold text-white">Keyboard Shortcuts</h2> | |
| <button onClick={() => setShowHelp(false)} className="text-gray-500 hover:text-white transition-colors text-lg">✕</button> | |
| </div> | |
| <div className="space-y-2 text-sm"> | |
| <div className="flex justify-between py-1.5 border-b border-gray-800/40"> | |
| <span className="text-gray-300">Previous image</span> | |
| <div className="flex gap-1"><span className="key-hint">A</span> <span className="key-hint">←</span></div> | |
| </div> | |
| <div className="flex justify-between py-1.5 border-b border-gray-800/40"> | |
| <span className="text-gray-300">Next image</span> | |
| <div className="flex gap-1"><span className="key-hint">D</span> <span className="key-hint">→</span></div> | |
| </div> | |
| <div className="flex justify-between py-1.5 border-b border-gray-800/40"> | |
| <span className="text-rose-400">FP · AIv3</span> | |
| <div className="flex gap-1"><span className="key-hint">1</span> <span className="key-hint">W</span></div> | |
| </div> | |
| <div className="flex justify-between py-1.5 border-b border-gray-800/40"> | |
| <span className="text-rose-400">FP · AIv4</span> | |
| <div className="flex gap-1"><span className="key-hint">2</span> <span className="key-hint">⇧W</span></div> | |
| </div> | |
| <div className="flex justify-between py-1.5 border-b border-gray-800/40"> | |
| <span className="text-rose-400">FP · Cả 2</span> | |
| <span className="key-hint">3</span> | |
| </div> | |
| <div className="flex justify-between py-1.5 border-b border-gray-800/40"> | |
| <span className="text-orange-400">FN · AIv3</span> | |
| <div className="flex gap-1"><span className="key-hint">4</span> <span className="key-hint">S</span></div> | |
| </div> | |
| <div className="flex justify-between py-1.5 border-b border-gray-800/40"> | |
| <span className="text-orange-400">FN · AIv4</span> | |
| <div className="flex gap-1"><span className="key-hint">5</span> <span className="key-hint">⇧S</span></div> | |
| </div> | |
| <div className="flex justify-between py-1.5 border-b border-gray-800/40"> | |
| <span className="text-orange-400">FN · Cả 2</span> | |
| <span className="key-hint">6</span> | |
| </div> | |
| <div className="flex justify-between py-1.5 border-b border-gray-800/40"> | |
| <span className="text-gray-300">Clear current</span> | |
| <span className="key-hint">Q</span> | |
| </div> | |
| <div className="flex justify-between py-1.5"> | |
| <span className="text-gray-300">Close modal</span> | |
| <span className="key-hint">Esc</span> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |
| // ========== MOUNT ========== | |
| const root = ReactDOM.createRoot(document.getElementById('root')); | |
| root.render(<App />); | |
| </script> | |
| <script src="https://deepsite.hf.co/deepsite-badge.js"></script> | |
| </body> | |
| </html> |