Skip to content

Commit 4590338

Browse files
committed
refactor: proper use of coordinate systems to interpret transforms
1 parent 3fc8a6a commit 4590338

6 files changed

Lines changed: 363 additions & 32 deletions

File tree

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
/**
2+
* coord-systems.test.js — Unit tests for parseTranslation and parseDeviceConfigCoords.
3+
*
4+
* Canonical output conventions:
5+
* ap: positive = anterior
6+
* ml: positive = right
7+
* dv: positive = dorsal (superior)
8+
* depth: positive = deeper from brain surface (always abs)
9+
*/
10+
11+
import { describe, it, expect } from 'vitest';
12+
import { parseTranslation, parseDeviceConfigCoords } from '../lib/coord-systems.js';
13+
14+
// ── Fixtures ────────────────────────────────────────────────────────────────
15+
16+
/**
17+
* The BREGMA_ARID coordinate system as it appears in procedures.coordinate_system:
18+
* AP axis → Posterior_to_anterior (positive = anterior)
19+
* ML axis → Left_to_right (positive = right)
20+
* SI axis → Superior_to_inferior (positive = ventral, i.e. canonical DV flipped)
21+
* Depth → depth-from-surface (index 3, always abs)
22+
*/
23+
const BREGMA_ARID_COORD_SYS = {
24+
object_type: 'Coordinate system',
25+
name: 'BREGMA_ARID',
26+
origin: 'Bregma',
27+
axes: [
28+
{ object_type: 'Axis', name: 'AP', direction: 'Posterior_to_anterior' },
29+
{ object_type: 'Axis', name: 'ML', direction: 'Left_to_right' },
30+
{ object_type: 'Axis', name: 'SI', direction: 'Superior_to_inferior' },
31+
{ object_type: 'Axis', name: 'Depth', direction: 'Superior_to_inferior' }, // index 3 — ignored (depth always uses abs v[3])
32+
],
33+
axis_unit: 'millimeter',
34+
};
35+
36+
// ── parseTranslation ────────────────────────────────────────────────────────
37+
38+
describe('parseTranslation', () => {
39+
describe('BREGMA_ARID coordinate system', () => {
40+
it('maps PA axis correctly: positive value → positive AP (anterior)', () => {
41+
// v[0]=1.3 along Posterior_to_anterior → ap = +1.3
42+
const result = parseTranslation(BREGMA_ARID_COORD_SYS, [1.3, 0, 0, 0]);
43+
expect(result.ap).toBeCloseTo(1.3);
44+
});
45+
46+
it('maps PA axis correctly: negative value → negative AP (posterior)', () => {
47+
const result = parseTranslation(BREGMA_ARID_COORD_SYS, [-1.5, 0, 0, 0]);
48+
expect(result.ap).toBeCloseTo(-1.5);
49+
});
50+
51+
it('maps LR axis correctly: positive value → positive ML (right)', () => {
52+
// v[1]=1.8 along Left_to_right → ml = +1.8
53+
const result = parseTranslation(BREGMA_ARID_COORD_SYS, [0, 1.8, 0, 0]);
54+
expect(result.ml).toBeCloseTo(1.8);
55+
});
56+
57+
it('maps LR axis correctly: negative value → negative ML (left)', () => {
58+
const result = parseTranslation(BREGMA_ARID_COORD_SYS, [0, -1.8, 0, 0]);
59+
expect(result.ml).toBeCloseTo(-1.8);
60+
});
61+
62+
it('maps SI axis correctly: positive value → negative DV (ventral)', () => {
63+
// v[2]=1 along Superior_to_inferior → dv = -1 (ventral in canonical)
64+
const result = parseTranslation(BREGMA_ARID_COORD_SYS, [0, 0, 1, 0]);
65+
expect(result.dv).toBeCloseTo(-1);
66+
});
67+
68+
it('maps SI axis correctly: negative value → positive DV (dorsal)', () => {
69+
const result = parseTranslation(BREGMA_ARID_COORD_SYS, [0, 0, -1, 0]);
70+
expect(result.dv).toBeCloseTo(1);
71+
});
72+
73+
it('depth is always abs of v[3] regardless of sign', () => {
74+
expect(parseTranslation(BREGMA_ARID_COORD_SYS, [0, 0, 0, 4.4]).depth).toBeCloseTo(4.4);
75+
expect(parseTranslation(BREGMA_ARID_COORD_SYS, [0, 0, 0, -4.4]).depth).toBeCloseTo(4.4);
76+
});
77+
78+
it('parses a complete realistic probe translation correctly', () => {
79+
// [AP=1.3, ML=-1.8, SI=0, depth=4.4]
80+
const result = parseTranslation(BREGMA_ARID_COORD_SYS, [1.3, -1.8, 0, 4.4]);
81+
expect(result.ap).toBeCloseTo(1.3);
82+
expect(result.ml).toBeCloseTo(-1.8);
83+
expect(result.dv).toBeCloseTo(0);
84+
expect(result.depth).toBeCloseTo(4.4);
85+
});
86+
87+
it('parses a right-hemisphere probe correctly', () => {
88+
// [AP=1.1, ML=1.8, SI=0, depth=4.4]
89+
const result = parseTranslation(BREGMA_ARID_COORD_SYS, [1.1, 1.8, 0, 4.4]);
90+
expect(result.ap).toBeCloseTo(1.1);
91+
expect(result.ml).toBeCloseTo(1.8);
92+
expect(result.depth).toBeCloseTo(4.4);
93+
});
94+
95+
it('parses a posterior probe correctly', () => {
96+
// [AP=-1.5, ML=3.0, SI=0, depth=3.9]
97+
const result = parseTranslation(BREGMA_ARID_COORD_SYS, [-1.5, 3.0, 0, 3.9]);
98+
expect(result.ap).toBeCloseTo(-1.5);
99+
expect(result.ml).toBeCloseTo(3.0);
100+
expect(result.depth).toBeCloseTo(3.9);
101+
});
102+
});
103+
104+
describe('axis direction sign conventions', () => {
105+
it('Anterior_to_posterior: positive value → negative AP (posterior)', () => {
106+
const cs = { axes: [{ direction: 'Anterior_to_posterior' }] };
107+
expect(parseTranslation(cs, [2, 0, 0, 0]).ap).toBeCloseTo(-2);
108+
});
109+
110+
it('Posterior_to_anterior: positive value → positive AP (anterior)', () => {
111+
const cs = { axes: [{ direction: 'Posterior_to_anterior' }] };
112+
expect(parseTranslation(cs, [2, 0, 0, 0]).ap).toBeCloseTo(2);
113+
});
114+
115+
it('Left_to_right: positive value → positive ML (right)', () => {
116+
const cs = { axes: [{ direction: 'Left_to_right' }] };
117+
expect(parseTranslation(cs, [3, 0, 0, 0]).ml).toBeCloseTo(3);
118+
});
119+
120+
it('Right_to_left: positive value → negative ML (left)', () => {
121+
const cs = { axes: [{ direction: 'Right_to_left' }] };
122+
expect(parseTranslation(cs, [3, 0, 0, 0]).ml).toBeCloseTo(-3);
123+
});
124+
125+
it('Superior_to_inferior: positive value → negative DV (ventral)', () => {
126+
const cs = { axes: [{ direction: 'Superior_to_inferior' }] };
127+
expect(parseTranslation(cs, [1, 0, 0, 0]).dv).toBeCloseTo(-1);
128+
});
129+
130+
it('Inferior_to_superior: positive value → positive DV (dorsal)', () => {
131+
const cs = { axes: [{ direction: 'Inferior_to_superior' }] };
132+
expect(parseTranslation(cs, [1, 0, 0, 0]).dv).toBeCloseTo(1);
133+
});
134+
});
135+
136+
describe('null/missing coordinate system fallback', () => {
137+
it('null coordinate system uses BREGMA_ARID index order: v0=AP+, v1=ML+, v2=DV+', () => {
138+
const result = parseTranslation(null, [1.3, -1.8, 0, 4.4]);
139+
expect(result.ap).toBeCloseTo(1.3);
140+
expect(result.ml).toBeCloseTo(-1.8);
141+
expect(result.dv).toBeCloseTo(0);
142+
expect(result.depth).toBeCloseTo(4.4);
143+
});
144+
145+
it('missing axes array falls back to index order', () => {
146+
const result = parseTranslation({ name: 'CUSTOM' }, [2.0, -1.0, 0, 3.0]);
147+
expect(result.ap).toBeCloseTo(2.0);
148+
expect(result.ml).toBeCloseTo(-1.0);
149+
expect(result.depth).toBeCloseTo(3.0);
150+
});
151+
152+
it('empty translation returns zeros with null depth', () => {
153+
const result = parseTranslation(null, []);
154+
expect(result.ap).toBe(0);
155+
expect(result.ml).toBe(0);
156+
expect(result.dv).toBeNull();
157+
expect(result.depth).toBeNull();
158+
});
159+
});
160+
});
161+
162+
// ── parseDeviceConfigCoords ──────────────────────────────────────────────────
163+
164+
describe('parseDeviceConfigCoords', () => {
165+
it('reads coordinate_system and first Translation from transform', () => {
166+
const deviceConfig = {
167+
coordinate_system: BREGMA_ARID_COORD_SYS,
168+
transform: [
169+
{ object_type: 'Translation', translation: [1.3, -1.8, 0, 4.4] },
170+
],
171+
};
172+
const result = parseDeviceConfigCoords(deviceConfig);
173+
expect(result.ap).toBeCloseTo(1.3);
174+
expect(result.ml).toBeCloseTo(-1.8);
175+
expect(result.depth).toBeCloseTo(4.4);
176+
});
177+
178+
it('skips non-Translation transform entries to find Translation', () => {
179+
const deviceConfig = {
180+
coordinate_system: BREGMA_ARID_COORD_SYS,
181+
transform: [
182+
{ object_type: 'Rotation', angles: [0, 0, 0] },
183+
{ object_type: 'Translation', translation: [1.1, 1.8, 0, -4.4] },
184+
],
185+
};
186+
const result = parseDeviceConfigCoords(deviceConfig);
187+
expect(result.ap).toBeCloseTo(1.1);
188+
expect(result.ml).toBeCloseTo(1.8);
189+
expect(result.depth).toBeCloseTo(4.4);
190+
});
191+
192+
it('falls back gracefully when no coordinate_system present', () => {
193+
const deviceConfig = {
194+
coordinate_system: null,
195+
transform: [
196+
{ object_type: 'Translation', translation: [2.0, 1.0, 0, 3.5] },
197+
],
198+
};
199+
const result = parseDeviceConfigCoords(deviceConfig);
200+
expect(result.ap).toBeCloseTo(2.0);
201+
expect(result.ml).toBeCloseTo(1.0);
202+
expect(result.depth).toBeCloseTo(3.5);
203+
});
204+
205+
it('returns zeros when no transform present', () => {
206+
const deviceConfig = {
207+
coordinate_system: BREGMA_ARID_COORD_SYS,
208+
transform: [],
209+
};
210+
const result = parseDeviceConfigCoords(deviceConfig);
211+
expect(result.ap).toBe(0);
212+
expect(result.ml).toBe(0);
213+
expect(result.depth).toBeNull();
214+
});
215+
216+
it('handles null deviceConfig gracefully', () => {
217+
const result = parseDeviceConfigCoords(null);
218+
expect(result.ap).toBe(0);
219+
expect(result.ml).toBe(0);
220+
expect(result.depth).toBeNull();
221+
});
222+
});

