HoangThe's picture
Initial DeepSite commit
1c1788b verified
<!DOCTYPE html>
<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) !important;
background: linear-gradient(135deg, rgba(59,130,246,0.15), rgba(139,92,246,0.15)) !important;
}
.toggle-cell.active-v3 {
border-color: rgba(59,130,246,0.6) !important;
background: linear-gradient(135deg, rgba(59,130,246,0.2), rgba(59,130,246,0.08)) !important;
box-shadow: 0 0 12px rgba(59,130,246,0.25);
}
.toggle-cell.active-v4 {
border-color: rgba(139,92,246,0.6) !important;
background: linear-gradient(135deg, rgba(139,92,246,0.2), rgba(139,92,246,0.08)) !important;
box-shadow: 0 0 12px rgba(139,92,246,0.25);
}
.toggle-cell.active-both {
border-color: rgba(245,158,11,0.6) !important;
background: linear-gradient(135deg, rgba(245,158,11,0.2), rgba(245,158,11,0.08)) !important;
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 !important; }
}
</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>