Skip to content

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

Merged
Hendrik-code merged 50 commits into
mainfrom
dicom-extract-cleanup
Oct 19, 2026
Merged

Hendrik-code merged 50 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 2190812 vs head 2190812 · python 3.11.17 · 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_filter_connected_components 3.39 ±0.03 3.57 ±0.07 +5.3% 0.006
nii_load_img 9.22 ±0.09 9.56 ±0.15 +3.8% 0.006
nii_fill_holes 3.74 ±0.04 3.87 ±0.06 +3.2% 0.014
poi_calc_centroids_nocrop 3.45 ±0.02 3.55 ±0.08 +2.8% 0.022
nii_erode_msk 4.86 ±0.02 4.96 ±0.11 +2.2% 0.035
… 31 more measurements
Measurement baseline ms (median ±½·range) head ms (median ±½·range) Δ % p
poi_calc_poi_from_subreg_vert 9.16 ±0.03 9.33 ±0.03 +1.8% 0.000
nii_resample_from_to 32.46 ±0.23 32.81 ±0.25 +1.1% 0.055
nii_rescale 31.04 ±0.11 31.25 ±0.24 +0.7% 0.132
nii_save 9.01 ±0.07 9.05 ±0.14 +0.4% 0.600
nii_dilate_msk 92.45 ±0.61 92.46 ±2.82 +0.0% 0.291
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_compute_crop 0.24 ±0.01 0.30 ±0.02 +24.3% (noise) 0.000
poi_reorient 0.27 ±0.01 0.33 ±0.03 +24.0% (noise) 0.005
nii_reorient 0.55 ±0.02 0.66 ±0.05 +19.0% (noise) 0.003
poi_resample_from_to 0.57 ±0.01 0.48 ±0.03 -15.4% (noise) 0.001
nii_center_of_masses 1.40 ±0.01 1.62 ±0.01 +15.3% (noise) 0.000
nii_pad_to 0.47 ±0.01 0.54 ±0.02 +14.7% (noise) 0.001
nii_volumes 1.50 ±0.02 1.72 ±0.03 +14.6% (noise) 0.000
nii_apply_crop 0.44 ±0.01 0.50 ±0.02 +13.9% (noise) 0.000
poi_map_labels 0.16 ±0.01 0.14 ±0.01 -13.6% (noise) 0.085
poi_calc_centroids 1.93 ±0.03 2.18 ±0.08 +13.0% (noise) 0.001
nii_set_dtype 0.36 ±0.02 0.40 ±0.05 +11.0% (noise) 0.094
poi_load 0.78 ±0.02 0.70 ±0.03 -10.4% (noise) 0.002
nii_map_labels 0.65 ±0.03 0.71 ±0.04 +9.1% (noise) 0.068
nii_unique 0.47 ±0.01 0.50 ±0.02 +5.8% (noise) 0.005
nii_load_seg 2.11 ±0.02 2.22 ±0.06 +5.3% (noise) 0.008
nii_rescale_seg 2.44 ±0.02 2.56 ±0.04 +5.0% (noise) 0.000
poi_rescale 0.15 ±0.02 0.14 ±0.00 -4.3% (noise) 0.157
nii_get_array 0.04 ±0.00 0.04 ±0.00 -4.1% (noise) 0.377
poi_to_global 0.13 ±0.01 0.13 ±0.02 +3.9% (noise) 0.444
nii_extract_label 0.27 ±0.02 0.28 ±0.04 +3.4% (noise) 0.188
nii_get_connected_components 1.62 ±0.03 1.67 ±0.10 +2.9% (noise) 0.177
poi_save 0.35 ±0.06 0.35 ±0.02 +1.0% (noise) 0.869
poi_local_to_global_arr 0.22 ±0.01 0.22 ±0.03 +0.5% (noise) 0.379

ct_2d — shape (73, 47, 1)

