Skip to main content

long_screen/kernels/
raster.rs

1//! Native-frame raster kernels: luma, box-filter analysis downscale, preview halving.
2
3use crate::kernels::pool::SyncPtr;
4
5/// BT.601-style integer luma `(77r + 150g + 29b) >> 8`, identical to the analysis path.
6#[inline]
7pub fn luma(r: u8, g: u8, b: u8) -> u8 {
8    ((r as u32 * 77 + g as u32 * 150 + b as u32 * 29) >> 8) as u8
9}
10
11pub fn grayscale(rgba: &[u8], out: &mut [u8]) {
12    let n = out.len().min(rgba.len() / 4);
13    let chunks = crate::kernels::pool::chunks_for(n, 256 * 1024);
14    let dst = SyncPtr(out.as_mut_ptr());
15    crate::kernels::pool::par_for(chunks, |c| {
16        let (a, b) = (
17            crate::kernels::pool::split(n, chunks, c),
18            crate::kernels::pool::split(n, chunks, c + 1),
19        );
20        // SAFETY: pixels `a..b` belong to this chunk alone.
21        let out = unsafe { std::slice::from_raw_parts_mut(dst.get().add(a), b - a) };
22        let mut sums = [0u32; 64];
23        let mut i = a;
24        while i < b {
25            let m = (b - i).min(64);
26            weighted_luma(&rgba[i * 4..(i + m) * 4], &mut sums[..m]);
27            for (o, s) in out[i - a..i - a + m].iter_mut().zip(&sums[..m]) {
28                *o = (s >> 8) as u8;
29            }
30            i += m;
31        }
32    });
33}
34
35/// `77r + 150g + 29b` per pixel (the luma numerator before `>> 8`).
36#[inline]
37pub fn weighted_luma(rgba: &[u8], out: &mut [u32]) {
38    let n = out.len();
39    #[allow(unused_mut)]
40    let mut i = 0;
41    #[cfg(target_feature = "simd128")]
42    {
43        use core::arch::wasm32::*;
44        // i16 lanes (r, g, b, a) · (77, 150, 29, 0), pairwise summed: (77r + 150g, 29b) per pixel, then added.
45        let weights = i16x8(77, 150, 29, 0, 77, 150, 29, 0);
46        while i + 4 <= n {
47            // SAFETY: four pixels (16 bytes) at `i` are in bounds.
48            let v = unsafe { v128_load(rgba.as_ptr().add(i * 4) as *const v128) };
49            let lo = i32x4_dot_i16x8(u16x8_extend_low_u8x16(v), weights);
50            let hi = i32x4_dot_i16x8(u16x8_extend_high_u8x16(v), weights);
51            let sums = i32x4_add(
52                i32x4_shuffle::<0, 2, 4, 6>(lo, hi),
53                i32x4_shuffle::<1, 3, 5, 7>(lo, hi),
54            );
55            // SAFETY: `out[i..i + 4]` is in bounds; unaligned store.
56            unsafe { v128_store(out.as_mut_ptr().add(i) as *mut v128, sums) };
57            i += 4;
58        }
59    }
60    for k in i..n {
61        let p = &rgba[k * 4..k * 4 + 3];
62        out[k] = p[0] as u32 * 77 + p[1] as u32 * 150 + p[2] as u32 * 29;
63    }
64}
65
66/// Output dimensions of an integer-factor box downscale: partial edge cells are kept, never dropped.
67#[inline]
68pub fn downscaled_size(width: usize, height: usize, factor: usize) -> (usize, usize) {
69    (
70        width.div_ceil(factor).max(1),
71        height.div_ceil(factor).max(1),
72    )
73}
74
75/// Box-filtered luma at an integer factor. The mean is taken over the luma-weighted RGB sum, then `>> 8`,
76/// exactly like the historical analysis path, so analysis features stay comparable across releases.
77///
78/// Rows are swept sequentially into one accumulator per output column (a factor-wide box), so the input is
79/// read once, in order, instead of once per output cell with a stride.
80pub fn downscale_gray(rgba: &[u8], width: usize, height: usize, factor: usize, out: &mut [u8]) {
81    let (ow, oh) = downscaled_size(width, height, factor);
82    debug_assert!(out.len() >= ow * oh);
83    if factor == 1 {
84        grayscale(&rgba[..width * height * 4], &mut out[..width * height]);
85        return;
86    }
87    // Output rows are independent; each chunk sweeps its own input rows with its own accumulator and luma row,
88    // allocated here because chunk bodies may not allocate.
89    let chunks = crate::kernels::pool::chunks_for(oh * width * factor, 128 * 1024);
90    let mut scratch = vec![0u64; chunks * ow];
91    let mut sums = vec![0u32; chunks * width];
92    let (acc_ptr, sum_ptr, dst) = (
93        SyncPtr(scratch.as_mut_ptr()),
94        SyncPtr(sums.as_mut_ptr()),
95        SyncPtr(out.as_mut_ptr()),
96    );
97    crate::kernels::pool::par_for(chunks, |c| {
98        // SAFETY: scratch slot `c` and output rows `y0..y1` belong to this chunk alone.
99        let (acc, line) = unsafe {
100            (
101                std::slice::from_raw_parts_mut(acc_ptr.get().add(c * ow), ow),
102                std::slice::from_raw_parts_mut(sum_ptr.get().add(c * width), width),
103            )
104        };
105        let full = width / factor;
106        for y in crate::kernels::pool::split(oh, chunks, c)
107            ..crate::kernels::pool::split(oh, chunks, c + 1)
108        {
109            let bh = factor.min(height - y * factor);
110            acc.fill(0);
111            for row in 0..bh {
112                let start = (y * factor + row) * width * 4;
113                weighted_luma(&rgba[start..start + width * 4], line);
114                // Whole boxes first, then the partial one; integer sums, so grouping cannot change them.
115                for (bx, cell) in line[..full * factor].chunks_exact(factor).enumerate() {
116                    acc[bx] += cell.iter().sum::<u32>() as u64;
117                }
118                if full < ow {
119                    acc[full] += line[full * factor..].iter().sum::<u32>() as u64;
120                }
121            }
122            let out_row = unsafe { std::slice::from_raw_parts_mut(dst.get().add(y * ow), ow) };
123            for (x, o) in out_row.iter_mut().enumerate() {
124                let bw = factor.min(width - x * factor);
125                *o = ((acc[x] / (bw * bh) as u64) >> 8) as u8;
126            }
127        }
128    });
129}
130
131/// `Uint8ClampedArray` assignment: clamp to 0..=255 and round halves to even.
132#[inline]
133fn clamp_u8(value: f64) -> u8 {
134    if value.is_nan() || value <= 0.0 {
135        0
136    } else if value >= 255.0 {
137        255
138    } else {
139        value.round_ties_even() as u8
140    }
141}
142
143/// 2:1 alpha-weighted preview reduction; unobserved (transparent) neighbours never darken observed pixels.
144pub fn halve_rgba(rgba: &[u8], width: usize, height: usize, out: &mut [u8]) -> (usize, usize) {
145    let ow = (width >> 1).max(1);
146    let oh = (height >> 1).max(1);
147    for y in 0..oh {
148        for x in 0..ow {
149            let (mut r, mut g, mut b, mut a) = (0u32, 0u32, 0u32, 0u32);
150            for j in 0..2 {
151                for k in 0..2 {
152                    let sx = (x * 2 + k).min(width - 1);
153                    let sy = (y * 2 + j).min(height - 1);
154                    let i = (sy * width + sx) * 4;
155                    let w = rgba[i + 3] as u32;
156                    r += rgba[i] as u32 * w;
157                    g += rgba[i + 1] as u32 * w;
158                    b += rgba[i + 2] as u32 * w;
159                    a += w;
160                }
161            }
162            let o = (y * ow + x) * 4;
163            if a > 0 {
164                out[o] = clamp_u8(r as f64 / a as f64);
165                out[o + 1] = clamp_u8(g as f64 / a as f64);
166                out[o + 2] = clamp_u8(b as f64 / a as f64);
167                out[o + 3] = clamp_u8(a as f64 / 4.0);
168            } else {
169                out[o..o + 4].fill(0);
170            }
171        }
172    }
173    (ow, oh)
174}
175
176/// Mean absolute difference of two equal-size grey images (255 when sizes differ).
177pub fn mean_difference(a: &[u8], b: &[u8]) -> f64 {
178    if a.len() != b.len() || a.is_empty() {
179        return 255.0;
180    }
181    let sum: u64 = a.iter().zip(b).map(|(x, y)| x.abs_diff(*y) as u64).sum();
182    sum as f64 / a.len() as f64
183}
184
185/// Refreshes the saved pixels of a fixed (screen-anchored) region from `rgba` and reports whether any pixel the
186/// region owns changed. `saved` is `rw × rh` RGBA laid out from the region's floored origin `(x0, y0)`; pixels
187/// outside the frame or not labelled `code` are neither compared nor written. Rows are split across the pool.
188#[allow(clippy::too_many_arguments)]
189pub fn fixed_update(
190    saved: &mut [u8],
191    rgba: &[u8],
192    labels: &[u8],
193    width: usize,
194    height: usize,
195    x0: i64,
196    y0: i64,
197    rw: usize,
198    rh: usize,
199    code: u8,
200) -> bool {
201    let (w, h) = (width as i64, height as i64);
202    let (lo, hi) = (x0.max(0), (x0 + rw as i64).min(w));
203    if hi <= lo {
204        return false;
205    }
206    let chunks = crate::kernels::pool::chunks_for(rh * rw, 64 * 1024);
207    let mut changed = vec![false; chunks];
208    let (dst, flags) = (SyncPtr(saved.as_mut_ptr()), SyncPtr(changed.as_mut_ptr()));
209    crate::kernels::pool::par_for(chunks, |c| {
210        let mut any = false;
211        for y in crate::kernels::pool::split(rh, chunks, c)
212            ..crate::kernels::pool::split(rh, chunks, c + 1)
213        {
214            let ny = y0 + y as i64;
215            if ny < 0 || ny >= h {
216                continue;
217            }
218            // SAFETY: saved row `y` belongs to this chunk alone.
219            let row = unsafe { std::slice::from_raw_parts_mut(dst.get().add(y * rw * 4), rw * 4) };
220            let base = ny as usize * width;
221            for nx in lo..hi {
222                let i = base + nx as usize;
223                if labels[i] != code {
224                    continue;
225                }
226                let j = (nx - x0) as usize * 4;
227                let src = &rgba[i * 4..i * 4 + 4];
228                if row[j..j + 4] != *src {
229                    row[j..j + 4].copy_from_slice(src);
230                    any = true;
231                }
232            }
233        }
234        // SAFETY: one flag per chunk.
235        unsafe { *flags.get().add(c) = any };
236    });
237    changed.iter().any(|&c| c)
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243
244    #[test]
245    fn downscale_keeps_partial_cells() {
246        let (w, h) = (5usize, 3usize);
247        let rgba: Vec<u8> = (0..w * h)
248            .flat_map(|i| [(i * 40) as u8, 0, 0, 255])
249            .collect();
250        let (ow, oh) = downscaled_size(w, h, 2);
251        assert_eq!((ow, oh), (3, 2));
252        let mut out = vec![0; ow * oh];
253        downscale_gray(&rgba, w, h, 2, &mut out);
254        // Last column is a single-pixel-wide box covering pixels 4 and 9 (red 160 and 104 after u8 wrap);
255        // the last row (pixels 10..15) is a single-pixel-high box.
256        let cell = |a: u64, b: u64| (((a * 77 + b * 77) / 2) >> 8) as u8;
257        assert_eq!(out[2], cell(160, 104));
258        assert_eq!(out[3], cell((10 * 40) % 256, (11 * 40) % 256));
259        assert_eq!(out[5], ((((14 * 40) % 256) as u64 * 77) >> 8) as u8);
260    }
261
262    #[test]
263    fn clamped_rounding_is_half_to_even() {
264        assert_eq!(clamp_u8(0.5), 0);
265        assert_eq!(clamp_u8(1.5), 2);
266        assert_eq!(clamp_u8(2.5), 2);
267        assert_eq!(clamp_u8(300.0), 255);
268    }
269}