Chapter 05Rev. 1.0.0

Derivatives of the projection, from voxels to pose

A useful projection derivative tells us how the predicted image responds to changes in attenuation or geometry. We follow those changes through interpolation and ray integration, then check the result independently.

Now we move the estimated vertebra a fraction of a millimetre. Some rays acquire more bone, some lose it, and some barely change. A registration method needs to know how each input changes its mismatch with the measured image (this is the heart of differentiability). Rendering both poses gives a finite difference, and a derivative computes that local response without requiring a second render for every parameter (this is the heart of differentiability without going bankrupt from GPU bills).

The exponential is the easy part. Chapter 2 already gave its derivative. Most of the work lies upstream: the pose changes sample locations, the interpolant changes beneath them, and clipping can move the integration interval. A plausible gradient image can omit one of those dependencies just as a plausible projection can contain a half-voxel error. The forward model is still the primary, fixed-energy measurement from the preceding chapters.

The recorded pelvic example below shows the projection and its six local count sensitivities. The anatomy is synthetic, the beam is fixed, and each displayed pose selects a separately recorded calculation. Translation and rotation retain their different derivative units. The image alone is not a derivative check.

The 3D anatomy loads as this figure comes into view. The recorded images are available below.

tx
0 mm
ty
0 mm
tz
0 mm
ωx
0 rad
ωy
0 rad
ωz
0 rad
Drag an axis handle. Each move uses one recorded coordinate. The other coordinates return to zero.

Rendered projection

Logarithmic count display · 0–1000 counts

Reference pose

Translation

counts/mm

Symmetric asinh colour scale

Rotation

counts/rad

Symmetric asinh colour scale

The six derivatives are evaluated at the reference pose, including when a perturbed projection is selected.

zero-based detector indices
Projectioncounts
tx counts/mm
ty counts/mm
tz counts/mm
ωx counts/rad
ωy counts/rad
ωz counts/rad

Pixel values load from the recorded execution data.

Independent finite-difference check status loads alongside the recorded samples.

What is shown, and where the data came from

The CT was generated using MAISI [36] and the bone mask was enhanced for visual clarity.

The illustrative 80 keV water-equivalent model converts synthetic CT values to attenuation with μ = 0.01837 × max(0, 1 + HU/1000) mm⁻¹. A primary-only cone beam and ideal count response use 1000 expected open-beam counts per pixel. The source is 750 mm anterior to the sacral pivot and the detector is 450 mm posterior to it. Scatter and spectral effects are omitted. The pivot and RAS coordinates are fixed across all recorded poses. Rotational derivatives use radians.

Display transforms preserve the stored values used by the pixel inspector. The grey level is log1p(F) / log1p(1000). For a family limit L, the signed colour coordinate is asinh(J / (0.02 L)) / asinh(50). The translation and rotation limits are separate. No values are clipped.

Drag an arrow to translate the pelvis or a ring to rotate it. Movements snap to the recorded poses. Translations span -2 to 2 mm in 0.5 mm increments, and rotations span -0.05 to 0.05 rad in 0.01 rad increments. Each move uses one coordinate at a time, while the other coordinates return to zero. The camera stays fixed. Image right is model right and the top is superior. The full CT supplies the projection. The 3D view shows its pelvic bone surfaces.

The Δ toggle subtracts the previous displayed projection from the current one, in counts. Its blue–rust colour scale is symmetric about zero, with limits set by the largest absolute difference in that pair. Before the first movement, the difference is zero. The pixel inspector reports the displayed counts or difference. The six derivatives stay at the reference pose. Switching the toggle does not change the comparison frame.

Projection and sensitivity colours use the recorded display scales. The browser selects recorded projections, subtracts their stored counts in Δ mode and transforms the supplied surfaces. It does not calculate photon transport. Recorded CUDA calculation and validation. Bone-label source: TotalSegmentator v2, CC BY 4.0.

Downloads: static figure · figure metadata · recorded float32 values.

Figure 5.1A projection and its six pose sensitivitiesThe six signed sensitivity images show where a small pose change increases or decreases expected counts for the synthetic pelvis in a fixed AP beam. Their different spatial patterns reveal which image features constrain each pose coordinate.

We now extend the existing projector with dpt.projection.projection_vjp. It receives the optical-depth cotangent produced by transmission_vjp and returns derivatives for the selected inputs. Chapter 6’s pose evaluator uses this operation to reduce the image’s contribution to six pose derivatives, without retaining a full image Jacobian.

5.1 Which projection derivative do we need?

Let θ\boldsymbol{\theta} collect the active inputs: perhaps the six local pose coordinates, perhaps volume coefficients, perhaps both. Hold the other inputs fixed. The renderer produces a vector of expected primary counts, and a scalar objective compares that prediction with observed data y\mathbf{y}:

λ=F(θ),Φ(θ)=D ⁣(F(θ),y).\begin{gathered} \boldsymbol{\lambda}=\mathcal{F}(\boldsymbol{\theta}),\\ \Phi(\boldsymbol{\theta}) =\mathcal{D}\!\left(\mathcal{F}(\boldsymbol{\theta}),\mathbf{y}\right). \end{gathered}
(5.1)

Equation (5.1) separates the renderer’s output from the comparison applied to it. Differentiating an expected count does not require sampling photons. A noise model matters when we choose D\mathcal D and interpret the observations.

If there are PP detector samples and KK active scalar parameters, the full Jacobian has PKP K entries. Two products are usually more useful:

JF=λθ,v=JFη,g=JFTλ.\begin{gathered} \mathbf{J}_{\mathcal F} =\frac{\partial\boldsymbol{\lambda}}{\partial\boldsymbol{\theta}},\\ \mathbf{v}=\mathbf{J}_{\mathcal F}\boldsymbol{\eta},\\ \mathbf{g}=\mathbf{J}_{\mathcal F}^{\mathsf T}\overline{\boldsymbol{\lambda}}. \end{gathered}
(5.2)

In equation (5.2), a Jacobian-vector product, or JVP, predicts the image change along the parameter direction η\boldsymbol{\eta}. A vector-Jacobian product, or VJP, carries the detector weights λ\overline{\boldsymbol{\lambda}} back to the inputs. In this chapter’s reverse rules, an overbar denotes a cotangent: the sensitivity of a downstream scalar to that variable.

