Add patient management, deployment scripts, and Docker fixes

This commit is contained in:
2026-01-30 01:51:33 -08:00
parent 745f9f827f
commit d28d2f20c6
33 changed files with 7496 additions and 284 deletions

View File

@@ -0,0 +1,464 @@
"""
Spine analysis functions for computing curves, Cobb angles, and identifying apex vertebrae.
"""
import numpy as np
from scipy.interpolate import splprep, splev
from typing import Tuple, List, Optional
from data_models import Spine2D, VertebraLandmark
def compute_spine_curve(
spine: Spine2D,
smooth: float = 1.0,
n_samples: int = 200
) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
"""
Compute smooth spine centerline from vertebra centroids.
Args:
spine: Spine2D object with detected vertebrae
smooth: Smoothing factor for spline (higher = smoother)
n_samples: Number of points to sample along the curve
Returns:
C: Curve points, shape (n_samples, 2)
T: Tangent vectors, shape (n_samples, 2)
N: Normal vectors, shape (n_samples, 2)
curvature: Curvature at each point, shape (n_samples,)
"""
pts = spine.get_centroids()
if len(pts) < 4:
raise ValueError(f"Need at least 4 vertebrae for spline, got {len(pts)}")
# Fit parametric spline through centroids
x = pts[:, 0]
y = pts[:, 1]
try:
tck, u = splprep([x, y], s=smooth, k=min(3, len(pts)-1))
except Exception as e:
# Fallback: simple linear interpolation
t = np.linspace(0, 1, n_samples)
xs = np.interp(t, np.linspace(0, 1, len(x)), x)
ys = np.interp(t, np.linspace(0, 1, len(y)), y)
C = np.stack([xs, ys], axis=1).astype(np.float32)
T = np.gradient(C, axis=0)
T = T / (np.linalg.norm(T, axis=1, keepdims=True) + 1e-8)
N = np.stack([-T[:, 1], T[:, 0]], axis=1)
curvature = np.zeros(n_samples, dtype=np.float32)
return C, T, N, curvature
# Sample the spline
u_new = np.linspace(0, 1, n_samples)
xs, ys = splev(u_new, tck)
# First and second derivatives
dx, dy = splev(u_new, tck, der=1)
ddx, ddy = splev(u_new, tck, der=2)
# Curve points
C = np.stack([xs, ys], axis=1).astype(np.float32)
# Tangent vectors (normalized)
T = np.stack([dx, dy], axis=1)
T_norm = np.linalg.norm(T, axis=1, keepdims=True) + 1e-8
T = (T / T_norm).astype(np.float32)
# Normal vectors (perpendicular to tangent)
N = np.stack([-T[:, 1], T[:, 0]], axis=1).astype(np.float32)
# Curvature: |x'y'' - y'x''| / (x'^2 + y'^2)^(3/2)
curvature = np.abs(dx * ddy - dy * ddx) / (np.power(dx**2 + dy**2, 1.5) + 1e-8)
curvature = curvature.astype(np.float32)
return C, T, N, curvature
def compute_cobb_angles(spine: Spine2D) -> Tuple[float, float, float]:
"""
Compute Cobb angles from vertebra orientations.
The Cobb angle is measured as the angle between:
- The superior endplate of the most tilted vertebra at the top of a curve
- The inferior endplate of the most tilted vertebra at the bottom
This function estimates 3 Cobb angles:
- PT: Proximal Thoracic (T1-T6 region)
- MT: Main Thoracic (T6-T12 region)
- TL: Thoracolumbar/Lumbar (T12-L5 region)
Args:
spine: Spine2D object with detected vertebrae
Returns:
(pt_angle, mt_angle, tl_angle) in degrees
"""
orientations = spine.get_orientations()
n = len(orientations)
if n < 7:
spine.cobb_pt = 0.0
spine.cobb_mt = 0.0
spine.cobb_tl = 0.0
return 0.0, 0.0, 0.0
# Divide into regions (approximately)
# PT: top 1/3, MT: middle 1/3, TL: bottom 1/3
pt_end = n // 3
mt_end = 2 * n // 3
# Find max tilt difference in each region
def region_cobb(start_idx: int, end_idx: int) -> float:
if end_idx <= start_idx:
return 0.0
region_angles = orientations[start_idx:end_idx]
if len(region_angles) < 2:
return 0.0
# Cobb angle = max angle - min angle in region
return abs(float(np.max(region_angles) - np.min(region_angles)))
pt_angle = region_cobb(0, pt_end)
mt_angle = region_cobb(pt_end, mt_end)
tl_angle = region_cobb(mt_end, n)
# Store in spine object
spine.cobb_pt = pt_angle
spine.cobb_mt = mt_angle
spine.cobb_tl = tl_angle
# Determine curve type
if mt_angle > 10 and tl_angle > 10:
spine.curve_type = "S" # Double curve
elif mt_angle > 10 or tl_angle > 10:
spine.curve_type = "C" # Single curve
else:
spine.curve_type = "Normal"
return pt_angle, mt_angle, tl_angle
def find_apex_vertebrae(spine: Spine2D) -> List[int]:
"""
Find indices of apex vertebrae (most deviated from midline).
Args:
spine: Spine2D with computed curve
Returns:
List of vertebra indices that are curve apexes
"""
centroids = spine.get_centroids()
if len(centroids) < 5:
return []
# Find midline (linear fit through endpoints)
start = centroids[0]
end = centroids[-1]
# Distance from midline for each vertebra
midline_vec = end - start
midline_len = np.linalg.norm(midline_vec)
if midline_len < 1e-6:
return []
midline_unit = midline_vec / midline_len
# Calculate perpendicular distance to midline
deviations = []
for i, pt in enumerate(centroids):
v = pt - start
# Project onto midline
proj_len = np.dot(v, midline_unit)
proj = proj_len * midline_unit
# Perpendicular distance
perp = v - proj
dist = np.linalg.norm(perp)
# Sign: positive if to the right of midline
sign = np.sign(np.cross(midline_unit, v / (np.linalg.norm(v) + 1e-8)))
deviations.append(dist * sign)
deviations = np.array(deviations)
# Find local extrema (peaks and valleys)
apexes = []
for i in range(1, len(deviations) - 1):
# Local maximum
if deviations[i] > deviations[i-1] and deviations[i] > deviations[i+1]:
if abs(deviations[i]) > 5: # Minimum deviation threshold (pixels)
apexes.append(i)
# Local minimum
elif deviations[i] < deviations[i-1] and deviations[i] < deviations[i+1]:
if abs(deviations[i]) > 5:
apexes.append(i)
return apexes
def get_curve_severity(cobb_angle: float) -> str:
"""
Get clinical severity classification from Cobb angle.
Args:
cobb_angle: Cobb angle in degrees
Returns:
Severity string: "Normal", "Mild", "Moderate", or "Severe"
"""
if cobb_angle < 10:
return "Normal"
elif cobb_angle < 25:
return "Mild"
elif cobb_angle < 40:
return "Moderate"
else:
return "Severe"
def classify_rigo_type(spine: Spine2D) -> dict:
"""
Classify scoliosis according to Rigo-Chêneau brace classification.
Rigo Classification Types:
- A1, A2, A3: 3-curve patterns (thoracic major)
- B1, B2: 4-curve patterns (double major)
- C1, C2: Single thoracolumbar/lumbar
- E1, E2: Single thoracic
Args:
spine: Spine2D object with detected vertebrae and Cobb angles
Returns:
dict with 'rigo_type', 'description', 'apex_region', 'curve_pattern'
"""
# Get Cobb angles
cobb_angles = spine.get_cobb_angles()
pt = cobb_angles.get('PT', 0)
mt = cobb_angles.get('MT', 0)
tl = cobb_angles.get('TL', 0)
n_verts = len(spine.vertebrae)
# Calculate lateral deviations to determine curve direction
centroids = spine.get_centroids()
deviations = _calculate_lateral_deviations(centroids)
# Find apex positions and directions
apex_info = _find_apex_info(centroids, deviations, n_verts)
# Determine curve pattern based on number of significant curves
significant_curves = []
if pt >= 10:
significant_curves.append(('PT', pt))
if mt >= 10:
significant_curves.append(('MT', mt))
if tl >= 10:
significant_curves.append(('TL', tl))
n_curves = len(significant_curves)
# Classification logic
rigo_type = "N/A"
description = ""
curve_pattern = ""
# No significant scoliosis
if n_curves == 0 or max(pt, mt, tl) < 10:
rigo_type = "Normal"
description = "No significant scoliosis (all Cobb angles < 10°)"
curve_pattern = "None"
# Single curve patterns
elif n_curves == 1:
max_curve = significant_curves[0][0]
max_angle = significant_curves[0][1]
if max_curve == 'MT' or max_curve == 'PT':
# Thoracic single curve
if apex_info['thoracic_apex_idx'] is not None:
# Check if there's a compensatory lumbar
if tl > 5:
rigo_type = "E2"
description = f"Single thoracic curve ({max_angle:.1f}°) with lumbar compensatory ({tl:.1f}°)"
curve_pattern = "Thoracic with compensation"
else:
rigo_type = "E1"
description = f"True single thoracic curve ({max_angle:.1f}°)"
curve_pattern = "Single thoracic"
else:
rigo_type = "E1"
description = f"Single thoracic curve ({max_angle:.1f}°)"
curve_pattern = "Single thoracic"
elif max_curve == 'TL':
# Thoracolumbar/Lumbar single curve
if mt > 5 or pt > 5:
rigo_type = "C2"
description = f"Thoracolumbar curve ({tl:.1f}°) with upper compensatory"
curve_pattern = "TL/L with compensation"
else:
rigo_type = "C1"
description = f"Single thoracolumbar/lumbar curve ({tl:.1f}°)"
curve_pattern = "Single TL/L"
# Double curve patterns
elif n_curves >= 2:
# Determine which curves are primary
thoracic_total = pt + mt
lumbar_total = tl
# Check curve directions for S vs C pattern
is_s_curve = apex_info['is_s_pattern']
if is_s_curve:
# S-curve: typically 3 or 4 curve patterns
if thoracic_total > lumbar_total * 1.5:
# Thoracic dominant - Type A (3-curve)
if apex_info['lumbar_apex_low']:
rigo_type = "A1"
description = f"3-curve: Thoracic major ({mt:.1f}°), lumbar apex low"
elif apex_info['apex_at_tl_junction']:
rigo_type = "A2"
description = f"3-curve: Thoracolumbar transition ({mt:.1f}°/{tl:.1f}°)"
else:
rigo_type = "A3"
description = f"3-curve: Thoracic major ({mt:.1f}°) with structural lumbar ({tl:.1f}°)"
curve_pattern = "3-curve (thoracic major)"
elif lumbar_total > thoracic_total * 1.5:
# Lumbar dominant
rigo_type = "C2"
description = f"Lumbar major ({tl:.1f}°) with thoracic compensatory ({mt:.1f}°)"
curve_pattern = "Lumbar major"
else:
# Double major - Type B (4-curve)
if tl >= mt:
rigo_type = "B1"
description = f"4-curve: Double major, lumbar prominent ({tl:.1f}°/{mt:.1f}°)"
else:
rigo_type = "B2"
description = f"4-curve: Double major, thoracic prominent ({mt:.1f}°/{tl:.1f}°)"
curve_pattern = "4-curve (double major)"
else:
# C-curve pattern (curves in same direction)
if mt >= tl:
if tl > 5:
rigo_type = "A3"
description = f"Long thoracic curve ({mt:.1f}°) extending to lumbar ({tl:.1f}°)"
else:
rigo_type = "E2"
description = f"Thoracic curve ({mt:.1f}°) with minor lumbar ({tl:.1f}°)"
curve_pattern = "Extended thoracic"
else:
rigo_type = "C2"
description = f"TL/Lumbar curve ({tl:.1f}°) with thoracic involvement ({mt:.1f}°)"
curve_pattern = "Extended lumbar"
# Store in spine object
spine.rigo_type = rigo_type
spine.rigo_description = description
return {
'rigo_type': rigo_type,
'description': description,
'curve_pattern': curve_pattern,
'apex_info': apex_info,
'cobb_angles': cobb_angles,
'n_significant_curves': n_curves
}
def _calculate_lateral_deviations(centroids: np.ndarray) -> np.ndarray:
"""Calculate lateral deviation from midline for each vertebra."""
if len(centroids) < 2:
return np.zeros(len(centroids))
# Midline from first to last vertebra
start = centroids[0]
end = centroids[-1]
midline_vec = end - start
midline_len = np.linalg.norm(midline_vec)
if midline_len < 1e-6:
return np.zeros(len(centroids))
midline_unit = midline_vec / midline_len
deviations = []
for pt in centroids:
v = pt - start
# Project onto midline
proj_len = np.dot(v, midline_unit)
proj = proj_len * midline_unit
# Perpendicular vector
perp = v - proj
dist = np.linalg.norm(perp)
# Sign: positive = right, negative = left
sign = np.sign(np.cross(midline_unit, perp / (dist + 1e-8)))
deviations.append(dist * sign)
return np.array(deviations)
def _find_apex_info(centroids: np.ndarray, deviations: np.ndarray, n_verts: int) -> dict:
"""Find apex positions and determine curve pattern."""
info = {
'thoracic_apex_idx': None,
'lumbar_apex_idx': None,
'lumbar_apex_low': False,
'apex_at_tl_junction': False,
'is_s_pattern': False,
'apex_directions': []
}
if len(deviations) < 3:
return info
# Find local extrema (apexes)
apexes = []
apex_values = []
for i in range(1, len(deviations) - 1):
if (deviations[i] > deviations[i-1] and deviations[i] > deviations[i+1]) or \
(deviations[i] < deviations[i-1] and deviations[i] < deviations[i+1]):
if abs(deviations[i]) > 3: # Minimum threshold
apexes.append(i)
apex_values.append(deviations[i])
# Determine S-pattern (alternating signs at apexes)
if len(apex_values) >= 2:
signs = [np.sign(v) for v in apex_values]
# S-pattern if adjacent apexes have opposite signs
for i in range(len(signs) - 1):
if signs[i] != signs[i+1]:
info['is_s_pattern'] = True
break
# Classify apex regions
# Assume: top 40% = thoracic, middle 20% = TL junction, bottom 40% = lumbar
thoracic_end = int(0.4 * n_verts)
tl_junction_end = int(0.6 * n_verts)
for apex_idx in apexes:
if apex_idx < thoracic_end:
if info['thoracic_apex_idx'] is None or \
abs(deviations[apex_idx]) > abs(deviations[info['thoracic_apex_idx']]):
info['thoracic_apex_idx'] = apex_idx
elif apex_idx < tl_junction_end:
info['apex_at_tl_junction'] = True
else:
if info['lumbar_apex_idx'] is None or \
abs(deviations[apex_idx]) > abs(deviations[info['lumbar_apex_idx']]):
info['lumbar_apex_idx'] = apex_idx
# Check if lumbar apex is in lower region (bottom 30%)
if info['lumbar_apex_idx'] is not None:
if info['lumbar_apex_idx'] > int(0.7 * n_verts):
info['lumbar_apex_low'] = True
info['apex_directions'] = apex_values
return info