Skip to main content

locus_core/test_utils/
subpixel.rs

1/// A simple line model for synthetic edge generation.
2/// Defined by a*x + b*y + c = 0, where (a,b) is the unit normal.
3#[derive(Clone, Copy, Debug)]
4pub struct Line {
5    /// Normal component X
6    pub a: f64,
7    /// Normal component Y
8    pub b: f64,
9    /// Distance constant
10    pub c: f64,
11}
12
13impl Line {
14    /// Create a line from two points, assuming CW winding.
15    /// The normal (a, b) will point "outward" (to the right of the direction p1->p2).
16    #[must_use]
17    pub fn from_points_cw(p1: [f64; 2], p2: [f64; 2]) -> Self {
18        let dx = p2[0] - p1[0];
19        let dy = p2[1] - p1[1];
20        let len = (dx * dx + dy * dy).sqrt();
21
22        // CW Outward normal: (dy/len, -dx/len)
23        let nx = dy / len;
24        let ny = -dx / len;
25        let c = -(nx * p1[0] + ny * p1[1]);
26
27        Self { a: nx, b: ny, c }
28    }
29
30    /// Calculate signed distance from a point to the line.
31    #[must_use]
32    pub fn signed_distance(&self, p: [f64; 2]) -> f64 {
33        self.a * p[0] + self.b * p[1] + self.c
34    }
35}
36
37/// Utility for rendering sub-pixel accurate synthetic edges.
38#[derive(Clone, Copy, Debug)]
39pub struct SubpixelEdgeRenderer {
40    /// Image width
41    pub width: usize,
42    /// Image height
43    pub height: usize,
44    /// Dark side intensity
45    pub dark_intensity: f64,
46    /// Light side intensity
47    pub light_intensity: f64,
48    /// Blur sigma
49    pub sigma: f64,
50}
51
52impl SubpixelEdgeRenderer {
53    /// Create a new renderer with default parameters.
54    #[must_use]
55    pub fn new(width: usize, height: usize) -> Self {
56        Self {
57            width,
58            height,
59            dark_intensity: 50.0,
60            light_intensity: 200.0,
61            sigma: 0.8,
62        }
63    }
64
65    /// Set intensities A and B.
66    #[must_use]
67    pub fn with_intensities(mut self, dark: f64, light: f64) -> Self {
68        self.dark_intensity = dark;
69        self.light_intensity = light;
70        self
71    }
72
73    /// Set the blur sigma.
74    #[must_use]
75    pub fn with_sigma(mut self, sigma: f64) -> Self {
76        self.sigma = sigma;
77        self
78    }
79
80    /// Render a floating-point image of a single edge.
81    /// Strictly evaluates the ERF model at pixel centers (x+0.5, y+0.5).
82    #[must_use]
83    pub fn render_edge(&self, line: &Line) -> Vec<f64> {
84        let mut data = vec![0.0; self.width * self.height];
85        let a = self.dark_intensity;
86        let b = self.light_intensity;
87
88        // I(d) = (A+B)/2 + (B-A)/2 * erf(d / sigma)
89        let s = self.sigma;
90
91        for y in 0..self.height {
92            let row_off = y * self.width;
93            for x in 0..self.width {
94                // Foundation Principle 1: Pixel center is at (x+0.5, y+0.5)
95                let px = x as f64 + 0.5;
96                let py = y as f64 + 0.5;
97
98                let d = line.signed_distance([px, py]);
99
100                let val =
101                    f64::midpoint(a, b) + (b - a) / 2.0 * crate::simd::math::erf_approx(d / s);
102                data[row_off + x] = val;
103            }
104        }
105        data
106    }
107
108    /// Render a u8 image (clamped).
109    #[must_use]
110    #[allow(clippy::cast_sign_loss)]
111    pub fn render_edge_u8(&self, line: &Line) -> Vec<u8> {
112        self.render_edge(line)
113            .into_iter()
114            .map(|v| v.clamp(0.0, 255.0) as u8)
115            .collect()
116    }
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122    use crate::Point;
123    use crate::image::ImageView;
124    use crate::quad::refine_edge_intensity;
125    use bumpalo::Bump;
126
127    #[test]
128    fn test_line_distance() {
129        // Vertical line at x=10, CW points (10,0) to (10,100)
130        // Outward normal points to x > 10.
131        let line = Line::from_points_cw([10.0, 0.0], [10.0, 100.0]);
132
133        assert!(line.signed_distance([11.0, 50.0]) > 0.0);
134        assert!(line.signed_distance([9.0, 50.0]) < 0.0);
135        assert!((line.signed_distance([10.0, 50.0])).abs() < 1e-12);
136    }
137
138    #[test]
139    fn test_renderer_center_rule() {
140        let renderer = SubpixelEdgeRenderer::new(10, 10)
141            .with_intensities(0.0, 200.0)
142            .with_sigma(0.1); // Sharp edge
143
144        // Vertical edge at exactly x=5.0
145        let line = Line::from_points_cw([5.0, 0.0], [5.0, 10.0]);
146        let data = renderer.render_edge(&line);
147
148        // Pixel x=4 center is 4.5 -> d = -0.5 -> Dark
149        // Pixel x=5 center is 5.5 -> d = 0.5 -> Light
150        assert!(data[4] < 50.0);
151        assert!(data[5] > 150.0);
152    }
153
154    #[test]
155    fn test_renderer_meta_hand_calculated() {
156        let sigma = 0.8;
157        let renderer = SubpixelEdgeRenderer::new(3, 1)
158            .with_intensities(0.0, 255.0)
159            .with_sigma(sigma);
160
161        // Vertical edge at x=1.5
162        let line = Line::from_points_cw([1.5, 0.0], [1.5, 1.0]);
163        let data = renderer.render_edge(&line);
164
165        let d = -1.0;
166        let expected_erf = libm::erf(d / sigma);
167        let expected_val = 127.5 + 127.5 * expected_erf;
168
169        // x=0 center is 0.5, d = -1.0
170        // Error of erf_approx is 1.5e-7. Scaled by 127.5, max error is ~1.9e-5.
171        assert!((data[0] - expected_val).abs() < 2e-5);
172
173        // x=1 center is 1.5, d = 0.0
174        // I = 127.5
175        assert!((data[1] - 127.5).abs() < 1e-7);
176
177        // x=2 center is 2.5, d = 1.0
178        let expected_val_high = 127.5 + 127.5 * libm::erf(1.0 / sigma);
179        assert!((data[2] - expected_val_high).abs() < 2e-5);
180    }
181
182    #[test]
183    fn test_edge_recovery_axis_aligned() {
184        let width = 100;
185        let height = 100;
186        let sigma = 0.6;
187        let renderer = SubpixelEdgeRenderer::new(width, height)
188            .with_intensities(0.0, 255.0)
189            .with_sigma(sigma);
190
191        // Sweeps: X = 50.0, 50.25, 50.5, 50.75
192        for x_gt in [50.0, 50.25, 50.5, 50.75] {
193            // Vertical edge at x_gt, CW (x_gt, 10) to (x_gt, 90)
194            // Outward normal points to light side B (x > x_gt)
195            let line_gt = Line::from_points_cw([x_gt, 10.0], [x_gt, 90.0]);
196            let data = renderer.render_edge_u8(&line_gt);
197            let img = ImageView::new(&data, width, height, width).expect("invalid image view");
198            let arena = Bump::new();
199
200            // Initial guess: exactly at integer x=50.0
201            let p1 = Point { x: 50.0, y: 10.0 };
202            let p2 = Point { x: 50.0, y: 90.0 };
203
204            let result = refine_edge_intensity(&arena, &img, p1, p2, sigma, 1);
205            assert!(
206                result.is_some(),
207                "refine_edge_intensity failed for x_gt={x_gt}"
208            );
209
210            if let Some((nx, _ny, d)) = result {
211                // Recovered edge: nx*x + ny*y + d = 0
212                // For vertical edge, nx should be 1.0
213                assert!((nx - 1.0).abs() < 1e-7);
214                let x_recovered = -d;
215                let error = (x_recovered - x_gt).abs();
216                println!("x_gt={x_gt}, recovered={x_recovered}, error={error}");
217
218                // Axis-aligned edges have higher quantization noise
219                assert!(error < 0.02, "Error {error} too high for x_gt={x_gt}");
220            }
221        }
222    }
223
224    #[test]
225    fn test_edge_recovery_arbitrary_angle() {
226        let width = 120;
227        let height = 120;
228        let sigma = 0.6;
229        let renderer = SubpixelEdgeRenderer::new(width, height)
230            .with_intensities(20.0, 240.0)
231            .with_sigma(sigma);
232
233        // Test angles from 0 to 45 degrees
234        for &angle_deg in &[5.0, 15.0, 30.0, 45.0] {
235            let angle = f64::to_radians(angle_deg);
236            let cos_a = angle.cos();
237            let sin_a = angle.sin();
238
239            // Line passing through (60, 60) with angle
240            // p1 = center - 40*dir, p2 = center + 40*dir
241            let p1_x = 60.0 - 40.0 * sin_a;
242            let p1_y = 60.0 + 40.0 * cos_a;
243            let p2_x = 60.0 + 40.0 * sin_a;
244            let p2_y = 60.0 - 40.0 * cos_a;
245
246            let line_gt = Line::from_points_cw([p1_x, p1_y], [p2_x, p2_y]);
247            let data = renderer.render_edge_u8(&line_gt);
248            let img = ImageView::new(&data, width, height, width).expect("invalid image view");
249            let arena = Bump::new();
250
251            let p1 = Point { x: p1_x, y: p1_y };
252            let p2 = Point { x: p2_x, y: p2_y };
253
254            let result = refine_edge_intensity(&arena, &img, p1, p2, sigma, 1);
255            assert!(
256                result.is_some(),
257                "refine_edge_intensity failed for angle {angle_deg}"
258            );
259
260            if let Some((nx, ny, d)) = result {
261                // Ground truth line is nx_gt*x + ny_gt*y + d_gt = 0
262                // Our recovered line parameters are (nx, ny, d)
263                let error_n = (nx - line_gt.a).abs() + (ny - line_gt.b).abs();
264                let error_d = (d - line_gt.c).abs();
265
266                println!("Angle {angle_deg}deg: error_n={error_n:.6}, error_d={error_d:.6}");
267
268                // Angle recovery is very accurate
269                assert!(error_n < 0.001);
270                assert!(error_d < 0.05);
271            }
272        }
273    }
274
275    #[test]
276    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
277    fn test_edge_recovery_scale_invariance() {
278        let sigma = 0.6;
279        let mut renderer = SubpixelEdgeRenderer::new(1, 1).with_sigma(sigma);
280
281        for len in [10.0, 30.0, 80.0, 150.0] {
282            let width = (len + 20.0) as usize;
283            let height = (len + 20.0) as usize;
284            let center = f64::midpoint(len, 20.0);
285
286            let p1_x = center;
287            let p1_y = center - len / 2.0;
288            let p2_x = center;
289            let p2_y = center + len / 2.0;
290
291            let line_gt = Line::from_points_cw([p1_x, p1_y], [p2_x, p2_y]);
292            renderer.width = width;
293            renderer.height = height;
294            let data = renderer
295                .with_intensities(50.0, 200.0)
296                .render_edge_u8(&line_gt);
297            let img = ImageView::new(&data, width, height, width).expect("invalid image view");
298            let arena = Bump::new();
299
300            let p1 = Point { x: p1_x, y: p1_y };
301            let p2 = Point { x: p2_x, y: p2_y };
302
303            let result = refine_edge_intensity(&arena, &img, p1, p2, sigma, 1);
304            assert!(
305                result.is_some(),
306                "refine_edge_intensity failed for length {len}"
307            );
308
309            if let Some((_nx, _ny, d)) = result {
310                let x_recovered = -d;
311                let error = (x_recovered - p1_x).abs();
312                println!("Length {len}: error={error:.6}");
313                assert!(error < 0.01);
314            }
315        }
316    }
317
318    #[test]
319    fn test_edge_recovery_decimation_mapping() {
320        let canvas_size = 200;
321        let sigma = 0.8;
322        let x_gt = 100.4;
323
324        let renderer = SubpixelEdgeRenderer::new(canvas_size, canvas_size).with_sigma(sigma);
325        let line_gt = Line::from_points_cw([x_gt, 0.0], [x_gt, 200.0]);
326        let data_full = renderer.render_edge_u8(&line_gt);
327        let img_full = ImageView::new(&data_full, canvas_size, canvas_size, canvas_size)
328            .expect("invalid image view");
329
330        // Test with decimation=2
331        let decimation = 2;
332        let mut data_dec = vec![0u8; (canvas_size / decimation) * (canvas_size / decimation)];
333        let img_dec = img_full
334            .decimate_to(decimation, &mut data_dec)
335            .expect("decimation failed");
336
337        let arena = Bump::new();
338
339        // Initial guess in decimated coordinates: roughly at x=50
340        // (maps to 100.0 in full-res)
341        let p1_dec = Point { x: 50.0, y: 0.0 };
342        let p2_dec = Point { x: 50.0, y: 100.0 };
343
344        // Refine on the decimated image
345        let result = refine_edge_intensity(&arena, &img_dec, p1_dec, p2_dec, sigma, decimation);
346        assert!(
347            result.is_some(),
348            "refine_edge_intensity failed with decimation upscaling"
349        );
350
351        if let Some((_nx, _ny, d)) = result {
352            // Mapping back to full res should be x_full = (x_dec - 0.5) * (decimation as f64) + 0.5
353            // because SubpixelEdgeRenderer::render_edge uses subsampling (pick top-left).
354            let x_dec_recovered = -d;
355            let x_full_recovered = (x_dec_recovered - 0.5) * (decimation as f64) + 0.5;
356
357            let error = (x_full_recovered - x_gt).abs();
358            println!("Decimated (d=2) recovered: {x_full_recovered:.4}, error: {error:.4}");
359
360            // Decimation reduces precision but mapping should be correct within ~0.1px
361            assert!(error < 0.1, "Mapping error {error} too high");
362        }
363    }
364
365    #[test]
366    fn test_edge_recovery_robustness_noise() {
367        use rand::prelude::*;
368
369        let width = 60;
370        let height = 60;
371        let sigma = 0.6;
372        let x_gt = 30.25;
373        let mut rng = rand::rng();
374
375        let renderer = SubpixelEdgeRenderer::new(width, height)
376            .with_intensities(50.0, 200.0)
377            .with_sigma(sigma);
378        let line_gt = Line::from_points_cw([x_gt, 0.0], [x_gt, 60.0]);
379        let mut data = renderer.render_edge_u8(&line_gt);
380
381        // Add Gaussian-like noise
382        #[allow(clippy::cast_sign_loss)]
383        for p in &mut data {
384            let noise: i16 = rng.random_range(-10..11);
385            *p = (i16::from(*p) + noise).clamp(0, 255) as u8;
386        }
387
388        let img = ImageView::new(&data, width, height, width).expect("invalid image view");
389        let arena = Bump::new();
390        let p1 = Point { x: 30.0, y: 0.0 };
391        let p2 = Point { x: 30.0, y: 60.0 };
392
393        if let Some((_nx, _ny, d)) = refine_edge_intensity(&arena, &img, p1, p2, sigma, 1) {
394            let error = (-d - x_gt).abs();
395            println!("Noisy recovery error: {error:.4}");
396            assert!(error < 0.05);
397        }
398    }
399
400    #[test]
401    fn test_edge_recovery_robustness_low_contrast() {
402        let width = 60;
403        let height = 60;
404        let sigma = 0.6;
405        let x_gt = 30.25;
406
407        let renderer = SubpixelEdgeRenderer::new(width, height)
408            .with_intensities(100.0, 130.0) // Only 30 levels of contrast
409            .with_sigma(sigma);
410        let line_gt = Line::from_points_cw([x_gt, 0.0], [x_gt, 60.0]);
411        let data = renderer.render_edge_u8(&line_gt);
412
413        let img = ImageView::new(&data, width, height, width).expect("invalid image view");
414        let arena = Bump::new();
415        let p1 = Point { x: 30.0, y: 0.0 };
416        let p2 = Point { x: 30.0, y: 60.0 };
417
418        if let Some((_nx, _ny, d)) = refine_edge_intensity(&arena, &img, p1, p2, sigma, 1) {
419            let error = (-d - x_gt).abs();
420            println!("Low contrast recovery error: {error:.4}");
421            assert!(error < 0.05);
422        }
423    }
424
425    #[test]
426    fn test_edge_recovery_robustness_clipping() {
427        let width = 60;
428        let height = 60;
429        let sigma = 0.6;
430        let x_gt = 30.25;
431
432        let renderer = SubpixelEdgeRenderer::new(width, height)
433            .with_intensities(-50.0, 300.0) // Intensities outside 0-255 (will clip)
434            .with_sigma(sigma);
435        let line_gt = Line::from_points_cw([x_gt, 0.0], [x_gt, 60.0]);
436        let data = renderer.render_edge_u8(&line_gt);
437
438        let img = ImageView::new(&data, width, height, width).expect("invalid image view");
439        let arena = Bump::new();
440        let p1 = Point { x: 30.0, y: 0.0 };
441        let p2 = Point { x: 30.0, y: 60.0 };
442
443        if let Some((_nx, _ny, d)) = refine_edge_intensity(&arena, &img, p1, p2, sigma, 1) {
444            let error = (-d - x_gt).abs();
445            println!("Clipped recovery error: {error:.4}");
446            assert!(error < 0.1);
447        }
448    }
449
450    #[test]
451    fn test_edge_recovery_robustness_off_edge_seed() {
452        let width = 60;
453        let height = 60;
454        let sigma = 0.6;
455        let x_gt = 30.25;
456
457        let renderer = SubpixelEdgeRenderer::new(width, height)
458            .with_intensities(50.0, 200.0)
459            .with_sigma(sigma);
460        let line_gt = Line::from_points_cw([x_gt, 0.0], [x_gt, 60.0]);
461        let data = renderer.render_edge_u8(&line_gt);
462
463        let img = ImageView::new(&data, width, height, width).expect("invalid image view");
464        let arena = Bump::new();
465
466        // Initial seed is 1.5 pixels away from the true edge (within 3*sigma window)
467        let p1 = Point { x: 31.75, y: 0.0 };
468        let p2 = Point { x: 31.75, y: 60.0 };
469
470        if let Some((_nx, _ny, d)) = refine_edge_intensity(&arena, &img, p1, p2, sigma, 1) {
471            let error = (-d - x_gt).abs();
472            println!("Off-edge seed recovery error: {error:.4}");
473            assert!(error < 0.1);
474        }
475    }
476}