Skip to content

DICOM extract: multi-echo plane detection, modality fallback, and 9 smaller fixes - #153

Open
robert-graf wants to merge 49 commits into
mainfrom
dicom-extract-cleanup
Open

robert-graf wants to merge 49 commits into
mainfrom
dicom-extract-cleanup

Conversation

@robert-graf

Copy link
Copy Markdown
Collaborator

Re-opens the work from #152 after renaming the head branch (Point-Reg → dicom-extract-cleanup). Content identical; description below is the same as before.

Summary

Cleanup pass on TPTBox/core/dicom/. Splits into 10 focused commits (plus the pre-existing 07aac40 fix multi echo bug). Every commit is reviewable on its own.

Correctness fixes

  • 88ab7e8 — _extract_nii_from_dicom returns False on unknown exceptions, deletes the half-written sidecar, and logs. Previously the bare except Exception fell through to return True, so _add_grid_info_to_json / _split_multi_echo_dixon then ran on a NIfTI that was never written.
  • 391a28e — _classic_get_grouped_dicoms no longer produces NaN directions on multi-echo series. New helper _split_by_echo_numbers runs stack detection on a single-echo subset, then re-attaches remaining echoes by ImagePositionPatient.
  • f0c70f4 — _split_multi_echo_dixon triggers on any 4-D output (not just part in ("magnitude", "phase")). Also switched frame/TE iteration to zip_strict.
  • 0894e46 — _find_all_files yields Path instances one at a time instead of yielding the raw list[Path] first, which downstream code cast to str and broke on.
  • 7f9e38b — _inc_key caps its retry loop at 10 000 iterations and raises RuntimeError with the offending key instead of spinning forever on a broken path_exists callback.
  • a09de6a — get_plane_dicom's real call now uses 0.8 (matches the docstring default); the bare-except now logs a warning so downstream acq-None files are traceable.

Perf / usability

  • 4c63431 — _read_dicom_files gates on a _looks_like_dicom check (extension blocklist + DICM magic at offset 128) before pydicom.dcmread. Skips reports, thumbnails, JSON sidecars, etc. on mixed source trees.
  • 7a7ade2 — Docs: n_cpu behaviour spelled out (1 sequential / >1 N threads / None → ThreadPoolExecutor-Default ≈ min(32, cpu_count + 4)).

Features

  • e1cddbc — Expanded map_series_description_to_file_format_default:
    • Quantitative maps (MP2RAGE, T2*, R2*, T1map, T2map, T2starmap, SWI) placed before the greedy .*t2.*/.*t1.* catch-alls that were misclassifying them.
    • Multi-echo VIBE variants (ME_vibe_fatquant_*, mevibe, fatquant, fatfrac, pdff, GE ideal).
    • Philips localizers (survey, refscan, smartexam).
  • 5280eb8 — Modality fallback + BIDS format whitelist + view/laterality/bodypart:
    • bids_constants.formats grows by xray, us, nm, sc, photo, endoscopy, rtimage, rtstruct, rtdose, rtplan, ot.
    • extract_keys_from_json no longer raises NotImplementedError for CR/DX/RG/PX/IO/MG/US/NM/SC/OP/XC/ES/RT*/OT — sensible defaults per DICOM modality group, with a warning + tag-based fallback for unknowns.
    • Two helpers _apply_view_keys and _apply_bodypart_key lift ViewPosition, Laterality/ImageLaterality, and BodyPartExamined into acq / part / desc, so MG four-view screenings, bilateral radiographs, ophthalmic photos, and multi-region NM/US sessions no longer collapse onto identical filenames.

Test plan

  • pytest unit_tests/ — 595 passed, 5 skipped (unchanged from base).
  • End-to-end run through the GLP1/RESIST DICOM tree, confirming the 6-echo Philips series is now labelled acq-ax (not acq-iso) and the classic stack detector no longer over-splits it.

🤖 Generated with Claude Code

robert-graf and others added 28 commits August 26, 2026 14:42
Roll the internal-scripts changes together with the new
precompute_grid_info_parallel helper in _load_nako_wh, which fans
per-file _add_grid_info_to_json calls out to a ProcessPoolExecutor so
the sidecar grid cache is populated up front.
The bare `except Exception: print(nii_path)` at the end of _extract_nii_from_dicom
still fell through to `return True`. Any unknown conversion error (network, out
of disk, dicom2nifti internal bug) was silently treated as success — downstream
`_add_grid_info_to_json` and `_split_multi_echo_dixon` then ran on a NIfTI that
was never actually written.

Now the branch logs a warning, deletes the half-written sidecar JSON, and
returns False so the caller stops the pipeline for that series.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Multi-echo series (e.g. Philips mDIX quant) put M echoes at each of N slice
positions inside a single ImageType sub-group. Sorted by InstanceNumber,
consecutive DICOMs then share ImagePositionPatient, so the direction test
computed `0 / 0 = NaN` and `np.allclose(NaN, prev)` false-triggered a stack
split — one physical acquisition would be shattered into arbitrary chunks.

New helper `_split_by_echo_numbers` picks the smallest `EchoNumbers` bucket
for stack detection so each position appears exactly once, then re-attaches
the remaining echoes to the detected stacks by `ImagePositionPatient`. Also
skips the direction update whenever `norm(delta) < 1e-6`, guarding against
residual duplicates and genuinely repeated slices.

No-op for single-echo series (0 or 1 distinct EchoNumbers value).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
`_from_dicom_to_nii` only tried to split multi-echo output when the BIDS
`part` entity resolved to "magnitude" or "phase". Any 4-D volume whose part
was mapped to something else via `dixon_mapping` / `parts_mapping` (or none
at all) escaped the split and sat on disk as an opaque 4-D file. Since
`_split_multi_echo_dixon` is already a no-op on 3-D input, drop the gate
and always try to split.

Also switch the frame/TE iteration in `_split_multi_echo_dixon` to
`zip_strict`. `tes` is padded to `n_echo` upstream, so `zip` is currently
safe — but `zip_strict` makes the invariant explicit and traps future
regressions instead of silently truncating.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The first `yield dcm_dirs` handed back the raw argument, which is a
`list[Path]` when the caller passed a list. Downstream code (in
`extract_dicom_folder`) then did `str(dicom_path).endswith(".pkl")` on that
list and eventually `_read_dicom_files(list)` — the list-repr string
match ate the guard and `.rglob` blew up with `AttributeError`.

Iterate the normalised `dcm_dirs` list instead, so we always yield `Path`
instances one at a time. Behaviour for the common single-Path case is
unchanged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Previously `_read_dicom_files` handed every file under a source folder to
`pydicom.dcmread(force=True)` — README.txt, .DS_Store, thumbnail PNGs, JSON
sidecars from earlier extractions, and so on. Each dcmread cost real time
on top-level scanner exports that mix DICOMs with reports and metadata.

