Initial commit - BraceIQMed platform with frontend, API, and brace generator
This commit is contained in:
552
frontend/src/pages/PipelineCaseDetail.tsx
Normal file
552
frontend/src/pages/PipelineCaseDetail.tsx
Normal file
@@ -0,0 +1,552 @@
|
||||
/**
|
||||
* Pipeline Case Detail Page
|
||||
* 3-stage pipeline: Landmarks → Analysis → Brace
|
||||
*/
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
fetchCase,
|
||||
uploadXrayForCase,
|
||||
detectLandmarks,
|
||||
updateLandmarks,
|
||||
approveLandmarks,
|
||||
recalculateAnalysis,
|
||||
generateBraceFromLandmarks,
|
||||
generateBothBraces,
|
||||
updateMarkers,
|
||||
getCaseAssets,
|
||||
uploadBodyScan,
|
||||
getBodyScan,
|
||||
deleteBodyScan,
|
||||
skipBodyScan,
|
||||
} from '../api/braceflowApi';
|
||||
import type {
|
||||
CaseRecord,
|
||||
LandmarksResult,
|
||||
RecalculationResult,
|
||||
GenerateBraceResponse,
|
||||
VertebraeStructure,
|
||||
BodyScanResponse,
|
||||
} from '../api/braceflowApi';
|
||||
|
||||
import PipelineSteps, { type PipelineStage } from '../components/pipeline/PipelineSteps';
|
||||
import LandmarkDetectionStage from '../components/pipeline/LandmarkDetectionStage';
|
||||
import SpineAnalysisStage from '../components/pipeline/SpineAnalysisStage';
|
||||
import BodyScanUploadStage from '../components/pipeline/BodyScanUploadStage';
|
||||
import BraceGenerationStage from '../components/pipeline/BraceGenerationStage';
|
||||
import BraceFittingStage from '../components/pipeline/BraceFittingStage';
|
||||
|
||||
export default function PipelineCaseDetail() {
|
||||
const { caseId } = useParams<{ caseId: string }>();
|
||||
const nav = useNavigate();
|
||||
|
||||
// Case data
|
||||
const [caseData, setCaseData] = useState<CaseRecord | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Stage data
|
||||
const [xrayUrl, setXrayUrl] = useState<string | null>(null);
|
||||
const [landmarksData, setLandmarksData] = useState<LandmarksResult | null>(null);
|
||||
const [analysisData, setAnalysisData] = useState<RecalculationResult | null>(null);
|
||||
const [bodyScanData, setBodyScanData] = useState<BodyScanResponse | null>(null);
|
||||
const [braceData, setBraceData] = useState<GenerateBraceResponse | null>(null);
|
||||
|
||||
// Current stage and loading states
|
||||
const [currentStage, setCurrentStage] = useState<PipelineStage>('upload');
|
||||
const [detectingLandmarks, setDetectingLandmarks] = useState(false);
|
||||
const [recalculating, setRecalculating] = useState(false);
|
||||
const [uploadingBodyScan, setUploadingBodyScan] = useState(false);
|
||||
const [generatingBrace, setGeneratingBrace] = useState(false);
|
||||
|
||||
// Load case data
|
||||
const loadCase = useCallback(async () => {
|
||||
if (!caseId) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const data = await fetchCase(caseId);
|
||||
setCaseData(data);
|
||||
|
||||
// Load assets
|
||||
const assets = await getCaseAssets(caseId).catch((err) => {
|
||||
console.error('Failed to get case assets:', err);
|
||||
return null;
|
||||
});
|
||||
console.log('Case assets:', assets);
|
||||
const xray = assets?.assets?.uploads?.find((f) =>
|
||||
f.filename.match(/\.(jpg|jpeg|png)$/i)
|
||||
);
|
||||
console.log('Found xray:', xray);
|
||||
if (xray) {
|
||||
console.log('Setting xrayUrl to:', xray.url);
|
||||
setXrayUrl(xray.url);
|
||||
}
|
||||
|
||||
// Check existing state from case data
|
||||
if (data?.landmarks_data) {
|
||||
// Handle both formats - full LandmarksResult or just VertebraeStructure
|
||||
const ld = data.landmarks_data as any;
|
||||
if (ld.vertebrae_structure) {
|
||||
// Full LandmarksResult format
|
||||
setLandmarksData(ld as LandmarksResult);
|
||||
} else if (ld.vertebrae || ld.all_levels) {
|
||||
// Just VertebraeStructure - wrap it
|
||||
setLandmarksData({
|
||||
case_id: caseId || '',
|
||||
status: 'landmarks_detected',
|
||||
input: { image_dimensions: { width: 0, height: 0 }, pixel_spacing_mm: null },
|
||||
detection_quality: {
|
||||
vertebrae_count: ld.detected_count || ld.vertebrae?.filter((v: any) => v.detected).length || 0,
|
||||
average_confidence: 0
|
||||
},
|
||||
cobb_angles: ld.cobb_angles || { PT: 0, MT: 0, TL: 0, max: 0, PT_severity: 'N/A', MT_severity: 'N/A', TL_severity: 'N/A' },
|
||||
rigo_classification: ld.rigo_classification || { type: 'Unknown', description: '' },
|
||||
curve_type: ld.curve_type || 'Unknown',
|
||||
vertebrae_structure: ld as VertebraeStructure,
|
||||
processing_time_ms: 0
|
||||
} as LandmarksResult);
|
||||
}
|
||||
}
|
||||
|
||||
// Load saved analysis data if available (from previous recalculate)
|
||||
if (data?.analysis_data) {
|
||||
setAnalysisData(data.analysis_data as RecalculationResult);
|
||||
}
|
||||
|
||||
// Load body scan data if available
|
||||
if (data?.body_scan_path || data?.body_scan_url) {
|
||||
setBodyScanData({
|
||||
caseId: caseId || '',
|
||||
has_body_scan: true,
|
||||
body_scan: {
|
||||
path: data.body_scan_path || '',
|
||||
url: data.body_scan_url || '',
|
||||
metadata: data.body_scan_metadata || {}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Load brace data if available
|
||||
let hasBraceData = false;
|
||||
if (data?.analysis_result) {
|
||||
const analysisResult = data.analysis_result as any;
|
||||
// Check for both braces format (regular + vase)
|
||||
if (analysisResult.braces) {
|
||||
setBraceData({
|
||||
rigo_classification: { type: analysisResult.rigoType || 'A1' },
|
||||
cobb_angles: analysisResult.cobbAngles,
|
||||
braces: analysisResult.braces,
|
||||
} as any);
|
||||
hasBraceData = true;
|
||||
}
|
||||
// Check for single brace format (legacy)
|
||||
else if (analysisResult.brace) {
|
||||
setBraceData(analysisResult.brace as GenerateBraceResponse);
|
||||
hasBraceData = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Determine current stage - prioritize actual data over status
|
||||
// This ensures the UI reflects reality even if status is out of sync
|
||||
if (hasBraceData) {
|
||||
// Brace exists - go straight to brace stage
|
||||
setCurrentStage('brace');
|
||||
} else {
|
||||
// Determine stage from status
|
||||
switch (data?.status) {
|
||||
case 'created':
|
||||
setCurrentStage(xray ? 'landmarks' : 'upload');
|
||||
break;
|
||||
case 'landmarks_detected':
|
||||
// Landmarks detected but not approved - stay on landmarks stage
|
||||
setCurrentStage('landmarks');
|
||||
break;
|
||||
case 'landmarks_approved':
|
||||
// Landmarks approved - move to analysis stage
|
||||
setCurrentStage('analysis');
|
||||
break;
|
||||
case 'analysis_complete':
|
||||
// Analysis complete - move to body scan stage
|
||||
setCurrentStage('bodyscan');
|
||||
break;
|
||||
case 'body_scan_uploaded':
|
||||
// Body scan uploaded - still on body scan stage (need to continue)
|
||||
setCurrentStage('bodyscan');
|
||||
break;
|
||||
case 'processing_brace':
|
||||
// Currently generating brace
|
||||
setCurrentStage('brace');
|
||||
break;
|
||||
case 'brace_generated':
|
||||
case 'completed':
|
||||
// Brace already generated - show brace stage
|
||||
setCurrentStage('brace');
|
||||
break;
|
||||
default:
|
||||
setCurrentStage(xray ? 'landmarks' : 'upload');
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
setError(e?.message || 'Failed to load case');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [caseId]);
|
||||
|
||||
useEffect(() => {
|
||||
loadCase();
|
||||
}, [loadCase]);
|
||||
|
||||
// Handle file upload
|
||||
const handleUpload = async (file: File) => {
|
||||
if (!caseId) return;
|
||||
try {
|
||||
await uploadXrayForCase(caseId, file);
|
||||
// Reload to get new X-ray URL
|
||||
await loadCase();
|
||||
} catch (e: any) {
|
||||
setError(e?.message || 'Upload failed');
|
||||
}
|
||||
};
|
||||
|
||||
// Stage 1: Detect landmarks
|
||||
const handleDetectLandmarks = async () => {
|
||||
if (!caseId) return;
|
||||
setDetectingLandmarks(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await detectLandmarks(caseId);
|
||||
setLandmarksData(result);
|
||||
setCurrentStage('landmarks');
|
||||
} catch (e: any) {
|
||||
setError(e?.message || 'Landmark detection failed');
|
||||
} finally {
|
||||
setDetectingLandmarks(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Stage 1: Update landmarks
|
||||
const handleUpdateLandmarks = async (landmarks: VertebraeStructure) => {
|
||||
if (!caseId) return;
|
||||
try {
|
||||
await updateLandmarks(caseId, landmarks);
|
||||
// Update local state
|
||||
if (landmarksData) {
|
||||
setLandmarksData({
|
||||
...landmarksData,
|
||||
vertebrae_structure: landmarks,
|
||||
});
|
||||
}
|
||||
} catch (e: any) {
|
||||
setError(e?.message || 'Failed to save landmarks');
|
||||
}
|
||||
};
|
||||
|
||||
// Stage 1 -> 2: Approve landmarks
|
||||
const handleApproveLandmarks = async (updatedLandmarks?: VertebraeStructure) => {
|
||||
if (!caseId) return;
|
||||
try {
|
||||
await approveLandmarks(caseId, updatedLandmarks);
|
||||
setCurrentStage('analysis');
|
||||
} catch (e: any) {
|
||||
setError(e?.message || 'Failed to approve landmarks');
|
||||
}
|
||||
};
|
||||
|
||||
// Stage 2: Recalculate analysis
|
||||
const handleRecalculate = async () => {
|
||||
if (!caseId) return;
|
||||
setRecalculating(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await recalculateAnalysis(caseId);
|
||||
setAnalysisData(result);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || 'Recalculation failed');
|
||||
} finally {
|
||||
setRecalculating(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Stage 2 -> 3: Continue to body scan
|
||||
const handleContinueToBodyScan = () => {
|
||||
setCurrentStage('bodyscan');
|
||||
};
|
||||
|
||||
// Stage 3: Upload body scan
|
||||
const handleUploadBodyScan = async (file: File) => {
|
||||
if (!caseId) return;
|
||||
setUploadingBodyScan(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await uploadBodyScan(caseId, file);
|
||||
setBodyScanData({
|
||||
caseId,
|
||||
has_body_scan: true,
|
||||
body_scan: result.body_scan
|
||||
});
|
||||
} catch (e: any) {
|
||||
setError(e?.message || 'Body scan upload failed');
|
||||
} finally {
|
||||
setUploadingBodyScan(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Stage 3: Skip body scan
|
||||
const handleSkipBodyScan = async () => {
|
||||
if (!caseId) return;
|
||||
try {
|
||||
await skipBodyScan(caseId);
|
||||
setBodyScanData({ caseId, has_body_scan: false, body_scan: null });
|
||||
setCurrentStage('brace');
|
||||
} catch (e: any) {
|
||||
setError(e?.message || 'Failed to skip body scan');
|
||||
}
|
||||
};
|
||||
|
||||
// Stage 3: Delete body scan
|
||||
const handleDeleteBodyScan = async () => {
|
||||
if (!caseId) return;
|
||||
try {
|
||||
await deleteBodyScan(caseId);
|
||||
setBodyScanData({ caseId, has_body_scan: false, body_scan: null });
|
||||
} catch (e: any) {
|
||||
setError(e?.message || 'Failed to delete body scan');
|
||||
}
|
||||
};
|
||||
|
||||
// Stage 3 -> 4: Continue to brace generation
|
||||
const handleContinueToBrace = () => {
|
||||
setCurrentStage('brace');
|
||||
};
|
||||
|
||||
// Stage 4 -> 5: Continue to fitting inspection
|
||||
const handleContinueToFitting = () => {
|
||||
setCurrentStage('fitting');
|
||||
};
|
||||
|
||||
// Handle clicking on pipeline step to navigate
|
||||
const handleStageClick = (stage: PipelineStage) => {
|
||||
setCurrentStage(stage);
|
||||
};
|
||||
|
||||
// Stage 3: Generate brace (both regular and vase types)
|
||||
const handleGenerateBrace = async () => {
|
||||
if (!caseId) return;
|
||||
setGeneratingBrace(true);
|
||||
setError(null);
|
||||
|
||||
let standardResult: any = null;
|
||||
|
||||
try {
|
||||
// First try to generate the standard brace (for backward compatibility)
|
||||
try {
|
||||
standardResult = await generateBraceFromLandmarks(caseId, {
|
||||
experiment: 'experiment_9',
|
||||
});
|
||||
} catch (stdErr) {
|
||||
console.warn('Standard brace generation failed, trying both braces:', stdErr);
|
||||
}
|
||||
|
||||
// Generate both brace types (regular + vase) with markers
|
||||
const bothBraces = await generateBothBraces(caseId, {
|
||||
rigoType: standardResult?.rigo_classification?.type,
|
||||
});
|
||||
|
||||
// Merge both braces data into the result
|
||||
setBraceData({
|
||||
...(standardResult || {}),
|
||||
rigo_classification: standardResult?.rigo_classification || { type: bothBraces.rigoType },
|
||||
cobb_angles: standardResult?.cobb_angles || bothBraces.cobbAngles,
|
||||
braces: bothBraces.braces,
|
||||
} as any);
|
||||
|
||||
} catch (e: any) {
|
||||
// If both fail, show error
|
||||
if (standardResult) {
|
||||
// At least we have the standard result
|
||||
setBraceData(standardResult);
|
||||
} else {
|
||||
setError(e?.message || 'Brace generation failed');
|
||||
}
|
||||
} finally {
|
||||
setGeneratingBrace(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Stage 3: Update markers
|
||||
const handleUpdateMarkers = async (markers: Record<string, unknown>) => {
|
||||
if (!caseId) return;
|
||||
try {
|
||||
await updateMarkers(caseId, markers);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || 'Failed to save markers');
|
||||
}
|
||||
};
|
||||
|
||||
// Loading state
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="pipeline-page">
|
||||
<div className="pipeline-loading">
|
||||
<div className="spinner"></div>
|
||||
<p>Loading case...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (error && !caseData) {
|
||||
return (
|
||||
<div className="pipeline-page">
|
||||
<div className="pipeline-error">
|
||||
<p className="error">{error}</p>
|
||||
<button className="btn secondary" onClick={() => nav('/')}>
|
||||
Back to Dashboard
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const visualizationUrl =
|
||||
landmarksData?.visualization_url ||
|
||||
(landmarksData as any)?.visualization_path?.replace(/^.*[\\\/]/, '/files/outputs/' + caseId + '/');
|
||||
|
||||
return (
|
||||
<div className="pipeline-page">
|
||||
{/* Header */}
|
||||
<header className="pipeline-header">
|
||||
<div className="header-left">
|
||||
<button className="back-btn" onClick={() => nav('/')}>
|
||||
← Back
|
||||
</button>
|
||||
<h1 className="case-title">{caseId}</h1>
|
||||
<span className={`status-badge status-${caseData?.status || 'created'}`}>
|
||||
{caseData?.status?.replace(/_/g, ' ') || 'Created'}
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Pipeline Steps Indicator */}
|
||||
<PipelineSteps
|
||||
currentStage={currentStage}
|
||||
landmarksApproved={
|
||||
caseData?.status === 'landmarks_approved' ||
|
||||
caseData?.status === 'analysis_complete' ||
|
||||
caseData?.status === 'body_scan_uploaded' ||
|
||||
caseData?.status === 'brace_generated'
|
||||
}
|
||||
analysisComplete={
|
||||
caseData?.status === 'analysis_complete' ||
|
||||
caseData?.status === 'body_scan_uploaded' ||
|
||||
caseData?.status === 'brace_generated'
|
||||
}
|
||||
bodyScanComplete={
|
||||
caseData?.status === 'body_scan_uploaded' ||
|
||||
caseData?.status === 'brace_generated'
|
||||
}
|
||||
braceGenerated={caseData?.status === 'brace_generated'}
|
||||
onStageClick={handleStageClick}
|
||||
/>
|
||||
|
||||
{/* Error Banner */}
|
||||
{error && (
|
||||
<div className="error-banner">
|
||||
<span>{error}</span>
|
||||
<button onClick={() => setError(null)}>×</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upload Section (if no X-ray) */}
|
||||
{!xrayUrl && currentStage === 'upload' && (
|
||||
<div className="upload-section">
|
||||
<div className="upload-box">
|
||||
<h2>Upload X-ray Image</h2>
|
||||
<p>Upload a frontal (AP) X-ray image to begin analysis.</p>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) handleUpload(file);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stage Components */}
|
||||
<div className="pipeline-stages">
|
||||
{(currentStage === 'landmarks' || xrayUrl) && (
|
||||
<LandmarkDetectionStage
|
||||
caseId={caseId || ''}
|
||||
landmarksData={landmarksData}
|
||||
xrayUrl={xrayUrl}
|
||||
visualizationUrl={visualizationUrl}
|
||||
isLoading={detectingLandmarks}
|
||||
onDetect={handleDetectLandmarks}
|
||||
onApprove={handleApproveLandmarks}
|
||||
onUpdateLandmarks={handleUpdateLandmarks}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(currentStage === 'analysis' || currentStage === 'bodyscan' || currentStage === 'brace') && (
|
||||
<SpineAnalysisStage
|
||||
landmarksData={landmarksData}
|
||||
analysisData={analysisData}
|
||||
isLoading={recalculating}
|
||||
onRecalculate={handleRecalculate}
|
||||
onContinue={handleContinueToBodyScan}
|
||||
/>
|
||||
)}
|
||||
|
||||
{(currentStage === 'bodyscan' || currentStage === 'brace') && (
|
||||
<BodyScanUploadStage
|
||||
caseId={caseId || ''}
|
||||
bodyScanData={bodyScanData}
|
||||
isLoading={uploadingBodyScan}
|
||||
onUpload={handleUploadBodyScan}
|
||||
onSkip={handleSkipBodyScan}
|
||||
onContinue={handleContinueToBrace}
|
||||
onDelete={handleDeleteBodyScan}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentStage === 'brace' && (
|
||||
<BraceGenerationStage
|
||||
caseId={caseId || ''}
|
||||
braceData={braceData}
|
||||
isLoading={generatingBrace}
|
||||
onGenerate={handleGenerateBrace}
|
||||
onUpdateMarkers={handleUpdateMarkers}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Stage 5: Brace Fitting - shows when brace is generated AND body scan exists */}
|
||||
{(currentStage === 'brace' || currentStage === 'fitting') && braceData && bodyScanData?.has_body_scan && (
|
||||
<BraceFittingStage
|
||||
caseId={caseId || ''}
|
||||
bodyScanUrl={bodyScanData?.body_scan?.url || null}
|
||||
regularBraceUrl={
|
||||
(braceData as any)?.braces?.regular?.outputs?.stl ||
|
||||
(braceData as any)?.braces?.regular?.outputs?.glb ||
|
||||
braceData?.outputs?.stl ||
|
||||
null
|
||||
}
|
||||
vaseBraceUrl={
|
||||
(braceData as any)?.braces?.vase?.outputs?.stl ||
|
||||
(braceData as any)?.braces?.vase?.outputs?.glb ||
|
||||
null
|
||||
}
|
||||
braceData={braceData}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user