Chapter 02Rev. 1.0.0

From photon survival to the transmission law

Each layer of material removes a fraction of the surviving primary photons. This local rule gives us the transmission law and tells us how the detector signal responds to changes along a ray.

Suppose our estimated pose puts the edge of a bone slightly to the left of where it appears in an X-ray. Moving the model changes which source-to-detector paths cross the bone, and how far they travel through it. Along some paths, bone replaces soft tissue, while along others, the reverse happens. To use that movement in registration, we need to predict the resulting change in detector signal.

Each extra layer removes a fraction of the photons that would otherwise reach the detector without interacting. The resulting change in expected count is smaller as the attenuation of the path increases. Two equal changes in thickness can therefore produce very different changes in a pixel, even for the same added material and photon energy. A derivative with respect to motion must account for the attenuation along the whole path.

We can derive that dependence by following a photon through a short stretch of material. The attenuation coefficient specifies its local probability of an interaction per unit distance, conditional on survival so far. Applying this rule successively gives survival through the whole object. For a homogeneous material, it gives transmission as a function of thickness, and for several materials, it tells us how their contributions combine. The exponential attenuation law follows from this local rule.

Moving the object changes the paths through the material while leaving its attenuation coefficients fixed.

2.1 From incident photons to a transmission image

Let us choose one detector pixel, indexed by pp, and follow a single straight path to it from a point source. We assume the object is stationary during the exposure and every photon has the same specified energy EE. Our ideal detector counts each photon reaching the pixel once, with no electronic noise, saturation or mixing between pixels. A physical pixel has finite area, so representing it by one ray is a spatial approximation.

A point source, a homogeneous slab and selected detector pixel p A straight representative ray of photon energy E in keV connects the point source to pixel p. The highlighted segment starts at the slab entry and ends at its exit. Its length d in millimetres is measured along the ray. A separate dimension marks the shorter slab thickness perpendicular to its faces.Homogeneous slabPoint sourceE (keV)EntryExitd (mm)pNormal thickness (mm)Detector A point source, a homogeneous slab and selected detector pixel p A straight representative ray of photon energy E in keV connects the point source to pixel p. The highlighted segment starts at the slab entry and ends at its exit. Its length d in millimetres is measured along the ray. A separate dimension marks the shorter slab thickness perpendicular to its faces.Homogeneous slabPoint sourceE (keV)EntryExitd (mm)pNormal thickness(mm)Detector
Figure 2.1Which ray belongs to a pixel?The red segment measures the path to pixel p through the slab. Its oblique length, rather than the perpendicular slab thickness, determines attenuation.

A photon contributes to the primary signal only if it traverses the object without an interaction. Both absorption and scattering remove it from that contribution, even if a scattered photon eventually reaches the detector somewhere else. Primary attenuation therefore uses the total coefficient for removal from the uncollided beam. [2]

Let n0,pn_{0,p} be the expected number of photons that would reach the ideal pixel during the exposure if the object were absent. This is an open-beam quantity defined at the detector. Source output, source-to-detector distance and the pixel’s geometric acceptance have already been included in it. Multiplying by another inverse-square factor would count that geometry twice.

If TpT_p is the probability that one of these photons survives the object without interacting, the expected primary count is

λp=n0,pTp.\lambda_p=n_{0,p}T_p.
(2.1)

An expected count can be fractional, although the realised count NpN_p is an integer. The deterministic renderer predicts λp\lambda_p or TpT_p, depending on which quantity we compare with data. It can calculate either expectation directly, without sampling photons.

To compare that deterministic prediction with a realised count, we also need a model of the count’s fluctuations around its mean. The following calculation supplies the observation model used by the likelihood in section 2.5; it does not add random sampling to the renderer.

Conditional on exactly mm incident photons, assume independent survival events with the same probability TpT_p. Then

NpN0,p=mBinomial(m,Tp),E[NpN0,p=m]=mTp.N_p\mid N_{0,p}=m \sim\operatorname{Binomial}(m,T_p), \qquad \mathbb E[N_p\mid N_{0,p}=m]=mT_p.
(2.2)

If the incident count is instead Poisson with mean n0,pn_{0,p}, the probability-generating function of the surviving count is

E[zNp]=E[(1Tp+Tpz)N0,p]=exp ⁣[n0,pTp(z1)].\begin{aligned} \mathbb E[z^{N_p}] &=\mathbb E[(1-T_p+T_pz)^{N_{0,p}}]\\ &=\exp\!\left[n_{0,p}T_p(z-1)\right]. \end{aligned}
(2.3)

This is the generating function of a Poisson distribution, giving

NpPoisson(λp),E[Np]=Var(Np)=λp.N_p\sim\operatorname{Poisson}(\lambda_p), \qquad \mathbb E[N_p]=\operatorname{Var}(N_p)=\lambda_p.
(2.4)

This Poisson result applies to the ideal photon count under the source and survival assumptions above. It does not generally describe the signal from a detector that integrates randomly deposited energy rather than counting arriving photons. [2] Appendix A.9 explains the distinction.

The incident count N0,pN_{0,p} also belongs to the exposure being modelled, not to a separate flat-field calibration. A flat-field image can help estimate the open-beam mean n0,pn_{0,p}, but it brings its own measurement uncertainty.

2.2 Attenuation coefficients and units

At a fixed energy, the linear attenuation coefficient μ(x,E)\mu(\mathbf x,E) specifies the local interaction probability per unit path length for a photon that has survived to x\mathbf x. More precisely, along a path parameterised by physical distance ss, the conditional probability of an interaction in a short interval is

Pr ⁣(interaction in [s,s+Δs]survival to s)=μ(s,E)Δs+o(Δs).\Pr\!\left(\text{interaction in }[s,s+\Delta s] \mid\text{survival to }s\right) =\mu(s,E)\,\Delta s+o(\Delta s).
(2.5)

The remainder divided by Δs\Delta s tends to zero as the interval shrinks. Treating μΔs\mu\Delta s as an exact interaction probability for an arbitrarily thick segment would eventually produce probabilities larger than one, which is a fairly decisive hint that the approximation has expired.

We use LpL_p to denote optical depth. Despite its letter, LpL_p is dimensionless. We will use dd for the physical length of a homogeneous segment and ss for distance along a path. With that, we can now formulate the dramatis personae of photon transmission. Table 2.1 collects the quantities and their units so that physical length and optical depth remain distinct.

Table 2.1. Transmission quantities and units.
QuantityMeaningUnits used here
EEPhoton energykeV\mathrm{keV}
ss, ddPhysical path distance or segment lengthmm\mathrm{mm}
μ\muLinear attenuation coefficientmm1\mathrm{mm^{-1}}
ρ\rhoMass densitygcm3\mathrm{g\,cm^{-3}}
μ/ρ\mu/\rhoMass attenuation coefficientcm2g1\mathrm{cm^2\,g^{-1}}
LpL_pIntegrated attenuation, or optical depthDimensionless
TpT_pPrimary survival probabilityDimensionless
n0,pn_{0,p}, λp\lambda_pExpected open-beam and transmitted countsPhotons per exposure

Physical tables often provide the mass attenuation coefficient. Multiplication by the material density gives the linear coefficient:

μ=ρ(μρ).\mu=\rho\left(\frac{\mu}{\rho}\right).
(2.6)

With the units in the table, the product is in cm1\mathrm{cm^{-1}}. Conversion to inverse millimetres divides the numerical value by ten. The density convention and length conversion must both be explicit before the coefficient enters an array.

For example, the NIST liquid-water table gives μ/ρ=0.2059 cm2g1\mu/\rho=0.2059\ \mathrm{cm^2\,g^{-1}} at 0.060 MeV0.060\ \mathrm{MeV}, or 60 keV60\ \mathrm{keV}. At an assumed density of 1.0 gcm31.0\ \mathrm{g\,cm^{-3}},