For the objective above, choose λ=λD\overline{\boldsymbol{\lambda}}=\nabla_{\boldsymbol{\lambda}}\mathcal D. Then g\mathbf g is the parameter gradient. Reverse mode is attractive when one scalar depends on many inputs. With only six pose parameters, a small set of directional derivatives can also be practical. The mathematical requirement is the same product, regardless of the differentiation strategy.

The implemented API computes the VJP. adj_L is a caller-owned detector array whose precision matches the prepared optical-depth output: binary32 by default, or binary64 when explicitly requested. The requested destinations identify the derivative coordinates: out_pose holds six binary64 cotangents for a local right increment, while out_mu holds the flat binary32 coefficient cotangents. A separate out_pose_matrix destination contains twelve unconstrained storage partials for a tape chain rule. The six-coordinate result describes changes along valid local rigid motions. A tape differentiating the construction of the stored transform instead needs partials with respect to its entries, which it can pass through that construction to the upstream parameters. Those twelve entries are not six pose parameters with spare capacity. Requesting both pose representations in one call is rejected, and grid or acquisition calibration is fixed in this projector.

A derivative with respect to translation has count per millimetre units, while one with respect to rotation has count per radian units. To compare or optimise scaled coordinates, write a local increment as

δθ=Dδz,Jz=JFD,zΦ=DTθΦ.\begin{gathered} \delta\boldsymbol{\theta}=\mathbf{D}\,\delta\mathbf{z},\\ \mathbf{J}_{\mathbf z}=\mathbf{J}_{\mathcal F}\mathbf{D},\\ \nabla_{\mathbf z}\Phi=\mathbf{D}^{\mathsf T}\nabla_{\boldsymbol{\theta}}\Phi. \end{gathered}
(5.3)

Here z\mathbf z is dimensionless and the diagonal matrix D\mathbf D supplies declared physical step scales. Equation (5.3) changes the coordinates of the derivative, but it does not add information to the image. Pose increments still compose with the current transform on the side chosen in Chapter 3.

5.2 Attenuation and transmission derivatives

For one detector sample, the expected primary count is λp=n0,pexp(L^p)\lambda_p=n_{0,p}\exp(-\widehat L_p). Let both optical depth and open-beam expectation be active. Their differential is

dλp=Tpdn0,pλpdL^p,dTp=TpdL^p.\begin{gathered} \mathrm d\lambda_p =T_p\,\mathrm dn_{0,p} -\lambda_p\,\mathrm d\widehat L_p,\\ \mathrm dT_p=-T_p\,\mathrm d\widehat L_p. \end{gathered}
(5.4)

Equation (5.4) supplies two distinct paths for an image change. Increasing illumination raises expected counts, while increasing attenuation lowers them. Holding the open beam fixed removes the first term. If it is itself calibrated by active acquisition parameters, that dependency remains part of the derivative.

For a fixed ray and sample pattern, Chapter 4’s linear optical-depth operator gives

L^p=vWpvav,L^pav=Wpv,λpav=λpWpv.\begin{gathered} \widehat L_p=\sum_v W_{pv}a_v,\\ \frac{\partial\widehat L_p}{\partial a_v}=W_{pv},\\ \frac{\partial\lambda_p}{\partial a_v}=-\lambda_p W_{pv}. \end{gathered}
(5.5)

The coefficient WpvW_{pv} in equation (5.5) has units of millimetres and includes both interpolation weights and physical path weights. It is zero when the ray’s sampled basis support never reaches coefficient vv. Increasing a nonnegative coefficient cannot increase the expected primary count for this field model.

The corresponding reverse rules are

Lp=λpλp,n0,p=Tpλp,av=pWpvLp.\begin{gathered} \overline L_p=-\lambda_p\overline\lambda_p,\\ \overline n_{0,p}=T_p\overline\lambda_p,\\ \overline a_v=\sum_p W_{pv}\overline L_p. \end{gathered}
(5.6)

Equation (5.6) turns an image-space cotangent into an attenuation-field gradient. If one open-beam scalar is shared across pixels, its cotangent is the sum of the per-pixel contributions. A per-pixel illumination array has a different derivative shape, even if all its values happen to be equal.

Strong attenuation makes λp\lambda_p small. For a fixed, bounded count-space seed, its contribution to Lp\overline L_p then becomes small too. The objective can alter that seed substantially. With known positive n0,pn_{0,p}, the Poisson negative log-likelihood, after dropping terms independent of LpL_p, has

Φp(Lp)=n0,peLp+ypLp,dΦpdLp=ypλp.\begin{gathered} \Phi_p(L_p)=n_{0,p}e^{-L_p}+y_pL_p,\\ \frac{\mathrm d\Phi_p}{\mathrm dL_p}=y_p-\lambda_p. \end{gathered}
(5.7)

Equation (5.7) can be evaluated without forming 1yp/λp1-y_p/\lambda_p, which becomes numerically troublesome when the predicted count is tiny. Its shortened objective assumes fixed illumination. Fitting n0,pn_{0,p} requires restoring the terms that depend on it. This is the same statistical distinction used in Chapter 2.

Derivatives must also retain the forward numerical policy. Computing a weighted derivative from an already-underflowed stored transmission can lose a representable result when the cotangent is large. Recompute the required exponential product with the declared precision and range, as for expected counts. The derivative of the intended smooth formula is the target, with floating-point error measured against it.

This is why the composition calls the existing transmission VJP with the original optical depths and illumination. Its optical-depth destination becomes adj_L for the projector. In a pose-only fit, the volume remains fixed and the open beam is a declared scalar, so the only shared reduction still needed is the six-coordinate pose gradient. If a later problem activates coefficients, the same projection traversal can request their gradient too.

For a Poisson fit, dpt’s recovery adapter currently composes these operators through a stored count-space seed. It rejects a positive observed count when the stored predicted mean is zero, and rejects an unrepresentable seed. Equation (5.7) permits a separately composed log-domain Poisson path for more extreme ranges, but the current adapter does not evaluate that path. Its intermediate count and seed arrays limit its numerical domain.

5.3 Geometry through the sampled field

A pose update moves the ray in object coordinates while the volume coefficients stay attached to the anatomy. For a fixed world point xW\mathbf{x}^W, let xO=RT(xWt)\mathbf{x}^O=\mathbf{R}^{\mathsf T}(\mathbf{x}^W-\mathbf{t}). Inverting the left and right updates from Chapter 3 gives the first-order motions

