import React, { useEffect, useState, useRef, useImperativeHandle, forwardRef } from 'react'; import { Wrench, Trash2, Circle as CircleIcon, Hexagon, Square as SquareIcon, ChevronDown, ChevronUp, Sparkles, Loader } from 'lucide-react'; type ShapeType = 'rect' | 'circle' | 'polygon'; interface Point { x: number; y: number; } interface Annotation { id: string; type: ShapeType; x: number; y: number; width: number; height: number; color: string; label?: string; points?: Point[]; source?: 'manual' | 'ai'; identified?: boolean; accepted?: boolean; } interface ImageAnnotatorProps { imageUrl?: string; imageUrls?: string[]; onAnnotationsChange?: (annotations: Annotation[]) => void; onAIAssist?: () => Promise; isAILoading?: boolean; } export interface ImageAnnotatorHandle { addAIAnnotations: (aiAnnotations: Annotation[]) => void; setImageIndex: (index: number) => void; clearAIAnnotations: () => void; clearAllAnnotations: () => void; resetViewport: () => void; waitForImageReady: () => Promise; } const ImageAnnotatorComponent = forwardRef(({ imageUrl, imageUrls, onAnnotationsChange, onAIAssist, isAILoading: externalAILoading = false }, ref) => { const canvasRef = useRef(null); const containerRef = useRef(null); const imageReadyResolveRef = useRef<(() => void) | null>(null); const cachedImagesRef = useRef>({}); const pendingDrawAnimationRef = useRef(null); const lastDrawStateRef = useRef(''); const [annotations, setAnnotations] = useState([]); const [tool, setTool] = useState('rect'); const [color, setColor] = useState('#05998c'); const [labelInput, setLabelInput] = useState(''); const [isDrawing, setIsDrawing] = useState(false); const [startPoint, setStartPoint] = useState(null); const [currentAnnotation, setCurrentAnnotation] = useState(null); const [imageLoaded, setImageLoaded] = useState(false); const [imageDimensions, setImageDimensions] = useState({ width: 0, height: 0 }); const [polygonPoints, setPolygonPoints] = useState([]); const [selectedImageIndex, setSelectedImageIndex] = useState(0); const [isAnnotationsOpen, setIsAnnotationsOpen] = useState(true); const [isLabelDropdownOpen, setIsLabelDropdownOpen] = useState(false); const [isAIAssistEnabled, setIsAIAssistEnabled] = useState(false); const [internalAILoading, setInternalAILoading] = useState(false); // Use internal loading state or external prop const isAILoading = externalAILoading || internalAILoading; // Edit state for annotation list const [editingId, setEditingId] = useState(null); const [editLabel, setEditLabel] = useState(''); const [editColor, setEditColor] = useState('#05998c'); // Annotation metadata state const [_annotationIdentified, setAnnotationIdentified] = useState>({}); const [annotationAccepted, setAnnotationAccepted] = useState>({}); // Predefined label options const labelOptions = [ 'Cervix', 'SCJ', 'OS', 'TZ', 'Blood Discharge', 'Scarring', 'Growth', 'Ulcer', 'Inflammation', 'Polypoidal Growth', 'Cauliflower-like Growth', 'Fungating Mass' ]; // Filter labels based on input const filteredLabels = labelOptions.filter(label => label.toLowerCase().includes(labelInput.toLowerCase()) ); // Expose annotator methods via ref useImperativeHandle(ref, () => ({ addAIAnnotations: (aiAnnotations: Annotation[]) => { setAnnotations(aiAnnotations); }, setImageIndex: (index: number) => { setSelectedImageIndex(index); }, clearAIAnnotations: () => { setAnnotations(prev => prev.filter(ann => ann.source !== 'ai')); }, clearAllAnnotations: () => { setAnnotations([]); setPolygonPoints([]); setCurrentAnnotation(null); }, resetViewport: () => { setSelectedImageIndex(0); setAnnotations([]); setPolygonPoints([]); setCurrentAnnotation(null); }, waitForImageReady: () => { return new Promise((resolve) => { if (imageLoaded) { resolve(); } else { imageReadyResolveRef.current = resolve; } }); } })); const images = imageUrls || (imageUrl ? [imageUrl] : []); const currentImageUrl = images[selectedImageIndex]; // Clear annotations when switching to a different image useEffect(() => { console.log('🖼️ Image switched, clearing annotations'); setAnnotations([]); setPolygonPoints([]); setCurrentAnnotation(null); setImageLoaded(false); }, [selectedImageIndex]); useEffect(() => { const img = new Image(); img.src = currentImageUrl; img.onload = () => { console.log('✅ Image loaded:', { width: img.width, height: img.height }); setImageDimensions({ width: img.width, height: img.height }); setImageLoaded(true); // Resolve waitForImageReady promise if pending if (imageReadyResolveRef.current) { console.log('🔔 Image ready resolver called'); imageReadyResolveRef.current(); imageReadyResolveRef.current = null; } // Force immediate canvas redraw with new dimensions setTimeout(() => drawCanvas(), 0); }; }, [currentImageUrl]); useEffect(() => { if (imageLoaded) drawCanvas(); }, [annotations, currentAnnotation, polygonPoints, imageLoaded]); useEffect(() => { if (onAnnotationsChange) onAnnotationsChange(annotations); }, [annotations, onAnnotationsChange]); // Cleanup pending draw on unmount useEffect(() => { return () => { if (pendingDrawAnimationRef.current) { cancelAnimationFrame(pendingDrawAnimationRef.current); } }; }, []); const clearAnnotations = () => setAnnotations([]); const deleteLastAnnotation = () => setAnnotations(prev => prev.slice(0, -1)); const getCanvasCoordinates = (e: React.MouseEvent) => { const canvas = canvasRef.current; if (!canvas) return { x: 0, y: 0 }; const rect = canvas.getBoundingClientRect(); const scaleX = imageDimensions.width / canvas.width || 1; const scaleY = imageDimensions.height / canvas.height || 1; return { x: (e.clientX - rect.left) * scaleX, y: (e.clientY - rect.top) * scaleY }; }; const startDrawing = (point: Point) => { setStartPoint(point); setIsDrawing(true); }; const finishDrawingRectOrCircle = () => { if (currentAnnotation && (currentAnnotation.width > 5 || currentAnnotation.height > 5)) { const ann: Annotation = { ...currentAnnotation, id: Date.now().toString(), label: labelInput, source: 'manual', identified: true }; setAnnotations(prev => [...prev, ann]); } setIsDrawing(false); setStartPoint(null); setCurrentAnnotation(null); }; const handleMouseDown = (e: React.MouseEvent) => { const p = getCanvasCoordinates(e); if (tool === 'polygon') { // add point to polygon setPolygonPoints(prev => [...prev, p]); return; } startDrawing(p); }; const handleMouseMove = (e: React.MouseEvent) => { if (tool === 'polygon') return; // polygon preview handled by polygonPoints if (!isDrawing || !startPoint) return; const p = getCanvasCoordinates(e); const w = p.x - startPoint.x; const h = p.y - startPoint.y; const ann: Annotation = { id: 'temp', type: tool, x: w > 0 ? startPoint.x : p.x, y: h > 0 ? startPoint.y : p.y, width: Math.abs(w), height: Math.abs(h), color, label: labelInput }; setCurrentAnnotation(ann); }; const handleMouseUp = () => { if (tool === 'polygon') return; if (isDrawing) finishDrawingRectOrCircle(); }; const finishPolygon = () => { if (polygonPoints.length < 3) return; const bounds = getBoundsFromPoints(polygonPoints); const ann: Annotation = { id: Date.now().toString(), type: 'polygon', x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height, color, label: labelInput, points: polygonPoints, source: 'manual', identified: true }; setAnnotations(prev => [...prev, ann]); setPolygonPoints([]); setLabelInput(''); }; const cancelPolygon = () => setPolygonPoints([]); const getBoundsFromPoints = (pts: Point[]) => { const xs = pts.map(p => p.x); const ys = pts.map(p => p.y); const minX = Math.min(...xs); const minY = Math.min(...ys); const maxX = Math.max(...xs); const maxY = Math.max(...ys); return { x: minX, y: minY, width: maxX - minX, height: maxY - minY }; }; const drawCanvas = () => { const canvas = canvasRef.current; const container = containerRef.current; if (!canvas || !container) return; // Cancel previous animation frame if pending if (pendingDrawAnimationRef.current) { cancelAnimationFrame(pendingDrawAnimationRef.current); } // Use requestAnimationFrame for smooth, optimized drawing pendingDrawAnimationRef.current = requestAnimationFrame(() => { performDraw(); }); }; const performDraw = () => { const canvas = canvasRef.current; const container = containerRef.current; if (!canvas || !container) return; const ctx = canvas.getContext('2d'); if (!ctx) return; // Create state key to prevent redundant draws const stateKey = `${selectedImageIndex}-${annotations.length}-${polygonPoints.length}-${currentAnnotation?.id}`; if (lastDrawStateRef.current === stateKey && canvas.width > 0) { return; // Skip if nothing changed } lastDrawStateRef.current = stateKey; // Get or create cached image let img = cachedImagesRef.current[currentImageUrl]; if (!img) { img = new Image(); img.src = currentImageUrl; cachedImagesRef.current[currentImageUrl] = img; } // If image not loaded yet, wait for it if (!img.complete) { img.onload = () => performDraw(); return; } // Resize canvas if needed const containerWidth = container.clientWidth; const aspectRatio = img.height / img.width || 1; const canvasHeight = containerWidth * aspectRatio; if (canvas.width !== containerWidth || canvas.height !== canvasHeight) { canvas.width = containerWidth; canvas.height = canvasHeight; } // Draw image ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.drawImage(img, 0, 0, canvas.width, canvas.height); // Draw saved annotations annotations.forEach(a => drawAnnotation(ctx, a, canvas.width, canvas.height)); // Draw polygon being created if (polygonPoints.length > 0) { drawPreviewPolygon(ctx, polygonPoints, canvas.width, canvas.height, color); } // Draw current temp annotation if (currentAnnotation) drawAnnotation(ctx, currentAnnotation, canvas.width, canvas.height); pendingDrawAnimationRef.current = null; }; const drawPreviewPolygon = (ctx: CanvasRenderingContext2D, pts: Point[], canvasWidth: number, canvasHeight: number, col: string) => { const scaleX = canvasWidth / imageDimensions.width || 1; const scaleY = canvasHeight / imageDimensions.height || 1; ctx.strokeStyle = col; ctx.lineWidth = 2; ctx.setLineDash([6, 4]); ctx.beginPath(); pts.forEach((p, i) => { const x = p.x * scaleX; const y = p.y * scaleY; if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); }); ctx.stroke(); ctx.setLineDash([]); }; const drawAnnotation = (ctx: CanvasRenderingContext2D, annotation: Annotation, canvasWidth: number, canvasHeight: number) => { const scaleX = canvasWidth / imageDimensions.width || 1; const scaleY = canvasHeight / imageDimensions.height || 1; ctx.strokeStyle = annotation.color; ctx.lineWidth = 3; ctx.setLineDash([]); if (annotation.type === 'rect') { ctx.strokeRect(annotation.x * scaleX, annotation.y * scaleY, annotation.width * scaleX, annotation.height * scaleY); // Only fill for manual annotations, not for AI-detected bounding boxes if (annotation.source !== 'ai') { ctx.fillStyle = annotation.color + '20'; ctx.fillRect(annotation.x * scaleX, annotation.y * scaleY, annotation.width * scaleX, annotation.height * scaleY); } } else if (annotation.type === 'circle') { const cx = (annotation.x + annotation.width / 2) * scaleX; const cy = (annotation.y + annotation.height / 2) * scaleY; const r = Math.max(annotation.width * scaleX, annotation.height * scaleY) / 2; ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.stroke(); // Only fill for manual annotations, not for AI-detected shapes if (annotation.source !== 'ai') { ctx.fillStyle = annotation.color + '20'; ctx.fill(); } } else if (annotation.type === 'polygon' && annotation.points && Array.isArray(annotation.points) && annotation.points.length > 0) { ctx.beginPath(); annotation.points.forEach((p, i) => { if (p && typeof p.x === 'number' && typeof p.y === 'number') { const x = p.x * scaleX; const y = p.y * scaleY; if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); } }); ctx.closePath(); ctx.stroke(); // Only fill for manual annotations, not for AI-detected shapes if (annotation.source !== 'ai') { ctx.fillStyle = annotation.color + '20'; ctx.fill(); } } }; // list editing const startEdit = (ann: Annotation) => { setEditingId(ann.id); setEditLabel(ann.label || ''); setEditColor(ann.color || '#05998c'); }; const saveEdit = () => { setAnnotations(prev => prev.map(a => a.id === editingId ? { ...a, label: editLabel, color: editColor } : a)); setEditingId(null); }; const deleteAnnotation = (id: string) => setAnnotations(prev => prev.filter(a => a.id !== id)); const handleAIAssistToggle = async () => { if (isAILoading) return; if (isAIAssistEnabled) { setIsAIAssistEnabled(false); return; } setIsAIAssistEnabled(true); if (onAIAssist) { setInternalAILoading(true); try { await onAIAssist(); } catch (error) { console.error('AI Assist error:', error); } finally { setInternalAILoading(false); } } }; const getShapeTypeName = (type: ShapeType): string => { const typeMap: Record = { 'rect': 'Rectangle', 'circle': 'Circle', 'polygon': 'Polygon' }; return typeMap[type] || type; }; return (
{/* Image Selection - Show if multiple images */} {images.length > 1 && (
{images.map((imgUrl, idx) => ( ))}
)}
Tools
{/* First row: AI Assist on right side */}
{onAIAssist && ( )}
{/* Second row: Label, Color, Undo on left, Clear All on right */}
setLabelInput(e.target.value)} onFocus={() => setIsLabelDropdownOpen(true)} onBlur={() => setTimeout(() => setIsLabelDropdownOpen(false), 200)} className="px-3 py-1 border rounded text-xs md:text-sm w-48" /> {isLabelDropdownOpen && filteredLabels.length > 0 && (
{filteredLabels.map((label, idx) => ( ))}
)}
setColor(e.target.value)} className="w-10 h-8 p-0 border rounded" />
{/* show guide */} {annotations.length === 0 && polygonPoints.length === 0 && !isDrawing && (
)}
{tool === 'polygon' && (
Polygon points: {polygonPoints.length}
)} {/* Annotations Table */}
{isAnnotationsOpen && (
{annotations.length === 0 ? (

No annotations yet. Draw on the image to create annotations.

) : ( {annotations.map((a) => ( {/* Annotation Column - shown in color it was drawn */} {/* Identified Checkbox Column */} {/* Source Column (Manual/AI) */} {/* Action Column - Accept/Reject for AI, Edit/Delete for Manual */} ))}
Annotation Identified Source Action
{editingId === a.id ? (
setEditColor(e.target.value)} className="w-6 h-6 rounded p-0 border cursor-pointer" /> setEditLabel(e.target.value)} className="px-2 py-1 border rounded text-xs flex-1" placeholder="Label" />
) : (
{a.label || '(no label)'}
{getShapeTypeName(a.type)}
)}
{ setAnnotations(prev => prev.map(ann => ann.id === a.id ? { ...ann, identified: e.target.checked } : ann )); }} className="w-4 h-4 rounded border-gray-300 cursor-pointer" /> {a.source === 'ai' ? 'AI' : 'Manual'}
{editingId === a.id ? ( <> ) : a.source === 'ai' ? ( <> {annotationAccepted[a.id] === true ? (
✓ Accepted
) : annotationAccepted[a.id] === false ? ( ✕ Rejected ) : ( <> )} ) : ( <> )}
)}
)}
); }); ImageAnnotatorComponent.displayName = 'ImageAnnotator'; export const ImageAnnotator = ImageAnnotatorComponent;