412 lines
15 KiB
Python
412 lines
15 KiB
Python
"""
|
|
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")
|