nx_pnprefine.nx source
↩ module page · 302 lines · 12301 B
1// nx_pnprefine.nx -- Refines camera pose by minimizing reprojection error with a Gauss-Newton linear solve.
2// nx_pnprefine.nx -- GAUSS-NEWTON pose refinement (PnP): given 3D points and their observed 2D projections,
3// refine the camera pose by minimising REPROJECTION error with a real 6x6 linear solve.
4//
5// WHY THIS EXISTS: nx_bundleadjust refines pose by COORDINATE DESCENT over an epipolar cost and is honestly
6// documented as PLATEAUING above machine precision (cost 1270 vs a floor of 67; rotation only ~29% recovered)
7// -- "full metric convergence needs coupled Gauss-Newton/LM (Jacobians + linear solve)" was named as the next
8// rung there. This is that rung. Two structural differences from the coordinate-descent attempt:
9// (1) the cost is REPROJECTION against points held FIXED, not re-triangulated ones. Re-triangulating inside
10// the cost lets the points chase any pose, which is degenerate -- every pose scores well.
11// (2) all six degrees of freedom move TOGETHER from one normal-equation solve, so rotation/translation
12// trade-offs that a per-axis search cannot see are resolved in a single step.
13// Jacobians are NUMERIC (forward differences at a fixed probe step) -- the projection already exists and is
14// exact, so a derived analytic Jacobian would add a second thing to get wrong for no accuracy gain.
15// All integer/fixed-point, so the refinement is bit-reproducible. license_tier: ORIGINAL
16import "nx_recon3d.nx"
17
18const PNP_DT: i64 = 32 // translation probe step, coord units
19const PNP_DW: i64 = 96 // rotation probe step, Q14 radians (~0.0059 rad)
20const PNP_SQ: i64 = 1024 // fixed-point scale for the linear solve
21const PNP_MAXSTEP: i64 = 8 // clamp a single update to 8 probe steps (a runaway solve cannot bolt)
22// ★image displacement a translation probe should produce, in pixels. This is a TWO-SIDED tradeoff and it was
23// MEASURED, not derived: too small and the forward difference drowns in the integer quantisation of the
24// residuals; too large and the linearisation the Jacobian assumes stops holding. On the real as1 bracket with
25// a wide lens, 8 px gave k1 710 permille out while 4 px gave 6 permille -- so bigger is NOT safer.
26const PNP_TARGET_PX: i64 = 4
27
28// âš âš SCENE-SCALE PROBE. PNP_DT is an ABSOLUTE step in world units, chosen when every scene in this lane sat
29// ~1000-3000 units from the camera. Aim the same solver at a real part in real millimetres 48723 units away
30// and a 32-unit camera nudge moves the projection by f*32/48723 -- UNDER ONE PIXEL. The forward-difference
31// column then quantises to 0s and 1s, the normal equations go ill-conditioned, and the solve STOPS CONVERGING
32// WHILE STILL RETURNING A PLAUSIBLE-LOOKING ANSWER. Measured on the real as1 bracket: SSE 3919 -> 2901 and
33// k1 451 -> 152 permille from this one change (nx_realcal_gate).
34// ★THE FLOOR IS WHAT MAKES THIS SAFE TO APPLY EVERYWHERE: at 1000-3000 units dist/100 is 10-30, below
35// PNP_DT, so every existing synthetic scene keeps EXACTLY the old probe of 32 and every existing gate result
36// is bit-identical. Only genuinely large scenes change, and there the old behaviour was broken.
37// ★★THE RULE IS EXPRESSED IN PIXELS, NOT IN WORLD UNITS, because pixels are what the integer residuals are
38// quantised in -- that is the quantity a numeric Jacobian actually needs. A probe moves the image by
39// f*dt/dist, so solving for a target displacement gives dt = dist*TARGET/f.
40// âš FIRST ATTEMPT AT THIS FIX USED dist/100 AND WAS WRONG -- not because it failed at real scale, but because
41// it ALSO engaged at ~3200 units where the old probe was producing a perfectly healthy 12 px of motion. It
42// silently changed four already-correct results (selfcalib far 1536/SSE 63 -> 1555/SSE 77, and three multical
43// probes). A FIX MUST BE SCOPED TO THE REGIME THAT IS ACTUALLY BROKEN; widening it further is not "more
44// safety", it is an unrequested change to working behaviour. With the pixel rule, dist 1000-3000 at f=1200
45// yields 6-20, below PNP_DT, so every existing scene keeps EXACTLY the old probe of 32.
46func pnp_probe_px(dist: i64, f: i64) -> i64 {
47 if f <= 0 { return PNP_DT }
48 var dt: i64 = (dist * PNP_TARGET_PX) / f
49 if dt < PNP_DT { dt = PNP_DT }
50 return dt
51}
52// working distance = camera centre to the centroid of the points it is looking at
53func pnp_dist_to(cam: *i64, p3: *i64, n: i64) -> i64 {
54 if n <= 0 { return 0 }
55 var sx: i64 = 0
56 var sy: i64 = 0
57 var sz: i64 = 0
58 var i: i64 = 0
59 while i < n {
60 sx = sx + p3[i * 3 + 0]
61 sy = sy + p3[i * 3 + 1]
62 sz = sz + p3[i * 3 + 2]
63 i = i + 1
64 }
65 let dx: i64 = sx / n - cam[0]
66 let dy: i64 = sy / n - cam[1]
67 let dz: i64 = sz / n - cam[2]
68 return r3_isqrt(dx * dx + dy * dy + dz * dz)
69}
70// derive the probe from the data itself, so a caller cannot forget to size it
71func pnp_probe_auto(cam: *i64, p3: *i64, n: i64, f: i64) -> i64 {
72 return pnp_probe_px(pnp_dist_to(cam, p3, n), f)
73}
74// residuals r[2k],r[2k+1] = projected - observed, for all n points. Returns the sum of squares.
75func pnp_resid(cx: i64, cy: i64, cz: i64, basis: *i64, f: i64, p3: *i64, obs: *i64, n: i64, r: *i64) -> i64 {
76 let o2: *i64 = sys_mmap(32) as *i64
77 var k: i64 = 0
78 var sse: i64 = 0
79 while k < n {
80 let zc: i64 = r3_project(cx, cy, cz, basis, f, p3[k * 3 + 0], p3[k * 3 + 1], p3[k * 3 + 2], o2)
81 var du: i64 = 0
82 var dv: i64 = 0
83 if zc > 0 {
84 du = o2[0] - obs[k * 2 + 0]
85 dv = o2[1] - obs[k * 2 + 1]
86 }
87 r[k * 2 + 0] = du
88 r[k * 2 + 1] = dv
89 sse = sse + du * du + dv * dv
90 k = k + 1
91 }
92 return sse
93}
94// apply a small world-space rotation w (Q14) to the camera basis rows, then RE-ORTHONORMALISE. Without the
95// re-orthonormalisation the small-angle update slowly shears the basis and it stops being a rotation.
96func pnp_rot(basis: *i64, wx: i64, wy: i64, wz: i64) -> i64 {
97 var i: i64 = 0
98 while i < 3 {
99 let rx: i64 = basis[i * 3 + 0]
100 let ry: i64 = basis[i * 3 + 1]
101 let rz: i64 = basis[i * 3 + 2]
102 basis[i * 3 + 0] = rx + (wy * rz - wz * ry) / R3_Q
103 basis[i * 3 + 1] = ry + (wz * rx - wx * rz) / R3_Q
104 basis[i * 3 + 2] = rz + (wx * ry - wy * rx) / R3_Q
105 i = i + 1
106 }
107 let t3: *i64 = sys_mmap(32) as *i64
108 // forward := normalize(forward); right := normalize(up x forward); up := forward x right
109 r3_normalize(basis[6], basis[7], basis[8], t3)
110 basis[6] = t3[0]
111 basis[7] = t3[1]
112 basis[8] = t3[2]
113 let c3: *i64 = sys_mmap(32) as *i64
114 r3_cross(basis[3], basis[4], basis[5], basis[6], basis[7], basis[8], c3)
115 r3_normalize(c3[0], c3[1], c3[2], t3)
116 basis[0] = t3[0]
117 basis[1] = t3[1]
118 basis[2] = t3[2]
119 r3_cross(basis[6], basis[7], basis[8], basis[0], basis[1], basis[2], c3)
120 r3_normalize(c3[0], c3[1], c3[2], t3)
121 basis[3] = t3[0]
122 basis[4] = t3[1]
123 basis[5] = t3[2]
124 return 0
125}
126// 6x6 Gaussian elimination with partial pivoting; x returned in PNP_SQ fixed point. 0 = singular.
127func pnp_solve6(A: *i64, b: *i64, x: *i64) -> i64 {
128 var col: i64 = 0
129 while col < 6 {
130 var piv: i64 = col
131 var best: i64 = 0
132 var rr: i64 = col
133 while rr < 6 {
134 var a: i64 = A[rr * 6 + col]
135 if a < 0 { a = 0 - a }
136 if a > best {
137 best = a
138 piv = rr
139 }
140 rr = rr + 1
141 }
142 if best == 0 { return 0 }
143 if piv != col {
144 var c1: i64 = 0
145 while c1 < 6 {
146 let tmp: i64 = A[col * 6 + c1]
147 A[col * 6 + c1] = A[piv * 6 + c1]
148 A[piv * 6 + c1] = tmp
149 c1 = c1 + 1
150 }
151 let tb: i64 = b[col]
152 b[col] = b[piv]
153 b[piv] = tb
154 }
155 var r2: i64 = col + 1
156 while r2 < 6 {
157 let m: i64 = (A[r2 * 6 + col] * PNP_SQ) / A[col * 6 + col]
158 var c2: i64 = col
159 while c2 < 6 {
160 A[r2 * 6 + c2] = A[r2 * 6 + c2] - (m * A[col * 6 + c2]) / PNP_SQ
161 c2 = c2 + 1
162 }
163 b[r2] = b[r2] - (m * b[col]) / PNP_SQ
164 r2 = r2 + 1
165 }
166 col = col + 1
167 }
168 var i: i64 = 5
169 while i >= 0 {
170 var s: i64 = b[i] * PNP_SQ
171 var j: i64 = i + 1
172 while j < 6 {
173 s = s - A[i * 6 + j] * x[j]
174 j = j + 1
175 }
176 if A[i * 6 + i] == 0 { return 0 }
177 x[i] = s / A[i * 6 + i]
178 i = i - 1
179 }
180 return 1
181}
182// ONE Gauss-Newton step. cam[0..2] = centre, basis = 3x3. Returns the sum-of-squares BEFORE the step.
183func pnp_step(cam: *i64, basis: *i64, f: i64, p3: *i64, obs: *i64, n: i64, w: *i64) -> i64 {
184 let nr: i64 = n * 2
185 let r0: *i64 = (w[0]) as *i64
186 let rp: *i64 = (w[1]) as *i64
187 let J: *i64 = (w[2]) as *i64
188 let A: *i64 = (w[3]) as *i64
189 let b: *i64 = (w[4]) as *i64
190 let x: *i64 = (w[5]) as *i64
191 let bb: *i64 = (w[6]) as *i64
192 let dt: i64 = pnp_probe_auto(cam, p3, n, f)
193 let sse: i64 = pnp_resid(cam[0], cam[1], cam[2], basis, f, p3, obs, n, r0)
194 var j: i64 = 0
195 while j < 6 {
196 var i2: i64 = 0
197 while i2 < 9 {
198 bb[i2] = basis[i2]
199 i2 = i2 + 1
200 }
201 var ax: i64 = cam[0]
202 var ay: i64 = cam[1]
203 var az: i64 = cam[2]
204 if j == 0 { ax = ax + dt }
205 if j == 1 { ay = ay + dt }
206 if j == 2 { az = az + dt }
207 if j == 3 { pnp_rot(bb, PNP_DW, 0, 0) }
208 if j == 4 { pnp_rot(bb, 0, PNP_DW, 0) }
209 if j == 5 { pnp_rot(bb, 0, 0, PNP_DW) }
210 let ig: i64 = pnp_resid(ax, ay, az, bb, f, p3, obs, n, rp)
211 var k: i64 = 0
212 while k < nr {
213 J[j * nr + k] = rp[k] - r0[k]
214 k = k + 1
215 }
216 j = j + 1
217 }
218 // normal equations: A = J^T J, b = -J^T r0
219 var a1: i64 = 0
220 while a1 < 6 {
221 var a2: i64 = 0
222 while a2 < 6 {
223 var s: i64 = 0
224 var k2: i64 = 0
225 while k2 < nr {
226 s = s + J[a1 * nr + k2] * J[a2 * nr + k2]
227 k2 = k2 + 1
228 }
229 A[a1 * 6 + a2] = s
230 a2 = a2 + 1
231 }
232 var sb: i64 = 0
233 var k3: i64 = 0
234 while k3 < nr {
235 sb = sb + J[a1 * nr + k3] * r0[k3]
236 k3 = k3 + 1
237 }
238 b[a1] = 0 - sb
239 a1 = a1 + 1
240 }
241 if pnp_solve6(A, b, x) == 0 { return sse }
242 // clamp, then apply: translation directly, rotation through the small-angle update
243 var q: i64 = 0
244 while q < 6 {
245 let lim: i64 = PNP_MAXSTEP * PNP_SQ
246 if x[q] > lim { x[q] = lim }
247 if x[q] < 0 - lim { x[q] = 0 - lim }
248 q = q + 1
249 }
250 cam[0] = cam[0] + (x[0] * dt) / PNP_SQ
251 cam[1] = cam[1] + (x[1] * dt) / PNP_SQ
252 cam[2] = cam[2] + (x[2] * dt) / PNP_SQ
253 pnp_rot(basis, (x[3] * PNP_DW) / PNP_SQ, (x[4] * PNP_DW) / PNP_SQ, (x[5] * PNP_DW) / PNP_SQ)
254 return sse
255}
256// iterate; keeps the BEST pose seen so the refinement can never end worse than it started
257func pnp_refine(cam: *i64, basis: *i64, f: i64, p3: *i64, obs: *i64, n: i64, iters: i64, w: *i64) -> i64 {
258 let rtmp: *i64 = (w[0]) as *i64
259 var best: i64 = pnp_resid(cam[0], cam[1], cam[2], basis, f, p3, obs, n, rtmp)
260 let bcam: *i64 = (w[7]) as *i64
261 let bbas: *i64 = (w[8]) as *i64
262 var i: i64 = 0
263 while i < 3 {
264 bcam[i] = cam[i]
265 i = i + 1
266 }
267 i = 0
268 while i < 9 {
269 bbas[i] = basis[i]
270 i = i + 1
271 }
272 var it: i64 = 0
273 while it < iters {
274 let ig: i64 = pnp_step(cam, basis, f, p3, obs, n, w)
275 let cur: i64 = pnp_resid(cam[0], cam[1], cam[2], basis, f, p3, obs, n, rtmp)
276 if cur < best {
277 best = cur
278 i = 0
279 while i < 3 {
280 bcam[i] = cam[i]
281 i = i + 1
282 }
283 i = 0
284 while i < 9 {
285 bbas[i] = basis[i]
286 i = i + 1
287 }
288 }
289 it = it + 1
290 }
291 i = 0
292 while i < 3 {
293 cam[i] = bcam[i]
294 i = i + 1
295 }
296 i = 0
297 while i < 9 {
298 basis[i] = bbas[i]
299 i = i + 1
300 }
301 return best
302}