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