Repository navigation
DICOM extract: multi-echo plane detection, modality fallback, and 9 smaller fixes - #153
Conversation
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>
Benchmark comparisonSpeed (wall time per call)baseline Showing the 5 most-changed measurements per case; the rest are collapsed. Rows with a baseline below 3 ms are tagged
|
| 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>
`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>
…x into dicom-extract-cleanup
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>
…x into dicom-extract-cleanup
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-existing07aac40 fix multi echo bug). Every commit is reviewable on its own.Correctness fixes
88ab7e8—_extract_nii_from_dicomreturnsFalseon unknown exceptions, deletes the half-written sidecar, and logs. Previously the bareexcept Exceptionfell through toreturn True, so_add_grid_info_to_json/_split_multi_echo_dixonthen ran on a NIfTI that was never written.391a28e—_classic_get_grouped_dicomsno longer producesNaNdirections on multi-echo series. New helper_split_by_echo_numbersruns stack detection on a single-echo subset, then re-attaches remaining echoes byImagePositionPatient.f0c70f4—_split_multi_echo_dixontriggers on any 4-D output (not justpart in ("magnitude", "phase")). Also switched frame/TE iteration tozip_strict.0894e46—_find_all_filesyieldsPathinstances one at a time instead of yielding the rawlist[Path]first, which downstream code cast tostrand broke on.7f9e38b—_inc_keycaps its retry loop at 10 000 iterations and raisesRuntimeErrorwith the offending key instead of spinning forever on a brokenpath_existscallback.a09de6a—get_plane_dicom's real call now uses0.8(matches the docstring default); the bare-except now logs a warning so downstreamacq-Nonefiles are traceable.Perf / usability
4c63431—_read_dicom_filesgates on a_looks_like_dicomcheck (extension blocklist +DICMmagic at offset 128) beforepydicom.dcmread. Skips reports, thumbnails, JSON sidecars, etc. on mixed source trees.7a7ade2— Docs:n_cpubehaviour spelled out (1 sequential / >1 N threads / None →ThreadPoolExecutor-Default ≈min(32, cpu_count + 4)).Features
e1cddbc— Expandedmap_series_description_to_file_format_default:.*t2.*/.*t1.*catch-alls that were misclassifying them.ME_vibe_fatquant_*,mevibe,fatquant,fatfrac,pdff, GEideal).survey,refscan,smartexam).5280eb8— Modality fallback + BIDS format whitelist + view/laterality/bodypart:bids_constants.formatsgrows byxray,us,nm,sc,photo,endoscopy,rtimage,rtstruct,rtdose,rtplan,ot.extract_keys_from_jsonno longer raisesNotImplementedErrorfor 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._apply_view_keysand_apply_bodypart_keyliftViewPosition,Laterality/ImageLaterality, andBodyPartExaminedintoacq/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).acq-ax(notacq-iso) and the classic stack detector no longer over-splits it.🤖 Generated with Claude Code