phasm_core/stego/ghost/capacity.rs
1// Copyright (c) 2026 Christoph Gaffga
2// SPDX-License-Identifier: GPL-3.0-only
3// https://github.com/cgaffga/phasmcore
4
5//! Ghost mode capacity estimation.
6//!
7//! Estimates the maximum plaintext message size that can be embedded in a
8//! given JPEG cover image using Ghost mode. The estimate accounts for:
9//! - Number of usable (non-WET) AC coefficients
10//! - Minimum capacity ratio to ensure reliable STC embedding
11//! - Frame overhead (length, salt, nonce, auth tag, CRC)
12
13use crate::codec::jpeg::JpegImage;
14use crate::codec::jpeg::dct::DctGrid;
15use crate::stego::frame::{FRAME_OVERHEAD, FRAME_OVERHEAD_EXT};
16use crate::stego::error::StegoError;
17use crate::stego::shadow;
18
19/// Minimum ratio of usable (non-WET) AC coefficients to message bits
20/// for standard J-UNIWARD. A ratio below this produces detectable artifacts.
21const MIN_CAPACITY_RATIO: f64 = 5.0;
22
23/// Minimum ratio for SI-UNIWARD ("Deep Cover").
24///
25/// SI-UNIWARD lowers embedding costs by exploiting quantization rounding errors,
26/// allowing a higher embedding rate at the same steganalysis detectability.
27/// Literature shows ~1.5-2× capacity gain; 3.5 is a conservative estimate (~43%
28/// more capacity than J-UNIWARD's ratio of 5.0).
29const MIN_CAPACITY_RATIO_SI: f64 = 3.5;
30
31/// Count non-zero AC coefficients in the DctGrid (fast capacity estimation).
32///
33/// This is equivalent to counting usable positions from a CostMap, since
34/// J-UNIWARD assigns finite costs to all non-zero AC coefficients. This
35/// avoids the expensive UNIWARD cost computation, making capacity estimation
36/// instantaneous even for very large images.
37fn count_nonzero_ac(grid: &DctGrid) -> usize {
38 let bw = grid.blocks_wide();
39 let bt = grid.blocks_tall();
40 let mut count = 0usize;
41 for br in 0..bt {
42 for bc in 0..bw {
43 let blk = grid.block(br, bc);
44 for k in 1..64 { // skip DC at index 0
45 if blk[k] != 0 {
46 count += 1;
47 }
48 }
49 }
50 }
51 count
52}
53
54/// Convert usable position count + capacity ratio → plaintext byte capacity.
55fn capacity_from_usable(usable: usize, ratio: f64) -> usize {
56 let max_frame_bits = (usable as f64 / ratio) as usize;
57 let max_frame_bytes = max_frame_bits / 8;
58
59 if max_frame_bytes <= FRAME_OVERHEAD {
60 return 0;
61 }
62
63 let capacity = max_frame_bytes - FRAME_OVERHEAD;
64 if capacity > u16::MAX as usize {
65 // v2 frame needs 4 extra bytes for the extended length header.
66 max_frame_bytes.saturating_sub(FRAME_OVERHEAD_EXT)
67 } else {
68 capacity
69 }
70}
71
72/// Estimate Ghost mode capacity (standard J-UNIWARD).
73///
74/// Conservative estimate: divides usable coefficient count by
75/// [`MIN_CAPACITY_RATIO`] (5.0) to ensure the STC has sufficient slack for
76/// low-distortion embedding, then subtracts the frame overhead.
77///
78/// # Errors
79/// Returns [`StegoError::NoLuminanceChannel`] if the image has no Y component
80/// or its quantization table is missing.
81pub fn estimate_capacity(img: &JpegImage) -> Result<usize, StegoError> {
82 if img.num_components() == 0 {
83 return Err(StegoError::NoLuminanceChannel);
84 }
85 let usable = count_nonzero_ac(img.dct_grid(0));
86 Ok(capacity_from_usable(usable, MIN_CAPACITY_RATIO))
87}
88
89/// Estimate Ghost mode capacity with SI-UNIWARD ("Deep Cover").
90///
91/// When the input image is non-JPEG (PNG, HEIC, RAW), SI-UNIWARD exploits
92/// quantization rounding errors to embed at lower distortion per bit. This
93/// allows a higher embedding rate at the same steganalysis risk, resulting
94/// in ~43% more capacity than standard J-UNIWARD.
95///
96/// Uses [`MIN_CAPACITY_RATIO_SI`] (3.5) — conservative relative to the
97/// literature's 1.5-2× improvement at equal detectability.
98///
99/// No raw pixels needed: the position count is identical, only the per-bit
100/// distortion budget changes.
101pub fn estimate_capacity_si(img: &JpegImage) -> Result<usize, StegoError> {
102 if img.num_components() == 0 {
103 return Err(StegoError::NoLuminanceChannel);
104 }
105 let usable = count_nonzero_ac(img.dct_grid(0));
106 Ok(capacity_from_usable(usable, MIN_CAPACITY_RATIO_SI))
107}
108
109/// Estimate shadow layer capacity for a JPEG image.
110///
111/// Shadow uses direct LSB embedding in the Y (luminance) channel with
112/// cost-pool position selection. Capacity is based on Y nzAC count.
113pub fn estimate_shadow_capacity(img: &JpegImage) -> Result<usize, StegoError> {
114 if img.num_components() == 0 {
115 return Err(StegoError::NoLuminanceChannel);
116 }
117 let y_nzac = count_nonzero_ac(img.dct_grid(0));
118 Ok(shadow::shadow_capacity(y_nzac))
119}
120
121/// Estimate Ghost primary capacity accounting for shadow position overhead.
122///
123/// Subtracts shadow positions from usable Y NZAC before computing primary
124/// capacity. Uses conservative RS parity (16) for the estimate since the
125/// escalation cascade frequently bumps parity above the initial 4.
126pub fn estimate_capacity_with_shadows(
127 img: &JpegImage,
128 shadow_count: usize,
129 shadow_total_bytes: usize,
130 is_si: bool,
131) -> Result<usize, StegoError> {
132 if img.num_components() == 0 {
133 return Err(StegoError::NoLuminanceChannel);
134 }
135 let y_nzac = count_nonzero_ac(img.dct_grid(0));
136 let ratio = if is_si { MIN_CAPACITY_RATIO_SI } else { MIN_CAPACITY_RATIO };
137
138 // Conservative RS parity for capacity estimate (cascade often escalates)
139 let parity = 16usize;
140 // Compute RS-encoded shadow bytes (per shadow: frame_overhead + payload, RS-encoded)
141 let shadow_frame_overhead = 46usize; // plaintext_len(2) + salt(16) + nonce(12) + tag(16)
142 let total_shadow_frame_bytes = shadow_count * shadow_frame_overhead + shadow_total_bytes;
143 // RS encoding expands data: for each 255-byte block, parity bytes added
144 let k = 255 - parity; // 239 data bytes per block
145 let full_blocks = total_shadow_frame_bytes.div_ceil(k);
146 let shadow_rs_bytes = full_blocks * 255;
147 let shadow_bits = shadow_rs_bytes * 8;
148
149 // Subtract shadow positions from available pool
150 let effective_nzac = y_nzac.saturating_sub(shadow_bits);
151 Ok(capacity_from_usable(effective_nzac, ratio))
152}
153
154#[cfg(test)]
155mod tests {
156 use super::*;
157
158 #[test]
159 fn capacity_reasonable_for_photo() {
160 let data = std::fs::read("test-vectors/image/photo_320x240_q75_420.jpg").unwrap();
161 let img = JpegImage::from_bytes(&data).unwrap();
162 let cap = estimate_capacity(&img).unwrap();
163 // 320×240 at 4:2:0 → 40×30=1200 Y blocks → 75,600 AC positions.
164 // Even with many zeros, should have >100 bytes capacity.
165 assert!(cap > 100, "capacity {cap} is too low for 320x240");
166 // But shouldn't be unreasonably high.
167 assert!(cap < 5000, "capacity {cap} is suspiciously high");
168 }
169
170 #[test]
171 fn si_capacity_higher_than_standard() {
172 let data = std::fs::read("test-vectors/image/photo_320x240_q75_420.jpg").unwrap();
173 let img = JpegImage::from_bytes(&data).unwrap();
174 let cap_j = estimate_capacity(&img).unwrap();
175 let cap_si = estimate_capacity_si(&img).unwrap();
176 // SI should give ~43% more capacity (ratio 3.5 vs 5.0)
177 assert!(
178 cap_si > cap_j,
179 "SI capacity ({cap_si}) should exceed J-UNIWARD capacity ({cap_j})"
180 );
181 // Verify the ratio is approximately 5.0/3.5 ≈ 1.43
182 let ratio = cap_si as f64 / cap_j as f64;
183 assert!(
184 ratio > 1.3 && ratio < 1.6,
185 "SI/J ratio {ratio:.2} should be ~1.43"
186 );
187 }
188}