178 lines
6.7 KiB
Python
178 lines
6.7 KiB
Python
"""
|
|
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
|