δxleftO=RTρWRT(ϕW×xW),δxrightO=ρOϕO×xO.\begin{gathered} \delta\mathbf{x}^O_{\mathrm{left}} =-\mathbf{R}^{\mathsf T}\boldsymbol{\rho}_W {}-\mathbf{R}^{\mathsf T}(\boldsymbol{\phi}_W\times\mathbf{x}^W),\\ \delta\mathbf{x}^O_{\mathrm{right}} =-\boldsymbol{\rho}_O-\boldsymbol{\phi}_O\times\mathbf{x}^O. \end{gathered}
(5.8)

The minus signs in equation (5.8) arise because we query the inverse pose. Translating the object to the right makes a fixed world ray visit object points farther to the left. These expressions differ from the active point motions in Chapter 3 because the point being held fixed is different.

Within an open trilinear interpolation cell, coefficient differences give the gradient with respect to grid coordinates. For example,

μAux=b,c{0,1}[A[mz+c,my+b,mx+1]A[mz+c,my+b,mx]]×wb(αy)wc(αz).\frac{\partial\mu_A}{\partial u_x} =\sum_{b,c\in\{0,1\}} \left[ A[m_z+c,m_y+b,m_x+1] {}-A[m_z+c,m_y+b,m_x] \right] {}\times w_b(\alpha_y)w_c(\alpha_z).
(5.9)

Equation (5.9) differentiates the interpolation weights while the selected cell stays fixed. The other components follow by exchanging directions. The zero-extended reference uses zero outside coefficients. The implemented half-cell sampler instead suppresses the slope along a clamped edge axis, and the interior formula is unchanged. Multiplying by QS1\mathbf{Q}\mathbf{S}^{-1} converts this grid gradient into xOμA\nabla_{\mathbf{x}^O}\mu_A with units mm2\mathrm{mm}^{-2}.

Now let one scalar parameter be θ\theta. For a fixed sample count in a region where the clipping and interpolation branches are differentiable, the discrete optical-depth derivative is

L^pθ=r[wprθμA(xprO)+wprμA(xprO)TxprOθ].\frac{\partial\widehat L_p}{\partial\theta} =\sum_r \left[ \frac{\partial w_{pr}}{\partial\theta}\mu_A(\mathbf{x}_{pr}^O) {}+w_{pr}\nabla\mu_A(\mathbf{x}_{pr}^O)^{\mathsf T} \frac{\partial\mathbf{x}_{pr}^O}{\partial\theta} \right].
(5.10)

Equation (5.10) displays both ways geometry changes a quadrature: it changes where we sample and how much physical distance each sample represents. This expression holds coefficients fixed. Active coefficient changes add the linear contribution from equation (5.5).

For samples interpolated between object-space endpoints, their derivative is

xprOθ=(1tpr)sOθ+tprqpOθ+(qpOsO)tprθ.\frac{\partial\mathbf{x}_{pr}^O}{\partial\theta} =(1-t_{pr})\frac{\partial\mathbf{s}^O}{\partial\theta} {}+t_{pr}\frac{\partial\mathbf{q}_p^O}{\partial\theta} {}+(\mathbf{q}_p^O-\mathbf{s}^O) \frac{\partial t_{pr}}{\partial\theta}.
(5.11)

The last term in equation (5.11) vanishes for a sample parameter fixed on the full source-to-detector segment. It generally remains when sample positions are redistributed over a pose-dependent clipped interval. Detaching the clipping result from automatic differentiation discards that motion.

In the dpt library, finite_interval() from Listing 4.3 returns the derivatives of whichever box faces currently determine entry and exit. A bound still fixed at the source or detector has zero derivative with respect to object motion. The VJP can therefore distinguish a sample moving because the inverse pose changed from a sample moving because its fraction of the clipped interval changed. It needs both effects even when every sampled coefficient happens to have the same value.

For equal midpoint quadrature with fixed MpM_p, write cr=(r+1/2)/Mpc_r=(r+1/2)/M_p. Differentiating the actual node and weight definitions gives

θtpr=(1cr)θtp+crθtp+,θwpr=tp+tpMpθdp+dpMp(θtp+θtp).\begin{gathered} \partial_\theta t_{pr} =(1-c_r)\partial_\theta t_p^- {}+c_r\partial_\theta t_p^+,\\ \partial_\theta w_{pr} =\frac{t_p^+-t_p^-}{M_p}\partial_\theta d_p {}+\frac{d_p}{M_p}(\partial_\theta t_p^+-\partial_\theta t_p^-). \end{gathered}
(5.12)

Equation (5.12) makes the clipping dependency explicit (Figure 5.2). Rigid object motion preserves the source-to-detector distance, so θdp=0\partial_\theta d_p=0 for those parameters. Moving source or detector parameters can change it. The active box face determines the derivative of each clipped endpoint away from ties.

A ray in object coordinates

Endpoints, clipped interval and sample positions A straight source-to-detector ray crosses a rectangular field support. Entry and exit determine the clipped interval. Four equally spaced midpoint nodes lie within it. This is a dependency schematic, not a recorded ray trace.sᴼqᴼₚField supportt⁻ₚt⁺ₚnode r
xprO=(1tpr)sO+tprqpO\mathbf x_{pr}^O=(1-t_{pr})\mathbf s^O+t_{pr}\mathbf q_p^O

The coefficients stay attached to the object. Inverse pose motion changes where the ray samples them.

Geometry enters twice

Pose incrementδθ  (sO,qpO)\delta\theta\ \longrightarrow\ (\mathbf s^O,\mathbf q_p^O)
Clip the finite segment(tp,tp+,dp)(t_p^-,t_p^+,d_p)
Node positionxprO\mathbf x_{pr}^OμA(xprO)\mu_A(\mathbf x_{pr}^O)
Physical weightwpr=dp(tp+tp)Mpw_{pr}=\frac{d_p(t_p^+-t_p^-)}{M_p}millimetres per node
L^p=rwprμA(xprO)\widehat L_p=\sum_r w_{pr}\mu_A(\mathbf x_{pr}^O)λp=n0,peL^p\lambda_p=n_{0,p}e^{-\widehat L_p}
Changing physical weightsr(θwpr)μA(xprO)\sum_r (\partial_\theta w_{pr})\mu_A(\mathbf x_{pr}^O)
Moving through the fieldrwprμATθxprO\sum_r w_{pr}\nabla\mu_A^{\mathsf T}\partial_\theta\mathbf x_{pr}^O

Fixed sample count and differentiable clipping/interpolation branches. Active coefficients add their basis-weight contribution separately.

Figure 5.2The geometric paths of a projection derivativeObject motion changes the object-space ray endpoints, clipped interval and sampling locations before it changes the detector counts. A pose derivative must follow each of these dependencies, including the motion of the entry and exit points.

