Generated from the full canonical file for this source snapshot. Line numbers match the library source.
Source SHA256: e6d9f55c17b2abbb3e9a6c091e692c9d4b8c34100355128807fd6c901193773e
1"""Fixed-geometry primary-count reconstruction; supplied inputs only.23Uses the canonical Warp projector and first-order volume VJP. Torch supplies4CUDA vector arithmetic with shared storage on the same stream. The examples5README links the recorded studies and their separate numerical acceptance.6"""78from __future__ import annotations910import importlib11import math12from collections.abc import Callable13from dataclasses import dataclass14from typing import Any1516from dpt.contracts import ContractError, NumericalError, finite_scalar, integer17from dpt.examples._common import example_parser, load_case, write_array18from dpt.geometry import DetectorGeometry, RigidTransform19from dpt.objectives import ObjectiveSpec, evaluate_objective, prepare_objective20from dpt.projection import ProjectionSpec, prepare_projection, project_optical_depth, projection_vjp21from dpt.transmission import TransmissionSpec, prepare_transmission, transmission_vjp, transmit22from dpt.volumes import GridSpec232425@dataclass(frozen=True, slots=True)26class SolverSettings:27iterations: int28initial_step_mm_inverse_squared: float29backtracking_factor: float30armijo: float31maximum_backtracks: int32gradient_mapping_tolerance_mm: float33regularisation_mm: float34mapping_step_mm_inverse_squared: float | None = None3536@classmethod37def read(cls, settings: dict[str, Any]) -> SolverSettings:38result = cls(39iterations=integer(settings["iterations"], "iterations", minimum=1),40initial_step_mm_inverse_squared=finite_scalar(41settings["initial_step_mm_inverse_squared"], "initial step", minimum=042),43backtracking_factor=finite_scalar(settings["backtracking_factor"], "backtracking"),44armijo=finite_scalar(settings["armijo"], "Armijo coefficient"),45maximum_backtracks=integer(46settings["maximum_backtracks"], "maximum_backtracks", minimum=147),48gradient_mapping_tolerance_mm=finite_scalar(49settings["gradient_mapping_tolerance_mm"], "gradient tolerance", minimum=050),51regularisation_mm=finite_scalar(52settings["regularisation_mm"], "regularisation coefficient", minimum=053),54mapping_step_mm_inverse_squared=(55finite_scalar(56settings["mapping_step_mm_inverse_squared"], "mapping step", minimum=057)58if settings.get("mapping_step_mm_inverse_squared") is not None59else None60),61)62if result.initial_step_mm_inverse_squared <= 0:63raise ContractError("Set initial_step_mm_inverse_squared to a positive value.")64if (65result.mapping_step_mm_inverse_squared is not None66and result.mapping_step_mm_inverse_squared <= 067):68raise ContractError("Set mapping_step_mm_inverse_squared to a positive value.")69if not 0 < result.backtracking_factor < 1 or not 0 < result.armijo < 1:70raise ContractError("Set backtracking_factor and armijo strictly between zero and one.")71return result727374class Reconstruction:75"""Serial full-data evaluations with persistent CUDA buffers and checked VJPs."""7677def __init__(self, case: Any, *, device: str, torch: Any, wp: Any) -> None:78self.torch, self.wp, self.device = torch, wp, device79settings = case.config["reconstruction"]80self.policy = SolverSettings.read(settings["solver"])81self.grid = GridSpec(**{key: tuple(value) for key, value in settings["grid"].items()})82pose = RigidTransform(83**{key: tuple(value) for key, value in settings["fixed_pose"].items()}84)85samples = integer(settings["samples_per_ray"], "samples_per_ray", minimum=1)86initial = case.array(settings["initial_volume"], shape=self.grid.shape, units="mm^-1")87if (initial < 0).any():88raise ContractError(89"initial_volume contains negative attenuation; supply values in mm^-1."90)91if not settings["views"]:92raise ContractError("Supply at least one calibrated view in reconstruction.views.")93self.torch_stream = torch.cuda.current_stream(device=device)94wp.init()95self.stream = wp.stream_from_torch(self.torch_stream)96self.mu = torch.as_tensor(initial, device=device).flatten().clone()97self.trial = torch.empty_like(self.mu)98self.gradient = torch.empty_like(self.mu)99self.delta = torch.empty_like(self.mu)100self.edge32 = torch.empty_like(self.mu)101self.volume64 = torch.empty(self.grid.voxels, dtype=torch.float64, device=device)102self.edge64 = torch.empty_like(self.volume64)103self.scalar = torch.empty((), dtype=torch.float64, device=device)104self.pose = wp.array(pose.packed(), dtype=wp.float64, device=device)105self.mu_wp = wp.from_torch(self.mu)106self.trial_wp = wp.from_torch(self.trial)107self.gradient_wp = wp.from_torch(self.gradient)108self.views: list[dict[str, Any]] = []109for entry in settings["views"]:110geometry = DetectorGeometry(111**{key: tuple(value) for key, value in entry["geometry"].items()}112)113observed = case.array(entry["counts"], shape=geometry.shape, units="counts")114beam = case.array(entry["open_beam"], shape=geometry.shape, units="counts")115if (observed < 0).any() or (observed % 1 != 0).any() or (observed > 2**24).any():116raise ContractError(117"counts must be nonnegative integers no greater than 2^24; "118"supply unprocessed counts exactly representable in float32."119)120if (beam <= 0).any():121raise ContractError("open_beam must contain positive calibrated mean counts.")122projection = prepare_projection(123self.grid,124geometry,125ProjectionSpec(samples, active_pose=False, active_volume=True),126device=device,127stream=self.stream,128)129view: dict[str, Any] = {130"projection": projection,131"transmission": prepare_transmission(132TransmissionSpec(beam="per-pixel"),133max_pixels=geometry.pixels,134device=device,135stream=self.stream,136),137"objective": prepare_objective(138ObjectiveSpec(kind="poisson", domain="counts", reduction="sum"),139max_pixels=geometry.pixels,140device=device,141stream=self.stream,142),143"observed": wp.array(observed.reshape(-1), dtype=wp.float32, device=device),144"beam": wp.array(beam.reshape(-1), dtype=wp.float32, device=device),145"shape": geometry.shape,146}147for name in ("depth", "prediction", "image_seed", "depth_seed"):148view[name] = wp.empty(geometry.pixels, dtype=wp.float32, device=device)149view["loss"] = wp.empty(1, dtype=wp.float64, device=device)150self.views.append(view)151# All uploads above are complete before either framework consumes them.152# Subsequent Torch/Warp work shares exactly one stream.153wp.synchronize_device(device)154self.edges: list[tuple[Any, Any, Any, Any, Any, float]] = []155shaped64 = self.volume64.view(self.grid.shape)156shaped_gradient = self.gradient.view(self.grid.shape)157cell_volume = math.prod(self.grid.spacing_mm)158for axis, spacing in enumerate(reversed(self.grid.spacing_mm)):159if self.grid.shape[axis] == 1:160continue161low = [slice(None)] * 3162high = [slice(None)] * 3163low[axis], high[axis] = slice(None, -1), slice(1, None)164lower, upper = tuple(low), tuple(high)165shape = shaped64[lower].shape166count = shaped64[lower].numel()167coefficient = self.policy.regularisation_mm * cell_volume / spacing**2168if not math.isfinite(coefficient):169raise ContractError(170"Grid spacing and regularisation overflow; rescale their units."171)172self.edges.append(173(174shaped64[lower],175shaped64[upper],176shaped_gradient[lower],177shaped_gradient[upper],178(self.edge64[:count].view(shape), self.edge32[:count].view(shape)),179coefficient,180)181)182183# region book:example-reconstruction-volume-gradient184def evaluate(self, volume: Any, volume_wp: Any, *, gradient: bool) -> float:185"""Evaluate every supplied view before an update; add the prior once."""186if gradient:187self.gradient.zero_()188loss = 0.0189for view in self.views:190project_optical_depth(191volume_wp,192self.pose,193workspace=view["projection"],194out_L=view["depth"],195stream=self.stream,196)197transmit(198view["depth"],199view["beam"],200workspace=view["transmission"],201out_counts=view["prediction"],202stream=self.stream,203)204evaluate_objective(205view["prediction"],206view["observed"],207workspace=view["objective"],208out_loss=view["loss"],209out_seed=view["image_seed"] if gradient else None,210stream=self.stream,211)212if gradient:213transmission_vjp(214view["depth"],215view["beam"],216seed_counts=view["image_seed"],217workspace=view["transmission"],218out_grad_L=view["depth_seed"],219stream=self.stream,220)221projection_vjp(222volume_wp,223self.pose,224adj_L=view["depth_seed"],225workspace=view["projection"],226out_mu=self.gradient_wp,227accumulate=True,228stream=self.stream,229)230loss += float(view["loss"].numpy()[0])231loss += self.regularise(volume, gradient=gradient)232if not math.isfinite(loss):233raise NumericalError("The objective is non-finite; inspect counts and model range.")234if gradient and not bool(self.torch.isfinite(self.gradient).all().item()):235raise NumericalError("The volume gradient overflowed; discard this evaluation.")236return loss237238# endregion book:example-reconstruction-volume-gradient239240# region book:example-reconstruction-regularisation241def regularise(self, volume: Any, *, gradient: bool) -> float:242"""Quadratic physical-space differences; omit edges beyond the grid."""243self.volume64.copy_(volume)244penalty = 0.0245for low, high, grad_low, grad_high, scratch, coefficient in self.edges:246difference, derivative = scratch247self.torch.sub(high, low, out=difference)248if gradient:249# Scale in FP64 before the FP32 accumulation boundary.250difference.mul_(coefficient)251derivative.copy_(difference)252grad_low.sub_(derivative)253grad_high.add_(derivative)254# Restore unscaled differences for the objective.255self.torch.sub(high, low, out=difference)256difference.square_()257self.torch.sum(difference, dim=(0, 1, 2), out=self.scalar)258penalty += 0.5 * coefficient * float(self.scalar.item())259return penalty260261# endregion book:example-reconstruction-regularisation262263def inner_product(self, left: Any, right: Any) -> float:264self.volume64.copy_(left)265self.edge64.copy_(right)266self.volume64.mul_(self.edge64)267self.torch.sum(self.volume64, dim=0, out=self.scalar)268return float(self.scalar.item())269270def displacement(self, step: float) -> tuple[float, float]:271# min(g, mu / step) evaluates the projected-gradient mapping272# without cancellation between almost identical accepted/trial voxels.273self.volume64.copy_(self.gradient)274self.edge64.copy_(self.mu).div_(step)275self.torch.minimum(self.volume64, self.edge64, out=self.volume64)276self.torch.abs(self.volume64, out=self.edge64)277mapping = float(self.edge64.max().item())278self.volume64.mul_(-step).add_(self.mu).clamp_(min=0)279self.trial.copy_(self.volume64)280self.torch.sub(self.trial, self.mu, out=self.delta)281slope = self.inner_product(self.gradient, self.delta)282return mapping, slope283284# region book:example-reconstruction-projected-armijo285def solve(286self, callback: Callable[[int, dict[str, Any]], None] | None = None287) -> dict[str, Any]:288"""Solve with optional observation of accepted updates only.289290The callback receives the stable one-based accepted iteration and a291detached history dictionary after ``mu`` has been updated. It may read292or export the accepted fields but must not mutate solver state. Callback293exceptions propagate with the last accepted volume intact.294295An optional fixed mapping step separates the stationarity diagnostic296from the initial Armijo trial. Omitting it preserves the original297diagnostic at ``initial_step_mm_inverse_squared``.298"""299policy = self.policy300mapping_step = (301policy.initial_step_mm_inverse_squared302if policy.mapping_step_mm_inverse_squared is None303else policy.mapping_step_mm_inverse_squared304)305history: list[dict[str, Any]] = []306reason = "iteration_budget"307for iteration in range(policy.iterations):308loss = self.evaluate(self.mu, self.mu_wp, gradient=True)309step = policy.initial_step_mm_inverse_squared310mapping, _ = self.displacement(mapping_step)311if not math.isfinite(mapping):312raise NumericalError("The trial update overflowed; reduce the initial step.")313if mapping <= policy.gradient_mapping_tolerance_mm:314reason = "projected_gradient_tolerance"315break316accepted = False317for attempt in range(policy.maximum_backtracks):318if step == 0:319break320_, slope = self.displacement(step)321if not math.isfinite(slope) or slope >= 0:322step *= policy.backtracking_factor323continue324# Trial value evaluation leaves the accepted gradient unchanged.325# Domain/range errors abort with the last accepted volume intact.326trial_loss = self.evaluate(self.trial, self.trial_wp, gradient=False)327if trial_loss < loss and trial_loss <= loss + policy.armijo * slope:328self.mu.copy_(self.trial)329history.append(330{331"iteration": iteration + 1,332"loss_before": loss,333"loss_after": trial_loss,334"step_mm_inverse_squared": step,335"backtracks": attempt,336"mapping_mm_before": mapping,337}338)339accepted = True340if callback is not None:341callback(iteration + 1, dict(history[-1]))342break343step *= policy.backtracking_factor344if not accepted:345reason = "line_search_failed"346break347# Trial buffers may describe a rejected volume; refresh accepted outputs.348final_loss = self.evaluate(self.mu, self.mu_wp, gradient=True)349final_mapping, _ = self.displacement(mapping_step)350if final_mapping <= policy.gradient_mapping_tolerance_mm:351reason = "projected_gradient_tolerance"352return {353"termination": reason,354"accepted_steps": len(history),355"final_objective": final_loss,356"final_gradient_mapping_mm": final_mapping,357"history": history,358}359360# endregion book:example-reconstruction-projected-armijo361362363def main() -> None:364args = example_parser(365"Reconstruct attenuation from supplied calibrated primary counts."366).parse_args()367case = load_case(args.case)368if not str(args.device).startswith("cuda:"):369raise ContractError("Select a CUDA device, for example --device cuda:0.")370torch: Any = importlib.import_module("torch")371wp: Any = importlib.import_module("warp")372with case.record(373args.output,374entrypoint=__file__,375metadata={376"application": "reconstruction",377"execution_acceptance": "requires separate numerical and physical assessment",378},379) as run:380# Never build a Torch autograd graph around the manually supplied VJP.381with torch.cuda.device(args.device), torch.no_grad():382try:383solver = Reconstruction(case, device=args.device, torch=torch, wp=wp)384run.set_metadata(385device=args.device,386gpu_name=torch.cuda.get_device_name(args.device),387torch_version=torch.__version__,388volume_units="mm^-1",389objective="summed Poisson half-deviance plus quadratic regularisation",390)391report = solver.solve()392wp.synchronize_stream(solver.stream)393write_array(394run, "attenuation.npy", solver.mu.cpu().numpy().reshape(solver.grid.shape)395)396for index, view in enumerate(solver.views):397write_array(398run,399f"predictions/view-{index:04d}.npy",400view["prediction"].numpy().reshape(view["shape"]),401)402run.write_json("solver.json", report)403finally:404# Drain pending work before exception paths release shared owners.405torch.cuda.synchronize(args.device)406407408if __name__ == "__main__":409main()410