Add patient management, deployment scripts, and Docker fixes
This commit is contained in:
354
brace-generator/brace_surface.py
Normal file
354
brace-generator/brace_surface.py
Normal file
@@ -0,0 +1,354 @@
|
||||
"""
|
||||
Brace surface generation from spine landmarks.
|
||||
|
||||
Two modes:
|
||||
- Version A: Generic/average body shape (parametric torso)
|
||||
- Version B: Uses actual 3D body scan mesh
|
||||
"""
|
||||
import numpy as np
|
||||
from typing import Tuple, Optional, List
|
||||
from pathlib import Path
|
||||
|
||||
from .data_models import Spine2D, BraceConfig
|
||||
from .spine_analysis import compute_spine_curve, find_apex_vertebrae
|
||||
|
||||
try:
|
||||
import trimesh
|
||||
HAS_TRIMESH = True
|
||||
except ImportError:
|
||||
HAS_TRIMESH = False
|
||||
|
||||
|
||||
class BraceGenerator:
|
||||
"""
|
||||
Generates 3D brace shell from spine landmarks.
|
||||
"""
|
||||
|
||||
def __init__(self, config: Optional[BraceConfig] = None):
|
||||
"""
|
||||
Initialize brace generator.
|
||||
|
||||
Args:
|
||||
config: Brace configuration parameters
|
||||
"""
|
||||
if not HAS_TRIMESH:
|
||||
raise ImportError("trimesh is required for brace generation. Install with: pip install trimesh")
|
||||
|
||||
self.config = config or BraceConfig()
|
||||
|
||||
def generate(self, spine: Spine2D) -> 'trimesh.Trimesh':
|
||||
"""
|
||||
Generate brace mesh from spine landmarks.
|
||||
|
||||
Args:
|
||||
spine: Spine2D object with detected vertebrae
|
||||
|
||||
Returns:
|
||||
trimesh.Trimesh object representing the brace shell
|
||||
"""
|
||||
if self.config.use_body_scan and self.config.body_scan_path:
|
||||
return self._generate_from_body_scan(spine)
|
||||
else:
|
||||
return self._generate_from_average_body(spine)
|
||||
|
||||
def _torso_profile(self, z01: float) -> Tuple[float, float]:
|
||||
"""
|
||||
Get torso cross-section radii at a given height.
|
||||
|
||||
Args:
|
||||
z01: Normalized height (0=top, 1=bottom)
|
||||
|
||||
Returns:
|
||||
(a_mm, b_mm): Radii in left-right and front-back directions
|
||||
"""
|
||||
# Torso shape varies with height
|
||||
# Wider at chest (z~0.3) and hips (z~0.8), narrower at waist (z~0.5)
|
||||
|
||||
# Base radii from config
|
||||
base_a = self.config.torso_width_mm / 2
|
||||
base_b = self.config.torso_depth_mm / 2
|
||||
|
||||
# Shape modulation
|
||||
# Chest region (z ~ 0.2-0.4): wider
|
||||
# Waist region (z ~ 0.5): narrower
|
||||
# Hip region (z ~ 0.8-1.0): wider
|
||||
|
||||
if z01 < 0.3:
|
||||
# Upper chest - moderate width
|
||||
mod = 1.0
|
||||
elif z01 < 0.5:
|
||||
# Transition to waist
|
||||
t = (z01 - 0.3) / 0.2
|
||||
mod = 1.0 - 0.15 * t # Decrease by 15%
|
||||
elif z01 < 0.7:
|
||||
# Waist region - narrowest
|
||||
mod = 0.85
|
||||
else:
|
||||
# Hips - widen again
|
||||
t = (z01 - 0.7) / 0.3
|
||||
mod = 0.85 + 0.2 * t # Increase by 20%
|
||||
|
||||
return base_a * mod, base_b * mod
|
||||
|
||||
def _generate_from_average_body(self, spine: Spine2D) -> 'trimesh.Trimesh':
|
||||
"""
|
||||
Generate brace using parametric average body shape.
|
||||
|
||||
The brace follows the spine curve laterally and applies
|
||||
pressure zones at curve apexes.
|
||||
"""
|
||||
cfg = self.config
|
||||
|
||||
# 1) Compute spine curve
|
||||
try:
|
||||
C_px, T_px, N_px, curvature = compute_spine_curve(spine, smooth=5.0, n_samples=cfg.n_vertical_slices)
|
||||
except ValueError as e:
|
||||
raise ValueError(f"Cannot generate brace: {e}")
|
||||
|
||||
# 2) Convert to mm
|
||||
if spine.pixel_spacing_mm is not None:
|
||||
sx, sy = spine.pixel_spacing_mm
|
||||
elif cfg.pixel_spacing_mm is not None:
|
||||
sx, sy = cfg.pixel_spacing_mm
|
||||
else:
|
||||
sx = sy = 0.25 # Default assumption
|
||||
|
||||
C_mm = np.zeros_like(C_px)
|
||||
C_mm[:, 0] = C_px[:, 0] * sx
|
||||
C_mm[:, 1] = C_px[:, 1] * sy
|
||||
|
||||
# 3) Determine brace vertical extent
|
||||
y_mm = C_mm[:, 1]
|
||||
y_min, y_max = y_mm.min(), y_mm.max()
|
||||
spine_height = y_max - y_min
|
||||
|
||||
# Brace height (might extend beyond detected vertebrae)
|
||||
brace_height = min(cfg.brace_height_mm, spine_height * 1.1)
|
||||
|
||||
# 4) Normalize curvature for pressure zones
|
||||
curv_norm = (curvature - curvature.min()) / (curvature.max() - curvature.min() + 1e-8)
|
||||
|
||||
# 5) Build vertices
|
||||
n_z = cfg.n_vertical_slices
|
||||
n_theta = cfg.n_circumference_points
|
||||
|
||||
# Opening angle (front of brace might be open)
|
||||
opening_half = np.radians(cfg.front_opening_deg / 2)
|
||||
|
||||
vertices = []
|
||||
|
||||
for i in range(n_z):
|
||||
z01 = i / (n_z - 1) # 0 to 1
|
||||
|
||||
# Z coordinate (vertical position in 3D)
|
||||
z_mm = y_min + z01 * spine_height
|
||||
|
||||
# Get torso profile at this height
|
||||
a_mm, b_mm = self._torso_profile(z01)
|
||||
|
||||
# Lateral offset from spine curve
|
||||
x_offset = C_mm[i, 0] - (C_mm[0, 0] + C_mm[-1, 0]) / 2 # Deviation from midline
|
||||
|
||||
# Pressure modulation based on curvature
|
||||
pressure = cfg.pressure_strength_mm * curv_norm[i]
|
||||
|
||||
for j in range(n_theta):
|
||||
theta = 2 * np.pi * (j / n_theta)
|
||||
|
||||
# Skip vertices in the opening region (front = theta around 0)
|
||||
# Actually, we'll still create them but can mark them for later removal
|
||||
|
||||
# Base ellipse point
|
||||
x = a_mm * np.cos(theta)
|
||||
y = b_mm * np.sin(theta)
|
||||
|
||||
# Apply lateral offset (brace follows spine curve)
|
||||
x += x_offset
|
||||
|
||||
# Apply pressure zones
|
||||
# Pressure on sides (theta near π/2 or 3π/2 = sides)
|
||||
# The side that's convex gets pushed in
|
||||
side_factor = abs(np.cos(theta)) # Max at sides (theta=0 or π)
|
||||
|
||||
# Determine which side based on spine deviation
|
||||
if x_offset > 0:
|
||||
# Spine deviated right, push on right side
|
||||
if np.cos(theta) > 0: # Right side
|
||||
x -= pressure * side_factor
|
||||
else:
|
||||
# Spine deviated left, push on left side
|
||||
if np.cos(theta) < 0: # Left side
|
||||
x -= pressure * side_factor * np.sign(np.cos(theta))
|
||||
|
||||
# Vertex position: x=left/right, y=front/back, z=vertical
|
||||
vertices.append([x, y, z_mm])
|
||||
|
||||
vertices = np.array(vertices, dtype=np.float32)
|
||||
|
||||
# 6) Build faces (quad strips between adjacent rings)
|
||||
faces = []
|
||||
|
||||
def vid(i, j):
|
||||
return i * n_theta + (j % n_theta)
|
||||
|
||||
for i in range(n_z - 1):
|
||||
for j in range(n_theta):
|
||||
j2 = (j + 1) % n_theta
|
||||
|
||||
# Two triangles per quad
|
||||
a = vid(i, j)
|
||||
b = vid(i, j2)
|
||||
c = vid(i + 1, j2)
|
||||
d = vid(i + 1, j)
|
||||
|
||||
faces.append([a, b, c])
|
||||
faces.append([a, c, d])
|
||||
|
||||
faces = np.array(faces, dtype=np.int32)
|
||||
|
||||
# 7) Create outer shell mesh
|
||||
outer_shell = trimesh.Trimesh(vertices=vertices, faces=faces, process=True)
|
||||
|
||||
# 8) Create inner shell (offset inward by wall thickness)
|
||||
outer_shell.fix_normals()
|
||||
vn = outer_shell.vertex_normals
|
||||
inner_vertices = vertices - cfg.wall_thickness_mm * vn
|
||||
|
||||
# Inner faces need reversed winding
|
||||
inner_faces = faces[:, ::-1]
|
||||
|
||||
# 9) Combine into solid shell
|
||||
all_vertices = np.vstack([vertices, inner_vertices])
|
||||
inner_faces_offset = inner_faces + len(vertices)
|
||||
all_faces = np.vstack([faces, inner_faces_offset])
|
||||
|
||||
# 10) Add end caps (top and bottom rings)
|
||||
# Top cap (connect outer to inner at z=0)
|
||||
top_faces = []
|
||||
for j in range(n_theta):
|
||||
j2 = (j + 1) % n_theta
|
||||
outer_j = vid(0, j)
|
||||
outer_j2 = vid(0, j2)
|
||||
inner_j = outer_j + len(vertices)
|
||||
inner_j2 = outer_j2 + len(vertices)
|
||||
top_faces.append([outer_j, inner_j, inner_j2])
|
||||
top_faces.append([outer_j, inner_j2, outer_j2])
|
||||
|
||||
# Bottom cap
|
||||
bottom_faces = []
|
||||
for j in range(n_theta):
|
||||
j2 = (j + 1) % n_theta
|
||||
outer_j = vid(n_z - 1, j)
|
||||
outer_j2 = vid(n_z - 1, j2)
|
||||
inner_j = outer_j + len(vertices)
|
||||
inner_j2 = outer_j2 + len(vertices)
|
||||
bottom_faces.append([outer_j, outer_j2, inner_j2])
|
||||
bottom_faces.append([outer_j, inner_j2, inner_j])
|
||||
|
||||
all_faces = np.vstack([all_faces, top_faces, bottom_faces])
|
||||
|
||||
# Create final mesh
|
||||
brace = trimesh.Trimesh(vertices=all_vertices, faces=all_faces, process=True)
|
||||
brace.merge_vertices()
|
||||
# Remove degenerate faces
|
||||
valid_faces = brace.nondegenerate_faces()
|
||||
brace.update_faces(valid_faces)
|
||||
brace.fix_normals()
|
||||
|
||||
return brace
|
||||
|
||||
def _generate_from_body_scan(self, spine: Spine2D) -> 'trimesh.Trimesh':
|
||||
"""
|
||||
Generate brace by offsetting from a 3D body scan mesh.
|
||||
|
||||
The body scan provides the actual torso shape, and we:
|
||||
1. Offset outward for clearance
|
||||
2. Apply pressure zones based on spine curvature
|
||||
3. Thicken for wall thickness
|
||||
"""
|
||||
cfg = self.config
|
||||
|
||||
if not cfg.body_scan_path or not Path(cfg.body_scan_path).exists():
|
||||
raise FileNotFoundError(f"Body scan not found: {cfg.body_scan_path}")
|
||||
|
||||
# Load body scan
|
||||
body = trimesh.load(cfg.body_scan_path, force='mesh')
|
||||
body.remove_unreferenced_vertices()
|
||||
body.fix_normals()
|
||||
|
||||
# Compute spine curve for pressure mapping
|
||||
try:
|
||||
C_px, T_px, N_px, curvature = compute_spine_curve(spine, smooth=5.0, n_samples=200)
|
||||
except ValueError:
|
||||
curvature = np.zeros(200)
|
||||
|
||||
# Convert spine coordinates to mm
|
||||
if spine.pixel_spacing_mm is not None:
|
||||
sx, sy = spine.pixel_spacing_mm
|
||||
else:
|
||||
sx = sy = 0.25
|
||||
|
||||
y_mm = C_px[:, 1] * sy
|
||||
y_min, y_max = y_mm.min(), y_mm.max()
|
||||
H = y_max - y_min + 1e-6
|
||||
|
||||
# Normalize curvature
|
||||
curv_norm = (curvature - curvature.min()) / (curvature.max() - curvature.min() + 1e-8)
|
||||
|
||||
# 1) Offset body surface outward for clearance (inner brace surface)
|
||||
clearance_mm = 6.0 # Gap between body and brace
|
||||
vn = body.vertex_normals
|
||||
inner_surface = trimesh.Trimesh(
|
||||
vertices=body.vertices + clearance_mm * vn,
|
||||
faces=body.faces.copy(),
|
||||
process=True
|
||||
)
|
||||
|
||||
# 2) Apply pressure deformation
|
||||
# Map each vertex's Z coordinate to spine curvature
|
||||
z_coords = inner_surface.vertices[:, 2] # Assuming Z is vertical
|
||||
z_min, z_max = z_coords.min(), z_coords.max()
|
||||
z01 = (z_coords - z_min) / (z_max - z_min + 1e-6)
|
||||
|
||||
# Sample curvature at each vertex height
|
||||
curv_idx = np.clip((z01 * (len(curv_norm) - 1)).astype(int), 0, len(curv_norm) - 1)
|
||||
pressure_per_vertex = cfg.pressure_strength_mm * curv_norm[curv_idx]
|
||||
|
||||
# Apply pressure on sides (based on X coordinate)
|
||||
x_coords = inner_surface.vertices[:, 0]
|
||||
x_range = np.abs(x_coords).max() + 1e-6
|
||||
side_factor = np.abs(x_coords) / x_range # 0 at center, 1 at sides
|
||||
|
||||
deformation = (pressure_per_vertex * side_factor)[:, np.newaxis] * inner_surface.vertex_normals
|
||||
inner_surface.vertices = inner_surface.vertices - deformation
|
||||
|
||||
# 3) Create outer surface (offset by wall thickness)
|
||||
inner_surface.fix_normals()
|
||||
outer_surface = trimesh.Trimesh(
|
||||
vertices=inner_surface.vertices + cfg.wall_thickness_mm * inner_surface.vertex_normals,
|
||||
faces=inner_surface.faces.copy(),
|
||||
process=True
|
||||
)
|
||||
|
||||
# 4) Combine surfaces
|
||||
# For a true solid, we'd need to stitch edges - simplified here
|
||||
brace = trimesh.util.concatenate([inner_surface, outer_surface])
|
||||
brace.merge_vertices()
|
||||
valid_faces = brace.nondegenerate_faces()
|
||||
brace.update_faces(valid_faces)
|
||||
brace.fix_normals()
|
||||
|
||||
return brace
|
||||
|
||||
def export_stl(self, mesh: 'trimesh.Trimesh', output_path: str):
|
||||
"""
|
||||
Export mesh to STL file.
|
||||
|
||||
Args:
|
||||
mesh: trimesh.Trimesh object
|
||||
output_path: Path for output STL file
|
||||
"""
|
||||
mesh.export(output_path)
|
||||
print(f"Exported brace to {output_path}")
|
||||
print(f" Vertices: {len(mesh.vertices)}")
|
||||
print(f" Faces: {len(mesh.faces)}")
|
||||
Reference in New Issue
Block a user