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/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