diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 81e3154..5ce4c7b 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -89,6 +89,14 @@ name = "simple_nft" # backend builds on the default feature set, so no `required-features` is # needed anymore. +[[example]] +name = "simple_nft_dual" +# M9-2 #157: diagnostic sibling of simple_nft, drives DualFreakMatcher to +# compare C++ vs pure-Rust FREAK backends on the same query. +# - dual-mode -> DualFreakMatcher (transitively pulls ffi-backend) +# - log-helpers -> ar_log_init_default() so arlog_*! macros emit output +required-features = ["dual-mode", "log-helpers"] + [[example]] name = "load_nft" required-features = ["log-helpers"] diff --git a/crates/core/examples/simple_nft_dual.rs b/crates/core/examples/simple_nft_dual.rs new file mode 100644 index 0000000..d89ec13 --- /dev/null +++ b/crates/core/examples/simple_nft_dual.rs @@ -0,0 +1,546 @@ +/* + * simple_nft_dual.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 + * + */ + +//! Diagnostic NFT example exercising [`DualFreakMatcher`]. +//! +//! This is the **diagnostic** sibling of [`simple_nft.rs`](./simple_nft.rs); +//! `simple_nft.rs` is the **production** example. Both run the same KPM +//! detection → AR2 tracking flow on the pinball reference image, but this +//! one drives [`DualFreakMatcher`] (M9-2, #141) so the C++ and pure-Rust +//! FREAK backends can be compared on the same query. +//! +//! **Side-by-side comparison granularity**: the per-backend comparison is +//! at the **homography (3×3 H)** level — that is what [`matched_geometry`] +//! exposes on each backend. Only **one** 3×4 camera pose is computed (from +//! the C++ ground-truth homography via [`KpmHandle::get_pose`]) and fed to +//! AR2. Per-backend 3×4 poses are intentionally not computed here; if you +//! need them, run `kpm_util_get_pose_binary` with each backend's inliers +//! manually. +//! +//! [`matched_geometry`]: webarkitlib_rs::kpm::DualFreakMatcher::cpp_matched_geometry +//! [`KpmHandle::get_pose`]: webarkitlib_rs::kpm::KpmHandle::get_pose +//! +//! What you should see (on the bundled pinball assets, modulo float noise +//! across machines): +//! +//! - Both backends agree on the matched id (no tier-1 / matched_id divergence). +//! - Two homographies printed side-by-side. Their corner reprojections +//! on pinball typically diverge by single-digit-to-low-double-digit +//! pixels — this is the cross-language BHC-variance envelope +//! `docs/design/m9-2-rust-backend.md` §10 discusses, and it is +//! **larger** than the M9 #152 tier-2 tolerance (2.0 px), so a +//! non-zero `divergence_count` is the normal observation on this +//! dataset. The milestone-gate test +//! (`test_dual_mode_no_divergence_on_pinball`) covers the `found.jpg`/ +//! `img.jpg` pair, where divergence stays at zero. +//! - One 3×4 KPM camera pose (C++-derived) matching §10 — KPM error +//! ≈ 7.1455 and pose row 0 ≈ `[0.9862, 0.1671, 0.0641, -182.1635]`. +//! The Rust-derived 3×4 pose is **not** printed (it would require +//! running `kpm_util_get_pose_binary` on the Rust inliers separately). +//! +//! Read non-zero divergences here as informational, not as a regression +//! — the C++ pose still feeds AR2 cleanly (M9-2 D5). +//! +//! After M9-3 flips the default backend off `ffi-backend` (#142), this +//! example becomes a niche debugging tool — most useful right now, +//! while both backends are still routinely available. +//! +//! Run with: +//! +//! ```sh +//! cargo run -p webarkitlib-rs --example simple_nft_dual +//! # or, explicit: +//! cargo run -p webarkitlib-rs --features "dual-mode log-helpers" --example simple_nft_dual +//! ``` + +use std::io::Cursor; +use std::path::Path; +use std::sync::Arc; + +use webarkitlib_rs::ar2::{ + ar2_read_surface_set, ar2_surface_set_marker_info, ar2_tracking, AR2Handle, +}; +use webarkitlib_rs::arlog::ar_log_init_default; +use webarkitlib_rs::icp::icp_create_handle; +use webarkitlib_rs::kpm::types::{KpmRefDataSet, FREAK_SUB_DIMENSION}; +use webarkitlib_rs::kpm::{ + CppFreakMatcher, DualFreakMatcher, FeaturePoint, FreakMatcherBackend, KpmHandle, Point3d, +}; +use webarkitlib_rs::types::{ARParam, ARParamLT, ARPixelFormat}; +use webarkitlib_rs::{arlog_e, arlog_i}; + +/// Project a 2D point through a 3×3 row-major homography (inhomogeneous). +fn project(h: &[f32; 9], x: f32, y: f32) -> (f32, f32) { + let w = h[6] * x + h[7] * y + h[8]; + ( + (h[0] * x + h[1] * y + h[2]) / w, + (h[3] * x + h[4] * y + h[5]) / w, + ) +} + +/// Max per-corner displacement between two homographies' reprojections +/// of the reference-image corners. Mirrors `DualFreakMatcher`'s private +/// tier-2 metric (M9 #152 corner-reprojection check) so the example can +/// report the same number for transparency. +fn max_corner_displacement(cpp_h: &[f32; 9], rust_h: &[f32; 9], rw: f32, rh: f32) -> f32 { + let corners = [(0.0, 0.0), (rw, 0.0), (rw, rh), (0.0, rh)]; + corners + .iter() + .map(|&(x, y)| { + let (cx, cy) = project(cpp_h, x, y); + let (rx, ry) = project(rust_h, x, y); + ((cx - rx).powi(2) + (cy - ry).powi(2)).sqrt() + }) + .fold(0.0_f32, f32::max) +} + +/// Feed a [`KpmRefDataSet`] into a `FreakMatcherBackend` by replicating +/// the page/image grouping logic from `KpmHandle::set_ref_data_set`. +/// Phase A doesn't go through `KpmHandle` (we need post-query access to +/// the concrete `DualFreakMatcher`), so this loop lives in the example. +/// +/// Returns the per-`db_id` reference image dimensions so the example can +/// reproduce `DualFreakMatcher`'s tier-2 corner-reprojection metric for +/// the matched id. +fn feed_ref_data( + matcher: &mut M, + ref_data: &KpmRefDataSet, +) -> Result, Box> { + let mut dims: Vec<(i32, i32)> = Vec::new(); + let mut db_id: usize = 0; + for page in &ref_data.page_info { + for img in &page.image_info { + let mut points: Vec = Vec::new(); + let mut descriptors: Vec = Vec::new(); + let mut points_3d: Vec = Vec::new(); + + for rp in &ref_data.ref_point { + if rp.ref_image_no == img.image_no && rp.page_no == page.page_no { + points.push(FeaturePoint { + x: rp.coord2d.x, + y: rp.coord2d.y, + angle: rp.feature_vec.angle, + scale: rp.feature_vec.scale, + maxima: rp.feature_vec.maxima != 0, + }); + points_3d.push(Point3d { + x: rp.coord3d.x, + y: rp.coord3d.y, + z: 0.0, + }); + descriptors.extend_from_slice(&rp.feature_vec.v[..FREAK_SUB_DIMENSION]); + } + } + + matcher.add_freak_features( + &points, + &descriptors, + &points_3d, + img.width as usize, + img.height as usize, + db_id, + )?; + + dims.push((img.width, img.height)); + db_id += 1; + } + } + Ok(dims) +} + +fn main() { + ar_log_init_default(); + + let data_dir = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("examples") + .join("Data"); + + arlog_i!("========================================"); + arlog_i!(" WebARKitLib-rs — Simple NFT Dual Example"); + arlog_i!("========================================"); + + // --------------------------------------------------------------- + // Step 1: Load camera parameters + // --------------------------------------------------------------- + let param_path = data_dir.join("camera_para.dat"); + arlog_i!("Step 1: Loading camera parameters..."); + let param_bytes = match std::fs::read(¶m_path) { + Ok(b) => b, + Err(e) => { + arlog_e!("failed to read camera_para.dat: {e}"); + return; + } + }; + let mut param = match ARParam::load(Cursor::new(¶m_bytes)) { + Ok(p) => p, + Err(e) => { + arlog_e!("failed to parse camera params: {e}"); + return; + } + }; + arlog_i!( + " Camera (original): {}x{}, mat[0][0]={:.2}", + param.xsize, + param.ysize, + param.mat[0][0] + ); + + // --------------------------------------------------------------- + // Step 2: Load test image (pinball demo) as grayscale luma + // --------------------------------------------------------------- + let img_path = data_dir.join("pinball-demo.jpg"); + arlog_i!("Step 2: Loading test image..."); + let jpeg_bytes = match std::fs::read(&img_path) { + Ok(b) => b, + Err(e) => { + arlog_e!("failed to read pinball-demo.jpg: {e}"); + return; + } + }; + let mut decoder = jpeg_decoder::Decoder::new(Cursor::new(&jpeg_bytes)); + let pixels = match decoder.decode() { + Ok(p) => p, + Err(e) => { + arlog_e!("JPEG decode failed: {e}"); + return; + } + }; + let info = match decoder.info() { + Some(i) => i, + None => { + arlog_e!("no JPEG info"); + return; + } + }; + let width = info.width as i32; + let height = info.height as i32; + arlog_i!(" Image: {}x{}", width, height); + + // RGB → luma (BT.601 integer formula). + let luma: Vec = pixels + .chunks_exact(3) + .map(|rgb| ((rgb[0] as u32 * 77 + rgb[1] as u32 * 150 + rgb[2] as u32 * 29) >> 8) as u8) + .collect(); + arlog_i!(" Luma: {} bytes", luma.len()); + + // Scale camera params to match image dimensions. + 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; + arlog_i!( + " Camera (scaled): {}x{}, mat[0][0]={:.2}", + param.xsize, + param.ysize, + param.mat[0][0] + ); + + // --------------------------------------------------------------- + // Step 3: Load NFT marker reference data + // --------------------------------------------------------------- + let marker_name = "pinball"; + let marker_base = data_dir.join(marker_name); + arlog_i!("Step 3: Loading NFT marker '{}'...", marker_name); + + let fset3_path = data_dir.join(format!("{}.fset3", marker_name)); + let mut ref_data_a = match KpmRefDataSet::load(&fset3_path) { + Ok(r) => r, + Err(e) => { + arlog_e!("failed to load .fset3: {e}"); + return; + } + }; + ref_data_a.change_page_no( + webarkitlib_rs::kpm::ref_data_set::KPM_CHANGE_PAGE_NO_ALL_PAGES, + 0, + ); + arlog_i!( + " .fset3: {} features, {} pages", + ref_data_a.num, + ref_data_a.page_num + ); + + // --------------------------------------------------------------- + // Phase A — DualFreakMatcher diagnostic + // + // Drives the dual matcher directly (bypassing KpmHandle) so we can + // read per-backend state after the query. KpmHandle wraps the + // backend in Box, which forbids recovering + // the concrete DualFreakMatcher type — hence the split phases. + // --------------------------------------------------------------- + arlog_i!("Phase A: DualFreakMatcher diagnostic"); + + let mut dual = match DualFreakMatcher::new(width, height) { + Ok(d) => d, + Err(e) => { + arlog_e!("failed to create DualFreakMatcher: {e:?}"); + return; + } + }; + let ref_dims = match feed_ref_data(&mut dual, &ref_data_a) { + Ok(d) => d, + Err(e) => { + arlog_e!("failed to feed ref data into DualFreakMatcher: {e}"); + return; + } + }; + arlog_i!( + " Reference data loaded into both backends ({} db_ids).", + ref_dims.len() + ); + + let dual_result = match dual.query(&luma, width as usize, height as usize) { + Ok(r) => r, + Err(e) => { + arlog_e!("DualFreakMatcher::query failed: {e:?}"); + return; + } + }; + arlog_i!( + " Query complete. matched_id = {} (C++ ground truth)", + dual_result.matched_id + ); + + arlog_i!(" divergence_count = {}", dual.divergence_count()); + match dual.last_divergence_reason() { + Some(reason) => arlog_i!(" last_divergence_reason = {reason}"), + None => arlog_i!(" last_divergence_reason = (none)"), + } + + match (dual.cpp_matched_geometry(), dual.rust_matched_geometry()) { + (Some(cpp_h), Some(rust_h)) => { + arlog_i!(" Side-by-side comparison is at the HOMOGRAPHY level (3x3 H)."); + arlog_i!(" The 3x4 camera pose printed in Phase B is derived from the"); + arlog_i!(" C++ homography only (DualFreakMatcher returns C++ as ground"); + arlog_i!(" truth, M9-2 D5); per-backend 3x4 poses are not computed here."); + arlog_i!(" C++ homography (3x3):"); + for r in 0..3 { + arlog_i!( + " [{:>10.4} {:>10.4} {:>10.4}]", + cpp_h[r * 3], + cpp_h[r * 3 + 1], + cpp_h[r * 3 + 2] + ); + } + arlog_i!(" Rust homography (3x3):"); + for r in 0..3 { + arlog_i!( + " [{:>10.4} {:>10.4} {:>10.4}]", + rust_h[r * 3], + rust_h[r * 3 + 1], + rust_h[r * 3 + 2] + ); + } + // Look up the matched id's reference dimensions so the + // displacement metric matches DualFreakMatcher's internal + // tier-2 check exactly. + let matched = dual_result.matched_id; + match (matched >= 0) + .then(|| matched as usize) + .and_then(|i| ref_dims.get(i)) + { + Some(&(ref_w, ref_h)) => { + let max_disp = + max_corner_displacement(cpp_h, rust_h, ref_w as f32, ref_h as f32); + arlog_i!(" Homography agreement (M9 #152 tier-2 metric):"); + arlog_i!(" max corner displacement between H_cpp and H_rust"); + arlog_i!( + " = {:.4} px (ref image {}x{}, tolerance: 2.0 px)", + max_disp, + ref_w, + ref_h + ); + } + None => { + arlog_i!( + " (no ref dims for matched_id={}; skipping corner-displacement metric)", + matched + ); + } + } + } + _ => { + arlog_i!(" (one or both backends produced no homography — no comparison possible)"); + } + } + + // --------------------------------------------------------------- + // Phase B — Production pipeline (mirror simple_nft.rs flow) + // + // Fresh KpmHandle + CppFreakMatcher. DualFreakMatcher::query returns + // C++ as ground truth (M9-2 D5), so using CppFreakMatcher directly + // here produces the same pose AR2 would receive in either case. + // --------------------------------------------------------------- + arlog_i!("Phase B: Production pipeline (KpmHandle + CppFreakMatcher)"); + + let ref_data_b = match KpmRefDataSet::load(&fset3_path) { + Ok(mut r) => { + r.change_page_no( + webarkitlib_rs::kpm::ref_data_set::KPM_CHANGE_PAGE_NO_ALL_PAGES, + 0, + ); + r + } + Err(e) => { + arlog_e!("failed to reload .fset3 for Phase B: {e}"); + return; + } + }; + + let mut surface_set = match ar2_read_surface_set(&marker_base) { + Ok(s) => s, + Err(e) => { + arlog_e!("failed to load surface set: {e:?}"); + return; + } + }; + if let Some((w, h, dpi)) = ar2_surface_set_marker_info(&surface_set) { + arlog_i!(" Surface set: marker {}x{} @ {:.0} DPI", w, h, dpi); + } + + let param_lt = ARParamLT::new_basic(param.clone()); + let param_lt_arc = Arc::new(param_lt); + + let cpp_backend = match CppFreakMatcher::new(width, height) { + Ok(b) => b, + Err(e) => { + arlog_e!("failed to create CppFreakMatcher: {e:?}"); + return; + } + }; + let mut kpm_handle = KpmHandle::new( + width, + height, + Some(param_lt_arc.clone()), + Box::new(cpp_backend), + ); + + if let Err(e) = kpm_handle.set_ref_data_set(ref_data_b) { + arlog_e!("failed to set ref data set: {e:?}"); + return; + } + + if let Err(e) = kpm_handle.kpm_matching(&luma) { + arlog_e!("kpm_matching failed: {e:?}"); + return; + } + + let pose = kpm_handle.get_pose(); + let Some((cam_pose, page_no, error)) = pose else { + arlog_i!(" KPM produced no valid pose — example ends here."); + return; + }; + + arlog_i!(" KPM match: page={}, error={:.4}", page_no, error); + arlog_i!(" Initial 3x4 pose matrix:"); + for r in 0..3 { + 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] + ); + } + + // AR2 tracking refinement (same flow as simple_nft.rs). + arlog_i!(" Running AR2 tracking..."); + surface_set.set_init_trans(cam_pose); + + let mut ar2_handle = AR2Handle::new(width, height, ARPixelFormat::MONO); + let param_lt_for_ar2 = Box::new(ARParamLT::new_basic(param.clone())); + ar2_handle.cparam_lt = Box::into_raw(param_lt_for_ar2); + let icp_handle_ptr = match icp_create_handle(¶m.mat) { + Ok(p) => p, + Err(e) => { + arlog_e!("failed to create ICP handle: {e:?}"); + return; + } + }; + ar2_handle.icp_handle = icp_handle_ptr; + + let mut refined_pose = *cam_pose; + let mut tracking_err = 0.0f32; + + match ar2_tracking( + &mut ar2_handle, + &mut surface_set, + &luma, + &mut refined_pose, + &mut tracking_err, + ) { + Ok(()) => { + arlog_i!(" AR2 tracking succeeded! Error={:.4}", tracking_err); + arlog_i!(" Refined 3x4 pose matrix:"); + for r in 0..3 { + 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] + ); + } + } + Err(code) => { + arlog_i!( + " AR2 tracking returned error code: {} (expected for a single static frame)", + code + ); + arlog_i!(" The KPM initial pose above is still valid and usable."); + } + } + + // Clean up raw pointers held by AR2Handle. + unsafe { + if !ar2_handle.cparam_lt.is_null() { + let _ = Box::from_raw(ar2_handle.cparam_lt); + ar2_handle.cparam_lt = std::ptr::null_mut(); + } + if !ar2_handle.icp_handle.is_null() { + let _ = Box::from_raw(ar2_handle.icp_handle); + ar2_handle.icp_handle = std::ptr::null_mut(); + } + } + + arlog_i!("========================================"); + arlog_i!(" Simple NFT Dual example complete."); + arlog_i!("========================================"); +} diff --git a/crates/core/src/kpm/rust_backend.rs b/crates/core/src/kpm/rust_backend.rs index 25e6ef8..2ad919f 100644 --- a/crates/core/src/kpm/rust_backend.rs +++ b/crates/core/src/kpm/rust_backend.rs @@ -412,6 +412,21 @@ impl DualFreakMatcher { self.last_divergence_reason.as_deref() } + /// Homography from the most recent `query` as observed by the C++ + /// backend, or `None` if no query has matched yet. Delegates to + /// [`CppFreakMatcher::matched_geometry`]. Used by the + /// `simple_nft_dual` example (#157) to print per-backend geometry. + pub fn cpp_matched_geometry(&self) -> Option<&[f32; 9]> { + self.cpp.matched_geometry() + } + + /// Homography from the most recent `query` as observed by the + /// pure-Rust backend, or `None` if no query has matched yet. + /// Delegates to [`RustFreakMatcher::matched_geometry`]. + pub fn rust_matched_geometry(&self) -> Option<&[f32; 9]> { + self.rust.matched_geometry() + } + /// Project the four reference-image corners through `h` (3x3 row-major /// homography). Returns the projected points in order: top-left, /// top-right, bottom-right, bottom-left. Mirrors M9 #152's diff --git a/docs/design/m9-2-simple-nft-dual.md b/docs/design/m9-2-simple-nft-dual.md new file mode 100644 index 0000000..bd8aa12 --- /dev/null +++ b/docs/design/m9-2-simple-nft-dual.md @@ -0,0 +1,219 @@ +# Milestone 9 — Step 2 follow-up: `simple_nft_dual.rs` diagnostic example + +**Status**: Design approved, ready for implementation +**Branch**: `feat/m9-2-simple-nft-dual` (PR target: `feat/freak-visual-database`) +**Parent milestone issue**: [#139](https://github.com/webarkit/WebARKitLib-rs/issues/139) +**Issue**: [#157](https://github.com/webarkit/WebARKitLib-rs/issues/157) +**Depends on**: [#141](https://github.com/webarkit/WebARKitLib-rs/issues/141) / [#156](https://github.com/webarkit/WebARKitLib-rs/pull/156) (M9-2 — `DualFreakMatcher` and concrete `matched_geometry()` accessors) — merged into `feat/freak-visual-database` +**Unblocks (informational)**: contributors verifying M9-2 design doc §10 numbers locally before [#142](https://github.com/webarkit/WebARKitLib-rs/issues/142) (M9-3) flips the default off `ffi-backend` +**Author**: Walter Perdan ([@kalwalt](https://github.com/kalwalt)) +**Date**: 2026-05-23 + +--- + +## 1. Understanding Summary + +- **What**: Create `crates/core/examples/simple_nft_dual.rs` — a diagnostic sibling of `simple_nft.rs` that exercises `DualFreakMatcher` on the pinball reference image and reports per-query divergence + side-by-side homographies. Add two tiny public accessors on `DualFreakMatcher` so the example can read each backend's matched homography. +- **Why**: Final end-to-end integration signal before M9-3 (#142) flips the default backend off `ffi-backend`. While both backends are still routinely available, this example lets contributors reproduce the M9-2 design doc §10 parity numbers locally and catch any late-arriving cross-backend regression. +- **Who for**: WebARKitLib-rs contributors debugging Rust-backend regressions; reviewers cross-checking M9-2 §10. After M9-3 lands, it becomes a niche debugging tool. +- **Key constraints**: + - Compiles only under `--features dual-mode` (transitively pulls `ffi-backend`). + - Logging via `arlog_*!` (per `CLAUDE.md` §2) — no `println!` in the new file. + - No new tests (M9-2 already has `test_dual_mode_no_divergence_on_pinball`); no CI integration; no C-FFI surface changes. + - Branch off `origin/feat/freak-visual-database`; PR back into `feat/freak-visual-database`. +- **Non-goals**: + - No conversion of the existing `simple_nft.rs` to `arlog_*!` (separate scope — [#90](https://github.com/webarkit/WebARKitLib-rs/issues/90) PR 4). + - No multi-frame iteration loop (single query — mirror `simple_nft.rs`). + - No `max_corner_displacement` promotion to public API (only one caller). + - No downcast helper on `FreakMatcherBackend` trait. + +--- + +## 2. Decision Log + +| # | Decision | Alternatives | Rationale | +|---|----------|--------------|-----------| +| D1 | **Two-phase structure**: Phase A drives `DualFreakMatcher` directly for diagnostics; Phase B uses a fresh `KpmHandle` + `CppFreakMatcher` for the production pose-estimation + AR2 pipeline | (a) Move dual into KpmHandle — loses post-move access since `FreakMatcherBackend` lacks `Any` bound and inner state is unreachable through `Box`; (b) add `as_any` to trait | No API surface change beyond the two accessors in D4. Cleanest "diagnostic vs production" mental model for a reader. C++ ground-truth pose feeding AR2 is identical either way (`DualFreakMatcher::query` returns C++ result per M9-2 D5). | +| D2 | **Output**: print both 3×3 homographies side-by-side + max corner displacement + one 3×4 KPM pose (C++-derived) + AR2 refined pose | (a) Compute two 3×4 poses via `kpm_util_get_pose_binary` for both backends — adds ~50 LOC + couples example to internals; (b) divergence-summary only — too minimal | Strictly uses `matched_geometry()` (the API the issue references) and reports the M9 #152 tier-2 metric. Keeps the example ~80 LOC. | +| D3 | **Single query iteration** (mirror `simple_nft.rs`) | 3 iterations (mirror M9-2 milestone-gate test) | Contributor reading both files side-by-side sees a near-symbol-level diff; "example shape ≠ test shape" stays clean. Multi-iteration coverage already lives in `test_dual_mode_no_divergence_on_pinball`. | +| D4 | **Two tiny accessors on `DualFreakMatcher`**: `cpp_matched_geometry()` and `rust_matched_geometry()`, each returning `Option<&[f32; 9]>` | (a) Three-matcher pattern (DualFreakMatcher + standalone CppFreakMatcher + standalone RustFreakMatcher) — triples setup + runs three queries; (b) also promote `max_corner_displacement` to public | Smallest delta. Not an FFI change. Rust-only, already `#[cfg(feature = "dual-mode")]`-gated. `max_corner_displacement` stays private — example reimplements ~10 LOC inline (identical algorithm). | +| D5 | **Branch off `origin/feat/freak-visual-database`, PR back into same** | Branch off `dev` | DualFreakMatcher only exists on the M9 integration branch. Matches the M9 PR pattern (#140, #141, etc.). | +| D6 | **Use `arlog_*!` macros from day one** in the new file: `arlog_i!` for narrative output, `arlog_e!` for error sites, call `ar_log_init_default()` at startup | `println!` (matches existing `simple_nft.rs` style); mix | Per CLAUDE.md §2 — all new code uses the project log macros. Issue [#90](https://github.com/webarkit/WebARKitLib-rs/issues/90) PR 4 will eventually retrofit `simple_nft.rs`; we don't preempt that scope. | +| D7 | **Don't touch `simple_nft.rs`** | Combined PR converting it to `arlog_*!` (closes part of #90) too | Strict CLAUDE.md "fresh branch per issue/sub-issue" rule. The two issues are logically related but procedurally distinct. | +| D8 | **`Cargo.toml` `[[example]]` entry: `required-features = ["dual-mode", "log-helpers"]`** | `["dual-mode"]` only (the issue's literal spec); add `log-helpers` to `dual-mode`'s feature chain | `dual-mode` for `DualFreakMatcher`; `log-helpers` for the `ar_log_init_default()` logger init so the arlog macros emit. The issue's literal spec was written assuming `println!`-based output; with arlog we need both. Adding `log-helpers` to `dual-mode`'s chain would force every `dual-mode` consumer to pull `env_logger`/`console_log` — wrong scope. | +| D9 | **Phase A loads ref data manually**: replicates `KpmHandle::set_ref_data_set`'s feature-feeding loop inline (~30 LOC) for the `DualFreakMatcher` | (a) Factor a shared helper into the library; (b) use `add_image` with a raw reference image extracted from the `.iset` surface set | (a) is library-API scope creep for a one-off example. (b) wouldn't be apples-to-apples with `simple_nft.rs`'s `.fset3`-driven path. Inline loop is honest about what `set_ref_data_set` does and the duplication is contained to one file. | +| D10 | **Phase B uses `CppFreakMatcher` inside `KpmHandle`**, not `DualFreakMatcher` | Move the same `DualFreakMatcher` from Phase A into `KpmHandle` | Can't — `Box` is one-way (no downcast). Re-creating a fresh matcher for Phase B is the cost of D1. Two queries on a static image is negligible. `CppFreakMatcher` produces identical ground-truth pose to `DualFreakMatcher::query` (per M9-2 D5). | + +--- + +## 3. Final Design + +### 3.1 File layout + +| File | Change | +|---|---| +| `crates/core/src/kpm/rust_backend.rs` | **Modify** — add `cpp_matched_geometry()` and `rust_matched_geometry()` to `impl DualFreakMatcher`. Both `#[cfg(feature = "dual-mode")]`-gated (the entire `impl DualFreakMatcher` block already is). | +| `crates/core/examples/simple_nft_dual.rs` | **Create** — new diagnostic example. LGPL-3.0 header per `.claude/HEADER.txt`. | +| `crates/core/Cargo.toml` | **Modify** — add `[[example]]` entry with `required-features = ["dual-mode", "log-helpers"]`. | + +No other files touched. No CHANGELOG.md edit (release-only per CLAUDE.md §4). + +### 3.2 `DualFreakMatcher` accessor additions + +```rust +#[cfg(feature = "dual-mode")] +impl DualFreakMatcher { + // ... existing items unchanged ... + + /// Homography from the most recent `query` as observed by the C++ + /// backend, or `None` if no query has matched yet. Used by the + /// `simple_nft_dual` example to print backend-by-backend geometry. + pub fn cpp_matched_geometry(&self) -> Option<&[f32; 9]> { + self.cpp.matched_geometry() + } + + /// Homography from the most recent `query` as observed by the + /// pure-Rust backend, or `None` if no query has matched yet. + pub fn rust_matched_geometry(&self) -> Option<&[f32; 9]> { + self.rust.matched_geometry() + } +} +``` + +Both delegate to the existing concrete-impl accessors. Zero new state; zero behaviour change for current callers. + +### 3.3 Example structure + +Module docstring opens with: + +> Diagnostic sibling of `simple_nft.rs`. Drives `DualFreakMatcher` instead of `RustFreakMatcher` to compare C++ vs pure-Rust homographies on a static image. For production use, see `simple_nft.rs`. + +Flow: + +```text +[init logger via ar_log_init_default()] + +Step 1: Load camera_para.dat -> ARParam (scaled to image) +Step 2: Load pinball-demo.jpg as luma -> Vec +Step 3: Load pinball.fset3 -> KpmRefDataSet + +Phase A — DualFreakMatcher diagnostic +───────────────────────────────────── +Step 4a: DualFreakMatcher::new(w, h) +Step 4b: For each page/image in ref_data: + dual.add_freak_features(...) [inline loop, ~30 LOC] +Step 4c: dual.query(&luma) +Step 4d: arlog_i! output: + - divergence_count + - last_divergence_reason (if any) + - cpp_matched_geometry (3×3 H) + - rust_matched_geometry (3×3 H) + - max corner displacement (computed inline against + the reference image dimensions from ref_data.page_info[0]) + +Phase B — Production pipeline (mirror simple_nft.rs) +─────────────────────────────────────────────────── +Step 5a: Reload .fset3 + .iset/.fset surface set +Step 5b: CppFreakMatcher::new(w, h) +Step 5c: KpmHandle::new(...) with cpp backend +Step 5d: kpm_handle.set_ref_data_set(ref_data) +Step 5e: kpm_handle.kpm_matching(&luma) +Step 5f: kpm_handle.get_pose() -> 3×4 pose + arlog_i! the pose +Step 5g: surface_set.set_init_trans(cam_pose); AR2 tracking + arlog_i! the refined pose +``` + +### 3.4 Inline `max_corner_displacement` (D4) + +```rust +fn project(h: &[f32; 9], x: f32, y: f32) -> (f32, f32) { + let w = h[6] * x + h[7] * y + h[8]; + ((h[0] * x + h[1] * y + h[2]) / w, (h[3] * x + h[4] * y + h[5]) / w) +} + +fn max_corner_displacement(cpp_h: &[f32; 9], rust_h: &[f32; 9], rw: f32, rh: f32) -> f32 { + let corners = [(0.0, 0.0), (rw, 0.0), (rw, rh), (0.0, rh)]; + corners + .iter() + .map(|&(x, y)| { + let (cx, cy) = project(cpp_h, x, y); + let (rx, ry) = project(rust_h, x, y); + ((cx - rx).powi(2) + (cy - ry).powi(2)).sqrt() + }) + .fold(0.0_f32, f32::max) +} +``` + +Mirrors `DualFreakMatcher::reproject_corners` + `max_corner_displacement` (which stay private per D4). Reference dimensions sourced from `ref_data.page_info[0].image_info[0].{width,height}`. + +### 3.5 `Cargo.toml` entry + +```toml +[[example]] +name = "simple_nft_dual" +# M9-2 #157: diagnostic sibling of simple_nft, drives DualFreakMatcher. +# - dual-mode -> DualFreakMatcher (transitively pulls ffi-backend) +# - log-helpers -> ar_log_init_default() so arlog_* macros emit output +required-features = ["dual-mode", "log-helpers"] +``` + +### 3.6 Run command (run-book in module docstring) + +```sh +# With required-features declared, cargo auto-enables them: +cargo run -p webarkitlib-rs --example simple_nft_dual + +# Explicit form: +cargo run -p webarkitlib-rs --features "dual-mode log-helpers" --example simple_nft_dual +``` + +### 3.7 Observed output (measured during implementation) + +On `pinball-demo.jpg`: + +- **Tier-1 (matched_id) divergence**: none — both backends agree on `matched_id = 2` (one of 9 internal db_ids spanning the page's image-scale variants). +- **Tier-2 (corner reprojection) divergence**: ~13.80 px on db_id=2 with reference dimensions 595×745. This exceeds the 2.0 px tolerance, so `divergence_count = 1` after a single query and `last_divergence_reason` populates with the reprojection message. +- C++ KPM error = **7.1455** and C++ 3×4 pose row 0 = `[0.9862, 0.1671, 0.0641, -182.1635]` — matches §10 of `m9-2-rust-backend.md` exactly. +- The C++ pose feeds AR2 cleanly (per M9-2 D5); AR2 returns the usual single-static-frame error code, identical to `simple_nft.rs` behavior. +- The example's internally-computed corner displacement matches `DualFreakMatcher`'s internal tier-2 number to four decimals (we look up the matched id's ref dims via the `Vec<(i32, i32)>` returned from `feed_ref_data`, exactly mirroring `DualFreakMatcher::ref_dims`). + +**Interpretation correction vs. the original issue**: Issue #157 anticipated "zero divergences, two near-identical poses, max corner displacement < 2.0 px" on pinball. That expectation was extrapolated from §10's `simple_nft` measurement (which never used `DualFreakMatcher` — it ran the two backends through `KpmHandle` separately). The actual `DualFreakMatcher` measurement reported here is what the milestone-gate test asserts for the `found.jpg`/`img.jpg` pair (zero divergence) but **not** for pinball. The pinball divergence is the cross-language BHC-variance envelope §10 already discusses — sub-degree rotation and sub-percent translation differences, but on the corner-reprojection metric they manifest as ~14 px because the matched scale (db_id=2) is at a smaller reference resolution where any rotation/translation difference projects to more pixels. + +This is informational, not a regression — the example surfaces a real signal that the milestone-gate test's fixture choice happens to miss. + +### 3.8 Risks acknowledged + +| Risk | Mitigation | +|---|---| +| AR2 tracking may fail on single frame (already documented in `simple_nft.rs`) | Same fallback message style; KPM pose still printed | +| Float-noise reproducibility across machines may make exact §10 numbers differ slightly | Module docstring explicitly says "or very close to them, modulo nondeterministic float behavior" | +| `log-helpers` feature requirement is a tiny departure from issue's literal spec | Documented in D8; the issue's spec predates the arlog decision | + +--- + +## 4. Implementation Checklist + +- [ ] `git fetch origin && git checkout -b feat/m9-2-simple-nft-dual origin/feat/freak-visual-database` +- [ ] Add `cpp_matched_geometry()` + `rust_matched_geometry()` to `impl DualFreakMatcher` in `crates/core/src/kpm/rust_backend.rs` +- [ ] Create `crates/core/examples/simple_nft_dual.rs` with LGPL-3.0 header +- [ ] Add `[[example]]` entry to `crates/core/Cargo.toml` +- [ ] `cargo fmt --all` +- [ ] `cargo build --all-features` clean +- [ ] `cargo clippy --all-targets --all-features -- --deny warnings` clean +- [ ] `cargo test --all-features` green (M9-2 dual-mode test still passes) +- [ ] `cargo run --example simple_nft_dual` produces expected output (divergence_count = 0, two near-identical homographies, max corner displacement < 2.0 px) +- [ ] PR title: `feat(examples): add simple_nft_dual.rs with DualFreakMatcher and per-frame divergence reporting` +- [ ] PR body references #157 and notes the §10 reference numbers +- [ ] PR base: `feat/freak-visual-database` + +--- + +## 5. References + +- Issue [#157](https://github.com/webarkit/WebARKitLib-rs/issues/157) — this work +- Issue [#141 comment-4482406138](https://github.com/webarkit/WebARKitLib-rs/issues/141#issuecomment-4482406138) — original "optional deliverable" framing of M9-2 +- [docs/design/m9-2-rust-backend.md §10](./m9-2-rust-backend.md) — reference parity numbers +- M9 #152 — corner reprojection metric (`max_displacement` < 2.0 px tolerance) +- Issue [#90](https://github.com/webarkit/WebARKitLib-rs/issues/90) PR 4 — future `simple_nft.rs` arlog conversion (not in this PR) +- [`CLAUDE.md`](../../CLAUDE.md) §2 — `arlog_*!` logging convention