diff --git a/.agents/instructions.md b/.agents/instructions.md new file mode 100644 index 0000000..0935e13 --- /dev/null +++ b/.agents/instructions.md @@ -0,0 +1,60 @@ +# Agent Instructions: Porting Core Data Structures (`include/AR/*.h`) + +## Phase 1: Header Triage & Analysis +1. Analyze in the https://github.com/webarkit/WebARKitLib the `include/AR/` directory. Consider code from lib/src/AR, lib/SRC/AR2, lib/SRC/ARUtils +2. **IGNORE** the following headers: `video.h`, `gsub.h`, `gsub_lite.h`, `arMulti.h`, `arGL.h`, `arOSG.h`. +3. Focus strictly on: `ar.h`, `param.h`, `matrix.h`, `arFilterTransMat.h`. + +## Phase 2: Struct Extraction & Translation +1. Identify ALL core data structures in `ar.h` and related math headers. This includes, but is not limited to: + - Calibration: `ARParam`, `ARParamLT` + - Tracking & State: `ARHandle`, `AR3DHandle`, `ARMarkerInfo` + - Math: `ARMat`, `ARVec` +2. Create the corresponding Rust modules (e.g., `crates/core/src/types.rs`, `crates/core/src/math.rs`). +3. Translate the C `struct`s into Rust. Ensure correct memory alignment and types. +4. Add `#[derive(Debug, Default, Clone)]` where applicable. + +## Phase 3: Validation +1. Write unit tests for every function and struct created to ensure their default instantiation behaves as expected and mathematical equivalence is maintained. +2. Ensure all generated documentation and inline comments are in English. + +## Phase 4: Clippy hygiene (post-#180) + +The strict clippy gate (`cargo clippy --workspace --all-targets --all-features -- -D warnings`) +is enforced in CI by the `kpm-build (ubuntu-latest)` job. When writing +or modifying any code in this repo, follow the conventions below to +avoid recreating lints cleared during the #180 series. Full reference +in [CLAUDE.md §7](../CLAUDE.md). + +1. **Struct-init over `Default::default()` + field reassign.** + `let x = T { f: ..., ..Default::default() };`, not + `let mut x = T::default(); x.f = ...;`. Refs: PR #186, #188. + +2. **FFI shim cfg must match its caller's cfg.** When declaring + `extern "C" { fn webarkit_cpp_*(...) }` only for dual-mode parity + tests, gate the block with `#[cfg(all(test, feature = "dual-mode"))]` + — same as the caller module. A `#[cfg(feature = "dual-mode")]` + alone leaks the extern into the non-test lib build, where it has + no caller and trips `dead_code`. Ref: PR #185. + +3. **Place new items above `#[cfg(test)] mod tests`.** Clippy's + `items_after_test_module` fires otherwise. Ref: PR #184. + +4. **SIMD runtime-dispatch variants get inline + `#[allow(clippy::too_many_arguments)]` + `// rationale:`** — not + a `clippy.toml` threshold raise. Their signatures are locked by + the dispatcher contract. Ref: PR #187. + +5. **Auto-generated test fixtures from external sources** (e.g. + `tests/kpm_regression.rs` constants captured from C++) get + file-scoped `#![allow(clippy::excessive_precision)]` + rationale + to preserve source-traceability. Ref: PR #188. + +6. **Re-run strict clippy after every clean.** Cargo halts on lib + errors before checking `--all-targets`. Lints in + examples/tests/benches stay masked until the lib is clean. Refs: + PR #187 → #188 (26 lints surfaced this way). + +When you must add a lint suppression, write it as `#[allow(...)]` with +a `// rationale:` comment explaining *why* — not `#[expect(...)]` or a +bare allow. The rationale is the contract for future contributors. \ No newline at end of file diff --git a/.agents/rules/rules.md b/.agents/rules/rules.md new file mode 100644 index 0000000..90229b3 --- /dev/null +++ b/.agents/rules/rules.md @@ -0,0 +1,21 @@ +--- +trigger: always_on +--- + +# Antigravity Agent Rules for WebARKitLib.rs + +## 1. Role & Identity +You are an expert systems programmer porting WebARKitLib from C/C++ to Rust. The ultimate target is a pure, side-effect-free WASM module and native library. + +## 2. Strict Exclusions (Out of Scope) +- **NO Video Handling**: Completely ignore all files and functions related to video capture (e.g., V4L2, DirectShow, QuickTime, `video.h`, `arVideo.h`). Video buffering will be handled externally (e.g., via JavaScript `Uint8Array` passed to WASM). +- **NO Rendering/OpenGL**: Completely ignore all files and functions related to OpenGL, GLUT, or 3D rendering (e.g., `gsub.h`, `gsub_lite.h`, `arGL.h`). +- **NO arMulti**: Skip all code related to multi-marker tracking (`arMulti.h`, `arMulti*.c`) for now. + +## 3. Code Standards & Language +- **Language**: ALL code comments, explanations, docstrings, and commit messages MUST be written in English. +- **Safety**: Prefer safe Rust. Use `unsafe` only for specific WASM SIMD intrinsics (`std::arch::wasm32`). + +## 4. C to Rust Translation Guidelines +- Translate C structs from headers into idiomatic Rust. Use exact primitive types (e.g., `f64` for `ARdouble` if configured so). +- Replace C arrays inside structs with Rust arrays or `Vec` depending on whether the size is known at compile time. \ No newline at end of file diff --git a/.agents/skills/license-header-adder/SKILL.md b/.agents/skills/license-header-adder/SKILL.md new file mode 100644 index 0000000..16f0133 --- /dev/null +++ b/.agents/skills/license-header-adder/SKILL.md @@ -0,0 +1,37 @@ +--- +name: license-header-adder +description: Automatically adds a license header to source files with a filename placeholder. +--- + +# License Header Adder Skill + +This skill allows you to add a consistent LGPLv3 license header to all source files in the project. + +## Instructions + +1. **Read the Template**: Locate the license template in `resources/HEADER.txt`. +2. **Placeholder Replacement**: Before applying the header to a file, replace the `{{FILENAME}}` placeholder with the basename of the target file. +3. **Supported Files**: apply this header to: + - `.rs` files + - `.c` and `.h` files + - `.js` and `.ts` files +4. **Application Logic**: + - If the file already has a license header, skip it. + - **Exclusions**: Never apply this to files in `benchmarks/c_benchmark/src/WebARKitLib` or third-party vendor directories. + - Insert the header at the very top of the file. + - Ensure there is a blank line after the header before the original content starts. + +## Example Usage + +When applying to `core/src/lib.rs`: +```rust +/* + * lib.rs + * + * WebARKitLib.rs - High-performance AR tracking library + ... + */ + +use wasm_bindgen::prelude::*; +... +``` diff --git a/.agents/skills/license-header-adder/resources/HEADER.txt b/.agents/skills/license-header-adder/resources/HEADER.txt new file mode 100644 index 0000000..8d9ef2c --- /dev/null +++ b/.agents/skills/license-header-adder/resources/HEADER.txt @@ -0,0 +1,36 @@ +/* + * {{FILENAME}} + * WebARKitLib-rs + * + * This file is part of WebARKitLib-rs - WebARKit. + * + * WebARKitLib-rs is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * WebARKitLib-rs is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with WebARKitLib-rs. If not, see . + * + * As a special exception, the copyright holders of this library give you + * permission to link this library with independent modules to produce an + * executable, regardless of the license terms of these independent modules, and to + * copy and distribute the resulting executable under terms of your choice, + * provided that you also meet, for each linked independent module, the terms and + * conditions of the license of that module. An independent module is a module + * which is neither derived from nor based on this library. If you modify this + * library, you may extend this exception to your version of the library, but you + * are not obligated to do so. If you do not wish to do so, delete this exception + * statement from your version. + * + * Copyright 2026 WebARKit. + * + * Author(s): Walter Perdan @kalwalt https://github.com/kalwalt + * + */ + diff --git a/.claude/issue-180-plan.md b/.claude/issue-180-plan.md new file mode 100644 index 0000000..eaed82d --- /dev/null +++ b/.claude/issue-180-plan.md @@ -0,0 +1,173 @@ +# Issue #180 — Tighten CI Clippy Gate to `--all-targets --all-features` + +**Status**: Planning complete, ready for implementation. +**Branch base**: `dev` (HEAD `a30b033` at planning time, post-PR #183 criterion 0.8 merge). +**Tracking issue**: [#180](https://github.com/webarkit/WebARKitLib-rs/issues/180). + +--- + +## Understanding Summary + +- **What**: Close the gap between CI's clippy gate (`--workspace -- -D warnings`, default features, lib targets only) and the strict local check (`--all-targets --all-features -- -D warnings`) — a ~70-lint delta on `dev` HEAD. +- **Why**: Contributor onboarding (new contributors run the strict check and assume the codebase is broken); the strict gate catches real lints in `ffi-backend` / `dual-mode` / `simd-*` paths CI never sees today. +- **Who**: WebARKitLib-rs contributors; no end-user behavior change. +- **How**: Execute issue #180's 5-PR sequence as the spec — trivial wins → FFI shim annotations → `ar/marker.rs` field-reassign cleanup → SIMD `#[allow]` with rationale → tighten CI in `.github/workflows/ci.yml`. +- **Non-goals**: + - No `clippy.toml` file (deferred to M10+ if SIMD surface grows). + - No `FeatureMapConfig` / struct refactor (SIMD signatures are locked by runtime-dispatch contract). + - No deletion of `webarkit_cpp_*` FFI shims unless truly orphaned. + - No `CHANGELOG.md` edits (project rule). + - Not blocking v0.7.0 (already released). + +--- + +## Decision Log + +| # | Decision | Alternatives considered | Why this choice | +|---|---|---|---| +| 1 | Follow #180's 5-PR sequence verbatim | Refine ordering; merge PRs; re-baseline lint counts first | Issue is well-scoped; counts may drift but plan structure is sound | +| 2 | **(Updated during PR 2)** Tighten each extern block's cfg from `#[cfg(feature = "dual-mode")]` to `#[cfg(all(test, feature = "dual-mode"))]` — matches caller cfg, lint disappears because the extern only exists when callers do. Originally planned `#[allow(dead_code)]` + rationale. | Original plan (allow + rationale); wire up callers (scope creep); bulk delete (irreversible) | Evidence at PR 2 time: all 17 "dead" shims have callers, but the callers are gated `cfg(all(test, feature = "dual-mode"))` while the extern blocks were gated only on `cfg(feature = "dual-mode")`. The cfg mismatch was the real root cause; tightening fixes it honestly. Same effort per block (one line), zero behavior change. | +| 3 | PR 4: inline `#[allow(clippy::too_many_arguments)]` + rationale on `get_similarity_sse41` / `get_similarity_avx2` | `clippy.toml` threshold raise; bundle args into struct | SIMD signatures locked by runtime-dispatch contract; inline is scoped, threshold raise loses signal everywhere | +| 4 | Defer `clippy.toml` | Add now with `too-many-arguments-threshold = 9` | Only 2 sites today; revisit when SIMD surface grows in M10+ | +| 5 | Keep CI on `dtolnay/rust-toolchain@stable` after PR 5 | Pin to a specific rustc version; document known-good rustc | Accept the churn; v0.7.0 is shipped, future rustc lint additions fix in follow-up PRs | +| 6 | Branch off `dev`, one PR per branch | Stacked PRs; single mega-PR | Project convention (CLAUDE.md §4); keeps reviews focused | + +--- + +## Assumptions + +- **A1**: CI keeps `dtolnay/rust-toolchain@stable` after PR 5 tightens the gate. When a new rustc adds lints, fix in a follow-up PR. No rustc pin. +- **A2**: PR 4 stays its own PR (not folded into PR 1) for review clarity, even though it's only 2 `#[allow]` lines. Revisit at execution time if it feels artificial. +- **A3**: Branch naming follows `chore/clippy-180-pr-` — branched off `dev`, one PR per branch, conventional commits per CLAUDE.md §6. +- **A4**: Re-baseline the lint inventory at the start of PR 1, not now. Counts may differ from #180's ~70 due to rustc drift since #180 was filed. + +--- + +## Open Risks + +- **R1** — *Rustc drift since #180*: lints may have been promoted/demoted between #180's filing and today. PR 1's scope could expand or contract. **Mitigation**: re-baseline before opening PR 1; update #180 with actual counts. +- **R2** — *FFI shim mislabeled*: a `webarkit_cpp_*` extern annotated as "kept for dual-mode parity" might actually be orphaned. **Mitigation**: run `cargo test --features dual-mode` and `cargo test --features ffi-backend` after PR 2; any annotated shim that has no caller under any feature combo gets deleted in the same PR. +- **R3** — *`ar/marker.rs` init-order dependency*: 44 mechanical `field-reassign-with-default` fixes touch many lines. Struct-init syntax could surface a subtle init-order dependency in `ARHandle` construction. **Mitigation**: rely on existing tests; if any fail, revert that specific site to the default+reassign pattern with an `#[allow]` + rationale. +- **R4** — *CI tightening fails after merge*: PR 5 flips the gate, then unrelated work introduces new lints under `--all-features` that the contributor didn't catch locally. **Mitigation**: PR 5 description includes a one-liner of the exact local command (`cargo clippy --all-targets --all-features -- -D warnings`); update CLAUDE.md §5 to match (already aligned) and add a §6 checklist note. + +--- + +## Implementation Plan — 5 PRs + +All PRs: branched off `dev`, opened against `dev`, conventional commits, pre-commit checklist (CLAUDE.md §5) green before push. + +### PR 1 — Trivial wins (~10 lints) + +**Branch**: `chore/clippy-180-pr1-trivial-wins` +**Scope**: +- Unused variables: `crates/core/src/ar/image_proc.rs:217` (`image_temp_u16`), `:218` (`image2`) — prefix `_` or remove. +- `items_after_test_module`: move items above `#[cfg(test)] mod tests` in `crates/core/src/ar/math.rs:831` and `ar/pattern.rs:1050`. +- `needless_deref`: `crates/core/src/kpm/freak/homography.rs:2645`. +- `needless_range_loop` (4 sites): convert to `iter().enumerate()` in `ar2/feature_map.rs`, `ar2/image_set.rs`, `ar/pattern.rs`. + +**Verification**: +```bash +cargo fmt --all -- --check +cargo clippy --all-targets --all-features -- -D warnings # the new lints here pass +cargo test --all-features +``` + +**Commit**: `fix(core): clean up trivial clippy lints under --all-targets --all-features (#180)` + +--- + +### PR 2 — Dead FFI shim hygiene (~14 lints) + +**Branch**: `chore/clippy-180-pr2-ffi-shims` +**Scope**: `crates/core/src/kpm/freak/{clustering,homography,math,hough,matcher}.rs` — for each dead `extern "C" { fn webarkit_cpp_*(...) }` block: + +**Implemented (Decision #2 updated)**: change each block's cfg gate from `#[cfg(feature = "dual-mode")]` to `#[cfg(all(test, feature = "dual-mode"))]` so the extern only exists when its caller (also `cfg(all(test, feature = "dual-mode"))`) does. Add a one-line comment above each block referencing #180 for traceability. Verified all 17 callers still resolve and dual-mode tests still pass. + +**Verification**: same triad as PR 1, plus `cargo test --features dual-mode --lib` and `cargo test --features ffi-backend` green. + +**Commit**: `chore(kpm): document or remove dead webarkit_cpp_* FFI shims (#180)` + +--- + +### PR 3 — `ar/marker.rs` field-reassign-with-default (44 lints) + +**Branch**: `chore/clippy-180-pr3-ar-marker-init` +**Scope**: `crates/core/src/ar/marker.rs` — mechanical conversion of `let mut h = ARHandle::default(); h.field = x; h.other = y;` to struct-init syntax `let h = ARHandle { field: x, other: y, ..Default::default() };`. + +**Process**: +- One commit per logical group (e.g. one per call site / constructor) to make review tractable. +- If any site fails tests after conversion, revert that specific site with `#[allow(clippy::field_reassign_with_default)]` + `// rationale: init order matters here because `. + +**Verification**: same triad as PR 1, with extra attention to `cargo test --all-features` since `ARHandle` is core path. + +**Commit**: `refactor(ar): convert ARHandle construction to struct-init syntax (#180)` + +--- + +### PR 4 — SIMD `too_many_arguments` `#[allow]` (2 lints) + +**Branch**: `chore/clippy-180-pr4-simd-too-many-args` +**Scope**: `crates/core/src/ar2/feature_map.rs:275` and `:387` — add to each: + +```rust +#[allow(clippy::too_many_arguments)] +// rationale: SIMD variant of get_similarity; signature locked to match +// scalar fallback and sibling SIMD impl for runtime dispatch via +// is_x86_feature_detected!. +``` + +**Verification**: same triad as PR 1. + +**Commit**: `chore(ar2): allow too_many_arguments on SIMD get_similarity variants (#180)` + +--- + +### PR 4.5 — Examples/tests/benches cleanup (unplanned, discovered during PR 4) + +**Branch**: `chore/clippy-180-pr4-5-examples-tests-benches` + +**Surfaced**: PR 4 cleared the final two **library** lints. Cargo had been halting on lib errors before checking `--all-targets`, so 26 examples/tests/benches lints were hidden until the lib went strict-clean. PR 5 couldn't tighten CI safely until these were also cleared. + +**Categories**: +- 26 `excessive_precision` in `tests/kpm_regression.rs` — handled via file-scoped `#![allow(clippy::excessive_precision)]` with rationale (C++ baseline fixtures, extra digits document source-traceability). +- 9 `field_reassign_with_default` in examples + benches — same struct-init conversion as PR 3. +- 7 `unnecessary_cast usize→usize` in `generate_patt.rs` — drop redundant `as usize`. +- 3 `or_fun_call inside expect` — `unwrap_or_else(|_| panic!(...))`. +- 3 `ptr_arg &Vec` → `&[u8]`. +- 3 `redundant_closure` (`|| String::new()` → `String::new`). +- 5 `needless_range_loop` — iterator forms. +- 5 singletons (`type_complexity` via `type RunReport = ...`, `redundant_pattern_matching` (`if let Ok(_)` → `.is_ok()`), `manual_range_contains`, `manual_is_multiple_of`, `let_unit_value`). + +**Coverage workflow tangent**: PR 4.5 also fixed an unrelated bug in `.github/workflows/coverage.yml` — tarpaulin's `--exclude-files "tests/*"` glob was workspace-root-relative and never matched our nested `crates/core/tests/`. Fixed to `**/tests/*`, and added a project `codecov.yml` mirroring the exclusions so the patch gate doesn't fail on PRs that land entirely in uninstrumented paths. + +**Commits**: +- `chore: clear strict clippy lints in examples/tests/benches (#180)` +- `chore(codecov): exclude examples/benches from coverage targets (#180)` +- `ci(coverage): fix tarpaulin tests/ glob and mirror in codecov (#180)` + +--- + +### PR 5 — Tighten CI + +**Branch**: `chore/clippy-180-pr5-ci-tighten` +**Pre-req**: PRs 1–4.5 merged; `cargo clippy --workspace --all-targets --all-features -- -D warnings` clean on `dev`. + +**Scope**: `.github/workflows/ci.yml` +- `kpm-build (ubuntu-latest)` job: add a new step `cargo clippy --workspace --all-targets --all-features -- -D warnings` (Linux-only). This job already has submodules + libclang, which `--all-features` requires (`ffi-backend` triggers C++ compilation in `build.rs`). +- `build-and-test` job: keep `cargo clippy --workspace -- -D warnings` (default features, no submodules). This job's checkout intentionally skips submodules to verify the pure-Rust path doesn't accidentally depend on them — adding `--all-features` here would conflict with that invariant. +- `pure-rust-build` job: keep `cargo clippy -p webarkitlib-rs -- -D warnings` (no-features path stays clean — a third invariant). + +**Discovery**: the original plan had the strict gate going into `build-and-test`. First push of PR 5 failed because that job's checkout doesn't include git submodules, so `--all-features` enabled `ffi-backend` and `build.rs` panicked looking for the C++ sources. Moved to `kpm-build (ubuntu-latest)` where the prerequisites already exist. Lints are OS-agnostic, so Linux-only is sufficient. + +**Verification**: open PR, watch CI go green. If a lint slips in between PR 4 merge and PR 5 (rustc drift, A1), fix in this PR. + +**Commit**: `ci: tighten clippy gate to --all-targets --all-features (#180)` + +--- + +## Closure + +When PR 5 merges: +- Close issue #180. +- Note in the closing comment whether actual lint count matched #180's ~70 estimate (data point for future audits). +- Confirm CLAUDE.md §5 is still accurate (it already prescribes the strict command — no update needed). diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d7ed67d..3fea445 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -93,12 +93,50 @@ jobs: if: runner.os == 'Linux' run: sudo apt-get update && sudo apt-get install -y libclang-dev + # cache-bin: false avoids caching ~/.cargo/bin. On macOS this job + # intermittently restored a stale `cargo` shim resolving to + # `rustup-init`, so `cargo build` was parsed as `rustup-init build` + # and failed (#134). This job installs no cargo-managed binaries, so + # disabling bin caching costs nothing while removing the flake. - name: Rust Cache uses: Swatinem/rust-cache@v2 + with: + cache-bin: false + + # Strict clippy gate (#180): --all-targets covers examples/tests/benches; + # --all-features exercises ffi-backend, dual-mode, simd-* paths the + # default-features check skipped. Ubuntu-only because this is the job + # that already has submodules + libclang; lints are OS-agnostic so + # running it on one platform is sufficient. + - name: Run cargo clippy (strict, --all-targets --all-features) + if: runner.os == 'Linux' + run: cargo clippy --workspace --all-targets --all-features -- -D warnings - name: Build kpm backend (FFI backend) run: cargo build -p webarkitlib-rs --features ffi-backend + # macOS ffi-backend link verification (#119). PR #117 fixed build.rs to + # link libc++ (not libstdc++) on Apple targets, but the dual-mode CI step + # was scoped to --lib, so the integration tests and the generate_patt + # example were never re-built on macOS after the fix. Build them here to + # confirm they link against libc++. + # + # --no-run (not a full run): this verifies the linker fix only. Actually + # executing the pose-sensitive integration tests cross-platform is out of + # scope — that float-drift question is tracked separately in #118, which + # keeps test_full_pipeline_pose gated to Linux. + - name: Verify ffi-backend tests + example link (macOS) + if: runner.os == 'macOS' + run: | + cargo test -p webarkitlib-rs \ + --test kpm_regression \ + --test nft_pipeline \ + --test ar2_pinball_io \ + --test cross_stack_parity \ + --features ffi-backend --no-run + cargo build -p webarkitlib-rs \ + --example generate_patt --features log-helpers,ffi-backend + # Dual-mode tests run pure-Rust ports against the C++ baseline via the # FFI backend (webarkit_cpp_* shims) and assert byte-level parity. # Catches regressions that would otherwise only surface in local runs. @@ -153,6 +191,10 @@ jobs: --test absolute_corner_error \ --features dual-mode -- --nocapture + # Miri UB validation (#182) lives in its own workflow at + # .github/workflows/miri.yml — split out so it can carry a dedicated + # status badge and run on its own cadence. + submodule-drift-check: runs-on: ubuntu-latest steps: @@ -219,24 +261,38 @@ jobs: working-directory: crates/core run: cargo run --example simple --features log-helpers -- ../../benchmarks/data/camera_para.dat ../../benchmarks/data/patt.hiro ../../benchmarks/data/img.jpg - - name: Run benchmarks + - name: Install C benchmark deps (svn for bootstrap) + run: sudo apt-get update && sudo apt-get install -y subversion + + - name: Cache C benchmark downloaded sources + uses: actions/cache@v4 + with: + path: benchmarks/c_benchmark/archives + key: c-bench-archives-${{ runner.os }}-${{ hashFiles('benchmarks/c_benchmark/libraries.json') }} + + # The C baseline pulls external sources (jpeg-9f from ijg.org, WebARKitLib) + # whose download occasionally fails with TLS/network errors (#204). It only + # produces an optional comparison log, so keep it NON-BLOCKING: a flake must + # not fail the benchmarks gate. The required signal is the Rust bench below. + - name: Build & run C baseline benchmark (non-blocking) + continue-on-error: true run: | - # Download and Build C benchmark + set -e cd benchmarks/c_benchmark - python ../bootstrap.py --bootstrap-file libraries.json - + for attempt in 1 2 3; do + python ../bootstrap.py --bootstrap-file libraries.json && break + echo "bootstrap attempt $attempt failed; retrying in 15s..." >&2 + sleep 15 + done mkdir -p build && cd build cmake .. -DCMAKE_BUILD_TYPE=Release make cd .. - - # Run C benchmark for baseline mkdir -p ../../target/criterion ./build/c_benchmark ../data/camera_para.dat ../data/patt.hiro ../data/hiro.raw 429 317 > ../../target/criterion/c_benchmark.log - - # Run Rust benchmarks - cd ../.. - cargo bench -p webarkitlib-rs --bench marker_bench + + - name: Run Rust benchmarks + run: cargo bench -p webarkitlib-rs --bench marker_bench - name: Upload benchmark reports uses: actions/upload-artifact@v5 with: diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index ad750a0..488aaef 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -32,7 +32,8 @@ jobs: --out Xml \ --output-dir coverage \ --timeout 300 \ - --exclude-files "tests/*" \ + --exclude-files "**/tests/*" \ + --exclude-files "**/examples/*" \ --ignore-panics - name: Upload coverage reports to Codecov diff --git a/.github/workflows/miri-skip.yml b/.github/workflows/miri-skip.yml new file mode 100644 index 0000000..3544a35 --- /dev/null +++ b/.github/workflows/miri-skip.yml @@ -0,0 +1,54 @@ +name: Miri + +# Skip-shim for the Miri required gate. +# +# The real Miri workflow (.github/workflows/miri.yml) uses `paths-ignore` +# to skip docs-only changes. But the `protect-main` / `protect-dev` +# rulesets require a check named `miri (pure-Rust UB validation)` to +# pass on every PR — including docs-only ones. Without this shim a +# README-only PR would have a "missing" required check and could not +# merge. +# +# This workflow declares the same `name:` and the same job `name:` as +# miri.yml, but triggers ONLY on the paths the real workflow ignores. +# It does no actual work — it just emits a passing check with the +# expected name so branch protection is satisfied. +# +# IMPORTANT: keep the workflow `name:` and job `name:` here in sync +# with miri.yml. GitHub matches required checks on those names +# exactly. + +on: + pull_request: + branches: + - dev + - main + paths: + - '**/*.md' + - 'docs/**' + - 'assets/**' + - 'LICENSE*' + - '.gitignore' + - '.gitattributes' + push: + branches: + - dev + - main + paths: + - '**/*.md' + - 'docs/**' + - 'assets/**' + - 'LICENSE*' + - '.gitignore' + - '.gitattributes' + +jobs: + miri: + name: miri (pure-Rust UB validation) + runs-on: ubuntu-latest + steps: + - name: Skip — docs-only change, no Rust touched + run: | + echo "This change touches only docs / assets / LICENSE." + echo "The real Miri workflow ran zero seconds because there" + echo "is no Rust code change to validate." diff --git a/.github/workflows/miri.yml b/.github/workflows/miri.yml new file mode 100644 index 0000000..8487b48 --- /dev/null +++ b/.github/workflows/miri.yml @@ -0,0 +1,87 @@ +name: Miri + +# Miri UB validation (#182): runs the pure-Rust lib under the MIR +# interpreter to catch use-after-free, OOB reads, invalid `unsafe` +# invariants, uninitialized-memory reads, and reference-aliasing +# violations that escape regular tests. +# +# Excludes `ffi-backend` (Miri can't execute C++) and `simd-x86-*` +# (Miri's x86 SIMD support is incomplete). Scalar fallbacks ARE +# validated. +# +# Heavy algorithmic / pipeline tests are skipped via +# `#[cfg_attr(miri, ignore)]` (#194) — they don't exercise unsafe +# boundaries and would dominate runtime. See docs/miri.md. +# +# Required gate: this workflow MUST pass for PRs targeting dev/main. + +on: + pull_request: + branches: + - dev + - main + paths-ignore: + - '**/*.md' + - 'docs/**' + - 'assets/**' + - 'LICENSE*' + - '.gitignore' + - '.gitattributes' + push: + branches: + - dev + - main + paths-ignore: + - '**/*.md' + - 'docs/**' + - 'assets/**' + - 'LICENSE*' + - '.gitignore' + - '.gitattributes' + +env: + CARGO_TERM_COLOR: always + +jobs: + miri: + name: miri (pure-Rust UB validation) + runs-on: ubuntu-latest + env: + # Pinned nightly — bump manually if Miri breaks on this date or + # every ~3 months for freshness. See docs/miri.md. + MIRI_NIGHTLY: nightly-2026-06-01 + steps: + - uses: actions/checkout@v5 + + - name: Install pinned nightly + Miri + run: | + rustup toolchain install $MIRI_NIGHTLY --component miri --profile minimal + rustup default $MIRI_NIGHTLY + cargo miri setup + + - name: Rust Cache + uses: Swatinem/rust-cache@v2 + with: + key: miri-${{ env.MIRI_NIGHTLY }} + shared-key: miri + + # NOTES on MIRIFLAGS: + # * -Zmiri-tree-borrows: use the Tree Borrows aliasing model instead + # of the default Stacked Borrows. Stacked Borrows trips inside + # crossbeam-epoch (a transitive dep of rayon, reached from our + # ar2::feature_map par_chunks_mut) on patterns that are sound in + # practice. Tree Borrows accepts them while still catching real + # UB in our own code. + # * -Zmiri-disable-isolation: allow filesystem ops so tests that + # use `tempfile` (e.g. ar2::feature_set roundtrip) can run. + # * -Zmiri-ignore-leaks: rayon's global thread pool spawns workers + # that aren't joined at process exit. Miri flags that as a leak + # by default; ignoring it is the recommended setting for rayon- + # using test suites and does NOT weaken UB detection. + # * -Zmiri-strict-provenance is intentionally NOT enabled — it also + # trips inside crossbeam-epoch (integer-to-pointer casts). Re- + # evaluate once the ecosystem migrates. See docs/miri.md. + - name: Run Miri (pure-Rust, lib targets only) + run: cargo miri test -p webarkitlib-rs --lib + env: + MIRIFLAGS: -Zmiri-backtrace=full -Zmiri-tree-borrows -Zmiri-disable-isolation -Zmiri-ignore-leaks diff --git a/.gitignore b/.gitignore index b9d78af..ed53543 100644 --- a/.gitignore +++ b/.gitignore @@ -13,9 +13,6 @@ Cargo.lock # MSVC Windows builds of rustc generate these, which store debugging information *.pdb -# Google Antigravity local agent state (optional, but usually good to keep out of repos) -.agents/ - # VSCode files .vscode/ diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 7a7f4e2..37b40ac 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -36,12 +36,14 @@ The unified core library containing all AR functionality: - `kpm::ref_data_set` — `.fset3` reference data I/O and compression modes. - `kpm::types` — KPM data structures and constants. - `kpm::freak` — FREAK descriptor math, homography, and matching utilities (pure Rust port of `WebARKitLib/lib/SRC/KPM/FreakMatcher`): + - `freak::visual_database` — top-level per-frame query orchestrator (M9-1): builds the query keyframe, then runs the two-pass pipeline (feature match → Hough voting → homography → inlier filter, then a homography-guided re-match) across stored reference keyframes. Exposes `query` / `query_from_keyframe` and facade-parity accessors on the database + `FeatureStore` slices (#147, #148). - `freak::math` — linear algebra (matrix operations, linear solvers) and Padé matrix exponential. - `freak::homography` — homography estimation and refinement pipeline. - `freak::hough` — Hough similarity voting (4D bin discretization over translation × angle × scale) for filtering matches by transformation consistency. - `freak::clustering` — K-Medoids partitioning + Binary Hierarchical Clustering (BHC) vocabulary tree for fast approximate-NN search on 96-byte FREAK descriptors. Hamming distance via 24×32-bit bit-magic. Includes a byte-identical port of C++ `vision::FastRandom` / `vision::ArrayShuffle` so the BHC tree topology matches the C++ baseline given the same seed. - `freak::matcher` — `FeatureStore` (points + flat descriptor buffer) and `FeatureMatcher` with three match variants: brute force, BHC-indexed (fast path), and homography-guided (spatial filter via 3×3 inverse + `tr` radius). All three apply the C++ ratio test (default 0.7) and filter by `FeaturePoint::maxima`. - `freak::image_pyramid` — image pyramid construction via `BoxFilterPyramid8u` (box filtering for 8-bit grayscale) and `BinomialPyramid32f` (32-bit floating-point binomial pyramid with scale-space interpolation). + - `freak::gaussian_pyramid` — Gaussian scale-space pyramid driving the DoG detector. Hot binomial-filter passes have NO-FMA SSE4.1 / AVX2 / wasm SIMD paths (runtime-detected, bit-exact against the scalar fallback for dual-mode parity) and rayon-parallelized rows (v0.8.0, #200–#207). - `freak::feature_extraction` — keypoint detection via Difference-of-Gaussians (DoG), dominant orientation assignment via circular gradient voting, and FREAK descriptor computation with native Rust implementation of the FREAK binary pattern and bit-pair comparison. - **Types** (`types`): core data structures (`ARHandle`, `ARParam`, etc.). @@ -50,6 +52,13 @@ The unified core library containing all AR functionality: WASM wrapper and JavaScript/TypeScript glue code for browser targets. Depends only on `webarkitlib-rs` (the core crate). +Since **v0.8.0 (#161)** it exposes the pure-Rust NFT pipeline to the +browser: a `WasmKpmHandle` binding for KPM detection (load reference data +from bytes, detect), `console_log` wiring via `ar_log_init_wasm()`, a dual +**standard + SIMD** build pipeline (`npm run build:wasm`), and an +end-to-end browser NFT demo under `crates/wasm/www`. The `cc`/`bindgen` +build dependencies are optional so the wasm target builds pure-Rust. + ## Feature Flags | Feature | Description | @@ -58,6 +67,7 @@ Depends only on `webarkitlib-rs` (the core crate). | `simd` | Umbrella: enables all SIMD sub-features | | `simd-wasm32` | WASM SIMD128 intrinsics | | `simd-x86-sse41` | x86 SSE4.1 intrinsics | +| `simd-x86-avx2` | x86 AVX2 intrinsics (runtime-detected; NO-FMA for bit-exact scalar parity) | | `log-helpers` | Enable logging infrastructure (installs `env_logger` for desktop/tests, `console_log` for WASM) | | `ffi-backend` | **Opt-in** — compile the C++ FreakMatcher library and generate FFI bindings. Used for cross-validation and the regression-test suite; not required for production tracking. | | `dual-mode` | Enables FFI-based parity tests that validate pure-Rust ports against the live C++ baseline (M6 math/solvers/homography, M7 BHC/matcher, PRNG). Transitively enables `ffi-backend`. Run in CI on Linux/macOS/Windows. | diff --git a/CHANGELOG.md b/CHANGELOG.md index 7756c7e..5f07f4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,76 @@ -# Changelog - webarkit/webarkitlib-rs +# Changelog - webarkit/webarkitlib-rs All notable changes to this project will be documented in this file. -## [0.7.0] - 2026-06-05 +## [0.8.0] - 2026-06-26 + +## Milestone — Pure-Rust completeness, validation & polish + +### ⚙️ Miscellaneous Tasks + +- *(ar2)* Allow too_many_arguments on SIMD get_similarity variants (#180) +- Clear strict clippy lints in examples/tests/benches (#180) +- *(codecov)* Exclude examples/benches from coverage targets (#180) +- *(coverage)* Fix tarpaulin tests/ glob and mirror in codecov (#180) +- Tighten clippy gate to --all-targets --all-features (#180) +- Relocate strict clippy gate to kpm-build (ubuntu) job (#180) +- *(miri)* Add Miri UB validation for pure-Rust code paths (#182) +- *(miri)* Drop -Zmiri-strict-provenance (trips on third-party deps) (#182) +- *(miri)* Switch to Tree Borrows to avoid crossbeam-epoch false positive (#182) +- *(miri)* Enable -Zmiri-disable-isolation for tempfile-using tests (#182) +- *(miri)* Enable -Zmiri-ignore-leaks for rayon thread pool (#194) +- *(miri)* Promote to required gate, split workflow, add README badge (#182) +- *(miri)* Skip docs-only changes via paths-ignore + same-name shim +- *(benchmarks)* Harden against flaky external C-library downloads (#204) + +### ⚡ Performance + +- *(ci)* Scope Miri job to unsafe boundaries via cfg_attr(miri, ignore) (#194) +- *(ci)* Annotate remaining real-image tests (#194 follow-up) +- *(kpm)* Gaussian scale-space pyramid — benchmark + rayon parallelization (#209) +- *(kpm)* Box-filter Pyramid downsample — criterion benchmark + SIMD (#211) + +### 🐛 Bug Fixes + +- *(core)* Clean up trivial clippy lints under --all-targets --all-features (#180) +- *(kpm)* Tighten dual-mode FFI extern cfg to match caller cfg (#180) +- *(kpm)* Replace unaligned transmute in hamming_distance_96 (#192) +- *(ci)* Disable cargo-bin cache on kpm-build to stop stale rustup-init shim (#134) + +### 💼 Other + +- *(wasm)* Make cc/bindgen optional, fix wasm dead-code (#161 goal 1) + +### 📚 Documentation + +- *(benches)* Refresh BENCHMARKS.md for criterion 0.8 +- *(agents)* Codify #180 clippy conventions and un-ignore .agents/ +- *(kpm)* Mark box-filter Pyramid as kept-but-unused reference (#203) +- *(wasm)* Note .fset3 / KPM detection in the NFT demo description (#161) +- Update README + ARCHITECTURE for the v0.8.0 milestone + +### 🕸️ WebAssembly & Emscripten + +- *(wasm)* Wire console logger + clean SIMD wasm build; add .fset3 asset (#161 goals 2,3) +- *(wasm)* KPM detection binding + browser NFT demo (#161 goal 4) + +### 🚀 Features + +- *(kpm)* Facade-parity convenience accessors on VisualDatabase + FeatureStore slices (#148) +- *(example)* Nft_marker_gen .fset3 via RustFreakMatcher — drop ffi-backend (#179) + +### 🚜 Refactor + +- *(ar)* Convert Default::default() + field reassign to struct-init (#180) +- *(kpm)* Factor VisualDatabase::query matching loop + add query_from_keyframe (#147) (#217) + +### 🧪 Testing + +- *(core)* Add unit tests for relocated math/pattern helpers (#180) +- *(homography)* Widen cauchy_cost tolerance to 1e-5 for Miri (#194) +- *(kpm)* Raise M9 coverage — exclude examples + edge tests (#177) (#218) +- *(ci)* Verify ffi-backend tests + example link on macOS (#119) +## [0.7.0] - 2026-06-05 ## Milestone 9 — VisualDatabase & pure Rust backend diff --git a/CLAUDE.md b/CLAUDE.md index 149095d..e2567a0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -196,6 +196,123 @@ cargo test --all-features - [ ] CHANGELOG.md **not** touched - [ ] Branch is off current `dev` +## 7. Codebase clippy conventions (#180 lessons) + +The strict clippy gate (`--all-targets --all-features -- -D warnings`) +is enforced in CI by the `kpm-build (ubuntu-latest)` job. The patterns +below are non-obvious — they were discovered during the #180 cleanup +series and aren't taught by clippy's lint messages alone. + +### 7.1 Struct-init over `Default::default()` + field reassign + +Clippy's `field_reassign_with_default` fires on the C-style pattern. + +```rust +// ❌ Don't +let mut h = ARHandle::default(); +h.xsize = w; +h.ysize = h; + +// ✅ Do +let mut h = ARHandle { + xsize: w, + ysize: h, + ..Default::default() +}; +``` + +If subsequent code mutates other fields (e.g. `h.o2i[i] = ...`), keep +the binding `mut`. Method calls (`h.set_pixel_format(...)`) stay +after the initializer — they're not field reassigns. + +Reference: PR #186, PR #188. + +### 7.2 FFI shim cfg must match its caller's cfg + +Dual-mode parity tests live under `#[cfg(all(test, feature = "dual-mode"))]`. +The `extern "C"` block declaring the `webarkit_cpp_*` shims it calls +must use the **same** cfg — not just `#[cfg(feature = "dual-mode")]` — +otherwise the lib target sees an extern with no caller and clippy +fires `dead_code` under `--all-features`. + +```rust +// ❌ Don't (extern declared in non-test builds where it has no caller) +#[cfg(feature = "dual-mode")] +extern "C" { + fn webarkit_cpp_foo(...) -> i32; +} + +#[cfg(all(test, feature = "dual-mode"))] +mod dual_mode_tests { + use super::*; + #[test] fn foo_matches_cpp() { unsafe { webarkit_cpp_foo(...) }; } +} + +// ✅ Do (extern and caller share the same gate) +#[cfg(all(test, feature = "dual-mode"))] +extern "C" { + fn webarkit_cpp_foo(...) -> i32; +} +``` + +Reference: PR #185. + +### 7.3 Items must come **above** `#[cfg(test)] mod tests` + +Clippy's `items_after_test_module` fires when `pub fn ...` or any item +appears after the test module. When adding helpers to a file that +already has a `mod tests`, insert them above the `#[cfg(test)]` line. + +Reference: PR #184 (`mat_mul_dff`, `dot_product*`). + +### 7.4 SIMD runtime-dispatch variants: inline `#[allow]` + rationale + +Functions like `get_similarity_sse41` / `get_similarity_avx2` are +runtime-dispatched alternatives to a scalar fallback. Their +signatures are **locked** to match each other and the scalar version. +Prefer inline `#[allow(clippy::too_many_arguments)]` with a +`// rationale:` line over raising the threshold in `clippy.toml` — the +allow scopes to exactly the sites that need it. + +```rust +#[cfg(all(feature = "simd-x86-sse41", target_arch = "x86_64"))] +#[target_feature(enable = "sse4.1")] +// rationale: SIMD variant of get_similarity; signature locked to match +// the scalar fallback and sibling SIMD impl for runtime dispatch via +// is_x86_feature_detected!. +#[allow(clippy::too_many_arguments)] +unsafe fn get_similarity_sse41(...) -> Option { ... } +``` + +Reference: PR #187. + +### 7.5 Auto-generated fixtures: file-scoped `#![allow]` + rationale + +For test files containing constants captured verbatim from external +sources (e.g. C++ baseline output dumped by `kpm_dump_fixtures`), +preserve source-traceability with a file-scoped allow rather than +truncating the literals. + +```rust +//! KPM regression tests — numerical validation against C++ baseline. + +#![allow(dead_code)] +// rationale: SCREEN/WORLD/pose constants below were generated by the +// C++ kpm_dump_fixtures tool. Extra decimal digits document the +// captured C++ output verbatim; truncating to f32-exact would +// round-trip to the same bit pattern but lose traceability (#180). +#![allow(clippy::excessive_precision)] +``` + +Reference: PR #188 (`tests/kpm_regression.rs`). + +### 7.6 Re-run strict clippy after the lib goes clean + +`cargo clippy --all-targets` halts on lib errors before checking +examples/tests/benches. After clearing the last lib lint, **re-run** +the strict command — previously masked lints in non-lib targets will +surface. PR #188 cleaned up 26 such lints unmasked by PR #187. + ## 📝 Documentation & Style - All comments, docstrings, and commit messages must be in **English**. - Use `Result` for error handling instead of `panic!`. diff --git a/Cargo.toml b/Cargo.toml index c00e669..3f4caa2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,7 @@ exclude = [ resolver = "2" [workspace.package] -version = "0.7.0" +version = "0.8.0" authors = ["kalwalt "] edition = "2021" description = "A high-performance, memory-safe Rust port of WebARKitLib (ARToolKit) for native and WASM." diff --git a/README.md b/README.md index 7684391..2e08be4 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,7 @@ ![WebARKitLib-rs](./assets/WebARKitLib-Rust-banner.jpg) [![CI](https://github.com/webarkit/WebARKitLib-rs/actions/workflows/ci.yml/badge.svg)](https://github.com/webarkit/WebARKitLib-rs/actions/workflows/ci.yml) +[![Miri](https://github.com/webarkit/WebARKitLib-rs/actions/workflows/miri.yml/badge.svg)](https://github.com/webarkit/WebARKitLib-rs/actions/workflows/miri.yml) [![codecov](https://codecov.io/gh/webarkit/WebARKitLib-rs/branch/main/graph/badge.svg)](https://codecov.io/gh/webarkit/WebARKitLib-rs) [![Crates.io](https://img.shields.io/crates/v/webarkitlib-rs.svg)](https://crates.io/crates/webarkitlib-rs) [![npm](https://img.shields.io/npm/v/@webarkit/webarkitlib-wasm.svg)](https://www.npmjs.com/package/@webarkit/webarkitlib-wasm) @@ -13,7 +14,7 @@ This project aims to provide a pure-Rust implementation of the core ARToolKit algorithms, targeting both **native** systems and **WebAssembly (WASM)** for high-performance augmented reality in the browser. > [!NOTE] -> This project is currently a **Work in Progress**. Core marker detection and pose estimation are functional. KPM/NFT (Natural Feature Tracking) is in an early stage with partial Rust porting -- a fully idiomatic Rust KPM pipeline with a working example is the primary goal for v1.0.0. +> Core marker detection and pose estimation are functional, and as of **v0.8.0** the **KPM/NFT (Natural Feature Tracking) pipeline is fully ported to pure Rust** — end-to-end keypoint matching, marker generation (`.iset`/`.fset`/`.fset3`), and runtime tracking all run with **no C++ toolchain**, natively and in the browser via WebAssembly. The project remains a **Work in Progress** toward v1.0.0 (broader API docs, multi-marker support, and a dedicated KPM benchmark are the remaining focus areas). ## 🌟 Key Features @@ -39,7 +40,7 @@ Add `webarkitlib-rs` to your `Cargo.toml`: ```toml [dependencies] -webarkitlib-rs = "0.7" +webarkitlib-rs = "0.8" ``` ### Pure Rust tracking (no C++ compiler required) @@ -65,7 +66,7 @@ need to do anything special: ```toml [dependencies] -webarkitlib-rs = "0.7" # default backend is pure Rust +webarkitlib-rs = "0.8" # default backend is pure Rust ``` ```bash @@ -82,7 +83,7 @@ NFT tracking**: ```toml [dependencies] -webarkitlib-rs = { version = "0.7", features = ["ffi-backend"] } +webarkitlib-rs = { version = "0.8", features = ["ffi-backend"] } ``` > When installing from crates.io, no extra setup is required — the C++ @@ -122,42 +123,33 @@ This example loads a camera parameter file, a marker (pattern or barcode), and a Generate NFT (Natural Feature Tracking) marker files compatible with ARnft and NFT-Marker-Creator-App: ```bash -# Pure-Rust default (no C++ toolchain required) — produces .iset + .fset. -# Pass --yes to skip the interactive "no .fset3" confirmation prompt -# (recommended for scripted / CI use): +# Pure Rust, no C++ toolchain — produces a COMPLETE marker (.iset + .fset + .fset3): cargo run --release --example nft_marker_gen -- \ - --input path/to/image.jpg \ - --output path/to/output_name \ - --dpi 220 \ - --yes - -# Full output including .fset3 (FREAK descriptors) — opt-in C++ backend: -# .fset3 generation still uses CppFreakMatcher today; the runtime KPM -# tracker reads .fset3 files in pure Rust, only the marker-creation step -# needs the C++ FREAK extractor. Wiring nft_marker_gen to RustFreakMatcher -# for .fset3 is a follow-up task. -cargo run --release --features ffi-backend --example nft_marker_gen -- \ --input path/to/image.jpg \ --output path/to/output_name \ --dpi 220 # With SIMD acceleration + Rayon parallelism (recommended for x86_64): -cargo run --release --features "ffi-backend,simd-x86-sse41,simd-x86-avx2" \ +cargo run --release --features "simd-x86-sse41,simd-x86-avx2" \ --example nft_marker_gen -- \ --input path/to/image.jpg \ --output path/to/output_name \ --dpi 220 ``` +> Since #179, `.fset3` (FREAK descriptors) is generated by the pure-Rust +> `RustFreakMatcher` — a single invocation produces a complete marker with no +> C++ toolchain. + > **Important:** Always use the `--release` flag. Debug builds are 5–10× slower due to missing compiler optimizations. -Output files (depends on whether `ffi-backend` is enabled): +Output files (all produced on the pure-Rust default): -| File | Default (pure Rust) | With `ffi-backend` | Description | -|---|:---:|:---:|---| -| `.iset` | ✅ | ✅ | JPEG-compressed image pyramid (~300 KB, matches C++ `ar2WriteImageSet()` format) | -| `.fset` | ✅ | ✅ | Feature map with one entry per pyramid level | -| `.fset3` | ❌ (skipped, with warning) | ✅ | FREAK descriptors for KPM-based recognition | +| File | Description | +|---|---| +| `.iset` | JPEG-compressed image pyramid (~300 KB, matches C++ `ar2WriteImageSet()` format) | +| `.fset` | Feature map with one entry per pyramid level | +| `.fset3` | FREAK descriptors for KPM-based recognition (pure-Rust `RustFreakMatcher`) | **Options:** @@ -167,14 +159,12 @@ Output files (depends on whether `ffi-backend` is enabled): | `-o` | `--output` | Output base path (without extension) | required | | `-d` | `--dpi` | Source image resolution in DPI | required | | `-l` | `--level` | Tracking extraction level (0–4) | `2` | -| `-n` | `--search-feature-num` | Max features per pyramid level | `250` | -| `-y` | `--yes` | Skip the "no .fset3" confirmation prompt (for scripted / CI use; only relevant when built without `ffi-backend`) | (interactive) | **Performance feature flags:** | Feature flag | What it enables | |---|---| -| `ffi-backend` | C++ FREAK backend (opt-in since M9-3 #142). Enables `.fset3` generation in `nft_marker_gen` (the `.iset`/`.fset` outputs work on the pure-Rust default) and powers development cross-validation (`dual-mode`, `cross_stack_parity`). Runtime NFT tracking with existing `.fset3` files works on the pure-Rust default. | +| `ffi-backend` | C++ FREAK backend (opt-in since M9-3 #142). Used only for development cross-validation (`dual-mode`, `cross_stack_parity`); all of `nft_marker_gen` (`.iset` + `.fset` + `.fset3`) and runtime NFT tracking run on the pure-Rust default. | | `simd-x86-sse41` | SSE4.1 SIMD acceleration for feature map correlation (`get_similarity`) | | `simd-x86-avx2` | AVX2+FMA SIMD acceleration for feature map correlation (faster than SSE4.1) | | `simd` | Umbrella flag — enables all SIMD optimizations (SSE4.1, AVX2, WASM SIMD, image, pattern) | @@ -258,7 +248,7 @@ Enable the `log-helpers` feature and call the bundled initializer once in your b ```toml [dependencies] -webarkitlib-rs = { version = "0.7", features = ["log-helpers"] } +webarkitlib-rs = { version = "0.8", features = ["log-helpers"] } ``` ```rust @@ -439,12 +429,16 @@ The workspace contains two crates: - Cross-platform / cross-stack matcher determinism — Rust `HashMap` → `BTreeMap` and upstream C++ `unordered_map` → `std::map` ([WebARKitLib#39](https://github.com/webarkit/WebARKitLib/pull/39)). - Hand-annotated absolute corner-error regression gate (browser-based annotation tool + 5 fixtures + Linux CI gate). Finding: pure-Rust backend is more accurate than C++ on `pinball-demo` (5.27 px vs 18.79 px max corner error). - Cross-stack parity gate against `@webarkit/jsartoolkit-nft@1.10.0` ([jsartoolkitNFT#584](https://github.com/webarkit/jsartoolkitNFT/pull/584)) — guarantees native Rust pose matches what production WASM consumers see. +- **v0.8.0 — Pure-Rust completeness, validation & polish**: Closed out the remaining gaps on top of M9: + - **Full WASM/NFT support** ([#161](https://github.com/webarkit/WebARKitLib-rs/issues/161)): `WasmKpmHandle` KPM-detection bindings, `console_log` wiring, clean dual (standard + SIMD) wasm builds, and an end-to-end **browser NFT demo** of the pure-Rust pipeline. + - **Pure-Rust `.fset3` marker generation** ([#179](https://github.com/webarkit/WebARKitLib-rs/issues/179)): `nft_marker_gen` now produces `.iset` + `.fset` + `.fset3` entirely via `RustFreakMatcher` — the `ffi-backend` is no longer needed for marker creation. + - **`VisualDatabase` ergonomics** ([#147](https://github.com/webarkit/WebARKitLib-rs/issues/147), [#148](https://github.com/webarkit/WebARKitLib-rs/issues/148)): factored the per-frame query loop, added `query_from_keyframe`, and facade-parity accessors on `VisualDatabase` + `FeatureStore` slices. + - **Gaussian scale-space pyramid performance** ([#200](https://github.com/webarkit/WebARKitLib-rs/issues/200)/[#201](https://github.com/webarkit/WebARKitLib-rs/issues/201)/[#207](https://github.com/webarkit/WebARKitLib-rs/issues/207)): criterion benchmark, NO-FMA SSE4.1/AVX2/wasm SIMD binomial filter with bit-exact scalar parity, and rayon-parallelized filter passes. + - **Validation & CI hardening**: raised M9 patch coverage ≥90% ([#177](https://github.com/webarkit/WebARKitLib-rs/issues/177)), Miri UB gate ([#182](https://github.com/webarkit/WebARKitLib-rs/issues/182)), strict `--all-targets --all-features` clippy gate ([#180](https://github.com/webarkit/WebARKitLib-rs/issues/180)), benchmark download hardening ([#204](https://github.com/webarkit/WebARKitLib-rs/issues/204)), and macOS/Windows build + cache fixes ([#134](https://github.com/webarkit/WebARKitLib-rs/issues/134), [#119](https://github.com/webarkit/WebARKitLib-rs/issues/119)). ### 🎯 Short-term Goals (toward v1.0.0) - **KPM-specific benchmark** ([deferred from #142](https://github.com/webarkit/WebARKitLib-rs/issues/142)): Add a dedicated `kpm_bench.rs` Criterion bench so we can verify the pure-Rust backend stays within 20% of C++ on `pinball-demo`. The existing `marker_bench` only measures barcode marker detection. -- **WASM browser examples for KPM/NFT** ([#161](https://github.com/webarkit/WebARKitLib-rs/issues/161)): End-to-end runnable browser demo of the pure-Rust NFT pipeline. -- **Raise M9 patch coverage** ([#177](https://github.com/webarkit/WebARKitLib-rs/issues/177)): Lift M9 modules from 84.76% → ≥90% patch coverage with no file under 85%. -- **Dependency upgrades**: Criterion 0.5 → 0.8 ([#174](https://github.com/webarkit/WebARKitLib-rs/issues/174)). +- **Live-camera WASM demo + pose parity** ([#215](https://github.com/webarkit/WebARKitLib-rs/issues/215)): Extend the browser NFT demo with a live camera feed and jsartoolkitNFT pose parity. - **Enhanced Documentation**: Expand API reference with complete module-level docs, integration walkthroughs for JS/TS, and detailed usage examples. - **WASM Memory Management**: Improve resource cleanup when switching engines or markers in long-running browser sessions. diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000..9150d44 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,33 @@ +# Codecov configuration for WebARKitLib-rs. +# +# Coverage scope: the pure-Rust library (`crates/core/src/`). Examples, +# benches, and integration tests are NOT instrumented by the coverage +# runner (tarpaulin uses `--exclude-files "**/tests/*"` in +# .github/workflows/coverage.yml; examples/benches are excluded by +# convention — they exist to demonstrate usage and measure performance, +# not to be exercised for line coverage). +# +# The `ignore` list below mirrors tarpaulin's exclusions on the codecov +# side so PRs that happen to land entirely in uninstrumented code don't +# fail `codecov/patch` at 0% diff hit. +# +# Surfaced during #180 PR 4.5: a cleanup PR touching examples/, benches/, +# and tests/ failed codecov/patch even though the library was unchanged. + +coverage: + status: + project: + default: + target: auto + threshold: 1% + patch: + default: + target: auto + threshold: 1% + +ignore: + - "crates/core/examples/**" + - "crates/core/benches/**" + - "crates/core/tests/**" + - "crates/wasm/**" + - "benchmarks/**" diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index cc31add..307ec53 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -55,8 +55,10 @@ env_logger = { version = "0.11", optional = true } console_log = { version = "1", optional = true } [build-dependencies] -cc = "1" -bindgen = "0.72.1" +# Only needed for the C++ FFI backend; gated behind `ffi-backend` so the +# default (pure-Rust, incl. wasm32) build pulls no C/C++ toolchain crates. +cc = { version = "1", optional = true } +bindgen = { version = "0.72.1", optional = true } [features] # Default features intentionally empty (M9-3, #142): a plain `cargo build` @@ -70,7 +72,7 @@ simd-pattern = [] simd-wasm32 = [] simd-x86-sse41 = [] simd-x86-avx2 = [] -ffi-backend = [] +ffi-backend = ["dep:cc", "dep:bindgen"] # dual-mode validates pure-Rust implementations against the C++ baseline; # requires ffi-backend so the C++ comparison functions are compiled in. dual-mode = ["ffi-backend"] @@ -80,7 +82,7 @@ log-helpers = ["dep:env_logger", "dep:console_log"] [dev-dependencies] chrono = "0.4" env_logger = "0.11" -criterion = "0.5.1" +criterion = "0.8" clap = { version = "4.5", features = ["derive"] } tempfile = "3" # Used by the absolute corner-error integration test (#166) to parse @@ -153,3 +155,11 @@ required-features = ["simd-x86-sse41"] name = "feature_map_bench" harness = false required-features = ["log-helpers"] + +[[bench]] +name = "pyramid_bench" +harness = false + +[[bench]] +name = "gaussian_pyramid_bench" +harness = false diff --git a/crates/core/benches/BENCHMARKS.md b/crates/core/benches/BENCHMARKS.md index fdf0883..d57f275 100644 --- a/crates/core/benches/BENCHMARKS.md +++ b/crates/core/benches/BENCHMARKS.md @@ -1,6 +1,6 @@ # WebARKitLib.rs Core Benchmarks -**Last Updated**: 2026-06-04 (M9-3 — #142) +**Last Updated**: 2026-06-10 (chore: criterion 0.5.1 → 0.8 — #174) This document tracks the performance of critical image processing and pattern matching functions in the `webarkitlib_rs` core crate. @@ -8,16 +8,16 @@ This document tracks the performance of critical image processing and pattern ma The following benchmarks were conducted on an x86_64 system with SSE4.1 support enabled via the `simd` feature. -| Function | Implementation | Average Time | Speedup | +| Function | Implementation | Median Time | Speedup | | :--- | :--- | :--- | :--- | -| `rgba_to_gray` | Scalar | 550.68 µs | - | -| | SIMD (SSE4.1) | 232.72 µs | **2.37x** | -| `dot_product` | Scalar | 347.85 ns | - | -| | SIMD (SSE4.1) | 54.56 ns | **6.44x** | -| `box_filter_h` | Scalar | 1.731 ms | - | -| | SIMD (SSE4.1) | 1.734 ms | 1.00x | -| `box_filter_v` | Scalar | 2.590 ms | - | -| | SIMD (SSE4.1) | 886.84 µs | **2.92x** | +| `rgba_to_gray` | Scalar | 629.57 µs | - | +| | SIMD (SSE4.1) | 238.42 µs | **2.64x** | +| `dot_product` | Scalar | 326.45 ns | - | +| | SIMD (SSE4.1) | 60.24 ns | **5.42x** | +| `box_filter_h` | Scalar | 1.8386 ms | - | +| | SIMD (SSE4.1) | 1.7245 ms | 1.07x | +| `box_filter_v` | Scalar | 2.3240 ms | - | +| | SIMD (SSE4.1) | 765.77 µs | **3.03x** | ### Analysis @@ -41,10 +41,12 @@ cargo bench --bench simd_bench ## Setup Details -- **Tooling**: [Criterion.rs](https://github.com/bheisler/criterion.rs) +- **Tooling**: [Criterion.rs](https://github.com/bheisler/criterion.rs) `0.8` - **Target OS**: Windows - **Target Architecture**: x86_64 (SSE4.1) - **Frame Size**: 640x480 (typical AR video resolution) +- **Toolchain**: `rustc 1.94.0 (stable)` +- **SIMD activation**: SSE4.1 intrinsics are gated by `#[cfg(target_feature = "sse4.1")]` — set `RUSTFLAGS="-C target-feature=+sse4.1"` (or `target-cpu=native`) when running `simd_bench` to exercise the SIMD paths. ## KPM / NFT performance (M9-3 status) diff --git a/crates/core/benches/feature_map_bench.rs b/crates/core/benches/feature_map_bench.rs index d19afd6..f6edc85 100644 --- a/crates/core/benches/feature_map_bench.rs +++ b/crates/core/benches/feature_map_bench.rs @@ -43,7 +43,8 @@ //! would make each iteration multi-minute. We target a realistic-but-bounded //! working size that still stresses the scoring loop. -use criterion::{black_box, criterion_group, criterion_main, Criterion}; +use criterion::{criterion_group, criterion_main, Criterion}; +use std::hint::black_box; use std::path::PathBuf; use webarkitlib_rs::ar2::{ar2_gen_feature_map, ar2_gen_image_set}; use webarkitlib_rs::arlog_e; diff --git a/crates/core/benches/gaussian_pyramid_bench.rs b/crates/core/benches/gaussian_pyramid_bench.rs new file mode 100644 index 0000000..451b7b0 --- /dev/null +++ b/crates/core/benches/gaussian_pyramid_bench.rs @@ -0,0 +1,187 @@ +/* + * gaussian_pyramid_bench.rs + * WebARKitLib-rs + * + * This file is part of WebARKitLib-rs - WebARKit. + * + * WebARKitLib-rs is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * WebARKitLib-rs is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with WebARKitLib-rs. If not, see . + * + * As a special exception, the copyright holders of this library give you + * permission to link this library with independent modules to produce an + * executable, regardless of the license terms of these independent modules, and to + * copy and distribute the resulting executable under terms of your choice, + * provided that you also meet, for each linked independent module, the terms and + * conditions of the license of that module. An independent module is a module + * which is neither derived from nor based on this library. If you modify this + * library, you may extend this exception to your version of the library, but you + * are not obligated to do so. If you do not wish to do so, delete this exception + * statement from your version. + * + * Copyright 2026 WebARKit. + * + * Author(s): Walter Perdan @kalwalt https://github.com/kalwalt + * + */ + +//! Criterion baseline for the **Gaussian scale-space pyramid** (#200) — the +//! pyramid the KPM/FREAK detector actually uses (via +//! `DoGScaleInvariantDetector`). +//! +//! Establishes the scalar baseline so the SIMD work can prove its speedup: +//! the binomial 5-tap filter (#201) and the bilinear downsample (#202). +//! +//! Groups, each at 640×480, 1280×720 and 1920×1080: +//! +//! - `gaussian_binomial_u8`: `binomial_4th_order_u8_to_f32_scalar` (octave 0). +//! - `gaussian_binomial_f32`: `binomial_4th_order_f32_to_f32_scalar` (the +//! dominant per-octave cost — runs 3× per octave). +//! - `gaussian_bilinear`: `downsample_bilinear_f32_scalar` (between octaves). +//! - `gaussian_build`: end-to-end `GaussianScaleSpacePyramid::build` with +//! `num_octaves` from `num_octaves_for(w, h, 8)`, matching the real KPM +//! backend (`rust_backend.rs`). +//! +//! Inputs are filled deterministically with a fixed-seed PRNG so runs are +//! comparable across machines and across PRs in the series. + +use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion, Throughput}; +use purecv::core::Matrix; +use rand::rngs::StdRng; +use rand::{Rng, SeedableRng}; +use std::hint::black_box; +use webarkitlib_rs::kpm::freak::gaussian_pyramid::{ + binomial_4th_order_f32_to_f32_scalar, binomial_4th_order_u8_to_f32_scalar, + downsample_bilinear_f32_scalar, +}; +use webarkitlib_rs::kpm::freak::{num_octaves_for, GaussianScaleSpacePyramid}; + +#[cfg(not(target_arch = "wasm32"))] +use webarkitlib_rs::kpm::freak::gaussian_pyramid::{ + binomial_4th_order_f32_to_f32_rayon, binomial_4th_order_u8_to_f32_rayon, + downsample_bilinear_f32_rayon, +}; + +/// Typical AR camera input resolutions as `(width, height)`. +const SIZES: &[(usize, usize)] = &[(640, 480), (1280, 720), (1920, 1080)]; + +/// `min_size` for `num_octaves_for`, matching the real KPM backend. +const MIN_COARSE_SIZE: usize = 8; + +/// Deterministic grayscale `Matrix` (`rows × cols`), fixed-seed PRNG. +fn random_u8(rows: usize, cols: usize) -> Matrix { + let mut rng = StdRng::seed_from_u64(0x6A05_51A2); + let data: Vec = (0..rows * cols).map(|_| rng.random::()).collect(); + Matrix::::from_vec(rows, cols, 1, data) +} + +/// Deterministic `Matrix` with values in `[0, 256)`, fixed-seed PRNG. +fn random_f32(rows: usize, cols: usize) -> Matrix { + let mut rng = StdRng::seed_from_u64(0x6A05_51A3); + let data: Vec = (0..rows * cols) + .map(|_| rng.random::() as f32) + .collect(); + Matrix::::from_vec(rows, cols, 1, data) +} + +fn bench_binomial_u8(c: &mut Criterion) { + let mut group = c.benchmark_group("gaussian_binomial_u8"); + for &(w, h) in SIZES { + let src = random_u8(h, w); + group.throughput(Throughput::Elements((w * h) as u64)); + group.bench_with_input( + BenchmarkId::new("scalar", format!("{w}x{h}")), + &src, + |bencher, src| bencher.iter(|| binomial_4th_order_u8_to_f32_scalar(black_box(src))), + ); + + #[cfg(not(target_arch = "wasm32"))] + group.bench_with_input( + BenchmarkId::new("rayon", format!("{w}x{h}")), + &src, + |bencher, src| bencher.iter(|| binomial_4th_order_u8_to_f32_rayon(black_box(src))), + ); + } + group.finish(); +} + +fn bench_binomial_f32(c: &mut Criterion) { + let mut group = c.benchmark_group("gaussian_binomial_f32"); + for &(w, h) in SIZES { + let src = random_f32(h, w); + group.throughput(Throughput::Elements((w * h) as u64)); + group.bench_with_input( + BenchmarkId::new("scalar", format!("{w}x{h}")), + &src, + |bencher, src| bencher.iter(|| binomial_4th_order_f32_to_f32_scalar(black_box(src))), + ); + + #[cfg(not(target_arch = "wasm32"))] + group.bench_with_input( + BenchmarkId::new("rayon", format!("{w}x{h}")), + &src, + |bencher, src| bencher.iter(|| binomial_4th_order_f32_to_f32_rayon(black_box(src))), + ); + } + group.finish(); +} + +fn bench_bilinear(c: &mut Criterion) { + let mut group = c.benchmark_group("gaussian_bilinear"); + for &(w, h) in SIZES { + let src = random_f32(h, w); + group.throughput(Throughput::Elements((w * h) as u64)); + group.bench_with_input( + BenchmarkId::new("scalar", format!("{w}x{h}")), + &src, + |bencher, src| bencher.iter(|| downsample_bilinear_f32_scalar(black_box(src))), + ); + + #[cfg(not(target_arch = "wasm32"))] + group.bench_with_input( + BenchmarkId::new("rayon", format!("{w}x{h}")), + &src, + |bencher, src| bencher.iter(|| downsample_bilinear_f32_rayon(black_box(src))), + ); + } + group.finish(); +} + +fn bench_build(c: &mut Criterion) { + let mut group = c.benchmark_group("gaussian_build"); + for &(w, h) in SIZES { + let src = random_u8(h, w); + let n_oct = num_octaves_for(w, h, MIN_COARSE_SIZE); + group.throughput(Throughput::Elements((w * h) as u64)); + group.bench_with_input( + BenchmarkId::new("scalar", format!("{w}x{h}")), + &src, + |bencher, src| { + bencher.iter(|| { + let mut p = GaussianScaleSpacePyramid::new(n_oct); + p.build(black_box(src)).expect("build should succeed"); + p + }) + }, + ); + } + group.finish(); +} + +criterion_group!( + benches, + bench_binomial_u8, + bench_binomial_f32, + bench_bilinear, + bench_build +); +criterion_main!(benches); diff --git a/crates/core/benches/marker_bench.rs b/crates/core/benches/marker_bench.rs index 3b2c3eb..4c0f07c 100644 --- a/crates/core/benches/marker_bench.rs +++ b/crates/core/benches/marker_bench.rs @@ -34,8 +34,9 @@ * */ -use criterion::{black_box, criterion_group, criterion_main, Criterion}; +use criterion::{criterion_group, criterion_main, Criterion}; use std::fs::File; +use std::hint::black_box; use std::io::Read; use webarkitlib_rs::{ marker::ar_detect_marker, @@ -70,12 +71,14 @@ fn marker_detection_benchmark(c: &mut Criterion) { param_ltf, }); - let mut ar_handle = ARHandle::default(); - ar_handle.xsize = width; - ar_handle.ysize = height; + let mut ar_handle = ARHandle { + xsize: width, + ysize: height, + ar_labeling_thresh: 100, // Standard threshold + ar_param_lt: &mut *param_lt, + ..Default::default() + }; ar_handle.set_pixel_format(ARPixelFormat::MONO); - ar_handle.ar_labeling_thresh = 100; // Standard threshold - ar_handle.ar_param_lt = &mut *param_lt; let mut patt_handle = webarkitlib_rs::types::ARPattHandle::new(16, 50); ar_patt_load(&mut patt_handle, patt_path).expect("Failed to load pattern"); @@ -96,7 +99,7 @@ fn marker_detection_benchmark(c: &mut Criterion) { c.bench_function("ar_full_pipeline_429x317", |b| { b.iter(|| { - let _ = ar_detect_marker(black_box(&mut ar_handle), black_box(&frame)).unwrap(); + ar_detect_marker(black_box(&mut ar_handle), black_box(&frame)).unwrap(); if ar_handle.marker_num > 0 { let marker_info = &ar_handle.marker_info[0]; let mut trans = [[0.0; 4]; 3]; diff --git a/crates/core/benches/pyramid_bench.rs b/crates/core/benches/pyramid_bench.rs new file mode 100644 index 0000000..3b479a9 --- /dev/null +++ b/crates/core/benches/pyramid_bench.rs @@ -0,0 +1,131 @@ +/* + * pyramid_bench.rs + * WebARKitLib-rs + * + * This file is part of WebARKitLib-rs - WebARKit. + * + * WebARKitLib-rs is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * WebARKitLib-rs is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with WebARKitLib-rs. If not, see . + * + * As a special exception, the copyright holders of this library give you + * permission to link this library with independent modules to produce an + * executable, regardless of the license terms of these independent modules, and to + * copy and distribute the resulting executable under terms of your choice, + * provided that you also meet, for each linked independent module, the terms and + * conditions of the license of that module. An independent module is a module + * which is neither derived from nor based on this library. If you modify this + * library, you may extend this exception to your version of the library, but you + * are not obligated to do so. If you do not wish to do so, delete this exception + * statement from your version. + * + * Copyright 2026 WebARKit. + * + * Author(s): Walter Perdan @kalwalt https://github.com/kalwalt + * + */ + +//! Criterion baseline for `kpm::freak::pyramid` downsampling (#131). +//! +//! Establishes the scalar baseline so the SIMD path (#132) and any +//! chunked-layout work (#133) can prove their speedup against stable +//! numbers. Two groups are measured at typical AR input resolutions +//! (640×480, 1280×720, 1920×1080): +//! +//! - `downsample`: one 2×2 box-filter decimation step (the hot inner loop). +//! - `pyramid_build`: end-to-end `Pyramid::build` with `num_levels = 4`. +//! +//! Inputs are filled deterministically with a fixed-seed PRNG so runs are +//! comparable across machines and across PRs in the trilogy. + +use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion, Throughput}; +use purecv::core::Matrix; +use rand::rngs::StdRng; +use rand::{Rng, SeedableRng}; +use std::hint::black_box; +use webarkitlib_rs::kpm::freak::pyramid::{downsample_scalar, Pyramid}; + +#[cfg(all(target_arch = "x86_64", feature = "simd-x86-sse41"))] +use webarkitlib_rs::kpm::freak::pyramid::downsample_sse41; + +#[cfg(all(target_arch = "x86_64", feature = "simd-x86-avx2"))] +use webarkitlib_rs::kpm::freak::pyramid::downsample_avx2; + +/// Typical AR camera input resolutions as `(width, height)`. +const SIZES: &[(usize, usize)] = &[(640, 480), (1280, 720), (1920, 1080)]; + +/// Build a deterministic grayscale `Matrix` of `rows × cols` using a +/// fixed-seed PRNG, so every benchmark run sees identical pixel data. +fn random_gray(rows: usize, cols: usize) -> Matrix { + let mut rng = StdRng::seed_from_u64(0xA12B_C3D4); + let data: Vec = (0..rows * cols).map(|_| rng.random::()).collect(); + Matrix::::from_vec(rows, cols, 1, data) +} + +fn bench_downsample(c: &mut Criterion) { + let mut group = c.benchmark_group("downsample"); + for &(w, h) in SIZES { + let src = random_gray(h, w); + // One output pixel per 2×2 source block; throughput is measured in + // source pixels touched. + group.throughput(Throughput::Elements((w * h) as u64)); + group.bench_with_input( + BenchmarkId::new("scalar", format!("{w}x{h}")), + &src, + |bencher, src| bencher.iter(|| downsample_scalar(black_box(src))), + ); + + #[cfg(all(target_arch = "x86_64", feature = "simd-x86-sse41"))] + if is_x86_feature_detected!("sse4.1") { + group.bench_with_input( + BenchmarkId::new("sse41", format!("{w}x{h}")), + &src, + // SAFETY: sse4.1 confirmed available by the runtime check above. + |bencher, src| bencher.iter(|| unsafe { downsample_sse41(black_box(src)) }), + ); + } + + #[cfg(all(target_arch = "x86_64", feature = "simd-x86-avx2"))] + if is_x86_feature_detected!("avx2") { + group.bench_with_input( + BenchmarkId::new("avx2", format!("{w}x{h}")), + &src, + // SAFETY: avx2 confirmed available by the runtime check above. + |bencher, src| bencher.iter(|| unsafe { downsample_avx2(black_box(src)) }), + ); + } + } + group.finish(); +} + +fn bench_pyramid_build(c: &mut Criterion) { + let mut group = c.benchmark_group("pyramid_build"); + for &(w, h) in SIZES { + let src = random_gray(h, w); + group.throughput(Throughput::Elements((w * h) as u64)); + group.bench_with_input( + BenchmarkId::new("levels4", format!("{w}x{h}")), + &src, + |bencher, src| { + bencher.iter(|| { + let mut p = Pyramid::new(4, 2.0); + p.build(black_box(src)).expect("build should succeed"); + p + }) + }, + ); + } + group.finish(); +} + +criterion_group!(benches, bench_downsample, bench_pyramid_build); +criterion_main!(benches); diff --git a/crates/core/benches/simd_bench.rs b/crates/core/benches/simd_bench.rs index 193d564..e8d94a9 100644 --- a/crates/core/benches/simd_bench.rs +++ b/crates/core/benches/simd_bench.rs @@ -34,7 +34,8 @@ * */ -use criterion::{black_box, criterion_group, criterion_main, Criterion}; +use criterion::{criterion_group, criterion_main, Criterion}; +use std::hint::black_box; use webarkitlib_rs::image_proc::{box_filter_h_scalar, box_filter_v_scalar, rgba_to_gray_scalar}; use webarkitlib_rs::pattern::dot_product_scalar; diff --git a/crates/core/build.rs b/crates/core/build.rs index 9e8e37f..e26322d 100644 --- a/crates/core/build.rs +++ b/crates/core/build.rs @@ -34,16 +34,23 @@ * */ +#[cfg(feature = "ffi-backend")] use std::env; +#[cfg(feature = "ffi-backend")] use std::path::PathBuf; fn main() { - if cfg!(feature = "ffi-backend") { + // `cc` / `bindgen` are optional build-dependencies enabled only by the + // `ffi-backend` feature, so the default (pure-Rust, incl. wasm) build pulls + // no C/C++ toolchain crates. The helpers below are gated to match. + #[cfg(feature = "ffi-backend")] + { build_freak_matcher(); generate_bindings(); } } +#[cfg(feature = "ffi-backend")] fn build_freak_matcher() { let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()); @@ -160,6 +167,7 @@ fn build_freak_matcher() { println!("cargo:rerun-if-changed=src/kpm/kpm_c_api.h"); } +#[cfg(feature = "ffi-backend")] fn generate_bindings() { let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()); let header = manifest_dir.join("src").join("kpm").join("kpm_c_api.h"); diff --git a/crates/core/examples/barcode.rs b/crates/core/examples/barcode.rs index ae7733e..8f1f1a9 100644 --- a/crates/core/examples/barcode.rs +++ b/crates/core/examples/barcode.rs @@ -132,20 +132,24 @@ fn main() -> Result<(), Box> { for thresh in thresholds { arlog_i!("--- Testing BlackRegion, Threshold: {} ---", thresh); - let mut ar_handle = ARHandle::default(); - ar_handle.xsize = width; - ar_handle.ysize = height; - ar_handle.ar_debug = 1; + let mut ar_handle = ARHandle { + xsize: width, + ysize: height, + ar_debug: 1, + ar_labeling_thresh: thresh, + ar_labeling_mode: 0, // BlackRegion + ..Default::default() + }; ar_handle.set_pixel_format(ARPixelFormat::RGB); - ar_handle.ar_labeling_thresh = thresh; - ar_handle.ar_labeling_mode = 0; // BlackRegion ar_handle.set_pattern_detection_mode(AR_MATRIX_CODE_DETECTION); ar_handle.set_matrix_code_type(code_type); - let mut param_ltf = ARParamLTf::default(); - param_ltf.xsize = width; - param_ltf.ysize = height; - param_ltf.o2i = vec![0.0; (width * height * 2) as usize]; + let mut param_ltf = ARParamLTf { + xsize: width, + ysize: height, + o2i: vec![0.0; (width * height * 2) as usize], + ..Default::default() + }; for y in 0..height { for x in 0..width { let idx = ((y * width + x) * 2) as usize; @@ -168,7 +172,7 @@ fn main() -> Result<(), Box> { time: AR2VideoTimestampT { sec: 1, usec: 0 }, }; - if let Ok(_) = ar_detect_marker(&mut ar_handle, &frame) { + if ar_detect_marker(&mut ar_handle, &frame).is_ok() { arlog_i!("Found {} square candidates.", ar_handle.marker2_num); arlog_i!("Found {} valid barcode markers.", ar_handle.marker_num); let mut found_valid = false; diff --git a/crates/core/examples/debug_labeling.rs b/crates/core/examples/debug_labeling.rs index 9dbd343..6de1d75 100644 --- a/crates/core/examples/debug_labeling.rs +++ b/crates/core/examples/debug_labeling.rs @@ -32,7 +32,7 @@ fn main() -> Result<(), Box> { arlog_i!("Loading image {}...", final_image_path.display()); let full_img = ImageReader::open(&final_image_path) - .expect(&format!("Failed to open {}", final_image_path.display())) + .unwrap_or_else(|_| panic!("Failed to open {}", final_image_path.display())) .decode() .expect("Failed to decode image"); let luma_img = full_img.to_luma8(); diff --git a/crates/core/examples/dump_patt.rs b/crates/core/examples/dump_patt.rs index 1cf74d2..71bc971 100644 --- a/crates/core/examples/dump_patt.rs +++ b/crates/core/examples/dump_patt.rs @@ -112,10 +112,12 @@ fn main() { arlog_i!("Otsu threshold: {}", otsu_thresh); // Identity LT (skip lens distortion correction for the diagnostic). - let mut param_ltf = ARParamLTf::default(); - param_ltf.xsize = width; - param_ltf.ysize = height; - param_ltf.o2i = vec![0.0; (width * height * 2) as usize]; + let mut param_ltf = ARParamLTf { + xsize: width, + ysize: height, + o2i: vec![0.0; (width * height * 2) as usize], + ..Default::default() + }; for y in 0..height { for x in 0..width { let idx = ((y * width + x) * 2) as usize; @@ -125,26 +127,30 @@ fn main() { } let mut param_lt = Box::new(ARParamLT { param, param_ltf }); - let mut ar_handle = ARHandle::default(); - ar_handle.xsize = width; - ar_handle.ysize = height; + let mut ar_handle = ARHandle { + xsize: width, + ysize: height, + ar_image_proc_mode: 0, // FrameImage + ar_pattern_detection_mode: AR_TEMPLATE_MATCHING_COLOR, + ar_labeling_mode: 0, + ar_labeling_thresh: otsu_thresh as i32, + patt_ratio: 0.5, + matrix_code_type: ARMatrixCodeType::Code3x3, + ar_param_lt: &mut *param_lt, + ..Default::default() + }; ar_handle.set_pixel_format(ARPixelFormat::MONO); - ar_handle.ar_image_proc_mode = 0; // FrameImage - ar_handle.ar_pattern_detection_mode = AR_TEMPLATE_MATCHING_COLOR; - ar_handle.ar_labeling_mode = 0; - ar_handle.ar_labeling_thresh = otsu_thresh as i32; - ar_handle.patt_ratio = 0.5; - ar_handle.matrix_code_type = ARMatrixCodeType::Code3x3; - ar_handle.ar_param_lt = &mut *param_lt; - let mut patt_handle = webarkitlib_rs::types::ARPattHandle::default(); - patt_handle.patt_num_max = 50; - patt_handle.patt_size = 16; - patt_handle.pattf = vec![0; 50]; - patt_handle.patt = vec![vec![0; 16 * 16 * 3 * 4]; 50]; - patt_handle.pattpow = vec![0.0; 50 * 4]; - patt_handle.patt_bw = vec![vec![0; 16 * 16 * 4]; 50]; - patt_handle.pattpow_bw = vec![0.0; 50 * 4]; + let mut patt_handle = webarkitlib_rs::types::ARPattHandle { + patt_num_max: 50, + patt_size: 16, + pattf: vec![0; 50], + patt: vec![vec![0; 16 * 16 * 3 * 4]; 50], + pattpow: vec![0.0; 50 * 4], + patt_bw: vec![vec![0; 16 * 16 * 4]; 50], + pattpow_bw: vec![0.0; 50 * 4], + ..Default::default() + }; arlog_i!("Loading hiro pattern from {}", patt_path); if let Err(e) = ar_patt_load(&mut patt_handle, &patt_path) { @@ -244,8 +250,7 @@ fn main() { "selected = {} (of {} markers)\n", sel, ar_handle.marker_num )); - for i in 0..4 { - let idx = vertex_indices[i]; + for (i, &idx) in vertex_indices.iter().enumerate() { meta.push_str(&format!( "vertex[{}] = ({}, {})\n", i, info2.x_coord[idx], info2.y_coord[idx] diff --git a/crates/core/examples/generate_patt.rs b/crates/core/examples/generate_patt.rs index a107993..93d02be 100644 --- a/crates/core/examples/generate_patt.rs +++ b/crates/core/examples/generate_patt.rs @@ -128,10 +128,11 @@ fn default_paths() -> (String, String) { ) } -fn run_once( - cfg: &Config, - suffix: &str, -) -> Result<(usize, u8, u8, f64, Option, Option, Option), String> { +/// Per-run report: `(num_detected, otsu_thresh, otsu_thresh_inv, +/// mean_abs_diff, mean_abs_diff_inv, vert_flip_idx, vert_flip_used)`. +type RunReport = (usize, u8, u8, f64, Option, Option, Option); + +fn run_once(cfg: &Config, suffix: &str) -> Result { // Load image let image = ImageReader::open(&cfg.input) .map_err(|e| format!("open image: {}", e))? @@ -339,13 +340,15 @@ fn run_once( ); } - let mut marker = ARMarkerInfo::default(); - marker.vertex = [ - [minx as f64, miny as f64], - [maxx as f64, miny as f64], - [maxx as f64, maxy as f64], - [minx as f64, maxy as f64], - ]; + let marker = ARMarkerInfo { + vertex: [ + [minx as f64, miny as f64], + [maxx as f64, miny as f64], + [maxx as f64, maxy as f64], + [minx as f64, maxy as f64], + ], + ..Default::default() + }; if cfg.debug { arlog_i!("Debug: marker vertices: {:?}", marker.vertex); } @@ -373,15 +376,15 @@ fn run_once( let _ = overlay_img.save(&overlay_path); // extract pattern image - let mut ext_patt = vec![0u8; (cfg.patt_size * cfg.patt_size * 3) as usize]; + let mut ext_patt = vec![0u8; cfg.patt_size * cfg.patt_size * 3]; if cfg.debug { arlog_i!("Debug: calling ar_patt_get_image2 with patt_size={} sample_size={} xsize={} ysize={} pixel_format={:?}", cfg.patt_size, cfg.patt_size * cfg.sample_factor, xsize, ysize, pixel_format); } if let Err(e) = ar_patt_get_image2( 0, ARPixelFormat::RGB as i32, - cfg.patt_size as usize, - (cfg.patt_size * cfg.sample_factor) as usize, + cfg.patt_size, + cfg.patt_size * cfg.sample_factor, &img, xsize, ysize, @@ -402,9 +405,9 @@ fn run_once( // save extracted PNG let mut out_img = image::RgbImage::new(cfg.patt_size as u32, cfg.patt_size as u32); - for y in 0..cfg.patt_size as usize { - for x in 0..cfg.patt_size as usize { - let idx = (y * cfg.patt_size as usize + x) * 3; + for y in 0..cfg.patt_size { + for x in 0..cfg.patt_size { + let idx = (y * cfg.patt_size + x) * 3; let b = ext_patt[idx]; let g = ext_patt[idx + 1]; let r = ext_patt[idx + 2]; @@ -433,7 +436,7 @@ fn run_once( 0, &marker, patt_ratio, - cfg.patt_size as usize, + cfg.patt_size, &patt_path, ) .map_err(|e| format!("ar_patt_save failed: {}", e))?; @@ -530,7 +533,7 @@ fn parse_patt_blocks(content: &str, patt_size: usize) -> Result>, St if tokens.len() == block_len { return Ok(vec![tokens]); } - if tokens.len() % block_len != 0 { + if !tokens.len().is_multiple_of(block_len) { return Err(format!( "unexpected token count {} (not multiple of block_len {})", tokens.len(), @@ -541,7 +544,7 @@ fn parse_patt_blocks(content: &str, patt_size: usize) -> Result>, St Ok(blocks) } -fn mean_abs_diff_u8(a: &Vec, b: &Vec) -> f64 { +fn mean_abs_diff_u8(a: &[u8], b: &[u8]) -> f64 { if a.len() != b.len() { return f64::INFINITY; } @@ -552,7 +555,7 @@ fn mean_abs_diff_u8(a: &Vec, b: &Vec) -> f64 { s / (a.len() as f64) } -fn flip_vert_u8(buf: &Vec, patt_size: usize) -> Vec { +fn flip_vert_u8(buf: &[u8], patt_size: usize) -> Vec { let mut out = vec![0u8; buf.len()]; let row_bytes = patt_size * 3; for y in 0..patt_size { @@ -670,10 +673,9 @@ fn main() { let patt = format!("generated{}.patt", suffix); let ref_mean_str: String = ref_mean .map(|v| format!("{:.2}", v)) - .unwrap_or_else(|| String::new()); - let ref_idx_str: String = ref_idx - .map(|i| i.to_string()) - .unwrap_or_else(|| String::new()); + .unwrap_or_else(String::new); + let ref_idx_str: String = + ref_idx.map(|i| i.to_string()).unwrap_or_else(String::new); let ref_flip_str: String = ref_flip .map(|b| { if b { @@ -682,7 +684,7 @@ fn main() { String::from("0") } }) - .unwrap_or_else(|| String::new()); + .unwrap_or_else(String::new); writeln!( csv, "{},{},{},{},{},{},{},{},{:.2},{},{},{},{},{}", diff --git a/crates/core/examples/nft_marker_gen.rs b/crates/core/examples/nft_marker_gen.rs index f0c43aa..2d581de 100644 --- a/crates/core/examples/nft_marker_gen.rs +++ b/crates/core/examples/nft_marker_gen.rs @@ -37,17 +37,14 @@ //! NFT marker creation example. //! //! A pure-Rust replacement for the C/Emscripten `markerCreator` tool. -//! Generates `.iset`, `.fset`, and (with `--features ffi-backend`) `.fset3` -//! from a JPEG input image. +//! Generates a complete NFT marker (`.iset` + `.fset` + `.fset3`) from a JPEG +//! input image with no C++ toolchain (#179 — `.fset3` now uses the pure-Rust +//! `RustFreakMatcher`). //! //! ## Usage //! //! ```bash -//! # Pure-Rust output (.iset + .fset only): -//! cargo run --example nft_marker_gen -- --input marker.jpg --output marker --dpi 72 -//! -//! # Full output including .fset3 (requires C++ FREAK backend): -//! cargo run --example nft_marker_gen --features ffi-backend -- \ +//! cargo run --release --example nft_marker_gen -- \ //! --input marker.jpg --output marker --dpi 72 //! ``` //! @@ -55,25 +52,20 @@ //! //! | File | Contents | //! |---|---| -//! | `.iset` | Multi-resolution image pyramid (always generated) | -//! | `.fset` | AR2 feature points per scale (always generated) | -//! | `.fset3` | KPM/FREAK keypoints (`ffi-backend` only) | +//! | `.iset` | Multi-resolution image pyramid | +//! | `.fset` | AR2 feature points per scale | +//! | `.fset3` | KPM/FREAK keypoints (pure-Rust `RustFreakMatcher`) | use chrono::Local; use clap::Parser; use std::path::Path; use std::time::Instant; use webarkitlib_rs::ar2::{ar2_gen_feature_map, ar2_gen_image_set}; -#[cfg(not(feature = "ffi-backend"))] -use webarkitlib_rs::arlog_rel; -#[cfg(feature = "ffi-backend")] -use webarkitlib_rs::arlog_w; -use webarkitlib_rs::{arlog_e, arlog_i}; +use webarkitlib_rs::{arlog_e, arlog_i, arlog_w}; /// KPM feature density for `.fset3` generation (default extraction level 1). /// /// Matches `KPM_SURF_FEATURE_DENSITY_L1` in `markerCreator.cpp`. -#[cfg(feature = "ffi-backend")] const KPM_SURF_FEATURE_DENSITY: i32 = 100; // --------------------------------------------------------------------------- @@ -110,10 +102,6 @@ struct Cli { /// Matches the `-level=n` flag in the C `markerCreator` tool. #[arg(long, default_value_t = 2)] level: u8, - - /// Skip the "no .fset3" confirmation prompt (for CI / scripted use). - #[arg(short, long)] - yes: bool, } // --------------------------------------------------------------------------- @@ -137,31 +125,8 @@ fn main() { let start = Instant::now(); let start_time = Local::now(); - // ----------------------------------------------------------------------- - // Warn the user if .fset3 won't be generated (no ffi-backend). - // ----------------------------------------------------------------------- - #[cfg(not(feature = "ffi-backend"))] - { - use std::io::Write as _; - if !cli.yes { - arlog_rel!(""); - arlog_rel!( - "Warning: built without `ffi-backend` feature — .fset3 will NOT be generated." - ); - arlog_rel!("An NFT marker without .fset3 cannot be used for initialization/detection."); - eprint!("Continue anyway? [y/N] "); - let _ = std::io::stdout().flush(); - let mut answer = String::new(); - std::io::stdin() - .read_line(&mut answer) - .expect("failed to read input"); - if !answer.trim().eq_ignore_ascii_case("y") { - arlog_rel!("Aborted."); - std::process::exit(1); - } - arlog_rel!(""); - } - } + // .fset3 is now generated by the pure-Rust RustFreakMatcher (#179), so the + // old "no .fset3 without ffi-backend" warning + confirmation prompt are gone. // ----------------------------------------------------------------------- // Header @@ -289,12 +254,11 @@ fn main() { ); // ----------------------------------------------------------------------- - // Step 3: generate KPM/FREAK keypoints → .fset3 (ffi-backend only) + // Step 3: generate KPM/FREAK keypoints → .fset3 (pure-Rust, #179) // ----------------------------------------------------------------------- - #[cfg(feature = "ffi-backend")] { use webarkitlib_rs::kpm::types::KpmRefDataSet; - use webarkitlib_rs::kpm::{CppFreakMatcher, KpmCompMode, KpmError, KpmProcMode}; + use webarkitlib_rs::kpm::{KpmCompMode, KpmError, KpmProcMode, RustFreakMatcher}; arlog_i!("Generating FeatureSet3..."); let step_start = Instant::now(); @@ -307,8 +271,8 @@ fn main() { let max_feat = KPM_SURF_FEATURE_DENSITY * scale.xsize * scale.ysize / (480 * 360); // Fresh matcher for each scale (scales may have different dimensions). - let mut matcher = CppFreakMatcher::new(scale.xsize, scale.ysize).unwrap_or_else(|e| { - arlog_e!("Error: CppFreakMatcher::new failed: {:?}", e); + let mut matcher = RustFreakMatcher::new(scale.xsize, scale.ysize).unwrap_or_else(|e| { + arlog_e!("Error: RustFreakMatcher::new failed: {:?}", e); std::process::exit(1); }); @@ -387,9 +351,6 @@ fn main() { } } - #[cfg(not(feature = "ffi-backend"))] - arlog_i!("[fset3] skipped (build with --features ffi-backend to generate)"); - // ----------------------------------------------------------------------- // Done // ----------------------------------------------------------------------- diff --git a/crates/core/examples/simple.rs b/crates/core/examples/simple.rs index 1d65d0d..c3e01cf 100644 --- a/crates/core/examples/simple.rs +++ b/crates/core/examples/simple.rs @@ -104,16 +104,14 @@ fn main() { // Load ARParam arlog_i!("Loading camera parameters from {}...", cparam_path); - let param_file = File::open(cparam_path).expect(&format!( - "Failed to open camera parameters: {}", - cparam_path - )); + let param_file = File::open(cparam_path) + .unwrap_or_else(|_| panic!("Failed to open camera parameters: {}", cparam_path)); let param = ARParam::load(param_file).expect("Failed to read camera parameters"); // Load image arlog_i!("Loading image {}...", img_path); let img = ImageReader::open(img_path) - .expect(&format!("Failed to open image: {}", img_path)) + .unwrap_or_else(|_| panic!("Failed to open image: {}", img_path)) .decode() .expect("Failed to decode image"); let width = img.width() as i32; @@ -128,7 +126,7 @@ fn main() { use std::io::Write; arlog_i!("Exporting {} for C benchmark...", hiro_raw_path.display()); let mut f = File::create(&hiro_raw_path) - .expect(&format!("Failed to create {}", hiro_raw_path.display())); + .unwrap_or_else(|_| panic!("Failed to create {}", hiro_raw_path.display())); f.write_all(luma_img.as_raw()) .expect("Failed to write luma raw data"); } @@ -157,12 +155,14 @@ fn main() { arlog_i!("Saved thresholded image to {}", thresh_path.display()); // We mock an identity lookup table for the image size to avoid distortion failure - let mut param_ltf = ARParamLTf::default(); - param_ltf.xsize = width; - param_ltf.ysize = height; - param_ltf.x_off = 0; - param_ltf.y_off = 0; - param_ltf.o2i = vec![0.0; (width * height * 2) as usize]; + let mut param_ltf = ARParamLTf { + xsize: width, + ysize: height, + x_off: 0, + y_off: 0, + o2i: vec![0.0; (width * height * 2) as usize], + ..Default::default() + }; for y in 0..height { for x in 0..width { let idx = ((y * width + x) * 2) as usize; @@ -180,28 +180,32 @@ fn main() { let mut ar3d_handle_ptr = ar_3d_create_handle(¶m).expect("Failed to create AR3DHandle"); // Initialize the main tracking handle - let mut ar_handle = ARHandle::default(); - ar_handle.ar_debug = 1; - ar_handle.xsize = width; - ar_handle.ysize = height; + let mut ar_handle = ARHandle { + ar_debug: 1, + xsize: width, + ysize: height, + ar_image_proc_mode: 0, // FrameImage + ar_pattern_detection_mode: 0, // Template matching color/mono + ar_labeling_mode: 0, // Black region + ar_labeling_thresh: otsu_thresh as i32, + patt_ratio: 0.5, + matrix_code_type: ARMatrixCodeType::Code3x3, + ar_param_lt: &mut *param_lt, + ..Default::default() + }; ar_handle.set_pixel_format(ARPixelFormat::MONO); // raw luma input - ar_handle.ar_image_proc_mode = 0; // FrameImage - ar_handle.ar_pattern_detection_mode = 0; // Template matching color/mono - ar_handle.ar_labeling_mode = 0; // Black region - ar_handle.ar_labeling_thresh = otsu_thresh as i32; - ar_handle.patt_ratio = 0.5; - ar_handle.matrix_code_type = ARMatrixCodeType::Code3x3; - ar_handle.ar_param_lt = &mut *param_lt; // Allocate pattern handle and load real pattern - let mut patt_handle = webarkitlib_rs::types::ARPattHandle::default(); - patt_handle.patt_num_max = 50; - patt_handle.patt_size = 16; - patt_handle.pattf = vec![0; 50]; - patt_handle.patt = vec![vec![0; 16 * 16 * 3 * 4]; 50]; - patt_handle.pattpow = vec![0.0; 50 * 4]; - patt_handle.patt_bw = vec![vec![0; 16 * 16 * 4]; 50]; - patt_handle.pattpow_bw = vec![0.0; 50 * 4]; + let mut patt_handle = webarkitlib_rs::types::ARPattHandle { + patt_num_max: 50, + patt_size: 16, + pattf: vec![0; 50], + patt: vec![vec![0; 16 * 16 * 3 * 4]; 50], + pattpow: vec![0.0; 50 * 4], + patt_bw: vec![vec![0; 16 * 16 * 4]; 50], + pattpow_bw: vec![0.0; 50 * 4], + ..Default::default() + }; arlog_i!("Loading hiro pattern from {}...", patt_path); match ar_patt_load(&mut patt_handle, patt_path) { diff --git a/crates/core/examples/simple_nft.rs b/crates/core/examples/simple_nft.rs index a2189d2..bd2645d 100644 --- a/crates/core/examples/simple_nft.rs +++ b/crates/core/examples/simple_nft.rs @@ -224,13 +224,13 @@ fn main() { Some((cam_pose, page_no, error)) => { arlog_i!(" ✓ KPM match found! Page={}, error={:.4}", page_no, error); arlog_i!(" Initial 3×4 pose matrix:"); - for r in 0..3 { + for row in cam_pose { arlog_i!( " [{:>10.4} {:>10.4} {:>10.4} {:>10.4}]", - cam_pose[r][0], - cam_pose[r][1], - cam_pose[r][2], - cam_pose[r][3] + row[0], + row[1], + row[2], + row[3] ); } @@ -273,13 +273,13 @@ fn main() { Ok(()) => { arlog_i!(" ✓ AR2 tracking succeeded! Error={:.4}", tracking_err); arlog_i!(" Refined 3×4 pose matrix:"); - for r in 0..3 { + for row in &refined_pose { arlog_i!( " [{:>10.4} {:>10.4} {:>10.4} {:>10.4}]", - refined_pose[r][0], - refined_pose[r][1], - refined_pose[r][2], - refined_pose[r][3] + row[0], + row[1], + row[2], + row[3] ); } } diff --git a/crates/core/examples/simple_nft_dual.rs b/crates/core/examples/simple_nft_dual.rs index 7fee0db..e19a136 100644 --- a/crates/core/examples/simple_nft_dual.rs +++ b/crates/core/examples/simple_nft_dual.rs @@ -462,7 +462,7 @@ fn main() { // tier-2 check exactly. let matched = dual_result.matched_id; match (matched >= 0) - .then(|| matched as usize) + .then_some(matched as usize) .and_then(|i| ref_dims.get(i)) { Some(&(ref_w, ref_h)) => { @@ -607,13 +607,13 @@ fn main() { arlog_i!(" KPM match: page={}, error={:.4}", page_no, error); arlog_i!(" Initial 3x4 pose matrix:"); - for r in 0..3 { + for row in cam_pose { arlog_i!( " [{:>10.4} {:>10.4} {:>10.4} {:>10.4}]", - cam_pose[r][0], - cam_pose[r][1], - cam_pose[r][2], - cam_pose[r][3] + row[0], + row[1], + row[2], + row[3] ); } @@ -646,13 +646,13 @@ fn main() { Ok(()) => { arlog_i!(" AR2 tracking succeeded! Error={:.4}", tracking_err); arlog_i!(" Refined 3x4 pose matrix:"); - for r in 0..3 { + for row in &refined_pose { arlog_i!( " [{:>10.4} {:>10.4} {:>10.4} {:>10.4}]", - refined_pose[r][0], - refined_pose[r][1], - refined_pose[r][2], - refined_pose[r][3] + row[0], + row[1], + row[2], + row[3] ); } } diff --git a/crates/core/src/ar/bch.rs b/crates/core/src/ar/bch.rs index 44799d8..53362f4 100644 --- a/crates/core/src/ar/bch.rs +++ b/crates/core/src/ar/bch.rs @@ -484,7 +484,7 @@ pub(crate) mod test_helpers { // elements stored as i32 (0 represents zero element). let mut m_coeffs: Vec = vec![1]; // M(x) = 1 in GF(2^7) for &i in &coset { - let alpha_i = BCH_127_ALPHA_TO[i] as i32; + let alpha_i = BCH_127_ALPHA_TO[i]; // Multiply m_coeffs by (x + α^i) (subtraction == addition in GF(2)). let mut new_m = vec![0i32; m_coeffs.len() + 1]; for (idx, &c) in m_coeffs.iter().enumerate() { diff --git a/crates/core/src/ar/image_proc.rs b/crates/core/src/ar/image_proc.rs index 28e33b0..801f752 100644 --- a/crates/core/src/ar/image_proc.rs +++ b/crates/core/src/ar/image_proc.rs @@ -196,6 +196,13 @@ impl ARImageProcInfo { } /// Calculate image histogram, and box filter image + // rationale: `bias`, `kernel_size_half`, `image_temp_u16`, `image2` are + // used only inside cfg blocks gated on `target_feature = "sse4.1"` / + // `not(target_arch = "x86_64")`. On x86_64 without the compile-time + // sse4.1 gate, no inner branch matches — the variables are genuinely + // unused. The deeper cfg-fallback gap is tracked separately; this + // allow keeps PR scope to the strict clippy gate (#180). + #[allow(unused_variables)] pub fn luma_hist_and_box_filter_with_bias( &mut self, data: &[u8], @@ -390,6 +397,9 @@ pub fn rgba_to_gray(rgba: &[u8]) -> Vec { } } + // On wasm32+simd128 the block above always returns, making this fallback + // unreachable; it is the active path on every other target. + #[allow(unreachable_code)] rgba_to_gray_scalar(rgba) } @@ -731,6 +741,6 @@ mod tests { let thresh = ipi.luma_hist_and_otsu(&img).unwrap(); // Since peaks are 100 and 200, threshold should be around 100. - assert!(thresh >= 100 && thresh < 200); + assert!((100..200).contains(&thresh)); } } diff --git a/crates/core/src/ar/marker.rs b/crates/core/src/ar/marker.rs index e5e4e00..9beb731 100644 --- a/crates/core/src/ar/marker.rs +++ b/crates/core/src/ar/marker.rs @@ -1475,13 +1475,15 @@ mod tests { /// `arGetMarkerInfo.c` lines 92-100. #[test] fn test_finalize_marker_id_cf_dir_template_color() { - let mut m = ARMarkerInfo::default(); - m.id_patt = 7; - m.dir_patt = 2; - m.cf_patt = 0.85; - m.id_matrix = 99; // should be ignored - m.dir_matrix = 3; - m.cf_matrix = 0.5; + let mut m = ARMarkerInfo { + id_patt: 7, + dir_patt: 2, + cf_patt: 0.85, + id_matrix: 99, // should be ignored + dir_matrix: 3, + cf_matrix: 0.5, + ..Default::default() + }; finalize_marker_id_cf_dir(&mut m, crate::pattern::AR_TEMPLATE_MATCHING_COLOR); @@ -1492,10 +1494,12 @@ mod tests { #[test] fn test_finalize_marker_id_cf_dir_template_mono() { - let mut m = ARMarkerInfo::default(); - m.id_patt = 4; - m.dir_patt = 1; - m.cf_patt = 0.7; + let mut m = ARMarkerInfo { + id_patt: 4, + dir_patt: 1, + cf_patt: 0.7, + ..Default::default() + }; finalize_marker_id_cf_dir(&mut m, crate::pattern::AR_TEMPLATE_MATCHING_MONO); @@ -1506,13 +1510,15 @@ mod tests { #[test] fn test_finalize_marker_id_cf_dir_matrix_only() { - let mut m = ARMarkerInfo::default(); - m.id_patt = 99; // should be ignored - m.dir_patt = 2; - m.cf_patt = 0.5; - m.id_matrix = 12; - m.dir_matrix = 0; - m.cf_matrix = 0.92; + let mut m = ARMarkerInfo { + id_patt: 99, // should be ignored + dir_patt: 2, + cf_patt: 0.5, + id_matrix: 12, + dir_matrix: 0, + cf_matrix: 0.92, + ..Default::default() + }; finalize_marker_id_cf_dir(&mut m, crate::types::AR_MATRIX_CODE_DETECTION); @@ -1523,17 +1529,19 @@ mod tests { #[test] fn test_finalize_marker_id_cf_dir_mixed_color_matrix_leaves_finals_alone() { - let mut m = ARMarkerInfo::default(); // Pre-set finals to sentinel values; verify they're not overwritten. - m.id = -42; - m.dir = -42; - m.cf = -42.0; - m.id_patt = 1; - m.dir_patt = 1; - m.cf_patt = 0.1; - m.id_matrix = 2; - m.dir_matrix = 2; - m.cf_matrix = 0.2; + let mut m = ARMarkerInfo { + id: -42, + dir: -42, + cf: -42.0, + id_patt: 1, + dir_patt: 1, + cf_patt: 0.1, + id_matrix: 2, + dir_matrix: 2, + cf_matrix: 0.2, + ..Default::default() + }; finalize_marker_id_cf_dir( &mut m, @@ -1547,10 +1555,12 @@ mod tests { #[test] fn test_finalize_marker_id_cf_dir_mixed_mono_matrix_leaves_finals_alone() { - let mut m = ARMarkerInfo::default(); - m.id = -42; - m.dir = -42; - m.cf = -42.0; + let mut m = ARMarkerInfo { + id: -42, + dir: -42, + cf: -42.0, + ..Default::default() + }; finalize_marker_id_cf_dir( &mut m, @@ -1920,16 +1930,18 @@ mod tests { /// leaves finals untouched. #[test] fn test_finalize_marker_id_cf_dir_mono_matrix_leaves_finals_alone() { - let mut m = ARMarkerInfo::default(); - m.id = -42; - m.dir = -42; - m.cf = -42.0; - m.id_patt = 1; - m.dir_patt = 1; - m.cf_patt = 0.6; - m.id_matrix = 2; - m.dir_matrix = 2; - m.cf_matrix = 0.7; + let mut m = ARMarkerInfo { + id: -42, + dir: -42, + cf: -42.0, + id_patt: 1, + dir_patt: 1, + cf_patt: 0.6, + id_matrix: 2, + dir_matrix: 2, + cf_matrix: 0.7, + ..Default::default() + }; finalize_marker_id_cf_dir( &mut m, @@ -1996,8 +2008,10 @@ mod tests { #[test] fn test_cutoff_phase_set_on_barcode_edc_fail() { use crate::types::{ARMarkerInfoCutoffPhase, MatchError}; - let mut marker = ARMarkerInfo::default(); - marker.cutoff_phase = MatchError::BarcodeEdcFail.into(); + let marker = ARMarkerInfo { + cutoff_phase: MatchError::BarcodeEdcFail.into(), + ..Default::default() + }; assert_eq!( marker.cutoff_phase, ARMarkerInfoCutoffPhase::MatchBarcodeEdcFail @@ -2008,8 +2022,10 @@ mod tests { #[test] fn test_cutoff_phase_set_on_match_generic() { use crate::types::{ARMarkerInfoCutoffPhase, MatchError}; - let mut marker = ARMarkerInfo::default(); - marker.cutoff_phase = MatchError::Generic.into(); + let marker = ARMarkerInfo { + cutoff_phase: MatchError::Generic.into(), + ..Default::default() + }; assert_eq!(marker.cutoff_phase, ARMarkerInfoCutoffPhase::MatchGeneric); } @@ -2025,9 +2041,11 @@ mod tests { #[test] fn test_history_disabled_skips_merge() { use crate::types::ARHandle; - let mut handle = ARHandle::default(); - handle.ar_marker_extraction_mode = crate::types::AR_NOUSE_TRACKING_HISTORY; - handle.ar_pattern_detection_mode = crate::pattern::AR_TEMPLATE_MATCHING_MONO; + let mut handle = ARHandle { + ar_marker_extraction_mode: crate::types::AR_NOUSE_TRACKING_HISTORY, + ar_pattern_detection_mode: crate::pattern::AR_TEMPLATE_MATCHING_MONO, + ..Default::default() + }; // Strong history record. handle.history[0].marker.id = 5; @@ -2065,8 +2083,10 @@ mod tests { #[test] fn test_history_merge_strengthens_weak_marker_single_mode() { use crate::types::ARHandle; - let mut handle = ARHandle::default(); - handle.ar_pattern_detection_mode = crate::pattern::AR_TEMPLATE_MATCHING_MONO; + let mut handle = ARHandle { + ar_pattern_detection_mode: crate::pattern::AR_TEMPLATE_MATCHING_MONO, + ..Default::default() + }; // Current marker: weak. handle.marker_info[0].area = 10000; @@ -2109,8 +2129,10 @@ mod tests { #[test] fn test_history_merge_does_nothing_when_current_cf_higher() { use crate::types::ARHandle; - let mut handle = ARHandle::default(); - handle.ar_pattern_detection_mode = crate::pattern::AR_TEMPLATE_MATCHING_MONO; + let mut handle = ARHandle { + ar_pattern_detection_mode: crate::pattern::AR_TEMPLATE_MATCHING_MONO, + ..Default::default() + }; handle.marker_info[0].area = 10000; handle.marker_info[0].pos = [100.0, 100.0]; @@ -2141,8 +2163,10 @@ mod tests { #[test] fn test_history_merge_skips_when_area_ratio_out_of_range() { use crate::types::ARHandle; - let mut handle = ARHandle::default(); - handle.ar_pattern_detection_mode = crate::pattern::AR_TEMPLATE_MATCHING_MONO; + let mut handle = ARHandle { + ar_pattern_detection_mode: crate::pattern::AR_TEMPLATE_MATCHING_MONO, + ..Default::default() + }; handle.marker_info[0].area = 10000; handle.marker_info[0].pos = [100.0, 100.0]; @@ -2175,9 +2199,11 @@ mod tests { #[test] fn test_history_merge_combined_mode_updates_both_cfs() { use crate::types::ARHandle; - let mut handle = ARHandle::default(); - handle.ar_pattern_detection_mode = - crate::types::AR_TEMPLATE_MATCHING_MONO_AND_MATRIX_CODE_DETECTION; + let mut handle = ARHandle { + ar_pattern_detection_mode: + crate::types::AR_TEMPLATE_MATCHING_MONO_AND_MATRIX_CODE_DETECTION, + ..Default::default() + }; handle.marker_info[0].area = 10000; handle.marker_info[0].pos = [100.0, 100.0]; @@ -2271,9 +2297,10 @@ mod tests { #[test] fn test_save_creates_new_record_for_new_id() { use crate::types::ARHandle; - let mut handle = ARHandle::default(); - - handle.history_num = 0; + let mut handle = ARHandle { + history_num: 0, + ..Default::default() + }; handle.marker_info[0].id = 42; handle.marker_info[0].area = 7777; @@ -2323,8 +2350,10 @@ mod tests { #[test] fn test_v2_mode_skips_resurrection() { use crate::types::ARHandle; - let mut handle = ARHandle::default(); - handle.ar_marker_extraction_mode = crate::types::AR_USE_TRACKING_HISTORY_V2; + let mut handle = ARHandle { + ar_marker_extraction_mode: crate::types::AR_USE_TRACKING_HISTORY_V2, + ..Default::default() + }; handle.history[0].marker.id = 9; handle.history[0].marker.area = 5000; @@ -2347,8 +2376,10 @@ mod tests { #[test] fn test_resurrection_reinserts_missing_history_marker() { use crate::types::ARHandle; - let mut handle = ARHandle::default(); - handle.ar_marker_extraction_mode = crate::types::AR_USE_TRACKING_HISTORY; + let mut handle = ARHandle { + ar_marker_extraction_mode: crate::types::AR_USE_TRACKING_HISTORY, + ..Default::default() + }; handle.history[0].marker.id = 9; handle.history[0].marker.area = 5000; @@ -2370,8 +2401,10 @@ mod tests { #[test] fn test_resurrection_does_not_duplicate_existing_marker() { use crate::types::ARHandle; - let mut handle = ARHandle::default(); - handle.ar_marker_extraction_mode = crate::types::AR_USE_TRACKING_HISTORY; + let mut handle = ARHandle { + ar_marker_extraction_mode: crate::types::AR_USE_TRACKING_HISTORY, + ..Default::default() + }; handle.history[0].marker.area = 5000; handle.history[0].marker.pos = [50.0, 50.0]; diff --git a/crates/core/src/ar/math.rs b/crates/core/src/ar/math.rs index 5e02bc0..6154a1d 100644 --- a/crates/core/src/ar/math.rs +++ b/crates/core/src/ar/math.rs @@ -827,6 +827,32 @@ pub fn ar_util_quat_slerp( q[3] = qy[3] * k0 + qz2[3] * k1; } +/// Multiply a 3x4 double matrix by a 3x4 float matrix, outputting a 3x4 float matrix. +/// Port of arUtilMatMuldff. +pub fn mat_mul_dff(a: &[[f64; 4]; 3], b: &[[f32; 4]; 3], dest: &mut [[f32; 4]; 3]) { + for i in 0..3 { + for j in 0..3 { + dest[i][j] = (a[i][0] * b[0][j] as f64 + + a[i][1] * b[1][j] as f64 + + a[i][2] * b[2][j] as f64) as f32; + } + dest[i][3] = (a[i][0] * b[0][3] as f64 + + a[i][1] * b[1][3] as f64 + + a[i][2] * b[2][3] as f64 + + a[i][3]) as f32; + } +} + +/// Multiply two 3x4 float matrices, outputting a 3x4 float matrix. +pub fn mat_mul_fff(a: &[[f32; 4]; 3], b: &[[f32; 4]; 3], dest: &mut [[f32; 4]; 3]) { + for i in 0..3 { + for j in 0..3 { + dest[i][j] = a[i][0] * b[0][j] + a[i][1] * b[1][j] + a[i][2] * b[2][j]; + } + dest[i][3] = a[i][0] * b[0][3] + a[i][1] * b[1][3] + a[i][2] * b[2][3] + a[i][3]; + } +} + #[cfg(test)] mod tests { use super::*; @@ -977,30 +1003,88 @@ mod tests { assert!((qr[2]).abs() < 1e-10); assert!((qr[3]).abs() < 1e-10); } -} -/// Multiply a 3x4 double matrix by a 3x4 float matrix, outputting a 3x4 float matrix. -/// Port of arUtilMatMuldff. -pub fn mat_mul_dff(a: &[[f64; 4]; 3], b: &[[f32; 4]; 3], dest: &mut [[f32; 4]; 3]) { - for i in 0..3 { - for j in 0..3 { - dest[i][j] = (a[i][0] * b[0][j] as f64 - + a[i][1] * b[1][j] as f64 - + a[i][2] * b[2][j] as f64) as f32; - } - dest[i][3] = (a[i][0] * b[0][3] as f64 - + a[i][1] * b[1][3] as f64 - + a[i][2] * b[2][3] as f64 - + a[i][3]) as f32; + #[test] + fn test_mat_mul_dff_identity() { + // I_dbl * B_f32 == B_f32 (columns 0..3); column 3 picks up the + // a[i][3] translation term. + let a: [[f64; 4]; 3] = [ + [1.0, 0.0, 0.0, 0.0], + [0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + ]; + let b: [[f32; 4]; 3] = [ + [1.0, 2.0, 3.0, 4.0], + [5.0, 6.0, 7.0, 8.0], + [9.0, 10.0, 11.0, 12.0], + ]; + let mut dest = [[0.0f32; 4]; 3]; + mat_mul_dff(&a, &b, &mut dest); + assert_eq!(dest, b); } -} -/// Multiply two 3x4 float matrices, outputting a 3x4 float matrix. -pub fn mat_mul_fff(a: &[[f32; 4]; 3], b: &[[f32; 4]; 3], dest: &mut [[f32; 4]; 3]) { - for i in 0..3 { - for j in 0..3 { - dest[i][j] = a[i][0] * b[0][j] + a[i][1] * b[1][j] + a[i][2] * b[2][j]; - } - dest[i][3] = a[i][0] * b[0][3] + a[i][1] * b[1][3] + a[i][2] * b[2][3] + a[i][3]; + #[test] + fn test_mat_mul_dff_known_values() { + // a row 0: [2, 0, 0, 1] picks 2*b[0][j] + a[0][3] for column 3. + let a: [[f64; 4]; 3] = [ + [2.0, 0.0, 0.0, 1.0], + [0.0, 3.0, 0.0, -1.0], + [0.0, 0.0, 4.0, 0.5], + ]; + let b: [[f32; 4]; 3] = [ + [1.0, 1.0, 1.0, 10.0], + [2.0, 2.0, 2.0, 20.0], + [3.0, 3.0, 3.0, 30.0], + ]; + let mut dest = [[0.0f32; 4]; 3]; + mat_mul_dff(&a, &b, &mut dest); + assert!((dest[0][0] - 2.0).abs() < 1e-5); + assert!((dest[0][3] - (20.0 + 1.0)).abs() < 1e-5); + assert!((dest[1][1] - 6.0).abs() < 1e-5); + assert!((dest[1][3] - (60.0 - 1.0)).abs() < 1e-5); + assert!((dest[2][2] - 12.0).abs() < 1e-5); + assert!((dest[2][3] - (120.0 + 0.5)).abs() < 1e-5); + } + + #[test] + fn test_mat_mul_fff_identity() { + let a: [[f32; 4]; 3] = [ + [1.0, 0.0, 0.0, 0.0], + [0.0, 1.0, 0.0, 0.0], + [0.0, 0.0, 1.0, 0.0], + ]; + let b: [[f32; 4]; 3] = [ + [1.0, 2.0, 3.0, 4.0], + [5.0, 6.0, 7.0, 8.0], + [9.0, 10.0, 11.0, 12.0], + ]; + let mut dest = [[0.0f32; 4]; 3]; + mat_mul_fff(&a, &b, &mut dest); + assert_eq!(dest, b); + } + + #[test] + fn test_mat_mul_fff_composes_translation() { + // Pure translation composed with pure translation == sum of + // translations; rotation block (cols 0..3) stays identity. + let a: [[f32; 4]; 3] = [ + [1.0, 0.0, 0.0, 1.0], + [0.0, 1.0, 0.0, 2.0], + [0.0, 0.0, 1.0, 3.0], + ]; + let b: [[f32; 4]; 3] = [ + [1.0, 0.0, 0.0, 10.0], + [0.0, 1.0, 0.0, 20.0], + [0.0, 0.0, 1.0, 30.0], + ]; + let mut dest = [[0.0f32; 4]; 3]; + mat_mul_fff(&a, &b, &mut dest); + assert!((dest[0][3] - 11.0).abs() < 1e-5); + assert!((dest[1][3] - 22.0).abs() < 1e-5); + assert!((dest[2][3] - 33.0).abs() < 1e-5); + // Diagonal stays 1. + assert!((dest[0][0] - 1.0).abs() < 1e-5); + assert!((dest[1][1] - 1.0).abs() < 1e-5); + assert!((dest[2][2] - 1.0).abs() < 1e-5); } } diff --git a/crates/core/src/ar/matrix.rs b/crates/core/src/ar/matrix.rs index 600619d..de4e95b 100644 --- a/crates/core/src/ar/matrix.rs +++ b/crates/core/src/ar/matrix.rs @@ -940,9 +940,9 @@ mod tests { // Build a known 120-bit pattern and confirm each rotation of the // input grid yields the same `recd[0..120]` (different dir values). let mut bits = [0u8; 120]; - for i in 0..120 { + for (i, b) in bits.iter_mut().enumerate() { // Pseudo-random pattern: prime-indexed positions are 1. - bits[i] = if matches!( + *b = if matches!( i, 2 | 3 | 5 diff --git a/crates/core/src/ar/param.rs b/crates/core/src/ar/param.rs index 17839fd..23f3f45 100644 --- a/crates/core/src/ar/param.rs +++ b/crates/core/src/ar/param.rs @@ -246,9 +246,11 @@ mod tests { #[test] fn test_ar_param_change_size_doubles_resolution() { - let mut src = ARParam::default(); - src.xsize = 640; - src.ysize = 480; + let mut src = ARParam { + xsize: 640, + ysize: 480, + ..Default::default() + }; // fx, skew=0, cx, tx src.mat[0] = [700.0, 0.0, 320.0, 0.0]; // 0, fy, cy, ty @@ -277,9 +279,11 @@ mod tests { #[test] fn test_ar_param_change_size_halves_resolution() { - let mut src = ARParam::default(); - src.xsize = 1280; - src.ysize = 960; + let mut src = ARParam { + xsize: 1280, + ysize: 960, + ..Default::default() + }; src.mat[0] = [1400.0, 0.0, 640.0, 0.0]; src.mat[1] = [0.0, 1400.0, 480.0, 0.0]; src.mat[2] = [0.0, 0.0, 1.0, 0.0]; @@ -294,9 +298,11 @@ mod tests { #[test] fn test_ar_param_change_size_identity() { - let mut src = ARParam::default(); - src.xsize = 640; - src.ysize = 480; + let mut src = ARParam { + xsize: 640, + ysize: 480, + ..Default::default() + }; src.mat[0] = [700.0, 0.0, 320.0, 0.0]; src.mat[1] = [0.0, 700.0, 240.0, 0.0]; src.mat[2] = [0.0, 0.0, 1.0, 0.0]; diff --git a/crates/core/src/ar/param_gl.rs b/crates/core/src/ar/param_gl.rs index d8588b8..e17c661 100644 --- a/crates/core/src/ar/param_gl.rs +++ b/crates/core/src/ar/param_gl.rs @@ -486,9 +486,11 @@ mod tests { use crate::types::ARParam; fn make_param(xsize: i32, ysize: i32, fx: f64, fy: f64, cx: f64, cy: f64) -> ARParam { - let mut p = ARParam::default(); - p.xsize = xsize; - p.ysize = ysize; + let mut p = ARParam { + xsize, + ysize, + ..Default::default() + }; p.mat[0] = [fx, 0.0, cx, 0.0]; p.mat[1] = [0.0, fy, cy, 0.0]; p.mat[2] = [0.0, 0.0, 1.0, 0.0]; diff --git a/crates/core/src/ar/pattern.rs b/crates/core/src/ar/pattern.rs index dc90a09..59beae5 100644 --- a/crates/core/src/ar/pattern.rs +++ b/crates/core/src/ar/pattern.rs @@ -1046,6 +1046,126 @@ pub fn ar_patt_get_id( pattern_match(patt_handle, patt_detect_mode, data, patt_size) } +#[inline] +fn dot_product(a: &[i16], b: &[i16]) -> i64 { + #[cfg(feature = "simd-pattern")] + { + #[cfg(all(target_arch = "wasm32", target_feature = "simd128"))] + { + return unsafe { dot_product_simd_wasm(a, b) }; + } + #[cfg(all(target_arch = "x86_64", target_feature = "sse4.1"))] + { + if is_x86_feature_detected!("sse4.1") { + return unsafe { dot_product_simd_x86(a, b) }; + } + } + } + + // On wasm32+simd128 the block above always returns, making this fallback + // unreachable; it is the active path on every other target. + #[allow(unreachable_code)] + dot_product_scalar(a, b) +} + +pub fn dot_product_scalar(a: &[i16], b: &[i16]) -> i64 { + let mut sum = 0i64; + for i in 0..a.len() { + sum += (a[i] as i32 * b[i] as i32) as i64; + } + sum +} + +#[cfg(all( + feature = "simd-pattern", + target_arch = "wasm32", + target_feature = "simd128" +))] +#[target_feature(enable = "simd128")] +unsafe fn dot_product_simd_wasm(a: &[i16], b: &[i16]) -> i64 { + use std::arch::wasm32::*; + + let mut sum_v = i32x4_splat(0); + let chunks_len = a.len() / 8; + + let mut a_ptr = a.as_ptr(); + let mut b_ptr = b.as_ptr(); + + for _ in 0..chunks_len { + let va = v128_load(a_ptr as *const v128); + let vb = v128_load(b_ptr as *const v128); + + // i32x4_dot_i16x8 computes (a0*b0 + a1*b1), (a2*b2 + a3*b3), ... + let dot = i32x4_dot_i16x8(va, vb); + sum_v = i32x4_add(sum_v, dot); + + a_ptr = a_ptr.add(8); + b_ptr = b_ptr.add(8); + } + + // Sum the 4 horizontal parts into i64x2 + let low = i64x2_extend_low_i32x4(sum_v); + let high = i64x2_extend_high_i32x4(sum_v); + let sum64 = i64x2_add(low, high); + + let mut res = [0i64; 2]; + v128_store(res.as_mut_ptr() as *mut v128, sum64); + let mut total = res[0] + res[1]; + + let rem_start = chunks_len * 8; + for i in rem_start..a.len() { + total += (a[i] as i32 * b[i] as i32) as i64; + } + + total +} + +/// Calculates the dot product of two i16 slices using SSE4.1 intrinsics. +/// +/// Processes 8 elements at a time using `_mm_madd_epi16`. +/// +/// # Safety +/// This function is unsafe because it uses SIMD intrinsics and raw pointer arithmetic. +/// The caller must ensure that the target CPU supports SSE4.1. +#[cfg(all( + feature = "simd-pattern", + target_arch = "x86_64", + target_feature = "sse4.1" +))] +#[target_feature(enable = "sse4.1")] +pub unsafe fn dot_product_simd_x86(a: &[i16], b: &[i16]) -> i64 { + use std::arch::x86_64::*; + + let mut sum_v = _mm_setzero_si128(); // i32x4 + let chunks_len = a.len() / 8; + + let mut a_ptr = a.as_ptr(); + let mut b_ptr = b.as_ptr(); + + for _ in 0..chunks_len { + let va = _mm_loadu_si128(a_ptr as *const __m128i); + let vb = _mm_loadu_si128(b_ptr as *const __m128i); + + // Multiply and add adjacent pairs. + let m = _mm_madd_epi16(va, vb); + sum_v = _mm_add_epi32(sum_v, m); + + a_ptr = a_ptr.add(8); + b_ptr = b_ptr.add(8); + } + + let mut res = [0i32; 4]; + _mm_storeu_si128(res.as_mut_ptr() as *mut __m128i, sum_v); + let mut total = (res[0] as i64) + (res[1] as i64) + (res[2] as i64) + (res[3] as i64); + + let rem_start = chunks_len * 8; + for i in rem_start..a.len() { + total += (a[i] as i32 * b[i] as i32) as i64; + } + + total +} + #[cfg(test)] mod tests { use super::*; @@ -1066,12 +1186,8 @@ mod tests { // Mock extracted pattern with high contrast let mut mock_data = vec![0; (AR_PATT_SIZE1 * AR_PATT_SIZE1 * 3) as usize]; - for i in 0..mock_data.len() { - if i % 2 == 0 { - mock_data[i] = 10; - } else { - mock_data[i] = 240; - } + for (i, px) in mock_data.iter_mut().enumerate() { + *px = if i % 2 == 0 { 10 } else { 240 }; } let result = pattern_match( @@ -1122,8 +1238,10 @@ mod tests { let param_ltf = ARParamLTf::new_basic(xsize_i32, ysize_i32); // Build a simple marker_info with the quad we defined - let mut marker = ARMarkerInfo::default(); - marker.vertex = vertex; + let marker = ARMarkerInfo { + vertex, + ..Default::default() + }; // Save using the image-first signature of ar_patt_save let filename_path = std::path::Path::new(filename); @@ -1165,121 +1283,37 @@ mod tests { // Cleanup let _ = fs::remove_file(filename); } -} - -#[inline] -fn dot_product(a: &[i16], b: &[i16]) -> i64 { - #[cfg(feature = "simd-pattern")] - { - #[cfg(all(target_arch = "wasm32", target_feature = "simd128"))] - { - return unsafe { dot_product_simd_wasm(a, b) }; - } - #[cfg(all(target_arch = "x86_64", target_feature = "sse4.1"))] - { - if is_x86_feature_detected!("sse4.1") { - return unsafe { dot_product_simd_x86(a, b) }; - } - } - } - - dot_product_scalar(a, b) -} - -pub fn dot_product_scalar(a: &[i16], b: &[i16]) -> i64 { - let mut sum = 0i64; - for i in 0..a.len() { - sum += (a[i] as i32 * b[i] as i32) as i64; - } - sum -} -#[cfg(all( - feature = "simd-pattern", - target_arch = "wasm32", - target_feature = "simd128" -))] -#[target_feature(enable = "simd128")] -unsafe fn dot_product_simd_wasm(a: &[i16], b: &[i16]) -> i64 { - use std::arch::wasm32::*; - - let mut sum_v = i32x4_splat(0); - let chunks_len = a.len() / 8; - - let mut a_ptr = a.as_ptr(); - let mut b_ptr = b.as_ptr(); - - for _ in 0..chunks_len { - let va = v128_load(a_ptr as *const v128); - let vb = v128_load(b_ptr as *const v128); - - // i32x4_dot_i16x8 computes (a0*b0 + a1*b1), (a2*b2 + a3*b3), ... - let dot = i32x4_dot_i16x8(va, vb); - sum_v = i32x4_add(sum_v, dot); - - a_ptr = a_ptr.add(8); - b_ptr = b_ptr.add(8); + #[test] + fn test_dot_product_scalar_known_values() { + let a: [i16; 4] = [1, 2, 3, 4]; + let b: [i16; 4] = [5, 6, 7, 8]; + // 1*5 + 2*6 + 3*7 + 4*8 = 5 + 12 + 21 + 32 = 70 + assert_eq!(dot_product_scalar(&a, &b), 70); } - // Sum the 4 horizontal parts into i64x2 - let low = i64x2_extend_low_i32x4(sum_v); - let high = i64x2_extend_high_i32x4(sum_v); - let sum64 = i64x2_add(low, high); - - let mut res = [0i64; 2]; - v128_store(res.as_mut_ptr() as *mut v128, sum64); - let mut total = res[0] + res[1]; - - let rem_start = chunks_len * 8; - for i in rem_start..a.len() { - total += (a[i] as i32 * b[i] as i32) as i64; + #[test] + fn test_dot_product_scalar_zero_inputs() { + let a = vec![0i16; 32]; + let b = vec![123i16; 32]; + assert_eq!(dot_product_scalar(&a, &b), 0); } - total -} - -/// Calculates the dot product of two i16 slices using SSE4.1 intrinsics. -/// -/// Processes 8 elements at a time using `_mm_madd_epi16`. -/// -/// # Safety -/// This function is unsafe because it uses SIMD intrinsics and raw pointer arithmetic. -/// The caller must ensure that the target CPU supports SSE4.1. -#[cfg(all( - feature = "simd-pattern", - target_arch = "x86_64", - target_feature = "sse4.1" -))] -#[target_feature(enable = "sse4.1")] -pub unsafe fn dot_product_simd_x86(a: &[i16], b: &[i16]) -> i64 { - use std::arch::x86_64::*; - - let mut sum_v = _mm_setzero_si128(); // i32x4 - let chunks_len = a.len() / 8; - - let mut a_ptr = a.as_ptr(); - let mut b_ptr = b.as_ptr(); - - for _ in 0..chunks_len { - let va = _mm_loadu_si128(a_ptr as *const __m128i); - let vb = _mm_loadu_si128(b_ptr as *const __m128i); - - // Multiply and add adjacent pairs. - let m = _mm_madd_epi16(va, vb); - sum_v = _mm_add_epi32(sum_v, m); - - a_ptr = a_ptr.add(8); - b_ptr = b_ptr.add(8); + #[test] + fn test_dot_product_scalar_negative() { + let a: [i16; 3] = [-1, -2, -3]; + let b: [i16; 3] = [4, 5, 6]; + // -4 + -10 + -18 = -32 + assert_eq!(dot_product_scalar(&a, &b), -32); } - let mut res = [0i32; 4]; - _mm_storeu_si128(res.as_mut_ptr() as *mut __m128i, sum_v); - let mut total = (res[0] as i64) + (res[1] as i64) + (res[2] as i64) + (res[3] as i64); - - let rem_start = chunks_len * 8; - for i in rem_start..a.len() { - total += (a[i] as i32 * b[i] as i32) as i64; + #[test] + fn test_dot_product_dispatcher_matches_scalar() { + // dot_product chooses SIMD when the runtime feature is detected + // and the cfg gate matches, else falls back to the scalar path. + // Both paths must produce the same integer-exact result. + let a: Vec = (0..23).map(|i| i as i16 - 11).collect(); + let b: Vec = (0..23).map(|i| 2 * i as i16 + 1).collect(); + assert_eq!(dot_product(&a, &b), dot_product_scalar(&a, &b)); } - - total } diff --git a/crates/core/src/ar2/feature_map.rs b/crates/core/src/ar2/feature_map.rs index 9ac287c..26bb537 100644 --- a/crates/core/src/ar2/feature_map.rs +++ b/crates/core/src/ar2/feature_map.rs @@ -272,6 +272,11 @@ fn get_similarity_scalar( /// after `is_x86_feature_detected!`. #[cfg(all(feature = "simd-x86-sse41", target_arch = "x86_64"))] #[target_feature(enable = "sse4.1")] +// rationale: SIMD variant of get_similarity; signature locked to match +// the scalar fallback and sibling SIMD impl for runtime dispatch via +// is_x86_feature_detected! — bundling args into a struct would force +// every caller to materialize it on a hot path with no design win. +#[allow(clippy::too_many_arguments)] unsafe fn get_similarity_sse41( image: &[u8], xsize: i32, @@ -326,8 +331,8 @@ unsafe fn get_similarity_sse41( i += 16; } // Tail for sx/sxx (scalar) - for ii in i..patch_size { - let p = img_row[ii] as u64; + for &b in &img_row[i..patch_size] { + let p = b as u64; sx_i += p; sxx_i += p * p; } @@ -384,6 +389,11 @@ unsafe fn get_similarity_sse41( /// [`get_similarity`] after `is_x86_feature_detected!`. #[cfg(all(feature = "simd-x86-avx2", target_arch = "x86_64"))] #[target_feature(enable = "avx2,fma")] +// rationale: SIMD variant of get_similarity; signature locked to match +// the scalar fallback and sibling SIMD impl for runtime dispatch via +// is_x86_feature_detected! — bundling args into a struct would force +// every caller to materialize it on a hot path with no design win. +#[allow(clippy::too_many_arguments)] unsafe fn get_similarity_avx2( image: &[u8], xsize: i32, @@ -432,8 +442,8 @@ unsafe fn get_similarity_avx2( sxx_i += _mm_cvtsi128_si32(sum1) as u32 as u64; i += 16; } - for ii in i..patch_size { - let p = img_row[ii] as u64; + for &b in &img_row[i..patch_size] { + let p = b as u64; sx_i += p; sxx_i += p * p; } @@ -932,6 +942,7 @@ mod tests { /// Lightweight version using a small synthetic image. #[test] + #[cfg_attr(miri, ignore)] // #194: full ar2_gen_feature_map pipeline (64×64) — too slow under Miri fn test_feature_map_small_synthetic() { let w: i32 = 64; let h: i32 = 64; @@ -959,6 +970,7 @@ mod tests { /// Verify mindpi/maxdpi are computed correctly for a 3-level pyramid. #[test] + #[cfg_attr(miri, ignore)] // #194: full ar2_gen_feature_map pipeline (32×32 × 3 scales) — too slow under Miri fn test_feature_map_mindpi_maxdpi() { // Build a 3-scale set manually: 72, 57, 45 dpi let make = |dpi: f32| AR2ImageT { diff --git a/crates/core/src/ar2/image_set.rs b/crates/core/src/ar2/image_set.rs index b2eeed1..2a71dd8 100644 --- a/crates/core/src/ar2/image_set.rs +++ b/crates/core/src/ar2/image_set.rs @@ -967,8 +967,8 @@ mod tests { // Level 1: 4×4 at 100 DPI — gradient. let mut pixels_1 = vec![0u8; 16]; - for i in 0..16 { - pixels_1[i] = (i as u8) * 16; + for (i, px) in pixels_1.iter_mut().enumerate() { + *px = (i as u8) * 16; } let original = AR2ImageSetT { @@ -1010,8 +1010,8 @@ mod tests { fn test_image_set_save_jpeg_load_roundtrip() { // 16×16 gradient at 150 DPI — single scale for simplicity. let mut pixels = vec![0u8; 256]; - for i in 0..256 { - pixels[i] = i as u8; + for (i, px) in pixels.iter_mut().enumerate() { + *px = i as u8; } let original = AR2ImageSetT { diff --git a/crates/core/src/arlog.rs b/crates/core/src/arlog.rs index 446ad67..d4a8842 100644 --- a/crates/core/src/arlog.rs +++ b/crates/core/src/arlog.rs @@ -296,10 +296,8 @@ fn init_env_logger(verbose: bool) { /// Call once in your binary's `main()` — never from library code. /// /// ```no_run -/// fn main() { -/// webarkitlib_rs::arlog::ar_log_init_default(); -/// // ... rest of your application -/// } +/// webarkitlib_rs::arlog::ar_log_init_default(); +/// // ... rest of your application /// ``` #[cfg(all(feature = "log-helpers", not(target_arch = "wasm32")))] pub fn ar_log_init_default() { diff --git a/crates/core/src/kpm/freak/clustering.rs b/crates/core/src/kpm/freak/clustering.rs index cdcb4f1..8945e44 100644 --- a/crates/core/src/kpm/freak/clustering.rs +++ b/crates/core/src/kpm/freak/clustering.rs @@ -63,16 +63,17 @@ fn hamming_distance_32(a: u32, b: u32) -> u32 { /// Computes Hamming distance between two 96-byte (768-bit) FREAK descriptors. /// C equivalent: HammingDistance768 pub fn hamming_distance_96(a: &[u8; 96], b: &[u8; 96]) -> u32 { - // SAFETY: Transmuting &[u8; 96] to &[u32; 24] is safe because: - // 1. Byte arrays are correctly sized (96 bytes = 24 × 4 bytes) - // 2. u32 alignment is guaranteed on all supported targets - // 3. The transmute creates a properly-aligned view of the same data - let a32 = unsafe { std::mem::transmute::<&[u8; 96], &[u32; 24]>(a) }; - let b32 = unsafe { std::mem::transmute::<&[u8; 96], &[u32; 24]>(b) }; - - a32.iter() - .zip(b32.iter()) - .map(|(x, y)| hamming_distance_32(*x, *y)) + // Reassemble each 4-byte chunk via `u32::from_ne_bytes` rather than + // transmuting `&[u8; 96]` to `&[u32; 24]`. The transmute requires + // 4-byte alignment on the input, which `&[u8; 96]` does not + // guarantee — Miri flagged it as UB (#192). + (0..24) + .map(|i| { + let off = i * 4; + let ax = u32::from_ne_bytes([a[off], a[off + 1], a[off + 2], a[off + 3]]); + let bx = u32::from_ne_bytes([b[off], b[off + 1], b[off + 2], b[off + 3]]); + hamming_distance_32(ax, bx) + }) .sum() } @@ -875,6 +876,22 @@ mod tests { assert!(bhc.query(&feat).is_err()); } + #[test] + fn test_bhc_build_all_identical() { + // Degenerate input: many identical descriptors. k-medoids distances are + // all zero, so the split is degenerate — exercise that path (#177). + let feat = [42u8; 96]; + let owned: Vec<[u8; 96]> = (0..20).map(|_| feat).collect(); + let features: Vec<&[u8; 96]> = owned.iter().collect(); + let mut bhc = BinaryHierarchicalClustering::new().unwrap(); + bhc.build(&features).expect("build over identical features"); + let res = bhc.query(&feat).expect("query"); + assert!( + !res.is_empty(), + "query of an identical feature must return the leaf members" + ); + } + #[test] fn test_bhc_roundtrip() { let mut bhc = BinaryHierarchicalClustering::new().unwrap(); @@ -953,6 +970,7 @@ mod tests { } #[test] + #[cfg_attr(miri, ignore)] // #194: BHC build over 80 descriptors — too slow under Miri fn test_bhc_set_num_hypotheses_then_build() { // Setting num_hypotheses to a non-default value must not break build/query. let descs = make_synth_descriptors(80); @@ -976,6 +994,7 @@ mod tests { } #[test] + #[cfg_attr(miri, ignore)] // #194: BHC build over 80 descriptors — too slow under Miri fn test_bhc_set_num_centers_preserves_num_hypotheses() { // Regression test for setter ordering: set_num_centers must not // silently reset num_hypotheses back to 1 (it did before #146). @@ -995,6 +1014,7 @@ mod tests { } #[test] + #[cfg_attr(miri, ignore)] // #194: BHC build over 80 descriptors — too slow under Miri fn test_bhc_max_nodes_to_pop_zero_is_tied_min_only() { // With max_nodes_to_pop=0 (default), query should produce exactly // the same result set as the pre-#146 implementation: only @@ -1012,6 +1032,7 @@ mod tests { } #[test] + #[cfg_attr(miri, ignore)] // #194: BHC build over 200 descriptors ×2 — by far the slowest test under Miri fn test_bhc_max_nodes_to_pop_widens_candidate_set() { // Monotonicity check: query(max_nodes_to_pop=N) should return at // least as many candidates as query(max_nodes_to_pop=0) on the same @@ -1047,7 +1068,10 @@ mod tests { // from math/rand.h). This is what enables the BHC-indexed match dual-mode // test in matcher.rs to assert pair equality with C++ rather than count-only. -#[cfg(feature = "dual-mode")] +// Only ever called from `#[cfg(all(test, feature = "dual-mode"))]` +// modules below; gate the extern declaration the same way so the lib +// target under --all-features doesn't see a phantom dead symbol (#180). +#[cfg(all(test, feature = "dual-mode"))] extern "C" { fn webarkit_cpp_fast_random(seed: *mut i32) -> i32; fn webarkit_cpp_array_shuffle(v: *mut i32, pop_size: i32, sample_size: i32, seed: *mut i32); diff --git a/crates/core/src/kpm/freak/descriptor.rs b/crates/core/src/kpm/freak/descriptor.rs index b2b6e0c..794d8cd 100644 --- a/crates/core/src/kpm/freak/descriptor.rs +++ b/crates/core/src/kpm/freak/descriptor.rs @@ -337,6 +337,7 @@ mod tests { // ── Length / shape ──────────────────────────────────────────────── #[test] + #[cfg_attr(miri, ignore)] // #194: real-image FREAK extraction — too slow under Miri fn test_freak_descriptor_length_one_keypoint() { let img = load_grayscale("../../benchmarks/data/found.jpg"); let pyr = build_pyramid(&img); @@ -348,6 +349,7 @@ mod tests { } #[test] + #[cfg_attr(miri, ignore)] // #194: real-image FREAK extraction — too slow under Miri fn test_freak_descriptor_length_multiple_keypoints() { let img = load_grayscale("../../benchmarks/data/found.jpg"); let pyr = build_pyramid(&img); @@ -361,6 +363,7 @@ mod tests { } #[test] + #[cfg_attr(miri, ignore)] // #194: real-image FREAK extraction — too slow under Miri fn test_freak_descriptor_padding_bytes_are_zero() { // Bytes 84..96 of each descriptor must be zero (matches C++ store layout). let img = load_grayscale("../../benchmarks/data/found.jpg"); @@ -382,6 +385,7 @@ mod tests { } #[test] + #[cfg_attr(miri, ignore)] // #194: real-image FREAK extraction — too slow under Miri fn test_freak_descriptor_empty_input() { let img = load_grayscale("../../benchmarks/data/found.jpg"); let pyr = build_pyramid(&img); @@ -391,6 +395,7 @@ mod tests { } #[test] + #[cfg_attr(miri, ignore)] // #194: real-image FREAK extraction (×2) — too slow under Miri fn test_freak_descriptor_is_reproducible() { let img = load_grayscale("../../benchmarks/data/found.jpg"); let pyr = build_pyramid(&img); diff --git a/crates/core/src/kpm/freak/detector.rs b/crates/core/src/kpm/freak/detector.rs index 1ba0b5d..dfd0162 100644 --- a/crates/core/src/kpm/freak/detector.rs +++ b/crates/core/src/kpm/freak/detector.rs @@ -1116,6 +1116,7 @@ mod tests { } #[test] + #[cfg_attr(miri, ignore)] // #194: real-image DoG detection — too slow under Miri fn test_dog_detector_finds_keypoints_on_real_image() { let img = load_grayscale("../../benchmarks/data/found.jpg"); let pyr = build_test_pyramid(&img, 3); @@ -1125,6 +1126,7 @@ mod tests { } #[test] + #[cfg_attr(miri, ignore)] // #194: real-image DoG detection — too slow under Miri fn test_dog_detector_keypoints_within_image_bounds() { let img = load_grayscale("../../benchmarks/data/found.jpg"); let pyr = build_test_pyramid(&img, 3); diff --git a/crates/core/src/kpm/freak/gaussian_pyramid.rs b/crates/core/src/kpm/freak/gaussian_pyramid.rs index 153a13c..cb2d1e9 100644 --- a/crates/core/src/kpm/freak/gaussian_pyramid.rs +++ b/crates/core/src/kpm/freak/gaussian_pyramid.rs @@ -269,204 +269,388 @@ pub fn num_octaves_for(width: usize, height: usize, min_size: usize) -> usize { } // ───────────────────────────────────────────────────────────────────────── -// Private filter helpers — byte-for-byte port of C++ `binomial_4th_order` -// and `downsample_bilinear` from `gaussian_scale_space_pyramid.cpp`. +// Filter helpers — byte-for-byte port of C++ `binomial_4th_order` and +// `downsample_bilinear` from `gaussian_scale_space_pyramid.cpp`. +// +// The per-row helpers below are the single source of truth for the +// arithmetic; the serial scalar paths and the rayon paths (#207) both call +// them, so output is bit-for-bit identical regardless of threading +// (validated by the `dual-mode` C++ parity test). rows are independent, so +// parallelizing over them changes nothing numerically. // ───────────────────────────────────────────────────────────────────────── +/// `1 / 256` normalization applied on the vertical pass. Exact in f32. +const INV_256: f32 = 1.0 / 256.0; + +/// Below this pixel count the rayon path's thread-pool overhead outweighs the +/// win, so the dispatchers run serially (CLAUDE.md §3: "don't parallelize +/// small loops"). Benchmarking showed the binomial filter regresses at +/// 640×480 (~0.3 MPx) but scales ~1.5× at 1280×720 (~0.9 MPx); this +/// conservative threshold keeps ≤480p serial and is a machine-dependent +/// heuristic, not a hard tuning point. +/// +/// Only referenced by the rayon dispatchers, which are gated off on wasm32 +/// (single-threaded), so the const is too. +#[cfg(not(target_arch = "wasm32"))] +const PARALLEL_MIN_PIXELS: usize = 600_000; + +// ── f32 → f32 per-row helpers ──────────────────────────────────────────── + +/// Horizontal pass of one row, f32 → f32. `s` and `t` are the row slices of +/// the source and the temp buffer. +#[inline] +fn binomial_h_row_f32(s: &[f32], t: &mut [f32], width: usize) { + t[0] = 6.0 * s[0] + 4.0 * (s[0] + s[1]) + s[0] + s[2]; + t[1] = 6.0 * s[1] + 4.0 * (s[0] + s[2]) + s[0] + s[3]; + for col in 2..width - 2 { + t[col] = 6.0 * s[col] + 4.0 * (s[col - 1] + s[col + 1]) + s[col - 2] + s[col + 2]; + } + let c = width - 2; + t[c] = 6.0 * s[c] + 4.0 * (s[c - 1] + s[c + 1]) + s[c - 2] + s[c + 1]; + let c = width - 1; + t[c] = 6.0 * s[c] + 4.0 * (s[c - 1] + s[c]) + s[c - 2] + s[c]; +} + +/// Vertical pass of one output `row`, f32 → f32. Reads `tmp`, writes +/// `dst_row` (length `width`). Border rows (0, 1, height-2, height-1) +/// replicate edges; interior rows use the full 5-tap. +#[inline] +fn binomial_v_row_f32(tmp: &[f32], dst_row: &mut [f32], row: usize, width: usize, height: usize) { + if row == 0 { + for (col, d) in dst_row.iter_mut().enumerate() { + let p = tmp[col]; + let pp1 = tmp[width + col]; + let pp2 = tmp[2 * width + col]; + *d = (6.0 * p + 4.0 * (p + pp1) + p + pp2) * INV_256; + } + } else if row == 1 { + for (col, d) in dst_row.iter_mut().enumerate() { + let pm = tmp[col]; + let p = tmp[width + col]; + let pp1 = tmp[2 * width + col]; + let pp2 = tmp[3 * width + col]; + *d = (6.0 * p + 4.0 * (pm + pp1) + pm + pp2) * INV_256; + } + } else if row == height - 2 { + for (col, d) in dst_row.iter_mut().enumerate() { + let pm2 = tmp[(height - 4) * width + col]; + let pm1 = tmp[(height - 3) * width + col]; + let p = tmp[(height - 2) * width + col]; + let pp = tmp[(height - 1) * width + col]; + *d = (6.0 * p + 4.0 * (pm1 + pp) + pm2 + pp) * INV_256; + } + } else if row == height - 1 { + for (col, d) in dst_row.iter_mut().enumerate() { + let pm2 = tmp[(height - 3) * width + col]; + let pm1 = tmp[(height - 2) * width + col]; + let p = tmp[(height - 1) * width + col]; + *d = (6.0 * p + 4.0 * (pm1 + p) + pm2 + p) * INV_256; + } + } else { + for (col, d) in dst_row.iter_mut().enumerate() { + let pm2 = tmp[(row - 2) * width + col]; + let pm1 = tmp[(row - 1) * width + col]; + let p = tmp[row * width + col]; + let pp1 = tmp[(row + 1) * width + col]; + let pp2 = tmp[(row + 2) * width + col]; + *d = (6.0 * p + 4.0 * (pm1 + pp1) + pm2 + pp2) * INV_256; + } + } +} + +// ── u8 → f32 per-row helpers ───────────────────────────────────────────── + +/// Horizontal pass of one row, u8 → u16 (exact integer; max 4080). +#[inline] +fn binomial_h_row_u8(s: &[u8], t: &mut [u16], width: usize) { + t[0] = 6 * s[0] as u16 + 4 * (s[0] as u16 + s[1] as u16) + (s[0] as u16 + s[2] as u16); + t[1] = 6 * s[1] as u16 + 4 * (s[0] as u16 + s[2] as u16) + (s[0] as u16 + s[3] as u16); + for col in 2..width - 2 { + t[col] = 6 * s[col] as u16 + + 4 * (s[col - 1] as u16 + s[col + 1] as u16) + + (s[col - 2] as u16 + s[col + 2] as u16); + } + let c = width - 2; + t[c] = 6 * s[c] as u16 + + 4 * (s[c - 1] as u16 + s[c + 1] as u16) + + (s[c - 2] as u16 + s[c + 1] as u16); + let c = width - 1; + t[c] = 6 * s[c] as u16 + 4 * (s[c - 1] as u16 + s[c] as u16) + (s[c - 2] as u16 + s[c] as u16); +} + +/// Vertical pass of one output `row`, u16 → f32 (sum in `u32`, then +/// `* INV_256`). +#[inline] +fn binomial_v_row_u8(tmp: &[u16], dst_row: &mut [f32], row: usize, width: usize, height: usize) { + if row == 0 { + for (col, d) in dst_row.iter_mut().enumerate() { + let p = tmp[col] as u32; + let pp1 = tmp[width + col] as u32; + let pp2 = tmp[2 * width + col] as u32; + *d = ((6 * p + 4 * (p + pp1) + p + pp2) as f32) * INV_256; + } + } else if row == 1 { + for (col, d) in dst_row.iter_mut().enumerate() { + let pm = tmp[col] as u32; + let p = tmp[width + col] as u32; + let pp1 = tmp[2 * width + col] as u32; + let pp2 = tmp[3 * width + col] as u32; + *d = ((6 * p + 4 * (pm + pp1) + pm + pp2) as f32) * INV_256; + } + } else if row == height - 2 { + for (col, d) in dst_row.iter_mut().enumerate() { + let pm2 = tmp[(height - 4) * width + col] as u32; + let pm1 = tmp[(height - 3) * width + col] as u32; + let p = tmp[(height - 2) * width + col] as u32; + let pp = tmp[(height - 1) * width + col] as u32; + *d = ((6 * p + 4 * (pm1 + pp) + pm2 + pp) as f32) * INV_256; + } + } else if row == height - 1 { + for (col, d) in dst_row.iter_mut().enumerate() { + let pm2 = tmp[(height - 3) * width + col] as u32; + let pm1 = tmp[(height - 2) * width + col] as u32; + let p = tmp[(height - 1) * width + col] as u32; + *d = ((6 * p + 4 * (pm1 + p) + pm2 + p) as f32) * INV_256; + } + } else { + for (col, d) in dst_row.iter_mut().enumerate() { + let pm2 = tmp[(row - 2) * width + col] as u32; + let pm1 = tmp[(row - 1) * width + col] as u32; + let p = tmp[row * width + col] as u32; + let pp1 = tmp[(row + 1) * width + col] as u32; + let pp2 = tmp[(row + 2) * width + col] as u32; + *d = ((6 * p + 4 * (pm1 + pp1) + pm2 + pp2) as f32) * INV_256; + } + } +} + +/// 2x2 bilinear downsample of one output row: `(p00+p01+p10+p11) * 0.25`. +#[inline] +fn bilinear_row_f32(src_data: &[f32], dst_row: &mut [f32], out_row: usize, src_w: usize) { + let r0 = (out_row * 2) * src_w; + let r1 = r0 + src_w; + for (col, d) in dst_row.iter_mut().enumerate() { + let c = col * 2; + let sum = src_data[r0 + c] + src_data[r0 + c + 1] + src_data[r1 + c] + src_data[r1 + c + 1]; + *d = sum * 0.25; + } +} + +// ── u8 → f32 ───────────────────────────────────────────────────────────── + /// 5-tap separable `[1, 4, 6, 4, 1]` binomial filter, u8 source → f32 dest. /// -/// H pass uses `u16` accumulator (max value `16 * 255 = 4080`, fits `u16`). -/// V pass multiplies by `1 / 256` to yield `f32` output. Border replication: -/// edge pixels extend the 2-pixel border on each side. +/// Dispatches to the rayon path above [`PARALLEL_MIN_PIXELS`] (non-wasm), +/// else scalar. Output is identical either way. +/// +/// # Note on visibility +/// +/// This dispatcher, the `*_scalar` / `*_rayon` variants, and +/// [`downsample_bilinear_f32`] are `pub` so the criterion benchmark (a +/// separate crate) can measure them directly. They are not a stability +/// guarantee — prefer [`GaussianScaleSpacePyramid::build`]. +#[must_use] +pub fn binomial_4th_order_u8_to_f32(src: &Matrix) -> Matrix { + #[cfg(not(target_arch = "wasm32"))] + { + if src.rows.saturating_mul(src.cols) >= PARALLEL_MIN_PIXELS { + return binomial_4th_order_u8_to_f32_rayon(src); + } + } + binomial_4th_order_u8_to_f32_scalar(src) +} + +/// Serial u8 → f32 binomial filter. H pass uses `u16` accumulators (max +/// `16 * 255 = 4080`); V pass sums in `u32` then `* 1/256`. Border +/// replication extends edge pixels by 2 on each side. /// /// C equivalent: `vision::binomial_4th_order(float*, unsigned short*, const unsigned char*, ...)`. -fn binomial_4th_order_u8_to_f32(src: &Matrix) -> Matrix { +#[must_use] +pub fn binomial_4th_order_u8_to_f32_scalar(src: &Matrix) -> Matrix { let width = src.cols; let height = src.rows; debug_assert!(width >= 5 && height >= 5); let src_data = src.as_slice(); let mut tmp = vec![0u16; width * height]; - - // Horizontal pass. for row in 0..height { - let row_off = row * width; - let s = &src_data[row_off..row_off + width]; - // col 0: xm2 = xm1 = c = s[0], xp1 = s[1], xp2 = s[2]. - tmp[row_off] = - 6 * s[0] as u16 + 4 * (s[0] as u16 + s[1] as u16) + (s[0] as u16 + s[2] as u16); - // col 1: xm2 = s[0], xm1 = s[0], c = s[1], xp1 = s[2], xp2 = s[3]. - tmp[row_off + 1] = - 6 * s[1] as u16 + 4 * (s[0] as u16 + s[2] as u16) + (s[0] as u16 + s[3] as u16); - // Non-border cols. - for col in 2..width - 2 { - tmp[row_off + col] = 6 * s[col] as u16 - + 4 * (s[col - 1] as u16 + s[col + 1] as u16) - + (s[col - 2] as u16 + s[col + 2] as u16); - } - // col width-2: xp2 clamped to s[width-1]. - let c = width - 2; - tmp[row_off + c] = 6 * s[c] as u16 - + 4 * (s[c - 1] as u16 + s[c + 1] as u16) - + (s[c - 2] as u16 + s[c + 1] as u16); - // col width-1: xp1 = xp2 = s[width-1]. - let c = width - 1; - tmp[row_off + c] = - 6 * s[c] as u16 + 4 * (s[c - 1] as u16 + s[c] as u16) + (s[c - 2] as u16 + s[c] as u16); - } - - // Vertical pass. - let mut dst_data = vec![0f32; width * height]; - const INV_256: f32 = 1.0 / 256.0; - - // Top border: rows 0 and 1. - for col in 0..width { - // row 0: pm2 = pm1 = p = tmp[col]; pp1 = tmp[width+col]; pp2 = tmp[2w+col]. - let p = tmp[col] as u32; - let pp1 = tmp[width + col] as u32; - let pp2 = tmp[2 * width + col] as u32; - dst_data[col] = ((6 * p + 4 * (p + pp1) + p + pp2) as f32) * INV_256; - // row 1: pm2 = pm1 = tmp[col]; p = tmp[w+col]; pp1 = tmp[2w+col]; pp2 = tmp[3w+col]. - let pm = tmp[col] as u32; - let p = tmp[width + col] as u32; - let pp1 = tmp[2 * width + col] as u32; - let pp2 = tmp[3 * width + col] as u32; - dst_data[width + col] = ((6 * p + 4 * (pm + pp1) + pm + pp2) as f32) * INV_256; - } - - // Non-border rows. - for row in 2..height - 2 { - let row_off = row * width; - for col in 0..width { - let pm2 = tmp[(row - 2) * width + col] as u32; - let pm1 = tmp[(row - 1) * width + col] as u32; - let p = tmp[row_off + col] as u32; - let pp1 = tmp[(row + 1) * width + col] as u32; - let pp2 = tmp[(row + 2) * width + col] as u32; - dst_data[row_off + col] = ((6 * p + 4 * (pm1 + pp1) + pm2 + pp2) as f32) * INV_256; - } + let off = row * width; + binomial_h_row_u8( + &src_data[off..off + width], + &mut tmp[off..off + width], + width, + ); } - // Bottom border: rows h-2 and h-1. - let h = height; - for col in 0..width { - // row h-2: pp1 = pp2 = tmp[(h-1)*w + col]. - let pm2 = tmp[(h - 4) * width + col] as u32; - let pm1 = tmp[(h - 3) * width + col] as u32; - let p = tmp[(h - 2) * width + col] as u32; - let pp = tmp[(h - 1) * width + col] as u32; - dst_data[(h - 2) * width + col] = ((6 * p + 4 * (pm1 + pp) + pm2 + pp) as f32) * INV_256; - // row h-1: p = pp1 = pp2 = tmp[(h-1)*w + col]. - let pm2 = tmp[(h - 3) * width + col] as u32; - let pm1 = tmp[(h - 2) * width + col] as u32; - let p = tmp[(h - 1) * width + col] as u32; - dst_data[(h - 1) * width + col] = ((6 * p + 4 * (pm1 + p) + pm2 + p) as f32) * INV_256; + let mut dst_data = vec![0f32; width * height]; + for row in 0..height { + let off = row * width; + binomial_v_row_u8(&tmp, &mut dst_data[off..off + width], row, width, height); } Matrix::::from_vec(height, width, 1, dst_data) } -/// 5-tap separable `[1, 4, 6, 4, 1]` binomial filter, f32 → f32. Used for -/// the second and third levels within an octave and for all levels in -/// non-zero octaves. +/// Rayon u8 → f32 binomial filter — parallel over rows. Bit-for-bit +/// identical to [`binomial_4th_order_u8_to_f32_scalar`] (rows independent). +#[cfg(not(target_arch = "wasm32"))] +#[must_use] +pub fn binomial_4th_order_u8_to_f32_rayon(src: &Matrix) -> Matrix { + use rayon::prelude::*; + + let width = src.cols; + let height = src.rows; + debug_assert!(width >= 5 && height >= 5); + + let src_data = src.as_slice(); + let mut tmp = vec![0u16; width * height]; + tmp.par_chunks_mut(width) + .zip(src_data.par_chunks(width)) + .for_each(|(t_row, s_row)| binomial_h_row_u8(s_row, t_row, width)); + + let mut dst_data = vec![0f32; width * height]; + dst_data + .par_chunks_mut(width) + .enumerate() + .for_each(|(row, dst_row)| binomial_v_row_u8(&tmp, dst_row, row, width, height)); + + Matrix::::from_vec(height, width, 1, dst_data) +} + +// ── f32 → f32 ──────────────────────────────────────────────────────────── + +/// 5-tap separable `[1, 4, 6, 4, 1]` binomial filter, f32 → f32. +/// +/// Dispatches to the rayon path above [`PARALLEL_MIN_PIXELS`] (non-wasm), +/// else scalar. Output is identical either way. +#[must_use] +pub fn binomial_4th_order_f32_to_f32(src: &Matrix) -> Matrix { + #[cfg(not(target_arch = "wasm32"))] + { + if src.rows.saturating_mul(src.cols) >= PARALLEL_MIN_PIXELS { + return binomial_4th_order_f32_to_f32_rayon(src); + } + } + binomial_4th_order_f32_to_f32_scalar(src) +} + +/// Serial f32 → f32 binomial filter. Used for scales 1 and 2 within an +/// octave and for all scales in non-zero octaves. /// /// C equivalent: `vision::binomial_4th_order(float*, float*, const float*, ...)`. -fn binomial_4th_order_f32_to_f32(src: &Matrix) -> Matrix { +#[must_use] +pub fn binomial_4th_order_f32_to_f32_scalar(src: &Matrix) -> Matrix { let width = src.cols; let height = src.rows; debug_assert!(width >= 5 && height >= 5); let src_data = src.as_slice(); let mut tmp = vec![0f32; width * height]; - - // Horizontal pass. Addition order matches C++ left-to-right exactly to - // preserve f32 bit-for-bit parity (no parenthesizing of the (ll + rr) term). for row in 0..height { - let row_off = row * width; - let s = &src_data[row_off..row_off + width]; - tmp[row_off] = 6.0 * s[0] + 4.0 * (s[0] + s[1]) + s[0] + s[2]; - tmp[row_off + 1] = 6.0 * s[1] + 4.0 * (s[0] + s[2]) + s[0] + s[3]; - for col in 2..width - 2 { - tmp[row_off + col] = - 6.0 * s[col] + 4.0 * (s[col - 1] + s[col + 1]) + s[col - 2] + s[col + 2]; - } - let c = width - 2; - tmp[row_off + c] = 6.0 * s[c] + 4.0 * (s[c - 1] + s[c + 1]) + s[c - 2] + s[c + 1]; - let c = width - 1; - tmp[row_off + c] = 6.0 * s[c] + 4.0 * (s[c - 1] + s[c]) + s[c - 2] + s[c]; + let off = row * width; + binomial_h_row_f32( + &src_data[off..off + width], + &mut tmp[off..off + width], + width, + ); } - // Vertical pass. let mut dst_data = vec![0f32; width * height]; - const INV_256: f32 = 1.0 / 256.0; - - for col in 0..width { - // row 0 - let p = tmp[col]; - let pp1 = tmp[width + col]; - let pp2 = tmp[2 * width + col]; - dst_data[col] = (6.0 * p + 4.0 * (p + pp1) + p + pp2) * INV_256; - // row 1 - let pm = tmp[col]; - let p = tmp[width + col]; - let pp1 = tmp[2 * width + col]; - let pp2 = tmp[3 * width + col]; - dst_data[width + col] = (6.0 * p + 4.0 * (pm + pp1) + pm + pp2) * INV_256; - } - - for row in 2..height - 2 { - let row_off = row * width; - for col in 0..width { - let pm2 = tmp[(row - 2) * width + col]; - let pm1 = tmp[(row - 1) * width + col]; - let p = tmp[row_off + col]; - let pp1 = tmp[(row + 1) * width + col]; - let pp2 = tmp[(row + 2) * width + col]; - dst_data[row_off + col] = (6.0 * p + 4.0 * (pm1 + pp1) + pm2 + pp2) * INV_256; - } + for row in 0..height { + let off = row * width; + binomial_v_row_f32(&tmp, &mut dst_data[off..off + width], row, width, height); } - let h = height; - for col in 0..width { - let pm2 = tmp[(h - 4) * width + col]; - let pm1 = tmp[(h - 3) * width + col]; - let p = tmp[(h - 2) * width + col]; - let pp = tmp[(h - 1) * width + col]; - dst_data[(h - 2) * width + col] = (6.0 * p + 4.0 * (pm1 + pp) + pm2 + pp) * INV_256; - let pm2 = tmp[(h - 3) * width + col]; - let pm1 = tmp[(h - 2) * width + col]; - let p = tmp[(h - 1) * width + col]; - dst_data[(h - 1) * width + col] = (6.0 * p + 4.0 * (pm1 + p) + pm2 + p) * INV_256; - } + Matrix::::from_vec(height, width, 1, dst_data) +} + +/// Rayon f32 → f32 binomial filter — parallel over rows. Bit-for-bit +/// identical to [`binomial_4th_order_f32_to_f32_scalar`] (rows independent). +#[cfg(not(target_arch = "wasm32"))] +#[must_use] +pub fn binomial_4th_order_f32_to_f32_rayon(src: &Matrix) -> Matrix { + use rayon::prelude::*; + + let width = src.cols; + let height = src.rows; + debug_assert!(width >= 5 && height >= 5); + + let src_data = src.as_slice(); + let mut tmp = vec![0f32; width * height]; + tmp.par_chunks_mut(width) + .zip(src_data.par_chunks(width)) + .for_each(|(t_row, s_row)| binomial_h_row_f32(s_row, t_row, width)); + + let mut dst_data = vec![0f32; width * height]; + dst_data + .par_chunks_mut(width) + .enumerate() + .for_each(|(row, dst_row)| binomial_v_row_f32(&tmp, dst_row, row, width, height)); Matrix::::from_vec(height, width, 1, dst_data) } +// ── bilinear downsample ────────────────────────────────────────────────── + /// 2x2 bilinear downsample. Output dims: `(src.rows >> 1, src.cols >> 1)`. -/// Per output pixel: `(p00 + p01 + p10 + p11) * 0.25`. No `ceil` adjustment -/// (unlike the M8-1 box-filter pyramid). +/// +/// Dispatches to the rayon path above [`PARALLEL_MIN_PIXELS`] (non-wasm), +/// else scalar. +#[must_use] +pub fn downsample_bilinear_f32(src: &Matrix) -> Matrix { + #[cfg(not(target_arch = "wasm32"))] + { + if src.rows.saturating_mul(src.cols) >= PARALLEL_MIN_PIXELS { + return downsample_bilinear_f32_rayon(src); + } + } + downsample_bilinear_f32_scalar(src) +} + +/// Serial 2x2 bilinear downsample. Per output pixel: +/// `(p00 + p01 + p10 + p11) * 0.25`. No `ceil` adjustment (unlike the M8-1 +/// box-filter pyramid). /// /// C equivalent: `vision::downsample_bilinear`. -fn downsample_bilinear_f32(src: &Matrix) -> Matrix { +#[must_use] +pub fn downsample_bilinear_f32_scalar(src: &Matrix) -> Matrix { let dst_w = src.cols >> 1; let dst_h = src.rows >> 1; let src_data = src.as_slice(); let src_w = src.cols; - let mut dst_data = Vec::::with_capacity(dst_w * dst_h); + let mut dst_data = vec![0f32; dst_w * dst_h]; for row in 0..dst_h { - let r0 = (row * 2) * src_w; - let r1 = r0 + src_w; - for col in 0..dst_w { - let c = col * 2; - let sum = - src_data[r0 + c] + src_data[r0 + c + 1] + src_data[r1 + c] + src_data[r1 + c + 1]; - dst_data.push(sum * 0.25); - } + bilinear_row_f32( + src_data, + &mut dst_data[row * dst_w..(row + 1) * dst_w], + row, + src_w, + ); } Matrix::::from_vec(dst_h, dst_w, 1, dst_data) } +/// Rayon 2x2 bilinear downsample — parallel over output rows. Bit-for-bit +/// identical to [`downsample_bilinear_f32_scalar`]. +#[cfg(not(target_arch = "wasm32"))] +#[must_use] +pub fn downsample_bilinear_f32_rayon(src: &Matrix) -> Matrix { + use rayon::prelude::*; + + let dst_w = src.cols >> 1; + let dst_h = src.rows >> 1; + let src_data = src.as_slice(); + let src_w = src.cols; + + let mut dst_data = vec![0f32; dst_w * dst_h]; + dst_data + .par_chunks_mut(dst_w) + .enumerate() + .for_each(|(out_row, dst_row)| bilinear_row_f32(src_data, dst_row, out_row, src_w)); + Matrix::::from_vec(dst_h, dst_w, 1, dst_data) +} + #[cfg(test)] mod tests { use super::*; @@ -476,6 +660,115 @@ mod tests { Matrix::::from_vec(rows, cols, 1, data) } + // ── rayon parity (#207) ────────────────────────────────────────────── + + /// Deterministic random u8 / f32 images, seeded so failures reproduce. + #[cfg(not(target_arch = "wasm32"))] + fn rng_u8(rows: usize, cols: usize, seed: u64) -> Matrix { + use rand::{Rng, SeedableRng}; + let mut rng = rand::rngs::StdRng::seed_from_u64(seed); + let data: Vec = (0..rows * cols).map(|_| rng.random::()).collect(); + Matrix::::from_vec(rows, cols, 1, data) + } + + #[cfg(not(target_arch = "wasm32"))] + fn rng_f32(rows: usize, cols: usize, seed: u64) -> Matrix { + use rand::{Rng, SeedableRng}; + let mut rng = rand::rngs::StdRng::seed_from_u64(seed); + let data: Vec = (0..rows * cols) + .map(|_| rng.random::() as f32) + .collect(); + Matrix::::from_vec(rows, cols, 1, data) + } + + /// Sizes spanning below, at, and above `PARALLEL_MIN_PIXELS`, with odd + /// dimensions and small images. All `>= 5x5`. + #[cfg(not(target_arch = "wasm32"))] + const RAYON_PARITY_SIZES: &[(usize, usize)] = &[ + (5, 5), + (33, 31), + (64, 64), + (256, 256), + (300, 301), + (480, 640), + (513, 511), + ]; + + #[cfg(not(target_arch = "wasm32"))] + fn assert_f32_bits_eq(a: &Matrix, b: &Matrix, what: &str) { + assert_eq!(a.as_slice().len(), b.as_slice().len(), "{what}: size"); + for (i, (&x, &y)) in a.as_slice().iter().zip(b.as_slice().iter()).enumerate() { + assert_eq!(x.to_bits(), y.to_bits(), "{what}: bit mismatch at {i}"); + } + } + + #[cfg(not(target_arch = "wasm32"))] + #[cfg_attr(miri, ignore)] // #207: rayon parity over large images — too slow under Miri + #[test] + fn test_binomial_f32_rayon_matches_scalar() { + for (i, &(rows, cols)) in RAYON_PARITY_SIZES.iter().enumerate() { + let src = rng_f32(rows, cols, 0x00F3_2A00 + i as u64); + let scalar = binomial_4th_order_f32_to_f32_scalar(&src); + let rayon = binomial_4th_order_f32_to_f32_rayon(&src); + assert_f32_bits_eq( + &scalar, + &rayon, + &format!("binomial_f32 rayon {rows}x{cols}"), + ); + } + } + + #[cfg(not(target_arch = "wasm32"))] + #[cfg_attr(miri, ignore)] // #207: rayon parity over large images — too slow under Miri + #[test] + fn test_binomial_u8_rayon_matches_scalar() { + for (i, &(rows, cols)) in RAYON_PARITY_SIZES.iter().enumerate() { + let src = rng_u8(rows, cols, 0x00C8_2A00 + i as u64); + let scalar = binomial_4th_order_u8_to_f32_scalar(&src); + let rayon = binomial_4th_order_u8_to_f32_rayon(&src); + assert_f32_bits_eq(&scalar, &rayon, &format!("binomial_u8 rayon {rows}x{cols}")); + } + } + + #[cfg(not(target_arch = "wasm32"))] + #[cfg_attr(miri, ignore)] // #207: rayon parity over large images — too slow under Miri + #[test] + fn test_bilinear_rayon_matches_scalar() { + for (i, &(rows, cols)) in RAYON_PARITY_SIZES.iter().enumerate() { + let src = rng_f32(rows, cols, 0x00B1_2A00 + i as u64); + let scalar = downsample_bilinear_f32_scalar(&src); + let rayon = downsample_bilinear_f32_rayon(&src); + assert_f32_bits_eq(&scalar, &rayon, &format!("bilinear rayon {rows}x{cols}")); + } + } + + /// The dispatchers (which pick rayon above the threshold) must agree with + /// the scalar path, including for a large image that triggers rayon. + #[cfg(not(target_arch = "wasm32"))] + #[cfg_attr(miri, ignore)] // #207: rayon parity over large images — too slow under Miri + #[test] + fn test_dispatchers_match_scalar() { + // 800×800 = 640k px > PARALLEL_MIN_PIXELS, so the dispatchers take the + // rayon path here. + let f = rng_f32(800, 800, 0x0D15_2A00); + assert_f32_bits_eq( + &binomial_4th_order_f32_to_f32_scalar(&f), + &binomial_4th_order_f32_to_f32(&f), + "binomial_f32 dispatch", + ); + assert_f32_bits_eq( + &downsample_bilinear_f32_scalar(&f), + &downsample_bilinear_f32(&f), + "bilinear dispatch", + ); + let u = rng_u8(800, 800, 0x0D15_2A01); + assert_f32_bits_eq( + &binomial_4th_order_u8_to_f32_scalar(&u), + &binomial_4th_order_u8_to_f32(&u), + "binomial_u8 dispatch", + ); + } + // ── Configuration sanity ───────────────────────────────────────────── #[test] diff --git a/crates/core/src/kpm/freak/homography.rs b/crates/core/src/kpm/freak/homography.rs index c5e9184..e55a901 100644 --- a/crates/core/src/kpm/freak/homography.rs +++ b/crates/core/src/kpm/freak/homography.rs @@ -2358,8 +2358,14 @@ mod tests { assert!(approx_eq(c, std::f32::consts::LN_2, 1e-6)); // 2D with x=3, y=4, 1/σ²=1: log(1 + (9+16)) = log(26) ≈ 3.2580965 + // Tolerance is 1e-5 (not 1e-6) because Miri's f32::ln impl is + // non-deterministic by a few ULPs across calls within a single + // run — the function calls .ln() internally and the assert calls + // it again on the RHS, so the two results can diverge slightly. + // 1e-5 is still well within f32 precision (~7 decimal digits at + // this magnitude) and tests mathematical correctness. let c2 = cauchy_cost_2d(3.0, 4.0, 1.0); - assert!(approx_eq(c2, 26.0_f32.ln(), 1e-6)); + assert!(approx_eq(c2, 26.0_f32.ln(), 1e-5)); } #[test] @@ -2642,7 +2648,7 @@ mod tests { // Verify: each point projects to itself (after dividing by w) for pt in &[&p1, &p2, &p3, &p4] { let mut out = [0.0_f32; 2]; - multiply_point_homography_inhomogenous(&mut out, &h, *pt); + multiply_point_homography_inhomogenous(&mut out, &h, pt); assert!( approx_eq(out[0], pt[0], 1e-3) && approx_eq(out[1], pt[1], 1e-3), "identity DLT failed for pt={:?}, got out={:?}", @@ -2813,7 +2819,10 @@ mod tests { // and the final homography agrees element-wise within accumulated float // rounding (~1e-5). -#[cfg(feature = "dual-mode")] +// Only ever called from `#[cfg(all(test, feature = "dual-mode"))]` +// modules below; gate the extern declaration the same way so the lib +// target under --all-features doesn't see a phantom dead symbol (#180). +#[cfg(all(test, feature = "dual-mode"))] extern "C" { fn webarkit_cpp_mat3_exp_pade_via_eigen(out: *mut f32, input: *const f32); diff --git a/crates/core/src/kpm/freak/hough.rs b/crates/core/src/kpm/freak/hough.rs index e03629c..0ee4b32 100644 --- a/crates/core/src/kpm/freak/hough.rs +++ b/crates/core/src/kpm/freak/hough.rs @@ -1195,7 +1195,10 @@ mod tests { // the algorithm level, separately from the end-to-end VisualDatabase // parity gate in `visual_database.rs`. -#[cfg(feature = "dual-mode")] +// Only ever called from `#[cfg(all(test, feature = "dual-mode"))]` +// modules below; gate the extern declaration the same way so the lib +// target under --all-features doesn't see a phantom dead symbol (#180). +#[cfg(all(test, feature = "dual-mode"))] extern "C" { /// M9 #150: reimplementation of `HoughSimilarityVoting::autoAdjustXYNumBins` /// in `kpm_c_api.cpp` (the C++ method is private, so the shim ports the diff --git a/crates/core/src/kpm/freak/keyframe.rs b/crates/core/src/kpm/freak/keyframe.rs index 9a6e1b0..6217b5c 100644 --- a/crates/core/src/kpm/freak/keyframe.rs +++ b/crates/core/src/kpm/freak/keyframe.rs @@ -171,6 +171,7 @@ mod tests { } #[test] + #[cfg_attr(miri, ignore)] // #194: full pyramid + DoG pipeline on real image — too slow under Miri fn test_find_features_populates_keyframe() { let img = load_grayscale("../../benchmarks/data/found.jpg"); let mut pyr = GaussianScaleSpacePyramid::new(3); @@ -197,6 +198,7 @@ mod tests { } #[test] + #[cfg_attr(miri, ignore)] // #194: full pyramid + DoG + BHC pipeline on real image — too slow under Miri fn test_keyframe_build_index_populates_index() { let img = load_grayscale("../../benchmarks/data/found.jpg"); let mut pyr = GaussianScaleSpacePyramid::new(3); @@ -215,6 +217,7 @@ mod tests { } #[test] + #[cfg_attr(miri, ignore)] // #194: full pipeline run twice — too slow under Miri fn test_keyframe_build_index_is_idempotent() { // Calling build_index twice should not error; the second call replaces // the first cleanly (mirrors C++ Keyframe::buildIndex which always diff --git a/crates/core/src/kpm/freak/matcher.rs b/crates/core/src/kpm/freak/matcher.rs index e730f4d..acb739c 100644 --- a/crates/core/src/kpm/freak/matcher.rs +++ b/crates/core/src/kpm/freak/matcher.rs @@ -133,6 +133,22 @@ impl FeatureStore { pub fn bytes_per_feature(&self) -> usize { self.bytes_per_feature } + + /// Returns all feature points as a slice. + /// + /// Slice counterpart to [`point`](Self::point) — avoids forcing callers to + /// loop `0..num_features()` element-by-element (facade parity, #148). + pub fn points(&self) -> &[FeaturePoint] { + &self.points + } + + /// Returns the flat descriptor buffer (`num_features() * bytes_per_feature()` + /// bytes, descriptor `i` at `[i * bpf .. (i+1) * bpf]`). + /// + /// Slice counterpart to [`descriptor`](Self::descriptor) (#148). + pub fn descriptors(&self) -> &[u8] { + &self.descriptors + } } // ============================================================================ @@ -583,6 +599,27 @@ mod tests { assert!(!s.point(2).maxima); } + #[test] + fn test_feature_store_slice_accessors() { + let mut s = FeatureStore::new(96).unwrap(); + let d0 = make_descriptor(0); + let d1 = make_descriptor(1); + s.add(fp(0.0, 0.0, true), &d0).unwrap(); + s.add(fp(1.0, 1.0, false), &d1).unwrap(); + + // points() slice agrees with per-element point(i). + let pts = s.points(); + assert_eq!(pts.len(), 2); + assert_eq!(pts[0].x, s.point(0).x); + assert!(!pts[1].maxima); + + // descriptors() flat buffer agrees with per-element descriptor(i). + let descs = s.descriptors(); + assert_eq!(descs.len(), 2 * 96); + assert_eq!(&descs[0..96], s.descriptor(0)); + assert_eq!(&descs[96..192], s.descriptor(1)); + } + #[test] fn test_feature_store_add_wrong_size() { let mut s = FeatureStore::new(96).unwrap(); @@ -805,7 +842,10 @@ mod tests { // assert that the match counts agree within a small tolerance (BHC RNG // differences and minor floating-point order can cause small variation). -#[cfg(feature = "dual-mode")] +// Only ever called from `#[cfg(all(test, feature = "dual-mode"))]` +// modules below; gate the extern declaration the same way so the lib +// target under --all-features doesn't see a phantom dead symbol (#180). +#[cfg(all(test, feature = "dual-mode"))] extern "C" { fn webarkit_cpp_match_features_brute( query_descs: *const u8, diff --git a/crates/core/src/kpm/freak/math.rs b/crates/core/src/kpm/freak/math.rs index 10aa581..59800f9 100644 --- a/crates/core/src/kpm/freak/math.rs +++ b/crates/core/src/kpm/freak/math.rs @@ -1651,7 +1651,7 @@ mod tests { #[test] fn test_fast_atan2_360() { let result = fast_atan2_360(0.0, 1.0); - assert!(result >= 0.0 && result <= 360.0); + assert!((0.0..=360.0).contains(&result)); } #[test] @@ -2106,7 +2106,10 @@ mod tests { // the input domain and assert that the pure-Rust ports produce results within // a small tolerance of the C++ baseline. -#[cfg(feature = "dual-mode")] +// Only ever called from `#[cfg(all(test, feature = "dual-mode"))]` +// modules below; gate the extern declaration the same way so the lib +// target under --all-features doesn't see a phantom dead symbol (#180). +#[cfg(all(test, feature = "dual-mode"))] extern "C" { // M6-1 (#63) — math_utils.h fn webarkit_cpp_fast_atan2(y: f32, x: f32) -> f32; diff --git a/crates/core/src/kpm/freak/pyramid.rs b/crates/core/src/kpm/freak/pyramid.rs index cf27206..51551a1 100644 --- a/crates/core/src/kpm/freak/pyramid.rs +++ b/crates/core/src/kpm/freak/pyramid.rs @@ -36,6 +36,12 @@ //! Image pyramid built by repeated box-filter downsampling. //! +//! **Not currently wired into any pipeline** (kept as reference — see #203). +//! This is a faithful, SIMD-accelerated port of the C++ `BoxFilterPyramid8u`, +//! which is itself unused in the original. The live KPM/FREAK detector builds +//! its scale space with [`super::gaussian_pyramid::GaussianScaleSpacePyramid`] +//! instead. Retained as a tested reference implementation. +//! //! Ported from `WebARKitLib/lib/SRC/KPM/FreakMatcher/detectors/pyramid.{h,cpp}` //! (`BoxFilterPyramid8u` / `BoxFilterDecimate`). //! @@ -187,6 +193,79 @@ impl Pyramid { /// 2x2 box-filter downsample, byte-identical to C++ `BoxFilterDecimate`. /// +/// Dispatches to the fastest available implementation at runtime +/// (AVX2 → SSE4.1 on x86_64, simd128 on wasm32), falling back to +/// [`downsample_scalar`]. Every path produces bit-for-bit identical +/// output to the scalar baseline — see the property tests below. +/// +/// Output dimensions: `new_h = ceil((src.rows - 1) / 2)`, +/// `new_w = ceil((src.cols - 1) / 2)`. +/// +/// # Note on visibility +/// +/// `downsample` / `downsample_scalar` (and the SIMD variants) are `pub` +/// so the criterion benchmark (a separate crate) can measure them +/// directly. They are not part of a stability guarantee — prefer +/// [`Pyramid::build`]. +#[must_use] +pub fn downsample(src: &Matrix) -> Matrix { + #[cfg(all(target_arch = "x86_64", feature = "simd-x86-avx2"))] + { + if is_x86_feature_detected!("avx2") { + // SAFETY: the `avx2` target feature is confirmed present at + // runtime, satisfying the precondition of `downsample_avx2`. + return unsafe { downsample_avx2(src) }; + } + } + #[cfg(all(target_arch = "x86_64", feature = "simd-x86-sse41"))] + { + if is_x86_feature_detected!("sse4.1") { + // SAFETY: the `sse4.1` target feature is confirmed present at + // runtime, satisfying the precondition of `downsample_sse41`. + return unsafe { downsample_sse41(src) }; + } + } + #[cfg(all( + target_arch = "wasm32", + feature = "simd-wasm32", + target_feature = "simd128" + ))] + { + // SAFETY: `simd128` is guaranteed by the `target_feature` cfg gate, + // so the intrinsics in `downsample_wasm` are always valid here. + return unsafe { downsample_wasm(src) }; + } + + #[allow(unreachable_code)] + downsample_scalar(src) +} + +/// Output dimensions of a single downsample step: `(new_h, new_w)`. +#[inline] +fn downsample_dims(src: &Matrix) -> (usize, usize) { + let new_h = (src.rows.saturating_sub(1) as f32 / 2.0).ceil() as usize; + let new_w = (src.cols.saturating_sub(1) as f32 / 2.0).ceil() as usize; + (new_h, new_w) +} + +/// Scalar fill of one output row for output columns `start..dst_row.len()`. +/// +/// Shared by the scalar implementation and the SIMD remainder tails so the +/// rounding (`(a + b + c + d + 2) >> 2`) is defined in exactly one place. +#[inline] +fn downsample_row_tail_scalar(row0: &[u8], row1: &[u8], dst_row: &mut [u8], start: usize) { + for (out_col, dst) in dst_row.iter_mut().enumerate().skip(start) { + let c = out_col * 2; + // u16 promotion prevents u8 wrap; max sum = 4*255 + 2 = 1022. + let sum: u16 = + row0[c] as u16 + row0[c + 1] as u16 + row1[c] as u16 + row1[c + 1] as u16 + 2; + *dst = (sum >> 2) as u8; + } +} + +/// Scalar 2x2 box-filter downsample, byte-identical to C++ +/// `BoxFilterDecimate`. +/// /// Output dimensions: `new_h = ceil((src.rows - 1) / 2)`, /// `new_w = ceil((src.cols - 1) / 2)`. /// @@ -194,28 +273,243 @@ impl Pyramid { /// to avoid overflow), add 2 for rounding, then shift right by 2. This /// is the combined effect of `HorizontalBoxFilterDecimate` plus /// `VerticalBoxFilter` from `pyramid-inline.h`. -fn downsample(src: &Matrix) -> Matrix { - let new_h = (src.rows.saturating_sub(1) as f32 / 2.0).ceil() as usize; - let new_w = (src.cols.saturating_sub(1) as f32 / 2.0).ceil() as usize; +#[must_use] +pub fn downsample_scalar(src: &Matrix) -> Matrix { + let (new_h, new_w) = downsample_dims(src); let src_data = src.as_slice(); let src_cols = src.cols; - let mut dst_data = Vec::::with_capacity(new_h * new_w); + let mut dst_data = vec![0u8; new_h * new_w]; for out_row in 0..new_h { let r0 = (out_row * 2) * src_cols; let r1 = r0 + src_cols; let row0 = &src_data[r0..r0 + src_cols]; let row1 = &src_data[r1..r1 + src_cols]; + let dst_row = &mut dst_data[out_row * new_w..(out_row + 1) * new_w]; + downsample_row_tail_scalar(row0, row1, dst_row, 0); + } + + Matrix::::from_vec(new_h, new_w, 1, dst_data) +} - for out_col in 0..new_w { - let c = out_col * 2; - // u16 promotion prevents u8 wrap; max sum = 4*255 + 2 = 1022. - let sum: u16 = - row0[c] as u16 + row0[c + 1] as u16 + row1[c] as u16 + row1[c + 1] as u16 + 2; - dst_data.push((sum >> 2) as u8); +/// AVX2 2x2 box-filter downsample. Processes 32 output columns per +/// iteration (64 source columns) using 256-bit lanes; the remaining +/// columns of each row fall back to [`downsample_row_tail_scalar`]. +/// +/// Output is byte-identical to [`downsample_scalar`]. +/// +/// # Safety +/// +/// The caller must ensure the `avx2` target feature is available at +/// runtime (the [`downsample`] dispatcher guarantees this via +/// `is_x86_feature_detected!`). +#[cfg(all(target_arch = "x86_64", feature = "simd-x86-avx2"))] +#[target_feature(enable = "avx2")] +#[must_use] +pub unsafe fn downsample_avx2(src: &Matrix) -> Matrix { + use std::arch::x86_64::*; + + let (new_h, new_w) = downsample_dims(src); + let src_data = src.as_slice(); + let src_cols = src.cols; + let mut dst_data = vec![0u8; new_h * new_w]; + + // Number of leading output columns we can serve with full 32-wide + // blocks. A block at output col `o` reads source cols `[2o, 2o + 64)` + // and writes `[o, o + 32)`; both stay in bounds while + // `2 * (o + 32) <= src_cols` (which also implies `o + 32 <= new_w`). + let simd_cols = if src_cols >= 64 { + ((src_cols - 64) / 2 / 32) * 32 + 32 + } else { + 0 + }; + + let ones = _mm256_set1_epi8(1); + let two = _mm256_set1_epi16(2); + + for out_row in 0..new_h { + let r0 = (out_row * 2) * src_cols; + let r1 = r0 + src_cols; + let row0 = &src_data[r0..r0 + src_cols]; + let row1 = &src_data[r1..r1 + src_cols]; + let dst_row = &mut dst_data[out_row * new_w..(out_row + 1) * new_w]; + + let mut o = 0; + while o < simd_cols { + let c = o * 2; + // SAFETY: `c + 64 <= src_cols` by construction of `simd_cols`, + // and `o + 32 <= new_w`, so all loads/stores are in bounds. + let a0 = _mm256_loadu_si256(row0.as_ptr().add(c) as *const __m256i); + let a1 = _mm256_loadu_si256(row0.as_ptr().add(c + 32) as *const __m256i); + let b0 = _mm256_loadu_si256(row1.as_ptr().add(c) as *const __m256i); + let b1 = _mm256_loadu_si256(row1.as_ptr().add(c + 32) as *const __m256i); + + // Horizontal pairwise sums of adjacent bytes -> u16 lanes. + let h0lo = _mm256_maddubs_epi16(a0, ones); + let h0hi = _mm256_maddubs_epi16(a1, ones); + let h1lo = _mm256_maddubs_epi16(b0, ones); + let h1hi = _mm256_maddubs_epi16(b1, ones); + + // Vertical: (h0 + h1 + 2) >> 2. + let slo = _mm256_srli_epi16(_mm256_add_epi16(_mm256_add_epi16(h0lo, h1lo), two), 2); + let shi = _mm256_srli_epi16(_mm256_add_epi16(_mm256_add_epi16(h0hi, h1hi), two), 2); + + // packus works per 128-bit lane, interleaving the halves; + // permute the 64-bit chunks back into sequential order. + let packed = _mm256_packus_epi16(slo, shi); + let out = _mm256_permute4x64_epi64(packed, 0xD8); + _mm256_storeu_si256(dst_row.as_mut_ptr().add(o) as *mut __m256i, out); + + o += 32; } + + downsample_row_tail_scalar(row0, row1, dst_row, simd_cols); + } + + Matrix::::from_vec(new_h, new_w, 1, dst_data) +} + +/// SSE4.1 2x2 box-filter downsample. Processes 16 output columns per +/// iteration (32 source columns) using 128-bit lanes; the remaining +/// columns of each row fall back to [`downsample_row_tail_scalar`]. +/// +/// Output is byte-identical to [`downsample_scalar`]. +/// +/// # Safety +/// +/// The caller must ensure the `sse4.1` target feature is available at +/// runtime (the [`downsample`] dispatcher guarantees this via +/// `is_x86_feature_detected!`). +#[cfg(all(target_arch = "x86_64", feature = "simd-x86-sse41"))] +#[target_feature(enable = "sse4.1")] +#[must_use] +pub unsafe fn downsample_sse41(src: &Matrix) -> Matrix { + use std::arch::x86_64::*; + + let (new_h, new_w) = downsample_dims(src); + let src_data = src.as_slice(); + let src_cols = src.cols; + let mut dst_data = vec![0u8; new_h * new_w]; + + // A block at output col `o` reads source cols `[2o, 2o + 32)` and + // writes `[o, o + 16)`; safe while `2 * (o + 16) <= src_cols`. + let simd_cols = if src_cols >= 32 { + ((src_cols - 32) / 2 / 16) * 16 + 16 + } else { + 0 + }; + + let ones = _mm_set1_epi8(1); + let two = _mm_set1_epi16(2); + + for out_row in 0..new_h { + let r0 = (out_row * 2) * src_cols; + let r1 = r0 + src_cols; + let row0 = &src_data[r0..r0 + src_cols]; + let row1 = &src_data[r1..r1 + src_cols]; + let dst_row = &mut dst_data[out_row * new_w..(out_row + 1) * new_w]; + + let mut o = 0; + while o < simd_cols { + let c = o * 2; + // SAFETY: `c + 32 <= src_cols` by construction of `simd_cols`, + // and `o + 16 <= new_w`, so all loads/stores are in bounds. + let a0 = _mm_loadu_si128(row0.as_ptr().add(c) as *const __m128i); + let a1 = _mm_loadu_si128(row0.as_ptr().add(c + 16) as *const __m128i); + let b0 = _mm_loadu_si128(row1.as_ptr().add(c) as *const __m128i); + let b1 = _mm_loadu_si128(row1.as_ptr().add(c + 16) as *const __m128i); + + let h0lo = _mm_maddubs_epi16(a0, ones); + let h0hi = _mm_maddubs_epi16(a1, ones); + let h1lo = _mm_maddubs_epi16(b0, ones); + let h1hi = _mm_maddubs_epi16(b1, ones); + + let slo = _mm_srli_epi16(_mm_add_epi16(_mm_add_epi16(h0lo, h1lo), two), 2); + let shi = _mm_srli_epi16(_mm_add_epi16(_mm_add_epi16(h0hi, h1hi), two), 2); + + let out = _mm_packus_epi16(slo, shi); + _mm_storeu_si128(dst_row.as_mut_ptr().add(o) as *mut __m128i, out); + + o += 16; + } + + downsample_row_tail_scalar(row0, row1, dst_row, simd_cols); + } + + Matrix::::from_vec(new_h, new_w, 1, dst_data) +} + +/// wasm32 SIMD (`simd128`) 2x2 box-filter downsample. Processes 16 output +/// columns per iteration (32 source columns); the remaining columns of +/// each row fall back to [`downsample_row_tail_scalar`]. +/// +/// Output is byte-identical to [`downsample_scalar`]. +/// +/// # Safety +/// +/// Requires the `simd128` target feature, which is guaranteed by the +/// `#[cfg(target_feature = "simd128")]` gate on the [`downsample`] +/// dispatcher call site. +#[cfg(all( + target_arch = "wasm32", + feature = "simd-wasm32", + target_feature = "simd128" +))] +#[target_feature(enable = "simd128")] +#[must_use] +pub unsafe fn downsample_wasm(src: &Matrix) -> Matrix { + use std::arch::wasm32::*; + + let (new_h, new_w) = downsample_dims(src); + let src_data = src.as_slice(); + let src_cols = src.cols; + let mut dst_data = vec![0u8; new_h * new_w]; + + // A block at output col `o` reads source cols `[2o, 2o + 32)` and + // writes `[o, o + 16)`; safe while `2 * (o + 16) <= src_cols`. + let simd_cols = if src_cols >= 32 { + ((src_cols - 32) / 2 / 16) * 16 + 16 + } else { + 0 + }; + + let two = i16x8_splat(2); + + for out_row in 0..new_h { + let r0 = (out_row * 2) * src_cols; + let r1 = r0 + src_cols; + let row0 = &src_data[r0..r0 + src_cols]; + let row1 = &src_data[r1..r1 + src_cols]; + let dst_row = &mut dst_data[out_row * new_w..(out_row + 1) * new_w]; + + let mut o = 0; + while o < simd_cols { + let c = o * 2; + // SAFETY: `c + 32 <= src_cols` by construction of `simd_cols`, + // and `o + 16 <= new_w`, so all loads/stores are in bounds. + let a0 = v128_load(row0.as_ptr().add(c) as *const v128); + let a1 = v128_load(row0.as_ptr().add(c + 16) as *const v128); + let b0 = v128_load(row1.as_ptr().add(c) as *const v128); + let b1 = v128_load(row1.as_ptr().add(c + 16) as *const v128); + + // Horizontal pairwise sums of adjacent u8 lanes -> u16 lanes. + let h0lo = u16x8_extadd_pairwise_u8x16(a0); + let h0hi = u16x8_extadd_pairwise_u8x16(a1); + let h1lo = u16x8_extadd_pairwise_u8x16(b0); + let h1hi = u16x8_extadd_pairwise_u8x16(b1); + + let slo = u16x8_shr(i16x8_add(i16x8_add(h0lo, h1lo), two), 2); + let shi = u16x8_shr(i16x8_add(i16x8_add(h0hi, h1hi), two), 2); + + let out = u8x16_narrow_i16x8(slo, shi); + v128_store(dst_row.as_mut_ptr().add(o) as *mut v128, out); + + o += 16; + } + + downsample_row_tail_scalar(row0, row1, dst_row, simd_cols); } Matrix::::from_vec(new_h, new_w, 1, dst_data) @@ -344,4 +638,149 @@ mod tests { Err(PyramidError::LevelTooSmall { .. }) )); } + + // ── SIMD parity (#132) ─────────────────────────────────────────────── + + /// Deterministic pseudo-random image, seeded so failures reproduce. + #[cfg(any( + all(target_arch = "x86_64", feature = "simd-x86-sse41"), + all(target_arch = "x86_64", feature = "simd-x86-avx2"), + ))] + fn random_img(rows: usize, cols: usize, seed: u64) -> Matrix { + use rand::{Rng, SeedableRng}; + let mut rng = rand::rngs::StdRng::seed_from_u64(seed); + let data: Vec = (0..rows * cols).map(|_| rng.random::()).collect(); + Matrix::::from_vec(rows, cols, 1, data) + } + + /// Sizes chosen to exercise the SIMD main loop, the scalar remainder + /// tail, odd dimensions, sub-block widths, and block boundaries. + #[cfg(any( + all(target_arch = "x86_64", feature = "simd-x86-sse41"), + all(target_arch = "x86_64", feature = "simd-x86-avx2"), + ))] + const PARITY_SIZES: &[(usize, usize)] = &[ + (2, 2), + (3, 3), + (4, 4), + (5, 7), + (15, 15), + (16, 16), + (17, 17), + (31, 33), + (32, 32), + (33, 31), + (63, 65), + (64, 64), + (65, 63), + (100, 100), + (129, 127), + (200, 320), + ]; + + #[cfg(all(target_arch = "x86_64", feature = "simd-x86-sse41"))] + #[test] + fn test_downsample_sse41_matches_scalar() { + if !is_x86_feature_detected!("sse4.1") { + eprintln!("sse4.1 not available at runtime; skipping parity test"); + return; + } + for (i, &(rows, cols)) in PARITY_SIZES.iter().enumerate() { + let img = random_img(rows, cols, 0x5E_5E_00 + i as u64); + let scalar = downsample_scalar(&img); + // SAFETY: guarded by the runtime `sse4.1` check above. + let simd = unsafe { downsample_sse41(&img) }; + assert_eq!( + scalar.as_slice(), + simd.as_slice(), + "sse41 mismatch at {rows}x{cols}" + ); + } + } + + #[cfg(all(target_arch = "x86_64", feature = "simd-x86-avx2"))] + #[test] + fn test_downsample_avx2_matches_scalar() { + if !is_x86_feature_detected!("avx2") { + eprintln!("avx2 not available at runtime; skipping parity test"); + return; + } + for (i, &(rows, cols)) in PARITY_SIZES.iter().enumerate() { + let img = random_img(rows, cols, 0xA2_A2_00 + i as u64); + let scalar = downsample_scalar(&img); + // SAFETY: guarded by the runtime `avx2` check above. + let simd = unsafe { downsample_avx2(&img) }; + assert_eq!( + scalar.as_slice(), + simd.as_slice(), + "avx2 mismatch at {rows}x{cols}" + ); + } + } + + /// The public dispatcher must agree with the scalar baseline regardless + /// of which path it selects at runtime. + #[cfg(all(target_arch = "x86_64", feature = "simd-x86-sse41"))] + #[test] + fn test_downsample_dispatch_matches_scalar() { + for (i, &(rows, cols)) in PARITY_SIZES.iter().enumerate() { + let img = random_img(rows, cols, 0xD1_5A_00 + i as u64); + assert_eq!( + downsample_scalar(&img).as_slice(), + downsample(&img).as_slice(), + "dispatch mismatch at {rows}x{cols}" + ); + } + } + + /// Exhaustive width sweep: every column count from 2..=160 (covering + /// sub-block widths, single/multi full blocks, and every possible + /// remainder for both the 16-wide SSE/wasm and 32-wide AVX2 main + /// loops) crossed with a few row counts. This is the strong guarantee + /// that the SIMD/scalar split point is correct for *all* dimensions, + /// not just the hand-picked sizes above. + #[cfg(all(target_arch = "x86_64", feature = "simd-x86-avx2"))] + #[test] + fn test_downsample_avx2_matches_scalar_exhaustive() { + if !is_x86_feature_detected!("avx2") { + eprintln!("avx2 not available at runtime; skipping exhaustive parity test"); + return; + } + for rows in [2usize, 3, 4, 5] { + for cols in 2usize..=160 { + let img = random_img(rows, cols, 0xE0_0000 + (rows * 1000 + cols) as u64); + let scalar = downsample_scalar(&img); + // SAFETY: guarded by the runtime `avx2` check above. + let simd = unsafe { downsample_avx2(&img) }; + assert_eq!( + scalar.as_slice(), + simd.as_slice(), + "avx2 exhaustive mismatch at {rows}x{cols}" + ); + } + } + } + + /// Same exhaustive width sweep for the SSE4.1 path. + #[cfg(all(target_arch = "x86_64", feature = "simd-x86-sse41"))] + #[test] + fn test_downsample_sse41_matches_scalar_exhaustive() { + if !is_x86_feature_detected!("sse4.1") { + eprintln!("sse4.1 not available at runtime; skipping exhaustive parity test"); + return; + } + for rows in [2usize, 3, 4, 5] { + for cols in 2usize..=160 { + let img = random_img(rows, cols, 0x55_0000 + (rows * 1000 + cols) as u64); + let scalar = downsample_scalar(&img); + // SAFETY: guarded by the runtime `sse4.1` check above. + let simd = unsafe { downsample_sse41(&img) }; + assert_eq!( + scalar.as_slice(), + simd.as_slice(), + "sse41 exhaustive mismatch at {rows}x{cols}" + ); + } + } + } } diff --git a/crates/core/src/kpm/freak/visual_database.rs b/crates/core/src/kpm/freak/visual_database.rs index 32db360..19463a4 100644 --- a/crates/core/src/kpm/freak/visual_database.rs +++ b/crates/core/src/kpm/freak/visual_database.rs @@ -354,10 +354,7 @@ impl VisualDatabase { /// /// C equivalent: `query(const Image&)` (`visual_database-inline.h:155`). pub fn query(&mut self, image: &Matrix) -> Result { - // Reset per-query state (C++ `visual_database-inline.h:194-195`). - self.inliers.clear(); - self.matched_db_id = -1; - self.matched_geometry = [0.0; 9]; + self.reset_query_state(); // Build the pyramid and the query keyframe. self.ensure_pyramid(image)?; @@ -368,12 +365,45 @@ impl VisualDatabase { query_kf.store.num_features() ); - // Iterate the database; the keyframe with the most inliers wins. - // Collect ids upfront because we need a `&mut self` for the matcher - // build inside the loop body. + let matched = self.match_against_database(&query_kf)?; + self.query_keyframe = Some(query_kf); + Ok(matched) + } + + /// Query with a pre-extracted [`Keyframe`] instead of a raw image. + /// + /// Skips pyramid construction and feature extraction, running only the + /// matching loop against the database. Useful for deterministic testing + /// (feed a hand-built keyframe) and for callers that already hold a + /// `Keyframe`. The supplied keyframe becomes the stashed `query_keyframe`. + /// + /// C equivalent: `query(const keyframe_t*)` (`visual_database.h:193`). + pub fn query_from_keyframe(&mut self, query_kf: Keyframe) -> Result { + self.reset_query_state(); + let matched = self.match_against_database(&query_kf)?; + self.query_keyframe = Some(query_kf); + Ok(matched) + } + + /// Reset per-query result state (C++ `visual_database-inline.h:194-195`). + fn reset_query_state(&mut self) { + self.inliers.clear(); + self.matched_db_id = -1; + self.matched_geometry = [0.0; 9]; + } + + /// Run the matching loop against every stored keyframe; the keyframe with + /// the most inliers (above `min_num_inliers`) wins. Assumes per-query state + /// was already reset; does not touch `query_keyframe`. + /// + /// Shared inner loop of [`query`](Self::query) and + /// [`query_from_keyframe`](Self::query_from_keyframe) + /// (C++ `visual_database-inline.h:200-241`). + fn match_against_database(&mut self, query_kf: &Keyframe) -> Result { + // Collect ids upfront because we need `&mut self` for try_match_one. let ids: Vec = self.keyframes.keys().copied().collect(); for id in ids { - if let Some((inliers, h)) = self.try_match_one(&query_kf, id)? { + if let Some((inliers, h)) = self.try_match_one(query_kf, id)? { if inliers.len() >= self.min_num_inliers && inliers.len() > self.inliers.len() { self.matched_geometry = h; self.inliers = inliers; @@ -381,8 +411,6 @@ impl VisualDatabase { } } } - - self.query_keyframe = Some(query_kf); Ok(self.matched_db_id >= 0) } @@ -667,6 +695,54 @@ impl VisualDatabase { pub fn keyframe(&self, id: usize) -> Option<&Keyframe> { self.keyframes.get(&id) } + + // ── Facade-parity convenience accessors (#148, Group A) ────────────── + // Thin getters mirroring `VisualDatabaseFacade`, so callers don't have to + // reach through `keyframe(id).store` and loop element-by-element. + + /// Feature points of the reference keyframe stored under `id`. + /// + /// C equivalent: `getFeaturePoints(id)`. + pub fn get_feature_points(&self, id: usize) -> Option<&[FeaturePoint]> { + self.keyframes.get(&id).map(|kf| kf.store.points()) + } + + /// Flat descriptor buffer of the reference keyframe stored under `id`. + /// + /// C equivalent: `getDescriptors(id)`. + pub fn get_descriptors(&self, id: usize) -> Option<&[u8]> { + self.keyframes.get(&id).map(|kf| kf.store.descriptors()) + } + + /// Feature points of the most recent query frame (see [`query`](Self::query)). + /// + /// C equivalent: `getQueryFeaturePoints()`. + pub fn get_query_feature_points(&self) -> Option<&[FeaturePoint]> { + self.query_keyframe.as_ref().map(|kf| kf.store.points()) + } + + /// Flat descriptor buffer of the most recent query frame. + /// + /// C equivalent: `getQueryDescriptors()`. + pub fn get_query_descriptors(&self) -> Option<&[u8]> { + self.query_keyframe + .as_ref() + .map(|kf| kf.store.descriptors()) + } + + /// Width of the reference image stored under `id`. + /// + /// C equivalent: `getWidth(id)`. + pub fn get_width(&self, id: usize) -> Option { + self.keyframes.get(&id).map(|kf| kf.width) + } + + /// Height of the reference image stored under `id`. + /// + /// C equivalent: `getHeight(id)`. + pub fn get_height(&self, id: usize) -> Option { + self.keyframes.get(&id).map(|kf| kf.height) + } } impl Default for VisualDatabase { @@ -887,6 +963,7 @@ mod tests { // ----------------------------------------------------------------- #[test] + #[cfg_attr(miri, ignore)] // #194: full pipeline + BHC index — too slow under Miri fn test_visual_database_add_and_query_same_image() { let img = load_grayscale("../../benchmarks/data/found.jpg"); let mut db = VisualDatabase::new().expect("new"); @@ -910,6 +987,7 @@ mod tests { } #[test] + #[cfg_attr(miri, ignore)] // #194: full pipeline + BHC index — too slow under Miri fn test_visual_database_query_different_image_returns_no_match() { let img = load_grayscale("../../benchmarks/data/found.jpg"); let blank = synthetic_blank(640, 480); @@ -930,6 +1008,7 @@ mod tests { // ----------------------------------------------------------------- #[test] + #[cfg_attr(miri, ignore)] // #194: full pipeline + BHC index — too slow under Miri fn test_visual_database_add_same_id_returns_err() { let img = load_grayscale("../../benchmarks/data/found.jpg"); let mut db = VisualDatabase::new().expect("new"); @@ -939,6 +1018,7 @@ mod tests { } #[test] + #[cfg_attr(miri, ignore)] // #194: full pipeline + BHC index — too slow under Miri fn test_visual_database_erase_removes_keyframe() { let img = load_grayscale("../../benchmarks/data/found.jpg"); let mut db = VisualDatabase::new().expect("new"); @@ -954,6 +1034,7 @@ mod tests { // ----------------------------------------------------------------- #[test] + #[cfg_attr(miri, ignore)] // #194: full pyramid + DoG + BHC pipeline on real image — too slow under Miri fn test_visual_database_add_image_builds_index() { let img = load_grayscale("../../benchmarks/data/found.jpg"); let mut db = VisualDatabase::new().expect("new"); @@ -965,6 +1046,7 @@ mod tests { } #[test] + #[cfg_attr(miri, ignore)] // #194: full pyramid + DoG + BHC pipeline on real image — too slow under Miri fn test_visual_database_add_keyframe_builds_index_when_absent() { // Build a keyframe externally without calling build_index, hand it to // add_keyframe, assert add_keyframe built the index for us. @@ -989,6 +1071,7 @@ mod tests { } #[test] + #[cfg_attr(miri, ignore)] // #194: full pyramid + DoG + BHC pipeline on real image — too slow under Miri fn test_visual_database_add_keyframe_preserves_caller_built_index() { // If the caller pre-built the index, add_keyframe must not rebuild. // Verified by the fact that after a successful pre-build the keyframe @@ -1012,6 +1095,115 @@ mod tests { assert!(db.keyframe(0).unwrap().index().is_some()); } + #[test] + #[cfg_attr(miri, ignore)] // real-image pyramid + DoG + BHC pipeline — too slow under Miri + fn test_query_from_keyframe_self_matches() { + // #147: query_from_keyframe runs the matching loop on a pre-built + // keyframe. An identical keyframe must self-match the database entry. + let img = load_grayscale("../../benchmarks/data/found.jpg"); + let build_kf = |img: &Matrix| -> Keyframe { + let mut pyr = crate::kpm::freak::gaussian_pyramid::GaussianScaleSpacePyramid::new(3); + pyr.build(img).unwrap(); + let det = + crate::kpm::freak::detector::DoGScaleInvariantDetector::new(3.0, 4.0, 500, true); + let mut kf = Keyframe::new(img.cols as i32, img.rows as i32).unwrap(); + find_features(&mut kf, &pyr, &det).expect("find_features"); + kf + }; + + let mut db = VisualDatabase::new().expect("new"); + db.add_keyframe(build_kf(&img), 0).expect("add_keyframe"); + + let matched = db + .query_from_keyframe(build_kf(&img)) + .expect("query_from_keyframe"); + assert!(matched, "an identical keyframe must self-match"); + assert_eq!(db.matched_db_id(), 0); + assert!( + db.inliers().len() >= 8, + "self-match should be a strong match" + ); + assert!( + db.query_keyframe().is_some(), + "query_from_keyframe must stash the query keyframe" + ); + } + + #[test] + fn test_query_from_keyframe_no_match() { + // Deterministic, no image: a db keyframe with all-0x00 descriptors vs a + // query keyframe with all-0xFF descriptors — maximally distant, so the + // ratio test rejects every candidate and try_match_one short-circuits + // (no-match path, #177). + let mk = |desc: u8, n: usize| -> Keyframe { + let mut kf = Keyframe::new(640, 480).unwrap(); + for i in 0..n { + let p = FeaturePoint { + x: i as f32, + y: i as f32, + angle: 0.0, + scale: 1.0, + maxima: true, + }; + kf.store.add(p, &[desc; 96]).unwrap(); + } + kf + }; + + let mut db = VisualDatabase::new().expect("new"); + db.add_keyframe(mk(0x00, 12), 0).expect("add_keyframe"); + + let matched = db + .query_from_keyframe(mk(0xFF, 12)) + .expect("query_from_keyframe"); + assert!(!matched, "maximally-distant descriptors must not match"); + assert_eq!(db.matched_db_id(), -1); + assert!(db.inliers().is_empty()); + } + + #[test] + fn test_facade_parity_accessors() { + // Build a tiny keyframe directly (no image pipeline) so the getters are + // exercised deterministically and fast (#148, Group A). + let mut kf = Keyframe::new(640, 480).unwrap(); + let p0 = FeaturePoint { + x: 1.0, + y: 2.0, + angle: 0.0, + scale: 1.0, + maxima: true, + }; + let p1 = FeaturePoint { + x: 3.0, + y: 4.0, + angle: 0.0, + scale: 1.0, + maxima: false, + }; + kf.store.add(p0, &[7u8; 96]).unwrap(); + kf.store.add(p1, &[9u8; 96]).unwrap(); + + let mut db = VisualDatabase::new().expect("new"); + db.add_keyframe(kf, 0).expect("add_keyframe"); + + // Reference accessors for the stored id. + assert_eq!(db.get_width(0), Some(640)); + assert_eq!(db.get_height(0), Some(480)); + assert_eq!(db.get_feature_points(0).unwrap().len(), 2); + assert_eq!(db.get_feature_points(0).unwrap()[0].x, 1.0); + let descs = db.get_descriptors(0).unwrap(); + assert_eq!(descs.len(), 2 * 96); + assert_eq!(&descs[0..96], &[7u8; 96]); + + // Missing id → None. + assert!(db.get_feature_points(99).is_none()); + assert!(db.get_width(99).is_none()); + + // No query run yet → query accessors are None. + assert!(db.get_query_feature_points().is_none()); + assert!(db.get_query_descriptors().is_none()); + } + // ----------------------------------------------------------------- // Dual-mode parity test (M9-1 gate per #140, closed by M9 #146) // ----------------------------------------------------------------- diff --git a/crates/core/src/kpm/ref_data_set.rs b/crates/core/src/kpm/ref_data_set.rs index 6a035d7..aeb7bf4 100644 --- a/crates/core/src/kpm/ref_data_set.rs +++ b/crates/core/src/kpm/ref_data_set.rs @@ -45,7 +45,7 @@ //! [`merge`](KpmRefDataSet::merge), and //! [`change_page_no`](KpmRefDataSet::change_page_no). -use std::io::{Read, Write}; +use std::io::Write; use std::path::Path; use byteorder::{LittleEndian, ReadBytesExt, WriteBytesExt}; @@ -405,8 +405,18 @@ impl KpmRefDataSet { /// Load a data set from a binary file written by [`save`](Self::save). pub fn load(path: &Path) -> std::io::Result { let file = std::fs::File::open(path)?; - let mut r = std::io::BufReader::new(file); + Self::load_from_reader(std::io::BufReader::new(file)) + } + + /// Load a data set from in-memory bytes (e.g. a `.fset3` fetched in the + /// browser, where there is no filesystem). Same format as [`load`](Self::load). + pub fn load_from_bytes(bytes: &[u8]) -> std::io::Result { + Self::load_from_reader(std::io::Cursor::new(bytes)) + } + /// Parse a data set from any reader. Shared by [`load`](Self::load) and + /// [`load_from_bytes`](Self::load_from_bytes). + fn load_from_reader(mut r: R) -> std::io::Result { let num = r.read_i32::()?; if num <= 0 { return Err(std::io::Error::new( @@ -728,4 +738,28 @@ mod tests { // The test data lives in crates/kpm/examples/data/ and CARGO_MANIFEST_DIR // now points to crates/core/. The test is covered by // crates/kpm/tests/regression.rs::test_fset3_load_structure. + + /// `load_from_bytes` (the wasm-friendly loader added for #161) must parse a + /// real `.fset3` identically to the path-based `load`. + #[cfg(not(target_arch = "wasm32"))] + #[cfg_attr(miri, ignore)] // reads + parses a 625 KB .fset3 — too slow under Miri + #[test] + fn test_load_from_bytes_matches_load() { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("examples") + .join("Data") + .join("pinball.fset3"); + let from_path = KpmRefDataSet::load(&path).expect("load(path) failed"); + let bytes = std::fs::read(&path).expect("read failed"); + let from_bytes = KpmRefDataSet::load_from_bytes(&bytes).expect("load_from_bytes failed"); + + assert_eq!(from_path.num, from_bytes.num); + assert_eq!(from_path.page_num, from_bytes.page_num); + assert_eq!(from_path.ref_point.len(), from_bytes.ref_point.len()); + // Spot-check the first ref point round-trips identically. + assert_eq!( + from_path.ref_point[0].feature_vec.v, + from_bytes.ref_point[0].feature_vec.v + ); + } } diff --git a/crates/core/src/kpm/rust_backend.rs b/crates/core/src/kpm/rust_backend.rs index 2ad919f..42acae6 100644 --- a/crates/core/src/kpm/rust_backend.rs +++ b/crates/core/src/kpm/rust_backend.rs @@ -596,6 +596,7 @@ mod tests { /// Issue #141 required test: add a reference image, query with a /// different image, expect a successful match. #[test] + #[cfg_attr(miri, ignore)] // #194: full FREAK matcher pipeline on real image pair — too slow under Miri fn test_rust_freak_matcher_implements_backend() { let (ref_img, rw, rh) = load_grayscale("../../benchmarks/data/found.jpg"); let (qry_img, qw, qh) = load_grayscale("../../benchmarks/data/img.jpg"); @@ -617,6 +618,7 @@ mod tests { /// Covers the `extract_features` path used by /// `KpmRefDataSet::generate()`. Detects features without storing. #[test] + #[cfg_attr(miri, ignore)] // #194: full FREAK extract on real image — too slow under Miri fn test_rust_freak_matcher_extract_features() { let (img, w, h) = load_grayscale("../../benchmarks/data/found.jpg"); let mut matcher = RustFreakMatcher::new(w as i32, h as i32).unwrap(); @@ -676,6 +678,7 @@ mod tests { /// insertion). Extracts features from `found.jpg`, feeds them back as /// the database, queries with the same image, expects a self-match. #[test] + #[cfg_attr(miri, ignore)] // #194: full FREAK extract + match cycle — too slow under Miri fn test_rust_freak_matcher_add_freak_features() { let (ref_img, rw, rh) = load_grayscale("../../benchmarks/data/found.jpg"); let mut matcher = RustFreakMatcher::new(rw as i32, rh as i32).unwrap(); diff --git a/crates/core/src/version.rs b/crates/core/src/version.rs index ee10128..905be4d 100644 --- a/crates/core/src/version.rs +++ b/crates/core/src/version.rs @@ -83,7 +83,7 @@ mod tests { let v = get_version(); // Only validate the numeric core (major.minor[.patch]) before any pre-release/build suffix. - let core = v.split(|c| c == '-' || c == '+').next().unwrap_or(v); + let core = v.split(['-', '+']).next().unwrap_or(v); let parts: Vec<&str> = core.split('.').collect(); assert!( diff --git a/crates/core/tests/kpm_regression.rs b/crates/core/tests/kpm_regression.rs index e8ec598..36db519 100644 --- a/crates/core/tests/kpm_regression.rs +++ b/crates/core/tests/kpm_regression.rs @@ -52,6 +52,13 @@ //! | `test_luminance_conversion` | JPEG→luma conversion consistency | none | #![allow(dead_code)] +// rationale: the SCREEN/WORLD/pose constants below were generated by the +// C++ kpm_dump_fixtures tool. The extra decimal digits document the +// captured C++ output verbatim; truncating to f32-exact representations +// would round-trip to the same bit pattern but lose source-traceability +// to the C++ baseline. Tracked under #180 — file-scoped allow rather +// than per-literal truncation. +#![allow(clippy::excessive_precision)] use std::path::Path; diff --git a/crates/core/tests/nft_pipeline.rs b/crates/core/tests/nft_pipeline.rs index 65699b4..f2503ca 100644 --- a/crates/core/tests/nft_pipeline.rs +++ b/crates/core/tests/nft_pipeline.rs @@ -196,7 +196,7 @@ fn test_compare_with_c_generated_pinball_marker() { // Allow ±10%: float rounding differs across compilers and platforms. let ratio = rust_total as f64 / c_total as f64; assert!( - ratio >= 0.90 && ratio <= 1.10, + (0.90..=1.10).contains(&ratio), "feature count diverges: Rust {} vs C {} ({:.1}% — expected within ±10%)", rust_total, c_total, diff --git a/crates/wasm/Cargo.toml b/crates/wasm/Cargo.toml index d2fe98b..1317504 100644 --- a/crates/wasm/Cargo.toml +++ b/crates/wasm/Cargo.toml @@ -24,7 +24,10 @@ serde-wasm-bindgen = "0.6" webarkitlib-rs = { path = "../core" } [features] -default = [] +default = ["log-helpers"] +# Routes `arlog_*!` output to the browser console via `console_log` +# (`ar_log_init_wasm()`), installed by `init_wasm()`. +log-helpers = ["webarkitlib-rs/log-helpers"] simd = ["webarkitlib-rs/simd"] [dev-dependencies] diff --git a/crates/wasm/pkg/README.md b/crates/wasm/pkg/README.md index 7684391..2e08be4 100644 --- a/crates/wasm/pkg/README.md +++ b/crates/wasm/pkg/README.md @@ -3,6 +3,7 @@ ![WebARKitLib-rs](./assets/WebARKitLib-Rust-banner.jpg) [![CI](https://github.com/webarkit/WebARKitLib-rs/actions/workflows/ci.yml/badge.svg)](https://github.com/webarkit/WebARKitLib-rs/actions/workflows/ci.yml) +[![Miri](https://github.com/webarkit/WebARKitLib-rs/actions/workflows/miri.yml/badge.svg)](https://github.com/webarkit/WebARKitLib-rs/actions/workflows/miri.yml) [![codecov](https://codecov.io/gh/webarkit/WebARKitLib-rs/branch/main/graph/badge.svg)](https://codecov.io/gh/webarkit/WebARKitLib-rs) [![Crates.io](https://img.shields.io/crates/v/webarkitlib-rs.svg)](https://crates.io/crates/webarkitlib-rs) [![npm](https://img.shields.io/npm/v/@webarkit/webarkitlib-wasm.svg)](https://www.npmjs.com/package/@webarkit/webarkitlib-wasm) @@ -13,7 +14,7 @@ This project aims to provide a pure-Rust implementation of the core ARToolKit algorithms, targeting both **native** systems and **WebAssembly (WASM)** for high-performance augmented reality in the browser. > [!NOTE] -> This project is currently a **Work in Progress**. Core marker detection and pose estimation are functional. KPM/NFT (Natural Feature Tracking) is in an early stage with partial Rust porting -- a fully idiomatic Rust KPM pipeline with a working example is the primary goal for v1.0.0. +> Core marker detection and pose estimation are functional, and as of **v0.8.0** the **KPM/NFT (Natural Feature Tracking) pipeline is fully ported to pure Rust** — end-to-end keypoint matching, marker generation (`.iset`/`.fset`/`.fset3`), and runtime tracking all run with **no C++ toolchain**, natively and in the browser via WebAssembly. The project remains a **Work in Progress** toward v1.0.0 (broader API docs, multi-marker support, and a dedicated KPM benchmark are the remaining focus areas). ## 🌟 Key Features @@ -39,7 +40,7 @@ Add `webarkitlib-rs` to your `Cargo.toml`: ```toml [dependencies] -webarkitlib-rs = "0.7" +webarkitlib-rs = "0.8" ``` ### Pure Rust tracking (no C++ compiler required) @@ -65,7 +66,7 @@ need to do anything special: ```toml [dependencies] -webarkitlib-rs = "0.7" # default backend is pure Rust +webarkitlib-rs = "0.8" # default backend is pure Rust ``` ```bash @@ -82,7 +83,7 @@ NFT tracking**: ```toml [dependencies] -webarkitlib-rs = { version = "0.7", features = ["ffi-backend"] } +webarkitlib-rs = { version = "0.8", features = ["ffi-backend"] } ``` > When installing from crates.io, no extra setup is required — the C++ @@ -122,42 +123,33 @@ This example loads a camera parameter file, a marker (pattern or barcode), and a Generate NFT (Natural Feature Tracking) marker files compatible with ARnft and NFT-Marker-Creator-App: ```bash -# Pure-Rust default (no C++ toolchain required) — produces .iset + .fset. -# Pass --yes to skip the interactive "no .fset3" confirmation prompt -# (recommended for scripted / CI use): +# Pure Rust, no C++ toolchain — produces a COMPLETE marker (.iset + .fset + .fset3): cargo run --release --example nft_marker_gen -- \ - --input path/to/image.jpg \ - --output path/to/output_name \ - --dpi 220 \ - --yes - -# Full output including .fset3 (FREAK descriptors) — opt-in C++ backend: -# .fset3 generation still uses CppFreakMatcher today; the runtime KPM -# tracker reads .fset3 files in pure Rust, only the marker-creation step -# needs the C++ FREAK extractor. Wiring nft_marker_gen to RustFreakMatcher -# for .fset3 is a follow-up task. -cargo run --release --features ffi-backend --example nft_marker_gen -- \ --input path/to/image.jpg \ --output path/to/output_name \ --dpi 220 # With SIMD acceleration + Rayon parallelism (recommended for x86_64): -cargo run --release --features "ffi-backend,simd-x86-sse41,simd-x86-avx2" \ +cargo run --release --features "simd-x86-sse41,simd-x86-avx2" \ --example nft_marker_gen -- \ --input path/to/image.jpg \ --output path/to/output_name \ --dpi 220 ``` +> Since #179, `.fset3` (FREAK descriptors) is generated by the pure-Rust +> `RustFreakMatcher` — a single invocation produces a complete marker with no +> C++ toolchain. + > **Important:** Always use the `--release` flag. Debug builds are 5–10× slower due to missing compiler optimizations. -Output files (depends on whether `ffi-backend` is enabled): +Output files (all produced on the pure-Rust default): -| File | Default (pure Rust) | With `ffi-backend` | Description | -|---|:---:|:---:|---| -| `.iset` | ✅ | ✅ | JPEG-compressed image pyramid (~300 KB, matches C++ `ar2WriteImageSet()` format) | -| `.fset` | ✅ | ✅ | Feature map with one entry per pyramid level | -| `.fset3` | ❌ (skipped, with warning) | ✅ | FREAK descriptors for KPM-based recognition | +| File | Description | +|---|---| +| `.iset` | JPEG-compressed image pyramid (~300 KB, matches C++ `ar2WriteImageSet()` format) | +| `.fset` | Feature map with one entry per pyramid level | +| `.fset3` | FREAK descriptors for KPM-based recognition (pure-Rust `RustFreakMatcher`) | **Options:** @@ -167,14 +159,12 @@ Output files (depends on whether `ffi-backend` is enabled): | `-o` | `--output` | Output base path (without extension) | required | | `-d` | `--dpi` | Source image resolution in DPI | required | | `-l` | `--level` | Tracking extraction level (0–4) | `2` | -| `-n` | `--search-feature-num` | Max features per pyramid level | `250` | -| `-y` | `--yes` | Skip the "no .fset3" confirmation prompt (for scripted / CI use; only relevant when built without `ffi-backend`) | (interactive) | **Performance feature flags:** | Feature flag | What it enables | |---|---| -| `ffi-backend` | C++ FREAK backend (opt-in since M9-3 #142). Enables `.fset3` generation in `nft_marker_gen` (the `.iset`/`.fset` outputs work on the pure-Rust default) and powers development cross-validation (`dual-mode`, `cross_stack_parity`). Runtime NFT tracking with existing `.fset3` files works on the pure-Rust default. | +| `ffi-backend` | C++ FREAK backend (opt-in since M9-3 #142). Used only for development cross-validation (`dual-mode`, `cross_stack_parity`); all of `nft_marker_gen` (`.iset` + `.fset` + `.fset3`) and runtime NFT tracking run on the pure-Rust default. | | `simd-x86-sse41` | SSE4.1 SIMD acceleration for feature map correlation (`get_similarity`) | | `simd-x86-avx2` | AVX2+FMA SIMD acceleration for feature map correlation (faster than SSE4.1) | | `simd` | Umbrella flag — enables all SIMD optimizations (SSE4.1, AVX2, WASM SIMD, image, pattern) | @@ -258,7 +248,7 @@ Enable the `log-helpers` feature and call the bundled initializer once in your b ```toml [dependencies] -webarkitlib-rs = { version = "0.7", features = ["log-helpers"] } +webarkitlib-rs = { version = "0.8", features = ["log-helpers"] } ``` ```rust @@ -439,12 +429,16 @@ The workspace contains two crates: - Cross-platform / cross-stack matcher determinism — Rust `HashMap` → `BTreeMap` and upstream C++ `unordered_map` → `std::map` ([WebARKitLib#39](https://github.com/webarkit/WebARKitLib/pull/39)). - Hand-annotated absolute corner-error regression gate (browser-based annotation tool + 5 fixtures + Linux CI gate). Finding: pure-Rust backend is more accurate than C++ on `pinball-demo` (5.27 px vs 18.79 px max corner error). - Cross-stack parity gate against `@webarkit/jsartoolkit-nft@1.10.0` ([jsartoolkitNFT#584](https://github.com/webarkit/jsartoolkitNFT/pull/584)) — guarantees native Rust pose matches what production WASM consumers see. +- **v0.8.0 — Pure-Rust completeness, validation & polish**: Closed out the remaining gaps on top of M9: + - **Full WASM/NFT support** ([#161](https://github.com/webarkit/WebARKitLib-rs/issues/161)): `WasmKpmHandle` KPM-detection bindings, `console_log` wiring, clean dual (standard + SIMD) wasm builds, and an end-to-end **browser NFT demo** of the pure-Rust pipeline. + - **Pure-Rust `.fset3` marker generation** ([#179](https://github.com/webarkit/WebARKitLib-rs/issues/179)): `nft_marker_gen` now produces `.iset` + `.fset` + `.fset3` entirely via `RustFreakMatcher` — the `ffi-backend` is no longer needed for marker creation. + - **`VisualDatabase` ergonomics** ([#147](https://github.com/webarkit/WebARKitLib-rs/issues/147), [#148](https://github.com/webarkit/WebARKitLib-rs/issues/148)): factored the per-frame query loop, added `query_from_keyframe`, and facade-parity accessors on `VisualDatabase` + `FeatureStore` slices. + - **Gaussian scale-space pyramid performance** ([#200](https://github.com/webarkit/WebARKitLib-rs/issues/200)/[#201](https://github.com/webarkit/WebARKitLib-rs/issues/201)/[#207](https://github.com/webarkit/WebARKitLib-rs/issues/207)): criterion benchmark, NO-FMA SSE4.1/AVX2/wasm SIMD binomial filter with bit-exact scalar parity, and rayon-parallelized filter passes. + - **Validation & CI hardening**: raised M9 patch coverage ≥90% ([#177](https://github.com/webarkit/WebARKitLib-rs/issues/177)), Miri UB gate ([#182](https://github.com/webarkit/WebARKitLib-rs/issues/182)), strict `--all-targets --all-features` clippy gate ([#180](https://github.com/webarkit/WebARKitLib-rs/issues/180)), benchmark download hardening ([#204](https://github.com/webarkit/WebARKitLib-rs/issues/204)), and macOS/Windows build + cache fixes ([#134](https://github.com/webarkit/WebARKitLib-rs/issues/134), [#119](https://github.com/webarkit/WebARKitLib-rs/issues/119)). ### 🎯 Short-term Goals (toward v1.0.0) - **KPM-specific benchmark** ([deferred from #142](https://github.com/webarkit/WebARKitLib-rs/issues/142)): Add a dedicated `kpm_bench.rs` Criterion bench so we can verify the pure-Rust backend stays within 20% of C++ on `pinball-demo`. The existing `marker_bench` only measures barcode marker detection. -- **WASM browser examples for KPM/NFT** ([#161](https://github.com/webarkit/WebARKitLib-rs/issues/161)): End-to-end runnable browser demo of the pure-Rust NFT pipeline. -- **Raise M9 patch coverage** ([#177](https://github.com/webarkit/WebARKitLib-rs/issues/177)): Lift M9 modules from 84.76% → ≥90% patch coverage with no file under 85%. -- **Dependency upgrades**: Criterion 0.5 → 0.8 ([#174](https://github.com/webarkit/WebARKitLib-rs/issues/174)). +- **Live-camera WASM demo + pose parity** ([#215](https://github.com/webarkit/WebARKitLib-rs/issues/215)): Extend the browser NFT demo with a live camera feed and jsartoolkitNFT pose parity. - **Enhanced Documentation**: Expand API reference with complete module-level docs, integration walkthroughs for JS/TS, and detailed usage examples. - **WASM Memory Management**: Improve resource cleanup when switching engines or markers in long-running browser sessions. diff --git a/crates/wasm/pkg/package.json b/crates/wasm/pkg/package.json index e3d4ed7..3bef609 100644 --- a/crates/wasm/pkg/package.json +++ b/crates/wasm/pkg/package.json @@ -5,7 +5,7 @@ "kalwalt \u003cgithub@kalwaltart.it\u003e" ], "description": "A high-performance, memory-safe Rust port of WebARKitLib (ARToolKit) for native and WASM.", - "version": "0.7.0", + "version": "0.8.0", "license": "LGPL-3.0-or-later", "repository": { "type": "git", diff --git a/crates/wasm/src/lib.rs b/crates/wasm/src/lib.rs index f36a03b..8ffbdcf 100644 --- a/crates/wasm/src/lib.rs +++ b/crates/wasm/src/lib.rs @@ -62,6 +62,12 @@ use webarkitlib_rs::types::{ }; use webarkitlib_rs::version; +// KPM detection (the `simple_nft.rs` steps 3a + 4 surface). +use std::sync::Arc; +use webarkitlib_rs::kpm::ref_data_set::KPM_CHANGE_PAGE_NO_ALL_PAGES; +use webarkitlib_rs::kpm::types::KpmRefDataSet; +use webarkitlib_rs::kpm::{KpmHandle, RustFreakMatcher}; + /// Returns the current version string of the library. #[wasm_bindgen] pub fn get_version() -> String { @@ -85,6 +91,9 @@ pub fn init_panic_hook() { #[wasm_bindgen] pub fn init_wasm() { console_error_panic_hook::set_once(); + // Route arlog_*! output to the browser console (DevTools) via console_log. + #[cfg(all(feature = "log-helpers", target_arch = "wasm32"))] + webarkitlib_rs::arlog::ar_log_init_wasm(); print_version(); } @@ -640,3 +649,122 @@ impl Drop for WasmNFTHandle { } } } + +/// Result of [`WasmKpmHandle::detect`]: the detected 3×4 pose (row-major, 12 +/// floats), the matched page number, and the matching error. +#[derive(serde::Serialize)] +struct KpmDetectResult { + pose: [f32; 12], + page: i32, + error: f32, +} + +/// KPM (Keypoint Matching) detection handle — the WASM equivalent of +/// `simple_nft.rs` steps 3a + 4. Loads NFT reference data (`.fset3`) and +/// detects the marker's initial 3×4 pose in a query frame using the pure-Rust +/// [`RustFreakMatcher`]. +/// +/// Pair with [`WasmNFTHandle`] for AR2 tracking: feed `detect()`'s pose into +/// [`WasmNFTHandle::set_initial_pose`]. +#[wasm_bindgen] +pub struct WasmKpmHandle { + handle: KpmHandle, + width: i32, + height: i32, + loaded: bool, +} + +#[wasm_bindgen] +impl WasmKpmHandle { + /// Create a KPM detection handle. + /// + /// * `param_bytes` — `camera_para.dat` contents. + /// * `width` / `height` — query-frame size in pixels. + #[wasm_bindgen(constructor)] + pub fn new(param_bytes: &[u8], width: i32, height: i32) -> Result { + let cursor = Cursor::new(param_bytes); + let mut param = ARParam::load(cursor) + .map_err(|e| JsValue::from_str(&format!("Failed to load camera param: {}", e)))?; + + // Scale camera params to the frame size (arParamChangeSize equivalent). + let sx = width as f64 / param.xsize as f64; + let sy = height as f64 / param.ysize as f64; + for col in 0..4 { + param.mat[0][col] *= sx; + param.mat[1][col] *= sy; + } + param.xsize = width; + param.ysize = height; + + let param_lt = Arc::new(ARParamLT::new_basic(param)); + let backend = RustFreakMatcher::new(width, height) + .map_err(|e| JsValue::from_str(&format!("Failed to create FREAK matcher: {:?}", e)))?; + let handle = KpmHandle::new(width, height, Some(param_lt), Box::new(backend)); + + Ok(WasmKpmHandle { + handle, + width, + height, + loaded: false, + }) + } + + /// Load NFT reference data from `.fset3` bytes (KPM reference keypoints). + /// All pages are remapped to page 0 (single-marker setup). + pub fn load_ref_data(&mut self, fset3_bytes: &[u8]) -> Result<(), JsValue> { + let mut ref_data = KpmRefDataSet::load_from_bytes(fset3_bytes) + .map_err(|e| JsValue::from_str(&format!("Failed to load .fset3: {}", e)))?; + ref_data.change_page_no(KPM_CHANGE_PAGE_NO_ALL_PAGES, 0); + self.handle + .set_ref_data_set(ref_data) + .map_err(|e| JsValue::from_str(&format!("Failed to set ref data: {:?}", e)))?; + self.loaded = true; + Ok(()) + } + + /// Run KPM detection on an RGBA frame (`width * height * 4` bytes, e.g. a + /// canvas `ImageData.data` buffer). + /// + /// Returns `{ pose: number[12], page, error }` on a match, or `null` if no + /// marker was found. + pub fn detect(&mut self, rgba_bytes: &[u8]) -> Result { + if !self.loaded { + return Err(JsValue::from_str( + "reference data not loaded — call load_ref_data first", + )); + } + let expected = (self.width * self.height * 4) as usize; + if rgba_bytes.len() != expected { + return Err(JsValue::from_str(&format!( + "rgba length {} != expected {} ({}x{}x4)", + rgba_bytes.len(), + expected, + self.width, + self.height + ))); + } + + let luma = rgba_to_gray(rgba_bytes); + self.handle + .kpm_matching(&luma) + .map_err(|e| JsValue::from_str(&format!("kpm_matching failed: {:?}", e)))?; + + match self.handle.get_pose() { + Some((cam_pose, page, error)) => { + let mut pose = [0f32; 12]; + for (r, row) in cam_pose.iter().enumerate() { + pose[r * 4..r * 4 + 4].copy_from_slice(row); + } + let result = KpmDetectResult { pose, page, error }; + serde_wasm_bindgen::to_value(&result) + .map_err(|e| JsValue::from_str(&format!("serialize failed: {}", e))) + } + None => Ok(JsValue::NULL), + } + } + + /// Whether reference data has been loaded. + pub fn is_loaded(&self) -> bool { + self.loaded + } +} diff --git a/crates/wasm/tests/kpm_detection.rs b/crates/wasm/tests/kpm_detection.rs new file mode 100644 index 0000000..106d7af --- /dev/null +++ b/crates/wasm/tests/kpm_detection.rs @@ -0,0 +1,79 @@ +/* + * kpm_detection.rs + * WebARKitLib-rs + * + * This file is part of WebARKitLib-rs - WebARKit. + * + * WebARKitLib-rs is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * WebARKitLib-rs is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with WebARKitLib-rs. If not, see . + * + * As a special exception, the copyright holders of this library give you + * permission to link this library with independent modules to produce an + * executable, regardless of the license terms of these independent modules, and to + * copy and distribute the resulting executable under terms of your choice, + * provided that you also meet, for each linked independent module, the terms and + * conditions of the license of that module. An independent module is a module + * which is neither derived from nor based on this library. If you modify this + * library, you may extend this exception to your version of the library, but you + * are not obligated to do so. If you do not wish to do so, delete this exception + * statement from your version. + * + * Copyright 2026 WebARKit. + * + * Author(s): Walter Perdan @kalwalt https://github.com/kalwalt + * + */ + +//! Headless wasm-bindgen tests for the KPM detection binding (#161). +//! +//! Run with: `wasm-pack test --node` (or `--headless --chrome`) from +//! `crates/wasm`. These exercise `WasmKpmHandle` in a real wasm runtime — +//! construction (camera-param parse + FREAK matcher init) and reference-data +//! loading from in-memory `.fset3` bytes (`KpmRefDataSet::load_from_bytes`). +//! +//! Full `detect()` is covered end-to-end by the native `simple_nft` pipeline +//! (same pure-Rust code) and the browser demo (`www/simple_nft_example.html`); +//! it needs a decoded RGBA frame, out of scope for this lightweight runtime +//! check. + +#![cfg(target_arch = "wasm32")] + +use wasm_bindgen_test::*; +use webarkitlib_wasm::WasmKpmHandle; + +/// Demo assets, baked into the test binary. +const CAMERA_PARA: &[u8] = include_bytes!("../www/assets/camera_para.dat"); +const PINBALL_FSET3: &[u8] = include_bytes!("../www/assets/pinball.fset3"); + +#[wasm_bindgen_test] +fn kpm_handle_constructs_and_loads_ref_data() { + let mut handle = + WasmKpmHandle::new(CAMERA_PARA, 640, 480).expect("WasmKpmHandle::new should succeed"); + assert!(!handle.is_loaded(), "should start unloaded"); + + handle + .load_ref_data(PINBALL_FSET3) + .expect("load_ref_data should parse the .fset3 bytes"); + assert!(handle.is_loaded(), "should be loaded after load_ref_data"); +} + +#[wasm_bindgen_test] +fn detect_before_load_errors() { + let mut handle = WasmKpmHandle::new(CAMERA_PARA, 4, 4).expect("new"); + // 4x4x4 RGBA; detection must refuse before reference data is loaded. + let rgba = [0u8; 4 * 4 * 4]; + assert!( + handle.detect(&rgba).is_err(), + "detect should error before load_ref_data" + ); +} diff --git a/crates/wasm/www/assets/pinball.fset3 b/crates/wasm/www/assets/pinball.fset3 new file mode 100644 index 0000000..0291b4a Binary files /dev/null and b/crates/wasm/www/assets/pinball.fset3 differ diff --git a/crates/wasm/www/simple_nft_example.html b/crates/wasm/www/simple_nft_example.html index f4ebe8f..dee49fa 100644 --- a/crates/wasm/www/simple_nft_example.html +++ b/crates/wasm/www/simple_nft_example.html @@ -133,14 +133,16 @@