Measurement baseline ms (median ±½·range) head ms (median ±½·range) Δ % p
nii_resample_from_to 6.04 ±0.36 5.65 ±0.21 -6.5% 0.107
nii_dilate_msk 10.60 ±0.04 10.78 ±0.08 +1.7% 0.004
nii_rescale 4.23 ±0.05 4.24 ±0.20 +0.4% 0.229
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% —
poi_local_to_global_arr 0.28 ±0.03 0.25 ±0.00 -10.7% (noise) 0.073
nii_rescale_seg 0.74 ±0.04 0.67 ±0.00 -9.9% (noise) 0.011
poi_resample_from_to 0.65 ±0.02 0.59 ±0.03 -9.7% (noise) 0.003
nii_map_labels 0.47 ±0.04 0.43 ±0.02 -9.0% (noise) 0.118
poi_rescale 0.22 ±0.01 0.24 ±0.02 +6.3% (noise) 0.412
nii_erode_msk 0.84 ±0.04 0.79 ±0.02 -5.3% (noise) 0.086
poi_load 0.82 ±0.03 0.78 ±0.02 -4.5% (noise) 0.018
nii_pad_to 0.51 ±0.02 0.49 ±0.02 -4.5% (noise) 0.410
nii_extract_label 0.38 ±0.03 0.40 ±0.01 +4.5% (noise) 0.267
nii_get_array 0.01 ±0.01 0.02 ±0.00 +4.3% (noise) 0.640
poi_calc_centroids_nocrop 0.94 ±0.05 0.90 ±0.02 -3.8% (noise) 0.103
nii_apply_crop 0.60 ±0.01 0.58 ±0.02 -3.8% (noise) 0.130
nii_load_img 1.24 ±0.03 1.19 ±0.02 -3.3% (noise) 0.038
nii_get_connected_components 0.58 ±0.04 0.59 ±0.02 +3.0% (noise) 0.865
poi_calc_centroids 0.79 ±0.03 0.81 ±0.03 +2.8% (noise) 0.679
nii_filter_connected_components 0.71 ±0.01 0.73 ±0.02 +2.7% (noise) 0.159
nii_center_of_masses 0.19 ±0.01 0.20 ±0.01 +2.7% (noise) 0.732
nii_fill_holes 0.71 ±0.04 0.72 ±0.02 +2.6% (noise) 0.290
poi_map_labels 0.18 ±0.01 0.18 ±0.01 -2.2% (noise) 0.478
nii_save 1.16 ±0.02 1.14 ±0.03 -2.0% (noise) 0.114
nii_set_dtype 0.38 ±0.02 0.39 ±0.02 +2.0% (noise) 0.670
nii_unique 0.13 ±0.01 0.12 ±0.01 -1.4% (noise) 0.563
nii_compute_crop 0.14 ±0.01 0.14 ±0.01 -1.2% (noise) 0.632
poi_save 0.45 ±0.02 0.45 ±0.04 -1.1% (noise) 0.235
nii_load_seg 1.36 ±0.05 1.37 ±0.03 +1.0% (noise) 0.647
poi_to_global 0.22 ±0.01 0.22 ±0.02 +0.2% (noise) 0.829
nii_reorient 0.69 ±0.09 0.69 ±0.04 -0.2% (noise) 0.510
poi_reorient 0.39 ±0.02 0.39 ±0.01 +0.1% (noise) 0.431
nii_volumes 0.26 ±0.01 0.26 ±0.01 -0.0% (noise) 0.806

mri_3d — shape (68, 52, 67)

