Skip to main content

imops/
lib.rs

1// Copyright (C) The Strand-Braid Authors
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4#![cfg_attr(not(feature = "std"), no_std)]
5
6// The public functions are `#[inline]` because I have found with the benchmarks
7// in this crate that this results in significant speedups.
8
9use machine_vision_formats::{
10    ImageMutData, iter::HasRowChunksExact, iter::HasRowChunksExactMut, pixel_format::Mono8,
11};
12
13/// Compute spatial image moment 0,0
14///
15/// Panics: panics on image shape or stride problems.
16#[inline]
17pub fn spatial_moment_00<IM>(im: &IM) -> f32
18where
19    IM: HasRowChunksExact<Mono8>,
20{
21    let mut accum: f64 = 0.0;
22
23    let chunk_iter = im.rowchunks_exact();
24
25    for rowdata in chunk_iter {
26        // trim from stride to width
27        let rowdata = &rowdata[..im.width() as usize];
28
29        let (head, body, tail): (&[u8], &[wide::u8x16], &[u8]) =
30            wide::AlignTo::simd_align_to(rowdata);
31
32        for x in head {
33            accum += *x as f64;
34        }
35
36        let mut tmpsum: wide::u16x16 = wide::u16x16::ZERO;
37        for (i, x) in body.iter().enumerate() {
38            if i % 256 == 0 {
39                // prevent overflow of u16 accumulator
40                for xi in tmpsum.as_array() {
41                    accum += *xi as f64;
42                }
43                tmpsum = wide::u16x16::ZERO;
44            }
45            let wide_x = wide::u16x16::from(*x);
46            tmpsum += wide_x;
47        }
48        for xi in tmpsum.as_array() {
49            accum += *xi as f64;
50        }
51
52        for x in tail {
53            accum += *x as f64;
54        }
55    }
56    accum as f32
57}
58
59/// Compute spatial image moment 0,1
60///
61/// Panics: panics on image shape or stride problems.
62#[inline]
63pub fn spatial_moment_01<IM>(im: &IM) -> f32
64where
65    IM: HasRowChunksExact<Mono8>,
66{
67    let mut accum: f64 = 0.0;
68    use wide::f32x8;
69
70    let chunk_iter = im.rowchunks_exact();
71
72    for (row, rowdata) in chunk_iter.enumerate() {
73        // trim from stride to width
74        let rowdata = &rowdata[..im.width() as usize];
75
76        let mut row_chunk_iter = rowdata.chunks_exact(8);
77
78        let mut rowsum = f32x8::splat(0.0);
79        let rowvec = f32x8::splat(row as f32);
80        for x in &mut row_chunk_iter {
81            let x = f32x8::new([
82                x[0] as f32,
83                x[1] as f32,
84                x[2] as f32,
85                x[3] as f32,
86                x[4] as f32,
87                x[5] as f32,
88                x[6] as f32,
89                x[7] as f32,
90            ]);
91            rowsum += x * rowvec;
92        }
93        accum += rowsum.reduce_add() as f64;
94
95        for x in row_chunk_iter.remainder() {
96            accum += *x as f64 * row as f64;
97        }
98    }
99    accum as f32
100}
101
102/// Compute spatial image moment 1,0
103///
104/// Panics: panics on image shape or stride problems.
105#[inline]
106pub fn spatial_moment_10<IM>(im: &IM) -> f32
107where
108    IM: HasRowChunksExact<Mono8>,
109{
110    let mut accum: f64 = 0.0;
111    use wide::f64x4;
112
113    let col_offset = f64x4::new([0.0, 1.0, 2.0, 3.0]);
114
115    let chunk_iter = im.rowchunks_exact();
116
117    let start_idx = im.width() as usize / 4 * 4;
118
119    for rowdata in chunk_iter {
120        // trim from stride to width
121        let rowdata = &rowdata[..im.width() as usize];
122
123        let mut row_chunk_iter = rowdata.chunks_exact(4);
124
125        let mut rowsum = f64x4::splat(0.0);
126        for (col_div_4, x) in (&mut row_chunk_iter).enumerate() {
127            let x = f64x4::new([x[0] as f64, x[1] as f64, x[2] as f64, x[3] as f64]);
128            let col = f64x4::splat((col_div_4 * 4) as f64) + col_offset;
129            rowsum += x * col;
130        }
131
132        accum += rowsum.reduce_add();
133
134        for (i, x) in row_chunk_iter.remainder().iter().enumerate() {
135            let col = i + start_idx;
136            accum += *x as f64 * col as f64;
137        }
138    }
139    accum as f32
140}
141
142#[derive(Debug)]
143pub struct Moments {
144    pub centroid_x: f32,
145    pub centroid_y: f32,
146
147    pub m00: f32,
148    pub m01: f32,
149    pub m10: f32,
150    pub u11: f32,
151    pub u02: f32,
152    pub u20: f32,
153}
154
155pub fn calculate_moments<IM>(im: &IM) -> Moments
156where
157    IM: HasRowChunksExact<Mono8>,
158{
159    // Compute all six raw moments in a single pass over the pixels rather than
160    // six separate passes (three of which used to be fully scalar). For each
161    // row `y` we accumulate exact integer per-row sums over the columns `x`:
162    //   rs0 = Σ v, rs_x = Σ x·v, rs_xx = Σ x²·v
163    // and combine them with the row index:
164    //   m00 += rs0,      m01 += y·rs0,   m02 += y²·rs0,
165    //   m10 += rs_x,     m11 += y·rs_x,  m20 += rs_xx
166    // matching spatial_moment_00 / _01 (Σ row·v) / _10 (Σ col·v) and
167    // spatial_moment(One,One)/(Zero,Two)/(Two,Zero). The per-row sums are exact
168    // integers, so this is at least as accurate as the previous per-moment SIMD
169    // (and more accurate than the old f32-intermediate `m01`).
170    let width = im.width() as usize;
171
172    let mut m00 = 0.0f64;
173    let mut m01 = 0.0f64;
174    let mut m10 = 0.0f64;
175    let mut m11 = 0.0f64;
176    let mut m02 = 0.0f64;
177    let mut m20 = 0.0f64;
178
179    for (row, rowdata) in im.rowchunks_exact().enumerate() {
180        // trim from stride to width
181        let rowdata = &rowdata[..width];
182
183        let mut rs0: u64 = 0;
184        let mut rs_x: u64 = 0;
185        let mut rs_xx: u64 = 0;
186        for (col, element) in rowdata.iter().enumerate() {
187            let v = *element as u64;
188            let x = col as u64;
189            rs0 += v;
190            rs_x += x * v;
191            rs_xx += x * x * v;
192        }
193
194        let y = row as f64;
195        let rs0 = rs0 as f64;
196        let rs_x = rs_x as f64;
197        m00 += rs0;
198        m01 += y * rs0;
199        m02 += y * y * rs0;
200        m10 += rs_x;
201        m11 += y * rs_x;
202        m20 += rs_xx as f64;
203    }
204
205    let m00 = m00 as f32;
206    let m01 = m01 as f32;
207    let m10 = m10 as f32;
208    let m11 = m11 as f32;
209    let m02 = m02 as f32;
210    let m20 = m20 as f32;
211
212    let centroid_x = m01 / m00;
213    let centroid_y = m10 / m00;
214
215    let u11 = m11 - centroid_x * m10;
216    let u02 = m02 - centroid_x * m01;
217    let u20 = m20 - centroid_y * m10;
218
219    // debug_assert_eq!(u11, m11 - centroid_y * m01);
220
221    Moments {
222        m00,
223        m01,
224        m10,
225        centroid_x,
226        centroid_y,
227        u11,
228        u02,
229        u20,
230    }
231}
232
233/// Set the minimum value of all pixels in the image to `low`.
234///
235/// Currently implemented only for `MONO8` pixel formats.
236///
237/// Panics: panics on image shape or stride problems.
238#[inline]
239pub fn clip_low<IM>(mut im: IM, low: u8) -> IM
240where
241    IM: HasRowChunksExact<Mono8> + ImageMutData<Mono8>,
242{
243    let width = im.width() as usize;
244
245    let chunk_iter = im.rowchunks_exact_mut();
246
247    #[inline]
248    fn scalar_clip_low(scalar_data: &mut [u8], low: u8) {
249        for element in scalar_data.iter_mut() {
250            if *element < low {
251                *element = low;
252            }
253        }
254    }
255
256    {
257        use wide::u8x32;
258
259        let low_vec = u8x32::splat(low);
260
261        for rowdata in chunk_iter {
262            // trim from stride to width
263            let rowdata = &mut rowdata[..width];
264
265            let (head, body, tail): (&mut [u8], &mut [wide::u8x32], &mut [u8]) =
266                wide::AlignTo::simd_align_to_mut(rowdata);
267
268            scalar_clip_low(head, low);
269
270            for y in body {
271                *y = u8x32::max(*y, low_vec);
272            }
273
274            scalar_clip_low(tail, low);
275        }
276    }
277
278    im
279}
280
281#[derive(Debug, Clone, Copy)]
282pub enum CmpOp {
283    LessThan,
284    LessEqual,
285    Equal,
286    GreaterEqual,
287    GreaterThan,
288}
289
290/// Threshold the image so that all pixels compared with `op` to `thresh` are
291/// set to `a` if true and otherwise to `b`.
292///
293/// Currently implemented only for `MONO8` pixel formats.
294///
295/// Panics: panics on image shape or stride problems.
296#[inline]
297pub fn threshold<IM>(mut im: IM, op: CmpOp, thresh: u8, a: u8, b: u8) -> IM
298where
299    IM: HasRowChunksExact<Mono8> + ImageMutData<Mono8>,
300{
301    let width = im.width() as usize;
302    let chunk_iter = im.rowchunks_exact_mut();
303
304    #[inline]
305    fn scalar_cmp(scalar_data: &mut [u8], thresh: u8, a: u8, b: u8, op: CmpOp) {
306        for x in scalar_data {
307            match op {
308                CmpOp::LessThan => {
309                    *x = if *x < thresh { a } else { b };
310                }
311                CmpOp::LessEqual => {
312                    *x = if *x <= thresh { a } else { b };
313                }
314                CmpOp::Equal => {
315                    *x = if *x == thresh { a } else { b };
316                }
317                CmpOp::GreaterEqual => {
318                    *x = if *x >= thresh { a } else { b };
319                }
320                CmpOp::GreaterThan => {
321                    *x = if *x > thresh { a } else { b };
322                }
323            }
324        }
325    }
326
327    use wide::u8x32;
328
329    let avec = u8x32::splat(a);
330    let bvec = u8x32::splat(b);
331    let thresh_vec = u8x32::splat(thresh);
332
333    for rowdata in chunk_iter {
334        // trim from stride to width
335        let rowdata = &mut rowdata[..width];
336
337        let (head, body, tail): (&mut [u8], &mut [wide::u8x32], &mut [u8]) =
338            wide::AlignTo::simd_align_to_mut(rowdata);
339
340        scalar_cmp(head, thresh, a, b, op);
341
342        for y in body.iter_mut() {
343            let indicator = match op {
344                CmpOp::LessThan => y.simd_lt(thresh_vec),
345                CmpOp::LessEqual => y.simd_le(thresh_vec),
346                CmpOp::Equal => y.simd_eq(thresh_vec),
347                CmpOp::GreaterEqual => y.simd_ge(thresh_vec),
348                CmpOp::GreaterThan => y.simd_gt(thresh_vec),
349            };
350            *y = indicator.blend(avec, bvec);
351        }
352
353        scalar_cmp(tail, thresh, a, b, op);
354    }
355
356    im
357}
358
359#[cfg(feature = "std")]
360#[cfg(test)]
361mod tests {
362
363    use super::*;
364
365    // `Power`, `mypow` and the generic `spatial_moment` are now used only as the
366    // reference oracle in the tests; `calculate_moments` computes all moments in a
367    // single fused pass. Gate them on `test` so they don't warn as dead code.
368    #[cfg(test)]
369    #[derive(Clone, Copy, Debug, PartialEq)]
370    enum Power {
371        Zero,
372        One,
373        Two,
374    }
375
376    #[cfg(test)]
377    #[inline]
378    fn mypow(x: u32, exp: Power) -> f64 {
379        match exp {
380            Power::Zero => 1.0,
381            Power::One => x as f64,
382            Power::Two => x as f64 * x as f64,
383        }
384    }
385
386    #[cfg(test)]
387    impl From<u8> for Power {
388        fn from(orig: u8) -> Self {
389            match orig {
390                0 => Power::Zero,
391                1 => Power::One,
392                2 => Power::Two,
393                _ => {
394                    unimplemented!();
395                }
396            }
397        }
398    }
399
400    #[cfg(test)]
401    fn spatial_moment<IM>(im: &IM, m_ord: Power, n_ord: Power) -> f32
402    where
403        IM: HasRowChunksExact<Mono8>,
404    {
405        let mut accum: f64 = 0.0;
406
407        let chunk_iter = im.rowchunks_exact();
408
409        for (row, rowdata) in chunk_iter.enumerate() {
410            for (col, element) in rowdata.iter().enumerate() {
411                accum += mypow(row as u32, n_ord) * mypow(col as u32, m_ord) * *element as f64;
412            }
413        }
414        accum as f32
415    }
416
417    #[test]
418    fn test_clip_low() {
419        const STRIDE: usize = 24;
420        const W: usize = 20;
421        const H: usize = 20;
422        const ALLOC_H: usize = 25;
423        let mut image_data = vec![0u8; STRIDE * ALLOC_H];
424        image_data[4 * STRIDE + 3] = 43;
425        image_data[5 * STRIDE + 3] = 1;
426        image_data[5 * STRIDE + 4] = 1;
427        image_data[6 * STRIDE + 4] = 1;
428
429        // Put some data in the buffer but outside the width and height. This
430        // tests that strides and height limit are working correctly.
431        image_data[4 * STRIDE + 23] = 1;
432        image_data[5 * STRIDE + 23] = 1;
433        image_data[H * STRIDE + 4] = 1;
434        image_data[(H + 1) * STRIDE + 6] = 1;
435
436        let im = machine_vision_formats::owned::OImage::new(W as u32, H as u32, STRIDE, image_data)
437            .unwrap();
438
439        let im = clip_low(im, 42);
440
441        let image_data: Vec<u8> = im.into();
442
443        for row in 0..ALLOC_H {
444            print!("row {row:2}: ");
445            for col in 0..STRIDE {
446                print!("{:3} ", image_data[row * STRIDE + col]);
447            }
448            println!();
449        }
450
451        assert_eq!(image_data[0], 42);
452        assert_eq!(image_data[(H - 1) * STRIDE + (W - 1)], 42);
453        assert_eq!(image_data[4 * STRIDE + 3], 43);
454        assert_eq!(image_data[4 * STRIDE + 23], 1);
455        assert_eq!(image_data[H * STRIDE + 4], 1);
456        assert_eq!(image_data[(H + 1) * STRIDE + 6], 1);
457    }
458
459    macro_rules! gen_threshold_test {
460        ($name:ident, $orig:expr_2021, $op:path, $thresh:expr_2021, $expected:expr_2021) => {
461            #[test]
462            fn $name() {
463                const W: usize = 33; // wider than u8x32
464
465                let im = machine_vision_formats::owned::OImage::new(W as u32, 1, W, vec![$orig; W])
466                    .unwrap();
467
468                let im = threshold(im, $op, $thresh, 0, 255);
469                let image_data: Vec<u8> = im.into();
470                assert_eq!(image_data[0], $expected);
471                assert_eq!(image_data[W - 1], $expected);
472            }
473        };
474    }
475
476    gen_threshold_test!(test_lt_1, 10, CmpOp::LessThan, 42, 0);
477    gen_threshold_test!(test_lt_2, 10, CmpOp::LessThan, 10, 255);
478    gen_threshold_test!(test_lt_3, 10, CmpOp::LessThan, 9, 255);
479
480    gen_threshold_test!(test_le_1, 10, CmpOp::LessEqual, 42, 0);
481    gen_threshold_test!(test_le_2, 10, CmpOp::LessEqual, 10, 0);
482    gen_threshold_test!(test_le_3, 10, CmpOp::LessEqual, 9, 255);
483
484    gen_threshold_test!(test_eq_1, 10, CmpOp::Equal, 42, 255);
485    gen_threshold_test!(test_eq_2, 10, CmpOp::Equal, 10, 0);
486    gen_threshold_test!(test_eq_3, 10, CmpOp::Equal, 9, 255);
487
488    gen_threshold_test!(test_ge_1, 10, CmpOp::GreaterEqual, 42, 255);
489    gen_threshold_test!(test_ge_2, 10, CmpOp::GreaterEqual, 10, 0);
490    gen_threshold_test!(test_ge_3, 10, CmpOp::GreaterEqual, 9, 0);
491
492    gen_threshold_test!(test_gt_1, 10, CmpOp::GreaterThan, 42, 255);
493    gen_threshold_test!(test_gt_2, 10, CmpOp::GreaterThan, 10, 255);
494    gen_threshold_test!(test_gt_3, 10, CmpOp::GreaterThan, 9, 0);
495
496    #[test]
497    fn test_threshold_less_than() {
498        const STRIDE: usize = 24;
499        const W: usize = 20;
500        const H: usize = 20;
501        const ALLOC_H: usize = 25;
502        let mut image_data = vec![2u8; STRIDE * ALLOC_H];
503        image_data[4 * STRIDE + 3] = 43;
504        image_data[4 * STRIDE + 4] = 42;
505        image_data[4 * STRIDE + 5] = 41;
506        image_data[5 * STRIDE + 3] = 1;
507        image_data[5 * STRIDE + 4] = 1;
508        image_data[6 * STRIDE + 4] = 1;
509
510        // Put some data in the buffer but outside the width and height. This
511        // tests that strides and height limit are working correctly.
512        image_data[4 * STRIDE + 23] = 1;
513        image_data[5 * STRIDE + 23] = 1;
514        image_data[H * STRIDE + 4] = 1;
515        image_data[(H + 1) * STRIDE + 6] = 1;
516
517        let im = machine_vision_formats::owned::OImage::new(W as u32, H as u32, STRIDE, image_data)
518            .unwrap();
519
520        let im = threshold(im, CmpOp::LessThan, 42, 0, 255);
521
522        let image_data: Vec<u8> = im.into();
523
524        assert_eq!(image_data[0], 0);
525        assert_eq!(image_data[(H - 1) * STRIDE + (W - 1)], 0);
526        assert_eq!(image_data[4 * STRIDE + 3], 255);
527        assert_eq!(image_data[4 * STRIDE + 4], 255);
528        assert_eq!(image_data[4 * STRIDE + 5], 0);
529        assert_eq!(image_data[4 * STRIDE + 23], 1);
530        assert_eq!(image_data[4 * STRIDE + 22], 2);
531        assert_eq!(image_data[H * STRIDE + 4], 1);
532        assert_eq!(image_data[(H + 1) * STRIDE + 6], 1);
533    }
534
535    #[test]
536    fn test_central_moments() {
537        const STRIDE: usize = 20;
538        const W: usize = 20;
539        const H: usize = 20;
540        const ALLOC_H: usize = 20;
541        let mut image_data = vec![0u8; STRIDE * ALLOC_H];
542
543        image_data[4 * STRIDE + 3] = 1;
544        image_data[5 * STRIDE + 3] = 1;
545        image_data[5 * STRIDE + 4] = 1;
546        image_data[6 * STRIDE + 4] = 1;
547
548        let im = machine_vision_formats::owned::OImage::new(W as u32, H as u32, STRIDE, image_data)
549            .unwrap();
550
551        let mr = calculate_moments(&im);
552        assert_eq!(mr.u11, 1.0);
553        assert_eq!(mr.u20, 1.0);
554        assert_eq!(mr.u02, 2.0);
555    }
556
557    #[test]
558    fn test_image_moments() {
559        const STRIDE: usize = 24;
560        const W: usize = 20;
561        const H: usize = 20;
562        const ALLOC_H: usize = 25;
563        let mut image_data = vec![0u8; STRIDE * ALLOC_H];
564        image_data[4 * STRIDE + 3] = 1;
565        image_data[5 * STRIDE + 3] = 1;
566        image_data[5 * STRIDE + 4] = 1;
567        image_data[6 * STRIDE + 4] = 1;
568
569        // Put some data in the buffer but outside the width and height. This
570        // tests that strides and height limit are working correctly.
571        image_data[4 * STRIDE + 23] = 255;
572        image_data[5 * STRIDE + 23] = 255;
573        image_data[H * STRIDE + 4] = 255;
574        image_data[(H + 1) * STRIDE + 6] = 255;
575
576        let im = machine_vision_formats::owned::OImage::new(W as u32, H as u32, STRIDE, image_data)
577            .unwrap();
578
579        assert_eq!(spatial_moment_00(&im), 4.0);
580        assert_eq!(spatial_moment_10(&im), 14.0);
581        assert_eq!(spatial_moment_01(&im), 20.0);
582    }
583
584    #[test]
585    fn test_image_moments_remainder() {
586        // This tests that data at column 19 in a width 20 image get used. This
587        // tests the case where the image width is not divisible by 8 and that
588        // the final column gets correctly used.
589
590        const STRIDE: usize = 24;
591        const W: usize = 20;
592        const H: usize = 20;
593        let mut image_data = vec![0u8; STRIDE * H];
594        image_data[4 * STRIDE + 3] = 20;
595        image_data[5 * STRIDE + 3] = 21;
596        image_data[5 * STRIDE + 4] = 22;
597        image_data[6 * STRIDE + 4] = 23;
598
599        image_data[4 * STRIDE + 19] = 1;
600        image_data[5 * STRIDE + 19] = 1;
601        image_data[6 * STRIDE + 19] = 1;
602
603        // Put some data in the buffer but outside the width. This tests that
604        // strides are working correctly.
605        image_data[4 * STRIDE + 23] = 255;
606        image_data[5 * STRIDE + 23] = 255;
607
608        let im = machine_vision_formats::owned::OImage::new(W as u32, H as u32, STRIDE, image_data)
609            .unwrap();
610
611        assert_eq!(spatial_moment_00(&im), 89.0);
612        assert_eq!(spatial_moment_01(&im), 448.0);
613        assert_eq!(spatial_moment_10(&im), 360.0);
614    }
615
616    #[test]
617    fn test_wide_image_moments_simd() {
618        // Test very wide image to check case where temporary u16 wide vector would overflow.
619        const STRIDE: usize = 10000;
620        const W: usize = 9000;
621        const H: usize = 20;
622        // Use maximum value to maximize potential chance of overflow.
623        let mut image_data = vec![u8::MAX; STRIDE * H];
624
625        // Put some other values in the first row of the buffer but outside the
626        // width to test that strides are working correctly.
627        image_data[W + 23] = 0;
628        image_data[W + 24] = 0;
629
630        let im = machine_vision_formats::owned::OImage::new(W as u32, H as u32, STRIDE, image_data)
631            .unwrap();
632
633        // computed the expected value.
634        let expected: f64 = H as f64 * u8::MAX as f64 * W as f64;
635        assert_eq!(
636            spatial_moment(&im, Power::Zero, Power::Zero) as f64,
637            expected
638        );
639        assert_eq!(
640            spatial_moment_00(&im),
641            spatial_moment(&im, Power::Zero, Power::Zero)
642        );
643        assert_eq!(spatial_moment_00(&im) as f64, expected);
644        assert_eq!(
645            spatial_moment_01(&im),
646            spatial_moment(&im, Power::Zero, Power::One)
647        );
648        assert_eq!(
649            spatial_moment_10(&im),
650            spatial_moment(&im, Power::One, Power::Zero)
651        );
652    }
653
654    #[test]
655    fn test_tall_image_moments_simd() {
656        // Test very wide image to check case where temporary u16 wide vector would overflow.
657        const STRIDE: usize = 32;
658        const W: usize = 20;
659        const H: usize = 10000;
660        // Use maximum value to maximize potential chance of overflow.
661        let mut image_data = vec![u8::MAX; STRIDE * H];
662
663        // Put some other values in the first row of the buffer but outside the
664        // width to test that strides are working correctly.
665        image_data[W + 3] = 0;
666        image_data[W + 4] = 0;
667
668        let im = machine_vision_formats::owned::OImage::new(W as u32, H as u32, STRIDE, image_data)
669            .unwrap();
670
671        // computed the expected value.
672        let expected: f64 = H as f64 * u8::MAX as f64 * W as f64;
673        assert_eq!(
674            spatial_moment(&im, Power::Zero, Power::Zero) as f64,
675            expected
676        );
677        assert_eq!(
678            spatial_moment_00(&im),
679            spatial_moment(&im, Power::Zero, Power::Zero)
680        );
681        assert_eq!(spatial_moment_00(&im) as f64, expected);
682        assert_eq!(
683            spatial_moment_01(&im),
684            spatial_moment(&im, Power::Zero, Power::One)
685        );
686        assert_eq!(
687            spatial_moment_10(&im),
688            spatial_moment(&im, Power::One, Power::Zero)
689        );
690    }
691
692    /// Independently recompute the same [`Moments`] using the per-moment
693    /// reference oracle [`spatial_moment`], with the identical central-moment
694    /// formula as [`calculate_moments`]. The single-pass `calculate_moments`
695    /// must agree with this for the same input.
696    fn oracle_moments<IM>(im: &IM) -> Moments
697    where
698        IM: HasRowChunksExact<Mono8>,
699    {
700        let m00 = spatial_moment(im, Power::Zero, Power::Zero);
701        let m01 = spatial_moment(im, Power::Zero, Power::One);
702        let m10 = spatial_moment(im, Power::One, Power::Zero);
703        let m11 = spatial_moment(im, Power::One, Power::One);
704        let m02 = spatial_moment(im, Power::Zero, Power::Two);
705        let m20 = spatial_moment(im, Power::Two, Power::Zero);
706
707        let centroid_x = m01 / m00;
708        let centroid_y = m10 / m00;
709
710        Moments {
711            centroid_x,
712            centroid_y,
713            m00,
714            m01,
715            m10,
716            u11: m11 - centroid_x * m10,
717            u02: m02 - centroid_x * m01,
718            u20: m20 - centroid_y * m10,
719        }
720    }
721
722    fn assert_moments_eq(got: &Moments, want: &Moments) {
723        // The raw moments are exact integer totals (well under 2^53) for every
724        // image built below, so f64 accumulation is exact regardless of
725        // summation order and both paths cast to the same f32. The central
726        // moments are then derived with the identical f32 formula, so equality
727        // is exact (no tolerance needed). A wrong coefficient or a swapped
728        // m02/m20 term would change these values and fail the check.
729        assert_eq!(got.m00, want.m00, "m00");
730        assert_eq!(got.m01, want.m01, "m01");
731        assert_eq!(got.m10, want.m10, "m10");
732        assert_eq!(got.centroid_x, want.centroid_x, "centroid_x");
733        assert_eq!(got.centroid_y, want.centroid_y, "centroid_y");
734        assert_eq!(got.u11, want.u11, "u11");
735        assert_eq!(got.u02, want.u02, "u02");
736        assert_eq!(got.u20, want.u20, "u20");
737    }
738
739    /// Cross-check the single-pass `calculate_moments` (in particular its fused
740    /// `m11`/`m02`/`m20` terms) against the per-moment `spatial_moment` oracle
741    /// on the same nontrivial, strided, and large images the other tests use.
742    #[test]
743    fn test_calculate_moments_matches_oracle() {
744        // Strided (stride 24 != width 20), varied pixel values, with data both
745        // in the final valid column and outside the width. Mirrors
746        // `test_image_moments_remainder`.
747        {
748            const STRIDE: usize = 24;
749            const W: usize = 20;
750            const H: usize = 20;
751            let mut image_data = vec![0u8; STRIDE * H];
752            image_data[4 * STRIDE + 3] = 20;
753            image_data[5 * STRIDE + 3] = 21;
754            image_data[5 * STRIDE + 4] = 22;
755            image_data[6 * STRIDE + 4] = 23;
756            image_data[4 * STRIDE + 19] = 1;
757            image_data[5 * STRIDE + 19] = 1;
758            image_data[6 * STRIDE + 19] = 1;
759            // Out-of-width data that must be ignored.
760            image_data[4 * STRIDE + 23] = 255;
761            image_data[5 * STRIDE + 23] = 255;
762
763            let im =
764                machine_vision_formats::owned::OImage::new(W as u32, H as u32, STRIDE, image_data)
765                    .unwrap();
766            assert_moments_eq(&calculate_moments(&im), &oracle_moments(&im));
767        }
768
769        // Very wide image (stride 10000, width 9000), exercising large-magnitude
770        // f64 accumulation before the f32 cast. Mirrors
771        // `test_wide_image_moments_simd`.
772        {
773            const STRIDE: usize = 10000;
774            const W: usize = 9000;
775            const H: usize = 20;
776            let mut image_data = vec![u8::MAX; STRIDE * H];
777            image_data[W + 23] = 0;
778            image_data[W + 24] = 0;
779
780            let im =
781                machine_vision_formats::owned::OImage::new(W as u32, H as u32, STRIDE, image_data)
782                    .unwrap();
783            assert_moments_eq(&calculate_moments(&im), &oracle_moments(&im));
784        }
785
786        // Very tall image (10000 rows), exercising large row indices in the
787        // `m01`/`m02`/`m11` terms. Mirrors `test_tall_image_moments_simd`.
788        {
789            const STRIDE: usize = 32;
790            const W: usize = 20;
791            const H: usize = 10000;
792            let mut image_data = vec![u8::MAX; STRIDE * H];
793            image_data[W + 3] = 0;
794            image_data[W + 4] = 0;
795
796            let im =
797                machine_vision_formats::owned::OImage::new(W as u32, H as u32, STRIDE, image_data)
798                    .unwrap();
799            assert_moments_eq(&calculate_moments(&im), &oracle_moments(&im));
800        }
801    }
802}