Skip to main content

stenoxide_core/cost/
mod.rs

1//! Layer 3 — adaptive cost analysis.
2//!
3//! Builds the HILL cost map used to steer embedding towards textured regions.
4//! The `'img` lifetime carried by the cost map ties it to the borrow of the
5//! validated image buffer, so the compiler — not convention — guarantees that
6//! the pixels cannot be mutated while the map is alive.
7//!
8//! This module holds the vocabulary of the layer: the map itself and the trait
9//! every cost model implements. The HILL model lives in [`hill`].
10
11use std::fmt;
12use std::marker::PhantomData;
13use std::ops::Index;
14
15use crate::image_io::buffer::{CoverSource, ImageBuffer};
16
17pub mod hill;
18
19/// Per-pixel embedding cost of a container image, in row-major order.
20///
21/// # What the lifetime buys
22///
23/// `'img` is the borrow of the [`ImageBuffer`] the map was computed from. The
24/// map holds no reference to the image at run time — only a [`PhantomData`] —
25/// but the borrow checker treats it as if it did, so for as long as a
26/// `CostMap<'img>` is alive the compiler refuses every call to
27/// [`CoverSource::pixels_mut`] on the source image.
28///
29/// That is the whole point. A cost map computed from one set of samples and
30/// applied to another is silently wrong: the embedder would steer bits towards
31/// regions that are no longer the textured ones, which is exactly the mistake a
32/// steganalyst is looking for. Rather than keeping map and image in sync by
33/// convention, the pipeline is forced to drop the map before it may touch the
34/// pixels, and the invariant is checked at compile time.
35pub struct CostMap<'img> {
36    /// One cost per pixel, row-major, exactly `width * height` entries long.
37    pub(crate) data: Vec<f32>,
38    /// Width of the source image, in pixels.
39    pub(crate) width: u32,
40    /// Height of the source image, in pixels.
41    pub(crate) height: u32,
42    /// Carrier of the borrow described above; occupies no space.
43    _phantom: PhantomData<&'img ImageBuffer>,
44}
45
46impl<'img> CostMap<'img> {
47    /// Wraps a freshly computed cost vector as the map of `image`.
48    ///
49    /// The dimensions are read from `image` rather than passed in, so a map can
50    /// never claim a geometry its source does not have. Callers must supply
51    /// exactly `image.pixel_count()` costs, in row-major order; the constructor
52    /// is `pub(crate)` because the only callers are the cost providers of this
53    /// layer, which build the vector from that very count.
54    pub(crate) fn new(image: &'img ImageBuffer, data: Vec<f32>) -> Self {
55        let (width, height) = image.dimensions();
56
57        Self {
58            data,
59            width,
60            height,
61            _phantom: PhantomData,
62        }
63    }
64
65    /// Dimensions of the map as `(width, height)`, in pixels.
66    ///
67    /// Always the dimensions of the image the map was computed from.
68    pub fn dimensions(&self) -> (u32, u32) {
69        (self.width, self.height)
70    }
71
72    /// Number of pixels covered by the map.
73    pub fn pixel_count(&self) -> usize {
74        self.width as usize * self.height as usize
75    }
76
77    /// The costs as a flat row-major slice.
78    ///
79    /// The embedding layer consumes the map in permuted order, so it needs the
80    /// raw vector rather than coordinate access.
81    pub fn costs(&self) -> &[f32] {
82        &self.data
83    }
84
85    /// Cost of the pixel at `(x, y)`, or `None` when the coordinates fall
86    /// outside the map.
87    ///
88    /// The total counterpart of the [`Index`] implementation.
89    pub fn get(&self, x: u32, y: u32) -> Option<f32> {
90        if x >= self.width || y >= self.height {
91            return None;
92        }
93
94        self.data.get(self.linear_index(x, y)).copied()
95    }
96
97    /// Offset of the pixel at `(x, y)` inside [`CostMap::data`].
98    ///
99    /// Not bounds-checked; both callers check the coordinates themselves.
100    fn linear_index(&self, x: u32, y: u32) -> usize {
101        y as usize * self.width as usize + x as usize
102    }
103}
104
105impl Index<(u32, u32)> for CostMap<'_> {
106    type Output = f32;
107
108    /// Cost of the pixel at `(x, y)`.
109    ///
110    /// Follows the convention of every indexing implementation in the standard
111    /// library and treats out-of-range coordinates as a programming error
112    /// rather than as a runtime condition. Use [`CostMap::get`] wherever the
113    /// coordinates come from outside the caller.
114    fn index(&self, (x, y): (u32, u32)) -> &f32 {
115        &self.data[self.linear_index(x, y)]
116    }
117}
118
119impl fmt::Debug for CostMap<'_> {
120    /// Prints the geometry of the map, never its contents.
121    ///
122    /// Written by hand rather than derived: a derived implementation would
123    /// format several million floats, which is not what anyone putting a cost
124    /// map behind `{:?}` is asking for.
125    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
126        f.debug_struct("CostMap")
127            .field("width", &self.width)
128            .field("height", &self.height)
129            .field("costs", &self.data.len())
130            .finish()
131    }
132}
133
134/// A model that assigns an embedding cost to every pixel of a container image.
135///
136/// The trait exists so that the pipeline can be generic over the cost model:
137/// [`hill::HillCostProvider`] is the only implementation today, but the layers
138/// above never name it.
139pub trait CostProvider {
140    /// How this model reports an image it refuses to work with.
141    type Error;
142
143    /// Computes the cost map of `image`.
144    ///
145    /// The returned map borrows `image` for `'img`, which freezes its samples
146    /// until the map is dropped; see [`CostMap`].
147    ///
148    /// # Errors
149    ///
150    /// Returns `Self::Error` when the image is unsuitable as a container. A
151    /// cost model is entitled to refuse an image outright: producing a map for
152    /// a container that cannot hide anything safely would only move the failure
153    /// downstream, past the point where it can still be explained to the user.
154    fn compute<'img>(&self, image: &'img ImageBuffer) -> Result<CostMap<'img>, Self::Error>;
155}
156
157#[cfg(test)]
158mod tests {
159    use super::*;
160
161    use crate::image_io::buffer::ColorSpace;
162
163    /// A 4x3 map whose cost is the linear index of the pixel.
164    fn map(image: &ImageBuffer) -> CostMap<'_> {
165        let costs = (0..image.pixel_count()).map(|index| index as f32).collect();
166
167        CostMap::new(image, costs)
168    }
169
170    /// A container of the geometry the map above is built against.
171    fn image() -> ImageBuffer {
172        ImageBuffer::new(vec![0u8; 4 * 3], 4, 3, ColorSpace::Luma8)
173    }
174
175    /// The map takes its geometry from the image, never from its caller.
176    #[test]
177    fn the_map_reports_the_geometry_of_its_source() {
178        let image = image();
179        let map = map(&image);
180
181        assert_eq!(map.dimensions(), image.dimensions());
182        assert_eq!(map.pixel_count(), image.pixel_count());
183        assert_eq!(map.costs().len(), image.pixel_count());
184    }
185
186    /// Coordinate access, in its total and its panicking form.
187    #[test]
188    fn coordinates_address_the_map_row_by_row() {
189        let image = image();
190        let map = map(&image);
191
192        assert_eq!(map.get(0, 0), Some(0.0));
193        assert_eq!(map.get(3, 0), Some(3.0));
194        assert_eq!(map.get(1, 2), Some(9.0));
195        assert_eq!(map[(1, 2)], 9.0);
196
197        // Out of range on either axis is `None` rather than a wrapped lookup
198        // into the next row.
199        assert_eq!(map.get(4, 0), None);
200        assert_eq!(map.get(0, 3), None);
201    }
202
203    /// The debug form prints the geometry and the size, never several million
204    /// floats.
205    #[test]
206    fn the_debug_form_is_a_summary() {
207        let image = image();
208        let rendered = format!("{:?}", map(&image));
209
210        assert!(rendered.contains("width: 4"), "got: {rendered}");
211        assert!(rendered.contains("height: 3"), "got: {rendered}");
212        assert!(rendered.contains("costs: 12"), "got: {rendered}");
213        assert!(!rendered.contains("0.0"), "got: {rendered}");
214    }
215}