Skip to content

Run the topology fix single-threaded for reproducibility, and stop reporting success when a step failed - #865

Merged
m-reuter merged 15 commits into
Deep-MI:devfrom
m-reuter:surf
Sep 5, 2026
Merged

Run the topology fix single-threaded for reproducibility, and stop reporting success when a step failed#865
m-reuter merged 15 commits into
Deep-MI:devfrom
m-reuter:surf

Conversation

@m-reuter

@m-reuter m-reuter commented Sep 3, 2026

Copy link
Copy Markdown
Member

Three independent fixes, found while investigating two near-identical runs of one subject that
produced different surfaces. Also unifies how the two surface scripts read --threads, and adds a
tool to compare runs by content.

  • Topology fix pinned to one thread. mris_fix_topology repairs defects in an order-dependent
    way. At --threads 4 the first differing file is lh.orig.premesh, which it writes (133836 vs
    133966 vertices, Left-Hippocampus off by 0.5%), while every input to the stage, all transforms
    and the whole rh side are bit-identical. Costs ~266 s of a 48.6 min pipeline on linux, nothing
    on macOS.
  • One thread rule for both surface scripts. --threads is a total budget for surfaces: from 2
    up, the hemispheres run at the same time and split it. Surface default moves 1 → 2. --parallel
    no longer changes the thread count, forces parallel hemispheres at --threads 1, and is no longer
    obsolete. recon-surfreg.sh previously ran hemispheres sequentially at --threads 2.
  • Two exit-path bugs in run_fastsurfer.sh. [[ ]] overwrites PIPESTATUS, so the license
    check and a failed segstats subshell both exited 0 on failure. Same defect as c747812. Plus
    --long "$basedir", a variable this script never sets.
  • Spherical projection fallback tidied. No observed failure: a space-padded " -hemi ", a
    -threads that undid the pinning above it, None reaching Popen, and ITK not pinned alongside
    OMP and BLAS.
  • recon-surfreg.sh quoting and arrays. 67 expansions quoted, CMDFS becomes a real array, a
    stale bash 4 guard removed. Reproduces the full run bit-identically; 47 → 34 min on macOS.
  • Version in the logs. VERSION='$Id$' was never expanded by git, so every recon-surf.log and
    .done file carried that literal text. Both scripts now read the project version, falling back to
    unknown.
  • recon_surf/make_upright deleted as dead code. long_prepare_template.sh calls it unqualified
    and nothing puts recon_surf on PATH, so FreeSurfer's copy has been running all along
    (command -v confirms /opt/freesurfer/bin/make_upright). Behaviour-neutral, and neuroreg retires
    the step. Note the deleted copy had silently diverged: it dropped --halfmov for a
    mri_vol2vol --no-resample step, avoiding an interpolation pass, and capped iterations with
    --highit 1 --maxit 3. Those unused improvements are worth carrying into the neuroreg
    replacement rather than losing.
  • tools/compare_subjects.py. Compares voxels, vertices, transforms, stats and labels rather
    than bytes, since headers record timestamps and the command line.

Release note needed (no changelog file in the repo): the surface default changes from 1 to 2
threads, --threads is a total budget so N-per-hemisphere callers should pass 2N, --parallel
is undeprecated, and --threads > 2 now yields slightly different surfaces.

Verified: two independent runs at --threads 4 with the pin in place are identical, which is
the direct demonstration that the pin works. Two runs at the new default of --threads 2 are
identical in all 169 files compared, while 76 of those differ byte for byte. bash -n clean under
3.2 and 5.3, ruff clean, 84 shell tests pass.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The unresolved ITK threading and geometry-comparison issues must be fixed before approval.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Pins topology repair for reproducibility, improves failure propagation, and adds semantic output comparison.

Changes:

  • Pins topology correction and projection threading.
  • Fixes shell exit-status and longitudinal path handling.
  • Adds reproducibility documentation and comparison tooling.
File summaries
File Review
tools/compare_subjects.py Adds semantic comparison. Moderate (2 votes): one-sided missing geometry metadata can be incorrectly ignored.
run_fastsurfer.sh Updates defaults and error handling. Nit (2 votes): update the outdated example and add the stated release note.
recon_surf/spherically_project_wrapper.py Pins fallback threading. Moderate (1 vote): also override inherited ITK thread settings.
recon_surf/recon-surfreg.sh Aligns threading and quoting behavior.
recon_surf/recon-surf.sh Pins topology correction to one thread.
doc/scripts/RUN_FASTSURFER.md Documents threading and reproducibility expectations.
Review details

Suppressed comments (3)

tools/compare_subjects.py:230

  • When a stats row has a different number of numeric columns, zip(..., strict=False) silently drops the unmatched values. The comparator can therefore report identical stats—and exit 0—even though one file contains an added or missing measurement. Compare the list lengths before comparing their shared values.
        for x, y in zip(va[key], vb[key], strict=False):

tools/compare_subjects.py:360

  • This second pass ignores each group's patterns and router, so an unsupported file or subdirectory that exists only in B is reported as a difference, while the same unsupported entry only in A is skipped above. Filter B-side candidates through the same patterns and route(name) logic to keep the comparison symmetric.
    for group, _, _ in GROUPS:
        if not (b_dir / group).is_dir():
            continue
        extra = {p.name for p in (b_dir / group).glob("*")} - {p.name for p in (a_dir / group).glob("*")}
        only_one_side.extend(f"{group}/{name} (only in B)" for name in sorted(extra))

