Skip to content

fix(fast_corners): index the per-row candidate buffer consistently (#202) - #203

Merged
kalwalt merged 5 commits into
devfrom
fix/202-fast-corners-off-by-one
Sep 12, 2026
Merged

fix(fast_corners): index the per-row candidate buffer consistently (#202)#203
kalwalt merged 5 commits into
devfrom
fix/202-fast-corners-off-by-one

Conversation

@kalwalt

@kalwalt kalwalt commented Sep 12, 2026

Copy link
Copy Markdown
Member

The defect

fast_corners.detect wrote each image row's corner columns into the cpbuf scratch buffer 1-based and read them back 0-based one row later:

++ncorners; cpbuf[cornerpos + ncorners] = j;        // writes [1 .. n]
for (k = 0; k < ncorners; ++k) j = cpbuf[cornerpos + k];  // reads  [0 .. n-1]

Two consequences, on every image:

  1. Each row's last candidate was never read, so genuine corners were dropped.
  2. cpbuf[cornerpos + 0] was read without ever being written. The buffer comes from the shared pool, and get_buffer only ever grows a node — it never zeroes one. That cell therefore held whatever an earlier, unrelated call left behind, so detect's output depended on process history rather than on the image.

(2) is why the same image with the same options could return different corner sets across calls. It is only observable when the recycled cell happens to hold a small non-negative integer — any other value indexes buf out of bounds, yields undefined, and fails every comparison — which is why the defect looked intermittent rather than constant.

The fix

Write 0-based, matching the read — the two lines swapped, in both the darker (d & 1) and brighter (d & 2) branches. detect is now a pure function of (image, border, threshold) and recovers the dropped corners. No API change.

The row's count cell cpbuf[cornerpos + w] is also safer than before: the highest candidate index now written is w - 7, strictly below it.

Why this is an intentional divergence, not a parity regression

The vendored jsfeat oracle carries the identical defect, so the correct behaviour cannot be bit-parity with jsfeat. Following the pattern already used for #102, #114, #119, #139 and #186, the cross-implementation assertion moves out of tests/parity/ into tests/divergences.test.ts.

What replaces equality is containment: jsfeatNext's corner set always contains jsfeat's. That is structural, not an artefact of the chosen scenes. jsfeat emits a corner only if score = buf[prev+j] is strictly greater than all eight neighbours. For a phantom column j that was not a real candidate of that row, buf[prev+j] === 0 and 0 > 0 is false; for a j out of range, every comparison against undefined is false. So a phantom can pass suppression only if it is already a real candidate of that row — i.e. already in jsfeatNext's list. cpbuf only enumerates candidates; every suppression decision reads scores out of buf, which the defect never touched.

Measured counts: 33 corners vs jsfeat's 27 on the divergence test's scene (threshold 20, border 3). The containment relation is asserted across seven scene seeds, so jsfeat's score arithmetic is still cross-checked bit-for-bit on the common subset — on seven scenes now, where the old parity test used one.

Coverage

Net stronger than what was removed. Three invariants are new, and each of them fails on the unfixed code (verified by reverting the swap):

  • detect is independent of what the shared buffer pool contains (poisons all 30 pool nodes with a plausible column index — note that poisoning with 0, or with a byte fill, is invisible).
  • detect does not drop the last corner candidate of a row.
  • The superset relation against jsfeat, across seven seeds.

Plus a purity check across repeated calls with an unrelated detect in between. Full suite: 356 tests, 28 files, green.

Audit answers (the two questions in #202)

Do any fixtures carry expected values produced by the current detect? No. Every fast_corners comparison in this repository runs live against the vendored oracle; nothing static pins detect output. This change alters no committed fixture — only the six files in this diff.

Does any other module share the pattern? No. All 44 get_buffer call sites across 10 files in src/ were walked: every borrowed buffer is fully written before it is read, and every get_buffer is balanced by a put_buffer on every exit path.

Notes for the reviewer

  • dist/ is deliberately not rebuilt here. Whether a fix PR rebuilds dist is a release-convention decision; it needs a rebuild at release time.
  • Follow-up worth its own issue: detect writes into the caller's corners pool without checking corners.length. Recovering the dropped corners raises the count, which makes that pre-existing hazard marginally more reachable.
  • Open question from fast_corners.detect reads uninitialised memory and drops each row's last corner #202, not answered here: whether PureCV's port shares the off-by-one. PureCV is a separate repository and out of this branch's scope.

Closes #202
Unblocks step 4 of webarkit/webarkit#22, tracked in webarkit/webarkit#27.

🤖 Generated with Claude Code

kalwalt and others added 4 commits September 12, 2026 18:24
orb.describe parity was asserting that the two FAST detectors agree before
comparing descriptors, which duplicated the fast_corners parity test and
coupled the descriptor test to detector behaviour. Feed both implementations
the same corner list so the test measures only orb.describe.

Refs #202

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
detect() wrote each row's corner columns into the cpbuf scratch buffer
1-based and read them back 0-based. One never-written cell per row therefore
fed non-maximum suppression, and since the shared pool only grows and never
zeroes, that cell held whatever an earlier, unrelated call left behind -
making detect's output depend on process history rather than on the image.
The row's real last candidate was never read at all, so genuine corners were
dropped.

Write 0-based, matching the read. detect is now a pure function of its inputs
and recovers the dropped corners (42 vs 35 on the parity scene). jsfeat has
the same defect, so this is an intentional divergence: the cross-implementation
assertion moves from tests/parity to tests/divergences, where it pins the
relation that does still hold - jsfeatNext's corners are a superset of
jsfeat's, structurally, because cpbuf only enumerates candidates while every
suppression decision reads scores the defect never touched.

Closes #202

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Refs #202

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…set relation

Refs #202

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@qodo-code-review

Copy link
Copy Markdown

ⓘ Qodo reviews are paused because your trial has ended. Ask your workspace admin to add credits to resume reviews. Manage billing

fast_corners.detect's off-by-one fix (#202) makes jsfeatNext's corner set a
structural superset of jsfeat's rather than an equal one, so the bench smoke
check's strict count-equality guards now fail on every run.

- bench/detectors.bench.ts: fast_corners.detect IS the measured workload, so
  both sides must keep detecting independently. Replace the equality guard
  with containment (next >= orig) plus a bounded excess tolerance (5%,
  headroom over the ~0.7%/~2.8% observed at thresholds 20/60), so the guard
  still catches a genuine workload divergence. yape06/yape keep strict
  equality, unaffected by #202.
- bench/optical_flow_lk.bench.ts: seed points are an input to the measured
  workload (optical_flow_lk.track), not the thing under test, so derive them
  once from jsfeatNext and feed the same points to both sides -- mirroring
  the ORB parity fix in 3a7f8ec -- making the equality guard unnecessary
  rather than merely relaxed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@codecov

codecov Bot commented Sep 12, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@kalwalt

kalwalt commented Sep 12, 2026

Copy link
Copy Markdown
Member Author

Follow-ups from the review of this PR, now tracked:

Also note the CI fix in 3a08720: the bench/ smoke check has its own cross-implementation equality guards that npm test never runs, and they failed for the same reason the parity assertion did. bench/optical_flow_lk.bench.ts now seeds both track() calls from one corner set (the seeds are an input to what it measures), and bench/detectors.bench.ts keeps both sides detecting independently but asserts containment within a tolerance instead of equality — detect is the measured workload there, so the workloads cannot be equalised. That tolerance (5%) was chosen with headroom over the largest measured excess rather than derived from the per-row bound; tightening it is fair review feedback if you want it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working code design enhancement New feature or request tests Typescript all about Typescript

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant