Chapter 04Rev. 1.0.0

Tracing rays through a sampled volume

A sampled volume specifies attenuation at discrete locations. Predicting a radiograph means defining the field between those samples and integrating it along each detector ray.

A ray can pass through the correct piece of anatomy and still produce the wrong attenuation. Moving its samples by half a voxel changes what it sees near an edge. Using the slice spacing for every direction stretches some paths and shortens others. Both errors can leave a projection looking perfectly respectable, which is inconsiderate of them.

Chapter 3 brought the source and detector into physical object coordinates. We can now ask what attenuation a point on their connecting segment encounters. The volume supplies values at specified locations, and interpolation fills the gaps between them. Integrating that field over physical distance produces the optical depth.

These choices determine the image before GPU execution enters the discussion. They also determine the function we will differentiate in Chapter 5.

Our library operation is dpt.projection.project_optical_depth: it combines GridSpec, the acquisition geometry and a pose with the resident attenuation array, then writes one optical depth per detector pixel. The output feeds Chapter 2’s transmission operator directly. Before presenting that call, we need to settle the field it integrates. The boundary extension deserves particular care: zero-extended hats and a field clamped to half-cell faces have different integrals even when their stored arrays agree.

Axial slice

AL
Actual middle axial slice of the synthetic pelvic CT. Top A, left L.
RP

Coronal slice

SL
Actual middle coronal slice of the synthetic pelvic CT. Top S, left L.
RI

Sagittal slice

SP
Actual middle sagittal slice of the synthetic pelvic CT. Top S, left P.
AI

AP projection through the whole field

Recorded AP projection of the full synthetic pelvic attenuation volume, retaining its native crop.

One slice shows one plane of samples. Each projection pixel depends on attenuation along an entire source-to-detector path.

Native grid
256 × 256 × 256 samples
Sample spacing
1.5177865 mm in each direction
Displayed CT interval
−1,000 to +1,000 HU-like values
Attenuation conversion
μ = 0.01837 max(0, 1 + HU/1000) mm⁻¹
Image model and data

These are simulated primary radiographs of three generated MAISI CT candidates, not patient acquisitions. The full native CT supplies attenuation through the stated water-equivalent approximation at 80 keV. No scatter, spectrum or material-specific calibration is included. AP and PA are cone-beam views and need not be exact horizontal mirrors.

Radiographs use a common optical-depth display interval 0–8. The 480 × 256 detector spans 600 × 480 mm: its pixels have unequal pitches, so the displayed physical aspect is 5:4. No clinical left/right mirror is applied. A/P/R/L/S/I mark body-relative directions.

All 39 distinct projections were checked at 4,096 and 8,192 samples per ray, with three independent reference rays per view. The larger rotation study retains one-degree steps without interpolating between images. Playback timing is illustrative.

Geometry, observations and numerical checks · Source and output digests

Figure 4.1A slice is not a projectionOrthogonal slices show individual planes of the synthetic CT, while the primary projection combines attenuation along rays through the whole volume. Structures that are separate in depth can therefore overlap in the detector image.

4.1 What a voxel represents

Fix a photon energy EE and suppose the stored values have already been converted to linear attenuation coefficients in mm1\mathrm{mm}^{-1}. The energy argument will be suppressed throughout this chapter. A CT intensity or Hounsfield unit needs the material conversion described in Appendix A.11 before it can fill this role.

Let the array have dimensions Nx,Ny,NzN_x,N_y,N_z, with storage order A[k,j,i]A[k,j,i]. Here ii advances along the first grid direction, jj along the second and kk along the third. These are volume indices, and the detector retains its own column and row indices. Let oO\mathbf{o}^O be the physical centre of sample (0,0,0)(0,0,0), and let the columns of the proper orthogonal matrix QSO(3)\mathbf{Q}\in\mathrm{SO}(3) give the right-handed grid axes in object coordinates. With positive spacings Δx,Δy,Δz\Delta_x,\Delta_y,\Delta_z, define

S=diag(Δx,Δy,Δz),xijkO=oO+QS[ijk].\begin{gathered} \mathbf{S}=\operatorname{diag}(\Delta_x,\Delta_y,\Delta_z),\\ \mathbf{x}_{ijk}^O=\mathbf{o}^O+\mathbf{Q}\,\mathbf{S} \begin{bmatrix}i\\j\\k\end{bmatrix}. \end{gathered}
(4.1)

Equation (4.1) fixes the sample locations independently of array storage. We interpret A[k,j,i]A[k,j,i] as the attenuation assigned to xijkO\mathbf{x}_{ijk}^O. A reconstruction may estimate an average over a region rather than a literal point sample. Treating its values as interpolation coefficients is then a modelling choice. We must still declare the locations and the interpolation rule.

A cell associated with an interior sample extends half a spacing in each grid direction. If u\mathbf{u} denotes continuous grid coordinates, the union of the stored cells has bounds

12uaNa12,a{x,y,z}.\begin{gathered} -\frac12\leq u_a\leq N_a-\frac12,\\ a\in\{x,y,z\}. \end{gathered}
(4.2)

The extent in equation (4.2) is NaΔaN_a\Delta_a, while the distance between the first and last sample centres is (Na1)Δa(N_a-1)\Delta_a. Confusing those two lengths moves the boundary by half a voxel at each end. Neither the filename nor the array shape tells us which physical origin a loader has supplied.

A piecewise-constant model assigns each value to its cell. A trilinear model blends neighbouring sample values. Their integrals differ at finite resolution, even with exact integration. Choose the field model before choosing the algorithm that integrates it. Table 4.1 collects the coefficient, grid and support conventions needed to define that field.

Table 4.1. Attenuation volume quantities and grid conventions.
QuantityMeaningUnits
A[k,j,i]A[k,j,i]Stored attenuation coefficient at a declared sample locationmm1\mathrm{mm}^{-1}
oO\mathbf{o}^OFirst sample centre in the object framemm
Q\mathbf{Q}Grid directions expressed in the object frameDimensionless
Δx,Δy,Δz\Delta_x,\Delta_y,\Delta_zPhysical distance between adjacent centresmm
Cell boundsHalf-spacing faces associated with stored samplesmm after applying the grid map
Interpolation supportRegion where the chosen interpolant can be nonzeroDetermined by interpolation and boundary extension

4.2 Physical coordinates to array indices

A ray sample arrives as xO\mathbf{x}^O in millimetres. Subtract the sample origin, rotate into the grid basis and divide by spacing:

u(xO)=S1QT(xOoO).\mathbf{u}(\mathbf{x}^O) =\mathbf{S}^{-1}\mathbf{Q}^{\mathsf T} (\mathbf{x}^O-\mathbf{o}^O).
(4.3)