tools/compare_subjects.py:293

  • With --subject, this returns an unchecked path. If the requested subject is absent from both roots, all group loops are skipped and the tool prints 0 files compared before exiting successfully, which can falsely certify invalid inputs as identical. Validate that the selected subject has an mri directory before returning it.
    if subject:
        return root / subject
  • Files reviewed: 6/6 changed files
  • Comments generated: 3
  • Review effort level: Balanced

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread recon_surf/spherically_project_wrapper.py
Comment thread tools/compare_subjects.py Outdated
Comment thread run_fastsurfer.sh

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Three moderate issues remain in default threading behavior and subject-comparison correctness.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details

Suppressed comments (2)

Previously missed (1) — in code that hasn't changed since the last review.

tools/compare_subjects.py:114

  • If two surfaces have the same vertex count but different face counts, fa != fb cannot broadcast and raises ValueError. The outer handler then reports only “could not compare” instead of the face-count difference (and loses any coordinate/geometry details). Check the face-array shape before elementwise comparison.

run_fastsurfer.sh:92

  • This also changes the no-flag surface default from 1 to 2, so the PR description's claim that only callers using --threads > 2 see a behavior change is incomplete. Default runs now execute hemispheres concurrently and give the pre/post-hemisphere stages two threads (as the new reproducibility docs acknowledge), changing CPU usage and potentially outputs. Please either retain the previous default or explicitly include this default-path change in the release notes/validation scope.
# 2, so the surface pipeline runs the two hemispheres at the same time with one thread each by
# default. recon-surf.sh is always called with --threads "$threads_surf", so its own default of 2
# would never be reached otherwise.
threads_surf="2"
  • Files reviewed: 7/7 changed files
  • Comments generated: 1
  • Review effort level: Balanced

Comment thread tools/compare_subjects.py Outdated
Three things in a path that is rarely taken but does get taken. It passed " -hemi " as
one argument, which works only because recon-all is tcsh and switch word-splits the
unquoted variable, so any other callee would reject it. It appended -threads, which calls
omp_set_num_threads in the child and so undoes the thread pinning three lines above,
though the output was identical in the one case we compared. And it fed None to Popen when
recon-all was not on PATH, replacing the real projection error with a TypeError. No
observed failure is fixed here; the fallback works today.
mris_fix_topology repairs defects in an order-dependent way, so with more than one
thread the repair and every surface derived from it can differ between identical runs.
Observed on one subject at --threads 4: lh.orig.premesh with 133836 vs 133966 vertices,
which moved Left-Hippocampus by 0.5%. Pinning the whole -fix stage costs a few minutes on
linux and nothing on macOS, whose FreeSurfer binaries carry no OpenMP in either 7.4.1 or
8.0.0. This removes the source we have evidence for, not every possible one, and the new
docs section is worded accordingly.
Two sites tested PIPESTATUS and then read it again to exit with, but [[ ]] is a command
and overwrites it, so the branch exited with the status of the test. A missing FreeSurfer
license and a failing segstats subshell were both reported as success. Same bug as
c747812, fixed the same way. Also passes $sd/$baseid to talairach-reg.sh instead of
$basedir, which this script never sets, so --long got an empty argument; only reachable
with --tal_reg and --long together, which is why runs did not hit it.
The section told users to keep --threads fixed, but the case we measured was two runs at
the same --threads 4, so a fixed count is not sufficient and only --threads 1 is. Also
drops a claim about --threads speeding up segmentation on macOS, which was never tested
and is wrong for MPS.
--threads is a total budget, so the new default of 2 runs the two hemispheres at the same time with
one thread each. --parallel no longer changes the thread count: it forces the hemispheres to run at
the same time at --threads 1, which is the setting to use for reproducible results, and has no
effect above that. It used to multiply the budget instead, so --threads 4 --parallel asked for four
threads per hemisphere, and in run_fastsurfer.sh it silently raised --threads 1 to 2, which
multi-threaded the sections outside the hemisphere loop.
All 67 unquoted expansions are quoted, the CMDFS list becomes a proper array, and the thread block
is now identical to recon-surf.sh. A bash 4 guard that never applied is gone. Verified against a
baseline subject: 146 files compared, 0 differ.
Compares voxels, surface vertices, morphometry, transforms, labels and stats values instead of raw
bytes, because headers record timestamps, the command line and the FreeSurfer version, so two
identical runs never match byte for byte. Volume, surface and transform geometry is reported apart
from the data it belongs to. Transforms are read through neuroreg, so the distance is measured in
mm on the RAS-to-RAS form and the storage type of an LTA does not matter.
Volume geometry recorded on one side only, a stats entry whose number of values changed, and a
file present on one side that no comparison covers were all reported as identical, the last one
only when it was the A side. A --subject that does not exist, and a pair with nothing comparable in
it, printed 0 files compared and exited 0, which reads as a clean bill of health. Verified on
crafted cases: each of them is now reported, and a bad path or an empty pair exits non-zero.
… gives

The section said two runs at the same setting had produced different surfaces, without saying
which. It was --threads 4, and the two runs it refers to came from different checkouts, so I first
confirmed that both issue byte-identical command lists once the paths are normalised. The same two
checkouts at --threads 2 produce identical output in every file compared, which the new paragraph
states, scoped to the one pair tested.
… keyword

VERSION was '$Id$', which git never expanded, so every recon-surf.log and .done file carried that
literal text. It is now read from the project, with a fallback of 'unknown'. Also deletes
recon_surf/make_upright: long_prepare_template.sh calls it unqualified and nothing puts recon_surf on
PATH, so FreeSurfer's copy has been running all along, and neuroreg will retire the step.
@m-reuter
m-reuter merged commit 08c01c6 into Deep-MI:dev Sep 5, 2026
5 checks passed
@m-reuter
m-reuter deleted the surf branch September 5, 2026 13:22
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