Drag previews of the regularized direct reconstructions (native Metal)#
Status: implemented 2026-10-25 (ssb-direct v0.2). Port of Python’s CUDA DragWindow (direct_ptycho/cuda/direct.py,
direct_ptycho/cuda/chebyshev.py, direct_ptycho/direct_workflow.py drag/prepare/advance).
Problem#
.phase and .complex (MetalSSBJointModel.reconstruct(_:upsamplingFactor:method:regularization:)) solve, per scan
frequency, the damped normal equations of every supported output alias. The normal equations depend on the optics:
every aberration, tilt or depth change prepares them again from all BF pixels. On an Apple M5 Max at 3x on a
512 x 512 scan that takes about 0.17 s (phase) and 0.17-0.19 s (complex) per change, so a slider drag redraws at about
5 frames per second, against 30 for SSB (ssb-redraw). The preparation is bound by the per (frequency, BF pixel)
transfer and products and by streaming the 9 GB of cached spectra; it cannot reach 33 ms.
Design#
While one control moves and every other input is held, each prepared entry (Gram, right side) is a sum over BF pixels of f_k exp(i (c - c0) u_k): an entire function of the control c. On a window [c0 - h, c0 + h] its Chebyshev series converges like 2 (z / 2)^(N + 1) / (N + 1)!, z = h max|u|, so N + 1 exact preparations at the Chebyshev nodes determine every entry inside the window to the truncation bound; a drag frame then evaluates the series (sum_n c_n T_n) and runs the same float64 solves, scatter and inverse FFT as an exact reconstruction.
Choice |
Value |
Reason |
|---|---|---|
Reach z, truncation |
10 and 1e-10 for a magnitude, tilt or depth (N = 28); 20 and 1e-14 for an azimuth (N = 49) |
Python’s measured choice: the image reaches the float32 floor; a looser bound leaves visible error after the solve (cancellation in the Gram) |
Half width h |
reach / max |
u |
Nodes |
exact preparations at the node optics (the same kernels as |
one code path, so every node is the exact system; any control works |
Coefficients |
float-float while nodes are added, rounded to float32 once complete; evaluated in float-float |
float32 storage halves the memory (about 1.4 GB per complex window at 3x) at the float32 level of the preparation itself |
Tiles |
up to three per control on a grid c0 + 2 h k |
a drag back and forth across an edge reuses both (Python |
Validity |
layout (factor, prior, weighting), every other parameter of the point, rotation |
any change drops the window; a damping change does not (solve-time) |
Methods |
a complex window also serves |
as the prepared systems |
Memory |
at most a quarter of the device’s recommended working set, least recently used dropped first |
a complex window at 3x is about 1.4 GB (2.9 GB while building) |
Previews are never exact solves: MetalSSBJointPhase.preview (SSBDragPreview: control, centre, half width, order)
is set on every preview and nil on every exact result. Callers show it as a preview and settle on reconstruct when
the drag ends; they must not save or export a preview.
API (MetalSSBKernels)#
public enum SSBDragControl: Hashable, Codable, Sendable {
case c10, c12, phi12, magnitude(order: Int, symmetry: Int), angle(order: Int, symmetry: Int), tiltRow, tiltColumn, depth
public func value(of point: SSBModelPoint) -> Double // the point's units: A, rad, mrad
public func setting(_ value: Double, in point: SSBModelPoint) -> SSBModelPoint
}
public struct SSBDragWindowState { control, built, total, range, ready }
public struct SSBDragPreview: Codable { control, center, halfWidth, order }
extension MetalSSBJointModel {
func buildDragWindow(along:at:upsamplingFactor:method:regularization:nodes:isCancelled:) throws -> SSBDragWindowState
func dragWindowState(along:at:upsamplingFactor:method:regularization:) -> SSBDragWindowState?
func dragPreview(_:along:upsamplingFactor:method:regularization:) throws -> MetalSSBJointPhase? // nil: no window
func releaseDragWindows()
}
// MetalSSBJointPhase.preview: SSBDragPreview? (nil for exact results)
buildDragWindow adds nodes exact preparations (one by default) so a viewer can interleave them with frames and
yield to user requests; dragPreview never prepares or builds anything. .ssb has its own interactive redraw and no
window. The viewer contract mirrors Python’s drag / prepare / advance: while dragging, show the preview when a
window covers the value, otherwise the exact solve while the window builds; after an exact image, build windows for
the controls dragged next (Live4DSTEM: the last dragged, then C10, C12 and phi12) between interactions.
Alternatives not taken#
Reduced BF subset while dragging (the MPS SSB preview): about 15x fewer pixels would be needed for 33 ms at 3x, so a visibly noisier image, and still a different solution of the regularized model. A window is exact to the float32 level.
Per-alias (decoupled) solve as a preview: the cost of an SSB redraw, but the off-diagonal alias blocks carry about a third of the Gram’s products at 3x; the image is not the method’s.
SSB redraw while dragging Phase or Complex: a different method’s image, no amplitude.
Faster exact preparation only: the one-thread-per-alias kernel (
direct_normal_tiled) brings complex 3x from about 0.28 to 0.17-0.19 s; the floor of the per-pixel work and the spectrum stream stays far above 33 ms.
Measured (Apple M5 Max 40-core GPU, 512 x 512 scans, 300 kV, 30 mrad, 2026-10-25)#
Benchmark (metal-ssb-fit-benchmark --direct-drag CASE, a measured 0.373 A acquisition, Auto 3x, window along C10):
build (29 nodes) |
preview frame median (max) |
evaluate |
preview vs |
|
|---|---|---|---|---|
complex |
5.4 s |
8.9 ms (22) |
3.5 ms |
phase rms 9e-7 of the image std, max 6e-7 rad; amplitude rms 1.3e-7 of its mean |
phase |
5.2 s |
4.8 ms (10) |
1.4 ms |
phase rms 3.5e-7 of the image std, max 4e-7 rad |
The window covers C10 +- 6.8 nm (C12 +- 3.4 nm). In the Live4DSTEM app (headed, built-in display, 4 s drags of C10 and C12 after the fifth-order fit; median per-render wall time and median interval between published renders): complex 9.8-10.4 ms per render, a render every 18-21 ms; phase 5.4-5.9 ms, every 16-18 ms, on two 0.373 A acquisitions at 3x; on a 0.495 A acquisition at 4x complex 26-27 ms (every 33-47 ms) and phase 11.7 ms (every 18-20 ms), with windows of 12-15 s to build. Every render of every drag was a preview; the exact image follows the release in 0.24-0.29 s at 3x and 0.46-0.54 s at 4x.
Open: a window takes about 29 exact preparations (5 s at 3x, 12-15 s at 4x), so a drag beyond the prepared tile shows exact frames (about 5 per second at 3x) until the next tile is built. Building several nodes per pass of the spectra (the transfer once, B rotated node columns) would cut that by about 2x; the products per node bound it.