Skip to content

Commit 0f9279e

Browse files
committed
Add DICOM to NIfTI conversion support (#396)
- Add pydicom dependency to handle DICOM input files - Implement is_dicom_file_or_directory() to detect DICOM files/directories - Implement convert_dicom_to_nifti() for format conversion - Update write_volume() to automatically convert DICOM to NIfTI - Add comprehensive tests and testing data - A DICOM series containing 2 slices (`tests/resources/dicom_series/`) - A DICOM file containing a CT (`tests/resources/CT_small.dcm`)
1 parent a6954dc commit 0f9279e

6 files changed

Lines changed: 238 additions & 30 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ dependencies = [
5151
"trimesh",
5252
"embreex; platform_machine=='x86_64' or platform_machine=='AMD64'",
5353
"onnxruntime==1.18.0",
54+
"pydicom",
5455
]
5556

5657
[project.optional-dependencies]

src/openlifu/db/database.py

Lines changed: 152 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,15 @@
55
import logging
66
import os
77
import shutil
8+
import tempfile
89
from enum import Enum
910
from pathlib import Path
1011
from typing import Dict, List
1112

1213
import h5py
14+
import nibabel as nib
15+
import numpy as np
16+
import pydicom
1317

1418
from openlifu.nav.photoscan import Photoscan, load_data_from_photoscan
1519
from openlifu.plan import Protocol, Run, Solution
@@ -24,6 +28,100 @@
2428

2529
OnConflictOpts = Enum('OnConflictOpts', ['ERROR', 'OVERWRITE', 'SKIP'])
2630

31+
32+
def is_dicom_file_or_directory(path: PathLike) -> bool:
33+
"""
34+
Check if a path is a DICOM file or directory containing DICOM files.
35+
36+
Args:
37+
path: Path to check
38+
39+
Returns:
40+
True if path is a DICOM file or directory with DICOM files, False otherwise
41+
"""
42+
path = Path(path)
43+
44+
if path.is_file():
45+
# check for 'DICM' magic bytes at offset 128
46+
try:
47+
with open(path, 'rb') as f:
48+
f.seek(128)
49+
return f.read(4) == b'DICM'
50+
except OSError:
51+
return False
52+
53+
elif path.is_dir():
54+
for file in path.iterdir():
55+
if file.is_file():
56+
try:
57+
with open(file, 'rb') as f:
58+
f.seek(128)
59+
if f.read(4) == b'DICM':
60+
return True
61+
except OSError:
62+
continue
63+
64+
return False
65+
66+
67+
def convert_dicom_to_nifti(input_path: PathLike, output_filepath: PathLike) -> None:
68+
"""
69+
Convert DICOM file(s) to NIfTI format using pydicom and nibabel.
70+
71+
Args:
72+
input_path: Path to either a DICOM file or directory containing DICOM files
73+
output_filepath: Path where the output NIfTI file should be saved
74+
75+
Raises:
76+
RuntimeError: If the conversion fails
77+
"""
78+
input_path = Path(input_path)
79+
output_filepath = Path(output_filepath)
80+
81+
try:
82+
if input_path.is_file():
83+
dicom_files = [input_path]
84+
else:
85+
# dicom files may not have .dcm extension
86+
dicom_files = [f for f in input_path.iterdir() if f.is_file()]
87+
88+
if not dicom_files:
89+
raise RuntimeError("No DICOM files found")
90+
91+
slices = []
92+
for dcm_file in dicom_files:
93+
try:
94+
ds = pydicom.dcmread(dcm_file)
95+
slices.append((ds.get('InstanceNumber', 0), ds.pixel_array))
96+
except Exception:
97+
# skip files that aren't valid dicom
98+
continue
99+
100+
if not slices:
101+
raise RuntimeError("No valid DICOM files found")
102+
103+
# sort by instance number - this is the slice order in the series
104+
# so we reconstruct the 3D volume in the right order
105+
slices.sort(key=lambda x: x[0])
106+
107+
# stack into 3D volume
108+
if len(slices) == 1:
109+
# single slice needs extra dimension
110+
volume = slices[0][1][np.newaxis, :, :] if slices[0][1].ndim == 2 else slices[0][1]
111+
else:
112+
# multiple slices stacked along last axis
113+
volume = np.stack([s[1] for s in slices], axis=-1)
114+
115+
# identity affine for now - could extract from dicom headers in the future
116+
affine = np.eye(4)
117+
118+
nifti_img = nib.Nifti1Image(volume, affine)
119+
nib.save(nifti_img, str(output_filepath))
120+
121+
except Exception as e:
122+
raise RuntimeError(f"DICOM to NIfTI conversion failed: {e}") from e
123+
124+
27125
class Database:
28126
def __init__(self, path: str | None = None):
29127
if path is None:
@@ -378,35 +476,60 @@ def write_volume(self, subject_id, volume_id, volume_name, volume_data_filepath,
378476
if not Path(volume_data_filepath).exists():
379477
raise ValueError(f'Volume data filepath does not exist: {volume_data_filepath}')
380478

381-
volume_ids = self.get_volume_ids(subject_id)
382-
if volume_id in volume_ids:
383-
if on_conflict == OnConflictOpts.ERROR:
384-
raise ValueError(f"Volume with ID {volume_id} already exists for subject {subject_id}.")
385-
elif on_conflict == OnConflictOpts.OVERWRITE:
386-
self.logger.info(f"Overwriting volume with ID {volume_id} for subject {subject_id}.")
387-
elif on_conflict == OnConflictOpts.SKIP:
388-
self.logger.info(f"Skipping volume with ID {volume_id} for subject {subject_id} as it already exists.")
389-
return
390-
else:
391-
raise ValueError("Invalid 'on_conflict' option. Use 'error', 'overwrite', or 'skip'.")
392-
393-
# Create volume metadata
394-
volume_metadata_dict = {"id": volume_id, "name": volume_name, "data_filename": Path(volume_data_filepath).name}
395-
volume_metadata_json = json.dumps(volume_metadata_dict, separators=(',', ':'), cls=PYFUSEncoder)
396-
397-
# Save the volume metadata to a JSON file and copy volume data file to database
398-
volume_metadata_filepath = self.get_volume_metadata_filepath(subject_id, volume_id) #subject_id/volume/volume_id/volume_id.json
399-
Path(volume_metadata_filepath).parent.parent.mkdir(exist_ok=True) # volume directory
400-
Path(volume_metadata_filepath).parent.mkdir(exist_ok=True)
401-
with open(volume_metadata_filepath, 'w') as file:
402-
file.write(volume_metadata_json)
403-
shutil.copy(Path(volume_data_filepath), Path(volume_metadata_filepath).parent)
404-
405-
if volume_id not in volume_ids:
406-
volume_ids.append(volume_id)
407-
self.write_volume_ids(subject_id, volume_ids)
408-
409-
self.logger.info(f"Added volume with ID {volume_id} for subject {subject_id} to the database.")
479+
path = Path(volume_data_filepath)
480+
if path.is_dir() and not is_dicom_file_or_directory(volume_data_filepath):
481+
raise ValueError(f'Volume data filepath is a directory without DICOM files: {volume_data_filepath}')
482+
483+
# convert dicom to nifti if needed
484+
temp_nifti_file = None
485+
if is_dicom_file_or_directory(volume_data_filepath):
486+
self.logger.info(f"Detected DICOM input for volume {volume_id}, converting to NIfTI format")
487+
temp_nifti_file = tempfile.NamedTemporaryFile(suffix='.nii.gz', delete=False)
488+
temp_nifti_path = Path(temp_nifti_file.name)
489+
temp_nifti_file.close()
490+
491+
try:
492+
convert_dicom_to_nifti(volume_data_filepath, temp_nifti_path)
493+
volume_data_filepath = temp_nifti_path
494+
except Exception as e:
495+
if temp_nifti_path.exists():
496+
temp_nifti_path.unlink()
497+
raise RuntimeError(f"Failed to convert DICOM to NIfTI: {e}") from e
498+
499+
try:
500+
volume_ids = self.get_volume_ids(subject_id)
501+
if volume_id in volume_ids:
502+
if on_conflict == OnConflictOpts.ERROR:
503+
raise ValueError(f"Volume with ID {volume_id} already exists for subject {subject_id}.")
504+
elif on_conflict == OnConflictOpts.OVERWRITE:
505+
self.logger.info(f"Overwriting volume with ID {volume_id} for subject {subject_id}.")
506+
elif on_conflict == OnConflictOpts.SKIP:
507+
self.logger.info(f"Skipping volume with ID {volume_id} for subject {subject_id} as it already exists.")
508+
return
509+
else:
510+
raise ValueError("Invalid 'on_conflict' option. Use 'error', 'overwrite', or 'skip'.")
511+
512+
volume_metadata_dict = {"id": volume_id, "name": volume_name, "data_filename": Path(volume_data_filepath).name}
513+
volume_metadata_json = json.dumps(volume_metadata_dict, separators=(',', ':'), cls=PYFUSEncoder)
514+
515+
volume_metadata_filepath = self.get_volume_metadata_filepath(subject_id, volume_id)
516+
Path(volume_metadata_filepath).parent.parent.mkdir(exist_ok=True)
517+
Path(volume_metadata_filepath).parent.mkdir(exist_ok=True)
518+
with open(volume_metadata_filepath, 'w') as file:
519+
file.write(volume_metadata_json)
520+
shutil.copy(Path(volume_data_filepath), Path(volume_metadata_filepath).parent)
521+
522+
if volume_id not in volume_ids:
523+
volume_ids.append(volume_id)
524+
self.write_volume_ids(subject_id, volume_ids)
525+
526+
self.logger.info(f"Added volume with ID {volume_id} for subject {subject_id} to the database.")
527+
finally:
528+
# cleanup temp nifti file
529+
if temp_nifti_file is not None:
530+
temp_path = Path(temp_nifti_file.name)
531+
if temp_path.exists():
532+
temp_path.unlink()
410533

411534
def write_photocollection(self, subject_id, session_id, reference_number: str, photo_paths: List[PathLike], on_conflict=OnConflictOpts.ERROR):
412535
""" Writes a photocollection to database and copies the associated

tests/resources/CT_small.dcm

38.3 KB
Binary file not shown.

tests/resources/dicom_series/6293

3.83 KB
Binary file not shown.

tests/resources/dicom_series/6924

3.82 KB
Binary file not shown.

tests/test_database.py

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515

1616
from openlifu import Solution
1717
from openlifu.db import Session, Subject, User
18-
from openlifu.db.database import Database, OnConflictOpts
18+
from openlifu.db.database import Database, OnConflictOpts, is_dicom_file_or_directory
1919
from openlifu.db.session import TransducerTrackingResult
2020
from openlifu.geo import ArrayTransform, Point
2121
from openlifu.nav.photoscan import Photoscan
@@ -743,3 +743,87 @@ def test_get_transducer_absolute_filepaths(example_database, tmp_path: Path, reg
743743
assert reconstructed_path.name == transducer_body.name
744744
else:
745745
assert absolute_file_paths["transducer_body_abspath"] is None
746+
747+
def test_is_dicom_file_or_directory(tmp_path: Path):
748+
test_dicom_file = Path(__file__).parent / "resources" / "CT_small.dcm"
749+
assert test_dicom_file.exists(), "CT_small.dcm test file should exist"
750+
assert is_dicom_file_or_directory(test_dicom_file)
751+
752+
non_dicom_file = tmp_path / "test.nii"
753+
non_dicom_file.write_bytes(b'not a dicom file')
754+
assert not is_dicom_file_or_directory(non_dicom_file)
755+
756+
dicom_dir = tmp_path / "dicom_series"
757+
dicom_dir.mkdir()
758+
shutil.copy(test_dicom_file, dicom_dir / "CT_small.dcm")
759+
assert is_dicom_file_or_directory(dicom_dir)
760+
761+
non_dicom_dir = tmp_path / "non_dicom"
762+
non_dicom_dir.mkdir()
763+
(non_dicom_dir / "file.txt").write_text("not dicom")
764+
assert not is_dicom_file_or_directory(non_dicom_dir)
765+
766+
assert not is_dicom_file_or_directory(tmp_path / "nonexistent.dcm")
767+
768+
def test_write_volume_dicom(example_database: Database):
769+
"""Test writing a volume from DICOM file - conversion to NIfTI and storage"""
770+
subject_id = "example_subject"
771+
volume_id = "test_dicom_volume"
772+
volume_name = "TEST_DICOM_VOLUME"
773+
774+
test_dicom_file = Path(__file__).parent / "resources" / "CT_small.dcm"
775+
assert test_dicom_file.exists(), "CT_small.dcm test file should exist"
776+
777+
example_database.write_volume(subject_id, volume_id, volume_name, test_dicom_file)
778+
779+
volume_ids = example_database.get_volume_ids(subject_id)
780+
assert volume_id in volume_ids
781+
782+
volume_metadata_filepath = example_database.get_volume_metadata_filepath(subject_id, volume_id)
783+
assert volume_metadata_filepath.exists()
784+
assert volume_metadata_filepath.name == f"{volume_id}.json"
785+
786+
# verify stored as nifti not dicom
787+
volume_info = example_database.get_volume_info(subject_id, volume_id)
788+
stored_file = Path(volume_info["data_abspath"])
789+
assert stored_file.exists()
790+
assert stored_file.suffix in [".nii", ".gz"] # .nii.gz suffix
791+
assert not is_dicom_file_or_directory(stored_file)
792+
793+
def test_write_volume_dicom_directory(example_database: Database):
794+
"""Test writing a volume from DICOM directory (multi-slice series) - conversion to NIfTI and storage"""
795+
subject_id = "example_subject"
796+
volume_id = "test_dicom_series_volume"
797+
volume_name = "TEST_DICOM_SERIES_VOLUME"
798+
799+
test_dicom_dir = Path(__file__).parent / "resources" / "dicom_series"
800+
assert test_dicom_dir.exists(), "dicom_series directory should exist"
801+
assert test_dicom_dir.is_dir()
802+
assert len(list(test_dicom_dir.iterdir())) > 0, "dicom_series should contain files"
803+
804+
example_database.write_volume(subject_id, volume_id, volume_name, test_dicom_dir)
805+
806+
volume_ids = example_database.get_volume_ids(subject_id)
807+
assert volume_id in volume_ids
808+
809+
volume_metadata_filepath = example_database.get_volume_metadata_filepath(subject_id, volume_id)
810+
assert volume_metadata_filepath.exists()
811+
assert volume_metadata_filepath.name == f"{volume_id}.json"
812+
813+
# verify stored as nifti not dicom
814+
volume_info = example_database.get_volume_info(subject_id, volume_id)
815+
stored_file = Path(volume_info["data_abspath"])
816+
assert stored_file.exists()
817+
assert stored_file.suffix in [".nii", ".gz"] # .nii.gz suffix
818+
assert not is_dicom_file_or_directory(stored_file)
819+
820+
def test_write_volume_empty_directory(example_database: Database, tmp_path: Path):
821+
subject_id = "example_subject"
822+
volume_id = "test_empty_dir_volume"
823+
volume_name = "TEST_EMPTY_DIR_VOLUME"
824+
825+
empty_dir = tmp_path / "empty_dir"
826+
empty_dir.mkdir()
827+
828+
with pytest.raises(ValueError, match="directory without DICOM files"):
829+
example_database.write_volume(subject_id, volume_id, volume_name, empty_dir)

0 commit comments

Comments
 (0)