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
radiobject/volume.py
ADDED
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
"""Volume - a single 3D or 4D radiology acquisition backed by TileDB."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import builtins
|
|
6
|
+
from functools import cached_property
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import nibabel as nib
|
|
10
|
+
import numpy as np
|
|
11
|
+
import tiledb
|
|
12
|
+
|
|
13
|
+
from radiobject.ctx import SliceOrientation, get_config
|
|
14
|
+
from radiobject.ctx import ctx as global_ctx
|
|
15
|
+
from radiobject.orientation import (
|
|
16
|
+
OrientationInfo,
|
|
17
|
+
detect_dicom_orientation,
|
|
18
|
+
detect_nifti_orientation,
|
|
19
|
+
metadata_to_orientation_info,
|
|
20
|
+
orientation_info_to_metadata,
|
|
21
|
+
reorient_to_canonical,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
VOXELS_ATTR = "voxels" # TileDB attribute name for dense volume arrays
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Volume:
|
|
28
|
+
"""Single 3D/4D radiology acquisition indexed by obs_id in VolumeCollection."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, uri: str, ctx: tiledb.Ctx | None = None):
|
|
31
|
+
self.uri: str = uri
|
|
32
|
+
self._ctx: tiledb.Ctx = ctx # None means use global
|
|
33
|
+
self._shape: tuple[int, ...] | None = None # Could be 3 or 4 dimensional
|
|
34
|
+
|
|
35
|
+
def _effective_ctx(self) -> tiledb.Ctx:
|
|
36
|
+
return self._ctx if self._ctx else global_ctx()
|
|
37
|
+
|
|
38
|
+
@cached_property
|
|
39
|
+
def _schema(self) -> tiledb.ArraySchema:
|
|
40
|
+
"""Cached TileDB array schema."""
|
|
41
|
+
return tiledb.ArraySchema.load(self.uri, ctx=self._effective_ctx())
|
|
42
|
+
|
|
43
|
+
@cached_property
|
|
44
|
+
def _metadata(self) -> dict:
|
|
45
|
+
"""Cached TileDB array metadata - single read for all metadata properties."""
|
|
46
|
+
with tiledb.open(self.uri, "r", ctx=self._effective_ctx()) as arr:
|
|
47
|
+
return dict(arr.meta)
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def shape(self) -> tuple[int, ...]:
|
|
51
|
+
"""Volume dimensions."""
|
|
52
|
+
if self._shape is None:
|
|
53
|
+
self._shape = tuple(
|
|
54
|
+
int(self._schema.domain.dim(i).domain[1] + 1)
|
|
55
|
+
for i in range(self._schema.domain.ndim)
|
|
56
|
+
)
|
|
57
|
+
return self._shape
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def ndim(self) -> int:
|
|
61
|
+
return len(self.shape)
|
|
62
|
+
|
|
63
|
+
def __repr__(self) -> str:
|
|
64
|
+
"""Concise representation of the Volume."""
|
|
65
|
+
shape_str = "x".join(str(d) for d in self.shape)
|
|
66
|
+
obs_id_str = f", obs_id='{self.obs_id}'" if self.obs_id else ""
|
|
67
|
+
return f"Volume(shape={shape_str}, dtype={self.dtype}{obs_id_str})"
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def dtype(self) -> np.dtype:
|
|
71
|
+
"""Data type of the volume."""
|
|
72
|
+
return self._schema.attr(0).dtype
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def tile_orientation(self) -> SliceOrientation | None:
|
|
76
|
+
"""Tile slicing strategy used at array creation (None if missing/legacy)."""
|
|
77
|
+
try:
|
|
78
|
+
# Support both new and legacy metadata keys
|
|
79
|
+
val = self._metadata.get("tile_orientation") or self._metadata.get("slice_orientation")
|
|
80
|
+
return SliceOrientation(val) if val else None
|
|
81
|
+
except (KeyError, ValueError):
|
|
82
|
+
return None
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def orientation_info(self) -> OrientationInfo | None:
|
|
86
|
+
"""Anatomical orientation information from TileDB metadata."""
|
|
87
|
+
try:
|
|
88
|
+
return metadata_to_orientation_info(self._metadata)
|
|
89
|
+
except Exception:
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def obs_id(self) -> str | None:
|
|
94
|
+
"""Observation identifier stored in array metadata."""
|
|
95
|
+
try:
|
|
96
|
+
return self._metadata.get("obs_id")
|
|
97
|
+
except Exception:
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
def set_obs_id(self, obs_id: str) -> None:
|
|
101
|
+
"""Store observation identifier in array metadata."""
|
|
102
|
+
with tiledb.open(self.uri, "w", ctx=self._effective_ctx()) as arr:
|
|
103
|
+
arr.meta["obs_id"] = obs_id
|
|
104
|
+
|
|
105
|
+
def axial(self, z: int, t: int | None = None) -> np.ndarray:
|
|
106
|
+
"""Get axial slice (X-Y plane at given Z)."""
|
|
107
|
+
return self.slice(
|
|
108
|
+
slice(None), slice(None), slice(z, z + 1), slice(t, t + 1) if t is not None else None
|
|
109
|
+
).squeeze()
|
|
110
|
+
|
|
111
|
+
def sagittal(self, x: int, t: int | None = None) -> np.ndarray:
|
|
112
|
+
"""Get sagittal slice (Y-Z plane at given X)."""
|
|
113
|
+
return self.slice(
|
|
114
|
+
slice(x, x + 1), slice(None), slice(None), slice(t, t + 1) if t is not None else None
|
|
115
|
+
).squeeze()
|
|
116
|
+
|
|
117
|
+
def coronal(self, y: int, t: int | None = None) -> np.ndarray:
|
|
118
|
+
"""Get coronal slice (X-Z plane at given Y)."""
|
|
119
|
+
return self.slice(
|
|
120
|
+
slice(None), slice(y, y + 1), slice(None), slice(t, t + 1) if t is not None else None
|
|
121
|
+
).squeeze()
|
|
122
|
+
|
|
123
|
+
def to_numpy(self) -> np.ndarray:
|
|
124
|
+
"""Read entire volume into memory."""
|
|
125
|
+
with tiledb.open(self.uri, "r", ctx=self._effective_ctx()) as arr:
|
|
126
|
+
return arr[:][VOXELS_ATTR]
|
|
127
|
+
|
|
128
|
+
def slice(
|
|
129
|
+
self,
|
|
130
|
+
x: builtins.slice,
|
|
131
|
+
y: builtins.slice,
|
|
132
|
+
z: builtins.slice,
|
|
133
|
+
t: builtins.slice | None = None,
|
|
134
|
+
) -> np.ndarray:
|
|
135
|
+
"""Partial read using slice objects. Prefer vol[x, y, z] for most cases."""
|
|
136
|
+
with tiledb.open(self.uri, "r", ctx=self._effective_ctx()) as arr:
|
|
137
|
+
if t is not None and self.ndim == 4:
|
|
138
|
+
return arr[x, y, z, t][VOXELS_ATTR]
|
|
139
|
+
return arr[x, y, z][VOXELS_ATTR]
|
|
140
|
+
|
|
141
|
+
def __getitem__(self, key: tuple[builtins.slice, ...] | builtins.slice) -> np.ndarray:
|
|
142
|
+
"""NumPy-like indexing for partial reads: vol[10:20, :, :]."""
|
|
143
|
+
if isinstance(key, slice):
|
|
144
|
+
key = (key,)
|
|
145
|
+
if not isinstance(key, tuple):
|
|
146
|
+
raise TypeError(f"Index must be slice or tuple of slices, got {type(key)}")
|
|
147
|
+
|
|
148
|
+
# Pad with full slices if needed
|
|
149
|
+
key = key + (slice(None),) * (self.ndim - len(key))
|
|
150
|
+
|
|
151
|
+
with tiledb.open(self.uri, "r", ctx=self._effective_ctx()) as arr:
|
|
152
|
+
return arr[key][VOXELS_ATTR]
|
|
153
|
+
|
|
154
|
+
# ===== Analysis Methods =====
|
|
155
|
+
|
|
156
|
+
def get_statistics(self, percentiles: list[float] | None = None) -> dict[str, float]:
|
|
157
|
+
"""Compute mean, std, min, max, median, and optional percentiles."""
|
|
158
|
+
data = self.to_numpy()
|
|
159
|
+
flat = data.ravel() # Single flatten operation
|
|
160
|
+
stats = {
|
|
161
|
+
"mean": float(flat.mean()),
|
|
162
|
+
"std": float(flat.std()),
|
|
163
|
+
"min": float(flat.min()),
|
|
164
|
+
"max": float(flat.max()),
|
|
165
|
+
"median": float(np.median(flat)),
|
|
166
|
+
}
|
|
167
|
+
if percentiles:
|
|
168
|
+
pct_values = np.percentile(flat, percentiles) # Single percentile call
|
|
169
|
+
for p, v in zip(percentiles, pct_values):
|
|
170
|
+
stats[f"p{int(p)}"] = float(v)
|
|
171
|
+
return stats
|
|
172
|
+
|
|
173
|
+
def compute_histogram(
|
|
174
|
+
self, bins: int = 256, value_range: tuple[float, float] | None = None
|
|
175
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
176
|
+
"""Compute intensity histogram. Returns (counts, bin_edges)."""
|
|
177
|
+
data = self.to_numpy().flatten()
|
|
178
|
+
return np.histogram(data, bins=bins, range=value_range)
|
|
179
|
+
|
|
180
|
+
def to_nifti(self, file_path: str | Path, compression: bool = True) -> None:
|
|
181
|
+
"""Export to NIfTI with metadata preservation."""
|
|
182
|
+
file_path = Path(file_path)
|
|
183
|
+
if compression and not str(file_path).endswith(".gz"):
|
|
184
|
+
file_path = Path(str(file_path) + ".gz")
|
|
185
|
+
|
|
186
|
+
data = self.to_numpy()
|
|
187
|
+
meta = self._metadata
|
|
188
|
+
|
|
189
|
+
# Reconstruct affine from stored metadata
|
|
190
|
+
affine = np.eye(4)
|
|
191
|
+
orient_info = self.orientation_info
|
|
192
|
+
if orient_info and orient_info.affine:
|
|
193
|
+
affine = np.array(orient_info.affine)
|
|
194
|
+
|
|
195
|
+
img = nib.Nifti1Image(data, affine)
|
|
196
|
+
header = img.header
|
|
197
|
+
|
|
198
|
+
# Restore NIfTI header fields if stored
|
|
199
|
+
if "nifti_sform_code" in meta:
|
|
200
|
+
header.set_sform(affine, code=int(meta["nifti_sform_code"]))
|
|
201
|
+
if "nifti_qform_code" in meta:
|
|
202
|
+
header.set_qform(affine, code=int(meta["nifti_qform_code"]))
|
|
203
|
+
if "nifti_scl_slope" in meta:
|
|
204
|
+
header["scl_slope"] = float(meta["nifti_scl_slope"])
|
|
205
|
+
if "nifti_scl_inter" in meta:
|
|
206
|
+
header["scl_inter"] = float(meta["nifti_scl_inter"])
|
|
207
|
+
if "nifti_xyzt_units" in meta:
|
|
208
|
+
header["xyzt_units"] = int(meta["nifti_xyzt_units"])
|
|
209
|
+
|
|
210
|
+
nib.save(img, file_path)
|
|
211
|
+
|
|
212
|
+
@classmethod
|
|
213
|
+
def create(
|
|
214
|
+
cls,
|
|
215
|
+
uri: str,
|
|
216
|
+
shape: tuple[int, ...],
|
|
217
|
+
dtype: np.dtype = np.float32,
|
|
218
|
+
ctx: tiledb.Ctx | None = None,
|
|
219
|
+
) -> Volume:
|
|
220
|
+
"""Create an empty Volume with explicit schema."""
|
|
221
|
+
if len(shape) not in (3, 4):
|
|
222
|
+
raise ValueError(f"Shape must be 3D or 4D, got {len(shape)}D")
|
|
223
|
+
|
|
224
|
+
effective_ctx = ctx if ctx else global_ctx()
|
|
225
|
+
config = get_config()
|
|
226
|
+
|
|
227
|
+
# Build dimensions with orientation-aware tiling
|
|
228
|
+
dim_names = ["x", "y", "z", "t"][: len(shape)]
|
|
229
|
+
tile_extents = config.tile.extents_for_shape(shape)
|
|
230
|
+
|
|
231
|
+
dims = [
|
|
232
|
+
tiledb.Dim(
|
|
233
|
+
name=dim_names[i],
|
|
234
|
+
domain=(0, shape[i] - 1),
|
|
235
|
+
tile=min(tile_extents[i], shape[i]),
|
|
236
|
+
dtype=np.int32,
|
|
237
|
+
ctx=effective_ctx,
|
|
238
|
+
)
|
|
239
|
+
for i in range(len(shape))
|
|
240
|
+
]
|
|
241
|
+
domain = tiledb.Domain(*dims, ctx=effective_ctx)
|
|
242
|
+
|
|
243
|
+
# Build attribute with compression
|
|
244
|
+
filters = tiledb.FilterList()
|
|
245
|
+
compression_filter = config.compression.as_filter()
|
|
246
|
+
if compression_filter:
|
|
247
|
+
filters.append(compression_filter)
|
|
248
|
+
|
|
249
|
+
attr = tiledb.Attr(
|
|
250
|
+
name=VOXELS_ATTR,
|
|
251
|
+
dtype=dtype,
|
|
252
|
+
filters=filters,
|
|
253
|
+
ctx=effective_ctx,
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
schema = tiledb.ArraySchema(
|
|
257
|
+
domain=domain,
|
|
258
|
+
attrs=[attr],
|
|
259
|
+
sparse=False,
|
|
260
|
+
ctx=effective_ctx,
|
|
261
|
+
)
|
|
262
|
+
tiledb.Array.create(uri, schema, ctx=effective_ctx)
|
|
263
|
+
|
|
264
|
+
# Persist tile orientation metadata
|
|
265
|
+
with tiledb.open(uri, mode="w", ctx=effective_ctx) as arr:
|
|
266
|
+
arr.meta["tile_orientation"] = config.tile.orientation.value
|
|
267
|
+
|
|
268
|
+
return cls(uri, ctx=ctx)
|
|
269
|
+
|
|
270
|
+
@classmethod
|
|
271
|
+
def from_numpy(
|
|
272
|
+
cls,
|
|
273
|
+
uri: str,
|
|
274
|
+
data: np.ndarray,
|
|
275
|
+
ctx: tiledb.Ctx | None = None,
|
|
276
|
+
) -> Volume:
|
|
277
|
+
"""Create Volume from numpy array."""
|
|
278
|
+
vol = cls.create(uri, shape=data.shape, dtype=data.dtype, ctx=ctx)
|
|
279
|
+
effective_ctx = ctx if ctx else global_ctx()
|
|
280
|
+
with tiledb.open(uri, mode="w", ctx=effective_ctx) as arr:
|
|
281
|
+
arr[:] = data
|
|
282
|
+
return vol
|
|
283
|
+
|
|
284
|
+
@classmethod
|
|
285
|
+
def from_nifti(
|
|
286
|
+
cls,
|
|
287
|
+
uri: str,
|
|
288
|
+
nifti_path: str | Path,
|
|
289
|
+
ctx: tiledb.Ctx | None = None,
|
|
290
|
+
reorient: bool | None = None,
|
|
291
|
+
) -> Volume:
|
|
292
|
+
"""Create a new Volume from a NIfTI file.
|
|
293
|
+
|
|
294
|
+
Args:
|
|
295
|
+
uri: TileDB array URI
|
|
296
|
+
nifti_path: Path to NIfTI file
|
|
297
|
+
ctx: TileDB context (uses global if None)
|
|
298
|
+
reorient: Reorient to canonical orientation. None uses config default.
|
|
299
|
+
"""
|
|
300
|
+
config = get_config()
|
|
301
|
+
should_reorient = reorient if reorient is not None else config.orientation.reorient_on_load
|
|
302
|
+
|
|
303
|
+
img = nib.load(nifti_path)
|
|
304
|
+
data = np.asarray(img.dataobj)
|
|
305
|
+
original_affine = img.affine.copy()
|
|
306
|
+
|
|
307
|
+
# Detect orientation from header
|
|
308
|
+
orientation_info = detect_nifti_orientation(img)
|
|
309
|
+
|
|
310
|
+
# Reorient if requested
|
|
311
|
+
if should_reorient and not orientation_info.is_canonical:
|
|
312
|
+
data, new_affine = reorient_to_canonical(
|
|
313
|
+
data, original_affine, target=config.orientation.canonical_target
|
|
314
|
+
)
|
|
315
|
+
# Update orientation info for reoriented data
|
|
316
|
+
reoriented_img = nib.Nifti1Image(data, new_affine)
|
|
317
|
+
orientation_info = detect_nifti_orientation(reoriented_img)
|
|
318
|
+
|
|
319
|
+
# Create volume
|
|
320
|
+
vol = cls.from_numpy(uri, data, ctx=ctx)
|
|
321
|
+
|
|
322
|
+
# Store orientation metadata
|
|
323
|
+
effective_ctx = ctx if ctx else global_ctx()
|
|
324
|
+
metadata = orientation_info_to_metadata(
|
|
325
|
+
orientation_info,
|
|
326
|
+
original_affine=(
|
|
327
|
+
original_affine
|
|
328
|
+
if should_reorient and config.orientation.store_original_affine
|
|
329
|
+
else None
|
|
330
|
+
),
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
# Store NIfTI header fields for roundtrip fidelity
|
|
334
|
+
header = img.header
|
|
335
|
+
metadata["nifti_sform_code"] = str(int(header.get("sform_code", 0)))
|
|
336
|
+
metadata["nifti_qform_code"] = str(int(header.get("qform_code", 0)))
|
|
337
|
+
|
|
338
|
+
# Handle scl_slope/scl_inter - nibabel returns nan for unset values
|
|
339
|
+
scl_slope = header.get("scl_slope", 1.0)
|
|
340
|
+
scl_inter = header.get("scl_inter", 0.0)
|
|
341
|
+
metadata["nifti_scl_slope"] = str(1.0 if np.isnan(scl_slope) else float(scl_slope))
|
|
342
|
+
metadata["nifti_scl_inter"] = str(0.0 if np.isnan(scl_inter) else float(scl_inter))
|
|
343
|
+
|
|
344
|
+
metadata["nifti_xyzt_units"] = str(int(header.get("xyzt_units", 0)))
|
|
345
|
+
|
|
346
|
+
with tiledb.open(uri, mode="w", ctx=effective_ctx) as arr:
|
|
347
|
+
for key, value in metadata.items():
|
|
348
|
+
arr.meta[key] = value
|
|
349
|
+
|
|
350
|
+
return vol
|
|
351
|
+
|
|
352
|
+
@classmethod
|
|
353
|
+
def from_dicom(
|
|
354
|
+
cls,
|
|
355
|
+
uri: str,
|
|
356
|
+
dicom_dir: str | Path,
|
|
357
|
+
ctx: tiledb.Ctx | None = None,
|
|
358
|
+
reorient: bool | None = None,
|
|
359
|
+
dtype: np.dtype | type | None = None,
|
|
360
|
+
) -> Volume:
|
|
361
|
+
"""Create a new Volume from a DICOM series directory.
|
|
362
|
+
|
|
363
|
+
Args:
|
|
364
|
+
uri: TileDB array URI
|
|
365
|
+
dicom_dir: Path to directory containing DICOM files
|
|
366
|
+
ctx: TileDB context (uses global if None)
|
|
367
|
+
reorient: Reorient to canonical orientation. None uses config default.
|
|
368
|
+
dtype: Output dtype. None preserves original DICOM dtype (uint16/int16).
|
|
369
|
+
Use np.float32 for backward-compatible behavior.
|
|
370
|
+
"""
|
|
371
|
+
import pydicom
|
|
372
|
+
|
|
373
|
+
config = get_config()
|
|
374
|
+
should_reorient = reorient if reorient is not None else config.orientation.reorient_on_load
|
|
375
|
+
|
|
376
|
+
dicom_path = Path(dicom_dir)
|
|
377
|
+
|
|
378
|
+
# Find DICOM files
|
|
379
|
+
dicom_files = sorted(dicom_path.glob("*.dcm"))
|
|
380
|
+
if not dicom_files:
|
|
381
|
+
dicom_files = sorted(
|
|
382
|
+
f for f in dicom_path.iterdir() if f.is_file() and not f.name.startswith(".")
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
if not dicom_files:
|
|
386
|
+
raise ValueError(f"No DICOM files found in {dicom_dir}")
|
|
387
|
+
|
|
388
|
+
# Read and sort slices by position
|
|
389
|
+
slices = []
|
|
390
|
+
for dcm_file in dicom_files:
|
|
391
|
+
ds = pydicom.dcmread(dcm_file)
|
|
392
|
+
slices.append(ds)
|
|
393
|
+
|
|
394
|
+
# Sort by Instance Number or Image Position Patient
|
|
395
|
+
if hasattr(slices[0], "InstanceNumber"):
|
|
396
|
+
slices.sort(key=lambda x: int(x.InstanceNumber))
|
|
397
|
+
elif hasattr(slices[0], "ImagePositionPatient"):
|
|
398
|
+
slices.sort(key=lambda x: float(x.ImagePositionPatient[2]))
|
|
399
|
+
|
|
400
|
+
# Stack pixel arrays into 3D volume
|
|
401
|
+
pixel_arrays = [s.pixel_array for s in slices]
|
|
402
|
+
data = np.stack(pixel_arrays, axis=-1) # Stack along Z
|
|
403
|
+
|
|
404
|
+
# Transpose to (X, Y, Z) from (row, col, slice)
|
|
405
|
+
data = np.transpose(data, (1, 0, 2))
|
|
406
|
+
|
|
407
|
+
# Detect orientation
|
|
408
|
+
orientation_info = detect_dicom_orientation(dicom_path)
|
|
409
|
+
original_affine = np.array(orientation_info.affine)
|
|
410
|
+
|
|
411
|
+
# Reorient if requested
|
|
412
|
+
if should_reorient and not orientation_info.is_canonical:
|
|
413
|
+
data, new_affine = reorient_to_canonical(
|
|
414
|
+
data, original_affine, target=config.orientation.canonical_target
|
|
415
|
+
)
|
|
416
|
+
# Update orientation info
|
|
417
|
+
reoriented_img = nib.Nifti1Image(data, new_affine)
|
|
418
|
+
orientation_info = detect_nifti_orientation(reoriented_img)
|
|
419
|
+
|
|
420
|
+
# Create volume with requested dtype (preserve original if None)
|
|
421
|
+
output_data = data if dtype is None else data.astype(dtype)
|
|
422
|
+
vol = cls.from_numpy(uri, output_data, ctx=ctx)
|
|
423
|
+
|
|
424
|
+
# Store orientation metadata
|
|
425
|
+
effective_ctx = ctx if ctx else global_ctx()
|
|
426
|
+
metadata = orientation_info_to_metadata(
|
|
427
|
+
orientation_info,
|
|
428
|
+
original_affine=(
|
|
429
|
+
original_affine
|
|
430
|
+
if should_reorient and config.orientation.store_original_affine
|
|
431
|
+
else None
|
|
432
|
+
),
|
|
433
|
+
)
|
|
434
|
+
with tiledb.open(uri, mode="w", ctx=effective_ctx) as arr:
|
|
435
|
+
for key, value in metadata.items():
|
|
436
|
+
arr.meta[key] = value
|
|
437
|
+
|
|
438
|
+
return vol
|