CBT_project/core/objective.py
Xiao Furen 4f5be3d3e9 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.
2026-08-30 00:54:55 +08:00

302 lines
No EOL
9.4 KiB
Python

from dataclasses import dataclass
from typing import Optional
import random
from scipy.ndimage import map_coordinates
import numpy as np
import torch
from core.cylinder import generate_cylinder_n_torch, generate_cylinder_o_torch, snap_to_discrete_values, generate_cylinder_tip_torch, snap_to_discrete_values_xfr
from core.intersection import center_line_intersections_torch
from core.scoring import cl_score_torch, cl_score_torch_xfr
# =====================================================================
# OptimizationContext: the single explicit container for all shared state
# needed to evaluate a candidate cylinder. New code should build one of
# these and bind it to an objective via make_objective_function[_xfr];
# no module globals are read during optimization.
# =====================================================================
@dataclass
class OptimizationContext:
"""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
def cylinder_circle_line_intersection_loss_deductions_torch(
ctx: OptimizationContext,
diameter: float,
length: float,
params: list[float],
) -> float:
"""
Computes the loss for a given set of cylinder params in PyTorch,
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
cyl_fwd = generate_cylinder_n_torch(
diameter,
length,
position_z,
position_y,
position_x,
float(azimuth),
float(altitude),
ctx.image2_shape,
ctx.spacing,
ctx.device,
ctx.grid
)
cyl_opp = generate_cylinder_o_torch(
diameter,
length,
position_z,
position_y,
position_x,
float(azimuth),
float(altitude),
ctx.image2_shape,
ctx.spacing,
ctx.device,
ctx.grid
)
# We call the center_line_intersections in Torch mode
intersections, _ = center_line_intersections_torch(
position_z,
position_y,
position_x,
azimuth,
altitude,
length,
ctx.spine_tensor,
ctx.spacing,
ctx.device
)
cyl_tip = None
if ctx.use_tip_penalty:
cyl_tip = generate_cylinder_tip_torch(
diameter, length,
position_z, position_y, position_x,
float(azimuth), float(altitude),
ctx.image2_shape, ctx.spacing, ctx.device, ctx.grid
)
# loss_value = cl_score_torch(
loss_value = cl_score_torch_xfr(
ctx.cortical_tensor, ctx.spine_tensor,
cyl_fwd, cyl_opp, intersections,
cylinder_tip_torch=cyl_tip
)
return loss_value
# =====================================================================
# Context-bound objective builders (preferred API)
# =====================================================================
def _evaluate(params: list[float], ctx: OptimizationContext) -> float:
"""
Core objective: params = [z, y, x, azimuth, altitude, diameter_raw, length_raw]
"""
position_params = params[:5] # [z, y, x, azimuth, altitude]
diameter_raw = params[5]
length_raw = params[6]
# 將連續值轉換為離散值
diameter_discrete, length_discrete = snap_to_discrete_values(diameter_raw, length_raw)
return cylinder_circle_line_intersection_loss_deductions_torch(
ctx,
diameter_discrete,
length_discrete,
position_params
)
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)