Run the topology fix single-threaded for reproducibility, and stop reporting success when a step failed - #865
Conversation
There was a problem hiding this comment.
🟡 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 prints0 files comparedbefore exiting successfully, which can falsely certify invalid inputs as identical. Validate that the selected subject has anmridirectory 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.
There was a problem hiding this comment.
🟡 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 != fbcannot broadcast and raisesValueError. 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 > 2see 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
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.
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 atool to compare runs by content.
mris_fix_topologyrepairs defects in an order-dependentway. At
--threads 4the first differing file islh.orig.premesh, which it writes (133836 vs133966 vertices,
Left-Hippocampusoff by 0.5%), while every input to the stage, all transformsand the whole
rhside are bit-identical. Costs ~266 s of a 48.6 min pipeline on linux, nothingon macOS.
--threadsis a total budget for surfaces: from 2up, the hemispheres run at the same time and split it. Surface default moves 1 → 2.
--parallelno longer changes the thread count, forces parallel hemispheres at
--threads 1, and is no longerobsolete.
recon-surfreg.shpreviously ran hemispheres sequentially at--threads 2.run_fastsurfer.sh.[[ ]]overwritesPIPESTATUS, so the licensecheck and a failed segstats subshell both exited 0 on failure. Same defect as
c747812. Plus--long "$basedir", a variable this script never sets." -hemi ", a-threadsthat undid the pinning above it,NonereachingPopen, and ITK not pinned alongsideOMP and BLAS.
recon-surfreg.shquoting and arrays. 67 expansions quoted,CMDFSbecomes a real array, astale bash 4 guard removed. Reproduces the full run bit-identically; 47 → 34 min on macOS.
VERSION='$Id$'was never expanded by git, so everyrecon-surf.logand.donefile carried that literal text. Both scripts now read the project version, falling back tounknown.recon_surf/make_uprightdeleted as dead code.long_prepare_template.shcalls it unqualifiedand nothing puts
recon_surfonPATH, so FreeSurfer's copy has been running all along(
command -vconfirms/opt/freesurfer/bin/make_upright). Behaviour-neutral, and neuroreg retiresthe step. Note the deleted copy had silently diverged: it dropped
--halfmovfor amri_vol2vol --no-resamplestep, avoiding an interpolation pass, and capped iterations with--highit 1 --maxit 3. Those unused improvements are worth carrying into the neuroregreplacement rather than losing.
tools/compare_subjects.py. Compares voxels, vertices, transforms, stats and labels ratherthan 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,
--threadsis a total budget soN-per-hemisphere callers should pass2N,--parallelis undeprecated, and
--threads > 2now yields slightly different surfaces.Verified: two independent runs at
--threads 4with the pin in place are identical, which isthe direct demonstration that the pin works. Two runs at the new default of
--threads 2areidentical in all 169 files compared, while 76 of those differ byte for byte.
bash -nclean under3.2 and 5.3, ruff clean, 84 shell tests pass.