Conversation
Closed-form regression test on a linear ramp: currently fails because build_optical_flow_pyramid still uses Sobel. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
OpenCV's buildOpticalFlowPyramid computes derivatives unconditionally with Scharr (calcScharrDeriv, lkpyramid.cpp:748) — there is no Sobel path. purecv used sobel(..., 3, ...), an undocumented divergence. scharr() already existed as sobel(ksize=-1) sharing the same fast_deriv_3x3 path, so this is a drop-in swap under both the parallel and simd features. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
…130) Reference computed independently via scharr() against the documented H-matrix formula; currently fails because calc_optical_flow_pyramid_lk still uses Sobel internally. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
) OpenCV's calcOpticalFlowPyrLK computes derivatives unconditionally with Scharr (calcScharrDeriv, lkpyramid.cpp:1332) — no Sobel path exists. purecv used sobel(..., 3, ...) for the previous-frame derivatives, an undocumented divergence from the module's own 'Mirrors cv::calcOpticalFlowPyrLK' contract. Scharr's unit-step response is ~4x Sobel's, so the spatial-gradient matrix H (proportional to the derivative squared) was ~16x smaller than OpenCV's on the same input. Any min_eigen_threshold tuned against OpenCV (e.g. the commonly used 1e-4) was therefore ~16x looser on purecv's old Sobel-scale H than intended, admitting weak-texture tracks OpenCV would reject. BREAKING BEHAVIOR: min_eigen_threshold values tuned against previous purecv releases must be retuned against the new Scharr scale — see the updated rustdoc. Closes #130. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
test_calc_optical_flow_pyramid_lk_uses_scharr_derivatives compared err[0] (f32) against an f64 reference with an absolute 1e-3 tolerance. At this test's magnitude (~2.5M), f32's precision has an inherent rounding step of ~0.125-0.25, exceeding that tolerance even with correct Scharr derivatives. Switch to a 1e-5 relative tolerance, which comfortably covers f32 rounding while remaining far tighter than the ~1530% relative Sobel-vs-Scharr divergence this test is designed to catch. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The Sobel-to-Scharr swap in build_optical_flow_pyramid and calc_optical_flow_pyramid_lk left several doc comments unchanged, found in final review: the OpticalFlowPyramid field docs, the wasm bindings' doc comments, lk_single_level's param docs, and a bench comment all still said Sobel. Also clarifies that min_eigen_threshold is still not directly transferable from OpenCV even after this fix, since OpenCV additionally applies a FLT_SCALE=2^-20 normalization that purecv does not (a separate, pre-existing gap, out of scope here). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The count was already stale before this branch (dev tip has 346 unit tests, not 342) and drifted further with the two regression tests added here for #130. Bump to the current count. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Remove the speculative #[cfg_attr(miri, ignore)] from both new regression tests: measured under Miri (std,simd) at ~0.8s and ~18s respectively, well under the project's >30s exclusion threshold (.agents/MIRI_PLAN.md §4), so per the plan's own "measured evidence only, never speculatively" policy they should not be excluded. - Fix test_build_pyramid_with_derivatives's Miri coverage comment (in both src/video/tests.rs and MIRI_PLAN.md §4/§12): it now exercises the Scharr unsafe fast path, not Sobel, and the mitigating coverage is imgproc::tests::test_scharr, not test_sobel (verified: also fast under Miri, ~0.75s). - Revert MIRI_PLAN.md's acceptance-criteria exclusion count back to 9 (unaffected, since the two new tests are no longer excluded). - benches/benchmark_results.md: the build_optical_flow_pyramid derivatives section described a Sobel pass; note the switch to Scharr and that performance is unchanged (same fast_deriv_3x3 path). - crates/wasm/src/lib.rs: add the same FLT_SCALE=2^-20 caveat to the wasm calcOpticalFlowPyrLK min_eigen_threshold doc that already exists on the Rust side, so JS callers aren't misled either. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The previous commit applied OpenCV's FLT_SCALE = 2^-20 but also changed the divisor to `2 * win_area`, copying `lkpyramid.cpp`'s `2*winSize.width*winSize.height` literally. That denominator pairs with OpenCV's numerator, which is deliberately `2*lambda_min` -- it omits the `/2` of the eigenvalue formula. purecv computes a true `lambda_min` (the `* 0.5` in `min_eigen`), so dividing by `2 * win_area` applied the halving twice and reported half of OpenCV's `minEig`. Use `win_area` as the divisor, hoist FLT_SCALE to a documented module-level constant, and record why the `2 *` must not be copied. Nothing pinned the scale before: `test_lk_min_eigenvals_flag` asserts only `is_finite()` and `>= 0.0`, so it passes with any factor. Add `test_lk_min_eigen_matches_opencv_scale`, which uses a separable image (for which the Scharr response is exact and one-dimensional) to derive a closed-form `51200 * 2^-20 / 9`; the doubled divisor and the pre-fix unscaled value both fail it. Also update the reference block in the #130 Scharr test, which hardcoded the window-area-only normalisation, and the `min_eigen_threshold` docs in both `optical_flow.rs` and the wasm wrapper, which stated that thresholds are not transferable to OpenCV -- with #130 and this change they are. Closes #138 BREAKING CHANGE: `min_eigen_threshold` and the `err` values reported via OPTFLOW_LK_GET_MIN_EIGENVALS are now on OpenCV's scale. OpenCV's `1e-4` default transfers directly; thresholds tuned against earlier purecv builds read about 2^20 times larger and must be retuned. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Address Copilot review findings on PR #139: - The migration factor was stated as `2^20`, ignoring that Scharr also multiplies the gradient matrix by 16 relative to the Sobel derivatives used before #130. Against a build predating both fixes the factor is `2^20 / 16 = 65536`. Corrected in `optical_flow.rs` and the wasm wrapper. - `FLT_SCALE` is `pub(crate)`, so the intra-doc link to it from public API docs could not resolve; use plain code text instead. - Derive the pinning test's expected value from a literal rather than from `FLT_SCALE`, so the test fails if that constant is ever changed instead of silently following it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulls the Newton-Raphson refinement loop out of lk_single_level into a standalone pub(crate) function generic over a compute_mismatch closure. Pure extraction, no behavior change (same test pass counts before and after) -- done so the loop itself is directly unit-testable without real image data, needed for #131's oscillation half-step fix. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Hand-crafted mismatch closure that cancels exactly on iterations 0 and 1 (no real image data needed). Currently fails because lk_iterate has no oscillation handling yet. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
lk_iterate only broke on eta_u^2+eta_v^2 < eps, unlike OpenCV (lkpyramid.cpp:620-626), which also detects when two consecutive Newton steps nearly cancel and, in that case, undoes half of the just-applied step instead of risking another full step that overshoots again. An oscillating solve was therefore taking one extra full step versus OpenCV, leaving a residual on the order of ~0.01-0.1px on high-contrast/repetitive texture. Closes #131. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…131) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The oscillation-half-step test's doc comment pointed at 'this function's own comment for the full trace', which didn't exist anywhere -- found in final review. Replace it with the actual iteration trace and the rationale for why this test uses a hand-crafted closure instead of real image data (a systematic search found no real-image scenario in this codebase that triggers the branch, and OpenCV's own test suite has none either). Also restores one-comment-per-argument on the lk_iterate(...) call, which cargo fmt's reformat (c9f470c) had collapsed to trailing only the last value of each 2-3-value group. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
) Pre-merge review of fix/lk-optical-flow into dev surfaced three cross-cutting numbers that each individual fix's review couldn't have caught, since each was locally correct at the time it landed: - README.md: unit test count was 348 (post-#130), actual current count is 350 after #138 and #131 each added one more test. - examples/optical_flow.rs: the save_flow_csv doc example's sample min_eigen value (88873.76) was on the pre-#138 scale; regenerated a real row from a fresh run (1.470835, on OpenCV's FLT_SCALE scale). - .agents/MIRI_PLAN.md: #138 added a third test reaching the unsafe Scharr fast path via calc_optical_flow_pyramid_lk, measured at ~11.9s under Miri (std,simd) -- under the plan's own >30s exclusion threshold, so not ignored, matching the two #130 tests. Updated the "two more tests" acceptance-criteria line to three, and corrected a stale "293 tests" remaining-runtime count (dated from before #130/#131/#138 added tests) to the current 341. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
#142) Closed-form-verified regression test on a 3x3 near-degenerate window (det=114620): currently fails because the determinant guard is still on purecv's raw scale, not OpenCV's FLT_SCALE-scaled one. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
lk_single_level's degeneracy guard compared purecv's raw (unscaled) det against f64::EPSILON, while OpenCV compares its FLT_SCALE-scaled D against FLT_EPSILON -- roughly 10^21 looser. A rank-deficient window (e.g. a clean 1-D edge with only a hairline of off-axis texture) could pass purecv's guard and yield a near-singular inv_det, a garbage flow estimate, and status = 1, where OpenCV would mark the point lost. det itself stays unscaled for the Newton solve a few lines below, where FLT_SCALE cancels out algebraically -- only this one comparison needed the factor applied. Closes #142. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
) test_lk_rejects_near_degenerate_window's doc comment said the effective raw determinant threshold was ~130795 -- a rounding approximation. The exact value is f32::EPSILON / FLT_SCALE^2 = 2^-23 / 2^-40 = 2^17 = 131072, confirmed during task review. Doc-only; det=114620 is comfortably below either number, so this doesn't change the test's behavior. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- README.md: unit test count was stale (350), actual current count is 351 -- this branch added one test. - tests.rs: cleaned up awkward wrapping in the near-degenerate-window test's doc comment (introduced by the #142 threshold-value correction), and noted that det=114620 is specific to the Scharr kernel purecv uses (#130), making the comment self-verifying against a future derivative-operator change. - optical_flow.rs: calc_optical_flow_pyramid_lk's docs now mention that a point can be marked lost via the determinant guard (matching OpenCV's D < FLT_EPSILON), not only via min_eigen_threshold. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
OpenCV rejects a window when `D < FLT_EPSILON` on the signed, FLT_SCALE-scaled determinant (lkpyramid.cpp:426). purecv compared `det.abs()`, so a singular H whose determinant rounds to a large negative value would pass the guard and be inverted. H is positive semi-definite, so a negative det is always rounding noise. Also names the effective raw threshold as `LK_DET_EPSILON` (FLT_EPSILON / FLT_SCALE^2 = 2^17) instead of rescaling det inline on every call, and makes the "tracking lost" debug log say which guard fired and print the det threshold alongside the det value. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
test_lk_rejects_near_degenerate_window only covers det below the threshold. Add a companion 3x3 window with det = 139552, about 6.5% above LK_DET_EPSILON = 2^17, that must still be tracked, so a future over-rejecting guard (wrong FLT_SCALE power, flipped comparison) is caught. Also asserts LK_DET_EPSILON == 131072 exactly. README unit test count: 351 -> 352. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two regression tests on a period-3 texture that only exists at full resolution: pyr_down's [1,4,6,4,1]/16 kernel has gain 1/16 at 2*pi/3, so level 1's minimum eigenvalue is ~256x smaller than level 0's (~0.005 vs 1.318 for a 9x9 window), and a threshold of 0.05 rejects level 1 only. - identical frames: the point must still be tracked (status = 1) - 1-px shift with the true shift as initial flow: the guess must carry through the skipped level unchanged (x = 33.0); restarting level 0 from zero only reaches ~32.61 on this texture Both currently fail: calc_optical_flow_pyramid_lk marks the point lost as soon as any level's window is degenerate. README unit test count: 352 -> 354. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
calc_optical_flow_pyramid_lk broke out of the pyramid loop and marked the point lost whenever lk_single_level rejected any level's window. OpenCV only clears status when the failing level is 0; at a coarser level it skips refinement there and carries the propagated flow estimate on to the finer levels. lk_single_level already returns the propagated (u, v) unchanged on rejection, so the fix is to stop breaking out and only clear `tracked` at level 0. Documents the level-0-only behaviour on min_eigen_threshold. Closes #145. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The previous version shifted the next frame by 1 px and relied on level 0 only reaching u ~ 0.61 from a zero start. That figure is not a property of the texture: it is 1 - (31/32)^30, a symptom of a separate bug where each LK Newton step is 1/32 of the full step (It is not scaled to match the Scharr-scaled gradients). Fixing that bug could let a reset estimate converge anyway and silently disarm the test. Use identical frames with a guess of one full texture period (3 px): u = 3 and u = 0 are both exact zero-mismatch solutions, so level 0 stays where it starts. A carried guess gives x = 35 and a dropped or reset one gives x = 32, whatever the step size. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…144) Closed-form fixtures on a quadratic bowl I = (x-16)^2 + (y-16)^2, whose Scharr gradient is exactly 64 (x-16) and interpolates exactly: - min_eigen (OPTFLOW_LK_GET_MIN_EIGENVALS) must be (min(W,H)^2 - 1) / 3072, i.e. exactly W x H samples at OpenCV's offsets k - (n-1)/2, normalised by W*H. The integer half-window currently gives 120/3072 for 10x10 (11x11 samples) and 48/3072 for 10x6 (11x7). - the tracking error (MAE, zero iterations) must be 10 * mean|dx|: 25 for W = 10, currently 300/11 = 27.27. - win_size below 3x3 must be rejected, like OpenCV's CV_Assert(winSize.width > 2 && winSize.height > 2). Odd sizes (9x9) already match and are included as a guard. README unit test count: 354 -> 357. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nCV (#144) The LK window used an integer half-size (win_size / 2) and inclusive loops, so it always sampled an odd 2*(n/2)+1 pixels per side: an even win_size of 10 sampled 11, and the eigenvalue was normalised by that area instead of the requested one. Adopt OpenCV's convention, halfWin = (winSize - 1) * 0.5: exactly width x height samples at offsets k - half for k = 0..n, which are half-integers (bilinearly interpolated) for even sizes. A small TrackingWindow helper owns the convention, and all five sampling loops (H accumulation and both Newton closures, scalar and SIMD, plus the tracking error) iterate its offsets in the same row-major order, so the SIMD gather buffers stay aligned. win_area is now W*H. Odd sizes produce the same samples as before. win_size below 3x3 is now rejected with InvalidInput, matching OpenCV's assertion; with exact sampling a non-positive size would otherwise give an empty window and a NaN eigenvalue. Closes #144. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- test_lk_single_newton_step_is_exact_on_quadratic_bowl: on the quadratic bowl translated by an integer (dx, dy), one Newton step over a symmetric window is exactly (dx, dy) in closed form (32 * 128 / 4096). It is currently (dx, dy) / 32 = 0.03125 px. - test_lk_recovers_subpixel_shift_with_opencv_defaults: a smooth texture shifted by (0.6, -0.4) must be recovered to within 0.02 px with OpenCV's calcOpticalFlowPyrLK defaults. It currently comes out as (0.35, -0.23). - test_lk_pure_translation_x: tighten the tolerance from +/-1.5 px to +/-0.05 px. The loose bound hid this bug (the estimate is 2.84 for a 3 px shift). README unit test count: 357 -> 359. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
H and b are built from Scharr derivatives, which are 32x the true image gradient, but the temporal difference It = I2 - I1 in b was the raw intensity difference. So H scaled by 32^2 and b by 32, and every Newton step H^-1 b was 1/32 of the true step: with OpenCV's defaults a clean 1 px shift was tracked as ~0.70 px. The tiny steps also tripped the oscillation fallback (#131) on ordinary same-direction steps. OpenCV keeps its window samples at x32 fixed-point (CV_DESCALE(..., W_BITS1-5)), so its diff carries the same factor and it cancels. Do the equivalent here: scale the accumulated mismatch vector b by SCHARR_GAIN = 32 in both the scalar and SIMD closures (scaling the sum once instead of It per sample). H is untouched, so min_eigen keeps its OpenCV-parity scale (#130, #138). Closes #149. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…TOKEN (#129) The v0.8.0 release's publish-npm job failed with EOTP ("This operation requires a one-time password"): npm accepted NPM_TOKEN but refused to publish without 2FA, so 0.8.0 had to be published by hand. crates.io had already been published by then, leaving a half-finished release. Switch the job to npm Trusted Publishing, as laid out in #129: - job-level `permissions: { contents: read, id-token: write }` - actions/setup-node@v7 with Node 24, plus `npm install -g npm@11` and a guard that fails fast if npm < 11.5.1 (trusted publishing's minimum; Node 24.18.0 bundles npm 10.9.4) - drop the `npm config set //registry.npmjs.org/:_authToken` line - `npm publish --access public --provenance` Needs a Trusted Publisher configured on npmjs.com for @webarkit/purecv-wasm (owner webarkit, repository purecv, workflow release.yml, no environment) before the next tag push. Closes #129. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Bump version 0.8.0 -> 0.9.0 in Cargo.toml ([package] and [workspace.package]) and root package.json. crates/wasm/Cargo.toml inherits via version.workspace = true. - crates/wasm/pkg/package.json: bump its version line. This is exactly what `npm run build` would regenerate: none of its inputs (Cargo metadata, crates/wasm/README.md, build scripts, LICENSE) changed since v0.8.0, and v0.8.0's regeneration differed from 0.7.1 only in this line. (wasm-pack isn't installed on this machine.) - README.md: bump the five hardcoded `purecv = "0.8"` install-snippet versions to "0.9" (crates/wasm/README.md has none). - Generate the v0.9.0 changelog entry via git-cliff, plus the blank line before the previous version heading that --prepend omits. Minor bump (0.8.0 -> 0.9.0): this release brings calc_optical_flow_pyramid_lk to OpenCV parity (#130, #131, #138, #142, #144, #145, #149), which changes tracking results for existing callers (eigenvalue scale, flow estimates, even window sizes, win_size < 3 now rejected), and moves npm publishing to trusted publishing (#129). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PR Summary by QodoRelease PureCV 0.9.0 with corrected LK tracking and npm publishing
AI Description
Diagram
High-Level Assessment
Files changed (13)
|
Code Review by Qodo
1. Large tracking windows panic on overflow
|
This pull request releases PureCV 0.9.0. It brings
calc_optical_flow_pyramid_lk(pyramidal Lucas-Kanade) to OpenCV parity and moves npm publishing to trusted publishing. 32 commits, 13 files changed (+1142 / −146); the full list is in the newCHANGELOG.mdentry.Lucas-Kanade optical flow: OpenCV parity (
src/video/optical_flow.rs)build_optical_flow_pyramidandcalc_optical_flow_pyramid_lk.FLT_SCALEand window-area normalisation), so OpenCV's1e-4default carries over directly. Breaking: thresholds tuned on older builds need retuning (the docs give the factor).lk_iterateso it can be unit-tested.D < FLT_EPSILONtest (LK_DET_EPSILON = 2^17).win_sizepixels using OpenCV's(n−1)/2half-window, so even sizes work correctly.win_sizesmaller than 3×3 now returns an error.These change tracking results for existing callers, which is why this is a minor version bump. Each fix has regression tests with hand-derived expected values; 359 unit tests in total.
CI and release process
publish-npmnow uses npm trusted publishing (OIDC) with provenance instead ofNPM_TOKEN. That token path made v0.8.0's npm publish fail withEOTP. The Trusted Publisher for@webarkit/purecv-wasmis configured on npmjs.com. Thev0.9.0tag will be this path's first real run. Ifpublish-npmfails, use Re-run failed jobs rather than re-tagging.Versioning and changelog (#152)
Cargo.toml([package]and[workspace.package]),package.jsonandcrates/wasm/pkg/package.json; README install snippets updated to"0.9";CHANGELOG.mdentry generated with git-cliff.cargo publish --dry-run -p purecvsucceeds, and a localnpm run build:wasm(std + SIMD) regeneratescrates/wasm/pkg/package.jsonbyte-identical to the committed file.After merging
v0.9.0on the newmainmerge commit and push the tag. This publishes to crates.io and npm and can't be undone. The release workflow waits for the Build and Test, WASM, Benchmarks and both Miri checks before publishing.🤖 Generated with Claude Code