HoangThe commited on
Commit
1c1788b
·
verified ·
1 Parent(s): 97f0d75

Initial DeepSite commit

Browse files
Files changed (5) hide show
  1. README.md +9 -6
  2. backend/package.json +17 -0
  3. backend/server.js +213 -0
  4. index.html +790 -19
  5. sample_annotations.json +22 -0
README.md CHANGED
@@ -1,10 +1,13 @@
1
  ---
2
- title: Deepsite Project O7ib8
3
- emoji: 🏆
4
- colorFrom: yellow
5
- colorTo: blue
6
  sdk: static
7
- pinned: false
 
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
1
  ---
2
+ title: DeepSite Project
3
+ colorFrom: blue
4
+ colorTo: purple
 
5
  sdk: static
6
+ emoji:
7
+ tags:
8
+ - deepsite-v4
9
  ---
10
 
11
+ # DeepSite Project
12
+
13
+ This project has been created with [DeepSite](https://deepsite.hf.co) AI Vibe Coding.
backend/package.json ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "ai-camera-hub-backend",
3
+ "version": "1.0.0",
4
+ "description": "AI Camera Hub - Lumi | Backend server for AI Model Error Analysis Dashboard",
5
+ "main": "server.js",
6
+ "scripts": {
7
+ "start": "node server.js",
8
+ "dev": "node --watch server.js"
9
+ },
10
+ "dependencies": {
11
+ "cors": "^2.8.5",
12
+ "express": "^4.18.2"
13
+ },
14
+ "engines": {
15
+ "node": ">=16.0.0"
16
+ }
17
+ }
backend/server.js ADDED
@@ -0,0 +1,213 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * AI Camera Hub - Lumi | Backend Server
3
+ *
4
+ * Serves images from save_images_v3/ and save_images_v4/ folders,
5
+ * provides API for image listing, annotation CRUD, and auto-save.
6
+ */
7
+
8
+ const express = require('express');
9
+ const cors = require('cors');
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+
13
+ const app = express();
14
+ const PORT = process.env.PORT || 3001;
15
+
16
+ // ========== PATHS ==========
17
+ // Image directories (relative to project root)
18
+ const PROJECT_ROOT = path.join(__dirname, '..');
19
+ const V3_DIR = path.join(PROJECT_ROOT, 'save_images_v3');
20
+ const V4_DIR = path.join(PROJECT_ROOT, 'save_images_v4');
21
+ const ANNOTATIONS_FILE = path.join(PROJECT_ROOT, 'annotations.json');
22
+
23
+ // Supported image extensions
24
+ const IMAGE_EXTENSIONS = /\.(jpg|jpeg|png|gif|bmp|webp|tiff|svg)$/i;
25
+
26
+ // ========== MIDDLEWARE ==========
27
+ app.use(cors());
28
+ app.use(express.json({ limit: '10mb' }));
29
+
30
+ // Serve frontend (index.html at project root)
31
+ app.use(express.static(PROJECT_ROOT));
32
+
33
+ // Serve image files statically
34
+ app.use('/images/v3', express.static(V3_DIR));
35
+ app.use('/images/v4', express.static(V4_DIR));
36
+
37
+ // Log requests in development
38
+ app.use((req, res, next) => {
39
+ const timestamp = new Date().toISOString();
40
+ console.log(`[${timestamp}] ${req.method} ${req.url}`);
41
+ next();
42
+ });
43
+
44
+ // ========== API ROUTES ==========
45
+
46
+ /**
47
+ * GET /api/images
48
+ * Returns list of matched images from both v3 and v4 folders.
49
+ * Images are matched by filename.
50
+ */
51
+ app.get('/api/images', (req, res) => {
52
+ try {
53
+ let v3Files = [];
54
+ let v4Files = [];
55
+
56
+ // Read v3 directory
57
+ if (fs.existsSync(V3_DIR)) {
58
+ v3Files = fs.readdirSync(V3_DIR)
59
+ .filter(f => IMAGE_EXTENSIONS.test(f));
60
+ } else {
61
+ console.warn('⚠️ save_images_v3 directory not found at:', V3_DIR);
62
+ }
63
+
64
+ // Read v4 directory
65
+ if (fs.existsSync(V4_DIR)) {
66
+ v4Files = fs.readdirSync(V4_DIR)
67
+ .filter(f => IMAGE_EXTENSIONS.test(f));
68
+ } else {
69
+ console.warn('⚠️ save_images_v4 directory not found at:', V4_DIR);
70
+ }
71
+
72
+ // Match images by filename
73
+ const v3Set = new Set(v3Files);
74
+ const v4Set = new Set(v4Files);
75
+ const allNames = new Set([...v3Files, ...v4Files]);
76
+
77
+ const matched = [];
78
+ allNames.forEach(name => {
79
+ matched.push({
80
+ name,
81
+ hasV3: v3Set.has(name),
82
+ hasV4: v4Set.has(name),
83
+ });
84
+ });
85
+
86
+ // Sort alphabetically
87
+ matched.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true }));
88
+
89
+ console.log(`✅ Found ${matched.length} images (v3: ${v3Files.length}, v4: ${v4Files.length})`);
90
+ res.json({ images: matched, total: matched.length });
91
+ } catch (err) {
92
+ console.error('❌ Error listing images:', err);
93
+ res.status(500).json({ error: err.message });
94
+ }
95
+ });
96
+
97
+ /**
98
+ * GET /api/annotations
99
+ * Returns saved annotations from annotations.json.
100
+ */
101
+ app.get('/api/annotations', (req, res) => {
102
+ try {
103
+ if (fs.existsSync(ANNOTATIONS_FILE)) {
104
+ const data = fs.readFileSync(ANNOTATIONS_FILE, 'utf8');
105
+ const parsed = JSON.parse(data);
106
+ console.log(`📋 Loaded annotations for ${Object.keys(parsed).length} images`);
107
+ res.json(parsed);
108
+ } else {
109
+ console.log('📋 No existing annotations file, returning empty object');
110
+ res.json({});
111
+ }
112
+ } catch (err) {
113
+ console.error('❌ Error reading annotations:', err);
114
+ res.status(500).json({ error: err.message });
115
+ }
116
+ });
117
+
118
+ /**
119
+ * POST /api/annotations
120
+ * Saves annotations to annotations.json.
121
+ * Auto-creates backup before overwriting.
122
+ */
123
+ app.post('/api/annotations', (req, res) => {
124
+ try {
125
+ const annotations = req.body;
126
+
127
+ // Create backup if file exists
128
+ if (fs.existsSync(ANNOTATIONS_FILE)) {
129
+ const backupPath = ANNOTATIONS_FILE.replace('.json', `_backup_${Date.now()}.json`);
130
+ fs.copyFileSync(ANNOTATIONS_FILE, backupPath);
131
+
132
+ // Keep only last 5 backups
133
+ const backupDir = path.dirname(ANNOTATIONS_FILE);
134
+ const backups = fs.readdirSync(backupDir)
135
+ .filter(f => f.startsWith('annotations_backup_'))
136
+ .sort()
137
+ .map(f => path.join(backupDir, f));
138
+
139
+ while (backups.length > 5) {
140
+ fs.unlinkSync(backups.shift());
141
+ }
142
+ }
143
+
144
+ // Write new annotations
145
+ fs.writeFileSync(
146
+ ANNOTATIONS_FILE,
147
+ JSON.stringify(annotations, null, 2),
148
+ 'utf8'
149
+ );
150
+
151
+ const count = Object.keys(annotations).length;
152
+ console.log(`💾 Saved annotations for ${count} images`);
153
+ res.json({ success: true, count });
154
+ } catch (err) {
155
+ console.error('❌ Error saving annotations:', err);
156
+ res.status(500).json({ error: err.message });
157
+ }
158
+ });
159
+
160
+ /**
161
+ * GET /api/export
162
+ * Export annotations as downloadable JSON file.
163
+ */
164
+ app.get('/api/export', (req, res) => {
165
+ try {
166
+ if (!fs.existsSync(ANNOTATIONS_FILE)) {
167
+ return res.status(404).json({ error: 'No annotations found' });
168
+ }
169
+ const data = fs.readFileSync(ANNOTATIONS_FILE, 'utf8');
170
+ const filename = `annotations_export_${new Date().toISOString().slice(0, 10)}.json`;
171
+ res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
172
+ res.setHeader('Content-Type', 'application/json');
173
+ res.send(data);
174
+ } catch (err) {
175
+ res.status(500).json({ error: err.message });
176
+ }
177
+ });
178
+
179
+ // SPA fallback - serve index.html for any unmatched routes
180
+ app.get('*', (req, res) => {
181
+ const indexPath = path.join(PROJECT_ROOT, 'index.html');
182
+ if (fs.existsSync(indexPath)) {
183
+ res.sendFile(indexPath);
184
+ } else {
185
+ res.status(404).send('Frontend not found. Place index.html in project root.');
186
+ }
187
+ });
188
+
189
+ // ========== START SERVER ==========
190
+ app.listen(PORT, () => {
191
+ console.log('');
192
+ console.log('═══════════════════════════════════════════════════════════');
193
+ console.log(' 🎯 AI Camera Hub - Lumi | Error Analysis Dashboard');
194
+ console.log('═══════════════════════════════════════════════════════════');
195
+ console.log(` Server: http://localhost:${PORT}`);
196
+ console.log(` API: http://localhost:${PORT}/api/images`);
197
+ console.log('');
198
+ console.log(` v3 path: ${V3_DIR}`);
199
+ console.log(` v4 path: ${V4_DIR}`);
200
+ console.log(` Save to: ${ANNOTATIONS_FILE}`);
201
+ console.log('');
202
+ console.log(' Press Ctrl+C to stop');
203
+ console.log('═══════════════════════════════════════════════════════════');
204
+ console.log('');
205
+
206
+ // Create image directories if they don't exist
207
+ [V3_DIR, V4_DIR].forEach(dir => {
208
+ if (!fs.existsSync(dir)) {
209
+ fs.mkdirSync(dir, { recursive: true });
210
+ console.log(`📁 Created directory: ${dir}`);
211
+ }
212
+ });
213
+ });
index.html CHANGED
@@ -1,19 +1,790 @@
1
- <!doctype html>
2
- <html>
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width" />
6
- <title>My static Space</title>
7
- <link rel="stylesheet" href="style.css" />
8
- </head>
9
- <body>
10
- <div class="card">
11
- <h1>Welcome to your static Space!</h1>
12
- <p>You can modify this app directly by editing <i>index.html</i> in the Files and versions tab.</p>
13
- <p>
14
- Also don't forget to check the
15
- <a href="https://huggingface.co/docs/hub/spaces" target="_blank">Spaces documentation</a>.
16
- </p>
17
- </div>
18
- </body>
19
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="vi">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>AI Camera Hub - Lumi | AI Model Error Analysis Dashboard</title>
7
+ <script src="https://cdn.tailwindcss.com"></script>
8
+ <script src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
9
+ <script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
10
+ <script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
11
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
12
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet" />
13
+ <script>
14
+ tailwind.config = {
15
+ darkMode: 'class',
16
+ theme: {
17
+ extend: {
18
+ fontFamily: { sans: ['Inter', 'system-ui', 'sans-serif'] },
19
+ colors: {
20
+ dark: { 900: '#060a13', 800: '#0a0e17', 700: '#111827', 600: '#1a2236', 500: '#1f2937' },
21
+ accent: { blue: '#3b82f6', violet: '#8b5cf6', amber: '#f59e0b', rose: '#f43f5e', emerald: '#10b981' }
22
+ }
23
+ }
24
+ }
25
+ };
26
+ </script>
27
+ <style>
28
+ * { margin: 0; padding: 0; box-sizing: border-box; }
29
+ body { background: #060a13; font-family: 'Inter', sans-serif; }
30
+ ::-webkit-scrollbar { width: 6px; }
31
+ ::-webkit-scrollbar-track { background: #0a0e17; }
32
+ ::-webkit-scrollbar-thumb { background: #374151; border-radius: 3px; }
33
+ ::-webkit-scrollbar-thumb:hover { background: #4b5563; }
34
+
35
+ @keyframes fadeIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: translateY(0); } }
36
+ @keyframes slideUp { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } }
37
+ @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); } }
38
+ @keyframes shimmer { 0% { background-position: -200% 0; } 100% { background-position: 200% 0; } }
39
+
40
+ .animate-fade-in { animation: fadeIn 0.3s ease-out; }
41
+ .animate-slide-up { animation: slideUp 0.4s ease-out; }
42
+ .glow-selected { animation: pulse-glow 2s ease-in-out infinite; }
43
+
44
+ .toggle-cell {
45
+ transition: all 0.2s ease;
46
+ position: relative;
47
+ overflow: hidden;
48
+ }
49
+ .toggle-cell::before {
50
+ content: '';
51
+ position: absolute;
52
+ inset: 0;
53
+ background: linear-gradient(135deg, rgba(59,130,246,0.1), rgba(139,92,246,0.1));
54
+ opacity: 0;
55
+ transition: opacity 0.2s;
56
+ }
57
+ .toggle-cell:hover::before { opacity: 1; }
58
+ .toggle-cell.active::before { opacity: 1; }
59
+ .toggle-cell.active {
60
+ border-color: rgba(59,130,246,0.5) !important;
61
+ background: linear-gradient(135deg, rgba(59,130,246,0.15), rgba(139,92,246,0.15)) !important;
62
+ }
63
+ .toggle-cell.active-v3 {
64
+ border-color: rgba(59,130,246,0.6) !important;
65
+ background: linear-gradient(135deg, rgba(59,130,246,0.2), rgba(59,130,246,0.08)) !important;
66
+ box-shadow: 0 0 12px rgba(59,130,246,0.25);
67
+ }
68
+ .toggle-cell.active-v4 {
69
+ border-color: rgba(139,92,246,0.6) !important;
70
+ background: linear-gradient(135deg, rgba(139,92,246,0.2), rgba(139,92,246,0.08)) !important;
71
+ box-shadow: 0 0 12px rgba(139,92,246,0.25);
72
+ }
73
+ .toggle-cell.active-both {
74
+ border-color: rgba(245,158,11,0.6) !important;
75
+ background: linear-gradient(135deg, rgba(245,158,11,0.2), rgba(245,158,11,0.08)) !important;
76
+ box-shadow: 0 0 12px rgba(245,158,11,0.25);
77
+ }
78
+
79
+ .img-card {
80
+ transition: all 0.3s ease;
81
+ }
82
+ .img-card:hover { transform: translateY(-2px); box-shadow: 0 8px 30px rgba(0,0,0,0.4); }
83
+ .img-card:hover .zoom-hint { opacity: 1; }
84
+
85
+ .progress-fill {
86
+ background: linear-gradient(90deg, #3b82f6, #8b5cf6, #3b82f6);
87
+ background-size: 200% 100%;
88
+ animation: shimmer 3s linear infinite;
89
+ }
90
+
91
+ .stat-card {
92
+ backdrop-filter: blur(12px);
93
+ -webkit-backdrop-filter: blur(12px);
94
+ }
95
+
96
+ .zoom-overlay {
97
+ backdrop-filter: blur(8px);
98
+ -webkit-backdrop-filter: blur(8px);
99
+ }
100
+
101
+ .key-hint {
102
+ font-size: 10px;
103
+ line-height: 1;
104
+ min-width: 18px;
105
+ height: 18px;
106
+ display: inline-flex;
107
+ align-items: center;
108
+ justify-content: center;
109
+ border-radius: 4px;
110
+ background: rgba(255,255,255,0.06);
111
+ border: 1px solid rgba(255,255,255,0.1);
112
+ color: rgba(255,255,255,0.4);
113
+ font-weight: 500;
114
+ }
115
+
116
+ @media (max-width: 768px) {
117
+ .image-grid { flex-direction: column !important; }
118
+ }
119
+ </style>
120
+ </head>
121
+ <body class="dark min-h-screen text-gray-100">
122
+ <div id="root"></div>
123
+
124
+ <script type="text/babel">
125
+ const { useState, useEffect, useCallback, useMemo, useRef } = React;
126
+
127
+ // ========== CONFIG ==========
128
+ const API_BASE = 'http://localhost:3001/api';
129
+ const DEMO_IMAGE_COUNT = 12;
130
+
131
+ // ========== DEMO DATA ==========
132
+ function generateDemoImages() {
133
+ const names = [];
134
+ for (let i = 1; i <= DEMO_IMAGE_COUNT; i++) {
135
+ names.push({
136
+ name: `frame_${String(i).padStart(3, '0')}.jpg`,
137
+ hasV3: true,
138
+ hasV4: true,
139
+ isDemo: true
140
+ });
141
+ }
142
+ return names;
143
+ }
144
+
145
+ function getDemoImageUrl(name, version) {
146
+ const num = parseInt(name.replace(/\D/g, ''));
147
+ const seed = version === 'v3' ? num : num + 100;
148
+ return `http://static.photos/technology/640x360/${seed}`;
149
+ }
150
+
151
+ // ========== MAIN APP ==========
152
+ function App() {
153
+ const [images, setImages] = useState([]);
154
+ const [currentIndex, setCurrentIndex] = useState(0);
155
+ const [annotations, setAnnotations] = useState({});
156
+ const [loading, setLoading] = useState(true);
157
+ const [isDemo, setIsDemo] = useState(false);
158
+ const [zoomSrc, setZoomSrc] = useState(null);
159
+ const [zoomLabel, setZoomLabel] = useState('');
160
+ const [showHelp, setShowHelp] = useState(false);
161
+ const [imgErrors, setImgErrors] = useState({});
162
+ const [isSaving, setIsSaving] = useState(false);
163
+ const lastSaveRef = useRef(null);
164
+
165
+ const totalImages = images.length;
166
+ const currentImage = images[currentIndex] || null;
167
+
168
+ // Current annotation for this image
169
+ const currentAnnotation = useMemo(() => {
170
+ if (!currentImage) return { false_positive: [], false_negative: [] };
171
+ return annotations[currentImage.name] || { false_positive: [], false_negative: [] };
172
+ }, [currentImage, annotations]);
173
+
174
+ // ========== FETCH DATA ==========
175
+ useEffect(() => {
176
+ async function init() {
177
+ try {
178
+ const controller = new AbortController();
179
+ const timeout = setTimeout(() => controller.abort(), 3000);
180
+
181
+ const [imgRes, annRes] = await Promise.all([
182
+ fetch(`${API_BASE}/images`, { signal: controller.signal }),
183
+ fetch(`${API_BASE}/annotations`, { signal: controller.signal })
184
+ ]);
185
+ clearTimeout(timeout);
186
+
187
+ const imgData = await imgRes.json();
188
+ const annData = await annRes.json();
189
+
190
+ if (imgData.images && imgData.images.length > 0) {
191
+ setImages(imgData.images);
192
+ setAnnotations(annData || {});
193
+ setIsDemo(false);
194
+ } else {
195
+ throw new Error('No images found');
196
+ }
197
+ } catch (err) {
198
+ console.warn('Backend unavailable, switching to demo mode:', err.message);
199
+ setImages(generateDemoImages());
200
+ const saved = localStorage.getItem('demo_annotations');
201
+ setAnnotations(saved ? JSON.parse(saved) : {});
202
+ setIsDemo(true);
203
+ }
204
+ setLoading(false);
205
+ }
206
+ init();
207
+ }, []);
208
+
209
+ // ========== SAVE ANNOTATION ==========
210
+ const saveAnnotation = useCallback(async (newAnnotations) => {
211
+ setAnnotations(newAnnotations);
212
+ setIsSaving(true);
213
+
214
+ if (isDemo) {
215
+ localStorage.setItem('demo_annotations', JSON.stringify(newAnnotations));
216
+ setTimeout(() => setIsSaving(false), 300);
217
+ return;
218
+ }
219
+
220
+ try {
221
+ await fetch(`${API_BASE}/annotations`, {
222
+ method: 'POST',
223
+ headers: { 'Content-Type': 'application/json' },
224
+ body: JSON.stringify(newAnnotations)
225
+ });
226
+ } catch (err) {
227
+ console.error('Save failed, cached locally:', err);
228
+ localStorage.setItem('cached_annotations', JSON.stringify(newAnnotations));
229
+ }
230
+ setTimeout(() => setIsSaving(false), 400);
231
+ }, [isDemo]);
232
+
233
+ // ========== TOGGLE ERROR ==========
234
+ const toggleError = useCallback((errorType, model) => {
235
+ if (!currentImage) return;
236
+ const current = currentAnnotation[errorType] || [];
237
+ let updated;
238
+ if (current.includes(model)) {
239
+ updated = current.filter(m => m !== model);
240
+ } else {
241
+ updated = [...current, model];
242
+ }
243
+ const newAnnotations = {
244
+ ...annotations,
245
+ [currentImage.name]: {
246
+ ...currentAnnotation,
247
+ [errorType]: updated
248
+ }
249
+ };
250
+ // If all arrays are empty, remove the entry
251
+ const ann = newAnnotations[currentImage.name];
252
+ if (ann.false_positive.length === 0 && ann.false_negative.length === 0) {
253
+ delete newAnnotations[currentImage.name];
254
+ }
255
+ saveAnnotation(newAnnotations);
256
+ }, [currentImage, currentAnnotation, annotations, saveAnnotation]);
257
+
258
+ // ========== CLEAR CURRENT ==========
259
+ const clearCurrent = useCallback(() => {
260
+ if (!currentImage) return;
261
+ const newAnnotations = { ...annotations };
262
+ delete newAnnotations[currentImage.name];
263
+ saveAnnotation(newAnnotations);
264
+ }, [currentImage, annotations, saveAnnotation]);
265
+
266
+ // ========== NAVIGATION ==========
267
+ const goNext = useCallback(() => {
268
+ setCurrentIndex(i => Math.min(totalImages - 1, i + 1));
269
+ }, [totalImages]);
270
+
271
+ const goPrev = useCallback(() => {
272
+ setCurrentIndex(i => Math.max(0, i - 1));
273
+ }, []);
274
+
275
+ // ========== KEYBOARD SHORTCUTS ==========
276
+ useEffect(() => {
277
+ function handleKeyDown(e) {
278
+ if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
279
+
280
+ const key = e.key.toLowerCase();
281
+ switch (key) {
282
+ case 'a': case 'arrowleft': goPrev(); break;
283
+ case 'd': case 'arrowright': goNext(); break;
284
+ case '1': toggleError('false_positive', 'v3'); break;
285
+ case '2': toggleError('false_positive', 'v4'); break;
286
+ case '3': toggleError('false_positive', 'both'); break;
287
+ case '4': toggleError('false_negative', 'v3'); break;
288
+ case '5': toggleError('false_negative', 'v4'); break;
289
+ case '6': toggleError('false_negative', 'both'); break;
290
+ case 'w': toggleError('false_positive', e.shiftKey ? 'v4' : 'v3'); break;
291
+ case 's': toggleError('false_negative', e.shiftKey ? 'v4' : 'v3'); break;
292
+ case 'q': clearCurrent(); break;
293
+ case 'escape':
294
+ setZoomSrc(null);
295
+ setShowHelp(false);
296
+ break;
297
+ case '?': setShowHelp(h => !h); break;
298
+ }
299
+ }
300
+ window.addEventListener('keydown', handleKeyDown);
301
+ return () => window.removeEventListener('keydown', handleKeyDown);
302
+ }, [goNext, goPrev, toggleError, clearCurrent]);
303
+
304
+ // ========== STATS ==========
305
+ const stats = useMemo(() => {
306
+ const s = { v3_fp: 0, v3_fn: 0, v4_fp: 0, v4_fn: 0, both_fp: 0, both_fn: 0, annotated: 0 };
307
+ Object.values(annotations).forEach(ann => {
308
+ const hasAny = (ann.false_positive?.length > 0) || (ann.false_negative?.length > 0);
309
+ if (hasAny) s.annotated++;
310
+ if (ann.false_positive?.includes('v3')) s.v3_fp++;
311
+ if (ann.false_positive?.includes('v4')) s.v4_fp++;
312
+ if (ann.false_positive?.includes('both')) s.both_fp++;
313
+ if (ann.false_negative?.includes('v3')) s.v3_fn++;
314
+ if (ann.false_negative?.includes('v4')) s.v4_fn++;
315
+ if (ann.false_negative?.includes('both')) s.both_fn++;
316
+ });
317
+ s.v3_total = s.v3_fp + s.v3_fn;
318
+ s.v4_total = s.v4_fp + s.v4_fn;
319
+ s.both_total = s.both_fp + s.both_fn;
320
+ s.total_all = s.v3_total + s.v4_total + s.both_total;
321
+ return s;
322
+ }, [annotations]);
323
+
324
+ // ========== IMAGE URL BUILDER ==========
325
+ const getImageUrl = useCallback((img, version) => {
326
+ if (!img) return '';
327
+ if (img.isDemo) return getDemoImageUrl(img.name, version);
328
+ return `${API_BASE.replace('/api', '')}/images/${version}/${encodeURIComponent(img.name)}`;
329
+ }, []);
330
+
331
+ // ========== RENDER: LOADING ==========
332
+ if (loading) {
333
+ return (
334
+ <div className="min-h-screen flex items-center justify-center">
335
+ <div className="text-center animate-fade-in">
336
+ <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>
337
+ <p className="text-gray-400 text-lg">Loading images...</p>
338
+ <p className="text-gray-600 text-sm mt-2">Connecting to backend server</p>
339
+ </div>
340
+ </div>
341
+ );
342
+ }
343
+
344
+ // ========== RENDER: EMPTY ==========
345
+ if (totalImages === 0) {
346
+ return (
347
+ <div className="min-h-screen flex items-center justify-center">
348
+ <div className="text-center max-w-md animate-fade-in">
349
+ <div className="text-6xl mb-4">📂</div>
350
+ <h2 className="text-xl font-semibold text-gray-200 mb-2">No Images Found</h2>
351
+ <p className="text-gray-500 text-sm leading-relaxed">
352
+ Place images in <code className="text-blue-400 bg-blue-500/10 px-1.5 py-0.5 rounded">save_images_v3/</code> and{' '}
353
+ <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.
354
+ </p>
355
+ </div>
356
+ </div>
357
+ );
358
+ }
359
+
360
+ // ========== GRID CELL CONFIG ==========
361
+ const gridCells = [
362
+ { errorType: 'false_positive', model: 'v3', label: 'FP · v3', key: '1', activeClass: 'active-v3' },
363
+ { errorType: 'false_positive', model: 'v4', label: 'FP · v4', key: '2', activeClass: 'active-v4' },
364
+ { errorType: 'false_positive', model: 'both', label: 'FP · Both', key: '3', activeClass: 'active-both' },
365
+ { errorType: 'false_negative', model: 'v3', label: 'FN · v3', key: '4', activeClass: 'active-v3' },
366
+ { errorType: 'false_negative', model: 'v4', label: 'FN · v4', key: '5', activeClass: 'active-v4' },
367
+ { errorType: 'false_negative', model: 'both', label: 'FN · Both', key: '6', activeClass: 'active-both' },
368
+ ];
369
+
370
+ // ========== RENDER ==========
371
+ return (
372
+ <div className="min-h-screen flex flex-col">
373
+ {/* ===== HEADER ===== */}
374
+ <header className="border-b border-gray-800/60 bg-dark-800/80 backdrop-blur-sm sticky top-0 z-30">
375
+ <div className="max-w-7xl mx-auto px-4 py-3 flex items-center justify-between">
376
+ <div className="flex items-center gap-3">
377
+ <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>
378
+ <div>
379
+ <h1 className="text-sm font-semibold text-gray-100 leading-tight">AI Camera Hub — Lumi</h1>
380
+ <p className="text-xs text-gray-500">AI Model Error Analysis Dashboard</p>
381
+ </div>
382
+ </div>
383
+ <div className="flex items-center gap-2">
384
+ {isDemo && (
385
+ <span className="text-[10px] font-medium bg-amber-500/15 text-amber-400 px-2 py-1 rounded-md border border-amber-500/20">
386
+ DEMO MODE
387
+ </span>
388
+ )}
389
+ {isSaving && (
390
+ <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">
391
+ ✓ Saved
392
+ </span>
393
+ )}
394
+ <button
395
+ onClick={() => setShowHelp(true)}
396
+ 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"
397
+ title="Keyboard shortcuts"
398
+ >
399
+ ?
400
+ </button>
401
+ </div>
402
+ </div>
403
+ </header>
404
+
405
+ <main className="flex-1 flex flex-col max-w-7xl mx-auto w-full px-4 py-4 gap-4">
406
+ {/* ===== IMAGE PANEL ===== */}
407
+ <div className="flex gap-4 image-grid" style={{ flex: '1 1 auto', minHeight: 0 }}>
408
+ {/* AIv3 Image */}
409
+ <div className="flex-1 min-w-0">
410
+ <div className="mb-2 flex items-center gap-2">
411
+ <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">
412
+ <span className="w-2 h-2 rounded-full bg-blue-500"></span>
413
+ AIv3
414
+ </span>
415
+ {currentImage && (
416
+ <span className="text-[11px] text-gray-500 truncate">{currentImage.name}</span>
417
+ )}
418
+ </div>
419
+ <div
420
+ className="img-card relative rounded-xl overflow-hidden bg-dark-700 border border-gray-800/60 cursor-zoom-in group"
421
+ style={{ minHeight: '200px' }}
422
+ onClick={() => {
423
+ if (currentImage) {
424
+ setZoomSrc(getImageUrl(currentImage, 'v3'));
425
+ setZoomLabel('AIv3 — ' + currentImage.name);
426
+ }
427
+ }}
428
+ >
429
+ {currentImage && currentImage.hasV3 ? (
430
+ <img
431
+ src={getImageUrl(currentImage, 'v3')}
432
+ alt="AIv3"
433
+ className="w-full h-full object-contain max-h-[42vh]"
434
+ onError={(e) => { e.target.style.display = 'none'; }}
435
+ />
436
+ ) : (
437
+ <div className="flex items-center justify-center h-48 text-gray-600 text-sm">
438
+ <span>Image not found in v3 folder</span>
439
+ </div>
440
+ )}
441
+ <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">
442
+ <span className="bg-black/50 text-white text-xs px-3 py-1.5 rounded-lg">🔍 Click to zoom</span>
443
+ </div>
444
+ </div>
445
+ </div>
446
+
447
+ {/* AIv4 Image */}
448
+ <div className="flex-1 min-w-0">
449
+ <div className="mb-2 flex items-center gap-2">
450
+ <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">
451
+ <span className="w-2 h-2 rounded-full bg-violet-500"></span>
452
+ AIv4
453
+ </span>
454
+ {currentImage && (
455
+ <span className="text-[11px] text-gray-500 truncate">{currentImage.name}</span>
456
+ )}
457
+ </div>
458
+ <div
459
+ className="img-card relative rounded-xl overflow-hidden bg-dark-700 border border-gray-800/60 cursor-zoom-in group"
460
+ style={{ minHeight: '200px' }}
461
+ onClick={() => {
462
+ if (currentImage) {
463
+ setZoomSrc(getImageUrl(currentImage, 'v4'));
464
+ setZoomLabel('AIv4 — ' + currentImage.name);
465
+ }
466
+ }}
467
+ >
468
+ {currentImage && currentImage.hasV4 ? (
469
+ <img
470
+ src={getImageUrl(currentImage, 'v4')}
471
+ alt="AIv4"
472
+ className="w-full h-full object-contain max-h-[42vh]"
473
+ onError={(e) => { e.target.style.display = 'none'; }}
474
+ />
475
+ ) : (
476
+ <div className="flex items-center justify-center h-48 text-gray-600 text-sm">
477
+ <span>Image not found in v4 folder</span>
478
+ </div>
479
+ )}
480
+ <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">
481
+ <span className="bg-black/50 text-white text-xs px-3 py-1.5 rounded-lg">🔍 Click to zoom</span>
482
+ </div>
483
+ </div>
484
+ </div>
485
+ </div>
486
+
487
+ {/* ===== NAVIGATION + PROGRESS ===== */}
488
+ <div className="flex items-center gap-4 animate-slide-up">
489
+ <button
490
+ onClick={goPrev}
491
+ disabled={currentIndex === 0}
492
+ 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"
493
+ >
494
+ <span>‹</span> Prev <span className="key-hint ml-1">A</span>
495
+ </button>
496
+
497
+ <div className="flex-1 flex flex-col gap-1.5">
498
+ <div className="flex items-center justify-between text-sm">
499
+ <span className="text-gray-400 font-medium">
500
+ Image <span className="text-white font-semibold">{currentIndex + 1}</span> / {totalImages}
501
+ </span>
502
+ <span className="text-gray-500 text-xs">
503
+ {stats.annotated}/{totalImages} annotated ({totalImages > 0 ? Math.round(stats.annotated / totalImages * 100) : 0}%)
504
+ </span>
505
+ </div>
506
+ <div className="w-full h-1.5 bg-gray-800/80 rounded-full overflow-hidden">
507
+ <div
508
+ className="h-full progress-fill rounded-full transition-all duration-300"
509
+ style={{ width: `${totalImages > 0 ? (stats.annotated / totalImages * 100) : 0}%` }}
510
+ />
511
+ </div>
512
+ </div>
513
+
514
+ <button
515
+ onClick={goNext}
516
+ disabled={currentIndex === totalImages - 1}
517
+ 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"
518
+ >
519
+ <span className="key-hint mr-1">D</span> Next <span>›</span>
520
+ </button>
521
+ </div>
522
+
523
+ {/* ===== ERROR GRID ===== */}
524
+ <div className="bg-dark-700/50 border border-gray-800/60 rounded-xl p-4 animate-slide-up">
525
+ <div className="flex items-center justify-between mb-3">
526
+ <h3 className="text-xs font-semibold text-gray-400 uppercase tracking-wider">Error Annotation</h3>
527
+ <button
528
+ onClick={clearCurrent}
529
+ className="text-[11px] text-gray-500 hover:text-rose-400 transition-colors flex items-center gap-1"
530
+ title="Clear all errors for this image (Q)"
531
+ >
532
+ <span>✕</span> Clear <span className="key-hint ml-0.5">Q</span>
533
+ </button>
534
+ </div>
535
+
536
+ {/* Column headers */}
537
+ <div className="grid gap-2" style={{ gridTemplateColumns: '140px 1fr 1fr 1fr' }}>
538
+ <div></div>
539
+ <div className="text-center text-xs font-semibold text-blue-400 pb-1">AIv3</div>
540
+ <div className="text-center text-xs font-semibold text-violet-400 pb-1">AIv4</div>
541
+ <div className="text-center text-xs font-semibold text-amber-400 pb-1">Cả 2</div>
542
+
543
+ {/* FP Row */}
544
+ <div className="flex items-center gap-2">
545
+ <span className="text-xs text-rose-400 font-medium">Phát hiện nhầm</span>
546
+ <span className="text-[10px] text-gray-600">(FP)</span>
547
+ </div>
548
+ {gridCells.slice(0, 3).map(cell => {
549
+ const isActive = (currentAnnotation[cell.errorType] || []).includes(cell.model);
550
+ return (
551
+ <button
552
+ key={`${cell.errorType}-${cell.model}`}
553
+ onClick={() => toggleError(cell.errorType, cell.model)}
554
+ 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'}`}
555
+ >
556
+ <div className={`text-sm font-semibold ${isActive ? 'text-white' : 'text-gray-500'}`}>
557
+ {isActive ? '✓' : '—'}
558
+ </div>
559
+ <div className="key-hint mx-auto mt-1">{cell.key}</div>
560
+ </button>
561
+ );
562
+ })}
563
+
564
+ {/* FN Row */}
565
+ <div className="flex items-center gap-2">
566
+ <span className="text-xs text-orange-400 font-medium">Không phát hiện</span>
567
+ <span className="text-[10px] text-gray-600">(FN)</span>
568
+ </div>
569
+ {gridCells.slice(3, 6).map(cell => {
570
+ const isActive = (currentAnnotation[cell.errorType] || []).includes(cell.model);
571
+ return (
572
+ <button
573
+ key={`${cell.errorType}-${cell.model}`}
574
+ onClick={() => toggleError(cell.errorType, cell.model)}
575
+ 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'}`}
576
+ >
577
+ <div className={`text-sm font-semibold ${isActive ? 'text-white' : 'text-gray-500'}`}>
578
+ {isActive ? '✓' : '—'}
579
+ </div>
580
+ <div className="key-hint mx-auto mt-1">{cell.key}</div>
581
+ </button>
582
+ );
583
+ })}
584
+ </div>
585
+
586
+ {/* Quick toggles */}
587
+ <div className="mt-3 pt-3 border-t border-gray-800/40 flex items-center gap-2 text-[11px] text-gray-500">
588
+ <span>Quick:</span>
589
+ <span className="flex items-center gap-1"><span className="key-hint">W</span> FP v3</span>
590
+ <span className="flex items-center gap-1"><span className="key-hint">⇧W</span> FP v4</span>
591
+ <span className="flex items-center gap-1"><span className="key-hint">S</span> FN v3</span>
592
+ <span className="flex items-center gap-1"><span className="key-hint">⇧S</span> FN v4</span>
593
+ </div>
594
+ </div>
595
+
596
+ {/* ===== STATISTICS ===== */}
597
+ <div className="grid grid-cols-2 md:grid-cols-4 gap-3 animate-slide-up">
598
+ {/* AIv3 Stats */}
599
+ <div className="stat-card bg-dark-700/50 border border-blue-500/10 rounded-xl p-4">
600
+ <div className="flex items-center gap-2 mb-2">
601
+ <span className="w-2 h-2 rounded-full bg-blue-500"></span>
602
+ <span className="text-xs font-semibold text-blue-400">AIv3 Errors</span>
603
+ </div>
604
+ <div className="text-2xl font-bold text-white mb-1">{stats.v3_total}</div>
605
+ <div className="flex gap-3 text-[11px]">
606
+ <span className="text-rose-400">FP: {stats.v3_fp}</span>
607
+ <span className="text-orange-400">FN: {stats.v3_fn}</span>
608
+ </div>
609
+ </div>
610
+
611
+ {/* AIv4 Stats */}
612
+ <div className="stat-card bg-dark-700/50 border border-violet-500/10 rounded-xl p-4">
613
+ <div className="flex items-center gap-2 mb-2">
614
+ <span className="w-2 h-2 rounded-full bg-violet-500"></span>
615
+ <span className="text-xs font-semibold text-violet-400">AIv4 Errors</span>
616
+ </div>
617
+ <div className="text-2xl font-bold text-white mb-1">{stats.v4_total}</div>
618
+ <div className="flex gap-3 text-[11px]">
619
+ <span className="text-rose-400">FP: {stats.v4_fp}</span>
620
+ <span className="text-orange-400">FN: {stats.v4_fn}</span>
621
+ </div>
622
+ </div>
623
+
624
+ {/* Both Stats */}
625
+ <div className="stat-card bg-dark-700/50 border border-amber-500/10 rounded-xl p-4">
626
+ <div className="flex items-center gap-2 mb-2">
627
+ <span className="w-2 h-2 rounded-full bg-amber-500"></span>
628
+ <span className="text-xs font-semibold text-amber-400">Cả 2 Errors</span>
629
+ </div>
630
+ <div className="text-2xl font-bold text-white mb-1">{stats.both_total}</div>
631
+ <div className="flex gap-3 text-[11px]">
632
+ <span className="text-rose-400">FP: {stats.both_fp}</span>
633
+ <span className="text-orange-400">FN: {stats.both_fn}</span>
634
+ </div>
635
+ </div>
636
+
637
+ {/* Progress Stats */}
638
+ <div className="stat-card bg-dark-700/50 border border-emerald-500/10 rounded-xl p-4">
639
+ <div className="flex items-center gap-2 mb-2">
640
+ <span className="w-2 h-2 rounded-full bg-emerald-500"></span>
641
+ <span className="text-xs font-semibold text-emerald-400">Progress</span>
642
+ </div>
643
+ <div className="text-2xl font-bold text-white mb-1">
644
+ {totalImages > 0 ? Math.round(stats.annotated / totalImages * 100) : 0}%
645
+ </div>
646
+ <div className="text-[11px] text-gray-500">
647
+ {stats.annotated} / {totalImages} images
648
+ </div>
649
+ </div>
650
+ </div>
651
+
652
+ {/* ===== BREAKDOWN TABLE ===== */}
653
+ {stats.total_all > 0 && (
654
+ <div className="bg-dark-700/50 border border-gray-800/60 rounded-xl p-4 animate-slide-up">
655
+ <h3 className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3">Error Breakdown</h3>
656
+ <div className="overflow-x-auto">
657
+ <table className="w-full text-sm">
658
+ <thead>
659
+ <tr className="text-gray-500 text-xs">
660
+ <th className="text-left pb-2 font-medium">Type</th>
661
+ <th className="text-center pb-2 font-medium text-blue-400">AIv3</th>
662
+ <th className="text-center pb-2 font-medium text-violet-400">AIv4</th>
663
+ <th className="text-center pb-2 font-medium text-amber-400">Cả 2</th>
664
+ <th className="text-center pb-2 font-medium text-gray-400">Total</th>
665
+ </tr>
666
+ </thead>
667
+ <tbody>
668
+ <tr className="border-t border-gray-800/40">
669
+ <td className="py-2 text-rose-400 text-xs font-medium">False Positive</td>
670
+ <td className="py-2 text-center text-white font-semibold">{stats.v3_fp}</td>
671
+ <td className="py-2 text-center text-white font-semibold">{stats.v4_fp}</td>
672
+ <td className="py-2 text-center text-white font-semibold">{stats.both_fp}</td>
673
+ <td className="py-2 text-center text-gray-300 font-semibold">{stats.v3_fp + stats.v4_fp + stats.both_fp}</td>
674
+ </tr>
675
+ <tr className="border-t border-gray-800/40">
676
+ <td className="py-2 text-orange-400 text-xs font-medium">False Negative</td>
677
+ <td className="py-2 text-center text-white font-semibold">{stats.v3_fn}</td>
678
+ <td className="py-2 text-center text-white font-semibold">{stats.v4_fn}</td>
679
+ <td className="py-2 text-center text-white font-semibold">{stats.both_fn}</td>
680
+ <td className="py-2 text-center text-gray-300 font-semibold">{stats.v3_fn + stats.v4_fn + stats.both_fn}</td>
681
+ </tr>
682
+ <tr className="border-t border-gray-700/60 font-semibold">
683
+ <td className="py-2 text-gray-300 text-xs font-medium">Total</td>
684
+ <td className="py-2 text-center text-blue-400">{stats.v3_total}</td>
685
+ <td className="py-2 text-center text-violet-400">{stats.v4_total}</td>
686
+ <td className="py-2 text-center text-amber-400">{stats.both_total}</td>
687
+ <td className="py-2 text-center text-white">{stats.total_all}</td>
688
+ </tr>
689
+ </tbody>
690
+ </table>
691
+ </div>
692
+ </div>
693
+ )}
694
+ </main>
695
+
696
+ {/* ===== ZOOM MODAL ===== */}
697
+ {zoomSrc && (
698
+ <div
699
+ className="zoom-overlay fixed inset-0 z-50 bg-black/80 flex items-center justify-center p-4 animate-fade-in"
700
+ onClick={() => setZoomSrc(null)}
701
+ >
702
+ <div className="relative max-w-6xl max-h-[90vh] w-full" onClick={e => e.stopPropagation()}>
703
+ <div className="absolute top-0 left-0 right-0 flex items-center justify-between p-3 z-10">
704
+ <span className="text-sm text-gray-300 bg-black/60 px-3 py-1 rounded-lg">{zoomLabel}</span>
705
+ <button
706
+ onClick={() => setZoomSrc(null)}
707
+ 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"
708
+ >
709
+
710
+ </button>
711
+ </div>
712
+ <img
713
+ src={zoomSrc}
714
+ alt="Zoomed"
715
+ className="w-full h-full object-contain max-h-[90vh] rounded-lg"
716
+ />
717
+ </div>
718
+ </div>
719
+ )}
720
+
721
+ {/* ===== HELP MODAL ===== */}
722
+ {showHelp && (
723
+ <div
724
+ className="fixed inset-0 z-50 bg-black/60 flex items-center justify-center p-4 animate-fade-in"
725
+ onClick={() => setShowHelp(false)}
726
+ >
727
+ <div
728
+ className="bg-dark-700 border border-gray-700/60 rounded-2xl p-6 max-w-md w-full animate-slide-up"
729
+ onClick={e => e.stopPropagation()}
730
+ >
731
+ <div className="flex items-center justify-between mb-4">
732
+ <h2 className="text-lg font-semibold text-white">Keyboard Shortcuts</h2>
733
+ <button onClick={() => setShowHelp(false)} className="text-gray-500 hover:text-white transition-colors text-lg">✕</button>
734
+ </div>
735
+ <div className="space-y-2 text-sm">
736
+ <div className="flex justify-between py-1.5 border-b border-gray-800/40">
737
+ <span className="text-gray-300">Previous image</span>
738
+ <div className="flex gap-1"><span className="key-hint">A</span> <span className="key-hint">←</span></div>
739
+ </div>
740
+ <div className="flex justify-between py-1.5 border-b border-gray-800/40">
741
+ <span className="text-gray-300">Next image</span>
742
+ <div className="flex gap-1"><span className="key-hint">D</span> <span className="key-hint">→</span></div>
743
+ </div>
744
+ <div className="flex justify-between py-1.5 border-b border-gray-800/40">
745
+ <span className="text-rose-400">FP · AIv3</span>
746
+ <div className="flex gap-1"><span className="key-hint">1</span> <span className="key-hint">W</span></div>
747
+ </div>
748
+ <div className="flex justify-between py-1.5 border-b border-gray-800/40">
749
+ <span className="text-rose-400">FP · AIv4</span>
750
+ <div className="flex gap-1"><span className="key-hint">2</span> <span className="key-hint">⇧W</span></div>
751
+ </div>
752
+ <div className="flex justify-between py-1.5 border-b border-gray-800/40">
753
+ <span className="text-rose-400">FP · Cả 2</span>
754
+ <span className="key-hint">3</span>
755
+ </div>
756
+ <div className="flex justify-between py-1.5 border-b border-gray-800/40">
757
+ <span className="text-orange-400">FN · AIv3</span>
758
+ <div className="flex gap-1"><span className="key-hint">4</span> <span className="key-hint">S</span></div>
759
+ </div>
760
+ <div className="flex justify-between py-1.5 border-b border-gray-800/40">
761
+ <span className="text-orange-400">FN · AIv4</span>
762
+ <div className="flex gap-1"><span className="key-hint">5</span> <span className="key-hint">⇧S</span></div>
763
+ </div>
764
+ <div className="flex justify-between py-1.5 border-b border-gray-800/40">
765
+ <span className="text-orange-400">FN · Cả 2</span>
766
+ <span className="key-hint">6</span>
767
+ </div>
768
+ <div className="flex justify-between py-1.5 border-b border-gray-800/40">
769
+ <span className="text-gray-300">Clear current</span>
770
+ <span className="key-hint">Q</span>
771
+ </div>
772
+ <div className="flex justify-between py-1.5">
773
+ <span className="text-gray-300">Close modal</span>
774
+ <span className="key-hint">Esc</span>
775
+ </div>
776
+ </div>
777
+ </div>
778
+ </div>
779
+ )}
780
+ </div>
781
+ );
782
+ }
783
+
784
+ // ========== MOUNT ==========
785
+ const root = ReactDOM.createRoot(document.getElementById('root'));
786
+ root.render(<App />);
787
+ </script>
788
+ <script src="https://deepsite.hf.co/deepsite-badge.js"></script>
789
+ </body>
790
+ </html>
sample_annotations.json ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "frame_001.jpg": {
3
+ "false_positive": ["v3"],
4
+ "false_negative": []
5
+ },
6
+ "frame_002.jpg": {
7
+ "false_positive": ["v4"],
8
+ "false_negative": ["v3"]
9
+ },
10
+ "frame_003.jpg": {
11
+ "false_positive": ["both"],
12
+ "false_negative": ["both"]
13
+ },
14
+ "frame_005.jpg": {
15
+ "false_positive": ["v3", "v4"],
16
+ "false_negative": ["v4"]
17
+ },
18
+ "frame_008.jpg": {
19
+ "false_positive": [],
20
+ "false_negative": ["v3", "both"]
21
+ }
22
+ }