|
import { useState, useRef, useCallback, useEffect } from 'react' |
|
import { |
|
Upload, |
|
Eraser, |
|
Loader2, |
|
X, |
|
Eye, |
|
EyeOff, |
|
Image as ImageIcon |
|
} from 'lucide-react' |
|
import { |
|
ImageClassificationWorkerInput, |
|
WorkerMessage, |
|
ImageExample |
|
} from '../../types' |
|
import { useModel } from '../../contexts/ModelContext' |
|
import { useImageClassification } from '../../contexts/ImageClassificationContext' |
|
|
|
|
|
const SAMPLE_IMAGES = [ |
|
{ |
|
name: 'Cat', |
|
url: 'https://images.unsplash.com/photo-1514888286974-6c03e2ca1dba?w=300&h=300&fit=crop' |
|
}, |
|
{ |
|
name: 'Dog', |
|
url: 'https://images.unsplash.com/photo-1552053831-71594a27632d?w=300&h=300&fit=crop' |
|
}, |
|
{ |
|
name: 'Car', |
|
url: 'https://images.unsplash.com/photo-1605559424843-9e4c228bf1c2?w=300&h=300&fit=crop' |
|
}, |
|
{ |
|
name: 'Flower', |
|
url: 'https://images.unsplash.com/photo-1490750967868-88aa4486c946?w=300&h=300&fit=crop' |
|
} |
|
] |
|
|
|
function ImageClassification() { |
|
const { |
|
examples, |
|
selectedExample, |
|
setSelectedExample, |
|
addExample, |
|
removeExample, |
|
updateExample, |
|
clearExamples, |
|
config |
|
} = useImageClassification() |
|
|
|
const [isClassifying, setIsClassifying] = useState<boolean>(false) |
|
const [dragOver, setDragOver] = useState<boolean>(false) |
|
const [progress, setProgress] = useState<number | null>(null) |
|
|
|
const { |
|
activeWorker, |
|
status, |
|
modelInfo, |
|
hasBeenLoaded, |
|
selectedQuantization |
|
} = useModel() |
|
|
|
const fileInputRef = useRef<HTMLInputElement>(null) |
|
const dropZoneRef = useRef<HTMLDivElement>(null) |
|
|
|
const classifyImage = useCallback( |
|
async (example: ImageExample) => { |
|
if (!modelInfo || !activeWorker || !example.url) return |
|
|
|
updateExample(example.id, { isLoading: true }) |
|
setIsClassifying(true) |
|
setProgress(0) |
|
const message: ImageClassificationWorkerInput = { |
|
type: 'classify', |
|
image: example.url, |
|
model: modelInfo.id, |
|
dtype: selectedQuantization ?? 'fp32', |
|
config |
|
} |
|
|
|
activeWorker.postMessage(message) |
|
}, |
|
[modelInfo, activeWorker, selectedQuantization, config, updateExample] |
|
) |
|
|
|
const handleFileSelect = useCallback( |
|
(files: FileList | null) => { |
|
if (!files) return |
|
|
|
Array.from(files).forEach((file) => { |
|
if (file.type.startsWith('image/')) { |
|
addExample(file) |
|
} |
|
}) |
|
}, |
|
[addExample] |
|
) |
|
|
|
const handleDragOver = useCallback((e: React.DragEvent) => { |
|
e.preventDefault() |
|
setDragOver(true) |
|
}, []) |
|
|
|
const handleDragLeave = useCallback((e: React.DragEvent) => { |
|
e.preventDefault() |
|
setDragOver(false) |
|
}, []) |
|
|
|
const handleDrop = useCallback( |
|
(e: React.DragEvent) => { |
|
e.preventDefault() |
|
setDragOver(false) |
|
handleFileSelect(e.dataTransfer.files) |
|
}, |
|
[handleFileSelect] |
|
) |
|
|
|
const handleClassifyAll = useCallback(() => { |
|
const imagesToClassify = examples.filter( |
|
(ex) => !ex.predictions && !ex.isLoading |
|
) |
|
|
|
imagesToClassify.forEach((example) => { |
|
classifyImage(example) |
|
}) |
|
}, [examples, classifyImage]) |
|
|
|
const handleSelectExample = useCallback( |
|
(example: ImageExample) => { |
|
setSelectedExample(example) |
|
}, |
|
[setSelectedExample] |
|
) |
|
|
|
const handleLoadSampleImages = useCallback(async () => { |
|
const existstingImages = new Set(examples.map((ex) => ex.name)) |
|
for (const sample of SAMPLE_IMAGES) { |
|
if (existstingImages.has(sample.name)) continue |
|
try { |
|
const response = await fetch(sample.url) |
|
const blob = await response.blob() |
|
const file = new File([blob], sample.name, { type: blob.type }) |
|
addExample(file) |
|
} catch (error) { |
|
console.error(`Failed to load sample image ${sample.name}:`, error) |
|
} |
|
} |
|
}, [addExample, examples]) |
|
|
|
useEffect(() => { |
|
if (!activeWorker) return |
|
|
|
const onMessageReceived = (e: MessageEvent<WorkerMessage>) => { |
|
const { status, output, progress: workerProgress } = e.data |
|
if (status === 'progress' && workerProgress !== undefined) { |
|
setProgress(workerProgress) |
|
} else if (status === 'output' && output?.predictions) { |
|
|
|
const processingExample = examples.find((ex) => ex.isLoading) |
|
if (processingExample) { |
|
updateExample(processingExample.id, { |
|
predictions: output.predictions, |
|
isLoading: false |
|
}) |
|
} |
|
setIsClassifying(false) |
|
setProgress(null) |
|
} else if (status === 'error') { |
|
|
|
examples.forEach((ex) => { |
|
if (ex.isLoading) { |
|
updateExample(ex.id, { isLoading: false }) |
|
} |
|
}) |
|
setIsClassifying(false) |
|
setProgress(null) |
|
} |
|
} |
|
|
|
activeWorker.addEventListener('message', onMessageReceived) |
|
return () => activeWorker.removeEventListener('message', onMessageReceived) |
|
}, [activeWorker, examples, updateExample]) |
|
|
|
const busy = status !== 'ready' || isClassifying |
|
|
|
return ( |
|
<div className="flex flex-col h-full max-h-[calc(100dvh-128px)] w-full p-4"> |
|
<div className="flex items-center justify-between mb-4"> |
|
<h1 className="text-2xl font-bold">Image Classification</h1> |
|
<div className="flex gap-2"> |
|
<button |
|
onClick={handleLoadSampleImages} |
|
disabled={!hasBeenLoaded || isClassifying} |
|
className="px-3 py-2 bg-purple-100 hover:bg-purple-200 disabled:bg-gray-100 disabled:cursor-not-allowed rounded-lg transition-colors text-sm" |
|
title="Load Sample Images" |
|
> |
|
Load Samples |
|
</button> |
|
<button |
|
onClick={clearExamples} |
|
className="p-2 bg-red-100 hover:bg-red-200 rounded-lg transition-colors" |
|
title="Clear All Images" |
|
> |
|
<Eraser className="w-4 h-4" /> |
|
</button> |
|
</div> |
|
</div> |
|
|
|
<div className="flex flex-col lg:flex-row gap-4 flex-1 min-h-0 overflow-y-auto"> |
|
{/* Left Panel - Image Upload and List */} |
|
<div className="lg:w-1/2 flex flex-col"> |
|
{/* Upload Area */} |
|
<div className="mb-4"> |
|
<label className="block text-sm font-medium text-gray-700 mb-2"> |
|
Upload Images: |
|
</label> |
|
<div |
|
ref={dropZoneRef} |
|
onDragOver={handleDragOver} |
|
onDragLeave={handleDragLeave} |
|
onDrop={handleDrop} |
|
className={`border-2 border-dashed rounded-lg p-3 lg:p-6 text-center transition-colors cursor-pointer ${ |
|
dragOver |
|
? 'border-blue-500 bg-blue-50' |
|
: 'border-gray-300 hover:border-gray-400' |
|
} ${!hasBeenLoaded ? 'opacity-50 cursor-not-allowed' : ''}`} |
|
onClick={() => hasBeenLoaded && fileInputRef.current?.click()} |
|
> |
|
<div className="flex flex-row lg:flex-col"> |
|
<Upload className="w-8 h-8 mx-auto mb-2 text-gray-400" /> |
|
<div className='flex flex-col'> |
|
<p className="text-sm text-gray-600"> |
|
{dragOver |
|
? 'Drop images here' |
|
: 'Click to upload or drag and drop images'} |
|
</p> |
|
<p className="text-xs text-gray-500 mt-1"> |
|
Supports JPG, PNG, GIF, WebP |
|
</p> |
|
</div> |
|
</div> |
|
|
|
<input |
|
ref={fileInputRef} |
|
type="file" |
|
multiple |
|
accept="image/*" |
|
onChange={(e) => handleFileSelect(e.target.files)} |
|
className="hidden" |
|
disabled={!hasBeenLoaded} |
|
/> |
|
</div> |
|
</div> |
|
|
|
{/* Classify Button */} |
|
{examples.some((ex) => !ex.predictions) && ( |
|
<div className="mb-4"> |
|
<button |
|
onClick={handleClassifyAll} |
|
disabled={busy || !hasBeenLoaded} |
|
className="px-6 py-2 bg-green-500 hover:bg-green-600 disabled:bg-gray-300 disabled:cursor-not-allowed text-white rounded-lg transition-colors flex items-center gap-2" |
|
> |
|
{isClassifying ? ( |
|
<> |
|
<Loader2 className="w-4 h-4 animate-spin" /> |
|
Classifying... |
|
{progress !== null && ` (${Math.round(progress * 100)}%)`} |
|
</> |
|
) : ( |
|
'Classify Images' |
|
)} |
|
</button> |
|
</div> |
|
)} |
|
|
|
{/* Images List */} |
|
<div className="flex-1 overflow-y-auto border border-gray-300 rounded-lg bg-white min-h-0 max-h-[30vh] sm:max-h-[20vh] lg:max-h-none"> |
|
<div className="p-4"> |
|
<h3 className="text-sm font-medium text-gray-700 mb-3 sticky top-0 bg-white z-10"> |
|
Images ({examples.length}) |
|
</h3> |
|
{examples.length === 0 ? ( |
|
<div className="text-gray-500 italic text-center py-8"> |
|
No images uploaded yet. Upload some images above to get |
|
started. |
|
</div> |
|
) : ( |
|
<div className="overflow-y-auto max-h-[calc(100%-10rem)] grid-cols-2 grid sm:grid-cols-3 lg:grid-cols-1 gap-2 "> |
|
{examples.map((example) => ( |
|
<div |
|
key={example.id} |
|
className={`p-3 border rounded-lg cursor-pointer transition-colors ${ |
|
selectedExample?.id === example.id |
|
? 'border-blue-500 bg-blue-50' |
|
: 'border-gray-200 hover:border-gray-300' |
|
}`} |
|
onClick={() => handleSelectExample(example)} |
|
> |
|
<div className="flex gap-3"> |
|
<div className="shrink-0"> |
|
<img |
|
src={example.url} |
|
alt={example.name} |
|
className="w-16 h-16 object-cover rounded-lg" |
|
/> |
|
</div> |
|
<div className="flex-1 min-w-0"> |
|
<div className="flex justify-between items-start"> |
|
<div className="flex-1 min-w-0"> |
|
<div className="text-sm font-medium text-gray-800 truncate"> |
|
{example.name} |
|
</div> |
|
<div className="flex items-center gap-2 mt-1"> |
|
{example.isLoading ? ( |
|
<div className="flex items-center gap-1 text-xs text-blue-600"> |
|
<Loader2 className="w-3 h-3 animate-spin" /> |
|
Classifying... |
|
</div> |
|
) : example.predictions ? ( |
|
<div className="text-xs text-green-600"> |
|
✓ Classified |
|
</div> |
|
) : ( |
|
<div className="text-xs text-gray-500"> |
|
Not classified |
|
</div> |
|
)} |
|
</div> |
|
</div> |
|
<button |
|
onClick={(e) => { |
|
e.stopPropagation() |
|
removeExample(example.id) |
|
}} |
|
className="ml-2 p-1 text-red-500 hover:text-red-700 transition-colors" |
|
> |
|
<X className="w-3 h-3" /> |
|
</button> |
|
</div> |
|
</div> |
|
</div> |
|
</div> |
|
))} |
|
</div> |
|
)} |
|
</div> |
|
</div> |
|
</div> |
|
|
|
{/* Right Panel - Preview and Results */} |
|
<div className="lg:w-1/2 flex lg:flex-col flex-row space-x-4 sm:max-h-[34vh] lg:max-h-none"> |
|
{/* Image Preview */} |
|
{selectedExample && ( |
|
<div className="mb-4"> |
|
<h3 className="text-sm font-medium text-gray-700 mb-2"> |
|
Selected Image |
|
</h3> |
|
<div className="border-none sm:border border-gray-300 rounded-lg bg-white p-4 sm:p-0"> |
|
<div className="flex flex-col items-center"> |
|
<img |
|
src={selectedExample.url} |
|
alt={selectedExample.name} |
|
className="max-w-24 lg:max-w-full max-h-60 lg:max-h-64 object-contain rounded-lg mb-2" |
|
/> |
|
<div className="text-sm text-gray-600 text-center"> |
|
{selectedExample.name} |
|
</div> |
|
</div> |
|
</div> |
|
</div> |
|
)} |
|
|
|
{/* Classification Results */} |
|
<div className="flex-1 overflow-y-auto border border-gray-300 rounded-lg bg-white "> |
|
<div className="p-4"> |
|
<h3 className="text-sm font-medium text-gray-700 mb-3 sticky top-0 bg-white"> |
|
Classification Results |
|
{selectedExample && ` - ${selectedExample.name}`} |
|
</h3> |
|
{!selectedExample ? ( |
|
<div className="text-gray-500 italic text-center py-8"> |
|
<ImageIcon className="w-12 h-12 mx-auto mb-2 text-gray-300" /> |
|
Select an image to see classification results |
|
</div> |
|
) : selectedExample.isLoading ? ( |
|
<div className="text-center py-8"> |
|
<Loader2 className="w-8 h-8 animate-spin mx-auto mb-2 text-blue-500" /> |
|
<div className="text-sm text-gray-600"> |
|
Classifying image... |
|
</div> |
|
{progress !== null && ( |
|
<div className="text-xs text-gray-500 mt-1"> |
|
{Math.round(progress * 100)}% complete |
|
</div> |
|
)} |
|
</div> |
|
) : !selectedExample.predictions ? ( |
|
<div className="text-gray-500 italic text-center py-8"> |
|
<button |
|
onClick={() => classifyImage(selectedExample)} |
|
disabled={busy || !hasBeenLoaded} |
|
className="px-4 py-2 bg-blue-500 hover:bg-blue-600 disabled:bg-gray-300 disabled:cursor-not-allowed text-white rounded-lg transition-colors" |
|
> |
|
Classify This Image |
|
</button> |
|
</div> |
|
) : ( |
|
<div className="space-y-3 overflow-y-auto max-h-[calc(100%-3rem)]"> |
|
{selectedExample.predictions.map((prediction, index) => { |
|
const confidencePercent = (prediction.score * 100).toFixed( |
|
1 |
|
) |
|
const isTopPrediction = index === 0 |
|
|
|
return ( |
|
<div |
|
key={index} |
|
className={`p-3 border rounded-lg ${ |
|
isTopPrediction |
|
? 'border-green-300 bg-green-50' |
|
: 'border-gray-200' |
|
}`} |
|
> |
|
<div className="flex justify-between items-center mb-2"> |
|
<div className="flex items-center gap-2"> |
|
<span className="text-sm font-medium text-gray-800"> |
|
{prediction.label} |
|
</span> |
|
</div> |
|
<span className="text-sm font-medium text-gray-600"> |
|
{confidencePercent}% |
|
</span> |
|
</div> |
|
<div className="w-full bg-gray-200 rounded-full h-2"> |
|
<div |
|
className={`h-2 rounded-full transition-all duration-300 ${ |
|
isTopPrediction |
|
? 'bg-green-500' |
|
: prediction.score > 0.5 |
|
? 'bg-blue-500' |
|
: 'bg-gray-400' |
|
}`} |
|
style={{ |
|
width: `${Math.max(prediction.score * 100, 2)}%` |
|
}} |
|
/> |
|
</div> |
|
</div> |
|
) |
|
})} |
|
</div> |
|
)} |
|
</div> |
|
</div> |
|
</div> |
|
</div> |
|
|
|
{!hasBeenLoaded && ( |
|
<div className="text-center text-gray-500 text-sm mt-2"> |
|
Please load an image classification model first to start classifying |
|
images |
|
</div> |
|
)} |
|
|
|
{hasBeenLoaded && examples.length === 0 && ( |
|
<div className="text-center text-blue-600 text-sm mt-2"> |
|
💡 Tip: Click "Load Samples" to try with example images, or upload |
|
your own images above |
|
</div> |
|
)} |
|
</div> |
|
) |
|
} |
|
|
|
export default ImageClassification |
|
|