Spaces:
Sleeping
Sleeping
| import { useState } from "react"; | |
| /* Minimal inline SVG icon components to avoid requiring 'lucide-react' */ | |
| const DownloadIcon = ({ className }: { className?: string }) => ( | |
| <svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden> | |
| <path d="M12 3v12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/> | |
| <path d="M8 11l4 4 4-4" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/> | |
| <path d="M21 21H3" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/> | |
| </svg> | |
| ); | |
| const FileTextIcon = ({ className }: { className?: string }) => ( | |
| <svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden> | |
| <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/> | |
| <path d="M14 2v6h6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/> | |
| <path d="M16 13H8" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/> | |
| <path d="M16 17H8" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/> | |
| </svg> | |
| ); | |
| const Loader2Icon = ({ className }: { className?: string }) => ( | |
| <svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden> | |
| <circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" strokeOpacity="0.25"/> | |
| <path d="M22 12a10 10 0 0 0-10-10" stroke="currentColor" strokeWidth="4" strokeLinecap="round"/> | |
| </svg> | |
| ); | |
| import { ImageWithFallback } from "./ImageWithFallback"; | |
| import { ReportModal } from "./ReportModal"; | |
| import axios from "axios"; | |
| interface ResultsPanelProps { | |
| uploadedImage: string | null; | |
| result?: any; | |
| loading?: boolean; | |
| } | |
| export function ResultsPanel({ uploadedImage, result, loading }: ResultsPanelProps) { | |
| const [showReportModal, setShowReportModal] = useState(false); | |
| // Make loading detection robust: sometimes values arrive as the string "true" from deployed envs | |
| const isLoading = loading === true || String(loading) === "true"; | |
| // Helpful debug information when checking issues on deployed spaces (open browser devtools) | |
| // Keep as debug (console.debug) so it doesn't clutter normal logs. | |
| console.debug("ResultsPanel: props", { loading, isLoading, result }); | |
| const handleGenerateReport = async (formData: FormData) => { | |
| try { | |
| const baseURL = import.meta.env.MODE === "development" | |
| ? "http://127.0.0.1:7860" | |
| : window.location.origin; | |
| const response = await axios.post(`${baseURL}/reports/`, formData, { | |
| headers: { "Content-Type": "multipart/form-data" }, | |
| }); | |
| if (response.data.html_url) { | |
| // Open report in new tab | |
| window.open(`${baseURL}${response.data.html_url}`, "_blank"); | |
| } | |
| if (response.data.pdf_url) { | |
| // Open PDF in new tab when available | |
| window.open(`${baseURL}${response.data.pdf_url}`, "_blank"); | |
| } | |
| setShowReportModal(false); | |
| } catch (err: any) { | |
| console.error("Failed to generate report:", err); | |
| alert(err.response?.data?.error || "Failed to generate report"); | |
| } | |
| }; | |
| // Safely destructure result (keep undefined values when result is null) so we can render | |
| // a stable panel while loading and avoid early returns that change layout. | |
| const { | |
| model_used, | |
| detections, | |
| annotated_image_url, | |
| summary, | |
| // prediction (not used here) | |
| confidence, | |
| } = (result || {}) as any; | |
| const handleDownload = () => { | |
| if (annotated_image_url) { | |
| const link = document.createElement("a"); | |
| link.href = annotated_image_url; | |
| link.download = "analysis_result.jpg"; | |
| // For Firefox it is necessary to add the link to the DOM | |
| document.body.appendChild(link); | |
| link.click(); | |
| link.remove(); | |
| } | |
| }; | |
| // Precompute some helpers for rendering confidences | |
| const isCINModel = /cin/i.test(String(model_used || "")); | |
| // Determine predicted class from summary.prediction if available, otherwise pick the highest confidence | |
| const predictedClassFromConfidence = (conf: any) => { | |
| try { | |
| const entries = Object.entries(conf || {}); | |
| if (entries.length === 0) return ""; | |
| return entries.reduce((a: any, b: any) => (Number(a[1]) > Number(b[1]) ? a : b))[0]; | |
| } catch (e) { | |
| return ""; | |
| } | |
| }; | |
| // Prefer the class key present in the `confidence` object to ensure we use the exact key/casing | |
| // (this avoids showing all bars when `summary.prediction` has different casing/format). | |
| const predictedClass = predictedClassFromConfidence(confidence || {}) || | |
| ((summary && (summary.prediction || summary.result)) ? String(summary.prediction || summary.result) : ""); | |
| return ( | |
| <div className="bg-white rounded-lg shadow-sm p-6"> | |
| {/* Header */} | |
| <div className="flex items-center justify-between mb-6"> | |
| <div> | |
| <h2 className="text-2xl font-bold text-gray-800"> | |
| {model_used || "Analysis Result"} | |
| </h2> | |
| <p className="text-sm text-gray-500">Automated Image Analysis</p> | |
| </div> | |
| {/* header actions intentionally empty; Generate Report button moved below analysis details */} | |
| </div> | |
| {/* If we're loading, show a centered loader inside the panel */} | |
| {isLoading ? ( | |
| <div className="bg-white rounded-lg shadow-sm p-6 flex flex-col items-center justify-center"> | |
| <Loader2Icon className="w-10 h-10 text-blue-600 animate-spin mb-3" /> | |
| <p className="text-teal-700 font-medium">Analyzing image...</p> | |
| </div> | |
| ) : ( | |
| // Image | |
| <div className="relative mb-6 rounded-lg overflow-hidden border border-gray-200"> | |
| <ImageWithFallback | |
| src={annotated_image_url || uploadedImage || "/ui.jpg"} | |
| alt="Analysis Result" | |
| className="w-full h-64 object-cover" | |
| /> | |
| </div> | |
| )} | |
| {/* Summary Section - model-specific rendering (colposcopy, cytology, histopathology) */} | |
| {summary && (() => { | |
| const model = (model_used || "").toString(); | |
| const isColpo = /colpo|colposcopy/i.test(model); | |
| const isCyto = /cyto|cytology/i.test(model); | |
| const isHistoLike = /mwt|cin|histopath/i.test(model); | |
| // ------helper values | |
| const abnormalCount = Number(summary.abnormal_cells) || 0; | |
| const pred = (summary.prediction || summary.result || "").toString().toLowerCase(); | |
| const isAbnormal = abnormalCount > 0 || /abnormal|positive|high-grade|malignant/.test(pred); | |
| // Colposcopy: show only Abnormal / Normal (based on abnormal_cells count or prediction) | |
| if (isColpo) { | |
| return ( | |
| <div className="bg-gray-50 p-4 rounded-lg mb-6"> | |
| <h3 className="text-lg font-semibold text-gray-800 mb-2">AI Summary</h3> | |
| <p className="text-gray-700 text-sm"> | |
| <strong>Result:</strong> {isAbnormal ? "Abnormal" : "Normal"} | |
| </p> | |
| <div className="mt-3 text-gray-800 text-sm italic border-t pt-2"> | |
| {summary.ai_interpretation || "No AI interpretation available."} | |
| </div> | |
| </div> | |
| ); | |
| } | |
| // Cytology: keep existing detailed summary (abnormal/normal counts + avg confidence + interpretation) | |
| if (isCyto) { | |
| return ( | |
| <div className="bg-gray-50 p-4 rounded-lg mb-6"> | |
| <h3 className="text-lg font-semibold text-gray-800 mb-2">AI Summary</h3> | |
| <p className="text-gray-700 text-sm leading-relaxed"> | |
| {typeof summary.abnormal_cells !== 'undefined' && ( | |
| <><strong>Abnormal Cells:</strong> {summary.abnormal_cells} <br /></> | |
| )} | |
| {typeof summary.normal_cells !== 'undefined' && ( | |
| <><strong>Normal Cells:</strong> {summary.normal_cells} <br /></> | |
| )} | |
| {/* average confidence removed */} | |
| </p> | |
| <div className="mt-3 text-gray-800 text-sm italic border-t pt-2"> | |
| {summary.ai_interpretation || "No AI interpretation available."} | |
| </div> | |
| </div> | |
| ); | |
| } | |
| // Histopathology / CIN / MWT: show average confidence prominently + interpretation | |
| if (isHistoLike) { | |
| return ( | |
| <div className="bg-gray-50 p-4 rounded-lg mb-6"> | |
| <h3 className="text-lg font-semibold text-gray-800 mb-2">AI Summary</h3> | |
| <div className="mt-3 text-gray-800 text-sm italic border-t pt-2"> | |
| {summary.ai_interpretation || "No AI interpretation available."} | |
| </div> | |
| </div> | |
| ); | |
| } | |
| // Fallback: render only the fields that exist to avoid empty labels | |
| return ( | |
| <div className="bg-gray-50 p-4 rounded-lg mb-6"> | |
| <h3 className="text-lg font-semibold text-gray-800 mb-2">AI Summary</h3> | |
| <p className="text-gray-700 text-sm leading-relaxed"> | |
| {typeof summary.abnormal_cells !== 'undefined' && ( | |
| <><strong>Abnormal Cells:</strong> {summary.abnormal_cells} <br /></> | |
| )} | |
| {typeof summary.normal_cells !== 'undefined' && ( | |
| <><strong>Normal Cells:</strong> {summary.normal_cells} <br /></> | |
| )} | |
| </p> | |
| <div className="mt-3 text-gray-800 text-sm italic border-t pt-2"> | |
| {summary.ai_interpretation || "No AI interpretation available."} | |
| </div> | |
| </div> | |
| ); | |
| })()} | |
| {/* Detection list */} | |
| {detections && detections.length > 0 && ( | |
| <div className="mb-6"> | |
| <h4 className="font-semibold text-gray-900 mb-3"> | |
| Detected Objects | |
| </h4> | |
| <ul className="text-sm text-gray-700 list-disc list-inside space-y-1"> | |
| {detections.map((det: any, i: number) => ( | |
| <li key={i}> | |
| {det.name || "Object"} – {(det.confidence * 100).toFixed(1)}% | |
| </li> | |
| ))} | |
| </ul> | |
| </div> | |
| )} | |
| {/* Probability / MWT visualization */} | |
| {confidence && ( | |
| <div className="mb-6"> | |
| <h4 className="font-semibold text-gray-900 mb-3">Confidence Levels</h4> | |
| {/* If MWT, CIN, or Histopathology classifier, show a visual bar for average confidence and per-class bars */} | |
| {model_used && /mwt|cin|histopathology/i.test(model_used) ? ( | |
| <div> | |
| {/* Average confidence bar */} | |
| {/* Average confidence removed from visualization */} | |
| {/* Per-class bars */} | |
| <div className="space-y-2"> | |
| {isCINModel ? ( | |
| // For CIN/colposcopy classifiers, show ONLY the predicted grade (no bars for other classes) | |
| (() => { | |
| const cls = String(predictedClass || ""); | |
| const val = (confidence && (confidence as any)[cls]) || 0; | |
| const num = Number(val) || 0; | |
| const pct = num * 100; | |
| const isNegative = cls.toLowerCase().includes("negative") || | |
| cls.toLowerCase().includes("benign") || | |
| cls.toLowerCase().includes("low-grade") || | |
| cls.toLowerCase().includes("cin1"); | |
| if (!cls) { | |
| return <div className="text-sm text-gray-600">Prediction not available.</div>; | |
| } | |
| return ( | |
| <div key={cls}> | |
| <div className="flex items-center justify-between text-sm mb-1"> | |
| <span className="text-gray-700">{cls}</span> | |
| <span className="text-gray-600">{pct.toFixed(2)}%</span> | |
| </div> | |
| <div className="w-full bg-gray-100 rounded-full h-3"> | |
| <div | |
| className={`h-3 rounded-full ${isNegative ? "bg-green-500" : "bg-red-500"}`} | |
| style={{ width: `${pct.toFixed(2)}%` }} | |
| /> | |
| </div> | |
| </div> | |
| ); | |
| })() | |
| ) : ( | |
| Object.entries(confidence).map(([cls, val]) => { | |
| const num = Number(val as any) || 0; | |
| const pct = (num * 100); | |
| // Color coding: Positive/Malignant/High-grade = red, Negative/Benign/Low-grade = green | |
| const isNegative = cls.toLowerCase().includes("negative") || | |
| cls.toLowerCase().includes("benign") || | |
| cls.toLowerCase().includes("low-grade"); | |
| return ( | |
| <div key={cls}> | |
| <div className="flex items-center justify-between text-sm mb-1"> | |
| <span className="text-gray-700">{cls}</span> | |
| <span className="text-gray-600">{pct.toFixed(2)}%</span> | |
| </div> | |
| <div className="w-full bg-gray-100 rounded-full h-3"> | |
| <div | |
| className={`h-3 rounded-full ${isNegative ? "bg-green-500" : "bg-red-500"}`} | |
| style={{ width: `${pct.toFixed(2)}%` }} | |
| /> | |
| </div> | |
| </div> | |
| ); | |
| }) | |
| )} | |
| </div> | |
| {/* Mistral comment */} | |
| <div className="mt-4 bg-gray-50 p-3 rounded-lg text-sm italic text-gray-800 border-t"> | |
| {summary?.ai_interpretation || "No AI interpretation available."} | |
| </div> | |
| </div> | |
| ) : ( | |
| // Fallback display for non-MWT models | |
| <pre className="bg-gray-100 rounded-lg p-3 text-sm overflow-x-auto"> | |
| {JSON.stringify(confidence, null, 2)} | |
| </pre> | |
| )} | |
| </div> | |
| )} | |
| {/* Report Generation Modal */} | |
| {/* Show only when not loading and we have at least a result (so Generate Report is available even if summary/confidence are missing) */} | |
| {!isLoading && result && ( | |
| <div className="flex items-center justify-end mb-6"> | |
| {annotated_image_url && ( | |
| <button | |
| onClick={handleDownload} | |
| className="flex items-center gap-2 mr-3 bg-gradient-to-r from-teal-700 via-teal-600 to-teal-700 text-white px-4 py-2 rounded-lg hover:opacity-90 transition-all" | |
| > | |
| <DownloadIcon className="w-4 h-4" /> | |
| Download Image | |
| </button> | |
| )} | |
| <button | |
| onClick={() => setShowReportModal(true)} | |
| className="flex items-center gap-2 bg-gradient-to-r from-teal-700 via-teal-600 to-teal-700 text-white px-4 py-2 rounded-lg hover:opacity-90 transition-all" | |
| > | |
| <FileTextIcon className="w-4 h-4" /> | |
| Generate Report | |
| </button> | |
| </div> | |
| )} | |
| <ReportModal | |
| isOpen={showReportModal} | |
| onClose={() => setShowReportModal(false)} | |
| onSubmit={handleGenerateReport} | |
| analysisId={annotated_image_url || ""} | |
| // Include annotated_image_url in the analysis summary so the backend can embed it | |
| analysisSummaryJson={summary ? JSON.stringify({ ...summary, model_used, confidence, annotated_image_url }) : "{}"} | |
| /> | |
| </div> | |
| ); | |
| } | |