Initial commit - BraceIQMed platform with frontend, API, and brace generator
This commit is contained in:
411
brace-generator/body_integration.py
Normal file
411
brace-generator/body_integration.py
Normal file
@@ -0,0 +1,411 @@
|
||||
"""
|
||||
Body scan integration for patient-specific brace fitting.
|
||||
|
||||
Based on EXPERIMENT_10's approach:
|
||||
1. Extract body measurements from 3D scan
|
||||
2. Compute body basis (coordinate frame)
|
||||
3. Select template based on Rigo classification
|
||||
4. Fit shell to body using basis alignment
|
||||
"""
|
||||
import sys
|
||||
import json
|
||||
import numpy as np
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Optional, Tuple
|
||||
from dataclasses import dataclass, asdict
|
||||
|
||||
try:
|
||||
import trimesh
|
||||
HAS_TRIMESH = True
|
||||
except ImportError:
|
||||
HAS_TRIMESH = False
|
||||
|
||||
# Add EXPERIMENT_10 to path for imports
|
||||
EXPERIMENTS_DIR = Path(__file__).parent.parent / "EXPERIMENTS"
|
||||
EXP10_DIR = EXPERIMENTS_DIR / "EXPERIMENT_10"
|
||||
if str(EXP10_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(EXP10_DIR))
|
||||
|
||||
# Import EXPERIMENT_10 modules
|
||||
try:
|
||||
from body_measurements import extract_body_measurements, measurements_to_dict, BodyMeasurements
|
||||
from body_basis import compute_body_basis, body_basis_to_dict, BodyBasis
|
||||
from shell_fitter_v2 import (
|
||||
fit_shell_to_body_v2,
|
||||
compute_brace_basis_from_geometry,
|
||||
brace_basis_to_dict,
|
||||
RIGO_TO_VASE,
|
||||
FittingFeedback
|
||||
)
|
||||
HAS_EXP10 = True
|
||||
except ImportError as e:
|
||||
print(f"Warning: Could not import EXPERIMENT_10 modules: {e}")
|
||||
HAS_EXP10 = False
|
||||
|
||||
# Vase templates directory
|
||||
VASES_DIR = Path(__file__).parent.parent.parent / "_vase" / "_vase"
|
||||
|
||||
|
||||
def extract_measurements_from_scan(scan_path: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Extract body measurements from a 3D body scan.
|
||||
|
||||
Args:
|
||||
scan_path: Path to STL/OBJ/PLY body scan file
|
||||
|
||||
Returns:
|
||||
Dictionary with measurements suitable for API response
|
||||
"""
|
||||
if not HAS_TRIMESH:
|
||||
raise ImportError("trimesh is required for body scan processing")
|
||||
|
||||
# Try EXPERIMENT_10 first
|
||||
if HAS_EXP10:
|
||||
try:
|
||||
measurements = extract_body_measurements(scan_path)
|
||||
result = measurements_to_dict(measurements)
|
||||
|
||||
# Flatten for API-friendly format
|
||||
flat = {
|
||||
"total_height_mm": result["overall_dimensions"]["total_height_mm"],
|
||||
"shoulder_width_mm": result["widths_mm"]["shoulder_width"],
|
||||
"chest_width_mm": result["widths_mm"]["chest_width"],
|
||||
"chest_depth_mm": result["depths_mm"]["chest_depth"],
|
||||
"waist_width_mm": result["widths_mm"]["waist_width"],
|
||||
"waist_depth_mm": result["depths_mm"]["waist_depth"],
|
||||
"hip_width_mm": result["widths_mm"]["hip_width"],
|
||||
"hip_depth_mm": result["depths_mm"]["hip_depth"],
|
||||
"brace_coverage_height_mm": result["brace_coverage_region"]["coverage_height_mm"],
|
||||
"chest_circumference_mm": result["circumferences_mm"]["chest"],
|
||||
"waist_circumference_mm": result["circumferences_mm"]["waist"],
|
||||
"hip_circumference_mm": result["circumferences_mm"]["hip"],
|
||||
}
|
||||
|
||||
# Also include full detailed result
|
||||
flat["detailed"] = result
|
||||
return flat
|
||||
except Exception as e:
|
||||
print(f"EXPERIMENT_10 measurement extraction failed: {e}, using fallback")
|
||||
|
||||
# Fallback: Simple trimesh-based measurements
|
||||
return _extract_measurements_trimesh_fallback(scan_path)
|
||||
|
||||
|
||||
def _extract_measurements_trimesh_fallback(scan_path: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Simple fallback for body measurements using trimesh bounding box analysis.
|
||||
Less accurate than EXPERIMENT_10 but provides basic measurements.
|
||||
"""
|
||||
mesh = trimesh.load(scan_path)
|
||||
|
||||
# Get bounding box
|
||||
bounds = mesh.bounds
|
||||
min_pt, max_pt = bounds[0], bounds[1]
|
||||
|
||||
# Assuming Y is up (typical human scan orientation)
|
||||
# Try to auto-detect orientation
|
||||
extents = max_pt - min_pt
|
||||
height_axis = np.argmax(extents) # Longest axis is usually height
|
||||
|
||||
if height_axis == 1: # Y-up
|
||||
total_height = extents[1]
|
||||
width_axis, depth_axis = 0, 2
|
||||
elif height_axis == 2: # Z-up
|
||||
total_height = extents[2]
|
||||
width_axis, depth_axis = 0, 1
|
||||
else: # X-up (unusual)
|
||||
total_height = extents[0]
|
||||
width_axis, depth_axis = 1, 2
|
||||
|
||||
width = extents[width_axis]
|
||||
depth = extents[depth_axis]
|
||||
|
||||
# Estimate body segments using height percentages
|
||||
# These are approximate ratios for human body
|
||||
chest_height_ratio = 0.75 # Chest at 75% of height from bottom
|
||||
waist_height_ratio = 0.60 # Waist at 60% of height
|
||||
hip_height_ratio = 0.50 # Hips at 50% of height
|
||||
shoulder_height_ratio = 0.82 # Shoulders at 82%
|
||||
|
||||
# Get cross-sections at different heights to estimate widths
|
||||
def get_width_at_height(height_ratio):
|
||||
if height_axis == 1:
|
||||
h = min_pt[1] + total_height * height_ratio
|
||||
mask = (mesh.vertices[:, 1] > h - total_height * 0.05) & \
|
||||
(mesh.vertices[:, 1] < h + total_height * 0.05)
|
||||
elif height_axis == 2:
|
||||
h = min_pt[2] + total_height * height_ratio
|
||||
mask = (mesh.vertices[:, 2] > h - total_height * 0.05) & \
|
||||
(mesh.vertices[:, 2] < h + total_height * 0.05)
|
||||
else:
|
||||
h = min_pt[0] + total_height * height_ratio
|
||||
mask = (mesh.vertices[:, 0] > h - total_height * 0.05) & \
|
||||
(mesh.vertices[:, 0] < h + total_height * 0.05)
|
||||
|
||||
if not np.any(mask):
|
||||
return width, depth
|
||||
|
||||
slice_verts = mesh.vertices[mask]
|
||||
slice_width = np.ptp(slice_verts[:, width_axis])
|
||||
slice_depth = np.ptp(slice_verts[:, depth_axis])
|
||||
return slice_width, slice_depth
|
||||
|
||||
shoulder_w, shoulder_d = get_width_at_height(shoulder_height_ratio)
|
||||
chest_w, chest_d = get_width_at_height(chest_height_ratio)
|
||||
waist_w, waist_d = get_width_at_height(waist_height_ratio)
|
||||
hip_w, hip_d = get_width_at_height(hip_height_ratio)
|
||||
|
||||
# Estimate circumferences using ellipse approximation
|
||||
def estimate_circumference(w, d):
|
||||
a, b = w / 2, d / 2
|
||||
# Ramanujan's approximation for ellipse circumference
|
||||
h = ((a - b) ** 2) / ((a + b) ** 2)
|
||||
return np.pi * (a + b) * (1 + 3 * h / (10 + np.sqrt(4 - 3 * h)))
|
||||
|
||||
return {
|
||||
"total_height_mm": float(total_height),
|
||||
"shoulder_width_mm": float(shoulder_w),
|
||||
"chest_width_mm": float(chest_w),
|
||||
"chest_depth_mm": float(chest_d),
|
||||
"waist_width_mm": float(waist_w),
|
||||
"waist_depth_mm": float(waist_d),
|
||||
"hip_width_mm": float(hip_w),
|
||||
"hip_depth_mm": float(hip_d),
|
||||
"brace_coverage_height_mm": float(total_height * 0.55), # 55% coverage
|
||||
"chest_circumference_mm": float(estimate_circumference(chest_w, chest_d)),
|
||||
"waist_circumference_mm": float(estimate_circumference(waist_w, waist_d)),
|
||||
"hip_circumference_mm": float(estimate_circumference(hip_w, hip_d)),
|
||||
"measurement_source": "trimesh_fallback"
|
||||
}
|
||||
|
||||
|
||||
def generate_fitted_brace(
|
||||
body_scan_path: str,
|
||||
rigo_type: str,
|
||||
output_dir: str,
|
||||
case_id: str,
|
||||
clearance_mm: float = 8.0,
|
||||
wall_thickness_mm: float = 2.4
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Generate a patient-specific brace fitted to body scan.
|
||||
|
||||
Args:
|
||||
body_scan_path: Path to 3D body scan (STL/OBJ/PLY)
|
||||
rigo_type: Rigo classification (A1, A2, B1, etc.)
|
||||
output_dir: Directory to save output files
|
||||
case_id: Case identifier for naming files
|
||||
clearance_mm: Clearance between body and shell (default 8mm)
|
||||
wall_thickness_mm: Shell wall thickness (default 2.4mm for 3D printing)
|
||||
|
||||
Returns:
|
||||
Dictionary with output file paths and fitting info
|
||||
"""
|
||||
if not HAS_TRIMESH:
|
||||
raise ImportError("trimesh is required for brace fitting")
|
||||
|
||||
if not HAS_EXP10:
|
||||
raise ImportError("EXPERIMENT_10 modules not available")
|
||||
|
||||
output_path = Path(output_dir)
|
||||
output_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Select template based on Rigo type
|
||||
template_file = RIGO_TO_VASE.get(rigo_type, "A1_vase.OBJ")
|
||||
template_path = VASES_DIR / template_file
|
||||
|
||||
if not template_path.exists():
|
||||
# Try alternative paths
|
||||
alt_paths = [
|
||||
EXPERIMENTS_DIR / "EXPERIMENT_10" / "_vase" / template_file,
|
||||
Path(__file__).parent.parent.parent / "_vase" / template_file,
|
||||
]
|
||||
for alt in alt_paths:
|
||||
if alt.exists():
|
||||
template_path = alt
|
||||
break
|
||||
else:
|
||||
raise FileNotFoundError(f"Template not found: {template_file}")
|
||||
|
||||
# Fit shell to body
|
||||
# Returns: (shell_mesh, body_mesh, combined_mesh, feedback)
|
||||
fitted_mesh, body_mesh, combined_mesh, feedback = fit_shell_to_body_v2(
|
||||
body_scan_path=body_scan_path,
|
||||
template_path=str(template_path),
|
||||
clearance_mm=clearance_mm
|
||||
)
|
||||
|
||||
# Generate output files
|
||||
outputs = {}
|
||||
|
||||
# Shell STL (for 3D printing)
|
||||
shell_stl = output_path / f"{case_id}_shell.stl"
|
||||
fitted_mesh.export(str(shell_stl))
|
||||
outputs["shell_stl"] = str(shell_stl)
|
||||
|
||||
# Shell GLB (for web viewing)
|
||||
shell_glb = output_path / f"{case_id}_shell.glb"
|
||||
fitted_mesh.export(str(shell_glb))
|
||||
outputs["shell_glb"] = str(shell_glb)
|
||||
|
||||
# Combined body + shell STL (for visualization)
|
||||
# combined_mesh is already returned from fit_shell_to_body_v2
|
||||
combined_stl = output_path / f"{case_id}_body_with_shell.stl"
|
||||
combined_mesh.export(str(combined_stl))
|
||||
outputs["combined_stl"] = str(combined_stl)
|
||||
|
||||
# Feedback JSON
|
||||
feedback_json = output_path / f"{case_id}_feedback.json"
|
||||
with open(feedback_json, "w") as f:
|
||||
json.dump(asdict(feedback), f, indent=2, default=_json_serializer)
|
||||
outputs["feedback_json"] = str(feedback_json)
|
||||
|
||||
# Create visualization
|
||||
try:
|
||||
viz_path = output_path / f"{case_id}_visualization.png"
|
||||
create_fitting_visualization(body_mesh, fitted_mesh, feedback, str(viz_path))
|
||||
outputs["visualization"] = str(viz_path)
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not create visualization: {e}")
|
||||
|
||||
# Return result
|
||||
return {
|
||||
"template_used": template_file,
|
||||
"rigo_type": rigo_type,
|
||||
"clearance_mm": clearance_mm,
|
||||
"fitting": {
|
||||
"scale_right": feedback.scale_right,
|
||||
"scale_up": feedback.scale_up,
|
||||
"scale_forward": feedback.scale_forward,
|
||||
"pelvis_distance_mm": feedback.pelvis_distance_mm,
|
||||
"up_alignment_dot": feedback.up_alignment_dot,
|
||||
"warnings": feedback.warnings,
|
||||
},
|
||||
"body_measurements": {
|
||||
"max_width_mm": feedback.max_body_width_mm,
|
||||
"max_depth_mm": feedback.max_body_depth_mm,
|
||||
},
|
||||
"shell_dimensions": {
|
||||
"width_mm": feedback.target_shell_width_mm,
|
||||
"depth_mm": feedback.target_shell_depth_mm,
|
||||
"bounds_min": feedback.final_bounds_min,
|
||||
"bounds_max": feedback.final_bounds_max,
|
||||
},
|
||||
"mesh_stats": {
|
||||
"vertices": len(fitted_mesh.vertices),
|
||||
"faces": len(fitted_mesh.faces),
|
||||
},
|
||||
"outputs": outputs,
|
||||
}
|
||||
|
||||
|
||||
def create_fitting_visualization(
|
||||
body_mesh: 'trimesh.Trimesh',
|
||||
shell_mesh: 'trimesh.Trimesh',
|
||||
feedback: 'FittingFeedback',
|
||||
output_path: str
|
||||
):
|
||||
"""Create a multi-panel visualization of the fitted brace."""
|
||||
try:
|
||||
import matplotlib
|
||||
matplotlib.use('Agg')
|
||||
import matplotlib.pyplot as plt
|
||||
from mpl_toolkits.mplot3d import Axes3D
|
||||
except ImportError:
|
||||
return
|
||||
|
||||
fig = plt.figure(figsize=(16, 10))
|
||||
|
||||
# Panel 1: Front view
|
||||
ax1 = fig.add_subplot(2, 3, 1, projection='3d')
|
||||
plot_mesh_silhouette(ax1, body_mesh, 'gray', alpha=0.3)
|
||||
plot_mesh_silhouette(ax1, shell_mesh, 'blue', alpha=0.6)
|
||||
ax1.set_title('Front View')
|
||||
ax1.view_init(elev=0, azim=0)
|
||||
|
||||
# Panel 2: Side view
|
||||
ax2 = fig.add_subplot(2, 3, 2, projection='3d')
|
||||
plot_mesh_silhouette(ax2, body_mesh, 'gray', alpha=0.3)
|
||||
plot_mesh_silhouette(ax2, shell_mesh, 'blue', alpha=0.6)
|
||||
ax2.set_title('Side View')
|
||||
ax2.view_init(elev=0, azim=90)
|
||||
|
||||
# Panel 3: Top view
|
||||
ax3 = fig.add_subplot(2, 3, 3, projection='3d')
|
||||
plot_mesh_silhouette(ax3, body_mesh, 'gray', alpha=0.3)
|
||||
plot_mesh_silhouette(ax3, shell_mesh, 'blue', alpha=0.6)
|
||||
ax3.set_title('Top View')
|
||||
ax3.view_init(elev=90, azim=0)
|
||||
|
||||
# Panel 4: Fitting info
|
||||
ax4 = fig.add_subplot(2, 3, 4)
|
||||
ax4.axis('off')
|
||||
info_text = f"""
|
||||
Fitting Information
|
||||
-------------------
|
||||
Template: {feedback.template_name}
|
||||
Clearance: {feedback.clearance_mm} mm
|
||||
|
||||
Scale Factors:
|
||||
Right: {feedback.scale_right:.3f}
|
||||
Up: {feedback.scale_up:.3f}
|
||||
Forward: {feedback.scale_forward:.3f}
|
||||
|
||||
Alignment:
|
||||
Pelvis Distance: {feedback.pelvis_distance_mm:.2f} mm
|
||||
Up Alignment: {feedback.up_alignment_dot:.4f}
|
||||
|
||||
Shell vs Body:
|
||||
Width Margin: {feedback.shell_minus_body_width_mm:.1f} mm
|
||||
Depth Margin: {feedback.shell_minus_body_depth_mm:.1f} mm
|
||||
"""
|
||||
ax4.text(0.1, 0.9, info_text, transform=ax4.transAxes, fontsize=10,
|
||||
verticalalignment='top', fontfamily='monospace')
|
||||
|
||||
# Panel 5: Warnings
|
||||
ax5 = fig.add_subplot(2, 3, 5)
|
||||
ax5.axis('off')
|
||||
warnings_text = "Warnings:\n" + ("\n".join(feedback.warnings) if feedback.warnings else "None")
|
||||
ax5.text(0.1, 0.9, warnings_text, transform=ax5.transAxes, fontsize=10,
|
||||
verticalalignment='top', color='orange' if feedback.warnings else 'green')
|
||||
|
||||
# Panel 6: Isometric view
|
||||
ax6 = fig.add_subplot(2, 3, 6, projection='3d')
|
||||
plot_mesh_silhouette(ax6, body_mesh, 'gray', alpha=0.3)
|
||||
plot_mesh_silhouette(ax6, shell_mesh, 'blue', alpha=0.6)
|
||||
ax6.set_title('Isometric View')
|
||||
ax6.view_init(elev=20, azim=45)
|
||||
|
||||
plt.tight_layout()
|
||||
plt.savefig(output_path, dpi=150, bbox_inches='tight')
|
||||
plt.close()
|
||||
|
||||
|
||||
def plot_mesh_silhouette(ax, mesh, color, alpha=0.5):
|
||||
"""Plot a simplified mesh representation."""
|
||||
# Sample vertices for plotting
|
||||
verts = mesh.vertices
|
||||
if len(verts) > 5000:
|
||||
indices = np.random.choice(len(verts), 5000, replace=False)
|
||||
verts = verts[indices]
|
||||
|
||||
ax.scatter(verts[:, 0], verts[:, 1], verts[:, 2],
|
||||
c=color, alpha=alpha, s=1)
|
||||
|
||||
# Set equal aspect ratio
|
||||
max_range = np.max(mesh.extents) / 2
|
||||
mid = mesh.centroid
|
||||
ax.set_xlim(mid[0] - max_range, mid[0] + max_range)
|
||||
ax.set_ylim(mid[1] - max_range, mid[1] + max_range)
|
||||
ax.set_zlim(mid[2] - max_range, mid[2] + max_range)
|
||||
|
||||
|
||||
def _json_serializer(obj):
|
||||
"""JSON serializer for numpy types."""
|
||||
if isinstance(obj, np.ndarray):
|
||||
return obj.tolist()
|
||||
if isinstance(obj, (np.float32, np.float64)):
|
||||
return float(obj)
|
||||
if isinstance(obj, (np.int32, np.int64)):
|
||||
return int(obj)
|
||||
raise TypeError(f"Object of type {type(obj)} is not JSON serializable")
|
||||
Reference in New Issue
Block a user