Skip to main content

chess_corners_core/detect/chess/
response.rs

1//! Dense ChESS response computation for 8-bit grayscale inputs.
2use super::ring::RingOffsets;
3use crate::{ChessParams, ResponseMap};
4
5#[cfg(feature = "rayon")]
6use rayon::prelude::*;
7
8#[cfg(feature = "simd")]
9use core::simd::Simd;
10
11#[cfg(feature = "simd")]
12use std::simd::prelude::{SimdFloat, SimdInt, SimdUint};
13
14#[cfg(feature = "simd")]
15const LANES: usize = 16;
16
17#[cfg(feature = "simd")]
18type U8s = Simd<u8, LANES>;
19
20#[cfg(feature = "simd")]
21type I16s = Simd<i16, LANES>;
22
23#[cfg(feature = "simd")]
24type I32s = Simd<i32, LANES>;
25
26#[cfg(feature = "simd")]
27type F32s = Simd<f32, LANES>;
28
29#[cfg(feature = "tracing")]
30use tracing::instrument;
31
32/// Rectangular region of interest with half-open coordinate semantics
33/// `[x0, x1) × [y0, y1)`.
34///
35/// Coordinates are in image-pixel space relative to the
36/// [`ImageView`](crate::ImageView)'s `origin` offset. The invariants
37/// `x0 < x1` and `y0 < y1` are enforced by [`Roi::new`], which returns
38/// `None` when they are violated.
39#[derive(Clone, Copy, Debug)]
40pub struct Roi {
41    x0: usize,
42    y0: usize,
43    x1: usize,
44    y1: usize,
45}
46
47impl Roi {
48    /// Create a new ROI. Returns `None` if `x0 >= x1` or `y0 >= y1`.
49    pub fn new(x0: usize, y0: usize, x1: usize, y1: usize) -> Option<Self> {
50        if x0 < x1 && y0 < y1 {
51            Some(Self { x0, y0, x1, y1 })
52        } else {
53            None
54        }
55    }
56
57    /// Left edge of the ROI (inclusive).
58    #[inline]
59    pub fn x0(&self) -> usize {
60        self.x0
61    }
62    /// Top edge of the ROI (inclusive).
63    #[inline]
64    pub fn y0(&self) -> usize {
65        self.y0
66    }
67    /// Right edge of the ROI (exclusive).
68    #[inline]
69    pub fn x1(&self) -> usize {
70        self.x1
71    }
72    /// Bottom edge of the ROI (exclusive).
73    #[inline]
74    pub fn y1(&self) -> usize {
75        self.y1
76    }
77}
78
79#[inline]
80fn ring_from_params(params: &ChessParams) -> (RingOffsets, &'static [(i32, i32); 16]) {
81    let ring = params.ring();
82    (ring, ring.offsets())
83}
84
85/// Compute the dense ChESS response for an 8-bit grayscale image.
86///
87/// The response at each valid pixel center is computed from a 16‑sample ring
88/// around the pixel and a 5‑pixel cross at the center. For a given center
89/// `c`, let `s[0..16)` be the ring samples in the canonical order:
90///
91/// - `SR` (sum of “square” responses) compares four opposite quadrants on
92///   the ring:
93///
94///   ```text
95///   SR = sum_{k=0..3} | (s[k] + s[k+8]) - (s[k+4] + s[k+12]) |
96///   ```
97///
98/// - `DR` (sum of “difference” responses) enforces edge‑like structure:
99///
100///   ```text
101///   DR = sum_{k=0..7} | s[k] - s[k+8] |
102///   ```
103///
104/// - `μₙ` is the mean of all 16 ring samples.
105/// - `μₗ` is the local mean of the 5‑pixel cross at the center
106///   (`c`, `north`, `south`, `east`, `west`).
107///
108/// The final ChESS response is:
109///
110/// ```text
111/// R = SR - DR - 16 * |μₙ - μₗ|
112/// ```
113///
114/// where high positive values correspond to chessboard‑like corners.
115///
116/// # Score contract
117///
118/// `R` is the **unnormalized** ChESS response defined by Bennett and
119/// Lasenby (2014). The units are 8-bit pixel sums — not normalized
120/// to `[0, 1]`, not divided by a local scale, not divided by the
121/// scene max. Every term is linear in the pixel values, so with 8-bit
122/// input `R` is bounded by `SR − DR − 16·MR ∈ [−24·255, 8·255]`
123/// (≈ `[−6120, 2040]`); in practice chessboard corners produce scores
124/// well below that upper bound.
125///
126/// The paper's acceptance criterion is simply `R > 0`. That is what
127/// [`ChessParams`]`::default()` encodes via `threshold = 0.0`
128/// combined with a strict comparison in
129/// [`crate::detect_corners_from_response`]. The `threshold` field is an
130/// **absolute floor layered on top** of the paper's score — a
131/// convenience for trading off sensitivity vs false positives, not part
132/// of the score definition.
133///
134/// # Implementation strategy
135///
136/// Internally the image is processed row‑by‑row, but only pixels whose
137/// full ring lies inside the image bounds are evaluated; border pixels
138/// are left at zero in the returned [`ResponseMap`].
139///
140/// - Without any features, `chess_response_u8` uses a straightforward
141///   nested `for y { for x { ... } }` scalar loop and relies on the
142///   compiler’s auto‑vectorization in release builds.
143/// - With the `rayon` feature, the work is split into independent row
144///   slices and processed in parallel using `rayon::par_chunks_mut`.
145/// - With the `simd` feature, the inner loop over `x` is rewritten to
146///   operate on `LANES` pixels at a time using portable SIMD vectors
147///   (currently 16 lanes of `u8`). The ring samples and the 5‑pixel
148///   local-mean cross are gathered into SIMD registers, `SR`/`DR`/`μₙ`/`μₗ`
149///   are accumulated in vector lanes, and the final response is stored a
150///   full vector at a time.
151/// - With both `rayon` and `simd` enabled, each row is processed in
152///   parallel *and* each row uses the SIMD‑accelerated inner loop.
153///
154/// All feature combinations produce the same output values (within a
155/// small tolerance for floating‑point rounding), and differ only in
156/// performance characteristics.
157///
158/// # Panics
159///
160/// Panics if `img.len() != w * h`.
161#[cfg_attr(
162    feature = "tracing",
163    instrument(level = "info", skip(img, params), fields(w, h))
164)]
165pub fn chess_response_u8(img: &[u8], w: usize, h: usize, params: &ChessParams) -> ResponseMap {
166    assert_eq!(
167        img.len(),
168        w * h,
169        "chess_response_u8: img.len() ({}) must equal w*h ({w} * {h} = {})",
170        img.len(),
171        w * h,
172    );
173    // rayon path compiled only when feature is enabled
174    #[cfg(feature = "rayon")]
175    {
176        compute_response_parallel(img, w, h, params)
177    }
178    #[cfg(not(feature = "rayon"))]
179    {
180        compute_response_sequential(img, w, h, params)
181    }
182}
183
184/// Always uses the scalar implementation (no rayon, no SIMD); the
185/// golden reference for the SIMD-equivalence tests.
186#[cfg(all(test, feature = "simd"))]
187fn chess_response_u8_scalar(img: &[u8], w: usize, h: usize, params: &ChessParams) -> ResponseMap {
188    compute_response_sequential_scalar(img, w, h, params)
189}
190
191/// Compute the ChESS response only inside a rectangular ROI of the image.
192///
193/// The ROI is given in image coordinates [x0, x1) × [y0, y1) via [`Roi`]. The
194/// returned [`ResponseMap`] has width (x1 - x0) and height (y1 - y0), with
195/// coordinates relative to (x0, y0).
196///
197/// Pixels where the ChESS ring would go out of bounds (w.r.t. the *full*
198/// image) are left at 0.0, and will be ignored by the detector because they
199/// lie inside the border margin. Internally this reuses the same scalar,
200/// SIMD, and optional `rayon` row kernels as [`chess_response_u8`], so ROI
201/// refinement benefits from the same feature combinations as the full-frame
202/// response path.
203///
204/// # Panics
205///
206/// Panics if `img.len() != img_w * img_h` (the *full* image, not the ROI).
207#[cfg_attr(
208    feature = "tracing",
209    instrument(
210        level = "debug",
211        skip(img, params),
212        fields(img_w, img_h, roi_w = roi.x1 - roi.x0, roi_h = roi.y1 - roi.y0)
213    )
214)]
215pub fn chess_response_u8_patch(
216    img: &[u8],
217    img_w: usize,
218    img_h: usize,
219    params: &ChessParams,
220    roi: Roi,
221) -> ResponseMap {
222    assert_eq!(
223        img.len(),
224        img_w * img_h,
225        "chess_response_u8_patch: img.len() ({}) must equal img_w*img_h ({img_w} * {img_h} = {})",
226        img.len(),
227        img_w * img_h,
228    );
229    let Roi { x0, y0, x1, y1 } = roi;
230
231    // Clamp ROI to the image bounds
232    let x0 = x0.min(img_w);
233    let y0 = y0.min(img_h);
234    let x1 = x1.min(img_w);
235    let y1 = y1.min(img_h);
236
237    if x1 <= x0 || y1 <= y0 {
238        return ResponseMap {
239            w: 0,
240            h: 0,
241            data: Vec::new(),
242        };
243    }
244
245    let patch_w = x1 - x0;
246    let patch_h = y1 - y0;
247    let mut data = vec![0.0f32; patch_w * patch_h];
248
249    let (ring_kind, ring) = ring_from_params(params);
250    let r = ring_kind.radius() as i32;
251
252    // Safe region for ring centers in *global* image coordinates
253    let gx0 = r as usize;
254    let gy0 = r as usize;
255    let gx1 = img_w - r as usize;
256    let gy1 = img_h - r as usize;
257
258    for py in 0..patch_h {
259        let gy = y0 + py;
260        if gy < gy0 || gy >= gy1 {
261            continue;
262        }
263
264        // Global x-range where the ring is valid on this row.
265        let row_gx0 = x0.max(gx0);
266        let row_gx1 = x1.min(gx1);
267        if row_gx0 >= row_gx1 {
268            continue;
269        }
270
271        let row = &mut data[py * patch_w..(py + 1) * patch_w];
272        let rel_start = row_gx0 - x0;
273        let rel_end = row_gx1 - x0;
274        let dst_row = &mut row[rel_start..rel_end];
275
276        #[cfg(feature = "simd")]
277        {
278            compute_row_range_simd(img, img_w, gy as i32, ring, dst_row, row_gx0, row_gx1);
279        }
280
281        #[cfg(not(feature = "simd"))]
282        {
283            compute_row_range_scalar(img, img_w, gy as i32, ring, dst_row, row_gx0, row_gx1);
284        }
285    }
286
287    ResponseMap {
288        w: patch_w,
289        h: patch_h,
290        data,
291    }
292}
293
294#[cfg(not(feature = "rayon"))]
295fn compute_response_sequential(
296    img: &[u8],
297    w: usize,
298    h: usize,
299    params: &ChessParams,
300) -> ResponseMap {
301    let (ring_kind, ring) = ring_from_params(params);
302    let r = ring_kind.radius() as i32;
303
304    let mut data = vec![0.0f32; w * h];
305
306    // only evaluate where full ring fits
307    let x0 = r as usize;
308    let y0 = r as usize;
309    let x1 = w - r as usize;
310    let y1 = h - r as usize;
311
312    for y in y0..y1 {
313        let row = &mut data[y * w..(y + 1) * w];
314        let dst_row = &mut row[x0..x1];
315
316        #[cfg(feature = "simd")]
317        {
318            compute_row_range_simd(img, w, y as i32, ring, dst_row, x0, x1);
319        }
320
321        #[cfg(not(feature = "simd"))]
322        {
323            compute_row_range_scalar(img, w, y as i32, ring, dst_row, x0, x1);
324        }
325    }
326
327    ResponseMap { w, h, data }
328}
329
330#[cfg(all(test, feature = "simd"))]
331fn compute_response_sequential_scalar(
332    img: &[u8],
333    w: usize,
334    h: usize,
335    params: &ChessParams,
336) -> ResponseMap {
337    let (ring_kind, ring) = ring_from_params(params);
338    let r = ring_kind.radius() as i32;
339
340    let mut data = vec![0.0f32; w * h];
341
342    // only evaluate where full ring fits
343    let x0 = r as usize;
344    let y0 = r as usize;
345    let x1 = w - r as usize;
346    let y1 = h - r as usize;
347
348    for y in y0..y1 {
349        let row = &mut data[y * w..(y + 1) * w];
350        let dst_row = &mut row[x0..x1];
351        compute_row_range_scalar(img, w, y as i32, ring, dst_row, x0, x1);
352    }
353
354    ResponseMap { w, h, data }
355}
356
357#[cfg(feature = "rayon")]
358fn compute_response_parallel(img: &[u8], w: usize, h: usize, params: &ChessParams) -> ResponseMap {
359    let (ring_kind, ring) = ring_from_params(params);
360    let r = ring_kind.radius() as i32;
361    let mut data = vec![0.0f32; w * h];
362
363    // ring margin
364    let x0 = r as usize;
365    let y0 = r as usize;
366    let x1 = w - r as usize;
367    let y1 = h - r as usize;
368
369    // Parallelize over rows. We keep the exact same logic and write
370    // each row's slice independently.
371    data.par_chunks_mut(w).enumerate().for_each(|(y, row)| {
372        let y_i = y as i32;
373        if y_i < y0 as i32 || y_i >= y1 as i32 {
374            return;
375        }
376
377        let dst_row = &mut row[x0..x1];
378
379        #[cfg(feature = "simd")]
380        {
381            compute_row_range_simd(img, w, y_i, ring, dst_row, x0, x1);
382        }
383
384        #[cfg(not(feature = "simd"))]
385        {
386            compute_row_range_scalar(img, w, y_i, ring, dst_row, x0, x1);
387        }
388    });
389
390    ResponseMap { w, h, data }
391}
392
393// Fallback stub: when the `rayon` feature is off, `chess_response_u8` takes
394// the sequential branch directly and never calls `compute_response_parallel`.
395// The stub keeps the name defined so no `#[cfg]` guards are needed at call
396// sites that are themselves already gated on `#[cfg(feature = "rayon")]`.
397#[cfg(not(feature = "rayon"))]
398#[cfg_attr(not(feature = "rayon"), allow(dead_code))]
399fn compute_response_parallel(img: &[u8], w: usize, h: usize, params: &ChessParams) -> ResponseMap {
400    compute_response_sequential(img, w, h, params)
401}
402
403/// Low-level ChESS response at a single pixel center.
404///
405/// This is the scalar reference implementation used by both the sequential
406/// and SIMD paths:
407///
408/// - gathers 16 ring samples around `(x, y)` using the offsets defined in
409///   [`crate::ring`],
410/// - computes `SR`, `DR`, the ring mean `μₙ`, and the 5‑pixel local mean
411///   `μₗ`, and
412/// - returns `R = SR - DR - 16 * |μₙ - μₗ|` as a `f32`.
413///
414/// Callers are responsible for ensuring that `(x, y)` is far enough from the
415/// image border so that all ring and 5‑pixel cross accesses are in‑bounds.
416#[inline(always)]
417fn chess_response_at_u8(img: &[u8], w: usize, x: i32, y: i32, ring: &[(i32, i32); 16]) -> f32 {
418    // gather ring samples into i32
419    let mut s = [0i32; 16];
420    for k in 0..16 {
421        let (dx, dy) = ring[k];
422        let xx = (x + dx) as usize;
423        let yy = (y + dy) as usize;
424        s[k] = img[yy * w + xx] as i32;
425    }
426
427    // SR
428    let mut sr = 0i32;
429    for k in 0..4 {
430        let a = s[k] + s[k + 8];
431        let b = s[k + 4] + s[k + 12];
432        sr += (a - b).abs();
433    }
434
435    // DR
436    let mut dr = 0i32;
437    for k in 0..8 {
438        dr += (s[k] - s[k + 8]).abs();
439    }
440
441    // neighbor mean
442    let sum_ring: i32 = s.iter().sum();
443    let mu_n = sum_ring as f32 / 16.0;
444
445    // local mean (5 px cross)
446    let c = img[(y as usize) * w + (x as usize)] as f32;
447    let n = img[((y - 1) as usize) * w + (x as usize)] as f32;
448    let s0 = img[((y + 1) as usize) * w + (x as usize)] as f32;
449    let e = img[(y as usize) * w + ((x + 1) as usize)] as f32;
450    let w0 = img[(y as usize) * w + ((x - 1) as usize)] as f32;
451    let mu_l = (c + n + s0 + e + w0) / 5.0;
452
453    let mr = (mu_n - mu_l).abs();
454
455    (sr as f32) - (dr as f32) - 16.0 * mr
456}
457
458// The scalar row kernel is the production path when SIMD is off; in a
459// SIMD build the dense paths use `compute_row_range_simd`, so the scalar
460// kernel survives only as the golden reference behind the
461// `#[cfg(all(test, feature = "simd"))]` scalar response above.
462#[cfg(any(not(feature = "simd"), test))]
463fn compute_row_range_scalar(
464    img: &[u8],
465    w: usize,
466    y: i32,
467    ring: &[(i32, i32); 16],
468    dst_row: &mut [f32],
469    x_start: usize,
470    x_end: usize,
471) {
472    for (offset, x) in (x_start..x_end).enumerate() {
473        dst_row[offset] = chess_response_at_u8(img, w, x as i32, y, ring);
474    }
475}
476
477#[cfg(feature = "simd")]
478fn compute_row_range_simd(
479    img: &[u8],
480    w: usize,
481    y: i32,
482    ring: &[(i32, i32); 16],
483    dst_row: &mut [f32],
484    x_start: usize,
485    x_end: usize,
486) {
487    let y_usize = y as usize;
488
489    // Precompute row bases for each ring sample to avoid recomputing (y+dy)*w.
490    let mut ring_bases: [isize; 16] = [0; 16];
491    for k in 0..16 {
492        let (dx, dy) = ring[k];
493        let yy = (y + dy) as usize;
494        ring_bases[k] = (yy * w) as isize + dx as isize;
495    }
496
497    let mut x = x_start;
498
499    while x + LANES <= x_end {
500        // Gather ring samples for LANES pixels starting at x
501        let mut s: [I16s; 16] = [I16s::splat(0); 16];
502
503        for k in 0..16 {
504            let base = (ring_bases[k] + x as isize) as usize;
505
506            // SAFETY: x range + radius guarantees we stay in bounds
507            let v_u8 = U8s::from_slice(&img[base..base + LANES]);
508            s[k] = v_u8.cast::<i16>();
509        }
510
511        // Sum of ring values (for neighbor mean)
512        let mut sum_ring_v = I32s::splat(0);
513        for &v in &s {
514            sum_ring_v += v.cast::<i32>();
515        }
516
517        // SR
518        let mut sr_v = I32s::splat(0);
519        for k in 0..4 {
520            let a = s[k].cast::<i32>() + s[k + 8].cast::<i32>();
521            let b = s[k + 4].cast::<i32>() + s[k + 12].cast::<i32>();
522            sr_v += (a - b).abs();
523        }
524
525        // DR
526        let mut dr_v = I32s::splat(0);
527        for k in 0..8 {
528            let a = s[k].cast::<i32>();
529            let b = s[k + 8].cast::<i32>();
530            dr_v += (a - b).abs();
531        }
532
533        // Local mean of the 5-pixel cross for all LANES centers at once.
534        // Center/north/south lie at the lane columns; east/west are the
535        // same rows shifted by +/-1 column. Every term is therefore a
536        // contiguous LANES-wide run, so the cross reduces to five slice
537        // loads (the same bytes the scalar path read per lane) with no
538        // per-lane gather.
539        let row_c = y_usize * w + x;
540        let c_v = U8s::from_slice(&img[row_c..row_c + LANES]).cast::<i16>();
541        let n_v = U8s::from_slice(&img[row_c - w..row_c - w + LANES]).cast::<i16>();
542        let s_v = U8s::from_slice(&img[row_c + w..row_c + w + LANES]).cast::<i16>();
543        let e_v = U8s::from_slice(&img[row_c + 1..row_c + 1 + LANES]).cast::<i16>();
544        let w_v = U8s::from_slice(&img[row_c - 1..row_c - 1 + LANES]).cast::<i16>();
545
546        // Sum of the five 0..=255 samples fits in i16 (<= 1275) and is
547        // exact in f32, so casting the integer sum and dividing by 5.0
548        // reproduces the scalar `(c + n + s0 + e + w0) / 5.0` bit-for-bit.
549        let local_sum = c_v + n_v + s_v + e_v + w_v;
550
551        let mu_n = sum_ring_v.cast::<f32>() / F32s::splat(16.0);
552        let mu_l = local_sum.cast::<f32>() / F32s::splat(5.0);
553        let mr = (mu_n - mu_l).abs();
554
555        // R = SR - DR - 16*MR, lane-wise in the same operation order as the
556        // scalar path (plain mul/sub, no fused multiply-add), stored to the
557        // contiguous output run.
558        let resp = sr_v.cast::<f32>() - dr_v.cast::<f32>() - F32s::splat(16.0) * mr;
559        let px = x - x_start;
560        resp.copy_to_slice(&mut dst_row[px..px + LANES]);
561
562        x += LANES;
563    }
564
565    // Tail: scalar for remaining pixels
566    while x < x_end {
567        let px = x - x_start;
568        let resp = chess_response_at_u8(img, w, x as i32, y, ring);
569        dst_row[px] = resp;
570        x += 1;
571    }
572}
573
574#[cfg(test)]
575mod tests {
576    use super::*;
577    use crate::detect::chess::ring::RING5;
578
579    fn idx(w: usize, x: usize, y: usize) -> usize {
580        y * w + x
581    }
582
583    #[test]
584    #[should_panic(expected = "chess_response_u8: img.len()")]
585    fn chess_response_u8_panics_on_dimension_mismatch() {
586        let img = vec![0u8; 10];
587        let params = ChessParams::default();
588        let _ = chess_response_u8(&img, 4, 4, &params);
589    }
590
591    #[test]
592    #[should_panic(expected = "chess_response_u8_patch: img.len()")]
593    fn chess_response_u8_patch_panics_on_dimension_mismatch() {
594        let img = vec![0u8; 10];
595        let params = ChessParams::default();
596        let roi = Roi::new(0, 0, 2, 2).unwrap();
597        let _ = chess_response_u8_patch(&img, 4, 4, &params, roi);
598    }
599
600    #[test]
601    fn response_matches_manual_ring_layout() {
602        let params = ChessParams::default();
603        let w = 11usize;
604        let h = 11usize;
605        let cx = 5usize;
606        let cy = 5usize;
607        let mut img = vec![0u8; w * h];
608
609        // Populate the 16 ring samples with the sequence 0..15.
610        for (i, (dx, dy)) in RING5.iter().enumerate() {
611            let x = (cx as i32 + dx) as usize;
612            let y = (cy as i32 + dy) as usize;
613            img[idx(w, x, y)] = i as u8;
614        }
615
616        // Fill the 5-pixel cross used in the local mean with distinct values.
617        for (dx, dy, v) in [
618            (0, 0, 10u8),
619            (0, -1, 20u8),
620            (0, 1, 30u8),
621            (1, 0, 40u8),
622            (-1, 0, 50u8),
623        ] {
624            let x = (cx as i32 + dx) as usize;
625            let y = (cy as i32 + dy) as usize;
626            img[idx(w, x, y)] = v;
627        }
628
629        let resp = chess_response_u8(&img, w, h, &params);
630        let center = resp.at(cx, cy);
631
632        // Expected value computed from the ring/cross assignments above.
633        let expected = -392.0_f32;
634        assert!(
635            (center - expected).abs() < 1e-3,
636            "expected center response {expected}, got {center}"
637        );
638
639        for (i, v) in resp.data().iter().enumerate() {
640            if i == idx(w, cx, cy) {
641                continue;
642            }
643            assert!(
644                v.abs() < 1e-6,
645                "non-center response should stay zero (idx={i}, val={v})"
646            );
647        }
648    }
649
650    #[cfg(feature = "simd")]
651    #[test]
652    fn simd_matches_scalar_reasonably() {
653        let params = ChessParams::default();
654        let img = image::GrayImage::from_fn(256, 256, |x, y| image::Luma([(x ^ y) as u8]));
655        let w = img.width() as usize;
656        let h = img.height() as usize;
657
658        let ref_map = chess_response_u8_scalar(img.as_raw(), w, h, &params);
659        let simd_map = chess_response_u8(img.as_raw(), w, h, &params);
660
661        let eps = 1e-3_f32;
662        for (a, b) in ref_map.data().iter().zip(simd_map.data().iter()) {
663            assert!((a - b).abs() <= eps, "diff: {a} vs {b}");
664        }
665    }
666
667    #[cfg(all(feature = "simd", feature = "rayon"))]
668    #[test]
669    fn simd_parallel_matches_scalar() {
670        let params = ChessParams::default();
671        let img = image::GrayImage::from_fn(192, 192, |x, y| {
672            image::Luma([(x.wrapping_mul(7) ^ y) as u8])
673        });
674        let w = img.width() as usize;
675        let h = img.height() as usize;
676
677        let ref_map = chess_response_u8_scalar(img.as_raw(), w, h, &params);
678        let simd_map = chess_response_u8(img.as_raw(), w, h, &params);
679
680        let eps = 1e-3_f32;
681        for (a, b) in ref_map.data().iter().zip(simd_map.data().iter()) {
682            assert!((a - b).abs() <= eps, "diff: {a} vs {b}");
683        }
684    }
685}