5.4 Discretise and differentiate

We have differentiated a finite weighted sum. The derivative of a continuous line integral provides another useful comparison, but it has its own hypotheses. Suppose f(t,θ)f(t,\theta) is differentiable on the changing interval, with enough regularity to differentiate under the integral. Then

ddθa(θ)b(θ)f(t,θ)dt=abθf(t,θ)dt+f(b,θ)b(θ)f(a,θ)a(θ).\frac{\mathrm d}{\mathrm d\theta} \int_{a(\theta)}^{b(\theta)}f(t,\theta)\,\mathrm dt ={}\int_a^b\partial_\theta f(t,\theta)\,\mathrm dt {}+f(b,\theta)b'(\theta) {}-f(a,\theta)a'(\theta).
(5.13)

For our ray, f=dpμA(xpO(t))f=d_p\mu_A(\mathbf{x}_p^O(t)). The endpoint terms in equation (5.13) account for changing integration bounds. At an exterior support face of the continuous zero-extended hat field, the field value is zero, so that face’s continuous endpoint term vanishes. Differentiating a finite midpoint sum still requires its moving weights and sample positions: those terms combine to approximate the continuous derivative.

The implemented clamped field can have a nonzero trace at its outer half-cell face. Its continuous endpoint term then remains, as does the corresponding contribution from the changing finite quadrature. This is one reason to keep the zero-extended reference and the executable field distinct: importing the reference’s vanishing endpoint term would remove a real sensitivity from the code’s declared model.

A sharp boundary shows why the distinction matters. Consider a one-dimensional material of coefficient μ0>0\mu_0>0 occupying [a,b][a,b] inside a fixed ray segment, with bb fixed and aa moving. Its integral is

L(a)=μ0(ba),dLda=μ0.\begin{gathered} L(a)=\mu_0(b-a),\\ \frac{\mathrm dL}{\mathrm da}=-\mu_0. \end{gathered}
(5.14)

The boundary derivative in equation (5.14) is nonzero even though the coefficient is constant everywhere inside the material. Sampling its hard indicator at a fixed finite set of points produces a staircase as aa moves: automatic differentiation of each selected branch returns zero between sample crossings. That is a derivative of the sampled staircase, and it does not approximate the moving-boundary derivative pointwise by merely increasing the number of samples.

Figure 5.3 separates the derivative of the continuous interval from that of the sampled staircase.

The integral changes, but fixed samples jump

Optical depth L against Entry position a (mm). 8 fixed samples, 16 fixed samples, 32 fixed samples and Exact interval.Optical depth L00.511.5102030405060Entry position a (mm)
  • 8 fixed samples
  • 16 fixed samples
  • 32 fixed samples
  • Exact interval

Their derivatives remain different

Entry derivative (mm⁻¹) against Entry position a (mm). Exact interval and 8-sample branches.Entry derivative (mm⁻¹)−0.02−0.010102030405060Entry position a (mm)
  • Exact interval
  • 8-sample branches

μ0=0.02mm1,b=80mm\mu_0=0.02\,\mathrm{mm}^{-1},\quad b=80\,\mathrm{mm}The exit stays fixed while the entry moves.

dLda=μ0\frac{\mathrm dL}{\mathrm da}=-\mu_0Every sampled branch is flat. At a sample crossing its derivative is undefined. The gaps in the right-hand curve retain those events.

Figure data

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

Figure 5.3A moving boundary and a sampled staircaseMoving the interval boundary changes the exact optical depth smoothly, while fixed quadrature samples produce a staircase. Differentiating its flat branches gives zero and misses the boundary contribution, even as the sampled integral approaches the exact one.

Exact cell traversal represents boundary motion through changing segment lengths. A continuous interpolant represents a softened boundary through spatial gradients over a finite width. Each can support a useful derivative, but they differentiate different finite-resolution fields. The boundary convention changes the gradient seen by the inverse problem.

DiffDRR expresses ray-plane intersections, sorting and length-weighted sums as tensor operations, allowing PyTorch automatic differentiation to propagate geometric sensitivities through its projection. It provides a useful comparison with our sampled trilinear operator: one exposes lengths through intersected cells, the other exposes basis weights at quadrature nodes. [22]

The sample-count rule adds another discrete decision. If Mp=p/hM_p=\lceil\ell_p/h\rceil, changing pose can change both the number and positions of samples. At the transition, two different midpoint rules need not produce the same value. Within a fixed-count branch, equation (5.10) differentiates the selected quadrature. It does not describe a derivative across that transition.

One option is to keep sample counts fixed during each local optimisation stage and refine the discretisation between stages. Another is to use fixed sample parameters on the complete finite ray, accepting extra samples in zero-valued regions. Either choice has computational consequences. What matters for gradient verification is that the perturbed forward evaluations and the derivative use the same declared discretisation.

5.5 Reverse-mode differentiation in Warp

First obtain the cotangent of each optical depth from the detector objective. Then revisit the contributing samples. Each sample sends a contribution to the volume coefficients it read and, when geometry is active, a contribution through its position and weight to the pose parameters.

For one trilinear sample, with basis value Bv(xprO)B_v(\mathbf{x}_{pr}^O) for coefficient vv, the local reverse contributions are

av+=LpwprBv(xprO),xprO=LpwprμA(xprO),wpr=LpμA(xprO).\begin{gathered} \overline a_v\mathrel{+}=\overline L_p w_{pr}B_v(\mathbf{x}_{pr}^O),\\ \overline{\mathbf{x}}_{pr}^O =\overline L_p w_{pr}\nabla\mu_A(\mathbf{x}_{pr}^O),\\ \overline w_{pr}=\overline L_p\mu_A(\mathbf{x}_{pr}^O). \end{gathered}
(5.15)

Equation (5.15) is an execution guide for a VJP. Many rays can contribute to one volume coefficient, so those writes require a correct accumulation strategy. Pose-only differentiation avoids the volume-gradient scatter, but still needs to reduce per-ray contributions into a small shared parameter vector.

With P=10242P=1024^2 rays and M=512M=512 samples per ray, storing only three binary32 position coordinates per sample uses 6 GiB. Coefficient indices, interpolation weights and their adjoints add to that total. Reconstructing positions from the endpoints and sample index can replace that retained tensor with arithmetic in the backward pass.

