Skip to content

STalign 2.0.0: much faster alignment, more accurate defaults, bug fixes - #57

Merged
JEFworks merged 85 commits into
JEFworks-Lab:mainfrom
slowkow:upstream-2.0
Oct 16, 2026
Merged

JEFworks merged 85 commits into
JEFworks-Lab:mainfrom
slowkow:upstream-2.0

Conversation

@slowkow

@slowkow slowkow commented Oct 14, 2026

Copy link
Copy Markdown
Contributor

Hi! Thank you for STalign. While trying to make it faster, we ended up with a larger set of changes than we planned. We hope they are useful upstream. Everything is explained in CHANGES.md. This PR is large, and we are happy to split it into smaller ones (for example speed, bug fixes, new defaults, tutorials) if that is easier to review.

Faster

LDDMM and LDDMM_3D_to_slice get an optional C++ engine (pybind11, no OpenMP, vendored BSD-3 pocketfft). pip builds it when a C++17 compiler is available. Without one, installation still succeeds, and a new pure-torch implementation computes the same iterations. Both engines are tested against a plain-autograd reference implementation of the original iteration: gradients agree to within 1e-9, and the reference reproduces version 1.0 bit for bit where no fix applies.

The 10 tutorial alignments, with their own parameters and iteration counts:

version 1.0 C++ engine torch engine
Apple M1 Ultra 61 min 1.7 min 7.5 min
AMD Ryzen 5 5560U (Linux) 120 min 8.2 min 22 min

For example, the MERFISH-to-MERFISH tutorial goes from 9.2 min to 10 s, and MERFISH to the Allen atlas from 21.6 min to 34 s. rasterize is exact and about 50× faster: 86,000 cells take 31 ms instead of 1.8 s.

Bug fixes

These are listed in CHANGES.md with their measured effects. The main ones:

  • 3D regularizer. LDDMM_3D_to_slice took the FFT of v over only 2 of its 3 axes, so the penalty had no smoothness along one axis and was position-dependent.
  • transform_points_source_to_target used a forward Euler map that is not the inverse of the fitted map. It now inverts it exactly; exact_inverse=False keeps the old behavior. The simulation tutorial's ground-truth error drops from 9.68 to 6.68 µm.
  • transform_points_target_to_source integrated time steps in the opposite order to build_transform.
  • analyze3Dalign coordinates were wrong whenever dx ≠ 10: 4.3 mm off in the STARmap tutorial.
  • Crashes:
    • rasterizePCA (tools undefined);
    • build_transform3D(direction='f');
    • the annotators' output names;
    • normalize on a constant array.
  • Returned values: LDDMM returned A and v from different iterations.

More accurate defaults (opt out with legacy_defaults=True)

We built a ground-truth benchmark: real MERFISH, Xenium, heart and Allen-atlas layouts, warped by known deformations at several pixel sizes. We kept held-out problems that were scored only after the configuration was frozen. Against legacy_defaults=True on the held-out problems:

  • 2D: error is 0.43× (27.5 → 11.9 µm); 2D failures drop from 5 to 1 of 27.
  • 3D: error is 0.21× (124 → 26 µm); the slice-plane error drops from 3.1° to 0.4°.

What changed, and why:

  • A coarse affine pre-fit before LDDMM (prefit='auto'). Unknown rotations and 3D slice tilts were the largest source of error.
  • sigmaR derived from a displacement scale in µm (sigma_disp). The best sigmaR moved with pixel size (∝ 1/dx), because the matching term is a pixel sum while the regularizer is an integral. An explicit sigmaR is still used as given.
  • Step sizes scaled to the data and capped at the measured stability limit. With fixed steps, the Xenium problems diverged after the first E-step. Explicit epL/epT/epV are still honored.
  • 2,000 iterations in 2D by default. The error was usually lowest well before 5,000.

legacy_defaults=True reproduces the previous defaults bit for bit. Every 1.0 function and argument still exists, and new arguments are keyword-only.

New

  • align_cells(xI, yI, xJ, yJ): rasterizes two sets of cells at 15 µm and fits coarse to fine. In our tests its error is 0.39× that of LDDMM.
  • mixture='em': a working background/tissue model for targets with missing tissue. With the default sigmas, the old one had near-zero background weights.
  • rasterize(blur_um=...), backend_info(), and the draw, backend and num_threads options. draw also covers Opt out of plots and error progresion in output #49.