WebARKitLib.rs – NFT Tracking Example

- This demo demonstrates AR2 Natural Feature Tracking running - entirely in Rust/WASM. The AR2 tracking engine uses template matching on - image pyramids to refine a camera pose. + This demo demonstrates the full NFT pipeline — KPM detection then + AR2 tracking — running entirely in Rust/WASM. KPM keypoint matching + finds the marker and its initial pose; the AR2 engine then refines that pose + via template matching on image pyramids.

- The pipeline loads .iset (image pyramid) and .fset - (feature points) marker data, accepts an initial 3×4 camera pose, and runs - per-frame tracking to produce a refined pose. + The pipeline loads .fset3 (KPM reference keypoints, used for + detection), plus .iset (image pyramid) and .fset + (feature points) for tracking. "Detect (KPM)" produces the initial 3×4 pose; + "Track Frame" then runs per-frame AR2 refinement.

Note: Full NFT detection (KPM) requires a pure-Rust FREAK @@ -153,7 +155,7 @@

WebARKitLib.rs – NFT Tracking Example

- +
@@ -180,17 +182,10 @@

WebARKitLib.rs – NFT Tracking Example

const resultsEl = document.getElementById('results'); let nftHandle = null; + let kpmHandle = null; let testImage = new Image(); let wasmModule = null; - // A known-good initial pose for the pinball marker - // (from the KPM detection step in the native example). - const TEST_POSE = [ - 0.9866, 0.1643, 0.0027, -181.9168, - 0.1531, -0.9197, -0.3612, 64.6862, - -0.0563, 0.3568, -0.9325, 590.5905 - ]; - async function run() { try { statusEl.innerText = 'Status: Loading WASM module...'; @@ -236,40 +231,46 @@

