raidAthmaneBenlala commited on
Commit
62fa15c
·
1 Parent(s): 41b08a0

feat: complete split-view redesign matching manar.jpeg

Browse files
Files changed (1) hide show
  1. toothmap_frontend/src/App.jsx +342 -287
toothmap_frontend/src/App.jsx CHANGED
@@ -1,77 +1,8 @@
1
  import React, { useState, useRef, useEffect } from 'react';
2
  import axios from 'axios';
3
- import { UploadCloud, Activity, Loader2, ScanLine, X } from 'lucide-react';
4
-
5
- const API_BASE = window.location.origin.includes('localhost') || window.location.origin.includes('127.0.0.1') ? "http://127.0.0.1:8000" : window.location.origin;
6
-
7
- const MODES = [
8
- {
9
- id: 'yolo',
10
- label: 'Localiser les dents 1',
11
- emoji: '🔍',
12
- desc: 'YOLOv8s bounding box detection',
13
- models: 'YOLOv8s',
14
- color: 'from-blue-500 to-cyan-500',
15
- active: 'bg-gradient-to-r from-blue-500/20 to-cyan-500/20 border-blue-400/60 text-blue-300',
16
- inactive: 'border-zinc-700/40 text-zinc-400 hover:border-blue-500/40 hover:text-blue-300',
17
- type: 'detection',
18
- },
19
- {
20
- id: 'frcnn',
21
- label: 'Localiser les dents 2',
22
- emoji: '🔎',
23
- desc: 'Faster R-CNN bounding box detection',
24
- models: 'Faster R-CNN (ResNet50-FPN)',
25
- color: 'from-indigo-500 to-blue-500',
26
- active: 'bg-gradient-to-r from-indigo-500/20 to-blue-500/20 border-indigo-400/60 text-indigo-300',
27
- inactive: 'border-zinc-700/40 text-zinc-400 hover:border-indigo-500/40 hover:text-indigo-300',
28
- type: 'detection',
29
- },
30
- {
31
- id: 'seg_yolo',
32
- label: 'Localiser et détourage dents 1',
33
- emoji: '🔬',
34
- desc: 'YOLO detection + U-Net segmentation masks',
35
- models: 'YOLOv8s + U-Net (ResNet34)',
36
- color: 'from-violet-500 to-purple-500',
37
- active: 'bg-gradient-to-r from-violet-500/20 to-purple-500/20 border-violet-400/60 text-violet-300',
38
- inactive: 'border-zinc-700/40 text-zinc-400 hover:border-violet-500/40 hover:text-violet-300',
39
- type: 'segmentation',
40
- },
41
- {
42
- id: 'seg_frcnn',
43
- label: 'Localiser et détourage dents 2',
44
- emoji: '🧬',
45
- desc: 'Faster R-CNN detection + U-Net segmentation masks',
46
- models: 'Faster R-CNN + U-Net (ResNet34)',
47
- color: 'from-fuchsia-500 to-pink-500',
48
- active: 'bg-gradient-to-r from-fuchsia-500/20 to-pink-500/20 border-fuchsia-400/60 text-fuchsia-300',
49
- inactive: 'border-zinc-700/40 text-zinc-400 hover:border-fuchsia-500/40 hover:text-fuchsia-300',
50
- type: 'segmentation',
51
- },
52
- {
53
- id: 'pipeline_yolo',
54
- label: 'Analyse détaillée: localiser, détourage, numérotation (charting) 1',
55
- emoji: '🦷',
56
- desc: 'YOLO detection + FDI classification + U-Net segmentation',
57
- models: 'YOLOv8s + ConvNeXt-Tiny + U-Net',
58
- color: 'from-emerald-500 to-teal-500',
59
- active: 'bg-gradient-to-r from-emerald-500/20 to-teal-500/20 border-emerald-400/60 text-emerald-300',
60
- inactive: 'border-zinc-700/40 text-zinc-400 hover:border-emerald-500/40 hover:text-emerald-300',
61
- type: 'pipeline',
62
- },
63
- {
64
- id: 'pipeline_frcnn',
65
- label: 'Analyse détaillée: localiser, détourage, numérotation (charting) 2',
66
- emoji: '🩺',
67
- desc: 'Faster R-CNN detection + FDI classification + U-Net segmentation',
68
- models: 'Faster R-CNN + ConvNeXt-Tiny + U-Net',
69
- color: 'from-amber-500 to-orange-500',
70
- active: 'bg-gradient-to-r from-amber-500/20 to-orange-500/20 border-amber-400/60 text-amber-300',
71
- inactive: 'border-zinc-700/40 text-zinc-400 hover:border-amber-500/40 hover:text-amber-300',
72
- type: 'pipeline',
73
- },
74
- ];
75
 
76
  const QUADRANT_COLOURS = ['#3b82f6', '#22c55e', '#f59e0b', '#ef4444'];
77
  const UPPER_TEETH = [18, 17, 16, 15, 14, 13, 12, 11, 21, 22, 23, 24, 25, 26, 27, 28];
@@ -98,7 +29,7 @@ function computeIoU(b1, b2) {
98
  }
99
 
