Skip to main content

stenoxide_core/cost/
hill.rs

1//! HILL adaptive cost map with rejection of smooth regions.
2//!
3//! # The model
4//!
5//! HILL — *High-pass, Low-pass, Low-pass* — answers one question per pixel:
6//! how much does changing this sample disturb the statistics a steganalyser
7//! measures? The answer is built in three convolutions over the luma plane:
8//!
9//! 1. A 3x3 Laplacian isolates the high-frequency content. Its absolute value
10//!    is large wherever neighbouring pixels disagree — edges, grain, texture —
11//!    and near zero across a gradient or a flat wall.
12//! 2. A 5x5 Gaussian spreads that residual over its neighbourhood, so that a
13//!    pixel sitting one step away from an edge inherits part of its texture
14//!    instead of being judged in isolation.
15//! 3. A second, wider Gaussian smooths the result again, and the reciprocal of
16//!    that value becomes the cost: dense texture yields a large residual and
17//!    therefore a small cost, a smooth region yields a residual near zero and a
18//!    cost that grows without bound until the guard epsilon caps it.
19//!
20//! The double low-pass is what distinguishes HILL from a plain edge detector.
21//! A single filter would hand the lowest costs to isolated pixels whose own
22//! neighbourhood happens to be noisy; two passes require a *region* to be
23//! textured before anything inside it becomes cheap, and clustered changes are
24//! far harder to detect than scattered ones.
25//!
26//! # Why the image can be rejected
27//!
28//! The cost map only ranks pixels; it does not decide whether the image has any
29//! good pixel at all. A poster, a screenshot or a synthetic gradient produces a
30//! perfectly valid map in which every position is expensive, and embedding into
31//! it would put every change where the eye and the detector both look first.
32//! [`HillCostProvider::compute`] therefore ends in three blocking gates, and a
33//! container that fails any of them is refused rather than used badly.
34
35use std::collections::VecDeque;
36use std::fmt;
37
38use rayon::prelude::*;
39
40use crate::cost::{CostMap, CostProvider};
41use crate::image_io::buffer::{CoverSource, ImageBuffer};
42use crate::image_io::jpeg_detect::luminance_plane;
43
44/// High-pass stage: the 3x3 Laplacian of step 1, row-major.
45const HIGH_PASS_KERNEL: [[f32; 3]; 3] = [[0.0, -1.0, 0.0], [-1.0, 4.0, -1.0], [0.0, -1.0, 0.0]];
46
47/// Standard deviation of the first low-pass stage, in pixels.
48///
49/// Together with [`gaussian_kernel`] this yields the 5x5 kernel the model calls
50/// for.
51const FIRST_SMOOTHING_SIGMA: f32 = 1.0;
52
53/// Standard deviation of the second low-pass stage, in pixels.
54///
55/// Wider than the first on purpose: the second pass is what turns "this pixel
56/// is next to an edge" into "this pixel is inside a textured region".
57const SECOND_SMOOTHING_SIGMA: f32 = 1.5;
58
59/// Guard added before the reciprocal of step 3.
60///
61/// A perfectly flat region smooths to exactly zero, so without it the cost of
62/// the worst possible pixel would be an infinity — a value that propagates
63/// through every sum the embedding layer takes. The epsilon caps that cost at
64/// `1e6` instead, which is large enough that no trellis path will ever choose
65/// such a pixel while a textured one is available.
66const INVERSION_EPSILON: f32 = 1e-6;
67
68/// Multiplier applied to the cost of pixels whose red channel can carry a bit.
69///
70/// Colour Rich Models, the strongest published detectors against colour images,
71/// build their features from inter-channel differences and weight the red plane
72/// most heavily: it is the channel whose demosaicing residual is most regular,
73/// so an LSB flip there breaks a correlation the model has already learnt.
74/// Raising the cost by twenty per cent does not forbid those pixels, it makes
75/// the trellis prefer any comparable alternative, which is exactly the pressure
76/// wanted — a hard exclusion would itself be a detectable statistic.
77const RED_CHANNEL_PENALTY: f32 = 1.20;
78
79/// Quantile of the cost distribution below which a pixel counts as smooth.
80const SMOOTH_PERCENTILE: f32 = 0.05;
81
82/// Largest fraction of the image allowed to be smooth.
83const MAX_SMOOTH_RATIO: f32 = 0.30;
84
85/// Largest fraction of the image one connected smooth region may occupy.
86const MAX_SMOOTH_REGION_RATIO: f32 = 0.10;
87
88/// Quantile of the cost distribution used to judge global texture.
89const TEXTURE_PERCENTILE: f32 = 0.95;
90
91/// Smallest value that quantile may take before the image is refused.
92const MIN_TEXTURE_COST: f32 = 0.10;
93
94/// Every reason the cost layer can refuse a container image.
95#[derive(Debug)]
96pub enum CostError {
97    /// Too much of the image sits in the smooth tail of the cost distribution.
98    ExcessiveSmoothRegions {
99        /// Fraction of the pixels found below the smoothness threshold.
100        ratio: f32,
101    },
102    /// One connected smooth region covers too much of the image.
103    LargeSmoothRegion {
104        /// Size of the offending region, in pixels.
105        size: usize,
106    },
107    /// The image carries no textured region anywhere.
108    InsufficientGlobalTexture,
109}
110
111impl fmt::Display for CostError {
112    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
113        match self {
114            CostError::ExcessiveSmoothRegions { ratio } => write!(
115                f,
116                "{:.1}% of the image is too smooth to hide anything; choose a container with \
117                 more texture",
118                ratio * 100.0
119            ),
120            CostError::LargeSmoothRegion { size } => write!(
121                f,
122                "the image contains a single flat area of {size} pixels; choose a container \
123                 without large uniform surfaces"
124            ),
125            CostError::InsufficientGlobalTexture => write!(
126                f,
127                "the image has no textured region anywhere; choose a container with more detail"
128            ),
129        }
130    }
131}
132
133impl std::error::Error for CostError {}
134
135/// The HILL cost model.
136///
137/// Stateless: every parameter of the model is a constant of this module, so the
138/// same container always produces the same map. Determinism is not a
139/// convenience here — the extraction path never sees a cost map, and the whole
140/// scheme rests on both sides deriving identical values from the image alone.
141#[derive(Debug, Default, Clone, Copy)]
142pub struct HillCostProvider;
143
144impl HillCostProvider {
145    /// Builds the provider.
146    pub fn new() -> Self {
147        Self
148    }
149}
150
151impl CostProvider for HillCostProvider {
152    type Error = CostError;
153
154    /// Computes the HILL cost map of `image` and runs the three blocking gates.
155    ///
156    /// # Errors
157    ///
158    /// Returns [`CostError::ExcessiveSmoothRegions`] when too much of the image
159    /// falls in the smooth tail, [`CostError::LargeSmoothRegion`] when one
160    /// connected flat area is too large, and
161    /// [`CostError::InsufficientGlobalTexture`] when no part of the image is
162    /// textured — including the degenerate case of an image with no pixels.
163    fn compute<'img>(&self, image: &'img ImageBuffer) -> Result<CostMap<'img>, CostError> {
164        let (width, height) = image.dimensions();
165        let width = width as usize;
166        let height = height as usize;
167        let color_space = image.color_space();
168
169        let luma = luminance_plane(image.pixels(), width * height, color_space);
170
171        // Step 1 — high-pass residual.
172        let residual = high_pass(&luma, width, height);
173        drop(luma);
174
175        // Step 2 — first low-pass.
176        let smoothed = convolve_separable(
177            &residual,
178            width,
179            height,
180            &gaussian_kernel(FIRST_SMOOTHING_SIGMA),
181        );
182        drop(residual);
183
184        // Step 3 — second low-pass, then inversion.
185        let second = convolve_separable(
186            &smoothed,
187            width,
188            height,
189            &gaussian_kernel(SECOND_SMOOTHING_SIGMA),
190        );
191        drop(smoothed);
192
193        let mut costs: Vec<f32> = second
194            .into_par_iter()
195            .map(|texture| 1.0 / (texture + INVERSION_EPSILON))
196            .collect();
197
198        // Step 4 — red channel penalty. The map holds one cost per pixel, so
199        // the penalty applies to whole pixels: on a colour layout the red plane
200        // of every pixel is a candidate carrier, whereas a grayscale image has
201        // no red channel to protect and is left untouched.
202        if color_space.has_explicit_red_channel() {
203            costs
204                .par_iter_mut()
205                .for_each(|cost| *cost *= RED_CHANNEL_PENALTY);
206        }
207
208        validate(&costs, width, height)?;
209
210        Ok(CostMap::new(image, costs))
211    }
212}
213
214/// Mirrors an out-of-range coordinate back into `0..len`.
215///
216/// Reflection about the edge pixel — `-1` maps to `1`, `len` maps to
217/// `len - 2` — rather than the zero padding a naive convolution would use.
218/// Zero padding surrounds the image with a black frame that does not exist, and
219/// the Laplacian answers it with a bright border of pure artifact; the cost map
220/// would then declare the outermost rows the most textured part of the image
221/// and the embedder would crowd its changes into a border where nothing is
222/// hidden. Reflection continues the image with its own content, so the border
223/// response stays in the same range as the interior.
224///
225/// The modular reduction makes the function total for any offset, so a kernel
226/// wider than the image cannot walk off the end of it.
227fn reflect(coord: isize, len: usize) -> usize {
228    if len <= 1 {
229        return 0;
230    }
231
232    let len = len as isize;
233    let period = 2 * (len - 1);
234
235    let mut folded = coord % period;
236    if folded < 0 {
237        folded += period;
238    }
239    if folded >= len {
240        folded = period - folded;
241    }
242
243    folded as usize
244}
245
246/// Step 1 — absolute response of the Laplacian in [`HIGH_PASS_KERNEL`].
247///
248/// The absolute value is taken per pixel and not at the end: the sign of the
249/// Laplacian says which side of an edge a pixel is on, which is a property of
250/// the scene and not of how much texture surrounds the pixel. Keeping it would
251/// let the low-pass stages cancel the two sides of an edge against each other
252/// and report the sharpest feature of the image as flat.
253fn high_pass(luma: &[f32], width: usize, height: usize) -> Vec<f32> {
254    let mut output = vec![0.0f32; luma.len()];
255    if width == 0 || height == 0 {
256        return output;
257    }
258
259    output
260        .par_chunks_mut(width)
261        .enumerate()
262        .for_each(|(y, row)| {
263            for (x, value) in row.iter_mut().enumerate() {
264                let mut accumulator = 0.0f32;
265
266                for (ky, weights) in HIGH_PASS_KERNEL.iter().enumerate() {
267                    let sy = reflect(y as isize + ky as isize - 1, height);
268                    for (kx, &weight) in weights.iter().enumerate() {
269                        if weight == 0.0 {
270                            continue;
271                        }
272                        let sx = reflect(x as isize + kx as isize - 1, width);
273                        // In bounds by construction of `reflect`; the fallback
274                        // keeps the function total without an index panic.
275                        accumulator += weight * luma.get(sy * width + sx).copied().unwrap_or(0.0);
276                    }
277                }
278
279                *value = accumulator.abs();
280            }
281        });
282
283    output
284}
285
286/// Normalised 1-D Gaussian taps for a given standard deviation.
287///
288/// The radius is `ceil(2 * sigma)`, so `sigma = 1.0` gives the five taps the
289/// model specifies and `sigma = 1.5` gives seven. Two standard deviations
290/// capture about 95% of the mass of the kernel; truncating closer would leave
291/// enough of the tail outside that the renormalisation below would visibly
292/// change the shape of the filter rather than merely rescale it.
293///
294/// Built at call time because `exp` is not available in a const context on
295/// stable Rust. The cost is a handful of exponentials against a convolution
296/// over every pixel of the image.
297fn gaussian_kernel(sigma: f32) -> Vec<f32> {
298    let radius = (2.0 * sigma).ceil().max(1.0) as usize;
299
300    let mut taps: Vec<f32> = (0..=2 * radius)
301        .map(|tap| {
302            let offset = tap as f32 - radius as f32;
303            (-(offset * offset) / (2.0 * sigma * sigma)).exp()
304        })
305        .collect();
306
307    // Normalised so the filter preserves the mean level of its input: an
308    // unnormalised Gaussian would scale the whole residual plane, and the
309    // absolute threshold of the texture gate would then depend on the kernel
310    // width instead of on the image.
311    let sum: f32 = taps.iter().sum();
312    if sum > 0.0 {
313        for tap in &mut taps {
314            *tap /= sum;
315        }
316    }
317
318    taps
319}
320
321/// Convolves with a 2-D Gaussian by running the 1-D kernel along each axis.
322///
323/// A Gaussian is separable: the outer product of the normalised 1-D taps *is*
324/// the normalised 2-D kernel, so this is not an approximation of the 5x5 and
325/// 7x7 filters the model calls for — it is those filters, evaluated in `2r`
326/// multiplications per pixel instead of `(2r + 1)^2`.
327fn convolve_separable(input: &[f32], width: usize, height: usize, taps: &[f32]) -> Vec<f32> {
328    let horizontal = convolve_axis(input, width, height, taps, Axis::Horizontal);
329
330    convolve_axis(&horizontal, width, height, taps, Axis::Vertical)
331}
332
333/// Which of the two passes of [`convolve_separable`] is running.
334#[derive(Clone, Copy)]
335enum Axis {
336    /// Along a row: neighbours differ in `x`.
337    Horizontal,
338    /// Down a column: neighbours differ in `y`.
339    Vertical,
340}
341
342/// One separable pass, parallelised over the rows of the output.
343///
344/// Rows are independent because the input is only ever read, never updated in
345/// place, so the vertical pass sees the complete horizontal result no matter in
346/// which order the threads finish.
347fn convolve_axis(input: &[f32], width: usize, height: usize, taps: &[f32], axis: Axis) -> Vec<f32> {
348    let mut output = vec![0.0f32; input.len()];
349    if width == 0 || height == 0 || taps.is_empty() {
350        return output;
351    }
352
353    let radius = (taps.len() / 2) as isize;
354
355    output
356        .par_chunks_mut(width)
357        .enumerate()
358        .for_each(|(y, row)| {
359            for (x, value) in row.iter_mut().enumerate() {
360                let mut accumulator = 0.0f32;
361
362                for (tap_index, &tap) in taps.iter().enumerate() {
363                    let offset = tap_index as isize - radius;
364                    let (sx, sy) = match axis {
365                        Axis::Horizontal => (reflect(x as isize + offset, width), y),
366                        Axis::Vertical => (x, reflect(y as isize + offset, height)),
367                    };
368
369                    // In bounds by construction of `reflect`; the fallback
370                    // keeps the function total without an index panic.
371                    accumulator += tap * input.get(sy * width + sx).copied().unwrap_or(0.0);
372                }
373
374                *value = accumulator;
375            }
376        });
377
378    output
379}
380
381/// Value at a quantile of an already sorted slice, by nearest rank.
382///
383/// `fraction` is in `0.0..=1.0`. The slice must be sorted ascending; sorting is
384/// left to the caller because both quantiles the gates need come from the same
385/// sort.
386fn percentile(sorted: &[f32], fraction: f32) -> f32 {
387    if sorted.is_empty() {
388        return 0.0;
389    }
390
391    let rank = (fraction * sorted.len() as f32).ceil() as usize;
392    let index = rank.saturating_sub(1).min(sorted.len() - 1);
393
394    // In bounds by the clamp above; the fallback keeps the function total.
395    sorted.get(index).copied().unwrap_or(0.0)
396}
397
398/// Size of the largest 4-connected region of pixels cheaper than `threshold`.
399///
400/// Breadth-first with an explicit queue rather than recursion: a smooth region
401/// can span millions of pixels, and a recursive flood fill would exhaust the
402/// stack on exactly the images this gate exists to catch.
403///
404/// Diagonal neighbours are deliberately not connected. Two flat areas that
405/// touch at a single corner are two areas as far as an embedder is concerned,
406/// and 8-connectivity would merge them across a one-pixel contact into a region
407/// large enough to fail the gate on its own.
408fn largest_smooth_region(costs: &[f32], width: usize, height: usize, threshold: f32) -> usize {
409    if width == 0 || height == 0 {
410        return 0;
411    }
412
413    let is_smooth = |index: usize| costs.get(index).is_some_and(|&cost| cost < threshold);
414
415    let mut visited = vec![false; costs.len()];
416    let mut queue: VecDeque<usize> = VecDeque::new();
417    let mut largest = 0usize;
418
419    for start in 0..costs.len() {
420        if visited[start] || !is_smooth(start) {
421            continue;
422        }
423
424        visited[start] = true;
425        queue.push_back(start);
426
427        let mut size = 0usize;
428        while let Some(index) = queue.pop_front() {
429            size += 1;
430
431            let x = index % width;
432            let y = index / width;
433            let neighbours = [
434                (x > 0).then(|| index - 1),
435                (x + 1 < width).then(|| index + 1),
436                (y > 0).then(|| index - width),
437                (y + 1 < height).then(|| index + width),
438            ];
439
440            for neighbour in neighbours.into_iter().flatten() {
441                if !visited[neighbour] && is_smooth(neighbour) {
442                    visited[neighbour] = true;
443                    queue.push_back(neighbour);
444                }
445            }
446        }
447
448        largest = largest.max(size);
449    }
450
451    largest
452}
453
454/// The three blocking gates, applied to the finished cost map.
455///
456/// # Reach of the smoothness threshold
457///
458/// The first two gates measure the population below `theta_smooth`, and that
459/// threshold is the fifth percentile of the very distribution being measured.
460/// By construction no more than five per cent of the pixels can fall strictly
461/// below it, so on any input the ratio stays under the thirty per cent limit
462/// and no connected subset of that population can reach the ten per cent region
463/// limit. Both gates are implemented exactly as specified, which keeps the
464/// layer's behaviour equal to its specification and leaves the definition of
465/// `theta_smooth` as the single place to edit if the thresholds are ever to be
466/// armed — but as they stand they cannot reject an image, and the smoothness
467/// rejection this layer performs in practice is the third gate.
468///
469/// # Orientation of the texture gate
470///
471/// The third gate reads the *upper* tail of the cost distribution against an
472/// absolute bound. Cost is the reciprocal of smoothed texture energy, so
473/// `percentile_95 < 0.10` says that ninety-five per cent of the image smooths
474/// to a residual above `10.0` — a container whose every region is high-energy.
475fn validate(costs: &[f32], width: usize, height: usize) -> Result<(), CostError> {
476    // An image with no pixels has no textured region, and every quantile below
477    // would be an invented number. Refused here rather than measured.
478    if costs.is_empty() {
479        return Err(CostError::InsufficientGlobalTexture);
480    }
481
482    // `total_cmp` rather than `partial_cmp`: the latter is fallible on NaN, and
483    // a comparator that has to decide what to do about NaN is a comparator that
484    // can panic.
485    let mut sorted = costs.to_vec();
486    sorted.par_sort_unstable_by(f32::total_cmp);
487
488    let theta_smooth = percentile(&sorted, SMOOTH_PERCENTILE);
489
490    // Gate 1 — how much of the image is smooth.
491    let smooth_pixels = costs
492        .par_iter()
493        .filter(|&&cost| cost < theta_smooth)
494        .count();
495    let ratio = smooth_pixels as f32 / costs.len() as f32;
496    if ratio > MAX_SMOOTH_RATIO {
497        return Err(CostError::ExcessiveSmoothRegions { ratio });
498    }
499
500    // Gate 2 — how much of it is smooth in one piece. Scattered smooth pixels
501    // are harmless: the embedder simply avoids them. A single large flat area
502    // is not, because it removes a whole part of the image from consideration
503    // and pushes every change into the remainder.
504    let largest = largest_smooth_region(costs, width, height, theta_smooth);
505    if largest as f32 > MAX_SMOOTH_REGION_RATIO * costs.len() as f32 {
506        return Err(CostError::LargeSmoothRegion { size: largest });
507    }
508
509    // Gate 3 — whether the image has any texture at all.
510    if percentile(&sorted, TEXTURE_PERCENTILE) < MIN_TEXTURE_COST {
511        return Err(CostError::InsufficientGlobalTexture);
512    }
513
514    Ok(())
515}
516