μ=0.2059 cm1=0.02059 mm1.\mu=0.2059\ \mathrm{cm^{-1}} =0.02059\ \mathrm{mm^{-1}}.
(2.7)

This is a helpful figure, and anyone in synthetic radiography ought to know it if roused from sleep at 3am. The neighbouring NIST column contains the mass energy-absorption coefficient, μen/ρ\mu_{\mathrm{en}}/\rho. It concerns energy absorption and is not interchangeable with the total mass attenuation coefficient used for primary survival. The attenuation total includes scattering interactions as well as absorption. [8]

For a non-negative coefficient with a finite integral along the path, optical depth is non-negative and transmission is strictly positive. Zero transmission occurs as an infinite-optical-depth limit, although finite-precision arithmetic can return zero much earlier.

2.3 The homogeneous path

Consider a homogeneous material with constant coefficient μ\mu at the chosen energy. Suppress the pixel and energy indices while following a photon through it. Let T(s)T(s) denote survival through a distance ss, with T(0)=1T(0)=1.

A photon survives to s+Δss+\Delta s if it survives to ss and then survives the next interval. The local interaction law gives

T(s+Δs)=T(s)[1μΔs+o(Δs)].T(s+\Delta s) =T(s)\left[1-\mu\Delta s+o(\Delta s)\right].
(2.8)

Subtract T(s)T(s), divide by Δs\Delta s and take the limit:

dTds=μT,T(0)=1.\frac{\mathrm dT}{\mathrm ds}=-\mu T, \qquad T(0)=1.
(2.9)

Solving this initial-value problem yields the Beer–Lambert transmission law,

T(d)=eμd,L=μd,λ=n0eμd.T(d)=e^{-\mu d}, \qquad L=\mu d, \qquad \lambda=n_0e^{-\mu d}.
(2.10)

Each additional millimetre removes a fraction of the photons still in the primary beam. As the beam weakens, the absolute number removed by the next millimetre falls. A layer of thickness Δd\Delta d multiplies the incoming primary population by eμΔde^{-\mu\Delta d}, whatever attenuation preceded it.

In particular,

T(d+Δd)T(d)=eμΔd.\frac{T(d+\Delta d)}{T(d)}=e^{-\mu\Delta d}.
(2.11)

For a monoenergetic homogeneous material with μ>0\mu>0, the half-value thickness is d1/2=ln(2)/μd_{1/2}=\ln(2)/\mu. Each such thickness halves the surviving primary signal, so the second half-value thickness is the same as the first. A spectrum that changes with depth need not have that property.

Substituting the water coefficient above and an open-beam mean of 10,00010{,}000 photons gives the analytic values in Table 2.2.

Table 2.2. Analytic monoenergetic water slab transmission.
Water thicknessOptical depth LLTransmission TTExpected primary count λ\lambda
0 mm0\ \mathrm{mm}001110,00010{,}000
50 mm50\ \mathrm{mm}1.02951.02950.3571860.3571863571.863571.86
100 mm100\ \mathrm{mm}2.0592.0590.1275810.1275811275.811275.81
200 mm200\ \mathrm{mm}4.1184.1180.01627700.0162770162.770162.770

At fixed energy and density, adding one millimetre multiplies transmission by e0.020590.979621e^{-0.02059}\approx0.979621. The fractional decrease is about 2.04%2.04\% of the photons entering that extra millimetre. It is not 2.04%2.04\% of the original open beam on every step.

The sensitivities are also available exactly:

Td=μT,Tμ=dT.\frac{\partial T}{\partial d}=-\mu T, \qquad \frac{\partial T}{\partial\mu}=-dT.
(2.12)

The thickness derivative has units of inverse length, while the coefficient derivative has units of length. Both give exact references for checking an implementation. At fixed n0n_0, the corresponding count derivatives acquire a factor of n0n_0.

Water transmission and thickness tangentsTransmission T against Water path length (mm). Transmission, Tangent at 50 mm, Tangent at 100 mm and Tabulated thicknesses.Transmission T00.250.50.751050100150200Water path length (mm)Tabulated thicknesses: 0, 1Tabulated thicknesses: 50, 0.35719Tabulated thicknesses: 100, 0.12758Tabulated thicknesses: 200, 0.016277
  • Transmission
  • Tangent at 50 mm
  • Tangent at 100 mm
  • Tabulated thicknesses

At 50 mm

T = 0.357186

Expected primary count
3,571.86
Thickness slope
-0.0073544 mm⁻¹
Count slope
-73.54 photons per mm

At 100 mm

T = 0.127581

Expected primary count
1,275.81
Thickness slope
-0.0026269 mm⁻¹
Count slope
-26.27 photons per mm

T(d+1mm)T(d)=e0.02059\frac{T(d+1\,\mathrm{mm})}{T(d)}=e^{-0.02059}0.979621\approx 0.979621

Each extra millimetre removes about 2.04% of remaining primary photons. Tangents show the local derivative.

Figure 2.2Equal thickness removes an equal fractionThe transmission curve and its tangents show that the same extra thickness removes fewer photons from an already weakened beam. The fraction removed by each extra millimetre stays constant, while the magnitude of the thickness derivative decreases with the surviving signal.

A single transmission value determines the product μd\mu d. If both attenuation and thickness are unknown, every positive pair with the same product gives the same prediction. Infinitesimally,

δL=dδμ+μδd.\delta L=d\,\delta\mu+\mu\,\delta d.
(2.13)

A perturbation satisfying dδμ+μδd=0d\,\delta\mu+\mu\,\delta d=0 leaves the prediction unchanged to first order. For c>0c>0, the replacement (μ,d)(cμ,d/c)(\mu,d)\mapsto(c\mu,d/c) leaves it unchanged exactly. An optimiser cannot recover two independently unknown quantities from information that only constrains their product. Additional information must come from the geometry, material knowledge or further measurements.

2.4 Heterogeneous paths and optical depth

A path through an object rarely stays in one homogeneous material. Let xp(s)\mathbf x_p(s) describe the path in the coordinates of the attenuation field, with ss measured as arc length. We assume μ(xp(s),E)\mu(\mathbf x_p(s),E) is non-negative and integrable along the finite path. The same local survival argument now gives

dTp(s)ds=μ(xp(s),E)Tp(s).\frac{\mathrm dT_p(s)}{\mathrm ds} =-\mu(\mathbf x_p(s),E)T_p(s).
(2.14)

Integrating from the source-side endpoint to the detector-side endpoint yields

Lp(E)=pμ(x,E)ds,Tp(E)=exp[Lp(E)].L_p(E)=\int_{\ell_p}\mu(\mathbf x,E)\,\mathrm ds, \qquad T_p(E)=\exp[-L_p(E)].
(2.15)

Integrability is enough for this solution in the almost-everywhere sense. Material interfaces can produce jumps in μ\mu without producing jumps in accumulated optical depth or survival. Vacuum segments contribute zero if we assign them μ=0\mu=0.

For piecewise-constant material segments, the integral is an exact finite sum for that representation:

Lp=j=1Mμjpj,Tp=j=1Meμjpj.L_p=\sum_{j=1}^{M}\mu_j\ell_{pj}, \qquad T_p=\prod_{j=1}^{M}e^{-\mu_j\ell_{pj}}.
(2.16)

Here pj\ell_{pj} is the physical length of segment jj traversed by path pp. For example, choose a 40 mm40\ \mathrm{mm} segment with μ1=0.02 mm1\mu_1=0.02\ \mathrm{mm^{-1}} followed by a 10 mm10\ \mathrm{mm} segment with μ2=0.05 mm1\mu_2=0.05\ \mathrm{mm^{-1}}. The resulting optical depth and transmission are

L=0.02×40+0.05×10=1.3,T=e1.30.272532.L=0.02\times40+0.05\times10=1.3, \qquad T=e^{-1.3}\approx0.272532.
(2.17)

