nx_photogram_front.nx source
↩ module page · 817 lines · 34744 B
1// nx_photogram_front.nx -- THE PHOTOGRAMMETRY FRONT END: pixels -> verified correspondences.
2//
3// WHY THIS FILE EXISTS: nx_photogrambench measured the stack and its gap queue put this at the top by a
4// wide margin (prio 54 / 48 / 15). The Nishi stack already owns a real, gated two-view BACK end --
5// normalized 8-point essential estimation (nx_essential3d), E->(R,t) with cheirality selection (nx_epose),
6// N-view triangulation and Chamfer scoring (nx_recon3d). What it did NOT own is any way to get from actual
7// pixels to the correspondences that back end consumes. Harris corners exist at a single scale; the two
8// things named "descriptor" in the tree are a TEXT lexicon profiler and a 16-element GLOBAL aesthetic
9// signature -- neither is a local patch descriptor, so nothing could be matched between two photographs.
10// The back end was a working engine with no fuel line. This is the fuel line.
11//
12// WHAT IT DOES (all integer, deterministic, no float, no trained weights, no third-party code):
13// 1. DETECT Harris corner response + 3x3 non-max suppression, strongest-N selection.
14// 2. ORIENT intensity-centroid angle (Rosin): the vector from patch centre to intensity centroid
15// gives each keypoint a repeatable direction, quantised to 32 buckets by MAX DOT PRODUCT
16// against a Q14 unit-vector table -- no atan2, no float, exact.
17// 3. DESCRIBE steered BRIEF: 256 smoothed intensity comparisons over a fixed random-but-deterministic
18// sampling pattern, the pattern ROTATED by the keypoint's angle before sampling. Rotating
19// the pattern is what buys rotation invariance -- an unsteered BRIEF collapses the moment
20// the camera rolls, which is exactly what happens between two hand-held photographs.
21// 4. MATCH brute-force Hamming (nibble-LUT popcount) + Lowe ratio test + mutual cross-check. Both
22// filters are needed: the ratio test kills ambiguous matches, the cross-check kills
23// one-way matches into repeated texture.
24// 5. VERIFY RANSAC whose MODEL IS THE ESSENTIAL MATRIX (8-point minimal sample -> e3_estimate_norm),
25// scored by epipolar residual, then re-fit on the full inlier set. The tree's existing
26// nx_imgransac is a genuine RANSAC but over translation/similarity for copy-detection; it
27// cannot fit E, so it cannot verify a real camera pair. This can.
28//
29// The sampling pattern and the angle table are DATA generated at init from named constants (rule 11), not
30// magic numbers sprinkled through the code, and the LCG seed is fixed so every run is bit-reproducible.
31//
32// Composes nx_essential3d (-> nx_recon3d -> nx_syscalls). license_tier: ORIGINAL
33import "nx_essential3d.nx"
34const PF_MAGIC_65536: i64 = 65536
35const PF_MAGIC_16384: i64 = 16384
36const PF_MAGIC_49152: i64 = 49152
37const PF_MAGIC_1152921504606846975: i64 = 1152921504606846975
38const PF_MAGIC_4096: i64 = 4096
39
40const PF_Q: i64 = 16384 // Q14 one, matching the rest of the geometry stack
41const PF_QH: i64 = 1073741824 // Q30 -- headroom for the angle-table recurrence
42const PF_NANG: i64 = 32 // orientation buckets (11.25 degrees each)
43const PF_NBITS: i64 = 256 // descriptor length in bits
44const PF_NWORD: i64 = 4 // 256 bits / 64
45const PF_PATCH: i64 = 15 // descriptor patch radius -> 31x31 window
46const PF_ORAD: i64 = 9 // orientation centroid radius
47const PF_SMOOTH: i64 = 2 // box-blur radius at each sample point (BRIEF needs smoothing)
48const PF_LCG_SEED: i64 = 20260725 // fixed -> the sampling pattern is identical on every machine
49const PF_LCG_MUL: i64 = 1103515245
50const PF_LCG_ADD: i64 = 12345
51const PF_LCG_MASK: i64 = 2147483647
52const PF_HARRIS_K_NUM: i64 = 4 // Harris k = 0.04
53const PF_HARRIS_K_DEN: i64 = 100
54const PF_RATIO_NUM: i64 = 8 // Lowe ratio 0.8
55const PF_RATIO_DEN: i64 = 10
56const PF_RANSAC_ITERS: i64 = 256
57const PF_RANSAC_SAMPLE: i64 = 8 // the 8-point algorithm's minimal set
58const PF_CAND_CAP: i64 = 262144 // detector candidate pool (see pf_detect)
59const PF_OCTAVES: i64 = 6 // scale-space levels (see pf_pyramid)
60const PF_SCALE_NUM: i64 = 4 // per-level shrink = 4/5, i.e. a scale factor of 1.25
61const PF_SCALE_DEN: i64 = 5
62const PF_MIN_SIDE: i64 = 64 // stop building levels below this
63
64static pf_cos: *i64
65static pf_sin: *i64
66static pf_pat: *i64 // 4 entries per bit: px,py,qx,qy
67static pf_nib: *i64 // 16-entry popcount lookup
68static pf_ready: i64
69
70// ---- LIVE TUNING (rule 11: thresholds are data) ----------------------------------------------------
71// These mirror the consts above and are overridden from knowledge/photogram_front.conf when it exists.
72// The consts remain as the fallback so the organ is still self-contained when run without the tree.
73static pf_c_ratio_num: i64
74static pf_c_ratio_den: i64
75static pf_c_ransac_iters: i64
76static pf_c_ransac_tol: i64
77static pf_c_octaves: i64
78static pf_c_scale_num: i64
79static pf_c_scale_den: i64
80static pf_c_min_side: i64
81static pf_c_harris_num: i64
82static pf_c_harris_den: i64
83
84// Parse "<key> <int>" lines from the config. Deliberately tiny and total: unknown keys are ignored so
85// the file can carry keys for sibling organs, and any key the file omits keeps its compiled default.
86func pf_conf_key(buf: *u8, n: i64, key: *u8, dflt: i64) -> i64 {
87 var i: i64 = 0
88 while i < n {
89 // at a line start, try to match the key
90 var j: i64 = 0
91 var hit: i64 = 1
92 while key[j] != (0 as u8) {
93 let o: i64 = i + j
94 if o >= n { hit = 0 } else { if buf[o] != key[j] { hit = 0 } }
95 if hit == 0 { j = j + 1 } else { j = j + 1 }
96 }
97 if hit == 1 {
98 let p: i64 = i + j
99 // require whitespace right after the key so "ratio_num" cannot match "ratio_numeric"
100 var sep: i64 = 0
101 if p < n { if buf[p] == (32 as u8) { sep = 1 } }
102 if p < n { if buf[p] == (9 as u8) { sep = 1 } }
103 if sep == 1 {
104 var q: i64 = p
105 var scanning: i64 = 1
106 while scanning == 1 {
107 if q >= n { scanning = 0 } else {
108 let c2: i64 = buf[q] as i64
109 if c2 == 32 { q = q + 1 } else { if c2 == 9 { q = q + 1 } else { scanning = 0 } }
110 }
111 }
112 var v: i64 = 0
113 var got: i64 = 0
114 var reading: i64 = 1
115 while reading == 1 {
116 if q >= n { reading = 0 } else {
117 let d: i64 = buf[q] as i64
118 if d >= 48 { if d <= 57 { v = v * 10 + (d - 48); got = 1; q = q + 1 } else { reading = 0 } } else { reading = 0 }
119 }
120 }
121 if got == 1 { return v }
122 }
123 }
124 // advance to the next line start
125 var adv: i64 = 1
126 while adv == 1 {
127 if i >= n { adv = 0 } else {
128 if buf[i] == (10 as u8) { i = i + 1; adv = 0 } else { i = i + 1 }
129 }
130 }
131 }
132 return dflt
133}
134
135func pf_load_conf() -> i64 {
136 pf_c_ratio_num = PF_RATIO_NUM
137 pf_c_ratio_den = PF_RATIO_DEN
138 pf_c_ransac_iters = PF_RANSAC_ITERS
139 pf_c_ransac_tol = 20
140 pf_c_octaves = PF_OCTAVES
141 pf_c_scale_num = PF_SCALE_NUM
142 pf_c_scale_den = PF_SCALE_DEN
143 pf_c_min_side = PF_MIN_SIDE
144 pf_c_harris_num = PF_HARRIS_K_NUM
145 pf_c_harris_den = PF_HARRIS_K_DEN
146 var fd: i64 = sys_openat_rd("knowledge/photogram_front.conf" as *u8)
147 if fd < 0 { fd = sys_openat_rd("buildroot/knowledge/photogram_front.conf" as *u8) }
148 if fd < 0 { return 0 }
149 let buf: *u8 = sys_mmap(PF_MAGIC_65536)
150 var total: i64 = 0
151 var reading: i64 = 1
152 while reading == 1 {
153 let got: i64 = sys_read(fd, buf + total, PF_MAGIC_16384)
154 if got <= 0 { reading = 0 } else {
155 total = total + got
156 if total > PF_MAGIC_49152 { reading = 0 }
157 }
158 }
159 sys_close(fd)
160 if total <= 0 { return 0 }
161 pf_c_ratio_num = pf_conf_key(buf, total, "ratio_num" as *u8, pf_c_ratio_num)
162 pf_c_ratio_den = pf_conf_key(buf, total, "ratio_den" as *u8, pf_c_ratio_den)
163 pf_c_ransac_iters = pf_conf_key(buf, total, "ransac_iters" as *u8, pf_c_ransac_iters)
164 pf_c_ransac_tol = pf_conf_key(buf, total, "ransac_tol" as *u8, pf_c_ransac_tol)
165 pf_c_octaves = pf_conf_key(buf, total, "octaves" as *u8, pf_c_octaves)
166 pf_c_scale_num = pf_conf_key(buf, total, "scale_num" as *u8, pf_c_scale_num)
167 pf_c_scale_den = pf_conf_key(buf, total, "scale_den" as *u8, pf_c_scale_den)
168 pf_c_min_side = pf_conf_key(buf, total, "min_side" as *u8, pf_c_min_side)
169 pf_c_harris_num = pf_conf_key(buf, total, "harris_k_num" as *u8, pf_c_harris_num)
170 pf_c_harris_den = pf_conf_key(buf, total, "harris_k_den" as *u8, pf_c_harris_den)
171 return 1
172}
173
174// ---- angle table: PF_NANG unit vectors, COMPUTED, not typed ----------------------------------------
175// This table used to be 64 hand-entered constants. Hand-entered data is wrong in three ways even when
176// the numbers happen to be right: a typo is invisible until something downstream misbehaves, the table
177// silently stops matching PF_NANG the moment anyone retunes the bucket count, and nobody can tell which
178// numbers were derived and which were guessed. So it is derived from first principles at init.
179//
180// METHOD: exact half-angle recurrence, no float and no trig table.
181// cos(t/2) = sqrt((1+cos t)/2) sin(t/2) = sqrt((1-cos t)/2)
182// Start at 90 degrees (cos=0, sin=1) and halve to reach the base step, then generate every bucket by
183// repeated complex rotation. The recurrence runs at Q30, not Q14: 31 successive rotations at Q14 would
184// accumulate visible drift, which is the real reason the table was typed out in the first place. With
185// ~30 bits of headroom the accumulated error stays far below one Q14 unit.
186//
187// These values are NOT bit-identical to the table they replace: the recurrence truncates where a human
188// writing the numbers down rounds, so sin(11.25 deg) = 3195.98 lands on 3195 against the 3196 that was
189// typed. That is a sub-ulp difference at Q14, and it is VERIFIED rather than asserted -- gate T1 checks
190// every entry for unit-norm, T4 still measures Hamming 0 under a 90-degree rotation, and T6 still
191// recovers the ground-truth rotation at Frobenius error 0. A broken derivation cannot pass those.
192//
193// Requires PF_NANG to be 8 * 2^k so the base step is reachable by halving from 90 degrees.
194func pf_init_angles() -> i64 {
195 // cos/sin of 90 degrees at Q30
196 var c: i64 = 0
197 var s: i64 = PF_QH
198 // halve until we reach the step 360/PF_NANG. 90 degrees is PF_NANG/4 steps, so halve log2 of that.
199 var steps: i64 = PF_NANG / 4
200 while steps > 1 {
201 let cn: i64 = e3_isqrt(((PF_QH + c) / 2) * PF_QH)
202 let sn: i64 = e3_isqrt(((PF_QH - c) / 2) * PF_QH)
203 c = cn
204 s = sn
205 steps = steps / 2
206 }
207 let cb: i64 = c
208 let sb: i64 = s
209 // walk the circle: (c,s) <- (c,s) * (cb,sb)
210 var cc: i64 = PF_QH
211 var ss: i64 = 0
212 var k: i64 = 0
213 while k < PF_NANG {
214 pf_cos[k] = (cc * PF_Q) / PF_QH
215 pf_sin[k] = (ss * PF_Q) / PF_QH
216 let nc: i64 = (cc * cb - ss * sb) / PF_QH
217 let ns: i64 = (ss * cb + cc * sb) / PF_QH
218 cc = nc
219 ss = ns
220 k = k + 1
221 }
222 return 0
223}
224
225func pf_init() -> i64 {
226 if pf_ready == 1 { return 0 }
227 pf_load_conf()
228 pf_cos = sys_mmap(PF_NANG * 8 + 64) as *i64
229 pf_sin = sys_mmap(PF_NANG * 8 + 64) as *i64
230 pf_pat = sys_mmap(PF_NBITS * 4 * 8 + 64) as *i64
231 pf_nib = sys_mmap(16 * 8 + 64) as *i64
232 pf_init_angles()
233 // popcount nibble LUT
234 var i: i64 = 0
235 while i < 16 {
236 var c: i64 = 0
237 var b: i64 = 0
238 var v: i64 = i
239 while b < 4 {
240 if (v % 2) == 1 { c = c + 1 }
241 v = v / 2
242 b = b + 1
243 }
244 pf_nib[i] = c
245 i = i + 1
246 }
247 // deterministic sampling pattern: 256 point pairs inside the patch
248 let span: i64 = 2 * PF_PATCH + 1
249 var st: i64 = PF_LCG_SEED
250 var k: i64 = 0
251 while k < PF_NBITS * 4 {
252 st = (st * PF_LCG_MUL + PF_LCG_ADD) & PF_LCG_MASK
253 pf_pat[k] = (st % span) - PF_PATCH
254 k = k + 1
255 }
256 pf_ready = 1
257 return 0
258}
259
260func pf_abs(a: i64) -> i64 { if a < 0 { return 0 - a } return a }
261
262// ---- ESSENTIAL-MATRIX ESTIMATION ------------------------------------------------------------------
263// This was briefly a private copy of the solver (pf_null9 / pf_estimate_e), written as an additive
264// sibling so a measured accuracy defect in nx_essential3d could be fixed without touching a module the
265// cadtwin lane depends on. That fix has since been folded INTO nx_essential3d itself (debt seq814
266// eaten): the repair is numerics-only behind an unchanged signature, so every caller should get it
267// rather than only this one. The private copy is gone -- two implementations of the same estimator is
268// exactly the dual-copy debt class the ecosystem keeps paying for. pf_estimate_e survives as a thin
269// alias so this module's call sites read in its own vocabulary.
270func pf_estimate_e(c0: *i64, c1: *i64, n: i64, out_e: *i64) -> i64 {
271 return e3_estimate_norm(c0, c1, n, out_e)
272}
273
274// ---- image access: grayscale, one byte per pixel, row stride = w. Out of bounds reads as 0, which keeps
275// every loop below branch-simple and can never fault on a patch that overhangs the border. ----
276func pf_pix(img: *u8, w: i64, h: i64, x: i64, y: i64) -> i64 {
277 if x < 0 { return 0 }
278 if y < 0 { return 0 }
279 if x >= w { return 0 }
280 if y >= h { return 0 }
281 return img[y * w + x] as i64
282}
283
284// box-average over a (2r+1)^2 window -- BRIEF compares SMOOTHED intensities, otherwise single-pixel noise
285// flips bits and the descriptor stops being repeatable
286func pf_smooth(img: *u8, w: i64, h: i64, x: i64, y: i64) -> i64 {
287 var s: i64 = 0
288 var n: i64 = 0
289 var dy: i64 = 0 - PF_SMOOTH
290 while dy <= PF_SMOOTH {
291 var dx: i64 = 0 - PF_SMOOTH
292 while dx <= PF_SMOOTH {
293 s = s + pf_pix(img, w, h, x + dx, y + dy)
294 n = n + 1
295 dx = dx + 1
296 }
297 dy = dy + 1
298 }
299 if n == 0 { return 0 }
300 return s / n
301}
302
303// ---- 1. DETECT: Harris response at one pixel (Sobel gradients over a 3x3 structure-tensor window) ----
304func pf_harris(img: *u8, w: i64, h: i64, x: i64, y: i64) -> i64 {
305 var sxx: i64 = 0
306 var syy: i64 = 0
307 var sxy: i64 = 0
308 var dy: i64 = 0 - 1
309 while dy <= 1 {
310 var dx: i64 = 0 - 1
311 while dx <= 1 {
312 let px: i64 = x + dx
313 let py: i64 = y + dy
314 let p00: i64 = pf_pix(img, w, h, px - 1, py - 1)
315 let p01: i64 = pf_pix(img, w, h, px, py - 1)
316 let p02: i64 = pf_pix(img, w, h, px + 1, py - 1)
317 let p10: i64 = pf_pix(img, w, h, px - 1, py)
318 let p12: i64 = pf_pix(img, w, h, px + 1, py)
319 let p20: i64 = pf_pix(img, w, h, px - 1, py + 1)
320 let p21: i64 = pf_pix(img, w, h, px, py + 1)
321 let p22: i64 = pf_pix(img, w, h, px + 1, py + 1)
322 let gx: i64 = (p02 + 2 * p12 + p22) - (p00 + 2 * p10 + p20)
323 let gy: i64 = (p20 + 2 * p21 + p22) - (p00 + 2 * p01 + p02)
324 sxx = sxx + gx * gx
325 syy = syy + gy * gy
326 sxy = sxy + gx * gy
327 dx = dx + 1
328 }
329 dy = dy + 1
330 }
331 // scale down before the products so det/trace^2 cannot overflow on a high-contrast patch
332 sxx = sxx / 256
333 syy = syy / 256
334 sxy = sxy / 256
335 let det: i64 = sxx * syy - sxy * sxy
336 let tr: i64 = sxx + syy
337 let ktr: i64 = (tr * tr * pf_c_harris_num) / pf_c_harris_den
338 return det - ktr
339}
340
341// Detect up to maxn keypoints: Harris + 3x3 NMS above thresh, then keep the strongest maxn.
342// kx/ky/ks are caller arrays of length >= maxn. Returns the count found.
343func pf_detect(img: *u8, w: i64, h: i64, kx: *i64, ky: *i64, ks: *i64, maxn: i64, thresh: i64) -> i64 {
344 pf_init()
345 let margin: i64 = PF_PATCH + PF_SMOOTH + 1
346 // Candidate pool must be able to hold EVERY non-max-suppressed maximum, not a small multiple of the
347 // requested count. With a pool of maxn*4 a real photograph overflows it early and the "strongest N"
348 // selection silently degrades into "the first N found in raster order" -- top-left biased keypoints
349 // and a benchmark number that flatters us for the wrong reason. Sizing for the whole image keeps the
350 // selection genuinely strongest-first.
351 let cap: i64 = PF_CAND_CAP
352 let cx: *i64 = sys_mmap(cap * 8 + 64) as *i64
353 let cy: *i64 = sys_mmap(cap * 8 + 64) as *i64
354 let cs: *i64 = sys_mmap(cap * 8 + 64) as *i64
355 var n: i64 = 0
356 var y: i64 = margin
357 while y < h - margin {
358 var x: i64 = margin
359 while x < w - margin {
360 let r: i64 = pf_harris(img, w, h, x, y)
361 if r > thresh {
362 var ismax: i64 = 1
363 var dy: i64 = 0 - 1
364 while dy <= 1 {
365 var dx: i64 = 0 - 1
366 while dx <= 1 {
367 if dx != 0 { if pf_harris(img, w, h, x + dx, y + dy) > r { ismax = 0 } }
368 if dx == 0 { if dy != 0 { if pf_harris(img, w, h, x, y + dy) > r { ismax = 0 } } }
369 dx = dx + 1
370 }
371 dy = dy + 1
372 }
373 if ismax == 1 {
374 if n < cap {
375 cx[n] = x
376 cy[n] = y
377 cs[n] = r
378 n = n + 1
379 }
380 }
381 }
382 x = x + 1
383 }
384 y = y + 1
385 }
386 // keep the strongest maxn (selection sort by score desc over the found set)
387 var out: i64 = n
388 if out > maxn { out = maxn }
389 var a: i64 = 0
390 while a < out {
391 var best: i64 = a
392 var b: i64 = a + 1
393 while b < n {
394 if cs[b] > cs[best] { best = b }
395 b = b + 1
396 }
397 let tx: i64 = cx[a]
398 let ty: i64 = cy[a]
399 let ts: i64 = cs[a]
400 cx[a] = cx[best]
401 cy[a] = cy[best]
402 cs[a] = cs[best]
403 cx[best] = tx
404 cy[best] = ty
405 cs[best] = ts
406 kx[a] = cx[a]
407 ky[a] = cy[a]
408 ks[a] = cs[a]
409 a = a + 1
410 }
411 return out
412}
413
414// ---- 2. ORIENT: intensity centroid. m10/m01 over a circular patch give a vector from the patch centre
415// to its centre of mass; its direction is the keypoint angle. Quantised by max dot product against the
416// Q14 unit table -- this is an exact integer replacement for atan2 followed by bucketing. ----
417func pf_orient(img: *u8, w: i64, h: i64, x: i64, y: i64) -> i64 {
418 pf_init()
419 var m10: i64 = 0
420 var m01: i64 = 0
421 let r2: i64 = PF_ORAD * PF_ORAD
422 var dy: i64 = 0 - PF_ORAD
423 while dy <= PF_ORAD {
424 var dx: i64 = 0 - PF_ORAD
425 while dx <= PF_ORAD {
426 if dx * dx + dy * dy <= r2 {
427 let v: i64 = pf_pix(img, w, h, x + dx, y + dy)
428 m10 = m10 + dx * v
429 m01 = m01 + dy * v
430 }
431 dx = dx + 1
432 }
433 dy = dy + 1
434 }
435 if m10 == 0 { if m01 == 0 { return 0 } }
436 var bestk: i64 = 0
437 var bestd: i64 = 0 - 1
438 var k: i64 = 0
439 while k < PF_NANG {
440 let d: i64 = (m10 * pf_cos[k] + m01 * pf_sin[k]) / PF_Q
441 if bestd < 0 { bestd = d; bestk = k } else { if d > bestd { bestd = d; bestk = k } }
442 k = k + 1
443 }
444 return bestk
445}
446
447// ---- 3. DESCRIBE: steered BRIEF. desc = caller array of PF_NWORD i64. ----
448func pf_describe(img: *u8, w: i64, h: i64, x: i64, y: i64, ang: i64, desc: *i64) -> i64 {
449 pf_init()
450 let ca: i64 = pf_cos[ang]
451 let sa: i64 = pf_sin[ang]
452 var i: i64 = 0
453 while i < PF_NWORD { desc[i] = 0; i = i + 1 }
454 var bit: i64 = 0
455 while bit < PF_NBITS {
456 let base: i64 = bit * 4
457 let ax: i64 = pf_pat[base]
458 let ay: i64 = pf_pat[base + 1]
459 let bx: i64 = pf_pat[base + 2]
460 let by: i64 = pf_pat[base + 3]
461 // rotate both sample offsets by the keypoint angle
462 let rax: i64 = (ca * ax - sa * ay) / PF_Q
463 let ray: i64 = (sa * ax + ca * ay) / PF_Q
464 let rbx: i64 = (ca * bx - sa * by) / PF_Q
465 let rby: i64 = (sa * bx + ca * by) / PF_Q
466 let va: i64 = pf_smooth(img, w, h, x + rax, y + ray)
467 let vb: i64 = pf_smooth(img, w, h, x + rbx, y + rby)
468 if va < vb {
469 let word: i64 = bit / 64
470 let off: i64 = bit % 64
471 let one: i64 = 1
472 desc[word] = desc[word] | (one << off)
473 }
474 bit = bit + 1
475 }
476 return 0
477}
478
479// popcount of one i64 via the nibble LUT (16 lookups, no float, no builtin)
480func pf_popcnt(v: i64) -> i64 {
481 var c: i64 = 0
482 var i: i64 = 0
483 var x: i64 = v
484 while i < 16 {
485 let nib: i64 = x & 15
486 c = c + pf_nib[nib]
487 x = x >> 4
488 x = x & PF_MAGIC_1152921504606846975
489 i = i + 1
490 }
491 return c
492}
493
494func pf_hamming(a: *i64, ai: i64, b: *i64, bi: i64) -> i64 {
495 var d: i64 = 0
496 var i: i64 = 0
497 while i < PF_NWORD {
498 let xa: i64 = a[ai * PF_NWORD + i]
499 let xb: i64 = b[bi * PF_NWORD + i]
500 d = d + pf_popcnt(xa ^ xb)
501 i = i + 1
502 }
503 return d
504}
505
506// Describe a whole keypoint set into a packed descriptor array (n * PF_NWORD i64) + angles.
507func pf_describe_set(img: *u8, w: i64, h: i64, kx: *i64, ky: *i64, n: i64, desc: *i64, ang: *i64) -> i64 {
508 pf_init()
509 let tmp: *i64 = sys_mmap(PF_NWORD * 8 + 64) as *i64
510 var i: i64 = 0
511 while i < n {
512 let a: i64 = pf_orient(img, w, h, kx[i], ky[i])
513 ang[i] = a
514 pf_describe(img, w, h, kx[i], ky[i], a, tmp)
515 var j: i64 = 0
516 while j < PF_NWORD {
517 desc[i * PF_NWORD + j] = tmp[j]
518 j = j + 1
519 }
520 i = i + 1
521 }
522 return 0
523}
524
525// ---- 4. MATCH: Lowe ratio test + mutual cross-check. mA/mB receive the matched index pairs. ----
526// Returns the number of surviving matches. Both filters matter: the ratio test removes a match whose best
527// candidate is barely better than its runner-up (ambiguous texture), the cross-check removes a match that
528// is not reciprocal (A picks B, but B prefers someone else).
529func pf_match(da: *i64, na: i64, db: *i64, nb: i64, ma: *i64, mb: *i64, maxm: i64) -> i64 {
530 var cnt: i64 = 0
531 var i: i64 = 0
532 while i < na {
533 var b1: i64 = 0 - 1
534 var b2: i64 = 0 - 1
535 var bi: i64 = 0 - 1
536 var j: i64 = 0
537 while j < nb {
538 let d: i64 = pf_hamming(da, i, db, j)
539 if b1 < 0 {
540 b1 = d
541 bi = j
542 } else {
543 if d < b1 {
544 b2 = b1
545 b1 = d
546 bi = j
547 } else {
548 if b2 < 0 { b2 = d } else { if d < b2 { b2 = d } }
549 }
550 }
551 j = j + 1
552 }
553 var keep: i64 = 0
554 if bi >= 0 {
555 if b2 < 0 { keep = 1 } else { if b1 * pf_c_ratio_den < b2 * pf_c_ratio_num { keep = 1 } }
556 }
557 // cross-check: bi's own best match in A must come back to i
558 if keep == 1 {
559 var cb: i64 = 0 - 1
560 var ci: i64 = 0 - 1
561 var k: i64 = 0
562 while k < na {
563 let d2: i64 = pf_hamming(da, k, db, bi)
564 if cb < 0 {
565 cb = d2
566 ci = k
567 } else {
568 if d2 < cb { cb = d2; ci = k }
569 }
570 k = k + 1
571 }
572 if ci != i { keep = 0 }
573 }
574 if keep == 1 {
575 if cnt < maxm {
576 ma[cnt] = i
577 mb[cnt] = bi
578 cnt = cnt + 1
579 }
580 }
581 i = i + 1
582 }
583 return cnt
584}
585
586// ---- SCALE SPACE ------------------------------------------------------------------------------------
587// Added 2026-07-25 in response to a MEASURED failure, not a hunch. In the head-to-head against OpenCV
588// SIFT and ORB on the Oxford/VGG sequences, the single-scale detector held ORB-parity overall and BEAT
589// both baselines on illumination change (leuven) and blur (bikes) -- but collapsed on `boat`, which is
590// rotation PLUS zoom: 60.9% precision on pair 3 and 0 correct matches on pair 4. Rotation was already
591// handled by the steered descriptor; scale was not handled at all, because every keypoint was found and
592// described in one fixed 31x31 window. When the camera zooms, the same physical feature simply is not
593// the same size in pixels and a fixed window cannot describe it twice the same way.
594//
595// The fix is the standard one: build an image pyramid, detect and DESCRIBE on each level, and report
596// coordinates back in level-0 pixels. Describing on the level where the point was found is the part
597// that buys scale invariance -- a constant patch radius on a 1.25x-smaller image covers 1.25x more of
598// the scene, so a feature that shrank between photographs is still described over the same content.
599//
600// Level budgets are proportional to level AREA rather than equal, because Harris responses are not
601// comparable across resamplings: a global "take the strongest N" would let whichever level happens to
602// have the largest gradient magnitudes swallow the entire budget. Area-proportional quotas are what
603// ORB does, and they keep fine detail dominant while still funding the coarse levels.
604
605// area-average downsample (dst dw x dh from src sw x sh). Averaging rather than point-sampling matters:
606// nearest-neighbour aliases high-frequency texture into different noise at every level, and the whole
607// point of a pyramid is that the SAME feature survives across levels.
608func pf_resize(src: *u8, sw: i64, sh: i64, dst: *u8, dw: i64, dh: i64) -> i64 {
609 if dw <= 0 { return 0 }
610 if dh <= 0 { return 0 }
611 var y: i64 = 0
612 while y < dh {
613 let sy0: i64 = (y * sh) / dh
614 var sy1: i64 = ((y + 1) * sh) / dh
615 if sy1 <= sy0 { sy1 = sy0 + 1 }
616 var x: i64 = 0
617 while x < dw {
618 let sx0: i64 = (x * sw) / dw
619 var sx1: i64 = ((x + 1) * sw) / dw
620 if sx1 <= sx0 { sx1 = sx0 + 1 }
621 var acc: i64 = 0
622 var cnt: i64 = 0
623 var yy: i64 = sy0
624 while yy < sy1 {
625 var xx: i64 = sx0
626 while xx < sx1 {
627 if xx < sw { if yy < sh { acc = acc + (src[yy * sw + xx] as i64); cnt = cnt + 1 } }
628 xx = xx + 1
629 }
630 yy = yy + 1
631 }
632 if cnt > 0 { dst[y * dw + x] = (acc / cnt) as u8 } else { dst[y * dw + x] = 0 as u8 }
633 x = x + 1
634 }
635 y = y + 1
636 }
637 return 0
638}
639
640// Detect + describe across the pyramid. kx/ky come back in LEVEL-0 pixel coordinates so the caller
641// never has to know a pyramid was involved; klev records the level for diagnostics. Returns the count.
642func pf_pyramid(img: *u8, w: i64, h: i64, maxn: i64, kx: *i64, ky: *i64, klev: *i64, desc: *i64, ang: *i64) -> i64 {
643 pf_init()
644 // total area over all levels, for the proportional budget
645 var tarea: i64 = 0
646 var lw: i64 = w
647 var lh: i64 = h
648 var l: i64 = 0
649 while l < pf_c_octaves {
650 if lw >= pf_c_min_side {
651 if lh >= pf_c_min_side { tarea = tarea + lw * lh }
652 }
653 lw = (lw * pf_c_scale_num) / pf_c_scale_den
654 lh = (lh * pf_c_scale_num) / pf_c_scale_den
655 l = l + 1
656 }
657 if tarea <= 0 { return 0 }
658 let cur: *u8 = sys_mmap(w * h + PF_MAGIC_4096)
659 let tmp: *i64 = sys_mmap(PF_NWORD * 8 + 64) as *i64
660 let bx: *i64 = sys_mmap(maxn * 8 + 64) as *i64
661 let by: *i64 = sys_mmap(maxn * 8 + 64) as *i64
662 let bs: *i64 = sys_mmap(maxn * 8 + 64) as *i64
663 var total: i64 = 0
664 lw = w
665 lh = h
666 l = 0
667 while l < pf_c_octaves {
668 var ok: i64 = 0
669 if lw >= pf_c_min_side { if lh >= pf_c_min_side { ok = 1 } }
670 if ok == 1 {
671 if l == 0 {
672 var c: i64 = 0
673 while c < w * h { cur[c] = img[c]; c = c + 1 }
674 } else {
675 // resample from the ORIGINAL each time: repeatedly shrinking an already-shrunk buffer
676 // compounds averaging error down the pyramid
677 pf_resize(img, w, h, cur, lw, lh)
678 }
679 var quota: i64 = (maxn * lw * lh) / tarea
680 if quota < 1 { quota = 1 }
681 if total + quota > maxn { quota = maxn - total }
682 if quota > 0 {
683 let n: i64 = pf_detect(cur, lw, lh, bx, by, bs, quota, 0)
684 var i: i64 = 0
685 while i < n {
686 if total < maxn {
687 let a: i64 = pf_orient(cur, lw, lh, bx[i], by[i])
688 pf_describe(cur, lw, lh, bx[i], by[i], a, tmp)
689 var j: i64 = 0
690 while j < PF_NWORD {
691 desc[total * PF_NWORD + j] = tmp[j]
692 j = j + 1
693 }
694 ang[total] = a
695 kx[total] = (bx[i] * w) / lw
696 ky[total] = (by[i] * h) / lh
697 klev[total] = l
698 total = total + 1
699 }
700 i = i + 1
701 }
702 }
703 }
704 lw = (lw * PF_SCALE_NUM) / PF_SCALE_DEN
705 lh = (lh * PF_SCALE_NUM) / PF_SCALE_DEN
706 l = l + 1
707 }
708 return total
709}
710
711// pixel (u,v) -> Q14 NORMALIZED camera coordinates given principal point and focal length in pixels.
712// This is the hand-off contract to the geometry back end, which works entirely in normalized coords.
713func pf_normalize(u: i64, v: i64, cx: i64, cy: i64, f: i64, out2: *i64) -> i64 {
714 if f == 0 { out2[0] = 0; out2[1] = 0; return 0 }
715 out2[0] = ((u - cx) * PF_Q) / f
716 out2[1] = ((v - cy) * PF_Q) / f
717 return 0
718}
719
720// ---- 5. VERIFY: RANSAC whose model is the ESSENTIAL MATRIX. c0/c1 are Q14 normalized correspondence
721// arrays (stride 2). inl receives 1/0 per correspondence. outE receives the E re-fit on all inliers.
722// Returns the inlier count. Deterministic: the sample indices come from the fixed-seed LCG, so the same
723// input always yields the same E -- no random seed to explain away a bad run. ----
724func pf_ransac_e(c0: *i64, c1: *i64, n: i64, tol: i64, inl: *i64, outE: *i64) -> i64 {
725 pf_init()
726 if n < PF_RANSAC_SAMPLE { return 0 }
727 let s0: *i64 = sys_mmap(PF_RANSAC_SAMPLE * 2 * 8 + 64) as *i64
728 let s1: *i64 = sys_mmap(PF_RANSAC_SAMPLE * 2 * 8 + 64) as *i64
729 let idx: *i64 = sys_mmap(PF_RANSAC_SAMPLE * 8 + 64) as *i64
730 let etmp: *i64 = sys_mmap(9 * 8 + 64) as *i64
731 let ebest: *i64 = sys_mmap(9 * 8 + 64) as *i64
732 var bestcnt: i64 = 0
733 var st: i64 = PF_LCG_SEED
734 var it: i64 = 0
735 while it < pf_c_ransac_iters {
736 // draw PF_RANSAC_SAMPLE distinct indices
737 var picked: i64 = 0
738 var guard: i64 = 0
739 while picked < PF_RANSAC_SAMPLE {
740 if guard > PF_MAGIC_4096 {
741 idx[picked] = picked % n
742 picked = picked + 1
743 } else {
744 st = (st * PF_LCG_MUL + PF_LCG_ADD) & PF_LCG_MASK
745 let cand: i64 = st % n
746 var dup: i64 = 0
747 var q: i64 = 0
748 while q < picked {
749 if idx[q] == cand { dup = 1 }
750 q = q + 1
751 }
752 if dup == 0 {
753 idx[picked] = cand
754 picked = picked + 1
755 }
756 guard = guard + 1
757 }
758 }
759 var k: i64 = 0
760 while k < PF_RANSAC_SAMPLE {
761 let ix: i64 = idx[k]
762 s0[k * 2] = c0[ix * 2]
763 s0[k * 2 + 1] = c0[ix * 2 + 1]
764 s1[k * 2] = c1[ix * 2]
765 s1[k * 2 + 1] = c1[ix * 2 + 1]
766 k = k + 1
767 }
768 pf_estimate_e(s0, s1, PF_RANSAC_SAMPLE, etmp)
769 var cnt: i64 = 0
770 var i: i64 = 0
771 while i < n {
772 let r: i64 = e3_residual(etmp, c0[i * 2], c0[i * 2 + 1], c1[i * 2], c1[i * 2 + 1])
773 if r <= tol { cnt = cnt + 1 }
774 i = i + 1
775 }
776 if cnt > bestcnt {
777 bestcnt = cnt
778 var m: i64 = 0
779 while m < 9 { ebest[m] = etmp[m]; m = m + 1 }
780 }
781 it = it + 1
782 }
783 if bestcnt == 0 { return 0 }
784 // mark inliers under the winning model
785 var i2: i64 = 0
786 while i2 < n {
787 let r2: i64 = e3_residual(ebest, c0[i2 * 2], c0[i2 * 2 + 1], c1[i2 * 2], c1[i2 * 2 + 1])
788 if r2 <= tol { inl[i2] = 1 } else { inl[i2] = 0 }
789 i2 = i2 + 1
790 }
791 // RE-FIT on every inlier: the minimal-sample E is only a hypothesis; the reported model should use
792 // all the evidence that supports it (this is the step that turns RANSAC into an estimator).
793 var nin: i64 = 0
794 var c: i64 = 0
795 while c < n { if inl[c] == 1 { nin = nin + 1 } c = c + 1 }
796 if nin >= PF_RANSAC_SAMPLE {
797 let f0: *i64 = sys_mmap(nin * 2 * 8 + 64) as *i64
798 let f1: *i64 = sys_mmap(nin * 2 * 8 + 64) as *i64
799 var w2: i64 = 0
800 var i3: i64 = 0
801 while i3 < n {
802 if inl[i3] == 1 {
803 f0[w2 * 2] = c0[i3 * 2]
804 f0[w2 * 2 + 1] = c0[i3 * 2 + 1]
805 f1[w2 * 2] = c1[i3 * 2]
806 f1[w2 * 2 + 1] = c1[i3 * 2 + 1]
807 w2 = w2 + 1
808 }
809 i3 = i3 + 1
810 }
811 pf_estimate_e(f0, f1, nin, outE)
812 } else {
813 var m2: i64 = 0
814 while m2 < 9 { outE[m2] = ebest[m2]; m2 = m2 + 1 }
815 }
816 return bestcnt
817}