Tutorials, packaging and CI

  • Tutorials: all 17 notebooks run from top to bottom, were re-executed, and their aligned-cell CSVs were regenerated. Several had stopped running against current libraries. The cell-to-cell tutorials gain an align_cells section.
  • Packaging: version 2.0.0, Python 3.10 or later. requirements.txt lists only the 6 runtime dependencies; it previously pinned about 50 packages, mostly for the docs.
  • CI: a GitHub Actions workflow builds the engine and runs 275 tests on Linux and macOS (Python 3.10, 3.12 and 3.14), plus a job without a compiler. It passes on our fork (STalign 2.0.0: much faster alignment, more accurate defaults, bug fixes slowkow/STalign#1).

Note that re-executing the notebooks and regenerating the CSVs adds about 395 MB of new file versions; a squash merge keeps main's history small. We'd be glad to answer questions or adjust anything.

rasterize and rasterize_with_signal looped over cells in Python. The
Gaussian kernel and its per-window normalization are separable, so each
cell's contribution is an outer product of two 1D kernels; _raster.py
accumulates these as tiled matrix products in numpy (no new
dependencies). Images match the previous code to 1e-12 relative; 86k
cells take ~35 ms instead of ~2 s, 2M cells ~0.85 s instead of ~47 s,
and peak memory is bounded.

Also fix utility bugs found while auditing the module:
- rasterizePCA called the undefined name tools (NameError).
- rasterize_with_signal took np.abs of signed signals.
- normalize returned NaN for constant arrays.
- saveRasters ignored a folder path without a trailing slash.
- download_aba_* saved HTTP error pages as data.
- calculate_tre rejected torch tensors.
- plot_brain_regions cycled 10 colors over ~150 regions.
- point_annotator and curve_annotator raised NameError on output names.
- Source-to-target maps (transform_points_source_to_target,
  build_transform(direction='f'), transform_image_target_to_source,
  build_transform3D(direction='f')) now use the exact inverse of the
  backward map that LDDMM fits, by fixed-point iteration, instead of a
  forward Euler map that is not its inverse. exact_inverse=False keeps
  the old map. The simulation tutorial's ground-truth error drops from
  9.68 to 6.68 um.
- transform_points_target_to_source integrated the velocity in the
  wrong time order; it now equals build_transform(direction='b').
- build_transform3D(direction='f') always raised an error.
- analyze3Dalign indexed a 10-unit grid with dx-based indices, so
  atlas coordinates were wrong whenever dx != 10 (by 4.3 mm in the
  STARmap tutorial); it now evaluates the map at each cell.
- v_to_phii accepts the velocity layout LDDMM returns; transform
  helpers accept numpy, torch and integer inputs.

The implementations move to _transforms.py; STalign.py keeps the public
functions and docstrings.
The per-iteration cost was dominated by grid_sample's CPU backward,
which runs single-threaded at batch size 1, and by redrawing figures
every 10 iterations. The new torch engine chains the gradient by hand
through torch's grid_sampler kernels with the points split into chunks,
uses the analytic smoothed regularizer gradient, and draws once at the
end by default (draw=True restores the old behavior).

_lddmm_common.py holds what both engines share: input checks, kernels,
drawing, threads (num_threads, STALIGN_NUM_THREADS) and backend
selection (backend='auto'|'cpp'|'torch', backend_info()).

Fixes:
- LDDMM_3D_to_slice's regularizer transformed v over 2 of its 3 axes;
  it now computes the documented 0.5/sigmaR^2 sum_t |L v_t|^2. The 3D
  default sigmaR is unchanged for now.
- The returned A, v (and 3D Xs) come from the same, last evaluated
  iteration; v used to be one update ahead of A.
- muA/muB are matched to the target's channels (a 3-entry mean against
  a grayscale target used to count the distance three times).
- 3D landmarks, which have no term, are still ignored, but now with a
  warning.

tests/reference.py is a plain-autograd oracle of the fixed iteration;
it reproduces the previous code bit for bit where no fix applies, and
the engine matches it to ~1e-12.
A fused C++17 engine (STalign/_cpp, pybind11 bindings, std::thread
pool, vendored BSD-3 pocketfft) runs the whole fitting loop with the
GIL released: one pass over target pixels for the forward map and
contrast terms, one for the gradient with thread-local splatting, and a
fused spectral velocity update. It computes the same iteration as the
torch engine (gradients within 1e-13 of tests/reference.py) and gives
bitwise identical results for any thread count. It is fork-safe and
shared between package and sys.path imports.

backend='auto' uses it for CPU float64 problems and falls back to torch
otherwise. setup.py builds it as an optional extension: without a
compiler or pybind11, pip install still succeeds with a warning.
pyproject.toml declares the build requirements and MANIFEST.in ships
the sources in the sdist.

Per iteration on an M1 Ultra (16 threads) it is 81-91x faster than the
previous code in 2D and 39-45x in 3D: the MERFISH tutorial's 10,000
iterations take about 11 s instead of ~14 min, and the 3D atlas
tutorial's 2,000 about 35 s instead of ~22 min.
requirements.txt (read by setup.py) pinned ~50 packages, mostly for
the docs, and old numpy/torch versions. It now lists only what the
package imports, unpinned: matplotlib, numpy, pandas, pynrrd, requests
and torch. STalign.py drops unused imports (plotly, tornado, glob,
copy, ...) and imports pandas, requests and pynrrd only inside the atlas
helpers that use them.

tests/test_api.py checks that every public function and parameter of
the previous version remains, both import styles of the tutorials, the
fallback from cpp to torch, and the draw modes.
README: how the optional engine is installed, backend_info(), backend
and thread options, the in-place build for clones, and a summary of
changes. CHANGES.md lists the speedups, new options, changes that
affect results and bug fixes.
With the corrected 3D regularizer (Fourier transform over all three
axes), the old default sigmaR=1e8 regularizes LDDMM_3D_to_slice much
less than before: the rms velocity of the 3D tutorial fits became up
to twice as large. DEFAULT_SIGMAR_3D is now 2e6, which gives
deformations of about the previous size.

