webarkit / webarkit/jsfeatNext

Add `pose_estimator` module to complete the natural-feature AR pipeline

Open
#83 0 comments 0 reactions 1 assignee View on GitHub

@kalwalt is already working on this.

Since Jul 11, 2026.

code design enhancement tests Typescript
Dominant language
TypeScript
Stars
12
Forks
4
Avg merge
16h 24m
Merged PRs (30d)
34

Description

Add pose_estimator module to complete the natural-feature AR pipeline

[!IMPORTANT]
Scope reduced. The bfmatcher / match_t half of this issue has moved to #133, because both the GMS filter (#134) and the TEBLID descriptor (#135) depend on a matcher and should not wait on pose_estimator. The prototype below is kept for reference — #133 starts from it. This issue now covers pose_estimator only.

Summary

jsfeatNext already covers most of the natural-feature-tracking pipeline for planar (image) targets: grayscale/pyramid, FAST/YAPE detectors, ORB descriptors, optical flow (optical_flow_lk), and RANSAC homography (motion_estimator + homography2d).

Two pieces are still missing to go from detection to a renderable 3D pose:

  1. Descriptor matching — moved to #133. (It still only exists inline in the ORB sample, as match_pattern() in sample_orb_pinball.html.)
  2. Homography → camera pose — the sample stops at warping the target outline into a 2D quad (render_pattern_shape); there is no H → [R | t] decomposition, so no 3D object can be anchored.

This issue tracks adding pose_estimator as a first-class module, with an OpenCV-style API so it cross-validates 1:1 against PureCV and can serve as a numeric reference oracle. Matching is tracked on the same terms in #133.

⚠️ Architecture update (scope correction)