New helper `_looks_like_dicom(path)`:
  1. Skips files with obviously non-DICOM extensions (json/txt/md/pdf, image
     formats, csv/tsv/yaml/html/xml, already-extracted NIfTI / nrrd).
  2. Reads the first 132 bytes and accepts the standard "DICM" magic at
     offset 128.
  3. Falls back to accepting extensionless files (and .dcm / .ima / .dicom),
     matching the previous permissive behaviour for Philips/Siemens exports
     that ship as `IM000001` and skip the preamble.

Archive extensions (`.zip` / `.tar` / …) are intentionally NOT in the block
list because top-level zips are unpacked by `extract_dicom_folder` before
`_read_dicom_files` sees them, and any residual archive would fail the
DICM-magic check anyway.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
`_inc_key` incremented its key while `path_exists(keys)` remained True with
no upper bound. A mis-configured `path_exists` callback (e.g. one that
never sees the mutation because it captures stale state) would spin
forever. Add a hard cap and raise `RuntimeError` with the offending key
value instead, so the mistake surfaces immediately.

10 000 tries covers `-a` through `-mnw` letter suffixes — well beyond any
realistic filename-collision run.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The previous one-liner claimed "Defaults to 1 (sequential)" but did not
mention that `n_cpu=None` — a common thing to try when reaching for "use
all cores" — hits the parallel path with `max_workers=None`, meaning
`min(32, os.cpu_count() + 4)` Python threads. Spell all three cases out
(1 / >1 / None) and note that this workload is I/O-heavy.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two related plane-detection tweaks:

1. The real caller inside `extract_keys_from_json` was passing
   `hires_threshold=1` while the docstring / function default advertised
   0.8. Push the actual call to 0.8 so the behaviour matches the docs
   (and so submillimetre isotropic MRI still resolves cleanly after the
   multi-echo pre-filter took the "everything clamps to 1.0" collapse
   out of the equation).

2. The bare `except Exception: return None` at the end of
   `get_plane_dicom` swallowed every failure silently. A follow-up
   `acq-None` filename would then be unattributable. Log a warning with
   the exception type + message before returning None, guarding the log
   import itself so the fallback still returns None cleanly if the
   logger isn't importable.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Several common series descriptions were misclassified because the greedy
``.*t2.*`` / ``.*t1.*`` catch-alls matched first (first-match wins). Add
specific patterns BEFORE them:

* Quantitative maps: MP2RAGE, T2*, R2*, T1map, T2map, T2starmap.
* SWI (susceptibility weighted).
* Multi-echo VIBE variants for both NAKO Siemens (`ME_vibe_fatquant_*`)
  and the generic mevibe / fatquant labels.
* DIXON auxiliary maps: fatfrac, PDFF, GE Ideal.
* Philips localizer aliases the existing pilot/scout entries missed:
  survey, refscan, smartexam.

Also add an explicit ``.*mdixon.*`` alongside the existing ``.*mdix.*``
so both spellings of the Philips protocol name resolve to "dixon".

No behaviour change for series that were already correctly classified.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…ypart

Three coupled additions so `extract_keys_from_json` stops raising on
non-CT/PET/MR/XA modalities.

* `bids_constants.formats`: add `xray`, `us`, `nm`, `sc`, `photo`,
  `endoscopy`, `rtimage`, `rtstruct`, `rtdose`, `rtplan`, `ot`. Without
  these entries, `BIDS_FILE.get_changed_bids` refuses the new formats
  under strict mode.

* New modality branches in `extract_keys_from_json`. Instead of
  `raise NotImplementedError`, dispatch:
    - CR/DX/RG/PX/IO/MG → `xray`
    - US → `us`
    - NM → `nm` (Radiopharmaceutical → `ce` when set)
    - SC → `sc`
    - OP/XC → `photo`
    - ES → `endoscopy`
    - RTIMAGE/RTSTRUCT/RTDOSE/RTPLAN → matching lowercase format
    - OT → `ot`
    - unknown → warn and fall back to `modality.lower()`
  so the pipeline can complete instead of aborting mid-tree.

* Two helpers extract the DICOM tags that actually differentiate these
  files in a session:
    - `_apply_view_keys` lifts `ViewPosition` into `acq` (only overwriting
      a None/iso plane) and `ImageLaterality`/`Laterality` into `part`,
      with ophthalmic OS/OD normalised to L/R.
    - `_apply_bodypart_key` lifts `BodyPartExamined` into `desc` when
      empty, matching common values (ABDOMEN, PELVIS, CHEST, HEAD…).
  Called from CT, PET, and every new modality branch so files that only
  differ by view (MG four-view screening), laterality (ophthalmic),
  tracer (NM), or body region no longer collapse onto identical BIDS
  filenames.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Non-imaging DICOMs (RTSTRUCT / RTDOSE / SR / presentation states, and any
other object without a spatial header) legally lack `ImagePositionPatient`
/ `ImageOrientationPatient`. `dicom2nifti.common.create_affine` then raises
`AttributeError`, and the previous warning branch surfaced that as noise
on every conversion of a mixed source tree.

Split the exception handler: `AttributeError` returns None silently, every
other exception still warns so real plane-detection bugs stay visible.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 15, 2026 •

Copy link
Copy Markdown

Benchmark comparison

Speed (wall time per call)

baseline 32c52dd vs head 32c52dd · python 3.11.16 · 5 repeats + 1 warmup

Showing the 5 most-changed measurements per case; the rest are collapsed. Rows with a baseline below 3 ms are tagged (noise) — runner jitter on those swamps any real change.

ct_3d — shape (73, 47, 73)

