1// Turn a RawSignal into a displayable RGBA image: the "raw k-space" grid, one
2// row per ADC readout (phase-encode line), columns along the readout. For a
3// Cartesian sequence this IS the k-space matrix; for anything else it is still
4// the raw acquired data laid out by readout. DOM-free so it can be unit-tested.
5import type { RawSignal } from '../sim/simulate.ts'
7export type KspaceMode = 'log-magnitude' | 'magnitude' | 'phase' | 'real' | 'imaginary'
9export interface KspaceImage {
10 width: number
11 height: number
12 rgba: Uint8ClampedArray
13 /** Descriptive scale info for a legend. */
14 scale: { label: string; min: number; max: number }
15}
17export const KSPACE_MODES: { id: KspaceMode; label: string }[] = [
18 { id: 'log-magnitude', label: 'log magnitude' },
19 { id: 'magnitude', label: 'magnitude' },
20 { id: 'phase', label: 'phase' },
21 { id: 'real', label: 'real' },
22 { id: 'imaginary', label: 'imaginary' },
23]
25function grayscale(t: number, out: Uint8ClampedArray, o: number) {
26 const v = Math.round(255 * Math.max(0, Math.min(1, t)))
27 out[o] = v
28 out[o + 1] = v
29 out[o + 2] = v
30 out[o + 3] = 255
31}
33/** Diverging blue-white-red for signed values, t in [-1,1]. */
34function diverging(t: number, out: Uint8ClampedArray, o: number) {
35 const x = Math.max(-1, Math.min(1, t))
36 let r: number, g: number, b: number
37 if (x < 0) {
38 const f = 1 + x // 0..1
39 r = 255 * f
40 g = 255 * f
41 b = 255
42 } else {
43 const f = 1 - x
44 r = 255
45 g = 255 * f
46 b = 255 * f
47 }
48 out[o] = r
49 out[o + 1] = g
50 out[o + 2] = b
51 out[o + 3] = 255
52}
54/** Cyclic colormap for phase, t in [0,1) → hue wheel. */
55function cyclic(t: number, out: Uint8ClampedArray, o: number) {
56 const h = (t % 1) * 6
57 const c = 1
58 const x = 1 - Math.abs((h % 2) - 1)
59 let r = 0,
60 g = 0,
61 b = 0
62 if (h < 1) [r, g, b] = [c, x, 0]
63 else if (h < 2) [r, g, b] = [x, c, 0]
64 else if (h < 3) [r, g, b] = [0, c, x]
65 else if (h < 4) [r, g, b] = [0, x, c]
66 else if (h < 5) [r, g, b] = [x, 0, c]
67 else [r, g, b] = [c, 0, x]
68 out[o] = 255 * r
69 out[o + 1] = 255 * g
70 out[o + 2] = 255 * b
71 out[o + 3] = 255
72}
74export function renderKspace(signal: RawSignal, mode: KspaceMode, dynamicRangeDb = 60): KspaceImage {
75 const width = Math.max(1, signal.maxSamplesPerReadout)
76 const height = Math.max(1, signal.numReadouts)
77 const rgba = new Uint8ClampedArray(width * height * 4)
78 const { re, im, offsets, samplesPerReadout } = signal
80 // Global scale factors.
81 let maxMag = 0
82 let maxAbs = 0
83 for (let i = 0; i < re.length; i++) {
84 const m = Math.hypot(re[i], im[i])
85 if (m > maxMag) maxMag = m
86 if (Math.abs(re[i]) > maxAbs) maxAbs = Math.abs(re[i])
87 if (Math.abs(im[i]) > maxAbs) maxAbs = Math.abs(im[i])
88 }
89 if (maxMag === 0) maxMag = 1
90 if (maxAbs === 0) maxAbs = 1
91 const floorDb = -Math.abs(dynamicRangeDb)
93 for (let r = 0; r < height; r++) {
94 const n = r < samplesPerReadout.length ? samplesPerReadout[r] : 0
95 const base = r < offsets.length ? offsets[r] : 0
96 for (let c = 0; c < width; c++) {
97 const o = (r * width + c) * 4
98 if (c >= n) {
99 // padding for ragged readouts
100 rgba[o] = 12
101 rgba[o + 1] = 14
102 rgba[o + 2] = 20
103 rgba[o + 3] = 255
104 continue
105 }
106 const idx = base + c
107 const reV = re[idx]
108 const imV = im[idx]
109 switch (mode) {
110 case 'log-magnitude': {
111 const m = Math.hypot(reV, imV)
112 const db = 20 * Math.log10(m / maxMag + 1e-12)
113 grayscale((db - floorDb) / -floorDb, rgba, o)
114 break
115 }
116 case 'magnitude':
117 grayscale(Math.hypot(reV, imV) / maxMag, rgba, o)
118 break
119 case 'phase':
120 cyclic((Math.atan2(imV, reV) + Math.PI) / (2 * Math.PI), rgba, o)
121 break
122 case 'real':
123 diverging(reV / maxAbs, rgba, o)
124 break
125 case 'imaginary':
126 diverging(imV / maxAbs, rgba, o)
127 break
128 }
129 }
130 }
132 const scale =
133 mode === 'phase'
134 ? { label: 'rad', min: -Math.PI, max: Math.PI }
135 : mode === 'log-magnitude'
136 ? { label: 'dB', min: floorDb, max: 0 }
137 : mode === 'magnitude'
138 ? { label: '', min: 0, max: maxMag }
139 : { label: '', min: -maxAbs, max: maxAbs }
141 return { width, height, rgba, scale }
142}