With the default epT the affine translation never converged: it
jumped back and forth by tens of um per iteration until the last one,
so changing the initial T in its last digit moved the cells by
24-84 um. The new keyword-only argument affine_step_control (default
True in LDDMM_3D_to_slice, False in LDDMM) scales the step of each
entry of L and T by its own multiplier in [1e-3, 1], which halves when
that parameter's gradient changes sign and grows by 5% per iteration
otherwise (Rprop's sign rule), so steps never exceed epL and epT. The
constants are module defaults in _lddmm_common; the state lives in the
torch and C++ engines and persists across run() segments. It is off in
2D, where the plain steps already converge (MERFISH) or the control
made the noisy H&E tutorial worse. Off, results are bitwise unchanged.

On the two 3D tutorials the fits now reproduce the research runs
behind these defaults (dev/research/3d) to rounding, a 1-ulp change of
T moves the cells by ~1e-12 um, and the C++ and torch engines agree to
~1e-14. Matching energy, cells in brain, Dice and correlation with the
atlas stay close to the previous version (CHANGES.md).

tests/reference.py implements the control, and both engines are
tested against it in 2D and 3D with oscillating affine steps,
diffeo_start, landmarks and segmented runs, plus defaults, validation,
cpp/torch agreement and thread-count invariance. The C++ binding API
version is now 2.

Also: test_raster.py and test_transforms.py find the frozen original
through STALIGN_ORIGINAL like the other tests, and the torch engine's
docstring points to CHANGES.md for the corrections.
Built and tested on Linux (Pop!_OS 22.04, gcc 11.4, torch 2.14.1+cpu):
200 tests pass after these two changes.

- test_fft_pool_after_fork hung on Linux. The cause is torch, not
  STalign: Linux torch wheels use GNU OpenMP, which deadlocks the first
  multi-threaded torch op in a forked child once the parent has used
  threads (a plain elementwise op hangs the same way). The child now runs
  torch on one thread, as torch's DataLoader workers do; STalign's FFT
  pool is still called with 2 workers, so the test still catches a pool
  that is not re-created after fork.
- Zero-initialize SplatAcc::acc to silence 8 false-positive gcc 11
  -Wuninitialized warnings (acc is zeroed before every read); results
  are unchanged.

README: the engine is now tested on macOS and Linux.
The kernel width of rasterize is given in pixels (standard deviation
2*blur pixels), so the same blur gives a wider kernel at a coarser pixel
size. The new keyword-only blur_um gives the standard deviation in the
units of the coordinates instead (blur = blur_um/(2*dx)), so a raster can
be redone at another pixel size with the same physical kernel. This is
what the re-rasterizing alignment workflow needs.

blur now defaults to None, which means 1.0 unless blur_um is given;
passing both raises ValueError. Calls without blur_um are unchanged bit
for bit (checked against bec34e4 on synthetic and eval-suite cell sets),
and blur_um gives exactly the result of the equivalent blur.
On the synthetic problems of the round 2 evaluation suite, the most
accurate 2D workflow re-rasterizes both cell sets at 15 um with a 30 um
kernel and fits LDDMM on a two level pyramid of mean-pooled copies of
those images (4x for 500 iterations, then 2x for 500), each level
starting from the previous level's affine and velocity field. Users
rasterize their cells themselves today, usually at a coarser pixel size
with a kernel tied to it.

align_cells(xI, yI, xJ, yJ, dx, blur_um, levels, **lddmm_kwargs) does
this with the public pieces: rasterize with blur_um, and one ordinary
LDDMM call per level with LDDMM's own defaults, so anything LDDMM
derives from the images is derived per level. The first level gets the
user's initial state, prefit and diffeo_start; later levels get the
previous A, v and xv (an initial velocity field, so LDDMM does not
pre-fit again) and diffeo_start=0; with landmarks, sigmaP is scaled by
the pooling factor; only the last level draws. It returns the last
level's LDDMM result (so the transform functions work on it unchanged)
plus the rasters and the pooled grids of each level.

The matching weights start afresh at each level because LDDMM takes no
initial weights. The research version (syn.py's W path) carries them
over; with its shipped settings that changed the errors on eight eval
problems by 0.1% (geometric mean). With carry-over disabled, the same
settings and the same velocity grid (LDDMM builds it from the first
level's source; syn.py from the full raster), align_cells reproduces
syn.py's pyramid bit for bit.
CHANGES lists the two additions with the measured effect of the
workflow: on the 27 held-out synthetic 2D problems of the round 2
evaluation suite (Modal runs cells_synW_holdout_modal vs
ship_core_holdout_modal), re-rasterizing the cells at 15 um and fitting
the two level pyramid had a geometric mean error of 5.2 um instead of
11.9 um for LDDMM with the round 2 defaults on the suite's rasters,
better on 24 problems, in half the fitting time. It notes the two
limits seen there: problems already rasterized at 15 um got slightly
worse, and partial tissue remains hard. The README points users with
two sets of cells to the new function.
bec34e4 zero-initialized SplatAcc::acc to silence a false-positive
-Wmaybe-uninitialized from GCC (acc is always zeroed before it is read).
With clang the initializer changes the code generated for pass 2 and
costs about 4% per 2D iteration: 2.48 vs 2.38 ms/iter on the MERFISH
bench input (342x326, 4 threads, Apple M1 Ultra, timing lock).

Keep the initializer for GCC only. GCC 16 compiles the engine without
warnings and gets the same code as before; clang gets the code of
3a5f88b back. Results are unchanged (bitwise, both backends).
LDDMM's landmark term moved pointsI by forward Euler steps,
p <- p + v_t(p)/nt, which invert the fitted backward map only up to a
time discretization error. Since transform_points_source_to_target uses
the exact inverse, the landmark energy measured a different map from
the one applied to the points after the fit.

Each step now solves p_{t+1} = p_t + v_t(p_{t+1})/nt: fixed-point
iteration from the Euler step (until no landmark moves by more than
1e-10 of a velocity grid cell, at most 100 sweeps), then one Newton
step, so the result does not depend on the tolerance. The gradient
uses the implicit function theorem with the same point and Jacobian.
Both engines and the oracle (tests/reference.py) implement it. It is
the new default, Problem.landmark_map = 'exact'; 'euler' keeps the old
map. Solves that do not converge are counted in the engine snapshot,
and the fit warns.

Validation: gradients match the oracle to ~1e-12 and the engines agree
to 1e-9 over 150 iterations. On the four real landmark cases (Modal)
both backends reproduce the research run syn_lm_torch_exact_modal:
same landmark errors, maps within 0.003 um, e.g. merfish_visium_points
in-fit 67.8 um (Euler 73.8) and held-out 265.1 um (Euler 270.7).
xenium_heimage differs by 6-8 um, within its 1-ulp noise floor (8.7
um). With landmark_map='euler' results are bitwise equal to bec34e4 in
2D and 3D, on both backends.

The oracle-vs-original test uses landmark_map='euler'. The affine
step-control fixture uses epL=5e-5: with 1e-4 and the exact map, L
oscillates with growing amplitude for ~20 iterations, which amplifies
rounding differences ~1e4-fold. The C++ binding API version is now 3.
New keyword-only argument mixture=None of LDDMM. 'em' runs the
mixture-model lens's working EM, natively in both engines and in the
oracle. WM starts at 1. From iteration 50, every 5 iterations, a
log-space E-step weighs each target pixel between the matching class
and a background class. The matching variance is estimated from the
residual. The background mean is the median of the target's border (or
the user's muB) and is kept fixed. The background variance is
(sigmaB/sigmaM)^2 times the matching variance, the matching prior is
kept >= 0.5, and there is no artifact class. A dict overrides the
options start, every, classes, pi_min, pi_init, fix_muA and fix_muB.
mixture=None leaves the original weight updates unchanged (bitwise).
LDDMM_3D_to_slice takes the argument but raises NotImplementedError,
since the EM was not studied in 3D.

The research prototype ran the E-step as a Python hook between engine
segments, which cost +35-40% per iteration. Native, it costs nothing
measurable (MERFISH bench input, 4 threads, timing lock): C++ 2.39 ->
2.37 ms/iter (the EM skips the original's class-mean sums), torch +1.6%.

Validation: both engines match the oracle over 200 iterations (~1e-9)
and each other, with results independent of the thread count and of
how a run is split into segments. Parity with the research
implementation (mixture/mixcand.py with its mixcpp engine and syn.py's
MIX_V3 options), on 5 eval problems including 3 partial-tissue ones,
5000 iterations: the maps are identical (0.0 um) on 4 of them. On
xen_m150_dx30_partial the energies agree through iteration 80 and the
maps end 51 um apart. That problem is chaotic: a 1-ulp change of T
moves its map by 43 um.

The C++ binding API version is now 4.
A new private module with the data-driven settings of round 2, ported
from the research reference (synthesis/syn_search.py and syn_scale.py)
with the same math:

- affine_prefit: a coarse multi-start affine pre-alignment. 2D: the
  initial affine rotated about the target tissue centroid in 12 steps,
  each start fitted by batched Adam (100 iterations) on images pooled
  to about 80 pixels across, the best one refined (60 iterations) at
  twice that resolution; with landmarks a single start with STalign's
  landmark term. 3D: the initial plane tilted by {-6, 0, 6} degrees
  about both in-plane axes and shifted by {-300, 0, 300} um (27 starts,
  200 + 100 iterations). The match is LDDMM's contrast-fitted data term
  with background weight 0.1 and a linearly varying intensity bias.
  Ties within 1% go to the start closest to the initial affine. There
  is no randomness.
- physical_sigmaR: sigmaR for a prior displacement scale in um,
  sigma_disp sqrt(C_d pi) a^(d/2) sqrt(nt) (dx/30) sigmaM / s_J.
- curvature_steps: steps scaled by sigmaM^2 * 1.1 / G, G a curvature
  proxy of the 2D image term.

On 12 eval-suite problems the pre-fitted affine and sigmaR are bitwise
those of syn.py (with float64 tilt angles in 3D, where syn.py computes
them in float32), and the steps agree to 4e-16 (bilinear sampling by
grid_sample instead of scipy's map_coordinates: no scipy dependency).
New defaults of LDDMM and LDDMM_3D_to_slice (round 2, A1-A4), all in
the engine-agnostic front-end (resolve_defaults in _lddmm_common, run
between prepare_problem and the engine):

- prefit='auto' (keyword-only): the coarse affine pre-fit of _prefit
  runs in 3D, and in 2D unless landmarks are given; never when an
  initial velocity field is given (it would move the affine under a
  fitted deformation, e.g. a warm start). True/False force it. It
  starts from the user's L/T/A; if it fails it warns and the fit
  starts from the given affine.
- sigmaR=None (2D and 3D): the physical rule with the new keyword-only
  sigma_disp=94.03 um. An explicit sigmaR is used as is.
- epL, epT, epV = None in LDDMM: the old defaults scaled to the
  curvature of the image term. Explicit values are used as is. 3D steps
  are unchanged.
- niter=None in LDDMM: 2000. LDDMM_3D_to_slice keeps 5000.

legacy_defaults=True (keyword-only, A6) restores the earlier fit bit
for bit: no pre-fit for 'auto', niter 5000, the old 2D steps, sigmaR
5e5 (2D) / 2e6 (3D), and the forward Euler landmark map when the
problem has a landmark_map field. LEGACY_DEFAULTS replaces
DEFAULT_SIGMAR_3D.

The result dict gains 'prefit' (what the pre-fit did) and 'settings'
(the niter, sigmaR, epL, epT, epV used); passing them back with
A=prefit['A'] and prefit=False repeats the fit exactly. run_lddmm
refuses a problem whose settings are still None.

Tests: test_round2_core.py covers the pre-fit (large 2D rotation,
tilted 3D plane, ties, determinism, the 'auto' rules, failure), the
sigmaR and step rules, the settings round trip, cpp/torch agreement on
the default path, and legacy_defaults (against the explicit old values,
and against commit bec34e4's package when STALIGN_LEGACY_SRC is set).
The oracle comparisons in test_lddmm_torch.py run with prefit=False,
test_api's 3D problem too, and the changed defaults are listed.
A "Round 2" section in CHANGES.md: the affine pre-fit, the sigmaR rule
with sigma_disp, the curvature-scaled 2D steps, niter 2000 in 2D, the
legacy_defaults switch and the new 'prefit' and 'settings' entries,
with the measured effect on the evaluation suite's held-out problems
(paired Modal runs of the shipped configuration against 3a5f88b: 2D
error ratio 0.43, 3D 0.21) and the partial-tissue caveat (5 of 8
partial-section problems got worse). The 3D sigmaR=2e6 default of the
earlier entry is marked as superseded; README gets a short note.
curvature_steps divided by max(G, 1e-300), so a constant target (G = 0)
got steps of order 1e300 and the fit blew up. Without a finite positive
curvature the steps are now returned unchanged.
'auto' skipped the pre-fit whenever v was passed. A zero v (passed
with xv to choose the velocity grid) carries no fitted deformation, so
the pre-fit is harmless there; only a nonzero v (a warm start) now
turns it off. resolve_defaults reads this from the problem instead of
an initial_velocity argument.
The overview of _lddmm_common now names the step between
prepare_problem and run_lddmm that runs the pre-fit and fills in the
data-driven settings.
The test module's docstring gives the commands that export and build
commit bec34e4 for STALIGN_LEGACY_SRC.
The core branch set problem.landmark_map = 'euler' only when the Problem
had that field, because the exact landmark map came from a parallel
branch. Both are merged now, so legacy_defaults=True sets it directly.

The bec34e4 comparison that covers the landmark case only runs when
STALIGN_LEGACY_SRC is set, so test_legacy_defaults_are_the_old_values
now also checks, on every run, that a fit with landmarks under
legacy_defaults=True equals one with the old values and the Euler map
on both backends, and that the default (exact) map gives a different
fit.
align_cells relied on LDDMM's prefit='auto' skipping the pre-fit when it
is given an initial velocity field. The merged rule skips it only for a
nonzero v, so after a level of affine-only iterations (diffeo_start at
least that level's niter) the next level ran the pre-fit again from the
warm start: with levels ((4, 20), (2, 20)) and diffeo_start=100 the
second level reported prefit ran=True.

Later levels now pass prefit=False. With the default levels the
previous level's v is nonzero, so 'auto' already skipped the pre-fit
and the results are unchanged.
The three branches documented their parts separately: the core branch
wrote the Round 2 section, the cells branch added align_cells and
blur_um under "New options", and the engines branch left its notes to
the merge.

CHANGES.md now has one Round 2 section. It adds the exact landmark map
and the mixture='em' option, with their measured effects; the
align_cells and blur_um entries move into it, the partial-tissue caveat
points to the mixture option, and the legacy_defaults entry lists the
Euler landmark map. The README's change list and the LDDMM docstring
(sigmaP, mixture, 'settings') say the same.

The landmark errors come from the merged package on the full
evaluation suite (results/r2_pkg_full_modal) against the core branch's
run with the same defaults and the Euler map (r2core_pkg_full_modal):
only the four landmark cases change. The mixture numbers are the
verification's marginal of the EM in the research configuration
(partial sections 0.25, 8 of 8 better; density problems 1.09; the rest
1.01).
The oracle's docstring and SIGMAR_3D comment still called 2e6 the
package's default. Since Round 2 the package chooses sigmaR from the
data, and 2e6 is what legacy_defaults=True gives; the oracle has no
pre-fit and no data-driven settings, so comparisons with it pass
prefit=False and explicit settings.
The exact landmark map finishes each implicit step with a Newton step
that solves M = I - Dv_t(z)/nt.  When M is exactly singular, the torch
engine raised from torch.linalg.solve and the C++ engine divided by a
zero pivot, so the fit stopped or went non-finite.

Both engines (and the oracle) now test det M = M00 M11 - M01 M10 with
the same formula and, where it is 0 or NaN, take the forward Euler
step p_t + v_t(p_t)/nt for that landmark and time step instead, with
the Euler step's gradient.  Such steps are counted with the solves
that did not converge, so the existing RuntimeWarning reports them;
its text now mentions a singular Jacobian.  Non-singular steps run the
same operations as before, so fits are unchanged.

The new test builds a velocity field with Dv/nt = diag(1, 0) exactly
at a landmark and checks both engines against the oracle (with the
image term switched off), the count and the warning.
The engines' gradients of the exact landmark map were checked only
against the oracle, which differentiates the same implicit-function
construction.  The new test checks dEP/dL, dEP/dT and the derivative
along a smooth direction of v against central finite differences of
the landmark energy on both engines (the image term is switched off
with WM = 0).  The errors are about 1e-10 (L, T) and 3e-9 (v) of the
gradient's size, against a tolerance of 1e-6; the Euler map's
gradient differs from the finite difference by 3%, so the test tells
the two maps apart.
curvature_steps keeps the given step sizes when the target has no
curvature, but curvature_proxy took a channel's variance from np.var,
which is a tiny positive number for most constants (np.var of an image
of 0.3 is about 1e-34, from the rounded mean).  On such a target the
2D steps were multiplied by about 6e33.

A target channel now adds variance only if it is not constant
(np.ptp > 0), so a constant target gives G = 0 and the steps are kept.
Likewise a source channel that is constant in I is skipped, as the
docstring says, even where its bilinear samples differ in the last
bit.  Images without constant channels give the same steps as before.
On a constant target the pre-fit's tissue weights are all zero, so the
tissue centroid was 0/0: numpy emitted four RuntimeWarnings, the
search failed with "attempt to get argmin of an empty sequence" and
the generic failure warning followed.

_prefit.has_tissue checks the target, pooled as the coarse search pools
it, before the pre-fit runs.  Without tissue the pre-fit is skipped
(also with prefit=True) with one warning that says why, 'prefit' reports
the reason 'no target tissue', and the fit starts from the given
affine.  Targets with tissue take the same path as before.
* The 2D affine step control: the Xenium-to-H&E landmark errors 34.2
  and 30.3 are in pixels of the H&E image, not um. The harness compares
  against pointsI, which are H&E pixel coordinates
  (dev/bench/cases.py, _xenium_heimage; dev/research/3d/tables/
  twod_xenium_heimage.md; dev/integration/integrate_output.json says
  "TRE is 34.2 vs 30.3 px"). In um they are about 106 um, as the
  Round 2 bullets report.

* The 3D table's "this version" rows came from the fits with
  sigmaR=2e6 and no pre-fit (dev/research/3d/tables/final.md), which
  are what legacy_defaults=True now gives: on all 41 problems of the
  evaluation suite, including both 3D tutorials, its fit files are
  bitwise identical to those of 3a5f88b
  (dev/round2_impl/validate/identical_r2val_legacy_vs_review_3a5f88b_full.md).
  The rows are relabelled, and the Round 2 defaults' own values are
  added from the verified run of b87713c
  (dev/research/round2/eval/results/vfy_full_modal/summary.md):
  MERFISH 97.3% in brain, Dice 0.885, correlation 0.632; STARmap
  98.8%, 0.806, 0.666. The 3a5f88b column of that comparison
  reproduces the old rows exactly, so the metrics are the same.

* The velocity-curvature cap's cost was given per million target
  pixels; it grows with the velocity grid instead. The verification of
  b87713c measured 1.9 s at a=500 and 14.6 s at a=200 on a 679 x 841
  target with 25 um pixels, 2 threads
  (dev/round2_impl/steprule_journal.jsonl, last record).
The changelog was an "Unreleased" section assembled from separate
branches. It is now one document for 2.0.0, dated 2026-10-14, in the
order speed, Round 2 defaults, new functions and options, corrections,
bug fixes, tutorials, packaging and testing.

* Faster comes first and says that the tutorials pass their own niter,
  so the timings do not include the 2000-iteration default; the Round 2
  overhead (up to 6 s / 12 s) is the pre-fit and step estimation
  (dev/round2_impl/bench_final/README.md, summary.json).
* The pre-fit times are "about 1-4 s" in 2D: the Mac's range is
  0.8-3.8 s at 4 or 16 threads (bench_final/summary.json, prefit_s).
* LDDMM_3D_to_slice's epV default is now None (1e3, capped by the
  regularizer bound), in its own bullet and in a list of all changed
  defaults, which matches CHANGED_DEFAULTS in tests/test_api.py.
  legacy_defaults=True in 3D gives epV=1e3 without the cap
  (STalign/_lddmm_common.py, LEGACY_DEFAULTS and resolve_defaults).
* align_cells' losses where the suite's rasters had 15 um pixels are
  10-14% on the held-out set and 5-14% over all three sets
  (dx=15 ratios 1.119/1.136/1.100, 1.137/1.051/1.107 and
  1.112/1.126/1.068; dev/round2_impl/steprule_journal.jsonl).
* blur_um moves to the options; the corrections section says its
  effects were measured with the pre-Round 2 defaults, and the 3D
  sigmaR history is told from version 1.0 (1e8) instead of from the
  intermediate 2e6 default that was never released.
* LDDMM_3D_to_slice takes mixture but raises NotImplementedError for
  anything but None; the text said it "does not have it".
* Tutorials: from the notebooks branch. The fit cells take 0.6-35 s
  (579 ms to 34.5 s in the executed notebooks' %%time outputs; the
  curve annotator 3 min 43 s); the medians 47.5, 14.1, 98.2 and
  69.3 um and the simulation's RMSE 6.383 um are printed in the
  notebooks; the start-plane offsets 252.8 and 47.2 um follow from
  slices 177 and 140 of the 50 um CCFv3 with scale_z 0.9. The old
  notebooks' recorded times, from other machines, are not compared;
  the Faster table is the controlled comparison. The simulation's
  6.68 um (exact inverse alone, dev/integration/E2E.md) and 6.38 um
  (this version) are both stated.
* Packaging and testing: the version, the pocketfft license in wheels,
  the GitHub Actions workflow, the documentation workflow's actions,
  docs/requirements.txt, the Pipfile and the setuptools note.
The README said align_cells had 10-14% more error than LDDMM where the
suite's rasters had 15 um pixels, speaking of "our tests" in general,
but 10-14% holds for the held-out set only. Over the main, held-out
and further held-out sets the dx=15 ratios are 1.137/1.051/1.107,
1.119/1.136/1.100 and 1.112/1.126/1.068, so 5-14% (verification of
b87713c, dev/round2_impl/steprule_journal.jsonl). CHANGES gives both.
* Installation: one section with the pip line, the tutorials' extra
  packages and the import; the C++ engine is a subsection that says
  the compiler is optional, shows backend_info() and the threads
  setting in one paragraph, and keeps the in-place build for clones.
  The engine was also built and tested with gcc 13 (Ubuntu 24.04, by
  the CI simulation of the pr-ci branch), so it says gcc 11 and 13.
* The pipenv instructions are condensed to their two commands and the
  note about Pipfile.lock.
* "What changed in version 2.0.0" keeps the five changes most likely
  to matter for existing scripts, ordered by impact, and points to
  CHANGES.md for the rest. The 2D time halving is left to CHANGES:
  next to the Performance table, whose tutorials pass their own niter,
  it would read as a contradiction.
* The Performance section is unchanged.
README and CHANGES said the Round 2 defaults cut the alignment error
to 0.43 (2D) and 0.21 (3D) times "the previous one", and that
align_cells had 0.17 times the error of "the previous version".  Every
one of these comparisons was made against this version with
legacy_defaults=True (the pre-Round 2 package, which already has the
exact inverse, the corrected 3D regularizer, 3D sigmaR=2e6 and the 3D
affine step control), not against version 1.0, which was not run on
the test problems.  Readers would take "the previous version" to mean
1.0, and CHANGES also used the phrase for 1.0 itself in the speed and
3D tables.

Say legacy_defaults=True wherever the baseline is the old defaults,
state once what that baseline is, and say "version 1.0" wherever the
baseline is the original code (the speed tables, the 3D atlas table
and the rounding paragraph).  No number changes.
- align_cells was not less accurate on every problem whose suite
  rasters had 15 um pixels: it was 5-14% worse on 9 of these 11, but
  had 0.42-0.44 times LDDMM's error on the 2 with a wider kernel
  (mer_m150_dx15_serial_b2 and its held-out twin).
- Of the two partial heart sections that the step caps "improved",
  hrt_m50_dx60_partial started at 42.5 um and fails with or without
  the caps (105.8 and 84.1 um); say that the error fell, and that.
- The 142 of 351 stress fits that were unstable had the uncapped
  Round 2 steps (scaled by G alone), not version 1.0's steps; say so,
  and call the steps the caps lower "uncapped" instead of "old".
The wheel ships STalign/_cpp/third_party/pocketfft/LICENSE.md as the
license of the pocketfft code compiled into the C++ engine, but that
file named only "Copyright (C) 2010-2018 Max-Planck-Society".  The
vendored pocketfft_hdronly.h, which is what the engine compiles in,
names more holders (Max-Planck-Society 2010-2024, Peter Bell, Matteo
Frigo and MIT, Tan Ping Liang, Cris Luengo), and BSD-3-Clause asks
binary distributions to reproduce that notice.  LICENSE.md is now the
header's notice verbatim; the conditions and disclaimer are unchanged.
The tutorial had a cell under the heading "TO DO: remove section below
before uploading" that ran importlib.reload(STalign).  Its output was
the module's repr, which showed the local path of the checkout that
ran the notebook (the author's machine on main, a development worktree
here) and would also be published on the documentation site.  The
reload has no effect on the tutorial, so the heading and the cell are
removed.  The notebook was re-run with this version; apart from the
two cells, only object addresses and timings differ in its outputs,
and every figure is identical.
The heart tutorial saved tpointsJ, the cells of CN73_E2 (the target)
mapped into the coordinates of CN73_E1 (the source), as
CN73_E1_to_CN73_E2.csv.gz, a name that reads as the opposite
direction.  It now writes CN73_E2_STalign_to_CN73_E1.csv.gz, like the
other tutorials' "<moved>_STalign_to_<reference>" files, and a comment
in the save cell says that the rows are (y, x) positions.  The file is
new in this version (the save cell never ran before), so nothing reads
the old name.  Its content is unchanged (same 11,770 rows); the
notebook was re-run, and only object addresses and timings differ in
its outputs.
The point-annotator tutorial thresholds the matching weights
(WM_values > 0.95) into results_filtered, plots it, and then saved
results, all 85,958 cells, as ..._with_point_annotator_filtered.csv.gz.
It now saves results_filtered: the same rows and columns as the cells
with WM_values > 0.95 in the previous file (38,657 cells).  This bug is
older than this version, but this version regenerates the file.

The same re-run includes the numpy 2 cleanup of this notebook: the
affine is converted with A.cpu().numpy() instead of np.array(A.cpu()),
which warned that torch's __array__ takes no copy argument (the
warning also printed the local temporary directory).  Apart from the
warning, only object addresses and timings differ in the outputs, and
every figure is identical.
Five tutorials printed warnings, each with the local temporary
directory of the kernel in its file name:

- np.array(A.cpu()) and np.array(tpointsI[:,1]) (affine only with
  points, simulation): numpy 2 warns that torch's __array__ takes no
  copy argument.  They now call .numpy().
- torch.sum(WA*J, ...) with a numpy J (MERFISH to Visium, with and
  without the curve annotator): the product went through numpy and
  torch's __array_wrap__, which numpy 2 deprecates.  J and I are now
  converted with torch.as_tensor; muA and muB are bit for bit the same
  (float64, as before).
- ax.scatter(..., vmin=0, vmax=1) without c (Xenium to Xenium):
  matplotlib warns that it ignores vmin and vmax.  They are removed.

The five notebooks were re-run.  They have no stderr output now; apart
from the warnings, only object addresses and timings differ, and every
figure is identical.  The CSV files they write are left as committed:
their content is the same, except the affine-only-with-points file,
which differs by under 1e-10 um, as in the previous regeneration.
- Name the heart tutorial's result file and say what it holds.
- Mention the point annotator's filtered file, the tutorials that now
  run without warnings and the removed reload cell of the L/T
  tutorial.
- Say that the uncompressed CSVs in docs/xenium_data and
  docs/starmap_data come from earlier versions of the tutorials and
  do not match this version's results (they are left in place).
- Update the range of the tutorials' alignment times to the re-run
  notebooks: 0.5-36 s, and 3.8 min for the curve annotator.
On main and so far on this branch, STalign/__init__.py exported
nothing, so `import STalign; STalign.LDDMM(...)` worked only when
STalign.py itself was imported as a top-level module.  The tutorials
that offer only "OPTION B" (sys.path.append("../../STalign"); import
STalign) do that from a clone, but with STalign installed the package
comes first on sys.path and the tutorials fail at their first
STalign.<function> call.  Five tutorials, including
merfish-xenium-alignment since this version, use only that import.

The package now star-imports STalign.STalign, so both imports give the
same functions; `from STalign import STalign` is unchanged.  A new test
imports the package the way an installed copy is found first and
checks that it has every function of the module.
The docstrings of legacy_defaults (LDDMM, LDDMM_3D_to_slice,
resolve_defaults) cited "commit bec34e4", which upstream will not have
if the pull request is squashed, and "Round 2", and align_cells cited
"the round 2 evaluation suite".  They now say what the options do in
terms of version 1.0 and point to CHANGES.md for the measurements.
The 3D legacy_defaults docstring also names epV=1e3, which
LEGACY_DEFAULTS sets.  Docstrings only.
The tests that compare the rasterization, the transforms, the
plain-autograd oracle and the function signatures with version 1.0's
STalign.py skipped in CI, because they looked for it only in the
untracked dev/ directory or in STALIGN_ORIGINAL; CHANGES says the
suite makes these comparisons.  Both jobs of tests.yaml now fetch
commit b2068ed (version 1.0, JEFworks-Lab/STalign main) and point
STALIGN_ORIGINAL at its STalign/STalign.py.  Locally, with that file,
the full suite passes (274 passed; the one skip needs the Allen data),
and the comparisons add about 4 s.

The test docstrings now say where the frozen original comes from
(STALIGN_ORIGINAL, e.g. git show b2068ed:STalign/STalign.py, or
dev/ref/stalign_orig.py), and the workflow's header comment says which
tests still skip (the development data and STALIGN_LEGACY_SRC).
The documentation build reported docutils errors in three docstrings:
the regularizer formulas of LDDMM and LDDMM_3D_to_slice contain
|L v_t| and |Lv|, which reStructuredText reads as substitution
references, and align_cells' Returns section nested a definition list
without blank lines ("Unexpected indentation").  The formulas are now
literals, and the two extra entries of align_cells' result form a
proper definition list.  Docstrings only.
The Sphinx build succeeded with 501 warnings and 5 docutils errors:

- The numpy-style docstrings were rendered without napoleon, so their
  Parameters sections were parsed as plain reStructuredText (errors in
  LDDMM, LDDMM_3D_to_slice, align_cells and analyze3Dalign, whose X_
  and Y_ read as link targets).  conf.py now loads sphinx.ext.napoleon.
- 442 warnings said that the notebooks' 'ipython3' lexer is unknown:
  ipython was not installed.  docs/requirements.txt now lists it.
- The README, included as the overview page, links to CHANGES.md and
  to its own headings, which myst could not resolve.  docs/CHANGES.md
  includes CHANGES.md as a page of the site (in the index toctree), and
  myst_heading_anchors = 3 gives the headings their GitHub anchors.

The documentation workflow installs docs/requirements.txt instead of
its own list, which also installed "collection", an unrelated PyPI
package (the standard library's collections was meant), and the CUDA
build of torch; it now installs the CPU build of torch first.  It also
uses actions/checkout@v7, like tests.yaml.

A build of the docs with these changes (Python 3.10, sphinx 8.1.3,
nbsphinx 0.9.8, myst-parser 4.0.1) succeeds with 32 warnings and no
errors.  As on main, the ones left come from tutorials.rst (section
titles inside its toctree, and 9 notebooks that it does not list), from
the README, whose headings start at level 2, and from the missing
_static folder.
Version 1.0's STalign.py imports plotly and tornado at the top, which
this version's requirements no longer include, so in a CI job the
seven tests that load it failed with ModuleNotFoundError (found by
running the job's steps in a fresh Python 3.14 venv on macOS).  The
step that fetches it now installs both.  With them, the job's full
test suite passes there: 272 passed, 3 skipped (the Allen data and
STALIGN_LEGACY_SRC).
The test matrix had Python 3.10, whose support ends this month, and
3.12, so no job tested a current release.  It now also runs 3.14 on
Linux and macOS.  Its steps were run on macOS in a fresh Python 3.14.7
venv (torch 2.14.1): pip install -v . compiles the C++ engine
(backend 'cpp'), build_ext --inplace works, and the full suite passes
with version 1.0's STalign.py (272 passed, 3 skipped).
- import STalign gives the functions of STalign.STalign.
- The test suite compares with version 1.0's STalign.py, which the CI
  workflow takes from git, and the workflow tests Python 3.10, 3.12
  and 3.14.
- The documentation workflow's checkout and CPU torch, napoleon, the
  CHANGES page and ipython in docs/requirements.txt.
The heart, Xenium-to-Xenium, Xenium-to-H&E and Xenium-to-STARmap
tutorials wrote bare numeric arrays with np.savetxt. They now write the
target's own columns plus aligned_x and aligned_y, like the MERFISH
tutorials, and were re-executed. Also clear the stale output that an
empty cell of the MERFISH-to-Xenium tutorial kept from version 1.0.
CHANGES.md says "the new defaults" instead of the development label
"Round 2", and describes the tutorials' new result tables. The test
file for the defaults is renamed test_defaults.py; the reference to
the last commit before the new defaults names its subject as well as
its hash.
Current torch and numpy need Python 3.10 or later, and CI tests 3.10,
3.12 and 3.14. The README's pip command installed upstream's version
1.0; it now installs this fork, which the README describes.
On GitHub's hosted macOS runners MPS is available, but the virtual GPU's
Metal compiler rejects one of torch's matmul kernels ("Failed to create
pipeline state object"), so the test failed on all three macOS jobs
with everything else passing. It now skips on that compiler error only;
any other error, or a wrong result, still fails it, and it still runs
on real Apple GPUs.
The fork's README pointed pip at slowkow/STalign; upstream installs from its own repository.
@JEFworks

Copy link
Copy Markdown
Contributor

I have confirmed that:

  • STalign v2 is readily installable
  • application generally recapitulates previous results while providing substantial speed improvements
  • application to new, previously unseen ST data produces reasonable results with new parameter defaults

Additional questions/requests:

  • please clarify why data files in docs/ have been modified a few kb since it is not clear to me why this was done
  • the new align_cells() function provides a higher abstraction by running all steps above in one call that may be useful for some users but should not be presented as 'recommended:' in notebooks as students should be encouraged to understand parameters; please change to 'alternative:'
  • likewise, please omit the statement associated with the align_cell() comparison "Without a ground truth we cannot tell which alignment is more accurate" as alignment accuracy can be evaluated using gene expression

The align_cells sections of the MERFISH-to-MERFISH, Xenium-to-Xenium,
Xenium-to-STARmap and heart tutorials were headed "Recommended". They
are now "Alternative", and say that align_cells chooses its settings
for you while the walkthrough shows what each one does, so students are
pointed back to the parameters. The comparisons no longer say that
accuracy cannot be judged without a ground truth: gene expression can
be used to evaluate an alignment. Markdown only; no outputs changed.
@slowkow

slowkow commented Oct 16, 2026

Copy link
Copy Markdown
Contributor Author

Thank you for testing this so thoroughly, and for confirming the installation, the agreement with previous results and the behavior on new data!

Data files in docs/. The modified files are the tutorials' output files, written by the notebooks' last cells. No input data were changed. I re-ran all notebooks with v2, so the output files were regenerated to match the notebooks.

In the files that changed by a few KB (the MERFISH ..._STalign_to_...csv.gz files and the Visium affine-only output), the rows and columns are unchanged. Only aligned_x and aligned_y differ, because they hold the v2 alignments. For MERFISH-to-MERFISH this includes the corrected transform_points_source_to_target, which now uses the exact inverse of the fitted map. The other columns agree to about 1e-13: a newer pandas prints the last digit of some floats differently. That, plus the new coordinates, accounts for the small size changes.

A few output files changed more:

  • The point-annotator tutorial's ..._filtered.csv.gz now contains only the filtered cells. Before, it held all cells.
  • The Xenium-to-Xenium, Xenium-to-H&E and STARmap-to-Xenium outputs are written as labeled tables (input columns plus aligned_x, aligned_y). Their save cells raised an error (to_csv was called on a NumPy array), so the files in the repository came from earlier versions of these notebooks.
  • The heart tutorial has a new output file.

If you would prefer to keep the files in docs/ unchanged, I'm happy to restore the v1 output files.

align_cells in the notebooks. Done. The sections are now titled "Alternative: align_cells", and the pointer at the top of each notebook calls it an alternative. Each section also says that align_cells chooses these settings itself, while the step-by-step walkthrough shows what each one does, and that align_cells accepts them as arguments.

Comparison statement. Done: I removed "Without a ground truth we cannot tell which is more accurate here." from both notebooks. The tutorial datasets in the repository contain cell positions and metadata but no gene expression matrices, so I didn't add an expression-based comparison. I'd be glad to add one if you'd like.

These changes are markdown only, so no outputs changed. Thanks again!

@JEFworks
JEFworks merged commit 9e9a704 into JEFworks-Lab:main Oct 16, 2026
8 checks passed
@JEFworks

Copy link
Copy Markdown
Contributor

Thanks for the clarification and contribution. Merging pull request.

TODO: check if gh-pages auto-recompiles and deploys appropriately post merge

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants