redbirdpy 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- redbirdpy/__init__.py +112 -0
- redbirdpy/analytical.py +927 -0
- redbirdpy/forward.py +589 -0
- redbirdpy/property.py +602 -0
- redbirdpy/recon.py +893 -0
- redbirdpy/solver.py +814 -0
- redbirdpy/utility.py +1117 -0
- redbirdpy-0.1.0.dist-info/METADATA +596 -0
- redbirdpy-0.1.0.dist-info/RECORD +13 -0
- redbirdpy-0.1.0.dist-info/WHEEL +5 -0
- redbirdpy-0.1.0.dist-info/licenses/LICENSE.txt +674 -0
- redbirdpy-0.1.0.dist-info/top_level.txt +1 -0
- redbirdpy-0.1.0.dist-info/zip-safe +1 -0
redbirdpy/property.py
ADDED
|
@@ -0,0 +1,602 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Redbird Property Module - Optical property management for DOT/NIRS.
|
|
3
|
+
|
|
4
|
+
INDEX CONVENTION: All mesh indices (elem, face) stored in cfg are 1-based
|
|
5
|
+
to match MATLAB/iso2mesh. This module converts to 0-based only when
|
|
6
|
+
indexing numpy arrays.
|
|
7
|
+
|
|
8
|
+
Functions:
|
|
9
|
+
extinction: Get molar extinction coefficients for chromophores
|
|
10
|
+
updateprop: Update optical properties from physiological parameters
|
|
11
|
+
getbulk: Get bulk/background optical properties
|
|
12
|
+
musp2sasp: Convert mus' to scattering amplitude/power
|
|
13
|
+
setmesh: Associate new mesh with simulation structure
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"extinction",
|
|
18
|
+
"updateprop",
|
|
19
|
+
"getbulk",
|
|
20
|
+
"musp2sasp",
|
|
21
|
+
"setmesh",
|
|
22
|
+
"get_chromophore_table",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
import numpy as np
|
|
26
|
+
from scipy import interpolate
|
|
27
|
+
from typing import Dict, Tuple, Optional, Union, List, Any
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def extinction(
|
|
31
|
+
wavelengths: Union[List[str], List[float], np.ndarray],
|
|
32
|
+
chromophores: Union[str, List[str]],
|
|
33
|
+
**interp_opts,
|
|
34
|
+
) -> Tuple[np.ndarray, dict]:
|
|
35
|
+
"""
|
|
36
|
+
Get molar extinction coefficients for chromophores.
|
|
37
|
+
|
|
38
|
+
Data compiled by Scott Prahl from https://omlc.org/spectra/hemoglobin/
|
|
39
|
+
|
|
40
|
+
Parameters
|
|
41
|
+
----------
|
|
42
|
+
wavelengths : list or ndarray
|
|
43
|
+
Wavelengths in nm (as strings or numbers)
|
|
44
|
+
chromophores : str or list
|
|
45
|
+
Chromophore names: 'hbo', 'hbr', 'water', 'lipids', 'aa3'
|
|
46
|
+
**interp_opts : dict
|
|
47
|
+
Options passed to scipy.interpolate.interp1d
|
|
48
|
+
|
|
49
|
+
Returns
|
|
50
|
+
-------
|
|
51
|
+
extin : ndarray
|
|
52
|
+
Extinction coefficients (Nwv x Nchrome)
|
|
53
|
+
Units: 1/(mm*uM) for hemoglobin, 1/mm for water/lipids
|
|
54
|
+
chrome : dict
|
|
55
|
+
Full lookup tables for each chromophore
|
|
56
|
+
"""
|
|
57
|
+
chrome = _get_chromophore_data()
|
|
58
|
+
|
|
59
|
+
# Convert wavelengths to float array
|
|
60
|
+
if isinstance(wavelengths, (list, tuple)):
|
|
61
|
+
wavelengths = [float(w) if isinstance(w, str) else w for w in wavelengths]
|
|
62
|
+
wavelengths = np.atleast_1d(wavelengths).astype(float)
|
|
63
|
+
|
|
64
|
+
# Handle single chromophore
|
|
65
|
+
if isinstance(chromophores, str):
|
|
66
|
+
chromophores = [chromophores]
|
|
67
|
+
|
|
68
|
+
extin = np.zeros((len(wavelengths), len(chromophores)))
|
|
69
|
+
|
|
70
|
+
for j, chrom in enumerate(chromophores):
|
|
71
|
+
chrom_lower = chrom.lower()
|
|
72
|
+
if chrom_lower not in chrome:
|
|
73
|
+
raise ValueError(
|
|
74
|
+
f"Unknown chromophore: {chrom}. " f"Available: {list(chrome.keys())}"
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
spectrum = chrome[chrom_lower]
|
|
78
|
+
|
|
79
|
+
# Interpolate to requested wavelengths
|
|
80
|
+
f = interpolate.interp1d(
|
|
81
|
+
spectrum[:, 0],
|
|
82
|
+
spectrum[:, 1],
|
|
83
|
+
kind="linear",
|
|
84
|
+
fill_value="extrapolate",
|
|
85
|
+
**interp_opts,
|
|
86
|
+
)
|
|
87
|
+
extin[:, j] = f(wavelengths)
|
|
88
|
+
|
|
89
|
+
return extin, chrome
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def updateprop(cfg: dict, wv: str = None) -> Union[np.ndarray, dict]:
|
|
93
|
+
"""
|
|
94
|
+
Update optical properties from physiological parameters.
|
|
95
|
+
|
|
96
|
+
Converts chromophore concentrations and scattering parameters to
|
|
97
|
+
wavelength-dependent mua and musp.
|
|
98
|
+
|
|
99
|
+
Parameters
|
|
100
|
+
----------
|
|
101
|
+
cfg : dict
|
|
102
|
+
Configuration with:
|
|
103
|
+
- param: dict with 'hbo', 'hbr', 'water', 'lipids', 'scatamp', 'scatpow'
|
|
104
|
+
- prop: template for output format
|
|
105
|
+
- node, elem: mesh data (1-based elem)
|
|
106
|
+
wv : str, optional
|
|
107
|
+
Specific wavelength to update (if None, update all)
|
|
108
|
+
|
|
109
|
+
Returns
|
|
110
|
+
-------
|
|
111
|
+
prop : ndarray or dict
|
|
112
|
+
Updated optical properties [mua, musp, g, n]
|
|
113
|
+
Dict keyed by wavelength if multi-wavelength
|
|
114
|
+
|
|
115
|
+
Notes
|
|
116
|
+
-----
|
|
117
|
+
mua = sum_i(extin_i * C_i) where C_i is concentration
|
|
118
|
+
musp = scatamp * (lambda_nm)^(-scatpow)
|
|
119
|
+
"""
|
|
120
|
+
if "param" not in cfg or not isinstance(cfg.get("prop"), dict):
|
|
121
|
+
return cfg.get("prop")
|
|
122
|
+
|
|
123
|
+
wavelengths = list(cfg["prop"].keys()) if wv is None else [wv]
|
|
124
|
+
params = cfg["param"]
|
|
125
|
+
|
|
126
|
+
prop_out = {}
|
|
127
|
+
|
|
128
|
+
for wavelen in wavelengths:
|
|
129
|
+
# Default tissue composition values
|
|
130
|
+
if "water" not in params:
|
|
131
|
+
params["water"] = 0.23
|
|
132
|
+
if "lipids" not in params:
|
|
133
|
+
params["lipids"] = 0.58
|
|
134
|
+
|
|
135
|
+
# Get chromophore types present in params
|
|
136
|
+
types = [t for t in ["hbo", "hbr", "water", "lipids", "aa3"] if t in params]
|
|
137
|
+
|
|
138
|
+
if not types:
|
|
139
|
+
raise ValueError(
|
|
140
|
+
"No recognized chromophores in cfg.param. "
|
|
141
|
+
"Expected one or more of: hbo, hbr, water, lipids, aa3"
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
# Get extinction coefficients at this wavelength
|
|
145
|
+
extin, _ = extinction(float(wavelen), types)
|
|
146
|
+
|
|
147
|
+
# Compute mua as sum of extinction * concentration
|
|
148
|
+
first_param = params[types[0]]
|
|
149
|
+
if np.isscalar(first_param):
|
|
150
|
+
mua = 0.0
|
|
151
|
+
else:
|
|
152
|
+
mua = np.zeros_like(first_param, dtype=float)
|
|
153
|
+
|
|
154
|
+
for j, t in enumerate(types):
|
|
155
|
+
mua = mua + extin[0, j] * params[t]
|
|
156
|
+
|
|
157
|
+
# Compute musp from scattering amplitude and power
|
|
158
|
+
# musp = scatamp * (wavelength_nm)^(-scatpow)
|
|
159
|
+
if "scatamp" in params and "scatpow" in params:
|
|
160
|
+
musp = params["scatamp"] * (float(wavelen)) ** (-params["scatpow"])
|
|
161
|
+
else:
|
|
162
|
+
musp = None
|
|
163
|
+
|
|
164
|
+
# Build property array based on mesh size
|
|
165
|
+
segprop = cfg["prop"][wavelen]
|
|
166
|
+
nn = (
|
|
167
|
+
cfg["node"].shape[0]
|
|
168
|
+
if "node" in cfg
|
|
169
|
+
else (len(mua) if hasattr(mua, "__len__") else 1)
|
|
170
|
+
)
|
|
171
|
+
ne = (
|
|
172
|
+
cfg["elem"].shape[0]
|
|
173
|
+
if "elem" in cfg
|
|
174
|
+
else (len(mua) if hasattr(mua, "__len__") else 1)
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
mua_len = len(mua) if hasattr(mua, "__len__") else 1
|
|
178
|
+
|
|
179
|
+
if mua_len < min(nn, ne):
|
|
180
|
+
# Label-based properties: mua/musp are per-label
|
|
181
|
+
# segprop[0] is background, segprop[1:] are tissue labels
|
|
182
|
+
new_prop = segprop.copy()
|
|
183
|
+
if hasattr(mua, "__len__"):
|
|
184
|
+
new_prop[1 : mua_len + 1, 0] = mua
|
|
185
|
+
if musp is not None:
|
|
186
|
+
new_prop[1 : mua_len + 1, 1] = musp
|
|
187
|
+
new_prop[1 : mua_len + 1, 2] = 0 # g=0 when using musp directly
|
|
188
|
+
else:
|
|
189
|
+
new_prop[1, 0] = mua
|
|
190
|
+
if musp is not None:
|
|
191
|
+
new_prop[1, 1] = musp
|
|
192
|
+
new_prop[1, 2] = 0
|
|
193
|
+
else:
|
|
194
|
+
# Node/element based properties
|
|
195
|
+
if musp is not None:
|
|
196
|
+
n_ref = segprop[1, 3] if segprop.shape[0] > 1 else 1.37
|
|
197
|
+
if hasattr(mua, "__len__"):
|
|
198
|
+
new_prop = np.column_stack(
|
|
199
|
+
[mua, musp, np.zeros_like(musp), np.full_like(musp, n_ref)]
|
|
200
|
+
)
|
|
201
|
+
else:
|
|
202
|
+
new_prop = np.array([[mua, musp, 0, n_ref]])
|
|
203
|
+
else:
|
|
204
|
+
# Keep existing musp, g, n from template
|
|
205
|
+
if hasattr(mua, "__len__"):
|
|
206
|
+
tile_prop = np.tile(segprop[1, 1:], (len(mua), 1))
|
|
207
|
+
new_prop = np.column_stack([mua, tile_prop])
|
|
208
|
+
else:
|
|
209
|
+
new_prop = np.array([[mua] + list(segprop[1, 1:])])
|
|
210
|
+
|
|
211
|
+
prop_out[wavelen] = new_prop
|
|
212
|
+
|
|
213
|
+
return prop_out if len(wavelengths) > 1 else prop_out[wavelengths[0]]
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def getbulk(cfg: dict) -> Union[np.ndarray, dict]:
|
|
217
|
+
"""
|
|
218
|
+
Get bulk/background optical properties.
|
|
219
|
+
|
|
220
|
+
Returns the optical properties of the outer-most layer that interfaces
|
|
221
|
+
with air (used for boundary condition calculation).
|
|
222
|
+
|
|
223
|
+
Parameters
|
|
224
|
+
----------
|
|
225
|
+
cfg : dict
|
|
226
|
+
Configuration with 'prop', optionally 'bulk', 'seg', 'face'
|
|
227
|
+
elem and face are 1-based indices
|
|
228
|
+
|
|
229
|
+
Returns
|
|
230
|
+
-------
|
|
231
|
+
bkprop : ndarray or dict
|
|
232
|
+
[mua, mus, g, n] for the bulk medium
|
|
233
|
+
Dict keyed by wavelength if multi-wavelength
|
|
234
|
+
|
|
235
|
+
Notes
|
|
236
|
+
-----
|
|
237
|
+
Priority: cfg.bulk > cfg.prop at surface node > default [0, 0, 0, 1.37]
|
|
238
|
+
"""
|
|
239
|
+
bkprop_default = np.array([0.0, 0.0, 0.0, 1.37])
|
|
240
|
+
|
|
241
|
+
# If explicit bulk properties provided, use those
|
|
242
|
+
if "bulk" in cfg:
|
|
243
|
+
bulk = cfg["bulk"]
|
|
244
|
+
bkprop = bkprop_default.copy()
|
|
245
|
+
if "mua" in bulk:
|
|
246
|
+
bkprop[0] = bulk["mua"]
|
|
247
|
+
if "dcoeff" in bulk:
|
|
248
|
+
# Convert diffusion coefficient to mus
|
|
249
|
+
bkprop[1] = 1.0 / (3 * bulk["dcoeff"])
|
|
250
|
+
bkprop[2] = 0
|
|
251
|
+
if "musp" in bulk:
|
|
252
|
+
bkprop[1] = bulk["musp"]
|
|
253
|
+
bkprop[2] = 0
|
|
254
|
+
if "g" in bulk:
|
|
255
|
+
bkprop[2] = bulk["g"]
|
|
256
|
+
if "n" in bulk:
|
|
257
|
+
bkprop[3] = bulk["n"]
|
|
258
|
+
return bkprop
|
|
259
|
+
|
|
260
|
+
if "prop" not in cfg or cfg["prop"] is None:
|
|
261
|
+
return bkprop_default
|
|
262
|
+
|
|
263
|
+
prop = cfg["prop"]
|
|
264
|
+
|
|
265
|
+
# Multi-wavelength handling
|
|
266
|
+
if isinstance(prop, dict):
|
|
267
|
+
bkprop = {}
|
|
268
|
+
for wv, p in prop.items():
|
|
269
|
+
bkprop[wv] = _extract_bulk_from_prop(p, cfg)
|
|
270
|
+
return bkprop
|
|
271
|
+
|
|
272
|
+
return _extract_bulk_from_prop(prop, cfg)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _extract_bulk_from_prop(prop: np.ndarray, cfg: dict) -> np.ndarray:
|
|
276
|
+
"""
|
|
277
|
+
Extract bulk property from property array.
|
|
278
|
+
|
|
279
|
+
Determines property format (label-based vs node/element-based) and
|
|
280
|
+
extracts the appropriate surface property.
|
|
281
|
+
"""
|
|
282
|
+
bkprop_default = np.array([0.0, 0.0, 0.0, 1.37])
|
|
283
|
+
|
|
284
|
+
nn = cfg["node"].shape[0] if "node" in cfg else prop.shape[0]
|
|
285
|
+
ne = cfg["elem"].shape[0] if "elem" in cfg else prop.shape[0]
|
|
286
|
+
|
|
287
|
+
if prop.shape[0] < min(nn, ne):
|
|
288
|
+
# Label-based properties
|
|
289
|
+
if "seg" in cfg:
|
|
290
|
+
seg = cfg["seg"]
|
|
291
|
+
if "face" in cfg and len(seg) == nn:
|
|
292
|
+
# Node-based segmentation, get label at first surface node
|
|
293
|
+
# face is 1-based, convert for indexing
|
|
294
|
+
face_node_0 = cfg["face"][0, 0] - 1 # Convert to 0-based
|
|
295
|
+
label = seg[face_node_0]
|
|
296
|
+
elif "face" in cfg and len(seg) == ne:
|
|
297
|
+
# Element-based segmentation, find element containing face node
|
|
298
|
+
elem_0 = cfg["elem"][:, :4].astype(int) - 1 # Convert to 0-based
|
|
299
|
+
face_node_0 = cfg["face"][0, 0] - 1
|
|
300
|
+
eid = np.where(np.any(elem_0 == face_node_0, axis=1))[0]
|
|
301
|
+
label = seg[eid[0]] if len(eid) > 0 else 0
|
|
302
|
+
else:
|
|
303
|
+
label = seg[0]
|
|
304
|
+
|
|
305
|
+
# prop row 0 is background, row label+1 is the tissue
|
|
306
|
+
prop_idx = int(label)
|
|
307
|
+
if prop.shape[0] > prop_idx:
|
|
308
|
+
return prop[prop_idx, :]
|
|
309
|
+
else:
|
|
310
|
+
return prop[1, :] if prop.shape[0] > 1 else bkprop_default
|
|
311
|
+
else:
|
|
312
|
+
# No segmentation, use first tissue label
|
|
313
|
+
return prop[1, :] if prop.shape[0] > 1 else bkprop_default
|
|
314
|
+
|
|
315
|
+
elif prop.shape[0] == nn:
|
|
316
|
+
# Node-based properties
|
|
317
|
+
if "face" in cfg:
|
|
318
|
+
face_node_0 = cfg["face"][0, 0] - 1 # Convert to 0-based
|
|
319
|
+
return prop[face_node_0, :]
|
|
320
|
+
return prop[0, :]
|
|
321
|
+
|
|
322
|
+
elif prop.shape[0] == ne:
|
|
323
|
+
# Element-based properties
|
|
324
|
+
if "face" in cfg:
|
|
325
|
+
elem_0 = cfg["elem"][:, :4].astype(int) - 1
|
|
326
|
+
face_node_0 = cfg["face"][0, 0] - 1
|
|
327
|
+
eid = np.where(np.any(elem_0 == face_node_0, axis=1))[0]
|
|
328
|
+
if len(eid) > 0:
|
|
329
|
+
return prop[eid[0], :]
|
|
330
|
+
return prop[0, :]
|
|
331
|
+
|
|
332
|
+
return bkprop_default
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def musp2sasp(musp: np.ndarray, wavelength: np.ndarray) -> Tuple[float, float]:
|
|
336
|
+
"""
|
|
337
|
+
Convert mus' at two wavelengths to scattering amplitude and power.
|
|
338
|
+
|
|
339
|
+
Uses the relation: musp = sa * (lambda/500nm)^(-sp)
|
|
340
|
+
|
|
341
|
+
Parameters
|
|
342
|
+
----------
|
|
343
|
+
musp : ndarray
|
|
344
|
+
Reduced scattering coefficients at two wavelengths (1/mm)
|
|
345
|
+
wavelength : ndarray
|
|
346
|
+
Wavelengths in nm (length 2)
|
|
347
|
+
|
|
348
|
+
Returns
|
|
349
|
+
-------
|
|
350
|
+
sa : float
|
|
351
|
+
Scattering amplitude (musp at 500nm)
|
|
352
|
+
sp : float
|
|
353
|
+
Scattering power (wavelength exponent)
|
|
354
|
+
"""
|
|
355
|
+
if len(musp) < 2 or len(wavelength) < 2:
|
|
356
|
+
raise ValueError("Need at least 2 wavelengths to fit scattering parameters")
|
|
357
|
+
|
|
358
|
+
lam = wavelength[:2] / 500.0
|
|
359
|
+
|
|
360
|
+
# sp = log(musp1/musp2) / log(lam2/lam1)
|
|
361
|
+
sp = np.log(musp[0] / musp[1]) / np.log(lam[1] / lam[0])
|
|
362
|
+
|
|
363
|
+
# sa = average of musp / lam^(-sp) at both wavelengths
|
|
364
|
+
sa = 0.5 * (musp[0] / lam[0] ** (-sp) + musp[1] / lam[1] ** (-sp))
|
|
365
|
+
|
|
366
|
+
return sa, sp
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def setmesh(
|
|
370
|
+
cfg0: dict,
|
|
371
|
+
node: np.ndarray,
|
|
372
|
+
elem: np.ndarray,
|
|
373
|
+
prop: np.ndarray = None,
|
|
374
|
+
propidx: np.ndarray = None,
|
|
375
|
+
) -> dict:
|
|
376
|
+
"""
|
|
377
|
+
Associate a new mesh with simulation structure.
|
|
378
|
+
|
|
379
|
+
Clears derived quantities that need recomputation with the new mesh.
|
|
380
|
+
|
|
381
|
+
Parameters
|
|
382
|
+
----------
|
|
383
|
+
cfg0 : dict
|
|
384
|
+
Original configuration
|
|
385
|
+
node : ndarray
|
|
386
|
+
New node coordinates (Nn x 3)
|
|
387
|
+
elem : ndarray
|
|
388
|
+
New element connectivity (Ne x 4+), 1-based indices
|
|
389
|
+
prop : ndarray, optional
|
|
390
|
+
New optical properties
|
|
391
|
+
propidx : ndarray, optional
|
|
392
|
+
Segmentation labels
|
|
393
|
+
|
|
394
|
+
Returns
|
|
395
|
+
-------
|
|
396
|
+
cfg : dict
|
|
397
|
+
Updated configuration with new mesh
|
|
398
|
+
"""
|
|
399
|
+
from .utility import meshprep
|
|
400
|
+
|
|
401
|
+
# Fields that depend on mesh geometry and need recomputation
|
|
402
|
+
clear_fields = [
|
|
403
|
+
"face",
|
|
404
|
+
"area",
|
|
405
|
+
"evol",
|
|
406
|
+
"deldotdel",
|
|
407
|
+
"isreoriented",
|
|
408
|
+
"nvol",
|
|
409
|
+
"cols",
|
|
410
|
+
"rows",
|
|
411
|
+
"idxsum",
|
|
412
|
+
"idxcount",
|
|
413
|
+
"musp0",
|
|
414
|
+
"reff",
|
|
415
|
+
]
|
|
416
|
+
|
|
417
|
+
cfg = {k: v for k, v in cfg0.items() if k not in clear_fields}
|
|
418
|
+
|
|
419
|
+
cfg["node"] = node
|
|
420
|
+
cfg["elem"] = elem[:, :4] if elem.shape[1] > 4 else elem
|
|
421
|
+
|
|
422
|
+
if prop is not None:
|
|
423
|
+
cfg["prop"] = prop
|
|
424
|
+
|
|
425
|
+
if propidx is not None:
|
|
426
|
+
cfg["seg"] = propidx
|
|
427
|
+
elif elem.shape[1] > 4:
|
|
428
|
+
cfg["seg"] = elem[:, 4].astype(int)
|
|
429
|
+
|
|
430
|
+
# Prepare mesh (computes face, area, evol, deldotdel, etc.)
|
|
431
|
+
cfg, _ = meshprep(cfg)
|
|
432
|
+
|
|
433
|
+
return cfg
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
# ============== Chromophore Data ==============
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _get_chromophore_data() -> dict:
|
|
440
|
+
"""
|
|
441
|
+
Get built-in chromophore extinction coefficient tables.
|
|
442
|
+
|
|
443
|
+
Returns dict with keys: 'hbo', 'hbr', 'water', 'lipids', 'aa3'
|
|
444
|
+
Each value is Nx2 array: [wavelength_nm, extinction_coeff]
|
|
445
|
+
|
|
446
|
+
Units:
|
|
447
|
+
- HbO2, Hb: 1/(mm*uM) - multiply by concentration in uM to get 1/mm
|
|
448
|
+
- Water, lipids: 1/mm - multiply by volume fraction
|
|
449
|
+
"""
|
|
450
|
+
chrome = {}
|
|
451
|
+
|
|
452
|
+
# Hemoglobin data (HbO2 and Hb) from Scott Prahl / OMLC
|
|
453
|
+
# Original units: cm-1/M, converted to 1/(mm*uM) via 2.303e-7
|
|
454
|
+
# Wavelength (nm), HbO2 (cm-1/M), Hb (cm-1/M)
|
|
455
|
+
hb_raw = np.array(
|
|
456
|
+
[
|
|
457
|
+
[250, 106112, 112736],
|
|
458
|
+
[260, 116376, 116296],
|
|
459
|
+
[270, 136068, 122880],
|
|
460
|
+
[280, 131936, 118872],
|
|
461
|
+
[290, 104752, 98364],
|
|
462
|
+
[300, 65972, 64440],
|
|
463
|
+
[310, 63352, 59156],
|
|
464
|
+
[320, 78752, 74508],
|
|
465
|
+
[330, 97512, 90856],
|
|
466
|
+
[340, 107884, 108472],
|
|
467
|
+
[350, 106576, 122092],
|
|
468
|
+
[360, 94744, 134940],
|
|
469
|
+
[370, 88176, 139968],
|
|
470
|
+
[380, 109564, 145232],
|
|
471
|
+
[390, 167748, 167780],
|
|
472
|
+
[400, 266232, 223296],
|
|
473
|
+
[410, 466840, 303956],
|
|
474
|
+
[420, 480360, 407560],
|
|
475
|
+
[430, 246072, 528600],
|
|
476
|
+
[440, 102580, 413280],
|
|
477
|
+
[450, 62816, 103292],
|
|
478
|
+
[460, 44480, 23388.8],
|
|
479
|
+
[470, 33209.2, 16156.4],
|
|
480
|
+
[480, 26629.2, 14550],
|
|
481
|
+
[490, 23684.4, 16684],
|
|
482
|
+
[500, 20932.8, 20862],
|
|
483
|
+
[510, 20035.2, 25773.6],
|
|
484
|
+
[520, 24202.4, 31589.6],
|
|
485
|
+
[530, 39956.8, 39036.4],
|
|
486
|
+
[540, 53236, 46592],
|
|
487
|
+
[550, 43016, 53412],
|
|
488
|
+
[560, 32613.2, 53788],
|
|
489
|
+
[570, 44496, 45072],
|
|
490
|
+
[580, 50104, 37020],
|
|
491
|
+
[590, 14400.8, 28324.4],
|
|
492
|
+
[600, 3200, 14677.2],
|
|
493
|
+
[610, 1506, 9443.6],
|
|
494
|
+
[620, 942, 6509.6],
|
|
495
|
+
[630, 610, 5148.8],
|
|
496
|
+
[640, 442, 4345.2],
|
|
497
|
+
[650, 368, 3750.12],
|
|
498
|
+
[660, 319.6, 3226.56],
|
|
499
|
+
[670, 294, 2795.12],
|
|
500
|
+
[680, 277.6, 2407.92],
|
|
501
|
+
[690, 276, 2334.68],
|
|
502
|
+
[700, 290, 1794.28],
|
|
503
|
+
[710, 314, 1540.48],
|
|
504
|
+
[720, 348, 1325.88],
|
|
505
|
+
[730, 390, 1102.2],
|
|
506
|
+
[740, 446, 1115.88],
|
|
507
|
+
[750, 518, 1405.24],
|
|
508
|
+
[760, 586, 1548.52],
|
|
509
|
+
[770, 650, 1311.88],
|
|
510
|
+
[780, 710, 1075.44],
|
|
511
|
+
[790, 756, 890.8],
|
|
512
|
+
[800, 816, 761.72],
|
|
513
|
+
[810, 864, 717.08],
|
|
514
|
+
[820, 916, 693.76],
|
|
515
|
+
[830, 974, 693.04],
|
|
516
|
+
[840, 1022, 692.36],
|
|
517
|
+
[850, 1058, 691.32],
|
|
518
|
+
[860, 1092, 694.32],
|
|
519
|
+
[870, 1128, 705.84],
|
|
520
|
+
[880, 1154, 726.44],
|
|
521
|
+
[890, 1178, 743.6],
|
|
522
|
+
[900, 1198, 761.84],
|
|
523
|
+
[910, 1214, 774.56],
|
|
524
|
+
[920, 1224, 777.36],
|
|
525
|
+
[930, 1222, 763.84],
|
|
526
|
+
[940, 1214, 693.44],
|
|
527
|
+
[950, 1204, 602.24],
|
|
528
|
+
[960, 1186, 525.56],
|
|
529
|
+
[970, 1162, 429.32],
|
|
530
|
+
[980, 1128, 359.656],
|
|
531
|
+
[990, 1080, 283.22],
|
|
532
|
+
[1000, 1024, 206.784],
|
|
533
|
+
]
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
# Convert units: 2.303 (ln to log10) * 1e-4 (cm to mm) * 1e-3 (M to mM) * 1e-3 (mM to uM)
|
|
537
|
+
# = 2.303e-7 converts from 1/(cm*M) to 1/(mm*uM)
|
|
538
|
+
conversion = 2.303e-7
|
|
539
|
+
chrome["hbo"] = np.column_stack([hb_raw[:, 0], hb_raw[:, 1] * conversion])
|
|
540
|
+
chrome["hbr"] = np.column_stack([hb_raw[:, 0], hb_raw[:, 2] * conversion])
|
|
541
|
+
|
|
542
|
+
# Water absorption coefficient (1/mm) - multiply by water fraction
|
|
543
|
+
# Data from Hale & Querry 1973, simplified for NIR range
|
|
544
|
+
water_wv = np.array([400, 500, 600, 650, 700, 750, 800, 850, 900, 950, 1000])
|
|
545
|
+
water_mua = (
|
|
546
|
+
np.array(
|
|
547
|
+
[
|
|
548
|
+
0.00058,
|
|
549
|
+
0.00025,
|
|
550
|
+
0.0023,
|
|
551
|
+
0.0032,
|
|
552
|
+
0.006,
|
|
553
|
+
0.026,
|
|
554
|
+
0.02,
|
|
555
|
+
0.043,
|
|
556
|
+
0.068,
|
|
557
|
+
0.39,
|
|
558
|
+
0.36,
|
|
559
|
+
]
|
|
560
|
+
)
|
|
561
|
+
* 0.1
|
|
562
|
+
) # Convert cm-1 to mm-1
|
|
563
|
+
chrome["water"] = np.column_stack([water_wv, water_mua])
|
|
564
|
+
|
|
565
|
+
# Lipids absorption (1/mm) - multiply by lipid fraction
|
|
566
|
+
# Simplified approximation for NIR range
|
|
567
|
+
lipid_wv = np.arange(650, 1000, 10)
|
|
568
|
+
lipid_mua = 0.0005 * np.ones_like(lipid_wv, dtype=float)
|
|
569
|
+
chrome["lipids"] = np.column_stack([lipid_wv, lipid_mua])
|
|
570
|
+
|
|
571
|
+
# Cytochrome c oxidase (aa3) - difference spectrum
|
|
572
|
+
# Gaussian-like peak around 830nm (oxidized-reduced difference)
|
|
573
|
+
aa3_wv = np.arange(650, 950, 5)
|
|
574
|
+
aa3_mua = 0.5 * np.exp(-((aa3_wv - 830) ** 2) / (2 * 50**2)) + 0.4
|
|
575
|
+
chrome["aa3"] = np.column_stack([aa3_wv, aa3_mua])
|
|
576
|
+
|
|
577
|
+
return chrome
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def get_chromophore_table(name: str) -> np.ndarray:
|
|
581
|
+
"""
|
|
582
|
+
Get full chromophore lookup table.
|
|
583
|
+
|
|
584
|
+
Parameters
|
|
585
|
+
----------
|
|
586
|
+
name : str
|
|
587
|
+
Chromophore name ('hbo', 'hbr', 'water', 'lipids', 'aa3')
|
|
588
|
+
|
|
589
|
+
Returns
|
|
590
|
+
-------
|
|
591
|
+
table : ndarray
|
|
592
|
+
Nx2 array of [wavelength_nm, extinction_coefficient]
|
|
593
|
+
"""
|
|
594
|
+
chrome = _get_chromophore_data()
|
|
595
|
+
|
|
596
|
+
name = name.lower()
|
|
597
|
+
if name not in chrome:
|
|
598
|
+
raise ValueError(
|
|
599
|
+
f"Unknown chromophore: {name}. " f"Available: {list(chrome.keys())}"
|
|
600
|
+
)
|
|
601
|
+
|
|
602
|
+
return chrome[name]
|