Reversing these two segments leaves the primary transmission unchanged: their optical-depth contributions add in either order.

A: 40 mm at 0.02 mm⁻¹B: 10 mm at 0.05 mm⁻¹

A then B

A then B: coefficient, optical depth and transmission Material A ends at 40 mm. The coefficient jumps there, while accumulated depth and transmission remain continuous. At 50 mm, optical depth is 1.3 and transmission is 0.272532.AB00.030.06μ (mm⁻¹)00.651.3Optical depth L00.51Transmission T01020304050Distance along the path (mm)

B then A

B then A: coefficient, optical depth and transmission Material B ends at 10 mm. The coefficient jumps there, while accumulated depth and transmission remain continuous. At 50 mm, optical depth is 1.3 and transmission is 0.272532.BA00.030.06μ (mm⁻¹)00.651.3Optical depth L00.51Transmission T01020304050Distance along the path (mm)

L(50mm)L(50\,\mathrm{mm})=0.02×40+0.05×10=0.02\times40+0.05\times10=1.3=1.3

Both orders finish at T=e1.30.272532T=e^{-1.3}\approx0.272532. Their intermediate transmissions differ because the material encountered first differs.

Figure 2.3Layer order changes the path, not the final transmissionReordering the material segments changes where the primary beam loses intensity along the path. The total optical depth and final transmission stay the same because the same material lengths are traversed.

A ray need not be parameterised by arc length in the implementation. For a regular curve x(t)\mathbf x(t),

ds=dxdtdt,L=tatbμ(x(t),E)dxdtdt.\mathrm ds=\left\|\frac{\mathrm d\mathbf x}{\mathrm dt}\right\|\mathrm dt, \qquad L=\int_{t_a}^{t_b} \mu(\mathbf x(t),E) \left\|\frac{\mathrm d\mathbf x}{\mathrm dt}\right\|\,\mathrm dt.
(2.18)

For the straight segment from a\mathbf a to b\mathbf b, write

x(t)=a+t(ba),0t1,L=ba01μ ⁣(a+t(ba),E)dt.\begin{aligned} \mathbf x(t)&=\mathbf a+t(\mathbf b-\mathbf a),\qquad 0\leq t\leq1,\\ L&=\|\mathbf b-\mathbf a\| \int_0^1\mu\!\left(\mathbf a+t(\mathbf b-\mathbf a),E\right)\,\mathrm dt. \end{aligned}
(2.19)

The factor ba\|\mathbf b-\mathbf a\| supplies physical length. If it is omitted, a homogeneous field would produce the same optical depth for a one-millimetre path and a one-metre path.

Gopalakrishnan and Golland’s DiffDRR computes intersections between a ray and the voxel planes, orders them along the ray, and weights each voxel value by the physical distance between successive intersections. For our piecewise-constant attenuation field, these distances supply the pj\ell_{pj} in the exact sum. [22]

For a sampled field, quadrature approximates the integral as

Lp(h)=jwpjμ(xpj,E),Tp(h)=eLp(h),L_p^{(h)}=\sum_j w_{pj}\,\mu(\mathbf x_{pj},E), \qquad T_p^{(h)}=e^{-L_p^{(h)}},
(2.20)

where each weight wpjw_{pj} includes physical distance. Non-negative weights preserve non-negative optical depth when the sampled coefficients are non-negative. Whether the approximation converges to the intended integral depends on the field representation, sample positions, boundary handling and refinement rule, and Chapter 4 examines those choices.

Suppose the optical-depth error is εL=Lp(h)Lp\varepsilon_L=L_p^{(h)}-L_p. The resulting relative transmission error is exactly

Tp(h)TpTp=eεL1=εL+O(εL2).\frac{T_p^{(h)}-T_p}{T_p} =e^{-\varepsilon_L}-1 =-\varepsilon_L+O(\varepsilon_L^2).
(2.21)

Thus a small absolute error in optical depth becomes a comparable relative error in transmission. When the true transmission is tiny, an absolute image-error criterion alone can conceal a substantial relative error.

Segments along one history multiply their survival probabilities, whereas alternative histories contributing to a measurement add their expected signals. If a pixel averages paths with normalised non-negative weights αi\alpha_i, its transmission is

Tp=iαieLpi,iαi=1.\overline T_p=\sum_i\alpha_i e^{-L_{pi}}, \qquad \sum_i\alpha_i=1.
(2.22)

Averaging optical depths first would instead give exp(iαiLpi)\exp(-\sum_i\alpha_iL_{pi}), which generally differs from the mean transmission. For two equally weighted paths with optical depths zero and two, averaging transmissions gives (1+e2)/20.567668(1+e^{-2})/2\approx0.567668, whereas exponentiating the mean optical depth gives e10.367879e^{-1}\approx0.367879.

Two paths, two different averagesTransmission T equals exp(−L). The points at optical depths zero and two have transmissions one and exp(−2). Their chord midpoint, A, is at mean depth one and mean transmission 0.567668. The curve at depth one, B, has transmission 0.367879. The vertical difference is 0.199788. Optical depth and transmission are dimensionless.00.51012Transmission TOptical depth LABΔ = 0.199788
T=eLT=e^{-L}T\overline{T}
A Average the transmissions12(1+e2)\frac{1}{2}\left(1+e^{-2}\right)0.567668
B Attenuate at the mean depthe12(0+2)e^{-\frac{1}{2}(0+2)}0.367879
Figure 2.4Averaging and attenuation do not commuteFor two equally weighted paths, mean transmission lies above transmission at their mean optical depth (B). Averaging optical depths would therefore underestimate the detected mean signal.

2.5 From transmission to log projections

If we know the expected primary count and the open-beam mean, with n0,p>0n_{0,p}>0, normalisation removes the exposure scale:

Tp=λpn0,p.T_p=\frac{\lambda_p}{n_{0,p}}.
(2.23)

The attraction of the logarithm is that it exposes the additive quantity accumulated along the ray. For the fixed segment lengths in section 2.4, that quantity is linear in the attenuation coefficients. Taking the negative natural logarithm of the expected transmission recovers optical depth,

lnTp=Lp.-\ln T_p=L_p.
(2.24)

The normalised ratio gives the logarithm a dimensionless argument. Without the open-beam reference, lnλp-\ln\lambda_p still depends on exposure. Recovering optical depth also requires the monoenergetic, representative-path model: spectral or spatial averaging takes place before the logarithm, as the preceding example showed. Appendix A.6 develops this limitation for clinical acquisitions.

A simple linear detector adds calibration terms. Let gp>0g_p>0 be a fixed mean output per arriving photon and bpb_p an additive mean electronic offset. Under a spatially local response model,

yˉp=gpn0,peLp+bp,yˉ0,p=gpn0,p+bp.\begin{aligned} \bar y_p&=g_pn_{0,p}e^{-L_p}+b_p,\\ \bar y_{0,p}&=g_pn_{0,p}+b_p. \end{aligned}
(2.25)

If gain, offset and open-beam exposure are matched, then

yˉpbpyˉ0,pbp=eLp.\frac{\bar y_p-b_p}{\bar y_{0,p}-b_p}=e^{-L_p}.
(2.26)

A dark calibration estimates the electronic offset, while a flat-field exposure estimates the open-beam response. The cancellation above holds for the matched expected signals. Calibration errors remain in the corrected signal, and changes in gain or source output between calibration and acquisition prevent exact cancellation.

An unmodelled additive contribution behaves differently from a gain. Suppose sp0s_p\geq0 is an additional mean signal in count-equivalent units, and the normalisation still uses n0,pn_{0,p}. Then

Tpapp=eLp+spn0,p,Lpapp=lnTpappLp.\begin{aligned} T_p^{\mathrm{app}}&=e^{-L_p}+\frac{s_p}{n_{0,p}},\\ L_p^{\mathrm{app}}&=-\ln T_p^{\mathrm{app}}\leq L_p. \end{aligned}
(2.27)

