From 40cf0e44e08a21205b90d72a03629de21de3f4ff Mon Sep 17 00:00:00 2001 From: deepak Date: Sun, 14 Jun 2026 15:03:35 +0530 Subject: [PATCH] feat(dashboard): add interactive gradcam heatmap overlay --- src/pages/AnalysisDashboard.tsx | 416 ++++++++++++++++++++++++++------ src/pages/ScannerPage.tsx | 10 + 2 files changed, 358 insertions(+), 68 deletions(-) diff --git a/src/pages/AnalysisDashboard.tsx b/src/pages/AnalysisDashboard.tsx index f6005dc..23a5f69 100644 --- a/src/pages/AnalysisDashboard.tsx +++ b/src/pages/AnalysisDashboard.tsx @@ -1,6 +1,6 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useState, useRef, useCallback } from 'react'; import { Link, useSearchParams } from 'react-router-dom'; -import { ArrowLeft, AlertTriangle, Droplets, Eye as EyeIcon, Fish } from 'lucide-react'; +import { ArrowLeft, AlertTriangle, Droplets, Eye as EyeIcon, Fish, MoveHorizontal, Sparkles, Image as ImageIcon } from 'lucide-react'; import GlassCard from '../components/GlassCard'; import StatusTerminal from '../components/StatusTerminal'; import { api } from '../lib/api'; @@ -20,6 +20,280 @@ function gradeColor(grade: string) { return 'text-error'; } +// ── Fish Image Overlay Component with Grad-CAM support ──────────────────────── + +interface FishImageOverlayProps { + photoUrl?: string | null; + scanId: string; +} + +function FishImageOverlay({ photoUrl, scanId }: FishImageOverlayProps) { + const [originalSrc, setOriginalSrc] = useState(null); + const [heatmapSrc, setHeatmapSrc] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [viewMode, setViewMode] = useState<'toggle' | 'slider'>('slider'); + const [showHeatmap, setShowHeatmap] = useState(false); + const [sliderPos, setSliderPos] = useState(50); + const [isDragging, setIsDragging] = useState(false); + const containerRef = useRef(null); + + useEffect(() => { + async function fetchHeatmap() { + let imgSrc = photoUrl; + const lastScanId = sessionStorage.getItem('lastScanId'); + const lastScanImage = sessionStorage.getItem('lastScanImage'); + if (lastScanId === scanId && lastScanImage) { + imgSrc = lastScanImage; + } + + if (!imgSrc) { + return; + } + + setOriginalSrc(imgSrc); + setLoading(true); + setError(null); + + try { + const response = await fetch(imgSrc); + const blob = await response.blob(); + const res = await api.getGradcam(blob); + setHeatmapSrc(res.gradcam_image); + } catch (err: unknown) { + console.error('Failed to load GradCAM overlay:', err); + setError(err instanceof Error ? err.message : 'Failed to generate heatmap.'); + } finally { + setLoading(false); + } + } + + fetchHeatmap(); + }, [photoUrl, scanId]); + + const onMove = useCallback((clientX: number) => { + if (!containerRef.current) return; + const rect = containerRef.current.getBoundingClientRect(); + const x = clientX - rect.left; + const percentage = Math.max(0, Math.min(100, (x / rect.width) * 100)); + setSliderPos(percentage); + }, []); + + const startDrag = useCallback((e: React.MouseEvent | React.TouchEvent) => { + e.preventDefault(); + setIsDragging(true); + if ('touches' in e && e.touches[0]) { + onMove(e.touches[0].clientX); + } else if ('clientX' in e) { + onMove(e.clientX); + } + }, [onMove]); + + useEffect(() => { + const handleGlobalMove = (e: MouseEvent) => { + if (isDragging) onMove(e.clientX); + }; + const handleGlobalTouchMove = (e: TouchEvent) => { + if (isDragging && e.touches[0]) onMove(e.touches[0].clientX); + }; + const handleGlobalUp = () => setIsDragging(false); + + if (isDragging) { + window.addEventListener('mousemove', handleGlobalMove); + window.addEventListener('touchmove', handleGlobalTouchMove, { passive: false }); + window.addEventListener('mouseup', handleGlobalUp); + window.addEventListener('touchend', handleGlobalUp); + } + return () => { + window.removeEventListener('mousemove', handleGlobalMove); + window.removeEventListener('touchmove', handleGlobalTouchMove); + window.removeEventListener('mouseup', handleGlobalUp); + window.removeEventListener('touchend', handleGlobalUp); + }; + }, [isDragging, onMove]); + + const handleContainerClick = useCallback((e: React.MouseEvent) => { + if ((e.target as HTMLElement).closest('.slider-handle')) return; + onMove(e.clientX); + }, [onMove]); + + if (loading) { + return ( + +
+
+
+ + +
+ + ); + } + + if (!originalSrc) { + return ( + +
+ +

+ NO_IMAGE_AVAILABLE +

+
+
+ ); + } + + return ( + + {/* Header */} +
+
+ + + XAI_VISUALIZER + + {error && ( + + GRAD_CAM_ERROR + + )} +
+ + {heatmapSrc && ( +
+ + +
+ )} +
+ + {/* Viewport container */} +
+ {/* Original (Always in background) */} + Original specimen scan + + {/* Heatmap Overlay */} + {heatmapSrc && ( + Grad-CAM activation overlay + )} + + {/* Slider Controls */} + {heatmapSrc && viewMode === 'slider' && ( + <> + {/* Slider bar */} +
+ {/* Drag Handle */} +
+ +
+ + )} + + {/* Heatmap Legend */} + {heatmapSrc && (viewMode === 'slider' || showHeatmap) && ( +
+ {['#3b82f6', '#22c55e', '#eab308', '#ef4444'].map((c, i) => ( +
+ ))} + + LOW → HIGH ATTN + +
+ )} +
+ + {/* Footer controls */} + {heatmapSrc ? ( + viewMode === 'toggle' ? ( +
+
+ + +
+
+ ) : ( +
+ ← DRAG SLIDER HORIZONTALLY TO REVEAL NEURAL FOCUS → +
+ ) + ) : ( + originalSrc && ( +
+ SPECIMEN SCAN CAPTURE +
+ ) + )} + + ); +} + + export default function AnalysisDashboard() { const [params] = useSearchParams(); const [scan, setScan] = useState(null); @@ -102,86 +376,92 @@ export default function AnalysisDashboard() { className="mb-6" /> - {/* Score + Species row */} -
- {/* Main score card */} - -
- - GRADE_{grade} - -
+ {/* Main Content Grid: Image visualizer on left, metrics on right */} +
+ {/* Left: Image Overlay visualizer */} + - - Freshness_Index - + {/* Right: Score and Species cards stacked */} +
+ {/* Main score card */} + +
+ + GRADE_{grade} + +
-
- - {freshness_index} - - - /100 + + Freshness_Index -
- -
-
-
-
- - CLASSIFICATION: {classification} - - - CONFIDENCE: {confidence}% - -
- +
+ + {freshness_index} + + + /100 + +
- {/* Species panel */} - - - Detected_Specimen - +
+
+
-
- {species.tags.map(tag => ( - - {tag} +
+ + CLASSIFICATION: {classification} - ))} -
- -
-
- WEIGHT_EST - - ~{species.weight_estimate_kg} kg + + CONFIDENCE: {confidence}%
-
- CATCH_AGE - - ~{species.catch_age_hours} hrs - + + + {/* Species panel */} + + + Detected_Specimen + + +
+ {species.tags.map(tag => ( + + {tag} + + ))}
- {scan.market_name && ( + +
- MARKET + WEIGHT_EST - {scan.market_name} + ~{species.weight_estimate_kg} kg
- )} -
-
+
+ CATCH_AGE + + ~{species.catch_age_hours} hrs + +
+ {scan.market_name && ( +
+ MARKET + + {scan.market_name} + +
+ )} +
+ +
{/* Biomarkers — 3 model-native streams */} diff --git a/src/pages/ScannerPage.tsx b/src/pages/ScannerPage.tsx index f357e5e..24115f2 100644 --- a/src/pages/ScannerPage.tsx +++ b/src/pages/ScannerPage.tsx @@ -74,6 +74,16 @@ export default function ScannerPage() { const result = await api.submitScan(blob); stopProgressBar(100); sessionStorage.setItem('lastScanId', result.scan.scan_id); + + // Save scanned image base64 in sessionStorage for immediate dashboard rendering + const reader = new FileReader(); + reader.onload = (e) => { + if (e.target?.result) { + sessionStorage.setItem('lastScanImage', e.target.result as string); + } + }; + reader.readAsDataURL(blob); + setFreshness(result.scan.freshness_index); setScanPhase('done'); // Auto-navigate to analysis after a short "done" flash