Measurement baseline ms (median ±½·range) head ms (median ±½·range) Δ % p
nii_rescale_seg 3.05 ±0.02 3.15 ±0.11 +3.4% 0.075
nii_filter_connected_components 4.33 ±0.07 4.20 ±0.08 -3.1% 0.045
nii_erode_msk 6.06 ±0.05 6.20 ±0.16 +2.3% 0.134
nii_fill_holes 4.43 ±0.03 4.53 ±0.05 +2.2% 0.009
nii_load_img 10.58 ±0.13 10.81 ±0.18 +2.1% 0.085
… 31 more measurements
Measurement baseline ms (median ±½·range) head ms (median ±½·range) Δ % p
poi_calc_poi_from_subreg_vert 12.64 ±0.25 12.48 ±0.12 -1.3% 0.586
nii_dilate_msk 124.22 ±0.27 125.79 ±1.07 +1.3% 0.050
nii_save 11.44 ±0.08 11.50 ±0.13 +0.5% 0.227
poi_calc_centroids_nocrop 4.16 ±0.04 4.15 ±0.07 -0.4% 0.558
nii_rescale 39.73 ±0.10 39.85 ±0.23 +0.3% 0.717
nii_resample_from_to 41.64 ±0.29 41.70 ±0.35 +0.1% 0.871
metric_voxels 250463.00 ±0.00 250463.00 ±0.00 +0.0% —
metric_labels 3.00 ±0.00 3.00 ±0.00 +0.0% —
metric_foreground_pct 14.51 ±0.00 14.51 ±0.00 +0.0% —
nii_extract_label 0.34 ±0.05 0.42 ±0.02 +26.2% (noise) 0.069
nii_get_array 0.04 ±0.01 0.03 ±0.00 -18.4% (noise) 0.115
nii_set_dtype 0.47 ±0.04 0.40 ±0.04 -15.7% (noise) 0.155
poi_map_labels 0.15 ±0.01 0.17 ±0.02 +14.3% (noise) 0.032
nii_apply_crop 0.56 ±0.01 0.62 ±0.04 +11.9% (noise) 0.019
poi_local_to_global_arr 0.30 ±0.02 0.27 ±0.03 -11.7% (noise) 0.351
poi_reorient 0.38 ±0.03 0.34 ±0.02 -11.0% (noise) 0.085
nii_load_seg 2.62 ±0.04 2.90 ±0.10 +10.7% (noise) 0.001
poi_to_global 0.17 ±0.02 0.19 ±0.01 +8.8% (noise) 0.270
nii_compute_crop 0.29 ±0.03 0.31 ±0.01 +6.7% (noise) 0.337
poi_rescale 0.19 ±0.01 0.17 ±0.01 -5.9% (noise) 0.249
poi_load 0.92 ±0.04 0.88 ±0.03 -3.5% (noise) 0.493
nii_volumes 1.88 ±0.04 1.82 ±0.03 -3.1% (noise) 0.078
nii_pad_to 0.63 ±0.07 0.61 ±0.07 -2.6% (noise) 0.520
nii_map_labels 0.90 ±0.04 0.92 ±0.02 +2.5% (noise) 0.803
nii_get_connected_components 2.17 ±0.04 2.22 ±0.06 +2.3% (noise) 0.240
poi_calc_centroids 2.39 ±0.01 2.34 ±0.05 -2.0% (noise) 0.031
poi_save 0.37 ±0.02 0.37 ±0.04 +1.2% (noise) 0.193
nii_center_of_masses 1.68 ±0.03 1.69 ±0.03 +0.8% (noise) 0.618
nii_reorient 0.79 ±0.07 0.80 ±0.03 +0.7% (noise) 0.499
poi_resample_from_to 0.58 ±0.03 0.57 ±0.03 -0.4% (noise) 0.616
nii_unique 0.64 ±0.04 0.64 ±0.02 +0.2% (noise) 0.446

ct_2d — shape (73, 47, 1)

Measurement baseline ms (median ±½·range) head ms (median ±½·range) Δ % p
nii_resample_from_to 7.54 ±0.06 7.37 ±0.06 -2.3% 0.000
nii_rescale 5.50 ±0.17 5.41 ±0.12 -1.6% 0.186
nii_dilate_msk 14.64 ±0.07 14.53 ±0.05 -0.7% 0.013
metric_voxels 3431.00 ±0.00 3431.00 ±0.00 +0.0% —
metric_labels 3.00 ±0.00 3.00 ±0.00 +0.0% —
… 30 more measurements
Measurement baseline ms (median ±½·range) head ms (median ±½·range) Δ % p
metric_foreground_pct 37.60 ±0.00 37.60 ±0.00 +0.0% —
nii_pad_to 0.59 ±0.02 0.53 ±0.01 -9.9% (noise) 0.001
nii_get_array 0.02 ±0.00 0.02 ±0.00 -9.4% (noise) 0.233
poi_map_labels 0.20 ±0.01 0.19 ±0.01 -6.6% (noise) 0.165
poi_to_global 0.25 ±0.01 0.23 ±0.01 -6.1% (noise) 0.177
nii_unique 0.12 ±0.01 0.12 ±0.01 -4.4% (noise) 0.417
nii_set_dtype 0.41 ±0.04 0.40 ±0.02 -4.3% (noise) 0.483
poi_save 0.49 ±0.01 0.47 ±0.02 -4.0% (noise) 0.489
nii_volumes 0.27 ±0.01 0.28 ±0.01 +3.5% (noise) 0.238
poi_rescale 0.26 ±0.01 0.25 ±0.01 -3.4% (noise) 0.192
nii_apply_crop 0.67 ±0.02 0.69 ±0.04 +2.9% (noise) 0.153
nii_rescale_seg 0.77 ±0.02 0.79 ±0.02 +2.8% (noise) 0.291
poi_local_to_global_arr 0.34 ±0.02 0.33 ±0.01 -2.5% (noise) 0.275
poi_reorient 0.44 ±0.01 0.45 ±0.03 +2.3% (noise) 0.843
poi_resample_from_to 0.73 ±0.01 0.71 ±0.03 -2.3% (noise) 0.628
nii_compute_crop 0.14 ±0.01 0.14 ±0.00 -2.2% (noise) 0.580
nii_erode_msk 0.98 ±0.01 1.00 ±0.06 +2.0% (noise) 0.196
nii_map_labels 0.48 ±0.02 0.49 ±0.03 +2.0% (noise) 0.168
nii_load_seg 1.66 ±0.03 1.70 ±0.08 +1.9% (noise) 0.236
nii_center_of_masses 0.21 ±0.01 0.21 ±0.01 +1.7% (noise) 0.273
poi_load 1.10 ±0.05 1.09 ±0.03 -1.6% (noise) 0.346
poi_calc_centroids 0.91 ±0.04 0.92 ±0.04 +1.4% (noise) 0.540
poi_calc_centroids_nocrop 1.13 ±0.03 1.14 ±0.03 +0.9% (noise) 0.984
nii_load_img 1.49 ±0.05 1.50 ±0.03 +0.8% (noise) 0.616
nii_filter_connected_components 0.82 ±0.02 0.83 ±0.02 +0.8% (noise) 0.886
nii_extract_label 0.44 ±0.01 0.44 ±0.03 +0.8% (noise) 0.856
nii_fill_holes 0.83 ±0.03 0.82 ±0.02 -0.7% (noise) 0.612
nii_save 1.35 ±0.08 1.34 ±0.03 -0.2% (noise) 0.248
nii_reorient 0.83 ±0.03 0.83 ±0.04 -0.2% (noise) 0.390
nii_get_connected_components 0.63 ±0.03 0.63 ±0.02 -0.1% (noise) 0.398

mri_3d — shape (68, 52, 67)

Measurement baseline ms (median ±½·range) head ms (median ±½·range) Δ % p
poi_calc_centroids_nocrop 8.85 ±0.09 8.46 ±0.63 -4.4% 0.943
nii_save 9.69 ±0.24 9.27 ±0.06 -4.3% 0.023
nii_fill_holes 5.46 ±0.06 5.33 ±0.07 -2.4% 0.007
nii_rescale 38.05 ±0.43 37.36 ±0.24 -1.8% 0.029
nii_dilate_msk 160.99 ±0.26 162.58 ±4.86 +1.0% 0.249
… 31 more measurements
Measurement baseline ms (median ±½·range) head ms (median ±½·range) Δ % p
nii_resample_from_to 39.80 ±0.66 39.44 ±0.40 -0.9% 0.290
poi_calc_poi_from_subreg_vert 11.95 ±0.11 11.85 ±0.12 -0.8% 0.267
nii_filter_connected_components 4.76 ±0.06 4.73 ±0.09 -0.7% 0.454
nii_load_img 8.79 ±0.30 8.73 ±0.07 -0.6% 0.189
nii_erode_msk 7.83 ±0.14 7.80 ±0.13 -0.4% 0.396
nii_rescale_seg 3.24 ±0.08 3.24 ±0.62 +0.1% 0.341
metric_voxels 236912.00 ±0.00 236912.00 ±0.00 +0.0% —
metric_labels 8.00 ±0.00 8.00 ±0.00 +0.0% —
metric_foreground_pct 18.21 ±0.00 18.21 ±0.00 +0.0% —
nii_get_array 0.06 ±0.01 0.05 ±0.00 -16.8% (noise) 0.024
poi_load 2.44 ±0.10 2.18 ±0.09 -10.4% (noise) 0.017
poi_local_to_global_arr 0.38 ±0.01 0.34 ±0.01 -10.3% (noise) 0.000
nii_reorient 0.93 ±0.02 0.85 ±0.03 -8.7% (noise) 0.000
nii_pad_to 0.80 ±0.04 0.75 ±0.03 -6.3% (noise) 0.153
poi_map_labels 0.26 ±0.02 0.25 ±0.01 -6.1% (noise) 0.224
poi_save 0.57 ±0.02 0.54 ±0.03 -5.9% (noise) 0.008
nii_set_dtype 0.56 ±0.01 0.54 ±0.02 -5.2% (noise) 0.010
poi_to_global 0.28 ±0.01 0.27 ±0.01 -5.1% (noise) 0.011
poi_rescale 0.31 ±0.02 0.29 ±0.01 -5.1% (noise) 0.057
nii_unique 0.68 ±0.01 0.65 ±0.00 -4.6% (noise) 0.003
poi_resample_from_to 0.81 ±0.03 0.78 ±0.02 -3.9% (noise) 0.042
nii_map_labels 1.08 ±0.03 1.04 ±0.02 -3.2% (noise) 0.084
poi_reorient 0.49 ±0.03 0.48 ±0.02 -2.5% (noise) 0.162
nii_extract_label 0.45 ±0.02 0.46 ±0.02 +1.8% (noise) 0.498
nii_load_seg 2.82 ±0.12 2.87 ±0.05 +1.6% (noise) 0.934
nii_get_connected_components 2.24 ±0.08 2.21 ±0.03 -1.4% (noise) 0.106
poi_calc_centroids 2.88 ±0.07 2.84 ±0.10 -1.3% (noise) 0.565
nii_center_of_masses 1.99 ±0.02 1.97 ±0.06 -0.9% (noise) 0.577
nii_compute_crop 0.34 ±0.01 0.34 ±0.02 +0.8% (noise) 0.791
nii_volumes 1.96 ±0.01 1.98 ±0.01 +0.7% (noise) 0.088
nii_apply_crop 0.74 ±0.04 0.75 ±0.04 +0.7% (noise) 0.666

mri_2d — shape (68, 52, 1)

Measurement baseline ms (median ±½·range) head ms (median ±½·range) Δ % p
nii_rescale 5.45 ±0.04 5.29 ±0.07 -2.9% 0.001
nii_resample_from_to 7.53 ±0.08 7.41 ±0.07 -1.6% 0.107
nii_dilate_msk 19.48 ±0.07 19.41 ±0.12 -0.4% 0.113
metric_voxels 3536.00 ±0.00 3536.00 ±0.00 +0.0% —
metric_labels 7.00 ±0.00 7.00 ±0.00 +0.0% —
… 30 more measurements
Measurement baseline ms (median ±½·range) head ms (median ±½·range) Δ % p
metric_foreground_pct 50.17 ±0.00 50.17 ±0.00 +0.0% —
poi_map_labels 0.24 ±0.01 0.22 ±0.01 -9.5% (noise) 0.010
nii_map_labels 0.48 ±0.02 0.51 ±0.02 +6.2% (noise) 0.015
poi_save 0.54 ±0.24 0.57 ±0.58 +6.1% (noise) 0.568
nii_volumes 0.49 ±0.03 0.46 ±0.01 -5.7% (noise) 0.120
nii_extract_label 0.43 ±0.02 0.41 ±0.01 -5.6% (noise) 0.072
poi_load 2.05 ±0.11 1.95 ±0.07 -5.1% (noise) 0.062
nii_unique 0.12 ±0.00 0.12 ±0.00 +4.1% (noise) 0.080
nii_compute_crop 0.15 ±0.01 0.14 ±0.01 -4.0% (noise) 0.324
poi_resample_from_to 0.77 ±0.03 0.75 ±0.02 -3.6% (noise) 0.082
poi_to_global 0.26 ±0.02 0.26 ±0.01 -3.5% (noise) 0.243
nii_get_array 0.02 ±0.00 0.02 ±0.00 -3.1% (noise) 0.337
nii_set_dtype 0.40 ±0.02 0.41 ±0.00 +3.1% (noise) 0.886
nii_get_connected_components 0.61 ±0.02 0.62 ±0.02 +2.8% (noise) 0.363
nii_load_img 1.51 ±0.05 1.47 ±0.07 -2.6% (noise) 0.287
poi_calc_centroids 1.17 ±0.06 1.14 ±0.02 -2.5% (noise) 0.161
nii_apply_crop 0.65 ±0.01 0.67 ±0.02 +2.2% (noise) 0.508
poi_reorient 0.48 ±0.02 0.47 ±0.02 -2.1% (noise) 0.650
nii_save 1.38 ±0.05 1.35 ±0.02 -2.1% (noise) 0.065
nii_pad_to 0.55 ±0.02 0.54 ±0.03 -1.8% (noise) 0.799
nii_rescale_seg 0.77 ±0.02 0.76 ±0.03 -1.7% (noise) 0.177
nii_filter_connected_components 0.86 ±0.04 0.85 ±0.03 -1.5% (noise) 0.482
nii_fill_holes 1.11 ±0.01 1.09 ±0.03 -1.5% (noise) 0.334
nii_erode_msk 1.30 ±0.03 1.29 ±0.04 -0.9% (noise) 0.636
nii_reorient 0.83 ±0.03 0.84 ±0.02 +0.7% (noise) 0.674
poi_calc_centroids_nocrop 1.44 ±0.05 1.43 ±0.05 -0.6% (noise) 0.455
poi_rescale 0.28 ±0.01 0.28 ±0.01 -0.6% (noise) 0.282
poi_local_to_global_arr 0.33 ±0.01 0.33 ±0.02 +0.5% (noise) 0.513
nii_center_of_masses 0.41 ±0.01 0.42 ±0.03 +0.2% (noise) 0.909
nii_load_seg 1.69 ±0.05 1.69 ±0.07 +0.0% (noise) 0.551