Equation (4.3) produces continuous, dimensionless grid coordinates. An integer coordinate selects a sample centre. A fractional coordinate describes a position between centres. The spacing division acts after the basis change: anisotropic scaling and a general rotation do not commute.

For an axis-aligned grid with oO=(10,20,30)Tmm\mathbf{o}^O=(10,-20,30)^{\mathsf T}\,\mathrm{mm} and spacings (0.5,0.8,2)mm(0.5,0.8,2)\,\mathrm{mm}, the point (11,17.6,34)Tmm(11,-17.6,34)^{\mathsf T}\,\mathrm{mm} maps to (2,3,2)T(2,3,2)^{\mathsf T}. It therefore selects A[2,3,2]A[2,3,2]. The point (10.25,19.8,31)Tmm(10.25,-19.8,31)^{\mathsf T}\,\mathrm{mm} maps to (0.5,0.25,0.5)T(0.5,0.25,0.5)^{\mathsf T} and needs interpolation. Unequal coordinates and unequal spacings make axis mistakes easier to see.

The coordinate conversion in Figure 4.2 keeps sample centres, cell faces and array indices distinct.

Physical lattice

Unequal sample spacing in three physical directions An axonometric view of a portion of the sample lattice, with spacings 0.5, 0.8 and 2 millimetres. The grid origin is a sample centre. Point A is at grid coordinate (2, 3, 2), on a sample centre, while point B is at (0.5, 0.25, 0.5), between samples. The dashed box marks the half-cell faces around the displayed centres, not the full eight by eight by eight array.x0.5 mmy0.8 mmz2 mmOABA portion of the 8 × 8 × 8 array, with spacing in mm
Sample centreCell faces
Sample origin O · object coordinates(10, −20, 30) mm

AAt a sample centre

Object coordinates · mm
(11, −17.6, 34)
Grid coordinates
(2, 3, 2)
A[2,3,2]A[2,3,2]

BBetween sample centres

Object coordinates · mm
(10.25, −19.8, 31)
Grid coordinates
(0.5, 0.25, 0.5)
Interpolate the surrounding samples.
Figure data

Numerical examples recorded on 9 September 2026. Sources and calculation records.

Figure 4.2Sample centres in an anisotropic volumeThe same physical points are located on an anisotropic grid and in array coordinates, with cell faces half a spacing beyond sample centres. Sample centres and half-cell faces must remain distinct when locating samples and clipping rays.

For a contiguous array whose last index varies fastest, the scalar address is

offset(i,j,k)=(kNy+j)Nx+i.\operatorname{offset}(i,j,k) =(kN_y+j)N_x+i.
(4.4)

The offset in equation (4.4) counts stored scalars, not millimetres. A strided view needs its actual strides instead. Keeping physical geometry out of the address calculation lets a storage change leave the represented field unchanged.

At the API boundary, establish whether an external origin refers to a first centre or an outer corner. A corner-to-centre conversion adds half a spacing along each grid axis in physical coordinates. Apply that conversion once when constructing the volume geometry. Adding another half voxel in the sampler would translate the entire field.

The origin and basis belong to the volume, even when the array was loaded from a file whose anatomical orientation differs from our object frame. Reordering array axes without updating the map changes the anatomy. Updating both the array and the map consistently changes only its storage description.

The executable grid contract fixes all of these choices at preparation time. Its shape is (nz, ny, nx) and its spacing is (dx, dy, dz), and the map converts between physical axis order and the x-fastest flat array explicitly. The library accepts a contiguous binary32 field, so a strided external volume must be converted by its caller before repeated projection begins. GridSpec contains no CT conversion and guesses no anatomical orientation.

Sample centres, array order and volume supportpython/dpt/volumes.pyL18–64
@dataclass(frozen=True, slots=True)
class GridSpec:
    """Oriented anisotropic grid, with origin at the first sample centre.

    Support extends half a sample spacing outside the outer centres. Within
    that support interpolation clamps to the outer sample; beyond it the field
    is zero. Nonzero boundary samples therefore produce a discontinuous field.
    """

    shape: tuple[int, int, int]
    spacing_mm: Vector3
    origin_mm: Vector3 = (0.0, 0.0, 0.0)
    orientation: Matrix3 = IDENTITY

    def __post_init__(self) -> None:
        if len(self.shape) != 3 or any(type(x) is not int or x < 1 for x in self.shape):
            raise ContractError("grid shape is (nz, ny, nx), each a positive integer")
        object.__setattr__(self, "shape", tuple(self.shape))
        object.__setattr__(self, "spacing_mm", vector3(self.spacing_mm, "spacing_mm"))
        object.__setattr__(self, "origin_mm", vector3(self.origin_mm, "origin_mm"))
        object.__setattr__(self, "orientation", rotation_matrix(self.orientation, "orientation"))
        if min(self.spacing_mm) <= 0:
            raise ContractError("grid spacing must be positive")
        if self.voxels > 2**31 - 1:
            raise ContractError("grid exceeds signed 32-bit indexing")

    @property
    def voxels(self) -> int:
        return self.shape[0] * self.shape[1] * self.shape[2]

    def object_to_grid(self, point_mm: Vector3) -> Vector3:
        displacement: Vector3 = cast(
            Vector3, tuple(point_mm[i] - self.origin_mm[i] for i in range(3))
        )
        aligned = matvec(transpose(self.orientation), displacement)
        return cast(Vector3, tuple(aligned[i] / self.spacing_mm[i] for i in range(3)))

    def grid_to_object(self, index: Vector3) -> Vector3:
        scaled: Vector3 = cast(Vector3, tuple(index[i] * self.spacing_mm[i] for i in range(3)))
        aligned = matvec(self.orientation, scaled)
        return cast(Vector3, tuple(aligned[i] + self.origin_mm[i] for i in range(3)))

    @property
    def support(self) -> tuple[Vector3, Vector3]:
        return (-0.5, -0.5, -0.5), (self.shape[2] - 0.5, self.shape[1] - 0.5, self.shape[0] - 0.5)

Notice the support property. The implemented field extends the outermost sample value to the outer half-cell face, then becomes zero beyond that face. Keeping support tied to the declared cell extent gives a constant field its full physical box width. A nonzero boundary coefficient produces a jump there. The interpolation derivation below first examines zero-extended hats, which gives us a useful comparison with this boundary choice, but its exterior ramp is not the one selected by GridSpec.

4.3 Interpolation and the field between samples

First consider trilinear interpolation with zero-valued samples outside the stored index range. Define the one-dimensional hat function β\beta and the resulting reference field by

β(v)=max(1v,0),μA(xO)=i=0Nx1j=0Ny1k=0Nz1A[k,j,i]β(uxi)β(uyj)β(uzk).\begin{gathered} \beta(v)=\max(1-|v|,0),\\ \mu_A(\mathbf{x}^O)=\sum_{i=0}^{N_x-1}\sum_{j=0}^{N_y-1}\sum_{k=0}^{N_z-1} A[k,j,i]\,\beta(u_x-i)\beta(u_y-j)\beta(u_z-k). \end{gathered}
(4.5)

