Initial commit - BraceIQMed platform with frontend, API, and brace generator

This commit is contained in:
2026-01-29 14:34:05 -08:00
commit 745f9f827f
187 changed files with 534688 additions and 0 deletions

View 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>
);
}