web/src/lib/coord-systems.js

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
/**
2+
* coord-systems.js — Coordinate system parsing for AIND procedure metadata.
3+
*
4+
* Converts a `coordinate_system` object (from a device_config or procedure) plus
5+
* a `translation` array into canonical brain coordinates:
6+
*
7+
* ap — mm from bregma, positive = anterior
8+
* ml — mm from bregma, positive = right
9+
* dv — mm from bregma, positive = dorsal (up)
10+
* depth — mm from brain surface, always positive
11+
*
12+
* The translation array has up to 4 values: [v0, v1, v2, v3].
13+
* v0..v2 correspond to coordinate_system.axes[0..2] in order.
14+
* v3 is always depth-from-surface (axis-independent, sign is ignored).
15+
*
16+
* If no coordinate_system is provided, falls back to the BREGMA_ARID convention:
17+
* v0 = AP (positive anterior), v1 = ML (positive right), v2 = DV, v3 = depth.
18+
*/
19+
20+
/**
21+
* Map an axis direction string to a canonical dimension and the sign
22+
* needed to convert from "positive in named direction" to "positive in
23+
* canonical direction" (anterior, right, dorsal).
24+
*
25+
* Supported directions (case-insensitive):
26+
* ML: "Left_to_right", "Right_to_left"
27+
* AP: "Anterior_to_posterior", "Posterior_to_anterior"
28+
* DV: "Inferior_to_superior", "Superior_to_inferior"
29+
*
30+
* Returns { dim: 'ap'|'ml'|'dv', sign: 1|-1 } or null if unrecognised.
31+
*/
32+
function _directionToCanonical(direction) {
33+
if (!direction) return null;
34+
const dir = direction.toLowerCase().trim();
35+
36+
// ── ML (canonical: right = positive) ──────────────────────────────
37+
if (dir === 'left_to_right') return { dim: 'ml', sign: 1 };
38+
if (dir === 'right_to_left') return { dim: 'ml', sign: -1 };
39+
40+
// ── AP (canonical: anterior = positive) ───────────────────────────
41+
if (dir === 'anterior_to_posterior') return { dim: 'ap', sign: -1 }; // positive direction is posterior → flip
42+
if (dir === 'posterior_to_anterior') return { dim: 'ap', sign: 1 }; // positive direction is anterior ✓
43+
44+
// ── DV (canonical: dorsal = positive) ─────────────────────────────
45+
if (dir === 'superior_to_inferior') return { dim: 'dv', sign: -1 }; // positive direction is ventral → flip
46+
if (dir === 'inferior_to_superior') return { dim: 'dv', sign: 1 }; // positive direction is dorsal ✓
47+
48+
console.warn('[coord-systems] Unrecognised axis direction:', direction);
49+
return null;
50+
}
51+
52+
/**
53+
* Parse a coordinate_system object and a translation array into canonical
54+
* brain coordinates.
55+
*
56+
* @param {object|null} coordinateSystem - The coordinate_system object from metadata,
57+
* or null to use the BREGMA_ARID fallback (v0=AP, v1=ML, v2=DV, v3=depth).
58+
* @param {number[]} translation - Array of up to 4 numeric values.
59+
* @returns {{ ap: number, ml: number, dv: number|null, depth: number|null }}
60+
* All values in millimetres, canonical sign conventions.
61+
*/
62+
export function parseTranslation(coordinateSystem, translation) {
63+
const v = Array.isArray(translation) ? translation : [];
64+
const safeNum = (x) => (x != null && isFinite(Number(x)) ? Number(x) : null);
65+
66+
// Always read depth from index 3 regardless of coordinate system
67+
const depth = v.length > 3 ? Math.abs(safeNum(v[3]) ?? 0) : null;
68+
69+
// Fallback: BREGMA_ARID convention (v0=AP anterior+, v1=ML right+, v2=DV dorsal+)
70+
if (!coordinateSystem || !Array.isArray(coordinateSystem.axes)) {
71+
return {
72+
ap: safeNum(v[0]) ?? 0,
73+
ml: safeNum(v[1]) ?? 0,
74+
dv: safeNum(v[2]),
75+
depth,
76+
};
77+
}
78+
79+
const result = { ap: 0, ml: 0, dv: null, depth };
80+
81+
coordinateSystem.axes.forEach((axis, i) => {
82+
if (i >= 3) return; // only first 3 axis components
83+
const val = safeNum(v[i]);
84+
if (val == null) return;
85+
const mapping = _directionToCanonical(axis?.direction);
86+
if (!mapping) return;
87+
result[mapping.dim] = val * mapping.sign;
88+
});
89+
90+
return result;
91+
}
92+
93+
/**
94+
* Convenience wrapper: extract canonical coords from a device_config object.
95+
* Reads device_config.coordinate_system and the first Translation in
96+
* device_config.transform.
97+
*
98+
* @param {object} deviceConfig
99+
* @returns {{ ap: number, ml: number, dv: number|null, depth: number|null }}
100+
*/
101+
export function parseDeviceConfigCoords(deviceConfig) {
102+
const coordSys = deviceConfig?.coordinate_system ?? null;
103+
let translation = null;
104+
for (const t of (deviceConfig?.transform ?? [])) {
105+
if (t?.object_type === 'Translation') {
106+
translation = t.translation;
107+
break;
108+
}
109+
}
110+
return parseTranslation(coordSys, translation ?? []);
111+
}

0 commit comments

Comments
 (0)