fix(motion_estimator): add find_homography, the missing refit layer RANSAC/LMEDS never had - #190
Conversation
…ANSAC/LMEDS never had ransac()/lmeds() are byte-for-byte faithful to jsfeat and OpenCV's RANSACPointSetRegistrator/LMeDSPointSetRegistrator: neither ever refits the winning minimal-sample model over its full inlier set, even though their own doc comments claimed one did. That refit lives one layer up, in OpenCV's findHomography, and jsfeat never ported that layer. find_homography() adds it as a composed, opt-in entry point: run ransac/lmeds, refit the model over the winning hypothesis's inliers via one extra kernel.run(), and recompute the mask against the refit model so model and mask describe the same transform. Falls back to the pre-refit model on a degenerate or collapsed refit. ransac()/lmeds() are untouched, so the existing parity suite against vendored jsfeat keeps passing unmodified. Closes #185, #188.
…in find_homography /code-review's Spec axis caught that the post-refit mask reclassification always reused params.thresh, but lmeds's own doc comment documents that field as ignored and callers correctly pass 0 for it (see the parity test's lmeds calls). With thresh=0, find_inliers' squared-error cutoff is 0, so no point can pass, the refit's inlier count collapses below model_points, and find_homography silently falls back to the pre-refit model — for the "lmeds" method the refit never actually engaged. Rederive lmeds's own robust threshold (same formula lmeds() itself uses, from the refit model's error median) instead of reusing params.thresh when method is "lmeds". Strengthened the lmeds test to assert a refit actually happened (model differs from the raw minimal-sample fit, error decreases) rather than only checking the fallback doesn't crash.
PR Summary by QodoAdd post-inlier refit via find_homography
AI Description
Diagram
High-Level Assessment
Files changed (2)
|
Code Review by Qodo
1.
|
…mask on failure; fix flaky lmeds test Qodo's review of PR #190 caught a real bug: find_homography copied its internal own_mask into the caller's mask matrix even when the underlying ransac()/lmeds() call returned false. own_mask is backed by a shared-cache buffer, which cache.get_buffer never zeroes on borrow — several ransac/lmeds failure paths (too few points, no valid subset found) return before writing to it at all, so a failure could hand the caller arbitrary leftover bytes from a previous, unrelated borrower. Fixed by leaving the caller's mask untouched on failure, matching ransac()/lmeds()'s own existing behavior. Added a regression test that seeds the mask with a sentinel value ransac/ lmeds never write and asserts it survives a failed call unchanged. Also fixes the test that made CI fail: the "lmeds actually refits" test compared minimal-sample vs. refit model coefficients directly on noise-free data, where any 4 points already recover the ground truth almost exactly, so the two models coincide up to float rounding that differs across platforms/ toolchains (it passed locally, failed in CI). Replaced with noisy, seeded-RNG correspondences and a ground-truth reprojection-error comparison averaged over 40 trials, which is what the test actually needed to show. Qodo also flagged `new math()` at the refit's threshold-derivation call site as a singleton-usage violation and the method's buffer releases as lacking try/finally. Neither is addressed here: `new math()`/`new matmath()`/ `new linalg()` for internal scratch use is the codebase's own established convention (motion_model.ts, imgproc.ts, linalg.ts, and this same file's pre-existing lmeds() already do it), and no get_buffer/put_buffer call site anywhere in this file uses try/finally today, including in ransac()/lmeds() themselves -- adding it only to the new method would be inconsistent with the rest of the file rather than a fix.
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
…refit-fallback branches Codecov flagged patch coverage below threshold; tracing lcov branch data to motion_estimator.ts confirmed the two uncovered branches were both inside find_homography's own new code: the kernel.run()<=0 degenerate-refit fallback and the numinliers<model_points collapsed-refit fallback. Neither is reachable through real ransac()/lmeds() data with homography2d: its degeneracy check is a point-spread test, and any inlier superset containing an already-valid 4-point minimal sample keeps non-zero spread too, so the real kernel can't be coaxed into failing only on the larger refit call. Covered both via dependency injection instead -- a fake kernel built on Object.create(homography2d) that only overrides run() for the >model_points call find_homography makes for the refit, exercising each fallback directly and asserting the pre-refit model/mask survive unchanged. Also seeded Math.random (matching tests/parity/motion_estimator.test.ts's own convention) in the two existing outlier tests that don't yet always succeed by construction: an unseeded run occasionally hit ransac()/lmeds() failing to land a clean subset within max_iters by bad luck (issue #189 -- the RNG isn't injectable yet), which showed up as an intermittent local `npx vitest run --coverage` failure unrelated to any of this PR's actual logic.
…mask on failure; fix flaky lmeds test Qodo's review of PR #190 caught a real bug: find_homography copied its internal own_mask into the caller's mask matrix even when the underlying ransac()/lmeds() call returned false. own_mask is backed by a shared-cache buffer, which cache.get_buffer never zeroes on borrow — several ransac/lmeds failure paths (too few points, no valid subset found) return before writing to it at all, so a failure could hand the caller arbitrary leftover bytes from a previous, unrelated borrower. Fixed by leaving the caller's mask untouched on failure, matching ransac()/lmeds()'s own existing behavior. Added a regression test that seeds the mask with a sentinel value ransac/ lmeds never write and asserts it survives a failed call unchanged. Also fixes the test that made CI fail: the "lmeds actually refits" test compared minimal-sample vs. refit model coefficients directly on noise-free data, where any 4 points already recover the ground truth almost exactly, so the two models coincide up to float rounding that differs across platforms/ toolchains (it passed locally, failed in CI). Replaced with noisy, seeded-RNG correspondences and a ground-truth reprojection-error comparison averaged over 40 trials, which is what the test actually needed to show. Qodo also flagged `new math()` at the refit's threshold-derivation call site as a singleton-usage violation and the method's buffer releases as lacking try/finally. Neither is addressed here: `new math()`/`new matmath()`/ `new linalg()` for internal scratch use is the codebase's own established convention (motion_model.ts, imgproc.ts, linalg.ts, and this same file's pre-existing lmeds() already do it), and no get_buffer/put_buffer call site anywhere in this file uses try/finally today, including in ransac()/lmeds() themselves -- adding it only to the new method would be inconsistent with the rest of the file rather than a fix.
Summary
ransac()/lmeds()'s own doc comments claimed a post-convergence refit over all inliers that neither ever performed — both are, and remain, byte-for-byte faithful tojsfeat.motion_estimatorand OpenCV'sRANSACPointSetRegistrator/LMeDSPointSetRegistrator, which don't refit either. The refit lives one layer up, in OpenCV'scv::findHomography, and jsfeat never ported that layer.This adds it as Option B (decided on #185): a new, composed
find_homography()entry point —ransac()/lmeds()are untouched.find_homography(params, kernel, from, to, count, model, mask?, method = "ransac", max_iters = 1000):ransac()orlmeds()to find a robust minimal-sample model + inlier mask.kernel.run().modelandmaskdescribe the same transform.kernel.run() <= 0) or collapsed (post-refit inliers <model_points) refit.Generic over
MotionKernel, so it works with bothhomography2dandaffine2dkernels without new code.Fixes found via
/code-review(two-axis) before mergeThe Spec-axis sub-agent caught that step 3's reclassification always used
params.thresh, but that field is documented as "ignored by lmeds" and callers correctly pass0for it — meaningfind_inliers' cutoff collapsed to zero for the"lmeds"method and the refit silently no-op'd. Fixed by deriving lmeds's own robust threshold (same formulalmeds()itself uses) instead of reusingparams.threshfor that branch, and strengthened the test to assert a refit actually occurred rather than only that the fallback doesn't crash.Testing
npm test: 317/317 passing, including the untouchedtests/parity/motion_estimator.test.ts(parity oracle forransac/lmedsagainst vendored jsfeat — confirms neither's behavior changed).tests/properties/find_homography.test.ts(10 tests): noise-free refit convergence (measured, not qualitative — see inline comment), outlier rejection + mask/model agreement, degenerate-input fallback, both"ransac"and"lmeds"methods, optionalmaskparam, insufficient-points failure path.npx tsc --noEmit,prettier --check,check-license-headers.mjs: clean.Closes #185, #188.