A recomputed backward must reproduce the forward’s clipping, interpolation, sampling and arithmetic policies. If its branch selection differs because inputs were mutated or precision changed, it can scatter cotangents into different coefficients. Retain the original inputs and any branch decisions needed for exact replay, or prove that recomputation obtains them under the recorded configuration.

Warp 1.17 documents that dynamic loops are not automatically replayed or unrolled in the backward pass. A generated adjoint of a ray loop therefore needs an explicit account of its intermediate values. The loop counter, selected interpolation cell and any intermediate consumed after the loop must have the values required by the backward. Trace each of those reads to retained storage or a recomputation. A small differentiable sample function can expose the local calculation, and a custom VJP using equation (5.15) can organise the replay traversal explicitly.

The custom kernel reconstructs the same clipped midpoint sequence as the forward operator. Its accumulators separate changes to the ray origin and direction from changes to the two interval bounds. lower_seed and upper_seed combine the field sum with motion of the sample positions. Their opposite signs include the loss or gain of physical interval length. The transpose of object_to_grid then converts these grid-coordinate cotangents back to physical object coordinates before the final right-pose contraction.

Backpropagation through sampled attenuation and clippingpython/dpt/kernels/projection.pyL411–601
@cache
def get_vjp(
    active_volume: bool,
    matrix_gradient: bool,
    active_pose: bool,
    *,
    per_ray: bool = False,
    double_seed: bool = False,
    cell_gauss: bool = False,
):
    if per_ray and (active_volume or matrix_gradient or not active_pose):
        raise ValueError("per-ray diagnostics require only the six local pose coordinates")
    components = 12 if matrix_gradient else 6
    seed_dtype = wp.float64 if double_seed else wp.float32

    @wp.kernel(module="unique", module_options=OPTIONS)
    def vjp(
        field: wp.array(dtype=wp.float32),
        pose: wp.array(dtype=wp.float64),
        config: Configuration,
        seeds: wp.array(dtype=seed_dtype),
        volume_gradient: wp.array(dtype=wp.float32),
        partials: wp.array(dtype=wp.float64),
        status: wp.array(dtype=wp.int32),
    ):
        block, lane = wp.tid()
        pixel = block * BLOCK + lane
        gradient = Vec12d()
        if pixel < config.pixels:
            ray = ray_for_pixel(config, pose, pixel)
            if not valid_ray(ray):
                wp.atomic_or(status, 0, 2)
            else:
                interval = finite_interval(ray.origin, ray.direction, config.shape)
                span = interval.upper - interval.lower
                if span > wp.float64(0.0):
                    origin_gradient = wp.vec3d()
                    direction_gradient = wp.vec3d()
                    factor = wp.float64(seeds[pixel]) * ray.length
                    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
                                point = ray.origin + position * ray.direction
                                if wp.static(active_pose):
                                    value = sample_field(field, point, config.shape)
                                    origin_gradient += radius * value.gradient
                                    direction_gradient += radius * position * value.gradient
                                if wp.static(active_volume):
                                    scatter_field(
                                        volume_gradient,
                                        point,
                                        config.shape,
                                        factor * radius,
                                        status,
                                    )
                            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)
                        if wp.static(active_pose):
                            # Field continuity cancels all internal moving-cell terms.
                            # The zero-extended support still has external endpoint terms.
                            lower_value = support_value(
                                field, ray.origin + interval.lower * ray.direction, config.shape
                            )
                            upper_value = support_value(
                                field, ray.origin + interval.upper * ray.direction, config.shape
                            )
                            origin_gradient += (
                                upper_value * interval.upper_origin_gradient
                                - lower_value * interval.lower_origin_gradient
                            )
                            direction_gradient += (
                                upper_value * interval.upper_direction_gradient
                                - lower_value * interval.lower_direction_gradient
                            )
                    else:
                        mean = wp.float64(0.0)
                        direct_origin = wp.vec3d()
                        direct_direction = wp.vec3d()
                        shift_lower = wp.float64(0.0)
                        shift_upper = wp.float64(0.0)
                        for sample in range(config.samples):
                            alpha = (wp.float64(sample) + wp.float64(0.5)) / wp.float64(
                                config.samples
                            )
                            position = interval.lower + alpha * span
                            point = ray.origin + position * ray.direction
                            if not finite_point(point):
                                wp.atomic_or(status, 0, 2)
                            if wp.static(active_pose):
                                value = sample_field(field, point, config.shape)
                                mean += value.value
                                direct_origin += value.gradient
                                direct_direction += position * value.gradient
                                along = wp.dot(value.gradient, ray.direction)
                                shift_lower += (wp.float64(1.0) - alpha) * along
                                shift_upper += alpha * along
                            if wp.static(active_volume):
                                scatter_field(
                                    volume_gradient,
                                    point,
                                    config.shape,
                                    wp.float64(seeds[pixel])
                                    * ray.length
                                    * span
                                    / wp.float64(config.samples),
                                    status,
                                )
                        if wp.static(active_pose):
                            lower_seed = -mean + span * shift_lower
                            upper_seed = mean + span * shift_upper
                            origin_gradient = (
                                span * direct_origin
                                + lower_seed * interval.lower_origin_gradient
                                + upper_seed * interval.upper_origin_gradient
                            )
                            direction_gradient = (
                                span * direct_direction
                                + lower_seed * interval.lower_direction_gradient
                                + upper_seed * interval.upper_direction_gradient
                            )
                        factor /= wp.float64(config.samples)
                    if wp.static(active_pose):
                        source_adjoint = factor * (
                            wp.transpose(config.object_to_grid) * origin_gradient
                        )
                        direction_adjoint = factor * (
                            wp.transpose(config.object_to_grid) * direction_gradient
                        )
                        if wp.static(matrix_gradient):
                            for row in range(3):
                                for column in range(3):
                                    gradient[3 * row + column] = (
                                        ray.world_source_relative[row] * source_adjoint[column]
                                        + ray.world_direction[row] * direction_adjoint[column]
                                    )
                            translation_adjoint = -(unpack_rotation(pose) * source_adjoint)
                            for axis in range(3):
                                gradient[9 + axis] = translation_adjoint[axis]
                        else:
                            rotation_adjoint = wp.cross(
                                source_adjoint, ray.object_source
                            ) + wp.cross(direction_adjoint, ray.object_direction)
                            for axis in range(3):
                                gradient[axis] = -source_adjoint[axis]
                                gradient[axis + 3] = rotation_adjoint[axis]
        # The diagnostic writes the same ray derivative before any reduction.
        # Its caller owns O(6 P) storage; the production specialisation retains
        # only reduction partials and performs exactly its existing reduction.
        if wp.static(per_ray):
            if pixel < config.pixels:
                for component in range(components):
                    component_value = gradient[component]
                    partials[wp.int64(pixel) * wp.int64(components) + wp.int64(component)] = (
                        component_value
                    )
                    if not wp.isfinite(component_value):
                        wp.atomic_or(status, 0, 2)
        # All lanes, including padded rays, participate in the fixed reduction.
        elif wp.static(active_pose):
            for component in range(components):
                values = wp.tile(gradient[component])
                total = wp.tile_sum(values)
                wp.tile_store(partials, total, offset=block * components + component)

    return vjp

