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