Parallax and scan rotation on the encoded acquisition - 2026-10-05#
Question#
io.load returns an encoded acquisition on CUDA (Dataset4dstemGPU holding
entropy-coded counts), but parallax.run called cp.asarray on its input and
geometry.rotate_scan accepted only dense arrays, so neither worked on the data
a scientist actually loads. Can both take the encoded acquisition, read it only
in bounded parts, and give exactly what the old dense path gives for the same
counts?
Setup#
One NVIDIA RTX PRO 6000 Blackwell (96 GB), shared with other jobs. Python 3.14, CuPy 14.2.0, PyTorch 2.13.0.
Base: quantem.gpu
a8065dc9(dense CuPy input). Branch:ea42d293(parallax),ee113bcb(rotation).Synthetic: Arina-style bitshuffle/LZ4 masters written with h5py. Parallax: 24 x 28 scan, 32 x 32 detector, Poisson counts of one scene shifted in proportion to the bright-field tilt. Rotation: 13 x 21 scan, 16 x 16 detector, Poisson(30) counts.
Real: one native Arina acquisition, 256 x 256 scan, 192 x 192 detector, uint32 file whose counts fit uint16 (no flagged pixels).
Method: the base code ran on the dense counts read with h5py (
cp.asarray); the branch ran onio.load(master, backend="cuda"). Arrays were compared byte for byte (SHA-256 for arrays over 64 MiB). For parallax, spies recorded the deterministic inputs of the later stages: the mean pattern given to the disk detection and the float32 bright-field stack given to the alignment.
Results#
Parallax (voltage_kV=300, scan_sampling=0.5, fit_aberrations=True,
upsampling_factor=2):
Case |
Mean pattern, detected disk |
BF stack |
Shifts, image, aberrations |
|---|---|---|---|
Synthetic, auto disk (149 px) and given disk (113 px) |
identical |
identical |
identical with fixed-order binning; otherwise within the run-to-run spread below |
Real, radius 20 (1257 px), auto and given disk |
identical: center (95, 96), radius 55 |
identical |
identical with fixed-order binning |
Real, full disk (9477 px) |
identical |
identical (SHA-256) |
branch: 13.8 s; base dense path ran out of memory (34 GB allocated) on the shared GPU |
The alignment bins the detector with cp.add.at, a float32 atomic addition, so
its result changes in the last bits from run to run on identical input. Two base
runs on the same synthetic counts differed by up to 1e-4 px in the shifts and
0.02 in image values near 23000; base against branch by up to 1e-4 px and 0.035,
the same order. Replacing cp.add.at with a fixed-order sum in both runs (proof only)
made every output byte-identical between base and branch.
Scan rotation, encoded result read back and compared with the base dense result:
Case |
Data and validity mask |
Encoded time |
Dense CuPy time |
|---|---|---|---|
Synthetic: 0, 90, -90, 180, 270, 450 degrees (full, same); 30, -45, 123.4 degrees nearest with fill 0 and 7 (full, same) |
identical to base NumPy and base CuPy in all 24 cases |
0.045-0.156 s |
0.004-0.074 s |
Real, -90 degrees, full |
identical (SHA-256) |
5.06 s |
0.054 s |
Real, 180 degrees, same |
identical (SHA-256) |
0.95 s |
0.046 s |
Real, 33 degrees nearest, full (355 x 355 scan) |
identical (SHA-256) |
4.58 s |
0.178 s |
The dense times exclude building the 4.8 GB dense acquisition the dense path
needs; the encoded path never holds more than 256 MiB of decoded frames per band
and per read. The rotated acquisition is itself encoded: detector means, exact
masked sums and io.save/io.load round trips match the rotated counts.
Conclusion#
parallax.run(io.load(...))reads the bright-field pixels in bands of scan rows cropped to the disk’s bounding box (Dataset4dstemGPU.read) and fits the disk fromdetector.mean. Every stage input equals the old dense path’s, byte for byte, on synthetic and real data.geometry.rotate_scan(io.load(...), angle)maps every output scan position to a source position with the existing dense rotation applied to the scan-index grid, gathers each band of output rows from bounded reads, and encodes it into a new acquisition. Quarter turns and nearest-neighbor rotations are exact; bilinear rotation of encoded counts raises because it would produce float32.Neither operation has an Apple GPU (MPS) implementation; both raise
NotImplementedErrornaming MPS.
Findings left as they are#
Parallax spectrum tiling inserts zeros: with
upsampling_factor=2(the default) every odd row and column of the image is exactly 0 on the real acquisition (base and branch alike), because tiling the summed spectrum before the inverse FFT is zero insertion, not interpolation.Parallax applies the measured shifts twice:
align_vbf_stack_multiscale_cpreturns a stack already shifted by its phase ramps, and the upsampling step shifts it again by the same global shifts.
Checked 2026-10-10 against 0.0.1rc15: each image is now shifted once;
align_vbf_stack_multiscale_cp returns only the measured shifts. The spectrum
tiling is unchanged and ParallaxResult.density reports how many samples reach
each output pixel.
Rejected#
One exact detector query per bright-field pixel (
masked_sum_exactwith a one-pixel mask): one launch and one partial decode per pixel, thousands of launches for a real disk; a band read decodes each needed pixel once.Reading column bands for 90-degree rotations: a read narrower than the scan decodes one 512-scan interval per scan row (0.85 s for a 256 x 8 band of the real acquisition, against 0.086 s to decode it whole), so the rotation reads whole source rows and keeps only the frames each output band needs.
Bilinear rotation of encoded counts: the result is float32, which the count encoder does not store; use quarter turns or
interpolation="nearest", or rotate the reconstructed scan images.