Gate: a measurement fails when the baseline is ≥ 1 ms, the median grows by ≥ 50%, and Welch's t-test gives p < 0.05. metric_* rows are context only.

Memory (peak RSS growth per call)

baseline 32c52dd vs head 32c52dd · python 3.11.16 · 5 repeats + 1 warmup · sampler proc-status, isolation fork · measurement floor ≈ 0.90 MiB

Showing the 5 most-changed measurements per case; the rest are collapsed. Rows with a baseline below 3 MiB are tagged (noise) — runner jitter on those swamps any real change.

ct_3d — shape (73, 47, 73)

Measurement baseline MiB (median ±½·range) head MiB (median ±½·range) Δ % p
nii_save 3.67 ±0.00 3.71 ±0.00 +1.0% —
nii_apply_crop 5.77 ±0.00 5.80 ±0.00 +0.6% —
nii_rescale_seg 5.84 ±0.00 5.88 ±0.00 +0.6% —
nii_rescale 6.03 ±0.00 6.07 ±0.00 +0.6% —
nii_resample_from_to 6.16 ±0.00 6.19 ±0.00 +0.6% —
… 31 more measurements
Measurement baseline MiB (median ±½·range) head MiB (median ±½·range) Δ % p
nii_get_connected_components 6.52 ±0.00 6.55 ±0.00 +0.5% —
nii_filter_connected_components 6.77 ±0.00 6.80 ±0.00 +0.5% —
poi_calc_poi_from_subreg_vert 7.01 ±0.00 7.05 ±0.00 +0.5% —
nii_erode_msk 5.86 ±0.00 5.88 ±0.00 +0.3% —
nii_load_img 3.53 ±0.00 3.52 ±0.00 -0.2% —
nii_load_seg 5.91 ±0.00 5.91 ±0.00 -0.1% —
poi_to_global 3.08 ±0.00 3.08 ±0.00 -0.1% —
nii_fill_holes 6.22 ±0.00 6.21 ±0.00 -0.1% —
nii_center_of_masses 3.27 ±0.00 3.27 ±0.00 -0.1% —
nii_volumes 3.52 ±0.00 3.52 ±0.00 -0.1% —
poi_reorient 3.83 ±0.00 3.83 ±0.00 -0.1% —
poi_resample_from_to 5.18 ±0.00 5.17 ±0.00 -0.1% —
nii_set_dtype 5.18 ±0.00 5.18 ±0.00 -0.1% —
nii_extract_label 5.30 ±0.00 5.30 ±0.00 -0.1% —
nii_pad_to 5.36 ±0.00 5.36 ±0.00 -0.1% —
nii_map_labels 5.43 ±0.00 5.43 ±0.00 -0.1% —
nii_reorient 5.80 ±0.00 5.80 ±0.00 -0.1% —
poi_calc_centroids_nocrop 5.86 ±0.00 5.86 ±0.00 -0.1% —
nii_dilate_msk 5.87 ±0.00 5.86 ±0.00 -0.1% —
poi_calc_centroids 5.99 ±0.00 5.99 ±0.00 -0.1% —
metric_voxels 250463.00 ±0.00 250463.00 ±0.00 +0.0% —
metric_labels 3.00 ±0.00 3.00 ±0.00 +0.0% —
metric_foreground_pct 14.51 ±0.00 14.51 ±0.00 +0.0% —
poi_rescale 2.61 ±0.00 2.60 ±0.00 -0.1% (noise) —
nii_compute_crop 2.90 ±0.00 2.89 ±0.00 -0.1% (noise) —
poi_local_to_global_arr 2.96 ±0.00 2.95 ±0.00 -0.1% (noise) —
nii_unique 2.96 ±0.00 2.96 ±0.00 -0.1% (noise) —
nii_get_array 1.65 ±0.00 1.65 ±0.00 +0.0% (noise) —
poi_save 1.40 ±0.00 1.40 ±0.00 +0.0% (noise) —
poi_map_labels 1.77 ±0.00 1.77 ±0.00 +0.0% (noise) —
poi_load 1.61 ±0.00 1.61 ±0.00 +0.0% (noise) —

ct_2d — shape (73, 47, 1)

