Files
braceiqmed/brace-generator/routes.py

991 lines
34 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
API routes for Brace Generator.
Note: S3 operations are handled by the Lambda function.
This server only handles ML inference and returns local file paths.
"""
import torch
from fastapi import APIRouter, HTTPException, UploadFile, File, Form, Request
from fastapi.responses import FileResponse
from typing import Optional
import json
from pathlib import Path
from .schemas import (
AnalysisResult, HealthResponse, ExperimentType, BraceConfigRequest
)
from .config import config
router = APIRouter()
@router.get("/", summary="Root endpoint")
async def root():
"""Welcome endpoint."""
return {
"service": "Brace Generator API",
"version": "1.0.0",
"docs": "/docs",
"health": "/health"
}
@router.get("/health", response_model=HealthResponse, summary="Health check")
async def health_check():
"""Check server health and GPU status."""
cuda_available = torch.cuda.is_available()
gpu_name = None
gpu_memory_mb = None
if cuda_available:
gpu_name = torch.cuda.get_device_name(0)
gpu_memory_mb = int(torch.cuda.get_device_properties(0).total_memory / (1024**2))
return HealthResponse(
status="healthy",
device=config.get_device(),
cuda_available=cuda_available,
model_loaded=True,
gpu_name=gpu_name,
gpu_memory_mb=gpu_memory_mb
)
@router.post("/analyze/upload", response_model=AnalysisResult, summary="Analyze uploaded X-ray")
async def analyze_upload(
req: Request,
file: UploadFile = File(..., description="X-ray image file"),
case_id: Optional[str] = Form(None, description="Case ID"),
experiment: str = Form("experiment_3", description="Experiment type"),
config_json: Optional[str] = Form(None, description="Brace config as JSON"),
landmarks_json: Optional[str] = Form(None, description="Pre-computed landmarks with manual edits")
):
"""
Analyze an uploaded X-ray image and generate brace.
This endpoint accepts multipart/form-data for direct file upload.
Returns analysis results with local file paths that can be downloaded
via the /download endpoint.
If landmarks_json is provided, it will use those landmarks (with manual edits)
instead of re-running automatic detection. This allows manual corrections
to be incorporated into the brace generation.
The Lambda function is responsible for:
1. Downloading the X-ray from S3
2. Calling this endpoint
3. Downloading output files via /download
4. Uploading files to S3
"""
# Validate file
if not file.filename:
raise HTTPException(status_code=400, detail="No file provided")
# Check file size
contents = await file.read()
if len(contents) > config.MAX_IMAGE_SIZE_MB * 1024 * 1024:
raise HTTPException(
status_code=400,
detail=f"File too large. Maximum size is {config.MAX_IMAGE_SIZE_MB}MB"
)
# Parse config if provided
brace_config = None
if config_json:
try:
config_data = json.loads(config_json)
brace_config = BraceConfigRequest(**config_data)
except (json.JSONDecodeError, ValueError) as e:
raise HTTPException(status_code=400, detail=f"Invalid config: {e}")
# Parse landmarks if provided (manual edits)
landmarks_data = None
if landmarks_json:
try:
landmarks_data = json.loads(landmarks_json)
except json.JSONDecodeError as e:
raise HTTPException(status_code=400, detail=f"Invalid landmarks JSON: {e}")
# Parse experiment type
try:
exp_type = ExperimentType(experiment)
except ValueError:
exp_type = ExperimentType.EXPERIMENT_3
service = req.app.state.brace_service
try:
result = await service.analyze_from_bytes(
image_data=contents,
filename=file.filename,
experiment=exp_type,
case_id=case_id,
brace_config=brace_config,
landmarks_data=landmarks_data # Pass pre-computed landmarks
)
return result
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/download/{case_id}/{filename}", summary="Download output file")
async def download_file(case_id: str, filename: str):
"""
Download a generated output file.
This endpoint is called by the Lambda function to retrieve
generated files (STL, PLY, PNG, JSON) for upload to S3.
"""
file_path = config.TEMP_DIR / case_id / filename
if not file_path.exists():
raise HTTPException(status_code=404, detail=f"File not found: {filename}")
# Determine media type
ext = file_path.suffix.lower()
media_types = {
".stl": "application/octet-stream",
".ply": "application/octet-stream",
".obj": "application/octet-stream",
".glb": "model/gltf-binary",
".gltf": "model/gltf+json",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".json": "application/json",
}
media_type = media_types.get(ext, "application/octet-stream")
return FileResponse(
path=str(file_path),
filename=filename,
media_type=media_type
)
@router.post("/extract-body-measurements", summary="Extract body measurements from 3D scan")
async def extract_body_measurements(
file: UploadFile = File(..., description="3D body scan file (STL/OBJ/PLY)")
):
"""
Extract body measurements from a 3D body scan.
Returns measurements needed for brace fitting:
- Total height
- Shoulder, chest, waist, hip widths and depths
- Circumferences
- Brace coverage region
"""
import tempfile
from pathlib import Path
try:
from server_DEV.body_integration import extract_measurements_from_scan
except ImportError as e:
raise HTTPException(status_code=500, detail=f"Body integration module not available: {e}")
# Validate file type
allowed_extensions = ['.stl', '.obj', '.ply', '.glb', '.gltf']
ext = Path(file.filename).suffix.lower() if file.filename else '.stl'
if ext not in allowed_extensions:
raise HTTPException(
status_code=400,
detail=f"Invalid file type. Allowed: {', '.join(allowed_extensions)}"
)
# Save to temp file
contents = await file.read()
with tempfile.NamedTemporaryFile(suffix=ext, delete=False) as f:
f.write(contents)
temp_path = f.name
try:
measurements = extract_measurements_from_scan(temp_path)
return measurements
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
finally:
# Cleanup
Path(temp_path).unlink(missing_ok=True)
@router.post("/generate-with-body", summary="Generate brace with body scan fitting")
async def generate_with_body_scan(
req: Request,
xray_file: UploadFile = File(..., description="X-ray image"),
body_scan_file: UploadFile = File(..., description="3D body scan (STL/OBJ/PLY)"),
case_id: Optional[str] = Form(None, description="Case ID"),
landmarks_json: Optional[str] = Form(None, description="Pre-computed landmarks"),
clearance_mm: float = Form(8.0, description="Shell clearance in mm"),
):
"""
Generate a patient-specific brace using X-ray analysis and 3D body scan.
This endpoint:
1. Analyzes X-ray to detect spine landmarks and compute Cobb angles
2. Classifies curve type using Rigo-Cheneau system
3. Fits a shell template to the 3D body scan
4. Returns STL, GLB, and visualization files
"""
import tempfile
import uuid
from pathlib import Path
try:
from server_DEV.body_integration import generate_fitted_brace, extract_measurements_from_scan
except ImportError as e:
raise HTTPException(status_code=500, detail=f"Body integration module not available: {e}")
# Generate case ID if not provided
case_id = case_id or f"case_{uuid.uuid4().hex[:8]}"
# Save files to temp directory
temp_dir = config.TEMP_DIR / case_id
temp_dir.mkdir(parents=True, exist_ok=True)
# Save X-ray
xray_contents = await xray_file.read()
xray_ext = Path(xray_file.filename).suffix if xray_file.filename else '.jpg'
xray_path = temp_dir / f"xray{xray_ext}"
xray_path.write_bytes(xray_contents)
# Save body scan
body_contents = await body_scan_file.read()
body_ext = Path(body_scan_file.filename).suffix if body_scan_file.filename else '.stl'
body_scan_path = temp_dir / f"body_scan{body_ext}"
body_scan_path.write_bytes(body_contents)
try:
# Parse landmarks if provided
landmarks_data = None
if landmarks_json:
import json
landmarks_data = json.loads(landmarks_json)
# Step 1: Analyze X-ray to get Rigo classification (this generates the brace)
service = req.app.state.brace_service
xray_result = await service.analyze_from_bytes(
image_data=xray_contents,
filename=xray_file.filename,
experiment=ExperimentType.EXPERIMENT_3,
case_id=case_id,
landmarks_data=landmarks_data
)
rigo_type = xray_result.rigo_classification.type if xray_result.rigo_classification else "A1"
# Step 2: Try to extract body measurements (optional - EXPERIMENT_10 may not be deployed)
body_measurements = None
fitting_result = None
body_scan_error = None
try:
body_measurements = extract_measurements_from_scan(str(body_scan_path))
# Step 3: Generate fitted brace (only if measurements worked)
fitting_result = generate_fitted_brace(
body_scan_path=str(body_scan_path),
rigo_type=rigo_type,
output_dir=str(temp_dir),
case_id=case_id,
clearance_mm=clearance_mm
)
except Exception as body_err:
print(f"Warning: Body scan processing failed, using X-ray only: {body_err}")
body_scan_error = str(body_err)
# If body fitting worked, return full result
if fitting_result:
return {
"case_id": case_id,
"experiment": "experiment_10",
"model_used": xray_result.model_used,
"vertebrae_detected": xray_result.vertebrae_detected,
"cobb_angles": {
"PT": xray_result.cobb_angles.PT,
"MT": xray_result.cobb_angles.MT,
"TL": xray_result.cobb_angles.TL,
},
"curve_type": xray_result.curve_type,
"rigo_classification": {
"type": rigo_type,
"description": xray_result.rigo_classification.description if xray_result.rigo_classification else ""
},
"body_scan": {
"measurements": body_measurements,
},
"brace_fitting": fitting_result,
"outputs": {
"shell_stl": fitting_result["outputs"]["shell_stl"],
"shell_glb": fitting_result["outputs"]["shell_glb"],
"combined_stl": fitting_result["outputs"]["combined_stl"],
"visualization": fitting_result["outputs"].get("visualization"),
"feedback_json": fitting_result["outputs"]["feedback_json"],
"xray_visualization": str(xray_result.outputs.get("visualization", "")),
},
"mesh_vertices": fitting_result["mesh_stats"]["vertices"],
"mesh_faces": fitting_result["mesh_stats"]["faces"],
"processing_time_ms": xray_result.processing_time_ms,
}
# Fallback: return X-ray only result (body scan processing not available)
return {
"case_id": case_id,
"experiment": "experiment_3_fallback",
"model_used": xray_result.model_used,
"vertebrae_detected": xray_result.vertebrae_detected,
"cobb_angles": {
"PT": xray_result.cobb_angles.PT,
"MT": xray_result.cobb_angles.MT,
"TL": xray_result.cobb_angles.TL,
},
"curve_type": xray_result.curve_type,
"rigo_classification": {
"type": rigo_type,
"description": xray_result.rigo_classification.description if xray_result.rigo_classification else ""
},
"body_scan": {
"error": body_scan_error or "Body scan processing not available",
"fallback": "Using X-ray only brace generation"
},
"outputs": xray_result.outputs,
"mesh_vertices": xray_result.mesh_vertices,
"mesh_faces": xray_result.mesh_faces,
"processing_time_ms": xray_result.processing_time_ms,
}
except Exception as e:
import traceback
traceback.print_exc()
raise HTTPException(status_code=500, detail=str(e))
@router.get("/experiments", summary="List available experiments")
async def list_experiments():
"""List available brace generation experiments."""
return {
"experiments": [
{
"id": "standard",
"name": "Standard Pipeline",
"description": "Original template-based brace generation using Rigo classification"
},
{
"id": "experiment_3",
"name": "Research-Based Adaptive",
"description": "Adaptive brace generation based on Guy et al. (2024) with patch-based deformation optimization"
},
{
"id": "experiment_10",
"name": "Patient-Specific Body Fitting",
"description": "X-ray analysis + 3D body scan for precise patient-specific brace fitting"
}
],
"default": "experiment_3"
}
@router.get("/models", summary="List available detection models")
async def list_models():
"""List available landmark detection models."""
return {
"models": [
{
"id": "scoliovis",
"name": "ScolioVis",
"description": "Keypoint R-CNN model for vertebrae detection",
"supports_gpu": True
},
{
"id": "vertebra-landmark",
"name": "Vertebra-Landmark-Detection",
"description": "SpineNet-based detection (alternative)",
"supports_gpu": True
}
],
"current": config.MODEL
}
# ============================================
# NEW ENDPOINTS FOR PIPELINE DEV
# ============================================
@router.post("/detect-landmarks", summary="Detect landmarks only (Stage 1)")
async def detect_landmarks(
req: Request,
file: UploadFile = File(..., description="X-ray image file"),
case_id: Optional[str] = Form(None, description="Case ID"),
):
"""
Detect vertebrae landmarks without generating a brace.
Returns landmarks, visualization, and vertebrae_structure for manual editing.
This is Stage 1 of the pipeline - just detection, no brace generation.
"""
if not file.filename:
raise HTTPException(status_code=400, detail="No file provided")
contents = await file.read()
if len(contents) > config.MAX_IMAGE_SIZE_MB * 1024 * 1024:
raise HTTPException(
status_code=400,
detail=f"File too large. Maximum size is {config.MAX_IMAGE_SIZE_MB}MB"
)
service = req.app.state.brace_service
try:
result = await service.detect_landmarks_only(
image_data=contents,
filename=file.filename,
case_id=case_id
)
return result
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/recalculate", summary="Recalculate Cobb/Rigo from landmarks")
async def recalculate_analysis(req: Request):
"""
Recalculate Cobb angles and Rigo classification from provided landmarks.
Use this after manual landmark editing to get updated analysis.
Request body:
{
"case_id": "case-xxx",
"landmarks": { ... vertebrae_structure from detect-landmarks ... }
}
"""
body = await req.json()
case_id = body.get("case_id")
landmarks = body.get("landmarks")
if not landmarks:
raise HTTPException(status_code=400, detail="landmarks data required")
service = req.app.state.brace_service
try:
result = await service.recalculate_from_landmarks(
landmarks_data=landmarks,
case_id=case_id
)
return result
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# =============================================================================
# GLB BRACE GENERATION WITH MARKERS
# =============================================================================
from .glb_generator import (
generate_glb_brace,
generate_both_brace_types,
list_available_templates,
calculate_pressure_zones,
load_template_markers,
AVAILABLE_RIGO_TYPES
)
@router.get("/templates", summary="List available brace templates")
async def list_templates():
"""
List all available brace templates (regular and vase types).
Returns which Rigo types have templates available.
"""
return {
"available_templates": list_available_templates(),
"rigo_types": AVAILABLE_RIGO_TYPES,
"template_types": ["regular", "vase"]
}
@router.get("/templates/{rigo_type}/markers", summary="Get template markers")
async def get_template_markers(
rigo_type: str,
template_type: str = "regular"
):
"""
Get marker positions for a specific template.
Args:
rigo_type: Rigo classification (A1, A2, A3, B1, B2, C1, C2, E1, E2)
template_type: "regular" or "vase"
Returns:
Marker positions and basis vectors
"""
if rigo_type not in AVAILABLE_RIGO_TYPES:
raise HTTPException(
status_code=400,
detail=f"Invalid rigo_type. Must be one of: {AVAILABLE_RIGO_TYPES}"
)
if template_type not in ["regular", "vase"]:
raise HTTPException(
status_code=400,
detail="template_type must be 'regular' or 'vase'"
)
try:
markers = load_template_markers(rigo_type, template_type)
return {
"rigo_type": rigo_type,
"template_type": template_type,
**markers
}
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
@router.post("/generate-glb", summary="Generate GLB brace with markers")
async def generate_glb_endpoint(
req: Request,
rigo_type: str = Form(..., description="Rigo classification (A1-E2)"),
template_type: str = Form("regular", description="Template type: 'regular' or 'vase'"),
case_id: str = Form(..., description="Case identifier"),
cobb_pt: float = Form(0.0, description="Proximal Thoracic Cobb angle"),
cobb_mt: float = Form(0.0, description="Main Thoracic Cobb angle"),
cobb_tl: float = Form(0.0, description="Thoracolumbar Cobb angle"),
body_scan: Optional[UploadFile] = File(None, description="Optional 3D body scan STL")
):
"""
Generate a GLB brace with embedded markers.
This endpoint generates a brace file that includes marker positions
for later editing. Optionally fits to a body scan.
**Pressure Zones in Output:**
- LM_PAD_TH: Thoracic pad (pushes INWARD on curve convex side)
- LM_BAY_TH: Thoracic bay (creates SPACE on curve concave side)
- LM_PAD_LUM: Lumbar pad (pushes INWARD)
- LM_BAY_LUM: Lumbar bay (creates SPACE)
- LM_ANCHOR_HIP_L/R: Hip anchors (stabilize brace)
Returns:
GLB and STL file paths, marker positions, pressure zone info
"""
if rigo_type not in AVAILABLE_RIGO_TYPES:
raise HTTPException(
status_code=400,
detail=f"Invalid rigo_type. Must be one of: {AVAILABLE_RIGO_TYPES}"
)
if template_type not in ["regular", "vase"]:
raise HTTPException(
status_code=400,
detail="template_type must be 'regular' or 'vase'"
)
import tempfile
from pathlib import Path
output_dir = Path(tempfile.gettempdir()) / "brace_generator" / case_id
output_dir.mkdir(parents=True, exist_ok=True)
body_scan_path = None
# Save body scan if provided
if body_scan:
body_ext = Path(body_scan.filename).suffix if body_scan.filename else ".stl"
body_scan_path = str(output_dir / f"body_scan{body_ext}")
with open(body_scan_path, "wb") as f:
content = await body_scan.read()
f.write(content)
cobb_angles = {
"PT": cobb_pt,
"MT": cobb_mt,
"TL": cobb_tl
}
try:
result = generate_glb_brace(
rigo_type=rigo_type,
template_type=template_type,
output_dir=output_dir,
case_id=case_id,
cobb_angles=cobb_angles,
body_scan_path=body_scan_path,
clearance_mm=8.0
)
return {
"success": True,
"case_id": case_id,
"rigo_type": rigo_type,
"template_type": template_type,
"outputs": {
"glb": result.glb_path,
"stl": result.stl_path,
"json": result.json_path
},
"markers": result.markers,
"basis": result.basis,
"pressure_zones": result.pressure_zones,
"mesh_stats": result.mesh_stats,
"body_fitting": result.transform_applied
}
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/generate-both-braces", summary="Generate both brace types for comparison")
async def generate_both_braces_endpoint(
req: Request,
rigo_type: str = Form(..., description="Rigo classification (A1-E2)"),
case_id: str = Form(..., description="Case identifier"),
cobb_pt: float = Form(0.0, description="Proximal Thoracic Cobb angle"),
cobb_mt: float = Form(0.0, description="Main Thoracic Cobb angle"),
cobb_tl: float = Form(0.0, description="Thoracolumbar Cobb angle"),
body_scan: Optional[UploadFile] = File(None, description="Optional 3D body scan STL"),
body_scan_path: Optional[str] = Form(None, description="Optional path to existing body scan file"),
clearance_mm: float = Form(8.0, description="Brace clearance from body in mm")
):
"""
Generate BOTH regular and vase brace types for side-by-side comparison.
This allows the user to compare the two brace shapes and choose
the preferred design.
Returns:
Both brace files with markers and pressure zones
"""
if rigo_type not in AVAILABLE_RIGO_TYPES:
raise HTTPException(
status_code=400,
detail=f"Invalid rigo_type. Must be one of: {AVAILABLE_RIGO_TYPES}"
)
import tempfile
from pathlib import Path
output_dir = Path(tempfile.gettempdir()) / "brace_generator" / case_id
output_dir.mkdir(parents=True, exist_ok=True)
final_body_scan_path = None
# Save body scan if uploaded as file
if body_scan:
body_ext = Path(body_scan.filename).suffix if body_scan.filename else ".stl"
final_body_scan_path = str(output_dir / f"body_scan{body_ext}")
with open(final_body_scan_path, "wb") as f:
content = await body_scan.read()
f.write(content)
# Or use provided path if it exists
elif body_scan_path and Path(body_scan_path).exists():
final_body_scan_path = body_scan_path
print(f"Using existing body scan at: {body_scan_path}")
cobb_angles = {
"PT": cobb_pt,
"MT": cobb_mt,
"TL": cobb_tl
}
try:
results = generate_both_brace_types(
rigo_type=rigo_type,
output_dir=output_dir,
case_id=case_id,
cobb_angles=cobb_angles,
body_scan_path=final_body_scan_path,
clearance_mm=clearance_mm
)
response = {
"success": True,
"case_id": case_id,
"rigo_type": rigo_type,
"cobb_angles": cobb_angles,
"body_scan_used": final_body_scan_path is not None,
"braces": {}
}
for brace_type, result in results.items():
if isinstance(result, dict) and "error" in result:
response["braces"][brace_type] = result
else:
response["braces"][brace_type] = {
"outputs": {
"glb": result.glb_path,
"stl": result.stl_path,
"json": result.json_path
},
"markers": result.markers,
"pressure_zones": result.pressure_zones,
"mesh_stats": result.mesh_stats
}
return response
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/pressure-zones/{rigo_type}", summary="Get pressure zone information")
async def get_pressure_zones(
rigo_type: str,
template_type: str = "regular",
cobb_mt: float = 25.0,
cobb_tl: float = 15.0
):
"""
Get detailed pressure zone information for a Rigo type.
This explains WHERE and HOW MUCH pressure is applied based on
the Cobb angles.
**Pressure Zone Types:**
- **PAD (Push Zone)**: Pushes INWARD on the convex side of the curve
to apply corrective force. Depth increases with Cobb angle severity.
- **BAY (Expansion Zone)**: Creates SPACE on the concave side for the
body to shift into during correction. Clearance is ~1.3x pad depth.
- **ANCHOR (Stability Zone)**: Grips the pelvis to prevent the brace
from riding up. Light inward pressure.
Returns:
Detailed pressure zone descriptions with depths in mm
"""
if rigo_type not in AVAILABLE_RIGO_TYPES:
raise HTTPException(
status_code=400,
detail=f"Invalid rigo_type. Must be one of: {AVAILABLE_RIGO_TYPES}"
)
try:
markers = load_template_markers(rigo_type, template_type)
zones = calculate_pressure_zones(
markers,
rigo_type,
{"PT": 0, "MT": cobb_mt, "TL": cobb_tl}
)
return {
"rigo_type": rigo_type,
"template_type": template_type,
"cobb_angles": {"MT": cobb_mt, "TL": cobb_tl},
"pressure_zones": [
{
"name": z.name,
"marker": z.marker_name,
"position": list(z.position),
"type": z.zone_type,
"direction": z.direction,
"function": z.function,
"depth_mm": round(z.depth_mm, 1),
"radius_mm": list(z.radius_mm)
}
for z in zones
],
"explanation": {
"pad_depth": f"Based on Cobb angle severity: {cobb_mt}° MT → {round(8 + min(max((cobb_mt - 10) / 40, 0), 1) * 14, 1)}mm thoracic pad",
"bay_clearance": "Bay clearance = 1.3 × pad depth + 4-5mm to allow body movement",
"hip_anchors": "4mm inward pressure to grip pelvis and stabilize brace"
}
}
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
# =============================================================================
# DEV MODE: LOCAL FILE STORAGE AND SERVING
# =============================================================================
# Local storage directory for DEV mode
DEV_STORAGE_DIR = config.TEMP_DIR / "dev_storage"
DEV_STORAGE_DIR.mkdir(parents=True, exist_ok=True)
@router.post("/cases", summary="Create a new case (DEV)")
async def create_case():
"""Create a new case with a generated ID (DEV mode)."""
import uuid
from datetime import datetime
case_id = f"case-{datetime.now().strftime('%Y%m%d')}-{uuid.uuid4().hex[:8]}"
case_dir = DEV_STORAGE_DIR / case_id
(case_dir / "uploads").mkdir(parents=True, exist_ok=True)
(case_dir / "outputs").mkdir(parents=True, exist_ok=True)
# Save case metadata
metadata = {
"case_id": case_id,
"created_at": datetime.now().isoformat(),
"status": "created"
}
(case_dir / "case.json").write_text(json.dumps(metadata, indent=2))
return {"caseId": case_id, "status": "created"}
@router.get("/cases/{case_id}", summary="Get case details (DEV)")
async def get_case(case_id: str):
"""Get case details (DEV mode)."""
case_dir = DEV_STORAGE_DIR / case_id
if not case_dir.exists():
raise HTTPException(status_code=404, detail=f"Case not found: {case_id}")
metadata_file = case_dir / "case.json"
if metadata_file.exists():
metadata = json.loads(metadata_file.read_text())
else:
metadata = {"case_id": case_id, "status": "unknown"}
return metadata
@router.post("/cases/{case_id}/upload", summary="Upload X-ray for case (DEV)")
async def upload_xray(
case_id: str,
file: UploadFile = File(..., description="X-ray image file")
):
"""Upload X-ray image for a case (DEV mode - saves locally)."""
case_dir = DEV_STORAGE_DIR / case_id
uploads_dir = case_dir / "uploads"
uploads_dir.mkdir(parents=True, exist_ok=True)
# Determine extension from filename
ext = Path(file.filename).suffix.lower() if file.filename else ".jpg"
if ext not in [".jpg", ".jpeg", ".png", ".webp"]:
ext = ".jpg"
# Save as xray.{ext}
xray_path = uploads_dir / f"xray{ext}"
contents = await file.read()
xray_path.write_bytes(contents)
# Update case metadata
metadata_file = case_dir / "case.json"
if metadata_file.exists():
metadata = json.loads(metadata_file.read_text())
else:
metadata = {"case_id": case_id}
metadata["xray_uploaded"] = True
metadata["xray_filename"] = f"xray{ext}"
metadata_file.write_text(json.dumps(metadata, indent=2))
return {
"filename": f"xray{ext}",
"path": f"/files/uploads/{case_id}/xray{ext}"
}
@router.get("/cases/{case_id}/assets", summary="Get case assets (DEV)")
async def get_case_assets(case_id: str):
"""List all uploaded and output files for a case (DEV mode)."""
case_dir = DEV_STORAGE_DIR / case_id
if not case_dir.exists():
raise HTTPException(status_code=404, detail=f"Case not found: {case_id}")
uploads = []
outputs = []
# List uploads
uploads_dir = case_dir / "uploads"
if uploads_dir.exists():
for f in uploads_dir.iterdir():
if f.is_file():
uploads.append({
"filename": f.name,
"url": f"/files/uploads/{case_id}/{f.name}"
})
# List outputs
outputs_dir = case_dir / "outputs"
if outputs_dir.exists():
for f in outputs_dir.iterdir():
if f.is_file():
outputs.append({
"filename": f.name,
"url": f"/files/outputs/{case_id}/{f.name}"
})
return {
"caseId": case_id,
"assets": {
"uploads": uploads,
"outputs": outputs
}
}
@router.get("/files/uploads/{case_id}/{filename}", summary="Serve uploaded file (DEV)")
async def serve_upload_file(case_id: str, filename: str):
"""Serve an uploaded file (DEV mode)."""
file_path = DEV_STORAGE_DIR / case_id / "uploads" / filename
if not file_path.exists():
raise HTTPException(status_code=404, detail=f"File not found: {filename}")
# Determine media type
ext = file_path.suffix.lower()
media_types = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".webp": "image/webp",
".stl": "application/octet-stream",
".glb": "model/gltf-binary",
".json": "application/json",
}
media_type = media_types.get(ext, "application/octet-stream")
return FileResponse(
path=str(file_path),
filename=filename,
media_type=media_type
)
@router.get("/files/outputs/{case_id}/{filename}", summary="Serve output file (DEV)")
async def serve_output_file(case_id: str, filename: str):
"""Serve an output file (DEV mode)."""
file_path = DEV_STORAGE_DIR / case_id / "outputs" / filename
if not file_path.exists():
raise HTTPException(status_code=404, detail=f"File not found: {filename}")
# Determine media type
ext = file_path.suffix.lower()
media_types = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".webp": "image/webp",
".stl": "application/octet-stream",
".ply": "application/octet-stream",
".obj": "application/octet-stream",
".glb": "model/gltf-binary",
".gltf": "model/gltf+json",
".json": "application/json",
}
media_type = media_types.get(ext, "application/octet-stream")
return FileResponse(
path=str(file_path),
filename=filename,
media_type=media_type
)