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.
@@ -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