Measurement baseline ms (median ±½·range) head ms (median ±½·range) Δ % p
nii_fill_holes 4.98 ±0.07 4.76 ±0.04 -4.3% 0.002
poi_calc_centroids_nocrop 7.35 ±0.27 7.18 ±0.13 -2.3% 0.191
nii_erode_msk 6.54 ±0.14 6.40 ±0.05 -2.2% 0.030
nii_save 7.62 ±0.13 7.48 ±0.06 -1.9% 0.062
nii_load_img 7.64 ±0.10 7.52 ±0.04 -1.6% 0.049
… 31 more measurements
Measurement baseline ms (median ±½·range) head ms (median ±½·range) Δ % p
poi_calc_poi_from_subreg_vert 8.58 ±0.04 8.68 ±0.07 +1.2% 0.015
nii_resample_from_to 30.87 ±0.22 30.61 ±0.25 -0.8% 0.183
nii_dilate_msk 118.72 ±0.61 119.09 ±0.45 +0.3% 0.136
nii_rescale 29.25 ±0.29 29.33 ±0.11 +0.3% 0.793
nii_filter_connected_components 3.72 ±0.05 3.72 ±0.02 -0.2% 0.495
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 0.23 ±0.01 0.26 ±0.01 +10.0% (noise) 0.017
nii_get_array 0.04 ±0.00 0.04 ±0.00 -10.0% (noise) 0.393
poi_reorient 0.41 ±0.01 0.44 ±0.02 +7.1% (noise) 0.018
nii_extract_label 0.44 ±0.02 0.41 ±0.02 -7.0% (noise) 0.122
poi_resample_from_to 0.69 ±0.13 0.65 ±0.03 -5.8% (noise) 0.200
nii_compute_crop 0.35 ±0.02 0.33 ±0.01 -5.5% (noise) 0.052
nii_set_dtype 0.51 ±0.01 0.49 ±0.01 -3.4% (noise) 0.006
nii_reorient 0.77 ±0.03 0.75 ±0.05 -2.8% (noise) 0.208
nii_volumes 1.66 ±0.03 1.64 ±0.01 -1.7% (noise) 0.089
nii_unique 0.51 ±0.01 0.51 ±0.01 -1.7% (noise) 0.141
poi_load 1.62 ±0.04 1.65 ±0.02 +1.7% (noise) 0.396
nii_apply_crop 0.65 ±0.05 0.64 ±0.02 -1.5% (noise) 0.562
poi_calc_centroids 2.29 ±0.07 2.27 ±0.03 -1.0% (noise) 0.102
poi_save 0.51 ±0.02 0.51 ±0.02 +1.0% (noise) 0.820
nii_center_of_masses 1.65 ±0.01 1.63 ±0.01 -1.0% (noise) 0.119
nii_load_seg 2.31 ±0.04 2.33 ±0.03 +0.9% (noise) 0.243
nii_map_labels 0.87 ±0.02 0.86 ±0.02 -0.9% (noise) 0.192
poi_to_global 0.25 ±0.01 0.25 ±0.01 -0.9% (noise) 0.525
poi_map_labels 0.22 ±0.01 0.22 ±0.02 +0.6% (noise) 0.970
poi_local_to_global_arr 0.26 ±0.01 0.26 ±0.01 +0.5% (noise) 0.366
nii_get_connected_components 1.73 ±0.04 1.72 ±0.01 -0.5% (noise) 0.696
nii_pad_to 0.65 ±0.02 0.65 ±0.03 -0.5% (noise) 0.792
nii_rescale_seg 2.48 ±0.04 2.49 ±0.04 +0.3% (noise) 0.930

mri_2d — shape (68, 52, 1)