The apparent optical depth is reduced. If the additional contribution pushes the ratio above one, the apparent optical depth becomes negative. Scatter can contribute such a signal in a linear detector domain, but a dark frame does not measure object-generated scatter.

Expected detector counts

Linear expected-count image: rays through the body are dark and the open beam is bright.

Black: 0 · white: 1,000 counts

Optical depth

The same calculated projection displayed as optical depth. The pelvis and spine are bright.

Black: L ≤ 2 · white: L ≥ 5.3

Image model and data

Synthetic MAISI CT. MAISI-v2, rectified flow (rflow-ct), generated with 30 inference steps using NV-Generate-CTMR revision 61c4ec709b84. [36]

The archived 480 × 256 calculation uses 1,000 incident photons per pixel and an illustrative water-equivalent conversion at 80 keV. Scatter, spectral response and detector noise are absent. This is a simulated radiograph, not a patient acquisition.

The radiographic display maps optical depth from 2 to 5.3 to black–white, with the same window for every pose. Pixels outside that interval saturate only in the display. The difference image shows absolute change in expected counts with a fixed asinh scale, where white is 704.5 counts. It does not show the sign of the change.

Translations use 0.5 mm steps about the reference, and rotations use 0.01 rad steps about the fixed sacral pivot. Playback traverses the recorded poses and reverses at the ends. The time per frame is a display choice.

Pose matrices and display metadata ·Calculation provenance · Original numerical checks

Figure 2.5One projection, two display domainsThe same recorded primary projection is displayed as linear expected counts and as optical depth. Taking the negative logarithm of open-beam-normalised counts changes the visible contrast and comparison domain without changing the underlying projection.

A noisy logarithm

With the ideal count model and a known, positive open-beam mean, the ratio Np/n0,pN_p/n_{0,p} is unbiased for TpT_p. Taking its negative logarithm does not give an unbiased measurement of LpL_p. Zero counts are an immediate problem:

Pr(Np=0)=eλp>0\Pr(N_p=0)=e^{-\lambda_p}>0
(2.28)

for any finite λp\lambda_p. At zero counts the logarithm is undefined. Defining ln0=+-\ln0=+\infty would make its expectation infinite.

At high counts, on observations near a positive mean where a local linear approximation is appropriate,

δL^pδNpλp,Varlocal(L^p)1λp.\delta\widehat L_p\approx-\frac{\delta N_p}{\lambda_p}, \qquad \operatorname{Var}_{\mathrm{local}}(\widehat L_p) \approx\frac{1}{\lambda_p}.
(2.29)

This approximation excludes zero-count observations. A noisy estimated denominator adds calibration uncertainty as well.

Replacing zero counts by a floor produces a finite array. Both this replacement and adding a pseudocount change the statistical properties of the data.

Compare predicted and observed counts directly. For a realised count NpN_p, the Poisson negative log likelihood, up to terms independent of LpL_p, is

Jp(Lp)=n0,peLp+NpLp.\mathcal J_p(L_p) =n_{0,p}e^{-L_p}+N_pL_p.
(2.30)

This follows by substituting lnλp=lnn0,pLp\ln\lambda_p=\ln n_{0,p}-L_p into λpNplnλp\lambda_p-N_p\ln\lambda_p. It never takes a logarithm of the observed count and remains defined when Np=0N_p=0. Its first two derivatives are

dJpdLp=Npλp,d2JpdLp2=λp.\frac{\mathrm d\mathcal J_p}{\mathrm dL_p}=N_p-\lambda_p, \qquad \frac{\mathrm d^2\mathcal J_p}{\mathrm dL_p^2}=\lambda_p.
(2.31)

For a zero-count observation, this objective decreases as LpL_p increases and approaches its infimum only as LpL_p\to\infty. Shared image structure or additional information must constrain the estimate. With Np>0N_p>0, the unconstrained single-pixel optimum is ln(Np/n0,p)-\ln(N_p/n_{0,p}), and restricting Lp0L_p\geq0 moves that optimum to zero if Np>n0,pN_p>n_{0,p}.

This likelihood follows from the ideal counting model in section 2.1. An energy-integrating or heavily processed image needs a statistical model for that detector output. The choice of comparison domain is part of section 6.2 on registration objectives.

2.6 Evaluating transmission numerically

In the library outlined in section 1.7, transmission sits between path integration and comparison with the observed image. It converts an array of optical depths into the requested primary-signal quantities and supplies derivatives with respect to its declared active inputs. Here we supply optical depths directly, using slabs and numerical test cases whose answers we can establish independently. Chapter 4 will produce those inputs by integrating through a volume.

The slab cases let us check behaviour near zero attenuation and in nearly opaque paths before we have rays to construct or a volume to sample. We will use the same transmission operation when those inputs come from the renderer.

For a collection of pixels, the deterministic operations are

Tp=eLp,λp=n0,pTp,lnTp=Lp.T_p=e^{-L_p}, \qquad \lambda_p=n_{0,p}T_p, \qquad \ln T_p=-L_p.
(2.32)

Compute log transmission directly from LpL_p. Taking an exponential and then its logarithm introduces rounding error and can fail after underflow. Table 2.3 specifies the input, output and numerical contract for this operation.

Table 2.3. What's what in the transmission contract.
ItemRequired meaning or behaviour
Optical depthFinite, non-negative, dimensionless LpL_p for each pixel.
Open-beam meanFinite, non-negative n0,pn_{0,p}, with explicit scalar or per-pixel broadcasting.
OutputsDimensionless TpT_p, expected count λp\lambda_p, and log transmission if required. No random sampling.
Zero open-beam meanReturn zero expected count. An observed normalised transmission is unavailable for that pixel.
Invalid physical inputReport negative optical depth, negative mean or non-finite input, and do not silently clip it into the allowed domain.
Numerical policyState storage and accumulation precision, exponential accuracy mode and any treatment of subnormal values.
Derivative meaningDifferentiate the declared deterministic map with acquisition parameters fixed, unless they are explicitly active inputs.

A non-negative parameterisation may be appropriate when optimising an attenuation field. Hidden clipping inside the forward operation would change the function and introduce its own derivative convention at the clipping boundary.

One operator, several useful outputs

The transmission operator, dpt.transmission.transmit, receives L, the optical-depth array produced by the path integrator. The n0 argument supplies illumination when counts are wanted, and the out_ arguments name the quantities the caller will consume. A count-domain registration objective will use out_counts, and a sensitivity calculation will also need the reverse operation developed below.

Its inputs are contiguous, one-dimensional CUDA arrays with binary32 storage, and the caller flattens the detector image and supplies each destination. Omitting out_T avoids storing a transmission image that the next operation does not need. The removed-primary fraction is a fourth, independently selectable output.

Inputs and output buffers for transmissionpython/dpt/transmission.pyL189–210
def transmit(
    L: Any,
    n0: Any = None,
    *,
    out_T: Any = None,
    out_counts: Any = None,
    out_log_T: Any = None,
    out_removed: Any = None,
    workspace: TransmissionWorkspace,
    stream: Any = None,
    tape: Any = None,
    validate: bool = True,
) -> None:
    """Evaluate selected deterministic outputs in caller-owned binary32 buffers.

    L is finite, non-negative optical depth. n0 is the declared open-beam
    expectation at the detector. No sampling, clipping or implicit transfer is
    performed. The default checks device contents before writing outputs.
    With validate=False the caller guarantees valid *current* device inputs;
    launches remain asynchronous. Pass tape explicitly to record the custom
    first-order adjoint, and retain original inputs until backward completes.
    """

