fix: reject the mirrored coplanar-ambiguity pose — camera-below-sheet and teleport gates, reproj gate 8px -> 4px, rejection reason reported
This commit is contained in:
+51
-10
@@ -8,8 +8,8 @@
|
|||||||
*
|
*
|
||||||
* Protocol (main <-> worker):
|
* Protocol (main <-> worker):
|
||||||
* -> { type:'init' } <- { type:'ready' } | { type:'failed', msg }
|
* -> { type:'init' } <- { type:'ready' } | { type:'failed', msg }
|
||||||
* -> { type:'solve', id, obj, img, n, W,H,f } <- { type:'pose', id, ok, C, m3, reproj, n }
|
* -> { type:'solve', id, obj, img, n, W,H,f } <- { type:'pose', id, ok, C, m3, reproj, n, why }
|
||||||
* -> { type:'reset' } (drop temporal warm start)
|
* -> { type:'reset' } (drop temporal warm start + gate state)
|
||||||
* obj/img are plain Float64Array payloads (transferred, so no copy).
|
* obj/img are plain Float64Array payloads (transferred, so no copy).
|
||||||
* The worker never touches three.js: it returns the camera position and the
|
* The worker never touches three.js: it returns the camera position and the
|
||||||
* 3x3 world-from-camera rotation, and the client builds the quaternion.
|
* 3x3 world-from-camera rotation, and the client builds the quaternion.
|
||||||
@@ -17,8 +17,28 @@
|
|||||||
|
|
||||||
let cv = null;
|
let cv = null;
|
||||||
let prev = null; // { r:[3], t:[3], at } temporal warm start
|
let prev = null; // { r:[3], t:[3], at } temporal warm start
|
||||||
|
let lastGood = null; // { C:[3], at } last ACCEPTED camera position
|
||||||
|
let rejectStreak = 0;
|
||||||
const PREV_TTL = 1500;
|
const PREV_TTL = 1500;
|
||||||
const MAX_REPROJ_PX = 8;
|
|
||||||
|
/* Sanity gates. Every crest on a printed sheet is coplanar, and a coplanar point
|
||||||
|
* set has TWO poses that reproject almost equally well — the true one and a
|
||||||
|
* mirrored twin. With only one or two crests in frame, solvePnP intermittently
|
||||||
|
* returns the twin, which on device looks like ghosts snapping to the wrong side
|
||||||
|
* and vanishing. Reprojection error alone can't separate them (the twin fits the
|
||||||
|
* pixels), so we reject on physics instead:
|
||||||
|
* - the camera is always ABOVE the sheet (world +Y); the twin usually isn't
|
||||||
|
* - a phone can't teleport 20cm between frames 50ms apart
|
||||||
|
* The streak escape hatch stops a bad lastGood from locking tracking out.
|
||||||
|
*
|
||||||
|
* These gates SUPPRESS the symptom. The cure is non-coplanar geometry: one crest
|
||||||
|
* standing vertical removes the ambiguity outright, because no mirrored pose can
|
||||||
|
* fit points that don't share a plane. */
|
||||||
|
const MAX_REPROJ_PX = 4; // was 8 — that accepted visibly wrong poses
|
||||||
|
const MIN_CAM_Y_CM = 1; // camera below the sheet is never real
|
||||||
|
const MAX_JUMP_CM = 20; // per accepted-pose gap, scaled by elapsed time
|
||||||
|
const JUMP_GRACE_MS = 500;
|
||||||
|
const MAX_REJECT_STREAK = 12;
|
||||||
|
|
||||||
// reusable Mats — allocating 7 Mats per frame was a measurable cost
|
// reusable Mats — allocating 7 Mats per frame was a measurable cost
|
||||||
let bufN = 0;
|
let bufN = 0;
|
||||||
@@ -57,7 +77,7 @@ function solve(msg) {
|
|||||||
let ok = false;
|
let ok = false;
|
||||||
try { ok = cv.solvePnP(objM, imgM, K, dist, rvec, tvec, false, cv.SOLVEPNP_SQPNP); } catch { ok = false; }
|
try { ok = cv.solvePnP(objM, imgM, K, dist, rvec, tvec, false, cv.SOLVEPNP_SQPNP); } catch { ok = false; }
|
||||||
if (!ok) { try { ok = cv.solvePnP(objM, imgM, K, dist, rvec, tvec, false, cv.SOLVEPNP_IPPE); } catch { ok = false; } }
|
if (!ok) { try { ok = cv.solvePnP(objM, imgM, K, dist, rvec, tvec, false, cv.SOLVEPNP_IPPE); } catch { ok = false; } }
|
||||||
if (!ok) return { ok: false };
|
if (!ok) return reject('nosolve', 0);
|
||||||
cv.solvePnP(objM, imgM, K, dist, rvec, tvec, true, cv.SOLVEPNP_ITERATIVE);
|
cv.solvePnP(objM, imgM, K, dist, rvec, tvec, true, cv.SOLVEPNP_ITERATIVE);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,9 +88,7 @@ function solve(msg) {
|
|||||||
err += Math.hypot(proj.data64F[2 * i] - img[2 * i], proj.data64F[2 * i + 1] - img[2 * i + 1]);
|
err += Math.hypot(proj.data64F[2 * i] - img[2 * i], proj.data64F[2 * i + 1] - img[2 * i + 1]);
|
||||||
}
|
}
|
||||||
err /= n;
|
err /= n;
|
||||||
if (err > MAX_REPROJ_PX) { prev = null; return { ok: false, reproj: err }; }
|
if (err > MAX_REPROJ_PX) { prev = null; return reject('reproj', err); }
|
||||||
|
|
||||||
prev = { r: [...rvec.data64F], t: [...tvec.data64F], at: Date.now() };
|
|
||||||
|
|
||||||
cv.Rodrigues(rvec, R);
|
cv.Rodrigues(rvec, R);
|
||||||
const d = R.data64F; // row-major world->cvCam
|
const d = R.data64F; // row-major world->cvCam
|
||||||
@@ -81,9 +99,32 @@ function solve(msg) {
|
|||||||
-(d[1] * t[0] + d[4] * t[1] + d[7] * t[2]),
|
-(d[1] * t[0] + d[4] * t[1] + d[7] * t[2]),
|
||||||
-(d[2] * t[0] + d[5] * t[1] + d[8] * t[2]),
|
-(d[2] * t[0] + d[5] * t[1] + d[8] * t[2]),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// ---- gates ----
|
||||||
|
const now = Date.now();
|
||||||
|
const forced = rejectStreak >= MAX_REJECT_STREAK; // escape hatch
|
||||||
|
if (!forced) {
|
||||||
|
if (C[1] < MIN_CAM_Y_CM) { prev = null; return reject('below', err); }
|
||||||
|
if (lastGood && (now - lastGood.at) < JUMP_GRACE_MS) {
|
||||||
|
const dt = Math.max(1, now - lastGood.at) / 1000;
|
||||||
|
const jump = Math.hypot(C[0] - lastGood.C[0], C[1] - lastGood.C[1], C[2] - lastGood.C[2]);
|
||||||
|
// allowance grows with elapsed time so real movement is never blocked
|
||||||
|
if (jump > MAX_JUMP_CM * Math.max(1, dt * 4)) { prev = null; return reject('jump', err); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rejectStreak = 0;
|
||||||
|
prev = { r: [...rvec.data64F], t: [...tvec.data64F], at: now };
|
||||||
|
lastGood = { C, at: now };
|
||||||
|
|
||||||
// world-from-threeCam = R^T * diag(1,-1,-1), row-major for Matrix4.set
|
// world-from-threeCam = R^T * diag(1,-1,-1), row-major for Matrix4.set
|
||||||
const m3 = [d[0], -d[3], -d[6], d[1], -d[4], -d[7], d[2], -d[5], -d[8]];
|
const m3 = [d[0], -d[3], -d[6], d[1], -d[4], -d[7], d[2], -d[5], -d[8]];
|
||||||
return { ok: true, C, m3, reproj: err };
|
return { ok: true, C, m3, reproj: err, forced };
|
||||||
|
}
|
||||||
|
|
||||||
|
function reject(why, reproj) {
|
||||||
|
rejectStreak++;
|
||||||
|
return { ok: false, why, reproj };
|
||||||
}
|
}
|
||||||
|
|
||||||
self.onmessage = (ev) => {
|
self.onmessage = (ev) => {
|
||||||
@@ -119,13 +160,13 @@ self.onmessage = (ev) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (msg.type === 'reset') { prev = null; return; }
|
if (msg.type === 'reset') { prev = null; lastGood = null; rejectStreak = 0; return; }
|
||||||
|
|
||||||
if (msg.type === 'solve') {
|
if (msg.type === 'solve') {
|
||||||
if (!cv) return self.postMessage({ type: 'pose', id: msg.id, ok: false });
|
if (!cv) return self.postMessage({ type: 'pose', id: msg.id, ok: false });
|
||||||
let out;
|
let out;
|
||||||
try { out = solve(msg); }
|
try { out = solve(msg); }
|
||||||
catch (e) { prev = null; out = { ok: false }; }
|
catch (e) { prev = null; out = { ok: false, why: 'throw' }; }
|
||||||
self.postMessage({ type: 'pose', id: msg.id, n: msg.n, ...out });
|
self.postMessage({ type: 'pose', id: msg.id, n: msg.n, ...out });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user