From 4e619a4e5f98d3f77d9bd9beacd300e537920c00 Mon Sep 17 00:00:00 2001 From: Walter Perdan Date: Tue, 7 Jul 2026 13:46:03 +0200 Subject: [PATCH 1/2] fix(motion_estimator): restore affine2d error() and check_subset() (#51) jsfeatNext's affine2d kernel was missing the error() and check_subset() methods that original jsfeat defines on it, so motion_estimator.ransac / lmeds with an affine2d kernel threw 'kernel.check_subset/error is not a function'. Port both from jsfeat's affine2d (error = affine reprojection residual; check_subset = true). The parity test added in #49 is flipped from pinning the crash to a full parity check: affine2d RANSAC now matches the oracle's 3x3 model and inlier mask on identical seeded data, and rejects the synthetic outliers. npm test: 57 passed; tsc --noEmit: clean. Closes #51 Co-Authored-By: Claude Opus 4.8 --- src/jsfeatNext.ts | 70 ++++++++++++++++++--------- tests/parity/motion_estimator.test.ts | 27 ++++++----- 2 files changed, 62 insertions(+), 35 deletions(-) diff --git a/src/jsfeatNext.ts b/src/jsfeatNext.ts index f93eb58..6f4dda0 100644 --- a/src/jsfeatNext.ts +++ b/src/jsfeatNext.ts @@ -271,6 +271,28 @@ class affine2d extends motion_model { return 1; } + + // Per-point reprojection error for the affine model. Ported from original + // jsfeat's affine2d; jsfeatNext was missing it, which made RANSAC/LMEDS + // with an affine2d kernel throw. See issue #51. + error(from: point_t[], to: point_t[], model: matrix_t, err: Int32Array | Float32Array, count: number): void { + let i = 0; + let pt0, pt1; + const m = model.data; + + for (; i < count; ++i) { + pt0 = from[i]; + pt1 = to[i]; + + err[i] = + this.sqr(pt1.x - m[0] * pt0.x - m[1] * pt0.y - m[2]) + + this.sqr(pt1.y - m[3] * pt0.x - m[4] * pt0.y - m[5]); + } + } + + check_subset(from: point_t[], to: point_t[], count: number): boolean { + return true; // all good + } } class homography2d extends motion_model { @@ -2164,13 +2186,13 @@ jsfeatNext.math = class math extends jsfeatNext { ? cmp(tb, tc) ? b : cmp(ta, tc) - ? c - : a + ? c + : a : cmp(tc, tb) - ? b - : cmp(ta, tc) - ? a - : c; + ? b + : cmp(ta, tc) + ? a + : c; (a = pivot - d), (b = pivot), (c = pivot + d); (ta = array[a]), (tb = array[b]), (tc = array[c]); @@ -2178,13 +2200,13 @@ jsfeatNext.math = class math extends jsfeatNext { ? cmp(tb, tc) ? b : cmp(ta, tc) - ? c - : a + ? c + : a : cmp(tc, tb) - ? b - : cmp(ta, tc) - ? a - : c; + ? b + : cmp(ta, tc) + ? a + : c; (a = right - (d << 1)), (b = right - d), (c = right); (ta = array[a]), (tb = array[b]), (tc = array[c]); @@ -2192,13 +2214,13 @@ jsfeatNext.math = class math extends jsfeatNext { ? cmp(tb, tc) ? b : cmp(ta, tc) - ? c - : a + ? c + : a : cmp(tc, tb) - ? b - : cmp(ta, tc) - ? a - : c; + ? b + : cmp(ta, tc) + ? a + : c; } (a = left), (b = pivot), (c = right); @@ -2207,13 +2229,13 @@ jsfeatNext.math = class math extends jsfeatNext { ? cmp(tb, tc) ? b : cmp(ta, tc) - ? c - : a + ? c + : a : cmp(tc, tb) - ? b - : cmp(ta, tc) - ? a - : c; + ? b + : cmp(ta, tc) + ? a + : c; if (pivot != left0) { t = array[pivot]; array[pivot] = array[left0]; diff --git a/tests/parity/motion_estimator.test.ts b/tests/parity/motion_estimator.test.ts index 6c0126a..1e4bd41 100644 --- a/tests/parity/motion_estimator.test.ts +++ b/tests/parity/motion_estimator.test.ts @@ -149,11 +149,10 @@ describe("parity: motion_estimator vs original jsfeat.motion_estimator", () => { to.push({ x: X, y: Y }); } - // BUG FOUND BY THIS SUITE (documented divergence, see the tracking - // issue): jsfeatNext's motion_model base class lost the default - // check_subset() (original jsfeat returns true there; only - // homography2d overrides it), so RANSAC with an affine2d kernel - // throws TypeError in jsfeatNext while it works in original jsfeat. + // Regression test for #51: jsfeatNext's motion_model base class was + // missing the default check_subset() (original jsfeat returns true + // there; only homography2d overrides it), which made RANSAC with an + // affine2d kernel throw TypeError. Now fixed — full parity check. const params = new jsfeatNext.ransac_params_t(3, 3.0, 0.5, 0.99); const me = new jsfeatNext.motion_estimator(); const kernel = new jsfeatNext.affine2d(); @@ -161,23 +160,29 @@ describe("parity: motion_estimator vs original jsfeat.motion_estimator", () => { const mask = new jsfeatNext.matrix_t(N, 1, U8C1); seededRandom(31337); - expect(() => me.ransac(params, kernel, from, to, N, model, mask, 1000)).toThrow(TypeError); + const okN = me.ransac(params, kernel, from, to, N, model, mask, 1000); vi.restoreAllMocks(); - // ...whereas the original jsfeat succeeds on the same data: const paramsO = new jsfeat.ransac_params_t(3, 3.0, 0.5, 0.99); const kernelO = new jsfeat.motion_model.affine2d(); const modelO = new jsfeat.matrix_t(3, 3, jsfeat.F32_t | jsfeat.C1_t); const maskO = new jsfeat.matrix_t(N, 1, jsfeat.U8_t | jsfeat.C1_t); - seededRandom(31337); + seededRandom(31337); // identical random sequence for the oracle const okO = jsfeat.motion_estimator.ransac(paramsO, kernelO, from, to, N, modelO, maskO, 1000); vi.restoreAllMocks(); - expect(okO).toBe(true); - // the oracle also rejects the synthetic outliers + expect(okN).toBe(okO); + expect(okN).toBe(true); + for (let i = 0; i < N; i++) { + expect(mask.data[i]).toBe(maskO.data[i]); + } + for (let i = 0; i < 9; i++) { + expect(model.data[i]).toBeCloseTo(modelO.data[i], 4); + } + // the outliers must be rejected on both sides for (let i = 0; i < OUT; i++) { - expect(maskO.data[i]).toBe(0); + expect(mask.data[i]).toBe(0); } }); }); From d9e22d7c9144dcfd605bec1e235729d5e066f9f5 Mon Sep 17 00:00:00 2001 From: Walter Perdan Date: Tue, 7 Jul 2026 13:56:58 +0200 Subject: [PATCH 2/2] style: prettier-format src/jsfeatNext.ts Co-Authored-By: Claude Opus 4.8 --- src/jsfeatNext.ts | 48 +++++++++++++++++++++++------------------------ 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/src/jsfeatNext.ts b/src/jsfeatNext.ts index 6f4dda0..91a02a9 100644 --- a/src/jsfeatNext.ts +++ b/src/jsfeatNext.ts @@ -2186,13 +2186,13 @@ jsfeatNext.math = class math extends jsfeatNext { ? cmp(tb, tc) ? b : cmp(ta, tc) - ? c - : a + ? c + : a : cmp(tc, tb) - ? b - : cmp(ta, tc) - ? a - : c; + ? b + : cmp(ta, tc) + ? a + : c; (a = pivot - d), (b = pivot), (c = pivot + d); (ta = array[a]), (tb = array[b]), (tc = array[c]); @@ -2200,13 +2200,13 @@ jsfeatNext.math = class math extends jsfeatNext { ? cmp(tb, tc) ? b : cmp(ta, tc) - ? c - : a + ? c + : a : cmp(tc, tb) - ? b - : cmp(ta, tc) - ? a - : c; + ? b + : cmp(ta, tc) + ? a + : c; (a = right - (d << 1)), (b = right - d), (c = right); (ta = array[a]), (tb = array[b]), (tc = array[c]); @@ -2214,13 +2214,13 @@ jsfeatNext.math = class math extends jsfeatNext { ? cmp(tb, tc) ? b : cmp(ta, tc) - ? c - : a + ? c + : a : cmp(tc, tb) - ? b - : cmp(ta, tc) - ? a - : c; + ? b + : cmp(ta, tc) + ? a + : c; } (a = left), (b = pivot), (c = right); @@ -2229,13 +2229,13 @@ jsfeatNext.math = class math extends jsfeatNext { ? cmp(tb, tc) ? b : cmp(ta, tc) - ? c - : a + ? c + : a : cmp(tc, tb) - ? b - : cmp(ta, tc) - ? a - : c; + ? b + : cmp(ta, tc) + ? a + : c; if (pivot != left0) { t = array[pivot]; array[pivot] = array[left0];