TransmissionSpec fixes the representation of the open beam: a Python scalar, a one-element device array or one device value per pixel. A Python scalar is explicitly rounded to binary32 and treated as fixed. A device array may be declared active when its derivative is required. This prevents an accidental broadcast from silently changing an image-sized calibration problem into a single exposure parameter, or vice versa.

Each output approximates its declared real-valued quantity independently. We do not require machine equality between out_counts and the product of n0 with the rounded out_T, or between out_removed and one minus out_T. Those apparently helpful consistency checks would require the more informative output to inherit the rounding damage of the less informative one. The weak- and strong-attenuation cases below show why. The plots draw recorded CUDA samples in the browser. Move over a curve or use its sample slider to inspect the stored values. The slider selects an existing sample rather than evaluating another optical depth.

Open-beam expectation: 1000000

Transmission T

10⁻⁹10⁻⁶10⁻³10⁰05101520Optical depth L
  • CUDA transmission

Expected counts λ

10⁻³10⁰10³10⁶05101520Optical depth L
  • CUDA expected counts

Log transmission

-20-15-10-5005101520Optical depth L
  • CUDA log transmission

Lines join recorded samples. Download exact values.

Figure 2.6Three outputs of the same transmission operatorThe recorded CUDA outputs show transmission and expected counts decreasing exponentially with optical depth, while log transmission remains linear. Rounding can erase attenuation information from one output while another still preserves it.

Weak attenuation

When LL is small, the fraction removed from the primary beam is

1T=1eL=LL22+O(L3).1-T=1-e^{-L}=L-\frac{L^2}{2}+O(L^3).
(2.33)

Subtracting an already rounded value of eLe^{-L} from one can lose most of the significant digits in this small difference. A numerically preferable evaluation is

1T=expm1(L),1-T=-\operatorname{expm1}(-L),
(2.34)

where expm1(x)\operatorname{expm1}(x) evaluates ex1e^x-1 accurately near zero. Similarly, if transmission is supplied through a small decrement δ=1T\delta=1-T, then

L=log1p(δ),0δ<1.L=-\operatorname{log1p}(-\delta),\qquad 0\leq\delta<1.
(2.35)

CUDA provides the device functions expm1f and log1pf for these expressions. We bind them directly through Warp’s native-function mechanism. The ellipses below are declarations whose bodies are the supplied CUDA strings. They do not invoke Python once per pixel. [31]

CUDA functions for small attenuation decrementspython/dpt/kernels/transmission.pyL23–34
@wp.func_native("return -expm1f(-optical_depth);")
def removed_primary(optical_depth: wp.float32) -> wp.float32:
    """Evaluate the decrement directly, without subtracting two near-unities."""
    ...


@wp.func_native("return -log1pf(-decrement);")
def optical_depth_from_decrement(decrement: wp.float32) -> wp.float32:
    """Invert a supplied decrement; the caller requires 0 <= decrement < 1."""
    ...

The inverse takes the decrement itself as its input. If a previous calculation rounded transmission to one and then formed the decrement by subtraction, the lost bits have already gone. log1p has no administrative procedure for requesting their return. Similarly, a decrement rounded to one cannot identify a finite optical depth: the inverse API rejects that endpoint instead of inventing a large finite answer.

Removed-primary fraction

10⁻¹³10⁻¹⁰10⁻⁷10⁻⁴10⁻¹10⁻¹²10⁻⁹10⁻⁶10⁻³zeroOptical depth L
  • CUDA removed fraction
  • 100-digit reference
  • 1 − stored T (binary32)
Numerical details

The reference curve is rounded to binary64. 1 − stored T (binary32): 58 stored zeros, first sampled zero at Optical depth L = 9.99999996e-13. Zeros appear on a separate labelled rail, outside the logarithmic scale.

Inverse error (binary32 ULPs)

00.250.50.75110⁻¹²10⁻⁹10⁻⁶10⁻³Supplied decrement δ
  • CUDA inverse error
Numerical details

Error against an independent high-precision inverse.

Lines join recorded samples. Download exact values.

Figure 2.7Keeping weak attenuation visibleFor sufficiently weak attenuation, subtracting the stored transmission from one rounds the removed fraction to zero. Computing it directly preserves that small loss (left), while the inverse calculation remains accurate for the tested decrements (right).

The removed-primary fraction includes photons that scattered out of the uncollided history. It is not an absorbed-energy fraction or a dose estimate.

Strong attenuation

For large LL, the exponential can underflow to zero even while optical depth and log transmission L-L remain representable. Keeping log transmission allows comparisons in that domain without introducing a tiny transmission floor.

An absolute error of 10810^{-8} is excellent for a transmission near one and disastrous for a transmission near 101210^{-12}. A validation criterion must say which regime it covers. Relative error is useful for positive, representable reference values, while behaviour near underflow needs a separate check. The underflow threshold depends on the numeric type, treatment of subnormal values and mathematical implementation.

Storage precision and intermediate precision need separate decisions. Consider the mathematical stress input L=110L=110 with an open-beam mean near 103010^{30}. Transmission is below the binary32 subnormal range, but the expected count is approximately 1.69×10181.69\times10^{-18} and remains representable. Multiplying the open beam by a transmission value already stored as zero destroys that count. This is a test of numerical range, not a claim about a plausible clinical exposure.

Our arrays remain binary32. The kernel keeps the exponential factor in a binary64 register until the requested products have been formed, then rounds each output independently. For L64L\leq64, it evaluates the ordinary binary32 exponential and promotes the result. Above that cutover it evaluates the exponential in binary64 from the exactly promoted input. At the cutover the binary32 exponential is still normal: promotion preserves its available precision, while the wider exponent range protects later products. The value 64 is an implementation boundary, with neighbouring representable inputs tested on both sides, and it has no physical significance.

Evaluating log transmission and the exponential factorpython/dpt/kernels/transmission.pyL39–54
@wp.func_native("return -value;")
def log_transmission_exact(value: wp.float32) -> wp.float32:
    """Use IEEE unary negation; Warp 1.17's generic neg is ``0 - value``."""
    ...


@wp.func
def transmission_factor(optical_depth: wp.float32) -> wp.float64:
    """Keep exponent range until all requested physical products are formed."""
    if optical_depth <= wp.float32(64.0):
        # Here exp(-L) is normal in FP32. Promotion retains its range, while
        # keeping the common case on the single-precision exponential path.
        return wp.float64(wp.exp(-optical_depth))
    return wp.exp(-wp.float64(optical_depth))

The kernel forms count directly from factor. The wider temporary lives within the kernel, so there is no hidden binary64 image to allocate, write and reread. Log transmission takes a different path altogether: unary negation of the input, preserving the sign of zero as well as every finite magnitude. It does not depend on the exponential’s range.

Stored transmission T

10⁻⁴⁵10⁻⁴²10⁻³⁹10⁻³⁶80110140170200zeroOptical depth L
  • CUDA transmission
Numerical details

Numerical stress test with an open-beam expectation of 1.00000002e+30. CUDA transmission: 103 stored zeros, first sampled zero at Optical depth L = 104.375. Zeros appear on a separate labelled rail, outside the logarithmic scale.

Stored expected counts λ

10⁻⁴⁵10⁻³⁴10⁻²³10⁻¹²80110140170200zeroOptical depth L
  • CUDA expected counts
Numerical details

Numerical stress test with an open-beam expectation of 1.00000002e+30. CUDA expected counts: 29 stored zeros, first sampled zero at Optical depth L = 173.75. Zeros appear on a separate labelled rail, outside the logarithmic scale.

Lines join recorded samples. Download exact values.

Figure 2.8A representable count after transmission has rounded to zeroIn this numerical stress test with extreme illumination, stored transmission reaches zero while the expected count is still representable. Forming counts before rounding the exponential preserves values that multiplication by the stored transmission would lose.

