Skip to content

Commit b67d12b

Browse files
authored
Merge pull request #92 from Hendrik-code/development_robert
Add new functions and test
2 parents 27b76e2 + 346e9db commit b67d12b

46 files changed

Lines changed: 1299 additions & 882 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/python-publish.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ jobs:
1717
- name: Set up Python
1818
uses: actions/setup-python@v4
1919
with:
20-
python-version: '3.10'
20+
python-version: "3.10"
2121
- name: Install dependencies
2222
run: |
2323
python -m pip install --upgrade pip

.github/workflows/tests_mr.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ jobs:
1414
fail-fast: false
1515
matrix:
1616
os: [ubuntu-latest]
17-
python-version: ["3.10"]
17+
python-version: ["3.9","3.12"]
1818

1919
steps:
2020
- uses: actions/checkout@v4

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,6 @@ tutorials/tutorial_data_processing/*
164164
tutorials/*PixelPandemonium/*
165165
tutorials/dataset-PixelPandemonium/*
166166
*.html
167-
_*.py
167+
#_*.py
168168
dicom_select
169169
examples

TPTBox/core/bids_constants.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,15 @@
138138
"recon",
139139
"reformat",
140140
"subtraction",
141+
"DSA",
142+
"DSA3D",
143+
"3DRA",
144+
"XA",
141145
"RI", # Raw input
146+
"tmax",
147+
"cbv",
148+
"mtt",
149+
"cbf",
142150
"stat",
143151
"snp",
144152
"log",
@@ -158,6 +166,88 @@
158166
formats_relaxed = [*formats, "t2", "t1", "t2c", "t1c", "cta", "mr", "snapshot", "t1dixon", "dwi"]
159167
# Recommended writing style: T1c, T2c; This list is not official and can be extended.
160168

169+
modalities = {
170+
"AR": "Autorefraction",
171+
"AS": "Angioscopy (Retired)",
172+
"ASMT": "Content Assessment Results",
173+
"AU": "Audio",
174+
"BDUS": "Bone Densitometry (ultrasound)",
175+
"BI": "Biomagnetic imaging",
176+
"BMD": "Bone Densitometry (X-Ray)",
177+
"CD": "Color flow Doppler (Retired)",
178+
"CF": "Cinefluorography (Retired)",
179+
"CP": "Colposcopy (Retired)",
180+
"CR": "Computed Radiography",
181+
"CS": "Cystoscopy (Retired)",
182+
"CT": "Computed Tomography",
183+
"DD": "Duplex Doppler (Retired)",
184+
"DF": "Digital fluoroscopy (Retired)",
185+
"DG": "Diaphanography",
186+
"DM": "Digital microscopy (Retired)",
187+
"DOC": "Document",
188+
"DS": "Digital Subtraction Angiography (Retired)",
189+
"DX": "Digital Radiography",
190+
"EC": "Echocardiography (Retired)",
191+
"ECG": "Electrocardiography",
192+
"EPS": "Cardiac Electrophysiology",
193+
"ES": "Endoscopy",
194+
"FA": "Fluorescein angiography (Retired)",
195+
"FID": "Fiducials",
196+
"FS": "Fundoscopy (Retired)",
197+
"GM": "General Microscopy ",
198+
"HC": "Hard Copy",
199+
"HD": "Hemodynamic Waveform",
200+
"IO": "Intra-Oral Radiography",
201+
"IOL": "Intraocular Lens Data",
202+
"IVOCT": "Intravascular Optical Coherence Tomography",
203+
"IVUS": "Intravascular Ultrasound",
204+
"KER": "Keratometry",
205+
"KO": "Key Object Selection",
206+
"LEN": "Lensometry",
207+
"LP": "Laparoscopy (Retired)",
208+
"LS": "Laser surface scan",
209+
"MA": "Magnetic resonance angiography (Retired)",
210+
"MG": "Mammography",
211+
"MR": "Magnetic Resonance",
212+
"MS": "Magnetic resonance spectroscopy (Retired)",
213+
"NM": "Nuclear Medicine",
214+
"OAM": "Ophthalmic Axial Measurements",
215+
"OCT": "Optical Coherence Tomography (non-Ophthalmic)",
216+
"OP": "Ophthalmic Photography",
217+
"OPM": "Ophthalmic Mapping",
218+
"OPR": "Ophthalmic Refraction (Retired)",
219+
"OPT": "Ophthalmic Tomography",
220+
"OPV": "Ophthalmic Visual Field",
221+
"OSS": "Optical Surface Scan",
222+
"OT": "Other ",
223+
"PLAN": "Plan",
224+
"PR": "Presentation State",
225+
"PT": "Positron emission tomography (PET)",
226+
"PX": "Panoramic X-Ray",
227+
"REG": "Registration",
228+
"RESP": "Respiratory Waveform",
229+
"RF": "Radio Fluoroscopy",
230+
"RG": "Radiographic imaging (conventional film/screen)",
231+
"RTDOSE": "Radiotherapy Dose",
232+
"RTIMAGE": "Radiotherapy Image",
233+
"RTPLAN": "Radiotherapy Plan",
234+
"RTRECORD": "RT Treatment Record",
235+
"RTSTRUCT": "Radiotherapy Structure Set",
236+
"RWV": "Real World Value Map",
237+
"SEG": "Segmentation",
238+
"SM": "Slide Microscopy",
239+
"SMR": "Stereometric Relationship",
240+
"SR": "SR Document",
241+
"SRF": "Subjective Refraction",
242+
"ST": "Single-photon emission computed tomography (SPECT) (Retired)",
243+
"STAIN": "Automated Slide Stainer",
244+
"TG": "Thermography",
245+
"US": "Ultrasound",
246+
"VA": "Visual Acuity",
247+
"VF": "Videofluorography (Retired)",
248+
"XA": "X-Ray Angiography",
249+
"XC": "External-camera Photography",
250+
}
161251

162252
# Actual official final folder
163253
# func (task based and resting state functional MRI)
@@ -246,6 +336,7 @@
246336
# Others (never used)
247337
"Split": "split",
248338
"Density": "den",
339+
"version": "version",
249340
"Description": "desc",
250341
"nameconflict": "nameconflict",
251342
}

TPTBox/core/bids_files.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -793,7 +793,8 @@ def get_changed_path( # noqa: C901
793793
if key in same_info:
794794
continue
795795
if value is not None:
796-
assert validate_entities(key, value, f"..._{key}-{value}_...", True)
796+
if not non_strict_mode:
797+
assert validate_entities(key, value, f"..._{key}-{value}_...", True), f"..._{key}-{value}_..."
797798
final_info[key] = value
798799
# file_name += f"{key}-{value}_"
799800
# sort by order

TPTBox/core/dicom/dicom2nii_utils.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import json
44
import pickle
55
from copy import deepcopy
6+
from datetime import date
67
from pathlib import Path
78

89
import numpy as np
@@ -152,9 +153,56 @@ def clean_dicom_data(dcm_data) -> dict:
152153
for tag in ["00291010", "00291020"]:
153154
if tag in py_dict and "InlineBinary" in py_dict[tag]:
154155
del py_dict[tag]["InlineBinary"]
156+
py_dict = replace_birthdate_with_age(py_dict)
155157
return py_dict
156158

157159

160+
def replace_birthdate_with_age(d):
161+
try:
162+
# DICOM tags
163+
BIRTH_TAG = "00100030" # PatientBirthDate
164+
STUDY_DATE_TAG = "00080020" # StudyDate
165+
AGE_TAG = "00101010" # PatientAge
166+
167+
birth_str = d.get(BIRTH_TAG, {}).get("Value", [None])[0]
168+
study_str = d.get(STUDY_DATE_TAG, {}).get("Value", [None])[0]
169+
170+
if not birth_str:
171+
return d # no birth date, nothing to do
172+
173+
# Parse birth date safely
174+
try:
175+
year = int(birth_str[:4])
176+
month = int(birth_str[4:6]) if len(birth_str) >= 6 and birth_str[4:6] != "00" else 6
177+
day = int(birth_str[6:8]) if len(birth_str) == 8 and birth_str[6:8] != "00" else 15
178+
birth_date = date(year, month, day)
179+
except Exception:
180+
return d # invalid date format, skip
181+
182+
# Reference date (study date or today)
183+
try:
184+
ref_date = date(
185+
int(study_str[:4]),
186+
int(study_str[4:6]) if study_str[4:6] != "00" else 6,
187+
int(study_str[6:8]) if study_str[6:8] != "00" else 15,
188+
)
189+
except Exception:
190+
ref_date = date.today()
191+
192+
# Compute integer age
193+
age = ref_date.year - birth_date.year - ((ref_date.month, ref_date.day) < (birth_date.month, birth_date.day))
194+
195+
# Replace PatientBirthDate with PatientAge
196+
d.pop(BIRTH_TAG, None)
197+
d[AGE_TAG] = {
198+
"vr": "AS", # Age String
199+
"Value": [f"{age:03d}Y"], # DICOM age format (e.g. '034Y')
200+
}
201+
except Exception:
202+
pass
203+
return d
204+
205+
158206
def get_json_from_dicom(data: list[pydicom.FileDataset] | pydicom.FileDataset):
159207
if isinstance(data, list):
160208
data = data[0]

TPTBox/core/dicom/dicom_extract.py

Lines changed: 90 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
import dicom2nifti
1313
import dicom2nifti.exceptions
14+
import nibabel as nib
1415
import numpy as np
1516
import pydicom
1617
from dicom2nifti import convert_dicom
@@ -87,6 +88,74 @@ def _generate_bids_path(
8788
return fname.file["json"], fname
8889

8990

91+
def dicom_to_nifti_multiframe(ds, nii_path):
92+
pixel_array = ds.pixel_array
93+
if len(pixel_array.shape) != 3 and len(pixel_array.shape) != 4:
94+
raise ValueError(f"Expected a shape with 3 colums not {len(pixel_array.shape)}; {pixel_array.shape=}")
95+
n_frames = pixel_array.shape[0]
96+
97+
# Pixel spacing (mm)
98+
if hasattr(ds, "PixelSpacing"):
99+
dy, dx = (float(v) for v in ds.PixelSpacing)
100+
# Image orientation (row and column direction cosines)
101+
orientation = [float(v) for v in ds.ImageOrientationPatient]
102+
row_cosines = np.array(orientation[0:3])
103+
col_cosines = np.array(orientation[3:6])
104+
# Normal vector (slice direction)
105+
slice_cosines = np.cross(row_cosines, col_cosines)
106+
107+
# Image position (origin of first slice)
108+
origin = np.array([float(v) for v in ds.ImagePositionPatient])
109+
# Slice spacing - robust: Abstand zwischen Slice 0 und 1
110+
if n_frames > 1:
111+
pos1 = np.array([float(v) for v in ds.PerFrameFunctionalGroupsSequence[0].PlanePositionSequence[0].ImagePositionPatient])
112+
pos2 = np.array([float(v) for v in ds.PerFrameFunctionalGroupsSequence[1].PlanePositionSequence[0].ImagePositionPatient])
113+
dz = np.linalg.norm(pos2 - pos1)
114+
else:
115+
dz = float(getattr(ds, "SpacingBetweenSlices", ds.SliceThickness))
116+
117+
# Affine bauen
118+
affine = np.eye(4)
119+
affine[0:3, 0] = row_cosines * dx
120+
affine[0:3, 1] = col_cosines * dy
121+
affine[0:3, 2] = slice_cosines * dz
122+
affine[0:3, 3] = origin
123+
nii = nib.Nifti1Image(np.transpose(pixel_array, (2, 1, 0)), affine)
124+
125+
elif hasattr(ds, "ImagerPixelSpacing"):
126+
dy, dx = (float(v) for v in ds.ImagerPixelSpacing)
127+
# Einfaches affine (nur 2D + Zeit, keine Lage im Patientenraum)
128+
affine = np.eye(4)
129+
affine[0, 0] = -dx
130+
affine[1, 1] = -dy
131+
nii = nib.Nifti1Image(np.transpose(pixel_array, (2, 1, 0)), affine)
132+
133+
else:
134+
if hasattr(ds, "RelatedSeriesSequence"):
135+
raise NotImplementedError("RelatedSeriesSequence Affine lookup not implemented")
136+
raise NotImplementedError("No spatial metadata found")
137+
### Some could be solved by looking up the "RelatedSeriesSequence"
138+
# "RelatedSeriesSequence": [
139+
# {
140+
# "StudyInstanceUID": "1.2.276.0.38.1.1.1.7712.20250929100319.54200288",
141+
# "SeriesInstanceUID": "1.3.46.670589.7.8.1.6.1403526999.1.9608.1759142950287.2",
142+
# "PurposeOfReferenceCodeSequence": []
143+
# }
144+
# ],
145+
# --- No geometry info (e.g. RGB screen captures or video frames) ---
146+
print("⚠️ No spatial metadata found — assuming pixel size = 1mm and identity orientation.")
147+
affine = np.eye(4)
148+
affine[0, 0] = 1.0
149+
affine[1, 1] = 1.0
150+
affine[2, 2] = 1.0
151+
nii = nib.Nifti1Image(pixel_array, affine)
152+
153+
# Reihenfolge anpassen: Nibabel erwartet (X,Y,Z)
154+
nib.save(nii, nii_path)
155+
156+
return nii_path
157+
158+
90159
def _convert_to_nifti(dicom_out_path, nii_path):
91160
"""
92161
Convert DICOM files to NIfTI format and handle common conversion errors.
@@ -105,15 +174,25 @@ def _convert_to_nifti(dicom_out_path, nii_path):
105174
"""
106175
try:
107176
if isinstance(dicom_out_path, list):
177+
try:
178+
if len(dicom_out_path) == 1:
179+
ds = dicom_out_path[0]
180+
if hasattr(ds, "pixel_array") and len(ds.pixel_array.shape) >= 2:
181+
dicom_to_nifti_multiframe(ds, nii_path)
182+
183+
return True
184+
except Exception as e:
185+
logger.on_debug("Multi-Frame DICOM did not work:", e)
108186
convert_dicom.dicom_array_to_nifti(dicom_out_path, nii_path, True)
109187
else:
110188
# func_timeout(10, dicom2nifti.dicom_series_to_nifti, (dicom_out_path, nii_path, True))
111189
dicom2nifti.dicom_series_to_nifti(dicom_out_path, nii_path, True)
112190
logger.print("Save ", nii_path, Log_Type.SAVE)
113191
except dicom2nifti.exceptions.ConversionValidationError as e:
114192
if e.args[0] in ["NON_IMAGING_DICOM_FILES"]:
193+
s = f"dicom_array_to_nifti len={len(dicom_out_path)}" if isinstance(dicom_out_path, list) else "dicom_series_to_nifti"
115194
Path(str(nii_path).replace(".nii.gz", ".json")).unlink(missing_ok=True)
116-
logger.on_debug(f"Not exportable '{Path(nii_path).name}':", e.args[0])
195+
logger.on_debug(f"Not exportable '{Path(nii_path).name}':", e.args[0], s)
117196
return False
118197
for key, reason in [
119198
("validate_orthogonal", "NON_CUBICAL_IMAGE/GANTRY_TILT"),
@@ -311,7 +390,7 @@ def _read_dicom_files(dicom_out_path: Path) -> tuple[dict[str, list[FileDataset]
311390
path = Path(_paths)
312391
if path.is_file():
313392
try:
314-
dcm_data = pydicom.dcmread(path, defer_size="1 KB", force=True)
393+
dcm_data = pydicom.dcmread(path, defer_size="1 KB", force=True) # , stop_before_pixels=True
315394
try:
316395
typ = (
317396
str(dcm_data.get_item((0x0008, 0x0008)).value)
@@ -324,7 +403,6 @@ def _read_dicom_files(dicom_out_path: Path) -> tuple[dict[str, list[FileDataset]
324403
except Exception:
325404
typ = ""
326405
key1 = str(dcm_data.SeriesInstanceUID)
327-
328406
key = f"{key1}_{typ}"
329407
if not hasattr(dcm_data, "ImageOrientationPatient"):
330408
key += "_" + dcm_data.get("SOPInstanceUID", 0)
@@ -437,6 +515,7 @@ def extract_dicom_folder(
437515
validate_slicecount=True,
438516
validate_orientation=True,
439517
validate_orthogonal=False,
518+
validate_slice_increment=True,
440519
n_cpu: int | None = 1,
441520
override_subject_name: Callable[[dict, Path], str] | None = None,
442521
skip_localizer=True,
@@ -463,7 +542,8 @@ def extract_dicom_folder(
463542
convert_dicom.settings.disable_validate_orientation()
464543
if not validate_orthogonal:
465544
convert_dicom.settings.disable_validate_orthogonal()
466-
545+
if not validate_slice_increment:
546+
convert_dicom.settings.disable_validate_slice_increment()
467547
outs = {}
468548

469549
for p in _find_all_files(dicom_folder):
@@ -512,6 +592,8 @@ def process_series(key, files, parts):
512592
try:
513593
key2, out = process_series(key, files, parts)
514594
outs[key2] = out
595+
except NotImplementedError as e:
596+
logger.on_warning("NotImplementedError:", e)
515597
except Exception:
516598
logger.print_error()
517599

@@ -523,6 +605,10 @@ def process_series(key, files, parts):
523605

524606

525607
if __name__ == "__main__":
608+
for p in Path("/DATA/NAS/datasets_source/brain/dsa").iterdir():
609+
extract_dicom_folder(p, Path("/DATA/NAS/datasets_source/brain/", "dataset-DSA"), False, False, validate_slice_increment=False)
610+
611+
sys.exit()
526612
# s = "/home/robert/Downloads/bein/dataset-oberschenkel/rawdata/sub-1-3-46-670589-11-2889201787-2305829596-303261238-2367429497/mr/sub-1-3-46-670589-11-2889201787-2305829596-303261238-2367429497_sequ-406_mr.nii.gz"
527613
# nii2 = NII.load(s, False)
528614
# print(nii2.affine, nii2.orientation)

0 commit comments

Comments
 (0)