Summary
homography2d builds the 9×9 DLT normal matrix and solves for its smallest eigenvector entirely in single precision:
this.mLtL = new matrix_t(9, 9, JSFEAT_CONSTANTS.F32_t | JSFEAT_CONSTANTS.C1_t);
this.Evec = new matrix_t(9, 9, JSFEAT_CONSTANTS.F32_t | JSFEAT_CONSTANTS.C1_t);
and motion_model.T0 / T1 are F32_t as well.
OpenCV's HomographyEstimatorCallback::runKernel (modules/calib3d/src/fundam.cpp) uses doubles throughout:
double LtL[9][9], W[9][1], V[9][9];
Mat _LtL( 9, 9, CV_64F, &LtL[0][0] );
...
double invHnorm[9] = { 1./sm.x, 0, cm.x, 0, 1./sm.y, cm.y, 0, 0, 1 };
Two compounding problems:
- Normal equations square the condition number. Forming
LᵀL instead of solving on L directly costs roughly half the available significant digits. f32 has ~7 decimal digits to begin with, so this is expensive.
- Every accumulation rounds.
LtL[0] += X * X reads a Float32Array slot, adds in f64, and stores back rounded to f32 — 36 upper-triangle accumulators × N points, each rounded on every step.
The structure of the hand-unrolled accumulation itself is correct — I checked all 45 upper-triangle slots against OpenCV's for(j) for(k=j) loop, and every omitted index is a genuine structural zero (Lx[0]*Lx[3] + Ly[0]*Ly[3] = X*0 + 0*X). This is purely a dtype issue.
Impact
Measured on webarkit/webarkit's cv-backend-jsfeatnext adapter: on a noise-free, mathematically exact 12-point translation, repeated ransac() calls returned homographies differing by up to ~0.36px in reprojection. The refit proposed in #185 will shrink that spread a lot but cannot remove it — f32 accumulation is the floor underneath it. Any sub-pixel acceptance criterion in #185 depends on this issue being fixed too.
Proposed change
eigenVV is already dtype-generic: it derives dt from A.type and sizes its scratch buffers with <<3 (8 bytes/element, f64-safe), and matrix_t already supports F64_t. matmath.multiply_3x3 operates on .data without dtype assumptions. So the change looks small:
static inline double scaleFor(double x){
return (std::fabs(x) > std::numeric_limits<float>::epsilon()) ? 1./x : 1.;
}
A near-zero md[8] (pure-perspective degenerate) currently produces Infinity silently.
Acceptance criteria
Out of scope
- Replacing normal equations with an SVD on
L directly. linalg.svd_decompose exists, and it would be the numerically right answer, but it is a bigger change with WASM-portability implications for the downstream no_std work. Worth its own issue if f64 turns out not to be enough.
affine2d's lu_solve path — same dtype question, but it is a 6×6 well-conditioned system and has not been measured as a problem.
Related
Summary
homography2dbuilds the 9×9 DLT normal matrix and solves for its smallest eigenvector entirely in single precision:and
motion_model.T0/T1areF32_tas well.OpenCV's
HomographyEstimatorCallback::runKernel(modules/calib3d/src/fundam.cpp) uses doubles throughout:Two compounding problems:
LᵀLinstead of solving onLdirectly costs roughly half the available significant digits. f32 has ~7 decimal digits to begin with, so this is expensive.LtL[0] += X * Xreads aFloat32Arrayslot, adds in f64, and stores back rounded to f32 — 36 upper-triangle accumulators × N points, each rounded on every step.The structure of the hand-unrolled accumulation itself is correct — I checked all 45 upper-triangle slots against OpenCV's
for(j) for(k=j)loop, and every omitted index is a genuine structural zero (Lx[0]*Lx[3] + Ly[0]*Ly[3] = X*0 + 0*X). This is purely a dtype issue.Impact
Measured on
webarkit/webarkit'scv-backend-jsfeatnextadapter: on a noise-free, mathematically exact 12-point translation, repeatedransac()calls returned homographies differing by up to ~0.36px in reprojection. The refit proposed in #185 will shrink that spread a lot but cannot remove it — f32 accumulation is the floor underneath it. Any sub-pixel acceptance criterion in #185 depends on this issue being fixed too.Proposed change
eigenVVis already dtype-generic: it derivesdtfromA.typeand sizes its scratch buffers with<<3(8 bytes/element, f64-safe), andmatrix_talready supportsF64_t.matmath.multiply_3x3operates on.datawithout dtype assumptions. So the change looks small:mLtL,Evec→F64_t | C1_tmotion_model.T0,T1→F64_t | C1_t(andAtA/AtBinaffine2d's path, for consistency)F64scratch for the 3×3 model and copy into the caller'smodelonce at the end, so the twomultiply_3x3denormalisation steps don't run at the caller's dtype (callers constructmodelasF32_t— see the existing tests)x = 1.0 / md[8]unconditionally; OpenCV usesscaleFor, which falls back to1.0when|x|is below float epsilon:A near-zero
md[8](pure-perspective degenerate) currently producesInfinitysilently.Acceptance criteria
md[8] ≈ 0tests/parity/linalg.test.tsandmatmath.test.tsstill pass (eigenVVis shared)tests/parity/motion_estimator.test.ts: thetoBeCloseTo(..., 5)comparison against the f32 jsfeat oracle will now diverge in our favour — expect this and document it, or compare against a higher-precision reference insteadbench/motion_model.bench.tsbefore/after — f64 arithmetic in JS is normally not slower than f32, butFloat64Arraydoubles the memory traffic on the 9×9, so confirm rather than assumeOut of scope
Ldirectly.linalg.svd_decomposeexists, and it would be the numerically right answer, but it is a bigger change with WASM-portability implications for the downstreamno_stdwork. Worth its own issue if f64 turns out not to be enough.affine2d'slu_solvepath — same dtype question, but it is a 6×6 well-conditioned system and has not been measured as a problem.Related
motion_estimator.ransac: doc comment promises a refit over all inliers; jsfeatNext has no layer that performs one #185 (RANSAC refit) — the two together determine the achievable precision; neither is sufficient alonewebarkit/webarkit#8(loosened adapter test tolerance)