The kernel disables fast mathematics and permits fused multiply-add contraction. FMA rounds a multiply-add once, whereas separate instructions round its product first. Flush-to-zero and approximate intrinsics are different choices that can change the small-value behaviour. NVIDIA’s floating-point guide describes those distinctions. Our tests cover the selected settings rather than assuming that the word CUDA guarantees a particular elementary-function accuracy. [32]

Fusing the work that is actually requested

Each CUDA thread handles one pixel, computing and writing only the outputs the caller requests. Those choices are fixed in the output mask when the kernel specialisation is created, allowing wp.static to remove the unused branches at compile time. A counts-only call therefore leaves transmission unstored and skips log transmission; a log-only call skips the exponential altogether. Array length and illumination values, by contrast, remain runtime inputs: changing the exposure does not require recompilation.

Writing the requested transmission outputspython/dpt/kernels/transmission.pyL59–108
@cache
def get_forward_kernel(mask: int, beam_mode: int):
    """Return a specialised pointwise kernel, launched with ``dim=P``.

    Inputs: L, beam_array, beam_scalar. Outputs: T, counts, log_T, removed.
    Static selection eliminates unused array reads, stores and mathematics.
    """
    if mask < 1 or mask > 15 or beam_mode not in (0, 1, 2, 3):
        raise ValueError("Invalid output mask or beam mode")
    if mask & 2 and beam_mode == 0:
        raise ValueError("Counts require an open beam")

    @wp.kernel(module="unique", module_options=STRICT_OPTIONS)
    def forward(
        optical_depth: wp.array(dtype=wp.float32),
        beam: wp.array(dtype=wp.float32),
        beam_scalar: wp.float32,
        transmission: wp.array(dtype=wp.float32),
        counts: wp.array(dtype=wp.float32),
        log_transmission: wp.array(dtype=wp.float32),
        removed: wp.array(dtype=wp.float32),
    ):
        p = wp.tid()
        depth = optical_depth[p]
        if wp.static(mask & 3 != 0):
            factor = transmission_factor(depth)
            if wp.static(mask & 1 != 0):
                transmission[p] = wp.float32(factor)
            if wp.static(mask & 2 != 0):
                illumination = beam_scalar
                if wp.static(beam_mode == 2):
                    illumination = beam[0]
                elif wp.static(beam_mode == 3):
                    illumination = beam[p]
                # In particular, never multiply illumination by stored T.
                count = wp.float32(wp.float64(illumination) * factor)
                if count == wp.float32(0.0):
                    count = wp.float32(0.0)
                counts[p] = count
        if wp.static(mask & 4 != 0):
            log_transmission[p] = log_transmission_exact(depth)
        if wp.static(mask & 8 != 0):
            decrement = removed_primary(depth)
            if decrement == wp.float32(0.0):
                decrement = wp.float32(0.0)
            removed[p] = decrement

    return forward

This fusion saves launches and repeated image traffic, neither of which is desirable if we want matters to proceed at pace. With a per-pixel beam and all four outputs, the pointwise forward operation requests two binary32 reads and four writes: 24 bytes per pixel before cache effects and transaction overhead. A transmission-only call needs one read and one write, or 8 bytes per pixel. These are traffic counts from the source, not measured DRAM bandwidth. Validation scans and scalar reductions have their own traffic and must be accounted for separately.

We also avoid adding shared-memory staging to a pointwise calculation with no cross-pixel reuse. It would introduce storage and synchronisation without reducing the compulsory image reads. The branch at the precision cutover is different: pixels can take different arithmetic paths, so mixed ordinary and extreme inputs belong in profiling workloads. We check the numerical results and repeat the device measurements after changing either arithmetic path.

Differentiating with respect to optical depth and the open-beam mean gives

TpLp=Tp,λpLp=λp,λpn0,p=Tp.\frac{\partial T_p}{\partial L_p}=-T_p, \qquad \frac{\partial\lambda_p}{\partial L_p}=-\lambda_p, \qquad \frac{\partial\lambda_p}{\partial n_{0,p}}=T_p.
(2.36)

At fixed open-beam mean and positive λp\lambda_p, a small optical-depth change δLp\delta L_p changes the expected count by approximately λpδLp-\lambda_p\delta L_p. Under the ideal Poisson model, the count standard deviation is λp\sqrt{\lambda_p}, so the size of that predicted change relative to the counting fluctuations is approximately λpδLp\sqrt{\lambda_p}|\delta L_p|. This ratio shrinks as attenuation increases, consistent with the falling Poisson curvature λp\lambda_p. Rescaling the objective can enlarge its numerical gradients, but it does not improve this local signal-to-noise ratio.

A backward pass that preserves the same range

An optimiser rarely asks for an isolated derivative at one pixel. It supplies a cotangent for each requested output: the derivative of its scalar objective with respect to that output. Write these incoming weights as Tˉ\bar T, λˉ\bar\lambda, ˉ\bar\ell and rˉ\bar r for transmission, counts, log transmission and removed fraction. The optical-depth contribution is Lˉ=(rˉTˉ)eLλˉn0eLˉ\bar L=(\bar r-\bar T)e^{-L}-\bar\lambda n_0e^{-L}-\bar\ell. This is a vector–Jacobian product (VJP): it combines the objective’s weights with the local derivatives without ever constructing a dense Jacobian.

Transmission gradients recomputed from the original inputspython/dpt/kernels/transmission.pyL122–136
@wp.func
def weighted_depth_adjoint(
    factor: wp.float64,
    illumination: wp.float32,
    seed_transmission: wp.float32,
    seed_counts: wp.float32,
    seed_log: wp.float32,
    seed_removed: wp.float32,
) -> wp.float64:
    """Differentiate the real-valued map, retaining range before rounding."""
    optical = (wp.float64(seed_removed) - wp.float64(seed_transmission)) * factor
    count = wp.float64(seed_counts) * wp.float64(illumination) * factor
    return optical - count - wp.float64(seed_log)

The derivative is that of the declared smooth real-valued map, evaluated approximately at the supplied floating-point inputs. It is not the derivative of a staircase of rounded storage values. Differentiating that staircase would produce zero almost everywhere and be singular at its jumps, which is a poor basis for moving a bone into place.

Reusing rounded forward outputs is especially dangerous here. A large incoming weight can make TˉeL\bar T e^{-L} representable even when stored transmission is zero. A small open-beam mean and a large count cotangent can similarly produce a useful product after stored counts have underflowed. For removed fraction, 1 - stored_removed can already be zero. The VJP therefore recomputes the factor from the original optical depth and forms weighted products in binary64 registers. It also computes the open-beam derivative directly: dividing counts by the open beam would fail at the perfectly valid input n0=0n_0=0.

There is one local open-beam derivative per pixel when the beam is an active image. When the active beam is a single shared device value, its gradient instead sums λˉpeLp\bar\lambda_p e^{-L_p} over all pixels. The implementation forms binary64 partial sums in tiles of 256 pixels and reduces those partials through a fixed tree. The tree allocation, later reduction levels and final cast use the shared reduction code also used by projection and objectives, while the first stage retains this operator’s range-sensitive products. This avoids sending every thread to contend for the same global scalar. It also avoids retaining a full-sized array of binary64 contributions: only the much smaller tree of partials is persistent. A separate reduction pass rereads optical depth and count cotangents. That cost is explicit, and the reduction must finish before its scalar result can be used.

