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