Skip to main content

trueno_image/
conv.rs

1//! 2D convolution with border handling.
2//!
3//! # Contract: image-conv2d-v1.yaml
4//!
5//! O(i,j) = Σ_p Σ_q I(i+p, j+q) · K(p, q)
6//!
7//! ## Proof obligations
8//! - Output dimensions match input (same-padding)
9//! - Separable kernel produces same result as 2D convolution
10//! - Identity kernel preserves input
11
12use crate::error::ImageError;
13
14/// Border handling mode for convolution.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
16pub enum BorderMode {
17    /// Zero-pad outside image bounds.
18    #[default]
19    Zero,
20    /// Clamp to edge pixel values.
21    Clamp,
22    /// Reflect at image boundary.
23    Reflect,
24    /// Wrap (periodic boundary).
25    Wrap,
26}
27
28/// 2D convolution with same-padding.
29///
30/// # Contract: image-conv2d-v1.yaml / conv2d
31///
32/// Kernel is row-major, dimensions kw×kh (both must be odd).
33/// Output has same dimensions as input.
34///
35/// # Errors
36///
37/// Returns error on invalid dimensions or buffer mismatch.
38pub fn conv2d(
39    image: &[f32],
40    width: usize,
41    height: usize,
42    kernel: &[f32],
43    kw: usize,
44    kh: usize,
45    border: BorderMode,
46) -> Result<Vec<f32>, ImageError> {
47    validate_conv_args(image, width, height, kernel, kw, kh)?;
48
49    let half_kw = kw / 2;
50    let half_kh = kh / 2;
51    let mut output = vec![0.0f32; width * height];
52
53    for y in 0..height {
54        for x in 0..width {
55            let mut sum = 0.0f64;
56            for ky in 0..kh {
57                for kx in 0..kw {
58                    let iy = y as isize + ky as isize - half_kh as isize;
59                    let ix = x as isize + kx as isize - half_kw as isize;
60
61                    let pixel = sample_border(image, width, height, ix, iy, border);
62                    sum += f64::from(pixel) * f64::from(kernel[ky * kw + kx]);
63                }
64            }
65            output[y * width + x] = sum as f32;
66        }
67    }
68
69    Ok(output)
70}
71
72/// Separable 2D convolution: apply horizontal then vertical 1D kernels.
73///
74/// Equivalent to conv2d(image, outer_product(v, h)) but O(kw + kh) per pixel
75/// instead of O(kw * kh).
76///
77/// # Errors
78///
79/// Returns error on invalid dimensions.
80pub fn separable_conv2d(
81    image: &[f32],
82    width: usize,
83    height: usize,
84    h_kernel: &[f32],
85    v_kernel: &[f32],
86    border: BorderMode,
87) -> Result<Vec<f32>, ImageError> {
88    if width == 0 || height == 0 {
89        return Err(ImageError::ZeroDimension { width, height });
90    }
91    if image.len() != width * height {
92        return Err(ImageError::BufferLengthMismatch {
93            expected: width * height,
94            got: image.len(),
95            width,
96            height,
97        });
98    }
99
100    // Horizontal pass
101    let hk = h_kernel.len();
102    let half_hk = hk / 2;
103    let mut temp = vec![0.0f32; width * height];
104
105    for y in 0..height {
106        for x in 0..width {
107            let mut sum = 0.0f64;
108            for k in 0..hk {
109                let ix = x as isize + k as isize - half_hk as isize;
110                let pixel = sample_border(image, width, height, ix, y as isize, border);
111                sum += f64::from(pixel) * f64::from(h_kernel[k]);
112            }
113            temp[y * width + x] = sum as f32;
114        }
115    }
116
117    // Vertical pass
118    let vk = v_kernel.len();
119    let half_vk = vk / 2;
120    let mut output = vec![0.0f32; width * height];
121
122    for y in 0..height {
123        for x in 0..width {
124            let mut sum = 0.0f64;
125            for k in 0..vk {
126                let iy = y as isize + k as isize - half_vk as isize;
127                let pixel = sample_border(&temp, width, height, x as isize, iy, border);
128                sum += f64::from(pixel) * f64::from(v_kernel[k]);
129            }
130            output[y * width + x] = sum as f32;
131        }
132    }
133
134    Ok(output)
135}
136
137/// Gaussian blur using separable convolution.
138///
139/// # Errors
140///
141/// Returns error on invalid dimensions.
142pub fn gaussian_blur(
143    image: &[f32],
144    width: usize,
145    height: usize,
146    sigma: f32,
147) -> Result<Vec<f32>, ImageError> {
148    let kernel = gaussian_kernel_1d(sigma);
149    separable_conv2d(image, width, height, &kernel, &kernel, BorderMode::Clamp)
150}
151
152/// Generate a 1D Gaussian kernel with the given sigma.
153///
154/// Kernel size = ceil(6*sigma) | 1 (minimum 3, always odd).
155#[allow(clippy::cast_precision_loss)]
156fn gaussian_kernel_1d(sigma: f32) -> Vec<f32> {
157    let radius = ((3.0 * sigma).ceil() as usize).max(1);
158    let size = 2 * radius + 1;
159    let sigma_sq = f64::from(sigma) * f64::from(sigma);
160
161    let mut kernel = vec![0.0f32; size];
162    let mut sum = 0.0f64;
163
164    for i in 0..size {
165        let x = i as f64 - radius as f64;
166        let v = (-x * x / (2.0 * sigma_sq)).exp();
167        kernel[i] = v as f32;
168        sum += v;
169    }
170
171    // Normalize
172    for k in &mut kernel {
173        *k = (*k as f64 / sum) as f32;
174    }
175
176    kernel
177}
178
179/// Sobel edge detection: returns (gradient_x, gradient_y).
180///
181/// # Errors
182///
183/// Returns error on invalid dimensions.
184pub fn sobel(
185    image: &[f32],
186    width: usize,
187    height: usize,
188) -> Result<(Vec<f32>, Vec<f32>), ImageError> {
189    if width == 0 || height == 0 {
190        return Err(ImageError::ZeroDimension { width, height });
191    }
192    if image.len() != width * height {
193        return Err(ImageError::BufferLengthMismatch {
194            expected: width * height,
195            got: image.len(),
196            width,
197            height,
198        });
199    }
200
201    // Sobel kernels
202    let sx = [-1.0, 0.0, 1.0, -2.0, 0.0, 2.0, -1.0, 0.0, 1.0_f32];
203    let sy = [-1.0, -2.0, -1.0, 0.0, 0.0, 0.0, 1.0, 2.0, 1.0_f32];
204
205    let gx = conv2d(image, width, height, &sx, 3, 3, BorderMode::Zero)?;
206    let gy = conv2d(image, width, height, &sy, 3, 3, BorderMode::Zero)?;
207
208    Ok((gx, gy))
209}
210
211/// Gradient magnitude from Sobel output.
212pub fn gradient_magnitude(gx: &[f32], gy: &[f32]) -> Vec<f32> {
213    gx.iter()
214        .zip(gy.iter())
215        .map(|(&x, &y)| (x * x + y * y).sqrt())
216        .collect()
217}
218
219/// Canny edge detection.
220///
221/// # Contract: image-conv2d-v1.yaml / canny
222///
223/// Steps: Gaussian blur → Sobel gradients → NMS → hysteresis thresholding.
224///
225/// # Errors
226///
227/// Returns error on invalid dimensions or thresholds.
228pub fn canny(
229    image: &[f32],
230    width: usize,
231    height: usize,
232    sigma: f32,
233    low_threshold: f32,
234    high_threshold: f32,
235) -> Result<Vec<f32>, ImageError> {
236    if low_threshold < 0.0 || high_threshold < low_threshold || high_threshold > 1.0 {
237        return Err(ImageError::InvalidThresholds {
238            low: low_threshold,
239            high: high_threshold,
240        });
241    }
242
243    // Step 1: Gaussian blur
244    let blurred = gaussian_blur(image, width, height, sigma)?;
245
246    // Step 2: Sobel gradients
247    let (gx, gy) = sobel(&blurred, width, height)?;
248    let mag = gradient_magnitude(&gx, &gy);
249
250    // Normalize magnitude to [0, 1]
251    let max_mag = mag.iter().copied().fold(0.0f32, f32::max);
252    let mag_norm: Vec<f32> = if max_mag > 0.0 {
253        mag.iter().map(|&m| m / max_mag).collect()
254    } else {
255        mag
256    };
257
258    // Step 3: Non-maximum suppression
259    let nms = non_maximum_suppression(&mag_norm, &gx, &gy, width, height);
260
261    // Step 4: Hysteresis thresholding
262    Ok(hysteresis_threshold(
263        &nms,
264        width,
265        height,
266        low_threshold,
267        high_threshold,
268    ))
269}
270
271/// Non-maximum suppression: thin edges to 1-pixel width.
272fn non_maximum_suppression(
273    mag: &[f32],
274    gx: &[f32],
275    gy: &[f32],
276    width: usize,
277    height: usize,
278) -> Vec<f32> {
279    let mut nms = vec![0.0f32; width * height];
280    for y in 1..height.saturating_sub(1) {
281        for x in 1..width.saturating_sub(1) {
282            let idx = y * width + x;
283            let angle = gy[idx].atan2(gx[idx]);
284            let m = mag[idx];
285
286            let dir =
287                ((angle + std::f32::consts::PI) / std::f32::consts::FRAC_PI_4).round() as usize % 4;
288            let (n1, n2) = match dir {
289                0 => (mag[idx - 1], mag[idx + 1]),
290                1 => (mag[(y - 1) * width + x + 1], mag[(y + 1) * width + x - 1]),
291                2 => (mag[(y - 1) * width + x], mag[(y + 1) * width + x]),
292                _ => (mag[(y - 1) * width + x - 1], mag[(y + 1) * width + x + 1]),
293            };
294
295            if m >= n1 && m >= n2 {
296                nms[idx] = m;
297            }
298        }
299    }
300    nms
301}
302
303/// Hysteresis thresholding: connect weak edges to strong edges.
304fn hysteresis_threshold(nms: &[f32], width: usize, height: usize, low: f32, high: f32) -> Vec<f32> {
305    let mut edges = vec![0.0f32; width * height];
306    for y in 1..height.saturating_sub(1) {
307        for x in 1..width.saturating_sub(1) {
308            let idx = y * width + x;
309            if nms[idx] >= high {
310                edges[idx] = 1.0;
311            } else if nms[idx] >= low {
312                let has_strong = [
313                    (y - 1, x - 1),
314                    (y - 1, x),
315                    (y - 1, x + 1),
316                    (y, x - 1),
317                    (y, x + 1),
318                    (y + 1, x - 1),
319                    (y + 1, x),
320                    (y + 1, x + 1),
321                ]
322                .iter()
323                .any(|&(ny, nx)| nms[ny * width + nx] >= high);
324
325                if has_strong {
326                    edges[idx] = 1.0;
327                }
328            }
329        }
330    }
331    edges
332}
333
334fn sample_border(
335    image: &[f32],
336    width: usize,
337    height: usize,
338    x: isize,
339    y: isize,
340    border: BorderMode,
341) -> f32 {
342    match border {
343        BorderMode::Zero => {
344            if x < 0 || y < 0 || x >= width as isize || y >= height as isize {
345                0.0
346            } else {
347                image[y as usize * width + x as usize]
348            }
349        }
350        BorderMode::Clamp => {
351            let cx = x.clamp(0, width as isize - 1) as usize;
352            let cy = y.clamp(0, height as isize - 1) as usize;
353            image[cy * width + cx]
354        }
355        BorderMode::Reflect => {
356            let rx = reflect(x, width);
357            let ry = reflect(y, height);
358            image[ry * width + rx]
359        }
360        BorderMode::Wrap => {
361            let wx = wrap(x, width);
362            let wy = wrap(y, height);
363            image[wy * width + wx]
364        }
365    }
366}
367
368fn wrap(i: isize, size: usize) -> usize {
369    let s = size as isize;
370    ((i % s + s) % s) as usize
371}
372
373fn reflect(i: isize, size: usize) -> usize {
374    if i < 0 {
375        (-i - 1).min(size as isize - 1) as usize
376    } else if i >= size as isize {
377        (2 * size as isize - i - 1).max(0) as usize
378    } else {
379        i as usize
380    }
381}
382
383fn validate_conv_args(
384    image: &[f32],
385    width: usize,
386    height: usize,
387    kernel: &[f32],
388    kw: usize,
389    kh: usize,
390) -> Result<(), ImageError> {
391    if width == 0 || height == 0 {
392        return Err(ImageError::ZeroDimension { width, height });
393    }
394    if kw == 0 || kh == 0 || kw.is_multiple_of(2) || kh.is_multiple_of(2) {
395        return Err(ImageError::InvalidKernelSize { kw, kh });
396    }
397    if image.len() != width * height {
398        return Err(ImageError::BufferLengthMismatch {
399            expected: width * height,
400            got: image.len(),
401            width,
402            height,
403        });
404    }
405    if kernel.len() != kw * kh {
406        return Err(ImageError::InvalidKernelSize { kw, kh });
407    }
408    Ok(())
409}
410
411/// Multi-channel Canny edge detection (NPP 3-channel parity).
412///
413/// Converts RGB input to grayscale using BT.601 weights, then applies
414/// the standard Canny pipeline (Gaussian blur → Sobel → NMS → hysteresis).
415///
416/// Input is `width * height * channels` row-major interleaved pixels.
417/// Output is `width * height` binary edge map (single channel).
418///
419/// # Errors
420///
421/// Returns error on dimension mismatch or invalid thresholds.
422pub fn canny_rgb(
423    image: &[f32],
424    width: usize,
425    height: usize,
426    channels: usize,
427    sigma: f32,
428    low_threshold: f32,
429    high_threshold: f32,
430) -> Result<Vec<f32>, ImageError> {
431    let expected = width * height * channels;
432    if image.len() != expected {
433        return Err(ImageError::BufferLengthMismatch {
434            expected,
435            got: image.len(),
436            width,
437            height,
438        });
439    }
440
441    // Convert to grayscale (BT.601 weights: 0.299R + 0.587G + 0.114B)
442    let gray = if channels == 1 {
443        image.to_vec()
444    } else {
445        let mut g = Vec::with_capacity(width * height);
446        for i in 0..width * height {
447            let base = i * channels;
448            let r = image[base];
449            let green = if channels > 1 { image[base + 1] } else { 0.0 };
450            let b = if channels > 2 { image[base + 2] } else { 0.0 };
451            g.push(0.299 * r + 0.587 * green + 0.114 * b);
452        }
453        g
454    };
455
456    canny(&gray, width, height, sigma, low_threshold, high_threshold)
457}