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 (
{isPipeline && fdi > 0 ? `Dent n°${fdi}` : `Dent n°${index + 1}`} {(score * 100).toFixed(0)}%
{/* Metrics Area */}
Area: {(((box[2]-box[0]) * (box[3]-box[1])) / 100).toFixed(1)} mm²
Confiance
); }; // ── 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 (
{present ? ( {fdi} ) : ( <> {fdi} )}
); }; return (

Odontogramme FDI — Schéma Dentaire

{UPPER_TEETH.map(t => )}
{LOWER_TEETH.map(t => )}
{['bg-blue-500','bg-green-500','bg-amber-500','bg-red-500'].map((c,i) => ( ))}
Détectée (Q1–Q4)
Non détectée / Absente
); } // ── 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 (
{/* ── Header Area ──────────────────────────────────────────────────────── */}
ToothMap IA
{/* Task Tabs */}
{[ { id: 'diagnostic', label: 'Diagnostic Rapide', icon: , desc: 'v1: YOLOv8s | v2: Faster R-CNN' }, { id: 'segmentation', label: 'Segmentation & Mesure', icon: , desc: 'v1: YOLOv8s + U-Net | v2: Faster R-CNN + U-Net' }, { id: 'charting', label: 'Charting Détaillé', icon: , desc: 'v1: YOLOv8s + ConvNeXt-T + U-Net | v2: Faster R-CNN + ConvNeXt-T + U-Net' } ].map(t => (
{/* Tooltip via pure CSS group-hover */}

{t.icon} {t.label}

{t.desc.split(' | ').map((d, i) => (
{d}
))}
))}
{/* Version Radio */}
{[ { id: 'v1', label: 'Version AI v1 (Rapide)' }, { id: 'v2', label: 'Version AI v2 (Précis)' } ].map(v => (
{/* ── Main Content Split View ────────────────────────────────────────── */}
{/* LEFT COLUMN */}
{/* Image Upload/Preview */}
!imagePreview && document.getElementById('fu').click()} onDragOver={e => e.preventDefault()} onDrop={e => { e.preventDefault(); if (e.dataTransfer.files[0]) handleFileSelect(e.dataTransfer.files[0]); }} > {imagePreview ? (
X-ray
) : ( <>

Glissez-déposez une radiographie

ou cliquez pour parcourir

)} handleFileSelect(e.target.files[0])} />
{/* Analyze Button */} {/* Stats Row */} {hasResults && (

{totalDetected}

Dents Détectées

{isPipeline && ( <>

{detectedFDIs.length}

Étiquettes FDI

{32 - detectedFDIs.length}

Absentes

)}

{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}%

Confiance Moyenne

)} {/* FDI Classification Results */} {hasResults && isPipeline && detectedFDIs.length > 0 && (

RÉSULTATS DE CLASSIFICATION FDI

{uniquePipelineResults.filter(r => r.fdi > 0).map((r, i) => { const col = getColour(r.fdi); return (
FDI {r.fdi} {(r.confidence*100).toFixed(0)}%
); })}
)} {/* Odontogram */} {hasResults && isPipeline && detectedFDIs.length > 0 && (
)}
{/* RIGHT COLUMN */}

Détails des Dents & Résultats {task === 'segmentation' || task === 'charting' ? 'de Segmentation' : 'de Détection'}

{!hasResults ? (

Aucun résultat à afficher.

Lancez l'analyse pour voir les détails ici.

) : (
{combinedResults.map((r, i) => ( ))}
)}
{/* Analyse Métrique Bottom Section */} {hasResults && (

Analyse Métrique

{[95, 85, 40, 20, 10, 5].map((h, i) => (
))}
90%+ 80-89% 70-79% <70%
90%+ confiance: {combinedResults.filter(r => (r.confidence??r.score) >= 0.9).length}
80-89% dentes: {combinedResults.filter(r => (r.confidence??r.score) >= 0.8 && (r.confidence??r.score) < 0.9).length}
70-79% dentes: {combinedResults.filter(r => (r.confidence??r.score) >= 0.7 && (r.confidence??r.score) < 0.8).length}
<70% confiance: {combinedResults.filter(r => (r.confidence??r.score) < 0.7).length}

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²

)}
); }