v2.0.0
Loading...
Searching...
No Matches
fwd_field_map.cpp
Go to the documentation of this file.
1//=============================================================================================================
44
45//=============================================================================================================
46// INCLUDES
47//=============================================================================================================
48
49#include "fwd_field_map.h"
50
51#include <fiff/fiff_proj.h>
52#include <fiff/fiff_file.h>
53
54#include <Eigen/SVD>
55#include <QRegularExpression>
56#include <algorithm>
57#include <cmath>
58#include <vector>
59
60//=============================================================================================================
61// USED NAMESPACES
62//=============================================================================================================
63
64using namespace Eigen;
65using namespace FWDLIB;
66
67//=============================================================================================================
68// ANONYMOUS NAMESPACE – internal helpers
69//=============================================================================================================
70
71namespace
72{
73
74//=========================================================================
75// Constants (matching MNE-Python mne/forward/_lead_dots.py)
76//=========================================================================
77
79constexpr int kNCoeff = 100;
80
82constexpr double kMegConst = 4e-14 * M_PI;
83
85constexpr double kEegConst = 1.0 / (4.0 * M_PI);
86
88constexpr double kEegIntradScale = 0.7;
89
91constexpr float kGradStd = 5e-13f; // 5 fT/cm (gradiometers)
92constexpr float kMagStd = 20e-15f; // 20 fT (magnetometers)
93constexpr float kEegStd = 1e-6f; // 0.2 µV (EEG, but value passed is 1 µV here matching _setup_dots)
94
95//=========================================================================
96// Legendre polynomials
97// Port of _next_legen_der / _get_legen_der / _get_legen in _lead_dots.py
98//=========================================================================
99
106void computeLegendreDer(double x, int ncoeff,
107 double* p, double* pd, double* pdd)
108{
109 p[0] = 1.0; pd[0] = 0.0; pdd[0] = 0.0;
110 if (ncoeff < 2) return;
111 p[1] = x; pd[1] = 1.0; pdd[1] = 0.0;
112 for (int n = 2; n < ncoeff; ++n) {
113 double old_p = p[n - 1];
114 double old_pd = pd[n - 1];
115 p[n] = ((2 * n - 1) * x * old_p - (n - 1) * p[n - 2]) / n;
116 pd[n] = n * old_p + x * old_pd;
117 pdd[n] = (n + 1) * old_pd + x * pdd[n - 1];
118 }
119}
120
126void computeLegendreVal(double x, int ncoeff, double* p)
127{
128 p[0] = 1.0;
129 if (ncoeff < 2) return;
130 p[1] = x;
131 for (int n = 2; n < ncoeff; ++n) {
132 p[n] = ((2 * n - 1) * x * p[n - 1] - (n - 1) * p[n - 2]) / n;
133 }
134}
135
136//=========================================================================
137// Series sums
138// Port of _comp_sums_meg / _comp_sum_eeg in _lead_dots.py
139//=========================================================================
140
150void compSumsMeg(double beta, double ctheta, double sums[4])
151{
152 double p[kNCoeff], pd[kNCoeff], pdd[kNCoeff];
153 computeLegendreDer(ctheta, kNCoeff, p, pd, pdd);
154
155 sums[0] = sums[1] = sums[2] = sums[3] = 0.0;
156 double betan = beta; // accumulates beta^(n+1)
157 for (int n = 1; n < kNCoeff; ++n) {
158 betan *= beta; // beta^(n+1)
159 double dn = static_cast<double>(n);
160 double multn = dn / (2.0 * dn + 1.0); // n / (2n+1)
161 double mult = multn / (dn + 1.0); // n / ((2n+1)(n+1))
162
163 sums[0] += (dn + 1.0) * multn * p[n] * betan;
164 sums[1] += multn * pd[n] * betan;
165 sums[2] += mult * pd[n] * betan;
166 sums[3] += mult * pdd[n] * betan;
167 }
168}
169
174double compSumEeg(double beta, double ctheta)
175{
176 double p[kNCoeff];
177 computeLegendreVal(ctheta, kNCoeff, p);
178
179 double sum = 0.0;
180 double betan = 1.0;
181 for (int n = 1; n < kNCoeff; ++n) {
182 betan *= beta; // beta^n
183 double dn = static_cast<double>(n);
184 double factor = 2.0 * dn + 1.0;
185 sum += p[n] * betan * factor * factor / dn;
186 }
187 return sum;
188}
189
190//=========================================================================
191// Sphere dot products
192// Port of _fast_sphere_dot_r0 in _lead_dots.py
193//=========================================================================
194
202double sphereDotMeg(double intrad,
203 const Vector3d& rr1, double lr1, const Vector3d& cosmag1,
204 const Vector3d& rr2, double lr2, const Vector3d& cosmag2)
205{
206 if (lr1 == 0.0 || lr2 == 0.0) return 0.0;
207
208 double beta = (intrad * intrad) / (lr1 * lr2);
209 double ct = std::clamp(rr1.dot(rr2), -1.0, 1.0);
210
211 double sums[4];
212 compSumsMeg(beta, ct, sums);
213
214 double n1c1 = cosmag1.dot(rr1);
215 double n1c2 = cosmag1.dot(rr2);
216 double n2c1 = cosmag2.dot(rr1);
217 double n2c2 = cosmag2.dot(rr2);
218 double n1n2 = cosmag1.dot(cosmag2);
219
220 double part1 = ct * n1c1 * n2c2;
221 double part2 = n1c1 * n2c1 + n1c2 * n2c2;
222
223 double result = n1c1 * n2c2 * sums[0]
224 + (2.0 * part1 - part2) * sums[1]
225 + (n1n2 + part1 - part2) * sums[2]
226 + (n1c2 - ct * n1c1) * (n2c1 - ct * n2c2) * sums[3];
227
228 result *= kMegConst / (lr1 * lr2);
229 return result;
230}
231
235double sphereDotEeg(double intrad,
236 const Vector3d& rr1, double lr1,
237 const Vector3d& rr2, double lr2)
238{
239 if (lr1 == 0.0 || lr2 == 0.0) return 0.0;
240
241 double beta = (intrad * intrad) / (lr1 * lr2);
242 double ct = std::clamp(rr1.dot(rr2), -1.0, 1.0);
243
244 double sum = compSumEeg(beta, ct);
245 return kEegConst * sum / (lr1 * lr2);
246}
247
248//=========================================================================
249// Coil data extraction
250// Port of the rmag/rlens/cosmags/ws extraction in _do_self_dots
251//=========================================================================
252
254struct CoilData
255{
256 std::vector<Vector3d> rmag; // normalised position vectors
257 std::vector<double> rlen; // magnitudes
258 std::vector<Vector3d> cosmag; // direction vectors
259 std::vector<double> w; // integration weights
260 int np; // number of integration points
261};
262
263Vector3d toVec3d(const float* v) { return Vector3d(v[0], v[1], v[2]); }
264
271CoilData extractCoilData(const FwdCoil* coil, const Vector3d& r0)
272{
273 CoilData cd;
274 cd.np = coil->np;
275 cd.rmag.resize(cd.np);
276 cd.rlen.resize(cd.np);
277 cd.cosmag.resize(cd.np);
278 cd.w.resize(cd.np);
279
280 for (int i = 0; i < cd.np; ++i) {
281 Vector3d rel = toVec3d(coil->rmag[i]) - r0;
282 double len = rel.norm();
283 cd.rmag[i] = (len > 0.0) ? Vector3d(rel / len) : Vector3d::Zero();
284 cd.rlen[i] = len;
285 cd.cosmag[i] = toVec3d(coil->cosmag[i]);
286 cd.w[i] = static_cast<double>(coil->w[i]);
287 }
288 return cd;
289}
290
291//=========================================================================
292// Self-dot and surface-dot matrices
293// Port of _do_self_dots / _do_surface_dots in _lead_dots.py
294//=========================================================================
295
302MatrixXd doSelfDots(double intrad, const FwdCoilSet& coils, const Vector3d& r0, bool isMeg)
303{
304 const int nc = coils.ncoil;
305 std::vector<CoilData> cdata(nc);
306 for (int i = 0; i < nc; ++i) {
307 cdata[i] = extractCoilData(coils.coils[i], r0);
308 }
309
310 MatrixXd products = MatrixXd::Zero(nc, nc);
311 for (int ci1 = 0; ci1 < nc; ++ci1) {
312 for (int ci2 = 0; ci2 <= ci1; ++ci2) {
313 double dot = 0.0;
314 const CoilData& c1 = cdata[ci1];
315 const CoilData& c2 = cdata[ci2];
316 for (int i = 0; i < c1.np; ++i) {
317 for (int j = 0; j < c2.np; ++j) {
318 double ww = c1.w[i] * c2.w[j];
319 if (isMeg) {
320 dot += ww * sphereDotMeg(intrad,
321 c1.rmag[i], c1.rlen[i], c1.cosmag[i],
322 c2.rmag[j], c2.rlen[j], c2.cosmag[j]);
323 } else {
324 dot += ww * sphereDotEeg(intrad,
325 c1.rmag[i], c1.rlen[i],
326 c2.rmag[j], c2.rlen[j]);
327 }
328 }
329 }
330 products(ci1, ci2) = dot;
331 products(ci2, ci1) = dot;
332 }
333 }
334 return products;
335}
336
347MatrixXd doSurfaceDots(double intrad, const FwdCoilSet& coils,
348 const MatrixX3f& rr, const MatrixX3f& nn,
349 const Vector3d& r0, bool isMeg)
350{
351 const int nc = coils.ncoil;
352 const int nv = rr.rows();
353
354 std::vector<CoilData> cdata(nc);
355 for (int i = 0; i < nc; ++i) {
356 cdata[i] = extractCoilData(coils.coils[i], r0);
357 }
358
359 MatrixXd products = MatrixXd::Zero(nv, nc);
360 for (int vi = 0; vi < nv; ++vi) {
361 // Surface vertex position relative to origin (normalised)
362 Vector3d rel = rr.row(vi).cast<double>() - r0.transpose();
363 double lsurf = rel.norm();
364 Vector3d rsurf = (lsurf > 0.0) ? Vector3d(rel / lsurf) : Vector3d::Zero();
365 Vector3d nsurf = nn.row(vi).cast<double>(); // surface normal (MEG cosmag)
366
367 for (int ci = 0; ci < nc; ++ci) {
368 const CoilData& c = cdata[ci];
369 double dot = 0.0;
370 for (int j = 0; j < c.np; ++j) {
371 if (isMeg) {
372 dot += c.w[j] * sphereDotMeg(intrad,
373 rsurf, lsurf, nsurf,
374 c.rmag[j], c.rlen[j], c.cosmag[j]);
375 } else {
376 dot += c.w[j] * sphereDotEeg(intrad,
377 rsurf, lsurf,
378 c.rmag[j], c.rlen[j]);
379 }
380 }
381 products(vi, ci) = dot;
382 }
383 }
384 return products;
385}
386
387//=========================================================================
388// Ad-hoc noise standard deviations
389// Matching MNE-Python make_ad_hoc_cov (called from _setup_dots)
390//=========================================================================
391
393VectorXd adHocMegStds(const FwdCoilSet& coils)
394{
395 VectorXd stds(coils.ncoil);
396 for (int k = 0; k < coils.ncoil; ++k) {
397 stds(k) = coils.coils[k]->is_axial_coil() ? static_cast<double>(kMagStd)
398 : static_cast<double>(kGradStd);
399 }
400 return stds;
401}
402
404VectorXd adHocEegStds(int ncoil)
405{
406 return VectorXd::Constant(ncoil, static_cast<double>(kEegStd));
407}
408
409//=========================================================================
410// Mapping matrix computation
411// Port of _compute_mapping_matrix / _pinv_trunc in _field_interpolation.py
412//=========================================================================
413
437QSharedPointer<MatrixXf> computeMappingMatrix(const MatrixXd& selfDots,
438 const MatrixXd& surfaceDots,
439 const VectorXd& noiseStds,
440 double miss,
441 const MatrixXd& projOp = MatrixXd(),
442 bool applyAvgRef = false)
443{
444 if (selfDots.rows() == 0 || surfaceDots.rows() == 0) {
445 return QSharedPointer<MatrixXf>();
446 }
447
448 const int nchan = selfDots.rows();
449
450 // Step 0: Apply SSP projector to self-dots
451 // proj_dots = P^T @ self_dots @ P
452 MatrixXd projDots;
453 bool hasProj = (projOp.rows() == nchan && projOp.cols() == nchan);
454 if (hasProj) {
455 projDots = projOp.transpose() * selfDots * projOp;
456 } else {
457 projDots = selfDots;
458 }
459
460 // Step 1: Build whitener = diag(1/std)
461 VectorXd whitener(nchan);
462 for (int i = 0; i < nchan; ++i) {
463 whitener(i) = (noiseStds(i) > 0.0) ? (1.0 / noiseStds(i)) : 0.0;
464 }
465
466 // Step 2: whitened_dots = whitener @ proj_dots @ whitener
467 MatrixXd whitenedDots = whitener.asDiagonal() * projDots * whitener.asDiagonal();
468
469 // Step 3: SVD → truncated pseudo-inverse (port of _pinv_trunc)
470 JacobiSVD<MatrixXd> svd(whitenedDots, ComputeFullU | ComputeFullV);
471 VectorXd s = svd.singularValues();
472 if (s.size() == 0 || s(0) <= 0.0) {
473 return QSharedPointer<MatrixXf>();
474 }
475
476 // Eigenvalue truncation: keep components explaining >= (1-miss) of total variance
477 // varexp = cumsum(s) / sum(s); n = first index where varexp >= (1-miss), +1
478 VectorXd varexp(s.size());
479 varexp(0) = s(0);
480 for (int i = 1; i < s.size(); ++i) {
481 varexp(i) = varexp(i - 1) + s(i);
482 }
483 double totalVar = varexp(s.size() - 1);
484
485 int n = s.size(); // keep all by default
486 for (int i = 0; i < s.size(); ++i) {
487 if (varexp(i) / totalVar >= (1.0 - miss)) {
488 n = i + 1;
489 break;
490 }
491 }
492
493 // Pseudo-inverse: inv = V[:,:n] @ diag(1/s[:n]) @ U[:,:n]^T
494 VectorXd sinv = VectorXd::Zero(s.size());
495 for (int i = 0; i < n; ++i) {
496 sinv(i) = (s(i) > 0.0) ? (1.0 / s(i)) : 0.0;
497 }
498 MatrixXd inv = svd.matrixV() * sinv.asDiagonal() * svd.matrixU().transpose();
499
500 // Step 4: inv_whitened = whitener @ inv @ whitener
501 MatrixXd invWhitened = whitener.asDiagonal() * inv * whitener.asDiagonal();
502
503 // Step 5: Apply projector to inverse (because surface_dots are unprojected)
504 // inv_whitened_proj = P^T @ inv_whitened
505 MatrixXd invWhitenedProj;
506 if (hasProj) {
507 invWhitenedProj = projOp.transpose() * invWhitened;
508 } else {
509 invWhitenedProj = invWhitened;
510 }
511
512 // Step 6: mapping = surface_dots @ inv_whitened_proj
513 MatrixXd mapping = surfaceDots * invWhitenedProj;
514
515 // Step 7: For EEG with average reference, subtract column means
516 if (applyAvgRef) {
517 VectorXd colMeans = mapping.colwise().mean();
518 mapping.rowwise() -= colMeans.transpose();
519 }
520
521 // Convert to float
522 MatrixXf mappingF = mapping.cast<float>();
523 return QSharedPointer<MatrixXf>::create(std::move(mappingF));
524}
525
526} // anonymous namespace
527
528//=============================================================================================================
529// DEFINE MEMBER METHODS
530//=============================================================================================================
531
532QSharedPointer<MatrixXf> FwdFieldMap::computeMegMapping(
533 const FwdCoilSet& coils,
534 const MatrixX3f& vertices,
535 const MatrixX3f& normals,
536 const Vector3f& origin,
537 float intrad,
538 float miss)
539{
540 if (coils.ncoil <= 0 || vertices.rows() == 0 || normals.rows() != vertices.rows()) {
541 return QSharedPointer<MatrixXf>();
542 }
543
544 const Vector3d r0 = origin.cast<double>();
545
546 // Compute self-dot matrix (nchan × nchan) in double precision
547 MatrixXd selfDots = doSelfDots(intrad, coils, r0, /*isMeg=*/true);
548
549 // Compute surface-dot matrix (nvert × nchan) in double precision
550 MatrixXd surfaceDots = doSurfaceDots(intrad, coils, vertices, normals, r0, /*isMeg=*/true);
551
552 // Ad-hoc noise for whitening
553 VectorXd stds = adHocMegStds(coils);
554
555 return computeMappingMatrix(selfDots, surfaceDots, stds, static_cast<double>(miss));
556}
557
558QSharedPointer<MatrixXf> FwdFieldMap::computeMegMapping(
559 const FwdCoilSet& coils,
560 const MatrixX3f& vertices,
561 const MatrixX3f& normals,
562 const Vector3f& origin,
563 const FIFFLIB::FiffInfo& info,
564 const QStringList& chNames,
565 float intrad,
566 float miss)
567{
568 if (coils.ncoil <= 0 || vertices.rows() == 0 || normals.rows() != vertices.rows()) {
569 return QSharedPointer<MatrixXf>();
570 }
571
572 const Vector3d r0 = origin.cast<double>();
573
574 MatrixXd selfDots = doSelfDots(intrad, coils, r0, /*isMeg=*/true);
575 MatrixXd surfaceDots = doSurfaceDots(intrad, coils, vertices, normals, r0, /*isMeg=*/true);
576 VectorXd stds = adHocMegStds(coils);
577
578 // Build SSP projector
579 MatrixXd projOp;
580 FIFFLIB::FiffProj::make_projector(info.projs, chNames, projOp, info.bads);
581
582 return computeMappingMatrix(selfDots, surfaceDots, stds,
583 static_cast<double>(miss), projOp, false);
584}
585
586QSharedPointer<MatrixXf> FwdFieldMap::computeEegMapping(
587 const FwdCoilSet& coils,
588 const MatrixX3f& vertices,
589 const Vector3f& origin,
590 float intrad,
591 float miss)
592{
593 if (coils.ncoil <= 0 || vertices.rows() == 0) {
594 return QSharedPointer<MatrixXf>();
595 }
596
597 // EEG uses scaled integration radius (matching _do_self_dots / _do_surface_dots)
598 const double eegIntrad = intrad * kEegIntradScale;
599 const Vector3d r0 = origin.cast<double>();
600
601 // For EEG surface dots we still need normals; since the EEG sphere dot
602 // product does not use cosmag1, we supply zero normals (they are unused).
603 MatrixX3f dummyNormals = MatrixX3f::Zero(vertices.rows(), 3);
604
605 // Compute self-dot matrix (nchan × nchan) in double precision
606 MatrixXd selfDots = doSelfDots(eegIntrad, coils, r0, /*isMeg=*/false);
607
608 // Compute surface-dot matrix (nvert × nchan) in double precision
609 MatrixXd surfaceDots = doSurfaceDots(eegIntrad, coils, vertices, dummyNormals, r0, /*isMeg=*/false);
610
611 // Ad-hoc noise for whitening
612 VectorXd stds = adHocEegStds(coils.ncoil);
613
614 return computeMappingMatrix(selfDots, surfaceDots, stds, static_cast<double>(miss));
615}
616
617QSharedPointer<MatrixXf> FwdFieldMap::computeEegMapping(
618 const FwdCoilSet& coils,
619 const MatrixX3f& vertices,
620 const Vector3f& origin,
621 const FIFFLIB::FiffInfo& info,
622 const QStringList& chNames,
623 float intrad,
624 float miss)
625{
626 if (coils.ncoil <= 0 || vertices.rows() == 0) {
627 return QSharedPointer<MatrixXf>();
628 }
629
630 const double eegIntrad = intrad * kEegIntradScale;
631 const Vector3d r0 = origin.cast<double>();
632
633 MatrixX3f dummyNormals = MatrixX3f::Zero(vertices.rows(), 3);
634
635 MatrixXd selfDots = doSelfDots(eegIntrad, coils, r0, /*isMeg=*/false);
636 MatrixXd surfaceDots = doSurfaceDots(eegIntrad, coils, vertices, dummyNormals, r0, /*isMeg=*/false);
637 VectorXd stds = adHocEegStds(coils.ncoil);
638
639 // Build SSP projector
640 MatrixXd projOp;
641 FIFFLIB::FiffProj::make_projector(info.projs, chNames, projOp, info.bads);
642
643 // Check for average EEG reference projection
644 // Matches MNE-Python's _has_eeg_average_ref_proj:
645 // - kind == FIFFV_PROJ_ITEM_EEG_AVREF (10), OR
646 // - desc matches "Average .* reference"
647 // Note: Python default is check_active=False, so we don't require active.
648 bool hasAvgRef = false;
649 for (const auto& proj : info.projs) {
650 if (proj.kind == FIFFV_PROJ_ITEM_EEG_AVREF ||
651 proj.desc.contains(QRegularExpression("^Average .* reference$",
652 QRegularExpression::CaseInsensitiveOption))) {
653 hasAvgRef = true;
654 break;
655 }
656 }
657
658 return computeMappingMatrix(selfDots, surfaceDots, stds,
659 static_cast<double>(miss), projOp, hasAvgRef);
660}
#define M_PI
FiffProj class declaration.
Header file describing the numerical values used in fif files.
#define FIFFV_PROJ_ITEM_EEG_AVREF
Definition fiff_file.h:828
Sphere-model field mapping.
Forward modelling (BEM, MEG/EEG lead fields).
Definition compute_fwd.h:95
QList< FiffProj > projs
Definition fiff_info.h:256
static fiff_int_t make_projector(const QList< FiffProj > &projs, const QStringList &ch_names, Eigen::MatrixXd &proj, const QStringList &bads=defaultQStringList, Eigen::MatrixXd &U=defaultMatrixXd)
Single MEG or EEG sensor coil with integration points, weights, and coordinate frame.
Definition fwd_coil.h:89
float ** rmag
Definition fwd_coil.h:180
bool is_axial_coil() const
Definition fwd_coil.cpp:268
float ** cosmag
Definition fwd_coil.h:181
Collection of FwdCoil objects representing a full MEG or EEG sensor array.
static QSharedPointer< Eigen::MatrixXf > computeEegMapping(const FwdCoilSet &coils, const Eigen::MatrixX3f &vertices, const Eigen::Vector3f &origin, float intrad=0.06f, float miss=1e-3f)
static QSharedPointer< Eigen::MatrixXf > computeMegMapping(const FwdCoilSet &coils, const Eigen::MatrixX3f &vertices, const Eigen::MatrixX3f &normals, const Eigen::Vector3f &origin, float intrad=0.06f, float miss=1e-4f)