Skip to content

fix(motion_estimator): add find_homography, the missing refit layer RANSAC/LMEDS never had - #190

Merged
kalwalt merged 4 commits into
devfrom
fix/ransac-find-homography-refit
Sep 5, 2026
Merged

fix(motion_estimator): add find_homography, the missing refit layer RANSAC/LMEDS never had#190
kalwalt merged 4 commits into
devfrom
fix/ransac-find-homography-refit

Conversation

@kalwalt

@kalwalt kalwalt commented Sep 5, 2026

Copy link
Copy Markdown
Member

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 to jsfeat.motion_estimator and OpenCV's RANSACPointSetRegistrator/LMeDSPointSetRegistrator, which don't refit either. The refit lives one layer up, in OpenCV's cv::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):

  1. Runs ransac() or lmeds() to find a robust minimal-sample model + inlier mask.
  2. Refits the model over the full inlier set via one extra kernel.run().
  3. Recomputes the mask against the refit model, so model and mask describe the same transform.
  4. Falls back to the pre-refit model/mask on a degenerate (kernel.run() <= 0) or collapsed (post-refit inliers < model_points) refit.

Generic over MotionKernel, so it works with both homography2d and affine2d kernels without new code.

Fixes found via /code-review (two-axis) before merge

The 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 pass 0 for it — meaning find_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 formula lmeds() itself uses) instead of reusing params.thresh for 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 untouched tests/parity/motion_estimator.test.ts (parity oracle for ransac/lmeds against vendored jsfeat — confirms neither's behavior changed).
  • New 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, optional mask param, insufficient-points failure path.
  • npx tsc --noEmit, prettier --check, check-license-headers.mjs: clean.

Closes #185, #188.

…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.
@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Add post-inlier refit via find_homography

🐞 Bug fix ✨ Enhancement 🧪 Tests 📝 Documentation 🕐 20-40 Minutes

Grey Divider

AI Description

• Adds an opt-in robust estimation layer that refits models using all detected inliers.
• Reclassifies masks after refitting, with safe fallback for degenerate or collapsed results.
• Preserves existing RANSAC/LMEDS parity and adds focused numerical property coverage.
Diagram

graph TD
    A["Point Pairs"] --> B{"Method"}
    B -->|ransac| C["RANSAC Model"] --> E["Inlier Refit"]
    B -->|lmeds| D["LMeDS Model"] --> E
    E --> F{"Refit Valid"}
    F -->|yes| G["Reclassified Mask"]
    F -->|no| H["Original Result"]
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Refit inside RANSAC and LMeDS
  • ➕ Makes every robust-estimation call return an all-inlier fit.
  • ➕ Avoids adding another public entry point.
  • ➖ Breaks behavioral parity with jsfeat and OpenCV’s point-set registrators.
  • ➖ Changes established APIs for callers that depend on minimal-sample results.
  • ➖ Would require modifying and revalidating both estimator implementations.
2. Add nonlinear LM refinement
  • ➕ Could further reduce reprojection error after the linear all-inlier fit.
  • ➕ More closely matches OpenCV’s complete homography refinement pipeline.
  • ➖ Adds substantial numerical complexity and review risk.
  • ➖ Requires convergence, stability, and failure-handling policies beyond this fix.
  • ➖ Expands scope beyond the missing linear refit layer.

Recommendation: Keep the composed find_homography approach. It restores the missing caller-level refit while preserving byte-for-byte estimator parity, centralizes shared behavior for homography and affine kernels, and safely defers nonlinear LM polishing to separate work.

Files changed (2) +402 / -4

Enhancement (1) +133 / -4
motion_estimator.tsAdd robust all-inlier model refitting +133/-4

Add robust all-inlier model refitting

• Introduces find_homography as a composed RANSAC/LMEDS entry point that refits the selected model over all initial inliers and recomputes its mask. It derives an LMeDS-specific robust threshold and falls back to the original model and mask when refitting is degenerate or loses too many inliers. Existing estimator documentation now accurately states that those methods return minimal-sample models without refitting.

src/motion_estimator/motion_estimator.ts

Tests (1) +269 / -0
find_homography.test.tsCover refit accuracy, consistency, and fallback behavior +269/-0

Cover refit accuracy, consistency, and fallback behavior

• Adds deterministic property tests for RANSAC and LMeDS refitting, outlier rejection, model-mask agreement, optional masks, minimal samples, and insufficient input. The tests also verify that zero-threshold LMeDS performs a real refit using its derived robust cutoff.

tests/properties/find_homography.test.ts

@qodo-code-review

qodo-code-review Bot commented Sep 5, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📜 Skill insights (0)

Grey Divider


Remediation recommended

1. Buffer releases lack finally ✗ Dismissed 📘 Rule violation ☼ Reliability
Description
The new method borrows shared-cache buffers and releases them only along normal return paths,
without try/finally protection. If an injected kernel or another operation throws, one or more
buffers remain checked out and can exhaust or degrade the shared pool.
Code

src/motion_estimator/motion_estimator.ts[R463-464]

+        const mask_buff = this.cache.get_buffer(count);
+        const own_mask = new matrix_t(count, 1, JSFEAT_CONSTANTS.U8C1_t, mask_buff.data);
Evidence
Rule 2965982 requires every shared-buffer acquisition to have a matching release on all paths,
especially error paths. The method acquires its first buffer at lines 463-464 and additional buffers
at lines 487-492, while releases occur later without any finally guard around intervening
estimator and kernel calls.

Rule 2965982: Release all shared buffer pool allocations with a matching put_buffer call
src/motion_estimator/motion_estimator.ts[463-474]
src/motion_estimator/motion_estimator.ts[487-524]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Shared-cache buffers borrowed by `find_homography` are not guaranteed to be returned when an operation throws.

## Issue Context
The method invokes caller-supplied kernel operations after borrowing buffers. Restructure buffer ownership with nested or centralized `try`/`finally` blocks so every successfully acquired buffer is returned exactly once on success, early return, and exception paths.

## Fix Focus Areas
- src/motion_estimator/motion_estimator.ts[463-528]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Failure returns stale mask ✓ Resolved 🐞 Bug ≡ Correctness
Description
When the underlying estimator returns false, find_homography copies own_mask even though
several RANSAC/LMEDS failure paths never initialize it. The caller can therefore receive arbitrary
mask values left in the shared cache instead of the underlying estimator's existing failure
behavior.
Code

src/motion_estimator/motion_estimator.ts[472]

+            if (mask) own_mask.copy_to(mask);
Evidence
The new mask wraps reused cache storage without clearing it and is copied on failure. The underlying
estimators return before writing a mask for insufficient input and initial subset-generation
failures, while the cache returns existing nodes without clearing their contents.

src/motion_estimator/motion_estimator.ts[463-474]
src/motion_estimator/motion_estimator.ts[198-198]
src/motion_estimator/motion_estimator.ts[249-255]
src/motion_estimator/motion_estimator.ts[317-317]
src/motion_estimator/motion_estimator.ts[371-379]
src/cache/cache.ts[108-118]
src/matrix_t/matrix_t.ts[117-136]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`find_homography` copies its cache-backed internal mask to the caller when `ransac` or `lmeds` returns `false`. Some estimator failure paths never write that internal mask, so stale pooled bytes become externally visible.

## Issue Context
Both estimators may return before initializing the mask, including when there are too few points or the first valid subset cannot be generated. Preserve the established failure behavior by not copying an unwritten mask, or explicitly initialize and document a deterministic failure mask.

## Fix Focus Areas
- src/motion_estimator/motion_estimator.ts[463-474]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. math singleton instantiated ✗ Dismissed 📘 Rule violation ≡ Correctness
Description
The new refit path constructs the stateless math algorithm with new math() instead of invoking
the provided singleton namespace. This violates the required singleton usage pattern and creates an
unnecessary algorithm instance per refit.
Code

src/motion_estimator/motion_estimator.ts[507]

+                    const median = new math().median(err, 0, count - 1);
Evidence
Compliance rule 2965977 prohibits constructing stateless algorithm modules and requires singleton
namespace calls. The added line directly constructs math solely to invoke median.

Rule 2965977: Do not instantiate algorithm modules; use provided singleton namespace functions
src/motion_estimator/motion_estimator.ts[507-507]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The new LMEDS refit path instantiates the stateless `math` algorithm with `new math()` instead of using its provided singleton.

## Issue Context
Algorithm modules such as `math` must be accessed through the singleton namespace rather than constructed at call sites. Replace the new construction while preserving the median calculation.

## Fix Focus Areas
- src/motion_estimator/motion_estimator.ts[507-507]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Context sources
✅ Compliance rules (platform): 27 rules
Review mode: ⚖️ Balanced: This adds a new generic robust-estimation/refit path with method-specific thresholds, masking, fallback behavior, buffer management, and public API semantics, creating genuine correctness risk but not enough independent complexity to require redundant extended review.

Grey Divider

Tip of the day
💡 Did you know, you can keep summaries lean with Finding overflow, which tucks the rest behind 'View more'

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment thread src/motion_estimator/motion_estimator.ts
Comment thread src/motion_estimator/motion_estimator.ts
Comment thread src/motion_estimator/motion_estimator.ts Outdated
…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

codecov Bot commented Sep 5, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@kalwalt kalwalt self-assigned this Sep 5, 2026
@kalwalt kalwalt added enhancement New feature or request code design labels Sep 5, 2026
@kalwalt kalwalt added this to the 1.0.0 milestone Sep 5, 2026
…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.
@kalwalt
kalwalt merged commit 5a9e774 into dev Sep 5, 2026
5 checks passed
kalwalt added a commit that referenced this pull request Sep 5, 2026
…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

code design enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant