1// Read a KomaMRI `.phantom` (HDF5) file into a Phantom, using h5wasm in the
2// browser. Robust to missing contrast fields (fills sensible defaults) so it
3// can also load arbitrary KomaMRI phantoms, not just our two built-ins.
4import * as h5wasm from 'h5wasm'
5import type { Phantom } from './phantomTypes.ts'
7let readyPromise: Promise<void> | null = null
8let counter = 0
10async function ensureReady(): Promise<void> {
11 if (!readyPromise) {
12 readyPromise = h5wasm.ready.then(() => undefined)
13 }
14 return readyPromise
15}
17function toF32(v: unknown, ns: number, fallback: number): Float32Array {
18 if (v instanceof Float32Array) return v
19 if (v instanceof Float64Array || Array.isArray(v)) return Float32Array.from(v as ArrayLike<number>)
20 if (ArrayBuffer.isView(v)) return Float32Array.from(v as unknown as ArrayLike<number>)
21 const a = new Float32Array(ns)
22 a.fill(fallback)
23 return a
24}
26/** Map a contrast group's (possibly Unicode-named) datasets onto our field names. */
27const CONTRAST_ALIASES: Record<string, string[]> = {
28 rho: ['ρ', 'rho', 'Rho', 'PD'],
29 t1: ['T1'],
30 t2: ['T2'],
31 t2s: ['T2s', 'T2*'],
32 dw: ['Δw', 'Deltaw', 'dw', 'B0'],
33}
35function readGroupField(group: h5wasm.Group | null, aliases: string[]): unknown {
36 if (!group) return null
37 const keys = group.keys()
38 for (const alias of aliases) {
39 if (keys.includes(alias)) {
40 const ds = group.get(alias)
41 if (ds && 'value' in ds) return (ds as h5wasm.Dataset).value
42 }
43 }
44 return null
45}
47export async function loadPhantom(buffer: ArrayBuffer, fallbackName = 'phantom'): Promise<Phantom> {
48 await ensureReady()
49 const FS = h5wasm.FS!
50 const filename = `/phantom_${counter++}.h5`
51 FS.writeFile(filename, new Uint8Array(buffer))
52 let f: h5wasm.File | null = null
53 try {
54 f = new h5wasm.File(filename, 'r')
56 const posGroup = f.get('position') as h5wasm.Group | null
57 const x0 = readGroupField(posGroup, ['x'])
58 const y0 = readGroupField(posGroup, ['y'])
59 const z0 = readGroupField(posGroup, ['z'])
60 const ns =
61 x0 instanceof Float32Array || x0 instanceof Float64Array || Array.isArray(x0)
62 ? (x0 as ArrayLike<number>).length
63 : 0
64 if (!ns) throw new Error('Phantom has no position/x dataset (not a valid .phantom file?)')
66 const x = toF32(x0, ns, 0)
67 const y = toF32(y0, ns, 0)
68 const z = toF32(z0, ns, 0)
70 const con = f.get('contrast') as h5wasm.Group | null
71 const rho = toF32(readGroupField(con, CONTRAST_ALIASES.rho), ns, 1)
72 const t1 = toF32(readGroupField(con, CONTRAST_ALIASES.t1), ns, 1)
73 const t2 = toF32(readGroupField(con, CONTRAST_ALIASES.t2), ns, 0.1)
74 const t2s = toF32(readGroupField(con, CONTRAST_ALIASES.t2s), ns, 0.05)
75 const dw = toF32(readGroupField(con, CONTRAST_ALIASES.dw), ns, 0)
77 const nameAttr = f.attrs['Name']?.value
78 const name = typeof nameAttr === 'string' && nameAttr.length ? nameAttr : fallbackName
80 return { name, ns, x, y, z, rho, t1, t2, t2s, dw }
81 } finally {
82 f?.close()
83 try {
84 FS.unlink(filename)
85 } catch {
86 // ignore
87 }
88 }
89}