Only the nearest two lattice locations in each direction can contribute to equation (4.5), giving at most eight array reads. Outside indices contribute zero, and the implementation must test their validity before reading them. Clamping an outside index to the nearest stored sample would implement a different extension.

Within one interpolation cell, write m=u\mathbf{m}=\lfloor\mathbf{u}\rfloor and α=um\boldsymbol{\alpha}=\mathbf{u}-\mathbf{m}. With w0(a)=1aw_0(a)=1-a and w1(a)=aw_1(a)=a, the same evaluation becomes

μA(xO)=a,b,c{0,1}A[mz+c,my+b,mx+a]wa(αx)wb(αy)wc(αz)\mu_A(\mathbf{x}^O)=\sum_{a,b,c\in\{0,1\}} A[m_z+c,m_y+b,m_x+a]\,w_a(\alpha_x)w_b(\alpha_y)w_c(\alpha_z)
(4.6)

where out-of-range coefficients in equation (4.6) mean zero. The weights are nonnegative and sum to one over the extended lattice. A constant stored region therefore reproduces its constant value wherever all contributing samples have that value. Near the edge of an unpadded array, mixing with the outside zeros creates a ramp.

That ramp fixes the interpolation support: the field is zero when any grid coordinate lies outside [1,Na][-1,N_a], and also on the outer faces. This is half a spacing farther out than the cell bounds in equation (4.2). The cell box covers the regions assigned to stored samples, while the interpolation box contains their full hat functions (Figure 4.3). When the intended object has a known zero exterior, an adequate border of zero-valued samples lets us place this interpolation boundary outside the anatomy of interest.

Piecewise-constant cells

Attenuation μ (mm⁻¹) against Grid coordinate u. Cell value and Stored sample.Attenuation μ (mm⁻¹)00.010.020.03−10123Grid coordinate uStored sample: 0, 0.01Stored sample: 1, 0.03Stored sample: 2, 0.02
  • Cell value
  • Stored sample

Cell box: [12,52]\text{Cell box: }[-\tfrac12,\,\tfrac52]

Zero-extended hat interpolation

Attenuation μ (mm⁻¹) against Grid coordinate u. Interpolated field and Stored sample.Attenuation μ (mm⁻¹)00.010.020.03−10123Grid coordinate uStored sample: 0, 0.01Stored sample: 1, 0.03Stored sample: 2, 0.02
  • Interpolated field
  • Stored sample

Hat support box: [1,3]\text{Hat support box: }[-1,\,3]

The outer ramps reach zero half a spacing beyond the cell faces. The production sampler uses a separate convention: clamping within the half-cell box.

Figure data

Numerical examples recorded on 9 September 2026. Sources and calculation records.

Figure 4.3Cell boundaries and interpolation supportPiecewise-constant cells end half a spacing beyond their centres, while the zero-extended hat basis reaches a full spacing beyond each centre. The different supports change the sampled field near the array edge and the interval over which a ray can encounter it.

The interpolant is continuous. Its spatial derivative generally jumps at integer grid planes, because neighbouring linear pieces have different slopes. Within an open interpolation cell, the chain rule gives

xOμA=QS1uμA.\nabla_{\mathbf{x}^O}\mu_A =\mathbf{Q}\mathbf{S}^{-1}\nabla_{\mathbf{u}}\mu_A.
(4.7)

The gradient in equation (4.7) has units mm2\mathrm{mm}^{-2}. The inverse spacing is essential: the same coefficient difference across a thinner interval produces a steeper physical gradient. We will use this derivative when a pose update moves ray samples through the field.

Higher-order interpolation can make the field smoother, but changes its support, reads and approximation properties. Some interpolants can overshoot nonnegative samples. For primary attenuation, a negative interpolated coefficient would imply amplification along that part of the ray. Trilinear interpolation avoids that overshoot for nonnegative inputs through its nonnegative weights.

The CUDA sampler uses the same trilinear weights between sample centres, with the half-cell extension declared in Listing 4.1. It first checks the physical support in grid coordinates. Inside that support it clamps each coordinate to the range of sample centres, then reads the corresponding corners. Clamping without the initial support test would extend the edge values indefinitely and turn a finite volume into a surprisingly large object.

Clamped trilinear values and grid-coordinate gradientspython/dpt/kernels/projection.pyL178–237
@wp.func
def sample_field(field: wp.array(dtype=wp.float32), point: wp.vec3d, shape: wp.vec3i) -> Sample:
    """Value and grid-coordinate slope of the half-cell-extended sampled field."""
    result = Sample()
    if not inside_support(point, shape):
        return result
    lower = wp.vec3i()
    fraction = wp.vec3d()
    slope = wp.vec3d()
    for axis in range(3):
        coordinate = wp.clamp(point[axis], wp.float64(0.0), wp.float64(shape[axis] - 1))
        lower[axis] = wp.min(int(wp.floor(coordinate)), wp.max(shape[axis] - 2, 0))
        fraction[axis] = coordinate - wp.float64(lower[axis])
        if point[axis] >= wp.float64(0.0) and point[axis] < wp.float64(shape[axis] - 1):
            slope[axis] = wp.float64(1.0)
    # Separable interpolation reuses the same eight loads for value and slope.
    # Form differences in FP64: FP32 subtraction would lose small field slopes.
    # The compiler removes slope work from the forward-only caller.
    x0, y0, z0 = lower[0], lower[1], lower[2]
    x1 = wp.min(x0 + 1, shape[0] - 1)
    y1 = wp.min(y0 + 1, shape[1] - 1)
    z1 = wp.min(z0 + 1, shape[2] - 1)
    row00 = (z0 * shape[1] + y0) * shape[0]
    row10 = (z0 * shape[1] + y1) * shape[0]
    row01 = (z1 * shape[1] + y0) * shape[0]
    row11 = (z1 * shape[1] + y1) * shape[0]
    v000 = wp.float64(field[row00 + x0])
    v100 = wp.float64(field[row00 + x1])
    v010 = wp.float64(field[row10 + x0])
    v110 = wp.float64(field[row10 + x1])
    v001 = wp.float64(field[row01 + x0])
    v101 = wp.float64(field[row01 + x1])
    v011 = wp.float64(field[row11 + x0])
    v111 = wp.float64(field[row11 + x1])
    dx00, dx10 = v100 - v000, v110 - v010
    dx01, dx11 = v101 - v001, v111 - v011
    fx, fy, fz = fraction[0], fraction[1], fraction[2]
    # Weighted lerp preserves a small endpoint next to a huge neighbour.
    # a+t*(b-a) would erase b at t=1 when the difference rounds to -a.
    ax, ay, az = wp.float64(1.0) - fx, wp.float64(1.0) - fy, wp.float64(1.0) - fz
    x00, x10 = ax * v000 + fx * v100, ax * v010 + fx * v110
    x01, x11 = ax * v001 + fx * v101, ax * v011 + fx * v111
    xy0, xy1 = ay * x00 + fy * x10, ay * x01 + fy * x11
    result.value = az * xy0 + fz * xy1
    # Differentiate corner values before interpolation. Subtracting two
    # already interpolated values can erase a small slope beside a huge
    # orthogonal background, even when its cotangent is representable.
    dx0, dx1 = ay * dx00 + fy * dx10, ay * dx01 + fy * dx11
    dy0 = ax * (v010 - v000) + fx * (v110 - v100)
    dy1 = ax * (v011 - v001) + fx * (v111 - v101)
    dz0 = ax * (v001 - v000) + fx * (v101 - v100)
    dz1 = ax * (v011 - v010) + fx * (v111 - v110)
    result.gradient = wp.vec3d(
        slope[0] * (az * dx0 + fz * dx1),
        slope[1] * (az * dy0 + fz * dy1),
        slope[2] * (ay * dz0 + fy * dz1),
    )
    return result