The flags passed to get_vjp() specialise the kernel when it is prepared. A field-only VJP needs basis weights but no field-value reads or pose reduction. A pose-only VJP needs values and spatial slopes but no scatter into a volume gradient. wp.static removes the unrequested work from each specialisation, so the flags do not branch separately for every ray at runtime.

For pose, each 128-lane block forms binary64 partial sums and subsequent kernels reduce those partials to the requested six or twelve entries. Padded lanes contribute zero and still participate in the block reduction. This avoids a numerical global atomic update for every ray and pose component. The active-volume path does use binary32 scatter atomics, because rays share coefficients. Their addition order is not deterministic, and a post-scatter check detects overflow after individually finite contributions. A fixed volume avoids that storage and contention altogether.

Warp records launches with wp.Tape, and arrays participating in differentiation use requires_grad=True. A supplied output-cotangent array seeds the VJP through Tape.backward(grads=...). The FirstOrderPass boundary checks that the seed has the output’s exact shape and dtype, contiguous storage and the same CUDA device before Warp copies it into the cotangent buffer. The pinned differentiation guide documents these calls and the buffer-overwrite rules. The forward inputs needed by the reverse calculation must remain available. Allocate the output and cotangent buffers before the repeated calls, keep them on the same CUDA device and use a declared stream order.

Table 5.1 identifies the reverse work and shared writes for each choice of active inputs.

Table 5.1. Projection reverse-mode execution decisions.
Active inputsMain reverse workShared writes
Volume coefficients onlyRecompute basis weights and scatter weighted optical-depth seedsMany rays can update one coefficient
Pose onlyRecompute spatial gradients and contract with endpoint derivativesReduce contributions to six pose coordinates
Volume and poseReuse sample values and gradients for both productsVolume scatter plus pose reduction
Per-pixel illuminationPointwise multiplication by transmissionIndependent output locations
Shared illumination scalarSum per-pixel illumination contributionsOne reduction

Measure both forward and backward traffic and occupancy, including contention where many rays update the same volume coefficients and memory retained between passes.

Clear or initialise gradient buffers according to the call’s accumulation contract. An isolated VJP can overwrite its output, while a node inside a larger reverse graph usually adds to an input’s existing cotangent. Repeated backward calls need explicit seed and reset rules.

FirstOrderPass makes a recorded evaluation’s lifetime explicit when the projector and transmission are used with Warp’s tape. Enter its context to record the forward work, pass its .tape to each participating operator, then call backward() after leaving the recording context. The caller supplies the detector output and its seed, and the workspaces perform their numerical checks before the pass is considered complete.

Seeding and releasing a first-order reverse passpython/dpt/autodiff.pyL24–86
@dataclass(slots=True)
class FirstOrderPass:
    """Own one forward/reverse evaluation and its explicit diagnostic checkpoint.

    Use as a context manager; pass .tape into canonical forward operators.
    Call backward after exiting the context. Keep original inputs immutable
    until close(). A new iteration requires a fresh context entry after close.
    """

    workspaces: Sequence[CheckedWorkspace]
    tape: Any = field(default=None, init=False, repr=False)
    _state: str = field(default="new", init=False)

    def __enter__(self) -> FirstOrderPass:
        if self._state != "new":
            raise ContractError("a FirstOrderPass owns exactly one evaluation")
        require_no_tape()
        for workspace in self.workspaces:
            workspace.clear_status()
        self.tape = load_warp().Tape()
        self.tape.__enter__()
        self._state = "recording"
        return self

    def __exit__(self, exc_type: Any, exc_value: Any, traceback: Any) -> None:
        self.tape.__exit__(exc_type, exc_value, traceback)
        self._state = "recorded"
        if exc_type is not None:
            self.close()

    def backward(self, output: Any, seed: Any) -> None:
        """Seed a caller-owned output cotangent and complete numerical checks."""
        if self._state != "recorded":
            raise ContractError("backward requires one completed, unconsumed forward pass")
        try:
            wp = load_warp()
            if not isinstance(output, wp.array) or output.grad is None:
                raise ContractError("output must be a Warp CUDA array with a preallocated gradient")
            ctx = prepare_context(device=output.device)
            ctx.array(output, "output", dtype=output.dtype, ndim=output.ndim)
            ctx.array(seed, "seed", dtype=output.dtype, shape=tuple(output.shape), ndim=output.ndim)
            self.tape.backward(grads={output: seed})
            for workspace in self.workspaces:
                workspace.check_status()
            self._state = "complete"
        except BaseException:
            self.close()
            raise

    def close(self) -> None:
        """Clear accumulated cotangents and release every abandoned recording."""
        if self._state == "recording":
            raise ContractError("exit the recording context before closing it")
        if self.tape is not None and self._state != "closed":
            self.tape.zero()
            self.tape.reset()
            for workspace in self.workspaces:
                discard = getattr(workspace, "discard_recording", None)
                if discard is not None:
                    discard(self.tape)
        self._state = "closed"

The original pose and field must remain unchanged through the reverse calculation. close() clears accumulated cotangents and resets the recording before releasing the workspaces. It also handles an abandoned forward pass, whose scratch cannot be reused while a later backward might still depend on it. These checks catch an ownership mistake before it turns into a gradient for a different pose. They do not add higher-order differentiation: the custom projection rule promises first-order products only. The recovery adapter uses explicit VJP calls and prepares its buffers once, which is sufficient for its single forward/reverse chain.

5.6 Independent directional checks

Choose a base pose and a direction in dimensionless scaled coordinates z\mathbf z. Build perturbed poses using the same left or right composition rule as the analytic derivative. For a vector output, a central difference estimates the JVP:

vh=F(z+hη)F(zhη)2h.\mathbf{v}_h =\frac{\mathcal F(\mathbf z+h\boldsymbol{\eta}) -\mathcal F(\mathbf z-h\boldsymbol{\eta})}{2h}.
(5.16)

