CBT_project/core/cylinder.py
Xiao Furen 4204d2cd4c feat(core): improve scoring logic and add X-ray projection rendering
Implement a more robust scoring mechanism for screw optimization and
add functionality to generate synthetic X-ray projections (AP and
lateral views) from CT data.

Key changes:
- core: add `generate_cylinder_butt_torch` to create a mask for the
  screw entrance (0.25mm) to exempt it from bone-breaching penalties.
- core: update `cl_score_torch_xfr` to include a diameter preference
  bonus and utilize the entrance mask.
- core: adjust optimizer bounds and scoring weights to favor larger
  diameter screws and improve convergence.
- xfr_cbt_native: implement `render_xray_projections` to generate
  synthetic AP and lateral X-ray images for visualization.
- visualization: enhance `render_bone_figure` with semi-transparent
  spinous process rendering and improved depth sorting for screws.
- xfr_debug: improve level detection to support arbitrary lumbar
  levels (L1-L9) and add safe volume-level cleanup for CBT writing.
- config: update allowed diameters and lengths constants.
2026-09-13 09:14:55 +08:00

381 lines
No EOL
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from scipy.spatial import KDTree
import torch
import numpy as np
from config.constant import ALLOWED_DIAMETERS, ALLOWED_LENGTHS
def create_coordinate_grid(
shape: tuple[int, int, int],
device: torch.device,
dtype: torch.dtype = torch.float32
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
"""
建立固定的 3D voxel coordinate grid
returns:
z_t, y_t, x_t with shape = (Z, Y, X)
"""
z_t = torch.arange(shape[0], device=device, dtype=dtype)
y_t = torch.arange(shape[1], device=device, dtype=dtype)
x_t = torch.arange(shape[2], device=device, dtype=dtype)
z_t, y_t, x_t = torch.meshgrid(z_t, y_t, x_t, indexing='ij')
return z_t, y_t, x_t
def snap_to_discrete_values(diameter_raw, length_raw):
"""
將連續值映射到最接近的允許離散值
Parameters:
diameter_raw: PSO 給的連續直徑值
length_raw: PSO 給的連續長度值
Returns:
diameter_discrete, length_discrete: 離散化後的值
"""
# 找最接近的 diameter
diameter_discrete = min(ALLOWED_DIAMETERS, key=lambda x: abs(x - diameter_raw))
# 找最接近的 length
length_discrete = min(ALLOWED_LENGTHS, key=lambda x: abs(x - length_raw))
return diameter_discrete, length_discrete
points = (
(4.5, 25),
(4.5, 30),
(4.5, 35),
(5.0, 30),
(5.0, 35),
(5.0, 40),
(5.5, 30),
(5.5, 35),
(5.5, 40),
(5.5, 45),
(5.5, 50),
(6.0, 35),
(6.0, 40),
(6.0, 45),
(6.0, 50),
(6.0, 55),
(6.5, 35),
(6.5, 40),
(6.5, 45),
(6.5, 50),
(6.5, 55),
(7.0, 35),
(7.0, 40),
(7.0, 45),
(7.0, 50),
(7.5, 60),
(7.5, 70),
(7.5, 80),
)
tree = KDTree(points)
def snap_to_discrete_values_xfr(diameter_raw, length_raw):
return snap_to_discrete_values(diameter_raw, length_raw)
distance, index = tree.query((diameter_raw, length_raw))
return points[index][0], points[index][1]
def round_down_to_discrete_values_xfr(diameter_raw, length_raw):
"""
將連續值映射到小於等於該值的最接近允許離散值 (向下取整)
Parameters:
diameter_raw: PSO 給的連續直徑值
length_raw: PSO 給的連續長度值
Returns:
diameter_discrete, length_discrete: 離散化後的值
"""
# 找小於等於 raw 的最接近 diameter如果都大於 raw 則取最小值
valid_diameters = [d for d in ALLOWED_DIAMETERS if d <= diameter_raw]
diameter_discrete = max(valid_diameters) if valid_diameters else min(ALLOWED_DIAMETERS)
# 找小於等於 raw 的最接近 length如果都大於 raw 則取最小值
valid_lengths = [l for l in ALLOWED_LENGTHS if l <= length_raw]
length_discrete = max(valid_lengths) if valid_lengths else min(ALLOWED_LENGTHS)
return diameter_discrete, length_discrete
def generate_cylinder_n_torch(
diameter: float,
length: float,
position_z: float,
position_y: float,
position_x: float,
azimuth: float,
altitude: float,
shape: tuple[int, int, int],
spacing: list[float],
device: torch.device,
grid: tuple[torch.Tensor, torch.Tensor, torch.Tensor] | None = None
) -> torch.Tensor:
"""
Generate a "forward" (positive z) cylinder mask in 3D space using PyTorch tensors.
Returns a binary mask (torch.uint8) on the specified device.
"""
if grid is None:
z_t, y_t, x_t = create_coordinate_grid(shape, device)
else:
z_t, y_t, x_t = grid
azimuth_rad_t = torch.deg2rad(torch.tensor(azimuth, device=device, dtype=torch.float32))
altitude_rad_t = torch.deg2rad(torch.tensor(altitude, device=device, dtype=torch.float32))
# Shift
z_t = z_t - position_z
y_t = y_t - position_y
x_t = x_t - position_x
# Apply rotation (same formula as your NumPy version, but in torch)
x_rot = (
x_t * torch.cos(azimuth_rad_t) * torch.cos(altitude_rad_t)
+ y_t * torch.sin(azimuth_rad_t) * torch.cos(altitude_rad_t)
- z_t * torch.sin(altitude_rad_t)
)
y_rot = -x_t * torch.sin(azimuth_rad_t) + y_t * torch.cos(azimuth_rad_t)
z_rot = (
x_t * torch.cos(azimuth_rad_t) * torch.sin(altitude_rad_t)
+ y_t * torch.sin(azimuth_rad_t) * torch.sin(altitude_rad_t)
+ z_t * torch.cos(altitude_rad_t)
)
# Handle spacing
# You can expand or generalize for more spacing options
if spacing == [1, 1, 1]:
radius = diameter / 2.0
mask = (
(x_rot**2 + y_rot**2 <= radius**2)
& (z_rot >= 0)
& (z_rot <= length)
)
elif spacing == [0.5, 0.5, 0.5]:
radius = (diameter / 2.0) * 2
mask = (
(x_rot**2 + y_rot**2 <= radius**2)
& (z_rot >= 0)
& (z_rot <= length * 2)
)
else:
raise ValueError(f"Unsupported spacing: {spacing}")
# Convert boolean mask to uint8
cylinder_mask = mask.to(torch.uint8)
return cylinder_mask
def generate_cylinder_o_torch(
diameter: float,
length: float,
position_z: float,
position_y: float,
position_x: float,
azimuth: float,
altitude: float,
shape: tuple[int, int, int],
spacing: list[float],
device: torch.device,
grid: tuple[torch.Tensor, torch.Tensor, torch.Tensor] | None = None
) -> torch.Tensor:
"""
Generate an "opposite" (negative z) cylinder mask in 3D space using PyTorch tensors.
Returns a binary mask (torch.uint8) on the specified device.
"""
if grid is None:
z_t, y_t, x_t = create_coordinate_grid(shape, device)
else:
z_t, y_t, x_t = grid
# Convert angles to torch
azimuth_rad_t = torch.deg2rad(torch.tensor(azimuth, device=device, dtype=torch.float32))
altitude_rad_t = torch.deg2rad(torch.tensor(altitude, device=device, dtype=torch.float32))
# Shift
z_t = z_t - position_z
y_t = y_t - position_y
x_t = x_t - position_x
# Apply rotation
x_rot = (
x_t * torch.cos(azimuth_rad_t) * torch.cos(altitude_rad_t)
+ y_t * torch.sin(azimuth_rad_t) * torch.cos(altitude_rad_t)
- z_t * torch.sin(altitude_rad_t)
)
y_rot = -x_t * torch.sin(azimuth_rad_t) + y_t * torch.cos(azimuth_rad_t)
z_rot = (
x_t * torch.cos(azimuth_rad_t) * torch.sin(altitude_rad_t)
+ y_t * torch.sin(azimuth_rad_t) * torch.sin(altitude_rad_t)
+ z_t * torch.cos(altitude_rad_t)
)
# Handle spacing
if spacing == [1, 1, 1]:
radius = diameter / 2.0
mask = (
(x_rot**2 + y_rot**2 <= radius**2)
& (z_rot <= 0)
& (z_rot <= length)
)
elif spacing == [0.5, 0.5, 0.5]:
radius = (diameter / 2.0) * 2
mask = (
(x_rot**2 + y_rot**2 <= radius**2)
& (z_rot <= 0)
& (z_rot >= -length * 2)
)
else:
raise ValueError(f"Unsupported spacing: {spacing}")
cylinder_mask_o = mask.to(torch.uint8)
return cylinder_mask_o
def generate_cylinder_numpy(diameter, length, position_z, position_y, position_x, azimuth, altitude, shape, spacing):
azimuth = np.radians(azimuth)
altitude = np.radians(altitude)
cylinder_mask = np.zeros(shape, dtype=np.uint8)
z, y, x = np.mgrid[0:shape[0], 0:shape[1], 0:shape[2]].astype(np.float64)
z -= float(position_z)
y -= float(position_y)
x -= float(position_x)
x_rot = x * np.cos(azimuth) * np.cos(altitude) + y * np.sin(azimuth) * np.cos(altitude) - z * np.sin(altitude)
y_rot = -x * np.sin(azimuth) + y * np.cos(azimuth)
z_rot = x * np.cos(azimuth) * np.sin(altitude) + y * np.sin(azimuth) * np.sin(altitude) + z * np.cos(altitude)
if spacing == [1, 1, 1]:
radius = diameter / 2.0
cylinder = (x_rot**2 + y_rot**2 <= radius**2) & (z_rot >= 0) & (z_rot <= length)
elif spacing == [0.5, 0.5, 0.5]:
radius = diameter / 2.0 * 2 # *2 is for resampling
cylinder = (x_rot**2 + y_rot**2 <= radius**2) & (z_rot >= 0) & (z_rot <= length * 2)
cylinder_mask[cylinder] = 1
return cylinder_mask
def generate_cylinder_butt_torch(
diameter,
position_z, position_y, position_x,
azimuth, altitude,
shape, spacing, device, grid=None,
butt_mm=0.25 # 入口端最後 butt_mmmm
) -> torch.Tensor:
"""生成圓柱「最後端」mask入口端z_rot=0遠離 VBODY 的一端)
最靠近的 butt_mm 圓柱短柱(與 generate_cylinder_n_torch 同慣例、同直徑)。
尖端在 z_rot=length靠 VBODY 端),不在此 mask。
此 mask 是完整圓柱的子集,可直接用於 not_in_bone 豁免。"""
if grid is None:
z_t, y_t, x_t = create_coordinate_grid(shape, device)
else:
z_t, y_t, x_t = grid
azimuth_rad_t = torch.deg2rad(torch.tensor(azimuth, device=device, dtype=torch.float32))
altitude_rad_t = torch.deg2rad(torch.tensor(altitude, device=device, dtype=torch.float32))
z_t = z_t - position_z
y_t = y_t - position_y
x_t = x_t - position_x
x_rot = (
x_t * torch.cos(azimuth_rad_t) * torch.cos(altitude_rad_t)
+ y_t * torch.sin(azimuth_rad_t) * torch.cos(altitude_rad_t)
- z_t * torch.sin(altitude_rad_t)
)
y_rot = -x_t * torch.sin(azimuth_rad_t) + y_t * torch.cos(azimuth_rad_t)
z_rot = (
x_t * torch.cos(azimuth_rad_t) * torch.sin(altitude_rad_t)
+ y_t * torch.sin(azimuth_rad_t) * torch.sin(altitude_rad_t)
+ z_t * torch.cos(altitude_rad_t)
)
# 與 generate_cylinder_n_torch 相同的 spacing/單位處理;
# 長度固定為 butt_mmmm→ voxel
if spacing == [1, 1, 1]:
radius = diameter / 2.0
butt_len = butt_mm
elif spacing == [0.5, 0.5, 0.5]:
radius = (diameter / 2.0) * 2
butt_len = butt_mm * 2
else:
raise ValueError(f"Unsupported spacing: {spacing}")
mask = (
(x_rot**2 + y_rot**2 <= radius**2)
& (z_rot >= 0)
& (z_rot < butt_len)
)
return mask.to(torch.uint8)
def generate_cylinder_tip_torch(
diameter, length,
position_z, position_y, position_x,
azimuth, altitude,
shape, spacing, device, grid=None,
tip_ratio=0.2 # 取末端 20% 當尖端
) -> torch.Tensor:
"""只生成圓柱末端的 mask"""
if grid is None:
z_t, y_t, x_t = create_coordinate_grid(shape, device)
else:
z_t, y_t, x_t = grid
azimuth_rad_t = torch.deg2rad(torch.tensor(azimuth, device=device, dtype=torch.float32))
altitude_rad_t = torch.deg2rad(torch.tensor(altitude, device=device, dtype=torch.float32))
z_t = z_t - position_z
y_t = y_t - position_y
x_t = x_t - position_x
x_rot = (
x_t * torch.cos(azimuth_rad_t) * torch.cos(altitude_rad_t)
+ y_t * torch.sin(azimuth_rad_t) * torch.cos(altitude_rad_t)
- z_t * torch.sin(altitude_rad_t)
)
y_rot = -x_t * torch.sin(azimuth_rad_t) + y_t * torch.cos(azimuth_rad_t)
z_rot = (
x_t * torch.cos(azimuth_rad_t) * torch.sin(altitude_rad_t)
+ y_t * torch.sin(azimuth_rad_t) * torch.sin(altitude_rad_t)
+ z_t * torch.cos(altitude_rad_t)
)
if spacing == [0.5, 0.5, 0.5]:
radius = (diameter / 2.0) * 2
total_length = length * 2
else:
radius = diameter / 2.0
total_length = length
tip_start = total_length * (1 - tip_ratio) # 末端 20% 開始的位置
mask = (
(x_rot**2 + y_rot**2 <= radius**2)
& (z_rot >= tip_start) # 只取末端
& (z_rot <= total_length)
)
return mask.to(torch.uint8)