Measurement baseline MiB (median ±½·range) head MiB (median ±½·range) Δ % p
nii_save 3.61 ±0.00 3.64 ±0.00 +1.0% —
nii_apply_crop 5.70 ±0.00 5.74 ±0.00 +0.6% —
nii_rescale_seg 5.78 ±0.00 5.82 ±0.00 +0.6% —
nii_rescale 5.97 ±0.00 6.00 ±0.00 +0.6% —
nii_resample_from_to 6.09 ±0.00 6.13 ±0.00 +0.6% —
… 30 more measurements
Measurement baseline MiB (median ±½·range) head MiB (median ±½·range) Δ % p
nii_get_connected_components 6.45 ±0.00 6.49 ±0.00 +0.5% —
nii_filter_connected_components 6.70 ±0.00 6.74 ±0.00 +0.5% —
nii_erode_msk 5.80 ±0.00 5.82 ±0.00 +0.3% —
nii_load_img 3.47 ±0.00 3.46 ±0.00 -0.2% —
nii_load_seg 5.85 ±0.00 5.84 ±0.00 -0.1% —
poi_to_global 3.02 ±0.00 3.02 ±0.00 -0.1% —
nii_fill_holes 6.16 ±0.00 6.15 ±0.00 -0.1% —
nii_center_of_masses 3.21 ±0.00 3.21 ±0.00 -0.1% —
nii_volumes 3.46 ±0.00 3.46 ±0.00 -0.1% —
poi_reorient 3.77 ±0.00 3.77 ±0.00 -0.1% —
poi_resample_from_to 5.11 ±0.00 5.11 ±0.00 -0.1% —
nii_set_dtype 5.12 ±0.00 5.11 ±0.00 -0.1% —
nii_extract_label 5.24 ±0.00 5.24 ±0.00 -0.1% —
nii_pad_to 5.30 ±0.00 5.30 ±0.00 -0.1% —
nii_map_labels 5.37 ±0.00 5.36 ±0.00 -0.1% —
nii_reorient 5.74 ±0.00 5.74 ±0.00 -0.1% —
nii_dilate_msk 5.80 ±0.00 5.80 ±0.00 -0.1% —
poi_calc_centroids_nocrop 5.86 ±0.00 5.86 ±0.00 -0.1% —
poi_calc_centroids 5.93 ±0.00 5.93 ±0.00 -0.1% —
metric_voxels 3431.00 ±0.00 3431.00 ±0.00 +0.0% —
metric_labels 3.00 ±0.00 3.00 ±0.00 +0.0% —
metric_foreground_pct 37.60 ±0.00 37.60 ±0.00 +0.0% —
poi_rescale 2.54 ±0.00 2.54 ±0.00 -0.2% (noise) —
nii_compute_crop 2.84 ±0.00 2.83 ±0.00 -0.1% (noise) —
poi_local_to_global_arr 2.89 ±0.00 2.89 ±0.00 -0.1% (noise) —
nii_unique 2.90 ±0.00 2.89 ±0.00 -0.1% (noise) —
nii_get_array 1.59 ±0.00 1.59 ±0.00 +0.0% (noise) —
poi_save 1.34 ±0.00 1.34 ±0.00 +0.0% (noise) —
poi_map_labels 1.71 ±0.00 1.71 ±0.00 +0.0% (noise) —
poi_load 1.55 ±0.00 1.55 ±0.00 +0.0% (noise) —

mri_3d — shape (68, 52, 67)

Measurement baseline MiB (median ±½·range) head MiB (median ±½·range) Δ % p
nii_save 3.61 ±0.00 3.64 ±0.00 +1.0% —
nii_apply_crop 5.70 ±0.00 5.74 ±0.00 +0.6% —
nii_rescale_seg 5.78 ±0.00 5.82 ±0.00 +0.6% —
nii_rescale 5.97 ±0.00 6.00 ±0.00 +0.6% —
nii_resample_from_to 6.09 ±0.00 6.13 ±0.00 +0.6% —
… 31 more measurements
Measurement baseline MiB (median ±½·range) head MiB (median ±½·range) Δ % p
nii_get_connected_components 6.45 ±0.00 6.49 ±0.00 +0.5% —
nii_filter_connected_components 6.70 ±0.00 6.74 ±0.00 +0.5% —
poi_calc_poi_from_subreg_vert 7.01 ±0.00 7.05 ±0.00 +0.5% —
nii_erode_msk 5.80 ±0.00 5.82 ±0.00 +0.3% —
nii_load_img 3.53 ±0.00 3.52 ±0.00 -0.2% —
nii_load_seg 5.85 ±0.00 5.84 ±0.00 -0.1% —
poi_to_global 3.02 ±0.00 3.02 ±0.00 -0.1% —
nii_fill_holes 6.16 ±0.00 6.15 ±0.00 -0.1% —
nii_center_of_masses 3.21 ±0.00 3.21 ±0.00 -0.1% —
nii_volumes 3.46 ±0.00 3.46 ±0.00 -0.1% —
poi_reorient 3.77 ±0.00 3.77 ±0.00 -0.1% —
poi_resample_from_to 5.11 ±0.00 5.11 ±0.00 -0.1% —
nii_set_dtype 5.18 ±0.00 5.18 ±0.00 -0.1% —
nii_extract_label 5.24 ±0.00 5.24 ±0.00 -0.1% —
nii_pad_to 5.30 ±0.00 5.30 ±0.00 -0.1% —
nii_map_labels 5.37 ±0.00 5.36 ±0.00 -0.1% —
nii_reorient 5.74 ±0.00 5.74 ±0.00 -0.1% —
poi_calc_centroids_nocrop 5.80 ±0.00 5.80 ±0.00 -0.1% —
nii_dilate_msk 5.80 ±0.00 5.80 ±0.00 -0.1% —
poi_calc_centroids 5.93 ±0.00 5.93 ±0.00 -0.1% —
metric_voxels 236912.00 ±0.00 236912.00 ±0.00 +0.0% —
metric_labels 8.00 ±0.00 8.00 ±0.00 +0.0% —
metric_foreground_pct 18.21 ±0.00 18.21 ±0.00 +0.0% —
poi_rescale 2.54 ±0.00 2.54 ±0.00 -0.2% (noise) —
nii_compute_crop 2.84 ±0.00 2.83 ±0.00 -0.1% (noise) —
poi_local_to_global_arr 2.89 ±0.00 2.89 ±0.00 -0.1% (noise) —
nii_unique 2.90 ±0.00 2.89 ±0.00 -0.1% (noise) —
nii_get_array 1.59 ±0.00 1.59 ±0.00 +0.0% (noise) —
poi_save 1.40 ±0.00 1.40 ±0.00 +0.0% (noise) —
poi_map_labels 1.71 ±0.00 1.71 ±0.00 +0.0% (noise) —
poi_load 1.55 ±0.00 1.55 ±0.00 +0.0% (noise) —

mri_2d — shape (68, 52, 1)