The value and its grid-coordinate gradient share eight coefficient loads. Each binary32 coefficient is promoted before differences and interpolation are evaluated in binary64. For the value, weighted interpolation retains a small endpoint beside a much larger neighbour. For a slope, the code differences the appropriate corner values before blending them across the other axes. Subtracting two already-interpolated values could erase a small transverse slope beneath a large common background. Those apparently interchangeable algebraic arrangements have different rounding behaviour, so the derivative needs the slope that the stored corner differences still contain.

In the outer half-cell, the field is constant along the clamped axis, so slope suppresses that component. This zero interior slope does not mean moving the support face has zero effect on a ray integral. The clipping calculation below supplies that dependency, and Chapter 5 carries it through the derivative. At a clamp transition or tied interpolation branch, the returned slope is a branch value, not a claim that the field is differentiable there.

4.4 Intersecting a ray with the volume

Take the finite object-space segment from Chapter 3, with endpoints sO\mathbf{s}^O and qpO\mathbf{q}_p^O. Its physical length is dp=qpOsO>0d_p=\lVert\mathbf{q}_p^O-\mathbf{s}^O\rVert>0. Applying the affine grid map to its endpoints gives

ap=u(sO),bp=u(qpO)ap,up(t)=ap+tbp,0t1.\begin{gathered} \mathbf{a}_p=\mathbf{u}(\mathbf{s}^O),\\ \mathbf{b}_p=\mathbf{u}(\mathbf{q}_p^O)-\mathbf{a}_p,\\ \mathbf{u}_p(t)=\mathbf{a}_p+t\mathbf{b}_p, \quad 0\leq t\leq1. \end{gathered}
(4.8)

The vector bp\mathbf{b}_p in equation (4.8) is a displacement in grid coordinates. Its Euclidean norm is not a physical ray length when spacing is anisotropic. Keep dpd_p for integration weights.

For a box with lower and upper bounds la,hal_a,h_a in each grid direction, a nonzero component bp,ab_{p,a} gives two plane intersections:

τa,0=laap,abp,a,τa,1=haap,abp,a,τa=min(τa,0,τa,1),τa+=max(τa,0,τa,1).\begin{gathered} \tau_{a,0}=\frac{l_a-a_{p,a}}{b_{p,a}},\\ \tau_{a,1}=\frac{h_a-a_{p,a}}{b_{p,a}},\\ \tau_a^- =\min(\tau_{a,0},\tau_{a,1}),\\ \tau_a^+ =\max(\tau_{a,0},\tau_{a,1}). \end{gathered}
(4.9)

Sorting each pair in equation (4.9) accommodates either ray direction. If bp,a=0b_{p,a}=0, the ray is parallel to those planes: it misses the box if ap,aa_{p,a} lies outside [la,ha][l_a,h_a], and otherwise that coordinate imposes no restriction on tt. Handle this case directly rather than evaluating a division that can produce an infinity or a NaN.

Intersect the three accepted intervals with the source-to-detector segment:

tp=max(0,τx,τy,τz),tp+=min(1,τx+,τy+,τz+).\begin{gathered} t_p^- =\max(0,\tau_x^-,\tau_y^-,\tau_z^-),\\ t_p^+ =\min(1,\tau_x^+,\tau_y^+,\tau_z^+). \end{gathered}
(4.10)

An unconstraining parallel coordinate supplies (,+)(-\infty,+\infty) to equation (4.10), or is omitted from the extrema. The interval has positive length only when tp+>tpt_p^+>t_p^-. A single tangency contributes zero path length. For the zero-extended hat reference, choose la=1l_a=-1 and ha=Nah_a=N_a. The implemented clamped trilinear field and a cellwise-constant volume instead use the half-cell bounds in equation (4.2), but their shared support does not make their interior fields identical.

The clipped physical length is

p=dpmax(0,tp+tp).\ell_p=d_p\max(0,t_p^+-t_p^-).
(4.11)

Equation (4.11) preserves the finite endpoints: a large volume can contain the source or detector, but material beyond either endpoint is still excluded. A miss gives Lp=0L_p=0 and Tp=1T_p=1 immediately, without invoking an interpolation routine on an invalid interval.

Figure 4.4 shows how the slab intervals combine before any attenuation samples are read.

Box bounds on each axis: −0.5 to 1.5 mm. All three rays lie in the plane z = 0.5 mm.

Crossing

2 mm inside the box
Crossing: finite source-to-detector segment in an x–y section of the box SD−0.51.5−0.51.5x / mmy / mm Slab intervals for the crossing The finite ray is restricted to lambda between zero and one. x slab: crossing, interval (0.25, 0.75). y slab: parallel inside. z slab: parallel inside. The common interval is 0.25 to 0.75, with length 2 millimetres.Finite rayx slaby slaball λz slaball λAccepted−0.500.511.5Ray parameter λ

Parallel miss

No accepted interval
Parallel miss: finite source-to-detector segment in an x–y section of the box SD−0.51.5−0.51.5x / mmy / mm Slab intervals for the parallel miss The finite ray is restricted to lambda between zero and one. x slab: crossing, interval (0.25, 0.75). y slab: parallel miss. z slab: parallel inside. There is no common interval.Finite rayx slaby slabOutside slabz slaball λAcceptedEmpty−0.500.511.5Ray parameter λ

Tangency

Contact at λ = ½, zero length
Tangency: finite source-to-detector segment in an x–y section of the box SD−0.51.5−0.51.5x / mmy / mm Slab intervals for the tangency The finite ray is restricted to lambda between zero and one. x slab: crossing, interval (0.5, 1.5). y slab: crossing, interval (−0.5, 0.5). z slab: parallel inside. The common interval is 0.5 to 0.5, with length 0 millimetres.Finite rayx slaby slabz slaball λAccepted−0.500.511.5Ray parameter λ
Figure data

