Spaces:
Running
Running
| import React, { useState, useRef, useEffect } from 'react'; | |
| import axios from 'axios'; | |
| import { UploadCloud, Activity, Loader2, ScanLine, Search } from 'lucide-react'; | |
| const API_BASE = import.meta.env.DEV ? "http://localhost:8000" : ""; | |
| const QUADRANT_COLOURS = ['#3b82f6', '#22c55e', '#f59e0b', '#ef4444']; | |
| const UPPER_TEETH = [18, 17, 16, 15, 14, 13, 12, 11, 21, 22, 23, 24, 25, 26, 27, 28]; | |
| const LOWER_TEETH = [48, 47, 46, 45, 44, 43, 42, 41, 31, 32, 33, 34, 35, 36, 37, 38]; | |
| const ALL_TEETH = [...UPPER_TEETH, ...LOWER_TEETH]; | |
| function getToothStyle(fdi) { | |
| const quad = Math.floor(fdi / 10); | |
| const colors = { | |
| 1: { border: '#3b82f6', bg: 'rgba(59,130,246,0.25)', text: '#93c5fd' }, | |
| 2: { border: '#22c55e', bg: 'rgba(34,197,94,0.25)', text: '#86efac' }, | |
| 3: { border: '#f59e0b', bg: 'rgba(245,158,11,0.25)', text: '#fcd34d' }, | |
| 4: { border: '#ef4444', bg: 'rgba(239,68,68,0.25)', text: '#fca5a5' }, | |
| }; | |
| return colors[quad] || { border: '#a855f7', bg: 'rgba(168,85,247,0.25)', text: '#d8b4fe' }; | |
| } | |
| function computeIoU(b1, b2) { | |
| const ix = Math.max(b1[0], b2[0]), iy = Math.max(b1[1], b2[1]); | |
| const iw = Math.min(b1[2], b2[2]) - ix, ih = Math.min(b1[3], b2[3]) - iy; | |
| if (iw <= 0 || ih <= 0) return 0; | |
| const inter = iw * ih; | |
| return inter / ((b1[2]-b1[0])*(b1[3]-b1[1]) + (b2[2]-b2[0])*(b2[3]-b2[1]) - inter); | |
| } | |
| function applyNMS(results, iouThreshold = 0.35) { | |
| const sorted = [...results].sort((a, b) => (b.confidence ?? b.score) - (a.confidence ?? a.score)); | |
| const keep = []; | |
| for (const item of sorted) { | |
| if (!keep.some(k => computeIoU(item.box, k.box) > iouThreshold)) keep.push(item); | |
| } | |
| return keep; | |
| } | |
| function getColour(fdi) { | |
| if (!fdi || fdi < 0) return '#a855f7'; | |
| const quad = Math.floor(fdi / 10) - 1; | |
| return QUADRANT_COLOURS[Math.min(quad, 3)] ?? '#a855f7'; | |
| } | |
| // ── ToothCrop Component (For the Right Panel) ───────────────────────────────── | |
| const ToothCrop = ({ imageSrc, box, mask_base64, score, fdi, isPipeline, index }) => { | |
| const canvasRef = useRef(null); | |
| useEffect(() => { | |
| if (!canvasRef.current || !imageSrc || !box) return; | |
| const [x1, y1, x2, y2] = box; | |
| const w = x2 - x1; | |
| const h = y2 - y1; | |
| if (w <= 0 || h <= 0) return; | |
| const img = new Image(); | |
| img.src = imageSrc; | |
| img.onload = () => { | |
| const cvs = canvasRef.current; | |
| const ctx = cvs.getContext('2d'); | |
| cvs.width = w; | |
| cvs.height = h; | |
| ctx.clearRect(0, 0, w, h); | |
| ctx.drawImage(img, x1, y1, w, h, 0, 0, w, h); | |
| if (mask_base64) { | |
| const maskImg = new Image(); | |
| maskImg.onload = () => { | |
| const off = document.createElement('canvas'); | |
| off.width = w; off.height = h; | |
| const octx = off.getContext('2d'); | |
| const col = isPipeline && fdi > 0 ? getColour(fdi) : '#22d3ee'; | |
| octx.fillStyle = col; | |
| octx.fillRect(0, 0, w, h); | |
| octx.globalCompositeOperation = 'destination-in'; | |
| octx.drawImage(maskImg, 0, 0, w, h); | |
| ctx.save(); | |
| ctx.globalAlpha = 0.5; | |
| ctx.drawImage(off, 0, 0); | |
| ctx.restore(); | |
| }; | |
| maskImg.src = `data:image/png;base64,${mask_base64}`; | |
| } | |
| }; | |
| }, [imageSrc, box, mask_base64, fdi, isPipeline]); | |
| const col = isPipeline && fdi > 0 ? getColour(fdi) : '#3b82f6'; | |
| return ( | |
| <div className="bg-[#111113] border rounded-lg overflow-hidden flex flex-col" style={{ borderColor: col + '40' }}> | |
| <div className="p-1 px-1.5 flex justify-between items-center text-[9px] font-bold" style={{ backgroundColor: col + '15', color: col }}> | |
| <span>{isPipeline && fdi > 0 ? `Dent n°${fdi}` : `Dent n°${index + 1}`}</span> | |
| <span>{(score * 100).toFixed(0)}%</span> | |
| </div> | |
| <div className="flex-1 flex items-center justify-center p-1 relative h-16 sm:h-20 bg-black"> | |
| <canvas ref={canvasRef} className="max-w-full max-h-full object-contain" /> | |
| </div> | |
| {/* Metrics Area */} | |
| <div className="p-1.5 bg-[#1a1a1f] border-t border-white/5 flex flex-col gap-0.5"> | |
| <span className="text-[8px] text-zinc-400 font-mono">Area: {(((box[2]-box[0]) * (box[3]-box[1])) / 100).toFixed(1)} mm²</span> | |
| <div className="w-full bg-zinc-800 rounded-full h-1 mt-0.5"> | |
| <div className="h-full rounded-full" style={{ width: `${score * 100}%`, backgroundColor: col }}></div> | |
| </div> | |
| <span className="text-[7px] text-zinc-500 uppercase">Confiance</span> | |
| </div> | |
| </div> | |
| ); | |
| }; | |
| // ── Odontogram Component ────────────────────────────────────────────────────── | |
| function Odontogram({ detectedFDIs }) { | |
| const detected = new Set(detectedFDIs); | |
| const ToothCell = ({ fdi, isUpper }) => { | |
| const present = detected.has(fdi); | |
| const style = present ? getToothStyle(fdi) : null; | |
| return ( | |
| <div | |
| title={present ? `FDI ${fdi} — détectée` : `FDI ${fdi} — non détectée`} | |
| style={present ? { | |
| border: `1px solid ${style.border}`, | |
| background: style.bg, | |
| color: style.text, | |
| boxShadow: `0 0 6px ${style.border}40`, | |
| } : { | |
| border: '1px solid rgba(113,113,122,0.3)', | |
| background: 'rgba(39,39,42,0.4)', | |
| color: 'transparent', | |
| position: 'relative', | |
| }} | |
| className={`flex items-center justify-center font-bold text-[9px] sm:text-[11px] rounded-sm transition-all select-none | |
| ${isUpper ? 'rounded-t-lg' : 'rounded-b-lg'} | |
| w-5 h-7 sm:w-6 sm:h-8 lg:w-7 lg:h-9`} | |
| > | |
| {present ? ( | |
| <span>{fdi}</span> | |
| ) : ( | |
| <> | |
| <svg viewBox="0 0 24 24" className="w-3 h-3 absolute" fill="none" stroke="#ef4444" strokeWidth="2.5" strokeLinecap="round"> | |
| <line x1="6" y1="6" x2="18" y2="18" /> | |
| <line x1="18" y1="6" x2="6" y2="18" /> | |
| </svg> | |
| <span className="text-zinc-600 text-[7px] absolute bottom-0.5">{fdi}</span> | |
| </> | |
| )} | |
| </div> | |
| ); | |
| }; | |
| return ( | |
| <div className="w-full bg-[#1a1a1f] rounded-lg p-2 sm:p-3 border border-white/5"> | |
| <p className="text-[10px] font-bold text-slate-400 uppercase tracking-widest mb-2 text-center"> | |
| Odontogramme FDI — Schéma Dentaire | |
| </p> | |
| <div className="flex justify-center flex-wrap gap-0.5 sm:gap-1 mb-1.5"> | |
| {UPPER_TEETH.map(t => <ToothCell key={t} fdi={t} isUpper={true} />)} | |
| </div> | |
| <div className="flex justify-center flex-wrap gap-0.5 sm:gap-1 mt-1.5"> | |
| {LOWER_TEETH.map(t => <ToothCell key={t} fdi={t} isUpper={false} />)} | |
| </div> | |
| <div className="flex flex-wrap justify-center gap-4 sm:gap-6 mt-3 text-[9px] text-zinc-400"> | |
| <div className="flex items-center gap-1.5"> | |
| <div className="flex -space-x-1"> | |
| {['bg-blue-500','bg-green-500','bg-amber-500','bg-red-500'].map((c,i) => ( | |
| <span key={i} className={`w-2 h-2 rounded-full ${c} border border-zinc-900`} /> | |
| ))} | |
| </div> | |
| <span>Détectée (Q1–Q4)</span> | |
| </div> | |
| <div className="flex items-center gap-1.5"> | |
| <svg viewBox="0 0 16 16" className="w-2.5 h-2.5" fill="none" stroke="#ef4444" strokeWidth="2.5" strokeLinecap="round"> | |
| <line x1="3" y1="3" x2="13" y2="13" /> | |
| <line x1="13" y1="3" x2="3" y2="13" /> | |
| </svg> | |
| <span>Non détectée / Absente</span> | |
| </div> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| // ── Main App ────────────────────────────────────────────────────────────────── | |
| export default function App() { | |
| const [file, setFile] = useState(null); | |
| const [imagePreview, setImagePreview] = useState(null); | |
| // Tasks: 'diagnostic', 'segmentation', 'charting' — default: leftmost button | |
| const [task, setTask] = useState('diagnostic'); | |
| // Versions: 'v1' (YOLO), 'v2' (FasterRCNN) — default: leftmost radio | |
| const [version, setVersion] = useState('v1'); | |
| const [pipelineResults, setPipelineResults] = useState(null); | |
| const [rawBoxes, setRawBoxes] = useState(null); | |
| const [maskSrc, setMaskSrc] = useState(null); | |
| const [loading, setLoading] = useState(false); | |
| const imgRef = useRef(null); | |
| const canvasRef = useRef(null); | |
| const modeStr = (() => { | |
| if (!task || !version) return null; | |
| if (task === 'diagnostic') return version === 'v1' ? 'yolo' : 'frcnn'; | |
| if (task === 'segmentation') return version === 'v1' ? 'seg_yolo' : 'seg_frcnn'; | |
| if (task === 'charting') return version === 'v1' ? 'pipeline_yolo' : 'pipeline_frcnn'; | |
| })(); | |
| const isPipeline = task === 'charting'; | |
| const handleFileSelect = (f) => { | |
| setFile(f); | |
| setImagePreview(URL.createObjectURL(f)); | |
| setPipelineResults(null); | |
| setRawBoxes(null); | |
| setMaskSrc(null); | |
| }; | |
| const handleAPI = async () => { | |
| if (!file || !modeStr) return; | |
| setLoading(true); | |
| setPipelineResults(null); setRawBoxes(null); setMaskSrc(null); | |
| const fd = new FormData(); | |
| fd.append("file", file); | |
| try { | |
| if (modeStr.startsWith('pipeline') || modeStr.startsWith('seg')) { | |
| const endpoint = modeStr.includes('yolo') ? 'yolo' : 'frcnn'; | |
| const r = await axios.post(`${API_BASE}/api/pipeline/${endpoint}`, fd); | |
| setPipelineResults(r.data.results); | |
| } else { | |
| const endpoint = modeStr.includes('yolo') ? 'yolo' : 'frcnn'; | |
| const r = await axios.post(`${API_BASE}/api/detect/${endpoint}`, fd); | |
| setRawBoxes(r.data); | |
| } | |
| } catch (e) { | |
| console.error(e); | |
| alert("Error contacting the AI server. Ensure FastAPI is running on port 8000."); | |
| } finally { | |
| setLoading(false); | |
| } | |
| }; | |
| // Draw bounding boxes + segmentation masks on canvas | |
| useEffect(() => { | |
| if (!canvasRef.current || !imgRef.current) return; | |
| const img = imgRef.current; | |
| const ctx = canvasRef.current.getContext('2d'); | |
| const { width, height } = img.getBoundingClientRect(); | |
| canvasRef.current.width = width; | |
| canvasRef.current.height = height; | |
| const sx = width / img.naturalWidth; | |
| const sy = height / img.naturalHeight; | |
| ctx.clearRect(0, 0, width, height); | |
| if (pipelineResults) { | |
| const showFDI = isPipeline; | |
| const uniqueResults = applyNMS(pipelineResults, 0.40); | |
| uniqueResults.forEach(({ box, score, confidence, fdi, mask_base64 }) => { | |
| const [x1, y1, x2, y2] = box; | |
| const rx = x1*sx, ry = y1*sy, rw = (x2-x1)*sx, rh = (y2-y1)*sy; | |
| const col = showFDI ? getColour(fdi) : '#22d3ee'; | |
| if (mask_base64 && task !== 'diagnostic') { | |
| const maskImg = new Image(); | |
| maskImg.onload = () => { | |
| const off = document.createElement('canvas'); | |
| off.width = rw; off.height = rh; | |
| const octx = off.getContext('2d'); | |
| octx.fillStyle = col; | |
| octx.fillRect(0, 0, rw, rh); | |
| octx.globalCompositeOperation = 'destination-in'; | |
| octx.drawImage(maskImg, 0, 0, rw, rh); | |
| ctx.save(); | |
| ctx.globalAlpha = 0.50; | |
| ctx.drawImage(off, rx, ry); | |
| ctx.restore(); | |
| }; | |
| maskImg.src = `data:image/png;base64,${mask_base64}`; | |
| } | |
| ctx.strokeStyle = col; ctx.lineWidth = 2; | |
| ctx.strokeRect(rx, ry, rw, rh); | |
| const s = confidence ?? score; | |
| const label = showFDI ? (fdi > 0 ? `FDI ${fdi}` : `${(s*100).toFixed(0)}%`) : `${(s*100).toFixed(0)}%`; | |
| const tw = ctx.measureText(label).width + 8; | |
| ctx.fillStyle = col; | |
| ctx.fillRect(rx, ry - 16, tw, 16); | |
| ctx.fillStyle = 'white'; ctx.font = 'bold 10px Arial'; | |
| ctx.fillText(label, rx + 4, ry - 4); | |
| }); | |
| } | |
| if (uniqueDetectionResults && !pipelineResults) { | |
| uniqueDetectionResults.forEach(({ box, score }) => { | |
| const [x1, y1, x2, y2] = box; | |
| const rx = x1*sx, ry = y1*sy, rw = (x2-x1)*sx, rh = (y2-y1)*sy; | |
| ctx.strokeStyle = '#3b82f6'; ctx.lineWidth = 2; | |
| ctx.strokeRect(rx, ry, rw, rh); | |
| const s = `${(score*100).toFixed(0)}%`; | |
| ctx.fillStyle = '#3b82f6'; ctx.fillRect(rx, ry - 16, 30, 16); | |
| ctx.fillStyle = 'white'; ctx.font = '10px Arial'; | |
| ctx.fillText(s, rx + 4, ry - 4); | |
| }); | |
| } | |
| }, [pipelineResults, rawBoxes, isPipeline, task]); | |
| const uniquePipelineResults = pipelineResults ? applyNMS(pipelineResults, 0.40) : null; | |
| const uniqueDetectionResults = rawBoxes ? applyNMS(rawBoxes.boxes.map((box, i) => ({ box, score: rawBoxes.scores[i] })), 0.40) : null; | |
| const combinedResults = uniquePipelineResults || uniqueDetectionResults || []; | |
| const detectedFDIs = uniquePipelineResults ? uniquePipelineResults.filter(r => r.fdi > 0).map(r => r.fdi) : []; | |
| const totalDetected = combinedResults.length; | |
| const hasResults = combinedResults.length > 0; | |
| return ( | |
| <div className="h-screen w-screen flex flex-col bg-[#0a0a0c] font-sans text-slate-200 overflow-hidden"> | |
| {/* ── Header Area ──────────────────────────────────────────────────────── */} | |
| <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"> | |
| <div className="absolute left-4 top-4 flex items-center gap-2 opacity-50"> | |
| <Activity size={18} className="text-blue-500" /> | |
| <span className="text-xs font-bold tracking-widest uppercase">ToothMap IA</span> | |
| </div> | |
| {/* Task Tabs */} | |
| <div className="flex gap-1 bg-[#1a1a1f] p-1 rounded-xl border border-white/5 mb-3"> | |
| {[ | |
| { id: 'diagnostic', label: 'Diagnostic Rapide', icon: <Search size={14} />, desc: 'v1: YOLOv8s | v2: Faster R-CNN' }, | |
| { id: 'segmentation', label: 'Segmentation & Mesure', icon: <Activity size={14} />, desc: 'v1: YOLOv8s + U-Net | v2: Faster R-CNN + U-Net' }, | |
| { 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' } | |
| ].map(t => ( | |
| <div key={t.id} className="relative flex group"> | |
| <button | |
| onClick={() => { setTask(t.id); setPipelineResults(null); setRawBoxes(null); setMaskSrc(null); }} | |
| className={`flex items-center gap-2 px-4 py-1.5 rounded-lg text-xs font-semibold transition-all duration-200 ${ | |
| task === t.id | |
| ? 'bg-blue-600 border border-blue-500/50 text-white shadow-lg shadow-blue-500/20' | |
| : 'bg-transparent border border-transparent text-slate-400 hover:text-slate-200 hover:bg-white/5' | |
| }`} | |
| > | |
| {t.icon} {t.label} | |
| </button> | |
| {/* Tooltip via pure CSS group-hover */} | |
| <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"> | |
| <p className="text-xs font-bold text-white mb-2 flex items-center gap-1.5">{t.icon} {t.label}</p> | |
| <div className="flex flex-col gap-1.5"> | |
| {t.desc.split(' | ').map((d, i) => ( | |
| <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"> | |
| {d} | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| </div> | |
| ))} | |
| </div> | |
| {/* Version Radio */} | |
| <div className="flex items-center justify-center gap-8"> | |
| {[ | |
| { id: 'v1', label: 'Version AI v1 (Rapide)' }, | |
| { id: 'v2', label: 'Version AI v2 (Précis)' } | |
| ].map(v => ( | |
| <label key={v.id} className="flex items-center gap-2 text-[11px] text-slate-300 cursor-pointer font-medium hover:text-white transition-colors"> | |
| <input | |
| type="radio" | |
| name="version" | |
| checked={version === v.id} | |
| onChange={() => { setVersion(v.id); setPipelineResults(null); setRawBoxes(null); setMaskSrc(null); }} | |
| className="hidden" | |
| /> | |
| <div className={`w-3.5 h-3.5 rounded-full border flex items-center justify-center transition-all ${ | |
| version === v.id ? 'border-blue-500 bg-blue-500/10' : 'border-slate-600 bg-[#1a1a1f]' | |
| }`}> | |
| {version === v.id && <div className="w-1.5 h-1.5 bg-blue-500 rounded-full" />} | |
| </div> | |
| {v.label} | |
| </label> | |
| ))} | |
| </div> | |
| </div> | |
| {/* ── Main Content Split View ────────────────────────────────────────── */} | |
| <main className="flex-1 flex flex-row overflow-hidden p-3 gap-3"> | |
| {/* LEFT COLUMN */} | |
| <div className="w-[55%] flex flex-col gap-3 h-full overflow-y-auto pr-1 custom-scrollbar"> | |
| {/* Image Upload/Preview */} | |
| <div | |
| 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 | |
| ${imagePreview ? 'border-zinc-700/50 bg-[#111113]' : 'border-zinc-700/50 hover:border-blue-500/50 bg-[#111113]'}`} | |
| onClick={() => !imagePreview && document.getElementById('fu').click()} | |
| onDragOver={e => e.preventDefault()} | |
| onDrop={e => { e.preventDefault(); if (e.dataTransfer.files[0]) handleFileSelect(e.dataTransfer.files[0]); }} | |
| > | |
| {imagePreview ? ( | |
| <div className="w-full h-full flex items-center justify-center p-2"> | |
| <div className="relative flex items-center justify-center max-w-full max-h-[40vh]"> | |
| <img ref={imgRef} src={imagePreview} alt="X-ray" className="max-w-full max-h-[40vh] object-contain rounded-lg block" /> | |
| <canvas ref={canvasRef} className="absolute top-0 left-0 w-full h-full pointer-events-none" /> | |
| </div> | |
| <button | |
| onClick={e => { e.stopPropagation(); document.getElementById('fu').click(); }} | |
| 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" | |
| > | |
| Remplacer | |
| </button> | |
| </div> | |
| ) : ( | |
| <> | |
| <UploadCloud size={36} className="text-zinc-600 group-hover:text-blue-400 mb-2 transition-colors" /> | |
| <p className="text-sm font-medium text-zinc-400 group-hover:text-zinc-200 transition-colors"> | |
| Glissez-déposez une radiographie | |
| </p> | |
| <p className="text-[10px] text-zinc-600 mt-1">ou cliquez pour parcourir</p> | |
| </> | |
| )} | |
| <input type="file" id="fu" className="hidden" accept="image/*" onChange={e => handleFileSelect(e.target.files[0])} /> | |
| </div> | |
| {/* Analyze Button */} | |
| <button | |
| onClick={handleAPI} | |
| disabled={!file || !task || !version || loading} | |
| 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 ${ | |
| !file || !task || !version | |
| ? 'bg-[#1a1a1f] text-zinc-600 border border-white/5 cursor-not-allowed' | |
| : loading | |
| ? 'bg-blue-700 text-white cursor-wait' | |
| : 'bg-blue-600 hover:bg-blue-500 text-white border border-blue-500' | |
| }`} | |
| > | |
| {loading ? <Loader2 size={16} className="animate-spin" /> : <ScanLine size={16} />} | |
| {loading | |
| ? 'Analyse en cours…' | |
| : !task || !version | |
| ? 'Sélectionnez un mode et une version' | |
| : `Lancer l'analyse IA — ${task === 'diagnostic' ? 'Détection' : task === 'segmentation' ? 'Segmentation' : 'Analyse complète'}` | |
| } | |
| </button> | |
| {/* Stats Row */} | |
| {hasResults && ( | |
| <div className="flex gap-2 shrink-0"> | |
| <div className="flex-1 bg-[#1a1a1f] border border-white/5 rounded-xl flex flex-col items-center justify-center py-2.5 shadow-sm"> | |
| <p className="text-2xl font-black text-white">{totalDetected}</p> | |
| <p className="text-[9px] text-zinc-500 uppercase font-semibold mt-0.5">Dents Détectées</p> | |
| </div> | |
| {isPipeline && ( | |
| <> | |
| <div className="flex-1 bg-[#1a1a1f] border border-white/5 rounded-xl flex flex-col items-center justify-center py-2.5 shadow-sm"> | |
| <p className="text-2xl font-black text-white">{detectedFDIs.length}</p> | |
| <p className="text-[9px] text-zinc-500 uppercase font-semibold mt-0.5">Étiquettes FDI</p> | |
| </div> | |
| <div className="flex-1 bg-[#1a1a1f] border border-white/5 rounded-xl flex flex-col items-center justify-center py-2.5 shadow-sm"> | |
| <p className="text-2xl font-black text-amber-400">{32 - detectedFDIs.length}</p> | |
| <p className="text-[9px] text-zinc-500 uppercase font-semibold mt-0.5">Absentes</p> | |
| </div> | |
| </> | |
| )} | |
| <div className="flex-1 bg-[#1a1a1f] border border-white/5 rounded-xl flex flex-col items-center justify-center py-2.5 shadow-sm"> | |
| <p className="text-2xl font-black text-emerald-400"> | |
| {uniquePipelineResults | |
| ? (uniquePipelineResults.reduce((s, r) => s + (r.confidence ?? r.score ?? 0), 0) / uniquePipelineResults.length * 100).toFixed(0) | |
| : uniqueDetectionResults | |
| ? (uniqueDetectionResults.reduce((s, r) => s + r.score, 0) / uniqueDetectionResults.length * 100).toFixed(0) | |
| : 0}% | |
| </p> | |
| <p className="text-[9px] text-zinc-500 uppercase font-semibold mt-0.5">Confiance Moyenne</p> | |
| </div> | |
| </div> | |
| )} | |
| {/* FDI Classification Results */} | |
| {hasResults && isPipeline && detectedFDIs.length > 0 && ( | |
| <div className="shrink-0 bg-[#1a1a1f] border border-white/5 rounded-xl p-3 shadow-sm"> | |
| <p className="text-[9px] font-bold text-slate-400 uppercase tracking-widest mb-2 text-center"> | |
| RÉSULTATS DE CLASSIFICATION FDI | |
| </p> | |
| <div className="grid grid-cols-2 md:grid-cols-4 gap-1.5"> | |
| {uniquePipelineResults.filter(r => r.fdi > 0).map((r, i) => { | |
| const col = getColour(r.fdi); | |
| return ( | |
| <div key={i} className="flex justify-between items-center px-1.5 py-1 border rounded bg-black/20" style={{ borderColor: col+'40' }}> | |
| <span className="text-[9px] font-bold" style={{ color: col }}>FDI {r.fdi}</span> | |
| <span className="text-[9px] text-slate-300 font-mono">{(r.confidence*100).toFixed(0)}%</span> | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| </div> | |
| )} | |
| {/* Odontogram */} | |
| {hasResults && isPipeline && detectedFDIs.length > 0 && ( | |
| <div className="shrink-0 mb-4"> | |
| <Odontogram detectedFDIs={detectedFDIs} /> | |
| </div> | |
| )} | |
| </div> | |
| {/* RIGHT COLUMN */} | |
| <div className="w-[45%] bg-[#111113] rounded-xl border border-white/5 p-3 flex flex-col overflow-hidden shadow-inner"> | |
| <h2 className="text-[11px] font-bold text-slate-300 mb-3 border-b border-white/5 pb-2"> | |
| Détails des Dents & Résultats {task === 'segmentation' || task === 'charting' ? 'de Segmentation' : 'de Détection'} | |
| </h2> | |
| <div className="flex-1 overflow-y-auto pr-2 custom-scrollbar"> | |
| {!hasResults ? ( | |
| <div className="h-full flex flex-col items-center justify-center text-zinc-600"> | |
| <Activity size={32} className="mb-2 opacity-20" /> | |
| <p className="text-xs">Aucun résultat à afficher.</p> | |
| <p className="text-[10px]">Lancez l'analyse pour voir les détails ici.</p> | |
| </div> | |
| ) : ( | |
| <div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-2"> | |
| {combinedResults.map((r, i) => ( | |
| <ToothCrop | |
| key={i} | |
| imageSrc={imagePreview} | |
| box={r.box} | |
| mask_base64={r.mask_base64} | |
| score={r.confidence ?? r.score} | |
| fdi={r.fdi} | |
| isPipeline={isPipeline} | |
| index={i} | |
| /> | |
| ))} | |
| </div> | |
| )} | |
| </div> | |
| {/* Analyse Métrique Bottom Section */} | |
| {hasResults && ( | |
| <div className="mt-3 shrink-0 bg-[#1a1a1f] border border-white/5 rounded-xl p-3 shadow-sm flex flex-col"> | |
| <h3 className="text-[11px] font-bold text-slate-300 mb-2">Analyse Métrique</h3> | |
| <div className="flex gap-4 items-end h-16"> | |
| <div className="flex items-end gap-1.5 h-full flex-1 border-b border-l border-zinc-700 pb-1 pl-1"> | |
| {[95, 85, 40, 20, 10, 5].map((h, i) => ( | |
| <div key={i} className="w-5 bg-[#22d3ee] hover:bg-[#06b6d4] rounded-t-sm transition-all" style={{ height: `${h}%` }}></div> | |
| ))} | |
| <div className="w-full text-center text-[7px] text-zinc-600 absolute bottom-[-10px] left-0"> | |
| 90%+ 80-89% 70-79% <70% | |
| </div> | |
| </div> | |
| <div className="text-[8px] text-zinc-400 flex flex-col justify-center gap-0.5 min-w-[100px]"> | |
| <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> | |
| <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> | |
| <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> | |
| <div className="flex items-center gap-1"><span className="w-2 h-2 bg-[#22d3ee]" style={{opacity: 0.4}}></span> <70% confiance: {combinedResults.filter(r => (r.confidence??r.score) < 0.7).length}</div> | |
| </div> | |
| </div> | |
| <p className="text-[10px] font-mono text-zinc-400 mt-3 pt-2 border-t border-white/5"> | |
| 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² | |
| </p> | |
| </div> | |
| )} | |
| </div> | |
| </main> | |
| <style jsx global>{` | |
| .custom-scrollbar::-webkit-scrollbar { | |
| width: 4px; | |
| } | |
| .custom-scrollbar::-webkit-scrollbar-track { | |
| background: rgba(255, 255, 255, 0.02); | |
| } | |
| .custom-scrollbar::-webkit-scrollbar-thumb { | |
| background: rgba(255, 255, 255, 0.1); | |
| border-radius: 4px; | |
| } | |
| .custom-scrollbar::-webkit-scrollbar-thumb:hover { | |
| background: rgba(255, 255, 255, 0.2); | |
| } | |
| `}</style> | |
| </div> | |
| ); | |
| } | |