Skip to content

Commit c088fa9

Browse files
kalwaltclaude
andcommitted
feat(wasm): KPM detection binding + browser NFT demo (#161 goal 4)
The wasm NFT scaffolding only did AR2 tracking from a hardcoded pose — there was no KPM detection binding. Add it so the browser demo runs the real pure-Rust KPM detection (the simple_nft.rs steps 3a + 4). - core: add `KpmRefDataSet::load_from_bytes` (refactor `load` to share a reader-based parser) so `.fset3` can be loaded from fetched bytes in the browser (no filesystem). Native parity test vs `load(path)`. - wasm: add `WasmKpmHandle` — `new(param, w, h)` builds a `RustFreakMatcher` + `KpmHandle`; `load_ref_data(.fset3 bytes)`; `detect(rgba)` runs `kpm_matching` and returns `{ pose:[12], page, error }` (or null). - demo: `www/simple_nft_example.html` now fetches `pinball.fset3`, creates a `WasmKpmHandle`, and the "Detect (KPM)" button runs real detection, prints the 3x4 pose, and feeds it into AR2 tracking (replacing the hardcoded pose). - test: headless `wasm-bindgen-test` (run via `wasm-pack test`) covering construction + `.fset3` load + pre-load detect guard. Verified in-browser (manual): KPM detection on pinball-demo.jpg yields page 0, error 5.0879, and a 3x4 pose matching the native simple_nft pipeline (~5.09 error) — the pure-Rust KPM->pose pipeline runs end-to-end in the browser. Also: `cargo build --target wasm32-unknown-unknown -p webarkitlib-wasm --tests` clean; `cargo clippy --all-targets --all-features -D warnings` clean; native kpm tests green. Closes #161. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 5da470c commit c088fa9

4 files changed

Lines changed: 286 additions & 27 deletions

File tree

crates/core/src/kpm/ref_data_set.rs

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@
4545
//! [`merge`](KpmRefDataSet::merge), and
4646
//! [`change_page_no`](KpmRefDataSet::change_page_no).
4747
48-
use std::io::{Read, Write};
48+
use std::io::Write;
4949
use std::path::Path;
5050

5151
use byteorder::{LittleEndian, ReadBytesExt, WriteBytesExt};
@@ -405,8 +405,18 @@ impl KpmRefDataSet {
405405
/// Load a data set from a binary file written by [`save`](Self::save).
406406
pub fn load(path: &Path) -> std::io::Result<Self> {
407407
let file = std::fs::File::open(path)?;
408-
let mut r = std::io::BufReader::new(file);
408+
Self::load_from_reader(std::io::BufReader::new(file))
409+
}
410+
411+
/// Load a data set from in-memory bytes (e.g. a `.fset3` fetched in the
412+
/// browser, where there is no filesystem). Same format as [`load`](Self::load).
413+
pub fn load_from_bytes(bytes: &[u8]) -> std::io::Result<Self> {
414+
Self::load_from_reader(std::io::Cursor::new(bytes))
415+
}
409416

417+
/// Parse a data set from any reader. Shared by [`load`](Self::load) and
418+
/// [`load_from_bytes`](Self::load_from_bytes).
419+
fn load_from_reader<R: std::io::Read>(mut r: R) -> std::io::Result<Self> {
410420
let num = r.read_i32::<LittleEndian>()?;
411421
if num <= 0 {
412422
return Err(std::io::Error::new(
@@ -728,4 +738,28 @@ mod tests {
728738
// The test data lives in crates/kpm/examples/data/ and CARGO_MANIFEST_DIR
729739
// now points to crates/core/. The test is covered by
730740
// crates/kpm/tests/regression.rs::test_fset3_load_structure.
741+
742+
/// `load_from_bytes` (the wasm-friendly loader added for #161) must parse a
743+
/// real `.fset3` identically to the path-based `load`.
744+
#[cfg(not(target_arch = "wasm32"))]
745+
#[cfg_attr(miri, ignore)] // reads + parses a 625 KB .fset3 — too slow under Miri
746+
#[test]
747+
fn test_load_from_bytes_matches_load() {
748+
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
749+
.join("examples")
750+
.join("Data")
751+
.join("pinball.fset3");
752+
let from_path = KpmRefDataSet::load(&path).expect("load(path) failed");
753+
let bytes = std::fs::read(&path).expect("read failed");
754+
let from_bytes = KpmRefDataSet::load_from_bytes(&bytes).expect("load_from_bytes failed");
755+
756+
assert_eq!(from_path.num, from_bytes.num);
757+
assert_eq!(from_path.page_num, from_bytes.page_num);
758+
assert_eq!(from_path.ref_point.len(), from_bytes.ref_point.len());
759+
// Spot-check the first ref point round-trips identically.
760+
assert_eq!(
761+
from_path.ref_point[0].feature_vec.v,
762+
from_bytes.ref_point[0].feature_vec.v
763+
);
764+
}
731765
}

crates/wasm/src/lib.rs

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,12 @@ use webarkitlib_rs::types::{
6262
};
6363
use webarkitlib_rs::version;
6464

65+
// KPM detection (the `simple_nft.rs` steps 3a + 4 surface).
66+
use std::sync::Arc;
67+
use webarkitlib_rs::kpm::ref_data_set::KPM_CHANGE_PAGE_NO_ALL_PAGES;
68+
use webarkitlib_rs::kpm::types::KpmRefDataSet;
69+
use webarkitlib_rs::kpm::{KpmHandle, RustFreakMatcher};
70+
6571
/// Returns the current version string of the library.
6672
#[wasm_bindgen]
6773
pub fn get_version() -> String {
@@ -643,3 +649,122 @@ impl Drop for WasmNFTHandle {
643649
}
644650
}
645651
}
652+
653+
/// Result of [`WasmKpmHandle::detect`]: the detected 3×4 pose (row-major, 12
654+
/// floats), the matched page number, and the matching error.
655+
#[derive(serde::Serialize)]
656+
struct KpmDetectResult {
657+
pose: [f32; 12],
658+
page: i32,
659+
error: f32,
660+
}
661+
662+
/// KPM (Keypoint Matching) detection handle — the WASM equivalent of
663+
/// `simple_nft.rs` steps 3a + 4. Loads NFT reference data (`.fset3`) and
664+
/// detects the marker's initial 3×4 pose in a query frame using the pure-Rust
665+
/// [`RustFreakMatcher`].
666+
///
667+
/// Pair with [`WasmNFTHandle`] for AR2 tracking: feed `detect()`'s pose into
668+
/// [`WasmNFTHandle::set_initial_pose`].
669+
#[wasm_bindgen]
670+
pub struct WasmKpmHandle {
671+
handle: KpmHandle,
672+
width: i32,
673+
height: i32,
674+
loaded: bool,
675+
}
676+
677+
#[wasm_bindgen]
678+
impl WasmKpmHandle {
679+
/// Create a KPM detection handle.
680+
///
681+
/// * `param_bytes` — `camera_para.dat` contents.
682+
/// * `width` / `height` — query-frame size in pixels.
683+
#[wasm_bindgen(constructor)]
684+
pub fn new(param_bytes: &[u8], width: i32, height: i32) -> Result<WasmKpmHandle, JsValue> {
685+
let cursor = Cursor::new(param_bytes);
686+
let mut param = ARParam::load(cursor)
687+
.map_err(|e| JsValue::from_str(&format!("Failed to load camera param: {}", e)))?;
688+
689+
// Scale camera params to the frame size (arParamChangeSize equivalent).
690+
let sx = width as f64 / param.xsize as f64;
691+
let sy = height as f64 / param.ysize as f64;
692+
for col in 0..4 {
693+
param.mat[0][col] *= sx;
694+
param.mat[1][col] *= sy;
695+
}
696+
param.xsize = width;
697+
param.ysize = height;
698+
699+
let param_lt = Arc::new(ARParamLT::new_basic(param));
700+
let backend = RustFreakMatcher::new(width, height)
701+
.map_err(|e| JsValue::from_str(&format!("Failed to create FREAK matcher: {:?}", e)))?;
702+
let handle = KpmHandle::new(width, height, Some(param_lt), Box::new(backend));
703+
704+
Ok(WasmKpmHandle {
705+
handle,
706+
width,
707+
height,
708+
loaded: false,
709+
})
710+
}
711+
712+
/// Load NFT reference data from `.fset3` bytes (KPM reference keypoints).
713+
/// All pages are remapped to page 0 (single-marker setup).
714+
pub fn load_ref_data(&mut self, fset3_bytes: &[u8]) -> Result<(), JsValue> {
715+
let mut ref_data = KpmRefDataSet::load_from_bytes(fset3_bytes)
716+
.map_err(|e| JsValue::from_str(&format!("Failed to load .fset3: {}", e)))?;
717+
ref_data.change_page_no(KPM_CHANGE_PAGE_NO_ALL_PAGES, 0);
718+
self.handle
719+
.set_ref_data_set(ref_data)
720+
.map_err(|e| JsValue::from_str(&format!("Failed to set ref data: {:?}", e)))?;
721+
self.loaded = true;
722+
Ok(())
723+
}
724+
725+
/// Run KPM detection on an RGBA frame (`width * height * 4` bytes, e.g. a
726+
/// canvas `ImageData.data` buffer).
727+
///
728+
/// Returns `{ pose: number[12], page, error }` on a match, or `null` if no
729+
/// marker was found.
730+
pub fn detect(&mut self, rgba_bytes: &[u8]) -> Result<JsValue, JsValue> {
731+
if !self.loaded {
732+
return Err(JsValue::from_str(
733+
"reference data not loaded — call load_ref_data first",
734+
));
735+
}
736+
let expected = (self.width * self.height * 4) as usize;
737+
if rgba_bytes.len() != expected {
738+
return Err(JsValue::from_str(&format!(
739+
"rgba length {} != expected {} ({}x{}x4)",
740+
rgba_bytes.len(),
741+
expected,
742+
self.width,
743+
self.height
744+
)));
745+
}
746+
747+
let luma = rgba_to_gray(rgba_bytes);
748+
self.handle
749+
.kpm_matching(&luma)
750+
.map_err(|e| JsValue::from_str(&format!("kpm_matching failed: {:?}", e)))?;
751+
752+
match self.handle.get_pose() {
753+
Some((cam_pose, page, error)) => {
754+
let mut pose = [0f32; 12];
755+
for (r, row) in cam_pose.iter().enumerate() {
756+
pose[r * 4..r * 4 + 4].copy_from_slice(row);
757+
}
758+
let result = KpmDetectResult { pose, page, error };
759+
serde_wasm_bindgen::to_value(&result)
760+
.map_err(|e| JsValue::from_str(&format!("serialize failed: {}", e)))
761+
}
762+
None => Ok(JsValue::NULL),
763+
}
764+
}
765+
766+
/// Whether reference data has been loaded.
767+
pub fn is_loaded(&self) -> bool {
768+
self.loaded
769+
}
770+
}

crates/wasm/tests/kpm_detection.rs

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
/*
2+
* kpm_detection.rs
3+
* WebARKitLib-rs
4+
*
5+
* This file is part of WebARKitLib-rs - WebARKit.
6+
*
7+
* WebARKitLib-rs is free software: you can redistribute it and/or modify
8+
* it under the terms of the GNU Lesser General Public License as published by
9+
* the Free Software Foundation, either version 3 of the License, or
10+
* (at your option) any later version.
11+
*
12+
* WebARKitLib-rs is distributed in the hope that it will be useful,
13+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
14+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15+
* GNU Lesser General Public License for more details.
16+
*
17+
* You should have received a copy of the GNU Lesser General Public License
18+
* along with WebARKitLib-rs. If not, see <http://www.gnu.org/licenses/>.
19+
*
20+
* As a special exception, the copyright holders of this library give you
21+
* permission to link this library with independent modules to produce an
22+
* executable, regardless of the license terms of these independent modules, and to
23+
* copy and distribute the resulting executable under terms of your choice,
24+
* provided that you also meet, for each linked independent module, the terms and
25+
* conditions of the license of that module. An independent module is a module
26+
* which is neither derived from nor based on this library. If you modify this
27+
* library, you may extend this exception to your version of the library, but you
28+
* are not obligated to do so. If you do not wish to do so, delete this exception
29+
* statement from your version.
30+
*
31+
* Copyright 2026 WebARKit.
32+
*
33+
* Author(s): Walter Perdan @kalwalt https://github.com/kalwalt
34+
*
35+
*/
36+
37+
//! Headless wasm-bindgen tests for the KPM detection binding (#161).
38+
//!
39+
//! Run with: `wasm-pack test --node` (or `--headless --chrome`) from
40+
//! `crates/wasm`. These exercise `WasmKpmHandle` in a real wasm runtime —
41+
//! construction (camera-param parse + FREAK matcher init) and reference-data
42+
//! loading from in-memory `.fset3` bytes (`KpmRefDataSet::load_from_bytes`).
43+
//!
44+
//! Full `detect()` is covered end-to-end by the native `simple_nft` pipeline
45+
//! (same pure-Rust code) and the browser demo (`www/simple_nft_example.html`);
46+
//! it needs a decoded RGBA frame, out of scope for this lightweight runtime
47+
//! check.
48+
49+
#![cfg(target_arch = "wasm32")]
50+
51+
use wasm_bindgen_test::*;
52+
use webarkitlib_wasm::WasmKpmHandle;
53+
54+
/// Demo assets, baked into the test binary.
55+
const CAMERA_PARA: &[u8] = include_bytes!("../www/assets/camera_para.dat");
56+
const PINBALL_FSET3: &[u8] = include_bytes!("../www/assets/pinball.fset3");
57+
58+
#[wasm_bindgen_test]
59+
fn kpm_handle_constructs_and_loads_ref_data() {
60+
let mut handle =
61+
WasmKpmHandle::new(CAMERA_PARA, 640, 480).expect("WasmKpmHandle::new should succeed");
62+
assert!(!handle.is_loaded(), "should start unloaded");
63+
64+
handle
65+
.load_ref_data(PINBALL_FSET3)
66+
.expect("load_ref_data should parse the .fset3 bytes");
67+
assert!(handle.is_loaded(), "should be loaded after load_ref_data");
68+
}
69+
70+
#[wasm_bindgen_test]
71+
fn detect_before_load_errors() {
72+
let mut handle = WasmKpmHandle::new(CAMERA_PARA, 4, 4).expect("new");
73+
// 4x4x4 RGBA; detection must refuse before reference data is loaded.
74+
let rgba = [0u8; 4 * 4 * 4];
75+
assert!(
76+
handle.detect(&rgba).is_err(),
77+
"detect should error before load_ref_data"
78+
);
79+
}

0 commit comments

Comments
 (0)