Skip to content

Commit 424dcdc

Browse files
authored
Merge pull request #581 from nipy/enh-custom-seqinfo
Add support for a custom seqinfo to extract from DICOMs any additional metadata desired for a heuristic
2 parents 153d522 + 41b22d7 commit 424dcdc

File tree

9 files changed

+121
-7
lines changed

9 files changed

+121
-7
lines changed

README.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ You can run your conversion automatically (which will produce a ``.heudiconv`` d
7171
.. image:: figs/workflow.png
7272

7373

74-
``heudiconv`` comes with `existing heuristics <https://github.com/nipy/heudiconv/tree/master/heudiconv/heuristics>`_ which can be used as is, or as examples.
74+
``heudiconv`` comes with `existing heuristics <https://github.com/nipy/heudiconv/tree/master/heudiconv/heuristics>`_ which can be used as is, or as examples.
7575
For instance, the Heuristic `convertall <https://github.com/nipy/heudiconv/blob/master/heudiconv/heuristics/convertall.py>`_ extracts standard metadata from all matching DICOMs.
7676
``heudiconv`` creates mapping files, ``<something>.edit.text`` which lets researchers simply establish their own conversion mapping.
7777

docs/heuristics.rst

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,19 @@ or::
119119
...
120120
return seqinfos # ordered dict containing seqinfo objects: list of DICOMs
121121

122+
---------------------------------------------------------------
123+
``custom_seqinfo(wrapper, series_files)``
124+
---------------------------------------------------------------
125+
If present this function will be called on each group of dicoms with
126+
a sample nibabel dicom wrapper to extract additional information
127+
to be used in ``infotodict``.
128+
129+
Importantly the return value of that function needs to be hashable.
130+
For instance the following non-hashable types can be converted to an alternative
131+
hashable type:
132+
- list > tuple
133+
- dict > frozendict
134+
- arrays > bytes (tobytes(), or pickle.dumps), str or tuple of tuples.
122135

123136
-------------------------------
124137
``POPULATE_INTENDED_FOR_OPTS``

heudiconv/convert.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,9 @@ def prep_conversion(
221221
dcmfilter=getattr(heuristic, "filter_dicom", None),
222222
flatten=True,
223223
custom_grouping=getattr(heuristic, "grouping", None),
224+
# callable which will be provided dcminfo and returned
225+
# structure extend seqinfo
226+
custom_seqinfo=getattr(heuristic, "custom_seqinfo", None),
224227
)
225228
elif seqinfo is None:
226229
raise ValueError("Neither 'dicoms' nor 'seqinfo' is given")

heudiconv/dicoms.py

Lines changed: 47 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,18 @@
99
from pathlib import Path
1010
import sys
1111
import tarfile
12-
from typing import TYPE_CHECKING, Any, Dict, List, NamedTuple, Optional, Union, overload
12+
from typing import (
13+
TYPE_CHECKING,
14+
Any,
15+
Dict,
16+
Hashable,
17+
List,
18+
NamedTuple,
19+
Optional,
20+
Protocol,
21+
Union,
22+
overload,
23+
)
1324
from unittest.mock import patch
1425
import warnings
1526

@@ -42,7 +53,17 @@
4253
compresslevel = 9
4354

4455

45-
def create_seqinfo(mw: dw.Wrapper, series_files: list[str], series_id: str) -> SeqInfo:
56+
class CustomSeqinfoT(Protocol):
57+
def __call__(self, wrapper: dw.Wrapper, series_files: list[str]) -> Hashable:
58+
...
59+
60+
61+
def create_seqinfo(
62+
mw: dw.Wrapper,
63+
series_files: list[str],
64+
series_id: str,
65+
custom_seqinfo: CustomSeqinfoT | None = None,
66+
) -> SeqInfo:
4667
"""Generate sequence info
4768
4869
Parameters
@@ -80,6 +101,20 @@ def create_seqinfo(mw: dw.Wrapper, series_files: list[str], series_id: str) -> S
80101
global total_files
81102
total_files += len(series_files)
82103

104+
custom_seqinfo_data = (
105+
custom_seqinfo(wrapper=mw, series_files=series_files)
106+
if custom_seqinfo
107+
else None
108+
)
109+
try:
110+
hash(custom_seqinfo_data)
111+
except TypeError:
112+
raise RuntimeError(
113+
"Data returned by the heuristics custom_seqinfo is not hashable. "
114+
"See https://heudiconv.readthedocs.io/en/latest/heuristics.html#custom_seqinfo for more "
115+
"details."
116+
)
117+
83118
return SeqInfo(
84119
total_files_till_now=total_files,
85120
example_dcm_file=op.basename(series_files[0]),
@@ -109,6 +144,7 @@ def create_seqinfo(mw: dw.Wrapper, series_files: list[str], series_id: str) -> S
109144
date=dcminfo.get("AcquisitionDate"),
110145
series_uid=dcminfo.get("SeriesInstanceUID"),
111146
time=dcminfo.get("AcquisitionTime"),
147+
custom=custom_seqinfo_data,
112148
)
113149

114150

@@ -181,6 +217,7 @@ def group_dicoms_into_seqinfos(
181217
dict[SeqInfo, list[str]],
182218
]
183219
| None = None,
220+
custom_seqinfo: CustomSeqinfoT | None = None,
184221
) -> dict[Optional[str], dict[SeqInfo, list[str]]]:
185222
...
186223

@@ -199,6 +236,7 @@ def group_dicoms_into_seqinfos(
199236
dict[SeqInfo, list[str]],
200237
]
201238
| None = None,
239+
custom_seqinfo: CustomSeqinfoT | None = None,
202240
) -> dict[SeqInfo, list[str]]:
203241
...
204242

@@ -215,6 +253,7 @@ def group_dicoms_into_seqinfos(
215253
dict[SeqInfo, list[str]],
216254
]
217255
| None = None,
256+
custom_seqinfo: CustomSeqinfoT | None = None,
218257
) -> dict[Optional[str], dict[SeqInfo, list[str]]] | dict[SeqInfo, list[str]]:
219258
"""Process list of dicoms and return seqinfo and file group
220259
`seqinfo` contains per-sequence extract of fields from DICOMs which
@@ -236,9 +275,11 @@ def group_dicoms_into_seqinfos(
236275
Creates a flattened `seqinfo` with corresponding DICOM files. True when
237276
invoked with `dicom_dir_template`.
238277
custom_grouping: str or callable, optional
239-
grouping key defined within heuristic. Can be a string of a
240-
DICOM attribute, or a method that handles more complex groupings.
241-
278+
grouping key defined within heuristic. Can be a string of a
279+
DICOM attribute, or a method that handles more complex groupings.
280+
custom_seqinfo: callable, optional
281+
A callable which will be provided MosaicWrapper giving possibility to
282+
extract any custom DICOM metadata of interest.
242283
243284
Returns
244285
-------
@@ -358,7 +399,7 @@ def group_dicoms_into_seqinfos(
358399
else:
359400
# nothing to see here, just move on
360401
continue
361-
seqinfo = create_seqinfo(mw, series_files, series_id_str)
402+
seqinfo = create_seqinfo(mw, series_files, series_id_str, custom_seqinfo)
362403

363404
key: Optional[str]
364405
if per_studyUID:

heudiconv/heuristics/convertall.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
from __future__ import annotations
22

3+
import logging
34
from typing import Optional
45

56
from heudiconv.utils import SeqInfo
67

8+
lgr = logging.getLogger("heudiconv")
9+
710

811
def create_key(
912
template: Optional[str],
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
"""A demo convertall heuristic with custom_seqinfo extracting affine and sample DICOM path
2+
3+
This heuristic also demonstrates on how to create a "derived" heuristic which would augment
4+
behavior of an already existing heuristic without complete rewrite. Such approach could be
5+
useful for heuristic like reproin to overload mapping etc.
6+
"""
7+
from __future__ import annotations
8+
9+
from typing import Any
10+
11+
import nibabel.nicom.dicomwrappers as dw
12+
13+
from .convertall import * # noqa: F403
14+
15+
16+
def custom_seqinfo(
17+
series_files: list[str], wrapper: dw.Wrapper, **kw: Any # noqa: U100
18+
) -> tuple[str | None, str]:
19+
"""Demo for extracting custom header fields into custom_seqinfo field
20+
21+
Operates on already loaded DICOM data.
22+
Origin: https://github.com/nipy/heudiconv/pull/333
23+
"""
24+
25+
from nibabel.nicom.dicomwrappers import WrapperError
26+
27+
try:
28+
affine = str(wrapper.affine)
29+
except WrapperError:
30+
lgr.exception("Errored out while obtaining/converting affine") # noqa: F405
31+
affine = None
32+
return affine, series_files[0]

heudiconv/parser.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,7 @@ def get_study_sessions(
224224
file_filter=getattr(heuristic, "filter_files", None),
225225
dcmfilter=getattr(heuristic, "filter_dicom", None),
226226
custom_grouping=getattr(heuristic, "grouping", None),
227+
custom_seqinfo=getattr(heuristic, "custom_seqinfo", None),
227228
)
228229

229230
if sids:

heudiconv/tests/test_dicoms.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,25 @@ def test_group_dicoms_into_seqinfos() -> None:
9999
]
100100

101101

102+
def test_custom_seqinfo() -> None:
103+
"""Tests for custom seqinfo extraction"""
104+
105+
from heudiconv.heuristics.convertall_custom import custom_seqinfo
106+
107+
dcmfiles = glob(op.join(TESTS_DATA_PATH, "phantom.dcm"))
108+
109+
seqinfos = group_dicoms_into_seqinfos(
110+
dcmfiles, "studyUID", flatten=True, custom_seqinfo=custom_seqinfo
111+
) # type: ignore
112+
113+
seqinfo = list(seqinfos.keys())[0]
114+
115+
assert hasattr(seqinfo, "custom")
116+
assert isinstance(seqinfo.custom, tuple)
117+
assert len(seqinfo.custom) == 2
118+
assert seqinfo.custom[1] == dcmfiles[0]
119+
120+
102121
def test_get_datetime_from_dcm_from_acq_date_time() -> None:
103122
typical_dcm = dcm.dcmread(
104123
op.join(TESTS_DATA_PATH, "phantom.dcm"), stop_before_pixels=True

heudiconv/utils.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from typing import (
2525
Any,
2626
AnyStr,
27+
Hashable,
2728
Mapping,
2829
NamedTuple,
2930
Optional,
@@ -69,6 +70,7 @@ class SeqInfo(NamedTuple):
6970
date: Optional[str] # 24
7071
series_uid: Optional[str] # 25
7172
time: Optional[str] # 26
73+
custom: Optional[Hashable] # 27
7274

7375

7476
class StudySessionInfo(NamedTuple):

0 commit comments

Comments
 (0)