v2.0.0
Loading...
Searching...
No Matches
polhemus_coregistration.cpp
Go to the documentation of this file.
1//=============================================================================================================
21
23#include "acquired_points.h"
24
25#include <Eigen/Dense>
26#include <QSettings>
27#include <cmath>
28
29using namespace UTILSLIB;
30
31//=============================================================================================================
32
34: QObject(parent)
35, m_pPoints(new AcquiredPoints(this))
36{
37 m_deviceToWorld.setToIdentity();
38 m_headToWorld.setToIdentity();
39 m_headToDevice.setToIdentity();
40}
41
42//=============================================================================================================
43
45{
46 m_trackerStation = station;
47}
48
50{
51 m_penStation = station;
52}
53
55{
56 m_probeStation = station;
57}
58
59//=============================================================================================================
60
61void PolhemusCoregistration::setTrackerToDeviceOffset(const QVector3D& translation,
62 const QQuaternion& rotation)
63{
64 m_offsetTranslation = translation;
65 m_offsetRotation = rotation;
66}
67
68//=============================================================================================================
69
71{
72 if (m_pConn) {
73 disconnect(m_pConn, nullptr, this, nullptr);
74 }
75 m_pConn = conn;
76 if (m_pConn) {
77 connect(m_pConn, &PolhemusConnection::pointReceived,
78 this, &PolhemusCoregistration::onPointReceived);
80 this, &PolhemusCoregistration::onPenButtonPressedFromConn);
81 }
82}
83
84//=============================================================================================================
85
87{
88 if (!m_havePenPos) {
89 return false;
90 }
91
92 static const char* labels[] = {nullptr, "LPA", "NAS", "RPA"};
93 const int ident = static_cast<int>(id);
94
95 // Store pen fiducial BEFORE append (which emits pointsChanged)
96 m_penFid[ident] = m_penPosition;
97 m_hasPenFid[ident] = true;
98
99 qInfo().nospace() << "Fiducial captured: " << labels[ident]
100 << " (" << m_penPosition.x() * 1000.f << ", "
101 << m_penPosition.y() * 1000.f << ", "
102 << m_penPosition.z() * 1000.f << ") mm";
103
104 // Log distances to previously captured fiducials
105 for (int j = 1; j <= 3; ++j) {
106 if (j != ident && m_hasPenFid[j]) {
107 float dist = (m_penFid[ident] - m_penFid[j]).length() * 1000.0f;
108 qInfo().nospace() << " " << labels[ident] << " ↔ " << labels[j]
109 << ": " << dist << " mm"
110 << (dist > 200.0f ? " *** WARNING: > 200 mm!" : "");
111 }
112 }
113
114 m_pPoints->removeFiducial(id);
115
118 dp.label = QString::fromLatin1(labels[ident]);
119 dp.identNumber = ident;
120 dp.position = m_penPosition;
121 m_pPoints->append(dp);
122 return true;
123}
124
126{
127 if (!m_havePenPos) {
128 return false;
129 }
130
131 const int n = m_pPoints->countOf(PointKind::HeadShape) + 1;
132
135 dp.label = QStringLiteral("HSP-%1").arg(n);
136 dp.identNumber = n;
137 dp.position = m_penPosition;
138 m_pPoints->append(dp);
139 return true;
140}
141
142//=============================================================================================================
143
145{
146 m_registrationValid = false;
147 m_headToWorld.setToIdentity();
148 m_headToDevice.setToIdentity();
149 m_worldToModel.setToIdentity();
150 m_hasPenVertex = false;
151 for (int i = 0; i < 4; ++i)
152 m_hasPenFid[i] = false;
153 emit registrationChanged();
154}
155
156//=============================================================================================================
157
158void PolhemusCoregistration::setModelFiducial(FiducialId id, const QVector3D& posInModel)
159{
160 const int i = static_cast<int>(id);
161 m_modelFid[i] = posInModel;
162 m_hasModelFid[i] = true;
163}
164
166{
167 return m_hasModelFid[static_cast<int>(id)];
168}
169
171{
172 return m_hasModelFid[1] && m_hasModelFid[2] && m_hasModelFid[3];
173}
174
176{
177 return m_modelFid[static_cast<int>(id)];
178}
179
181{
182 return m_hasPenFid[1] && m_hasPenFid[2] && m_hasPenFid[3];
183}
184
185//=============================================================================================================
186
188{
189 if (!m_havePenPos)
190 return false;
191 m_penVertex = m_penPosition;
192 m_hasPenVertex = true;
193 qInfo() << "Captured pen vertex (CZ) at" << m_penVertex * 1000.0f << "mm";
194 // Notify observers so auto-registration can trigger
195 if (m_pPoints)
196 emit m_pPoints->pointsChanged();
197 return true;
198}
199
200//=============================================================================================================
201
203{
204 if (!hasAllPenFiducials()) {
205 return false;
206 }
207
208 // Pen fiducial positions in Polhemus world frame (metres)
209 const QVector3D pNas = m_penFid[static_cast<int>(FiducialId::NAS)];
210 const QVector3D pLpa = m_penFid[static_cast<int>(FiducialId::LPA)];
211 const QVector3D pRpa = m_penFid[static_cast<int>(FiducialId::RPA)];
212
213 // Spread check: reject degenerate input
214 const float dNL = (pNas - pLpa).length() * 1000.0f;
215 const float dNR = (pNas - pRpa).length() * 1000.0f;
216 const float dLR = (pLpa - pRpa).length() * 1000.0f;
217 const float minSpread = std::min({dNL, dNR, dLR});
218 if (minSpread < 20.0f) {
219 qWarning() << "Registration failed: pen fiducials too close"
220 << "(min spread" << minSpread << "mm, need > 20 mm)";
221 m_registrationValid = false;
222 emit registrationChanged();
223 return false;
224 }
225
226 // --- Paired path: SVD-based rigid registration (Kabsch / Procrustes) ---
227 //
228 // Finds the optimal rotation R and translation t that map pen
229 // fiducials to model fiducials: model_pos ≈ R * pen_pos + t
230 //
231 // The Kabsch algorithm guarantees det(R) = +1 (proper rotation,
232 // no reflection) regardless of how the two coordinate systems are
233 // oriented. This avoids the left-right / up-down inversion bugs
234 // that plagued the old buildFrame approach, where an asymmetric
235 // vertex correction could flip ey or ez in one frame but not the
236 // other.
237 if (hasAllModelFiducials()) {
238 const QVector3D mNas = m_modelFid[static_cast<int>(FiducialId::NAS)];
239 const QVector3D mLpa = m_modelFid[static_cast<int>(FiducialId::LPA)];
240 const QVector3D mRpa = m_modelFid[static_cast<int>(FiducialId::RPA)];
241
242 // Compare pen vs model fiducial distances — should be similar for the same head
243 const float mNL = (mNas - mLpa).length() * 1000.0f;
244 const float mNR = (mNas - mRpa).length() * 1000.0f;
245 const float mLR = (mLpa - mRpa).length() * 1000.0f;
246
247 const float maxRatio = std::max({dNL / mNL, dNR / mNR, dLR / mLR});
248 const float minRatio = std::min({dNL / mNL, dNR / mNR, dLR / mLR});
249 if (maxRatio / minRatio > 2.0f) {
250 qWarning() << "Registration: shape mismatch — pen fiducial triangle"
251 << "has very different proportions from model."
252 << "Check fiducial placement!";
253 }
254
255 // --- Kabsch algorithm (SVD least-squares rigid alignment) ---
256
257 // 1. Centroids
258 const Eigen::Vector3d penC(
259 (pNas.x() + pLpa.x() + pRpa.x()) / 3.0,
260 (pNas.y() + pLpa.y() + pRpa.y()) / 3.0,
261 (pNas.z() + pLpa.z() + pRpa.z()) / 3.0);
262 const Eigen::Vector3d modC(
263 (mNas.x() + mLpa.x() + mRpa.x()) / 3.0,
264 (mNas.y() + mLpa.y() + mRpa.y()) / 3.0,
265 (mNas.z() + mLpa.z() + mRpa.z()) / 3.0);
266
267 // 2. Centered point matrices (3×3, each column = one centered point)
268 Eigen::Matrix3d P, Q;
269 P.col(0) = Eigen::Vector3d(pNas.x(), pNas.y(), pNas.z()) - penC;
270 P.col(1) = Eigen::Vector3d(pLpa.x(), pLpa.y(), pLpa.z()) - penC;
271 P.col(2) = Eigen::Vector3d(pRpa.x(), pRpa.y(), pRpa.z()) - penC;
272
273 Q.col(0) = Eigen::Vector3d(mNas.x(), mNas.y(), mNas.z()) - modC;
274 Q.col(1) = Eigen::Vector3d(mLpa.x(), mLpa.y(), mLpa.z()) - modC;
275 Q.col(2) = Eigen::Vector3d(mRpa.x(), mRpa.y(), mRpa.z()) - modC;
276
277 // 3. Cross-covariance matrix H = P * Qᵀ
278 const Eigen::Matrix3d H = P * Q.transpose();
279
280 // 4. SVD of H
281 Eigen::JacobiSVD<Eigen::Matrix3d> svd(H, Eigen::ComputeFullU | Eigen::ComputeFullV);
282 const Eigen::Matrix3d U = svd.matrixU();
283 const Eigen::Matrix3d V = svd.matrixV();
284
285 // 5. Optimal rotation — ensure proper rotation (det = +1)
286 const double d = (V * U.transpose()).determinant();
287 Eigen::Matrix3d D = Eigen::Matrix3d::Identity();
288 D(2, 2) = (d > 0.0) ? 1.0 : -1.0;
289 Eigen::Matrix3d R = V * D * U.transpose();
290
291 // 6. Translation
292 Eigen::Vector3d t = modC - R * penC;
293
294 // 6b. Vertex disambiguation for coplanar degeneracy.
295 //
296 // With only 3 coplanar fiducials the SVD's 3rd singular value
297 // is ~0, leaving the out-of-plane rotation direction ambiguous.
298 // The det(V*Uᵀ) sign heuristic picks one direction but may
299 // choose wrong, inverting superior ↔ inferior.
300 //
301 // Fix: if a pen vertex (CZ) and model vertex are available,
302 // test both candidate rotations (D₃₃ = +1 and D₃₃ = -1) and
303 // keep whichever maps pen CZ closer to model CZ.
304 if (m_hasPenVertex && m_hasModelVertex) {
305 const Eigen::Vector3d penCZ(m_penVertex.x(), m_penVertex.y(), m_penVertex.z());
306 const Eigen::Vector3d modCZ(m_modelVertex.x(), m_modelVertex.y(), m_modelVertex.z());
307
308 const Eigen::Vector3d mappedCZ = R * penCZ + t;
309 const double errCurrent = (mappedCZ - modCZ).norm();
310
311 // Try the alternative sign
312 Eigen::Matrix3d D_alt = D;
313 D_alt(2, 2) = -D(2, 2);
314 const Eigen::Matrix3d R_alt = V * D_alt * U.transpose();
315 const Eigen::Vector3d t_alt = modC - R_alt * penC;
316 const Eigen::Vector3d mappedCZ_alt = R_alt * penCZ + t_alt;
317 const double errAlt = (mappedCZ_alt - modCZ).norm();
318
319 if (errAlt < errCurrent) {
320 R = R_alt;
321 t = t_alt;
322 D = D_alt;
323 }
324 }
325
326 // 7. Build QMatrix4x4 worldToModel = [R | t]
327 m_worldToModel.setToIdentity();
328 for (int r = 0; r < 3; ++r) {
329 for (int c = 0; c < 3; ++c) {
330 m_worldToModel(r, c) = static_cast<float>(R(r, c));
331 }
332 m_worldToModel(r, 3) = static_cast<float>(t(r));
333 }
334
335 m_headToWorld.setToIdentity();
336 m_headToDevice = m_deviceToWorld.inverted() * m_headToWorld;
337 m_registrationValid = true;
338 qInfo() << "Registration succeeded (analytical paired).";
339 emit registrationChanged();
340 return true;
341 }
342
343 // --- Fallback: head-frame-only registration ---
344 const QMatrix4x4 headFrame = buildHeadFrame();
345 const QVector3D ez(headFrame(0, 2), headFrame(1, 2), headFrame(2, 2));
346 if (ez.length() < 1e-6f) {
347 return false;
348 }
349
350 m_headToWorld = headFrame;
351 m_headToDevice = m_deviceToWorld.inverted() * m_headToWorld;
352 m_worldToModel.setToIdentity();
353 m_registrationValid = true;
354 qInfo() << "Registration succeeded (head-frame fallback).";
355 emit registrationChanged();
356 return true;
357}
358
359//=============================================================================================================
360
361void PolhemusCoregistration::onPointReceived(int station,
362 const QVector3D& position,
363 const QQuaternion& orientation)
364{
365 // Apply axis mirroring to compensate for transmitter placement
366 const QVector3D pos(m_mirrorX ? -position.x() : position.x(),
367 m_mirrorY ? -position.y() : position.y(),
368 position.z());
369
370 if (station == m_trackerStation) {
371 m_deviceToWorld = buildDevicePose(pos, orientation);
372 emit devicePoseChanged(m_deviceToWorld);
373 } else if (station == m_penStation) {
374 // Gimbal-lock guard: for ZYX Euler, sin(el) = 2*(w*y - x*z).
375 // When |el| > 80° the Euler→quaternion conversion is unreliable,
376 // so freeze the pen pose at its last good value.
377 const float sinEl = 2.0f * (orientation.scalar() * orientation.y() - orientation.x() * orientation.z());
378 constexpr float kGimbalSinEl = 0.9848f; // sin(80°)
379 const bool gimbalLock = (std::abs(sinEl) > kGimbalSinEl);
380
381 if (!gimbalLock) {
382 const QVector3D tipAdj = m_tipOffsetEnabled
383 ? orientation.rotatedVector(m_penTipOffset)
384 : QVector3D();
385 m_penPosition = pos + tipAdj;
386 m_penOrientation = orientation;
387 m_havePenPos = true;
388 }
389
390 // Collect raw (un-offset) samples during pivot calibration.
391 // Only keep samples with sufficient angular change from the last
392 // accepted sample to avoid redundant near-identical rows in the SVD.
393 // Also reject position jumps and gimbal-lock orientations.
394 if (m_pivotState == PivotState::Collecting) {
395 constexpr float kMinAngleDeg = 3.0f;
396 constexpr float kMaxPosJumpM = 0.05f; // 5 cm
397 constexpr float kGimbalSinEl2 = 0.9848f; // sin(80°) — reject |el|>80°
398
399 // Gimbal-lock check: for ZYX Euler, sin(el) = 2*(w*y - x*z)
400 const float sinEl2 = 2.0f * (orientation.scalar() * orientation.y() - orientation.x() * orientation.z());
401 if (std::abs(sinEl2) > kGimbalSinEl2) {
402 // Near gimbal lock — skip this sample silently
403 } else {
404 bool accept = m_pivotOrientations.empty();
405 if (!accept) {
406 const QQuaternion& prev = m_pivotOrientations.back();
407 float dot = std::abs(QQuaternion::dotProduct(prev, orientation));
408 float angleDeg = 2.0f * std::acos(std::min(dot, 1.0f)) * (180.0f / 3.14159265f);
409 float posDelta = (pos - m_pivotPositions.back()).length();
410 accept = (angleDeg >= kMinAngleDeg) && (posDelta < kMaxPosJumpM);
411 }
412 if (accept) {
413 m_pivotPositions.push_back(pos);
414 m_pivotOrientations.push_back(orientation);
415
416 // Compute angular span for live feedback
417 float spanDeg = 0.0f;
418 if (m_pivotOrientations.size() > 1) {
419 float minDot = 1.0f;
420 const auto& first = m_pivotOrientations.front();
421 for (size_t k = 1; k < m_pivotOrientations.size(); ++k) {
422 float d = std::abs(QQuaternion::dotProduct(first, m_pivotOrientations[k]));
423 if (d < minDot)
424 minDot = d;
425 }
426 spanDeg = 2.0f * std::acos(std::min(minDot, 1.0f)) * (180.0f / 3.14159265f);
427 }
428 emit pivotSampleCollected(static_cast<int>(m_pivotPositions.size()), spanDeg);
429 }
430 }
431 }
432
433 emit penPoseChanged(m_penPosition, m_penOrientation);
434 } else if (station == m_probeStation) {
435 m_probePosition = pos;
436 m_probeOrientation = orientation;
437 m_haveProbePos = true;
438 emit probePoseChanged(m_probePosition, m_probeOrientation);
439 }
440}
441
442void PolhemusCoregistration::onPenButtonPressedFromConn(int station,
443 const QVector3D& position,
444 const QQuaternion& orientation)
445{
446 if (station == m_penStation) {
447 // Apply axis mirroring to compensate for transmitter placement
448 const QVector3D pos(m_mirrorX ? -position.x() : position.x(),
449 m_mirrorY ? -position.y() : position.y(),
450 position.z());
451
452 const QVector3D tipAdj = m_tipOffsetEnabled
453 ? orientation.rotatedVector(m_penTipOffset)
454 : QVector3D();
455 m_penPosition = pos + tipAdj;
456 m_penOrientation = orientation;
457 m_havePenPos = true;
458
459 // Pivot calibration state machine
460 if (m_pivotState == PivotState::WaitingForStart) {
461 m_pivotState = PivotState::Collecting;
462 m_pivotPositions.clear();
463 m_pivotOrientations.clear();
464 qInfo() << "Pivot calibration: collecting — pivot the pen around its tip";
465 emit pivotStateChanged(m_pivotState);
466 return;
467 }
468 if (m_pivotState == PivotState::Collecting) {
469 solvePivotCalibration();
470 return;
471 }
472
473 emit penButtonPressed(m_penPosition, orientation);
474 }
475}
476
477//=============================================================================================================
478
479QMatrix4x4 PolhemusCoregistration::buildDevicePose(const QVector3D& trackerPos,
480 const QQuaternion& trackerOri) const
481{
482 QMatrix4x4 trackerToWorld;
483 trackerToWorld.setToIdentity();
484 trackerToWorld.translate(trackerPos);
485 trackerToWorld.rotate(trackerOri);
486
487 QMatrix4x4 offset;
488 offset.setToIdentity();
489 offset.translate(m_offsetTranslation);
490 offset.rotate(m_offsetRotation);
491
492 return trackerToWorld * offset;
493}
494
495QMatrix4x4 PolhemusCoregistration::buildHeadFrame() const
496{
497 const QVector3D nas = m_pPoints->fiducial(FiducialId::NAS);
498 const QVector3D lpa = m_pPoints->fiducial(FiducialId::LPA);
499 const QVector3D rpa = m_pPoints->fiducial(FiducialId::RPA);
500
501 const QVector3D origin = (lpa + rpa) * 0.5f;
502 const QVector3D ex = (nas - origin).normalized();
503 const QVector3D eyApprox = (lpa - origin).normalized();
504 // Gram-Schmidt: orthogonalize ey against ex, preserving LPA direction
505 const QVector3D ey = (eyApprox - QVector3D::dotProduct(eyApprox, ex) * ex).normalized();
506 const QVector3D ez = QVector3D::crossProduct(ex, ey).normalized();
507
508 QMatrix4x4 frame;
509 frame.setToIdentity();
510 frame(0, 0) = ex.x();
511 frame(0, 1) = ey.x();
512 frame(0, 2) = ez.x();
513 frame(0, 3) = origin.x();
514 frame(1, 0) = ex.y();
515 frame(1, 1) = ey.y();
516 frame(1, 2) = ez.y();
517 frame(1, 3) = origin.y();
518 frame(2, 0) = ex.z();
519 frame(2, 1) = ey.z();
520 frame(2, 2) = ez.z();
521 frame(2, 3) = origin.z();
522 frame(3, 0) = 0.0f;
523 frame(3, 1) = 0.0f;
524 frame(3, 2) = 0.0f;
525 frame(3, 3) = 1.0f;
526 return frame;
527}
528
529//=============================================================================================================
530// Pivot calibration
531//=============================================================================================================
532
534{
535 m_pivotPositions.clear();
536 m_pivotOrientations.clear();
537 m_pivotResidualMm = 0.0f;
538 m_pivotState = PivotState::WaitingForStart;
539 qInfo() << "Pivot calibration: press stylus button to begin collecting";
540 emit pivotStateChanged(m_pivotState);
541}
542
544{
545 m_pivotPositions.clear();
546 m_pivotOrientations.clear();
547 m_pivotState = PivotState::Idle;
548 emit pivotStateChanged(m_pivotState);
549}
550
551bool PolhemusCoregistration::solvePivotCalibration()
552{
553 const int N = static_cast<int>(m_pivotPositions.size());
554 if (N < 10) {
555 qWarning() << "Pivot calibration: only" << N << "samples, need at least 10";
556 m_pivotState = PivotState::Idle;
557 emit pivotStateChanged(m_pivotState);
558 return false;
559 }
560
561 // Build the linear system A * x = b (double precision)
562 // where x = [offset(3); tipPos(3)]
563 // For each sample i: R_i * offset - I * tipPos = -p_i
564 Eigen::MatrixXd A(3 * N, 6);
565 Eigen::VectorXd b(3 * N);
566
567 for (int i = 0; i < N; ++i) {
568 const QVector3D& p = m_pivotPositions[static_cast<size_t>(i)];
569 const QQuaternion& q = m_pivotOrientations[static_cast<size_t>(i)];
570
571 // Extract 3x3 rotation matrix directly from quaternion
572 const QMatrix3x3 rm = q.toRotationMatrix();
573
574 const int row = 3 * i;
575 for (int r = 0; r < 3; ++r) {
576 for (int c = 0; c < 3; ++c) {
577 A(row + r, c) = static_cast<double>(rm(r, c)); // R_i
578 A(row + r, 3 + c) = (r == c) ? -1.0 : 0.0; // -I
579 }
580 }
581 b(row + 0) = -static_cast<double>(p.x());
582 b(row + 1) = -static_cast<double>(p.y());
583 b(row + 2) = -static_cast<double>(p.z());
584 }
585
586 // Solve via SVD least squares
587 Eigen::JacobiSVD<Eigen::MatrixXd> svd(A, Eigen::ComputeThinU | Eigen::ComputeThinV);
588
589 // Check condition number for rank deficiency (insufficient angular diversity)
590 const auto& sv = svd.singularValues();
591 double cond = sv(0) / sv(sv.size() - 1);
592 if (cond > 1e6) {
593 qWarning() << "Pivot calibration: ill-conditioned (cond =" << cond
594 << "). Pivot the pen through a wider range of angles.";
595 m_pivotState = PivotState::Idle;
596 emit pivotStateChanged(m_pivotState);
597 return false;
598 }
599
600 Eigen::VectorXd x = svd.solve(b);
601
602 QVector3D offset(static_cast<float>(x(0)),
603 static_cast<float>(x(1)),
604 static_cast<float>(x(2)));
605 QVector3D tipPos(static_cast<float>(x(3)),
606 static_cast<float>(x(4)),
607 static_cast<float>(x(5)));
608
609 // Compute RMS residual
610 double sumSq = 0.0;
611 for (int i = 0; i < N; ++i) {
612 const QVector3D& p = m_pivotPositions[static_cast<size_t>(i)];
613 const QQuaternion& q = m_pivotOrientations[static_cast<size_t>(i)];
614 QVector3D computed = p + q.rotatedVector(offset);
615 float err = (computed - tipPos).length();
616 sumSq += static_cast<double>(err) * static_cast<double>(err);
617 }
618 float rms = static_cast<float>(std::sqrt(sumSq / static_cast<double>(N))) * 1000.0f;
619 m_pivotResidualMm = rms;
620
621 qInfo() << "Pivot calibration:" << N << "samples, cond =" << cond
622 << ", offset =" << offset.x() * 1000.0f << offset.y() * 1000.0f
623 << offset.z() * 1000.0f << "mm, RMS residual =" << rms << "mm";
624
625 m_penTipOffset = offset;
626 m_pivotState = PivotState::Done;
627 emit pivotStateChanged(m_pivotState);
628 emit pivotCalibrationDone(offset, rms);
629 return true;
630}
631
632//=============================================================================================================
633// Optical path calibration
634//=============================================================================================================
635
637{
638 if (!m_havePenPos) {
639 qWarning() << "Optical calibration: no pen position available";
640 return false;
641 }
642
643 const QMatrix4x4& dev = m_deviceToWorld;
644 if (dev.isIdentity()) {
645 qWarning() << "Optical calibration: no tracker data available";
646 return false;
647 }
648
649 // Extract tracker position and orientation from deviceToWorld.
650 // deviceToWorld = trackerToWorld * offset, but we want the raw tracker
651 // pose (before offset). Reconstruct from the current raw tracker data
652 // by removing the offset: trackerToWorld = deviceToWorld * offset^-1
653 QMatrix4x4 offsetInv;
654 offsetInv.setToIdentity();
655 offsetInv.translate(m_offsetTranslation);
656 offsetInv.rotate(m_offsetRotation);
657 offsetInv = offsetInv.inverted();
658
659 const QMatrix4x4 trackerToWorld = dev * offsetInv;
660 const QVector3D trackerPos(trackerToWorld(0, 3), trackerToWorld(1, 3), trackerToWorld(2, 3));
661 const QQuaternion trackerOri = QQuaternion::fromRotationMatrix(trackerToWorld.toGenericMatrix<3, 3>());
662
663 OpticalCalibSample sample;
664 sample.trackerPos = trackerPos;
665 sample.trackerOri = trackerOri;
666 sample.focusPoint = m_penPosition;
667 m_opticalCalibSamples.push_back(sample);
668
669 const int n = static_cast<int>(m_opticalCalibSamples.size());
670 const QVector3D localFocus = trackerOri.inverted().rotatedVector(m_penPosition - trackerPos);
671 const float distToTracker = localFocus.length() * 1000.0f;
672
673 qInfo().nospace()
674 << "Optical calibration sample " << n << ":"
675 << "\n tracker pos: (" << trackerPos.x() * 1000.f << ", " << trackerPos.y() * 1000.f << ", " << trackerPos.z() * 1000.f << ") mm"
676 << "\n focus point: (" << m_penPosition.x() * 1000.f << ", " << m_penPosition.y() * 1000.f << ", " << m_penPosition.z() * 1000.f << ") mm"
677 << "\n focus (local): (" << localFocus.x() * 1000.f << ", " << localFocus.y() * 1000.f << ", " << localFocus.z() * 1000.f << ") mm"
678 << "\n distance tracker\u2194focus: " << distToTracker << " mm";
679
680 // Log convergence angle between tracker→focus directions for successive samples.
681 // As the OPMI moves farther away, this angle shrinks (lines become parallel);
682 // at close range the angle is large because the ~20 cm tracker offset dominates.
683 if (n >= 2) {
684 const auto& prev = m_opticalCalibSamples[static_cast<size_t>(n - 2)];
685 const QVector3D prevLocal = prev.trackerOri.inverted().rotatedVector(prev.focusPoint - prev.trackerPos);
686 const QVector3D curDir = localFocus.normalized();
687 const QVector3D prevDir = prevLocal.normalized();
688 const float dot = std::clamp(QVector3D::dotProduct(curDir, prevDir), -1.0f, 1.0f);
689 const float angleDeg = std::acos(dot) * (180.0f / 3.14159265f);
690 const float prevDist = prevLocal.length() * 1000.0f;
691 qInfo().nospace()
692 << " convergence angle (sample " << n - 1 << "\u2194" << n << "): " << angleDeg << "\u00b0"
693 << " (distances: " << prevDist << " / " << distToTracker << " mm)";
694 }
695
696 return true;
697}
698
700{
701 m_opticalCalibSamples.clear();
702 m_opticalCalibValid = false;
703 m_opticalCalibResidualMm = 0.0f;
704 m_opticalCalibDepthSpreadMm = 0.0f;
706}
707
708//=============================================================================================================
709
711{
712 if (!m_havePenPos) {
713 qWarning() << "Objective center capture: no pen position available";
714 return false;
715 }
716
717 const QMatrix4x4& dev = m_deviceToWorld;
718 if (dev.isIdentity()) {
719 qWarning() << "Objective center capture: no tracker data available";
720 return false;
721 }
722
723 // Recover raw tracker pose (remove device offset)
724 QMatrix4x4 offsetInv;
725 offsetInv.setToIdentity();
726 offsetInv.translate(m_offsetTranslation);
727 offsetInv.rotate(m_offsetRotation);
728 offsetInv = offsetInv.inverted();
729
730 const QMatrix4x4 trackerToWorld = dev * offsetInv;
731 const QVector3D trackerPos(trackerToWorld(0, 3), trackerToWorld(1, 3), trackerToWorld(2, 3));
732 const QQuaternion trackerOri = QQuaternion::fromRotationMatrix(trackerToWorld.toGenericMatrix<3, 3>());
733
734 // Transform pen position into tracker-local frame
735 m_objectiveCenterLocal = trackerOri.inverted().rotatedVector(m_penPosition - trackerPos);
736 m_hasObjectiveCenter = true;
737
738 const float distMm = m_objectiveCenterLocal.length() * 1000.0f;
739 qInfo().nospace()
740 << "Objective center captured (tracker-local): ("
741 << m_objectiveCenterLocal.x() * 1000.f << ", "
742 << m_objectiveCenterLocal.y() * 1000.f << ", "
743 << m_objectiveCenterLocal.z() * 1000.f << ") mm"
744 << ", distance from tracker: " << distMm << " mm";
745
746 return true;
747}
748
750{
751 m_objectiveCenterLocal = QVector3D();
752 m_hasObjectiveCenter = false;
753}
754
756{
757 const int N = static_cast<int>(m_opticalCalibSamples.size());
758 if (N < 2) {
759 qWarning() << "Optical calibration: need at least 2 samples, have" << N;
760 return false;
761 }
762
763 // Step 1: Transform all focus points into the tracker's local frame
764 std::vector<Eigen::Vector3d> localPoints(static_cast<size_t>(N));
765 for (int i = 0; i < N; ++i) {
766 const auto& s = m_opticalCalibSamples[static_cast<size_t>(i)];
767 const QVector3D local = s.trackerOri.inverted().rotatedVector(s.focusPoint - s.trackerPos);
768 localPoints[static_cast<size_t>(i)] = Eigen::Vector3d(local.x(), local.y(), local.z());
769 }
770
771 // Step 2: PCA initial estimate — centroid + SVD for starting axis & center
772 Eigen::Vector3d centroid = Eigen::Vector3d::Zero();
773 for (const auto& p : localPoints)
774 centroid += p;
775 centroid /= static_cast<double>(N);
776
777 Eigen::MatrixXd centered(3, N);
778 for (int i = 0; i < N; ++i) {
779 centered.col(i) = localPoints[static_cast<size_t>(i)] - centroid;
780 }
781
782 Eigen::JacobiSVD<Eigen::MatrixXd> svd(centered, Eigen::ComputeThinU);
783 Eigen::Vector3d axisDir = svd.matrixU().col(0);
784 if (centroid.dot(axisDir) < 0.0)
785 axisDir = -axisDir;
786
787 const double t0 = -centroid.dot(axisDir);
788 Eigen::Vector3d opticalCenter = centroid + t0 * axisDir;
789
790 // Step 3: Constrained refinement.
791 //
792 // Priority: (a) directly captured objective center, (b) known distance
793 // constraint, (c) unconstrained PCA fallback.
794 //
795 // (a) If the user touched the pen to the objective lens, we know the
796 // optical center exactly in tracker-local frame. Only the axis
797 // direction needs to be determined from the focus samples.
798 // (b) If only the distance is known, optimise O on the sphere |O| = R.
799 // (c) Otherwise fall back to unconstrained PCA.
800 const double knownDist = static_cast<double>(m_knownTrackerToObjectiveDist);
801 if (m_hasObjectiveCenter) {
802 // Direct objective center — use it as-is, recompute axis from it
803 opticalCenter = Eigen::Vector3d(m_objectiveCenterLocal.x(),
804 m_objectiveCenterLocal.y(),
805 m_objectiveCenterLocal.z());
806
807 Eigen::MatrixXd rays(3, N);
808 for (int i = 0; i < N; ++i)
809 rays.col(i) = localPoints[static_cast<size_t>(i)] - opticalCenter;
810
811 Eigen::JacobiSVD<Eigen::MatrixXd> raySvd(rays, Eigen::ComputeThinU);
812 axisDir = raySvd.matrixU().col(0);
813 // Axis must point from objective toward focus points
814 Eigen::Vector3d meanRay = Eigen::Vector3d::Zero();
815 for (int i = 0; i < N; ++i)
816 meanRay += rays.col(i);
817 if (meanRay.dot(axisDir) < 0.0)
818 axisDir = -axisDir;
819
820 qInfo().nospace()
821 << "Optical calibration: using directly captured objective center, |O|="
822 << opticalCenter.norm() * 1000.0 << " mm";
823 } else if (knownDist > 0.0 && N >= 2) {
824 // Start where the PCA axis meets the sphere |O| = R on the side the axis points away from the
825 // tracker; the descent below only takes small steps, so it cannot travel from a distant start.
826 double R = knownDist;
827 Eigen::Vector3d O = opticalCenter;
828 const double along = centroid.dot(axisDir);
829 const double disc = along * along - centroid.squaredNorm() + R * R;
830 if (disc >= 0.0) {
831 O = centroid + (-along + std::sqrt(disc)) * axisDir;
832 } else if (O.norm() > 1e-9) {
833 O = O * (R / O.norm());
834 } else {
835 O = centroid.normalized() * R;
836 }
837
838 // Gauss-Newton iterations: optimise O on the sphere, recompute axis each step
839 for (int iter = 0; iter < 30; ++iter) {
840 // Recompute axis direction from O: SVD of (F_i - O)
841 Eigen::MatrixXd rays(3, N);
842 for (int i = 0; i < N; ++i)
843 rays.col(i) = localPoints[static_cast<size_t>(i)] - O;
844
845 Eigen::JacobiSVD<Eigen::MatrixXd> raySvd(rays, Eigen::ComputeThinU);
846 Eigen::Vector3d d = raySvd.matrixU().col(0);
847 if (O.dot(d) < 0.0)
848 d = -d; // axis points away from tracker
849
850 // Compute gradient of cost w.r.t. O (on the tangent plane of the sphere)
851 // Cost: E = sum_i |(F_i-O) - ((F_i-O).d)d|^2
852 // dE/dO = sum_i -2 * perp_i where perp_i = (F_i-O) - ((F_i-O).d)d
853 // but we project gradient onto tangent plane of sphere at O
854 Eigen::Vector3d grad = Eigen::Vector3d::Zero();
855 double cost = 0.0;
856 for (int i = 0; i < N; ++i) {
857 Eigen::Vector3d v = localPoints[static_cast<size_t>(i)] - O;
858 Eigen::Vector3d perp = v - v.dot(d) * d;
859 cost += perp.squaredNorm();
860 grad -= 2.0 * perp;
861 }
862
863 // Project gradient onto tangent plane of sphere at O
864 Eigen::Vector3d normal = O.normalized();
865 Eigen::Vector3d tangentGrad = grad - grad.dot(normal) * normal;
866
867 double gradNorm = tangentGrad.norm();
868 if (gradNorm < 1e-12)
869 break;
870
871 // Line search with backtracking
872 double step = 0.01 * R / gradNorm; // conservative initial step
873 for (int ls = 0; ls < 10; ++ls) {
874 Eigen::Vector3d candidate = O - step * tangentGrad;
875 // Project back onto sphere
876 candidate = candidate.normalized() * R;
877
878 // Recompute cost at candidate
879 Eigen::MatrixXd cRays(3, N);
880 for (int i = 0; i < N; ++i)
881 cRays.col(i) = localPoints[static_cast<size_t>(i)] - candidate;
882 Eigen::JacobiSVD<Eigen::MatrixXd> cSvd(cRays, Eigen::ComputeThinU);
883 Eigen::Vector3d cd = cSvd.matrixU().col(0);
884
885 double cCost = 0.0;
886 for (int i = 0; i < N; ++i) {
887 Eigen::Vector3d v = localPoints[static_cast<size_t>(i)] - candidate;
888 Eigen::Vector3d perp = v - v.dot(cd) * cd;
889 cCost += perp.squaredNorm();
890 }
891
892 if (cCost < cost) {
893 O = candidate;
894 break;
895 }
896 step *= 0.5;
897 }
898 }
899
900 opticalCenter = O;
901
902 // Final axis from refined center
903 Eigen::MatrixXd finalRays(3, N);
904 for (int i = 0; i < N; ++i)
905 finalRays.col(i) = localPoints[static_cast<size_t>(i)] - opticalCenter;
906 Eigen::JacobiSVD<Eigen::MatrixXd> finalSvd(finalRays, Eigen::ComputeThinU);
907 axisDir = finalSvd.matrixU().col(0);
908 if (opticalCenter.dot(axisDir) < 0.0)
909 axisDir = -axisDir;
910
911 qInfo().nospace()
912 << "Optical calibration: constrained refinement (R="
913 << knownDist * 1000.0 << " mm) converged, |O|="
914 << opticalCenter.norm() * 1000.0 << " mm";
915 }
916
917 // Step 4: Compute RMS residual (perpendicular distance from each point to the ray from O)
918 double sumSq = 0.0;
919 for (const auto& p : localPoints) {
920 const Eigen::Vector3d diff = p - opticalCenter;
921 const double along = diff.dot(axisDir);
922 const double perpSq = (diff - along * axisDir).squaredNorm();
923 sumSq += perpSq;
924 }
925 const double rms = std::sqrt(sumSq / static_cast<double>(N)) * 1000.0; // mm
926
927 // Step 5: Depth spread along the optical axis from the refined center
928 double minDepth = 1e30, maxDepth = -1e30;
929 for (const auto& p : localPoints) {
930 const double depth = (p - opticalCenter).dot(axisDir);
931 minDepth = std::min(minDepth, depth);
932 maxDepth = std::max(maxDepth, depth);
933 }
934 const double depthSpreadMm = (maxDepth - minDepth) * 1000.0;
935
936 m_opticalAxisLocal = QVector3D(static_cast<float>(axisDir.x()),
937 static_cast<float>(axisDir.y()),
938 static_cast<float>(axisDir.z()));
939 m_opticalCenterLocal = QVector3D(static_cast<float>(opticalCenter.x()),
940 static_cast<float>(opticalCenter.y()),
941 static_cast<float>(opticalCenter.z()));
942 m_opticalCalibResidualMm = static_cast<float>(rms);
943 m_opticalCalibDepthSpreadMm = static_cast<float>(depthSpreadMm);
944 m_opticalCalibValid = true;
945
946 const float distMm = m_opticalCenterLocal.length() * 1000.0f;
947
948 qInfo().nospace()
949 << "Optical calibration solved (" << N << " samples"
950 << (knownDist > 0.0 ? ", constrained R=" + QString::number(knownDist * 1000.0, 'f', 1) + " mm" : QString()) << "):"
951 << "\n axis (local): (" << m_opticalAxisLocal.x() << ", " << m_opticalAxisLocal.y() << ", " << m_opticalAxisLocal.z() << ")"
952 << "\n center (local): (" << m_opticalCenterLocal.x() * 1000.f << ", " << m_opticalCenterLocal.y() * 1000.f << ", " << m_opticalCenterLocal.z() * 1000.f << ") mm"
953 << "\n tracker\u2194center: " << distMm << " mm"
954 << "\n RMS residual: " << rms << " mm"
955 << "\n depth spread: " << depthSpreadMm << " mm (range " << minDepth * 1000.0 << " .. " << maxDepth * 1000.0 << " mm)";
956
957 // Per-sample: report convergence angle between tracker→focus direction
958 // and fitted optical axis. This angle shrinks as the OPMI moves farther
959 // from the head (lines become parallel) and grows at close range where
960 // the tracker offset dominates.
961 qInfo() << " Per-sample convergence angles (tracker\u2192focus vs optical axis):";
962 for (int i = 0; i < N; ++i) {
963 const Eigen::Vector3d& p = localPoints[static_cast<size_t>(i)];
964 const Eigen::Vector3d trkToFocus = p.normalized();
965 const double cosAngle = std::clamp(trkToFocus.dot(axisDir), -1.0, 1.0);
966 const double angleDeg = std::acos(cosAngle) * (180.0 / 3.14159265358979);
967 const double depth = (p - opticalCenter).dot(axisDir);
968 const Eigen::Vector3d diff = p - opticalCenter;
969 const double perpDist = (diff - diff.dot(axisDir) * axisDir).norm() * 1000.0;
970 qInfo().nospace()
971 << " sample " << (i + 1) << ": depth=" << depth * 1000.0
972 << " mm, convergence angle=" << angleDeg << "\u00b0"
973 << ", perp err=" << perpDist << " mm";
974 }
975
976 if (depthSpreadMm < 50.0) {
977 qWarning() << "Optical calibration: depth spread is only" << depthSpreadMm
978 << "mm. For a reliable axis direction, move the OPMI to vary the"
979 << "focal distance by at least 50 mm between samples.";
980 }
981
982 if (rms > 10.0) {
983 qWarning() << "Optical calibration: RMS residual" << rms
984 << "mm is large. Check that the stylus accurately marks the microscope focus point.";
985 }
986
987 // Plausibility check: when not using constrained mode, verify the
988 // tracker-to-optical-center distance is in a plausible range.
989 if (knownDist <= 0.0) {
990 constexpr float kMinPlausibleMm = 100.0f;
991 constexpr float kMaxPlausibleMm = 500.0f;
992 if (distMm < kMinPlausibleMm || distMm > kMaxPlausibleMm) {
993 qWarning().nospace()
994 << "Optical calibration: tracker\u2194axis distance " << distMm
995 << " mm is outside the plausible range [" << kMinPlausibleMm
996 << ", " << kMaxPlausibleMm << "] mm. Calibration data may be unreliable.";
997 }
998 }
999
1001 return true;
1002}
1003
1004bool PolhemusCoregistration::opticalRayInWorld(QVector3D& origin, QVector3D& direction) const
1005{
1006 if (!m_opticalCalibValid)
1007 return false;
1008
1009 const QMatrix4x4& dev = m_deviceToWorld;
1010 if (dev.isIdentity())
1011 return false;
1012
1013 // Recover raw tracker pose (before device offset)
1014 QMatrix4x4 offsetMat;
1015 offsetMat.setToIdentity();
1016 offsetMat.translate(m_offsetTranslation);
1017 offsetMat.rotate(m_offsetRotation);
1018
1019 const QMatrix4x4 trackerToWorld = dev * offsetMat.inverted();
1020 const QVector3D trackerPos(trackerToWorld(0, 3), trackerToWorld(1, 3), trackerToWorld(2, 3));
1021 const QQuaternion trackerOri = QQuaternion::fromRotationMatrix(trackerToWorld.toGenericMatrix<3, 3>());
1022
1023 origin = trackerPos + trackerOri.rotatedVector(m_opticalCenterLocal);
1024 direction = trackerOri.rotatedVector(m_opticalAxisLocal).normalized();
1025 return true;
1026}
1027
1028//=============================================================================================================
1029
1031{
1032 if (!m_opticalCalibValid)
1033 return false;
1034
1035 const QMatrix4x4& dev = m_deviceToWorld;
1036 if (dev.isIdentity())
1037 return false;
1038
1039 QMatrix4x4 offsetMat;
1040 offsetMat.setToIdentity();
1041 offsetMat.translate(m_offsetTranslation);
1042 offsetMat.rotate(m_offsetRotation);
1043
1044 const QMatrix4x4 trackerToWorld = dev * offsetMat.inverted();
1045 const QQuaternion trackerOri = QQuaternion::fromRotationMatrix(trackerToWorld.toGenericMatrix<3, 3>());
1046
1047 // Tracker local Z axis = "up" in the microscope view.
1048 // Orthogonalise against the optical axis to remove any tilt component.
1049 const QVector3D rawUp = trackerOri.rotatedVector(QVector3D(0.0f, 0.0f, 1.0f));
1050 const QVector3D axis = trackerOri.rotatedVector(m_opticalAxisLocal).normalized();
1051 up = (rawUp - QVector3D::dotProduct(rawUp, axis) * axis).normalized();
1052 return up.lengthSquared() > 0.5f; // degenerate if Y ≈ optical axis
1053}
1054
1055//=============================================================================================================
1056
1057bool PolhemusCoregistration::applyOpticalAxisFineAdjust(const QVector3D& targetWorldPos,
1058 float& correctionDeg)
1059{
1060 correctionDeg = 0.0f;
1061 if (!m_opticalCalibValid)
1062 return false;
1063
1064 // Get tracker-to-world transform (same as opticalRayInWorld)
1065 const QMatrix4x4& dev = m_deviceToWorld;
1066 if (dev.isIdentity())
1067 return false;
1068
1069 QMatrix4x4 offsetMat;
1070 offsetMat.setToIdentity();
1071 offsetMat.translate(m_offsetTranslation);
1072 offsetMat.rotate(m_offsetRotation);
1073
1074 const QMatrix4x4 trackerToWorld = dev * offsetMat.inverted();
1075 const QVector3D trackerPos(trackerToWorld(0, 3), trackerToWorld(1, 3), trackerToWorld(2, 3));
1076 const QQuaternion trackerOri = QQuaternion::fromRotationMatrix(trackerToWorld.toGenericMatrix<3, 3>());
1077
1078 // Current optical center in world frame
1079 const QVector3D optCenterWorld = trackerPos + trackerOri.rotatedVector(m_opticalCenterLocal);
1080
1081 // Desired axis direction: from optical center to the target point
1082 const QVector3D toTarget = (targetWorldPos - optCenterWorld);
1083 if (toTarget.length() < 0.001f)
1084 return false; // target too close to optical center
1085
1086 const QVector3D desiredDirWorld = toTarget.normalized();
1087
1088 // Current axis direction in world frame
1089 const QVector3D currentDirWorld = trackerOri.rotatedVector(m_opticalAxisLocal).normalized();
1090
1091 // Compute correction angle
1092 const float cosAngle = std::clamp(QVector3D::dotProduct(currentDirWorld, desiredDirWorld), -1.0f, 1.0f);
1093 correctionDeg = std::acos(cosAngle) * (180.0f / 3.14159265358979f);
1094
1095 // Sanity: reject corrections > 10° — likely a user error
1096 if (correctionDeg > 10.0f) {
1097 qWarning().nospace() << "Optical fine adjust: correction " << correctionDeg
1098 << "° exceeds 10° limit — rejected.";
1099 return false;
1100 }
1101
1102 // Save pre-adjustment axis for undo
1103 if (!m_opticalFineAdjustApplied)
1104 m_opticalAxisPreFineAdjust = m_opticalAxisLocal;
1105
1106 // Compute the rotation from current to desired direction, in world frame
1107 const QQuaternion worldCorrection = QQuaternion::rotationTo(currentDirWorld, desiredDirWorld);
1108
1109 // Transform the correction into tracker-local frame:
1110 // localCorrection = trackerOri⁻¹ * worldCorrection * trackerOri
1111 const QQuaternion trackerOriInv = trackerOri.inverted();
1112 const QQuaternion localCorrection = trackerOriInv * worldCorrection * trackerOri;
1113
1114 // Apply to the local axis
1115 m_opticalAxisLocal = localCorrection.rotatedVector(m_opticalAxisLocal).normalized();
1116 m_opticalFineAdjustDeg = correctionDeg;
1117 m_opticalFineAdjustApplied = true;
1118
1119 qInfo().nospace()
1120 << "Optical fine adjust applied: " << correctionDeg << "°"
1121 << "\n axis (local): (" << m_opticalAxisLocal.x() << ", "
1122 << m_opticalAxisLocal.y() << ", " << m_opticalAxisLocal.z() << ")";
1123
1125 return true;
1126}
1127
1128//=============================================================================================================
1129
1131{
1132 if (!m_opticalFineAdjustApplied)
1133 return;
1134
1135 m_opticalAxisLocal = m_opticalAxisPreFineAdjust;
1136 m_opticalFineAdjustApplied = false;
1137 m_opticalFineAdjustDeg = 0.0f;
1138
1139 qInfo() << "Optical fine adjust cleared — axis restored to original calibration.";
1141}
1142
1143//=============================================================================================================
1144// Session persistence helpers
1145//=============================================================================================================
1146
1147namespace
1148{
1149
1150void saveVec3(QSettings& s, const QString& key, const QVector3D& v)
1151{
1152 s.setValue(key + "/x", static_cast<double>(v.x()));
1153 s.setValue(key + "/y", static_cast<double>(v.y()));
1154 s.setValue(key + "/z", static_cast<double>(v.z()));
1155}
1156
1157QVector3D loadVec3(const QSettings& s, const QString& key)
1158{
1159 return QVector3D(
1160 static_cast<float>(s.value(key + "/x", 0.0).toDouble()),
1161 static_cast<float>(s.value(key + "/y", 0.0).toDouble()),
1162 static_cast<float>(s.value(key + "/z", 0.0).toDouble()));
1163}
1164
1165void saveMat4(QSettings& s, const QString& key, const QMatrix4x4& m)
1166{
1167 QByteArray data(reinterpret_cast<const char*>(m.constData()), 16 * sizeof(float));
1168 s.setValue(key, data);
1169}
1170
1171QMatrix4x4 loadMat4(const QSettings& s, const QString& key)
1172{
1173 QByteArray data = s.value(key).toByteArray();
1174 QMatrix4x4 m;
1175 if (data.size() == 16 * static_cast<int>(sizeof(float)))
1176 memcpy(m.data(), data.constData(), 16 * sizeof(float));
1177 return m;
1178}
1179
1180void saveQuat(QSettings& s, const QString& key, const QQuaternion& q)
1181{
1182 s.setValue(key + "/w", static_cast<double>(q.scalar()));
1183 s.setValue(key + "/x", static_cast<double>(q.x()));
1184 s.setValue(key + "/y", static_cast<double>(q.y()));
1185 s.setValue(key + "/z", static_cast<double>(q.z()));
1186}
1187
1188QQuaternion loadQuat(const QSettings& s, const QString& key)
1189{
1190 return QQuaternion(
1191 static_cast<float>(s.value(key + "/w", 1.0).toDouble()),
1192 static_cast<float>(s.value(key + "/x", 0.0).toDouble()),
1193 static_cast<float>(s.value(key + "/y", 0.0).toDouble()),
1194 static_cast<float>(s.value(key + "/z", 0.0).toDouble()));
1195}
1196
1197} // anonymous namespace
1198
1199//=============================================================================================================
1200
1201void PolhemusCoregistration::saveSessionState(QSettings& settings, const QString& prefix) const
1202{
1203 settings.beginGroup(prefix);
1204
1205 // Stations & axis mirror
1206 settings.setValue("trackerStation", m_trackerStation);
1207 settings.setValue("penStation", m_penStation);
1208 settings.setValue("mirrorX", m_mirrorX);
1209 settings.setValue("mirrorY", m_mirrorY);
1210
1211 // Pen fiducials
1212 const char* fidNames[] = {"LPA", "NAS", "RPA", "CZ"};
1213 for (int i = 0; i < 4; ++i) {
1214 settings.setValue(QString("hasPenFid/%1").arg(fidNames[i]), m_hasPenFid[i]);
1215 if (m_hasPenFid[i])
1216 saveVec3(settings, QString("penFid/%1").arg(fidNames[i]), m_penFid[i]);
1217 }
1218
1219 // Model fiducials
1220 for (int i = 0; i < 4; ++i) {
1221 settings.setValue(QString("hasModelFid/%1").arg(fidNames[i]), m_hasModelFid[i]);
1222 if (m_hasModelFid[i])
1223 saveVec3(settings, QString("modelFid/%1").arg(fidNames[i]), m_modelFid[i]);
1224 }
1225
1226 // Vertex
1227 settings.setValue("hasPenVertex", m_hasPenVertex);
1228 if (m_hasPenVertex)
1229 saveVec3(settings, "penVertex", m_penVertex);
1230 settings.setValue("hasModelVertex", m_hasModelVertex);
1231 if (m_hasModelVertex)
1232 saveVec3(settings, "modelVertex", m_modelVertex);
1233
1234 // Calibration offset
1235 saveVec3(settings, "offsetTranslation", m_offsetTranslation);
1236 saveQuat(settings, "offsetRotation", m_offsetRotation);
1237
1238 // Pen tip offset
1239 saveVec3(settings, "penTipOffset", m_penTipOffset);
1240 settings.setValue("tipOffsetEnabled", m_tipOffsetEnabled);
1241
1242 // Optical calibration
1243 settings.setValue("opticalCalibValid", m_opticalCalibValid);
1244 settings.setValue("knownTrackerToObjectiveDist", static_cast<double>(m_knownTrackerToObjectiveDist));
1245 settings.setValue("hasObjectiveCenter", m_hasObjectiveCenter);
1246 if (m_hasObjectiveCenter)
1247 saveVec3(settings, "objectiveCenterLocal", m_objectiveCenterLocal);
1248 if (m_opticalCalibValid) {
1249 saveVec3(settings, "opticalAxisLocal", m_opticalAxisLocal);
1250 saveVec3(settings, "opticalCenterLocal", m_opticalCenterLocal);
1251 settings.setValue("opticalCalibResidualMm", static_cast<double>(m_opticalCalibResidualMm));
1252 settings.setValue("opticalCalibDepthSpreadMm", static_cast<double>(m_opticalCalibDepthSpreadMm));
1253
1254 // Fine adjustment
1255 settings.setValue("opticalFineAdjustApplied", m_opticalFineAdjustApplied);
1256 if (m_opticalFineAdjustApplied) {
1257 saveVec3(settings, "opticalAxisPreFineAdjust", m_opticalAxisPreFineAdjust);
1258 settings.setValue("opticalFineAdjustDeg", static_cast<double>(m_opticalFineAdjustDeg));
1259 }
1260 }
1261
1262 // Optical calibration samples (so calibration can be re-solved after restart)
1263 const int nOptSamples = static_cast<int>(m_opticalCalibSamples.size());
1264 settings.setValue("opticalCalibSampleCount", nOptSamples);
1265 for (int i = 0; i < nOptSamples; ++i) {
1266 const auto& s = m_opticalCalibSamples[static_cast<size_t>(i)];
1267 const QString key = QString("opticalCalibSample/%1").arg(i);
1268 saveVec3(settings, key + "/trackerPos", s.trackerPos);
1269 saveQuat(settings, key + "/trackerOri", s.trackerOri);
1270 saveVec3(settings, key + "/focusPoint", s.focusPoint);
1271 }
1272
1273 // Registration transforms
1274 settings.setValue("registrationValid", m_registrationValid);
1275 if (m_registrationValid) {
1276 saveMat4(settings, "headToDevice", m_headToDevice);
1277 saveMat4(settings, "headToWorld", m_headToWorld);
1278 saveMat4(settings, "worldToModel", m_worldToModel);
1279 }
1280
1281 settings.endGroup();
1282}
1283
1284//=============================================================================================================
1285
1286bool PolhemusCoregistration::restoreSessionState(QSettings& settings, const QString& prefix)
1287{
1288 settings.beginGroup(prefix);
1289 if (settings.allKeys().isEmpty()) {
1290 settings.endGroup();
1291 return false;
1292 }
1293
1294 // Stations & axis mirror
1295 m_trackerStation = settings.value("trackerStation", 1).toInt();
1296 m_penStation = settings.value("penStation", 2).toInt();
1297 m_mirrorX = settings.value("mirrorX", false).toBool();
1298 m_mirrorY = settings.value("mirrorY", false).toBool();
1299
1300 // Pen fiducials
1301 const char* fidNames[] = {"LPA", "NAS", "RPA", "CZ"};
1302 for (int i = 0; i < 4; ++i) {
1303 m_hasPenFid[i] = settings.value(QString("hasPenFid/%1").arg(fidNames[i]), false).toBool();
1304 if (m_hasPenFid[i])
1305 m_penFid[i] = loadVec3(settings, QString("penFid/%1").arg(fidNames[i]));
1306 }
1307
1308 // Model fiducials
1309 for (int i = 0; i < 4; ++i) {
1310 m_hasModelFid[i] = settings.value(QString("hasModelFid/%1").arg(fidNames[i]), false).toBool();
1311 if (m_hasModelFid[i])
1312 m_modelFid[i] = loadVec3(settings, QString("modelFid/%1").arg(fidNames[i]));
1313 }
1314
1315 // Vertex
1316 m_hasPenVertex = settings.value("hasPenVertex", false).toBool();
1317 if (m_hasPenVertex)
1318 m_penVertex = loadVec3(settings, "penVertex");
1319 m_hasModelVertex = settings.value("hasModelVertex", false).toBool();
1320 if (m_hasModelVertex)
1321 m_modelVertex = loadVec3(settings, "modelVertex");
1322
1323 // Calibration offset
1324 m_offsetTranslation = loadVec3(settings, "offsetTranslation");
1325 m_offsetRotation = loadQuat(settings, "offsetRotation");
1326
1327 // Pen tip offset
1328 m_penTipOffset = loadVec3(settings, "penTipOffset");
1329 m_tipOffsetEnabled = settings.value("tipOffsetEnabled", false).toBool();
1330
1331 // Optical calibration
1332 m_knownTrackerToObjectiveDist = static_cast<float>(settings.value("knownTrackerToObjectiveDist", 0.200).toDouble());
1333 m_hasObjectiveCenter = settings.value("hasObjectiveCenter", false).toBool();
1334 if (m_hasObjectiveCenter)
1335 m_objectiveCenterLocal = loadVec3(settings, "objectiveCenterLocal");
1336 m_opticalCalibValid = settings.value("opticalCalibValid", false).toBool();
1337 if (m_opticalCalibValid) {
1338 m_opticalAxisLocal = loadVec3(settings, "opticalAxisLocal");
1339 m_opticalCenterLocal = loadVec3(settings, "opticalCenterLocal");
1340 m_opticalCalibResidualMm = static_cast<float>(settings.value("opticalCalibResidualMm", 0.0).toDouble());
1341 m_opticalCalibDepthSpreadMm = static_cast<float>(settings.value("opticalCalibDepthSpreadMm", 0.0).toDouble());
1342
1343 // Fine adjustment
1344 m_opticalFineAdjustApplied = settings.value("opticalFineAdjustApplied", false).toBool();
1345 if (m_opticalFineAdjustApplied) {
1346 m_opticalAxisPreFineAdjust = loadVec3(settings, "opticalAxisPreFineAdjust");
1347 m_opticalFineAdjustDeg = static_cast<float>(settings.value("opticalFineAdjustDeg", 0.0).toDouble());
1348 }
1349 }
1350
1351 // Optical calibration samples
1352 const int nOptSamples = settings.value("opticalCalibSampleCount", 0).toInt();
1353 m_opticalCalibSamples.clear();
1354 m_opticalCalibSamples.reserve(static_cast<size_t>(nOptSamples));
1355 for (int i = 0; i < nOptSamples; ++i) {
1356 const QString key = QString("opticalCalibSample/%1").arg(i);
1358 s.trackerPos = loadVec3(settings, key + "/trackerPos");
1359 s.trackerOri = loadQuat(settings, key + "/trackerOri");
1360 s.focusPoint = loadVec3(settings, key + "/focusPoint");
1361 m_opticalCalibSamples.push_back(s);
1362 }
1363
1364 // Registration transforms
1365 m_registrationValid = settings.value("registrationValid", false).toBool();
1366 if (m_registrationValid) {
1367 m_headToDevice = loadMat4(settings, "headToDevice");
1368 m_headToWorld = loadMat4(settings, "headToWorld");
1369 m_worldToModel = loadMat4(settings, "worldToModel");
1370 }
1371
1372 settings.endGroup();
1373
1374 // Re-populate the AcquiredPoints list so that countOf() / UI status
1375 // reflect the restored fiducials.
1376 if (m_pPoints) {
1377 const char* fidLabels[] = {"", "LPA", "NAS", "RPA"};
1378 for (int i = 1; i <= 3; ++i) {
1379 m_pPoints->removeFiducial(static_cast<FiducialId>(i));
1380 if (m_hasPenFid[i]) {
1381 DigitizedPoint dp;
1383 dp.label = QString::fromLatin1(fidLabels[i]);
1384 dp.identNumber = i;
1385 dp.position = m_penFid[i];
1386 m_pPoints->append(dp);
1387 }
1388 }
1389 }
1390
1391 if (m_registrationValid)
1392 emit registrationChanged();
1393
1394 return m_registrationValid;
1395}
Eigen::Matrix3f R
Eigen::JacobiSVD< Eigen::Matrix3f > svd(S, Eigen::ComputeFullU|Eigen::ComputeFullV)
Head–device coregistration using the Polhemus Fastrak.
Shared digitised-point store for Polhemus digitizer sessions.
Shared utilities (I/O helpers, spectral analysis, layout management, warp algorithms).
FiducialId
Identifier for the three cardinal fiducials.
@ HeadShape
Free head-shape point (continuous mode).
@ Fiducial
Anatomical landmark (NAS / LPA / RPA).
A single digitised point captured during the alignment session.
QString label
Human label ("NAS", "Cz", "HSP-42", …).
QVector3D position
Position in metres, sensor frame.
int identNumber
1-based id used by FIFF on export.
In-memory store of all points captured during a session.
Polhemus digitizer connection (mock + serial-port backends).
void penButtonPressed(int station, const QVector3D &position, const QQuaternion &orientation)
void pointReceived(int station, const QVector3D &position, const QQuaternion &orientation)
bool applyOpticalAxisFineAdjust(const QVector3D &targetWorldPos, float &correctionDeg)
Fine-adjust the optical axis so it passes through a known world-frame point (e.g. the probe tip touch...
void penPoseChanged(const QVector3D &position, const QQuaternion &orientation)
bool solveOpticalCalibration()
Fit a 3D line through the focus points in tracker-local frame.
void setConnection(PolhemusConnection *conn)
bool restoreSessionState(QSettings &settings, const QString &prefix=QStringLiteral("polhemus"))
QVector3D modelFiducial(FiducialId id) const
bool captureCurrentPenPositionAsFiducial(FiducialId id)
bool opticalRayInWorld(QVector3D &origin, QVector3D &direction) const
Compute the current optical axis ray in Polhemus world frame.
void penButtonPressed(const QVector3D &position, const QQuaternion &orientation)
void setModelFiducial(FiducialId id, const QVector3D &posInModel)
void devicePoseChanged(const QMatrix4x4 &deviceToWorld)
bool captureObjectiveCenter()
Capture the current pen position as the objective lens center.
void pivotCalibrationDone(const QVector3D &offset, float residualMm)
void pivotStateChanged(PolhemusCoregistration::PivotState state)
bool captureOpticalCalibSample()
Record one calibration sample (current tracker pose + current pen position).
PolhemusCoregistration(QObject *parent=nullptr)
void setTrackerToDeviceOffset(const QVector3D &translation, const QQuaternion &rotation)
Set the rigid offset from the tracker sensor body frame to the device frame.
void resetRegistration()
Reset the registration state (headToWorld, headToDevice) to identity. Call this when the user clears ...
void saveSessionState(QSettings &settings, const QString &prefix=QStringLiteral("polhemus")) const
void probePoseChanged(const QVector3D &position, const QQuaternion &orientation)
void pivotSampleCollected(int sampleCount, float angularSpanDeg)
bool computeRegistration()
Compute the head→device rigid transform from the three captured fiducials (NAS, LPA,...
QVector3D focusPoint
Stylus focus point in world frame (metres).
QVector3D trackerPos
Tracker position in world frame (metres).
QQuaternion trackerOri
Tracker orientation in world frame.