1#[derive(Clone, Copy, Debug)]
4pub struct Line {
5 pub a: f64,
7 pub b: f64,
9 pub c: f64,
11}
12
13impl Line {
14 #[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 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 #[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#[derive(Clone, Copy, Debug)]
39pub struct SubpixelEdgeRenderer {
40 pub width: usize,
42 pub height: usize,
44 pub dark_intensity: f64,
46 pub light_intensity: f64,
48 pub sigma: f64,
50}
51
52impl SubpixelEdgeRenderer {
53 #[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 #[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 #[must_use]
75 pub fn with_sigma(mut self, sigma: f64) -> Self {
76 self.sigma = sigma;
77 self
78 }
79
80 #[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 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 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 #[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 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); let line = Line::from_points_cw([5.0, 0.0], [5.0, 10.0]);
146 let data = renderer.render_edge(&line);
147
148 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 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 assert!((data[0] - expected_val).abs() < 2e-5);
172
173 assert!((data[1] - 127.5).abs() < 1e-7);
176
177 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 for x_gt in [50.0, 50.25, 50.5, 50.75] {
193 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 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 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 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 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 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 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 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 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 let p1_dec = Point { x: 50.0, y: 0.0 };
342 let p2_dec = Point { x: 50.0, y: 100.0 };
343
344 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 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 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 #[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) .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) .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 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}