Numerical examples recorded on 9 September 2026. Sources and calculation records.

Figure 4.4Clipping a finite detector rayThe slab construction clips a finite source-to-detector segment against the volume, distinguishing a crossing from a parallel miss and a zero-length contact. Only a positive-length intersection contributes attenuation.

A tiny direction component is not mathematically zero. Replacing every component below a fixed threshold by zero can discard a real, distant intersection. A numerical parallel policy therefore needs a scale-aware error argument and tests against the resulting interval, particularly for long rays and thin volumes.

finite_interval() implements the slab calculation for the library’s half-cell bounds. Initialising its limits to zero and one clips the finite source-to-detector segment before any field samples are requested. A parallel miss produces an empty interval, while an exactly parallel coordinate inside its slab imposes no further restriction. There is no adjustable epsilon that quietly changes a nearly parallel ray into a parallel one.

Finite-ray clipping and its active-face derivativespython/dpt/kernels/projection.pyL128–167
@wp.func
def finite_interval(origin: wp.vec3d, direction: wp.vec3d, shape: wp.vec3i) -> Interval:
    """Clip a finite segment and retain the active face derivatives.

    Exact parallelism is handled separately; arbitrary epsilon thresholds would
    change the physical interval. At tied faces a derivative is not unique:
    strict comparisons choose the first active axis, a documented branch value.
    """
    interval = Interval()
    interval.lower = wp.float64(0.0)
    interval.upper = wp.float64(1.0)
    for axis in range(3):
        low = wp.float64(-0.5)
        high = wp.float64(shape[axis]) - wp.float64(0.5)
        velocity = direction[axis]
        if velocity == wp.float64(0.0):
            if origin[axis] < low or origin[axis] > high:
                interval.upper = wp.float64(-1.0)
        else:
            first = (low - origin[axis]) / velocity
            last = (high - origin[axis]) / velocity
            if first > last:
                temporary = first
                first = last
                last = temporary
            if first > interval.lower:
                interval.lower = first
                interval.lower_origin_gradient = wp.vec3d(0.0)
                interval.lower_direction_gradient = wp.vec3d(0.0)
                interval.lower_origin_gradient[axis] = -wp.float64(1.0) / velocity
                interval.lower_direction_gradient[axis] = -first / velocity
            if last < interval.upper:
                interval.upper = last
                interval.upper_origin_gradient = wp.vec3d(0.0)
                interval.upper_direction_gradient = wp.vec3d(0.0)
                interval.upper_origin_gradient[axis] = -wp.float64(1.0) / velocity
                interval.upper_direction_gradient[axis] = -last / velocity
    return interval

The extra vectors record the derivatives of the selected entry and exit parameters with respect to grid-space origin and direction. They are a small amount of per-ray state, independent of the number of integration samples. Keeping them with the interval prevents the reverse calculation from treating its bounds as constants. Strict comparisons select the first winning axis at a tie, and tests treat that outcome as a declared branch convention, since choosing a deterministic branch cannot create a unique derivative at a corner.

4.5 Quadrature along a physical path

For a nonempty interval, choose a positive integer sample count MpM_p. Equal midpoint samples and their physical weights are

Δtp=tp+tpMp,tpr=tp+(r+12)Δtp,wpr=dpΔtp,0r<Mp.\begin{gathered} \Delta t_p=\frac{t_p^+-t_p^-}{M_p},\\ t_{pr}=t_p^-+\left(r+\frac12\right)\Delta t_p,\\ w_{pr}=d_p\Delta t_p, \quad 0\leq r<M_p. \end{gathered}
(4.12)

Every weight in equation (4.12) is measured in millimetres, and their sum is p\ell_p. Midpoints avoid evaluating exactly on the two clipped endpoints. They can still land on internal interpolation knots.

The discrete optical depth is

L^p=r=0Mp1wpr×μA ⁣((1tpr)sO+tprqpO).\widehat L_p=\sum_{r=0}^{M_p-1}w_{pr} {}\times\mu_A\!\left((1-t_{pr})\mathbf{s}^O+t_{pr}\mathbf{q}_p^O\right).
(4.13)

Equation (4.13) is dimensionless, as the continuous line integral is. Accumulating attenuation values without the distance weights would make a projection depend on the number of samples.

A target physical step hh can set Mp=max(1,p/h)M_p=\max(1,\lceil\ell_p/h\rceil). Then the actual step is p/Mph\ell_p/M_p\leq h, so the final sample represents a full equal-width subinterval rather than an accidentally truncated endpoint weight. Another choice fixes MpM_p during an optimisation stage. The latter keeps the sample-count branch unchanged as pose varies. Chapter 5 will examine the effect of allowing that integer to change.

Midpoint integration is not the only option. For a piecewise-constant field, intersect the ray with the grid planes and accumulate the exact segment lengths through its cells:

Lpcell=vVppvμv.L_p^{\mathrm{cell}} =\sum_{v\in\mathcal{V}_p}\ell_{pv}\,\mu_v.
(4.14)

The expression in equation (4.14) is exact for that field model, apart from numerical arithmetic. It does not become the exact integral of a trilinear field merely because the cells came from the same array. A trilinear polynomial restricted to an oblique line can be cubic within an interpolation cell, so two-point Gauss integration is exact there if the ray is split at every crossed interpolation plane. That is a different integration strategy again.

Siddon’s method organises the cellwise calculation around three families of voxel-boundary planes. The intersections determine which cells a ray traverses and the physical lengths that weight their coefficients. This geometric organisation avoids testing every voxel individually. [1]

We can measure midpoint error without any volume loader. Along a prescribed physical segment of length \ell, let the analytic attenuation be μ(s)=μ0+bs+cs2\mu(s)=\mu_0+b s+c s^2. Choose coefficients so it is nonnegative on the segment. Its exact optical depth is

L=μ0+b22+c33.L=\mu_0\ell+\frac{b\ell^2}{2}+\frac{c\ell^3}{3}.
(4.15)

Here bb has units mm2\mathrm{mm}^{-2} and cc has units mm3\mathrm{mm}^{-3}. For MM equal midpoint intervals, subtracting the sum of squared midpoint locations from equation (4.15) gives

LL^M=c312M2.L-\widehat L_M=\frac{c\ell^3}{12M^2}.
(4.16)

Equation (4.16) predicts both sign and convergence rate of the midpoint error illustrated in Figure 4.5. With μ0=0.01mm1\mu_0=0.01\,\mathrm{mm}^{-1}, b=0b=0, c=106mm3c=10^{-6}\,\mathrm{mm}^{-3} and =100mm\ell=100\,\mathrm{mm}, the exact optical depth is 4/34/3. Counts M=10,20,40M=10,20,40 give errors 1/12001/1200, 1/48001/4800 and 1/192001/19200.

