Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,54 @@

## 0.16.0 - 2026-09-06

### 🐛 Bug Fixes

- Add find_homography, the missing refit layer RANSAC/LMEDS never had (eb1fd65)

- Derive lmeds's own reclassification threshold in find_homography (c52b05c)

- Don't leak stale pool bytes into the caller's mask on failure; fix flaky lmeds test (0c97300)

- Get_subset must not hang forever on a degenerate injected RandomFn (db1e134)

- Run homography2d's DLT solve in F64 instead of F32 (81689eb)

- Report a near-zero h33 as degenerate instead of fabricating a corrupted model (23e1ba2)

- Fix flaky test and recompute the mask after LM refine (90b0da8)

- Reject S64_t instead of silently returning an F64_t view (0965875)

- Correct outline sizing and level-scale coordinate un-scaling in ORB training (5873899)

- Validate S64_t before discarding allocate()'s existing storage (66c8059)


### 👷 CI

- Switch npm publish to Trusted Publishers (OIDC), drop NPM_TOKEN (ac8006e)


### 📚 Documentation

- Note that Trusted Publisher setup must explicitly allow npm publish (3a2d9c7)


### 🚀 Features

- Injectable RNG for ransac/lmeds minimal-sample draws (ca82e30)

- Levenberg-Marquardt refinement for homography/affine (issue #187) (7186ff6)


### 🧪 Testing

- Seed RNG to deflake outlier tests, cover both refit-fallback branches (5a9e774)

- Cover the two remaining branches Codecov flagged in find_homography's refine block (d98b1c2)



## 0.15.0 - 2026-09-03

### 🐛 Bug Fixes
Expand Down
4 changes: 2 additions & 2 deletions dist/jsfeatNext.js

Large diffs are not rendered by default.

190 changes: 165 additions & 25 deletions dist/jsfeatNext.mjs

Large diffs are not rendered by default.

12 changes: 6 additions & 6 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@webarkit/jsfeat-next",
"version": "0.15.0",
"version": "0.16.0",
"description": "Typescript version of jsfeat for WebARKit",
"main": "dist/jsfeatNext.js",
"module": "dist/jsfeatNext.mjs",
Expand Down
2 changes: 1 addition & 1 deletion types/src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,4 @@ export type { ransac_params_t } from './motion_estimator/ransac_params_t';
export type { match_t, IMatch_T } from './bfmatcher/match_t';
export type { pose_t, IPose_T } from './pose_estimator/pose_estimator';
export type { ICache } from './cache/cache';
export type { TypedArray, NumericArray, MotionKernel } from './types';
export type { TypedArray, NumericArray, MotionKernel, RandomFn } from './types';
45 changes: 45 additions & 0 deletions types/src/linalg/linalg.d.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,20 @@
import { default as jsfeatNext } from '../core/core';
import { matrix_t } from '../matrix_t/matrix_t';
import { default as matmath } from '../matmath/matmath';
/**
* The residual/Jacobian contract {@link linalg.lm_solve} refines against.
* Original to jsfeatNext (issue #187) — jsfeat has no non-linear solver.
*/
export interface LMCallback {
/**
* Fills `err` (length `m`, the solver's `num_residuals`) with the
* residuals at `params` (length `n`). When `J` is not `null`, also fills
* it — row-major, `m`×`n` — with the Jacobian at `params`.
*
* @returns `false` to report the parameters as degenerate and abort the solve.
*/
compute(params: Float64Array, err: Float64Array, J: Float64Array | null): boolean;
}
/**
* Dense linear-algebra solvers built on Jacobi rotations: LU and Cholesky
* linear-system solvers, singular value decomposition (and SVD-based solve /
Expand Down Expand Up @@ -63,6 +77,37 @@ export declare class linalg extends jsfeatNext {
* @returns 1 (the decomposition does not detect failure).
*/
cholesky_solve(A: matrix_t, B: matrix_t): number;
/**
* Levenberg-Marquardt: refines `params` to minimize `Σ err(params)²` by
* repeatedly solving the damped normal system `(JᵀJ + λI)·Δ = Jᵀ·err`
* for a step `Δ`, accepting it (and shrinking `λ`) when it reduces the
* cost, or rejecting it (and growing `λ`) when it doesn't — the standard
* trust-region compromise between Gauss-Newton (fast near the optimum)
* and gradient descent (robust far from it).
*
* Original to jsfeatNext (issue #187) — jsfeat has no non-linear solver.
* Uses {@link cholesky_solve} on the damped normal system rather than an
* SVD-based solve: with `λ > 0` the damped `JᵀJ + λI` is always SPD, so
* Cholesky suffices and the solver has no dependency on SVD (relevant to
* a future `no_std`/WASM port, where SVD is a materially bigger ask than
* Cholesky).
*
* @param params `n`×1 F64 matrix, the parameter vector — mutated
* in place to the refined result (or left as the
* best point found, on a degenerate step).
* @param num_residuals `m`, the number of residuals `callback` fills.
* @param callback Computes residuals (and, when asked, the
* Jacobian) at a given parameter vector.
* @param max_iters Iteration cap. Default 10 (matches OpenCV's
* `LMSolver`/`refineIters` default).
* @param eps Stops early once the relative cost improvement
* between iterations drops below this. Default 1e-10.
* @returns `false` if `callback` ever reports degeneracy; `true` otherwise
* (matching `LMSolver`, this does not mean "converged" — only
* that `max_iters` were run, or `eps` was reached, without
* numerical failure).
*/
lm_solve(params: matrix_t, num_residuals: number, callback: LMCallback, max_iters?: number, eps?: number): boolean;
/**
* Singular value decomposition `A = U · diag(W) · Vᵀ` via one-sided
* Jacobi rotations. Singular values arrive in descending order.
Expand Down
15 changes: 15 additions & 0 deletions types/src/math/math.d.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { default as jsfeatNext } from '../core/core';
import { matrix_t } from '../matrix_t/matrix_t';
import { RandomFn } from '../types';
/**
* General math utilities: Gaussian-kernel generation, an in-place quicksort
* and a selection-based median. Mirrors `jsfeat.math` from the original
Expand Down Expand Up @@ -60,4 +61,18 @@ export declare class math extends jsfeatNext {
* @returns The median value of the range.
*/
median(array: number[] | Int32Array | Float32Array, low: number, high: number): number;
/**
* Seedable pseudo-random generator (mulberry32), returning a {@link RandomFn}
* matching `Math.random`'s `[0, 1)` contract. Deterministic for a given
* seed and fast (one 32-bit multiply-heavy mix per call).
*
* Original to jsfeatNext (not ported from jsfeat), added for issue #189:
* `ransac_params_t`'s `rng` field and `motion_estimator.get_subset` accept
* `Math.random` by default, so this is what a caller reaches for to make
* `ransac()`/`lmeds()` reproducible across runs instead.
*
* @param seed 32-bit integer seed.
* @returns A `RandomFn` producing the same sequence for the same seed.
*/
mulberry32(seed: number): RandomFn;
}
12 changes: 12 additions & 0 deletions types/src/matrix_t/matrix_t.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,18 @@ export declare class matrix_t implements IMatrix_T {
* allocating a new one (used with cache-pool buffers).
*/
constructor(c: number, r: number, _data_type: number, _data_buffer?: data_t);
/**
* `S64_t` is a declared, publicly exported data type (inherited from
* jsfeat) that no view-selection code path actually supports: without
* this check, requesting it silently falls through to an F64_t view —
* right byte count, wrong interpretation, no error (issue #139).
* Rejecting it loudly is a deliberate divergence from jsfeat, which
* returns the wrong view silently; see `tests/divergences.test.ts`.
*
* @param type The data-type component of a packed type signature.
* @throws If `type` includes `S64_t`.
*/
private static _reject_S64_t;
/**
* Allocates a fresh backing buffer sized from the current
* `cols * rows * channel * sizeof(type)` and points {@link data} at the
Expand Down
69 changes: 61 additions & 8 deletions types/src/motion_estimator/motion_estimator.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { default as jsfeatNext } from '../core/core';
import { matrix_t } from '../matrix_t/matrix_t';
import { point_t } from '../point_t/point_t';
import { ransac_params_t } from './ransac_params_t';
import { MotionKernel, TypedArray } from '../types';
import { MotionKernel, RandomFn, TypedArray } from '../types';
/**
* Robust motion-model estimation from noisy point correspondences via
* RANSAC or LMEDS, parameterized by a kernel implementing
Expand All @@ -16,18 +16,21 @@ export declare class motion_estimator extends jsfeatNext {
constructor();
/**
* Draws a random minimal sample of `need_cnt` distinct correspondences
* (via `Math.random`) and validates it with `kernel.check_subset`.
* Retries up to 1000 times before giving up.
* and validates it with `kernel.check_subset`. Retries up to 1000 times
* before giving up.
*
* @param kernel The motion-model kernel (validates the sample).
* @param from Source points. @param to Destination points.
* @param need_cnt Sample size to draw.
* @param max_cnt Total number of correspondences to draw from.
* @param from_sub Output array receiving the sampled source points.
* @param to_sub Output array receiving the sampled destination points.
* @param rng Source of `[0, 1)` randomness. Default `Math.random`
* (issue #189 — pass a seeded {@link RandomFn}, e.g.
* `math.mulberry32`, for reproducible draws).
* @returns `true` when a valid subset was found.
*/
get_subset(kernel: MotionKernel, from: point_t[], to: point_t[], need_cnt: number, max_cnt: number, from_sub: point_t[], to_sub: point_t[]): boolean;
get_subset(kernel: MotionKernel, from: point_t[], to: point_t[], need_cnt: number, max_cnt: number, from_sub: point_t[], to_sub: point_t[], rng?: RandomFn): boolean;
/**
* Classifies every correspondence as inlier/outlier by thresholding the
* kernel's squared reprojection error of `model`.
Expand All @@ -44,9 +47,14 @@ export declare class motion_estimator extends jsfeatNext {
find_inliers(kernel: MotionKernel, model: matrix_t, from: point_t[], to: point_t[], count: number, thresh: number, err: Int32Array | Float32Array, mask: TypedArray | number[]): number;
/**
* RANSAC estimation: repeatedly fits the kernel's model to random
* minimal samples, keeps the hypothesis with the most inliers (adapting
* the iteration count from the observed inlier ratio), and finally
* refits the model on all inliers of the best hypothesis.
* minimal samples, keeping the hypothesis with the most inliers
* (adapting the iteration count from the observed inlier ratio).
*
* Returns the winning **minimal-sample** model as-is — it does not refit
* over the full inlier set, matching both `jsfeat.motion_estimator.ransac`
* and `cv::RANSACPointSetRegistrator::run`. The refit OpenCV performs
* afterwards, in `cv::findHomography`, is a separate caller-level layer;
* see {@link find_homography} (issue #185).
*
* @param params Estimation parameters ({@link ransac_params_t}).
* @param kernel Motion-model kernel (`homography2d` / `affine2d`).
Expand All @@ -62,7 +70,13 @@ export declare class motion_estimator extends jsfeatNext {
* Least-median-of-squares estimation: like {@link ransac} but scores each
* hypothesis by the MEDIAN squared error (no inlier threshold needed —
* robust up to 50% outliers), then derives an inlier threshold from the
* winning median's robust standard deviation and refits on the inliers.
* winning median's robust standard deviation and classifies inliers
* against it.
*
* Like {@link ransac}, the returned model is the winning **minimal-sample**
* fit — it does not refit over the classified inliers, matching
* `jsfeat.motion_estimator.lmeds` and `cv::LMeDSPointSetRegistrator::run`.
* See {@link find_homography} (issues #185, #188).
*
* @param params Estimation parameters (`thresh` is ignored).
* @param kernel Motion-model kernel (`homography2d` / `affine2d`).
Expand All @@ -74,4 +88,43 @@ export declare class motion_estimator extends jsfeatNext {
* @returns `true` when a model was found.
*/
lmeds(params: ransac_params_t, kernel: MotionKernel, from: point_t[], to: point_t[], count: number, model: matrix_t, mask: matrix_t, max_iters: number): boolean;
/**
* The caller-level layer OpenCV has (`cv::findHomography` /
* `cv::estimateAffine2D`) and jsfeat never ported: runs {@link ransac} or
* {@link lmeds} to find a robust minimal-sample model, then refits the
* model over the full inlier set of the winning hypothesis via a single
* extra `kernel.run()`, and recomputes the inlier mask against the refit
* model so `model` and `mask` describe the same transform (mirroring
* OpenCV's `runKernel` + `LMSolver`-less refit + `computeError` steps in
* `fundam.cpp`, minus the Levenberg-Marquardt polish tracked in #187).
*
* `ransac()`/`lmeds()` themselves are untouched by this and stay at
* jsfeat/OpenCV parity — see their doc comments and issues #185/#188.
*
* If the refit is degenerate (`kernel.run()` on the inlier set returns
* `<= 0`) or collapses the inlier count below `params.size`, the
* pre-refit minimal-sample model and mask are kept rather than returning
* garbage.
*
* @param params Estimation parameters ({@link ransac_params_t});
* `params.size` is the kernel's minimal sample size
* (4 for `homography2d`, 3 for `affine2d`).
* @param kernel Motion-model kernel (`homography2d` / `affine2d`).
* @param from Source points. @param to Destination points.
* @param count Number of correspondences.
* @param model Output model matrix; refit over all inliers on success.
* @param mask Output 0/1 inlier mask (`count`×1 matrix), recomputed
* against the refit model. Optional.
* @param method `"ransac"` (default) or `"lmeds"`.
* @param max_iters Iteration cap forwarded to the underlying estimator. Default 1000.
* @param refine_iters Non-linear (Levenberg-Marquardt) polish over the
* final inlier set after the linear refit, via
* `kernel.refine()` (issue #187) — minimizes actual
* reprojection error rather than the linear refit's
* algebraic residual. Default 0 (skipped, matching
* this method's pre-#187 behavior); has no effect
* when `kernel` doesn't implement `refine()`.
* @returns `true` when the underlying estimator (`ransac`/`lmeds`) found a model.
*/
find_homography(params: ransac_params_t, kernel: MotionKernel, from: point_t[], to: point_t[], count: number, model: matrix_t, mask?: matrix_t, method?: "ransac" | "lmeds", max_iters?: number, refine_iters?: number): boolean;
}
12 changes: 10 additions & 2 deletions types/src/motion_estimator/ransac_params_t.d.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { RandomFn } from '../types';
/**
* Parameter block for `motion_estimator.ransac` / `motion_estimator.lmeds`.
*
* Mirrors `jsfeat.ransac_params_t` from the original library.
* Mirrors `jsfeat.ransac_params_t` from the original library, plus an
* injectable `rng` (issue #189): `get_subset`'s minimal-sample draws use
* `Math.random` by default, matching jsfeat and existing callers exactly, but
* a caller can pass a seeded {@link RandomFn} (e.g. `math.mulberry32`) here
* for reproducible RANSAC/LMEDS runs instead.
*/
export declare class ransac_params_t {
/** Minimal sample size per model hypothesis (e.g. 4 for homography2d, 3 for affine2d). */
Expand All @@ -12,13 +17,16 @@ export declare class ransac_params_t {
eps: number;
/** Desired probability (0–1) of finding an outlier-free sample. */
prob: number;
/** Source of `[0, 1)` randomness for `get_subset`'s minimal-sample draws. Default `Math.random`. */
rng: RandomFn;
/**
* @param size Minimal sample size per hypothesis. Default 0.
* @param thresh Inlier error threshold in pixels. Default 0.5.
* @param eps Assumed outlier ratio. Default 0.5.
* @param prob Desired success probability. Default 0.99.
* @param rng Source of `[0, 1)` randomness for minimal-sample draws. Default `Math.random`.
*/
constructor(size?: number, thresh?: number, eps?: number, prob?: number);
constructor(size?: number, thresh?: number, eps?: number, prob?: number, rng?: RandomFn);
/**
* Recomputes the RANSAC iteration count from the standard formula
* `log(1 - prob) / log(1 - (1 - eps)^size)`, capped at `max_iters`.
Expand Down
Loading