Skip to main content

vyre_libs/visual/blur/
mod.rs

1//! Two-dispatch separable Gaussian blur.
2//!
3//! Composes `vyre_primitives::math::conv1d` for horizontal + vertical
4//! passes. The approach: since conv1d operates on scalar u32 values
5//! but pixels are packed RGBA, we process the image as a flat array
6//! of u32 values where each pixel's channels are handled by the
7//! per-channel unpack→convolve→repack strategy.
8//!
9//! For initial simplicity, we inline the convolution directly (pure IR)
10//! and compose the conv1d primitive's node as the inner kernel.
11//!
12//! Category A composition  -  composes Tier 2.5 `math::conv1d`.
13
14use std::sync::Arc;
15
16use vyre_foundation::ir::model::expr::{GeneratorRef, Ident};
17use vyre_foundation::ir::{BufferAccess, BufferDecl, DataType, Expr, Node, Program};
18
19const OP_ID: &str = "vyre-libs::visual::blur";
20
21/// Build the two dispatches for a separable Gaussian blur.
22///
23/// Since `conv1d` operates on scalar u32 values but our pixels are
24/// packed RGBA, this composition:
25/// 1. Dispatches per-pixel with 2D grid
26/// 2. For each pixel, manually reads the horizontal/vertical
27///    neighbors, unpacks per channel, convolves, and repacks
28///
29/// The composition wraps `conv1d_node` as a tagged child region
30/// for composition tracking, even though the pixel unpacking is
31/// handled by this composition's own IR.
32///
33/// # Parameters
34/// - `width`, `height`: image dimensions
35/// - `radius`: blur kernel half-width
36/// - `sigma`: Gaussian sigma
37#[must_use]
38pub fn gaussian_blur_2pass(
39    input: &str,
40    output: &str,
41    scratch: &str,
42    width: u32,
43    height: u32,
44    radius: u32,
45    sigma: f32,
46) -> GaussianBlurStages {
47    let kernel = GaussianKernel::new(radius, sigma);
48    gaussian_blur_2pass_with_kernel(input, output, scratch, width, height, &kernel)
49}
50
51/// Build the two dispatches for a separable Gaussian blur using precomputed
52/// weights.
53///
54/// Hot paths that rebuild programs for multiple buffers or frames should keep
55/// a `GaussianKernel` and pass it here instead of recomputing the same
56/// fixed-point weights on every build.
57#[must_use]
58pub fn gaussian_blur_2pass_with_kernel(
59    input: &str,
60    output: &str,
61    scratch: &str,
62    width: u32,
63    height: u32,
64    kernel: &GaussianKernel,
65) -> GaussianBlurStages {
66    GaussianBlurStages {
67        horizontal: gaussian_blur_pass(
68            input,
69            scratch,
70            width,
71            height,
72            kernel.radius(),
73            kernel.weights(),
74            Axis::Horizontal,
75        ),
76        vertical: gaussian_blur_pass(
77            scratch,
78            output,
79            width,
80            height,
81            kernel.radius(),
82            kernel.weights(),
83            Axis::Vertical,
84        ),
85    }
86}
87
88/// Reusable fixed-point Gaussian blur kernel.
89#[derive(Debug, Clone, PartialEq, Eq)]
90pub struct GaussianKernel {
91    radius: u32,
92    weights: Vec<u32>,
93}
94
95impl GaussianKernel {
96    /// Precompute weights for a Gaussian blur radius and sigma.
97    #[must_use]
98    pub fn new(radius: u32, sigma: f32) -> Self {
99        let clamped = radius.min(vyre_primitives::math::conv1d::MAX_RADIUS);
100        Self {
101            radius: clamped,
102            weights: vyre_primitives::math::conv1d::gaussian_weights(clamped, sigma),
103        }
104    }
105
106    /// Build a kernel from caller-owned precomputed fixed-point weights.
107    ///
108    /// # Errors
109    ///
110    /// Returns an actionable error when `weights.len()` does not match
111    /// `2 * min(radius, MAX_RADIUS) + 1`.
112    pub fn from_weights(radius: u32, weights: Vec<u32>) -> Result<Self, GaussianKernelError> {
113        let clamped = radius.min(vyre_primitives::math::conv1d::MAX_RADIUS);
114        let expected = (2 * clamped + 1) as usize;
115        if weights.len() != expected {
116            return Err(GaussianKernelError {
117                radius: clamped,
118                expected,
119                actual: weights.len(),
120            });
121        }
122        Ok(Self {
123            radius: clamped,
124            weights,
125        })
126    }
127
128    /// Clamped kernel radius.
129    #[must_use]
130    pub const fn radius(&self) -> u32 {
131        self.radius
132    }
133
134    /// Fixed-point 16.16 weights.
135    #[must_use]
136    pub fn weights(&self) -> &[u32] {
137        &self.weights
138    }
139}
140
141/// Invalid reusable Gaussian kernel shape.
142#[derive(Debug, Clone, PartialEq, Eq)]
143pub struct GaussianKernelError {
144    /// Clamped radius requested by the caller.
145    pub radius: u32,
146    /// Expected weight count.
147    pub expected: usize,
148    /// Actual weight count supplied.
149    pub actual: usize,
150}
151
152impl std::fmt::Display for GaussianKernelError {
153    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
154        write!(
155            f,
156            "invalid Gaussian kernel for radius {}: expected {} weights, got {}. Fix: supply 2 * radius + 1 fixed-point weights.",
157            self.radius, self.expected, self.actual
158        )
159    }
160}
161
162impl std::error::Error for GaussianKernelError {}
163
164/// The two dispatches that make up a correct separable Gaussian blur.
165#[derive(Debug)]
166pub struct GaussianBlurStages {
167    /// Horizontal pass: `input -> scratch`.
168    pub horizontal: Program,
169    /// Vertical pass: `scratch -> output`.
170    pub vertical: Program,
171}
172
173impl GaussianBlurStages {
174    /// Number of dispatches required for global correctness.
175    #[must_use]
176    pub const fn stage_count(&self) -> usize {
177        2
178    }
179
180    /// Programs in dispatch order.
181    #[must_use]
182    pub fn programs(&self) -> [&Program; 2] {
183        [&self.horizontal, &self.vertical]
184    }
185}
186
187#[derive(Clone, Copy)]
188enum Axis {
189    Horizontal,
190    Vertical,
191}
192
193fn gaussian_blur_pass(
194    input: &str,
195    output: &str,
196    width: u32,
197    height: u32,
198    radius: u32,
199    weights: &[u32],
200    axis: Axis,
201) -> Program {
202    let clamped = radius.min(vyre_primitives::math::conv1d::MAX_RADIUS);
203    let diameter = 2 * clamped + 1;
204    let count = width.saturating_mul(height);
205    let is_horiz = matches!(axis, Axis::Horizontal);
206    let dim = if is_horiz {
207        width.max(1)
208    } else {
209        height.max(1)
210    };
211    let parent = GeneratorRef {
212        name: OP_ID.to_string(),
213    };
214
215    // The per-pixel blur body: for each channel, run a weighted sum
216    // over the kernel window, reading neighbors along the given axis.
217    let blur_pass = Node::Region {
218        generator: Ident::from(vyre_primitives::math::conv1d::OP_ID),
219        source_region: Some(parent),
220        body: Arc::new(vec![
221            Node::let_bind("idx", Expr::gid_x()),
222            Node::if_then(Expr::lt(Expr::var("idx"), Expr::u32(count)), {
223                let mut body = vec![
224                    Node::let_bind("px", Expr::rem(Expr::var("idx"), Expr::u32(width.max(1)))),
225                    Node::let_bind("py", Expr::div(Expr::var("idx"), Expr::u32(width.max(1)))),
226                    // Accumulators per channel (fixed-point).
227                    Node::let_bind("acc_r", Expr::u32(0)),
228                    Node::let_bind("acc_g", Expr::u32(0)),
229                    Node::let_bind("acc_b", Expr::u32(0)),
230                    Node::let_bind("acc_a", Expr::u32(0)),
231                ];
232
233                // Kernel loop: manually unrolled weight application.
234                // We bake the weights as constants.
235                for k in 0..diameter {
236                    let w_val = weights[k as usize];
237                    if w_val == 0 {
238                        continue;
239                    }
240                    // Sample coordinate: clamp(coord + k - radius, 0, dim-1)
241                    let offset = k as i32 - clamped as i32;
242                    let sample_coord = if is_horiz {
243                        // sx = clamp(px + offset, 0, width-1)
244                        if offset >= 0 {
245                            Expr::select(
246                                Expr::lt(
247                                    Expr::add(Expr::var("px"), Expr::u32(offset as u32)),
248                                    Expr::u32(dim),
249                                ),
250                                Expr::add(Expr::var("px"), Expr::u32(offset as u32)),
251                                Expr::u32(dim - 1),
252                            )
253                        } else {
254                            Expr::select(
255                                Expr::ge(Expr::var("px"), Expr::u32((-offset) as u32)),
256                                Expr::sub(Expr::var("px"), Expr::u32((-offset) as u32)),
257                                Expr::u32(0),
258                            )
259                        }
260                    } else {
261                        // sy = clamp(py + offset, 0, height-1)
262                        if offset >= 0 {
263                            Expr::select(
264                                Expr::lt(
265                                    Expr::add(Expr::var("py"), Expr::u32(offset as u32)),
266                                    Expr::u32(dim),
267                                ),
268                                Expr::add(Expr::var("py"), Expr::u32(offset as u32)),
269                                Expr::u32(dim - 1),
270                            )
271                        } else {
272                            Expr::select(
273                                Expr::ge(Expr::var("py"), Expr::u32((-offset) as u32)),
274                                Expr::sub(Expr::var("py"), Expr::u32((-offset) as u32)),
275                                Expr::u32(0),
276                            )
277                        }
278                    };
279
280                    // Pixel index: sample_coord used for the varying axis.
281                    let pixel_idx = if is_horiz {
282                        Expr::add(Expr::mul(Expr::var("py"), Expr::u32(width)), sample_coord)
283                    } else {
284                        Expr::add(Expr::mul(sample_coord, Expr::u32(width)), Expr::var("px"))
285                    };
286
287                    let tap_name = format!("tap_{k}");
288                    body.push(Node::let_bind(&tap_name, Expr::load(input, pixel_idx)));
289
290                    // Unpack and accumulate each channel.
291                    body.push(Node::assign(
292                        "acc_r",
293                        Expr::add(
294                            Expr::var("acc_r"),
295                            Expr::mul(
296                                Expr::bitand(Expr::var(&tap_name), Expr::u32(0xFF)),
297                                Expr::u32(w_val),
298                            ),
299                        ),
300                    ));
301                    body.push(Node::assign(
302                        "acc_g",
303                        Expr::add(
304                            Expr::var("acc_g"),
305                            Expr::mul(
306                                Expr::bitand(
307                                    Expr::shr(Expr::var(&tap_name), Expr::u32(8)),
308                                    Expr::u32(0xFF),
309                                ),
310                                Expr::u32(w_val),
311                            ),
312                        ),
313                    ));
314                    body.push(Node::assign(
315                        "acc_b",
316                        Expr::add(
317                            Expr::var("acc_b"),
318                            Expr::mul(
319                                Expr::bitand(
320                                    Expr::shr(Expr::var(&tap_name), Expr::u32(16)),
321                                    Expr::u32(0xFF),
322                                ),
323                                Expr::u32(w_val),
324                            ),
325                        ),
326                    ));
327                    body.push(Node::assign(
328                        "acc_a",
329                        Expr::add(
330                            Expr::var("acc_a"),
331                            Expr::mul(
332                                Expr::shr(Expr::var(&tap_name), Expr::u32(24)),
333                                Expr::u32(w_val),
334                            ),
335                        ),
336                    ));
337                }
338
339                // Convert from fixed-point >> 16 and clamp to 255.
340                let shift_clamp = |acc: &str, out: &str| -> Vec<Node> {
341                    vec![
342                        Node::let_bind(out, Expr::shr(Expr::var(acc), Expr::u32(16))),
343                        Node::assign(
344                            out,
345                            Expr::select(
346                                Expr::gt(Expr::var(out), Expr::u32(255)),
347                                Expr::u32(255),
348                                Expr::var(out),
349                            ),
350                        ),
351                    ]
352                };
353                body.extend(shift_clamp("acc_r", "or"));
354                body.extend(shift_clamp("acc_g", "og"));
355                body.extend(shift_clamp("acc_b", "ob"));
356                body.extend(shift_clamp("acc_a", "oa"));
357
358                // Pack.
359                body.push(Node::let_bind(
360                    "packed",
361                    Expr::bitor(
362                        Expr::bitor(Expr::var("or"), Expr::shl(Expr::var("og"), Expr::u32(8))),
363                        Expr::bitor(
364                            Expr::shl(Expr::var("ob"), Expr::u32(16)),
365                            Expr::shl(Expr::var("oa"), Expr::u32(24)),
366                        ),
367                    ),
368                ));
369                body.push(Node::let_bind(
370                    "oidx",
371                    Expr::add(
372                        Expr::mul(Expr::var("py"), Expr::u32(width)),
373                        Expr::var("px"),
374                    ),
375                ));
376                body.push(Node::store(output, Expr::var("oidx"), Expr::var("packed")));
377                body
378            }),
379        ]),
380    };
381
382    Program::wrapped(
383        vec![
384            BufferDecl::storage(input, 0, BufferAccess::ReadOnly, DataType::U32).with_count(count),
385            BufferDecl::storage(output, 1, BufferAccess::ReadWrite, DataType::U32)
386                .with_count(count),
387        ],
388        super::PIXEL_WORKGROUP_SIZE,
389        vec![crate::region::wrap_anonymous(OP_ID, vec![blur_pass])],
390    )
391}
392
393/// Re-export weight computation from the Tier 2.5 primitive.
394pub use vyre_primitives::math::conv1d::gaussian_weights;
395
396inventory::submit! {
397    vyre_foundation::operation::OperationRegistration {
398        semantic_version: 1,
399        signature: None,
400        tier: vyre_foundation::operation::OperationTier::Library,
401        laws: &[],
402        tolerance: vyre_foundation::operation::TolerancePolicy::EXACT,
403        id: OP_ID,
404        build: Some(|| gaussian_blur_2pass("input", "output", "scratch", 4, 4, 1, 0.8).horizontal),
405        test_inputs: Some(|| {
406            // 4×4 all-white → blurred all-white (identity for uniform).
407            let pixels = vec![0xFFFF_FFFFu32; 16];
408            vec![vec![
409                crate::visual::byte_helpers::u32_words_to_le_bytes(&pixels),     // input
410                vec![0u8; 64],         // output (scratch for horizontal pass)
411            ]]
412        }),
413        expected_output: Some(|| {
414            // All-white blurred → all-white (±1).
415            let pixels = vec![0xFFFF_FFFFu32; 16];
416            vec![vec![crate::visual::byte_helpers::u32_words_to_le_bytes(&pixels)]]
417        }),
418        category: Some("visual"),
419    }
420}