nx_lensdist.nx source
↩ module page · 196 lines · 8827 B
1// nx_lensdist.nx -- Models and corrects radial lens distortion in images using fixed-point arithmetic.
2// nx_lensdist.nx -- RADIAL LENS DISTORTION: the model, the inverse, and the image correction.
3//
4// WHY: the cadtwin census listed four real-camera defects as untested -- sensor noise, lighting, unknown
5// intrinsics, and LENS DISTORTION. The first three have since been priced and fixed (nx_photoreal_gate,
6// nx_selfcalib/nx_multical). Distortion is the last one on that list, and it is the one EVERY real lens has:
7// a straight edge photographs as a curve, and a pipeline that assumes a pinhole camera silently measures the
8// curve. Nothing downstream can recover from it, exactly like a wrong focal.
9//
10// Model: Brown-Conrady radial terms evaluated at a NORMALISED radius rn = r / rref, so the coefficients are
11// dimensionless and the same k1/k2 describe the lens at any image size.
12// factor = 1 + k1*rn^2 + k2*rn^4 (all in Q14 fixed point)
13// distorted = ideal * factor
14// âš rref MUST be at least the largest radius in the image (the half-diagonal). The polynomial is only sane for
15// rn <= 1; feed it rn = 5 and the factor goes NEGATIVE and the "lens" folds the image through itself.
16// license_tier: ORIGINAL
17import "nx_planesweep.nx"
18import "nx_vecmath.nx"
19
20const LD_Q: i64 = 16384 // Q14, matching R3_Q so the two mix without rescaling
21const LD_ITERS: i64 = 12 // fixed-point iterations for the inverse
22const LD_SUB: i64 = 256 // sub-pixel resolution carried THROUGH the inverse iteration
23
24// the radial scale factor at normalised radius rn (Q14), returned in Q14
25func ld_factor(rn: i64, k1: i64, k2: i64) -> i64 {
26 let rn2: i64 = (rn * rn) / LD_Q
27 let rn4: i64 = (rn2 * rn2) / LD_Q
28 return LD_Q + (k1 * rn2) / LD_Q + (k2 * rn4) / LD_Q
29}
30// integer square root, for the radius
31func ld_isqrt(v: i64) -> i64 { return vm_isqrt(v) }
32// IDEAL pinhole image coords -> what the lens actually records
33func ld_distort(u: i64, v: i64, k1: i64, k2: i64, rref: i64, out2: *i64) -> i64 {
34 if rref <= 0 { return 0 }
35 let r: i64 = ld_isqrt(u * u + v * v)
36 let rn: i64 = (r * LD_Q) / rref
37 let fac: i64 = ld_factor(rn, k1, k2)
38 out2[0] = (u * fac) / LD_Q
39 out2[1] = (v * fac) / LD_Q
40 return fac
41}
42// what the lens recorded -> IDEAL pinhole coords. No closed form exists for the radial polynomial, so this
43// is the standard fixed-point iteration u <- ud / factor(|u|), started at the distorted point.
44// ★It converges because barrel distortion is a CONTRACTION near the centre; it is NOT valid for arbitrarily
45// large k1, so the round-trip is asserted in the gate rather than assumed.
46// âš THE ITERATION MUST CARRY SUB-PIXEL STATE. Iterating in whole pixels re-quantises the estimate on EVERY
47// pass, and because each pass divides by a factor < 1 the truncation is amplified rather than averaged out --
48// measured at 4 px of round-trip error against a 1 px quantisation floor. Carrying u,v in LD_SUB-ths of a
49// pixel and rounding only at the very end costs nothing and removes it.
50func ld_undistort(ud: i64, vd: i64, k1: i64, k2: i64, rref: i64, out2: *i64) -> i64 {
51 if rref <= 0 { return 0 }
52 let udf: i64 = ud * LD_SUB
53 let vdf: i64 = vd * LD_SUB
54 var u: i64 = udf
55 var v: i64 = vdf
56 var it: i64 = 0
57 while it < LD_ITERS {
58 let r: i64 = ld_isqrt(u * u + v * v)
59 let rn: i64 = (r * LD_Q) / (rref * LD_SUB)
60 let fac: i64 = ld_factor(rn, k1, k2)
61 if fac == 0 { return 0 }
62 u = (udf * LD_Q) / fac
63 v = (vdf * LD_Q) / fac
64 it = it + 1
65 }
66 out2[0] = ld_round(u)
67 out2[1] = ld_round(v)
68 return 1
69}
70// round a LD_SUB fixed-point value to the nearest whole pixel, away from zero on a tie
71func ld_round(fx: i64) -> i64 {
72 if fx >= 0 { return (fx + LD_SUB / 2) / LD_SUB }
73 return (fx - LD_SUB / 2) / LD_SUB
74}
75// render the textured plane AS A DISTORTED CAMERA RECORDS IT. For each recorded pixel, recover the ideal
76// image coords it corresponds to, and cast the ray through those -- which is exactly what a real lens does.
77func ld_render(cx: i64, cy: i64, cz: i64, basis: *i64, f: i64, zp: i64, w: i64, h: i64, k1: i64, k2: i64, rref: i64, img: *i64) -> i64 {
78 let d3: *i64 = sys_mmap(32) as *i64
79 let id2: *i64 = sys_mmap(32) as *i64
80 var py: i64 = 0
81 while py < h {
82 var px: i64 = 0
83 while px < w {
84 let ud: i64 = px - w / 2
85 let vd: i64 = py - h / 2
86 let ok: i64 = ld_undistort(ud, vd, k1, k2, rref, id2)
87 var val: i64 = 0
88 if ok == 1 {
89 r3_ray(basis, f, id2[0], id2[1], d3)
90 if d3[2] != 0 {
91 let s: i64 = ((zp - cz) * R3_Q) / d3[2]
92 if s > 0 {
93 let wx: i64 = cx + (d3[0] * s) / R3_Q
94 let wy: i64 = cy + (d3[1] * s) / R3_Q
95 val = ps_tex(wx, wy)
96 }
97 }
98 }
99 img[py * w + px] = val
100 px = px + 1
101 }
102 py = py + 1
103 }
104 return 0
105}
106// forward distortion at SUB-PIXEL resolution: same model as ld_distort, but the answer is returned in
107// LD_SUB-ths of a pixel so a resampler can interpolate instead of snapping to the nearest source pixel.
108func ld_distort_fx(u: i64, v: i64, k1: i64, k2: i64, rref: i64, out2: *i64) -> i64 {
109 if rref <= 0 { return 0 }
110 let r: i64 = ld_isqrt(u * u + v * v)
111 let rn: i64 = (r * LD_Q) / rref
112 let fac: i64 = ld_factor(rn, k1, k2)
113 out2[0] = (u * fac * LD_SUB) / LD_Q
114 out2[1] = (v * fac * LD_SUB) / LD_Q
115 return fac
116}
117// floor division -- âš integer division TRUNCATES TOWARD ZERO, so a source coordinate left of centre would
118// round the wrong way and the interpolation weights would come out mirrored on one half of the image.
119func ld_floordiv(a: i64, b: i64) -> i64 {
120 var q: i64 = a / b
121 if a % b != 0 {
122 if a < 0 { q = q - 1 }
123 }
124 return q
125}
126// bilinear sample at LD_SUB fixed-point coordinates. -1 if any of the four taps falls outside.
127func ld_bilinear(src: *i64, w: i64, h: i64, xfx: i64, yfx: i64) -> i64 {
128 let x0: i64 = ld_floordiv(xfx, LD_SUB)
129 let y0: i64 = ld_floordiv(yfx, LD_SUB)
130 let fx: i64 = xfx - x0 * LD_SUB
131 let fy: i64 = yfx - y0 * LD_SUB
132 let v00: i64 = ps_at(src, w, h, x0, y0)
133 let v10: i64 = ps_at(src, w, h, x0 + 1, y0)
134 let v01: i64 = ps_at(src, w, h, x0, y0 + 1)
135 let v11: i64 = ps_at(src, w, h, x0 + 1, y0 + 1)
136 if v00 < 0 { return 0 - 1 }
137 if v10 < 0 { return 0 - 1 }
138 if v01 < 0 { return 0 - 1 }
139 if v11 < 0 { return 0 - 1 }
140 let top: i64 = v00 * (LD_SUB - fx) + v10 * fx
141 let bot: i64 = v01 * (LD_SUB - fx) + v11 * fx
142 return (top * (LD_SUB - fy) + bot * fy) / (LD_SUB * LD_SUB)
143}
144// THE CORRECTION, BILINEAR. Identical geometry to ld_correct -- only the sampling differs -- so any change in
145// downstream error is attributable to RESAMPLING ALONE and to nothing else.
146func ld_correct_bl(src: *i64, dst: *i64, w: i64, h: i64, k1: i64, k2: i64, rref: i64) -> i64 {
147 let o2: *i64 = sys_mmap(32) as *i64
148 var miss: i64 = 0
149 var py: i64 = 0
150 while py < h {
151 var px: i64 = 0
152 while px < w {
153 let u: i64 = px - w / 2
154 let v: i64 = py - h / 2
155 let ig: i64 = ld_distort_fx(u, v, k1, k2, rref, o2)
156 let sx: i64 = o2[0] + (w / 2) * LD_SUB
157 let sy: i64 = o2[1] + (h / 2) * LD_SUB
158 let val: i64 = ld_bilinear(src, w, h, sx, sy)
159 if val < 0 {
160 dst[py * w + px] = 0
161 miss = miss + 1
162 }
163 if val >= 0 { dst[py * w + px] = val }
164 px = px + 1
165 }
166 py = py + 1
167 }
168 return miss
169}
170// THE CORRECTION. Resample a recorded image into an ideal pinhole one: for every output pixel, ask where the
171// lens PUT that ray (forward distort) and read the source there. Backward mapping, so the output has no holes.
172// Returns the count of pixels that fell outside the source and were written as 0.
173func ld_correct(src: *i64, dst: *i64, w: i64, h: i64, k1: i64, k2: i64, rref: i64) -> i64 {
174 let o2: *i64 = sys_mmap(32) as *i64
175 var miss: i64 = 0
176 var py: i64 = 0
177 while py < h {
178 var px: i64 = 0
179 while px < w {
180 let u: i64 = px - w / 2
181 let v: i64 = py - h / 2
182 let ig: i64 = ld_distort(u, v, k1, k2, rref, o2)
183 let sx: i64 = o2[0] + w / 2
184 let sy: i64 = o2[1] + h / 2
185 let val: i64 = ps_at(src, w, h, sx, sy)
186 if val < 0 {
187 dst[py * w + px] = 0
188 miss = miss + 1
189 }
190 if val >= 0 { dst[py * w + px] = val }
191 px = px + 1
192 }
193 py = py + 1
194 }
195 return miss
196}