100
  function applyNMS(results, iouThreshold = 0.35) {
101
- const sorted = [...results].sort((a, b) => b.score - a.score);
102
  const keep = [];
103
  for (const item of sorted) {
104
  if (!keep.some(k => computeIoU(item.box, k.box) > iouThreshold)) keep.push(item);
@@ -112,6 +43,71 @@ function getColour(fdi) {
112
  return QUADRANT_COLOURS[Math.min(quad, 3)] ?? '#a855f7';
113
  }
114
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
115
  // ── Odontogram Component ──────────────────────────────────────────────────────
116
  function Odontogram({ detectedFDIs }) {
117
  const detected = new Set(detectedFDIs);
@@ -123,30 +119,29 @@ function Odontogram({ detectedFDIs }) {
123
  <div
124
  title={present ? `FDI ${fdi} — détectée` : `FDI ${fdi} — non détectée`}
125
  style={present ? {
126
- border: `2px solid ${style.border}`,
127
  background: style.bg,
128
  color: style.text,
129
- boxShadow: `0 0 12px ${style.border}55`,
130
  } : {
131
- border: '2px solid rgba(113,113,122,0.35)',
132
- background: 'rgba(39,39,42,0.6)',
133
  color: 'transparent',
134
  position: 'relative',
135
  }}
136
- className={`flex items-center justify-center font-bold text-xs rounded-md transition-all select-none
137
- ${isUpper ? 'rounded-t-full' : 'rounded-b-full'}
138
- w-8 h-10 sm:w-9 sm:h-12 md:w-10 md:h-12 lg:w-9 lg:h-12`}
139
  >
140
  {present ? (
141
  <span>{fdi}</span>
142
  ) : (
143
  <>
144
- {/* Red X cross for missing teeth */}
145
- <svg viewBox="0 0 24 24" className="w-4 h-4 absolute" fill="none" stroke="#ef4444" strokeWidth="2.5" strokeLinecap="round">
146
  <line x1="6" y1="6" x2="18" y2="18" />
147
  <line x1="18" y1="6" x2="6" y2="18" />
148
  </svg>
149
- <span className="text-zinc-600 text-[9px] absolute bottom-0.5">{fdi}</span>
150
  </>
151
  )}
152
  </div>
@@ -154,42 +149,30 @@ function Odontogram({ detectedFDIs }) {
154
  };
155
 
156
  return (
157
- <div className="w-full bg-zinc-900/60 rounded-2xl p-4 sm:p-6 border border-zinc-800/80 shadow-2xl">
158
- <p className="text-xs font-bold text-slate-400 uppercase tracking-widest mb-4 text-center">
159
  Odontogramme FDI — Schéma Dentaire
160
  </p>
161
 
162
- {/* Upper arch */}
163
- <div className="flex justify-center flex-wrap gap-1 sm:gap-1.5 mb-2">
164
  {UPPER_TEETH.map(t => <ToothCell key={t} fdi={t} isUpper={true} />)}
165
  </div>
166
 
167
- {/* Maxillary / Mandibular divider */}
168
- <div className="flex items-center gap-2 my-2">
169
- <div className="flex-1 h-px bg-gradient-to-r from-transparent via-zinc-700 to-transparent" />
170
- <span className="text-[9px] text-zinc-600 font-mono tracking-widest whitespace-nowrap">
171
- MAXILLAIRE / MANDIBULAIRE
172
- </span>
173
- <div className="flex-1 h-px bg-gradient-to-r from-transparent via-zinc-700 to-transparent" />
174
- </div>
175
-
176
- {/* Lower arch */}
177
- <div className="flex justify-center flex-wrap gap-1 sm:gap-1.5 mt-2">
178
  {LOWER_TEETH.map(t => <ToothCell key={t} fdi={t} isUpper={false} />)}
179
  </div>
180
 
181
- {/* Legend */}
182
- <div className="flex flex-wrap justify-center gap-4 sm:gap-8 mt-4 text-xs text-zinc-400">
183
- <div className="flex items-center gap-2">
184
  <div className="flex -space-x-1">
185
  {['bg-blue-500','bg-green-500','bg-amber-500','bg-red-500'].map((c,i) => (
186
- <span key={i} className={`w-3 h-3 rounded-full ${c} border border-zinc-900`} />
187
  ))}
188
  </div>
189
  <span>Détectée (Q1–Q4)</span>
190
  </div>
191
- <div className="flex items-center gap-2">
192
- <svg viewBox="0 0 16 16" className="w-3 h-3" fill="none" stroke="#ef4444" strokeWidth="2.5" strokeLinecap="round">
193
  <line x1="3" y1="3" x2="13" y2="13" />
194
  <line x1="13" y1="3" x2="3" y2="13" />
195
  </svg>
@@ -204,18 +187,26 @@ function Odontogram({ detectedFDIs }) {
204
  export default function App() {
205
  const [file, setFile] = useState(null);
206
  const [imagePreview, setImagePreview] = useState(null);
207
- const [mode, setMode] = useState('pipeline_yolo');
 
 
 
 
 
208
  const [pipelineResults, setPipelineResults] = useState(null);
209
  const [rawBoxes, setRawBoxes] = useState(null);
210
  const [maskSrc, setMaskSrc] = useState(null);
211
  const [loading, setLoading] = useState(false);
212
- const [hoveredMode, setHoveredMode] = useState(null);
213
  const imgRef = useRef(null);
214
  const canvasRef = useRef(null);
215
 
216
- const currentMode = MODES.find(m => m.id === mode);
217
- const isPipeline = currentMode?.type === 'pipeline';
218
- const isDetection = currentMode?.type === 'detection';
 
 
 
 
219
 
220
  const handleFileSelect = (f) => {
221
  setFile(f);
@@ -232,17 +223,13 @@ export default function App() {
232
  const fd = new FormData();
233
  fd.append("file", file);
234
  try {
235
- if (mode === 'pipeline_yolo' || mode === 'seg_yolo') {
236
- const r = await axios.post(`${API_BASE}/api/pipeline/yolo`, fd);
 
237
  setPipelineResults(r.data.results);
238
- } else if (mode === 'pipeline_frcnn' || mode === 'seg_frcnn') {
239
- const r = await axios.post(`${API_BASE}/api/pipeline/frcnn`, fd);
240
- setPipelineResults(r.data.results);
241
- } else if (mode === 'yolo') {
242
- const r = await axios.post(`${API_BASE}/api/detect/yolo`, fd);
243
- setRawBoxes(r.data);
244
- } else if (mode === 'frcnn') {
245
- const r = await axios.post(`${API_BASE}/api/detect/frcnn`, fd);
246
  setRawBoxes(r.data);
247
  }
248
  } catch (e) {
@@ -268,11 +255,11 @@ export default function App() {
268
  if (pipelineResults) {
269
  const showFDI = isPipeline;
270
  const uniqueResults = applyNMS(pipelineResults, 0.40);
271
- uniqueResults.forEach(({ box, score, fdi, mask_base64 }) => {
272
  const [x1, y1, x2, y2] = box;
273
  const rx = x1*sx, ry = y1*sy, rw = (x2-x1)*sx, rh = (y2-y1)*sy;
274
  const col = showFDI ? getColour(fdi) : '#22d3ee';
275
- if (mask_base64) {
276
  const maskImg = new Image();
277
  maskImg.onload = () => {
278
  const off = document.createElement('canvas');
@@ -291,229 +278,297 @@ export default function App() {
291
  }
292
  ctx.strokeStyle = col; ctx.lineWidth = 2;
293
  ctx.strokeRect(rx, ry, rw, rh);
294
- const label = showFDI ? (fdi > 0 ? `FDI ${fdi}` : `${(score*100).toFixed(0)}%`) : `${(score*100).toFixed(0)}%`;
295
- const tw = ctx.measureText(label).width + 10;
 
296
  ctx.fillStyle = col;
297
- ctx.fillRect(rx, ry - 20, tw, 20);
298
- ctx.fillStyle = 'white'; ctx.font = 'bold 12px Arial';
299
- ctx.fillText(label, rx + 5, ry - 5);
300
  });
301
  }
302
 
303
- if (uniqueDetectionResults) {
304
- uniqueDetectionResults.forEach(({ box, score }, i) => {
305
  const [x1, y1, x2, y2] = box;
306
  const rx = x1*sx, ry = y1*sy, rw = (x2-x1)*sx, rh = (y2-y1)*sy;
307
  ctx.strokeStyle = '#3b82f6'; ctx.lineWidth = 2;
308
  ctx.strokeRect(rx, ry, rw, rh);
309
  const s = `${(score*100).toFixed(0)}%`;
310
- ctx.fillStyle = '#3b82f6'; ctx.fillRect(rx, ry - 20, 40, 20);
311
- ctx.fillStyle = 'white'; ctx.font = '12px Arial';
312
- ctx.fillText(s, rx + 4, ry - 5);
313
  });
314
  }
315
- }, [pipelineResults, rawBoxes, isPipeline]);
316
 
317
  const uniquePipelineResults = pipelineResults ? applyNMS(pipelineResults, 0.40) : null;
318
  const uniqueDetectionResults = rawBoxes ? applyNMS(rawBoxes.boxes.map((box, i) => ({ box, score: rawBoxes.scores[i] })), 0.40) : null;
 
 
319
  const detectedFDIs = uniquePipelineResults ? uniquePipelineResults.filter(r => r.fdi > 0).map(r => r.fdi) : [];
320
- const totalDetected = uniquePipelineResults ? uniquePipelineResults.length : (uniqueDetectionResults ? uniqueDetectionResults.length : 0);
321
- const hasResults = uniquePipelineResults || uniqueDetectionResults;
322
 
323
  return (
324
- <div className="min-h-screen flex flex-col bg-zinc-950 font-sans text-slate-200">
325
-
326
- {/* ── Header ─────────────────────────────────────────────────────────── */}
327
- <header className="px-4 sm:px-8 py-3 border-b border-zinc-800/50 flex items-center gap-3">
328
- <Activity size={20} className="text-blue-500 flex-shrink-0" />
329
- <h1 className="text-base sm:text-lg font-bold bg-gradient-to-r from-blue-400 to-indigo-400 bg-clip-text text-transparent">
330
- Tableau de bord IA ToothMap
331
- </h1>
332
- </header>
333
-
334
- {/* ── Mode Selection Bar ─────────────────────────────────────────────── */}
335
- <div className="border-b border-zinc-800/50 px-3 sm:px-6 py-3 bg-zinc-900/30">
336
- <div className="flex flex-wrap gap-2 justify-center">
337
- {MODES.map(m => (
338
- <div key={m.id} className="relative"
339
- onMouseEnter={() => setHoveredMode(m.id)}
340
- onMouseLeave={() => setHoveredMode(null)}>
341
  <button
342
- onClick={() => { setMode(m.id); setPipelineResults(null); setRawBoxes(null); setMaskSrc(null); }}
343
- className={`px-3 sm:px-4 py-2 rounded-xl border text-xs sm:text-sm font-semibold transition-all duration-200 whitespace-nowrap ${
344
- mode === m.id ? m.active : m.inactive
 
 
345
  }`}
346
  >
347
- <span className="mr-1.5">{m.emoji}</span>{m.label}
348
  </button>
349
- {/* Hover tooltip */}
350
- {hoveredMode === m.id && (
351
- <div className="absolute top-full left-1/2 -translate-x-1/2 mt-2 z-50 w-56 bg-zinc-800 border border-zinc-700/60 rounded-xl p-3 shadow-2xl pointer-events-none">
352
- <p className="text-xs font-bold text-white mb-1">{m.emoji} {m.label}</p>
353
- <p className="text-xs text-zinc-300 mb-2">{m.desc}</p>
354
- <div className="flex flex-wrap gap-1">
355
- {m.models.split(' + ').map((mdl, i) => (
356
- <span key={i} className={`text-[10px] px-2 py-0.5 rounded-full bg-gradient-to-r ${m.color} text-white font-medium`}>
357
- {mdl}
358
- </span>
359
- ))}
360
- </div>
361
  </div>
362
- )}
363
  </div>
364
  ))}
365
  </div>
366
- </div>
367
-
368
- {/* ── Main Content ───────────────────────────────────────────────────── */}
369
- <main className="flex-1 flex flex-col items-center px-3 sm:px-6 pt-16 pb-6 gap-5 overflow-auto">
370
 
371
- {/* Image Upload zone */}
372
- <div
373
- className={`w-full max-w-3xl border-2 border-dashed rounded-2xl transition-all duration-200 cursor-pointer group
374
- ${imagePreview
375
- ? 'border-zinc-700/50 bg-zinc-900/30 p-2'
376
- : 'border-zinc-700/50 hover:border-blue-500/50 bg-zinc-900/20 p-8 flex flex-col items-center justify-center min-h-[220px]'
377
- }`}
378
- onClick={() => !imagePreview && document.getElementById('fu').click()}
379
- onDragOver={e => e.preventDefault()}
380
- onDrop={e => { e.preventDefault(); if (e.dataTransfer.files[0]) handleFileSelect(e.dataTransfer.files[0]); }}
381
- >
382
- {imagePreview ? (
383
- <div className="relative inline-block w-full">
384
- <img ref={imgRef} src={imagePreview} alt="X-ray"
385
- className="w-full max-h-[55vh] object-contain rounded-xl block bg-black" />
386
- <canvas ref={canvasRef} className="absolute top-0 left-0 w-full h-full pointer-events-none rounded-xl" />
387
- {maskSrc && (
388
- <img src={maskSrc} className="absolute top-0 left-0 w-full h-full object-fill pointer-events-none opacity-50 mix-blend-screen rounded-xl" alt="Mask" />
389
- )}
390
- {/* Replace image button */}
391
- <button
392
- onClick={e => { e.stopPropagation(); document.getElementById('fu').click(); }}
393
- className="absolute top-2 right-2 bg-zinc-800/80 hover:bg-zinc-700 text-zinc-300 rounded-lg px-2 py-1 text-xs border border-zinc-700/50 backdrop-blur-sm"
394
- >
395
- Remplacer
396
- </button>
397
- </div>
398
- ) : (
399
- <>
400
- <UploadCloud size={48} className="text-zinc-600 group-hover:text-blue-400 mb-3 transition-colors" />
401
- <p className="text-base font-medium text-zinc-400 group-hover:text-zinc-200 transition-colors">
402
- Glissez-déposez une radiographie ici
403
- </p>
404
- <p className="text-xs text-zinc-600 mt-1">ou cliquez pour parcourir</p>
405
- </>
406
- )}
407
- <input type="file" id="fu" className="hidden" accept="image/*"
408
- onChange={e => handleFileSelect(e.target.files[0])} />
409
  </div>
 
410
 
411
- {/* Analyze Button — full width under uploader */}
412
- <div className="w-full max-w-3xl">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
413
  <button
414
  onClick={handleAPI}
415
  disabled={!file || loading}
416
- className={`w-full py-3.5 rounded-xl font-bold text-base flex items-center justify-center gap-2 transition-all duration-200 shadow-lg ${
417
  !file
418
- ? 'bg-zinc-800 text-zinc-500 cursor-not-allowed'
419
  : loading
420
  ? 'bg-blue-700 text-white cursor-wait'
421
- : 'bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 text-white shadow-blue-900/40'
422
  }`}
423
  >
424
- {loading ? <Loader2 size={20} className="animate-spin" /> : <ScanLine size={20} />}
425
- {loading ? 'Analyse en cours…' : `Lancer l'analyse IA — ${currentMode?.label}`}
426
  </button>
427
- </div>
428
 
429
- {/* ── Results ───────────────────────────────────────────────────────── */}
430
- {hasResults && (
431
- <div className="w-full max-w-3xl flex flex-col gap-4 animate-fade-in">
432
-
433
- {/* Stats row */}
434
- <div className="flex flex-wrap gap-3 justify-center">
435
- <div className="bg-zinc-900/60 border border-zinc-800/60 rounded-xl px-5 py-3 text-center">
436
- <p className="text-3xl font-black text-white">{totalDetected}</p>
437
- <p className="text-xs text-zinc-500 mt-0.5">Dents Détectées</p>
438
  </div>
439
  {isPipeline && (
440
  <>
441
- <div className="bg-zinc-900/60 border border-zinc-800/60 rounded-xl px-5 py-3 text-center">
442
- <p className="text-3xl font-black text-white">{detectedFDIs.length}</p>
443
- <p className="text-xs text-zinc-500 mt-0.5">Étiquettes FDI</p>
444
  </div>
445
- <div className="bg-zinc-900/60 border border-zinc-800/60 rounded-xl px-5 py-3 text-center">
446
- <p className="text-3xl font-black text-amber-400">{32 - detectedFDIs.length}</p>
447
- <p className="text-xs text-zinc-500 mt-0.5">Absentes</p>
448
  </div>
449
  </>
450
  )}
451
- {uniquePipelineResults && (
452
- <div className="bg-zinc-900/60 border border-zinc-800/60 rounded-xl px-5 py-3 text-center">
453
- <p className="text-3xl font-black text-emerald-400">
454
- {(uniquePipelineResults.reduce((s, r) => s + (r.confidence ?? r.score ?? 0), 0) / uniquePipelineResults.length * 100).toFixed(0)}%
455
- </p>
456
- <p className="text-xs text-zinc-500 mt-0.5">Confiance Moyenne</p>
457
- </div>
458
- )}
 
 
459
  </div>
 
460
 
461
- {/* FDI Legend — only for pipeline/classification modes */}
462
- {isPipeline && (
463
- <div className="flex flex-wrap justify-center gap-4 text-xs">
464
- {['Q1 Supérieur Droit', 'Q2 Supérieur Gauche', 'Q3 Inférieur Gauche', 'Q4 Inférieur Droit'].map((name, i) => (
465
- <div key={i} className="flex items-center gap-1.5 text-zinc-400">
466
- <span className="w-3 h-3 rounded-sm flex-shrink-0" style={{ background: QUADRANT_COLOURS[i] }} />
467
- {name}
468
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
469
  ))}
470
  </div>
471
  )}
 
472
 
473
- {/* Detection results list (for non-pipeline modes) */}
474
- {uniqueDetectionResults && (
475
- <div className="bg-zinc-900/60 border border-zinc-800/60 rounded-xl p-4">
476
- <p className="text-xs font-bold text-zinc-400 uppercase tracking-widest mb-3">Résultats de détection</p>
477
- <div className="flex flex-wrap gap-2">
478
- {uniqueDetectionResults.map((r, i) => (
479
- <span key={i} className="px-2 py-1 rounded-lg bg-blue-500/10 border border-blue-500/30 text-blue-300 text-xs font-medium">
480
- Dent n°{i+1} — {(r.score*100).toFixed(0)}%
481
- </span>
482
  ))}
 
 
 
483
  </div>
484
- </div>
485
- )}
486
-
487
- {/* Pipeline detection brief list */}
488
- {uniquePipelineResults && (
489
- <div className="bg-zinc-900/60 border border-zinc-800/60 rounded-xl p-4">
490
- <p className="text-xs font-bold text-zinc-400 uppercase tracking-widest mb-3">
491
- {isPipeline ? 'Résultats de classification FDI' : 'Résultats de segmentation'}
492
- </p>
493
- <div className="flex flex-wrap gap-2">
494
- {uniquePipelineResults.map((r, i) => {
495
- const col = getColour(r.fdi);
496
- return (
497
- <span key={i}
498
- style={{ borderColor: col+'55', color: col, background: col+'15' }}
499
- className="px-2 py-1 rounded-lg border text-xs font-medium"
500
- >
501
- {isPipeline && r.fdi > 0 ? `FDI ${r.fdi}` : `Dent n°${i+1}`}
502
- {' '}— {((r.confidence ?? r.score) * 100).toFixed(0)}%
503
- </span>
504
- );
505
- })}
506
  </div>
507
  </div>
508
- )}
 
 
 
 
 
509
 
510
- {/* Odontogram — ONLY for full pipeline modes (requires FDI classification) */}
511
- {isPipeline && detectedFDIs.length > 0 && (
512
- <Odontogram detectedFDIs={detectedFDIs} />
513
- )}
514
- </div>
515
- )}
516
  </main>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
517
  </div>
518
  );
519
  }
 
1
  import React, { useState, useRef, useEffect } from 'react';
2
  import axios from 'axios';
3
+ import { UploadCloud, Activity, Loader2, ScanLine, Search } from 'lucide-react';
4
+
5
+ const API_BASE = "";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
 
7
  const QUADRANT_COLOURS = ['#3b82f6', '#22c55e', '#f59e0b', '#ef4444'];
8
  const UPPER_TEETH = [18, 17, 16, 15, 14, 13, 12, 11, 21, 22, 23, 24, 25, 26, 27, 28];
 
29
  }
30
 
31
  function applyNMS(results, iouThreshold = 0.35) {
32
+ const sorted = [...results].sort((a, b) => (b.confidence ?? b.score) - (a.confidence ?? a.score));
33
  const keep = [];
34
  for (const item of sorted) {
35
  if (!keep.some(k => computeIoU(item.box, k.box) > iouThreshold)) keep.push(item);
 
43
  return QUADRANT_COLOURS[Math.min(quad, 3)] ?? '#a855f7';
44
  }
45
 
46
+ // ── ToothCrop Component (For the Right Panel) ─────────────────────────────────
47
+ const ToothCrop = ({ imageSrc, box, mask_base64, score, fdi, isPipeline, index }) => {
48
+ const canvasRef = useRef(null);
49
+
50
+ useEffect(() => {
51
+ if (!canvasRef.current || !imageSrc || !box) return;
52
+ const [x1, y1, x2, y2] = box;
53
+ const w = x2 - x1;
54
+ const h = y2 - y1;
55
+ if (w <= 0 || h <= 0) return;
56
+
57
+ const img = new Image();
58
+ img.src = imageSrc;
59
+ img.onload = () => {
60
+ const cvs = canvasRef.current;
61
+ const ctx = cvs.getContext('2d');
62
+ cvs.width = w;
63
+ cvs.height = h;
64
+ ctx.clearRect(0, 0, w, h);
65
+ ctx.drawImage(img, x1, y1, w, h, 0, 0, w, h);
66
+
67
+ if (mask_base64) {
68
+ const maskImg = new Image();
69
+ maskImg.onload = () => {
70
+ const off = document.createElement('canvas');
71
+ off.width = w; off.height = h;
72
+ const octx = off.getContext('2d');
73
+ const col = isPipeline && fdi > 0 ? getColour(fdi) : '#22d3ee';
74
+ octx.fillStyle = col;
75
+ octx.fillRect(0, 0, w, h);
76
+ octx.globalCompositeOperation = 'destination-in';
77
+ octx.drawImage(maskImg, 0, 0, w, h);
78
+ ctx.save();
79
+ ctx.globalAlpha = 0.5;
80
+ ctx.drawImage(off, 0, 0);
81
+ ctx.restore();
82
+ };
83
+ maskImg.src = `data:image/png;base64,${mask_base64}`;
84
+ }
85
+ };
86
+ }, [imageSrc, box, mask_base64, fdi, isPipeline]);
87
+
88
+ const col = isPipeline && fdi > 0 ? getColour(fdi) : '#3b82f6';
89
+
90
+ return (
91
+ <div className="bg-[#111113] border rounded-lg overflow-hidden flex flex-col" style={{ borderColor: col + '40' }}>
92
+ <div className="p-1 px-1.5 flex justify-between items-center text-[9px] font-bold" style={{ backgroundColor: col + '15', color: col }}>
93
+ <span>{isPipeline && fdi > 0 ? `Dent n°${fdi}` : `Dent n°${index + 1}`}</span>
94
+ <span>{(score * 100).toFixed(0)}%</span>
95
+ </div>
96
+ <div className="flex-1 flex items-center justify-center p-1 relative h-16 sm:h-20 bg-black">
97
+ <canvas ref={canvasRef} className="max-w-full max-h-full object-contain" />
98
+ </div>
99
+ {/* Metrics Area */}
100
+ <div className="p-1.5 bg-[#1a1a1f] border-t border-white/5 flex flex-col gap-0.5">
101
+ <span className="text-[8px] text-zinc-400 font-mono">Area: {(((box[2]-box[0]) * (box[3]-box[1])) / 100).toFixed(1)} mm²</span>
102
+ <div className="w-full bg-zinc-800 rounded-full h-1 mt-0.5">
103
+ <div className="h-full rounded-full" style={{ width: `${score * 100}%`, backgroundColor: col }}></div>
104
+ </div>
105
+ <span className="text-[7px] text-zinc-500 uppercase">Confiance</span>
106
+ </div>
107
+ </div>
108
+ );
109
+ };
110
+
111
  // ── Odontogram Component ──────────────────────────────────────────────────────
112
  function Odontogram({ detectedFDIs }) {
113
  const detected = new Set(detectedFDIs);
 
119
  <div
120
  title={present ? `FDI ${fdi} — détectée` : `FDI ${fdi} — non détectée`}
121
  style={present ? {
122
+ border: `1px solid ${style.border}`,
123
  background: style.bg,
124
  color: style.text,
125
+ boxShadow: `0 0 6px ${style.border}40`,
126
  } : {
127
+ border: '1px solid rgba(113,113,122,0.3)',
128
+ background: 'rgba(39,39,42,0.4)',
129
  color: 'transparent',
130
  position: 'relative',
131
  }}
132
+ className={`flex items-center justify-center font-bold text-[9px] sm:text-[11px] rounded-sm transition-all select-none
133
+ ${isUpper ? 'rounded-t-lg' : 'rounded-b-lg'}
134
+ w-5 h-7 sm:w-6 sm:h-8 lg:w-7 lg:h-9`}
135
  >
136
  {present ? (
137
  <span>{fdi}</span>
138
  ) : (
139
  <>
140
+ <svg viewBox="0 0 24 24" className="w-3 h-3 absolute" fill="none" stroke="#ef4444" strokeWidth="2.5" strokeLinecap="round">
 
141
  <line x1="6" y1="6" x2="18" y2="18" />
142
  <line x1="18" y1="6" x2="6" y2="18" />
143
  </svg>
144
+ <span className="text-zinc-600 text-[7px] absolute bottom-0.5">{fdi}</span>
145
  </>
146
  )}
147
  </div>
 
149
  };
150
 
151
  return (
152
+ <div className="w-full bg-[#1a1a1f] rounded-lg p-2 sm:p-3 border border-white/5">
153
+ <p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest mb-2 text-center">
154
  Odontogramme FDI — Schéma Dentaire
155
  </p>
156
 
157
+ <div className="flex justify-center flex-wrap gap-0.5 sm:gap-1 mb-1.5">
 
158
  {UPPER_TEETH.map(t => <ToothCell key={t} fdi={t} isUpper={true} />)}
159
  </div>
160
 
161
+ <div className="flex justify-center flex-wrap gap-0.5 sm:gap-1 mt-1.5">
 
 
 
 
 
 
 
 
 
 
162
  {LOWER_TEETH.map(t => <ToothCell key={t} fdi={t} isUpper={false} />)}
163
  </div>
164
 
165
+ <div className="flex flex-wrap justify-center gap-4 sm:gap-6 mt-3 text-[9px] text-zinc-400">
166
+ <div className="flex items-center gap-1.5">
 
167
  <div className="flex -space-x-1">
168
  {['bg-blue-500','bg-green-500','bg-amber-500','bg-red-500'].map((c,i) => (
169
+ <span key={i} className={`w-2 h-2 rounded-full ${c} border border-zinc-900`} />
170
  ))}
171
  </div>
172
  <span>Détectée (Q1–Q4)</span>
173
  </div>
174
+ <div className="flex items-center gap-1.5">
175
+ <svg viewBox="0 0 16 16" className="w-2.5 h-2.5" fill="none" stroke="#ef4444" strokeWidth="2.5" strokeLinecap="round">
176
  <line x1="3" y1="3" x2="13" y2="13" />
177
  <line x1="13" y1="3" x2="3" y2="13" />
178
  </svg>
 
187
  export default function App() {
188
  const [file, setFile] = useState(null);
189
  const [imagePreview, setImagePreview] = useState(null);
190
+
191
+ // Tasks: 'diagnostic', 'segmentation', 'charting'
192
+ const [task, setTask] = useState('charting');
193
+ // Versions: 'v1' (YOLO), 'v2' (FasterRCNN)
194
+ const [version, setVersion] = useState('v1');
195
+
196
  const [pipelineResults, setPipelineResults] = useState(null);
197
  const [rawBoxes, setRawBoxes] = useState(null);
198
  const [maskSrc, setMaskSrc] = useState(null);
199
  const [loading, setLoading] = useState(false);
 
200
  const imgRef = useRef(null);
201
  const canvasRef = useRef(null);
202
 
203
+ const modeStr = (() => {
204
+ if (task === 'diagnostic') return version === 'v1' ? 'yolo' : 'frcnn';
205
+ if (task === 'segmentation') return version === 'v1' ? 'seg_yolo' : 'seg_frcnn';
206
+ if (task === 'charting') return version === 'v1' ? 'pipeline_yolo' : 'pipeline_frcnn';
207
+ })();
208
+
209
+ const isPipeline = task === 'charting';
210
 
211
  const handleFileSelect = (f) => {
212
  setFile(f);
 
223
  const fd = new FormData();
224
  fd.append("file", file);
225
  try {
226
+ if (modeStr.startsWith('pipeline') || modeStr.startsWith('seg')) {
227
+ const endpoint = modeStr.includes('yolo') ? 'yolo' : 'frcnn';
228
+ const r = await axios.post(`${API_BASE}/api/pipeline/${endpoint}`, fd);
229
  setPipelineResults(r.data.results);
230
+ } else {
231
+ const endpoint = modeStr.includes('yolo') ? 'yolo' : 'frcnn';
232
+ const r = await axios.post(`${API_BASE}/api/detect/${endpoint}`, fd);
 
 
 
 
 
233
  setRawBoxes(r.data);
234
  }
235
  } catch (e) {
 
255
  if (pipelineResults) {
256
  const showFDI = isPipeline;
257
  const uniqueResults = applyNMS(pipelineResults, 0.40);
258
+ uniqueResults.forEach(({ box, score, confidence, fdi, mask_base64 }) => {
259
  const [x1, y1, x2, y2] = box;
260
  const rx = x1*sx, ry = y1*sy, rw = (x2-x1)*sx, rh = (y2-y1)*sy;
261
  const col = showFDI ? getColour(fdi) : '#22d3ee';
262
+ if (mask_base64 && task !== 'diagnostic') {
263
  const maskImg = new Image();
264
  maskImg.onload = () => {
265
  const off = document.createElement('canvas');
 
278
  }
279
  ctx.strokeStyle = col; ctx.lineWidth = 2;
280
  ctx.strokeRect(rx, ry, rw, rh);
281
+ const s = confidence ?? score;
282
+ const label = showFDI ? (fdi > 0 ? `FDI ${fdi}` : `${(s*100).toFixed(0)}%`) : `${(s*100).toFixed(0)}%`;
283
+ const tw = ctx.measureText(label).width + 8;
284
  ctx.fillStyle = col;
285
+ ctx.fillRect(rx, ry - 16, tw, 16);
286
+ ctx.fillStyle = 'white'; ctx.font = 'bold 10px Arial';
287
+ ctx.fillText(label, rx + 4, ry - 4);
288
  });
289
  }
290
 
291
+ if (uniqueDetectionResults && !pipelineResults) {
292
+ uniqueDetectionResults.forEach(({ box, score }) => {
293
  const [x1, y1, x2, y2] = box;
294
  const rx = x1*sx, ry = y1*sy, rw = (x2-x1)*sx, rh = (y2-y1)*sy;
295
  ctx.strokeStyle = '#3b82f6'; ctx.lineWidth = 2;
296
  ctx.strokeRect(rx, ry, rw, rh);
297
  const s = `${(score*100).toFixed(0)}%`;
298
+ ctx.fillStyle = '#3b82f6'; ctx.fillRect(rx, ry - 16, 30, 16);
299
+ ctx.fillStyle = 'white'; ctx.font = '10px Arial';
300
+ ctx.fillText(s, rx + 4, ry - 4);
301
  });
302
  }
303
+ }, [pipelineResults, rawBoxes, isPipeline, task]);
304
 
305
  const uniquePipelineResults = pipelineResults ? applyNMS(pipelineResults, 0.40) : null;
306
  const uniqueDetectionResults = rawBoxes ? applyNMS(rawBoxes.boxes.map((box, i) => ({ box, score: rawBoxes.scores[i] })), 0.40) : null;
307
+ const combinedResults = uniquePipelineResults || uniqueDetectionResults || [];
308
+
309
  const detectedFDIs = uniquePipelineResults ? uniquePipelineResults.filter(r => r.fdi > 0).map(r => r.fdi) : [];
310
+ const totalDetected = combinedResults.length;
311
+ const hasResults = combinedResults.length > 0;
312
 
313
  return (
314
+ <div className="h-screen w-screen flex flex-col bg-[#0a0a0c] font-sans text-slate-200 overflow-hidden">
315
+
316
+ {/* ── Header Area ──────────────────────────────────────────────────────── */}
317
+ <div className="flex-none pt-4 pb-2 flex flex-col items-center justify-center bg-[#111113] border-b border-white/5 shadow-md z-10 relative">
318
+ <div className="absolute left-4 top-4 flex items-center gap-2 opacity-50">
319
+ <Activity size={18} className="text-blue-500" />
320
+ <span className="text-xs font-bold tracking-widest uppercase">ToothMap IA</span>
321
+ </div>
322
+
323
+ {/* Task Tabs */}
324
+ <div className="flex gap-1 bg-[#1a1a1f] p-1 rounded-xl border border-white/5 mb-3">
325
+ {[
326
+ { id: 'diagnostic', label: 'Diagnostic Rapide', icon: <Search size={14} />, desc: 'v1: YOLOv8s | v2: Faster R-CNN' },
327
+ { id: 'segmentation', label: 'Segmentation & Mesure', icon: <Activity size={14} />, desc: 'v1: YOLOv8s + U-Net | v2: Faster R-CNN + U-Net' },
328
+ { id: 'charting', label: 'Charting Détaillé', icon: <ScanLine size={14} />, desc: 'v1: YOLOv8s + ConvNeXt-T + U-Net | v2: Faster R-CNN + ConvNeXt-T + U-Net' }
329
+ ].map(t => (
330
+ <div key={t.id} className="relative flex group">
331
  <button
332
+ onClick={() => { setTask(t.id); setPipelineResults(null); setRawBoxes(null); setMaskSrc(null); }}
333
+ className={`flex items-center gap-2 px-4 py-1.5 rounded-lg text-xs font-semibold transition-all duration-200 ${
334
+ task === t.id
335
+ ? 'bg-blue-600 border border-blue-500/50 text-white shadow-lg shadow-blue-500/20'
336
+ : 'bg-transparent border border-transparent text-slate-400 hover:text-slate-200 hover:bg-white/5'
337
  }`}
338
  >
339
+ {t.icon} {t.label}
340
  </button>
341
+
342
+ {/* Tooltip via pure CSS group-hover */}
343
+ <div className="absolute top-full left-1/2 -translate-x-1/2 mt-3 z-[9999] w-[260px] bg-[#1a1a1f] border border-zinc-700 rounded-lg p-3 shadow-2xl pointer-events-none opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200">
344
+ <p className="text-xs font-bold text-white mb-2 flex items-center gap-1.5">{t.icon} {t.label}</p>
345
+ <div className="flex flex-col gap-1.5">
346
+ {t.desc.split(' | ').map((d, i) => (
347
+ <div key={i} className="text-[10px] text-zinc-300 font-medium bg-zinc-800/50 px-2 py-1.5 rounded border border-white/5">
348
+ {d}
349
+ </div>
350
+ ))}
 
 
351
  </div>
352
+ </div>
353
  </div>
354
  ))}
355
  </div>
 
 
 
 
356
 
357
+ {/* Version Radio */}
358
+ <div className="flex items-center justify-center gap-8">
359
+ {[
360
+ { id: 'v1', label: 'Version AI v1 (Rapide)' },
361
+ { id: 'v2', label: 'Version AI v2 (Précis)' }
362
+ ].map(v => (
363
+ <label key={v.id} className="flex items-center gap-2 text-[11px] text-slate-300 cursor-pointer font-medium hover:text-white transition-colors">
364
+ <input
365
+ type="radio"
366
+ name="version"
367
+ checked={version === v.id}
368
+ onChange={() => { setVersion(v.id); setPipelineResults(null); setRawBoxes(null); setMaskSrc(null); }}
369
+ className="hidden"
370
+ />
371
+ <div className={`w-3.5 h-3.5 rounded-full border flex items-center justify-center transition-all ${
372
+ version === v.id ? 'border-blue-500 bg-blue-500/10' : 'border-slate-600 bg-[#1a1a1f]'
373
+ }`}>
374
+ {version === v.id && <div className="w-1.5 h-1.5 bg-blue-500 rounded-full" />}
375
+ </div>
376
+ {v.label}
377
+ </label>
378
+ ))}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
379
  </div>
380
+ </div>
381
 
382
+ {/* ── Main Content Split View ────────────────────────────────────────── */}
383
+ <main className="flex-1 flex flex-row overflow-hidden p-3 gap-3">
384
+
385
+ {/* LEFT COLUMN */}
386
+ <div className="w-[55%] flex flex-col gap-3 h-full overflow-y-auto pr-1 custom-scrollbar">
387
+
388
+ {/* Image Upload/Preview */}
389
+ <div
390
+ className={`w-full min-h-[250px] border border-dashed rounded-xl transition-all duration-200 cursor-pointer group flex flex-col items-center justify-center relative overflow-hidden
391
+ ${imagePreview ? 'border-zinc-700/50 bg-[#111113]' : 'border-zinc-700/50 hover:border-blue-500/50 bg-[#111113]'}`}
392
+ onClick={() => !imagePreview && document.getElementById('fu').click()}
393
+ onDragOver={e => e.preventDefault()}
394
+ onDrop={e => { e.preventDefault(); if (e.dataTransfer.files[0]) handleFileSelect(e.dataTransfer.files[0]); }}
395
+ >
396
+ {imagePreview ? (
397
+ <div className="w-full h-full flex items-center justify-center p-2">
398
+ <div className="relative flex items-center justify-center max-w-full max-h-[40vh]">
399
+ <img ref={imgRef} src={imagePreview} alt="X-ray" className="max-w-full max-h-[40vh] object-contain rounded-lg block" />
400
+ <canvas ref={canvasRef} className="absolute top-0 left-0 w-full h-full pointer-events-none" />
401
+ </div>
402
+ <button
403
+ onClick={e => { e.stopPropagation(); document.getElementById('fu').click(); }}
404
+ className="absolute top-3 right-3 bg-zinc-800/80 hover:bg-zinc-700 text-zinc-300 rounded-md px-2.5 py-1 text-[10px] border border-zinc-700/50 font-medium transition-colors"
405
+ >
406
+ Remplacer
407
+ </button>
408
+ </div>
409
+ ) : (
410
+ <>
411
+ <UploadCloud size={36} className="text-zinc-600 group-hover:text-blue-400 mb-2 transition-colors" />
412
+ <p className="text-sm font-medium text-zinc-400 group-hover:text-zinc-200 transition-colors">
413
+ Glissez-déposez une radiographie
414
+ </p>
415
+ <p className="text-[10px] text-zinc-600 mt-1">ou cliquez pour parcourir</p>
416
+ </>
417
+ )}
418
+ <input type="file" id="fu" className="hidden" accept="image/*" onChange={e => handleFileSelect(e.target.files[0])} />
419
+ </div>
420
+
421
+ {/* Analyze Button */}
422
  <button
423
  onClick={handleAPI}
424
  disabled={!file || loading}
425
+ className={`w-full py-2.5 rounded-xl font-bold text-[13px] flex items-center justify-center gap-2 transition-all duration-200 shadow-md ${
426
  !file
427
+ ? 'bg-[#1a1a1f] text-zinc-600 border border-white/5 cursor-not-allowed'
428
  : loading
429
  ? 'bg-blue-700 text-white cursor-wait'
430
+ : 'bg-blue-600 hover:bg-blue-500 text-white border border-blue-500'
431
  }`}
432
  >
433
+ {loading ? <Loader2 size={16} className="animate-spin" /> : <ScanLine size={16} />}
434
+ {loading ? 'Analyse en cours…' : `Lancer l'analyse IA — ${task === 'diagnostic' ? 'Détection' : task === 'segmentation' ? 'Segmentation' : 'Analyse complète'}`}
435
  </button>
 
436
 
437
+ {/* Stats Row */}
438
+ {hasResults && (
439
+ <div className="flex gap-2 shrink-0">
440
+ <div className="flex-1 bg-[#1a1a1f] border border-white/5 rounded-xl flex flex-col items-center justify-center py-2.5 shadow-sm">
441
+ <p className="text-2xl font-black text-white">{totalDetected}</p>
442
+ <p className="text-[9px] text-zinc-500 uppercase font-semibold mt-0.5">Dents Détectées</p>
 
 
 
443
  </div>
444
  {isPipeline && (
445
  <>
446
+ <div className="flex-1 bg-[#1a1a1f] border border-white/5 rounded-xl flex flex-col items-center justify-center py-2.5 shadow-sm">
447
+ <p className="text-2xl font-black text-white">{detectedFDIs.length}</p>
448
+ <p className="text-[9px] text-zinc-500 uppercase font-semibold mt-0.5">Étiquettes FDI</p>
449
  </div>
450
+ <div className="flex-1 bg-[#1a1a1f] border border-white/5 rounded-xl flex flex-col items-center justify-center py-2.5 shadow-sm">
451
+ <p className="text-2xl font-black text-amber-400">{32 - detectedFDIs.length}</p>
452
+ <p className="text-[9px] text-zinc-500 uppercase font-semibold mt-0.5">Absentes</p>
453
  </div>
454
  </>
455
  )}
456
+ <div className="flex-1 bg-[#1a1a1f] border border-white/5 rounded-xl flex flex-col items-center justify-center py-2.5 shadow-sm">
457
+ <p className="text-2xl font-black text-emerald-400">
458
+ {uniquePipelineResults
459
+ ? (uniquePipelineResults.reduce((s, r) => s + (r.confidence ?? r.score ?? 0), 0) / uniquePipelineResults.length * 100).toFixed(0)
460
+ : uniqueDetectionResults
461
+ ? (uniqueDetectionResults.reduce((s, r) => s + r.score, 0) / uniqueDetectionResults.length * 100).toFixed(0)
462
+ : 0}%
463
+ </p>
464
+ <p className="text-[9px] text-zinc-500 uppercase font-semibold mt-0.5">Confiance Moyenne</p>
465
+ </div>
466
  </div>
467
+ )}
468
 
469
+ {/* FDI Classification Results */}
470
+ {hasResults && isPipeline && detectedFDIs.length > 0 && (
471
+ <div className="shrink-0 bg-[#1a1a1f] border border-white/5 rounded-xl p-3 shadow-sm">
472
+ <p className="text-[9px] font-bold text-slate-400 uppercase tracking-widest mb-2 text-center">
473
+ RÉSULTATS DE CLASSIFICATION FDI
474
+ </p>
475
+ <div className="grid grid-cols-2 md:grid-cols-4 gap-1.5">
476
+ {uniquePipelineResults.filter(r => r.fdi > 0).map((r, i) => {
477
+ const col = getColour(r.fdi);
478
+ return (
479
+ <div key={i} className="flex justify-between items-center px-1.5 py-1 border rounded bg-black/20" style={{ borderColor: col+'40' }}>
480
+ <span className="text-[9px] font-bold" style={{ color: col }}>FDI {r.fdi}</span>
481
+ <span className="text-[9px] text-slate-300 font-mono">{(r.confidence*100).toFixed(0)}%</span>
482
+ </div>
483
+ );
484
+ })}
485
+ </div>
486
+ </div>
487
+ )}
488
+
489
+ {/* Odontogram */}
490
+ {hasResults && isPipeline && detectedFDIs.length > 0 && (
491
+ <div className="shrink-0 mb-4">
492
+ <Odontogram detectedFDIs={detectedFDIs} />
493
+ </div>
494
+ )}
495
+ </div>
496
+
497
+ {/* RIGHT COLUMN */}
498
+ <div className="w-[45%] bg-[#111113] rounded-xl border border-white/5 p-3 flex flex-col overflow-hidden shadow-inner">
499
+ <h2 className="text-[11px] font-bold text-slate-300 mb-3 border-b border-white/5 pb-2">
500
+ Détails des Dents & Résultats {task === 'segmentation' || task === 'charting' ? 'de Segmentation' : 'de Détection'}
501
+ </h2>
502
+
503
+ <div className="flex-1 overflow-y-auto pr-2 custom-scrollbar">
504
+ {!hasResults ? (
505
+ <div className="h-full flex flex-col items-center justify-center text-zinc-600">
506
+ <Activity size={32} className="mb-2 opacity-20" />
507
+ <p className="text-xs">Aucun résultat à afficher.</p>
508
+ <p className="text-[10px]">Lancez l'analyse pour voir les détails ici.</p>
509
+ </div>
510
+ ) : (
511
+ <div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-2">
512
+ {combinedResults.map((r, i) => (
513
+ <ToothCrop
514
+ key={i}
515
+ imageSrc={imagePreview}
516
+ box={r.box}
517
+ mask_base64={r.mask_base64}
518
+ score={r.confidence ?? r.score}
519
+ fdi={r.fdi}
520
+ isPipeline={isPipeline}
521
+ index={i}
522
+ />
523
  ))}
524
  </div>
525
  )}
526
+ </div>
527
 
528
+ {/* Analyse Métrique Bottom Section */}
529
+ {hasResults && (
530
+ <div className="mt-3 shrink-0 bg-[#1a1a1f] border border-white/5 rounded-xl p-3 shadow-sm flex flex-col">
531
+ <h3 className="text-[11px] font-bold text-slate-300 mb-2">Analyse Métrique</h3>
532
+ <div className="flex gap-4 items-end h-16">
533
+ <div className="flex items-end gap-1.5 h-full flex-1 border-b border-l border-zinc-700 pb-1 pl-1">
534
+ {[95, 85, 40, 20, 10, 5].map((h, i) => (
535
+ <div key={i} className="w-5 bg-[#22d3ee] hover:bg-[#06b6d4] rounded-t-sm transition-all" style={{ height: `${h}%` }}></div>
 
536
  ))}
537
+ <div className="w-full text-center text-[7px] text-zinc-600 absolute bottom-[-10px] left-0">
538
+ 90%+ 80-89% 70-79% &lt;70%
539
+ </div>
540
  </div>
541
+ <div className="text-[8px] text-zinc-400 flex flex-col justify-center gap-0.5 min-w-[100px]">
542
+ <div className="flex items-center gap-1"><span className="w-2 h-2 bg-[#22d3ee]"></span> 90%+ confiance: {combinedResults.filter(r => (r.confidence??r.score) >= 0.9).length}</div>
543
+ <div className="flex items-center gap-1"><span className="w-2 h-2 bg-[#22d3ee]" style={{opacity: 0.8}}></span> 80-89% dentes: {combinedResults.filter(r => (r.confidence??r.score) >= 0.8 && (r.confidence??r.score) < 0.9).length}</div>
544
+ <div className="flex items-center gap-1"><span className="w-2 h-2 bg-[#22d3ee]" style={{opacity: 0.6}}></span> 70-79% dentes: {combinedResults.filter(r => (r.confidence??r.score) >= 0.7 && (r.confidence??r.score) < 0.8).length}</div>
545
+ <div className="flex items-center gap-1"><span className="w-2 h-2 bg-[#22d3ee]" style={{opacity: 0.4}}></span> &lt;70% confiance: {combinedResults.filter(r => (r.confidence??r.score) < 0.7).length}</div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
546
  </div>
547
  </div>
548
+ <p className="text-[10px] font-mono text-zinc-400 mt-3 pt-2 border-t border-white/5">
549
+ Area de segmentation: {(combinedResults.reduce((s, r) => s + ((r.box[2]-r.box[0]) * (r.box[3]-r.box[1])), 0) / 100).toFixed(2)} mm²
550
+ </p>
551
+ </div>
552
+ )}
553
+ </div>
554
 
 
 
 
 
 
 
555
  </main>
556
+
557
+ <style jsx global>{`
558
+ .custom-scrollbar::-webkit-scrollbar {
559
+ width: 4px;
560
+ }
561
+ .custom-scrollbar::-webkit-scrollbar-track {
562
+ background: rgba(255, 255, 255, 0.02);
563
+ }
564
+ .custom-scrollbar::-webkit-scrollbar-thumb {
565
+ background: rgba(255, 255, 255, 0.1);
566
+ border-radius: 4px;
567
+ }
568
+ .custom-scrollbar::-webkit-scrollbar-thumb:hover {
569
+ background: rgba(255, 255, 255, 0.2);
570
+ }
571
+ `}</style>
572
  </div>
573
  );
574
  }