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