Equation (5.16) uses a dimensionless step hh, and the physical perturbations come from the scaling matrix in equation (5.3). For pose parameters, the notation means composing the scaled increment with the base pose, not adding entries to a rotation matrix.

For the implemented VJP, hold a detector seed fixed and take its dot product with each perturbed forward output before forming the central difference. Compare that scalar difference with the dot product of the VJP cotangent, expressed in the same scaled parameter coordinates, and the chosen direction. Both calculations then predict the change in the same seeded scalar, so this check needs neither a stored image Jacobian nor a separately implemented JVP. The seed must correspond to the output being checked: optical depth for the projector alone, or expected counts for the composed rendering chain.

Check a range of steps. In a smooth region, central-difference truncation error decreases quadratically as hh shrinks, until subtraction and floating-point errors dominate. One favourable step size can conceal a wrong derivative. A useful discrepancy measure is

ϵh=vhv2max(s0,vh2,v2).\epsilon_h =\frac{\lVert\mathbf v_h-\mathbf v\rVert_2} {\max(s_0,\lVert\mathbf v_h\rVert_2,\lVert\mathbf v\rVert_2)}.
(5.17)

The scale s0s_0 in equation (5.17) has the same units as the image derivative. It prevents an almost-zero reference from turning a harmless absolute error into a large relative ratio. Report the absolute error too. The normalisation must not conceal a materially wrong signal change.

A scalar Taylor test avoids dividing two nearly equal output values by a very small step. For a differentiable scalar objective and direction, form

R(h)=Φ(z+hη)Φ(z)hΦ(z)Tη.R(h)=\left| \Phi(\mathbf z+h\boldsymbol{\eta})-\Phi(\mathbf z) {}-h\nabla\Phi(\mathbf z)^{\mathsf T}\boldsymbol{\eta} \right|.
(5.18)

With sufficient local smoothness, equation (5.18) has a quadratic remainder. Leaving the linear term out gives a first-order change when the directional derivative is nonzero. Seeing those two slopes separates a meaningful derivative correction from an objective that is simply insensitive to the chosen direction.

Figure 5.4 compares the error patterns used by the central-difference and Taylor checks.

Φ(z)=e(1+0.7z)\Phi(z)=e^{-(1+0.7z)}Central differences and a first-order Taylor check at z = 0

Central-difference error

Absolute derivative error against Perturbation h. The horizontal scale is logarithmic. The vertical scale is logarithmic. Binary64 central difference.Absolute derivative error10⁻¹²10⁻⁸10⁻⁴110⁻¹⁶10⁻¹²10⁻⁸10⁻⁴1Perturbation h
  • Binary64 central difference

Subtracting the predicted linear change

Absolute change or remainder against Perturbation h. The horizontal scale is logarithmic. The vertical scale is logarithmic. Uncorrected change, Binary64 remainder and 100-digit reference remainder.Absolute change or remainder10⁻³²10⁻²⁴10⁻¹⁶10⁻⁸110⁻¹⁶10⁻¹²10⁻⁸10⁻⁴1Perturbation h
  • Uncorrected change
  • Binary64 remainder
  • 100-digit reference remainder

Smooth truncationBefore round-off dominates, halving the step reduces both the central-difference error and Taylor remainder by about four.

Floating-point subtractionAt small steps the binary64 curves depart from that trend. The uncorrected change rounds to zero at h = 10⁻¹⁶. That point is absent from the logarithmic axis.

Figure data

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

Figure 5.4Directional checks across perturbation sizesCentral-difference errors and first-order Taylor remainders are plotted across perturbation sizes for an analytic transmission function. Their decrease over a range of steps, followed by rounding effects at small steps, is more informative than agreement at a single step size.

Analytic fields provide references that share no interpolation code with the implementation. For μ(xO)=μ0+bTxO\mu(\mathbf{x}^O)=\mu_0+\mathbf{b}^{\mathsf T}\mathbf{x}^O along a finite segment entirely inside an affine interpolation region,

Lp=dp[μ0+bTsO+qpO2],tLp=dpRb.\begin{gathered} L_p=d_p\left[\mu_0+ \mathbf b^{\mathsf T}\frac{\mathbf s^O+\mathbf q_p^O}{2}\right],\\ \nabla_{\mathbf t}L_p=-d_p\mathbf R\mathbf b. \end{gathered}
(5.19)

The translation derivative in equation (5.19) holds the world ray and object rotation fixed and differentiates with respect to world translation. The vector b\mathbf b has units mm2\mathrm{mm}^{-2}. Choose μ0\mu_0 so attenuation remains nonnegative on the tested neighbourhood. This case checks inverse-pose signs, the physical spacing conversion and distance weights without a moving support boundary.

Test the volume derivative separately using coefficient directions and the fixed-geometry linear operator. Then test pure translations, pure rotations and mixed directions. Mixed directions matter: separate component checks can miss an error in the way shared intermediate contributions are accumulated.

experiments/projection-gradients/run.py carries out the six right-pose directional sweeps with a prescribed signed detector seed and an asymmetric quadratic field. Its gradients.json keeps two comparisons: differences of actual CUDA forward outputs, and differences of an independent binary64 implementation of the same fixed-count quadrature over the uploaded coefficients. The former includes rounding each optical depth to binary32, while the latter exposes the derivative of the unrounded discrete formula. Their small-step error floors should differ. Requiring both to reach the lower floor would confuse the stored interface with the arithmetic used to evaluate its derivative.

The GPU regression suite also includes exact small-slope cases beside a large orthogonal field background. These exercise the corner-difference arrangement in the sampler: an ordinary smooth phantom can pass while a representable transverse derivative disappears through cancellation. The acceptance target remains the independent derivative, not whichever answer looks most plausible after plotting it.

5.7 Boundaries, conditioning and failure cases

Between its exterior support faces, a trilinear field is continuous but not generally differentiable at its knot planes. The implemented field can additionally jump at a nonzero half-cell boundary. In one dimension, let adjacent slopes on either side of a sample centre be gg_- and g+g_+. At the knot,

limh0f(0+h)f(0)h=g+,limh0f(0)f(0h)h=g.\begin{gathered} \lim_{h\downarrow0}\frac{f(0+h)-f(0)}{h}=g_+,\\ \lim_{h\downarrow0}\frac{f(0)-f(0-h)}{h}=g_-. \end{gathered}
(5.20)