WebARKitLib.rs – NFT Tracking Example

const paramRes = await fetch('assets/camera_para.dat'); const paramData = new Uint8Array(await paramRes.arrayBuffer()); - // Fetch NFT marker data. - const [isetRes, fsetRes] = await Promise.all([ + // Fetch NFT marker data: .iset/.fset for AR2 tracking, + // .fset3 for KPM detection. + const [isetRes, fsetRes, fset3Res] = await Promise.all([ fetch('assets/pinball.iset'), fetch('assets/pinball.fset'), + fetch('assets/pinball.fset3'), ]); const isetData = new Uint8Array(await isetRes.arrayBuffer()); const fsetData = new Uint8Array(await fsetRes.arrayBuffer()); + const fset3Data = new Uint8Array(await fset3Res.arrayBuffer()); console.log(`[NFT] Loaded: camera_para.dat (${paramData.length} bytes)`); console.log(`[NFT] Loaded: pinball.iset (${isetData.length} bytes)`); console.log(`[NFT] Loaded: pinball.fset (${fsetData.length} bytes)`); + console.log(`[NFT] Loaded: pinball.fset3 (${fset3Data.length} bytes)`); - // Create NFT handle. + // Create handles. const width = canvas.width; const height = canvas.height; nftHandle = new wasmModule.WasmNFTHandle(paramData, width, height); + kpmHandle = new wasmModule.WasmKpmHandle(paramData, width, height); - // Load marker data. + // Load marker data: surface set (tracking) + KPM ref data (detection). nftHandle.load_nft_marker(isetData, fsetData); + kpmHandle.load_ref_data(fset3Data); const mw = nftHandle.get_marker_width(); const mh = nftHandle.get_marker_height(); const dpi = nftHandle.get_marker_dpi(); resultsEl.innerText = - `NFT Handle initialized:\n` + + `NFT + KPM handles initialized:\n` + ` Frame size: ${width}x${height}\n` + ` Marker: ${mw}x${mh} @ ${dpi} DPI\n` + ` Loaded: ✓\n\n` + - `Click "Set Test Pose" to provide an initial pose,\n` + - `then "Track Frame" to run AR2 tracking.`; + `Click "Detect (KPM)" to detect the marker pose,\n` + + `then "Track Frame" to run AR2 tracking refinement.`; - statusEl.innerText = 'Status: NFT marker loaded. Set a pose to begin tracking.'; + statusEl.innerText = 'Status: NFT marker loaded. Run KPM detection to begin.'; statusEl.className = 'status success'; btnInit.disabled = true; btnSetPose.disabled = false; @@ -283,18 +284,40 @@

