Skip to content

Commit e9a53b8

Browse files
Merge branch 'main' into feature/vmonitor
2 parents ad2c01c + 4c58110 commit e9a53b8

9 files changed

Lines changed: 418 additions & 33 deletions

File tree

README.rst

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,14 +58,14 @@ Dev mode
5858
Installing Meshroom
5959
-------------------
6060

61-
If you are using ``openlifu.nav.photoscan`` to reconstruct meshes from photo collections, then you will need to set up **Meshroom**.
61+
If you are using ``openlifu.nav.photoscan`` to reconstruct meshes from photo collections, then you will need to set up **Meshroom 2023.3.0**.
6262

6363
Ubuntu
6464
~~~~~~
6565

6666
Download and Extract
6767
^^^^^^^^^^^^^^^^^^^^
68-
1. Download Meshroom for Linux from `<https://alicevision.org/#meshroom>`_.
68+
1. Download Meshroom 2023.3.0 for Linux from `<https://github.com/alicevision/Meshroom/releases/tag/v2023.3.0>`_.
6969
2. Extract the downloaded archive:
7070

7171
.. code:: bash
@@ -98,7 +98,7 @@ Windows
9898
Download and Extract
9999
^^^^^^^^^^^^^^^^^^^^
100100

101-
1. Download Meshroom for Windows from `<https://alicevision.org/#meshroom>`_.
101+
1. Download Meshroom 2023.3.0 for Windows from `<https://github.com/alicevision/Meshroom/releases/tag/v2023.3.0>`_.
102102
2. Extract the downloaded archive to a directory of your choice.
103103

104104
Add Meshroom to PATH

pyproject.toml

Lines changed: 2 additions & 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]
@@ -250,6 +251,7 @@ messages_control.disable = [
250251
"duplicate-code",
251252
"cyclic-import",
252253
"unreachable", # This is to allow marking of TODO items with a NotImplementedError
254+
"c-extension-no-member",
253255
]
254256

255257
[tool.setuptools.package-data]

src/openlifu/db/database.py

Lines changed: 58 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,22 @@
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
1315

1416
from openlifu.nav.photoscan import Photoscan, load_data_from_photoscan
1517
from openlifu.plan import Protocol, Run, Solution
1618
from openlifu.util.json import PYFUSEncoder
1719
from openlifu.util.types import PathLike
20+
from openlifu.util.volume_conversion import (
21+
convert_dicom_to_nifti,
22+
is_dicom_file_or_directory,
23+
)
1824
from openlifu.xdc import Transducer, TransducerArray
1925
from openlifu.xdc.util import load_transducer_from_file
2026

@@ -24,6 +30,7 @@
2430

2531
OnConflictOpts = Enum('OnConflictOpts', ['ERROR', 'OVERWRITE', 'SKIP'])
2632

33+
2734
class Database:
2835
def __init__(self, path: str | None = None):
2936
if path is None:
@@ -378,35 +385,57 @@ def write_volume(self, subject_id, volume_id, volume_name, volume_data_filepath,
378385
if not Path(volume_data_filepath).exists():
379386
raise ValueError(f'Volume data filepath does not exist: {volume_data_filepath}')
380387

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.")
388+
path = Path(volume_data_filepath)
389+
if path.is_dir() and not is_dicom_file_or_directory(volume_data_filepath):
390+
raise ValueError(f'Volume data filepath is a directory without DICOM files: {volume_data_filepath}')
391+
392+
# convert dicom to nifti if needed
393+
temp_nifti_path = None
394+
if is_dicom_file_or_directory(volume_data_filepath):
395+
self.logger.info(f"Detected DICOM input for volume {volume_id}, converting to NIfTI format")
396+
with tempfile.NamedTemporaryFile(suffix='.nii.gz', delete=False) as temp_file:
397+
temp_nifti_path = Path(temp_file.name)
398+
399+
try:
400+
convert_dicom_to_nifti(volume_data_filepath, temp_nifti_path)
401+
volume_data_filepath = temp_nifti_path
402+
except Exception as e:
403+
if temp_nifti_path.exists():
404+
temp_nifti_path.unlink()
405+
raise RuntimeError(f"Failed to convert DICOM to NIfTI: {e}") from e
406+
407+
try:
408+
volume_ids = self.get_volume_ids(subject_id)
409+
if volume_id in volume_ids:
410+
if on_conflict == OnConflictOpts.ERROR:
411+
raise ValueError(f"Volume with ID {volume_id} already exists for subject {subject_id}.")
412+
elif on_conflict == OnConflictOpts.OVERWRITE:
413+
self.logger.info(f"Overwriting volume with ID {volume_id} for subject {subject_id}.")
414+
elif on_conflict == OnConflictOpts.SKIP:
415+
self.logger.info(f"Skipping volume with ID {volume_id} for subject {subject_id} as it already exists.")
416+
return
417+
else:
418+
raise ValueError("Invalid 'on_conflict' option. Use 'error', 'overwrite', or 'skip'.")
419+
420+
volume_metadata_dict = {"id": volume_id, "name": volume_name, "data_filename": Path(volume_data_filepath).name}
421+
volume_metadata_json = json.dumps(volume_metadata_dict, separators=(',', ':'), cls=PYFUSEncoder)
422+
423+
volume_metadata_filepath = self.get_volume_metadata_filepath(subject_id, volume_id)
424+
Path(volume_metadata_filepath).parent.parent.mkdir(exist_ok=True)
425+
Path(volume_metadata_filepath).parent.mkdir(exist_ok=True)
426+
with open(volume_metadata_filepath, 'w') as file:
427+
file.write(volume_metadata_json)
428+
shutil.copy(Path(volume_data_filepath), Path(volume_metadata_filepath).parent)
429+
430+
if volume_id not in volume_ids:
431+
volume_ids.append(volume_id)
432+
self.write_volume_ids(subject_id, volume_ids)
433+
434+
self.logger.info(f"Added volume with ID {volume_id} for subject {subject_id} to the database.")
435+
finally:
436+
# cleanup temp nifti file
437+
if temp_nifti_path is not None and temp_nifti_path.exists():
438+
temp_nifti_path.unlink()
410439

411440
def write_photocollection(self, subject_id, session_id, reference_number: str, photo_paths: List[PathLike], on_conflict=OnConflictOpts.ERROR):
412441
""" Writes a photocollection to database and copies the associated
@@ -980,7 +1009,6 @@ def load_volume(self, subject, volume_id):
9801009
if not volume_data_filepath.exists() or not volume_data_filepath.is_file():
9811010
self.logger.error(f"Volume data file not found for volume {volume_id}, subject {subject.id}")
9821011
raise FileNotFoundError(f"Volume data file not found for volume {volume_id}, subject {subject.id}")
983-
import nibabel as nib
9841012
# Load the volume data using nibabel
9851013
volume_data = nib.load(volume_data_filepath)
9861014
self.logger.info(f"Loaded volume {volume_id} for subject {subject.id}")
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
"""Utilities for converting between different medical imaging formats."""
2+
3+
from __future__ import annotations
4+
5+
from pathlib import Path
6+
7+
import nibabel as nib
8+
import numpy as np
9+
import pydicom
10+
11+
from openlifu.util.types import PathLike
12+
13+
14+
def is_dicom_file_or_directory(path: PathLike) -> bool:
15+
"""
16+
Check if a path is a DICOM file or directory containing DICOM files.
17+
18+
Args:
19+
path: Path to check
20+
21+
Returns:
22+
True if path is a DICOM file or directory with DICOM files, False otherwise
23+
"""
24+
path = Path(path)
25+
26+
if path.is_file():
27+
# check for 'DICM' magic bytes at offset 128
28+
try:
29+
with open(path, 'rb') as f:
30+
f.seek(128)
31+
return f.read(4) == b'DICM'
32+
except OSError:
33+
return False
34+
35+
elif path.is_dir():
36+
for file in path.iterdir():
37+
if file.is_file():
38+
try:
39+
with open(file, 'rb') as f:
40+
f.seek(128)
41+
if f.read(4) == b'DICM':
42+
return True
43+
except OSError:
44+
continue
45+
46+
return False
47+
48+
49+
def extract_affine_from_dicom(
50+
dicom_slices: list[tuple[int, np.ndarray, pydicom.Dataset]]
51+
) -> np.ndarray:
52+
"""
53+
Extract the affine transformation matrix from DICOM header information.
54+
Converts from DICOM LPS (Left-Posterior-Superior) to NIfTI RAS.
55+
56+
Args:
57+
dicom_slices: List of tuples (instance_number, pixel_array, header) where
58+
header is a pydicom.Dataset containing DICOM metadata tags
59+
60+
Returns:
61+
4x4 affine transformation matrix mapping voxel coordinates to RAS world coordinates
62+
63+
Raises:
64+
RuntimeError: If required DICOM tags are missing
65+
"""
66+
# use the first slice to extract most parameters
67+
first_header = dicom_slices[0][2]
68+
69+
try:
70+
# ImageOrientationPatient (0020,0037): direction cosines for row and column
71+
orientation = np.array(first_header.ImageOrientationPatient, dtype=float)
72+
row_cosine = orientation[:3] # direction cosines for rows
73+
col_cosine = orientation[3:] # direction cosines for columns
74+
75+
# ImagePositionPatient (0020,0032): position of the upper-left voxel
76+
position = np.array(first_header.ImagePositionPatient, dtype=float)
77+
78+
# PixelSpacing is [row_spacing, col_spacing], so map to dy, dx
79+
dy, dx = np.array(first_header.PixelSpacing, dtype=float)
80+
81+
except AttributeError as e:
82+
raise RuntimeError(
83+
f"Missing required DICOM tag for affine calculation: {e}"
84+
) from e
85+
86+
# Compute Z direction and spacing (handling potential gantry tilt)
87+
slice_cosine = np.cross(row_cosine, col_cosine)
88+
89+
# calculate slice spacing
90+
if len(dicom_slices) > 1:
91+
# calculate from the distance between first two slices
92+
first_pos = np.array(dicom_slices[0][2].ImagePositionPatient, dtype=float)
93+
second_pos = np.array(dicom_slices[1][2].ImagePositionPatient, dtype=float)
94+
dz = np.dot(second_pos - first_pos, slice_cosine)
95+
else:
96+
# single slice - try to get from SliceThickness or default to 1.0
97+
dz = float(getattr(first_header, 'SliceThickness', 1.0))
98+
99+
# Construct affine in DICOM LPS space
100+
affine = np.eye(4)
101+
affine[:3, 0] = row_cosine * dx
102+
affine[:3, 1] = col_cosine * dy
103+
affine[:3, 2] = slice_cosine * dz
104+
affine[:3, 3] = position
105+
106+
# Convert LPS to RAS by flipping X and Y axes
107+
return np.diag([-1, -1, 1, 1]) @ affine
108+
109+
110+
def convert_dicom_to_nifti(input_path: PathLike, output_filepath: PathLike) -> None:
111+
"""
112+
Convert DICOM file(s) to NIfTI format using pydicom and nibabel.
113+
114+
Args:
115+
input_path: Path to either a DICOM file or directory containing DICOM files
116+
output_filepath: Path where the output NIfTI file should be saved
117+
118+
Raises:
119+
RuntimeError: If the conversion fails
120+
"""
121+
input_path = Path(input_path)
122+
output_filepath = Path(output_filepath)
123+
124+
try:
125+
if input_path.is_file():
126+
dicom_files = [input_path]
127+
else:
128+
# dicom files may not have .dcm extension
129+
dicom_files = [f for f in input_path.iterdir() if f.is_file()]
130+
131+
if not dicom_files:
132+
raise RuntimeError("No DICOM files found")
133+
134+
slices = []
135+
for dcm_file in dicom_files:
136+
try:
137+
header = pydicom.dcmread(dcm_file)
138+
# Transpose to swap (Row, Col) -> (X, Y) for NIfTI
139+
slices.append((header.get('InstanceNumber', 0), header.pixel_array.T, header))
140+
except Exception:
141+
# skip files that aren't valid dicom
142+
continue
143+
144+
if not slices:
145+
raise RuntimeError("No valid DICOM files found")
146+
147+
# sort by instance number - this is the slice order in the series
148+
# so we reconstruct the 3D volume in the right order
149+
slices.sort(key=lambda x: x[0])
150+
151+
# stack into 3D volume (handles both single and multiple slices)
152+
volume = np.stack([s[1] for s in slices], axis=-1)
153+
154+
# extract affine from DICOM headers
155+
affine = extract_affine_from_dicom(slices)
156+
157+
nib.save(nib.Nifti1Image(volume, affine), str(output_filepath))
158+
159+
except Exception as e:
160+
raise RuntimeError(f"DICOM to NIfTI conversion failed: {e}") from e

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.

0 commit comments

Comments
 (0)