Reducing the shared open-beam gradientpython/dpt/kernels/transmission.pyL213–238
@cache
def get_beam_partial_kernel():
    """Inputs L, seed_counts; output FP64 partials.

    Use ``launch_tiled(dim=ceil(P/256), block_dim=256)``. Tile loads zero-pad
    their bounds, so padded seeds contribute zero even though exp(-0) is one.
    The separate pass deliberately avoids retaining an 8P-byte contribution
    image or contending for a global scalar. Its traffic is accounted separately.
    """

    @wp.kernel(module="unique", module_options=STRICT_OPTIONS)
    def partials(
        optical_depth: wp.array(dtype=wp.float32),
        seed_counts: wp.array(dtype=wp.float32),
        output: wp.array(dtype=wp.float64),
    ):
        block = wp.tid()
        depths = wp.tile_load(optical_depth, shape=TILE_SIZE, offset=block * TILE_SIZE)
        seeds = wp.tile_load(seed_counts, shape=TILE_SIZE, offset=block * TILE_SIZE)
        contributions = wp.tile_map(beam_contribution, depths, seeds)
        total = wp.tile_sum(contributions)
        wp.tile_store(output, total, offset=block)

    return partials

The inverse helper follows the same rule: its VJP uses the original decrement and evaluates δˉ=Lˉ/(1δ)\bar\delta=\bar L/(1-\delta). As δ\delta approaches one, this derivative grows. That is the inverse problem’s conditioning, not an invitation to clamp the denominator. A final gradient that cannot be represented in binary32 sets a numerical error flag, and the checked completion boundary raises GradientRangeError. A legitimate forward underflow and an unusable overflowing gradient are different outcomes.

Owning storage, streams and reverse execution

prepare_transmission binds a workspace to one CUDA stream and allocates its persistent diagnostics and any required scalar-reduction scratch. The caller allocates inputs, destinations and, for taped execution, gradient buffers. These buffers must remain alive until the stream has finished using them. Original inputs must also remain unmodified until backward completes, because recomputation deliberately reads them again. Two concurrent operations need separate workspaces or explicit serialisation: sharing an object in Python does not order GPU execution.

The default checked call validates shape, dtype, device, capacity and actual memory-range overlap, then scans device values before launching numerical writes. It rejects unsupported layouts, so the caller must explicitly prepare contiguous inputs. An invalid depth is still invalid when illumination is zero: multiplying it by zero would conceal an upstream error. Failure diagnosis may copy the offending input back to identify its index, while a successful checked call only needs the small status result on the host.

Inside a composition whose current inputs are already guaranteed valid, validate=False omits the content scan and its synchronisation. Metadata checks remain. This boundary is useful for a warmed CUDA graph or a device-resident optimisation loop, but it transfers responsibility for the current values to the caller. Mutating an array after checking it does not preserve a certificate of good behaviour. Compilation and allocation must happen before graph capture, and graph replay then reuses the same buffers and launch structure.

Passing a tape explicitly records the library’s custom first-order backward callback through Warp’s Tape.record_func. Warp provides this hook for custom reverse work and tracks the participating arrays, while our adapter supplies the VJP and its buffer-consumption rules. [33] A shared input can influence the objective through several downstream operations, so its cotangent must add their contributions rather than overwrite an earlier one. Our adapter therefore accumulates input cotangents. It consumes ordinary output cotangents after all local contributions have been read, while explicitly retained gradients remain available. Before an independent reverse pass, tape.zero() clears the previous derivative sums so they are not included in the new calculation.

After backward, workspace.check_status() waits for the owning stream and reports gradient-range failure before the optimiser accepts the result.

Cross-stream producers and consumers require explicit events and waits. Internal stream scopes disable Warp’s automatic entry synchronisation: once ownership is established, inserting another event and wait into every call would add ordering work without supplying a missing dependency.

A VJP recorded on an enclosing tape is rejected: this operator supports first derivatives, and silently returning zero for a second derivative would be an especially unhelpful form of optimism.

Supporting second derivatives would require another defined and independently verified differentiation boundary. Chapter 5 develops the broader derivative execution problem once transmission is composed with geometry and integration.

2.7 Checks before adding geometry

The transmission law gives us reference cases whose answers can be derived independently of a renderer. Table 2.4 pairs each case with a common footgun in modelling transmission.

Table 2.4. Analytic transmission checks.
CaseReference resultWhat it can detect
Empty path or zero attenuationL=0L=0, T=1T=1, λ=n0\lambda=n_0Incorrect initial values or path handling.
Homogeneous segmentL=μdL=\mu d, T=eμdT=e^{-\mu d}Unit errors and an incorrect exponent sign.
Split a homogeneous segmentμd=μd1+μd2\mu d=\mu d_1+\mu d_2 when d1+d2=dd_1+d_2=dSegment weights that fail to preserve length.
Fixed layered pathL=jμjjL=\sum_j\mu_j\ell_jIncorrect material association or missing segments.
Convert the length unitμcmdcm=μmmdmm\mu_{\rm cm}d_{\rm cm}=\mu_{\rm mm}d_{\rm mm}Coefficients and distances expressed in different units.
Add a non-negative segmentTextendedToriginalT_{\rm extended}\leq T_{\rm original}Sign mistakes or unphysical numerical behaviour.
Finite zero-attenuation derivativeT/L=1\partial T/\partial L=-1 at L=0L=0A backward rule that incorrectly vanishes in an empty path.

A varying coefficient adds a check that a constant slab cannot provide. Prescribe

μ(s)=a+bs,0sd,\mu(s)=a+bs,\qquad 0\leq s\leq d,
(2.37)

with aa in inverse length, bb in inverse length squared, and coefficients chosen so that μ(s)0\mu(s)\geq0 throughout the interval. Direct integration gives

L=ad+12bd2,T=exp ⁣(ad12bd2).L=ad+\frac12bd^2, \qquad T=\exp\!\left(-ad-\frac12bd^2\right).
(2.38)

Evaluate the polynomial antiderivative from the specified parameters to obtain an independent reference for the sampled path integral. Calling the same quadrature through another wrapper would repeat its errors.

Derivative checks can use the closed-form slab sensitivities from section 2.3. For a positive interior optical depth LL and a step satisfying 0<h<L0<h<L, compare the analytic derivative with the central difference

T(L+h)T(Lh)2h.\frac{T(L+h)-T(L-h)}{2h}.
(2.39)

For this smooth function, truncation error decreases quadratically with hh until floating-point effects become significant. Check a range of steps. At the boundary L=0L=0, a centred perturbation would leave the physical input domain, so use a suitable one-sided check or explicitly test an agreed mathematical extension.

An oracle that does not repeat the kernel

The test reference converts each supplied binary32 value into its exact decimal value before evaluating the mathematics at high precision. Comparing a GPU input rounded from a decimal literal with the unrounded literal would charge input quantisation to the kernel. The reference uses independent scalar Decimal expressions and explicit binary32 rounding, and it never calls the CUDA helper to obtain the expected answer. Increasing reference precision checks that the oracle itself has settled.

Closed-form reference pathspython/dpt/validation/transmission.pyL266–299
def analytic_cases() -> tuple[AnalyticCase, ...]:
    """Closed-form path cases, without claiming to validate a path integrator.

    All coefficients and lengths below are exact dyadic mathematical stress inputs.
    The mm and cm expressions describe the same optical depth in different units.
    """
    coefficient, distance = Decimal("0.125"), Decimal(4)
    first_length, second_length = Decimal(1), Decimal(3)
    intercept, slope = Decimal("0.125"), Decimal("0.0625")
    return (
        AnalyticCase("empty", Decimal(0), "empty path"),
        AnalyticCase("zero-attenuation", Decimal(0), "mu = 0"),
        AnalyticCase("homogeneous", coefficient * distance, "mu*d"),
        AnalyticCase(
            "split-homogeneous",
            coefficient * first_length + coefficient * second_length,
            "mu*d1 + mu*d2",
        ),
        AnalyticCase("layered", Decimal("0.25") * 2 + Decimal("0.5") * 3, "mu1*d1 + mu2*d2"),
        AnalyticCase("millimetres", Decimal("0.125") * 4, "0.125/mm * 4 mm"),
        AnalyticCase("centimetres", Decimal("1.25") * Decimal("0.4"), "1.25/cm * 0.4 cm"),
        AnalyticCase(
            "added-segment",
            coefficient * distance + Decimal("0.25"),
            "mu*d + non-negative optical depth",
        ),
        AnalyticCase(
            "linear-coefficient",
            intercept * distance + slope * distance * distance / 2,
            "integral_0^d (a+b*s) ds",
        ),
    )

