@fideus-labs/fidnii 0.3.0 → 0.5.0

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.
@@ -68,8 +68,14 @@ import {
68
68
  affineToNiftiSrows,
69
69
  calculateWorldBounds,
70
70
  createAffineFromNgffImage,
71
+ createAffineFromOMEZarr,
71
72
  } from "./utils/affine.js"
72
73
  import { worldToPixel } from "./utils/coordinates.js"
74
+ import {
75
+ applyOrientationToAffine,
76
+ getOrientationMapping,
77
+ getOrientationSigns,
78
+ } from "./utils/orientation.js"
73
79
  import {
74
80
  boundsApproxEqual,
75
81
  computeViewportBounds2D,
@@ -301,8 +307,14 @@ export class OMEZarrNVImage extends NVImage {
301
307
  this._is2D = highResImage.dims.indexOf("z") === -1
302
308
  this._flipY2D = options.flipY2D ?? true
303
309
 
304
- // Calculate volume bounds from highest resolution for most accurate bounds
305
- const highResAffine = createAffineFromNgffImage(highResImage)
310
+ // Calculate volume bounds from highest resolution for most accurate bounds.
311
+ // Use the unadjusted affine (no orientation signs) because volume bounds
312
+ // live in OME-Zarr world space and drive internal clip-plane / viewport math.
313
+ // Orientation is only applied to the NIfTI affine passed to NiiVue.
314
+ const highResAffine = createAffineFromOMEZarr(
315
+ highResImage.scale,
316
+ highResImage.translation,
317
+ )
306
318
  const highResShape = getVolumeShape(highResImage)
307
319
  this._volumeBounds = calculateWorldBounds(highResAffine, highResShape)
308
320
 
@@ -400,13 +412,27 @@ export class OMEZarrNVImage extends NVImage {
400
412
  // Placeholder pixel dimensions
401
413
  hdr.pixDims = [1, 1, 1, 1, 0, 0, 0, 0]
402
414
 
403
- // Placeholder affine (identity, with y-flip for 2D images)
404
- hdr.affine = [
405
- [1, 0, 0, 0],
406
- [0, this._flipY2D && this._is2D ? -1 : 1, 0, 0],
407
- [0, 0, 1, 0],
415
+ // Placeholder affine (unit scale with orientation permutation/signs)
416
+ // This is replaced by real data when loadResolutionLevel completes,
417
+ // but having a reasonable placeholder avoids rendering glitches during
418
+ // the initial frame.
419
+ const mapping = getOrientationMapping(
420
+ this.multiscales.images[0]?.axesOrientations,
421
+ )
422
+ const placeholderAffine = [
423
+ [0, 0, 0, 0],
424
+ [0, 0, 0, 0],
425
+ [0, 0, 0, 0],
408
426
  [0, 0, 0, 1],
409
427
  ]
428
+ placeholderAffine[mapping.x.physicalRow][0] = mapping.x.sign
429
+ let ySign = mapping.y.sign
430
+ if (this._flipY2D && this._is2D) {
431
+ ySign = (ySign * -1) as 1 | -1
432
+ }
433
+ placeholderAffine[mapping.y.physicalRow][1] = ySign
434
+ placeholderAffine[mapping.z.physicalRow][2] = mapping.z.sign
435
+ hdr.affine = placeholderAffine
410
436
 
411
437
  hdr.sform_code = 1 // Scanner coordinates
412
438
 
@@ -698,25 +724,22 @@ export class OMEZarrNVImage extends NVImage {
698
724
  1,
699
725
  ]
700
726
 
701
- // Build affine with offset for region start
702
- const affine = createAffineFromNgffImage(ngffImage)
703
-
704
- // Adjust translation for region offset
705
- // Buffer pixel [0,0,0] corresponds to source pixel region.chunkAlignedStart
727
+ // Build the unadjusted affine (no orientation signs) and offset it
728
+ // to the loaded region. Buffer bounds use this OME-Zarr-space affine
729
+ // for internal clip-plane / viewport math.
706
730
  const regionStart = region.chunkAlignedStart
731
+ const affine = createAffineFromOMEZarr(
732
+ ngffImage.scale,
733
+ ngffImage.translation,
734
+ )
707
735
  // regionStart is [z, y, x], affine translation is [x, y, z] (indices 12, 13, 14)
708
736
  affine[12] += regionStart[2] * sx // x offset
709
737
  affine[13] += regionStart[1] * sy // y offset
710
738
  affine[14] += regionStart[0] * sz // z offset
711
739
 
712
- // Update current buffer bounds from the un-flipped affine
713
- // (bounds stay in OME-Zarr world space for clip plane math)
740
+ // Update current buffer bounds in OME-Zarr world space
714
741
  this._currentBufferBounds = {
715
- min: [
716
- affine[12], // x offset (world coord of buffer origin)
717
- affine[13], // y offset
718
- affine[14], // z offset
719
- ],
742
+ min: [affine[12], affine[13], affine[14]],
720
743
  max: [
721
744
  affine[12] + fetchedShape[2] * sx,
722
745
  affine[13] + fetchedShape[1] * sy,
@@ -724,11 +747,21 @@ export class OMEZarrNVImage extends NVImage {
724
747
  ],
725
748
  }
726
749
 
727
- // For 2D images, negate y-scale so NiiVue's calculateRAS() flips
728
- // the rows to account for top-to-bottom pixel storage order.
750
+ // Apply orientation signs so the NIfTI affine encodes anatomical
751
+ // direction for NiiVue's calculateRAS()
752
+ applyOrientationToAffine(affine, ngffImage.axesOrientations)
753
+
754
+ // For 2D images, flip y so NiiVue's calculateRAS() accounts for
755
+ // top-to-bottom pixel storage order. We shift the translation so
756
+ // the last row maps to where the first row was, then negate the
757
+ // y column. This composes correctly with any orientation sign.
729
758
  if (this._flipY2D && this._is2D) {
730
- affine[5] = -sy
731
- affine[13] += (fetchedShape[1] - 1) * sy
759
+ // Get the y axis orientation mapping to find where the y scale is stored
760
+ const mapping = getOrientationMapping(ngffImage.axesOrientations)
761
+ // The y scale is at affine[4 + physicalRow] (column 1, appropriate row)
762
+ const yScaleIndex = 4 + mapping.y.physicalRow
763
+ affine[13] += affine[yScaleIndex] * (fetchedShape[1] - 1)
764
+ affine[yScaleIndex] = -affine[yScaleIndex]
732
765
  }
733
766
 
734
767
  // Update affine in header
@@ -2366,17 +2399,28 @@ export class OMEZarrNVImage extends NVImage {
2366
2399
  1,
2367
2400
  ]
2368
2401
 
2369
- // Build affine with offset for region start, then normalize
2402
+ // Build affine with orientation signs, then offset for region start.
2403
+ // Use the original unoriented scale values with orientation signs for
2404
+ // the translation offset calculation. This ensures correctness even when
2405
+ // axes are permuted (where affine diagonal may be zero).
2370
2406
  const affine = createAffineFromNgffImage(ngffImage)
2407
+
2408
+ // Get orientation signs to apply to the offset calculation
2409
+ const signs = getOrientationSigns(ngffImage.axesOrientations)
2410
+
2371
2411
  // Adjust translation for region offset (fetchStart is [z, y, x])
2372
- affine[12] += fetchStart[2] * sx // x offset
2373
- affine[13] += fetchStart[1] * sy // y offset
2374
- affine[14] += fetchStart[0] * sz // z offset
2412
+ affine[12] += fetchStart[2] * signs.x * sx // x offset (orientation-aware)
2413
+ affine[13] += fetchStart[1] * signs.y * sy // y offset (orientation-aware)
2414
+ affine[14] += fetchStart[0] * signs.z * sz // z offset (orientation-aware)
2375
2415
 
2376
- // For 2D images, negate y-scale before normalization
2416
+ // For 2D images, flip y before normalization (composes with orientation)
2377
2417
  if (this._flipY2D && this._is2D) {
2378
- affine[5] = -sy
2379
- affine[13] += (fetchedShape[1] - 1) * sy
2418
+ // Get the y axis orientation mapping to find where the y scale is stored
2419
+ const mapping = getOrientationMapping(ngffImage.axesOrientations)
2420
+ // The y scale is at affine[4 + physicalRow] (column 1, appropriate row)
2421
+ const yScaleIndex = 4 + mapping.y.physicalRow
2422
+ affine[13] += affine[yScaleIndex] * (fetchedShape[1] - 1)
2423
+ affine[yScaleIndex] = -affine[yScaleIndex]
2380
2424
  }
2381
2425
 
2382
2426
  // Apply normalization to the entire affine (scale columns + translation)
@@ -0,0 +1,116 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) Fideus Labs LLC
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ /**
5
+ * Load TIFF files (OME-TIFF, pyramidal, plain) and produce a
6
+ * {@link Multiscales} object ready for {@link OMEZarrNVImage.create}.
7
+ *
8
+ * Uses {@link https://github.com/fideus-labs/fiff | @fideus-labs/fiff}
9
+ * to present TIFFs as zarrita-compatible stores, then delegates to
10
+ * {@link fromNgffZarr} for OME-Zarr metadata parsing.
11
+ */
12
+
13
+ import type { DeflatePool, TiffStoreOptions } from "@fideus-labs/fiff"
14
+ import { TiffStore } from "@fideus-labs/fiff"
15
+ import type { Multiscales } from "@fideus-labs/ngff-zarr"
16
+ import type { FromNgffZarrOptions } from "@fideus-labs/ngff-zarr/browser"
17
+ import { fromNgffZarr } from "@fideus-labs/ngff-zarr/browser"
18
+ import type { Readable } from "zarrita"
19
+
20
+ /** Options for {@link fromTiff}. */
21
+ export interface FromTiffOptions {
22
+ /** Options forwarded to {@link TiffStore} (e.g. IFD offsets, HTTP headers). */
23
+ tiff?: TiffStoreOptions
24
+ /** Options forwarded to {@link fromNgffZarr} (e.g. validate, cache). */
25
+ ngffZarr?: Omit<FromNgffZarrOptions, "version">
26
+ /**
27
+ * Optional worker pool for offloading deflate decompression to Web
28
+ * Workers when reading compressed TIFFs.
29
+ *
30
+ * When provided, registers a worker-backed deflate decoder with
31
+ * geotiff.js so that all subsequent chunk reads decompress off the
32
+ * main thread.
33
+ *
34
+ * Accepts any object matching the {@link DeflatePool} interface
35
+ * (e.g. `new WorkerPool(n)` from `@fideus-labs/worker-pool`).
36
+ *
37
+ * This is a convenience shorthand — the same pool can also be
38
+ * passed via `tiff.pool`.
39
+ *
40
+ * Note: {@link fromTiff} does not manage the lifecycle of the pool.
41
+ * If you create a pool and pass it here (or via `tiff.pool`), you
42
+ * are responsible for terminating it when it is no longer needed,
43
+ * e.g. by calling `pool.terminateWorkers()` after you are done with
44
+ * the associated {@link TiffStore} and image usage.
45
+ */
46
+ pool?: DeflatePool
47
+ }
48
+
49
+ /**
50
+ * Load a TIFF file and return a {@link Multiscales} object ready for
51
+ * {@link OMEZarrNVImage.create}.
52
+ *
53
+ * Supports OME-TIFF, pyramidal TIFF (SubIFDs / legacy / COG), and
54
+ * plain single-image TIFFs. Both local (Blob/ArrayBuffer) and
55
+ * remote (URL with HTTP range requests) sources are supported.
56
+ *
57
+ * @param source - A URL string, Blob/File, ArrayBuffer, or
58
+ * pre-built {@link TiffStore}.
59
+ * @param options - Optional {@link FromTiffOptions}.
60
+ * @returns A {@link Multiscales} object for use with
61
+ * {@link OMEZarrNVImage.create}.
62
+ * @throws If the source cannot be opened as a TIFF or if the
63
+ * synthesized OME-Zarr metadata is invalid.
64
+ *
65
+ * @example
66
+ * ```typescript
67
+ * // From a remote URL
68
+ * const ms = await fromTiff("https://example.com/image.ome.tif")
69
+ * const image = await OMEZarrNVImage.create({
70
+ * multiscales: ms,
71
+ * niivue: nv,
72
+ * })
73
+ *
74
+ * // From a File input
75
+ * const file = inputElement.files[0]
76
+ * const ms = await fromTiff(file)
77
+ * ```
78
+ */
79
+ export async function fromTiff(
80
+ source: string | Blob | ArrayBuffer | TiffStore,
81
+ options: FromTiffOptions = {},
82
+ ): Promise<Multiscales> {
83
+ // Merge top-level pool into tiff sub-options (top-level takes precedence)
84
+ const tiffOpts: TiffStoreOptions | undefined = options.pool
85
+ ? { ...options.tiff, pool: options.pool }
86
+ : options.tiff
87
+
88
+ let store: TiffStore
89
+ if (source instanceof TiffStore) {
90
+ // Note: When passing a pre-built TiffStore, configure the pool via
91
+ // TiffStore constructor options rather than through FromTiffOptions.pool.
92
+ store = source
93
+ } else if (typeof source === "string") {
94
+ store = await TiffStore.fromUrl(source, tiffOpts)
95
+ } else if (source instanceof Blob) {
96
+ store = await TiffStore.fromBlob(source, tiffOpts)
97
+ } else if (source instanceof ArrayBuffer) {
98
+ store = await TiffStore.fromArrayBuffer(source, tiffOpts)
99
+ } else {
100
+ throw new Error(
101
+ "[fidnii] fromTiff: source must be a URL string, Blob, " +
102
+ "ArrayBuffer, or TiffStore",
103
+ )
104
+ }
105
+
106
+ // TiffStore implements zarrita's Readable interface structurally
107
+ // (its get() method accepts string keys including the leading "/"
108
+ // that zarrita uses for AbsolutePath). Since fromNgffZarr (v0.10+)
109
+ // accepts zarr.Readable directly, we only need a type assertion.
110
+ //
111
+ // TiffStore always produces OME-Zarr v0.5 metadata.
112
+ return fromNgffZarr(store as unknown as Readable, {
113
+ version: "0.5",
114
+ ...options.ngffZarr,
115
+ })
116
+ }
package/src/index.ts CHANGED
@@ -34,6 +34,8 @@
34
34
  * ```
35
35
  */
36
36
 
37
+ export type { DeflatePool, TiffStoreOptions } from "@fideus-labs/fiff"
38
+ export { TiffStore } from "@fideus-labs/fiff"
37
39
  // Re-export Methods enum so consumers can check isLabelImage or compare method values
38
40
  export { Methods } from "@fideus-labs/ngff-zarr"
39
41
  // Worker pool lifecycle (re-exported from ngff-zarr)
@@ -72,6 +74,9 @@ export type {
72
74
  } from "./events.js"
73
75
  // Event system (browser-native EventTarget API)
74
76
  export { OMEZarrNVImageEvent } from "./events.js"
77
+ export type { FromTiffOptions } from "./fromTiff.js"
78
+ // TIFF support (via @fideus-labs/fiff)
79
+ export { fromTiff } from "./fromTiff.js"
75
80
  // RGB normalization utilities
76
81
  export type { ChannelWindow } from "./normalize.js"
77
82
  export { computeChannelMinMax, normalizeToUint8 } from "./normalize.js"
@@ -145,6 +150,17 @@ export {
145
150
  worldToPixel,
146
151
  worldToPixelAffine,
147
152
  } from "./utils/coordinates.js"
153
+ // Orientation utilities (NGFF RFC-4 anatomical orientation)
154
+ export type {
155
+ OrientationMapping,
156
+ OrientationSigns,
157
+ } from "./utils/orientation.js"
158
+ export {
159
+ applyOrientationToAffine,
160
+ getOrientationInfo,
161
+ getOrientationMapping,
162
+ getOrientationSigns,
163
+ } from "./utils/orientation.js"
148
164
  // Viewport bounds utilities
149
165
  export {
150
166
  boundsApproxEqual,
@@ -4,6 +4,8 @@
4
4
  import type { NgffImage } from "@fideus-labs/ngff-zarr"
5
5
  import { mat4 } from "gl-matrix"
6
6
 
7
+ import { applyOrientationToAffine } from "./orientation.js"
8
+
7
9
  /**
8
10
  * Create a 4x4 affine transformation matrix from OME-Zarr scale and translation.
9
11
  *
@@ -76,11 +78,22 @@ export function createAffineFromOMEZarr(
76
78
  /**
77
79
  * Create an affine matrix from an NgffImage.
78
80
  *
79
- * @param ngffImage - The NgffImage containing scale and translation
80
- * @returns 4x4 affine matrix
81
+ * If the image has RFC-4 anatomical orientation metadata
82
+ * (`axesOrientations`), the affine column vectors and translations
83
+ * are sign-flipped so the matrix encodes direction relative to
84
+ * the NIfTI RAS+ convention. This allows NiiVue's `calculateRAS()`
85
+ * to correctly determine the anatomical layout.
86
+ *
87
+ * When no orientation metadata is present, the matrix is identical
88
+ * to a plain scale + translation affine (backward-compatible).
89
+ *
90
+ * @param ngffImage - The NgffImage containing scale, translation,
91
+ * and optional `axesOrientations`
92
+ * @returns 4x4 affine matrix with orientation signs applied
81
93
  */
82
94
  export function createAffineFromNgffImage(ngffImage: NgffImage): mat4 {
83
- return createAffineFromOMEZarr(ngffImage.scale, ngffImage.translation)
95
+ const affine = createAffineFromOMEZarr(ngffImage.scale, ngffImage.translation)
96
+ return applyOrientationToAffine(affine, ngffImage.axesOrientations)
84
97
  }
85
98
 
86
99
  /**
@@ -0,0 +1,292 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) Fideus Labs LLC
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { AnatomicalOrientation } from "@fideus-labs/ngff-zarr"
5
+ import type { mat4 } from "gl-matrix"
6
+
7
+ /**
8
+ * Mapping from an array axis orientation to the physical (RAS) row
9
+ * and sign it should occupy in the NIfTI affine.
10
+ *
11
+ * - `physicalRow`: which row of the 4x4 affine the scale/translation
12
+ * should be placed in (0 = R/L, 1 = A/P, 2 = S/I)
13
+ * - `sign`: `1` if the orientation is in the RAS+ direction,
14
+ * `-1` if opposite
15
+ */
16
+ export interface OrientationMapping {
17
+ readonly physicalRow: 0 | 1 | 2
18
+ readonly sign: 1 | -1
19
+ }
20
+
21
+ /**
22
+ * Sign multipliers for each spatial axis, used to encode anatomical
23
+ * orientation into the NIfTI affine matrix.
24
+ *
25
+ * A value of `1` means the axis increases in the RAS+ direction
26
+ * (Right, Anterior, Superior). A value of `-1` means it increases
27
+ * in the opposite direction (Left, Posterior, Inferior).
28
+ *
29
+ * @deprecated Use {@link getOrientationMapping} for full permutation
30
+ * support. This interface only captures sign, not axis permutations.
31
+ */
32
+ export interface OrientationSigns {
33
+ readonly x: 1 | -1
34
+ readonly y: 1 | -1
35
+ readonly z: 1 | -1
36
+ }
37
+
38
+ /**
39
+ * Lookup table mapping each RFC-4 anatomical orientation string to
40
+ * its NIfTI RAS+ physical row and sign.
41
+ *
42
+ * RAS+ convention:
43
+ * - Row 0 (X): left-to-right (R+)
44
+ * - Row 1 (Y): posterior-to-anterior (A+)
45
+ * - Row 2 (Z): inferior-to-superior (S+)
46
+ */
47
+ const ORIENTATION_INFO: Record<string, OrientationMapping> = {
48
+ // L/R pair → physical row 0
49
+ "left-to-right": { physicalRow: 0, sign: 1 },
50
+ "right-to-left": { physicalRow: 0, sign: -1 },
51
+ // A/P pair → physical row 1
52
+ "posterior-to-anterior": { physicalRow: 1, sign: 1 },
53
+ "anterior-to-posterior": { physicalRow: 1, sign: -1 },
54
+ // S/I pair → physical row 2
55
+ "inferior-to-superior": { physicalRow: 2, sign: 1 },
56
+ "superior-to-inferior": { physicalRow: 2, sign: -1 },
57
+ }
58
+
59
+ /**
60
+ * Get the orientation mapping (physical row + RAS sign) for a single
61
+ * axis orientation.
62
+ *
63
+ * For unknown/exotic orientations, returns `undefined`.
64
+ *
65
+ * @param orientation - The anatomical orientation for one axis
66
+ * @returns The physical row and sign, or `undefined` if not a standard
67
+ * L/R, A/P, or S/I orientation
68
+ */
69
+ export function getOrientationInfo(
70
+ orientation: AnatomicalOrientation,
71
+ ): OrientationMapping | undefined {
72
+ return ORIENTATION_INFO[orientation.value]
73
+ }
74
+
75
+ /**
76
+ * Get orientation mappings for all three spatial axes.
77
+ *
78
+ * Each mapping tells you which physical (RAS) row the array axis
79
+ * maps to and what sign to apply. This supports both sign flips
80
+ * (e.g. LPS) and axis permutations (e.g. when the OME-Zarr y axis
81
+ * encodes S/I instead of A/P).
82
+ *
83
+ * When no orientation metadata is present, returns the identity
84
+ * mapping: x→row 0 sign +1, y→row 1 sign +1, z→row 2 sign +1.
85
+ *
86
+ * @param axesOrientations - Orientation metadata from
87
+ * `NgffImage.axesOrientations`, or `undefined`
88
+ * @returns Mappings for x, y, and z axes
89
+ *
90
+ * @example
91
+ * ```typescript
92
+ * // LPS data: x→row0 sign-1, y→row1 sign-1, z→row2 sign+1
93
+ * getOrientationMapping(LPS)
94
+ *
95
+ * // Permuted (mri.nii.gz): x→row0 sign-1, y→row2 sign-1, z→row1 sign+1
96
+ * getOrientationMapping(permutedOrientations)
97
+ * ```
98
+ */
99
+ export function getOrientationMapping(
100
+ axesOrientations: Record<string, AnatomicalOrientation> | undefined,
101
+ ): { x: OrientationMapping; y: OrientationMapping; z: OrientationMapping } {
102
+ const defaultMapping = {
103
+ x: { physicalRow: 0 as const, sign: 1 as const },
104
+ y: { physicalRow: 1 as const, sign: 1 as const },
105
+ z: { physicalRow: 2 as const, sign: 1 as const },
106
+ }
107
+
108
+ if (!axesOrientations) {
109
+ return defaultMapping
110
+ }
111
+
112
+ const xOrientation = axesOrientations.x ?? axesOrientations.X
113
+ const yOrientation = axesOrientations.y ?? axesOrientations.Y
114
+ const zOrientation = axesOrientations.z ?? axesOrientations.Z
115
+
116
+ const mapping = {
117
+ x:
118
+ (xOrientation ? getOrientationInfo(xOrientation) : undefined) ??
119
+ defaultMapping.x,
120
+ y:
121
+ (yOrientation ? getOrientationInfo(yOrientation) : undefined) ??
122
+ defaultMapping.y,
123
+ z:
124
+ (zOrientation ? getOrientationInfo(zOrientation) : undefined) ??
125
+ defaultMapping.z,
126
+ }
127
+
128
+ // Validate that each physicalRow is used exactly once to prevent
129
+ // degenerate affine matrices where columns overwrite each other
130
+ const rowsUsed = new Set([
131
+ mapping.x.physicalRow,
132
+ mapping.y.physicalRow,
133
+ mapping.z.physicalRow,
134
+ ])
135
+
136
+ if (rowsUsed.size !== 3) {
137
+ console.warn(
138
+ "[fidnii] Invalid orientation metadata: multiple axes map to the same physical row. Falling back to identity mapping.",
139
+ )
140
+ return defaultMapping
141
+ }
142
+
143
+ return mapping
144
+ }
145
+
146
+ /**
147
+ * Compute RAS+ sign multipliers from RFC-4 anatomical orientation metadata.
148
+ *
149
+ * For each spatial axis, determines whether the axis direction is aligned
150
+ * with (+1) or opposite to (-1) the NIfTI RAS+ convention:
151
+ * - x: positive = left-to-right (R), negative = right-to-left (L)
152
+ * - y: positive = posterior-to-anterior (A), negative = anterior-to-posterior (P)
153
+ * - z: positive = inferior-to-superior (S), negative = superior-to-inferior (I)
154
+ *
155
+ * Only the 6 standard L/R, A/P, I/S orientations are handled. Exotic
156
+ * orientations (dorsal/ventral, rostral/caudal, etc.) are treated as
157
+ * unknown and default to +1.
158
+ *
159
+ * **Note**: This function only returns sign information, not axis
160
+ * permutation. For full permutation support, use
161
+ * {@link getOrientationMapping} and {@link applyOrientationToAffine}.
162
+ *
163
+ * @param axesOrientations - Orientation metadata from `NgffImage.axesOrientations`,
164
+ * or `undefined` if no orientation metadata is present
165
+ * @returns Sign multipliers for x, y, and z axes
166
+ *
167
+ * @example
168
+ * ```typescript
169
+ * import { LPS, RAS } from "@fideus-labs/ngff-zarr"
170
+ *
171
+ * // LPS data: x and y are anti-RAS+
172
+ * getOrientationSigns(LPS)
173
+ * // => { x: -1, y: -1, z: 1 }
174
+ *
175
+ * // RAS data: all axes are RAS+
176
+ * getOrientationSigns(RAS)
177
+ * // => { x: 1, y: 1, z: 1 }
178
+ *
179
+ * // No orientation: defaults to all positive
180
+ * getOrientationSigns(undefined)
181
+ * // => { x: 1, y: 1, z: 1 }
182
+ * ```
183
+ */
184
+ export function getOrientationSigns(
185
+ axesOrientations: Record<string, AnatomicalOrientation> | undefined,
186
+ ): OrientationSigns {
187
+ const mapping = getOrientationMapping(axesOrientations)
188
+ return {
189
+ x: mapping.x.sign,
190
+ y: mapping.y.sign,
191
+ z: mapping.z.sign,
192
+ }
193
+ }
194
+
195
+ /**
196
+ * Apply anatomical orientation to an affine matrix in place.
197
+ *
198
+ * Builds a rotation/permutation matrix from the orientation metadata
199
+ * and applies it to the affine's 3x3 rotation/scale submatrix. This
200
+ * supports both simple sign flips (e.g. LPS where axes align with
201
+ * physical axes but directions differ) and full axis permutations
202
+ * (e.g. when OME-Zarr y axis encodes S/I instead of A/P).
203
+ *
204
+ * The input affine is expected to be a diagonal scale+translation
205
+ * matrix in gl-matrix column-major format, as produced by
206
+ * `createAffineFromOMEZarr()`:
207
+ *
208
+ * ```
209
+ * | sx 0 0 tx |
210
+ * | 0 sy 0 ty |
211
+ * | 0 0 sz tz |
212
+ * | 0 0 0 1 |
213
+ * ```
214
+ *
215
+ * **3x3 submatrix**: Each column's scale is placed in the row
216
+ * corresponding to the physical RAS axis the array axis maps to,
217
+ * with the appropriate sign:
218
+ *
219
+ * ```
220
+ * Column j (array axis j):
221
+ * row = physicalRow for axis j
222
+ * affine[j*4 + row] = sign * scale_j
223
+ * ```
224
+ *
225
+ * **Translation column**: Sign-flipped for LPS→RAS conversion but
226
+ * NOT row-permuted. This is because `itkImageToNgffImage` stores
227
+ * the ITK LPS origin values in array-axis-label order without
228
+ * transforming them through the direction matrix. The label
229
+ * assignment (x/y/z) follows the reversed ITK axis indices, so the
230
+ * physical meaning of each translation value matches its original
231
+ * LPS axis, regardless of axis permutation.
232
+ *
233
+ * When `axesOrientations` is `undefined`, the affine is left
234
+ * unchanged (backward-compatible identity mapping).
235
+ *
236
+ * @param affine - 4x4 affine matrix (column-major, modified in place)
237
+ * @param axesOrientations - Orientation metadata from `NgffImage.axesOrientations`
238
+ * @returns The same affine matrix (for chaining)
239
+ */
240
+ export function applyOrientationToAffine(
241
+ affine: mat4,
242
+ axesOrientations: Record<string, AnatomicalOrientation> | undefined,
243
+ ): mat4 {
244
+ if (!axesOrientations) {
245
+ return affine
246
+ }
247
+
248
+ const mapping = getOrientationMapping(axesOrientations)
249
+
250
+ // Extract the current diagonal scale and translation values
251
+ // (the input affine is expected to be diagonal from createAffineFromOMEZarr)
252
+ const sx = affine[0]
253
+ const sy = affine[5]
254
+ const sz = affine[10]
255
+ const tx = affine[12]
256
+ const ty = affine[13]
257
+ const tz = affine[14]
258
+
259
+ // Clear the 3x3 submatrix (translation will be overwritten below)
260
+ // Column 0
261
+ affine[0] = 0
262
+ affine[1] = 0
263
+ affine[2] = 0
264
+ // Column 1
265
+ affine[4] = 0
266
+ affine[5] = 0
267
+ affine[6] = 0
268
+ // Column 2
269
+ affine[8] = 0
270
+ affine[9] = 0
271
+ affine[10] = 0
272
+
273
+ // Place each axis' scale into the correct physical row.
274
+ // gl-matrix is column-major: index = col * 4 + row
275
+
276
+ // Column 0 (array x axis): place sx at physicalRow for x
277
+ affine[0 + mapping.x.physicalRow] = mapping.x.sign * sx
278
+
279
+ // Column 1 (array y axis): place sy at physicalRow for y
280
+ affine[4 + mapping.y.physicalRow] = mapping.y.sign * sy
281
+
282
+ // Column 2 (array z axis): place sz at physicalRow for z
283
+ affine[8 + mapping.z.physicalRow] = mapping.z.sign * sz
284
+
285
+ // Translation: sign-flip for LPS→RAS but keep at original row
286
+ // positions (x→row 0, y→row 1, z→row 2). See JSDoc for why.
287
+ affine[12] = mapping.x.sign * tx
288
+ affine[13] = mapping.y.sign * ty
289
+ affine[14] = mapping.z.sign * tz
290
+
291
+ return affine
292
+ }