|
9 | 9 | SNAP_ATOL, |
10 | 10 | SIMPLEITK_AXIS_LABELS, |
11 | 11 | CoordinateContext, |
| 12 | + _is_signed_permutation, |
12 | 13 | affine_to_rotation, |
13 | 14 | affine_to_shear, |
14 | 15 | canonical_coordinate_context, |
|
18 | 19 | deoblique_affine, |
19 | 20 | decompose_affine, |
20 | 21 | normalize_backend_name, |
| 22 | + parse_coordinate_system, |
| 23 | + snap_values, |
21 | 24 | validate_affine, |
22 | 25 | ) |
23 | 26 | from medvol.registry import get_backend, resolve_backend |
@@ -61,16 +64,11 @@ def __init__( |
61 | 64 | coordinate_system: str | None = None, |
62 | 65 | backend: str | None = None, |
63 | 66 | canonicalize: bool = True, |
64 | | - remove_obliqueness: bool = False, |
65 | 67 | ) -> None: |
66 | | - if remove_obliqueness and not canonicalize: |
67 | | - raise ValueError("remove_obliqueness requires canonicalize=True.") |
68 | | - |
69 | 68 | self._coordinate_context = None |
70 | 69 | self._header = None |
71 | 70 | self._backend = normalize_backend_name(backend) |
72 | 71 | self._canonicalize = canonicalize |
73 | | - self._remove_obliqueness = remove_obliqueness |
74 | 72 |
|
75 | 73 | if isinstance(source, (str, Path)): |
76 | 74 | if coordinate_system is not None: |
@@ -119,8 +117,6 @@ def _apply_orientation_policy(self) -> None: |
119 | 117 | self._coordinate_context, |
120 | 118 | atol=SNAP_ATOL, |
121 | 119 | ) |
122 | | - if self._remove_obliqueness: |
123 | | - self._affine = deoblique_affine(self._affine, atol=SNAP_ATOL) |
124 | 120 |
|
125 | 121 | @staticmethod |
126 | 122 | def _validate_array(array: np.ndarray) -> np.ndarray: |
@@ -228,6 +224,135 @@ def header(self, value: Any) -> None: |
228 | 224 | def backend(self) -> str | None: |
229 | 225 | return self._backend |
230 | 226 |
|
| 227 | + def get_geometry( |
| 228 | + self, |
| 229 | + coordinate_system: str = "RAS+", |
| 230 | + *, |
| 231 | + deoblique: bool = False, |
| 232 | + ) -> dict: |
| 233 | + """Return geometry converted to the requested coordinate system. |
| 234 | +
|
| 235 | + The internal state is not modified — only the returned values are |
| 236 | + converted. Requires ``canonicalize=True`` (raises ``ValueError`` |
| 237 | + otherwise). |
| 238 | +
|
| 239 | + Args: |
| 240 | + coordinate_system: Target coordinate system, e.g. ``"RAS+"``, |
| 241 | + ``"LPS+"``, ``"ASR+"``. Must contain exactly |
| 242 | + ``spatial_ndim`` anatomical letters (R/L, A/P, S/I), each |
| 243 | + from a different anatomical axis, with an optional trailing |
| 244 | + ``"+"``. |
| 245 | + deoblique: If ``True``, strip off-diagonal entries from the |
| 246 | + returned affine (diagonal affine, keeps origin). Equivalent |
| 247 | + to calling ``get_geometry(deoblique=False)`` and then |
| 248 | + removing the oblique component. |
| 249 | +
|
| 250 | + Returns: |
| 251 | + Dict with keys: |
| 252 | +
|
| 253 | + * ``"affine"`` — (ndim+1)×(ndim+1) affine in the target system. |
| 254 | + * ``"spacing"`` — always-positive voxel spacing (column norms). |
| 255 | + * ``"origin"`` — world coordinates of voxel (0, 0, …, 0). |
| 256 | + * ``"direction"`` — unit-column direction cosine matrix. |
| 257 | + * ``"coordinate_system"`` — the *coordinate_system* argument. |
| 258 | + * ``"oblique"`` — ``True`` when the spatial direction block is |
| 259 | + not a signed permutation matrix (i.e. the image is oblique). |
| 260 | +
|
| 261 | + Raises: |
| 262 | + ValueError: If ``canonicalize=False`` or the coordinate context |
| 263 | + is unknown. |
| 264 | + """ |
| 265 | + if not self._canonicalize: |
| 266 | + raise ValueError("get_geometry requires canonicalize=True.") |
| 267 | + if self._coordinate_context is None: |
| 268 | + raise ValueError( |
| 269 | + "get_geometry requires a known coordinate context. " |
| 270 | + "Load from a file with a recognised coordinate system." |
| 271 | + ) |
| 272 | + |
| 273 | + spatial_ndim = self._coordinate_context.anatomical_ndim |
| 274 | + axis_order, flips = parse_coordinate_system(coordinate_system, spatial_ndim) |
| 275 | + signs = [-1 if f else 1 for f in flips] |
| 276 | + |
| 277 | + A = self._affine |
| 278 | + shape = self._array.shape |
| 279 | + |
| 280 | + # ── Step 1: permute and sign spatial columns (data-axis transform) ── |
| 281 | + # Read from original A; write to A_mid so we never clobber a source col. |
| 282 | + A_mid = A.copy() |
| 283 | + for m in range(spatial_ndim): |
| 284 | + A_mid[:, m] = signs[m] * A[:, axis_order[m]] |
| 285 | + # Adjust translation column for flipped axes: |
| 286 | + # flip on axis m maps voxel i → (N-1-i), shifting the origin to the far corner. |
| 287 | + for m in range(spatial_ndim): |
| 288 | + if flips[m]: |
| 289 | + A_mid[:, -1] += A[:, axis_order[m]] * (shape[axis_order[m]] - 1) |
| 290 | + |
| 291 | + # ── Step 2: permute and sign spatial rows (world-basis transform) ── |
| 292 | + A_final = A_mid.copy() |
| 293 | + for m in range(spatial_ndim): |
| 294 | + A_final[m, :] = signs[m] * A_mid[axis_order[m], :] |
| 295 | + |
| 296 | + A_final = snap_values(A_final) |
| 297 | + |
| 298 | + if deoblique: |
| 299 | + A_final = deoblique_affine(A_final) |
| 300 | + |
| 301 | + spacing, origin, direction = decompose_affine(A_final) |
| 302 | + |
| 303 | + # Oblique iff the spatial direction block is not a signed permutation. |
| 304 | + spatial_dir = direction[:spatial_ndim, :spatial_ndim] |
| 305 | + is_oblique = not _is_signed_permutation(spatial_dir) |
| 306 | + |
| 307 | + return { |
| 308 | + "affine": A_final, |
| 309 | + "spacing": spacing, |
| 310 | + "origin": origin, |
| 311 | + "direction": direction, |
| 312 | + "coordinate_system": coordinate_system, |
| 313 | + "oblique": is_oblique, |
| 314 | + } |
| 315 | + |
| 316 | + def get_array(self, coordinate_system: str = "RAS+") -> np.ndarray: |
| 317 | + """Return the array converted to the requested coordinate system. |
| 318 | +
|
| 319 | + Returns a zero-copy NumPy view — no interpolation is performed. |
| 320 | + Only axis permutations and flips are applied. Requires |
| 321 | + ``canonicalize=True`` (raises ``ValueError`` otherwise). |
| 322 | +
|
| 323 | + Args: |
| 324 | + coordinate_system: Target coordinate system string (see |
| 325 | + ``get_geometry`` for the accepted format). |
| 326 | +
|
| 327 | + Returns: |
| 328 | + NumPy array in the requested axis order and orientation. |
| 329 | + Non-spatial axes (e.g. time for 4-D images) are appended |
| 330 | + unchanged at the end. |
| 331 | +
|
| 332 | + Raises: |
| 333 | + ValueError: If ``canonicalize=False`` or the coordinate context |
| 334 | + is unknown. |
| 335 | + """ |
| 336 | + if not self._canonicalize: |
| 337 | + raise ValueError("get_array requires canonicalize=True.") |
| 338 | + if self._coordinate_context is None: |
| 339 | + raise ValueError( |
| 340 | + "get_array requires a known coordinate context." |
| 341 | + ) |
| 342 | + |
| 343 | + spatial_ndim = self._coordinate_context.anatomical_ndim |
| 344 | + axis_order, flips = parse_coordinate_system(coordinate_system, spatial_ndim) |
| 345 | + |
| 346 | + # Non-spatial axes (e.g. time) stay at the end, in their original order. |
| 347 | + full_axis_order = list(axis_order) + list(range(spatial_ndim, self.ndims)) |
| 348 | + result = np.transpose(self._array, full_axis_order) |
| 349 | + |
| 350 | + for m, flip in enumerate(flips): |
| 351 | + if flip: |
| 352 | + result = np.flip(result, axis=m) |
| 353 | + |
| 354 | + return result |
| 355 | + |
231 | 356 | def save(self, filepath: str | Path, *, backend: str | None = None) -> None: |
232 | 357 | resolved_backend = resolve_backend(filepath, backend) |
233 | 358 | get_backend(resolved_backend).save(Path(filepath), self) |
|
0 commit comments