""" Data models for unified spine landmark representation. This is the "glue" that connects different model outputs to the brace generator. """ from dataclasses import dataclass, field from typing import Optional, List, Dict, Any import numpy as np @dataclass class VertebraLandmark: """ Unified representation of a single vertebra's landmarks. All coordinates are in pixels (can be converted to mm with pixel_spacing). """ # Vertebra level identifier (e.g., "T1", "T4", "L1", etc.) - None if unknown level: Optional[str] = None # Center point of vertebra [x, y] in pixels centroid_px: np.ndarray = field(default_factory=lambda: np.zeros(2)) # Four corner points [top_left, top_right, bottom_right, bottom_left] shape (4, 2) corners_px: Optional[np.ndarray] = None # Upper endplate points [left, right] shape (2, 2) endplate_upper_px: Optional[np.ndarray] = None # Lower endplate points [left, right] shape (2, 2) endplate_lower_px: Optional[np.ndarray] = None # Orientation angle of vertebra in degrees (tilt in coronal plane) orientation_deg: Optional[float] = None # Detection confidence (0-1) confidence: float = 1.0 # Additional metadata from source model meta: Optional[Dict[str, Any]] = None def compute_orientation(self) -> float: """Compute vertebra orientation from corners or endplates.""" if self.orientation_deg is not None: return self.orientation_deg # Try to compute from upper endplate if self.endplate_upper_px is not None: left, right = self.endplate_upper_px[0], self.endplate_upper_px[1] dx = right[0] - left[0] dy = right[1] - left[1] angle = np.degrees(np.arctan2(dy, dx)) self.orientation_deg = angle return angle # Try to compute from corners (top-left to top-right) if self.corners_px is not None: top_left, top_right = self.corners_px[0], self.corners_px[1] dx = top_right[0] - top_left[0] dy = top_right[1] - top_left[1] angle = np.degrees(np.arctan2(dy, dx)) self.orientation_deg = angle return angle return 0.0 def compute_centroid(self) -> np.ndarray: """Compute centroid from corners if not set.""" if self.corners_px is not None and np.all(self.centroid_px == 0): self.centroid_px = np.mean(self.corners_px, axis=0) return self.centroid_px @dataclass class Spine2D: """ Complete 2D spine representation from an X-ray. Contains all detected vertebrae and computed angles. """ # List of vertebrae, ordered from top (C7/T1) to bottom (L5/S1) vertebrae: List[VertebraLandmark] = field(default_factory=list) # Pixel spacing in mm [sx, sy] - from DICOM if available pixel_spacing_mm: Optional[np.ndarray] = None # Original image shape (height, width) image_shape: Optional[tuple] = None # Computed Cobb angles in degrees (individual fields) cobb_pt: Optional[float] = None # Proximal Thoracic cobb_mt: Optional[float] = None # Main Thoracic cobb_tl: Optional[float] = None # Thoracolumbar/Lumbar # Cobb angles as dictionary (alternative format) cobb_angles: Optional[Dict[str, float]] = None # {'PT': angle, 'MT': angle, 'TL': angle} # Curve type: "S" (double curve) or "C" (single curve) or "Normal" curve_type: Optional[str] = None # Rigo-ChĂȘneau classification rigo_type: Optional[str] = None # A1, A2, A3, B1, B2, C1, C2, E1, E2, Normal rigo_description: Optional[str] = None # Detailed description # Source model that generated this data source_model: Optional[str] = None # Additional metadata meta: Optional[Dict[str, Any]] = None def get_cobb_angles(self) -> Dict[str, float]: """Get Cobb angles as dictionary, preferring computed individual fields.""" # Prefer individual fields (set by compute_cobb_angles) over dictionary # This ensures consistency between displayed values and classification if self.cobb_pt is not None or self.cobb_mt is not None or self.cobb_tl is not None: return { 'PT': self.cobb_pt or 0.0, 'MT': self.cobb_mt or 0.0, 'TL': self.cobb_tl or 0.0 } if self.cobb_angles is not None: return self.cobb_angles return {'PT': 0.0, 'MT': 0.0, 'TL': 0.0} def get_centroids(self) -> np.ndarray: """Get array of all vertebra centroids, shape (N, 2).""" centroids = [] for v in self.vertebrae: v.compute_centroid() centroids.append(v.centroid_px) return np.array(centroids, dtype=np.float32) def get_orientations(self) -> np.ndarray: """Get array of all vertebra orientations in degrees, shape (N,).""" return np.array([v.compute_orientation() for v in self.vertebrae], dtype=np.float32) def to_mm(self, coords_px: np.ndarray) -> np.ndarray: """Convert pixel coordinates to millimeters.""" if self.pixel_spacing_mm is None: # Default assumption: 0.25 mm/pixel (typical for spine X-rays) spacing = np.array([0.25, 0.25]) else: spacing = self.pixel_spacing_mm return coords_px * spacing def sort_vertebrae(self): """Sort vertebrae by vertical position (top to bottom).""" self.vertebrae.sort(key=lambda v: float(v.centroid_px[1])) @dataclass class BraceConfig: """ Configuration parameters for brace generation. """ # Brace dimensions brace_height_mm: float = 400.0 # Total height of brace wall_thickness_mm: float = 4.0 # Shell thickness # Torso shape parameters (for average body mode) torso_width_mm: float = 280.0 # Left-right diameter at widest torso_depth_mm: float = 200.0 # Front-back diameter at widest # Correction parameters pressure_strength_mm: float = 15.0 # Max indentation at apex pressure_spread_deg: float = 45.0 # Angular spread of pressure zone # Mesh resolution n_vertical_slices: int = 100 # Number of cross-sections n_circumference_points: int = 72 # Points per cross-section (every 5°) # Opening (for brace accessibility) front_opening_deg: float = 60.0 # Angular width of front opening (0 = closed) # Mode use_body_scan: bool = False # True = use 3D body scan, False = average body body_scan_path: Optional[str] = None # Path to body scan mesh # Scale pixel_spacing_mm: Optional[np.ndarray] = None # Override pixel spacing