feat(kpm): report every matched page, not only the best one - #71
Conversation
VisualDatabase::query() now keeps every reference image that passes the inlier tests, exposed as matches(); matchedId()/inliers() still give the best. kpmMatching writes one pose per page, keeping the best-supported image of each page, and the dead per-page loop is removed. Refs webarkit/jsartoolkitNFT#635 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
PR Summary by QodoReport every matched KPM page with an independent pose
AI Description
Diagram
High-Level Assessment
Files changed (6)
|
Code Review by Qodo
1.
|
…s images Two review findings on #71: - result[] is allocated densely, one slot per position in refDataSet.pageInfo, and kpmSetMatchingSkipPage() indexes it that way, but the per-page loop indexed it by the page's external number. Those only coincide when pages are numbered 0..n-1 in load order; kpmChangePageNoOfRefDataSet() can set any value. Record each db_id's page position (pageIndices) and index result[] by it, reporting the external pageNo in KpmResult::pageNo as before. - If the pose of a page's best-supported image could not be fitted, the page was dropped even when another verified image of the same page would give a pose. Try the page's images in descending inlier order and keep the first that succeeds. No extra cost when the first succeeds. Refs webarkit/jsartoolkitNFT#635 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Picks up webarkit/WebARKitLib#71 review fixes (f3d4959): - KPM results are indexed by the page's position in the reference data set rather than by its external page number, which only coincided for pages numbered 0..n-1 in load order; - if the pose of a page's best-supported image cannot be fitted, the page's other verified images are tried instead of dropping the page. Rebuilt build/ and dist/ included. Refs #635 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
webarkit/WebARKitLib#71 was squash-merged into dev as 4b5af7b. Its tree is identical to f3d4959, which this branch pointed at, so the compiled sources and the committed build/ and dist/ are unchanged; only the pointer moves to a commit that is on WebARKitLib's dev history. Refs #635 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A minor bump, as defined in WebARKitConfig.cpp ("additions to the API,
or other significant backwards-compatible changes in runtime
functionality"): #71 added VisualDatabase::matches() / image_match_t,
and kpmMatching now reports one result per matched page instead of only
the best one. Updates WEBARKIT_HEADER_VERSION_STRING and _MINOR, and the
two gtest assertions that pin the version string.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two review findings on #73 (pre-existing, widened by #71's per-page results): - kpmSetRefDataSet() never clears the FREAK matcher, so when a reference set is replaced by a smaller one its extra images stay in the matcher and are still matched. Their ids keep old pageIndices entries that can point past the new, smaller result[]. Record how many images the current set registered (dbImageNum) and ignore, with an error, any match at or above it, or mapping outside result[]. Clearing the matcher on reload is the proper fix and belongs to the non-idempotent kpmSetRefDataSet (webarkit/jsartoolkitNFT#612). - pageIDs[] and pageIndices[] hold DB_IMAGE_MAX (1024) entries, one per registered image, but nothing bounded the image count. Reject a dataset with more images than that before changing any state. Built and tested through jsartoolkitNFT (the only build that compiles lib/SRC/KPM): detection, multi-marker and marker-limit suites, 82/82. Refs webarkit/jsartoolkitNFT#635, webarkit/jsartoolkitNFT#612 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* doc: design for multi-NFT-marker tracking Records the decisions behind multi-marker support, building on the 2026-09-22 plan which holds the evidence. Adds two corrections the plan does not account for: detection is gated on detectedPage == -2, so #635 alone changes nothing until that gate goes; and the _td variant is a separate architecture whose trackingInitGetResult carries a single page by signature. Four phases: #631 threshold, #635 multi-result, the JS/native multi-marker change for main and simd, then _td. #612 is out of scope. Refs #635, #631, #613, #611 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * doc: implementation plan for multi-NFT-marker tracking Refs #635, #631, #613, #611 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * doc: record #631 measurements — kuva is in the test photo The inlier-ratio measurements found no separating threshold because there is no false positive: pinball-demo.jpg carries both printed targets, so kuva's matches are real detections. Refs #631 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * feat: track every visible NFT marker, not only the first The binding keeps a tracking state per marker instead of one detectedPage/ftmi pair. KPM runs while any loaded marker is untracked, and tracking happens once per frame in detectNFTMarker(), so getNFTMarker(i) is now a pure read. Bumps WebARKitLib for per-page KPM results. Adds a multi-marker suite over the default and SIMD builds. Rebuilt build/ and dist/ included. Rewrites the two #631 detection tests: pinball-demo.jpg carries both printed targets, so both markers are expected, and the tests now pass. Refs #635, #613 Refs #631 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: reject NFT marker loads beyond PAGES_MAX The binding keeps per-marker state in fixed arrays of PAGES_MAX entries (surfaceSet, and since the previous commit markerStates), and the per-frame loops index them up to surfaceSetCount. addNFTMarkers() checked only the size of the current batch, while surfaceSetCount accumulates across calls, so repeated loads could push it past PAGES_MAX and overrun those arrays and the skipPages stack buffer. addNFTMarkers() now refuses a load when the running total would exceed PAGES_MAX, before any state changes. The check replaces the per-batch one, which also rejected a single batch of exactly PAGES_MAX markers although that fits. Adds a marker-limit test over the default and SIMD builds. Rebuilt build/ and dist/ included. Refs #613 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: report each NFT marker lost on its own The controllers tracked found/lost with one index and one timestamp, so with two markers in view only one could be reported lost. A MarkerLostTracker now does this per marker, and lostNFTMarker carries that marker's last pose. Each getNFTMarker event also gets its own matrix instead of a shared buffer the next marker overwrites. Applied to the default, SIMD, threaded and Node controllers. Rebuilt dist/ included. Refs #611 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * feat(parallel): track every visible NFT marker in the threaded build The detection worker reported only its single best page, and the threaded getter reported every marker index as found whenever any page was tracked. The worker now returns every matched page; detection and tracking run once per frame in detectNFTMarker() with the per-marker state shared with the default build. The multi-marker suite now covers the threaded build, with the test page cross-origin isolated. Also bounds the threaded addNFTMarkers by PAGES_MAX in total, returning an empty result instead of calling exit(). Keeps trackingInitGetResult as a compatibility wrapper for the legacy threaded binding; the threaded tests run on a half-scale frame because that build's fixed 128 MB heap cannot hold 2000x1500 frames. Rebuilt build/ and dist/ included. Refs #635, #613, #611 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * doc: record multi-marker implementation notes and KPM cost Refs #635 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * feat: throttle NFT detection while markers are tracked With several markers loaded and one in view, KPM ran on every frame while any loaded marker was untracked: about 320 ms per frame at 2000x1500, where 1.12.0 stopped detecting once a marker was tracked. Detection now runs on every frame only while nothing is tracked. While some markers are tracked and some are not, it runs at most once per interval, measured in milliseconds; while all are tracked it does not run. Two new setters on every controller: setContinuousDetection(), default true (false restores the 1.12.0 behaviour), and setDetectionInterval(ms), default 300 ms in the default and SIMD builds. The threaded build keeps its behaviour (interval 0, detection on the worker) and applies the same gate to starting a worker search. The Node controller gets both setters for API parity; its single-marker binding cannot honour them, so they only warn once. New tests cover a marker entering while another is held, continuous detection off, and the interval, on all three browser builds. Refs #635 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: pick the best KPM result in the legacy bindings Since KPM reports one result per matched page, the single-marker loops in ARToolKitJS.cpp, ARToolKitJS_td.cpp (commented-out code) and the Python binding set detectedPage for every matched page, so the last page won. With pinball and kuva both loaded, the Node build locked on kuva (page 1, 32 inliers) instead of pinball (page 0, 36 inliers). Each loop now picks one result: the most inliers, ties broken by the lowest error. trackingInitGetResult, the single-page wrapper the legacy threaded binding uses, chose the lowest error; it now uses the same rule, the one the single-result KPM used, and TrackingInitResult carries inlierNum. The detection worker also fetches the KPM result array after every kpmMatching() instead of once at start-up, since kpmSetRefDataSet() reallocates it when markers are loaded. Refs #635 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(parallel): stop logging an error before markers load detectNFTMarker() logged "Error: threadHandle" on every process() until addNFTMarkers() created the detection worker. With no markers loaded it now returns -1 quietly; the error is kept for markers loaded without a worker. Refs #635 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore: rebuild build/ and dist/ for the detection policy and KPM fixes Full Docker build (emsdk 4.0.17) and build-ts of the three previous commits: the detection policy setters and throttle, the best-result choice in the legacy bindings and the detection worker, and the quiet threaded detectNFTMarker() before markers load. Refs #635 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: keep the KPM cost harness as a skipped suite tests/vitest/kpm-cost.test.ts times process() on the default build at camera-like sizes, with an unseen marker loaded, under detection on every frame and under the 300 ms default. It measures rather than asserts, so it is committed as describe.skip; its header says how to run it. At 320x240 pinball is too small in the test photo to be detected, so 340x255 stands in for it. Refs #635 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * doc: record the detection policy, its cost, and multi-marker usage The design spec gets the detection policy the maintainer chose (time throttle, 300 ms default, 0 in the threaded build, and the two setters), the detection cost measured at 340x255 and 640x480, a dated correction of the claim that skipped pages make KPM cheaper (the FREAK matcher still matches every keyframe), and why KpmProcHalfSize is not a safe way to cut the cost. The README gets a multi-marker tracking section, including the note that getTransformationMatrix() now returns a fresh array each frame. Refs #635, #613, #611 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: count the detection interval from the end of a pass The interval was measured from the start of one detection pass to the start of the next. A pass slower than the interval (about 320 ms at 2000x1500 against the 300 ms default) was therefore due again on the very next frame, so large frames paid detection on every frame. It is now counted from when a pass finishes (threaded build: when a finished search is collected), so every pass is followed by tracking-only frames whatever the frame size. A new test pins this with a 50 ms interval, far shorter than a pass: before the fix no frame was tracking-only. Rebuilt build/ and dist/ included. Refs #635 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: bump WebARKitLib for KPM result indexing and pose fallback Picks up webarkit/WebARKitLib#71 review fixes (f3d4959): - KPM results are indexed by the page's position in the reference data set rather than by its external page number, which only coincided for pages numbered 0..n-1 in load order; - if the pose of a page's best-supported image cannot be fitted, the page's other verified images are tried instead of dropping the page. Rebuilt build/ and dist/ included. Refs #635 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: require both markers from a single detection pass "finds both markers in the same frame" pushed frames until both were tracked, so a matcher returning one page per pass (#635) could pass it over two passes. The new test loses both markers first, then requires that the first process() finding any marker finds both. The old test is renamed to what it proves: "tracks both markers at once". Refs #635 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: pass only the marker index to getNFTData in the examples getNFTData(index) takes one argument; the controller id is implied. The examples called getNFTData(ar.id, i), so the controller id became the index and the real index was dropped. Every call returned marker 0: the multi-marker worker centred kuva's and chalk_multi's models using pinball's size (893x1117 @120 dpi instead of 640x480 @72), shifting them off their markers. Single-marker examples only worked because the controller id happened to be 0. Refs #613 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: stand the multi example's cone upright on its marker cone.rotation.x = 90 is in radians (about 116.6 degrees), tilting the cone ~27 degrees off vertical. Use Math.PI / 2, and lift the cone by half its height, since ConeGeometry is centred on its mid-height and was half sunk into the marker. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat: show every tracked marker at once in the ES6 multi example The worker kept one result per frame, so each getNFTMarker event overwrote the previous one, and one OneEuroFilter was shared by all markers and reset whenever any was lost. It now reports every tracked marker's pose per frame, with a filter and warm-up count per marker, and sends each marker's own getNFTData. The page draws each model under its own root at its own marker's pose, and stands the cone upright on its marker. Refs #611, #613 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: serve the Pthread examples reliably from python-server.py python-server.py could not serve the threaded examples: pages failed with net::ERR_CONNECTION_RESET (status 200) on the large scripts, and the server stopped answering altogether. - It was single-threaded, so one idle connection (a browser preconnect) blocked every other request. Use ThreadingHTTPServer. - It spoke HTTP/1.0 and closed the socket after every response; on Windows that close could reset the connection before a large file was fully delivered. Use HTTP/1.1 keep-alive (Content-Length is always sent). Measured with 3 idle connections held open plus 12 parallel downloads of build/artoolkitNFT_thread.js and three.module.min.js: the old server completed 0/12 and then hung; threading alone, 4-11/12; threading plus HTTP/1.1, 12/12 in 5 of 5 runs. basic_threading.html now loads every file and is cross-origin isolated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: keep the threaded examples' model on the marker on mobile setCameraMatrix() runs on every frame and scaled camera_matrix in place by ratioW/ratioH. Whenever the camera frame is not 4:3 one of those ratios is not 1, so the projection compounded frame after frame until the model was projected out of view: it flashed, then disappeared, while tracking and the pose stayed correct. On a 4:3 desktop webcam both ratios are exactly 1, which is why only phones were affected. Scale a copy instead. Confirmed on an Android phone (OPPO A72) with ARToolkitNFT_ES6_threading_example.html: the model now stays on the marker. The other example pages already build their projection from a fresh JSON.parse(msg.proj) once, so only this shared threaded page was affected. Refs #391 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore: stop logging the pose on every frame in the threaded examples threejs_wasm_thread.js logged the full 16-element world matrix on each animation frame, flooding the console while a marker was tracked. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore: point WebARKitLib at the merged dev commit webarkit/WebARKitLib#71 was squash-merged into dev as 4b5af7b. Its tree is identical to f3d4959, which this branch pointed at, so the compiled sources and the committed build/ and dist/ are unchanged; only the pointer moves to a commit that is on WebARKitLib's dev history. Refs #635 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: bump WebARKitLib for KPM matcher and image-count bounds Points the submodule at WebARKitLib dev 5cbf69d, which adds (#74): - kpmSetRefDataSet() registers each reference set in a fresh matcher, so a reloaded set can no longer pair old keyframes with new, shorter 3D point lists (the WebARKitLib side of #612); - a DB_IMAGE_MAX bound on the reference image count, checked before any state changes and safe against overflow; - invariant checks on matcher image ids and page positions in kpmMatching; plus the WebARKitLib 0.9.0 version bump (#72), which is not compiled into these builds. Rebuilt build/ and dist/ in Docker (emsdk 4.0.17). npm test: Vitest 118 passed, 13 skipped; all 7 Karma targets pass. The Node example detects. Refs #612, #635 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore: point WebARKitLib at the 0.9.0 release Moves the submodule from dev 5cbf69d to the WebARKitLib 0.9.0 tag (fe4b069, the merge of webarkit/WebARKitLib#73 into master). The tag keeps 5cbf69d in its history and its tree is identical, so the compiled sources and the committed build/ and dist/ are unchanged. Refs #635 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Summary
KPM's FREAK path could report one matched page per frame:
VisualDatabase::query()kept a running maximum, andkpmMatchingwrote a single result. This change makes it report every page that passes the matcher's tests, with one pose per page. That is the native half of multi-NFT-marker tracking in jsartoolkitNFT.Companion PR: webarkit/jsartoolkitNFT#658. It bumps this submodule to this branch and makes the bindings track every reported page.
What changed (
lib/SRC/KPM/)matchers/matcher_types.h: newimage_match_t { id, inliers, geometry[9] }andimage_matches_t.matchers/visual_database{.h,-inline.h}:query()now collects every reference image that clears the existing inlier floor intomMatches, exposed asmatches().matchedId(),inliers()andmatchedGeometry()are unchanged: they still return the single best match, so existing callers are unaffected.facade/visual_database_facade.{h,cpp}: forwardsmatches()across the pimpl boundary.kpmMatching.cpp:kpmSetRefDataSet), so it keeps the best-supported keyframe per page.matchedId()inside the loop, so it gave every page the same id.No thresholds changed. The inlier floor (
kMinNumInliers = 8) is as before.Behaviour change for single-result consumers
Consumers that looped over every
camPoseF == 0result and kept the last one used to be safe, because there was only ever one. Now they would pick the highest page number, not the best match. jsartoolkitNFT#658 updates its legacy bindings to pick the result with the most inliers (ties to the lowest error). Other consumers ofkpmGetResultshould do the same.A note on
lib/SRC/**CONTRIBUTING.md asks contributors not to modify the vendored ARToolKit5 sources under
lib/SRC/**. This change does, because the KPM matcher lives there and jsartoolkitNFT consumes it. It follows the precedent of #67 (fix/kpm-matched-id-sentinel), which changedlib/SRC/KPM/kpmMatching.cppfor the same consumer. The parkedWebARKit/NFTrelocation would move this code out oflib/SRC/; until then, KPM fixes land here.Testing
Verified end-to-end through jsartoolkitNFT, since this repo's gtest suite does not cover KPM:
Page[0]andPage[1](previously only the best).Refs webarkit/jsartoolkitNFT#635
🤖 Generated with Claude Code