μ(s)=μ0+cs2\mu(s)=\mu_0+cs^2μ0=0.01  mm1\mu_0=0.01\;\mathrm{mm}^{-1}c=106  mm3c=10^{-6}\;\mathrm{mm}^{-3}0s100  mm0\leq s\leq100\;\mathrm{mm}

Ten midpoint intervals

Midpoint rectangles beneath the quadratic attenuation curve The prescribed field increases from 0.01 to 0.02 inverse millimetres along a 100 millimetre path. Ten equal intervals use the field at each midpoint. The recorded optical-depth estimate is 1.3325, below the exact value four thirds.00.010.020255075100Attenuation μ (mm⁻¹)Distance s (mm)
L=43L=\frac43L^10=1.3325\widehat L_{10}=1.3325

Midpoint error falls as M⁻²

Optical-depth error against Intervals M. The horizontal scale is logarithmic. The vertical scale is logarithmic. Predicted error and Recorded midpoint error.Optical-depth error10⁻⁶10⁻⁵10⁻⁴0.001510204080160320Intervals MRecorded midpoint error: 5, 0.0033333Recorded midpoint error: 10, 8.33×10⁻⁴Recorded midpoint error: 20, 2.08×10⁻⁴Recorded midpoint error: 40, 5.21×10⁻⁵Recorded midpoint error: 80, 1.3×10⁻⁵Recorded midpoint error: 160, 3.26×10⁻⁶Recorded midpoint error: 320, 8.14×10⁻⁷
LL^M=c312M2L-\widehat L_M=\frac{c\ell^3}{12M^2}
  • Predicted error
  • Recorded midpoint error
Figure data

Numerical examples recorded on 9 September 2026. Sources and calculation records.

Figure 4.5Midpoint integration of a quadratic attenuation fieldMidpoint quadrature approximates a nonnegative quadratic attenuation field over a 100 mm path. Doubling the number of intervals quarters the optical-depth error, exposing the second-order convergence rate against an exact integral.

The forward kernel applies the fixed-count midpoint rule to that clipped interval. One invocation handles one flat detector index: misses write zero, and every intersecting ray uses the prepared samples_per_ray. This makes the discretisation repeatable as pose changes within a clipping branch. It also means physical step size varies with chord length, so choosing a sample count requires convergence checks on the longer relevant paths.

Midpoint quadrature along each raypython/dpt/kernels/projection.pyL338–406
@cache
def get_forward(double_output: bool = False, cell_gauss: bool = False):
    dtype = wp.float64 if double_output else wp.float32

    @wp.kernel(module="unique", module_options=OPTIONS)
    def forward(
        field: wp.array(dtype=wp.float32),
        pose: wp.array(dtype=wp.float64),
        config: Configuration,
        output: wp.array(dtype=dtype),
        status: wp.array(dtype=wp.int32),
    ):
        pixel = wp.tid()
        ray = ray_for_pixel(config, pose, pixel)
        if not valid_ray(ray):
            wp.atomic_or(status, 0, 2)
            output[pixel] = dtype(0.0)
            return
        interval = finite_interval(ray.origin, ray.direction, config.shape)
        integral = wp.float64(0.0)
        if interval.upper > interval.lower:
            if wp.static(cell_gauss):
                traversal = begin_cells(ray, interval, config.shape)
                start = interval.lower
                segments = wp.int64(0)
                limit = (
                    wp.int64(config.shape[0])
                    + wp.int64(config.shape[1])
                    + wp.int64(config.shape[2])
                    + wp.int64(1)
                )
                while start < interval.upper and segments < limit:
                    end = wp.min(traversal.next[0], wp.min(traversal.next[1], traversal.next[2]))
                    if end <= start:
                        wp.atomic_or(status, 0, 2)
                        break
                    radius = (end - start) * wp.float64(0.5)
                    centre = start + radius
                    offset = radius / wp.sqrt(wp.float64(3.0))
                    for node in range(2):
                        position = centre + wp.float64(2 * node - 1) * offset
                        integral += (
                            radius
                            * sample_field(
                                field, ray.origin + position * ray.direction, config.shape
                            ).value
                        )
                    start = end
                    traversal = advance_cells(traversal, ray, config.shape, end, interval.upper)
                    segments += wp.int64(1)
                if start < interval.upper:
                    wp.atomic_or(status, 0, 2)
                integral *= ray.length
            else:
                step = (interval.upper - interval.lower) / wp.float64(config.samples)
                for sample in range(config.samples):
                    position = interval.lower + (wp.float64(sample) + wp.float64(0.5)) * step
                    point = ray.origin + position * ray.direction
                    if not finite_point(point):
                        wp.atomic_or(status, 0, 2)
                    integral += sample_field(field, point, config.shape).value
                integral *= ray.length * step
        output[pixel] = dtype(integral)
        if not wp.isfinite(output[pixel]):
            wp.atomic_or(status, 0, 2)

    return forward

ray.length remains a millimetre distance even though ray.origin and ray.direction use grid coordinates. Multiplying it by step supplies the physical quadrature weight. Sample values accumulate in a binary64 register. The default output is rounded to binary32; precision="float64" retains the binary64 result while leaving the stored field coefficients unchanged. No positions or interpolation weights are written to a ray-by-sample array. The status flag records invalid coordinates or an unrepresentable output, and a zero written on a failed ray is not permission to interpret the result as vacuum.

The same operator also accepts integration="cell_gauss", which splits at interpolation planes and applies the exact cubic rule described above. Chapter 11 uses that mode with binary64 outputs to complete a pose fit whose midpoint samples otherwise introduce gradient jumps near the solution.

4.6 A GPU projection operator

Let us assign each detector ray to one CUDA thread. Each thread clips its ray to the volume, accumulates optical depth in registers and writes the result to its detector pixel. All threads read from the same volume without modifying it. Neighbouring threads write adjacent output values, but their rays may cross the volume obliquely, so adjacent writes do not imply contiguous reads.

In Warp, this maps to a typed kernel over a flat pixel index or a two-dimensional detector grid. The pinned Warp programming guide defines wp.tid() for identifying each execution instance and explicit array arguments for its reads and writes. The array and geometry descriptors are allocated before the repeated projection calls. The loop forms each sample position from its index, and it does not materialise a tensor containing every position along every ray. The source, detector geometry and attenuation volume stay on the CUDA device throughout a sequence of pose evaluations.

For fixed geometry and quadrature, the complete operator is linear in the stored coefficients. Flattening them into a\mathbf{a} gives

L^p=vWpvav,Wpv=rwprBv(xprO)\begin{gathered} \widehat L_p=\sum_v W_{pv}a_v,\\ W_{pv}=\sum_r w_{pr}B_v(\mathbf{x}_{pr}^O) \end{gathered}
(4.17)

