refactor(core): remove global state and implement OptimizationContext
Refactor the optimization pipeline to eliminate module-level global variables, improving thread safety and modularity. Introduced `OptimizationContext` to explicitly manage shared state during cylinder evaluation. Key changes: - core: Replace global variables with `OptimizationContext` dataclass in `objective.py`. - core: Implement `refine_lateral_longer` in `optimizer.py` for deterministic local refinement of screw placement. - core: Update scoring logic in `scoring.py` to use higher penalties for out-of-bone voxels. - imaging: Add advanced symmetry detection including `best_symmetry_plane` and `best_symmetry_axis_angle` in `orientation.py`. - visualization: Enhance 3D plotting in `res_plot_3d.py` with volume absorption rendering (Beer-Lambert law) for an X-ray-like appearance. - xfr_debug: Implement a custom `_Tee` logger to support multi-process logging with volume and level-specific tags. - chore: Update `.gitignore` to include local logs and kilo directories.
This commit is contained in:
parent
0274e954be
commit
4f5be3d3e9
13 changed files with 1278 additions and 345 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -215,4 +215,6 @@ __marimo__/
|
||||||
# Streamlit
|
# Streamlit
|
||||||
.streamlit/secrets.toml
|
.streamlit/secrets.toml
|
||||||
|
|
||||||
|
.kilo/
|
||||||
|
logs/
|
||||||
progress.json
|
progress.json
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,6 @@
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
import random
|
import random
|
||||||
|
|
||||||
from scipy.ndimage import map_coordinates
|
from scipy.ndimage import map_coordinates
|
||||||
|
|
@ -9,50 +12,58 @@ from core.cylinder import generate_cylinder_n_torch, generate_cylinder_o_torch,
|
||||||
from core.intersection import center_line_intersections_torch
|
from core.intersection import center_line_intersections_torch
|
||||||
from core.scoring import cl_score_torch, cl_score_torch_xfr
|
from core.scoring import cl_score_torch, cl_score_torch_xfr
|
||||||
|
|
||||||
# Global variables (used in objective_function)
|
|
||||||
image1_array = None # cortical_nii.gz
|
|
||||||
image2_array = None # binarynii.gz
|
|
||||||
image2_shape = None
|
|
||||||
image3_array = None # roi2.nii.gz
|
|
||||||
diameter = None
|
|
||||||
length = None
|
|
||||||
spacing = [0.5, 0.5, 0.5]
|
|
||||||
device = None
|
|
||||||
grid = None
|
|
||||||
USE_TIP_PENALTY = None
|
|
||||||
|
|
||||||
def set_global_context(
|
# =====================================================================
|
||||||
cortical,
|
# OptimizationContext: the single explicit container for all shared state
|
||||||
spine,
|
# needed to evaluate a candidate cylinder. New code should build one of
|
||||||
shape,
|
# these and bind it to an objective via make_objective_function[_xfr];
|
||||||
spacing_,
|
# no module globals are read during optimization.
|
||||||
device_,
|
# =====================================================================
|
||||||
grid_,
|
|
||||||
use_tip_penalty=False # 新增
|
@dataclass
|
||||||
):
|
class OptimizationContext:
|
||||||
global cortical_tensor, spine_tensor, image2_shape, spacing, device, grid, USE_TIP_PENALTY
|
"""Shared state for evaluating candidate cylinder placements.
|
||||||
|
|
||||||
|
Required:
|
||||||
|
cortical_tensor: cortical bone mask (uint8, 0/1)
|
||||||
|
spine_tensor: bone (binary) mask (uint8, 0/1)
|
||||||
|
image2_shape: (Z, Y, X) volume shape
|
||||||
|
spacing: voxel spacing, [sx, sy, sz] (mm)
|
||||||
|
device: torch device for tensor ops
|
||||||
|
|
||||||
|
Optional:
|
||||||
|
grid: precomputed coordinate grid (z_t, y_t, x_t)
|
||||||
|
use_tip_penalty: add the tip-cylinder penalty to the loss
|
||||||
|
The *_array / spine_roi_tensor / diameter / length fields are kept
|
||||||
|
for compatibility and debugging; the loss itself does not use them.
|
||||||
|
"""
|
||||||
|
cortical_tensor: torch.Tensor
|
||||||
|
spine_tensor: torch.Tensor
|
||||||
|
image2_shape: tuple
|
||||||
|
spacing: list
|
||||||
|
device: torch.device
|
||||||
|
grid: Optional[tuple] = None
|
||||||
|
use_tip_penalty: bool = False
|
||||||
|
spine_roi_tensor: Optional[torch.Tensor] = None
|
||||||
|
image1_array: Optional[np.ndarray] = None
|
||||||
|
image2_array: Optional[np.ndarray] = None
|
||||||
|
image3_array: Optional[np.ndarray] = None
|
||||||
|
diameter: Optional[float] = None
|
||||||
|
length: Optional[float] = None
|
||||||
|
|
||||||
cortical_tensor = cortical
|
|
||||||
spine_tensor = spine
|
|
||||||
image2_shape = shape
|
|
||||||
spacing = spacing_
|
|
||||||
device = device_
|
|
||||||
grid = grid_
|
|
||||||
USE_TIP_PENALTY = use_tip_penalty
|
|
||||||
|
|
||||||
def cylinder_circle_line_intersection_loss_deductions_torch(
|
def cylinder_circle_line_intersection_loss_deductions_torch(
|
||||||
|
ctx: OptimizationContext,
|
||||||
diameter: float,
|
diameter: float,
|
||||||
length: float,
|
length: float,
|
||||||
params: list[float],
|
params: list[float],
|
||||||
image_shape: tuple[int, int, int],
|
|
||||||
cortical_tensor: torch.Tensor,
|
|
||||||
spine_tensor: torch.Tensor,
|
|
||||||
spacing: list[float],
|
|
||||||
device: torch.device
|
|
||||||
) -> float:
|
) -> float:
|
||||||
"""
|
"""
|
||||||
Computes the loss for a given set of cylinder params in PyTorch,
|
Computes the loss for a given set of cylinder params in PyTorch,
|
||||||
returning a Python float for PSO consumption.
|
returning a Python float for PSO consumption.
|
||||||
|
|
||||||
|
All shared state (tensors, shape, spacing, device, grid, tip penalty)
|
||||||
|
is taken from `ctx`; no module globals are involved.
|
||||||
"""
|
"""
|
||||||
position_z, position_y, position_x, azimuth, altitude = params
|
position_z, position_y, position_x, azimuth, altitude = params
|
||||||
|
|
||||||
|
|
@ -64,10 +75,10 @@ def cylinder_circle_line_intersection_loss_deductions_torch(
|
||||||
position_x,
|
position_x,
|
||||||
float(azimuth),
|
float(azimuth),
|
||||||
float(altitude),
|
float(altitude),
|
||||||
image_shape,
|
ctx.image2_shape,
|
||||||
spacing,
|
ctx.spacing,
|
||||||
device,
|
ctx.device,
|
||||||
grid
|
ctx.grid
|
||||||
)
|
)
|
||||||
|
|
||||||
cyl_opp = generate_cylinder_o_torch(
|
cyl_opp = generate_cylinder_o_torch(
|
||||||
|
|
@ -78,13 +89,12 @@ def cylinder_circle_line_intersection_loss_deductions_torch(
|
||||||
position_x,
|
position_x,
|
||||||
float(azimuth),
|
float(azimuth),
|
||||||
float(altitude),
|
float(altitude),
|
||||||
image_shape,
|
ctx.image2_shape,
|
||||||
spacing,
|
ctx.spacing,
|
||||||
device,
|
ctx.device,
|
||||||
grid
|
ctx.grid
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# We call the center_line_intersections in Torch mode
|
# We call the center_line_intersections in Torch mode
|
||||||
intersections, _ = center_line_intersections_torch(
|
intersections, _ = center_line_intersections_torch(
|
||||||
position_z,
|
position_z,
|
||||||
|
|
@ -93,73 +103,37 @@ def cylinder_circle_line_intersection_loss_deductions_torch(
|
||||||
azimuth,
|
azimuth,
|
||||||
altitude,
|
altitude,
|
||||||
length,
|
length,
|
||||||
spine_tensor,
|
ctx.spine_tensor,
|
||||||
spacing,
|
ctx.spacing,
|
||||||
device
|
ctx.device
|
||||||
)
|
)
|
||||||
|
|
||||||
cyl_tip = None
|
cyl_tip = None
|
||||||
if USE_TIP_PENALTY:
|
if ctx.use_tip_penalty:
|
||||||
cyl_tip = generate_cylinder_tip_torch(
|
cyl_tip = generate_cylinder_tip_torch(
|
||||||
diameter, length,
|
diameter, length,
|
||||||
position_z, position_y, position_x,
|
position_z, position_y, position_x,
|
||||||
float(azimuth), float(altitude),
|
float(azimuth), float(altitude),
|
||||||
image_shape, spacing, device, grid
|
ctx.image2_shape, ctx.spacing, ctx.device, ctx.grid
|
||||||
)
|
)
|
||||||
|
|
||||||
# loss_value = cl_score_torch(
|
# loss_value = cl_score_torch(
|
||||||
loss_value = cl_score_torch_xfr(
|
loss_value = cl_score_torch_xfr(
|
||||||
cortical_tensor, spine_tensor,
|
ctx.cortical_tensor, ctx.spine_tensor,
|
||||||
cyl_fwd, cyl_opp, intersections,
|
cyl_fwd, cyl_opp, intersections,
|
||||||
cylinder_tip_torch=cyl_tip
|
cylinder_tip_torch=cyl_tip
|
||||||
)
|
)
|
||||||
|
|
||||||
return loss_value
|
return loss_value
|
||||||
|
|
||||||
def objective_function_xfr(params: list[float], y_indices) -> float:
|
|
||||||
|
# =====================================================================
|
||||||
|
# Context-bound objective builders (preferred API)
|
||||||
|
# =====================================================================
|
||||||
|
|
||||||
|
def _evaluate(params: list[float], ctx: OptimizationContext) -> float:
|
||||||
"""
|
"""
|
||||||
Wrapper for the PSO objective function, calling our Torch-based loss function.
|
Core objective: params = [z, y, x, azimuth, altitude, diameter_raw, length_raw]
|
||||||
Now params includes diameter and length at the end.
|
|
||||||
params = [position_z, position_y, position_x, azimuth, altitude, diameter_raw, length_raw]
|
|
||||||
"""
|
|
||||||
|
|
||||||
# position_params = params[:5] # [z, y, x, azimuth, altitude]
|
|
||||||
# diameter_raw = params[5]
|
|
||||||
# length_raw = params[6]
|
|
||||||
|
|
||||||
z, x, azimuth, altitude, diameter_raw, length_raw = params
|
|
||||||
y = y_indices[round(z), round(x)] #+ random.uniform(-0.5, 0.5)
|
|
||||||
|
|
||||||
# coords = np.array([[z], [x]])
|
|
||||||
# result = map_coordinates(y_indices, coords, order=1)
|
|
||||||
# y= result[0]
|
|
||||||
|
|
||||||
position_params = [z, y, x, azimuth, altitude]
|
|
||||||
|
|
||||||
# 將連續值轉換為離散值
|
|
||||||
# diameter_discrete, length_discrete = snap_to_discrete_values(diameter_raw, length_raw)
|
|
||||||
diameter_discrete, length_discrete = snap_to_discrete_values_xfr(diameter_raw, length_raw)
|
|
||||||
|
|
||||||
diameter_loss = .9*diameter_discrete + .1*diameter_raw
|
|
||||||
length_loss = .9* length_discrete + .1* length_raw
|
|
||||||
|
|
||||||
loss = cylinder_circle_line_intersection_loss_deductions_torch(
|
|
||||||
diameter_loss,
|
|
||||||
length_loss,
|
|
||||||
position_params,
|
|
||||||
image2_shape,
|
|
||||||
cortical_tensor,
|
|
||||||
spine_tensor,
|
|
||||||
spacing,
|
|
||||||
device
|
|
||||||
)
|
|
||||||
return loss
|
|
||||||
|
|
||||||
def objective_function(params: list[float]) -> float:
|
|
||||||
"""
|
|
||||||
Wrapper for the PSO objective function, calling our Torch-based loss function.
|
|
||||||
Now params includes diameter and length at the end.
|
|
||||||
params = [position_z, position_y, position_x, azimuth, altitude, diameter_raw, length_raw]
|
|
||||||
"""
|
"""
|
||||||
position_params = params[:5] # [z, y, x, azimuth, altitude]
|
position_params = params[:5] # [z, y, x, azimuth, altitude]
|
||||||
diameter_raw = params[5]
|
diameter_raw = params[5]
|
||||||
|
|
@ -168,14 +142,161 @@ def objective_function(params: list[float]) -> float:
|
||||||
# 將連續值轉換為離散值
|
# 將連續值轉換為離散值
|
||||||
diameter_discrete, length_discrete = snap_to_discrete_values(diameter_raw, length_raw)
|
diameter_discrete, length_discrete = snap_to_discrete_values(diameter_raw, length_raw)
|
||||||
|
|
||||||
loss = cylinder_circle_line_intersection_loss_deductions_torch(
|
return cylinder_circle_line_intersection_loss_deductions_torch(
|
||||||
|
ctx,
|
||||||
diameter_discrete,
|
diameter_discrete,
|
||||||
length_discrete,
|
length_discrete,
|
||||||
position_params,
|
position_params
|
||||||
image2_shape,
|
|
||||||
cortical_tensor,
|
|
||||||
spine_tensor,
|
|
||||||
spacing,
|
|
||||||
device
|
|
||||||
)
|
)
|
||||||
return loss
|
|
||||||
|
|
||||||
|
def _evaluate_xfr(params: list[float], ctx: OptimizationContext, y_indices) -> float:
|
||||||
|
"""
|
||||||
|
Core xfr objective: params = [z, x, azimuth, altitude, diameter_raw, length_raw];
|
||||||
|
y is derived from the per-column `y_indices` surface.
|
||||||
|
"""
|
||||||
|
z, x, azimuth, altitude, diameter_raw, length_raw = params
|
||||||
|
y = y_indices[round(z), round(x)] #+ random.uniform(-0.5, 0.5)
|
||||||
|
|
||||||
|
position_params = [z, y, x, azimuth, altitude]
|
||||||
|
|
||||||
|
# 將連續值轉換為離散值
|
||||||
|
diameter_discrete, length_discrete = snap_to_discrete_values_xfr(diameter_raw, length_raw)
|
||||||
|
|
||||||
|
diameter_loss = .9*diameter_discrete + .1*diameter_raw
|
||||||
|
length_loss = .9* length_discrete + .1* length_raw
|
||||||
|
|
||||||
|
return cylinder_circle_line_intersection_loss_deductions_torch(
|
||||||
|
ctx,
|
||||||
|
diameter_loss,
|
||||||
|
length_loss,
|
||||||
|
position_params
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def make_objective_function(ctx: OptimizationContext):
|
||||||
|
"""
|
||||||
|
Return an objective function bound to `ctx`.
|
||||||
|
Pass the returned callable directly to pso / differential_evolution / minimize.
|
||||||
|
"""
|
||||||
|
def objective(params: list[float]) -> float:
|
||||||
|
return _evaluate(params, ctx)
|
||||||
|
return objective
|
||||||
|
|
||||||
|
|
||||||
|
def make_objective_function_xfr(ctx: OptimizationContext, y_indices):
|
||||||
|
"""
|
||||||
|
Return an xfr objective function bound to `ctx` and the `y_indices` surface.
|
||||||
|
Pass the returned callable directly to pso / differential_evolution / minimize.
|
||||||
|
"""
|
||||||
|
def objective(params: list[float]) -> float:
|
||||||
|
return _evaluate_xfr(params, ctx, y_indices)
|
||||||
|
return objective
|
||||||
|
|
||||||
|
|
||||||
|
# =====================================================================
|
||||||
|
# Legacy API (backward compatible)
|
||||||
|
#
|
||||||
|
# The old flow mutated module attributes on this file from the optimizers
|
||||||
|
# ("跨檔案注入變數"), which was fragile: any forgotten attribute showed up
|
||||||
|
# deep inside an optimizer callback as a NoneType/NameError. It is kept so
|
||||||
|
# existing callers (set_global_context + objective_function) keep working,
|
||||||
|
# but new code should use OptimizationContext + the make_* factories.
|
||||||
|
# =====================================================================
|
||||||
|
|
||||||
|
# Module-level state for the legacy path only.
|
||||||
|
cortical_tensor = None
|
||||||
|
spine_tensor = None
|
||||||
|
image1_array = None # cortical_nii.gz
|
||||||
|
image2_array = None # binarynii.gz
|
||||||
|
image2_shape = None
|
||||||
|
image3_array = None # roi2.nii.gz
|
||||||
|
diameter = None
|
||||||
|
length = None
|
||||||
|
spacing = [0.5, 0.5, 0.5]
|
||||||
|
device = None
|
||||||
|
grid = None
|
||||||
|
USE_TIP_PENALTY = None
|
||||||
|
|
||||||
|
_current_context: Optional[OptimizationContext] = None
|
||||||
|
|
||||||
|
|
||||||
|
def set_global_context(
|
||||||
|
cortical,
|
||||||
|
spine,
|
||||||
|
shape,
|
||||||
|
spacing_,
|
||||||
|
device_,
|
||||||
|
grid_,
|
||||||
|
use_tip_penalty=False
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Legacy: set the process-wide context used by the legacy
|
||||||
|
objective_function / objective_function_xfr wrappers.
|
||||||
|
|
||||||
|
Returns the built OptimizationContext for convenience.
|
||||||
|
"""
|
||||||
|
global _current_context
|
||||||
|
global cortical_tensor, spine_tensor, image2_shape, spacing, device, grid, USE_TIP_PENALTY
|
||||||
|
|
||||||
|
_current_context = OptimizationContext(
|
||||||
|
cortical_tensor=cortical,
|
||||||
|
spine_tensor=spine,
|
||||||
|
image2_shape=shape,
|
||||||
|
spacing=spacing_,
|
||||||
|
device=device_,
|
||||||
|
grid=grid_,
|
||||||
|
use_tip_penalty=use_tip_penalty,
|
||||||
|
)
|
||||||
|
|
||||||
|
# mirror into legacy module attributes for any code that reads them
|
||||||
|
cortical_tensor = cortical
|
||||||
|
spine_tensor = spine
|
||||||
|
image2_shape = shape
|
||||||
|
spacing = spacing_
|
||||||
|
device = device_
|
||||||
|
grid = grid_
|
||||||
|
USE_TIP_PENALTY = use_tip_penalty
|
||||||
|
|
||||||
|
return _current_context
|
||||||
|
|
||||||
|
|
||||||
|
def _active_context() -> OptimizationContext:
|
||||||
|
"""Resolve the context for the legacy wrappers, with a clear error."""
|
||||||
|
if _current_context is not None:
|
||||||
|
return _current_context
|
||||||
|
|
||||||
|
# Fallback: a caller (old-style optimizers) may have set the legacy
|
||||||
|
# module attributes directly — rebuild a context from them.
|
||||||
|
if cortical_tensor is not None and spine_tensor is not None:
|
||||||
|
return OptimizationContext(
|
||||||
|
cortical_tensor=cortical_tensor,
|
||||||
|
spine_tensor=spine_tensor,
|
||||||
|
image2_shape=image2_shape,
|
||||||
|
spacing=spacing,
|
||||||
|
device=device,
|
||||||
|
grid=grid,
|
||||||
|
use_tip_penalty=bool(USE_TIP_PENALTY),
|
||||||
|
)
|
||||||
|
|
||||||
|
raise RuntimeError(
|
||||||
|
"No optimization context available. Either call set_global_context(...) "
|
||||||
|
"or (preferred) build an OptimizationContext and use "
|
||||||
|
"make_objective_function / make_objective_function_xfr."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def objective_function(params: list[float]) -> float:
|
||||||
|
"""
|
||||||
|
Legacy wrapper: evaluates against the context set by set_global_context.
|
||||||
|
params = [z, y, x, azimuth, altitude, diameter_raw, length_raw]
|
||||||
|
"""
|
||||||
|
return _evaluate(params, _active_context())
|
||||||
|
|
||||||
|
|
||||||
|
def objective_function_xfr(params: list[float], y_indices) -> float:
|
||||||
|
"""
|
||||||
|
Legacy wrapper: evaluates against the context set by set_global_context.
|
||||||
|
params = [z, x, azimuth, altitude, diameter_raw, length_raw]
|
||||||
|
"""
|
||||||
|
return _evaluate_xfr(params, _active_context(), y_indices)
|
||||||
|
|
@ -2,16 +2,89 @@ import time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import SimpleITK as sitk
|
import SimpleITK as sitk
|
||||||
import torch
|
import torch
|
||||||
from imaging.orientation import azimuth_rotation, analyze_vertebral_tilt_contour
|
from imaging.orientation import (azimuth_rotation, analyze_vertebral_tilt_contour,
|
||||||
|
best_symmetry_plane, best_upper_endplate_plane,
|
||||||
|
segment_spinous_process)
|
||||||
from config.constant import ALLOWED_DIAMETERS, ALLOWED_LENGTHS
|
from config.constant import ALLOWED_DIAMETERS, ALLOWED_LENGTHS
|
||||||
from core.objective import objective_function, objective_function_xfr
|
from core.objective import OptimizationContext, make_objective_function, make_objective_function_xfr
|
||||||
from pyswarm import pso
|
from pyswarm import pso
|
||||||
import core.objective # <--- 加入這行,讓我們可以直接操作 objective 模組
|
from core.cylinder import generate_cylinder_n_torch, generate_cylinder_o_torch, snap_to_discrete_values, create_coordinate_grid, snap_to_discrete_values_xfr
|
||||||
from core.cylinder import generate_cylinder_n_torch, snap_to_discrete_values, create_coordinate_grid, snap_to_discrete_values_xfr
|
from core.intersection import center_line_intersections_torch
|
||||||
from core.scoring import compute_overlap_ratio_from_cylinder_mask, is_solution_ok
|
from core.scoring import compute_overlap_ratio_from_cylinder_mask, is_solution_ok, cl_score_torch_xfr
|
||||||
from config.constant import OVERLAP_THRESH
|
from config.constant import OVERLAP_THRESH
|
||||||
from visualization.res_plot_3d import res_plt_2_torch
|
from visualization.res_plot_3d import res_plt_2_torch
|
||||||
|
|
||||||
|
LATERAL_REFINE_MIN_IN_BONE = 0.97
|
||||||
|
|
||||||
|
|
||||||
|
def refine_lateral_longer(
|
||||||
|
z, x, azimuth, altitude, diameter_raw, length_raw,
|
||||||
|
side, az_bounds, x_bounds, y_indices,
|
||||||
|
image_shape, spacing, device, grid,
|
||||||
|
cortical_tensor, spine_tensor,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Deterministic local refinement after PSO: try aiming more laterally and
|
||||||
|
using a longer screw. Only accepts a candidate if it stays
|
||||||
|
>= LATERAL_REFINE_MIN_IN_BONE inside bone AND improves the score.
|
||||||
|
|
||||||
|
left (x-lower half): more lateral = larger azimuth, entry shifted toward -x
|
||||||
|
right (x-upper half): more lateral = smaller azimuth, entry shifted toward +x
|
||||||
|
"""
|
||||||
|
def _score_candidate(cand):
|
||||||
|
z_c, x_c, az_c, alt_c, d_c, L_c = cand
|
||||||
|
y_c = y_indices[round(z_c), round(x_c)]
|
||||||
|
if y_c < 0:
|
||||||
|
return None
|
||||||
|
cyl = generate_cylinder_n_torch(d_c, L_c, z_c, y_c, x_c, az_c, alt_c,
|
||||||
|
image_shape, spacing, device, grid)
|
||||||
|
cyl_o = generate_cylinder_o_torch(d_c, L_c, z_c, y_c, x_c, az_c, alt_c,
|
||||||
|
image_shape, spacing, device, grid)
|
||||||
|
if cyl.sum().item() == 0:
|
||||||
|
return None
|
||||||
|
inter, _ = center_line_intersections_torch(z_c, y_c, x_c, az_c, alt_c,
|
||||||
|
L_c, spine_tensor, spacing, device)
|
||||||
|
loss = cl_score_torch_xfr(cortical_tensor, spine_tensor, cyl, cyl_o, inter)
|
||||||
|
in_bone = ((spine_tensor == 1) & (cyl == 1)).sum().item() / cyl.sum().item()
|
||||||
|
return {'pos': cand, 'loss': loss, 'in_bone': in_bone}
|
||||||
|
|
||||||
|
d_snap, L_snap = snap_to_discrete_values_xfr(diameter_raw, length_raw)
|
||||||
|
az_lo, az_hi = az_bounds
|
||||||
|
x_lo, x_hi = x_bounds
|
||||||
|
|
||||||
|
az_steps = [0.0, 5.0, 10.0, 15.0] if side == "L" else [0.0, -5.0, -10.0, -15.0]
|
||||||
|
x_shifts = [0.0, -4.0, -8.0] if side == "L" else [0.0, 4.0, 8.0]
|
||||||
|
|
||||||
|
best = _score_candidate((z, x, azimuth, altitude, d_snap, L_snap))
|
||||||
|
if best is None:
|
||||||
|
return z, x, azimuth, altitude, diameter_raw, length_raw, False, None
|
||||||
|
|
||||||
|
for ds in az_steps:
|
||||||
|
az_c = min(az_hi - 0.01, max(az_lo + 0.01, azimuth + ds))
|
||||||
|
if abs(az_c - azimuth) < 0.5 and ds != 0:
|
||||||
|
continue
|
||||||
|
for dx in x_shifts:
|
||||||
|
x_c = min(x_hi, max(x_lo, x + dx))
|
||||||
|
if abs(x_c - x) < 0.5 and dx != 0:
|
||||||
|
continue
|
||||||
|
for L_c in sorted({L_snap} | {l for l in ALLOWED_LENGTHS if l > L_snap}):
|
||||||
|
cand = _score_candidate((z, x_c, az_c, altitude, d_snap, L_c))
|
||||||
|
if cand is None:
|
||||||
|
continue
|
||||||
|
if cand['in_bone'] >= LATERAL_REFINE_MIN_IN_BONE and cand['loss'] < best['loss']:
|
||||||
|
best = cand
|
||||||
|
|
||||||
|
z_r, x_r, az_r, alt_r, d_r, L_r = best['pos']
|
||||||
|
adopted = not (abs(z_r - z) < 1e-6 and abs(x_r - x) < 1e-6
|
||||||
|
and abs(az_r - azimuth) < 1e-6 and abs(L_r - L_snap) < 1e-6)
|
||||||
|
return z_r, x_r, az_r, alt_r, d_r, L_r, adopted, best
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_bounds(lb, ub, name):
|
||||||
|
for i, (lo, hi) in enumerate(zip(lb, ub)):
|
||||||
|
if lo >= hi:
|
||||||
|
raise ValueError(f'PSO bounds invalid for {name}: dim {i} lower {lo} >= upper {hi}')
|
||||||
|
|
||||||
|
|
||||||
def get_first_nonzero_y(arr):
|
def get_first_nonzero_y(arr):
|
||||||
OUTSIDE_VALUE = -100
|
OUTSIDE_VALUE = -100
|
||||||
|
|
@ -80,59 +153,100 @@ def run_pso_torch_xfr(
|
||||||
spine_tensor = torch.from_numpy(image2_array).to(device=device, dtype=torch.uint8)
|
spine_tensor = torch.from_numpy(image2_array).to(device=device, dtype=torch.uint8)
|
||||||
spine_roi_tensor = torch.from_numpy(image3_array).to(device=device, dtype=torch.uint8)
|
spine_roi_tensor = torch.from_numpy(image3_array).to(device=device, dtype=torch.uint8)
|
||||||
|
|
||||||
# ================= [跨檔案注入變數:終極防呆版] =================
|
# 建立明確的優化上下文(取代舊的跨檔案 global 注入;狀態全部顯式傳入目標函數)
|
||||||
import core.objective
|
ctx = OptimizationContext(
|
||||||
|
cortical_tensor=cortical_tensor,
|
||||||
# 1. 注入 Tensors
|
spine_tensor=spine_tensor,
|
||||||
core.objective.cortical_tensor = cortical_tensor
|
spine_roi_tensor=spine_roi_tensor,
|
||||||
core.objective.spine_tensor = spine_tensor
|
image1_array=image1_array,
|
||||||
core.objective.spine_roi_tensor = spine_roi_tensor
|
image2_array=image2_array,
|
||||||
|
image3_array=image3_array,
|
||||||
# 2. 注入 Arrays (以防 objective 裡面偷偷用到 Numpy 陣列)
|
image2_shape=image2_shape,
|
||||||
core.objective.image1_array = image1_array
|
spacing=spacing,
|
||||||
core.objective.image2_array = image2_array
|
device=device,
|
||||||
core.objective.image3_array = image3_array
|
grid=grid,
|
||||||
|
diameter=diameter if not optimize_size else None,
|
||||||
# 3. 注入 Shapes (這就是導致這次 NoneType 報錯的真兇!)
|
length=length if not optimize_size else None,
|
||||||
core.objective.image2_shape = image2_shape # <--- 解除警報的最關鍵一行
|
)
|
||||||
core.objective.image_shape = image_shape
|
|
||||||
core.objective.shape = image_shape
|
|
||||||
|
|
||||||
# 4. 注入環境變數
|
|
||||||
core.objective.spacing = spacing
|
|
||||||
core.objective.device = device
|
|
||||||
core.objective.grid = grid
|
|
||||||
|
|
||||||
# 5. 注入尺寸參數 (兼容固定尺寸模式)
|
|
||||||
if not optimize_size:
|
|
||||||
core.objective.diameter = diameter
|
|
||||||
core.objective.length = length
|
|
||||||
# ==============================================================
|
|
||||||
|
|
||||||
azi = azimuth_rotation(image2_path)
|
azi = azimuth_rotation(image2_path)
|
||||||
res = analyze_vertebral_tilt_contour(image2_path, edge_type='superior', show_plot=False, debug=False)
|
res = analyze_vertebral_tilt_contour(image2_path, edge_type='superior', show_plot=False, debug=False)
|
||||||
alt = res['superior']['tilt_angle_deg']
|
alt = res['superior']['tilt_angle_deg']
|
||||||
|
|
||||||
|
# ===== az/alt 搜尋範圍錨點:以椎體自身座標系取代 2D 近似 =====
|
||||||
|
# 鏡稱對稱面(最佳 mirror plane):法線 (s_nx, s_ny, s_nz),s_nx>0 固定符號
|
||||||
|
# theta_v = atan2(s_ny, s_nx) 是椎體真實左右軸相對 +x 的旋轉角,
|
||||||
|
# 取代舊 2D 輪廓前點角 azi(舊式 az 中心 90-azi == 新式 90+theta_v)。
|
||||||
|
# 上終板面(normal 朝上, e_nz>0):
|
||||||
|
# tau_y = atan2(e_ny, e_nz):終板 AP 面傾斜;負 = 面向 +y(終板側)升高,
|
||||||
|
# 軌跡需爬得更陡 → altitude 中心減小(舊式 65-alt 的 3D 版)。
|
||||||
|
# tau_x = atan2(e_nx, e_nz):終板 LR 面傾斜;正 = 面向小 x(L 側)升高,
|
||||||
|
# L 側需更陡(-tau_x)、R 側較平(+tau_x)。
|
||||||
|
sym_plane = best_symmetry_plane(image2_array)
|
||||||
|
|
||||||
|
# 入口面 y_indices 取「每個 (z,x) 柱第一個 bone voxel(最後側)」,
|
||||||
|
# 中線柱會落在棘突上(入口太靠內後)。先把棘突(鏡稱面中線後側,
|
||||||
|
# 見 segment_spinous_process)從 image2_array 移除再取 surface,
|
||||||
|
# 讓 y_indices 永不落在棘突上(loss 用 spine_tensor,不受影響)。
|
||||||
|
sp_mask, sp_th, sp_info = segment_spinous_process(image2_array, sym_plane)
|
||||||
|
if sp_mask is not None and sp_mask.any():
|
||||||
|
n_sp = int(sp_mask.sum())
|
||||||
|
image2_array[sp_mask] = 0
|
||||||
|
print(f"[Y-INDEX] spinous process removed from entry surface: {n_sp} vox "
|
||||||
|
f"(band=+/-{sp_info['band_w']:.1f} voxel, AP>={sp_info['ap_thresh']:.1f}, "
|
||||||
|
f"mode={sp_info['mode']})")
|
||||||
|
|
||||||
y_indices = get_first_nonzero_y(image2_array)
|
y_indices = get_first_nonzero_y(image2_array)
|
||||||
|
objective_fn = make_objective_function_xfr(ctx, y_indices)
|
||||||
|
|
||||||
|
s_nx, s_ny, s_nz = sym_plane['normal']
|
||||||
|
theta_v = float(np.degrees(np.arctan2(s_ny, s_nx)))
|
||||||
|
endplate_plane = best_upper_endplate_plane(image2_array)
|
||||||
|
if endplate_plane is not None:
|
||||||
|
e_nx, e_ny, e_nz = endplate_plane['normal']
|
||||||
|
tau_y = float(np.degrees(np.arctan2(e_ny, e_nz)))
|
||||||
|
tau_x = float(np.degrees(np.arctan2(e_nx, e_nz)))
|
||||||
|
else:
|
||||||
|
tau_y, tau_x = 0.0, 0.0
|
||||||
|
print(f"[PLANE] mirror : {sym_plane['plane'][0]:+.3f}x {sym_plane['plane'][1]:+.3f}y "
|
||||||
|
f"{sym_plane['plane'][2]:+.3f}z = {sym_plane['plane'][3]:.1f}"
|
||||||
|
f" (theta_v={theta_v:+.2f} deg, mirror ratio={sym_plane['ratio']:.3f})")
|
||||||
|
if endplate_plane is not None:
|
||||||
|
p = endplate_plane['plane']
|
||||||
|
print(f"[PLANE] endplate: {p[0]:+.3f}x {p[1]:+.3f}y {p[2]:+.3f}z = {p[3]:.1f}"
|
||||||
|
f" (tau_y={tau_y:+.2f} deg, tau_x={tau_x:+.2f} deg, "
|
||||||
|
f"inlier={endplate_plane['inlier_ratio']:.2f})")
|
||||||
|
else:
|
||||||
|
print("[PLANE] endplate: 資料不足,altitude 錨點退回 tau_y=tau_x=0")
|
||||||
|
|
||||||
# flat_min_index = np.argmin(y_indices)
|
# flat_min_index = np.argmin(y_indices)
|
||||||
# z_border, x_border = np.unravel_index(flat_min_index, y_indices.shape)
|
# z_border, x_border = np.unravel_index(flat_min_index, y_indices.shape)
|
||||||
|
|
||||||
x_with_nonzero = np.where(np.any(image2_array[:,image_shape[1]//10,:] != 0, axis=0))[0]
|
# 脊椎中線:整段 (全體積) 骨頭 x 範圍的中點。
|
||||||
|
# 不取單一行的原因 (0005 L5):椎體軸狀面旋轉時單行只罩到單側骨塊
|
||||||
|
# (x 1..76 / W=224 → x_mid≈0.17W),L/R 兩個 band 被壓到同一側。
|
||||||
|
# 不用鏡稱對稱軸的原因 (0001 L4):逐切面對稱軸會被肋、後側要素
|
||||||
|
# 左右不對稱與椎體傾斜牽引 (69.0 vs 範圍中點 74.5),把 R band 內緣
|
||||||
|
# (x_mid+0.1W) 拉進中線棘突/椎板區,R 側入口落在棘突上 (太靠內後)。
|
||||||
|
# Laminectomy 只移除中線後側要素,左右極端 x 位置不變,
|
||||||
|
# 所以範圍中點同樣不受其影響,作為 L/R band 分割線比對稱軸穩定。
|
||||||
|
x_with_nonzero = np.where(np.any(image2_array != 0, axis=(0, 1)))[0]
|
||||||
x1 = x_with_nonzero[0]
|
x1 = x_with_nonzero[0]
|
||||||
x2 = x_with_nonzero[-1]
|
x2 = x_with_nonzero[-1]
|
||||||
|
x_width = x2 - x1
|
||||||
|
|
||||||
# print(x1,x2)
|
# print(x1,x2)
|
||||||
# exit()
|
# exit()
|
||||||
|
|
||||||
x_mid = (x1+x2)/2
|
# x_mid = (x1 + x2) / 2
|
||||||
x1 = x_mid-image_shape[2]*.1
|
# x1 = x_mid-image_shape[2]*.1
|
||||||
x2 = x_mid+image_shape[2]*.1
|
# x2 = x_mid+image_shape[2]*.1
|
||||||
|
|
||||||
z_sum = np.sum(image2_array, axis=(1, 2))
|
z_sum = np.sum(image2_array, axis=(1, 2))
|
||||||
z_with_nonzero = np.where(z_sum > 0)[0]
|
z_with_nonzero = np.where(z_sum > 0)[0]
|
||||||
z1 = z_with_nonzero[0]
|
z1 = z_with_nonzero[0]
|
||||||
z2 = z_with_nonzero[-1]
|
z2 = z_with_nonzero[-1]
|
||||||
|
z_height = z2-z1
|
||||||
|
|
||||||
# print(x1,x2)
|
# print(x1,x2)
|
||||||
# exit()
|
# exit()
|
||||||
|
|
@ -149,18 +263,37 @@ def run_pso_torch_xfr(
|
||||||
if CBT == True:
|
if CBT == True:
|
||||||
# z_bounds = (0, image_shape[0]-1)
|
# z_bounds = (0, image_shape[0]-1)
|
||||||
# z_bounds = (z1, (z1+z2)/2)
|
# z_bounds = (z1, (z1+z2)/2)
|
||||||
z_bounds = (.1*image_shape[0], .8*image_shape[0])
|
# z_bounds = (.1*image_shape[0], .8*image_shape[0])
|
||||||
|
z_bounds = (z1+z_height*.1, z1+z_height*.9)
|
||||||
|
|
||||||
# x_bounds_right = (image_shape[2]/2 + image_shape[2]/10, image_shape[2] - 1)
|
# x_bounds_right = (image_shape[2]/2 + image_shape[2]/10, image_shape[2] - 1)
|
||||||
# x_bounds_left = (0, image_shape[2]/2 - image_shape[2]/10 - 1)
|
# x_bounds_left = (0, image_shape[2]/2 - image_shape[2]/10 - 1)
|
||||||
x_bounds_right = (x2, image_shape[2]*.9)
|
# x_bounds_right = (x2, image_shape[2]*.9)
|
||||||
x_bounds_left = (image_shape[2]*.1, x1)
|
# x_bounds_left = (image_shape[2]*.1, x1)
|
||||||
|
x_bounds_right = (x1+x_width*.6, +x_width*.9)
|
||||||
|
x_bounds_left = (x1+x_width*.1, +x_width*.4)
|
||||||
|
|
||||||
# azimuth_bounds_l = ((95-azi), (145-azi))
|
# 脊椎若被體積邊界切到(真正偏心、骨頭貼著左/右邊緣),
|
||||||
# azimuth_bounds_r = ((50-azi), (85-azi))
|
# 對應那側的 x band 下限會 >= 上限,PSO 會丟 "upper-bound must be greater"。
|
||||||
# altitude_bounds = ((60-alt), (75-alt))
|
# 出錯時 clamp 成同側最小寬度(5% 寬度)的合法 band。
|
||||||
azimuth_bounds_l = ((98-azi), (120-azi))
|
min_band = .05 * image_shape[2]
|
||||||
azimuth_bounds_r = ((60-azi), (82-azi))
|
if x_bounds_left[1] <= x_bounds_left[0]:
|
||||||
altitude_bounds = ((60-alt), (70-alt))
|
x_bounds_left = (x_bounds_left[0], x_bounds_left[0] + min_band)
|
||||||
|
if x_bounds_right[0] >= x_bounds_right[1]:
|
||||||
|
x_bounds_right = (x_bounds_right[1] - min_band, x_bounds_right[1])
|
||||||
|
|
||||||
|
# 舊 2D 版本(以輪廓角 azi / 矢狀面傾斜 alt 平移固定範圍),保留供對照:
|
||||||
|
# azimuth_bounds_l = ((98-azi), (120-azi))
|
||||||
|
# azimuth_bounds_r = ((60-azi), (82-azi))
|
||||||
|
# altitude_bounds = ((60-alt), (70-alt))
|
||||||
|
# 新:範圍以「椎體自身座標系」為中心 —— 椎體系中 az=90° 是 AP 直向、
|
||||||
|
# L 帶 = AP 後退 8~30°(偏 -x)、R 帶 = AP 前進 8~30°(偏 +x)、
|
||||||
|
# altitude 60~70。再換算回影像系:az 整體加 theta_v(鏡稱面),
|
||||||
|
# altitude 加 tau_y(終板 AP 傾斜)並逐側加 -/+tau_x(終板 LR 傾斜)。
|
||||||
|
azimuth_bounds_l = ((98+theta_v), (120+theta_v))
|
||||||
|
azimuth_bounds_r = ((60+theta_v), (82+theta_v))
|
||||||
|
altitude_bounds_l = ((60+tau_y-tau_x), (70+tau_y-tau_x))
|
||||||
|
altitude_bounds_r = ((60+tau_y+tau_x), (70+tau_y+tau_x))
|
||||||
|
|
||||||
else:
|
else:
|
||||||
z_bounds = (0, image_shape[0] - 1)
|
z_bounds = (0, image_shape[0] - 1)
|
||||||
|
|
@ -170,6 +303,9 @@ def run_pso_torch_xfr(
|
||||||
azimuth_bounds_l = (60-azi, 90-azi)
|
azimuth_bounds_l = (60-azi, 90-azi)
|
||||||
azimuth_bounds_r = (90-azi, 120-azi)
|
azimuth_bounds_r = (90-azi, 120-azi)
|
||||||
altitude_bounds = (65-alt, 80-alt)
|
altitude_bounds = (65-alt, 80-alt)
|
||||||
|
# 非 CBT 分支維持單一式,兩側同用
|
||||||
|
altitude_bounds_l = altitude_bounds
|
||||||
|
altitude_bounds_r = altitude_bounds
|
||||||
|
|
||||||
def eval_overlap_from_position(pos, side: str, optimize_size: bool,
|
def eval_overlap_from_position(pos, side: str, optimize_size: bool,
|
||||||
spine_tensor: torch.Tensor,
|
spine_tensor: torch.Tensor,
|
||||||
|
|
@ -204,16 +340,16 @@ def run_pso_torch_xfr(
|
||||||
diameter_bounds = (min(ALLOWED_DIAMETERS), max(ALLOWED_DIAMETERS)*1.01)
|
diameter_bounds = (min(ALLOWED_DIAMETERS), max(ALLOWED_DIAMETERS)*1.01)
|
||||||
length_bounds = (min(ALLOWED_LENGTHS), max(ALLOWED_LENGTHS)*1.01)
|
length_bounds = (min(ALLOWED_LENGTHS), max(ALLOWED_LENGTHS)*1.01)
|
||||||
|
|
||||||
# bounds 現在有 7 個參數
|
# bounds 現在有 7 個參數(altitude 分 L/R 兩側,由終板面 tau_x 決定)
|
||||||
lb_l = [z_bounds[0], x_bounds_left[0], azimuth_bounds_l[0],
|
lb_l = [z_bounds[0], x_bounds_left[0], azimuth_bounds_l[0],
|
||||||
altitude_bounds[0], diameter_bounds[0], length_bounds[0]]
|
altitude_bounds_l[0], diameter_bounds[0], length_bounds[0]]
|
||||||
ub_l = [z_bounds[1], x_bounds_left[1], azimuth_bounds_l[1],
|
ub_l = [z_bounds[1], x_bounds_left[1], azimuth_bounds_l[1],
|
||||||
altitude_bounds[1], diameter_bounds[1], length_bounds[1]]
|
altitude_bounds_l[1], diameter_bounds[1], length_bounds[1]]
|
||||||
|
|
||||||
lb_r = [z_bounds[0], x_bounds_right[0], azimuth_bounds_r[0],
|
lb_r = [z_bounds[0], x_bounds_right[0], azimuth_bounds_r[0],
|
||||||
altitude_bounds[0], diameter_bounds[0], length_bounds[0]]
|
altitude_bounds_r[0], diameter_bounds[0], length_bounds[0]]
|
||||||
ub_r = [z_bounds[1], x_bounds_right[1], azimuth_bounds_r[1],
|
ub_r = [z_bounds[1], x_bounds_right[1], azimuth_bounds_r[1],
|
||||||
altitude_bounds[1], diameter_bounds[1], length_bounds[1]]
|
altitude_bounds_r[1], diameter_bounds[1], length_bounds[1]]
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# 模式 2:固定 diameter 和 length(向後兼容)
|
# 模式 2:固定 diameter 和 length(向後兼容)
|
||||||
|
|
@ -222,11 +358,11 @@ def run_pso_torch_xfr(
|
||||||
diameter = 4.5 # 或從參數傳入
|
diameter = 4.5 # 或從參數傳入
|
||||||
length = 45 # 或從參數傳入
|
length = 45 # 或從參數傳入
|
||||||
|
|
||||||
lb_l = [z_bounds[0], y_bounds[0], x_bounds_left[0], azimuth_bounds_l[0], altitude_bounds[0]]
|
lb_l = [z_bounds[0], y_bounds[0], x_bounds_left[0], azimuth_bounds_l[0], altitude_bounds_l[0]]
|
||||||
ub_l = [z_bounds[1], y_bounds[1], x_bounds_left[1], azimuth_bounds_l[1], altitude_bounds[1]]
|
ub_l = [z_bounds[1], y_bounds[1], x_bounds_left[1], azimuth_bounds_l[1], altitude_bounds_l[1]]
|
||||||
|
|
||||||
lb_r = [z_bounds[0], y_bounds[0], x_bounds_right[0], azimuth_bounds_r[0], altitude_bounds[0]]
|
lb_r = [z_bounds[0], y_bounds[0], x_bounds_right[0], azimuth_bounds_r[0], altitude_bounds_r[0]]
|
||||||
ub_r = [z_bounds[1], y_bounds[1], x_bounds_right[1], azimuth_bounds_r[1], altitude_bounds[1]]
|
ub_r = [z_bounds[1], y_bounds[1], x_bounds_right[1], azimuth_bounds_r[1], altitude_bounds_r[1]]
|
||||||
|
|
||||||
if True or debug:
|
if True or debug:
|
||||||
print(lb_l)
|
print(lb_l)
|
||||||
|
|
@ -241,17 +377,31 @@ def run_pso_torch_xfr(
|
||||||
|
|
||||||
# Left side optimization
|
# Left side optimization
|
||||||
print(f"\n=== {label_str} 左側 ===")
|
print(f"\n=== {label_str} 左側 ===")
|
||||||
kwargs = {'y_indices': y_indices}
|
_validate_bounds(lb_l, ub_l, f'{label_str} L')
|
||||||
position_l, loss_l = pso(objective_function_xfr, lb_l, ub_l,
|
position_l, loss_l = pso(objective_fn, lb_l, ub_l,
|
||||||
|
|
||||||
# ieqcons=[constraint_y],
|
# ieqcons=[constraint_y],
|
||||||
kwargs=kwargs,
|
|
||||||
swarmsize=swarm_size,
|
swarmsize=swarm_size,
|
||||||
omega = omega,
|
omega = omega,
|
||||||
maxiter=max_iter, debug=debug)
|
maxiter=max_iter, debug=debug)
|
||||||
|
|
||||||
z, x, azimuth, altitude, diameter, length = position_l
|
z, x, azimuth, altitude, diameter, length = position_l
|
||||||
|
az_pso, x_pso, L_pso = azimuth, x, length
|
||||||
y = y_indices[round(z), round(x)]
|
y = y_indices[round(z), round(x)]
|
||||||
|
|
||||||
|
z, x, azimuth, altitude, diameter, length, adopted_l, ref_l = refine_lateral_longer(
|
||||||
|
z, x, azimuth, altitude, diameter, length,
|
||||||
|
"L", azimuth_bounds_l, x_bounds_left, y_indices,
|
||||||
|
image_shape, spacing, device, grid,
|
||||||
|
cortical_tensor, spine_tensor,
|
||||||
|
)
|
||||||
|
y = y_indices[round(z), round(x)]
|
||||||
|
if adopted_l:
|
||||||
|
loss_l = ref_l['loss']
|
||||||
|
print(f"[LEFT] lateral-refine: az {az_pso:.2f} -> {azimuth:.2f}, x {x_pso:.2f} -> {x:.2f}, "
|
||||||
|
f"L {L_pso:.2f} -> {length:.2f}, in-bone {ref_l['in_bone']*100:.1f}%")
|
||||||
|
else:
|
||||||
|
print("[LEFT] lateral-refine: no improvement")
|
||||||
|
|
||||||
position_l = z, y, x, azimuth, altitude, diameter, length
|
position_l = z, y, x, azimuth, altitude, diameter, length
|
||||||
|
|
||||||
overlap_l, diameter_l, length_l = eval_overlap_from_position(
|
overlap_l, diameter_l, length_l = eval_overlap_from_position(
|
||||||
|
|
@ -277,7 +427,7 @@ def run_pso_torch_xfr(
|
||||||
|
|
||||||
# 左側 retry:loss 要 <=0 且 overlap >= 0.5 才算過關
|
# 左側 retry:loss 要 <=0 且 overlap >= 0.5 才算過關
|
||||||
# while (best_loss_l > 0 or best_overlap_l < OVERLAP_THRESH) and retries < max_retries:
|
# while (best_loss_l > 0 or best_overlap_l < OVERLAP_THRESH) and retries < max_retries:
|
||||||
# position_l, loss_l = pso(objective_function, lb_l, ub_l, swarmsize=swarm_size, maxiter=max_iter)
|
# position_l, loss_l = pso(objective_fn, lb_l, ub_l, swarmsize=swarm_size, maxiter=max_iter)
|
||||||
# overlap_l, diameter_l, length_l = eval_overlap_from_position(
|
# overlap_l, diameter_l, length_l = eval_overlap_from_position(
|
||||||
# position_l, "L", optimize_size, spine_tensor, image_shape, spacing
|
# position_l, "L", optimize_size, spine_tensor, image_shape, spacing
|
||||||
# )
|
# )
|
||||||
|
|
@ -307,15 +457,31 @@ def run_pso_torch_xfr(
|
||||||
|
|
||||||
# Right side optimization
|
# Right side optimization
|
||||||
print(f"\n=== {label_str} 右側 ===")
|
print(f"\n=== {label_str} 右側 ===")
|
||||||
position_r, loss_r = pso(objective_function_xfr, lb_r, ub_r,
|
_validate_bounds(lb_r, ub_r, f'{label_str} R')
|
||||||
|
position_r, loss_r = pso(objective_fn, lb_r, ub_r,
|
||||||
# ieqcons=[constraint_y],
|
# ieqcons=[constraint_y],
|
||||||
kwargs=kwargs,
|
|
||||||
swarmsize=swarm_size,
|
swarmsize=swarm_size,
|
||||||
omega = omega,
|
omega = omega,
|
||||||
maxiter=max_iter, debug=debug)
|
maxiter=max_iter, debug=debug)
|
||||||
|
|
||||||
z, x, azimuth, altitude, diameter, length = position_r
|
z, x, azimuth, altitude, diameter, length = position_r
|
||||||
|
az_pso, x_pso, L_pso = azimuth, x, length
|
||||||
y = y_indices[round(z), round(x)]
|
y = y_indices[round(z), round(x)]
|
||||||
|
|
||||||
|
z, x, azimuth, altitude, diameter, length, adopted_r, ref_r = refine_lateral_longer(
|
||||||
|
z, x, azimuth, altitude, diameter, length,
|
||||||
|
"R", azimuth_bounds_r, x_bounds_right, y_indices,
|
||||||
|
image_shape, spacing, device, grid,
|
||||||
|
cortical_tensor, spine_tensor,
|
||||||
|
)
|
||||||
|
y = y_indices[round(z), round(x)]
|
||||||
|
if adopted_r:
|
||||||
|
loss_r = ref_r['loss']
|
||||||
|
print(f"[RIGHT] lateral-refine: az {az_pso:.2f} -> {azimuth:.2f}, x {x_pso:.2f} -> {x:.2f}, "
|
||||||
|
f"L {L_pso:.2f} -> {length:.2f}, in-bone {ref_r['in_bone']*100:.1f}%")
|
||||||
|
else:
|
||||||
|
print("[RIGHT] lateral-refine: no improvement")
|
||||||
|
|
||||||
position_r = z, y, x, azimuth, altitude, diameter, length
|
position_r = z, y, x, azimuth, altitude, diameter, length
|
||||||
|
|
||||||
overlap_r, diameter_r, length_r = eval_overlap_from_position(
|
overlap_r, diameter_r, length_r = eval_overlap_from_position(
|
||||||
|
|
@ -345,7 +511,7 @@ def run_pso_torch_xfr(
|
||||||
# retries = 0
|
# retries = 0
|
||||||
|
|
||||||
# while (best_loss_r > 0 or best_overlap_r < OVERLAP_THRESH) and retries < max_retries:
|
# while (best_loss_r > 0 or best_overlap_r < OVERLAP_THRESH) and retries < max_retries:
|
||||||
# position_r, loss_r = pso(objective_function, lb_r, ub_r, swarmsize=swarm_size, maxiter=max_iter)
|
# position_r, loss_r = pso(objective_fn, lb_r, ub_r, swarmsize=swarm_size, maxiter=max_iter)
|
||||||
# overlap_r, diameter_r, length_r = eval_overlap_from_position(
|
# overlap_r, diameter_r, length_r = eval_overlap_from_position(
|
||||||
# position_r, "R", optimize_size, spine_tensor, image_shape, spacing
|
# position_r, "R", optimize_size, spine_tensor, image_shape, spacing
|
||||||
# )
|
# )
|
||||||
|
|
@ -458,34 +624,22 @@ def run_pso_torch(
|
||||||
spine_tensor = torch.from_numpy(image2_array).to(device=device, dtype=torch.uint8)
|
spine_tensor = torch.from_numpy(image2_array).to(device=device, dtype=torch.uint8)
|
||||||
spine_roi_tensor = torch.from_numpy(image3_array).to(device=device, dtype=torch.uint8)
|
spine_roi_tensor = torch.from_numpy(image3_array).to(device=device, dtype=torch.uint8)
|
||||||
|
|
||||||
# ================= [跨檔案注入變數:終極防呆版] =================
|
# 建立明確的優化上下文(取代舊的跨檔案 global 注入;狀態全部顯式傳入目標函數)
|
||||||
import core.objective
|
ctx = OptimizationContext(
|
||||||
|
cortical_tensor=cortical_tensor,
|
||||||
# 1. 注入 Tensors
|
spine_tensor=spine_tensor,
|
||||||
core.objective.cortical_tensor = cortical_tensor
|
spine_roi_tensor=spine_roi_tensor,
|
||||||
core.objective.spine_tensor = spine_tensor
|
image1_array=image1_array,
|
||||||
core.objective.spine_roi_tensor = spine_roi_tensor
|
image2_array=image2_array,
|
||||||
|
image3_array=image3_array,
|
||||||
# 2. 注入 Arrays (以防 objective 裡面偷偷用到 Numpy 陣列)
|
image2_shape=image2_shape,
|
||||||
core.objective.image1_array = image1_array
|
spacing=spacing,
|
||||||
core.objective.image2_array = image2_array
|
device=device,
|
||||||
core.objective.image3_array = image3_array
|
grid=grid,
|
||||||
|
diameter=diameter if not optimize_size else None,
|
||||||
# 3. 注入 Shapes (這就是導致這次 NoneType 報錯的真兇!)
|
length=length if not optimize_size else None,
|
||||||
core.objective.image2_shape = image2_shape # <--- 解除警報的最關鍵一行
|
)
|
||||||
core.objective.image_shape = image_shape
|
objective_fn = make_objective_function(ctx)
|
||||||
core.objective.shape = image_shape
|
|
||||||
|
|
||||||
# 4. 注入環境變數
|
|
||||||
core.objective.spacing = spacing
|
|
||||||
core.objective.device = device
|
|
||||||
core.objective.grid = grid
|
|
||||||
|
|
||||||
# 5. 注入尺寸參數 (兼容固定尺寸模式)
|
|
||||||
if not optimize_size:
|
|
||||||
core.objective.diameter = diameter
|
|
||||||
core.objective.length = length
|
|
||||||
# ==============================================================
|
|
||||||
|
|
||||||
azi = azimuth_rotation(image2_path)
|
azi = azimuth_rotation(image2_path)
|
||||||
res = analyze_vertebral_tilt_contour(image2_path, edge_type='superior', show_plot=False, debug=False)
|
res = analyze_vertebral_tilt_contour(image2_path, edge_type='superior', show_plot=False, debug=False)
|
||||||
|
|
@ -497,9 +651,11 @@ def run_pso_torch(
|
||||||
y_bounds = (image_shape[1]/5, image_shape[1]/2 - 1)
|
y_bounds = (image_shape[1]/5, image_shape[1]/2 - 1)
|
||||||
x_bounds_right = (image_shape[2]/2 + image_shape[2]/10, image_shape[2] - 1)
|
x_bounds_right = (image_shape[2]/2 + image_shape[2]/10, image_shape[2] - 1)
|
||||||
x_bounds_left = (0, image_shape[2]/2 - image_shape[2]/10 - 1)
|
x_bounds_left = (0, image_shape[2]/2 - image_shape[2]/10 - 1)
|
||||||
azimuth_bounds_l = ((95-azi), (145-azi))
|
# CBT 參數依據 references/:Santoni 2009 (Spine J 9:366) 冠狀面 25-30° cranial (caudo-cephalad)、
|
||||||
azimuth_bounds_r = ((50-azi), (85-azi))
|
# 軸狀面自正中線向外 (medial→lateral) ≤30°;Delgado-Fernandez 2017 (ASJ 11:817)、Kim 2022 (SSRR 6:1)
|
||||||
altitude_bounds = ((60-alt), (75-alt))
|
azimuth_bounds_l = ((98-azi), (120-azi))
|
||||||
|
azimuth_bounds_r = ((60-azi), (82-azi))
|
||||||
|
altitude_bounds = ((60-alt), (70-alt))
|
||||||
|
|
||||||
# xfr
|
# xfr
|
||||||
# z_bounds = (0, image_shape[0] - 1)
|
# z_bounds = (0, image_shape[0] - 1)
|
||||||
|
|
@ -588,7 +744,7 @@ def run_pso_torch(
|
||||||
|
|
||||||
# Left side optimization
|
# Left side optimization
|
||||||
print("\n=== 左側 ===")
|
print("\n=== 左側 ===")
|
||||||
position_l, loss_l = pso(objective_function, lb_l, ub_l, swarmsize=swarm_size, maxiter=max_iter, debug=debug)
|
position_l, loss_l = pso(objective_fn, lb_l, ub_l, swarmsize=swarm_size, maxiter=max_iter, debug=debug)
|
||||||
|
|
||||||
overlap_l, diameter_l, length_l = eval_overlap_from_position(
|
overlap_l, diameter_l, length_l = eval_overlap_from_position(
|
||||||
position_l, "L", optimize_size, spine_tensor, image_shape, spacing
|
position_l, "L", optimize_size, spine_tensor, image_shape, spacing
|
||||||
|
|
@ -612,7 +768,7 @@ def run_pso_torch(
|
||||||
|
|
||||||
# 左側 retry:loss 要 <=0 且 overlap >= 0.5 才算過關
|
# 左側 retry:loss 要 <=0 且 overlap >= 0.5 才算過關
|
||||||
# while (best_loss_l > 0 or best_overlap_l < OVERLAP_THRESH) and retries < max_retries:
|
# while (best_loss_l > 0 or best_overlap_l < OVERLAP_THRESH) and retries < max_retries:
|
||||||
# position_l, loss_l = pso(objective_function, lb_l, ub_l, swarmsize=swarm_size, maxiter=max_iter)
|
# position_l, loss_l = pso(objective_fn, lb_l, ub_l, swarmsize=swarm_size, maxiter=max_iter)
|
||||||
# overlap_l, diameter_l, length_l = eval_overlap_from_position(
|
# overlap_l, diameter_l, length_l = eval_overlap_from_position(
|
||||||
# position_l, "L", optimize_size, spine_tensor, image_shape, spacing
|
# position_l, "L", optimize_size, spine_tensor, image_shape, spacing
|
||||||
# )
|
# )
|
||||||
|
|
@ -642,7 +798,7 @@ def run_pso_torch(
|
||||||
|
|
||||||
# Right side optimization
|
# Right side optimization
|
||||||
print("\n=== 右側 ===")
|
print("\n=== 右側 ===")
|
||||||
position_r, loss_r = pso(objective_function, lb_r, ub_r, swarmsize=swarm_size, maxiter=max_iter, debug=debug)
|
position_r, loss_r = pso(objective_fn, lb_r, ub_r, swarmsize=swarm_size, maxiter=max_iter, debug=debug)
|
||||||
overlap_r, diameter_r, length_r = eval_overlap_from_position(
|
overlap_r, diameter_r, length_r = eval_overlap_from_position(
|
||||||
position_r, "R", optimize_size, spine_tensor, image_shape, spacing
|
position_r, "R", optimize_size, spine_tensor, image_shape, spacing
|
||||||
)
|
)
|
||||||
|
|
@ -670,7 +826,7 @@ def run_pso_torch(
|
||||||
# retries = 0
|
# retries = 0
|
||||||
|
|
||||||
# while (best_loss_r > 0 or best_overlap_r < OVERLAP_THRESH) and retries < max_retries:
|
# while (best_loss_r > 0 or best_overlap_r < OVERLAP_THRESH) and retries < max_retries:
|
||||||
# position_r, loss_r = pso(objective_function, lb_r, ub_r, swarmsize=swarm_size, maxiter=max_iter)
|
# position_r, loss_r = pso(objective_fn, lb_r, ub_r, swarmsize=swarm_size, maxiter=max_iter)
|
||||||
# overlap_r, diameter_r, length_r = eval_overlap_from_position(
|
# overlap_r, diameter_r, length_r = eval_overlap_from_position(
|
||||||
# position_r, "R", optimize_size, spine_tensor, image_shape, spacing
|
# position_r, "R", optimize_size, spine_tensor, image_shape, spacing
|
||||||
# )
|
# )
|
||||||
|
|
@ -748,7 +904,6 @@ from scipy.optimize import differential_evolution
|
||||||
from scipy.optimize import minimize
|
from scipy.optimize import minimize
|
||||||
from imaging.orientation import azimuth_rotation, analyze_vertebral_tilt_contour
|
from imaging.orientation import azimuth_rotation, analyze_vertebral_tilt_contour
|
||||||
from config.constant import ALLOWED_DIAMETERS, ALLOWED_LENGTHS
|
from config.constant import ALLOWED_DIAMETERS, ALLOWED_LENGTHS
|
||||||
from core.objective import objective_function
|
|
||||||
from core.cylinder import generate_cylinder_n_torch, snap_to_discrete_values, create_coordinate_grid
|
from core.cylinder import generate_cylinder_n_torch, snap_to_discrete_values, create_coordinate_grid
|
||||||
from core.scoring import compute_overlap_ratio_from_cylinder_mask, is_solution_ok
|
from core.scoring import compute_overlap_ratio_from_cylinder_mask, is_solution_ok
|
||||||
from config.constant import OVERLAP_THRESH
|
from config.constant import OVERLAP_THRESH
|
||||||
|
|
@ -790,34 +945,22 @@ def run_de_torch(
|
||||||
spine_tensor = torch.from_numpy(image2_array).to(device=device, dtype=torch.uint8)
|
spine_tensor = torch.from_numpy(image2_array).to(device=device, dtype=torch.uint8)
|
||||||
spine_roi_tensor = torch.from_numpy(image3_array).to(device=device, dtype=torch.uint8)
|
spine_roi_tensor = torch.from_numpy(image3_array).to(device=device, dtype=torch.uint8)
|
||||||
|
|
||||||
# ================= [跨檔案注入變數:終極防呆版] =================
|
# 建立明確的優化上下文(取代舊的跨檔案 global 注入;狀態全部顯式傳入目標函數)
|
||||||
import core.objective
|
ctx = OptimizationContext(
|
||||||
|
cortical_tensor=cortical_tensor,
|
||||||
# 1. 注入 Tensors
|
spine_tensor=spine_tensor,
|
||||||
core.objective.cortical_tensor = cortical_tensor
|
spine_roi_tensor=spine_roi_tensor,
|
||||||
core.objective.spine_tensor = spine_tensor
|
image1_array=image1_array,
|
||||||
core.objective.spine_roi_tensor = spine_roi_tensor
|
image2_array=image2_array,
|
||||||
|
image3_array=image3_array,
|
||||||
# 2. 注入 Arrays (以防 objective 裡面偷偷用到 Numpy 陣列)
|
image2_shape=image2_shape,
|
||||||
core.objective.image1_array = image1_array
|
spacing=spacing,
|
||||||
core.objective.image2_array = image2_array
|
device=device,
|
||||||
core.objective.image3_array = image3_array
|
grid=grid,
|
||||||
|
diameter=diameter if not optimize_size else None,
|
||||||
# 3. 注入 Shapes (這就是導致這次 NoneType 報錯的真兇!)
|
length=length if not optimize_size else None,
|
||||||
core.objective.image2_shape = image2_shape # <--- 解除警報的最關鍵一行
|
)
|
||||||
core.objective.image_shape = image_shape
|
objective_fn = make_objective_function(ctx)
|
||||||
core.objective.shape = image_shape
|
|
||||||
|
|
||||||
# 4. 注入環境變數
|
|
||||||
core.objective.spacing = spacing
|
|
||||||
core.objective.device = device
|
|
||||||
core.objective.grid = grid
|
|
||||||
|
|
||||||
# 5. 注入尺寸參數 (兼容固定尺寸模式)
|
|
||||||
if not optimize_size:
|
|
||||||
core.objective.diameter = diameter
|
|
||||||
core.objective.length = length
|
|
||||||
# ==============================================================
|
|
||||||
|
|
||||||
azi = azimuth_rotation(image2_path)
|
azi = azimuth_rotation(image2_path)
|
||||||
res = analyze_vertebral_tilt_contour(image2_path, edge_type='superior', show_plot=False, debug=False)
|
res = analyze_vertebral_tilt_contour(image2_path, edge_type='superior', show_plot=False, debug=False)
|
||||||
|
|
@ -828,9 +971,11 @@ def run_de_torch(
|
||||||
y_bounds = (image_shape[1]/5, image_shape[1]/2 - 1)
|
y_bounds = (image_shape[1]/5, image_shape[1]/2 - 1)
|
||||||
x_bounds_right = (image_shape[2]/2 + image_shape[2]/10, image_shape[2] - 1)
|
x_bounds_right = (image_shape[2]/2 + image_shape[2]/10, image_shape[2] - 1)
|
||||||
x_bounds_left = (0, image_shape[2]/2 - image_shape[2]/10 - 1)
|
x_bounds_left = (0, image_shape[2]/2 - image_shape[2]/10 - 1)
|
||||||
azimuth_bounds_l = ((95-azi), (145-azi))
|
# CBT 參數依據 references/:Santoni 2009 (Spine J 9:366) 冠狀面 25-30° cranial (caudo-cephalad)、
|
||||||
azimuth_bounds_r = ((50-azi), (85-azi))
|
# 軸狀面自正中線向外 (medial→lateral) ≤30°;Delgado-Fernandez 2017 (ASJ 11:817)、Kim 2022 (SSRR 6:1)
|
||||||
altitude_bounds = ((60-alt), (75-alt))
|
azimuth_bounds_l = ((98-azi), (120-azi))
|
||||||
|
azimuth_bounds_r = ((60-azi), (82-azi))
|
||||||
|
altitude_bounds = ((60-alt), (70-alt))
|
||||||
else:
|
else:
|
||||||
z_bounds = (0, image_shape[0] - 1)
|
z_bounds = (0, image_shape[0] - 1)
|
||||||
y_bounds = (image_shape[1]/5, image_shape[1]/2 - 1)
|
y_bounds = (image_shape[1]/5, image_shape[1]/2 - 1)
|
||||||
|
|
@ -877,7 +1022,7 @@ def run_de_torch(
|
||||||
|
|
||||||
# --- 左側最佳化 ---
|
# --- 左側最佳化 ---
|
||||||
print("\n=== 左側 (DE) ===")
|
print("\n=== 左側 (DE) ===")
|
||||||
res_l = differential_evolution(objective_function, bounds_l, popsize=de_popsize, maxiter=max_iter)
|
res_l = differential_evolution(objective_fn, bounds_l, popsize=de_popsize, maxiter=max_iter)
|
||||||
position_l, loss_l = res_l.x, res_l.fun
|
position_l, loss_l = res_l.x, res_l.fun
|
||||||
|
|
||||||
overlap_l, diameter_l, length_l = eval_overlap_from_position(position_l, "L", optimize_size, spine_tensor, image_shape, spacing)
|
overlap_l, diameter_l, length_l = eval_overlap_from_position(position_l, "L", optimize_size, spine_tensor, image_shape, spacing)
|
||||||
|
|
@ -886,7 +1031,7 @@ def run_de_torch(
|
||||||
"""
|
"""
|
||||||
retries = 0
|
retries = 0
|
||||||
while (best_loss_l > 0 or best_overlap_l < OVERLAP_THRESH) and retries < 10:
|
while (best_loss_l > 0 or best_overlap_l < OVERLAP_THRESH) and retries < 10:
|
||||||
res_l = differential_evolution(objective_function, bounds_l, popsize=de_popsize, maxiter=max_iter)
|
res_l = differential_evolution(objective_fn, bounds_l, popsize=de_popsize, maxiter=max_iter)
|
||||||
position_l, loss_l = res_l.x, res_l.fun
|
position_l, loss_l = res_l.x, res_l.fun
|
||||||
overlap_l, diameter_l, length_l = eval_overlap_from_position(position_l, "L", optimize_size, spine_tensor, image_shape, spacing)
|
overlap_l, diameter_l, length_l = eval_overlap_from_position(position_l, "L", optimize_size, spine_tensor, image_shape, spacing)
|
||||||
|
|
||||||
|
|
@ -899,7 +1044,7 @@ def run_de_torch(
|
||||||
"""
|
"""
|
||||||
# --- 右側最佳化 ---
|
# --- 右側最佳化 ---
|
||||||
print("\n=== 右側 (DE) ===")
|
print("\n=== 右側 (DE) ===")
|
||||||
res_r = differential_evolution(objective_function, bounds_r, popsize=de_popsize, maxiter=max_iter)
|
res_r = differential_evolution(objective_fn, bounds_r, popsize=de_popsize, maxiter=max_iter)
|
||||||
position_r, loss_r = res_r.x, res_r.fun
|
position_r, loss_r = res_r.x, res_r.fun
|
||||||
|
|
||||||
overlap_r, diameter_r, length_r = eval_overlap_from_position(position_r, "R", optimize_size, spine_tensor, image_shape, spacing)
|
overlap_r, diameter_r, length_r = eval_overlap_from_position(position_r, "R", optimize_size, spine_tensor, image_shape, spacing)
|
||||||
|
|
@ -908,7 +1053,7 @@ def run_de_torch(
|
||||||
"""
|
"""
|
||||||
retries = 0
|
retries = 0
|
||||||
while (best_loss_r > 0 or best_overlap_r < OVERLAP_THRESH) and retries < 10:
|
while (best_loss_r > 0 or best_overlap_r < OVERLAP_THRESH) and retries < 10:
|
||||||
res_r = differential_evolution(objective_function, bounds_r, popsize=de_popsize, maxiter=max_iter)
|
res_r = differential_evolution(objective_fn, bounds_r, popsize=de_popsize, maxiter=max_iter)
|
||||||
position_r, loss_r = res_r.x, res_r.fun
|
position_r, loss_r = res_r.x, res_r.fun
|
||||||
overlap_r, diameter_r, length_r = eval_overlap_from_position(position_r, "R", optimize_size, spine_tensor, image_shape, spacing)
|
overlap_r, diameter_r, length_r = eval_overlap_from_position(position_r, "R", optimize_size, spine_tensor, image_shape, spacing)
|
||||||
|
|
||||||
|
|
@ -969,34 +1114,22 @@ def run_nm_torch(
|
||||||
cortical_tensor = torch.from_numpy(image1_array).to(device=device, dtype=torch.uint8)
|
cortical_tensor = torch.from_numpy(image1_array).to(device=device, dtype=torch.uint8)
|
||||||
spine_tensor = torch.from_numpy(image2_array).to(device=device, dtype=torch.uint8)
|
spine_tensor = torch.from_numpy(image2_array).to(device=device, dtype=torch.uint8)
|
||||||
spine_roi_tensor = torch.from_numpy(image3_array).to(device=device, dtype=torch.uint8)
|
spine_roi_tensor = torch.from_numpy(image3_array).to(device=device, dtype=torch.uint8)
|
||||||
# ================= [跨檔案注入變數:終極防呆版] =================
|
# 建立明確的優化上下文(取代舊的跨檔案 global 注入;狀態全部顯式傳入目標函數)
|
||||||
import core.objective
|
ctx = OptimizationContext(
|
||||||
|
cortical_tensor=cortical_tensor,
|
||||||
# 1. 注入 Tensors
|
spine_tensor=spine_tensor,
|
||||||
core.objective.cortical_tensor = cortical_tensor
|
spine_roi_tensor=spine_roi_tensor,
|
||||||
core.objective.spine_tensor = spine_tensor
|
image1_array=image1_array,
|
||||||
core.objective.spine_roi_tensor = spine_roi_tensor
|
image2_array=image2_array,
|
||||||
|
image3_array=image3_array,
|
||||||
# 2. 注入 Arrays (以防 objective 裡面偷偷用到 Numpy 陣列)
|
image2_shape=image2_shape,
|
||||||
core.objective.image1_array = image1_array
|
spacing=spacing,
|
||||||
core.objective.image2_array = image2_array
|
device=device,
|
||||||
core.objective.image3_array = image3_array
|
grid=grid,
|
||||||
|
diameter=diameter if not optimize_size else None,
|
||||||
# 3. 注入 Shapes (這就是導致這次 NoneType 報錯的真兇!)
|
length=length if not optimize_size else None,
|
||||||
core.objective.image2_shape = image2_shape # <--- 解除警報的最關鍵一行
|
)
|
||||||
core.objective.image_shape = image_shape
|
objective_fn = make_objective_function(ctx)
|
||||||
core.objective.shape = image_shape
|
|
||||||
|
|
||||||
# 4. 注入環境變數
|
|
||||||
core.objective.spacing = spacing
|
|
||||||
core.objective.device = device
|
|
||||||
core.objective.grid = grid
|
|
||||||
|
|
||||||
# 5. 注入尺寸參數 (兼容固定尺寸模式)
|
|
||||||
if not optimize_size:
|
|
||||||
core.objective.diameter = diameter
|
|
||||||
core.objective.length = length
|
|
||||||
# ==============================================================
|
|
||||||
|
|
||||||
azi = azimuth_rotation(image2_path)
|
azi = azimuth_rotation(image2_path)
|
||||||
res = analyze_vertebral_tilt_contour(image2_path, edge_type='superior', show_plot=False, debug=False)
|
res = analyze_vertebral_tilt_contour(image2_path, edge_type='superior', show_plot=False, debug=False)
|
||||||
|
|
@ -1007,9 +1140,11 @@ def run_nm_torch(
|
||||||
y_bounds = (image_shape[1]/5, image_shape[1]/2 - 1)
|
y_bounds = (image_shape[1]/5, image_shape[1]/2 - 1)
|
||||||
x_bounds_right = (image_shape[2]/2 + image_shape[2]/10, image_shape[2] - 1)
|
x_bounds_right = (image_shape[2]/2 + image_shape[2]/10, image_shape[2] - 1)
|
||||||
x_bounds_left = (0, image_shape[2]/2 - image_shape[2]/10 - 1)
|
x_bounds_left = (0, image_shape[2]/2 - image_shape[2]/10 - 1)
|
||||||
azimuth_bounds_l = ((95-azi), (145-azi))
|
# CBT 參數依據 references/:Santoni 2009 (Spine J 9:366) 冠狀面 25-30° cranial (caudo-cephalad)、
|
||||||
azimuth_bounds_r = ((50-azi), (85-azi))
|
# 軸狀面自正中線向外 (medial→lateral) ≤30°;Delgado-Fernandez 2017 (ASJ 11:817)、Kim 2022 (SSRR 6:1)
|
||||||
altitude_bounds = ((60-alt), (75-alt))
|
azimuth_bounds_l = ((98-azi), (120-azi))
|
||||||
|
azimuth_bounds_r = ((60-azi), (82-azi))
|
||||||
|
altitude_bounds = ((60-alt), (70-alt))
|
||||||
else:
|
else:
|
||||||
z_bounds = (0, image_shape[0] - 1)
|
z_bounds = (0, image_shape[0] - 1)
|
||||||
y_bounds = (image_shape[1]/5, image_shape[1]/2 - 1)
|
y_bounds = (image_shape[1]/5, image_shape[1]/2 - 1)
|
||||||
|
|
@ -1054,7 +1189,7 @@ def run_nm_torch(
|
||||||
# --- 左側最佳化 ---
|
# --- 左側最佳化 ---
|
||||||
print("\n=== 左側 (Nelder-Mead) ===")
|
print("\n=== 左側 (Nelder-Mead) ===")
|
||||||
x0_l = get_random_x0(bounds_l)
|
x0_l = get_random_x0(bounds_l)
|
||||||
res_l = minimize(objective_function, x0_l, method='Nelder-Mead', bounds=bounds_l, options={'maxiter': max_iter})
|
res_l = minimize(objective_fn, x0_l, method='Nelder-Mead', bounds=bounds_l, options={'maxiter': max_iter})
|
||||||
position_l, loss_l = res_l.x, res_l.fun
|
position_l, loss_l = res_l.x, res_l.fun
|
||||||
|
|
||||||
overlap_l, diameter_l, length_l = eval_overlap_from_position(position_l, "L", optimize_size, spine_tensor, image_shape, spacing)
|
overlap_l, diameter_l, length_l = eval_overlap_from_position(position_l, "L", optimize_size, spine_tensor, image_shape, spacing)
|
||||||
|
|
@ -1064,7 +1199,7 @@ def run_nm_torch(
|
||||||
retries = 0
|
retries = 0
|
||||||
while (best_loss_l > 0 or best_overlap_l < OVERLAP_THRESH) and retries < 10:
|
while (best_loss_l > 0 or best_overlap_l < OVERLAP_THRESH) and retries < 10:
|
||||||
x0_l = get_random_x0(bounds_l) # 每次 retry 都換一個隨機起始點
|
x0_l = get_random_x0(bounds_l) # 每次 retry 都換一個隨機起始點
|
||||||
res_l = minimize(objective_function, x0_l, method='Nelder-Mead', bounds=bounds_l, options={'maxiter': max_iter})
|
res_l = minimize(objective_fn, x0_l, method='Nelder-Mead', bounds=bounds_l, options={'maxiter': max_iter})
|
||||||
position_l, loss_l = res_l.x, res_l.fun
|
position_l, loss_l = res_l.x, res_l.fun
|
||||||
overlap_l, diameter_l, length_l = eval_overlap_from_position(position_l, "L", optimize_size, spine_tensor, image_shape, spacing)
|
overlap_l, diameter_l, length_l = eval_overlap_from_position(position_l, "L", optimize_size, spine_tensor, image_shape, spacing)
|
||||||
|
|
||||||
|
|
@ -1078,7 +1213,7 @@ def run_nm_torch(
|
||||||
# --- 右側最佳化 ---
|
# --- 右側最佳化 ---
|
||||||
print("\n=== 右側 (Nelder-Mead) ===")
|
print("\n=== 右側 (Nelder-Mead) ===")
|
||||||
x0_r = get_random_x0(bounds_r)
|
x0_r = get_random_x0(bounds_r)
|
||||||
res_r = minimize(objective_function, x0_r, method='Nelder-Mead', bounds=bounds_r, options={'maxiter': max_iter})
|
res_r = minimize(objective_fn, x0_r, method='Nelder-Mead', bounds=bounds_r, options={'maxiter': max_iter})
|
||||||
position_r, loss_r = res_r.x, res_r.fun
|
position_r, loss_r = res_r.x, res_r.fun
|
||||||
|
|
||||||
overlap_r, diameter_r, length_r = eval_overlap_from_position(position_r, "R", optimize_size, spine_tensor, image_shape, spacing)
|
overlap_r, diameter_r, length_r = eval_overlap_from_position(position_r, "R", optimize_size, spine_tensor, image_shape, spacing)
|
||||||
|
|
@ -1088,7 +1223,7 @@ def run_nm_torch(
|
||||||
retries = 0
|
retries = 0
|
||||||
while (best_loss_r > 0 or best_overlap_r < OVERLAP_THRESH) and retries < 10:
|
while (best_loss_r > 0 or best_overlap_r < OVERLAP_THRESH) and retries < 10:
|
||||||
x0_r = get_random_x0(bounds_r)
|
x0_r = get_random_x0(bounds_r)
|
||||||
res_r = minimize(objective_function, x0_r, method='Nelder-Mead', bounds=bounds_r, options={'maxiter': max_iter})
|
res_r = minimize(objective_fn, x0_r, method='Nelder-Mead', bounds=bounds_r, options={'maxiter': max_iter})
|
||||||
position_r, loss_r = res_r.x, res_r.fun
|
position_r, loss_r = res_r.x, res_r.fun
|
||||||
overlap_r, diameter_r, length_r = eval_overlap_from_position(position_r, "R", optimize_size, spine_tensor, image_shape, spacing)
|
overlap_r, diameter_r, length_r = eval_overlap_from_position(position_r, "R", optimize_size, spine_tensor, image_shape, spacing)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -36,10 +36,12 @@ def cl_score_torch_xfr(
|
||||||
# if in_bone == 0:
|
# if in_bone == 0:
|
||||||
# return float(not_in_bone*200)
|
# return float(not_in_bone*200)
|
||||||
|
|
||||||
score += 10 * in_bone # 10 實在太低
|
score += 20 * in_bone # 10 實在太低
|
||||||
score += 100 * overlap
|
score += 100 * overlap
|
||||||
score -= 2000 * max(0, not_in_bone-10)
|
# score -= 2000 * max(0, not_in_bone-10)
|
||||||
score -= 1000 * max(0, null_vox2-10)
|
# score -= 1000 * max(0, null_vox2-10)
|
||||||
|
score -= 1000 * not_in_bone
|
||||||
|
score -= 1000 * null_vox2
|
||||||
|
|
||||||
return float(-score)
|
return float(-score)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,309 @@
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import SimpleITK as sitk
|
import SimpleITK as sitk
|
||||||
from scipy.ndimage import center_of_mass
|
from scipy.ndimage import center_of_mass, rotate
|
||||||
import matplotlib.pyplot as plt
|
import matplotlib.pyplot as plt
|
||||||
|
|
||||||
|
def _best_vertical_split(mask2d):
|
||||||
|
"""
|
||||||
|
binary 2D 陣列(最後一軸 = x):找讓 mask 與其鏡射重疊最大的垂直線 x = t。
|
||||||
|
c[s] = Σ_u A[u]·A[s-u] 是每列自卷積;批次 FFT 一次算出所有 s。
|
||||||
|
回傳 (score, s_best);s = 2·t(t 可能為半整數)。
|
||||||
|
"""
|
||||||
|
n = mask2d.shape[-1]
|
||||||
|
flat = np.asarray(mask2d).reshape(-1, n)
|
||||||
|
rows = flat[flat.max(axis=1) > 0]
|
||||||
|
if rows.size == 0:
|
||||||
|
return 0.0, 0
|
||||||
|
L = 1 << (2 * n - 1).bit_length()
|
||||||
|
padded = np.zeros((rows.shape[0], L), dtype=np.float32)
|
||||||
|
padded[:, :n] = rows.astype(np.float32)
|
||||||
|
F = np.fft.rfft(padded, axis=1)
|
||||||
|
conv = np.fft.irfft(F * F, axis=1)[:, :2 * n - 1]
|
||||||
|
score = np.clip(conv.sum(axis=0), 0, None)
|
||||||
|
s_best = int(np.argmax(score))
|
||||||
|
return float(score[s_best]), s_best
|
||||||
|
|
||||||
|
|
||||||
|
def best_symmetry_axis_angle(proj, coarse_step=10.0, fine_step=1.0):
|
||||||
|
"""
|
||||||
|
2D binary 投影(axis0=row y, axis1=col x)的左右鏡稱軸搜尋:
|
||||||
|
將影像旋轉 θ 後,鏡稱軸變成垂直線(col = const),用 _best_vertical_split 評分;
|
||||||
|
粗搜 0..180°(coarse_step)再在 winner 附近細搜(fine_step)。
|
||||||
|
回傳 (theta_deg, score, s_best)。
|
||||||
|
"""
|
||||||
|
base = (np.asarray(proj) > 0).astype(np.float32)
|
||||||
|
best_th, best, best_s = 0.0, -1.0, 0
|
||||||
|
for th in np.arange(0.0, 180.0, coarse_step):
|
||||||
|
sc, s = _best_vertical_split(rotate(base, th, reshape=False, order=0) > 0.5)
|
||||||
|
if sc > best:
|
||||||
|
best_th, best, best_s = float(th), sc, s
|
||||||
|
for th in np.arange(best_th - coarse_step, best_th + coarse_step, fine_step):
|
||||||
|
th2 = float(th) % 180.0
|
||||||
|
sc, s = _best_vertical_split(rotate(base, th2, reshape=False, order=0) > 0.5)
|
||||||
|
if sc > best:
|
||||||
|
best_th, best, best_s = th2, sc, s
|
||||||
|
return best_th, best, best_s
|
||||||
|
|
||||||
|
|
||||||
|
def _mirror_hit_count(X, Y, Z, n, d, m, sz, sy, sx):
|
||||||
|
"""每個骨 voxel 對平面 n·p=d 鏡射後四捨五入到最近 voxel,回傳命中骨 voxel 的數量"""
|
||||||
|
nx, ny, nz = n
|
||||||
|
dist = X * nx + Y * ny + Z * nz - d
|
||||||
|
rx = (X - 2.0 * dist * nx).round().astype(np.int32)
|
||||||
|
ry = (Y - 2.0 * dist * ny).round().astype(np.int32)
|
||||||
|
rz = (Z - 2.0 * dist * nz).round().astype(np.int32)
|
||||||
|
ok = (rx >= 0) & (rx < sx) & (ry >= 0) & (ry < sy) & (rz >= 0) & (rz < sz)
|
||||||
|
if not ok.any():
|
||||||
|
return 0
|
||||||
|
return int(m[rz[ok], ry[ok], rx[ok]].sum())
|
||||||
|
|
||||||
|
|
||||||
|
def best_symmetry_plane(mask_zyx, phi_max=45.0, subsample=7,
|
||||||
|
coarse_step=7.5, fine_step=1.0):
|
||||||
|
"""
|
||||||
|
3D bone mask (z, y, x) 的最佳鏡稱面,一般平面方程 a·x + b·y + c·z = d
|
||||||
|
(voxel index 座標;(a,b,c) 為單位法線,方向任意,不限制平行 YZ 面)。
|
||||||
|
以「每個 bone voxel 鏡射後四捨五入到最近 voxel 的命中率」為分數,
|
||||||
|
參數化 n = R_y(phi)·R_z(theta)·(1,0,0):
|
||||||
|
theta : 法線在 axial (x,y) 平面內的旋轉
|
||||||
|
phi : 法線出平面的傾斜,限制在 [-phi_max, +phi_max] 以確保仍切分左右
|
||||||
|
搜尋:theta 由 2D axial 投影粗定位,再 3D 三段式(粗→細→微細)
|
||||||
|
(粗/細階段用等距子樣 voxel 加速)。
|
||||||
|
回傳 dict:
|
||||||
|
plane : (a, b, c, d) -> a*x + b*y + c*z = d
|
||||||
|
normal : (a, b, c)
|
||||||
|
offset : d
|
||||||
|
theta_deg / phi_deg : 法線參數
|
||||||
|
ratio : 鏡射命中率(0..1)
|
||||||
|
u, v : 平面內兩個正交方向(供繪製用)
|
||||||
|
"""
|
||||||
|
m = np.asarray(mask_zyx) > 0
|
||||||
|
sz, sy, sx = m.shape
|
||||||
|
zz, yy, xx = np.nonzero(m)
|
||||||
|
if xx.size < 100:
|
||||||
|
n = np.array([1.0, 0.0, 0.0])
|
||||||
|
d = (sx - 1) / 2.0
|
||||||
|
return {'plane': (1.0, 0.0, 0.0, float(d)), 'normal': (1.0, 0.0, 0.0),
|
||||||
|
'offset': float(d), 'theta_deg': 0.0, 'phi_deg': 0.0,
|
||||||
|
'ratio': 1.0, 'u': (0.0, 1.0, 0.0), 'v': (0.0, 0.0, 1.0)}
|
||||||
|
xf = xx.astype(np.float32)
|
||||||
|
yf = yy.astype(np.float32)
|
||||||
|
zf = zz.astype(np.float32)
|
||||||
|
N = xf.size
|
||||||
|
c0 = np.array([(sx - 1) / 2.0, (sy - 1) / 2.0, (sz - 1) / 2.0])
|
||||||
|
Xs, Ys, Zs = xf[::subsample], yf[::subsample], zf[::subsample]
|
||||||
|
|
||||||
|
def n_of(theta_deg, phi_deg):
|
||||||
|
t = np.deg2rad(theta_deg)
|
||||||
|
p = np.deg2rad(phi_deg)
|
||||||
|
return np.array([np.cos(t) * np.cos(p), np.sin(t), -np.cos(t) * np.sin(p)])
|
||||||
|
|
||||||
|
# theta 初值:2D axial 投影搜尋(垂直面情形)
|
||||||
|
proj = m.max(axis=0)
|
||||||
|
theta0 = best_symmetry_axis_angle(proj.astype(np.float32))[0] if proj.sum() >= 10 else 0.0
|
||||||
|
|
||||||
|
best = None
|
||||||
|
|
||||||
|
def consider(theta, phi, d, full=False):
|
||||||
|
nonlocal best
|
||||||
|
n = n_of(theta, phi)
|
||||||
|
X, Y, Z = (xf, yf, zf) if full else (Xs, Ys, Zs)
|
||||||
|
s = _mirror_hit_count(X, Y, Z, n, d, m, sz, sy, sx)
|
||||||
|
if best is None or s > best['score']:
|
||||||
|
best = {'score': s, 'theta': float(theta), 'phi': float(phi), 'd': float(d)}
|
||||||
|
|
||||||
|
# stage 1:粗搜尋(子樣)
|
||||||
|
for th in np.arange(theta0 - coarse_step * 2, theta0 + coarse_step * 2 + 1e-9, coarse_step):
|
||||||
|
for ph in np.arange(-phi_max, phi_max + 1e-9, coarse_step):
|
||||||
|
n = n_of(th, ph)
|
||||||
|
d0 = float(n @ c0)
|
||||||
|
for dd in (-10.0, 0.0, 10.0):
|
||||||
|
consider(th, ph, d0 + dd)
|
||||||
|
# stage 2:細搜尋(子樣)
|
||||||
|
for th in np.arange(best['theta'] - 2 * fine_step * 2, best['theta'] + 2 * fine_step * 2 + 1e-9, fine_step):
|
||||||
|
for ph in np.arange(best['phi'] - 2 * fine_step * 2, best['phi'] + 2 * fine_step * 2 + 1e-9, fine_step):
|
||||||
|
for dd in (-3.0, -1.0, 0.0, 1.0, 3.0):
|
||||||
|
consider(th, ph, best['d'] + dd)
|
||||||
|
# stage 3:微細搜尋(全分辨率)
|
||||||
|
for th in np.arange(best['theta'] - fine_step, best['theta'] + fine_step + 1e-9, fine_step / 2):
|
||||||
|
for ph in np.arange(best['phi'] - fine_step, best['phi'] + fine_step + 1e-9, fine_step / 2):
|
||||||
|
for dd in (-1.0, -0.5, 0.0, 0.5, 1.0):
|
||||||
|
consider(th, ph, best['d'] + dd, full=True)
|
||||||
|
# stage 4:d 微調(全分辨率)
|
||||||
|
b = best
|
||||||
|
for dd in np.arange(-1.0, 1.0 + 1e-9, 0.25):
|
||||||
|
consider(b['theta'], b['phi'], b['d'] + dd, full=True)
|
||||||
|
|
||||||
|
n = n_of(best['theta'], best['phi'])
|
||||||
|
theta = best['theta']
|
||||||
|
d = best['d']
|
||||||
|
if theta > 90.0 or theta < -90.0: # 正規化到 (-90, 90],法線翻轉時 d 取反
|
||||||
|
theta -= 180.0
|
||||||
|
n = -n
|
||||||
|
d = -d
|
||||||
|
ratio = _mirror_hit_count(xf, yf, zf, n, d, m, sz, sy, sx) / N
|
||||||
|
u = np.cross(n, [0.0, 0.0, 1.0])
|
||||||
|
if np.linalg.norm(u) < 0.1:
|
||||||
|
u = np.cross(n, [1.0, 0.0, 0.0])
|
||||||
|
u = u / np.linalg.norm(u)
|
||||||
|
v = np.cross(n, u)
|
||||||
|
return {
|
||||||
|
'plane': (float(n[0]), float(n[1]), float(n[2]), float(d)),
|
||||||
|
'normal': (float(n[0]), float(n[1]), float(n[2])),
|
||||||
|
'offset': float(d),
|
||||||
|
'theta_deg': float(theta),
|
||||||
|
'phi_deg': float(best['phi']),
|
||||||
|
'ratio': float(ratio),
|
||||||
|
'u': (float(u[0]), float(u[1]), float(u[2])),
|
||||||
|
'v': (float(v[0]), float(v[1]), float(v[2])),
|
||||||
|
}
|
||||||
|
|
||||||
|
def segment_spinous_process(mask_zyx, sym, band_frac=0.06, min_band=6.0,
|
||||||
|
min_mass_frac=0.05):
|
||||||
|
"""
|
||||||
|
以 best_symmetry_plane 的結果 sym 從 3D bone mask (z, y, x) 切出棘突。
|
||||||
|
棘突是中線後側構造,利用鏡稱面 a·x+b·y+c·z=d 定義:
|
||||||
|
1) 中線帶:骨 voxel 到平面的有號距離 |s| <= w,
|
||||||
|
w = max(min_band, band_frac * s 全寬)
|
||||||
|
2) 前後方向:平面內兩軸 (u, v) 中 |y| 分量大者,
|
||||||
|
正規化成 +AP = 後側(本資料系 y 往前遞增,後側 = y 小側)
|
||||||
|
3) 中線帶的 AP 分佈呈兩大叢(椎體在前、椎弓/棘突在後),
|
||||||
|
以兩叢間的 AP 谷底為界,AP >= 谷底 的中線帶 voxel = 棘突(含中線椎弓);
|
||||||
|
無明顯谷底(如骨橋)fallback 取中線帶後側 15%。
|
||||||
|
回傳 (sp_mask (z,y,x) bool, ap_thresh, info dict);
|
||||||
|
資料過少時 sp_mask = None(info['mode'] 說明原因)。
|
||||||
|
"""
|
||||||
|
m = np.asarray(mask_zyx) > 0
|
||||||
|
zz, yy, xx = np.nonzero(m)
|
||||||
|
info = {'n_bone': int(zz.size), 'n_sp': 0, 'ap_thresh': None,
|
||||||
|
'band_w': None, 'mode': 'empty'}
|
||||||
|
if zz.size < 50:
|
||||||
|
return None, None, info
|
||||||
|
a, b, c, d = sym['plane']
|
||||||
|
X = xx.astype(np.float64)
|
||||||
|
Y = yy.astype(np.float64)
|
||||||
|
Z = zz.astype(np.float64)
|
||||||
|
s = X * a + Y * b + Z * c - d
|
||||||
|
u = np.array(sym['u'])
|
||||||
|
v = np.array(sym['v'])
|
||||||
|
u_ap = u if abs(u[1]) >= abs(v[1]) else v
|
||||||
|
if u_ap[1] > 0:
|
||||||
|
u_ap = -u_ap # +AP = 後側(y 小側)
|
||||||
|
w = max(float(min_band), float(band_frac) * float(s.max() - s.min()))
|
||||||
|
mid = np.abs(s) <= w
|
||||||
|
ap = X * u_ap[0] + Y * u_ap[1] + Z * u_ap[2]
|
||||||
|
aps = ap[mid]
|
||||||
|
info['band_w'] = float(w)
|
||||||
|
if aps.size < 50:
|
||||||
|
info['mode'] = 'too_few_midline'
|
||||||
|
return None, None, info
|
||||||
|
lo = int(np.floor(aps.min()))
|
||||||
|
hi = int(np.ceil(aps.max()))
|
||||||
|
th = None
|
||||||
|
mode = 'fallback'
|
||||||
|
if hi - lo >= 10:
|
||||||
|
hist, edges = np.histogram(aps, bins=range(lo, hi + 1))
|
||||||
|
csum = np.concatenate([[0], np.cumsum(hist)])
|
||||||
|
total = csum[-1]
|
||||||
|
peak = hist.max()
|
||||||
|
best_i, best_score = None, -1.0
|
||||||
|
for i in range(len(hist)):
|
||||||
|
if hist[i] >= 0.05 * peak:
|
||||||
|
continue
|
||||||
|
if csum[i] < min_mass_frac * total or (total - csum[i + 1]) < min_mass_frac * total:
|
||||||
|
continue
|
||||||
|
score = min(csum[i], total - csum[i + 1])
|
||||||
|
if score > best_score:
|
||||||
|
best_score, best_i = score, i
|
||||||
|
if best_i is not None:
|
||||||
|
th = float(0.5 * (edges[best_i] + edges[best_i + 1]))
|
||||||
|
mode = 'gap'
|
||||||
|
if th is None:
|
||||||
|
th = float(np.quantile(aps, 0.85))
|
||||||
|
sp_mask = np.zeros(m.shape, dtype=bool)
|
||||||
|
sel = mid & (ap >= th)
|
||||||
|
sp_mask[zz[sel], yy[sel], xx[sel]] = True
|
||||||
|
info.update(n_sp=int(sel.sum()), ap_thresh=th, mode=mode)
|
||||||
|
return sp_mask, th, info
|
||||||
|
|
||||||
|
def best_upper_endplate_plane(mask_zyx, angle_max=45.0, thresh=3.0,
|
||||||
|
n_iter=500, seed=42):
|
||||||
|
"""
|
||||||
|
3D bone mask (z, y, x) 的最佳「上終板」近似平面 a·x + b·y + c·z = d
|
||||||
|
(voxel index 座標;(a,b,c) 為朝上的單位法線)。
|
||||||
|
1) 每個 (y, x) 欄位取最上方 bone voxel 作為頂面點(僅前側半邊,
|
||||||
|
y >= COM_y,避開後方元素,與 2D superior endplate 定義一致)
|
||||||
|
2) RANSAC 三點擬平面:法線限制在與 +z 軸 ≤ angle_max° 內,
|
||||||
|
計數 ±thresh voxel 內的頂面點為 inlier,取 inlier 最多者
|
||||||
|
3) SVD 最小二乘微調
|
||||||
|
回傳 dict(資料不足時回傳 None):
|
||||||
|
plane : (a, b, c, d)
|
||||||
|
normal / offset
|
||||||
|
tilt_deg : 法線與 +z 軸的夾角(上終板傾斜)
|
||||||
|
inlier_ratio : 頂面點落在平面 ±thresh 的比例
|
||||||
|
n_points / n_inliers
|
||||||
|
u, v : 平面內正交方向(供繪製用)
|
||||||
|
"""
|
||||||
|
m = np.asarray(mask_zyx) > 0
|
||||||
|
nz, ny, nx = m.shape
|
||||||
|
idx = np.where(m, np.arange(nz)[:, None, None], -1)
|
||||||
|
ztop = idx.max(axis=0) # (y, x) 每欄最上 z
|
||||||
|
y_split = int(round(center_of_mass(m)[1])) if m.sum() else 0
|
||||||
|
# ztop 是 (y, x):條件作用在 y 軸(axis 0)
|
||||||
|
sel = (ztop >= 0) & (np.arange(ny)[:, None] >= y_split)
|
||||||
|
yy, xx = np.where(sel)
|
||||||
|
if xx.size < 8:
|
||||||
|
return None
|
||||||
|
P = np.stack((xx, yy, ztop[yy, xx]), axis=1).astype(np.float64)
|
||||||
|
n = P.shape[0]
|
||||||
|
|
||||||
|
rng = np.random.default_rng(seed)
|
||||||
|
cos_min = np.cos(np.deg2rad(angle_max))
|
||||||
|
best_cnt, best_nv, best_d = -1, None, 0.0
|
||||||
|
for _ in range(n_iter):
|
||||||
|
i, j, k = rng.choice(n, 3, replace=False)
|
||||||
|
cr = np.cross(P[j] - P[i], P[k] - P[i])
|
||||||
|
ln = np.linalg.norm(cr)
|
||||||
|
if ln < 1e-6:
|
||||||
|
continue
|
||||||
|
nv = cr / ln
|
||||||
|
if nv[2] < 0:
|
||||||
|
nv = -nv
|
||||||
|
if nv[2] < cos_min: # 法線必須朝上
|
||||||
|
continue
|
||||||
|
d = float(nv @ P[i])
|
||||||
|
cnt = int(np.count_nonzero(np.abs(P @ nv - d) <= thresh))
|
||||||
|
if cnt > best_cnt:
|
||||||
|
best_cnt, best_nv, best_d = cnt, nv, d
|
||||||
|
if best_nv is None:
|
||||||
|
return None
|
||||||
|
# SVD 微調
|
||||||
|
inl = P[np.abs(P @ best_nv - best_d) <= thresh]
|
||||||
|
if inl.shape[0] < 3:
|
||||||
|
return None
|
||||||
|
mean = inl.mean(axis=0)
|
||||||
|
_, _, Vt = np.linalg.svd(inl - mean, full_matrices=False)
|
||||||
|
nv = Vt[2]
|
||||||
|
if nv[2] < 0:
|
||||||
|
nv = -nv
|
||||||
|
d = float(nv @ mean)
|
||||||
|
dist = np.abs(P @ nv - d)
|
||||||
|
inl = P[dist <= thresh]
|
||||||
|
u = np.cross(nv, [1.0, 0.0, 0.0])
|
||||||
|
u = u / np.linalg.norm(u)
|
||||||
|
v = np.cross(nv, u)
|
||||||
|
return {
|
||||||
|
'plane': (float(nv[0]), float(nv[1]), float(nv[2]), d),
|
||||||
|
'normal': (float(nv[0]), float(nv[1]), float(nv[2])),
|
||||||
|
'offset': d,
|
||||||
|
'tilt_deg': float(np.degrees(np.arccos(np.clip(nv[2], -1.0, 1.0)))),
|
||||||
|
'inlier_ratio': float(inl.shape[0] / n),
|
||||||
|
'n_points': int(n),
|
||||||
|
'n_inliers': int(inl.shape[0]),
|
||||||
|
'u': (float(u[0]), float(u[1]), float(u[2])),
|
||||||
|
'v': (float(v[0]), float(v[1]), float(v[2])),
|
||||||
|
}
|
||||||
|
|
||||||
def azimuth_rotation(image, show_plt=False, save_plt=False, output_path=None):
|
def azimuth_rotation(image, show_plt=False, save_plt=False, output_path=None):
|
||||||
|
|
||||||
img = sitk.ReadImage(image, sitk.sitkUInt8)
|
img = sitk.ReadImage(image, sitk.sitkUInt8)
|
||||||
|
|
@ -55,6 +356,23 @@ def azimuth_rotation(image, show_plt=False, save_plt=False, output_path=None):
|
||||||
plt.plot([x_center, cx], [y_min, cy], 'c-', lw=2,
|
plt.plot([x_center, cx], [y_min, cy], 'c-', lw=2,
|
||||||
label=f'Angle with y-axis: {angle_deg:.1f}°')
|
label=f'Angle with y-axis: {angle_deg:.1f}°')
|
||||||
|
|
||||||
|
# 中矢狀面:最佳鏡稱面 a·x + b·y + c·z = d(法線方向任意,不平行 YZ 面)
|
||||||
|
# 畫該平面在體積中央 z 切片上的截線
|
||||||
|
sym3 = best_symmetry_plane(arr_zyx)
|
||||||
|
a3, b3, c3, d3 = sym3['plane']
|
||||||
|
zc_ = (arr_zyx.shape[0] - 1) / 2.0
|
||||||
|
rhs_ = d3 - c3 * zc_ # a*x + b*y = rhs
|
||||||
|
n2d = np.hypot(a3, b3)
|
||||||
|
x0_ = a3 * rhs_ / (n2d * n2d)
|
||||||
|
y0_ = b3 * rhs_ / (n2d * n2d)
|
||||||
|
u2_ = np.array([b3, -a3]) / n2d
|
||||||
|
tfg_ = (xs - x0_) * u2_[0] + (ys - y0_) * u2_[1]
|
||||||
|
L_ = float(np.abs(tfg_).max())
|
||||||
|
plt.plot([x0_ - L_ * u2_[0], x0_ + L_ * u2_[0]],
|
||||||
|
[y0_ - L_ * u2_[1], y0_ + L_ * u2_[1]],
|
||||||
|
color='magenta', lw=2, linestyle='--',
|
||||||
|
label=f'Mirror plane: {a3:+.2f}x {b3:+.2f}y {c3:+.2f}z = {d3:.1f}')
|
||||||
|
|
||||||
plt.legend()
|
plt.legend()
|
||||||
plt.title("Top point + centroid-directed line")
|
plt.title("Top point + centroid-directed line")
|
||||||
plt.axis("off")
|
plt.axis("off")
|
||||||
|
|
|
||||||
BIN
references/1-s2.0-S1529943008007213-main.pdf
Normal file
BIN
references/1-s2.0-S1529943008007213-main.pdf
Normal file
Binary file not shown.
BIN
references/6_2021-0059.pdf
Normal file
BIN
references/6_2021-0059.pdf
Normal file
Binary file not shown.
BIN
references/MIRU 2026/MIRU_poster_chou_CBT_v3.pdf
Normal file
BIN
references/MIRU 2026/MIRU_poster_chou_CBT_v3.pdf
Normal file
Binary file not shown.
BIN
references/MIRU 2026/miru2026_CBT_v3.docx
Normal file
BIN
references/MIRU 2026/miru2026_CBT_v3.docx
Normal file
Binary file not shown.
BIN
references/MIRU 2026/miru2026_CBT_v3.pdf
Normal file
BIN
references/MIRU 2026/miru2026_CBT_v3.pdf
Normal file
Binary file not shown.
BIN
references/asj-11-817.pdf
Normal file
BIN
references/asj-11-817.pdf
Normal file
Binary file not shown.
|
|
@ -1,6 +1,9 @@
|
||||||
import torch
|
import torch
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import matplotlib.pyplot as plt
|
import matplotlib.pyplot as plt
|
||||||
|
from matplotlib.colors import to_rgba
|
||||||
|
from matplotlib.lines import Line2D
|
||||||
|
from mpl_toolkits.mplot3d.art3d import Poly3DCollection
|
||||||
import os
|
import os
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import csv
|
import csv
|
||||||
|
|
@ -8,9 +11,20 @@ import csv
|
||||||
from core.cylinder import generate_cylinder_n_torch, generate_cylinder_o_torch, snap_to_discrete_values
|
from core.cylinder import generate_cylinder_n_torch, generate_cylinder_o_torch, snap_to_discrete_values
|
||||||
from core.intersection import center_line_intersections_torch
|
from core.intersection import center_line_intersections_torch
|
||||||
from core.scoring import cl_score_torch, compute_overlap_ratio_from_cylinder_mask, cl_score_torch_xfr
|
from core.scoring import cl_score_torch, compute_overlap_ratio_from_cylinder_mask, cl_score_torch_xfr
|
||||||
from imaging.orientation import azimuth_rotation, analyze_vertebral_tilt_contour
|
from imaging.orientation import (azimuth_rotation, analyze_vertebral_tilt_contour,
|
||||||
|
best_symmetry_plane, best_upper_endplate_plane,
|
||||||
|
segment_spinous_process)
|
||||||
from utils.helpers import save_with_unique_name
|
from utils.helpers import save_with_unique_name
|
||||||
|
|
||||||
|
# Volume absorption 渲染(Beer-Lambert):每 voxel 不透明度 = 1 - exp(-mu * voxel_width)
|
||||||
|
# 骨頭核心厚度達 70-90 voxel,沿視線堆疊會使任何 per-voxel alpha 累積成不透明。
|
||||||
|
# 因此以「抽稀 (SUBSAMPLE) 降低堆疊數量」+「低 mu 控制每點吸收」兩項共同調出淡薄 X-ray 陰影,
|
||||||
|
# 同時保留皮質 / 鬆質的吸收入射差異(mu 比值)。
|
||||||
|
BONE_MU_CORTICAL = 0.02 # 1/mm → 每 voxel = 1-exp(-0.02*0.5) ~ 0.010
|
||||||
|
BONE_MU_TRABECULAR = 0.005 # 1/mm → 每 voxel = 1-exp(-0.005*0.5) ~ 0.0025
|
||||||
|
BONE_MARKER_SIZE = 3.0 # 骨骼散點點面積 (pt^2);略大以補償抽稀後的顆粒感
|
||||||
|
BONE_SUBSAMPLE = 1 # 每 10 個骨 voxel 畫 1 個,降低堆疊不透明度(1=全畫)
|
||||||
|
|
||||||
def set_axes_equal_3d(ax):
|
def set_axes_equal_3d(ax):
|
||||||
"""
|
"""
|
||||||
Make axes of 3D plot have equal scale so that spheres appear as spheres,
|
Make axes of 3D plot have equal scale so that spheres appear as spheres,
|
||||||
|
|
@ -165,55 +179,171 @@ def res_plt_2_torch(
|
||||||
z_cyl_r1, y_cyl_r1, x_cyl_r1 = np.where(cyl_r_cpu == 1)
|
z_cyl_r1, y_cyl_r1, x_cyl_r1 = np.where(cyl_r_cpu == 1)
|
||||||
z_cyl_r2, y_cyl_r2, x_cyl_r2 = np.where(cyl_ro_cpu == 1)
|
z_cyl_r2, y_cyl_r2, x_cyl_r2 = np.where(cyl_ro_cpu == 1)
|
||||||
|
|
||||||
z_img, y_img, x_img = np.where(spine_cpu == 1)
|
# 骨頭 voxel 依「體積吸收」分成兩組:皮質(高不透明度)與鬆質(低不透明度)
|
||||||
|
cortical_cpu = cortical_tensor.cpu().numpy()
|
||||||
|
voxel_mm = float(spacing[0])
|
||||||
|
alpha_cortical = 1.0 - np.exp(-BONE_MU_CORTICAL * voxel_mm)
|
||||||
|
alpha_trabecular = 1.0 - np.exp(-BONE_MU_TRABECULAR * voxel_mm)
|
||||||
|
z_corti, y_corti, x_corti = np.where((spine_cpu == 1) & (cortical_cpu == 1))
|
||||||
|
z_trab, y_trab, x_trab = np.where((spine_cpu == 1) & (cortical_cpu == 0))
|
||||||
|
|
||||||
|
# 中矢狀面:骨頭的最佳鏡稱面,一般平面 a·x + b·y + c·z = d(法線方向任意)
|
||||||
|
sym = best_symmetry_plane(spine_cpu)
|
||||||
|
|
||||||
|
# 棘突:鏡稱面中線帶(|s|<=w)且在 AP 谷底之後側的骨 voxel,換不同顏色標示
|
||||||
|
sp_mask, sp_th, sp_info = segment_spinous_process(spine_cpu, sym)
|
||||||
|
if sp_mask is not None and sp_mask.any():
|
||||||
|
sp_corti = sp_mask[z_corti, y_corti, x_corti]
|
||||||
|
sp_trab = sp_mask[z_trab, y_trab, x_trab]
|
||||||
|
sp_n_bone = max(int(spine_cpu.sum()), 1)
|
||||||
|
print(f"[SPINOUS] n={sp_info['n_sp']} "
|
||||||
|
f"({100.0 * sp_info['n_sp'] / sp_n_bone:.1f}% of bone) "
|
||||||
|
f"band=+/-{sp_info['band_w']:.1f} voxel AP>={sp_info['ap_thresh']:.1f} "
|
||||||
|
f"mode={sp_info['mode']}")
|
||||||
|
else:
|
||||||
|
sp_corti = sp_trab = None
|
||||||
|
|
||||||
|
# X-ray 外觀:骨頭合成一個半透明體積吸收點雲(下方);
|
||||||
|
# 螺絲(中心線 + 圓柱 + 入口軌跡延长)合成一個全不透明點雲,永遠畫在骨頭之上
|
||||||
|
def _rgba_block(n, color, a):
|
||||||
|
arr = np.empty((n, 4))
|
||||||
|
arr[:] = to_rgba(color)
|
||||||
|
arr[:, 3] = a
|
||||||
|
return arr
|
||||||
|
|
||||||
|
x_bone = np.concatenate([x_corti, x_trab])
|
||||||
|
y_bone = np.concatenate([y_corti, y_trab])
|
||||||
|
z_bone = np.concatenate([z_corti, z_trab])
|
||||||
|
bone_rgba = np.concatenate([
|
||||||
|
_rgba_block(len(x_corti), 'lightblue', float(alpha_cortical)),
|
||||||
|
_rgba_block(len(x_trab), 'lightblue', float(alpha_trabecular)),
|
||||||
|
])
|
||||||
|
bone_size = np.full(len(x_bone), BONE_MARKER_SIZE)
|
||||||
|
|
||||||
|
# 抽稀:降低堆疊不透明度以呈現淡薄 X-ray 陰影
|
||||||
|
if BONE_SUBSAMPLE > 1:
|
||||||
|
x_bone = x_bone[::BONE_SUBSAMPLE]
|
||||||
|
y_bone = y_bone[::BONE_SUBSAMPLE]
|
||||||
|
z_bone = z_bone[::BONE_SUBSAMPLE]
|
||||||
|
bone_rgba = bone_rgba[::BONE_SUBSAMPLE]
|
||||||
|
bone_size = bone_size[::BONE_SUBSAMPLE]
|
||||||
|
if sp_corti is not None:
|
||||||
|
sp_flag = np.concatenate([sp_corti, sp_trab])
|
||||||
|
if BONE_SUBSAMPLE > 1:
|
||||||
|
sp_flag = sp_flag[::BONE_SUBSAMPLE]
|
||||||
|
bone_rgba[sp_flag] = to_rgba('purple', 0.95)
|
||||||
|
|
||||||
|
x_screw = np.concatenate([x_lin1, x_lin2, x_cyl_l1, x_cyl_l2, x_cyl_r1, x_cyl_r2])
|
||||||
|
y_screw = np.concatenate([y_lin1, y_lin2, y_cyl_l1, y_cyl_l2, y_cyl_r1, y_cyl_r2])
|
||||||
|
z_screw = np.concatenate([z_lin1, z_lin2, z_cyl_l1, z_cyl_l2, z_cyl_r1, z_cyl_r2])
|
||||||
|
|
||||||
|
_a, _b, _c, _d = sym['plane']
|
||||||
|
_n = np.array([_a, _b, _c])
|
||||||
|
_u = np.array(sym['u'])
|
||||||
|
_v = np.array(sym['v'])
|
||||||
|
_p0 = _d * _n # 平面上最接近原點的點
|
||||||
|
xyz_bone = np.stack([x_bone - _p0[0], y_bone - _p0[1], z_bone - _p0[2]], axis=1)
|
||||||
|
_pu = xyz_bone @ _u
|
||||||
|
_pv = xyz_bone @ _v
|
||||||
|
_U_, _V_ = np.meshgrid(np.linspace(_pu.min(), _pu.max(), 8),
|
||||||
|
np.linspace(_pv.min(), _pv.max(), 8))
|
||||||
|
_Xp = _p0[0] + _U_ * _u[0] + _V_ * _v[0]
|
||||||
|
_Yp = _p0[1] + _U_ * _u[1] + _V_ * _v[1]
|
||||||
|
_Zp = _p0[2] + _U_ * _u[2] + _V_ * _v[2]
|
||||||
|
|
||||||
|
# 上終板平面:RANSAC 擬合骨頭頂面(前側)的最佳 a·x + b·y + c·z = d
|
||||||
|
symp = best_upper_endplate_plane(spine_cpu)
|
||||||
|
_EX = _EY = _EZ = None
|
||||||
|
if symp is not None:
|
||||||
|
_ea, _eb, _ec, _ed = symp['plane']
|
||||||
|
_en = np.array([_ea, _eb, _ec])
|
||||||
|
_eu = np.array(symp['u'])
|
||||||
|
_ev = np.array(symp['v'])
|
||||||
|
_ep0 = _ed * _en
|
||||||
|
xz_ep = np.stack([x_bone - _ep0[0], y_bone - _ep0[1], z_bone - _ep0[2]], axis=1)
|
||||||
|
_pu_ep = xz_ep @ _eu
|
||||||
|
_pv_ep = xz_ep @ _ev
|
||||||
|
_EU, _EV = np.meshgrid(np.linspace(_pu_ep.min(), _pu_ep.max(), 8),
|
||||||
|
np.linspace(_pv_ep.min(), _pv_ep.max(), 8))
|
||||||
|
_EX = _ep0[0] + _EU * _eu[0] + _EV * _ev[0]
|
||||||
|
_EY = _ep0[1] + _EU * _eu[1] + _EV * _ev[1]
|
||||||
|
_EZ = _ep0[2] + _EU * _eu[2] + _EV * _ev[2]
|
||||||
|
screw_rgba = np.concatenate([
|
||||||
|
_rgba_block(len(x_lin1), 'r', 1.0),
|
||||||
|
_rgba_block(len(x_lin2), 'r', 1.0),
|
||||||
|
_rgba_block(len(x_cyl_l1), 'darkcyan', 1.0),
|
||||||
|
_rgba_block(len(x_cyl_l2), 'pink', 1.0),
|
||||||
|
_rgba_block(len(x_cyl_r1), 'blue', 1.0),
|
||||||
|
_rgba_block(len(x_cyl_r2), 'pink', 1.0),
|
||||||
|
])
|
||||||
|
screw_size = np.concatenate([
|
||||||
|
np.full(len(x_lin1), 3), np.full(len(x_lin2), 3),
|
||||||
|
np.full(len(x_cyl_l1), 36), np.full(len(x_cyl_l2), 36),
|
||||||
|
np.full(len(x_cyl_r1), 36), np.full(len(x_cyl_r2), 36),
|
||||||
|
])
|
||||||
|
|
||||||
fig = plt.figure(figsize=(12, 12))
|
fig = plt.figure(figsize=(12, 12))
|
||||||
|
|
||||||
|
# 圖例色塊提高到可讀不透明度(實際渲染仍用真實吸收 alpha)
|
||||||
|
_leg_alpha_c = max(float(alpha_cortical), 0.35)
|
||||||
|
_leg_alpha_t = max(float(alpha_trabecular), 0.2)
|
||||||
|
legend_handles = [
|
||||||
|
Line2D([], [], marker='o', ls='', ms=5, color=to_rgba('lightblue', _leg_alpha_c), label='Spine (cortical)'),
|
||||||
|
Line2D([], [], marker='o', ls='', ms=5, color=to_rgba('lightblue', _leg_alpha_t), label='Spine (trabecular)'),
|
||||||
|
Line2D([], [], marker='o', ls='', ms=2, color='r', label='Centerline'),
|
||||||
|
Line2D([], [], marker='o', ls='', ms=6, color='darkcyan', label='Cylinder(L)'),
|
||||||
|
Line2D([], [], marker='o', ls='', ms=6, color='blue', label='Cylinder(R)'),
|
||||||
|
Line2D([], [], marker='o', ls='', ms=6, color='pink', label='Entry track (outer)'),
|
||||||
|
Line2D([], [], color='orange', lw=2, alpha=0.6,
|
||||||
|
label=f"Mirror plane {sym['plane'][0]:+.2f}x {sym['plane'][1]:+.2f}y {sym['plane'][2]:+.2f}z = {sym['plane'][3]:.1f}"),
|
||||||
|
]
|
||||||
|
if sp_corti is not None:
|
||||||
|
legend_handles.append(Line2D([], [], marker='o', ls='', ms=6, color='purple',
|
||||||
|
label=f"Spinous process (mirror-plane midline, {sp_info['n_sp']} vox)"))
|
||||||
|
if symp is not None:
|
||||||
|
legend_handles.append(Line2D([], [], color='green', lw=2, alpha=0.7,
|
||||||
|
label=f"Upper endplate plane tilt {symp['tilt_deg']:.1f} deg"))
|
||||||
|
|
||||||
|
def _fill_ax(ax):
|
||||||
|
# X-ray 外觀:關閉 mplot3d 依深度自動排序 zorder(否則半透明骨頭會被重繪到
|
||||||
|
# 螺絲上方);改為固定分層:吸收骨頭 zorder=5,全不透明螺絲 zorder=10
|
||||||
|
ax.computed_zorder = False
|
||||||
|
sc_bone = ax.scatter(x_bone, y_bone, z_bone, c=bone_rgba, s=bone_size, marker='o')
|
||||||
|
sc_bone.set_zorder(5)
|
||||||
|
sc_screw = ax.scatter(x_screw, y_screw, z_screw, c=screw_rgba, s=screw_size, marker='o')
|
||||||
|
sc_screw.set_zorder(10)
|
||||||
|
# 中矢狀面(理論左右對稱切分面):半透明橘色平面 x = x_mid
|
||||||
|
# 平面邊緣畫橘色線,讓 axial / 正視(側看時)也能清楚看到切分線
|
||||||
|
plane = ax.plot_surface(_Xp, _Yp, _Zp, color='orange', alpha=0.30,
|
||||||
|
linewidth=1.0, edgecolor='orange', rstride=1, cstride=1)
|
||||||
|
plane.set_zorder(8)
|
||||||
|
# 上終板平面:半透明綠色平面(邊緣綠線)
|
||||||
|
if _EX is not None:
|
||||||
|
ep = ax.plot_surface(_EX, _EY, _EZ, color='green', alpha=0.35,
|
||||||
|
linewidth=1.0, edgecolor='green', rstride=1, cstride=1)
|
||||||
|
ep.set_zorder(7)
|
||||||
|
|
||||||
ax1 = fig.add_subplot(221, projection='3d')
|
ax1 = fig.add_subplot(221, projection='3d')
|
||||||
ax1.scatter(x_lin1, y_lin1, z_lin1, c='r', marker='o', s=1)
|
_fill_ax(ax1)
|
||||||
ax1.scatter(x_lin2, y_lin2, z_lin2, c='r', marker='o', s=1)
|
|
||||||
ax1.scatter(x_cyl_l1, y_cyl_l1, z_cyl_l1, c='darkcyan', marker='o', label='Cylinder(L)')
|
|
||||||
ax1.scatter(x_cyl_l2, y_cyl_l2, z_cyl_l2, c='pink', marker='o')
|
|
||||||
ax1.scatter(x_cyl_r1, y_cyl_r1, z_cyl_r1, c='blue', marker='o', label='Cylinder(R)')
|
|
||||||
ax1.scatter(x_cyl_r2, y_cyl_r2, z_cyl_r2, c='pink', marker='o')
|
|
||||||
ax1.scatter(x_img, y_img, z_img, c='lightblue', marker='+', alpha=0.04, label='Spine')
|
|
||||||
ax1.set_xlabel('X-axis'); ax1.set_ylabel('Y-axis'); ax1.set_zlabel('Z-axis')
|
ax1.set_xlabel('X-axis'); ax1.set_ylabel('Y-axis'); ax1.set_zlabel('Z-axis')
|
||||||
set_axes_equal_3d(ax1)
|
set_axes_equal_3d(ax1)
|
||||||
|
|
||||||
ax2 = fig.add_subplot(222, projection='3d')
|
ax2 = fig.add_subplot(222, projection='3d')
|
||||||
ax2.view_init(elev=90, azim=-90, roll=0)
|
ax2.view_init(elev=90, azim=-90, roll=0)
|
||||||
ax2.scatter(x_lin1, y_lin1, z_lin1, c='r', marker='o', s=1)
|
_fill_ax(ax2)
|
||||||
ax2.scatter(x_lin2, y_lin2, z_lin2, c='r', marker='o', s=1)
|
|
||||||
ax2.scatter(x_cyl_l1, y_cyl_l1, z_cyl_l1, c='darkcyan', marker='o', label='Cylinder(L)')
|
|
||||||
ax2.scatter(x_cyl_l2, y_cyl_l2, z_cyl_l2, c='pink', marker='o')
|
|
||||||
ax2.scatter(x_cyl_r1, y_cyl_r1, z_cyl_r1, c='blue', marker='o', label='Cylinder(R)')
|
|
||||||
ax2.scatter(x_cyl_r2, y_cyl_r2, z_cyl_r2, c='pink', marker='o')
|
|
||||||
ax2.scatter(x_img, y_img, z_img, c='lightblue', marker='+', alpha=0.04, label='Spine')
|
|
||||||
ax2.set_xlabel('X-axis'); ax2.set_ylabel('Y-axis'); ax2.set_zlabel('Z-axis')
|
ax2.set_xlabel('X-axis'); ax2.set_ylabel('Y-axis'); ax2.set_zlabel('Z-axis')
|
||||||
set_axes_equal_3d(ax2)
|
set_axes_equal_3d(ax2)
|
||||||
ax2.legend()
|
ax2.legend(handles=legend_handles)
|
||||||
|
|
||||||
ax3 = fig.add_subplot(223, projection='3d')
|
ax3 = fig.add_subplot(223, projection='3d')
|
||||||
ax3.view_init(elev=0, azim=90, roll=0)
|
ax3.view_init(elev=0, azim=90, roll=0)
|
||||||
ax3.scatter(x_lin1, y_lin1, z_lin1, c='r', marker='o', s=1)
|
_fill_ax(ax3)
|
||||||
ax3.scatter(x_lin2, y_lin2, z_lin2, c='r', marker='o', s=1)
|
|
||||||
ax3.scatter(x_cyl_l1, y_cyl_l1, z_cyl_l1, c='darkcyan', marker='o', label='Cylinder(L)')
|
|
||||||
ax3.scatter(x_cyl_l2, y_cyl_l2, z_cyl_l2, c='pink', marker='o')
|
|
||||||
ax3.scatter(x_cyl_r1, y_cyl_r1, z_cyl_r1, c='blue', marker='o', label='Cylinder(R)')
|
|
||||||
ax3.scatter(x_cyl_r2, y_cyl_r2, z_cyl_r2, c='pink', marker='o')
|
|
||||||
ax3.scatter(x_img, y_img, z_img, c='lightblue', marker='+', alpha=0.04, label='Spine')
|
|
||||||
ax3.set_xlabel('X-axis'); ax3.set_ylabel('Y-axis'); ax3.set_zlabel('Z-axis')
|
ax3.set_xlabel('X-axis'); ax3.set_ylabel('Y-axis'); ax3.set_zlabel('Z-axis')
|
||||||
set_axes_equal_3d(ax3)
|
set_axes_equal_3d(ax3)
|
||||||
|
|
||||||
ax4 = fig.add_subplot(224, projection='3d')
|
ax4 = fig.add_subplot(224, projection='3d')
|
||||||
ax4.view_init(elev=0, azim=0, roll=0)
|
ax4.view_init(elev=0, azim=0, roll=0)
|
||||||
ax4.scatter(x_lin1, y_lin1, z_lin1, c='r', marker='o', s=1)
|
_fill_ax(ax4)
|
||||||
ax4.scatter(x_lin2, y_lin2, z_lin2, c='r', marker='o', s=1)
|
|
||||||
ax4.scatter(x_cyl_l1, y_cyl_l1, z_cyl_l1, c='darkcyan', marker='o', label='Cylinder(L)')
|
|
||||||
ax4.scatter(x_cyl_l2, y_cyl_l2, z_cyl_l2, c='pink', marker='o')
|
|
||||||
ax4.scatter(x_cyl_r1, y_cyl_r1, z_cyl_r1, c='blue', marker='o', label='Cylinder(R)')
|
|
||||||
ax4.scatter(x_cyl_r2, y_cyl_r2, z_cyl_r2, c='pink', marker='o')
|
|
||||||
ax4.scatter(x_img, y_img, z_img, c='lightblue', marker='+', alpha=0.04, label='Spine')
|
|
||||||
ax4.set_xlabel('X-axis'); ax4.set_ylabel('Y-axis'); ax4.set_zlabel('Z-axis')
|
ax4.set_xlabel('X-axis'); ax4.set_ylabel('Y-axis'); ax4.set_zlabel('Z-axis')
|
||||||
set_axes_equal_3d(ax4)
|
set_axes_equal_3d(ax4)
|
||||||
|
|
||||||
|
|
@ -225,12 +355,12 @@ def res_plt_2_torch(
|
||||||
overlap_b_l = ((spine_tensor == 1) & (cyl_l == 1)).sum().item()
|
overlap_b_l = ((spine_tensor == 1) & (cyl_l == 1)).sum().item()
|
||||||
overlap_b_r = ((spine_tensor == 1) & (cyl_r == 1)).sum().item()
|
overlap_b_r = ((spine_tensor == 1) & (cyl_r == 1)).sum().item()
|
||||||
|
|
||||||
overlap_cortical_l = (overlap_l / cyl_points_l) * 100
|
overlap_cortical_l = (overlap_l / cyl_points_l) * 100 if cyl_points_l else 0.0
|
||||||
overlap_cortical_r = (overlap_r / cyl_points_r) * 100
|
overlap_cortical_r = (overlap_r / cyl_points_r) * 100 if cyl_points_r else 0.0
|
||||||
overlap_vertebral_l = (overlap_b_l / cyl_points_l) * 100
|
overlap_vertebral_l = (overlap_b_l / cyl_points_l) * 100 if cyl_points_l else 0.0
|
||||||
overlap_vertebral_r = (overlap_b_r / cyl_points_r) * 100
|
overlap_vertebral_r = (overlap_b_r / cyl_points_r) * 100 if cyl_points_r else 0.0
|
||||||
cb_ratio_l = overlap_cortical_l/overlap_vertebral_l
|
cb_ratio_l = overlap_cortical_l/overlap_vertebral_l if overlap_vertebral_l else 0.0
|
||||||
cb_ratio_r = overlap_cortical_r/overlap_vertebral_r
|
cb_ratio_r = overlap_cortical_r/overlap_vertebral_r if overlap_vertebral_r else 0.0
|
||||||
user_altitude_l = 90 - best_position_l[4] - alt
|
user_altitude_l = 90 - best_position_l[4] - alt
|
||||||
user_altitude_r = 90 - best_position_r[4] - alt
|
user_altitude_r = 90 - best_position_r[4] - alt
|
||||||
user_azimuth_l = 90 - best_position_l[3] - azi
|
user_azimuth_l = 90 - best_position_l[3] - azi
|
||||||
|
|
|
||||||
281
xfr_debug.py
281
xfr_debug.py
|
|
@ -1,4 +1,10 @@
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import queue as queue_module
|
||||||
|
import subprocess
|
||||||
|
import multiprocessing as mp
|
||||||
|
|
||||||
import SimpleITK as sitk
|
import SimpleITK as sitk
|
||||||
import torch
|
import torch
|
||||||
|
|
@ -15,6 +21,108 @@ azimuth_rotation_dir = '/mnt/1248/open2/cyrou/azimuth_rotation'
|
||||||
tilt_contour_dir = '/mnt/1248/open2/cyrou/tilt_contour'
|
tilt_contour_dir = '/mnt/1248/open2/cyrou/tilt_contour'
|
||||||
Output_dir = '/mnt/1248/open/cyrou/Output'
|
Output_dir = '/mnt/1248/open/cyrou/Output'
|
||||||
|
|
||||||
|
LEVELS = ('L1', 'L2', 'L3', 'L4', 'L5')
|
||||||
|
|
||||||
|
LOG_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'logs')
|
||||||
|
|
||||||
|
|
||||||
|
# 目前任務標記(每個 worker 流程各自一份),讓交錯的 log 可以歸屬到 (volume, level, side)
|
||||||
|
_TASK_TAG = {'vid': None, 'level': None, 'side': None}
|
||||||
|
|
||||||
|
_NP_WRAP_RE = re.compile(r'\bnp\.[A-Za-z_][A-Za-z0-9_]*\(([^()]*)\)')
|
||||||
|
|
||||||
|
|
||||||
|
def set_task_tag(volume_id, level):
|
||||||
|
_TASK_TAG['vid'] = volume_id
|
||||||
|
_TASK_TAG['level'] = level
|
||||||
|
_TASK_TAG['side'] = None
|
||||||
|
|
||||||
|
|
||||||
|
class _Tee:
|
||||||
|
"""同時輸出到 console 與 log 檔,逐行加上如 [0001 L1 LEFT] 的標記。
|
||||||
|
volume 取 id 末段(1.3.6.1.4.1.9328.50.4.0001 -> 0001);
|
||||||
|
side 由 [LEFT]/[RIGHT]/左側/右側 段落標記判定並沿用給後續行;
|
||||||
|
最終結果段屬於整椎,重置沿用值,其中的 Left/Right 摘要行只標該行本身;
|
||||||
|
np.float64(...) 之類包裹會解包成裸數值。"""
|
||||||
|
|
||||||
|
def __init__(self, console, log_fh):
|
||||||
|
self.console = console
|
||||||
|
self.log_fh = log_fh
|
||||||
|
self.buf = ''
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _marker_side(line):
|
||||||
|
"""回傳 (side, persist):[LEFT]/[RIGHT]/左側/右側 是段落標記(persist=True),
|
||||||
|
Left/Right 摘要行只標該行(persist=False)"""
|
||||||
|
s = line.lstrip()
|
||||||
|
if s.startswith('[LEFT]') or '左側' in s:
|
||||||
|
return 'LEFT', True
|
||||||
|
if s.startswith('[RIGHT]') or '右側' in s:
|
||||||
|
return 'RIGHT', True
|
||||||
|
if s.startswith('Left '):
|
||||||
|
return 'LEFT', False
|
||||||
|
if s.startswith('Right '):
|
||||||
|
return 'RIGHT', False
|
||||||
|
return None, False
|
||||||
|
|
||||||
|
def _prefix(self, side):
|
||||||
|
if not _TASK_TAG['vid'] or not _TASK_TAG['level']:
|
||||||
|
return ''
|
||||||
|
vol = _TASK_TAG['vid'].rsplit('.', 1)[-1]
|
||||||
|
tag = f'{vol} {_TASK_TAG["level"]}'
|
||||||
|
if side:
|
||||||
|
tag += f' {side}'
|
||||||
|
return f'[{tag}] '
|
||||||
|
|
||||||
|
def _emit(self, line):
|
||||||
|
line = _NP_WRAP_RE.sub(r'\1', line)
|
||||||
|
if '最終結果' in line:
|
||||||
|
_TASK_TAG['side'] = None
|
||||||
|
side, persist = self._marker_side(line)
|
||||||
|
if persist:
|
||||||
|
_TASK_TAG['side'] = side
|
||||||
|
eff_side = side if side is not None else _TASK_TAG['side']
|
||||||
|
prefix = self._prefix(eff_side) if line.strip() else ''
|
||||||
|
if prefix:
|
||||||
|
# 前綴已含 side 時,去掉行首重複的 [LEFT] / [RIGHT] 標記
|
||||||
|
marker = f'[{eff_side}] '
|
||||||
|
if line.startswith(marker):
|
||||||
|
line = line[len(marker):]
|
||||||
|
self.console.write(prefix + line + '\n')
|
||||||
|
self.log_fh.write(prefix + line + '\n')
|
||||||
|
|
||||||
|
def write(self, data):
|
||||||
|
if not data:
|
||||||
|
return
|
||||||
|
self.buf += data
|
||||||
|
while True:
|
||||||
|
idx_n = self.buf.find('\n')
|
||||||
|
idx_r = self.buf.find('\r')
|
||||||
|
candidates = [i for i in (idx_n, idx_r) if i != -1]
|
||||||
|
if not candidates:
|
||||||
|
break
|
||||||
|
idx = min(candidates)
|
||||||
|
self._emit(self.buf[:idx])
|
||||||
|
self.buf = self.buf[idx + 1:]
|
||||||
|
|
||||||
|
def flush(self):
|
||||||
|
if self.buf:
|
||||||
|
self._emit(self.buf)
|
||||||
|
self.buf = ''
|
||||||
|
self.console.flush()
|
||||||
|
self.log_fh.flush()
|
||||||
|
|
||||||
|
def isatty(self):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def setup_tee(log_path):
|
||||||
|
"""把這個流程的 stdout/stderr 同時寫到 log_path(append、line-buffered)"""
|
||||||
|
log_fh = open(log_path, 'a', buffering=1)
|
||||||
|
sys.stdout = _Tee(sys.stdout, log_fh)
|
||||||
|
sys.stderr = _Tee(sys.stderr, log_fh)
|
||||||
|
|
||||||
|
|
||||||
def get_device(gpu_id=None):
|
def get_device(gpu_id=None):
|
||||||
if torch.cuda.is_available():
|
if torch.cuda.is_available():
|
||||||
if gpu_id is None:
|
if gpu_id is None:
|
||||||
|
|
@ -22,7 +130,7 @@ def get_device(gpu_id=None):
|
||||||
gpu_id = 0
|
gpu_id = 0
|
||||||
for i in range(torch.cuda.device_count()):
|
for i in range(torch.cuda.device_count()):
|
||||||
free_mem, _ = torch.cuda.mem_get_info(i)
|
free_mem, _ = torch.cuda.mem_get_info(i)
|
||||||
# print(f'GPU {i}: {torch.cuda.get_device_name(i)} {free_mem}')
|
# print(f'GPU {gpu_id}: {torch.cuda.get_device_name(gpu_id)} {free_mem}')
|
||||||
if free_mem > max_free:
|
if free_mem > max_free:
|
||||||
max_free = free_mem
|
max_free = free_mem
|
||||||
gpu_id = i
|
gpu_id = i
|
||||||
|
|
@ -52,13 +160,14 @@ def debug_orientation(volume_id, level):
|
||||||
# print(f'Azimuth: {azi}, Alt: {alt}')
|
# print(f'Azimuth: {azi}, Alt: {alt}')
|
||||||
print(f'Alt: {alt}')
|
print(f'Alt: {alt}')
|
||||||
|
|
||||||
def debug_pso(volume_id, level):
|
def debug_pso(volume_id, level, device=None):
|
||||||
# ====== PSO ======
|
# ====== PSO ======
|
||||||
swarm_size = 100
|
swarm_size = 100
|
||||||
max_iter = 100
|
max_iter = 100
|
||||||
|
|
||||||
# ====== DEVICE ======
|
# ====== DEVICE ======
|
||||||
device = get_device()
|
if device is None:
|
||||||
|
device = get_device()
|
||||||
|
|
||||||
# ====== OTHER ======
|
# ====== OTHER ======
|
||||||
spacing = [0.5, 0.5, 0.5]
|
spacing = [0.5, 0.5, 0.5]
|
||||||
|
|
@ -79,6 +188,12 @@ def debug_pso(volume_id, level):
|
||||||
roi_array = sitk.GetArrayFromImage(roi_image)
|
roi_array = sitk.GetArrayFromImage(roi_image)
|
||||||
image_shape = binary_array.shape
|
image_shape = binary_array.shape
|
||||||
|
|
||||||
|
# 資料層級的快速失敗(例:…9328.50.4.0653 L5 是 1-voxel 寬的退化體積)
|
||||||
|
if binary_array.sum() == 0:
|
||||||
|
raise ValueError(f'{volume_id} {level}: empty bone mask')
|
||||||
|
if min(image_shape) < 8:
|
||||||
|
raise ValueError(f'{volume_id} {level}: degenerate volume shape {image_shape}')
|
||||||
|
|
||||||
cortical_tensor = torch.tensor(cortical_array, device=device)
|
cortical_tensor = torch.tensor(cortical_array, device=device)
|
||||||
binary_tensor = torch.tensor(binary_array, device=device)
|
binary_tensor = torch.tensor(binary_array, device=device)
|
||||||
|
|
||||||
|
|
@ -112,6 +227,57 @@ def debug_pso(volume_id, level):
|
||||||
# exit()
|
# exit()
|
||||||
|
|
||||||
|
|
||||||
|
def list_gpu_ids():
|
||||||
|
"""透過 nvidia-smi 取得 GPU ID(主流程不初始化 CUDA,避免污染 fork/spawn 子流程)"""
|
||||||
|
try:
|
||||||
|
out = subprocess.check_output(
|
||||||
|
['nvidia-smi', '--query-gpu=index', '--format=csv,noheader'],
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
return [int(line.strip()) for line in out.splitlines() if line.strip()]
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def gpu_worker(gpu_id, log_path, task_queue, result_queue):
|
||||||
|
"""每張 GPU 一個工作流程:先鎖死該 GPU,再從共享隊列領 (volume, level) 任務"""
|
||||||
|
# worker 是獨立流程,要自己把輸出 tee 到 log 檔
|
||||||
|
setup_tee(log_path)
|
||||||
|
# 必須在任何 torch.cuda 呼叫前設定
|
||||||
|
os.environ['CUDA_VISIBLE_DEVICES'] = str(gpu_id)
|
||||||
|
torch.cuda.set_device(0)
|
||||||
|
device = torch.device('cuda:0')
|
||||||
|
print(f'=== [GPU {gpu_id}] worker started ===', flush=True)
|
||||||
|
|
||||||
|
while True:
|
||||||
|
# 注意:multiprocessing.Queue 沒有 task_done()(只有 queue.Queue 有),別加回來
|
||||||
|
item = task_queue.get()
|
||||||
|
if item is None:
|
||||||
|
break
|
||||||
|
volume_id, level = item
|
||||||
|
set_task_tag(volume_id, level)
|
||||||
|
try:
|
||||||
|
debug_pso(volume_id, level, device)
|
||||||
|
result_queue.put(('task', gpu_id, volume_id, level, True, ''))
|
||||||
|
except Exception as e:
|
||||||
|
print(f'[GPU {gpu_id}] Error in {volume_id} {level}: {e}', flush=True)
|
||||||
|
result_queue.put(('task', gpu_id, volume_id, level, False, str(e)))
|
||||||
|
|
||||||
|
result_queue.put(('done', gpu_id))
|
||||||
|
print(f'=== [GPU {gpu_id}] worker finished ===', flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _run_sequential(tasks):
|
||||||
|
"""沒有(或只有一張)GPU 時的回退:單流程串行"""
|
||||||
|
device = get_device()
|
||||||
|
for volume_id, level in tasks:
|
||||||
|
set_task_tag(volume_id, level)
|
||||||
|
try:
|
||||||
|
debug_pso(volume_id, level, device)
|
||||||
|
except Exception as e:
|
||||||
|
print(f'Error in {volume_id} {level}: {e}')
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
# level = 'L1'
|
# level = 'L1'
|
||||||
|
|
||||||
|
|
@ -121,38 +287,97 @@ def main():
|
||||||
# process_volume(volume_id, level)
|
# process_volume(volume_id, level)
|
||||||
# exit()
|
# exit()
|
||||||
|
|
||||||
|
# 要處理的 volume 數(並行模式下是「最多嘗試的 volume 數」)
|
||||||
MAX_SUCCESSFUL_VOLUMES = 100
|
MAX_SUCCESSFUL_VOLUMES = 100
|
||||||
number_of_successful_volumes = 0
|
MAX_SUCCESSFUL_VOLUMES = 1
|
||||||
|
|
||||||
# for volume_id in (
|
# log 檔(console 與檔案同時輸出;各 GPU worker 也會 append 進同一個檔)
|
||||||
# '1.3.6.1.4.1.9328.50.4.0001',
|
os.makedirs(LOG_DIR, exist_ok=True)
|
||||||
# '1.3.6.1.4.1.9328.50.4.0002',
|
log_path = os.path.join(LOG_DIR, f'xfr_debug_{time.strftime("%Y%m%d_%H%M%S")}.log')
|
||||||
# '1.3.6.1.4.1.9328.50.4.0003',
|
setup_tee(log_path)
|
||||||
# '1.3.6.1.4.1.9328.50.4.0004',
|
print(f'Log file: {log_path}', flush=True)
|
||||||
# '1.3.6.1.4.1.9328.50.4.0005',
|
print(f'Command: {sys.executable} {" ".join(sys.argv)}', flush=True)
|
||||||
# # '1.3.6.1.4.1.9328.50.4.0006',
|
print(f'Working directory: {os.getcwd()}', flush=True)
|
||||||
# ):
|
|
||||||
|
|
||||||
for volume_id in sorted(os.listdir(standardized_dir)):
|
volumes = [d for d in sorted(os.listdir(standardized_dir))
|
||||||
# debug_orientation(volume_id, level)
|
if os.path.isdir(os.path.join(standardized_dir, d))]
|
||||||
error_flag = False
|
volumes = volumes[:MAX_SUCCESSFUL_VOLUMES]
|
||||||
for level in ('L1', 'L2', 'L3', 'L4', 'L5'):
|
|
||||||
# for level in ('L5',):
|
|
||||||
# debug_orientation(volume_id, level)
|
|
||||||
try:
|
|
||||||
debug_pso(volume_id, level)
|
|
||||||
except Exception as e:
|
|
||||||
print(f'Error in {volume_id} {level}: {e}')
|
|
||||||
error_flag = True
|
|
||||||
continue
|
|
||||||
if not error_flag:
|
|
||||||
number_of_successful_volumes += 1
|
|
||||||
|
|
||||||
if number_of_successful_volumes >= MAX_SUCCESSFUL_VOLUMES:
|
tasks = [(vid, level) for vid in volumes for level in LEVELS]
|
||||||
|
print(f'Total {len(volumes)} volumes / {len(tasks)} (volume, level) tasks', flush=True)
|
||||||
|
|
||||||
|
gpu_ids = list_gpu_ids()
|
||||||
|
|
||||||
|
if len(gpu_ids) <= 1:
|
||||||
|
print(f'Only {len(gpu_ids)} GPU(s) available, running sequentially', flush=True)
|
||||||
|
_run_sequential(tasks)
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f'Found {len(gpu_ids)} GPUs: {gpu_ids}, starting {len(gpu_ids)} workers (one per GPU)', flush=True)
|
||||||
|
|
||||||
|
ctx = mp.get_context('spawn')
|
||||||
|
task_queue = ctx.Queue()
|
||||||
|
result_queue = ctx.Queue()
|
||||||
|
for t in tasks:
|
||||||
|
task_queue.put(t)
|
||||||
|
for _ in gpu_ids:
|
||||||
|
task_queue.put(None) # 每個 worker 一個結束哨兵
|
||||||
|
|
||||||
|
procs = [ctx.Process(target=gpu_worker, args=(g, log_path, task_queue, result_queue), name=f'cbt-gpu-{g}')
|
||||||
|
for g in gpu_ids]
|
||||||
|
for p in procs:
|
||||||
|
p.start()
|
||||||
|
|
||||||
|
start_time = time.time()
|
||||||
|
results = []
|
||||||
|
finished = 0
|
||||||
|
while finished < len(gpu_ids):
|
||||||
|
if not any(p.is_alive() for p in procs):
|
||||||
|
print('Warning: a worker exited early; reaping remaining tasks...', flush=True)
|
||||||
break
|
break
|
||||||
|
try:
|
||||||
|
msg = result_queue.get(timeout=5)
|
||||||
|
except queue_module.Empty:
|
||||||
|
continue
|
||||||
|
if msg[0] == 'done':
|
||||||
|
finished += 1
|
||||||
|
else:
|
||||||
|
results.append(msg)
|
||||||
|
|
||||||
# exit()
|
# 抽乾剩下排進來的結果
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
msg = result_queue.get(timeout=1)
|
||||||
|
except queue_module.Empty:
|
||||||
|
break
|
||||||
|
if msg[0] == 'task':
|
||||||
|
results.append(msg)
|
||||||
|
|
||||||
|
for p in procs:
|
||||||
|
p.join(timeout=60)
|
||||||
|
|
||||||
|
total_time = time.time() - start_time
|
||||||
|
ok = [r for r in results if r[4]]
|
||||||
|
fail = [r for r in results if not r[4]]
|
||||||
|
per_volume = {}
|
||||||
|
for _, _, vid, level, success, _ in results:
|
||||||
|
per_volume.setdefault(vid, set()).add(success)
|
||||||
|
missing = len(tasks) - len(results)
|
||||||
|
|
||||||
|
# 一個 volume 算「成功」必須它的(level)全部執行過且全部成功
|
||||||
|
n_success_volumes = sum(1 for vid in volumes
|
||||||
|
if per_volume.get(vid, set()) == {True})
|
||||||
|
|
||||||
|
print('=' * 60)
|
||||||
|
print(f'Finished in {total_time / 60:.1f} min | '
|
||||||
|
f'tasks {len(results)}/{len(tasks)} (ok {len(ok)} / failed {len(fail)} / not-run {missing})')
|
||||||
|
if missing:
|
||||||
|
print(f'Warning: {missing} task(s) were never executed (worker crash?)')
|
||||||
|
print(f'Successful volumes (all levels OK): {n_success_volumes}/{len(volumes)}')
|
||||||
|
if fail:
|
||||||
|
print('Failed tasks:')
|
||||||
|
for _, g, vid, level, _, err in fail:
|
||||||
|
print(f' [GPU {g}] {vid} {level}: {err}')
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue