radiobject 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- radiobject/__init__.py +24 -0
- radiobject/_types.py +19 -0
- radiobject/ctx.py +359 -0
- radiobject/dataframe.py +186 -0
- radiobject/imaging_metadata.py +387 -0
- radiobject/indexing.py +45 -0
- radiobject/ingest.py +132 -0
- radiobject/ml/__init__.py +26 -0
- radiobject/ml/cache.py +53 -0
- radiobject/ml/compat/__init__.py +33 -0
- radiobject/ml/compat/torchio.py +99 -0
- radiobject/ml/config.py +42 -0
- radiobject/ml/datasets/__init__.py +12 -0
- radiobject/ml/datasets/collection_dataset.py +198 -0
- radiobject/ml/datasets/multimodal.py +129 -0
- radiobject/ml/datasets/patch_dataset.py +158 -0
- radiobject/ml/datasets/segmentation_dataset.py +219 -0
- radiobject/ml/datasets/volume_dataset.py +233 -0
- radiobject/ml/distributed.py +82 -0
- radiobject/ml/factory.py +249 -0
- radiobject/ml/utils/__init__.py +13 -0
- radiobject/ml/utils/labels.py +106 -0
- radiobject/ml/utils/validation.py +85 -0
- radiobject/ml/utils/worker_init.py +10 -0
- radiobject/orientation.py +270 -0
- radiobject/parallel.py +65 -0
- radiobject/py.typed +0 -0
- radiobject/query.py +788 -0
- radiobject/radi_object.py +1665 -0
- radiobject/streaming.py +389 -0
- radiobject/utils.py +17 -0
- radiobject/volume.py +438 -0
- radiobject/volume_collection.py +1182 -0
- radiobject-0.1.0.dist-info/METADATA +139 -0
- radiobject-0.1.0.dist-info/RECORD +37 -0
- radiobject-0.1.0.dist-info/WHEEL +4 -0
- radiobject-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
"""Orientation detection and reorientation for radiology volumes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Literal
|
|
8
|
+
|
|
9
|
+
import nibabel as nib
|
|
10
|
+
import numpy as np
|
|
11
|
+
from pydantic import BaseModel, Field
|
|
12
|
+
|
|
13
|
+
from radiobject.utils import affine_to_list
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class OrientationInfo(BaseModel):
|
|
17
|
+
"""Anatomical orientation information for a volume."""
|
|
18
|
+
|
|
19
|
+
axcodes: tuple[str, str, str] = Field(
|
|
20
|
+
description="Axis codes indicating anatomical direction, e.g., ('R', 'A', 'S')"
|
|
21
|
+
)
|
|
22
|
+
affine: list[list[float]] = Field(description="4x4 affine transformation matrix")
|
|
23
|
+
is_canonical: bool = Field(description="True if orientation is RAS canonical")
|
|
24
|
+
confidence: Literal["header", "inferred", "unknown"] = Field(
|
|
25
|
+
description="Source confidence: header-based, ML-inferred, or unknown"
|
|
26
|
+
)
|
|
27
|
+
source: Literal["nifti_sform", "nifti_qform", "dicom_iop", "identity"] = Field(
|
|
28
|
+
description="Where the orientation was derived from"
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
model_config = {"frozen": True}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _is_identity_affine(affine: np.ndarray, tol: float = 1e-6) -> bool:
|
|
35
|
+
"""Check if affine is effectively an identity matrix."""
|
|
36
|
+
return np.allclose(affine, np.eye(4), atol=tol)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _is_degenerate_affine(affine: np.ndarray) -> bool:
|
|
40
|
+
"""Check if affine has degenerate determinant (not invertible or extreme scaling)."""
|
|
41
|
+
det = np.linalg.det(affine[:3, :3])
|
|
42
|
+
return not (1e-6 < abs(det) < 1e6)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _are_axes_orthogonal(affine: np.ndarray, tol: float = 1e-4) -> bool:
|
|
46
|
+
"""Check if rotation/scaling axes are orthogonal within tolerance."""
|
|
47
|
+
rotation_matrix = affine[:3, :3]
|
|
48
|
+
gram = rotation_matrix.T @ rotation_matrix
|
|
49
|
+
# For orthogonal axes, off-diagonal elements should be near zero
|
|
50
|
+
off_diag = gram - np.diag(np.diag(gram))
|
|
51
|
+
return np.allclose(off_diag, 0, atol=tol * np.max(np.abs(np.diag(gram))))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def is_orientation_valid(info: OrientationInfo) -> bool:
|
|
55
|
+
"""Check if orientation information represents a valid, usable transform."""
|
|
56
|
+
affine = np.array(info.affine)
|
|
57
|
+
|
|
58
|
+
if _is_identity_affine(affine):
|
|
59
|
+
return False
|
|
60
|
+
|
|
61
|
+
if _is_degenerate_affine(affine):
|
|
62
|
+
return False
|
|
63
|
+
|
|
64
|
+
if not _are_axes_orthogonal(affine):
|
|
65
|
+
return False
|
|
66
|
+
|
|
67
|
+
return True
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def detect_nifti_orientation(img: nib.Nifti1Image) -> OrientationInfo:
|
|
71
|
+
"""Extract orientation information from a NIfTI image header."""
|
|
72
|
+
header = img.header
|
|
73
|
+
|
|
74
|
+
# Determine affine source: sform has priority if valid
|
|
75
|
+
sform_code = int(header.get("sform_code", 0))
|
|
76
|
+
qform_code = int(header.get("qform_code", 0))
|
|
77
|
+
|
|
78
|
+
if sform_code > 0:
|
|
79
|
+
affine = img.get_sform()
|
|
80
|
+
source: Literal["nifti_sform", "nifti_qform", "dicom_iop", "identity"] = "nifti_sform"
|
|
81
|
+
elif qform_code > 0:
|
|
82
|
+
affine = img.get_qform()
|
|
83
|
+
source = "nifti_qform"
|
|
84
|
+
else:
|
|
85
|
+
affine = img.affine
|
|
86
|
+
source = "identity"
|
|
87
|
+
|
|
88
|
+
# Check if affine is effectively identity (common default when missing)
|
|
89
|
+
if _is_identity_affine(affine):
|
|
90
|
+
source = "identity"
|
|
91
|
+
|
|
92
|
+
# Get axis codes using nibabel's orientation utilities
|
|
93
|
+
ornt = nib.orientations.io_orientation(affine)
|
|
94
|
+
axcodes = tuple(nib.orientations.ornt2axcodes(ornt))
|
|
95
|
+
|
|
96
|
+
# Check if already RAS canonical
|
|
97
|
+
is_canonical = axcodes == ("R", "A", "S")
|
|
98
|
+
|
|
99
|
+
# Confidence based on source (identity means unreliable header)
|
|
100
|
+
confidence: Literal["header", "inferred", "unknown"] = (
|
|
101
|
+
"header" if source != "identity" else "unknown"
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
return OrientationInfo(
|
|
105
|
+
axcodes=axcodes,
|
|
106
|
+
affine=affine_to_list(affine),
|
|
107
|
+
is_canonical=is_canonical,
|
|
108
|
+
confidence=confidence,
|
|
109
|
+
source=source,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def detect_dicom_orientation(series_path: Path) -> OrientationInfo:
|
|
114
|
+
"""Extract orientation from a DICOM series using Image Orientation Patient tag."""
|
|
115
|
+
import pydicom
|
|
116
|
+
|
|
117
|
+
# Find all DICOM files in the directory
|
|
118
|
+
dicom_files = sorted(series_path.glob("*.dcm"))
|
|
119
|
+
if not dicom_files:
|
|
120
|
+
# Try without extension filtering
|
|
121
|
+
dicom_files = sorted(
|
|
122
|
+
f for f in series_path.iterdir() if f.is_file() and not f.name.startswith(".")
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
if not dicom_files:
|
|
126
|
+
return OrientationInfo(
|
|
127
|
+
axcodes=("R", "A", "S"),
|
|
128
|
+
affine=affine_to_list(np.eye(4)),
|
|
129
|
+
is_canonical=True,
|
|
130
|
+
confidence="unknown",
|
|
131
|
+
source="identity",
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
# Read first DICOM for orientation info
|
|
135
|
+
ds = pydicom.dcmread(dicom_files[0])
|
|
136
|
+
|
|
137
|
+
# Get Image Orientation Patient (direction cosines)
|
|
138
|
+
iop = getattr(ds, "ImageOrientationPatient", None)
|
|
139
|
+
ipp = getattr(ds, "ImagePositionPatient", None)
|
|
140
|
+
pixel_spacing = getattr(ds, "PixelSpacing", [1.0, 1.0])
|
|
141
|
+
slice_thickness = getattr(ds, "SliceThickness", 1.0)
|
|
142
|
+
|
|
143
|
+
if iop is None:
|
|
144
|
+
return OrientationInfo(
|
|
145
|
+
axcodes=("R", "A", "S"),
|
|
146
|
+
affine=affine_to_list(np.eye(4)),
|
|
147
|
+
is_canonical=True,
|
|
148
|
+
confidence="unknown",
|
|
149
|
+
source="identity",
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
# Build affine from DICOM tags
|
|
153
|
+
row_cosines = np.array([float(iop[0]), float(iop[1]), float(iop[2])])
|
|
154
|
+
col_cosines = np.array([float(iop[3]), float(iop[4]), float(iop[5])])
|
|
155
|
+
|
|
156
|
+
# Slice direction is cross product of row and column
|
|
157
|
+
slice_cosines = np.cross(row_cosines, col_cosines)
|
|
158
|
+
|
|
159
|
+
# Build rotation matrix
|
|
160
|
+
voxel_spacing = [float(pixel_spacing[1]), float(pixel_spacing[0]), float(slice_thickness)]
|
|
161
|
+
|
|
162
|
+
affine = np.eye(4)
|
|
163
|
+
affine[:3, 0] = row_cosines * voxel_spacing[0]
|
|
164
|
+
affine[:3, 1] = col_cosines * voxel_spacing[1]
|
|
165
|
+
affine[:3, 2] = slice_cosines * voxel_spacing[2]
|
|
166
|
+
|
|
167
|
+
if ipp is not None:
|
|
168
|
+
affine[:3, 3] = [float(ipp[0]), float(ipp[1]), float(ipp[2])]
|
|
169
|
+
|
|
170
|
+
# Convert DICOM LPS to nibabel-style for consistent axis code computation
|
|
171
|
+
# DICOM uses LPS, nibabel uses RAS
|
|
172
|
+
lps_to_ras = np.diag([-1, -1, 1, 1])
|
|
173
|
+
affine_ras = lps_to_ras @ affine
|
|
174
|
+
|
|
175
|
+
# Get axis codes
|
|
176
|
+
ornt = nib.orientations.io_orientation(affine_ras)
|
|
177
|
+
axcodes = tuple(nib.orientations.ornt2axcodes(ornt))
|
|
178
|
+
|
|
179
|
+
is_canonical = axcodes == ("R", "A", "S")
|
|
180
|
+
|
|
181
|
+
return OrientationInfo(
|
|
182
|
+
axcodes=axcodes,
|
|
183
|
+
affine=affine_to_list(affine_ras),
|
|
184
|
+
is_canonical=is_canonical,
|
|
185
|
+
confidence="header",
|
|
186
|
+
source="dicom_iop",
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def reorient_to_canonical(
|
|
191
|
+
data: np.ndarray,
|
|
192
|
+
affine: np.ndarray,
|
|
193
|
+
target: Literal["RAS", "LAS", "LPS"] = "RAS",
|
|
194
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
195
|
+
"""Reorient volume data to canonical orientation.
|
|
196
|
+
|
|
197
|
+
Uses nibabel's as_closest_canonical() internally for RAS, with axis flipping
|
|
198
|
+
for other target orientations.
|
|
199
|
+
"""
|
|
200
|
+
# Create temporary NIfTI image to use nibabel's reorientation
|
|
201
|
+
img = nib.Nifti1Image(data, affine)
|
|
202
|
+
|
|
203
|
+
if target == "RAS":
|
|
204
|
+
canonical = nib.as_closest_canonical(img)
|
|
205
|
+
return np.asarray(canonical.dataobj), canonical.affine
|
|
206
|
+
|
|
207
|
+
# For other targets, first go to RAS then flip axes
|
|
208
|
+
canonical = nib.as_closest_canonical(img)
|
|
209
|
+
reoriented_data = np.asarray(canonical.dataobj)
|
|
210
|
+
reoriented_affine = canonical.affine.copy()
|
|
211
|
+
|
|
212
|
+
if target == "LAS":
|
|
213
|
+
# Flip X axis (R->L)
|
|
214
|
+
nx = reoriented_data.shape[0]
|
|
215
|
+
reoriented_data = np.flip(reoriented_data, axis=0)
|
|
216
|
+
# Adjust origin before negating direction vector
|
|
217
|
+
reoriented_affine[:3, 3] += (nx - 1) * reoriented_affine[:3, 0]
|
|
218
|
+
# Negate column 0 (X axis direction vector)
|
|
219
|
+
reoriented_affine[:3, 0] = -reoriented_affine[:3, 0]
|
|
220
|
+
elif target == "LPS":
|
|
221
|
+
# Flip X and Y axes (R->L, A->P)
|
|
222
|
+
nx, ny = reoriented_data.shape[0], reoriented_data.shape[1]
|
|
223
|
+
reoriented_data = np.flip(np.flip(reoriented_data, axis=0), axis=1)
|
|
224
|
+
# Adjust origin for both axes
|
|
225
|
+
reoriented_affine[:3, 3] += (nx - 1) * reoriented_affine[:3, 0]
|
|
226
|
+
reoriented_affine[:3, 3] += (ny - 1) * reoriented_affine[:3, 1]
|
|
227
|
+
# Negate columns 0 and 1 (X and Y axis direction vectors)
|
|
228
|
+
reoriented_affine[:3, 0] = -reoriented_affine[:3, 0]
|
|
229
|
+
reoriented_affine[:3, 1] = -reoriented_affine[:3, 1]
|
|
230
|
+
|
|
231
|
+
return reoriented_data, reoriented_affine
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def orientation_info_to_metadata(
|
|
235
|
+
info: OrientationInfo, original_affine: np.ndarray | None = None
|
|
236
|
+
) -> dict[str, str]:
|
|
237
|
+
"""Convert OrientationInfo to TileDB metadata key-value pairs."""
|
|
238
|
+
metadata = {
|
|
239
|
+
"orientation_axcodes": "".join(info.axcodes),
|
|
240
|
+
"orientation_affine": json.dumps(info.affine),
|
|
241
|
+
"orientation_source": info.source,
|
|
242
|
+
"orientation_confidence": info.confidence,
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if original_affine is not None:
|
|
246
|
+
metadata["original_affine"] = json.dumps(affine_to_list(original_affine))
|
|
247
|
+
|
|
248
|
+
return metadata
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def metadata_to_orientation_info(metadata: dict) -> OrientationInfo | None:
|
|
252
|
+
"""Reconstruct OrientationInfo from TileDB metadata."""
|
|
253
|
+
axcodes_str = metadata.get("orientation_axcodes")
|
|
254
|
+
affine_json = metadata.get("orientation_affine")
|
|
255
|
+
|
|
256
|
+
if not axcodes_str or not affine_json:
|
|
257
|
+
return None
|
|
258
|
+
|
|
259
|
+
axcodes = tuple(axcodes_str)
|
|
260
|
+
affine = json.loads(affine_json)
|
|
261
|
+
source = metadata.get("orientation_source", "identity")
|
|
262
|
+
confidence = metadata.get("orientation_confidence", "unknown")
|
|
263
|
+
|
|
264
|
+
return OrientationInfo(
|
|
265
|
+
axcodes=axcodes,
|
|
266
|
+
affine=affine,
|
|
267
|
+
is_canonical=axcodes == ("R", "A", "S"),
|
|
268
|
+
confidence=confidence,
|
|
269
|
+
source=source,
|
|
270
|
+
)
|
radiobject/parallel.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Parallel execution utilities for RadiObject I/O operations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable, Iterable
|
|
6
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import TypeVar
|
|
9
|
+
|
|
10
|
+
import tiledb
|
|
11
|
+
|
|
12
|
+
from radiobject.ctx import get_config
|
|
13
|
+
|
|
14
|
+
T = TypeVar("T")
|
|
15
|
+
R = TypeVar("R")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class WriteResult:
|
|
20
|
+
"""Result of a parallel volume write operation."""
|
|
21
|
+
|
|
22
|
+
index: int
|
|
23
|
+
uri: str
|
|
24
|
+
obs_id: str
|
|
25
|
+
success: bool
|
|
26
|
+
error: Exception | None = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def create_worker_ctx(base_ctx: tiledb.Ctx | None = None) -> tiledb.Ctx:
|
|
30
|
+
"""Create a thread-safe TileDB context for worker threads."""
|
|
31
|
+
if base_ctx is not None:
|
|
32
|
+
return tiledb.Ctx(base_ctx.config())
|
|
33
|
+
return get_config().to_tiledb_ctx()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def map_on_threads(
|
|
37
|
+
fn: Callable[[T], R],
|
|
38
|
+
items: Iterable[T],
|
|
39
|
+
max_workers: int | None = None,
|
|
40
|
+
progress: bool = False,
|
|
41
|
+
desc: str | None = None,
|
|
42
|
+
) -> list[R]:
|
|
43
|
+
"""Execute fn on each item using thread pool, preserving order."""
|
|
44
|
+
items_list = list(items)
|
|
45
|
+
if not items_list:
|
|
46
|
+
return []
|
|
47
|
+
|
|
48
|
+
default_workers = get_config().io.max_workers
|
|
49
|
+
workers = max_workers or min(default_workers, len(items_list))
|
|
50
|
+
results: list[R | None] = [None] * len(items_list)
|
|
51
|
+
|
|
52
|
+
with ThreadPoolExecutor(max_workers=workers) as executor:
|
|
53
|
+
futures = {executor.submit(fn, item): idx for idx, item in enumerate(items_list)}
|
|
54
|
+
|
|
55
|
+
completed = as_completed(futures)
|
|
56
|
+
if progress:
|
|
57
|
+
from tqdm.auto import tqdm
|
|
58
|
+
|
|
59
|
+
completed = tqdm(completed, total=len(futures), desc=desc, unit="vol")
|
|
60
|
+
|
|
61
|
+
for future in completed:
|
|
62
|
+
idx = futures[future]
|
|
63
|
+
results[idx] = future.result()
|
|
64
|
+
|
|
65
|
+
return results # type: ignore
|
radiobject/py.typed
ADDED
|
File without changes
|