fix: derive camera FOV from the video and the object-fit:cover crop (was hardcoded 60deg vertical vs the video's 36deg — measured 1.7x projection error); aim check now compares in viewport NDC
This commit is contained in:
+43
-6
@@ -91,7 +91,7 @@ function dbgStatus() {
|
|||||||
const ws = netRef && netRef.ws ? ['conn','open','closing','closed'][netRef.ws.readyState] : 'none';
|
const ws = netRef && netRef.ws ? ['conn','open','closing','closed'][netRef.ws.readyState] : 'none';
|
||||||
return `ws ${ws} rx ${rxLog.join(',') || '-'}\n` +
|
return `ws ${ws} rx ${rxLog.join(',') || '-'}\n` +
|
||||||
`scene ${sceneLoaded ? anchorCount + ' anchors' : 'NOT LOADED'} ghost recs ${activeGhosts.size}\n` +
|
`scene ${sceneLoaded ? anchorCount + ' anchors' : 'NOT LOADED'} ghost recs ${activeGhosts.size}\n` +
|
||||||
`${fps} fps detect ${DETECT_HZ}Hz\n`;
|
`${fps} fps detect ${DETECT_HZ}Hz fov ${camera3 ? camera3.fov.toFixed(0) : '?'}\n`;
|
||||||
}
|
}
|
||||||
function paintDbg() { if (dbgEl) dbgEl.textContent = dbgStatus() + dbgPose + dbgAim; }
|
function paintDbg() { if (dbgEl) dbgEl.textContent = dbgStatus() + dbgPose + dbgAim; }
|
||||||
|
|
||||||
@@ -99,9 +99,11 @@ function paintDbg() { if (dbgEl) dbgEl.textContent = dbgStatus() + dbgPose + dbg
|
|||||||
* camera frame sits where the solved pose projects it. Compares the marker's
|
* camera frame sits where the solved pose projects it. Compares the marker's
|
||||||
* measured pixel centre against its projected position, and reports the ghost's
|
* measured pixel centre against its projected position, and reports the ghost's
|
||||||
* on-screen NDC. |ndc| < 1 means the ghost is inside the frustum.
|
* on-screen NDC. |ndc| < 1 means the ghost is inside the frustum.
|
||||||
* px = where the crest actually is in frame (-1..1, 0 = centre)
|
* px = where the crest actually is on screen (-1..1, 0 = centre)
|
||||||
* proj= where the pose says it should be
|
* proj= where the pose says it should be
|
||||||
* Those two disagreeing is a wrong frame convention, not tracking noise. */
|
* Those two disagreeing is a camera-model or frame-convention error, not
|
||||||
|
* tracking noise: a sign flip means a mirrored axis, a constant ratio means the
|
||||||
|
* projection does not match the lens. */
|
||||||
let dbgAim = '';
|
let dbgAim = '';
|
||||||
const _aimV = new THREE.Vector3();
|
const _aimV = new THREE.Vector3();
|
||||||
function updateAim(markers, W, H) {
|
function updateAim(markers, W, H) {
|
||||||
@@ -112,8 +114,10 @@ function updateAim(markers, W, H) {
|
|||||||
const a = cvSolver.anchors.get(m0.id);
|
const a = cvSolver.anchors.get(m0.id);
|
||||||
let cxp = 0, cyp = 0;
|
let cxp = 0, cyp = 0;
|
||||||
for (const c of m0.corners) { cxp += c.x; cyp += c.y; }
|
for (const c of m0.corners) { cxp += c.x; cyp += c.y; }
|
||||||
cxp = (cxp / 4) / W * 2 - 1;
|
// full-frame NDC, then rescaled into viewport NDC so it is directly
|
||||||
cyp = -((cyp / 4) / H * 2 - 1);
|
// comparable with proj (the cropped sides are not on screen)
|
||||||
|
cxp = ((cxp / 4) / W * 2 - 1) / _crop.x;
|
||||||
|
cyp = -((cyp / 4) / H * 2 - 1) / _crop.y;
|
||||||
s += `m${m0.id} px(${cxp.toFixed(2)},${cyp.toFixed(2)})`;
|
s += `m${m0.id} px(${cxp.toFixed(2)},${cyp.toFixed(2)})`;
|
||||||
if (a) {
|
if (a) {
|
||||||
_aimV.setFromMatrixPosition(a.mat).project(camera3);
|
_aimV.setFromMatrixPosition(a.mat).project(camera3);
|
||||||
@@ -225,6 +229,9 @@ async function start() {
|
|||||||
});
|
});
|
||||||
video.srcObject = stream;
|
video.srcObject = stream;
|
||||||
await video.play();
|
await video.play();
|
||||||
|
// dimensions can land after play() resolves on iOS; recompute when they do
|
||||||
|
video.addEventListener('loadedmetadata', updateCameraIntrinsics);
|
||||||
|
video.addEventListener('resize', updateCameraIntrinsics);
|
||||||
|
|
||||||
// detection canvas
|
// detection canvas
|
||||||
canvas2d = document.createElement('canvas');
|
canvas2d = document.createElement('canvas');
|
||||||
@@ -327,7 +334,37 @@ async function start() {
|
|||||||
|
|
||||||
function onResize() {
|
function onResize() {
|
||||||
renderer.setSize(innerWidth, innerHeight);
|
renderer.setSize(innerWidth, innerHeight);
|
||||||
|
updateCameraIntrinsics();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Match the virtual camera to the video the user can actually SEE.
|
||||||
|
*
|
||||||
|
* Two things were wrong before: the camera was hardcoded to a 60 degree
|
||||||
|
* VERTICAL fov, while a 1280x720 feed at ~60 degrees HORIZONTAL is only ~36
|
||||||
|
* degrees vertically; and #cam is `object-fit: cover`, so on a portrait screen
|
||||||
|
* the sides of the frame are cropped away and never seen. Both make world
|
||||||
|
* geometry project too far from centre — measured at 1.7x on device, which
|
||||||
|
* reads as ghosts sliding the wrong way when you tilt.
|
||||||
|
*
|
||||||
|
* So: take the focal length implied by HFOV_DEG over the full video width, work
|
||||||
|
* out how much of the frame survives the cover-crop, and set the fov from that
|
||||||
|
* visible height. Cover crops symmetrically, so the principal point stays
|
||||||
|
* centred and no lens shift is needed. */
|
||||||
|
const _crop = { x: 1, y: 1 }; // fraction of the video width/height still visible
|
||||||
|
function updateCameraIntrinsics() {
|
||||||
|
if (!camera3) return;
|
||||||
camera3.aspect = innerWidth / innerHeight;
|
camera3.aspect = innerWidth / innerHeight;
|
||||||
|
if (!video || !video.videoWidth) { camera3.updateProjectionMatrix(); return; }
|
||||||
|
|
||||||
|
const vw = video.videoWidth, vh = video.videoHeight;
|
||||||
|
const focal = (vw / 2) / Math.tan(THREE.MathUtils.degToRad(HFOV_DEG) / 2);
|
||||||
|
const scale = Math.max(innerWidth / vw, innerHeight / vh); // object-fit: cover
|
||||||
|
const visW = Math.min(vw, innerWidth / scale);
|
||||||
|
const visH = Math.min(vh, innerHeight / scale);
|
||||||
|
_crop.x = visW / vw;
|
||||||
|
_crop.y = visH / vh;
|
||||||
|
|
||||||
|
camera3.fov = 2 * Math.atan((visH / 2) / focal) * 180 / Math.PI;
|
||||||
camera3.updateProjectionMatrix();
|
camera3.updateProjectionMatrix();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -433,7 +470,7 @@ function loop(t) {
|
|||||||
tracking.lastSeen = performance.now();
|
tracking.lastSeen = performance.now();
|
||||||
|
|
||||||
if (engine === 'opencv' && cvState === 'ready') {
|
if (engine === 'opencv' && cvState === 'ready') {
|
||||||
// v3 path: one joint solve over every visible crest (runs in the worker)
|
// v3 path: one joint solve over every visible crest
|
||||||
const fused = cvSolver.solve(markers, W, H, focal);
|
const fused = cvSolver.solve(markers, W, H, focal);
|
||||||
if (fused) {
|
if (fused) {
|
||||||
camera3.position.copy(fused.position);
|
camera3.position.copy(fused.position);
|
||||||
|
|||||||
Reference in New Issue
Block a user