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}