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 = 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 #[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 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); let line = Line::from_points_cw([5.0, 0.0], [5.0, 10.0]);
144 let data = renderer.render_edge(&line);
145
146 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 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 assert!((data[0] - expected_val).abs() < 2e-5);
170
171 assert!((data[1] - 127.5).abs() < 1e-7);
174
175 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 for x_gt in [50.0, 50.25, 50.5, 50.75] {
191 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 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 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 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 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 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 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 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 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 let p1_dec = Point { x: 50.0, y: 0.0 };
340 let p2_dec = Point { x: 50.0, y: 100.0 };
341
342 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 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 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 #[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) .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) .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 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}