If the two values in equation (5.20) differ, no ordinary derivative exists there. A symmetric finite difference tends to their average, while an executed interpolation branch may report either one-sided slope. Agreement with that average is therefore not the acceptance criterion for a branch derivative. Move the base point into a regular cell for a smooth test, and test the knot with its stated one-sided behaviour.

A physical tangency can also create a large derivative. For a homogeneous sphere of radius RR and a line whose perpendicular distance from the centre is b<Rb<R, assume the finite source-detector segment contains the whole chord. Then

L(b)=2μR2b2,dLdb=2μbR2b2.\begin{gathered} L(b)=2\mu\sqrt{R^2-b^2},\\ \frac{\mathrm dL}{\mathrm db} =-\frac{2\mu b}{\sqrt{R^2-b^2}}. \end{gathered}
(5.21)

Equation (5.21) diverges as the line approaches tangency from inside. This is a property of the sharp sphere model. A sampled smooth boundary can regularise it at the cost of changing that model. A large gradient near a boundary and a missing gradient caused by a detached clipping operation require different repairs.

Figure 5.5 shows why a small signal can coexist with a steep boundary derivative.

L(b)=2μ0R2b2,0b<RL(b)=2\mu_0\sqrt{R^2-b^2},\qquad 0\leq b<RR = 10 mm, μ₀ = 0.02 mm⁻¹

The chord vanishes at the edge

Optical depth L against Impact parameter b (mm). Exact sphere chord.Optical depth L00.10.20.30.40246810Impact parameter b (mm)
  • Exact sphere chord

The interior derivative grows towards tangency

Derivative magnitude (mm⁻¹) against Distance to tangency R − b (mm). The horizontal scale is logarithmic. The vertical scale is logarithmic. Interior |dL/db|, with tangency to the left.Derivative magnitude (mm⁻¹)10⁻⁴0.01110010,00010⁶10⁻¹⁴10⁻¹⁰10⁻⁶0.0110Distance to tangency R − b (mm)
  • Interior |dL/db|, with tangency to the left

b=R:L=0,limbRdLdb=b=R:\quad L=0,\qquad \lim_{b\to R^-}\frac{\mathrm dL}{\mathrm db}=-\inftyThere is no finite derivative at tangency. The logarithmic panel contains only positive magnitudes at interior points.

Figure data

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

Figure 5.5Sensitivity at the edge of a sphereAs a ray approaches tangency to a sharp sphere, its optical depth falls to zero while the magnitude of its positional derivative grows without bound. A weak grazing-ray signal can therefore be extremely sensitive to motion.

A uniform region may barely change when translated along directions that preserve the traversed material. Symmetry can make several poses indistinguishable. Strong attenuation can suppress count-space sensitivity. The derivative can be correct and small in all these cases. Adding a numerical floor to force a gradient invents a response that the forward model does not have.

Use spatially asymmetric fields, unequal spacings and off-centre rotation pivots to expose coordinate errors. Also include deliberately insensitive cases, where the expected derivative is zero. Tests consisting only of large nonzero gradients will miss accidental offsets and spurious boundary responses.

During debugging, inspect the first layer at which agreement is lost: point motion, interpolated field value, optical depth, detector expectation or scalar objective. A forward finite difference of the final loss combines all of them. Layered analytic cases make the sign or scale error much easier to locate without lowering the final acceptance standard.

5.8 Gradient validation as an executable contract

A derivative result is identified by more than its output array. Record the active parameters, their frame and units, the update side and centre, the field extension, interpolation, sampling policy and precision. Those choices define which mathematical function the derivative claims to approximate.

For any consistent JVP and VJP under Euclidean inner products, parameter direction η\boldsymbol\eta and detector seed λ\overline{\boldsymbol{\lambda}} satisfy

λT(JFη)=ηT(JFTλ).\overline{\boldsymbol{\lambda}}^{\mathsf T}(\mathbf J_{\mathcal F}\boldsymbol\eta) =\boldsymbol\eta^{\mathsf T}(\mathbf J_{\mathcal F}^{\mathsf T}\overline{\boldsymbol{\lambda}}).
(5.22)

Equation (5.22) checks adjoint consistency without storing the Jacobian. Use several independent, asymmetric directions and seeds. This check catches missing accumulation terms and mismatched interpolation weights, but two mutually consistent implementations of the wrong operator can still pass. The analytic forward and directional checks remain necessary.

If the desired inner products include quadrature or voxel-volume weights, the corresponding adjoint changes. With positive definite weighting matrices Mθ\mathbf M_\theta and My\mathbf M_y,

JF=Mθ1JFTMy.\mathbf J_{\mathcal F}^{*} =\mathbf M_\theta^{-1}\mathbf J_{\mathcal F}^{\mathsf T}\mathbf M_y.
(5.23)

Equation (5.23) distinguishes that weighted adjoint from the ordinary array transpose computed by a Euclidean VJP. Introduce such weights through the objective and parameter convention deliberately. Multiplying by a voxel volume in one direction only breaks the identity being tested. Record the conventions and evidence in Table 5.2 alongside each gradient check.

Table 5.2. Gradient validation record.
RecordRequired content
Forward definitionField coefficients, grid map, finite rays, interpolation and boundary rule
Active inputsParameter names, units, shape, pose update side and pivot
ReferenceIndependent formula, exact arithmetic case or separately derived directional construction
PerturbationsBase values, scaled directions and complete step-size sweep
Error criteriaAbsolute and relative tolerances, derivative scale and expected convergence regime
Reverse executionSeed values, overwrite or accumulation rules, replay inputs and buffer lifetimes
Arithmetic and deviceDtypes, compiler options, Warp version and device on which results were obtained
Boundary casesKnots, clipping ties, zero-length intersections, misses and sample-count transitions
EvidenceForward/JVP/VJP discrepancies, adjoint residuals and reproducible configuration

Run fresh-buffer and reused-buffer cases, repeat the backward pass under its documented reset rule, and test shared-input accumulation inside a larger computation. For a CUDA operator, include real device execution, memory checks and both ordinary and exceptional numerical regimes in that record.

Choosing a data term, controlling step size and deciding whether a fitted pose explains the acquisition are the next tasks. Chapter 6 uses these derivatives to recover pose.

References

  1. Gopalakrishnan, Vivek and Golland, Polina (2023). Fast Auto-differentiable Digitally Reconstructed Radiographs for Solving Inverse Problems in Intraoperative Imaging. Clinical Image-Based Procedures, 13746, 1-11. Cham: Springer. https://doi.org/10.1007/978-3-031-23179-7_1