These fixtures validate transmission at known optical depths. The ray integrator must independently recover those depths from the geometry and material field.

For normal outputs, the value tests use a budget of four binary32 units in the last place (ULPs) for transmission, removed fraction and the inverse, and eight for expected counts. A ULP measures spacing between neighbouring representable values at the result’s magnitude. Subnormal results have their own one-ULP budget, and exact cases such as log negation are checked directly. The budgets are acceptance criteria, not statements that every value reaches the limit. Each independently requested output is compared with its own correctly rounded reference.

VJPs need a different error scale. Two large weighted contributions can nearly cancel, leaving a small correct gradient. Dividing the error by that residual would make the apparent relative error arbitrarily large. We instead scale the acceptance bound by the magnitudes of the contributing terms, with explicit rounding allowances, and scalar beam gradients also account for the reduction depth. The tests include cancellation, zero illumination, extreme weights and subnormal inputs.

Checking transmission derivatives across step sizespython/dpt/validation/transmission.pyL312–357
def directional_check(
    evaluate: Callable[[float], float],
    anchor: float,
    analytic_derivative: float,
    *,
    exponents: Iterable[int] = range(2, 25),
) -> tuple[DirectionalRecord, ...]:
    """Sweep a real operator callback over admissible, distinct binary32 inputs.

    Interior anchors use centred differences; zero uses a second-order one-sided
    stencil. No convergence claim is inferred from the smallest step: callers retain
    all records to expose truncation, agreement and storage-rounding regimes.
    """
    centre = binary32(anchor)
    if centre < 0:
        raise ValueError("anchor must be non-negative")
    records: list[DirectionalRecord] = []
    for exponent in exponents:
        step = 2.0**-exponent
        right = binary32(centre + step)
        if right == centre:
            continue
        if centre == 0:
            second = binary32(2 * step)
            if second <= right:
                continue
            derivative = (-3 * evaluate(centre) + 4 * evaluate(right) - evaluate(second)) / (
                2 * right
            )
            stencil = "one-sided-second-order"
        else:
            if centre - step < 0:
                continue
            left = binary32(centre - step)
            if left == centre or centre - left != right - centre:
                continue
            derivative = (evaluate(right) - evaluate(left)) / (right - left)
            stencil = "centred"
        records.append(
            DirectionalRecord(
                right - centre, derivative, abs(derivative - analytic_derivative), stencil
            )
        )
    return tuple(records)

The finite-difference helper accepts a callback to the actual GPU operator. It checks that perturbed inputs remain distinct after binary32 rounding and retains every admissible step. At an interior point, agreement should improve over a range of steps before storage rounding dominates, while at zero, the helper uses a second-order one-sided stencil. Selecting only the smallest step would often select the least informative comparison. Agreement with both the independent analytic VJP and the directional sweep checks different failure modes.

Execution is part of correctness

A formula test cannot detect every GPU integration error. We therefore also need empty batches, lengths that leave a partial tile, and a scalar reduction large enough to require several levels. Reusing a large workspace for a smaller batch must not include stale partial sums. Output masks, fixed and active beam representations, overlapping array views, non-default streams and graph replay each exercise a distinct part of the contract. Tape tests compose transmission with surrounding kernels so that missing contributions or incorrectly consumed cotangents become observable.

Device timing begins after compilation and warm-up. Checked-call timing includes the validation scan and status synchronisation, while CUDA-event batches measure the warmed stream interval, including gaps while the host submits work. Isolated per-kernel durations come from the profiler. Forward and backward measurements record requested outputs, pixel count, beam representation, precision settings, scratch size and launch structure. A short runtime without that context cannot tell us whether an optimisation removed work, hid a transfer or simply measured a smaller problem. Memory and synchronisation tools complement numerical tests, but a clean run in either category does not replace the other.

Running and validating the transmission operatorexperiments/transmission-contract/run.pyL126–171
def evaluate_sweep(wp: Any, np: Any, depths: Any, beam: float, device: Any) -> dict[str, Any]:
    """Execute all four canonical outputs, then check every stored value."""
    workspace = prepare_transmission(
        TransmissionSpec(beam="scalar"), device=str(device), max_pixels=len(depths)
    )
    optical_depth = wp.array(depths, dtype=wp.float32, device=device)
    outputs = [wp.empty(len(depths), dtype=wp.float32, device=device) for _ in OUTPUT_NAMES]
    transmit(
        optical_depth,
        beam,
        workspace=workspace,
        **dict(zip(OUTPUT_ARGUMENTS, outputs, strict=True)),
    )
    wp.synchronize_stream(workspace.stream)
    measured = [output.numpy() for output in outputs]
    cases: list[dict[str, Any]] = []
    for index, depth in enumerate(depths):
        reference = forward_reference(float(depth), beam)
        checks = {}
        for name, values in zip(OUTPUT_NAMES, measured, strict=True):
            result = value_error(
                float(values[index]), getattr(reference, name), counts=name == "counts"
            )
            exact_log = name != "log_T" or struct.pack("!f", float(values[index])) == struct.pack(
                "!f", -float(depth)
            )
            checks[name] = {
                "actual": float(values[index]),
                "reference_decimal": str(getattr(reference, name)),
                "ulps": result.ulps,
                "allowed_ulps": 0 if name == "log_T" else result.allowed_ulps,
                "passed": result.passed and exact_log,
            }
            if not result.passed or not exact_log:
                raise AssertionError(f"{name} failed at optical depth {depth}: {checks[name]}")
        cases.append({"optical_depth": float(depth), "checks": checks})
    return {
        "beam": float(np.float32(beam)),
        "cases": cases,
        "outputs": {
            name: values.tolist() for name, values in zip(OUTPUT_NAMES, measured, strict=True)
        },
        "optical_depths": depths.tolist(),
    }

To form an image, every detector pixel needs the correct path through the object. Chapter 3 fixes the coordinate conventions for those paths and for moving the object while leaving its material attenuation coefficients unchanged.

References

  1. Dance, D. R., Christofides, S., Maidment, A. D. A., McLean, I. D. and Ng, K.-H. (eds.) (2014). Diagnostic Radiology Physics: A Handbook for Teachers and Students. Vienna: International Atomic Energy Agency. https://www.iaea.org/publications/8841/diagnostic-radiology-physics
  2. Hubbell, J. H. and Seltzer, S. M. (1995). Tables of X-Ray Mass Attenuation Coefficients and Mass Energy-Absorption Coefficients 1 keV to 20 MeV for Elements Z = 1 to 92 and 48 Additional Substances of Dosimetric Interest. Gaithersburg, Maryland: National Institute of Standards and Technology. https://doi.org/10.6028/NIST.IR.5632
  3. 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
  4. NVIDIA Corporation (n.d.). CUDA Math API Reference Manual 13.0: Single Precision Mathematical Functions. https://docs.nvidia.com/cuda/archive/13.0.0/cuda-math-api/cuda_math_api/group__CUDA__MATH__SINGLE.html
  5. Whitehead, Nathan and Fit-Florea, Alex (n.d.). Floating Point and IEEE 754. NVIDIA Corporation. https://docs.nvidia.com/cuda/archive/13.0.0/floating-point/index.html
  6. NVIDIA Warp contributors (n.d.). Warp 1.17.0: Tape implementation. https://github.com/NVIDIA/warp/blob/v1.17.0/warp/_src/tape.py
Provenancegenerated
Source commit
d3cac5dc689a
Updated
2026-09-13

Transmission plates are generated from the canonical CUDA library. The linked run record identifies source and environment, while the validation data distinguish numerical stress inputs from physical measurement data.