WebARKitLib.rs – NFT Tracking Example

}; btnSetPose.onclick = () => { - if (!nftHandle) return; + if (!nftHandle || !kpmHandle) return; try { - nftHandle.set_initial_pose(new Float32Array(TEST_POSE)); + // Real KPM detection on the current frame (#161 goal 4): + // canvas RGBA -> WasmKpmHandle.detect() -> 3x4 pose. + const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); + const rgba = new Uint8Array(imageData.data.buffer); - resultsEl.innerText = - `Initial pose set (from KPM detection):\n\n` + - formatMatrix(TEST_POSE) + - `\nContinuity: ${nftHandle.get_cont_num()} frame(s)\n\n` + - `Click "Track Frame" to run AR2 tracking refinement.`; + const t0 = performance.now(); + const det = kpmHandle.detect(rgba); + const elapsed = performance.now() - t0; + + if (!det) { + resultsEl.innerText = + `KPM detection: no marker found (${elapsed.toFixed(1)} ms).`; + statusEl.innerText = 'Status: KPM found no match.'; + statusEl.className = 'status error'; + return; + } - statusEl.innerText = 'Status: Initial pose set. Ready to track.'; + console.log('[KPM] detected pose', det.pose, 'page', det.page, 'error', det.error); + + // Feed the detected pose into the AR2 tracker. + nftHandle.set_initial_pose(new Float32Array(det.pose)); + + resultsEl.innerText = + `KPM detection (${elapsed.toFixed(1)} ms):\n` + + ` Page: ${det.page}\n` + + ` Error: ${det.error.toFixed(4)}\n\n` + + `Detected 3×4 pose:\n` + + formatMatrix(det.pose) + + `\nInitial pose set. Click "Track Frame" for AR2 refinement.`; + + statusEl.innerText = 'Status: KPM detected pose. Ready to track.'; statusEl.className = 'status success'; btnTrack.disabled = false; diff --git a/docs/design/issue-174-criterion-upgrade.md b/docs/design/issue-174-criterion-upgrade.md new file mode 100644 index 0000000..d1231f3 --- /dev/null +++ b/docs/design/issue-174-criterion-upgrade.md @@ -0,0 +1,162 @@ +# Issue #174 — Upgrade criterion 0.5.1 → 0.8.x + +Design document for the dependency bump described in +[issue #174](https://github.com/webarkit/WebARKitLib-rs/issues/174). +Produced via the `/brainstorming` workflow on 2026-06-10. + +--- + +## 1. Understanding Lock (confirmed) + +**What is being built** +A dependency-only upgrade of `criterion` from `0.5.1` → `0.8` in +`crates/core/Cargo.toml`, plus the minimal source fixes in the three +bench files to match criterion 0.8's API. + +**Why it exists** +`criterion 0.5.1` (May 2023) is ~3 years stale; `0.8.2` (Feb 2026) is +the current stable. Surfaced as a follow-up to M9-3 (#142), intentionally +separated per CLAUDE.md's "fresh branch per sub-issue" rule — the +upgrade is unrelated to the "remove C++ FFI as default" theme of #142, +and the 0.5 → 0.8 span has API breaks that deserve their own focused +review. + +**Who it is for** +WebARKitLib-rs maintainers running local benchmarks, and CI's +`benchmarks` job. + +**Key constraints** +- No API changes outside the three bench files. +- Pin style: `criterion = "0.8"` (matches the existing dev-dep style). +- Single bench matrix for `BENCHMARKS.md`: `--features simd`. +- `BENCHMARKS.md` refresh is **scope-driven by the bench run output** + (see Decision #4). +- All four CLAUDE.md §5 pre-commit checks must pass. + +**Explicit non-goals** +- No new benchmarks (KPM/NFT-specific gaps tracked separately under #142). +- No migration to `divan` / `iai`. +- No bumps to other dev-deps. +- No changes to library code. + +**Assumptions** +1. The 0.5 → 0.8 break surface in these benches is limited to + `criterion::black_box` → `std::hint::black_box`. Verified by + compiling. +2. `Criterion::default()`, `criterion_group!`, `criterion_main!`, and + `group.sample_size(N)` are stable across 0.5 → 0.8. (Spot-checked + criterion 0.8 changelog.) +3. `BENCHMARKS.md` edits will be scope-driven by the bench run output: + minimal numeric refresh by default; add sections only if the run + reveals something that needs explanation. +4. CI's `benchmarks` job uses the same `cargo bench` invocation and + doesn't pin criterion separately. + +--- + +## 2. Decision Log + +| # | Decision | Alternatives | Why | +|----|--------------------------------------------------------------------------------------------|-----------------------------------------------|------------------------------------------------------------------------------------------------------------------| +| 1 | Pin as `criterion = "0.8"` | `"0.8.2"`, `"=0.8.2"` | Matches existing dev-dep style (`clap = "4.5"`); allows patch updates. User-confirmed. | +| 2 | Single PR, two commits (deps+code, then doc) | One commit / per-file commits | Clean blame across deps vs doc; one logical change → one PR. | +| 3 | Bench matrix: `--features simd` only | + scalar / + ffi-backend / + dual-mode | Matches issue acceptance; minimizes scope creep. User-confirmed. | +| 4 | `BENCHMARKS.md`: scope-driven refresh | Full rewrite / skip-numbers | "Full rewrite" overstates the work for a dep bump; decide minimal-edit vs added section after seeing run output. | +| 5 | `black_box` migration: `criterion::black_box` → `std::hint::black_box` | Keep `criterion::black_box` (removed in 0.7+) | Required by 0.7+. No call-site changes, just the import line. | +| 6 | Verify MSRV before pushing | Bump MSRV silently | criterion 0.8 needs Rust 1.74+; if workspace MSRV is lower, surface as its own decision before pushing. | + +--- + +## 3. File Changes + +### 3.1 `crates/core/Cargo.toml` + +```toml +# was +criterion = "0.5.1" +# after +criterion = "0.8" +``` + +### 3.2 `crates/core/benches/{marker_bench,feature_map_bench,simd_bench}.rs` + +```rust +// before +use criterion::{black_box, criterion_group, criterion_main, Criterion}; +// after +use criterion::{criterion_group, criterion_main, Criterion}; +use std::hint::black_box; +``` + +No other call-site changes expected. + +### 3.3 `crates/core/benches/BENCHMARKS.md` + +Scope-driven after the bench run: + +- **Default path (minimal edit)**: replace numbers table, bump the + "Tooling" line to `criterion 0.8`, refresh the timestamp. +- **If the run reveals a shape change** (new metric reported, restructured + output): add a small section explaining the delta. +- **If a bench had to be restructured to compile**: document the + restructuring in a new subsection. + +--- + +## 4. Execution Sequence + +1. Branch off `dev` → `chore/criterion-0.8-upgrade`. +2. Bump `criterion = "0.8"` in `crates/core/Cargo.toml`. +3. Fix `black_box` imports in the 3 bench files. +4. **Compile gate**: `cargo build --benches --all-features`. If it fails + outside the `black_box` line, stop and re-evaluate (assumption #1 + broken). +5. **Pre-commit gates** (CLAUDE.md §5): + - `cargo fmt --all` + - `cargo build --all-features` + - `cargo clippy --all-targets --all-features -- --deny warnings` + - `cargo test --all-features` +6. Commit 1: `chore(deps): upgrade criterion 0.5.1 → 0.8`. +7. Bench run: `cargo bench -p webarkitlib-rs --features simd`. Capture + output. +8. Diff old vs new numbers in `BENCHMARKS.md`. Apply scope-driven edit. +9. Commit 2: `doc(benches): refresh BENCHMARKS.md for criterion 0.8`. +10. Push branch, open PR against `dev` titled + `chore(deps): upgrade criterion 0.5.1 → 0.8.x`. + +--- + +## 5. Risks and Rollback + +- **Risk**: a transitive of criterion 0.8 conflicts with another + dev-dep. *Mitigation*: `cargo tree -p webarkitlib-rs --target all` + if `cargo build` errors. +- **Risk**: workspace MSRV is below criterion 0.8's requirement + (Rust 1.74+). *Mitigation*: check `rust-version` before pushing; + surface a separate decision if a bump is needed. +- **Rollback**: revert the two commits. No library-code or public API + changes, so no downstream breakage is possible. + +--- + +## 6. Testing Strategy + +- `cargo build --benches` is the real correctness gate — benches don't + have unit tests. +- `cargo bench --features simd` must produce numbers for every existing + bench (issue acceptance criterion). +- `cargo test --all-features` must stay green (sanity check; should be + unaffected by a dev-dep bump). + +--- + +## 7. Acceptance (mirrors issue #174) + +- [ ] `cargo bench -p webarkitlib-rs --features simd` builds and runs + cleanly with criterion 0.8. +- [ ] All existing benches produce output (no API-mismatch panics). +- [ ] `BENCHMARKS.md` refreshed with new numbers. +- [ ] `cargo fmt --all -- --check` and + `cargo clippy --all-targets --all-features -- --deny warnings` + green. +- [ ] CI's `benchmarks` job passes on the upgrade PR. diff --git a/docs/design/issue-182-miri-plan.md b/docs/design/issue-182-miri-plan.md new file mode 100644 index 0000000..1058612 --- /dev/null +++ b/docs/design/issue-182-miri-plan.md @@ -0,0 +1,143 @@ +# Issue #182 — Miri UB Validation for Pure-Rust Code Paths + +Design plan produced via `/brainstorming` skill. +Source issue: https://github.com/webarkit/WebARKitLib-rs/issues/182 +Branch: `ci/miri-validation-182` (off `dev`). + +--- + +## Understanding Summary + +- **What**: Add a Miri-based undefined-behavior validation CI job + (`cargo +nightly miri test -p webarkitlib-rs --lib`) and accompanying + `docs/miri.md`. +- **Why**: Catch use-after-free, OOB reads, invalid `unsafe` invariants, + uninitialized-memory reads, and reference-aliasing violations in the + freshly-ported KPM/FREAK/AR/AR2 code before contributors build on top + of it. Sister to #180 (clippy strictness) as the second half of the + post-M9 safety net. +- **Who**: Maintainers and contributors touching `unsafe`, FFI + boundaries, or hot loops with unchecked slicing. +- **Scope**: Pure-Rust default features, lib targets only. Excludes + `ffi-backend` (Miri can't execute C++), `simd-x86-*` (Miri x86 SIMD + support is incomplete), and FFI-dependent integration tests under + `tests/`. +- **Non-goals**: Fixing whatever UB Miri surfaces in this PR — each + finding gets its own follow-up PR, mirroring the #180 cleanup series + pattern. + +## Modules under Miri validation + +Explicit list (per user correction during brainstorming — issue draft +only highlighted KPM/FREAK): + +- `crates/core/src/ar/` — labeling, pattern matching, marker decoding, + image processing (C ARToolKit port). +- `crates/core/src/ar2/` — image_set, feature_map, byteorder I/O. +- `crates/core/src/kpm/` — KPM pipeline. +- `crates/core/src/kpm/freak/` — math, homography, clustering, matcher, + visual_database (M6→M9 fresh port). + +## Assumptions + +- `kpm-build (ubuntu-latest)` is the right neighbor for the Miri job in + `ci.yml` — both are pure-Rust correctness gates. +- No `rust-toolchain.toml` change needed; nightly stays scoped to the + Miri job. +- Conventional Commits: PR is + `ci(miri): add Miri UB validation for pure-Rust code paths (#182)`. +- The repo-wide `on:` block (`push: branches: ['**']` + `pull_request:`) + requires a job-level `if:` guard to restrict to PRs against + `dev`/`main` (per Q2 decision). + +## Non-Functional Requirements + +- **CI cost**: Single Ubuntu runner, no matrix. Expected 10–30 min on + warm cache. Acceptable per issue. +- **Reliability**: `continue-on-error: true` initially; transient + nightly breaks → manual rerun. +- **Maintenance**: Nightly pin bumped manually when Miri breaks or + every ~3 months. No automation. +- **Security**: None — checkout + rustup + cache action; no secrets. +- **Scope discipline**: This PR adds *only* CI infra + docs. Any UB + Miri surfaces → separate fix PRs. + +## Decision Log + +| # | Decision | Alternatives | Rationale | +|---|---|---|---| +| 1 | `continue-on-error: true`, ratchet later | Block PR until clean; bundle fixes here | Matches #180 ratcheting precedent; decouples infra from unknown-size UB cleanup | +| 2 | Trigger on PRs to `dev`/`main` only | Every push; nightly schedule; both | Pre-merge gate is the actual value; nightly schedule = YAGNI | +| 3 | `Swatinem/rust-cache@v2`, Miri-keyed | No cache; explicit sysroot cache | Standard; handles Miri sysroot when keyed on toolchain | +| 4 | Pin nightly date inline in workflow | Floating nightly; `rust-toolchain.toml` | Local to Miri job; survives nightly regressions; doesn't bleed into stable jobs | +| 5 | `docs/miri.md` (new file) | Section in `CONTRIBUTING.md`; both | Single canonical URL for CI annotations & follow-up PRs | +| 6 | Standalone top-level `miri:` job | Step in existing job; reusable workflow | Matches repo pattern; trivial ratchet to required; no nightly contamination of stable jobs | +| 7 | Scope description names `ar/`, `ar2/`, `kpm/`, `kpm/freak/` explicitly | KPM/FREAK only (per issue draft) | User correction — `ar/` modules contain the C ARToolKit port surface and matter equally | +| 8 | `-Zmiri-strict-provenance` enabled in CI | Default provenance | Catches pointer-provenance bugs that loose-provenance hides; appropriate for a careful port | +| 9 | Job-level `if:` guard for PR-to-dev/main scoping | Workflow-level `on:` override (not possible per-job); separate workflow file | Cleanest; one line; doesn't touch other jobs' triggers | + +## Final Design + +### Workflow job (added to `.github/workflows/ci.yml`) + +```yaml +miri: + name: miri (pure-Rust UB validation) + runs-on: ubuntu-latest + # Per Q2: restrict to PRs against dev/main only. + if: github.event_name == 'pull_request' && + (github.base_ref == 'dev' || github.base_ref == 'main') + continue-on-error: true # ratchet to false after UB fix PRs land (#182) + env: + MIRI_NIGHTLY: nightly-2026-06-01 + steps: + - uses: actions/checkout@v5 + - name: Install pinned nightly + Miri + run: | + rustup toolchain install $MIRI_NIGHTLY --component miri --profile minimal + rustup default $MIRI_NIGHTLY + cargo miri setup + - name: Rust cache + uses: Swatinem/rust-cache@v2 + with: + key: miri-${{ env.MIRI_NIGHTLY }} + shared-key: miri + - name: Run Miri (pure-Rust, lib targets only) + run: cargo miri test -p webarkitlib-rs --lib + env: + MIRIFLAGS: -Zmiri-backtrace=full -Zmiri-strict-provenance +``` + +### `docs/miri.md` skeleton + +Sections: +1. What Miri validates here (module list). +2. What Miri does NOT validate (`ffi-backend`, SIMD, FFI integration tests). +3. Running locally (commands). +4. Investigating failures (`MIRIFLAGS` knobs). +5. Bumping the nightly pin (policy). +6. CI gate status (`continue-on-error` → required ratchet). + +## Risks + +| Risk | Mitigation | +|---|---| +| Nightly Miri broken on the pinned date | Pick known-good date during PR; document bump procedure | +| First-run UB findings overwhelming | `continue-on-error: true`; separate follow-up PRs | +| CI minute cost on PRs | PR-scoped via `if:` guard; rust-cache keeps warm runs cheap | +| Cache pollution between nightly bumps | Cache key includes `MIRI_NIGHTLY` → automatic invalidation | + +## Exit Criteria — all met + +- [x] Understanding Lock confirmed +- [x] Design approach A (standalone top-level job) accepted +- [x] Assumptions documented +- [x] Risks acknowledged +- [x] Decision Log complete + +## Out of Scope + +- Fixing any UB findings surfaced by the first Miri run. +- Adding Miri to `simd-*` or `ffi-backend` paths. +- Multi-OS Miri matrix (Linux-only is sufficient — Miri's findings are + not OS-specific for pure-Rust code). diff --git a/docs/miri.md b/docs/miri.md new file mode 100644 index 0000000..7aeccee --- /dev/null +++ b/docs/miri.md @@ -0,0 +1,189 @@ +# Miri — Undefined Behavior Validation + +[Miri](https://github.com/rust-lang/miri) is the Rust MIR interpreter. +It detects undefined behavior that escapes regular tests: use-after-free, +out-of-bounds reads, invalid `unsafe` invariants, uninitialized-memory +reads, and reference-aliasing violations. + +WebARKitLib-rs runs Miri in CI as the second half of the post-M9 safety +net (sister to #180 clippy strictness). + +--- + +## What Miri validates here + +CI runs: + +```bash +cargo +nightly miri test -p webarkitlib-rs --lib +``` + +That exercises the pure-Rust lib of the `webarkitlib-rs` crate, which +covers the modules where manual buffer math and `unsafe` live: + +- `crates/core/src/ar/` — labeling, pattern matching, marker decoding, + image processing (C ARToolKit port). +- `crates/core/src/ar2/` — `image_set`, `feature_map`, `byteorder` I/O + on disk buffers. +- `crates/core/src/kpm/` — KPM pipeline. +- `crates/core/src/kpm/freak/` — `math`, `homography`, `clustering`, + `matcher`, `visual_database` (the freshly-ported M6→M9 surface). + +## What Miri does NOT validate + +- **`--features ffi-backend`** — Miri cannot execute foreign C/C++ + functions, so all FFI shims would fail under it. The `webarkit_cpp_*` + externs are intentionally outside Miri's scope. +- **`--features simd-x86-sse41` / `simd-x86-avx2`** — Miri's x86 SIMD + intrinsic support is incomplete. Scalar fallbacks ARE exercised under + Miri; SIMD parity is covered by the scalar/SIMD parity tests in + CI separately. +- **Integration tests under `tests/`** that depend on FFI (e.g. + `kpm_regression`, `cross_stack_parity`, `absolute_corner_error` in + `dual-mode`) — these need the C++ baseline to run. +- **Heavy algorithmic / pipeline tests** annotated with + `#[cfg_attr(miri, ignore)]` (see next section). + +## Tests skipped under Miri (`#[cfg_attr(miri, ignore)]`) + +Some unit tests run full pipelines — BHC tree construction over hundreds +of descriptors, DoG keypoint detection on real benchmark images, full +`ar2_gen_feature_map` runs. Native `cargo test` finishes them in +milliseconds because the code is compiled and parallelized. Miri +interprets MIR single-threaded, so the same tests can take 30+ minutes +each — and they exercise no `unsafe` boundary that targeted unit tests +don't already cover (#194). + +We annotate these with `#[cfg_attr(miri, ignore)]`: + +```rust +#[test] +#[cfg_attr(miri, ignore)] // #194: full pipeline — too slow under Miri +fn test_heavy_pipeline() { ... } +``` + +Effect: the test still runs under regular `cargo test`; it's skipped +only under `cargo miri test`. Targeted unit tests on `unsafe` +boundaries (`hamming_distance_*`, descriptor pack/unpack, byteorder +reads, image_proc indexing) stay enabled — those are the ones Miri +actually validates. + +To run a Miri-ignored test locally for investigation: + +```bash +cargo +nightly miri test -p webarkitlib-rs --lib -- --ignored +``` + +## Running Miri locally + +```bash +# One-time setup (matches the CI pin — update if CI pin changes) +rustup toolchain install nightly-2026-06-01 --component miri +cargo +nightly-2026-06-01 miri setup + +# Run the full Miri suite (matches CI) +cargo +nightly-2026-06-01 miri test -p webarkitlib-rs --lib + +# Run a single test +cargo +nightly-2026-06-01 miri test -p webarkitlib-rs --lib +``` + +Expect the run to be **5–30× slower** than `cargo test`. This is normal +— Miri interprets MIR rather than executing native code. + +## Investigating failures + +When Miri reports UB, useful environment knobs: + +```bash +# Full backtrace on the offending operation +MIRIFLAGS="-Zmiri-backtrace=full" cargo +nightly miri test -p webarkitlib-rs --lib + +# Strict provenance (NOT enabled in CI — see note below; useful locally +# for spot-checking our own code) +MIRIFLAGS="-Zmiri-strict-provenance" cargo +nightly miri test -p webarkitlib-rs --lib + +# -Zmiri-disable-isolation (already set in CI — lets tests using +# tempfile / disk I/O run; e.g. ar2::feature_set roundtrip) +MIRIFLAGS="-Zmiri-disable-isolation" cargo +nightly miri test -p webarkitlib-rs --lib + +# -Zmiri-ignore-leaks (already set in CI — rayon's global thread pool +# spawns workers that aren't joined at exit; Miri flags that as a leak +# by default. Ignoring it is standard for rayon-using suites and does +# NOT weaken UB detection.) +MIRIFLAGS="-Zmiri-ignore-leaks" cargo +nightly miri test -p webarkitlib-rs --lib +``` + +If a finding is intentional (e.g. a known-safe `transmute` pattern that +Miri can't model), mark the test `#[ignore]` under Miri and open a +tracking issue. **Do not** add `unsafe` to silence Miri without +understanding the report. + +## Bumping the nightly pin + +The pin lives in `.github/workflows/miri.yml` as the `MIRI_NIGHTLY` job +env var. Bump when: + +- CI hits a known nightly Miri regression (link the upstream issue in + the PR description). +- Every ~3 months for general freshness. + +Procedure: + +1. Pick a recent known-good nightly from + (filter on + `miri`). +2. Update `MIRI_NIGHTLY` in `miri.yml` AND the "Running locally" snippet + in this file. +3. Open a PR; CI will validate the new pin. + +The `Swatinem/rust-cache@v2` key includes `MIRI_NIGHTLY`, so the cache +invalidates automatically on bump. + +## Aliasing model: Tree Borrows (not Stacked Borrows) + +CI sets `MIRIFLAGS=-Zmiri-tree-borrows`. We use the **Tree Borrows** +aliasing model instead of Miri's default **Stacked Borrows** because +Stacked Borrows trips inside `crossbeam-epoch` (a transitive dep of +`rayon`, reached from `ar2::feature_map`'s `par_chunks_mut`) on +patterns that are sound in practice. Tree Borrows is a newer +experimental aliasing model that accepts those patterns while still +catching real UB in our code. + +If you want to spot-check our own `unsafe` under the stricter Stacked +Borrows locally, just drop the flag: + +```bash +MIRIFLAGS="-Zmiri-backtrace=full" cargo +nightly miri test \ + -p webarkitlib-rs --lib +``` + +## Why `-Zmiri-strict-provenance` is NOT enabled in CI + +Strict provenance also trips inside `crossbeam-epoch` (it predates the +Strict Provenance APIs and still uses integer-to-pointer casts +internally). Running CI with strict provenance against unmigrated +third-party deps produces failures we cannot fix in this repo. We +revisit once the ecosystem catches up. Developers are encouraged to +spot-check their own new `unsafe` code locally with +`MIRIFLAGS="-Zmiri-strict-provenance"`. + +## CI gate status + +The Miri job is a **required gate** for PRs targeting `dev` and `main`, +runs in its own workflow at `.github/workflows/miri.yml`, and surfaces +via a dedicated badge in the project README. + +Docs-only changes (`**/*.md`, `docs/**`, `assets/**`, `LICENSE*`, +`.gitignore`, `.gitattributes`) are skipped via `paths-ignore` in the +real Miri workflow. A companion shim workflow at +`.github/workflows/miri-skip.yml` runs on exactly those paths and +emits a passing check with the same name (`miri (pure-Rust UB +validation)`), so branch protection's required-check rule is +satisfied without spinning up the nightly toolchain for a README typo. +If you change the `paths-ignore` list in `miri.yml`, mirror the change +to the `paths` list in `miri-skip.yml`. + +Each future finding gets its own fix PR — never a mega-PR — mirroring +the #180 cleanup series pattern (and #192 from this safety net's first +run, which uncovered a real unaligned-transmute UB). diff --git a/package.json b/package.json index abf75af..f520df7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "webarkitlib-rs", - "version": "0.7.0", + "version": "0.8.0", "description": "Port of WebARKitLib (ARToolKit) to Rust and WASM", "private": true, "scripts": {