Measurement baseline ms (median ±½·range) head ms (median ±½·range) Δ % p
nii_rescale 4.75 ±0.04 4.53 ±0.62 -4.6% 0.798
nii_resample_from_to 5.80 ±0.22 5.92 ±0.18 +2.2% 0.590
nii_dilate_msk 14.09 ±0.06 14.25 ±0.08 +1.1% 0.017
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% —
nii_get_array 0.02 ±0.00 0.02 ±0.00 -29.4% (noise) 0.030
nii_set_dtype 0.41 ±0.02 0.36 ±0.02 -11.5% (noise) 0.001
nii_map_labels 0.52 ±0.01 0.47 ±0.01 -11.2% (noise) 0.000
nii_load_seg 1.52 ±0.07 1.37 ±0.06 -10.2% (noise) 0.019
nii_apply_crop 0.67 ±0.03 0.60 ±0.01 -10.1% (noise) 0.022
nii_pad_to 0.57 ±0.01 0.51 ±0.01 -9.9% (noise) 0.000
nii_reorient 0.78 ±0.02 0.71 ±0.03 -8.5% (noise) 0.003
nii_compute_crop 0.16 ±0.01 0.14 ±0.00 -8.3% (noise) 0.029
nii_save 1.29 ±0.01 1.18 ±0.04 -8.3% (noise) 0.001
nii_center_of_masses 0.37 ±0.01 0.34 ±0.01 -7.3% (noise) 0.021
nii_volumes 0.43 ±0.01 0.40 ±0.02 -6.5% (noise) 0.066
nii_get_connected_components 0.61 ±0.02 0.58 ±0.03 -6.1% (noise) 0.032
nii_rescale_seg 0.74 ±0.03 0.70 ±0.04 -5.6% (noise) 0.333
poi_calc_centroids 1.02 ±0.02 0.96 ±0.02 -5.3% (noise) 0.001
poi_map_labels 0.22 ±0.01 0.21 ±0.01 -3.4% (noise) 0.471
nii_filter_connected_components 0.81 ±0.04 0.78 ±0.01 -3.3% (noise) 0.087
nii_unique 0.14 ±0.01 0.14 ±0.01 -2.7% (noise) 0.539
poi_resample_from_to 0.68 ±0.02 0.67 ±0.02 -2.4% (noise) 0.075
poi_load 1.47 ±0.02 1.50 ±0.01 +2.0% (noise) 0.076
poi_save 0.52 ±0.01 0.51 ±0.02 -1.8% (noise) 0.337
poi_rescale 0.26 ±0.01 0.26 ±0.01 +1.5% (noise) 0.652
poi_calc_centroids_nocrop 1.12 ±0.04 1.13 ±0.07 +1.5% (noise) 0.259
nii_erode_msk 1.04 ±0.02 1.02 ±0.02 -1.4% (noise) 0.133
nii_load_img 1.22 ±0.04 1.20 ±0.02 -1.4% (noise) 0.363
poi_local_to_global_arr 0.25 ±0.01 0.25 ±0.01 -1.0% (noise) 0.803
poi_reorient 0.42 ±0.02 0.42 ±0.01 -0.6% (noise) 0.538
poi_to_global 0.25 ±0.01 0.25 ±0.01 -0.3% (noise) 0.512
nii_fill_holes 0.89 ±0.02 0.89 ±0.02 -0.2% (noise) 0.131
nii_extract_label 0.42 ±0.02 0.42 ±0.04 -0.2% (noise) 0.435

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 2190812 vs head 2190812 · python 3.11.17 · 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_rescale 6.48 ±0.00 6.32 ±0.00 -2.5% —
nii_resample_from_to 6.61 ±0.00 6.45 ±0.00 -2.4% —
nii_rescale_seg 6.25 ±0.00 6.13 ±0.00 -2.0% —
poi_resample_from_to 5.53 ±0.00 5.43 ±0.00 -1.8% —
nii_reorient 5.97 ±0.00 5.87 ±0.00 -1.6% —
… 31 more measurements
Measurement baseline MiB (median ±½·range) head MiB (median ±½·range) Δ % p
nii_load_seg 6.07 ±0.00 5.97 ±0.00 -1.5% —
nii_load_img 3.56 ±0.00 3.52 ±0.00 -1.1% —
nii_dilate_msk 6.05 ±0.00 5.99 ±0.00 -1.1% —
nii_erode_msk 6.07 ±0.00 6.00 ±0.00 -1.1% —
nii_set_dtype 5.37 ±0.00 5.31 ±0.00 -1.1% —
nii_pad_to 5.49 ±0.00 5.43 ±0.00 -1.1% —
nii_extract_label 5.49 ±0.00 5.43 ±0.00 -1.1% —
nii_map_labels 5.55 ±0.00 5.50 ±0.00 -1.1% —
nii_apply_crop 5.93 ±0.00 5.87 ±0.00 -1.0% —
poi_calc_centroids_nocrop 6.05 ±0.00 5.99 ±0.00 -1.0% —
poi_calc_centroids 6.18 ±0.00 6.12 ±0.00 -0.9% —
nii_fill_holes 6.41 ±0.00 6.35 ±0.00 -0.9% —
nii_get_connected_components 6.68 ±0.00 6.62 ±0.00 -0.9% —
nii_filter_connected_components 6.99 ±0.00 6.93 ±0.00 -0.8% —
poi_calc_poi_from_subreg_vert 7.30 ±0.00 7.24 ±0.00 -0.7% —
poi_local_to_global_arr 3.02 ±0.00 3.02 ±0.00 -0.1% —
poi_to_global 3.14 ±0.00 3.14 ±0.00 -0.1% —
nii_center_of_masses 3.21 ±0.00 3.21 ±0.00 -0.1% —
nii_volumes 3.52 ±0.00 3.52 ±0.00 -0.1% —
nii_save 3.83 ±0.00 3.83 ±0.00 -0.1% —
poi_reorient 3.89 ±0.00 3.89 ±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_save 1.46 ±0.00 1.46 ±0.00 -0.3% (noise) —
poi_load 1.61 ±0.00 1.61 ±0.00 -0.2% (noise) —
nii_get_array 1.65 ±0.00 1.64 ±0.00 -0.2% (noise) —
poi_map_labels 1.77 ±0.00 1.77 ±0.00 -0.2% (noise) —
poi_rescale 2.61 ±0.00 2.60 ±0.00 -0.1% (noise) —
nii_unique 2.90 ±0.00 2.89 ±0.00 -0.1% (noise) —
nii_compute_crop 2.96 ±0.00 2.96 ±0.00 -0.1% (noise) —

ct_2d — shape (73, 47, 1)