Measurement baseline MiB (median ±½·range) head MiB (median ±½·range) Δ % p
nii_save 3.61 ±0.00 3.64 ±0.00 +1.0% —
nii_apply_crop 5.70 ±0.00 5.74 ±0.00 +0.6% —
nii_rescale_seg 5.78 ±0.00 5.82 ±0.00 +0.6% —
nii_rescale 5.97 ±0.00 6.00 ±0.00 +0.6% —
nii_resample_from_to 6.09 ±0.00 6.13 ±0.00 +0.6% —
… 30 more measurements
Measurement baseline MiB (median ±½·range) head MiB (median ±½·range) Δ % p
nii_get_connected_components 6.45 ±0.00 6.49 ±0.00 +0.5% —
nii_filter_connected_components 6.70 ±0.00 6.74 ±0.00 +0.5% —
nii_erode_msk 5.80 ±0.00 5.82 ±0.00 +0.3% —
nii_load_img 3.53 ±0.00 3.52 ±0.00 -0.2% —
nii_load_seg 5.85 ±0.00 5.84 ±0.00 -0.1% —
poi_to_global 3.02 ±0.00 3.02 ±0.00 -0.1% —
nii_fill_holes 6.16 ±0.00 6.15 ±0.00 -0.1% —
nii_center_of_masses 3.21 ±0.00 3.21 ±0.00 -0.1% —
nii_volumes 3.46 ±0.00 3.46 ±0.00 -0.1% —
poi_reorient 3.77 ±0.00 3.77 ±0.00 -0.1% —
poi_resample_from_to 5.11 ±0.00 5.11 ±0.00 -0.1% —
nii_set_dtype 5.18 ±0.00 5.18 ±0.00 -0.1% —
nii_extract_label 5.24 ±0.00 5.24 ±0.00 -0.1% —
nii_pad_to 5.30 ±0.00 5.30 ±0.00 -0.1% —
nii_map_labels 5.37 ±0.00 5.36 ±0.00 -0.1% —
nii_reorient 5.74 ±0.00 5.74 ±0.00 -0.1% —
nii_dilate_msk 5.80 ±0.00 5.80 ±0.00 -0.1% —
poi_calc_centroids_nocrop 5.86 ±0.00 5.86 ±0.00 -0.1% —
poi_calc_centroids 5.93 ±0.00 5.93 ±0.00 -0.1% —
metric_voxels 3536.00 ±0.00 3536.00 ±0.00 +0.0% —
metric_labels 7.00 ±0.00 7.00 ±0.00 +0.0% —
metric_foreground_pct 50.17 ±0.00 50.17 ±0.00 +0.0% —
poi_rescale 2.54 ±0.00 2.54 ±0.00 -0.2% (noise) —
nii_compute_crop 2.84 ±0.00 2.83 ±0.00 -0.1% (noise) —
poi_local_to_global_arr 2.89 ±0.00 2.89 ±0.00 -0.1% (noise) —
nii_unique 2.90 ±0.00 2.89 ±0.00 -0.1% (noise) —
nii_get_array 1.59 ±0.00 1.59 ±0.00 +0.0% (noise) —
poi_save 1.40 ±0.00 1.40 ±0.00 +0.0% (noise) —
poi_map_labels 1.71 ±0.00 1.71 ±0.00 +0.0% (noise) —
poi_load 1.55 ±0.00 1.55 ±0.00 +0.0% (noise) —

Gate: a measurement fails when the baseline is ≥ 1 MiB, the median grows by ≥ 50%, and Welch's t-test gives p < 0.05. metric_* rows are context only.

`_extract_nii_from_dicom` had `return True` inside the `len == 1` branch
regardless of whether `dicom_to_nifti_multiframe` actually wrote a file.
Any single-DICOM series without `pixel_array` — Presentation State, Key
Object Selection, Registration, Fiducials, some RT objects — was reported
as successfully converted, and the caller then crashed inside
`_add_grid_info_to_json` with `FileNotFoundError` on a NIfTI that was
never produced.

Move `return True` inside the pixel-data guard; when the guard fails,
delete the half-written sidecar and return False so the caller stops the
pipeline for that series.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
robert-graf and others added 20 commits September 15, 2026 10:35
`test_name_conflict` opened the existing file with `encoding="utf-8"` and
handed the bytes straight to `json.load`. When an interrupted extraction
or an unrelated same-name file left non-UTF-8 / non-JSON content on
disk (seen in the wild as `UnicodeDecodeError: 'utf-8' codec can't decode
byte 0xa4 in position 1`), the exception propagated out of
`_generate_bids_path` and killed the whole series conversion.

Catch UnicodeDecodeError / JSONDecodeError / OSError and return True
(= conflict) so the caller's `_inc_key` path assigns a fresh, non-
colliding filename instead of crashing.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Previously the modality fallback in `extract_keys_from_json` routed every
non-CT/PET/MR/XA modality to a `.nii.gz` output — including Presentation
State (PR), Key Object Selection (KO), Registration (REG), Fiducials
(FID), Real World Value Map (RWV), Plan (PLAN), Slide Stainer (STAIN),
waveform types (RESP / HD / ECG / EPS) and ophthalmic measurements
(AR / KER / LEN / VA / OPV / OPM). None of those carry a NIfTI-shaped
pixel volume, so the caller then either wrote a corrupt file or crashed
in the follow-up grid step.

Route them through the same `.txt` sidecar path already used for SR:
lower-cased modality tag becomes the BIDS format, `SeriesDescription`
becomes `desc`, and the ImageType-driven `part` / view / laterality
helpers upstream still enrich the filename.

Also drop the now-unused `simp_json` argument from `_apply_view_keys`
so ruff stops complaining about ARG001.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Previous behaviour deleted the sidecar JSON when `_extract_nii_from_dicom`
hit a single DICOM without `pixel_array`. That threw away every
non-pixel DICOM tag (which the JSON already holds in full) for anything
that didn't match the explicit modality routing (PR/KO/REG/…) — malformed
CT/MR series, unknown modalities, DICOMs with `pixel_array.shape < 2`.

Now the branch:
  * Attempts to write a `.txt` companion via `_extract_txt_from_dicom` so
    a DICOM that only carries a ContentSequence still leaves its textual
    payload on disk. An empty result is removed to avoid noise.
  * Keeps the JSON header sidecar so downstream inspection still has
    every tag from the source DICOM.
  * Returns False so the caller skips `_add_grid_info_to_json` on the
    nonexistent NIfTI.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…g around it

The previous fix caught UnicodeDecodeError / JSONDecodeError / OSError and
returned True (= "conflict"), which sent the caller through `_inc_key` to
find a fresh non-colliding filename. In practice the unreadable file is
almost always a half-written sidecar from an earlier crashed extraction,
not an unrelated file the extractor should tiptoe around — so the
`_inc_key` path only piled up `_sequ-<n>-a` copies next to the broken
original.

Now we unlink the corrupt file, log a warning, and return False so the
caller writes fresh content over the slot. If the unlink itself fails
(permissions, read-only filesystem) we fall back to the rename path so
the caller still succeeds.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
`_collect_text` only produced output for DICOMs with a `ContentSequence`
(Structured Reports). For every other non-imaging modality routed through
the `.txt` path — Presentation State, Key Object Selection, Registration,
Fiducials, waveforms, ophthalmic measurements — the walker returned
nothing and left an empty `_pr.txt` / `_ko.txt` / `_reg.txt` on disk.

Fall back to iterating every DICOM element (via `Dataset.iterall`) and
appending the tag/name/VR/value line pydicom already knows how to render.
Skip the PixelData group so the sidecar stays small; ultimate fallback
is `str(ds).splitlines()` if `iterall` raises for any reason.

