diff --git a/packages/tools/src/utilities/orientation/getOrientationStringLPS.spec.ts b/packages/tools/src/utilities/orientation/getOrientationStringLPS.spec.ts new file mode 100644 index 0000000000..6de9bddbe9 --- /dev/null +++ b/packages/tools/src/utilities/orientation/getOrientationStringLPS.spec.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from '@jest/globals'; +import getOrientationStringLPS from './getOrientationStringLPS'; +import invertOrientationStringLPS from './invertOrientationStringLPS'; +import { + BIPED_ORIENTATION_LABELS, + QUADRUPED_TRUNK_ORIENTATION_LABELS, + QUADRUPED_HEAD_ORIENTATION_LABELS, +} from './orientationLabels'; + +describe('getOrientationStringLPS', () => { + describe('biped (default, human)', () => { + it('maps each patient axis to its anatomical designator', () => { + expect(getOrientationStringLPS([1, 0, 0])).toBe('L'); // +X Left + expect(getOrientationStringLPS([-1, 0, 0])).toBe('R'); // -X Right + expect(getOrientationStringLPS([0, 1, 0])).toBe('P'); // +Y Posterior + expect(getOrientationStringLPS([0, -1, 0])).toBe('A'); // -Y Anterior + expect(getOrientationStringLPS([0, 0, 1])).toBe('H'); // +Z Head + expect(getOrientationStringLPS([0, 0, -1])).toBe('F'); // -Z Foot + }); + + it('combines designators for oblique directions', () => { + expect(getOrientationStringLPS([1, 1, 0])).toBe('LP'); + expect(getOrientationStringLPS([1, 0, 1])).toBe('LH'); + }); + + it('matches the explicitly passed biped labels', () => { + expect(getOrientationStringLPS([1, 0, 0], BIPED_ORIENTATION_LABELS)).toBe( + 'L' + ); + }); + }); + + describe('quadruped trunk (veterinary)', () => { + it('maps the Z axis to cranial/caudal', () => { + expect( + getOrientationStringLPS([0, 0, 1], QUADRUPED_TRUNK_ORIENTATION_LABELS) + ).toBe('CR'); // +Z Cranial + expect( + getOrientationStringLPS([0, 0, -1], QUADRUPED_TRUNK_ORIENTATION_LABELS) + ).toBe('CD'); // -Z Caudal + }); + + it('shares left/right and dorsal/ventral with the head frame', () => { + expect( + getOrientationStringLPS([1, 0, 0], QUADRUPED_TRUNK_ORIENTATION_LABELS) + ).toBe('LE'); // +X Left + expect( + getOrientationStringLPS([0, -1, 0], QUADRUPED_TRUNK_ORIENTATION_LABELS) + ).toBe('V'); // -Y Ventral + }); + + it('combines multi-letter designators without delimiters', () => { + // Left + Ventral, matching the DICOM "LEV" style example. + expect( + getOrientationStringLPS([1, -1, 0], QUADRUPED_TRUNK_ORIENTATION_LABELS) + ).toBe('LEV'); + }); + }); + + describe('quadruped head (veterinary)', () => { + it('maps the Z axis to rostral/caudal instead of cranial/caudal', () => { + expect( + getOrientationStringLPS([0, 0, 1], QUADRUPED_HEAD_ORIENTATION_LABELS) + ).toBe('R'); // +Z Rostral (toward the nose) + expect( + getOrientationStringLPS([0, 0, -1], QUADRUPED_HEAD_ORIENTATION_LABELS) + ).toBe('CD'); // -Z Caudal + }); + }); +}); + +describe('invertOrientationStringLPS', () => { + describe('biped (default, human)', () => { + it('swaps each designator for its opposite', () => { + expect(invertOrientationStringLPS('L')).toBe('R'); + expect(invertOrientationStringLPS('P')).toBe('A'); + expect(invertOrientationStringLPS('H')).toBe('F'); + expect(invertOrientationStringLPS('LPS')).toBe('RAS'); + expect(invertOrientationStringLPS('HF')).toBe('FH'); + }); + }); + + describe('quadruped trunk (veterinary)', () => { + it('swaps single- and multi-letter designators for their opposites', () => { + expect( + invertOrientationStringLPS('LE', QUADRUPED_TRUNK_ORIENTATION_LABELS) + ).toBe('RT'); + expect( + invertOrientationStringLPS('D', QUADRUPED_TRUNK_ORIENTATION_LABELS) + ).toBe('V'); + expect( + invertOrientationStringLPS('CR', QUADRUPED_TRUNK_ORIENTATION_LABELS) + ).toBe('CD'); + expect( + invertOrientationStringLPS('LECD', QUADRUPED_TRUNK_ORIENTATION_LABELS) + ).toBe('RTCR'); + }); + }); + + describe('quadruped head (veterinary)', () => { + it('swaps rostral and caudal', () => { + expect( + invertOrientationStringLPS('R', QUADRUPED_HEAD_ORIENTATION_LABELS) + ).toBe('CD'); + expect( + invertOrientationStringLPS('CD', QUADRUPED_HEAD_ORIENTATION_LABELS) + ).toBe('R'); + }); + }); +}); diff --git a/packages/tools/src/utilities/orientation/getOrientationStringLPS.ts b/packages/tools/src/utilities/orientation/getOrientationStringLPS.ts index 0aaf9d699d..56f37c367d 100644 --- a/packages/tools/src/utilities/orientation/getOrientationStringLPS.ts +++ b/packages/tools/src/utilities/orientation/getOrientationStringLPS.ts @@ -1,20 +1,31 @@ import type { Types } from '@cornerstonejs/core'; +import { + BIPED_ORIENTATION_LABELS, + type OrientationLabels, +} from './orientationLabels'; /** * Returns the orientation of the vector in the patient coordinate system. * @public * * @param vector - Input array + * @param labels - Designators to use for each patient axis. Defaults to the + * human (biped) set; pass {@link QUADRUPED_TRUNK_ORIENTATION_LABELS} or + * {@link QUADRUPED_HEAD_ORIENTATION_LABELS} for veterinary imaging, per + * DICOM PS3.3 C.7.6.1.1.1 (quadruped designators are body-region-specific). * @returns The orientation in the patient coordinate system. */ -export default function getOrientationStringLPS(vector: Types.Point3): string { +export default function getOrientationStringLPS( + vector: Types.Point3, + labels: OrientationLabels = BIPED_ORIENTATION_LABELS +): string { // Thanks to David Clunie // https://sites.google.com/site/dicomnotes/ let orientation = ''; - const orientationX = vector[0] < 0 ? 'R' : 'L'; - const orientationY = vector[1] < 0 ? 'A' : 'P'; - const orientationZ = vector[2] < 0 ? 'F' : 'H'; + const orientationX = vector[0] < 0 ? labels.negativeX : labels.positiveX; + const orientationY = vector[1] < 0 ? labels.negativeY : labels.positiveY; + const orientationZ = vector[2] < 0 ? labels.negativeZ : labels.positiveZ; // Should probably make this a function vector3.abs const abs = [Math.abs(vector[0]), Math.abs(vector[1]), Math.abs(vector[2])]; diff --git a/packages/tools/src/utilities/orientation/index.ts b/packages/tools/src/utilities/orientation/index.ts index 1d77e4600c..a8c9cafcec 100644 --- a/packages/tools/src/utilities/orientation/index.ts +++ b/packages/tools/src/utilities/orientation/index.ts @@ -1,4 +1,17 @@ import getOrientationStringLPS from './getOrientationStringLPS'; import invertOrientationStringLPS from './invertOrientationStringLPS'; +import { + BIPED_ORIENTATION_LABELS, + QUADRUPED_TRUNK_ORIENTATION_LABELS, + QUADRUPED_HEAD_ORIENTATION_LABELS, + type OrientationLabels, +} from './orientationLabels'; -export { getOrientationStringLPS, invertOrientationStringLPS }; +export { + getOrientationStringLPS, + invertOrientationStringLPS, + BIPED_ORIENTATION_LABELS, + QUADRUPED_TRUNK_ORIENTATION_LABELS, + QUADRUPED_HEAD_ORIENTATION_LABELS, + type OrientationLabels, +}; diff --git a/packages/tools/src/utilities/orientation/invertOrientationStringLPS.ts b/packages/tools/src/utilities/orientation/invertOrientationStringLPS.ts index 75fd4d8f19..32c2244157 100644 --- a/packages/tools/src/utilities/orientation/invertOrientationStringLPS.ts +++ b/packages/tools/src/utilities/orientation/invertOrientationStringLPS.ts @@ -1,21 +1,53 @@ +import { + BIPED_ORIENTATION_LABELS, + type OrientationLabels, +} from './orientationLabels'; + /** - * Inverts an orientation string. + * Inverts an orientation string by replacing every designator with the one + * pointing along the opposite patient axis. * @public * * @param orientationString - The orientation. + * @param labels - Designators per patient axis. Must match the set used to + * produce `orientationString`. Defaults to the human (biped) set; pass + * {@link QUADRUPED_TRUNK_ORIENTATION_LABELS} or + * {@link QUADRUPED_HEAD_ORIENTATION_LABELS} for veterinary strings. * @returns The inverted orientationString. */ export default function invertOrientationStringLPS( - orientationString: string + orientationString: string, + labels: OrientationLabels = BIPED_ORIENTATION_LABELS ): string { - let inverted = orientationString.replace('H', 'f'); + // Opposite-direction pairs along each patient axis. + const oppositePairs: ReadonlyArray = [ + [labels.positiveX, labels.negativeX], + [labels.positiveY, labels.negativeY], + [labels.positiveZ, labels.negativeZ], + ]; + + // Map each designator to its opposite. Longest first so multi-letter + // veterinary abbreviations (e.g. "CR"/"CD") match before a single letter, + // as described in DICOM PS3.3 C.7.6.1.1.1 (parseable left-to-right). + const swaps: Array<[string, string]> = oppositePairs + .flatMap(([a, b]) => [ + [a, b] as [string, string], + [b, a] as [string, string], + ]) + .sort((left, right) => right[0].length - left[0].length); - inverted = inverted.replace('F', 'h'); - inverted = inverted.replace('R', 'l'); - inverted = inverted.replace('L', 'r'); - inverted = inverted.replace('A', 'p'); - inverted = inverted.replace('P', 'a'); - inverted = inverted.toUpperCase(); + let inverted = ''; + for (let i = 0; i < orientationString.length; ) { + const swap = swaps.find(([from]) => orientationString.startsWith(from, i)); + if (swap) { + inverted += swap[1]; + i += swap[0].length; + } else { + // Preserve any character that is not a known designator (e.g. a delimiter). + inverted += orientationString[i]; + i += 1; + } + } return inverted; } diff --git a/packages/tools/src/utilities/orientation/orientationLabels.ts b/packages/tools/src/utilities/orientation/orientationLabels.ts new file mode 100644 index 0000000000..084fa66065 --- /dev/null +++ b/packages/tools/src/utilities/orientation/orientationLabels.ts @@ -0,0 +1,102 @@ +/** + * Orientation label designators for the patient-based coordinate system (LPS), + * as defined in DICOM PS3.3 Section C.7.6.1.1.1 (Patient Orientation). + * + * The geometric axes are identical for every species; only the anatomical + * designator letters differ. The `AnatomicalOrientationType` (0010,2210) + * attribute selects which set applies: + * + * - `BIPED` (human, the default when absent) — a single universal mapping, + * see {@link BIPED_ORIENTATION_LABELS}. + * - `QUADRUPED` (veterinary) — **body-region-specific**. DICOM lists many + * quadruped designators (rostral, proximal, distal, palmar, plantar, ...) + * precisely because no single mapping covers every study: + * - **trunk, neck, tail** → cranial/caudal on the Z axis + * ({@link QUADRUPED_TRUNK_ORIENTATION_LABELS}) + * - **head** → rostral/caudal on the Z axis + * ({@link QUADRUPED_HEAD_ORIENTATION_LABELS}) + * - **limbs** → proximal/distal along the limb; the designators depend on + * limb positioning, so there is no fixed-axis preset. Callers must build + * a region-appropriate {@link OrientationLabels} from the DICOM designators + * (PR/DI proximal-distal, PA palmar, PL plantar, ...) for each study. + * + * @public + */ +export type OrientationLabels = { + /** Designator for the +X axis (toward the patient's left side). */ + positiveX: string; + /** Designator for the -X axis (toward the patient's right side). */ + negativeX: string; + /** Designator for the +Y axis (posterior / dorsal side). */ + positiveY: string; + /** Designator for the -Y axis (anterior / ventral side). */ + negativeY: string; + /** Designator for the +Z axis (toward the head / cranial direction). */ + positiveZ: string; + /** Designator for the -Z axis (toward the feet / caudal direction). */ + negativeZ: string; +}; + +/** + * Human (biped) orientation designators. Used when `AnatomicalOrientationType` + * (0010,2210) is absent or has the value `BIPED`. The biped mapping is + * universal, so this is the historical default and keeps existing behavior + * unchanged. + * + * @public + */ +export const BIPED_ORIENTATION_LABELS: OrientationLabels = { + positiveX: 'L', // Left + negativeX: 'R', // Right + positiveY: 'P', // Posterior + negativeY: 'A', // Anterior + positiveZ: 'H', // Head + negativeZ: 'F', // Foot +}; + +/** + * Veterinary (quadruped) designators for the **trunk, neck and tail** — the + * standard whole-body cranial-caudal frame. This is the mapping used in the + * worked example of DICOM PS3.3 C.7.6.1.1.1 (a quadruped abdomen view encoded + * "LEV\\CD"), and cranial/caudal apply to the neck, trunk and tail per + * veterinary anatomy. + * + * - +X/-X = Left/Right (LE/RT) + * - +Y/-Y = Dorsal/Ventral (D/V) + * - +Z/-Z = Cranial/Caudal (CR/CD) + * + * Do not use this preset for the head or limbs — see + * {@link QUADRUPED_HEAD_ORIENTATION_LABELS} and the limb note above. + * + * @public + */ +export const QUADRUPED_TRUNK_ORIENTATION_LABELS: OrientationLabels = { + positiveX: 'LE', // Left + negativeX: 'RT', // Right + positiveY: 'D', // Dorsal + negativeY: 'V', // Ventral + positiveZ: 'CR', // Cranial + negativeZ: 'CD', // Caudal +}; + +/** + * Veterinary (quadruped) designators for the **head**. Identical to the trunk + * frame except the +Z/-Z axis is rostral/caudal (R/CD): "rostral" points + * toward the nose and is used only for the head, while cranial/caudal apply to + * the neck, trunk and tail (per veterinary anatomy). Dorsal/ventral and + * left/right still apply to the head. + * + * - +X/-X = Left/Right (LE/RT) + * - +Y/-Y = Dorsal/Ventral (D/V) + * - +Z/-Z = Rostral/Caudal (R/CD) + * + * @public + */ +export const QUADRUPED_HEAD_ORIENTATION_LABELS: OrientationLabels = { + positiveX: 'LE', // Left + negativeX: 'RT', // Right + positiveY: 'D', // Dorsal + negativeY: 'V', // Ventral + positiveZ: 'R', // Rostral + negativeZ: 'CD', // Caudal +};