Measurement baseline MiB (median ±½·range) head MiB (median ±½·range) Δ % p
nii_rescale 6.42 ±0.00 6.25 ±0.00 -2.6% —
nii_resample_from_to 6.54 ±0.00 6.38 ±0.00 -2.4% —
nii_rescale_seg 6.19 ±0.00 6.07 ±0.00 -2.0% —
poi_resample_from_to 5.46 ±0.00 5.37 ±0.00 -1.8% —
nii_reorient 5.91 ±0.00 5.81 ±0.00 -1.7% —
… 30 more measurements
Measurement baseline MiB (median ±½·range) head MiB (median ±½·range) Δ % p
nii_load_seg 6.00 ±0.00 5.91 ±0.00 -1.6% —
nii_load_img 3.50 ±0.00 3.46 ±0.00 -1.1% —
nii_dilate_msk 5.99 ±0.00 5.93 ±0.00 -1.1% —
nii_erode_msk 6.01 ±0.00 5.94 ±0.00 -1.1% —
nii_set_dtype 5.30 ±0.00 5.25 ±0.00 -1.1% —
nii_pad_to 5.43 ±0.00 5.37 ±0.00 -1.1% —
nii_extract_label 5.43 ±0.00 5.37 ±0.00 -1.1% —
nii_map_labels 5.49 ±0.00 5.43 ±0.00 -1.1% —
nii_apply_crop 5.86 ±0.00 5.80 ±0.00 -1.0% —
poi_calc_centroids_nocrop 6.05 ±0.00 5.99 ±0.00 -1.0% —
poi_calc_centroids 6.12 ±0.00 6.06 ±0.00 -1.0% —
nii_fill_holes 6.34 ±0.00 6.29 ±0.00 -0.9% —
nii_get_connected_components 6.62 ±0.00 6.56 ±0.00 -0.9% —
nii_filter_connected_components 6.93 ±0.00 6.87 ±0.00 -0.8% —
poi_to_global 3.08 ±0.00 3.08 ±0.00 -0.1% —
nii_center_of_masses 3.15 ±0.00 3.14 ±0.00 -0.1% —
nii_volumes 3.46 ±0.00 3.46 ±0.00 -0.1% —
nii_save 3.77 ±0.00 3.77 ±0.00 -0.1% —
poi_reorient 3.83 ±0.00 3.83 ±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_save 1.40 ±0.00 1.39 ±0.00 -0.3% (noise) —
poi_load 1.55 ±0.00 1.55 ±0.00 -0.3% (noise) —
nii_get_array 1.59 ±0.00 1.58 ±0.00 -0.2% (noise) —
poi_map_labels 1.71 ±0.00 1.71 ±0.00 -0.2% (noise) —
poi_rescale 2.54 ±0.00 2.54 ±0.00 -0.2% (noise) —
nii_unique 2.84 ±0.00 2.83 ±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) —

mri_3d — shape (68, 52, 67)

Measurement baseline MiB (median ±½·range) head MiB (median ±½·range) Δ % p
nii_rescale 6.42 ±0.00 6.25 ±0.00 -2.6% —
nii_resample_from_to 6.54 ±0.00 6.38 ±0.00 -2.4% —
nii_rescale_seg 6.19 ±0.00 6.07 ±0.00 -2.0% —
poi_resample_from_to 5.46 ±0.00 5.37 ±0.00 -1.8% —
nii_reorient 5.91 ±0.00 5.81 ±0.00 -1.7% —
… 31 more measurements
Measurement baseline MiB (median ±½·range) head MiB (median ±½·range) Δ % p
nii_load_seg 6.00 ±0.00 5.91 ±0.00 -1.6% —
nii_dilate_msk 5.99 ±0.00 5.93 ±0.00 -1.1% —
nii_erode_msk 6.01 ±0.00 5.94 ±0.00 -1.1% —
nii_load_img 3.56 ±0.00 3.52 ±0.00 -1.1% —
nii_set_dtype 5.37 ±0.00 5.31 ±0.00 -1.1% —
nii_pad_to 5.43 ±0.00 5.37 ±0.00 -1.1% —
nii_extract_label 5.43 ±0.00 5.37 ±0.00 -1.1% —
nii_map_labels 5.49 ±0.00 5.43 ±0.00 -1.1% —
nii_apply_crop 5.86 ±0.00 5.80 ±0.00 -1.0% —
poi_calc_centroids_nocrop 5.99 ±0.00 5.93 ±0.00 -1.0% —
poi_calc_centroids 6.12 ±0.00 6.06 ±0.00 -1.0% —
nii_fill_holes 6.34 ±0.00 6.29 ±0.00 -0.9% —
nii_get_connected_components 6.62 ±0.00 6.56 ±0.00 -0.9% —
nii_filter_connected_components 6.93 ±0.00 6.87 ±0.00 -0.8% —
poi_calc_poi_from_subreg_vert 7.30 ±0.00 7.24 ±0.00 -0.8% —
poi_to_global 3.08 ±0.00 3.08 ±0.00 -0.1% —
nii_center_of_masses 3.15 ±0.00 3.14 ±0.00 -0.1% —
nii_volumes 3.46 ±0.00 3.46 ±0.00 -0.1% —
nii_save 3.77 ±0.00 3.77 ±0.00 -0.1% —
poi_reorient 3.83 ±0.00 3.83 ±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_save 1.46 ±0.00 1.46 ±0.00 -0.3% (noise) —
poi_load 1.55 ±0.00 1.55 ±0.00 -0.3% (noise) —
nii_get_array 1.59 ±0.00 1.58 ±0.00 -0.2% (noise) —
poi_map_labels 1.71 ±0.00 1.71 ±0.00 -0.2% (noise) —
poi_rescale 2.54 ±0.00 2.54 ±0.00 -0.2% (noise) —
nii_unique 2.84 ±0.00 2.83 ±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) —

