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}