Lab cone-beam CT
Import, calibrate and reconstruct a lab cone-beam scan.
This guide takes a lab CT scan (a point source, a flat detector and a sample on
a turntable) from the scanner's files to a reconstruction: import, calibrate
the rotation axis, reconstruct with FDK or an iterative solver, and correct
per-view motion. Every step is one tomojax command, run with
uv run --no-sync from the checkout root as in the other guides; the Python
calls are at the end.
Import
A Nikon (X-Tek) scan is an .xtekct parameter file beside its projection
TIFFs. tomojax preprocess reads the source and detector distances, the detector
pixels and offsets, vendor reconstruction metadata, the white level and nominal
angles from InitialAngle and AngularStep; .ang and _ctdata.txt sidecars
are not used. tomojax inspect describes and checks the result.
Only TIFFs named <Name> plus the input separator and a number are
projections; flat fields, thumbnails and reconstructions sharing the prefix are
skipped. If a directory holds several parameter files, name the intended
calibration file explicitly. The examples choose zero dark level; use that
only for already dark-corrected counts, otherwise supply measured darks:
uv run --no-sync tomojax preprocess scan/part.xtekct --darks 0 -o scan.nxs
uv run --no-sync tomojax inspect scan.nxsProjections become line integrals, -log((I - D) / (WhiteLevel - D)), with
both TIFF rows and columns reversed from Nikon's top-right panel origin.
Rotation angles and horizontal calibration offsets are converted consistently
into TomoJAX's detector and object-angle frame.
Lengths stay in the scanner's units, millimetres for Nikon. The calibrated
rotation axis is read from ObjectOffsetX, ObjectRoll and ObjectTilt, or
the older CentreOfRotationTop/Bottom detector-plane offsets. The declared
view count excludes an extra closing frame on a full turn, with a warning.
Supply a measured schedule through explicit angles= in load_frames
(or --angles in preprocessing), already expressed in TomoJAX's object-angle
frame rather than the vendor's stage convention.
Zeiss Xradia projection acquisitions can be corrected directly, without a TIFF
export; see the Zeiss preview below. For scanners without
a reader, provide the measured ConeGeometry to tj.load_frames(..., geometry=..., flats=..., darks=...). Already-corrected TIFF line integrals can
be packaged on the command line; calibration.toml supplies the measured
detector_center = [u, v] in physical length units:
uv run --no-sync tomojax import projections/ --angles angles.csv --geometry cone \
--source-to-axis 120.5 --source-to-detector 980.0 --pixel-size 0.2 \
--config calibration.toml -o scan.nxs--flats and --darks take TIFF frames, or one level for every pixel
(--flats 60000 --darks 0) when the scanner calibrates its flat field.
Use one length unit throughout (the pixel size and both distances).
--pixel-size takes one value for square pixels, or the u then v sizes.
tomojax preprocess also corrects lab-CT artefacts.
Beam-hardening correction uses an explicit polynomial or spectrum through the
Python BeamHardening step; the
corrections reference
describes these models. Vendor settings are retained unapplied: their intensity
domains and scaling conventions are not established. --stripes 9 removes rings:
it subtracts each detector pixel's constant offset, comparing its values sorted
over views with those of its 9 neighbouring columns. Structure symmetric about the rotation axis also
looks stationary, so a narrow centred feature can be altered: use the smallest
width that covers the defect. A faint offset on a steep gradient can remain.
--zingers replaces bright specks.
In Python these are steps of tomojax.corrections (BeamHardening,
Stripes, Zingers) passed to tj.load_frames(path).corrected(...).
tomojax import --axis-offset and TOML keys detector_roll_deg,
detector_pitch_deg, detector_yaw_deg take values the scanner reports. A TIFF import
records no grid: cone datasets without one reconstruct one voxel per detector
pixel at the axis, the pixel size divided by the magnification
source_to_detector / source_to_axis, and tomojax recon --grid NX NY NZ
sets the number of voxels while preserving the selected physical field of view
(adjusting voxel sizes). For a small whole-volume preview, bin the detector
with preprocess and use recon --grid 64 64 64 --roi off. In Python the same
grid is scan.grid.resampled((64, 64, 64)), passed as grid= to reconstruction.
Zeiss Xradia preview
Use the projection acquisition (usually .txrm), not a reconstructed .txm
volume or a single scout image. TomoJAX reads the embedded reference image,
angles, detector calibration and source distances. Fractional per-view image
shifts stay in the geometry rather than resampling the measured pixels. For a
separate reference image, pass --flats reference_Front.xrm or
load_frames("scan.txrm", flats="reference_Front.xrm", darks=0).
Sample and reference exposure times are used to compare dark-subtracted count
rates. Multiple embedded references are interpolated at their acquisition
timestamps. Raw pixels remain unchanged. If supplying calibration arrays rather
than XRM files, also pass their flat_exposure_times= in seconds.
If no flat is available, supply the measured open-beam level as flats=; one is not inferred
from the detector's bit depth. If no
dark reference is stored, supply measured darks
with --darks or load_frames(..., darks=...); absent dark calibration is an
error, not an assumed zero. The examples below explicitly choose zero for
already dark-corrected counts.
This is a coarse, whole-volume CPU preview. It keeps every eighth view, bins
16 x 16 detector pixels and resamples the original physical volume to 64³ voxels.
Replace 801 below with the acquisition's number of sample views; CLI ranges
require both start and stop indices:
printf 'binning = 16\n' > preview.toml
uv run --no-sync tomojax preprocess scan.txrm --darks 0 -o corrected.nxs \
--select-views 0:801:8 --config preview.toml
uv run --no-sync tomojax recon corrected.nxs -o preview.nxs \
--method cgls --iterations 10 --grid 64 64 64 --roi off
uv run --no-sync tomojax inspect preview.nxs --preview previewsThe equivalent Python route is:
import tomojax as tj
scan = tj.load_frames("scan.txrm", darks=0).selected(slice(None, None, 8)).corrected(binning=16)
grid = scan.grid.resampled((64, 64, 64))
recon = tj.reconstruct(scan, "cgls", iterations=10, grid=grid)
tj.save("preview.nxs", recon)Per-view detector shifts can make this a ConeSegments geometry. Automatic
reconstruction then selects matched CGLS, not the single-arrangement FDK path.
Binning changes the detector sampling, not the recorded reconstruction grid;
keep the explicit grid for a small preview. Inspect all three slice planes and
increase view sampling, resolution and iterations for a final reconstruction.
A finite volume or lower residual alone does not establish its accuracy.
Calibrate the rotation axis
Scanners record the source and detector distances well but rarely the exact position of the rotation axis. An axis offset from the central ray doubles and blurs every slice; a rolled detector makes that offset change with height.
uv run --no-sync tomojax align scan.nxs -o calibrated.nxs --mode corOn cone data --mode cor estimates the axis offset (ConeBeam.axis_offset,
the lab-CT centre of rotation) and the detector roll. It reconstructs thin
FDK slabs near the centre, top and bottom of the volume for trial offsets,
coarse to fine on binned data, and keeps the sharpest; the slabs' offsets
change linearly with height in proportion to the roll. The output records the
calibrated beam, so later commands on calibrated.nxs use it. On simulated
scans it recovers offsets to 0.08 voxels and rolls to 0.04°
(measurements). It needs some in-plane structure at the slab heights and a
scan of at least 180° plus the fan angle. Detector pitch and yaw, the axis
direction and the source distances are not estimated.
Reconstruct
The default --method auto selects FDK for a single cone-beam arrangement;
--method fbp selects it explicitly:
uv run --no-sync tomojax recon calibrated.nxs -o fdk.nxsIt weights full turns, offset-detector full turns (the axis projecting near one edge of the detector, which widens the field of view) and short scans of at least 180° plus the fan angle (Parker weights). Like every FDK, it is exact only in the orbit plane, with cone artefacts growing away from it.
The iterative solvers model the cone beam exactly, with any per-view poses. CGLS suits low-noise data, and FISTA with total variation suits noisy or sparse scans:
uv run --no-sync tomojax recon calibrated.nxs -o cgls.nxs --method cgls --iterations 20
uv run --no-sync tomojax recon calibrated.nxs -o tv.nxs --method fista --iterations 50 --tv-weight 0.002Expert settings are fields of the explicitly selected method's configuration
class in a --config TOML file: views_per_batch = 16, say, or FISTA's
regulariser = "huber_tv" (see the
quickstart); in Python, pass
the class, tj.reconstruct(scan, "fista", config=FistaConfig(...)).
Their backprojection is the exact transpose of the projector, so CGLS stays stable as it iterates; ASTRA's CGLS, whose backprojector is approximate, diverges after about 20 iterations on the same scan (see measurements).
When the volume would not fit in GPU memory, FDK switches to z-slabs on the host by itself; see large scans and several GPUs.
Export
Volume viewers and analysis packages read slice stacks or raw volumes:
uv run --no-sync tomojax export fdk.nxs -o fdk_slices/ # 32-bit TIFF per z slice
uv run --no-sync tomojax export fdk.nxs -o fdk.raw --dtype uint16 # one raw fileAn output ending in .raw is written as one raw file; any other output is a
directory of TIFF slices. TIFF slices have y rows and x columns, numbered from
the bottom of the volume; raw files are little-endian and z-major. A JSON
sidecar (fdk.json beside the raw file, slice.json in the slice directory)
records the shape, the voxel size and, for uint16, the value range
mapped to 0–65535 (--range, by default the 0.1 and 99.9 percentiles). The
export reads one slice at a time.
Correct per-view motion
Sample drift, stage wobble and thermal motion move the sample a little in
every view. Cone-beam alignment estimates all six pose parameters per view,
including dy along the beam, which changes the magnification:
uv run --no-sync tomojax align scan.nxs -o aligned.nxs --mode cor-then-pose
uv run --no-sync tomojax recon aligned.nxs -o recon.nxs --method cglscor-then-pose calibrates the axis as --mode cor does, then aligns the
poses; --mode pose aligns the poses alone, and also takes scans that already
carry poses (an ASTRA import, say, or an earlier alignment), which it corrects
on top of, and multi-orbit scans, whose orbits it brings into register (see
multi-orbit scans). tomojax recon applies the
poses saved in aligned.nxs; with either command, --no-poses starts from the
nominal geometry instead. See
cone-beam alignment
for its accuracy and gauges.
Speed
On a 256³, 360-view scan on a laptop RTX 4070, FDK takes 0.048 s (ASTRA 0.29 s, TIGRE 0.51 s) and the forward projection 0.093 s (ASTRA 0.17 s). The exact transpose takes 0.18 s, slower than ASTRA's approximate backprojector (0.075 s). The measurements give the protocol, errors and the 1024³ FDK.
Python
import tomojax as tj
scan = tj.load_frames("scan/part.xtekct", darks=0).corrected()
calibrated = tj.align(scan, mode="cor").scan # axis offset and detector roll
recon = tj.reconstruct(calibrated) # FDK
tj.save("part-recon.nxs", recon)The tomojax commands hold the projections and the volume in host memory.
For scans larger than that, fdk_host reconstructs from a projection memmap
into a volume memmap; see large scans and several GPUs.
Coming from ASTRA
tj.Scan.from_astra takes projections and geometries exactly as an ASTRA
Toolbox script holds them: data in ASTRA's (rows, views, columns) layout, a
cone or cone_vec projection geometry and a 3-D volume geometry, as
astra.create_proj_geom and astra.create_vol_geom make them.
import astra
import tomojax as tj
proj_geom = astra.create_proj_geom("cone_vec", rows, cols, vectors)
vol_geom = astra.create_vol_geom(501, 501, 501) # with its window set as usual
scan = tj.Scan.from_astra(data, proj_geom, vol_geom)
volume = tj.reconstruct(scan).volume # (x, y, z): ASTRA's (z, y, x) transposed
data, proj_geom, vol_geom = scan.to_astra() # and backASTRA moves the source and detector around a fixed object; TomoJAX fixes them
and moves the object, so each ASTRA view becomes a rigid object pose. The
conversion fits a circular orbit (scan.geometry, with scan.angles running
the opposite way to ASTRA's) and keeps the scan's departure from it, such as a
tilted axis or per-view corrections, in scan.poses; a source raised above the
volume moves the grid instead. The source and detector must keep one
arrangement for runs of views: where the arrangement changes partway through,
as in a multi-orbit scan, each run becomes a segment of a ConeSegments scan
(see the next section), and vectors whose arrangement changes from view to view
are refused. Projections of the converted scan agree with ASTRA's to 0.03% on
tilted, offset and jittered geometries.
Try one FIPS walnut orbit on a CPU
Download and unpack a WalnutN directory from the FIPS collection linked in the examples. The importer handles its portrait TIFF orientation, reversed acquisition order, dark/flat correction and ASTRA vector geometry. It does not require ASTRA. Its defaults keep every twentieth view of the middle orbit, bin the detector 12 × 12 and use 64³ voxels over the authors' 50.1 mm cube:
uv run --no-sync python examples/import_walnut.py ~/data/walnuts/Walnut1 -o orbit.nxs
uv run --no-sync tomojax recon orbit.nxs -o fdk.nxs --method fbp --roi off
uv run --no-sync tomojax recon orbit.nxs -o cgls.nxs --method cgls --iterations 20 --roi off
uv run --no-sync tomojax inspect cgls.nxs --preview walnut-previewsIn Python, after the same import:
import tomojax as tj
scan = tj.load("orbit.nxs")
fdk = tj.reconstruct(scan, "fbp")
cgls = tj.reconstruct(scan, "cgls", iterations=20)
tj.save("walnut-cgls.nxs", cgls)Open all three slice planes, not just the axial plane: a single circular orbit
has cone artefacts away from that plane. The input uses the scanner's original
geometry, not the authors' corrected geometry; this is a coarse preview, not a
reference-quality reconstruction. --orbit 1 or --orbit 3 selects another
orbit. Smaller --every and --binning and larger --size increase sampling,
resolution, memory and runtime; leave the physical volume extent unchanged.
For all three orbits together, use the alignment example below, not FDK.
Multi-orbit scans
A scan whose source and detector arrangement changes partway through, such as
a multi-orbit scan with the source at several heights or a tall sample scanned
in stacked sections, has a ConeSegments geometry: each run of views with one
arrangement is a segment. tj.Scan.from_astra makes one from ASTRA
cone_vec vectors, and tj.Scan.combine([a, b, c]) joins separate scans.
Views that repeat an angle under one arrangement (several turns) stay in one
segment.
The iterative methods reconstruct all the segments together. FDK reconstructs
one arrangement and refuses segmented scans; warm_start=True starts an
iterative method from the view-count-weighted mean of each segment's FDK,
which needs every segment to cover an arc FDK accepts.
Recorded orbit heights are often slightly wrong, which doubles and blurs
edges. tj.align(scan) selects pose alignment for segments as one scan and
brings the orbits into register; the other modes refuse segmented scans. Only
the orbits' heights relative to one another are observable, so the result is
the least-motion estimate, and the whole object can sit away from where the
record puts it (0.38 mm, almost four voxels, on the FIPS walnut).
For a quick check without reconstructing, orbit_heights estimates each
orbit's height above the first from the consistency of the views' data. It is
good to about half a pixel at the axis, so treat it as a check or a starting
point, not a replacement for alignment:
import numpy as np
from tomojax.alignment.api import orbit_heights
found = orbit_heights(scan.geometry, np.asarray(scan.projections))
print(found.heights) # in the scan's length unit; the first orbit is 0
print(found.contrast) # near 0: the data cannot decide that orbit's heightIt returns None for a single orbit.
examples/align_walnut_orbits.py runs
the whole workflow on a FIPS walnut, three orbits recorded with the
uncorrected geometry: it reads them with from_astra, bins the detector 2 × 2
(scan.binned(2)), aligns and reconstructs. It needs a CUDA GPU with 8 GB and
about six minutes. On that walnut tj.align recovers the orbits' heights to
within 0.04 mm of the authors' correction; the
measurements give the
details and the comparison with ASTRA.
Limitations
TomoJAX models a circular source orbit (no helical scans) and a flat detector. Beam-hardening correction is a polynomial with given coefficients, and scatter is not corrected. Known limitations lists the rest.