Source code for diffct.surfaces

"""Explicit detector samplers and bounded, global-pixel geometry preparation."""

from dataclasses import dataclass

import torch

from .utils import _raise_geometry_errors, _work_dtype

@dataclass(frozen=True)
class _GeometrySpec:
    kind: str
    sampler: object = None
    dtype: object = None
    # One (U, V, 3) host offset snapshot shared by all views.
    shared: bool = False


[docs] class ParameterizedSurface: """Sample local detector offsets from global CPU pixel/view coordinates. ``sampler(u, v, view_indices, *parameters)`` returns shared or per-view ``(..., 3)`` offsets on CPU or CUDA. Parameters are explicit finite floating tensors; other geometry configuration must be immutable. Projector snapshots the sampler reference and parameter values per invocation. Snapshot storage scales with parameter sizes, and the sampler's own allocations remain caller-controlled. """ def __init__(self, sampler, parameters=()): if not callable(sampler): raise TypeError("surface sampler must be callable") self.sampler = sampler self.parameters = tuple(parameters) for parameter in self.parameters: if not isinstance(parameter, torch.Tensor) or not torch.is_floating_point(parameter): raise TypeError("surface parameters must be floating-point tensors") if not torch.isfinite(parameter).all().item(): raise ValueError("surface parameters must be finite")
[docs] def __call__(self, u, v, view_indices): return self.sampler(u, v, view_indices, *self.parameters)
def _detector_grids(detector_shape, spacing, pixels): axes = [(torch.arange(selected.start, selected.stop, selected.step or 1, dtype=torch.float64, device="cpu") + .5 - size / 2) * pitch for selected, size, pitch in zip(pixels, detector_shape, spacing)] if len(axes) == 2: return torch.meshgrid(*axes, indexing="ij") return axes[0], torch.zeros_like(axes[0]) def _sample_offsets(beam, detector_shape, spacing, geometry, spec, views, pixels, *, validate=True, local_geometry=False): """Sample one offset batch without expanding world coordinates.""" frame_count = 4 if beam in ("cone", "parallel3d") else 3 frames = geometry[:frame_count] dimensions = len(detector_shape) view_count = (views.stop - views.start if local_geometry else len(range(*views.indices(frames[0].shape[0])))) pixel_shape = tuple(len(range(*selected.indices(size))) for selected, size in zip(pixels, detector_shape)) if spec is not None and spec.kind == "offsets": complete_offsets = geometry[frame_count] selection = pixels if complete_offsets.ndim == dimensions + 1 else (views, *pixels) offsets = complete_offsets if local_geometry else complete_offsets[selection] else: u, v = _detector_grids(detector_shape, spacing, pixels) ids = torch.arange(views.start, views.stop, dtype=torch.int64, device="cpu") offsets = (spec.sampler(u, v, ids, *geometry[frame_count:]) if spec is not None and spec.kind == "sampled" else torch.stack((u, v, torch.zeros_like(u)), dim=-1)) if not isinstance(offsets, torch.Tensor) or not torch.is_floating_point(offsets): raise TypeError("detector surface must return a floating-point tensor") shared_shape = (*pixel_shape, 3) if tuple(offsets.shape) not in (shared_shape, (view_count, *shared_shape)): raise ValueError("detector surface shape must match the selected view/pixel batch") if validate and beam not in ("cone", "parallel3d") and torch.any(offsets[..., 1] != 0).item(): raise ValueError("2D detector surface middle coordinates must be zero") return offsets def _sample_geometry(beam, detector_shape, spacing, geometry, spec, views, pixels, voxel_spacing, *, device=None, dtype=None, validate=True, local_geometry=False, native_legacy=False): """Build one selected world-point batch on the requested compute device.""" frame_count = 4 if beam in ("cone", "parallel3d") else 3 frames = geometry[:frame_count] device = torch.device("cpu") if device is None else torch.device(device) dimensions = len(detector_shape) view_count = (views.stop - views.start if local_geometry else len(range(*views.indices(frames[0].shape[0])))) offsets = _sample_offsets(beam, detector_shape, spacing, geometry, spec, views, pixels, validate=validate, local_geometry=local_geometry) if dtype is None: dtype = torch.float32 for component in (*frames, offsets): dtype = torch.promote_types(dtype, component.dtype) if dtype not in (torch.float32, torch.float64): raise ValueError("surface geometry dtype must be float32 or float64") if dtype.itemsize > _work_dtype(device).itemsize: # CPU-side FP64 geometry must not follow FP64-less compute devices. dtype = _work_dtype(device) offset_dtype = offsets.dtype if native_legacy else dtype if beam in ("cone", "parallel3d"): offsets = offsets.to(device=device, dtype=offset_dtype) local = offsets if offsets.ndim == dimensions + 2 else offsets.unsqueeze(0) tangent, vertical, depth = local.unbind(-1) else: tangent = offsets[..., 0].to(device=device, dtype=offset_dtype) depth = offsets[..., 2].to(device=device, dtype=offset_dtype) if offsets.ndim == dimensions + 1: tangent, depth = tangent.unsqueeze(0), depth.unsqueeze(0) checks = [(~torch.isfinite(offsets).all(), "detector surface coordinates must be finite")] if validate else [] def frame(component): selected = component if local_geometry else component[views] frame_dtype = torch.promote_types(torch.float32, component.dtype) if native_legacy else dtype return selected.to(device=device, dtype=frame_dtype).reshape( view_count, *([1] * dimensions), component.shape[-1], ) center, axis_u = frame(frames[1]), frame(frames[2]) points = center + tangent[..., None] * axis_u if beam in ("cone", "parallel3d"): axis_v = frame(frames[3]) normal_dtype = torch.promote_types(axis_u.dtype, axis_v.dtype) normal = torch.linalg.cross(axis_u.to(dtype=normal_dtype), axis_v.to(dtype=normal_dtype), dim=-1) points = points + vertical[..., None] * axis_v + depth[..., None] * normal else: normal = torch.stack((axis_u[..., 1], -axis_u[..., 0]), dim=-1) points = points + depth[..., None] * normal source_dtype = torch.promote_types(torch.float32, frames[0].dtype) if native_legacy else dtype source = (frames[0] if local_geometry else frames[0][views]).to(device=device, dtype=source_dtype) if validate: for component in (source, points): checks.append((~torch.isfinite(component).all() | ~torch.isfinite(component.float()).all(), "surface world coordinates must be finite in float32")) if beam not in ("parallel", "parallel3d") and view_count: sources = source.unsqueeze(1) endpoints = points.reshape(view_count, -1, source.shape[-1]) checks.append((torch.any(torch.all(sources.float() == endpoints.float(), dim=-1)), "source and detector surface pixels must differ")) work = _work_dtype(sources.device) if isinstance(voxel_spacing, (tuple, list)): pitches = sources.new_tensor(voxel_spacing[::-1], dtype=work) source_distance = torch.linalg.vector_norm(sources.to(dtype=work) / pitches, dim=-1) pixel_distance = torch.linalg.vector_norm(endpoints.to(dtype=work) / pitches, dim=-1) else: source_distance = torch.linalg.vector_norm(sources.to(dtype=work), dim=-1) / voxel_spacing pixel_distance = torch.linalg.vector_norm(endpoints.to(dtype=work), dim=-1) / voxel_spacing checks.append((torch.any(torch.minimum(source_distance, pixel_distance) > 1e6), "surface source or pixel must lie within 1e6 voxels")) checks.append((torch.any(source_distance > 1e15) | torch.any(pixel_distance > 1e15), "surface coordinates must lie within 1e15 voxels")) _raise_geometry_errors(checks) return source, points