mri_2d — shape (68, 52, 1)

Measurement baseline MiB (median ±½·range) head MiB (median ±½·range) Δ % p
nii_rescale 6.42 ±0.00 6.25 ±0.00 -2.6% —
nii_resample_from_to 6.54 ±0.00 6.38 ±0.00 -2.4% —
nii_rescale_seg 6.19 ±0.00 6.07 ±0.00 -2.0% —
poi_resample_from_to 5.46 ±0.00 5.37 ±0.00 -1.8% —
nii_reorient 5.91 ±0.00 5.81 ±0.00 -1.7% —
… 30 more measurements
Measurement baseline MiB (median ±½·range) head MiB (median ±½·range) Δ % p
nii_load_seg 6.00 ±0.00 5.91 ±0.00 -1.6% —
nii_dilate_msk 5.99 ±0.00 5.93 ±0.00 -1.1% —
nii_erode_msk 6.01 ±0.00 5.94 ±0.00 -1.1% —
nii_load_img 3.56 ±0.00 3.52 ±0.00 -1.1% —
nii_set_dtype 5.37 ±0.00 5.31 ±0.00 -1.1% —
nii_pad_to 5.43 ±0.00 5.37 ±0.00 -1.1% —
nii_extract_label 5.43 ±0.00 5.37 ±0.00 -1.1% —
nii_map_labels 5.49 ±0.00 5.43 ±0.00 -1.1% —
nii_apply_crop 5.86 ±0.00 5.80 ±0.00 -1.0% —
poi_calc_centroids_nocrop 6.05 ±0.00 5.99 ±0.00 -1.0% —
poi_calc_centroids 6.12 ±0.00 6.06 ±0.00 -1.0% —
nii_fill_holes 6.34 ±0.00 6.29 ±0.00 -0.9% —
nii_get_connected_components 6.62 ±0.00 6.56 ±0.00 -0.9% —
nii_filter_connected_components 6.93 ±0.00 6.87 ±0.00 -0.8% —
nii_center_of_masses 3.15 ±0.00 3.14 ±0.00 -0.1% —
nii_volumes 3.46 ±0.00 3.46 ±0.00 -0.1% —
nii_save 3.77 ±0.00 3.77 ±0.00 -0.1% —
poi_reorient 3.83 ±0.00 3.83 ±0.00 -0.1% —
poi_to_global 3.08 ±0.00 3.08 ±0.00 +0.0% —
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_save 1.46 ±0.00 1.46 ±0.00 -0.3% (noise) —
poi_load 1.55 ±0.00 1.55 ±0.00 -0.3% (noise) —
nii_get_array 1.59 ±0.00 1.58 ±0.00 -0.2% (noise) —
poi_map_labels 1.71 ±0.00 1.71 ±0.00 -0.2% (noise) —
poi_rescale 2.54 ±0.00 2.54 ±0.00 -0.2% (noise) —
nii_unique 2.84 ±0.00 2.83 ±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) —

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>
@Hendrik-code
Hendrik-code self-requested a review October 19, 2026 11:56
@Hendrik-code
Hendrik-code merged commit 079aaf0 into main Oct 19, 2026
8 of 9 checks passed
@Hendrik-code
Hendrik-code deleted the dicom-extract-cleanup branch October 19, 2026 11:56
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.

2 participants