Drift across a time series#

A time series of the same specimen drifts: each frame sees it a little further along. quantem.gpu.drift measures how far between the frames’ phase images and moves the frames back into register. It corrects translation only: no scan distortion, no change of the scan positions.

from quantem.gpu.drift import drift

found = drift(phases, sampling_A=0.124)    # phases: (frames, rows, cols)
found                                      # table: each frame's displacement and σ, pairs rejected, closure
found.show(phases)                         # frame 0, the mean before and after correction, spectra, the path
aligned, mean = found.corrected(phases)    # every frame on frame 0, over the region all frames share

phases can be a GPU tensor or CuPy array, a NumPy array (moved to the GPU), a list of 2D images, a list of SSB results, or the SSB stream itself. A stream or result supplies its own sampling and a stream its frame names, so drift(stream) needs no other argument. On a stream, stream.align() runs the same drift and also re-places the stream’s own combined image with it; call drift(stream) for the Drift alone.

What the numbers mean#

found.displacement_A[f] is (row, col) in Å: where frame f sees the specimen against frame 0. Frame f’s image is frame 0’s moved by it, I_f(r) = I_0(r - d_f); corrected reads I_f(r + d_f) by an exact Fourier shift and keeps the rows and columns inside every frame. found.sigma_A[f] is its one-sigma uncertainty from the least-squares residuals.

attribute

meaning

displacement_A

(frames, 2) Å, frame 0 at zero

sigma_A

(frames, 2) Å, one sigma

pairs

every registered pair: shift, confidence, whether it was kept

closure_A

d_ij + d_jk - d_ik for each consecutive triple: near zero when the pairs agree

frames

the frame labels (a stream’s names, else 0, 1, 2, …)

Which registration#

call

when

how

drift(phases)

each frame images the specimen well (a bilayer at full dose)

every pair up to 3 frames apart by its own lattice-safe search, a confident chain outvoting a pair that took a wrong lattice image, solved by least squares

running(phases)

frames too sparse to place one against another

each frame against the sum of all the others in register; no pairs, so no σ or closure

Which reconstruction to register on#

It does not matter. On a 40-frame experimental MoS2 bilayer series the drift measured on SSB, phase-only and amplitude + phase agree to 0.05 Å over 116 Å of drift, the size of the drift’s own σ. Measure it on whichever you have (the stream gives SSB) and correct any reconstruction of the same frames with it: found.corrected(phase_only_stack). The frames must share frame 0’s origin; a stack at another pixel size moves by its own pixels (corrected(stack, sampling_A=...), read from a stream or results when they carry it). Evidence: method comparison.

Advanced#

drift(..., frames=None, neighbours=3, band_A=(50.0, 0.67), reach_A=30.0, outlier_A=0.5): the frame labels (a stream’s names, else 0, 1, 2, …), how many frames apart pairs are registered, the band of periods compared (Å), how far a pair’s search reaches (Å), and how far a pair may disagree with the confident chain before it is rejected (Å). running(..., passes=2): how many rounds each frame is re-placed against all the others. register(reference, moving, sampling_A=...) registers one pair. Drift.plot(others) draws the path beside other measures of it (an ADF drift, an earlier run).