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}