Verified against a synthetic PR-modality dataset: previously 0 lines,
now the full header (Modality, SeriesDescription, PatientName, …).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Presentation State (GSPS) DICOMs — SOP Class 1.2.840.10008.5.1.4.1.11.1,
BIDS format ``pr`` after the modality fallback — hold no pixel data.
They only carry Referenced SOP Instance UIDs into other series plus
viewer window/level, zoom, pan and annotation presets. For an automated
segmentation pipeline they are pure noise: `_read_dicom_files` still
groups them per-echo/per-SeriesInstanceUID via `_inc_key`, which meant a
single scanner session shipped ~10 `_pr.txt` files per subject next to
the actual imaging.

Add a `skip_formats: set[str]` parameter to both `_from_dicom_to_nii`
and `extract_dicom_folder`, default `{"pr"}`, and drop matching series
after the BIDS format is resolved but before any JSON / NIfTI / sidecar
is written. Callers can pass `set()` to keep every format, or add more
formats to also drop e.g. Key Object Selection (`"ko"`) or Registration
(`"reg"`).

Behaviour otherwise unchanged for every currently-imaging modality.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
CI's ruff 0.16 caught six lint errors the pinned pre-commit ruff 0.11
didn't. They're all cosmetic / lint-shape fixes with no behaviour change:

  * D205 x2 — docstring summary now sits on its own line, followed by a
    blank line before the description block.
  * W293 — trailing whitespace on a blank line (auto-fixed by ruff).
  * PLW2901 — silence "loop variable rebinding" via `# noqa` on the
    `v = preferred` re-target (kept because renaming would ripple).
  * PERF102 — `for _key, t2w in dict.items()` → `for t2w in dict.values()`
    (the key was never used).
  * TRY300 — move successful `return` from the `try` body into an
    `else:` block after the except.

Unblocks the ruff linter GitHub Action on this PR.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The AttributeError branch already handled non-imaging DICOMs that lack
`ImagePositionPatient`. Callers that hand `get_plane_dicom` an empty
list (e.g. the new `dicom_renamer` when there are no DICOMs available),
a dict, or a stub object also mean "no plane to compute" but tripped
IndexError / KeyError / TypeError inside `create_affine`, which then
surfaced as a warning and bogus `acq-None` filenames.

Add IndexError / KeyError / TypeError to the silent-catch tuple so
"no plane" cases stay quiet while genuine plane-detection bugs still
warn via the general Exception branch below.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
New module `TPTBox/core/dicom/dicom_renamer.py` walks an already-extracted
dataset root, feeds each sidecar JSON back through the current
`extract_keys_from_json` + BIDS path logic, and moves each file family to
its new path. Two use cases:

  1. Sanitise BIDS-illegal subject ids. BIDS forbids `_` inside an entity
     value (it's the entity separator). Datasets imported before that
     rule was enforced end up with names like `sub-180217_711279`,
     `sub-1YI_Za7zmmM`, `sub--1c0TOvqxPw` — legal-ish on disk but
     `BIDS_FILE` parses them wrong. Default mode strips `_` and any
     leading `-`.

  2. Compact numeric ids. Passing `subject_prefix="ID"` reassigns every
     subject to `ID001`, `ID002`, … zero-padded to
     `subject_number_width` (default 3). The old → new mapping is
     written to `<dataset_root>/<info_dir>/subject_map.tsv` so the
     original ids stay recoverable.

Additional benefit: any change to `extract_keys_from_json` upstream
(view/laterality/BodyPart extraction, modality fallback, ordering fixes
in the series-description map) becomes effective on-disk without a
full DICOM re-extraction — the JSON sidecar already carries the same
metadata dict the DICOM did.

Performance shortcut: the plane is read from the sidecar's `grid` block
(populated by `_add_grid_info_to_json` during extraction) rather than
re-loaded from the NIfTI. A small `_FakeDicomList` stub keeps
`extract_keys_from_json` from touching disk at all — dry-runs cost O(N
JSONs) file opens instead of loading every NIfTI. Layout composition is
done manually (not via `_generate_bids_path`) so flat datasets like
`TOF_MPRAGE/sub-*/…` don't lose the subject folder to the parent-slot
default.

Ships with a `python -m TPTBox.core.dicom.dicom_renamer …` CLI: dry-run
by default, `--apply` to actually move files, `--parent ''` for flat
datasets, `--subject-prefix ID` for the numeric rename.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Analysis of the TOF-MPRAGE dataset shows 851 of 1248 files currently
labelled ``_mr`` are already classified correctly by the current map on
a re-run. Of the remaining 397, three tight patterns cover 324 (~81 %):

* ``.*3di[_-]?mc.*`` → ``TOF`` — Philips 3D-Inflow Motion-Corrected
  High-Resolution MIP projections (ImageType carries ``PROJECTION
  IMAGE``, MRAcquisitionType is ``3D``, ScanningSequence ``GR``).
  Covers 318 files, all variants of ``3DI_MC_HR cc`` / ``rl`` /
  ``s3DI_MC_HR REC``.
* ``.*dyn.*post.*km.*`` → ``DCE`` — dynamic post-contrast MR series
  (``\d+ Dyn post KM``). Requires all three tokens so a bare ``dyn``
  doesn't over-match generic dynamic acquisitions.
* ``.*halsgef.*`` → ``angio`` — German ``Halsgefäße`` neck-vessel
  angiography.

Placed before the greedy ``.*t2.*`` / ``.*t1.*`` / ``.*mp?ra?ge?.*``
catch-alls so the specific classification wins.

Remainder (73 files) is genuinely ambiguous or corrupt SeriesDescription
(dates, empty strings, internal abbreviations) and legitimately stays
``mr``.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two additions so a re-run after a partial or interrupted apply is safe
and complete:

1. Reload the previous `subject_map.tsv` on start. Folders that already
   match a `new_sub` value from the prior run map to themselves; folders
   whose id is still a `key` in the map get the previously assigned new
   id back; only unseen subjects claim a fresh number, taken from the
   next slot beyond the highest already-used one. Same idempotence for
   the sanitise-in-place branch.

2. Second pass after the JSON-driven rename: sweep any file left behind
   in an old `sub-<OLD>/` tree (typical orphans are `.bval` / `.bvec`
   companions and DWI-derived `_dwi_ADC.nii.gz` maps). Each orphan looks
   for a twin in the new subject folder by `sequ-<N>` match — when
   found, it inherits the twin's folder + stem so BIDS stem-linkage
   stays intact (`_dwi.bval` next to `_DWI.nii.gz` becomes
   `_DWI.bval`). Files that can't be twinned fall back to the session
   level of the new subject folder with just the subject prefix swapped.

Users can now re-run the same command safely and pick up companion
files that the first pass missed.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant