Skip to main content

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}