A later design decision refined the layering (see the WebAR roadmap #97 and the CvBackend contract #96): jsfeatNext stays a generic, stateless CV library, so pose_estimator here stops at (R, t) in the camera frame. The renderer glue — modelViewGL(pose) and projectionGL(K, …) — is a graphics-integration concern (OpenGL axis flip, three.js column-major layout) and moves up to the high-level AR layer as renderer adapters, keeping jsfeatNext renderer-agnostic. The modelViewGL items below are struck through accordingly; the reference code stays in the prototype for whoever writes the high-level adapter. The intrinsics() bootstrap helper is a grey area (camera geometry, not rendering) — it may stay as a convenience or also move up; decide during implementation.

Scope

1. bfmatcher — moved to #133

Brute-force Hamming matcher for binary descriptors, refactored out of the sample's match_pattern(). No longer part of this issue — #133 carries the full scope and acceptance criteria.

One item there has changed status: generalising the descriptor width (columns a multiple of 4) was a nice-to-have here and is now load-bearing, because TEBLID p512 (#135) produces 64-byte descriptors.

The prototype below is retained because #133 starts from it.

Prototype implementation — src/bfmatcher/bfmatcher.ts
import { matrix_t } from "../matrix_t/matrix_t";

/** One correspondence, equivalent to OpenCV's `cv::DMatch`. */
export class match_t {
    public queryIdx: number;
    public trainIdx: number;
    public distance: number;

    constructor(queryIdx = 0, trainIdx = 0, distance = 0) {
        this.queryIdx = queryIdx;
        this.trainIdx = trainIdx;
        this.distance = distance;
    }
}

export class bfmatcher {
    /** Mirrors OpenCV's NORM_HAMMING constant value. Only norm supported here. */
    static readonly NORM_HAMMING = 6;

    public norm_type: number;
    public cross_check: boolean;

    constructor(norm_type: number = bfmatcher.NORM_HAMMING, cross_check = false) {
        this.norm_type = norm_type;
        this.cross_check = cross_check;
    }

    // SWAR population count — identical to the sample's popcnt32, so results
    // match bit-for-bit and this can serve as a reference oracle for the Rust
    // port (u32::count_ones / i32.popcnt).
    private static popcnt32(n: number): number {
        n -= (n >> 1) & 0x55555555;
        n = (n & 0x33333333) + ((n >> 2) & 0x33333333);
        return (((n + (n >> 4)) & 0x0f0f0f0f) * 0x01010101) >> 24;
    }

    /** Read a U8 descriptor matrix as an Int32Array view (mirrors `buffer.i32`). */
    private static asInt32(m: matrix_t): Int32Array {
        const d = m.data as Uint8Array;
        return new Int32Array(d.buffer, d.byteOffset, d.byteLength >> 2);
    }

    private static hamming(
        qb: Int32Array, qoff: number,
        tb: Int32Array, toff: number,
        int_len: number
    ): number {
        let d = 0;
        for (let k = 0; k < int_len; ++k) {
            d += bfmatcher.popcnt32(qb[qoff + k] ^ tb[toff + k]);
        }
        return d;
    }

    /**
     * Nearest-neighbour match. With `cross_check`, keeps only mutually-best
     * pairs (q,t): q must also be t's best match back.
     */
    match(query: matrix_t, train: matrix_t, max_distance = 256): match_t[] {
        const q_cnt = query.rows;
        const t_cnt = train.rows;
        const int_len = query.cols >> 2;
        const qb = bfmatcher.asInt32(query);
        const tb = bfmatcher.asInt32(train);

        const out: match_t[] = [];
        const fwd = new Int32Array(q_cnt);

        for (let qi = 0; qi < q_cnt; ++qi) {
            const qoff = qi * int_len;
            let best = 0x7fffffff;
            let best_idx = -1;
            for (let ti = 0; ti < t_cnt; ++ti) {
                const d = bfmatcher.hamming(qb, qoff, tb, ti * int_len, int_len);
                if (d < best) { best = d; best_idx = ti; }
            }
            fwd[qi] = best_idx;
            if (!this.cross_check && best_idx >= 0 && best <= max_distance) {
                out.push(new match_t(qi, best_idx, best));
            }
        }

        if (!this.cross_check) return out;

        for (let qi = 0; qi < q_cnt; ++qi) {
            const ti = fwd[qi];
            if (ti < 0) continue;
            const toff = ti * int_len;
            let best = 0x7fffffff;
            let back_idx = -1;
            for (let qj = 0; qj < q_cnt; ++qj) {
                const d = bfmatcher.hamming(qb, qj * int_len, tb, toff, int_len);
                if (d < best) { best = d; back_idx = qj; }
            }
            if (back_idx === qi && best <= max_distance) {
                out.push(new match_t(qi, ti, best));
            }
        }
        return out;
    }

    /** k-nearest matches per query, sorted ascending by distance. */
    knnMatch(query: matrix_t, train: matrix_t, k = 2): match_t[][] {
        const q_cnt = query.rows;
        const t_cnt = train.rows;
        const int_len = query.cols >> 2;
        const qb = bfmatcher.asInt32(query);
        const tb = bfmatcher.asInt32(train);

        const result: match_t[][] = [];
        for (let qi = 0; qi < q_cnt; ++qi) {
            const qoff = qi * int_len;
            const top: match_t[] = [];
            for (let ti = 0; ti < t_cnt; ++ti) {
                const d = bfmatcher.hamming(qb, qoff, tb, ti * int_len, int_len);
                if (top.length < k) {
                    top.push(new match_t(qi, ti, d));
                    top.sort((a, b) => a.distance - b.distance);
                } else if (d < top[top.length - 1].distance) {
                    const slot = top[top.length - 1];
                    slot.queryIdx = qi; slot.trainIdx = ti; slot.distance = d;
                    top.sort((a, b) => a.distance - b.distance);
                }
            }
            result.push(top);
        }
        return result;
    }

    /** Lowe's ratio test over `knnMatch(query, train, 2)`. */
    static ratio_test(knn: match_t[][], ratio = 0.75): match_t[] {
        const good: match_t[] = [];
        for (let i = 0; i < knn.length; ++i) {
            const m = knn[i];
            if (m.length >= 2) {
                if (m[0].distance < ratio * m[1].distance) good.push(m[0]);
            } else if (m.length === 1) {
                good.push(m[0]);
            }
        }
        return good;
    }
}
2. pose_estimatorsrc/pose_estimator/pose_estimator.ts

Closed-form planar pose from homography + intrinsics (no SVD dependency, to stay no_std-portable for the Rust side).

  • pose_t { R: matrix_t(3x3), t: Float64Array(3), good: boolean }
  • estimate(H, out?)B = K⁻¹H, normalize columns, closed-form re-orthonormalization, r3 = r1 × r2
  • Front-of-camera sign disambiguation (t.z > 0)
  • modelViewGL(pose) — column-major 4×4 for three.js / WebGL, CV→GL via diag(1, -1, -1)moved to the high-level renderer adapters (#97); jsfeatNext stays renderer-agnostic
  • intrinsics(width, height, fovX) — rough pinhole K for uncalibrated bootstrap (camera geometry, not rendering — may stay or move up; decide at implementation)
  • Attach to namespace (jsfeatNext.pose_estimator, jsfeatNext.pose_t)
  • Tests: synthesize H = K[r1 r2 t] from a known pose and check round-trip within tolerance
Prototype implementation — src/pose_estimator/pose_estimator.ts
import { matrix_t } from "../matrix_t/matrix_t";
import { JSFEAT_CONSTANTS } from "../constants/constants";

export class pose_t {
    public R: matrix_t;     // 3x3, row-major, OpenCV camera frame
    public t: Float64Array; // length 3
    public good: boolean;   // false if H/K were degenerate

    constructor() {
        this.R = new matrix_t(3, 3, JSFEAT_CONSTANTS.F64_t | JSFEAT_CONSTANTS.C1_t);
        this.t = new Float64Array(3);
        this.good = false;
    }
}

export class pose_estimator {
    private Kinv: Float64Array; // inverse intrinsics, row-major, zero skew

    constructor(K: matrix_t) {
        this.Kinv = pose_estimator.invertIntrinsics(K);
    }

    setIntrinsics(K: matrix_t): void {
        this.Kinv = pose_estimator.invertIntrinsics(K);
    }

    private static invertIntrinsics(K: matrix_t): Float64Array {
        const k = K.data;
        const fx = k[0], cx = k[2], fy = k[4], cy = k[5];
        return new Float64Array([
            1 / fx, 0, -cx / fx,
            0, 1 / fy, -cy / fy,
            0, 0, 1,
        ]);
    }

    /** Recover pose from a homography mapping model-plane points to pixels. */
    estimate(H: matrix_t, out?: pose_t): pose_t {
        const pose = out || new pose_t();
        const h = H.data;
        const ki = this.Kinv;

        // B = Kinv * H, row-major
        const B = new Float64Array(9);
        for (let r = 0; r < 3; ++r) {
            for (let c = 0; c < 3; ++c) {
                B[r * 3 + c] =
                    ki[r * 3] * h[c] +
                    ki[r * 3 + 1] * h[3 + c] +
                    ki[r * 3 + 2] * h[6 + c];
            }
        }

        const b1 = [B[0], B[3], B[6]];
        const b2 = [B[1], B[4], B[7]];
        const b3 = [B[2], B[5], B[8]];

        const n1 = Math.hypot(b1[0], b1[1], b1[2]);
        const n2 = Math.hypot(b2[0], b2[1], b2[2]);
        if (n1 < 1e-12 || n2 < 1e-12) { pose.good = false; return pose; }

        const lambda = 2.0 / (n1 + n2);
        const s = b3[2] >= 0 ? 1 : -1; // target in front of camera (t.z > 0)

        const r1 = [s * b1[0] / n1, s * b1[1] / n1, s * b1[2] / n1];
        const r2 = [s * b2[0] / n2, s * b2[1] / n2, s * b2[2] / n2];
        const t  = [s * b3[0] * lambda, s * b3[1] * lambda, s * b3[2] * lambda];

        // Closed-form re-orthonormalization of (r1, r2):
        const c = pose_estimator.normalize([r1[0] + r2[0], r1[1] + r2[1], r1[2] + r2[2]]);
        const p = pose_estimator.cross(r1, r2);
        const d = pose_estimator.normalize(pose_estimator.cross(c, p));
        const q = Math.SQRT1_2;

        const c1 = [(c[0] + d[0]) * q, (c[1] + d[1]) * q, (c[2] + d[2]) * q];
        const c2 = [(c[0] - d[0]) * q, (c[1] - d[1]) * q, (c[2] - d[2]) * q];
        const c3 = pose_estimator.cross(c1, c2);

        const R = pose.R.data; // store columns [c1 c2 c3] into row-major R
        R[0] = c1[0]; R[1] = c2[0]; R[2] = c3[0];
        R[3] = c1[1]; R[4] = c2[1]; R[5] = c3[1];
        R[6] = c1[2]; R[7] = c2[2]; R[8] = c3[2];

        pose.t[0] = t[0]; pose.t[1] = t[1]; pose.t[2] = t[2];
        pose.good = true;
        return pose;
    }

    /** Column-major 4x4 modelview for three.js / WebGL, CV -> GL via diag(1,-1,-1). */
    modelViewGL(pose: pose_t, out?: Float32Array): Float32Array {
        const m = out || new Float32Array(16);
        const R = pose.R.data;
        const t = pose.t;
        m[0] = R[0];  m[1] = -R[3];  m[2] = -R[6];  m[3] = 0;
        m[4] = R[1];  m[5] = -R[4];  m[6] = -R[7];  m[7] = 0;
        m[8] = R[2];  m[9] = -R[5];  m[10] = -R[8]; m[11] = 0;
        m[12] = t[0]; m[13] = -t[1]; m[14] = -t[2]; m[15] = 1;
        return m;
    }

    /** Rough pinhole intrinsics from image size + horizontal FOV (uncalibrated bootstrap). */
    static intrinsics(width: number, height: number, fovXdeg = 60): matrix_t {
        const K = new matrix_t(3, 3, JSFEAT_CONSTANTS.F64_t | JSFEAT_CONSTANTS.C1_t);
        const f = 0.5 * width / Math.tan(0.5 * fovXdeg * Math.PI / 180);
        const k = K.data;
        k[0] = f; k[1] = 0; k[2] = 0.5 * width;
        k[3] = 0; k[4] = f; k[5] = 0.5 * height;
        k[6] = 0; k[7] = 0; k[8] = 1;
        return K;
    }

    private static cross(a: number[], b: number[]): number[] {
        return [
            a[1] * b[2] - a[2] * b[1],
            a[2] * b[0] - a[0] * b[2],
            a[0] * b[1] - a[1] * b[0],
        ];
    }

    private static normalize(v: number[]): number[] {
        const n = Math.hypot(v[0], v[1], v[2]) || 1;
        return [v[0] / n, v[1] / n, v[2] / n];
    }
}

Proposed API (sketch)

// matching
const matcher = new jsfeat.bfmatcher(jsfeat.bfmatcher.NORM_HAMMING);
const knn  = matcher.knnMatch(screenDescriptors, patternDescriptors, 2);
const good = jsfeat.bfmatcher.ratio_test(knn, 0.75);

// pose
const K = jsfeat.pose_estimator.intrinsics(640, 480);
const estimator = new jsfeat.pose_estimator(K);
const pose = new jsfeat.pose_t();
estimator.estimate(homography3x3, pose);
if (pose.good) object3D.matrix.fromArray(estimator.modelViewGL(pose));

Acceptance criteria

  • pose_estimator is exported and usable as jsfeat.pose_estimator.
  • Pose round-trip: given K and a synthesized H = K[r1 r2 t], recovered R, t match the ground truth within ε.
  • Public API surface mirrors OpenCV / PureCV naming to keep TS ↔ Rust cross-validation trivial.

Out of scope (follow-ups)

  • Temporal smoothing / optical_flow_lk tracking between ORB matches (reduces jitter and recompute cost).
  • Real camera calibration workflow (the intrinsics() helper is a bootstrap only).
  • Non-Hamming norms (L1/L2) in the matcher.
  • Indexed / approximate matching (FLANN-style). The current matcher is O(query × train) — fine for image targets (a few hundred descriptors), not general-purpose.

Notes

  • Lands as feat(pose): … (Conventional Commits); the matcher half lands separately under #133.
  • Split recorded in the features2d expansion plan, docs/features2d-expansion-plan.md.
  • Both modules were prototyped against sample_orb_pinball.html; this issue promotes that throwaway sample code into maintained, tested modules.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.