where BvB_v is the tensor-product basis from equation (4.5) for the zero-extended reference. For the implemented field, use the corresponding clamped interpolation weights inside its half-cell support. Both choices remain linear in the stored coefficients. The matrix in equation (4.17) describes the selected operator, but we need not store it. Evaluating its nonzero contributions while traversing each ray avoids a potentially enormous sparse matrix and keeps the interpolation convention visible in the calculation. The interface in Table 4.2 makes the required geometry, coefficient storage and repeated-call behaviour explicit.

Table 4.2. Projection operator inputs and outputs.
Interface itemRequired meaningRepeated-call behaviour
Volume coefficientsNonnegative finite attenuation, declared dtype and stridesRemain resident and read-only during a projection
Grid geometrySample-centre origin, basis, positive spacing and dimensionsSmall device-side parameters, updated only when the grid changes
Ray geometryFinite physical endpoints or parameters that construct themUses the Chapter 3 conventions
Sampling policyFixed count or physical target step, boundary extension and interpolationIdentifies the discrete operator being evaluated
Optical-depth outputOne dimensionless scalar per detector sampleCaller-owned storage written once per ray
Optional detector outputsTransmission or expected primary countsDerived using Chapter 2’s numerical rules

Each thread writes to a distinct detector pixel, so the forward projection needs no atomic operations. It still sums contributions along each ray, and the precision of that sum must be chosen independently of the volume’s storage precision. Tests for long rays should include many small contributions added after larger ones, when rounding can erase their effect. If optical depth is not needed separately, evaluating the exponential in the projection kernel can avoid a second kernel launch and a read of the intermediate optical-depth image. Retaining optical depth as an explicit output, however, supports log-domain objectives and diagnostics.

The public call keeps optical depth as an explicit output. prepare_projection() has already bound the grid, detector, fixed quadrature and owning CUDA stream, and allocated the small reduction workspace needed if pose is active. The caller supplies both the immutable field and the current packed pose, plus the destination array. A projection call allocates no device buffer and copies no image or volume between host and device, but checked calls do transfer diagnostic status.

The projection API and its device bufferspython/dpt/projection.pyL214–260
def project_optical_depth(
    mu: Any,
    pose: Any,
    *,
    workspace: ProjectionWorkspace,
    out_L: Any,
    stream: Any = None,
    tape: Any = None,
    validate: bool = True,
) -> None:
    """Overwrite dimensionless optical depth for every finite detector ray.

    mu is a flat non-negative FP32 field in inverse mm. pose is twelve FP64
    values (R_WO row-major, t_WO in mm). out_L is flat row-major detector
    storage in the precision declared by ProjectionSpec. A checked call
    synchronises domain diagnostics before writing and
    range diagnostics afterwards. Unchecked calls promise valid current inputs
    and require check_status() at the next acceptance checkpoint.
    """
    ensure_tape(tape)
    _inputs(mu, pose, workspace, stream)
    if workspace._recorded_tape is not None:
        raise ContractError("finish or discard the outstanding projection tape before reuse")
    ctx = workspace.context
    ctx.array(out_L, "out_L", dtype=workspace.dtype, shape=(workspace.geometry.pixels,))
    ctx.disjoint([("mu", mu), ("pose", pose)], [("out_L", out_L)])
    if tape is not None:
        _check_tape_arrays(mu, pose, out_L, workspace)
    if validate:
        _validate(mu, pose, workspace)
    ctx.wp.launch(
        workspace._kernels.get_forward(
            workspace.spec.precision == "float64", workspace.spec.integration == "cell_gauss"
        ),
        dim=workspace.geometry.pixels,
        inputs=[mu, pose, workspace._configuration],
        outputs=[out_L, workspace._status],
        stream=workspace.stream,
        block_dim=_BLOCK,
        record_tape=False,
    )
    if validate:
        workspace.check_status()
    if tape is not None:
        _record(tape, mu, pose, out_L, workspace)

With validation enabled, the call checks input values before writing any pixels, then checks the numerical status reported by the kernel after execution. It also checks buffer metadata and memory ranges, rejecting an output buffer that overlaps the field or pose storage.

Setting validate=False makes the caller responsible for ensuring that the current input values are valid and for checking the accumulated numerical status before accepting a result. An earlier validation remains sufficient only while the validated contents are unchanged. A pose-recovery loop can therefore validate its fixed volume once, but must ensure that each new trial pose is valid and check execution status before accepting the corresponding projection.

The next call passes out_L to transmit() on the same stream, with independently prepared output destinations. Keeping the two operators separate gives both counts and log transmission access to the same optical depths. It also lets the reverse chain reuse their independently tested numerical policies.

A variable-step projector can give neighbouring rays different loop lengths. Our fixed-count kernel removes that source of divergence for intersecting rays, although misses exit early and interpolation branches can differ. Measure the remaining divergence together with memory throughput and occupancy. A block cooperating on one long ray may expose more parallelism but introduces a reduction, changes rounding order and spends more threads on short rays. The useful choice depends on the detector size, path-length distribution and device.

A trilinear sample requests at most eight coefficients. Cache reuse can reduce the associated memory transactions, while scattered access can make each requested scalar expensive. Profile register pressure, achieved occupancy and cache behaviour on the actual acquisition. The launch configuration should follow those measurements rather than a generic claim that one block size is best.

4.7 Resolution, truncation and convergence

There are three different approximations we need to keep apart.

  • Field approximation. Sampling the anatomy and choosing an interpolant produces a field μA\mu_A. The sample spacing, coefficient values and boundary extension determine what this field can represent. Fine structures may be lost or blurred before any ray is integrated; taking more samples along that ray cannot restore them.
  • Quadrature error. Numerical quadrature replaces the exact line integral of μA\mu_A with a finite weighted sum. Its error depends on the integration rule, sample placement and variation of the field along the ray, including crossings of interpolation knots. Refining the quadrature tests how accurately we integrate the chosen field, not how faithfully that field represents the anatomy.
  • Floating-point error. Finite-precision coordinate calculations, interpolation, weighting and accumulation perturb the exact-arithmetic quadrature. Small contributions can disappear when added to a much larger running total, and rounding the final output introduces another error. Increasing the sample count does not guarantee that this error decreases; arithmetic precision and evaluation order need their own checks.

If LL denotes the intended continuous-field integral, LAL_A its exact interpolated-field counterpart and L^\widehat L the exact-arithmetic quadrature, then

LcomputedL=(LAL)+(L^LA)+(LcomputedL^).L_{\mathrm{computed}}-L =(L_A-L) {}+(\widehat L-L_A) {}+(L_{\mathrm{computed}}-\widehat L).
(4.18)

