Skip to main content

geronimo_captcha/
image.rs

1use crate::SpriteFormat;
2use crate::error::{CaptchaError, Result};
3
4use image::{ExtendedColorType, RgbImage, Rgba, RgbaImage, imageops};
5use imageproc::geometric_transformations::{Border, Interpolation, Projection, warp_into};
6use rand::RngExt;
7use webp::{Encoder as WebPEncoder, WebPConfig};
8
9const MAX_NOISE_COUNT: u32 = 1_000_000;
10const MAX_NOISE_SIZE: u32 = 64;
11const MAX_NOISE_WORK: u64 = 4_000_000;
12const MAX_BLUR_SIGMA: f32 = 32.0;
13
14// libwebp effort level, valid range 0..=6.
15const WEBP_METHOD: i32 = 1;
16
17// JPEG at the qualities this crate encodes lands near 0.2 bytes per pixel.
18const JPEG_BYTES_PER_PIXEL_SHIFT: usize = 4;
19
20#[derive(Clone, Copy, Debug, Default)]
21pub enum NoisePattern {
22    Dots,
23    Lines,
24    #[default]
25    Grid,
26}
27
28/// Parameters for the noise watermark drawn over
29/// the composed sprite.
30#[derive(Clone, Copy, Debug)]
31pub struct NoiseOptions {
32    /// Number of noise marks. Capped at 1_000_000.
33    /// `count` multiplied by the pixels one mark
34    /// covers (1, `size`, or `size` squared, by
35    /// pattern) must not exceed 4_000_000.
36    pub count: u32,
37
38    /// Edge length of one mark, in pixels,
39    /// for [`NoisePattern::Lines`]
40    /// and [`NoisePattern::Grid`].
41    /// Capped at 64.
42    pub size: u32,
43
44    /// Gaussian blur applied after the noise.
45    /// Zero disables it. Capped at 32.0.
46    pub blur_sigma: f32,
47
48    /// Opacity of each mark, 0 (invisible) to 255 (opaque).
49    /// Marks are alpha-blended onto the sprite.
50    pub alpha: u8,
51
52    /// Inclusive channel range each mark's colour
53    /// is drawn from. `.0` must not exceed `.1`.
54    pub color_range: (u8, u8),
55    pub shape: NoisePattern,
56    pub red: bool,
57    pub green: bool,
58    pub blue: bool,
59}
60
61impl Default for NoiseOptions {
62    fn default() -> Self {
63        NoiseOptions {
64            count: 300 * 9,
65            size: 2,
66            alpha: 100,
67            color_range: (0, 255),
68            shape: NoisePattern::default(),
69            red: true,
70            green: true,
71            blue: true,
72            blur_sigma: 0.7,
73        }
74    }
75}
76
77impl NoiseOptions {
78    /// # Errors
79    ///
80    /// Returns [`CaptchaError::InvalidInput`] if
81    /// any field is outside its documented range.
82    pub fn validate(&self) -> Result<()> {
83        if self.color_range.0 > self.color_range.1 {
84            return Err(CaptchaError::InvalidInput(format!(
85                "noise color_range is inverted: {} > {}",
86                self.color_range.0, self.color_range.1
87            )));
88        }
89
90        if self.count > MAX_NOISE_COUNT {
91            return Err(CaptchaError::InvalidInput(format!(
92                "noise count {} exceeds maximum {MAX_NOISE_COUNT}",
93                self.count
94            )));
95        }
96
97        if self.size == 0 || self.size > MAX_NOISE_SIZE {
98            return Err(CaptchaError::InvalidInput(format!(
99                "noise size {} outside 1..={MAX_NOISE_SIZE}",
100                self.size
101            )));
102        }
103
104        if !self.blur_sigma.is_finite() || self.blur_sigma < 0.0 || self.blur_sigma > MAX_BLUR_SIGMA
105        {
106            return Err(CaptchaError::InvalidInput(format!(
107                "noise blur_sigma {} outside 0.0..={MAX_BLUR_SIGMA}",
108                self.blur_sigma
109            )));
110        }
111
112        let work = u64::from(self.count) * self.pixels_per_mark();
113        if work > MAX_NOISE_WORK {
114            return Err(CaptchaError::InvalidInput(format!(
115                "noise count {} at size {} blends {work} pixels, maximum is {MAX_NOISE_WORK}",
116                self.count, self.size
117            )));
118        }
119
120        Ok(())
121    }
122
123    fn pixels_per_mark(&self) -> u64 {
124        match self.shape {
125            NoisePattern::Dots => 1,
126            NoisePattern::Lines => u64::from(self.size),
127            NoisePattern::Grid => u64::from(self.size) * u64::from(self.size),
128        }
129    }
130}
131
132/// Caller must compose the result onto `bg`.
133pub fn warp_tile(base: &RgbaImage, angle_deg: f32, flip: bool, bg: Rgba<u8>, out: &mut RgbaImage) {
134    let (in_w, in_h) = base.dimensions();
135    let (out_w, out_h) = out.dimensions();
136
137    let scale = out_w as f32 / in_w as f32;
138    let scale_x = match flip {
139        true => -scale,
140        false => scale,
141    };
142
143    let projection = Projection::translate(out_w as f32 / 2.0, out_h as f32 / 2.0)
144        * Projection::scale(scale_x, scale)
145        * Projection::rotate(angle_deg.to_radians())
146        * Projection::translate(-(in_w as f32) / 2.0, -(in_h as f32) / 2.0);
147
148    warp_into(
149        base,
150        projection,
151        Interpolation::Bilinear,
152        Border::Constant(bg),
153        out,
154    );
155
156    mask_outside_circle(out, bg);
157}
158
159pub fn watermark_with_noise(img: &mut RgbaImage, opts: NoiseOptions) {
160    let mut rng = rand::rng();
161    let (width, height) = img.dimensions();
162
163    for _ in 0..opts.count {
164        let x = rng.random_range(0..width);
165        let y = rng.random_range(0..height);
166
167        let channel = |enabled: bool, rng: &mut _| match enabled {
168            true => RngExt::random_range(rng, opts.color_range.0..=opts.color_range.1),
169            false => 0,
170        };
171
172        let color = Rgba([
173            channel(opts.red, &mut rng),
174            channel(opts.green, &mut rng),
175            channel(opts.blue, &mut rng),
176            opts.alpha,
177        ]);
178
179        match opts.shape {
180            NoisePattern::Dots => blend_pixel(img, x, y, color),
181            NoisePattern::Lines => {
182                for i in 0..opts.size.min(width - x) {
183                    blend_pixel(img, x + i, y, color);
184                }
185            }
186            NoisePattern::Grid => {
187                for dx in 0..opts.size.min(width - x) {
188                    for dy in 0..opts.size.min(height - y) {
189                        blend_pixel(img, x + dx, y + dy, color);
190                    }
191                }
192            }
193        }
194    }
195}
196
197pub fn blur_rgb(img: RgbImage, sigma: f32) -> RgbImage {
198    match sigma > 0.0 {
199        true => imageops::fast_blur(&img, sigma),
200        false => img,
201    }
202}
203
204pub fn encode_image(img: &RgbImage, fmt: &SpriteFormat) -> Result<(Vec<u8>, &'static str)> {
205    match *fmt {
206        SpriteFormat::Jpeg { quality } => {
207            let mut buf = Vec::with_capacity(img.as_raw().len() >> JPEG_BYTES_PER_PIXEL_SHIFT);
208            let mut enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut buf, quality);
209
210            enc.encode(
211                img.as_raw(),
212                img.width(),
213                img.height(),
214                ExtendedColorType::Rgb8,
215            )
216            .map_err(CaptchaError::Encode)?;
217
218            Ok((buf, "image/jpeg"))
219        }
220        SpriteFormat::Webp { quality, lossless } => {
221            let enc = WebPEncoder::from_rgb(img.as_raw(), img.width(), img.height());
222
223            let webp = match lossless {
224                true => enc.encode_lossless(),
225                false => {
226                    let mut config = WebPConfig::new()
227                        .map_err(|()| CaptchaError::Internal("build webp config".into()))?;
228                    config.quality = quality as f32;
229                    config.method = WEBP_METHOD;
230
231                    enc.encode_advanced(&config)
232                        .map_err(|e| CaptchaError::Internal(format!("webp encode: {e:?}")))?
233                }
234            };
235
236            Ok((webp.to_vec(), "image/webp"))
237        }
238    }
239}
240
241fn mask_outside_circle(img: &mut RgbaImage, fill: Rgba<u8>) {
242    let (width, height) = img.dimensions();
243
244    let radius = width.min(height) as f32 / 2.0 - 1.0;
245    if radius <= 0.0 {
246        return;
247    }
248
249    let (cx, cy) = (width as f32 / 2.0, height as f32 / 2.0);
250    let r2 = radius * radius;
251
252    for y in 0..height {
253        let dy = y as f32 + 0.5 - cy;
254        let span = r2 - dy * dy;
255
256        let (lo, hi) = match span > 0.0 {
257            true => {
258                let dx = span.sqrt();
259                (
260                    (cx - dx).ceil().clamp(0.0, width as f32) as u32,
261                    (cx + dx).floor().clamp(0.0, width as f32) as u32,
262                )
263            }
264            false => (width, width),
265        };
266
267        for x in (0..lo).chain(hi..width) {
268            img.put_pixel(x, y, fill);
269        }
270    }
271}
272
273fn blend_pixel(img: &mut RgbaImage, x: u32, y: u32, src: Rgba<u8>) {
274    let dst = img.get_pixel_mut(x, y);
275    let a = u32::from(src.0[3]);
276    let inv = 255 - a;
277
278    for c in 0..3 {
279        dst.0[c] = ((u32::from(src.0[c]) * a + u32::from(dst.0[c]) * inv) / 255) as u8;
280    }
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286
287    #[test]
288    fn noise_validation_rejects_out_of_range() {
289        let cases = [
290            (
291                "inverted color_range",
292                NoiseOptions {
293                    color_range: (200, 100),
294                    ..NoiseOptions::default()
295                },
296            ),
297            (
298                "count over cap",
299                NoiseOptions {
300                    count: MAX_NOISE_COUNT + 1,
301                    shape: NoisePattern::Dots,
302                    ..NoiseOptions::default()
303                },
304            ),
305            (
306                "size zero",
307                NoiseOptions {
308                    size: 0,
309                    ..NoiseOptions::default()
310                },
311            ),
312            (
313                "size over cap",
314                NoiseOptions {
315                    size: MAX_NOISE_SIZE + 1,
316                    ..NoiseOptions::default()
317                },
318            ),
319            (
320                "negative blur",
321                NoiseOptions {
322                    blur_sigma: -0.1,
323                    ..NoiseOptions::default()
324                },
325            ),
326            (
327                "blur over cap",
328                NoiseOptions {
329                    blur_sigma: MAX_BLUR_SIGMA + 0.1,
330                    ..NoiseOptions::default()
331                },
332            ),
333            (
334                "blur nan",
335                NoiseOptions {
336                    blur_sigma: f32::NAN,
337                    ..NoiseOptions::default()
338                },
339            ),
340            (
341                "blur infinite",
342                NoiseOptions {
343                    blur_sigma: f32::INFINITY,
344                    ..NoiseOptions::default()
345                },
346            ),
347            (
348                "grid work over cap",
349                NoiseOptions {
350                    count: MAX_NOISE_COUNT,
351                    size: MAX_NOISE_SIZE,
352                    shape: NoisePattern::Grid,
353                    ..NoiseOptions::default()
354                },
355            ),
356            (
357                "lines work over cap",
358                NoiseOptions {
359                    count: MAX_NOISE_COUNT,
360                    size: MAX_NOISE_SIZE,
361                    shape: NoisePattern::Lines,
362                    ..NoiseOptions::default()
363                },
364            ),
365        ];
366
367        for (name, noise) in cases {
368            assert!(
369                matches!(noise.validate(), Err(CaptchaError::InvalidInput(_))),
370                "{name} should be rejected"
371            );
372        }
373    }
374
375    #[test]
376    fn noise_validation_accepts_bounds() {
377        let accepted = [
378            NoiseOptions::default(),
379            NoiseOptions {
380                count: 0,
381                ..NoiseOptions::default()
382            },
383            NoiseOptions {
384                count: MAX_NOISE_COUNT,
385                shape: NoisePattern::Dots,
386                ..NoiseOptions::default()
387            },
388            NoiseOptions {
389                size: MAX_NOISE_SIZE,
390                count: 1,
391                ..NoiseOptions::default()
392            },
393            NoiseOptions {
394                blur_sigma: 0.0,
395                ..NoiseOptions::default()
396            },
397            NoiseOptions {
398                blur_sigma: MAX_BLUR_SIGMA,
399                ..NoiseOptions::default()
400            },
401            NoiseOptions {
402                color_range: (7, 7),
403                ..NoiseOptions::default()
404            },
405        ];
406
407        for noise in accepted {
408            assert!(noise.validate().is_ok(), "{noise:?} should be accepted");
409        }
410    }
411}