Initial commit - BraceIQMed platform with frontend, API, and brace generator
This commit is contained in:
990
brace-generator/routes.py
Normal file
990
brace-generator/routes.py
Normal file
@@ -0,0 +1,990 @@
|
||||
"""
|
||||
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
|
||||
)
|
||||
Reference in New Issue
Block a user