1#![allow(clippy::cast_sign_loss)]
4#![allow(clippy::cast_possible_wrap)]
5#![allow(clippy::missing_panics_doc)]
6
7use crate::image::ImageView;
8use bumpalo::Bump;
9use bumpalo::collections::Vec as BumpVec;
10
11#[derive(Clone, Copy, Default)]
13#[allow(dead_code)]
14pub(crate) struct Gradient {
15 pub gx: i16,
17 pub gy: i16,
19 pub mag: u16,
21}
22
23use multiversion::multiversion;
24
25#[must_use]
28#[multiversion(targets(
29 "x86_64+avx2+bmi1+bmi2+popcnt+lzcnt",
30 "x86_64+avx512f+avx512bw+avx512dq+avx512vl",
31 "aarch64+neon"
32))]
33pub(crate) fn compute_sobel(img: &ImageView) -> Vec<Gradient> {
34 let w = img.width;
35 let h = img.height;
36 let mut grads = vec![Gradient::default(); w * h];
37
38 for y in 1..h - 1 {
43 for x in 1..w - 1 {
44 let p00 = i16::from(img.get_pixel(x - 1, y - 1));
45 let p10 = i16::from(img.get_pixel(x, y - 1));
46 let p20 = i16::from(img.get_pixel(x + 1, y - 1));
47 let p01 = i16::from(img.get_pixel(x - 1, y));
48 let p21 = i16::from(img.get_pixel(x + 1, y));
49 let p02 = i16::from(img.get_pixel(x - 1, y + 1));
50 let p12 = i16::from(img.get_pixel(x, y + 1));
51 let p22 = i16::from(img.get_pixel(x + 1, y + 1));
52
53 let gx = -p00 + p20 - 2 * p01 + 2 * p21 - p02 + p22;
54 let gy = -p00 - 2 * p10 - p20 + p02 + 2 * p12 + p22;
55
56 let mag = ((gx.abs() + gy.abs()) as u16).min(1000);
57
58 grads[y * w + x] = Gradient { gx, gy, mag };
59 }
60 }
61
62 grads
63}
64
65#[derive(Clone, Copy, Debug)]
67#[allow(dead_code)]
68pub(crate) struct LineSegment {
69 pub x0: f32,
71 pub y0: f32,
73 pub x1: f32,
75 pub y1: f32,
77 pub angle: f32,
79}
80
81#[must_use]
84#[allow(dead_code)]
85pub(crate) fn extract_line_segments(
86 grads: &[Gradient],
87 width: usize,
88 height: usize,
89 mag_thresh: u16,
90) -> Vec<LineSegment> {
91 let mut used = vec![false; width * height];
92 let mut segments = Vec::new();
93
94 for y in 2..height - 2 {
95 for x in 2..width - 2 {
96 let idx = y * width + x;
97 if used[idx] || grads[idx].mag < mag_thresh {
98 continue;
99 }
100
101 let seed_angle = f32::from(grads[idx].gy).atan2(f32::from(grads[idx].gx));
103
104 let mut points: Vec<(usize, usize)> = vec![(x, y)];
105 used[idx] = true;
106
107 let mut changed = true;
109 while changed && points.len() < 500 {
110 changed = false;
111 let (lx, ly) = *points
112 .last()
113 .expect("Points list should not be empty during refinement");
114
115 for dy in -1i32..=1 {
116 for dx in -1i32..=1 {
117 if dx == 0 && dy == 0 {
118 continue;
119 }
120 let nx = (lx as i32 + dx) as usize;
121 let ny = (ly as i32 + dy) as usize;
122 if nx >= width || ny >= height {
123 continue;
124 }
125
126 let nidx = ny * width + nx;
127 if used[nidx] || grads[nidx].mag < mag_thresh {
128 continue;
129 }
130
131 let angle = f32::from(grads[nidx].gy).atan2(f32::from(grads[nidx].gx));
132 let angle_diff = (angle - seed_angle).abs();
133 let angle_diff = angle_diff.min(std::f32::consts::PI - angle_diff);
134
135 if angle_diff < 0.3 {
136 points.push((nx, ny));
138 used[nidx] = true;
139 changed = true;
140 break;
141 }
142 }
143 if changed {
144 break;
145 }
146 }
147 }
148
149 if points.len() >= 10 {
150 let (x0, y0) = points[0];
152 let (x1, y1) = points[points.len() - 1];
153
154 segments.push(LineSegment {
155 x0: x0 as f32,
156 y0: y0 as f32,
157 x1: x1 as f32,
158 y1: y1 as f32,
159 angle: seed_angle,
160 });
161 }
162 }
163 }
164
165 segments
166}
167
168#[must_use]
170#[allow(dead_code)]
171pub(crate) fn find_first_quad_from_segments(segments: &[LineSegment]) -> Option<[[f32; 2]; 4]> {
172 if segments.len() < 4 {
173 return None;
174 }
175
176 for i in 0..segments.len() {
179 for j in i + 1..segments.len() {
180 for k in j + 1..segments.len() {
181 for l in k + 1..segments.len() {
182 if let Some(corners) =
183 try_form_quad(&segments[i], &segments[j], &segments[k], &segments[l])
184 {
185 return Some(corners);
186 }
187 }
188 }
189 }
190 }
191
192 None
193}
194
195#[allow(dead_code)]
196fn try_form_quad(
197 s0: &LineSegment,
198 s1: &LineSegment,
199 s2: &LineSegment,
200 s3: &LineSegment,
201) -> Option<[[f32; 2]; 4]> {
202 let mut segs = [*s0, *s1, *s2, *s3];
204 segs.sort_by(|a, b| a.angle.total_cmp(&b.angle));
205
206 let mut group1 = [None; 2];
207 let mut group2 = [None; 2];
208 let mut g1_idx = 0;
209 let mut g2_idx = 0;
210
211 group1[0] = Some(segs[0]);
212 g1_idx += 1;
213
214 for i in 1..4 {
215 let diff = angle_diff(segs[0].angle, segs[i].angle);
216 if diff < 0.6 {
217 if g1_idx < 2 {
218 group1[g1_idx] = Some(segs[i]);
219 g1_idx += 1;
220 } else {
221 return None;
222 }
223 } else if g2_idx < 2 {
224 group2[g2_idx] = Some(segs[i]);
225 g2_idx += 1;
226 } else {
227 return None;
228 }
229 }
230
231 if g1_idx != 2 || g2_idx != 2 {
232 return None;
233 }
234
235 let g1 = [
236 group1[0].expect("g1[0] exists"),
237 group1[1].expect("g1[1] exists"),
238 ];
239 let g2 = [
240 group2[0].expect("g2[0] exists"),
241 group2[1].expect("g2[1] exists"),
242 ];
243
244 let angle_between_groups = angle_diff(g1[0].angle, g2[0].angle);
246 if (angle_between_groups - std::f32::consts::FRAC_PI_2).abs() > 0.6 {
247 return None;
248 }
249
250 let c0 = line_intersection(&g1[0], &g2[0])?;
252 let c1 = line_intersection(&g1[0], &g2[1])?;
253 let c2 = line_intersection(&g1[1], &g2[1])?;
254 let c3 = line_intersection(&g1[1], &g2[0])?;
255
256 let area = quad_area(&[c0, c1, c2, c3]);
259
260 if area.abs() < 16.0 || area.abs() > 1_000_000.0 {
262 return None;
263 }
264
265 if area > 0.0 {
266 Some([c0, c1, c2, c3]) } else {
268 Some([c0, c3, c2, c1]) }
270}
271
272#[allow(dead_code)]
273fn line_intersection(s1: &LineSegment, s2: &LineSegment) -> Option<[f32; 2]> {
274 let dx1 = s1.x1 - s1.x0;
275 let dy1 = s1.y1 - s1.y0;
276 let dx2 = s2.x1 - s2.x0;
277 let dy2 = s2.y1 - s2.y0;
278
279 let denom = dx1 * dy2 - dy1 * dx2;
280 if denom.abs() < 1e-6 {
281 return None; }
283
284 let t = ((s2.x0 - s1.x0) * dy2 - (s2.y0 - s1.y0) * dx2) / denom;
285 let p = [s1.x0 + t * dx1, s1.y0 + t * dy1];
286
287 let dist_sq = (p[0] - s1.x0).powi(2) + (p[1] - s1.y0).powi(2);
290 if dist_sq > 1000.0 * 1000.0 {
291 return None;
292 }
293
294 Some(p)
295}
296
297#[allow(dead_code)]
298fn quad_area(corners: &[[f32; 2]; 4]) -> f32 {
299 let mut area = 0.0;
300 for i in 0..4 {
301 let j = (i + 1) % 4;
302 area += corners[i][0] * corners[j][1];
303 area -= corners[j][0] * corners[i][1];
304 }
305 area * 0.5
306}
307
308#[allow(clippy::cast_possible_wrap)]
322#[allow(clippy::cast_sign_loss)]
323#[allow(clippy::similar_names)]
324#[allow(clippy::too_many_arguments)]
325#[must_use]
326#[allow(dead_code)]
327struct ComponentBounds {
328 min_x: usize,
329 min_y: usize,
330 max_x: usize,
331 max_y: usize,
332}
333
334#[allow(dead_code)]
335struct QuadFitter<'a> {
336 arena: &'a Bump,
337 img: &'a ImageView<'a>,
338 labels: &'a [u32],
339 label: u32,
340 bounds: ComponentBounds,
341}
342
343impl<'a> QuadFitter<'a> {
344 #[allow(clippy::too_many_arguments)]
345 fn new(
346 arena: &'a Bump,
347 img: &'a ImageView<'a>,
348 labels: &'a [u32],
349 label: u32,
350 min_x: usize,
351 min_y: usize,
352 max_x: usize,
353 max_y: usize,
354 ) -> Self {
355 Self {
356 arena,
357 img,
358 labels,
359 label,
360 bounds: ComponentBounds {
361 min_x,
362 min_y,
363 max_x,
364 max_y,
365 },
366 }
367 }
368
369 #[allow(
370 clippy::cast_possible_wrap,
371 clippy::cast_sign_loss,
372 clippy::similar_names
373 )]
374 #[allow(unsafe_code)]
375 fn collect_boundary_points(&self) -> BumpVec<'a, (usize, usize, f32, f32)> {
376 let width = self.img.width;
377 let height = self.img.height;
378 let x0 = self.bounds.min_x.saturating_sub(1);
379 let y0 = self.bounds.min_y.saturating_sub(1);
380 let x1 = (self.bounds.max_x + 2).min(width);
381 let y1 = (self.bounds.max_y + 2).min(height);
382
383 let mut points = BumpVec::new_in(self.arena);
384
385 for y in y0.max(1)..y1.min(height - 1) {
386 for x in x0.max(1)..x1.min(width - 1) {
387 let idx = y * width + x;
388 if self.labels[idx] != self.label {
389 continue;
390 }
391
392 if self.labels[idx - 1] == self.label
393 && self.labels[idx + 1] == self.label
394 && self.labels[idx - width] == self.label
395 && self.labels[idx + width] == self.label
396 {
397 continue;
398 }
399
400 unsafe {
403 let p00 = i16::from(self.img.get_pixel_unchecked(x - 1, y - 1));
404 let p10 = i16::from(self.img.get_pixel_unchecked(x, y - 1));
405 let p20 = i16::from(self.img.get_pixel_unchecked(x + 1, y - 1));
406 let p01 = i16::from(self.img.get_pixel_unchecked(x - 1, y));
407 let p21 = i16::from(self.img.get_pixel_unchecked(x + 1, y));
408 let p02 = i16::from(self.img.get_pixel_unchecked(x - 1, y + 1));
409 let p12 = i16::from(self.img.get_pixel_unchecked(x, y + 1));
410 let p22 = i16::from(self.img.get_pixel_unchecked(x + 1, y + 1));
411
412 let gx = -p00 + p20 - 2 * p01 + 2 * p21 - p02 + p22;
413 let gy = -p00 - 2 * p10 - p20 + p02 + 2 * p12 + p22;
414 let mag = (gx.abs() + gy.abs()) as u16;
415
416 if mag > 10 {
417 let angle = f32::from(gy).atan2(f32::from(gx));
418 points.push((x, y, angle, f32::from(mag)));
419 }
420 }
421 }
422 }
423 points
424 }
425
426 fn fit(&self) -> Option<[[f32; 2]; 4]> {
427 let boundary_points = self.collect_boundary_points();
428 if boundary_points.len() < 8 {
429 return None;
430 }
431
432 let mut centroids = Self::compute_initial_centroids(&boundary_points);
433 let assignments = self.kmeans_cluster(&boundary_points, &mut centroids);
434 let lines = self.fit_lines(&boundary_points, &assignments);
435
436 if lines.len() < 4 {
437 return None;
438 }
439
440 find_first_quad_from_segments(&lines)
441 }
442
443 #[allow(clippy::similar_names)]
444 fn compute_initial_centroids(boundary_points: &[(usize, usize, f32, f32)]) -> [f32; 4] {
445 let mut weight_sum = 0.0f32;
446 let mut gx_sum = 0.0f32;
447 let mut gy_sum = 0.0f32;
448 let mut gxx_sum = 0.0f32;
449 let mut gyy_sum = 0.0f32;
450 let mut gxy_sum = 0.0f32;
451
452 for &(_x, _y, angle, mag) in boundary_points {
453 let w = mag;
454 let gx = angle.cos();
455 let gy = angle.sin();
456 weight_sum += w;
457 gx_sum += gx * w;
458 gy_sum += gy * w;
459 gxx_sum += gx * gx * w;
460 gyy_sum += gy * gy * w;
461 gxy_sum += gx * gy * w;
462 }
463
464 let mean_gx = gx_sum / weight_sum;
465 let mean_gy = gy_sum / weight_sum;
466 let cov_xx = gxx_sum / weight_sum - mean_gx * mean_gx;
467 let cov_yy = gyy_sum / weight_sum - mean_gy * mean_gy;
468 let cov_xy = gxy_sum / weight_sum - mean_gx * mean_gy;
469
470 let trace = cov_xx + cov_yy;
471 let det = cov_xx * cov_yy - cov_xy * cov_xy;
472 let discriminant = (trace * trace / 4.0 - det).max(0.0);
473 let lambda1 = trace / 2.0 + discriminant.sqrt();
474
475 let theta = if cov_xy.abs() > 1e-6 {
476 (lambda1 - cov_xx).atan2(cov_xy)
477 } else if cov_xx >= cov_yy {
478 0.0
479 } else {
480 std::f32::consts::FRAC_PI_2
481 };
482
483 let mut centroids = [
484 theta,
485 theta + std::f32::consts::FRAC_PI_2,
486 theta + std::f32::consts::PI,
487 theta - std::f32::consts::FRAC_PI_2,
488 ];
489 for c in &mut centroids {
491 while *c > std::f32::consts::PI {
492 *c -= 2.0 * std::f32::consts::PI;
493 }
494 while *c < -std::f32::consts::PI {
495 *c += 2.0 * std::f32::consts::PI;
496 }
497 }
498 centroids
499 }
500
501 fn kmeans_cluster(
502 &self,
503 boundary_points: &[(usize, usize, f32, f32)],
504 centroids: &mut [f32; 4],
505 ) -> BumpVec<'a, usize> {
506 let mut assignments = BumpVec::with_capacity_in(boundary_points.len(), self.arena);
507 assignments.resize(boundary_points.len(), 0);
508
509 for _ in 0..5 {
510 for (i, &(_x, _y, angle, _mag)) in boundary_points.iter().enumerate() {
512 let mut best_cluster = 0;
513 let mut best_dist = f32::MAX;
514 for (c, ¢roid) in centroids.iter().enumerate() {
515 let diff = angle_diff(angle, centroid);
516 if diff < best_dist {
517 best_dist = diff;
518 best_cluster = c;
519 }
520 }
521 assignments[i] = best_cluster;
522 }
523
524 for (c, centroid) in centroids.iter_mut().enumerate() {
526 let mut sum_sin = 0.0f32;
527 let mut sum_cos = 0.0f32;
528 for (i, &(_x, _y, angle, mag)) in boundary_points.iter().enumerate() {
529 if assignments[i] == c {
530 sum_sin += angle.sin() * mag;
531 sum_cos += angle.cos() * mag;
532 }
533 }
534 if sum_sin.abs() > 1e-6 || sum_cos.abs() > 1e-6 {
535 *centroid = sum_sin.atan2(sum_cos);
536 }
537 }
538 }
539 assignments
540 }
541
542 #[allow(clippy::similar_names)]
543 fn fit_lines(
544 &self,
545 boundary_points: &[(usize, usize, f32, f32)],
546 assignments: &[usize],
547 ) -> BumpVec<'a, LineSegment> {
548 let mut lines = BumpVec::new_in(self.arena);
549 for c in 0..4 {
551 let mut cluster_count = 0;
552 let mut sum_w = 0.0f32;
553 let mut sum_wx = 0.0f32;
554 let mut sum_wy = 0.0f32;
555
556 for (i, &(x, y, _, mag)) in boundary_points.iter().enumerate() {
557 if assignments[i] == c {
558 cluster_count += 1;
559 let w = mag * mag;
560 sum_w += w;
561 sum_wx += x as f32 * w;
562 sum_wy += y as f32 * w;
563 }
564 }
565
566 if cluster_count < 3 {
567 continue;
568 }
569
570 let mean_x = sum_wx / sum_w;
571 let mean_y = sum_wy / sum_w;
572
573 let mut cov_xx = 0.0f32;
574 let mut cov_yy = 0.0f32;
575 let mut cov_xy = 0.0f32;
576 for (i, &(x, y, _, mag)) in boundary_points.iter().enumerate() {
577 if assignments[i] == c {
578 let w = mag * mag;
579 let dx = x as f32 - mean_x;
580 let dy = y as f32 - mean_y;
581 cov_xx += dx * dx * w;
582 cov_yy += dy * dy * w;
583 cov_xy += dx * dy * w;
584 }
585 }
586
587 let direction = 0.5 * (2.0 * cov_xy).atan2(cov_xx - cov_yy);
588 let nx = direction.cos();
589 let ny = direction.sin();
590
591 let mut min_t = f32::MAX;
592 let mut max_t = f32::MIN;
593 for (i, &(x, y, _, _)) in boundary_points.iter().enumerate() {
594 if assignments[i] == c {
595 let t = (x as f32 - mean_x) * nx + (y as f32 - mean_y) * ny;
596 min_t = min_t.min(t);
597 max_t = max_t.max(t);
598 }
599 }
600
601 let mut grad_angle = direction + std::f32::consts::FRAC_PI_2;
602 if grad_angle > std::f32::consts::PI {
603 grad_angle -= 2.0 * std::f32::consts::PI;
604 }
605
606 lines.push(LineSegment {
607 x0: mean_x + nx * min_t,
608 y0: mean_y + ny * min_t,
609 x1: mean_x + nx * max_t,
610 y1: mean_y + ny * max_t,
611 angle: grad_angle,
612 });
613 }
614 lines
615 }
616}
617
618#[allow(clippy::too_many_arguments)]
620#[must_use]
621#[allow(dead_code)]
622pub(crate) fn fit_quad_from_component(
623 arena: &Bump,
624 img: &ImageView,
625 labels: &[u32],
626 label: u32,
627 min_x: usize,
628 min_y: usize,
629 max_x: usize,
630 max_y: usize,
631) -> Option<[[f32; 2]; 4]> {
632 QuadFitter::new(arena, img, labels, label, min_x, min_y, max_x, max_y).fit()
633}
634
635#[allow(clippy::needless_range_loop)]
641#[allow(dead_code)]
642fn solve_quad_from_boundary_points(
643 boundary_points: &[(f32, f32, f32)], _img_width: usize, min_pixels: usize,
646) -> Option<[[f32; 2]; 4]> {
647 let mut centroids = [
650 0.0f32,
651 std::f32::consts::FRAC_PI_2,
652 std::f32::consts::PI,
653 -std::f32::consts::FRAC_PI_2,
654 ];
655 let mut assignments = vec![0usize; boundary_points.len()];
656
657 for _ in 0..5 {
658 for (i, (_x, _y, angle)) in boundary_points.iter().enumerate() {
661 let mut best_cluster = 0;
662 let mut best_dist = f32::MAX;
663 for (c, ¢roid) in centroids.iter().enumerate() {
664 let diff = angle_diff(*angle, centroid);
665 if diff < best_dist {
666 best_dist = diff;
667 best_cluster = c;
668 }
669 }
670 assignments[i] = best_cluster;
671 }
672
673 for c in 0..4 {
675 let mut sum_sin = 0.0f32;
676 let mut sum_cos = 0.0f32;
677 for (i, (_x, _y, angle)) in boundary_points.iter().enumerate() {
678 if assignments[i] == c {
679 sum_sin += angle.sin();
680 sum_cos += angle.cos();
681 }
682 }
683 if sum_sin.abs() > 1e-6 || sum_cos.abs() > 1e-6 {
684 centroids[c] = sum_sin.atan2(sum_cos);
685 }
686 }
687 }
688
689 let mut lines = [LineSegment {
691 x0: 0.0,
692 y0: 0.0,
693 x1: 0.0,
694 y1: 0.0,
695 angle: 0.0,
696 }; 4];
697
698 for c in 0..4 {
700 let mut sum_x = 0.0f32;
703 let mut sum_y = 0.0f32;
704 let mut count = 0usize;
705
706 for (i, (x, y, _)) in boundary_points.iter().enumerate() {
709 if assignments[i] == c {
710 sum_x += *x;
711 sum_y += *y;
712 count += 1;
713 }
714 }
715
716 if count < min_pixels {
717 return None;
718 }
719
720 let mean_x = sum_x / count as f32;
721 let mean_y = sum_y / count as f32;
722
723 let angle = centroids[c];
726 let nx = angle.cos();
727 let ny = angle.sin();
728
729 lines[c] = LineSegment {
732 x0: mean_x - nx * 100.0,
733 y0: mean_y - ny * 100.0,
734 x1: mean_x + nx * 100.0,
735 y1: mean_y + ny * 100.0,
736 angle,
737 };
738 }
739
740 let tl = line_intersection(&lines[2], &lines[3])?;
750 let tr = line_intersection(&lines[3], &lines[0])?;
751 let br = line_intersection(&lines[0], &lines[1])?;
752 let bl = line_intersection(&lines[1], &lines[2])?;
753
754 let corners = [tl, tr, br, bl];
755
756 let area = quad_area(&corners);
758 if !(16.0..=1_000_000.0).contains(&area) {
759 return None;
760 }
761
762 Some(corners)
763}
764
765#[allow(clippy::cast_possible_wrap)]
767#[allow(clippy::cast_sign_loss)]
768#[allow(clippy::similar_names)]
769#[must_use]
770#[allow(dead_code)]
771pub(crate) fn fit_quad_from_gradients(
772 grads: &[Gradient],
773 labels: &[u32],
774 label: u32,
775 width: usize,
776 height: usize,
777 min_edge_pixels: usize,
778) -> Option<[[f32; 2]; 4]> {
779 let mut boundary_points: Vec<(f32, f32, f32)> = Vec::with_capacity(min_edge_pixels * 4); for y in 1..height - 1 {
783 for x in 1..width - 1 {
784 let idx = y * width + x;
785 if labels[idx] != label {
786 continue;
787 }
788
789 let is_boundary = labels[idx - 1] != label
791 || labels[idx + 1] != label
792 || labels[idx - width] != label
793 || labels[idx + width] != label;
794
795 if is_boundary && grads[idx].mag > 20 {
796 let angle = f32::from(grads[idx].gy).atan2(f32::from(grads[idx].gx));
797 boundary_points.push((x as f32, y as f32, angle));
798 }
799 }
800 }
801
802 if boundary_points.len() < min_edge_pixels * 4 {
803 return None; }
805
806 solve_quad_from_boundary_points(&boundary_points, width, min_edge_pixels)
807}
808
809#[allow(dead_code)]
811fn angle_diff(a: f32, b: f32) -> f32 {
812 let diff = (a - b).abs();
813 let diff = diff % std::f32::consts::PI;
814 diff.min(std::f32::consts::PI - diff)
815}
816
817#[cfg(test)]
818#[allow(clippy::unwrap_used, clippy::float_cmp)]
819mod tests {
820 use super::*;
821 use crate::image::ImageView;
822 use proptest::prelude::*;
823
824 proptest! {
825 #[test]
826 fn prop_sobel_magnitude_bounds(
827 width in 3..64usize,
828 height in 3..64usize,
829 data in prop::collection::vec(0..=255u8, 64*64)
830 ) {
831 let slice = &data[..width * height];
832 let view = ImageView::new(slice, width, height, width).unwrap();
833 let grads = compute_sobel(&view);
834
835 for g in grads {
836 assert!(g.mag <= 1000);
837 }
838 }
839
840 #[test]
841 fn prop_sobel_orientation_ramp(
842 width in 3..10usize,
843 height in 3..10usize,
844 is_horizontal in prop::bool::ANY
845 ) {
846 let mut data = vec![0u8; width * height];
847 for y in 0..height {
848 for x in 0..width {
849 data[y * width + x] = if is_horizontal { x as u8 * 10 } else { y as u8 * 10 };
850 }
851 }
852
853 let view = ImageView::new(&data, width, height, width).unwrap();
854 let grads = compute_sobel(&view);
855
856 for y in 1..height-1 {
858 for x in 1..width-1 {
859 let g = grads[y * width + x];
860 if is_horizontal {
861 assert!(g.gx > 0);
862 assert_eq!(g.gy, 0);
863 } else {
864 assert_eq!(g.gx, 0);
865 assert!(g.gy > 0);
866 }
867 }
868 }
869 }
870
871 #[test]
872 fn prop_quad_area_invariants(
873 c in prop::collection::vec((0.0..100.0f32, 0.0..100.0f32), 4)
874 ) {
875 let corners = [
876 [c[0].0, c[0].1],
877 [c[1].0, c[1].1],
878 [c[2].0, c[2].1],
879 [c[3].0, c[3].1],
880 ];
881 let area = quad_area(&corners);
882
883 let mut corners_rev = corners;
887 corners_rev.reverse();
888 let area_rev = quad_area(&corners_rev);
889
890 assert!((area + area_rev).abs() < 0.01);
891
892 let identical_corners = [[0.0, 0.0], [0.0, 0.0], [0.0, 0.0], [0.0, 0.0]];
894 assert_eq!(quad_area(&identical_corners), 0.0);
895 }
896 }
897}