diff --git a/src/pages/AnalysisDashboard.tsx b/src/pages/AnalysisDashboard.tsx index 771227f..dbb2607 100644 --- a/src/pages/AnalysisDashboard.tsx +++ b/src/pages/AnalysisDashboard.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } 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, Loader2 } from 'lucide-react'; import GlassCard from '../components/GlassCard'; import StatusTerminal from '../components/StatusTerminal'; import { api } from '../lib/api'; @@ -26,6 +26,14 @@ export default function AnalysisDashboard() { const [loading, setLoading] = useState(true); const [error, setError] = useState(''); + const [gradcamImage, setGradcamImage] = useState(null); + const [gradcamLoading, setGradcamLoading] = useState(false); + const [gradcamError, setGradcamError] = useState(null); + const [blendOpacity, setBlendOpacity] = useState(0.5); + const [retryTrigger, setRetryTrigger] = useState(0); + + const photo_url = scan?.photo_url; + useEffect(() => { async function load() { setLoading(true); @@ -49,6 +57,47 @@ export default function AnalysisDashboard() { load(); }, [params]); + useEffect(() => { + if (!photo_url) return; + const url = photo_url; + + let isMounted = true; + async function loadGradcam() { + setGradcamLoading(true); + setGradcamError(null); + try { + const res = await fetch(url); + if (!res.ok) { + throw new Error(`Failed to download scan image (${res.status})`); + } + const blob = await res.blob(); + const gradcamRes = await api.getGradcam(blob); + if (isMounted) { + setGradcamImage(gradcamRes.gradcam_image); + } + } catch (err) { + console.error('Grad-CAM generation error:', err); + if (isMounted) { + setGradcamError(err instanceof Error ? err.message : 'Heatmap generation failed.'); + } + } finally { + if (isMounted) { + setGradcamLoading(false); + } + } + } + + loadGradcam(); + + return () => { + isMounted = false; + }; + }, [photo_url, retryTrigger]); + + const handleRetry = () => { + setRetryTrigger(prev => prev + 1); + }; + // ── Loading state ──────────────────────────────────────────────────────── if (loading) { return ( @@ -102,96 +151,323 @@ export default function AnalysisDashboard() { className="mb-6" /> - {/* Score + Species row */} + {/* Score + Species row / Visualization */}
- {/* Main score card */} - -
- - GRADE_{grade} - -
+ {photo_url ? ( + <> + {/* Left Column: Image Overlay Card */} + +
+ + SCAN_VISUALIZATION + - - Freshness_Index - + {/* Stacked Images container */} +
+ {/* Viewfinder corner brackets */} +
+
+
+
-
- - {freshness_index} - - - /100 - -
- -
-
-
- -
- - CLASSIFICATION: {classification} - + {/* Base Original Image */} + Original Fish Scan - - CONFIDENCE: {confidence}% - + {/* Heatmap Overlay Image */} + {gradcamImage && ( + GradCAM Heatmap Overlay + )} - - {confidence < 70 ? "LOW_CONFIDENCE" : "HIGH_CONFIDENCE"} - -
- - - {/* Species panel */} - - - Detected_Specimen - - -
- {species.tags.map(tag => ( - - {tag} - - ))} -
- -
-
- WEIGHT_EST - - ~{species.weight_estimate_kg} kg - + {/* Legend overlay */} + {blendOpacity > 0 && gradcamImage && ( +
+ {['#3b82f6', '#22c55e', '#eab308', '#ef4444'].map((c, i) => ( +
+ ))} + + LOW → HIGH + +
+ )} + + {/* Loading overlay */} + {gradcamLoading && ( +
+ + + GENERATING_HEATMAP... + +
+ )} + + {/* Error overlay */} + {gradcamError && ( +
+ + + ACTIVATION_MAP_ERROR + +

+ {gradcamError} +

+ +
+ )} +
+
+ +
+ {/* Toggle Buttons */} +
+ + + +
+ + {/* Opacity slider */} +
+
+ BLEND LEVEL + {Math.round(blendOpacity * 100)}% +
+ setBlendOpacity(parseFloat(e.target.value))} + className="w-full accent-neon bg-surface-lowest border border-outline-variant/30 cursor-pointer h-1.5 outline-none" + /> +
+
+ + + {/* Right Column: Score & Species stacked */} +
+ {/* Main score card */} + +
+ + GRADE_{grade} + +
+ + + Freshness_Index + + +
+ + {freshness_index} + + + /100 + +
+ +
+
+
+ +
+ + CLASSIFICATION: {classification} + + + + CONFIDENCE: {confidence}% + + + + {confidence < 70 ? "LOW_CONFIDENCE" : "HIGH_CONFIDENCE"} + +
+ + + {/* Species panel */} + + + Detected_Specimen + + +
+ {species.tags.map(tag => ( + + {tag} + + ))} +
+ +
+
+ WEIGHT_EST + + ~{species.weight_estimate_kg} kg + +
+
+ CATCH_AGE + + ~{species.catch_age_hours} hrs + +
+ {scan.market_name && ( +
+ MARKET + + {scan.market_name} + +
+ )} +
+
-
- CATCH_AGE - - ~{species.catch_age_hours} hrs + + ) : ( + <> + {/* Main score card */} + +
+ + GRADE_{grade} + +
+ + + Freshness_Index -
- {scan.market_name && ( -
- MARKET - - {scan.market_name} + +
+ + {freshness_index} + + /100 + +
+ +
+
- )} -
- + +
+ + CLASSIFICATION: {classification} + + + + CONFIDENCE: {confidence}% + + + + {confidence < 70 ? "LOW_CONFIDENCE" : "HIGH_CONFIDENCE"} + +
+ + + {/* Species panel */} + + + Detected_Specimen + + +
+ {species.tags.map(tag => ( + + {tag} + + ))} +
+ +
+
+ WEIGHT_EST + + ~{species.weight_estimate_kg} kg + +
+
+ CATCH_AGE + + ~{species.catch_age_hours} hrs + +
+ {scan.market_name && ( +
+ MARKET + + {scan.market_name} + +
+ )} +
+
+ + )}
{/* Biomarkers — 3 model-native streams */}