Equation (4.18) tells us what a convergence experiment can establish. Halving the integration step while keeping the array fixed tests quadrature convergence towards LAL_A. It cannot recover anatomy that the array and interpolation have already lost.

experiments/projection-convergence/run.py records those comparisons separately in convergence.json. Its prescribed object is a positive quadratic field in a fixed physical box, with no claim to represent anatomy. One reference integrates that continuous polynomial analytically. A second, dpt.validation.projection.integrate_sampled_field, splits the uploaded field at every interpolation or clamp transition and integrates each line segment with a two-point Gauss rule. Since trilinear interpolation restricted to a line is at most cubic, this second reference is exact for the selected sampled field up to binary64 arithmetic. It uses the actual uploaded binary32 coefficients, so quantising the field is not accidentally counted as a kernel error.

The CUDA midpoint sweep can then expose quadrature error relative to the sampled-field reference, while the grid sweep exposes the difference from the continuous quadratic. The recorder retains both errors, configuration and source hashes. A run that finishes has produced evidence to inspect, but it has not made an arbitrary sample count suitable for every acquisition.

A grid-refinement study holds the physical object and acquisition fixed, samples the same analytic field on successively finer grids and reduces quadrature error enough to expose the field approximation. Keep the zero boundary outside the prescribed object’s support at every resolution. Changing array dimensions while retaining the old spacing changes the physical object size, which is a different experiment.

The boundary extension also makes truncation a physical modelling question. If tissue reaches an edge of the supplied array, the hat reference ramps towards zero while the implemented field holds the edge value to a half-cell face and then drops to zero. Refining quadrature integrates the chosen exterior more accurately. It does not tell us what attenuation was outside the scan. Padding with known zeros is appropriate where the exterior is actually known to be zero, and it is not a reconstruction of missing tissue.

Errors in optical depth pass nonlinearly into transmission. For a perturbation δL\delta L,

T(L+δL)T(L)T(L)=exp(δL)1.\frac{T(L+\delta L)-T(L)}{T(L)} =\exp(-\delta L)-1.
(4.19)

For small errors, equation (4.19) gives a relative transmission error of approximately δL-\delta L. A heavily attenuated ray can have a tiny absolute count error while retaining a substantial relative transmission error. Report optical-depth error alongside the quantity the application actually compares.

For smooth fields and a suitable uniform midpoint sequence, a quadratic error regime is expected. An unsplit interpolation knot interrupts the smoothness assumptions behind the usual second-derivative error formula. A finite study may also reach a plateau where field error or floating-point error dominates. The slope of one log-log plot is therefore useful only with the field, quadrature and arithmetic held to a declared comparison.

4.8 Boundary and consistency checks

As it has (hopefully, by now) become our habit, we close this chapter too by checking our work. We begin with coordinates whose answers fit on a page.

  • An axis-aligned box gives a known chord length.
  • A ray that misses it has zero optical depth.
  • Reversing the endpoints leaves the integral unchanged, provided the field and physical segment are unchanged.

These tests detect mistakes that an anatomical projection can conceal.

The zero-extended hat reference also has a compact exact check. Put one nonzero coefficient aa at grid index (0,0,0)(0,0,0) and all other coefficients at zero. Trace a grid-axis ray through that sample, with its other two grid coordinates fixed at zero and endpoints beyond the full support. Then

L=aΔx11β(ux)dux=aΔx.L=a\Delta_x\int_{-1}^{1}\beta(u_x)\,\mathrm du_x=a\Delta_x.
(4.20)

The triangular area in equation (4.20) tests the zero extension and its physical width together. For a single-sample axis, Nx=1N_x=1, clipping to both half-cell faces retains only three quarters of that area. With Nx2N_x\geq2, the boundary sample loses only its outer eighth, so clipping retains seven eighths. A clamped extension has a different support again. These are distinguishable numerical models, not interchangeable boundary conveniences.

For the implemented field, use a constant box to test the full half-cell chord and use an outer-sample case to test the clamp. A single nonzero boundary sample beside a zero neighbour contributes a constant half-cell followed by a linear decline to the next centre. The resulting area can happen to match the complete hat area even though the functions differ. Shorten the segment to the outer half-cell and the two values separate. Tests need such partial segments: one agreeing total integral is insufficient to identify the boundary model.

For nonnegative coefficients and quadrature weights, the discrete operator has useful ordering properties:

a0  L^p0,aa  L^p(a)L^p(a),0<Tp1.\begin{gathered} \mathbf{a}\geq\mathbf{0}\ \Longrightarrow\ \widehat L_p\geq0,\\ \mathbf{a}'\geq\mathbf{a}\ \Longrightarrow\ \widehat L_p(\mathbf{a}')\geq\widehat L_p(\mathbf{a}),\\ 0<T_p\leq1. \end{gathered}
(4.21)

The transmission inequality in equation (4.21) concerns finite real optical depths, and stored transmission may underflow to zero. The two optical-depth inequalities are direct tests of interpolation and accumulation signs. A negative result from finite nonnegative inputs cannot be explained by a different physical interpretation of primary attenuation. The independent cases in Table 4.3 separate these sign errors from geometry and quadrature errors.

Table 4.3. Independent projection checks.
CaseExpected comparisonError isolated
Zero coefficients and missed raysL=0L=0, T=1T=1Initialisation, clipping and empty paths
Constant cellwise fieldAttenuation times exact box chordCell traversal and physical units
Constant interpolation neighbourhoodConstant coefficient times segment lengthPartition of unity under the declared extension
Single hat basisEquation (4.20), for the zero-extended referenceReference support convention and spacing
Affine field within interpolation cellsEndpoint-average attenuation times lengthCoordinates and trilinear reproduction
Prescribed quadratic along a rayEquations (4.15)–(4.16)Midpoint error independently of volume sampling
Grid and endpoints translated togetherIdentical continuous indices and integralOrigin handling
Consistent grid-axis permutationSame physical field and projectionStorage order versus geometry
Reversed endpointsSame physical integralDirection signs and endpoint weights
Increasing one coefficientNondecreasing optical depths for affected raysBasis weights and accumulation

Set absolute and relative error tolerances in the quantity being tested. Coordinates are in millimetres, while optical depth is dimensionless. Near zero, an absolute optical-depth tolerance carries the comparison, because a relative error alone divides by a vanishing reference. Keep a separate tolerance for the quadrature error predicted by an analytic test and the floating-point difference between two evaluations of the same quadrature.

Once those checks hold, a pose update has a definite meaning: it moves a set of physical samples through one declared field and changes their weighted sum. We can now ask for the derivative of that operation, including the places where the sample pattern or interpolation branch changes.

References

  1. Siddon, Robert L. (1985). Fast calculation of the exact radiological path for a three-dimensional CT array. Medical Physics, 12(2), 252-255. https://doi.org/10.1118/1.595715