v2.0.0
Loading...
Searching...
No Matches
sensorfieldmapper.cpp
Go to the documentation of this file.
1//=============================================================================================================
34
35//=============================================================================================================
36// INCLUDES
37//=============================================================================================================
38
39#include "sensorfieldmapper.h"
41#include "core/rendertypes.h"
42
43#include <fwd/fwd_field_map.h>
44#include <Eigen/LU>
45
46#include <fiff/fiff_ch_info.h>
47#include <fiff/fiff_constants.h>
49#include <fwd/fwd_coil_set.h>
50#include <fs/fs_surface.h>
52
53#include <QCoreApplication>
54#include <QVector3D>
55#include <QMatrix4x4>
56#include <QDebug>
57#include <cmath>
58
59using namespace FIFFLIB;
60
61//=============================================================================================================
62// ANONYMOUS HELPERS
63//=============================================================================================================
64
65namespace
66{
67
71Eigen::Vector3f applyTransform(const Eigen::Vector3f &point,
72 const FiffCoordTrans &trans)
73{
74 if (trans.isEmpty()) return point;
75 float r[3] = {point.x(), point.y(), point.z()};
77 return Eigen::Vector3f(r[0], r[1], r[2]);
78}
79
80} // anonymous namespace
81
82//=============================================================================================================
83// MEMBER METHODS
84//=============================================================================================================
85
87{
88 m_evoked = evoked;
89 m_loaded = (m_evoked.nave != -1 && m_evoked.data.rows() > 0);
90
91 // Apply baseline correction if not already applied.
92 // This matches MNE-Python's default baseline=(None, 0) which subtracts
93 // the mean of the pre-stimulus period (t < 0) from each channel.
94 // Without baseline correction the DC offset dominates the mapped field,
95 // causing it to appear static ("slightly wobbling") instead of showing
96 // the actual temporal evolution of the neural response.
97 if (m_loaded && m_evoked.baseline.first == m_evoked.baseline.second) {
98 // Find earliest time and t=0 boundaries
99 float tmin = m_evoked.times.size() > 0 ? m_evoked.times(0) : 0.0f;
100 if (tmin < 0.0f) {
101 QPair<float,float> bl(tmin, 0.0f);
102 m_evoked.applyBaselineCorrection(bl);
103 }
104 }
105}
106
107//=============================================================================================================
108
110{
111 // No existing mapping to reuse
112 if (!m_loaded || (!m_megMapping && !m_eegMapping))
113 return false;
114
115 // Quick check: same number of channels
116 if (m_evoked.info.chs.size() != newEvoked.info.chs.size())
117 return false;
118
119 // Same channel names in same order
120 for (int i = 0; i < m_evoked.info.chs.size(); ++i) {
121 if (m_evoked.info.chs[i].ch_name != newEvoked.info.chs[i].ch_name)
122 return false;
123 }
124
125 // Same bad channels
126 if (m_evoked.info.bads != newEvoked.info.bads)
127 return false;
128
129 // Same number of SSP projectors
130 if (m_evoked.info.projs.size() != newEvoked.info.projs.size())
131 return false;
132
133 // Same dev_head transform (sensor positions)
134 if (m_evoked.info.dev_head_t.trans != newEvoked.info.dev_head_t.trans)
135 return false;
136
137 return true;
138}
139
140//=============================================================================================================
141
143 const QMap<QString, std::shared_ptr<BrainSurface>> &surfaces)
144{
145 if (surfaces.contains("bem_head"))
146 return QStringLiteral("bem_head");
147
148 QString fallback;
149 for (auto it = surfaces.cbegin(); it != surfaces.cend(); ++it) {
150 if (!it.key().startsWith("bem_")) continue;
151 if (it.value() && it.value()->tissueType() == BrainSurface::TissueSkin)
152 return it.key();
153 if (fallback.isEmpty())
154 fallback = it.key();
155 }
156 return fallback;
157}
158
159//=============================================================================================================
160
162 const QMap<QString, std::shared_ptr<BrainSurface>> &surfaces)
163{
164 return surfaces.contains("sens_surface_meg")
165 ? QStringLiteral("sens_surface_meg")
166 : QString();
167}
168
169//=============================================================================================================
170
171float SensorFieldMapper::contourStep(float minVal, float maxVal, int targetTicks)
172{
173 if (targetTicks <= 0) return 0.0f;
174 const double range = static_cast<double>(maxVal - minVal);
175 if (range <= 0.0) return 0.0f;
176
177 const double raw = range / static_cast<double>(targetTicks);
178 const double exponent = std::floor(std::log10(raw));
179 const double base = std::pow(10.0, exponent);
180 const double frac = raw / base;
181
182 double niceFrac = 1.0;
183 if (frac <= 1.0) niceFrac = 1.0;
184 else if (frac <= 2.0) niceFrac = 2.0;
185 else if (frac <= 5.0) niceFrac = 5.0;
186 else niceFrac = 10.0;
187
188 return static_cast<float>(niceFrac * base);
189}
190
191//=============================================================================================================
192
194 const QMap<QString, std::shared_ptr<BrainSurface>> &surfaces,
195 const FiffCoordTrans &headToMriTrans,
196 bool applySensorTrans)
197{
198 if (!m_loaded || m_evoked.isEmpty()) return false;
199
200 // ── Reset state ────────────────────────────────────────────────────
201 m_megPick.resize(0);
202 m_eegPick.resize(0);
203 m_megPositions.clear();
204 m_eegPositions.clear();
205 m_megMapping.reset();
206 m_eegMapping.reset();
207
208 // ── Resolve target surfaces ────────────────────────────────────────
209 m_megSurfaceKey = m_megOnHead
210 ? findHeadSurfaceKey(surfaces)
211 : findHelmetSurfaceKey(surfaces);
212
213 if (m_megOnHead && m_megSurfaceKey.isEmpty()) {
214 m_megSurfaceKey = findHelmetSurfaceKey(surfaces);
215 if (!m_megSurfaceKey.isEmpty())
216 qWarning() << "SensorFieldMapper: Head surface missing, falling back to helmet.";
217 }
218 m_eegSurfaceKey = findHeadSurfaceKey(surfaces);
219
220 if (m_megSurfaceKey.isEmpty() && m_eegSurfaceKey.isEmpty()) {
221 qWarning() << "SensorFieldMapper: No helmet/head surface for field mapping.";
222 return false;
223 }
224
225 // ── Build coordinate transforms ────────────────────────────────────
226 bool hasDevHead = false;
227 QMatrix4x4 devHeadQt;
228 if (!m_evoked.info.dev_head_t.isEmpty() &&
229 m_evoked.info.dev_head_t.from == FIFFV_COORD_DEVICE &&
230 m_evoked.info.dev_head_t.to == FIFFV_COORD_HEAD &&
231 !m_evoked.info.dev_head_t.trans.isIdentity()) {
232 hasDevHead = true;
233 for (int r = 0; r < 4; ++r)
234 for (int c = 0; c < 4; ++c)
235 devHeadQt(r, c) = m_evoked.info.dev_head_t.trans(r, c);
236 }
237
238 QMatrix4x4 headToMri;
239 if (applySensorTrans && !headToMriTrans.isEmpty()) {
240 for (int r = 0; r < 4; ++r)
241 for (int c = 0; c < 4; ++c)
242 headToMri(r, c) = headToMriTrans.trans(r, c);
243 }
244
245 // ── Classify channels ──────────────────────────────────────────────
246 QList<FiffChInfo> megChs, eegChs;
247 QStringList megChNames, eegChNames;
248
249 auto isBad = [this](const QString &name) {
250 return m_evoked.info.bads.contains(name);
251 };
252
253 const int nChs = m_evoked.info.chs.size();
254 m_megPick.resize(nChs); // upper bound
255 m_eegPick.resize(nChs);
256 int nMeg = 0, nEeg = 0;
257
258 for (int k = 0; k < nChs; ++k) {
259 const auto &ch = m_evoked.info.chs[k];
260 if (isBad(ch.ch_name)) continue;
261
262 QVector3D pos(ch.chpos.r0(0), ch.chpos.r0(1), ch.chpos.r0(2));
263
264 if (ch.kind == FIFFV_MEG_CH) {
265 if (hasDevHead) pos = devHeadQt.map(pos);
266 if (applySensorTrans && !headToMriTrans.isEmpty()) pos = headToMri.map(pos);
267 m_megPick(nMeg++) = k;
268 m_megPositions.push_back(Eigen::Vector3f(pos.x(), pos.y(), pos.z()));
269 megChs.append(ch);
270 megChNames.append(ch.ch_name);
271 } else if (ch.kind == FIFFV_EEG_CH) {
272 if (applySensorTrans && !headToMriTrans.isEmpty()) pos = headToMri.map(pos);
273 m_eegPick(nEeg++) = k;
274 m_eegPositions.push_back(Eigen::Vector3f(pos.x(), pos.y(), pos.z()));
275 eegChs.append(ch);
276 eegChNames.append(ch.ch_name);
277 }
278 }
279
280 m_megPick.conservativeResize(nMeg);
281 m_eegPick.conservativeResize(nEeg);
282
283 // ── Constants (matching MNE-Python) ────────────────────────────────
284 constexpr float kIntrad = 0.06f;
285 constexpr float kMegMiss = 1e-4f;
286 constexpr float kEegMiss = 1e-3f;
287
288 // Fit sphere origin to digitisation points (matching MNE-Python's
289 // make_field_map with origin='auto').
290 const Eigen::Vector3f fittedOrigin = fitSphereOrigin(m_evoked.info);
291
292 FiffCoordTrans headMri = (applySensorTrans && !headToMriTrans.isEmpty())
293 ? headToMriTrans : FiffCoordTrans();
294 FiffCoordTrans devHead = (!m_evoked.info.dev_head_t.isEmpty() &&
295 m_evoked.info.dev_head_t.from == FIFFV_COORD_DEVICE &&
296 m_evoked.info.dev_head_t.to == FIFFV_COORD_HEAD)
297 ? m_evoked.info.dev_head_t : FiffCoordTrans();
298
299 // ── MEG mapping ────────────────────────────────────────────────────
300 if (!m_megSurfaceKey.isEmpty() && surfaces.contains(m_megSurfaceKey) && !megChs.isEmpty()) {
301 const BrainSurface &surf = *surfaces[m_megSurfaceKey];
302 Eigen::MatrixX3f verts = surf.vertexPositions();
303 Eigen::MatrixX3f norms = surf.vertexNormals();
304
305 // Recompute normals if missing
306 if (norms.rows() != verts.rows()) {
307 const QVector<uint32_t> idx = surf.triangleIndices();
308 const int nTris = idx.size() / 3;
309 if (nTris > 0) {
310 Eigen::MatrixX3i tris(nTris, 3);
311 for (int t = 0; t < nTris; ++t) {
312 tris(t, 0) = static_cast<int>(idx[t * 3]);
313 tris(t, 1) = static_cast<int>(idx[t * 3 + 1]);
314 tris(t, 2) = static_cast<int>(idx[t * 3 + 2]);
315 }
316 norms = FSLIB::FsSurface::compute_normals(verts, tris);
317 }
318 }
319
320 if (verts.rows() > 0 && norms.rows() == verts.rows()) {
321 const QString coilPath = QCoreApplication::applicationDirPath()
322 + "/../resources/general/coilDefinitions/coil_def.dat";
323 auto templates =
325
326 if (templates) {
327 FiffCoordTrans devToTarget;
328 if (m_megOnHead && !headMri.isEmpty()) {
329 if (!devHead.isEmpty()) {
330 devToTarget = FiffCoordTrans::combine(
332 devHead, headMri);
333 }
334 } else if (!devHead.isEmpty()) {
335 devToTarget = devHead;
336 }
337
338 Eigen::Vector3f origin = fittedOrigin;
339 if (m_megOnHead && !headMri.isEmpty())
340 origin = applyTransform(origin, headMri);
341
342 auto coils = templates->create_meg_coils(
343 megChs, megChs.size(), FWDLIB::FWD_COIL_ACCURACY_NORMAL, devToTarget);
344
345 if (coils && coils->ncoil() > 0) {
347 *coils, verts, norms, origin,
348 m_evoked.info, megChNames,
349 kIntrad, kMegMiss);
350 }
351 } else {
352 qWarning() << "MEG coil definitions not found at" << coilPath;
353 }
354 }
355 }
356
357 // ── EEG mapping ────────────────────────────────────────────────────
358 if (!m_eegSurfaceKey.isEmpty() && surfaces.contains(m_eegSurfaceKey) && !eegChs.isEmpty()) {
359 const BrainSurface &surf = *surfaces[m_eegSurfaceKey];
360 Eigen::MatrixX3f verts = surf.vertexPositions();
361
362 if (verts.rows() > 0) {
363 Eigen::Vector3f origin = fittedOrigin;
364 if (!headMri.isEmpty()) origin = applyTransform(origin, headMri);
365
366 auto eegCoils =
368 eegChs, eegChs.size(), headMri);
369
370 if (eegCoils && eegCoils->ncoil() > 0) {
372 *eegCoils, verts, origin,
373 m_evoked.info, eegChNames,
374 kIntrad, kEegMiss);
375 }
376 }
377 }
378
380 return true;
381}
382
383//=============================================================================================================
384
386 float *radius)
387{
388 const Eigen::Vector3f fallback(0.0f, 0.0f, 0.04f);
389
390 // ── Gather head-frame digitization points ──────────────────────────
391 // MNE-Python's fit_sphere_to_headshape (bem.py) first tries
392 // FIFFV_POINT_EXTRA only; if < 4 points, falls back to EXTRA + EEG.
393 // Points in the nose/face region (z < 0 && y > 0) are excluded.
394
395 auto gatherPoints = [&](bool includeEeg) -> Eigen::MatrixXd {
396 QVector<Eigen::Vector3d> pts;
397 for (const auto &dp : info.dig) {
398 if (dp.coord_frame != FIFFV_COORD_HEAD)
399 continue;
400 if (dp.kind == FIFFV_POINT_EXTRA ||
401 (includeEeg && dp.kind == FIFFV_POINT_EEG)) {
402 const double x = dp.r[0], y = dp.r[1], z = dp.r[2];
403 // Exclude nose / face region
404 if (z < 0.0 && y > 0.0)
405 continue;
406 pts.append(Eigen::Vector3d(x, y, z));
407 }
408 }
409
410 Eigen::MatrixXd mat(pts.size(), 3);
411 for (int i = 0; i < pts.size(); ++i)
412 mat.row(i) = pts[i].transpose();
413 return mat;
414 };
415
416 Eigen::MatrixXd points = gatherPoints(false); // EXTRA only
417 if (points.rows() < 4)
418 points = gatherPoints(true); // EXTRA + EEG
419 if (points.rows() < 4) {
420 qWarning() << "SensorFieldMapper::fitSphereOrigin: fewer than 4 dig "
421 "points – falling back to default origin (0, 0, 0.04).";
422 if (radius) *radius = 0.0f;
423 return fallback;
424 }
425
426 // ── Linear least-squares sphere fit ────────────────────────────────
427 // Expanding (x-cx)^2 + (y-cy)^2 + (z-cz)^2 = R^2 gives:
428 // 2*cx*x + 2*cy*y + 2*cz*z + (R^2 - cx^2 - cy^2 - cz^2) = x^2 + y^2 + z^2
429 // which is linear in [cx, cy, cz, D] with D = R^2 - cx^2 - cy^2 - cz^2.
430 const int n = static_cast<int>(points.rows());
431 Eigen::MatrixXd A(n, 4);
432 Eigen::VectorXd b(n);
433 for (int i = 0; i < n; ++i) {
434 A(i, 0) = 2.0 * points(i, 0);
435 A(i, 1) = 2.0 * points(i, 1);
436 A(i, 2) = 2.0 * points(i, 2);
437 A(i, 3) = 1.0;
438 b(i) = points(i, 0) * points(i, 0)
439 + points(i, 1) * points(i, 1)
440 + points(i, 2) * points(i, 2);
441 }
442
443 // Solve via normal equations: x = (A^T A)^{-1} A^T b
444 // The 4x4 system (A^T A) is tiny and well-conditioned for n >> 4.
445 // Use Cramer's rule via Eigen's fixed-size matrix solve.
446 Eigen::Matrix4d AtA = A.transpose() * A;
447 Eigen::Vector4d Atb = A.transpose() * b;
448 // Full-pivot LU for a 4×4 matrix — no extra Eigen module needed.
449 Eigen::Vector4d x;
450 x = AtA.fullPivLu().solve(Atb);
451
452 const float cx = static_cast<float>(x(0));
453 const float cy = static_cast<float>(x(1));
454 const float cz = static_cast<float>(x(2));
455 const float R = static_cast<float>(
456 std::sqrt(x(0) * x(0) + x(1) * x(1) + x(2) * x(2) + x(3)));
457
458 if (radius) *radius = R;
459
460 return Eigen::Vector3f(cx, cy, cz);
461}
462
463//=============================================================================================================
464
466{
467 m_megVmax = 0.0f;
468 m_eegVmax = 0.0f;
469
470 if (!m_loaded || m_evoked.isEmpty())
471 return;
472
473 const int nTimes = static_cast<int>(m_evoked.data.cols());
474
475 // ── Helper: find peak-GFP time for a set of channels ───────────────
476 // GFP = sqrt(mean(V_i^2)). We only need the argmax, so comparing
477 // the sum-of-squares is sufficient (avoids sqrt).
478 auto peakGfpTime = [&](const Eigen::VectorXi &pick) -> int {
479 if (pick.size() == 0 || nTimes == 0) return 0;
480 int best = 0;
481 double bestSS = -1.0;
482 for (int t = 0; t < nTimes; ++t) {
483 double ss = 0.0;
484 for (int i = 0; i < pick.size(); ++i) {
485 double v = m_evoked.data(pick(i), t);
486 ss += v * v;
487 }
488 if (ss > bestSS) { bestSS = ss; best = t; }
489 }
490 return best;
491 };
492
493 // MEG: anchor vmax to the peak-GFP time point.
494 // MNE-Python's plot_field defaults to showing the evoked peak, so its
495 // vmax = max(|mapped|) is effectively computed at peak GFP. Using
496 // abs so the symmetric range [-vmax, vmax] always covers both poles.
497 if (m_megMapping && m_megMapping->rows() > 0 && m_megPick.size() > 0) {
498 const int tPeak = peakGfpTime(m_megPick);
499 Eigen::VectorXf meas(m_megPick.size());
500 for (int i = 0; i < m_megPick.size(); ++i)
501 meas(i) = static_cast<float>(m_evoked.data(m_megPick(i), tPeak));
502
503 Eigen::VectorXf mapped = (*m_megMapping) * meas;
504 m_megVmax = mapped.cwiseAbs().maxCoeff();
505 }
506
507 // EEG: same strategy
508 if (m_eegMapping && m_eegMapping->rows() > 0 && m_eegPick.size() > 0) {
509 const int tPeak = peakGfpTime(m_eegPick);
510 Eigen::VectorXf meas(m_eegPick.size());
511 for (int i = 0; i < m_eegPick.size(); ++i)
512 meas(i) = static_cast<float>(m_evoked.data(m_eegPick(i), tPeak));
513
514 Eigen::VectorXf mapped = (*m_eegMapping) * meas;
515 m_eegVmax = mapped.cwiseAbs().maxCoeff();
516 }
517
518 if (m_megVmax <= 0.0f) m_megVmax = 1.0f;
519 if (m_eegVmax <= 0.0f) m_eegVmax = 1.0f;
520}
521
522//=============================================================================================================
523
525 QMap<QString, std::shared_ptr<BrainSurface>> &surfaces,
526 const SubView &singleView,
527 const QVector<SubView> &subViews)
528{
529 if (!m_loaded || m_evoked.isEmpty()) return;
530
531 // ── Lambda that maps one modality onto its target surface ───────────
532 auto applyMap = [&](const QString &key,
533 const QString &contourPrefix,
534 const Eigen::VectorXi &pick,
535 const Eigen::MatrixXf *mat,
536 float globalMaxAbs,
537 bool visible,
538 bool showContours) {
539 if (key.isEmpty() || !surfaces.contains(key)) return;
540
541 auto surface = surfaces[key];
542 if (!visible || !mat || pick.size() == 0) {
543 surface->setVisualizationMode(BrainSurface::ModeSurface);
544 updateContourSurfaces(surfaces, contourPrefix, *surface,
545 QVector<float>(), 0.0f, false);
546 return;
547 }
548 if (mat->cols() != pick.size()) {
549 surface->setVisualizationMode(BrainSurface::ModeSurface);
550 updateContourSurfaces(surfaces, contourPrefix, *surface,
551 QVector<float>(), 0.0f, false);
552 return;
553 }
554
555 // Assemble measurement vector
556 Eigen::VectorXf meas(pick.size());
557 for (int i = 0; i < pick.size(); ++i)
558 meas(i) = static_cast<float>(m_evoked.data(pick(i), m_timePoint));
559
560 Eigen::VectorXf mapped = (*mat) * meas;
561
562 // Use normalisation range (computed at the anchor time point,
563 // matching MNE-Python's plot_field vmax behaviour).
564 const float maxAbs = globalMaxAbs;
565
566 // Per-vertex ABGR colours
567 QVector<uint32_t> colors(mapped.size());
568 for (int i = 0; i < mapped.size(); ++i) {
569 double norm = (mapped(i) / maxAbs) * 0.5 + 0.5;
570 norm = qBound(0.0, norm, 1.0);
571
572 QRgb rgb = (m_colormap == "MNE")
573 ? mneAnalyzeColor(norm)
574 : DISPLIB::ColorMap::valueToColor(norm, m_colormap);
575
576 uint32_t r = qRed(rgb);
577 uint32_t g = qGreen(rgb);
578 uint32_t b = qBlue(rgb);
579 colors[i] = packABGR(r, g, b);
580 }
581 surface->applySourceEstimateColors(colors);
582
583 // Contour lines — 21 levels matching MNE-Python's default
584 // (linspace(-vmax, vmax, 21) → step = vmax / 10)
585 QVector<float> values(mapped.size());
586 for (int i = 0; i < mapped.size(); ++i)
587 values[i] = mapped(i);
588
589 constexpr int nContours = 21;
590 float step = (2.0f * maxAbs) / static_cast<float>(nContours - 1);
591 updateContourSurfaces(surfaces, contourPrefix, *surface,
592 values, step, showContours);
593 };
594
595 // ── Aggregate visibility across all views ──────────────────────────
596 bool anyMegField = singleView.visibility.megFieldMap;
597 bool anyEegField = singleView.visibility.eegFieldMap;
598 bool anyMegContours = singleView.visibility.megFieldContours;
599 bool anyEegContours = singleView.visibility.eegFieldContours;
600 for (int i = 0; i < subViews.size(); ++i) {
601 anyMegField |= subViews[i].visibility.megFieldMap;
602 anyEegField |= subViews[i].visibility.eegFieldMap;
603 anyMegContours |= subViews[i].visibility.megFieldContours;
604 anyEegContours |= subViews[i].visibility.eegFieldContours;
605 }
606
607 applyMap(m_megSurfaceKey, m_megContourPrefix,
608 m_megPick, m_megMapping.get(),
609 m_megVmax,
610 anyMegField, anyMegContours);
611
612 applyMap(m_eegSurfaceKey, m_eegContourPrefix,
613 m_eegPick, m_eegMapping.get(),
614 m_eegVmax,
615 anyEegField, anyEegContours);
616}
617
618//=============================================================================================================
619
620void SensorFieldMapper::updateContourSurfaces(
621 QMap<QString, std::shared_ptr<BrainSurface>> &surfaces,
622 const QString &prefix,
623 const BrainSurface &surface,
624 const QVector<float> &values,
625 float step,
626 bool visible)
627{
628 // ── Helper: hide all three contour sets ─────────────────────────────
629 auto hideContours = [&]() {
630 for (const auto &suffix : {QStringLiteral("_neg"),
631 QStringLiteral("_zero"),
632 QStringLiteral("_pos")}) {
633 const QString key = prefix + suffix;
634 if (surfaces.contains(key)) surfaces[key]->setVisible(false);
635 }
636 };
637
638 if (!visible || values.isEmpty() || step <= 0.0f) {
639 hideContours();
640 return;
641 }
642
643 // ── Value range ────────────────────────────────────────────────────
644 float minVal = values[0], maxVal = values[0];
645 for (int i = 1; i < values.size(); ++i) {
646 minVal = std::min(minVal, values[i]);
647 maxVal = std::max(maxVal, values[i]);
648 }
649
650 // ── Contour levels ─────────────────────────────────────────────────
651 QVector<float> negLevels, posLevels;
652 const bool hasZero = (minVal < 0.0f && maxVal > 0.0f);
653 for (float lv = -step; lv >= minVal; lv -= step) negLevels.append(lv);
654 for (float lv = step; lv <= maxVal; lv += step) posLevels.append(lv);
655
656 // ── Segment buffer ─────────────────────────────────────────────────
657 struct ContourBuf {
658 QVector<Eigen::Vector3f> verts;
659 QVector<Eigen::Vector3f> norms;
660 QVector<Eigen::Vector3i> tris;
661 };
662
663 auto addSegment = [](ContourBuf &buf,
664 const QVector3D &p0, const QVector3D &p1,
665 const QVector3D &normal,
666 float halfW, float shift) {
667 QVector3D dir = p1 - p0;
668 const float len = dir.length();
669 if (len < 1e-6f) return;
670 dir /= len;
671
672 QVector3D binormal = QVector3D::crossProduct(normal, dir);
673 if (binormal.length() < 1e-6f)
674 binormal = QVector3D::crossProduct(QVector3D(0, 1, 0), dir);
675 if (binormal.length() < 1e-6f)
676 binormal = QVector3D::crossProduct(QVector3D(1, 0, 0), dir);
677 binormal.normalize();
678
679 const QVector3D off = normal * shift;
680
681 auto toEig = [](const QVector3D &v) {
682 return Eigen::Vector3f(v.x(), v.y(), v.z());
683 };
684
685 // Horizontal quad: width along binormal (visible from above)
686 {
687 const QVector3D w = binormal * halfW;
688 const int base = buf.verts.size();
689 Eigen::Vector3f n(normal.x(), normal.y(), normal.z());
690
691 buf.verts.append(toEig(p0 - w + off));
692 buf.verts.append(toEig(p0 + w + off));
693 buf.verts.append(toEig(p1 - w + off));
694 buf.verts.append(toEig(p1 + w + off));
695 buf.norms.append(n); buf.norms.append(n);
696 buf.norms.append(n); buf.norms.append(n);
697 buf.tris.append(Eigen::Vector3i(base, base + 1, base + 2));
698 buf.tris.append(Eigen::Vector3i(base + 1, base + 3, base + 2));
699 }
700
701 // Vertical quad: height along normal (visible from the side)
702 {
703 const QVector3D h = normal * halfW;
704 const int base = buf.verts.size();
705 Eigen::Vector3f n(binormal.x(), binormal.y(), binormal.z());
706
707 buf.verts.append(toEig(p0 - h + off));
708 buf.verts.append(toEig(p0 + h + off));
709 buf.verts.append(toEig(p1 - h + off));
710 buf.verts.append(toEig(p1 + h + off));
711 buf.norms.append(n); buf.norms.append(n);
712 buf.norms.append(n); buf.norms.append(n);
713 buf.tris.append(Eigen::Vector3i(base, base + 1, base + 2));
714 buf.tris.append(Eigen::Vector3i(base + 1, base + 3, base + 2));
715 }
716 };
717
718 // ── Marching-triangle iso-line extraction ──────────────────────────
719 auto buildContours = [&](const QVector<float> &levels, ContourBuf &buf) {
720 const Eigen::MatrixX3f rr = surface.vertexPositions();
721 const Eigen::MatrixX3f nn = surface.vertexNormals();
722 const QVector<uint32_t> idx = surface.triangleIndices();
723 if (rr.rows() == 0 || nn.rows() == 0 || idx.isEmpty()) return;
724
725 constexpr float shift = 0.001f;
726 constexpr float halfW = 0.0005f;
727
728 for (float level : levels) {
729 for (int t = 0; t + 2 < idx.size(); t += 3) {
730 const int i0 = idx[t], i1 = idx[t + 1], i2 = idx[t + 2];
731 const float v0 = values[i0], v1 = values[i1], v2 = values[i2];
732
733 QVector3D p0(rr(i0, 0), rr(i0, 1), rr(i0, 2));
734 QVector3D p1(rr(i1, 0), rr(i1, 1), rr(i1, 2));
735 QVector3D p2(rr(i2, 0), rr(i2, 1), rr(i2, 2));
736
737 QVector3D n0(nn(i0, 0), nn(i0, 1), nn(i0, 2));
738 QVector3D n1(nn(i1, 0), nn(i1, 1), nn(i1, 2));
739 QVector3D n2(nn(i2, 0), nn(i2, 1), nn(i2, 2));
740 QVector3D triN = (n0 + n1 + n2).normalized();
741 if (triN.length() < 1e-6f)
742 triN = QVector3D::crossProduct(p1 - p0, p2 - p0).normalized();
743
744 QVector<QVector3D> hits;
745 auto checkEdge = [&](const QVector3D &a, const QVector3D &b,
746 float va, float vb) {
747 if (va == vb) return;
748 float tval = (level - va) / (vb - va);
749 if (tval >= 0.0f && tval < 1.0f)
750 hits.append(a + (b - a) * tval);
751 };
752 checkEdge(p0, p1, v0, v1);
753 checkEdge(p1, p2, v1, v2);
754 checkEdge(p2, p0, v2, v0);
755
756 if (hits.size() == 2)
757 addSegment(buf, hits[0], hits[1], triN, halfW, shift);
758 }
759 }
760 };
761
762 ContourBuf negBuf, posBuf, zeroBuf;
763 buildContours(negLevels, negBuf);
764 buildContours(posLevels, posBuf);
765 if (hasZero) {
766 QVector<float> zeroLevels = {0.0f};
767 buildContours(zeroLevels, zeroBuf);
768 }
769
770 // ── Upload contour meshes ──────────────────────────────────────────
771 auto updateSurf = [&](const QString &suffix,
772 const ContourBuf &buf,
773 const QColor &color,
774 bool show) {
775 const QString key = prefix + suffix;
776 if (!show || buf.verts.isEmpty()) {
777 if (surfaces.contains(key)) surfaces[key]->setVisible(false);
778 return;
779 }
780
781 Eigen::MatrixX3f rr(buf.verts.size(), 3);
782 Eigen::MatrixX3f nn(buf.norms.size(), 3);
783 Eigen::MatrixX3i tris(buf.tris.size(), 3);
784 for (int i = 0; i < buf.verts.size(); ++i) {
785 rr.row(i) = buf.verts[i];
786 nn.row(i) = buf.norms[i];
787 }
788 for (int i = 0; i < buf.tris.size(); ++i)
789 tris.row(i) = buf.tris[i];
790
791 std::shared_ptr<BrainSurface> csurf;
792 if (surfaces.contains(key)) {
793 csurf = surfaces[key];
794 } else {
795 csurf = std::make_shared<BrainSurface>();
796 surfaces[key] = csurf;
797 }
798 csurf->createFromData(rr, nn, tris, color);
799 csurf->setVisible(true);
800 };
801
802 updateSurf("_neg", negBuf, QColor(0, 0, 255, 200), visible && !negBuf.verts.isEmpty());
803 updateSurf("_zero", zeroBuf, QColor(0, 0, 0, 220), visible && !zeroBuf.verts.isEmpty());
804 updateSurf("_pos", posBuf, QColor(255, 0, 0, 200), visible && !posBuf.verts.isEmpty());
805}
ColorMap class declaration.
Fiff constants.
#define FIFFV_POINT_EXTRA
#define FIFFV_EEG_CH
#define FIFFV_COORD_DEVICE
#define FIFFV_MEG_CH
#define FIFFV_COORD_HEAD
#define FIFFV_POINT_EEG
#define FIFFV_COORD_MRI
#define FIFFV_MOVE
FiffChInfo class declaration.
FiffCoordTrans class declaration.
FsSurface class declaration.
SensorFieldMapper class declaration.
BrainSurface class declaration.
QRgb mneAnalyzeColor(double v)
Lightweight render-related enums shared across the disp3D library.
uint32_t packABGR(uint32_t r, uint32_t g, uint32_t b, uint32_t a=0xFF)
Definition rendertypes.h:60
FwdFieldMap class declaration.
FwdCoilSet class declaration.
FIFF file I/O and data structures (raw, epochs, evoked, covariance, forward).
constexpr int FWD_COIL_ACCURACY_NORMAL
Definition fwd_coil.h:81
static QRgb valueToColor(double v, const QString &sMap)
Definition colormap.h:688
Viewport subdivision holding its own camera, projection, and scissor rectangle.
Definition viewstate.h:148
ViewVisibilityProfile visibility
Definition viewstate.h:154
Renderable cortical surface mesh with per-vertex color, curvature data, and GPU buffer management.
Eigen::MatrixX3f vertexNormals() const
QVector< uint32_t > triangleIndices() const
Eigen::MatrixX3f vertexPositions() const
static constexpr VisualizationMode ModeSurface
static QString findHeadSurfaceKey(const QMap< QString, std::shared_ptr< BrainSurface > > &surfaces)
static float contourStep(float minVal, float maxVal, int targetTicks)
bool hasMappingFor(const FIFFLIB::FiffEvoked &newEvoked) const
const FIFFLIB::FiffEvoked & evoked() const
static Eigen::Vector3f fitSphereOrigin(const FIFFLIB::FiffInfo &info, float *radius=nullptr)
bool buildMapping(const QMap< QString, std::shared_ptr< BrainSurface > > &surfaces, const FIFFLIB::FiffCoordTrans &headToMriTrans, bool applySensorTrans)
static QString findHelmetSurfaceKey(const QMap< QString, std::shared_ptr< BrainSurface > > &surfaces)
void setEvoked(const FIFFLIB::FiffEvoked &evoked)
void apply(QMap< QString, std::shared_ptr< BrainSurface > > &surfaces, const SubView &singleView, const QVector< SubView > &subViews)
Coordinate transformation description.
Eigen::Matrix< float, 4, 4, Eigen::DontAlign > trans
FIFF measurement file information.
Definition fiff_info.h:86
QList< FiffDigPoint > dig
Definition fiff_info.h:273
QList< FiffProj > projs
Definition fiff_info.h:275
QList< FiffChInfo > chs
FiffCoordTrans dev_head_t
static Eigen::MatrixX3f compute_normals(const Eigen::MatrixX3f &rr, const Eigen::MatrixX3i &tris)
static FwdCoilSet::UPtr read_coil_defs(const QString &name)
static FwdCoilSet::UPtr create_eeg_els(const QList< FIFFLIB::FiffChInfo > &chs, int nch, const FIFFLIB::FiffCoordTrans &t=FIFFLIB::FiffCoordTrans())
static std::unique_ptr< Eigen::MatrixXf > computeEegMapping(const FwdCoilSet &coils, const Eigen::MatrixX3f &vertices, const Eigen::Vector3f &origin, float intrad=0.06f, float miss=1e-3f)
static std::unique_ptr< 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)
static FiffCoordTrans combine(int from, int to, const FiffCoordTrans &t1, const FiffCoordTrans &t2)
Eigen::MatrixX3f apply_trans(const Eigen::MatrixX3f &rr, bool do_move=true) const