Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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');
});
});
});
Original file line number Diff line number Diff line change
@@ -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])];
Expand Down
15 changes: 14 additions & 1 deletion packages/tools/src/utilities/orientation/index.ts
Original file line number Diff line number Diff line change
@@ -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,
};
Original file line number Diff line number Diff line change
@@ -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<readonly [string, string]> = [
[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;
}
102 changes: 102 additions & 0 deletions packages/tools/src/utilities/orientation/orientationLabels.ts
Original file line number Diff line number Diff line change
@@ -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
};
Loading