feat: add imgsecret embed/extract with DCT and majority voting

Implements DCT-based steganography module that hides 256-bit secrets in
JPEG luminance channel using Quantization Index Modulation (QIM) with
redundant copies and majority voting for reliable extraction.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
adlee-was-taken
2026-04-11 23:07:17 -04:00
parent 9751ee4d7d
commit 8e1d7f5358
2 changed files with 767 additions and 0 deletions

View File

@@ -0,0 +1,765 @@
//! DCT-based secret embedding that survives JPEG re-encoding and mild cropping.
//!
//! Hides a 256-bit secret in the mid-frequency DCT coefficients of the luminance
//! channel using Quantization Index Modulation (QIM) with majority voting.
use crate::error::{IdfotoError, Result};
use image::codecs::jpeg::JpegEncoder;
use image::ImageReader;
use image::{ImageEncoder, Rgb, RgbImage};
use std::f64::consts::PI;
use std::io::Cursor;
// ─── Constants ───────────────────────────────────────────────────────────────
const BLOCK_SIZE: usize = 8;
const QUANT_STEP: f64 = 50.0;
const MIN_DIMENSION: u32 = 100;
const SECRET_BITS: usize = 256;
const MIN_COPIES: usize = 5;
const BITS_PER_BLOCK: usize = 12; // EMBED_POSITIONS.len()
const BLOCKS_PER_COPY: usize = (SECRET_BITS + BITS_PER_BLOCK - 1) / BITS_PER_BLOCK; // 22
/// Mid-frequency DCT positions (zig-zag positions 415)
const EMBED_POSITIONS: [(usize, usize); 12] = [
(0, 3),
(1, 2),
(2, 1),
(3, 0), // zig-zag 4-7
(0, 4),
(1, 3),
(2, 2),
(3, 1), // zig-zag 8-11
(4, 0),
(0, 5),
(1, 4),
(2, 3), // zig-zag 12-15
];
// ─── YChannel ────────────────────────────────────────────────────────────────
struct YChannel {
data: Vec<f64>,
width: usize,
height: usize,
}
impl YChannel {
fn get(&self, x: usize, y: usize) -> f64 {
self.data[y * self.width + x]
}
fn set(&mut self, x: usize, y: usize, val: f64) {
self.data[y * self.width + x] = val;
}
}
// ─── EmbedRegion ─────────────────────────────────────────────────────────────
struct EmbedRegion {
x_offset: usize,
y_offset: usize,
#[allow(dead_code)]
region_width: usize,
#[allow(dead_code)]
region_height: usize,
blocks_x: usize,
blocks_y: usize,
}
// ─── Helper functions ────────────────────────────────────────────────────────
fn extract_y_channel(jpeg_bytes: &[u8]) -> Result<YChannel> {
let reader = ImageReader::new(Cursor::new(jpeg_bytes))
.with_guessed_format()
.map_err(|e| IdfotoError::ImgSecret(format!("failed to read image: {e}")))?;
let img = reader
.decode()
.map_err(|e| IdfotoError::ImgSecret(format!("failed to decode image: {e}")))?;
let rgb = img.to_rgb8();
let (width, height) = (rgb.width() as usize, rgb.height() as usize);
let mut data = Vec::with_capacity(width * height);
for y in 0..height {
for x in 0..width {
let p = rgb.get_pixel(x as u32, y as u32);
let luma = 0.299 * p[0] as f64 + 0.587 * p[1] as f64 + 0.114 * p[2] as f64;
data.push(luma);
}
}
Ok(YChannel {
data,
width,
height,
})
}
fn central_region(y: &YChannel) -> EmbedRegion {
compute_region(y.width, y.height)
}
fn compute_region(width: usize, height: usize) -> EmbedRegion {
let margin_x = (width as f64 * 0.15) as usize;
let margin_y = (height as f64 * 0.15) as usize;
let x_offset = margin_x;
let y_offset = margin_y;
let region_width = width - 2 * margin_x;
let region_height = height - 2 * margin_y;
let blocks_x = region_width / BLOCK_SIZE;
let blocks_y = region_height / BLOCK_SIZE;
EmbedRegion {
x_offset,
y_offset,
region_width,
region_height,
blocks_x,
blocks_y,
}
}
fn read_block_abs(y: &YChannel, px: usize, py: usize) -> Option<[[f64; 8]; 8]> {
if px + 8 > y.width || py + 8 > y.height {
return None;
}
let mut block = [[0.0f64; 8]; 8];
for row in 0..8 {
for col in 0..8 {
block[row][col] = y.get(px + col, py + row);
}
}
Some(block)
}
fn read_block(y: &YChannel, bx: usize, by: usize, region: &EmbedRegion) -> [[f64; 8]; 8] {
let start_x = region.x_offset + bx * BLOCK_SIZE;
let start_y = region.y_offset + by * BLOCK_SIZE;
read_block_abs(y, start_x, start_y).unwrap()
}
fn write_block(y: &mut YChannel, bx: usize, by: usize, region: &EmbedRegion, block: &[[f64; 8]; 8]) {
let start_x = region.x_offset + bx * BLOCK_SIZE;
let start_y = region.y_offset + by * BLOCK_SIZE;
for row in 0..8 {
for col in 0..8 {
y.set(start_x + col, start_y + row, block[row][col]);
}
}
}
// ─── DCT ─────────────────────────────────────────────────────────────────────
fn dct1d(input: &[f64; 8]) -> [f64; 8] {
let mut output = [0.0f64; 8];
for k in 0..8 {
let ck = if k == 0 {
(1.0 / 8.0_f64).sqrt()
} else {
(2.0 / 8.0_f64).sqrt()
};
let mut sum = 0.0;
for i in 0..8 {
sum += input[i] * ((2 * i + 1) as f64 * k as f64 * PI / 16.0).cos();
}
output[k] = ck * sum;
}
output
}
fn idct1d(input: &[f64; 8]) -> [f64; 8] {
let mut output = [0.0f64; 8];
for i in 0..8 {
let mut sum = 0.0;
for k in 0..8 {
let ck = if k == 0 {
(1.0 / 8.0_f64).sqrt()
} else {
(2.0 / 8.0_f64).sqrt()
};
sum += ck * input[k] * ((2 * i + 1) as f64 * k as f64 * PI / 16.0).cos();
}
output[i] = sum;
}
output
}
fn dct2_8x8(block: &[[f64; 8]; 8]) -> [[f64; 8]; 8] {
let mut temp = [[0.0f64; 8]; 8];
for row in 0..8 {
temp[row] = dct1d(&block[row]);
}
let mut result = [[0.0f64; 8]; 8];
for col in 0..8 {
let mut column = [0.0f64; 8];
for row in 0..8 {
column[row] = temp[row][col];
}
let transformed = dct1d(&column);
for row in 0..8 {
result[row][col] = transformed[row];
}
}
result
}
fn idct2_8x8(block: &[[f64; 8]; 8]) -> [[f64; 8]; 8] {
let mut temp = [[0.0f64; 8]; 8];
for col in 0..8 {
let mut column = [0.0f64; 8];
for row in 0..8 {
column[row] = block[row][col];
}
let transformed = idct1d(&column);
for row in 0..8 {
temp[row][col] = transformed[row];
}
}
let mut result = [[0.0f64; 8]; 8];
for row in 0..8 {
result[row] = idct1d(&temp[row]);
}
result
}
// ─── QIM ─────────────────────────────────────────────────────────────────────
fn qim_embed(coef: f64, bit: u8, q: f64) -> f64 {
let offset = if bit == 1 { q / 2.0 } else { 0.0 };
let shifted = coef - offset;
let quantized = (shifted / q).round() * q;
quantized + offset
}
fn qim_extract(coef: f64, q: f64) -> u8 {
let d0 = (coef - (coef / q).round() * q).abs();
let offset = q / 2.0;
let shifted = coef - offset;
let d1 = (shifted - (shifted / q).round() * q).abs();
if d0 <= d1 { 0 } else { 1 }
}
// ─── Bit conversion ──────────────────────────────────────────────────────────
fn bytes_to_bits(bytes: &[u8]) -> Vec<u8> {
let mut bits = Vec::with_capacity(bytes.len() * 8);
for &byte in bytes {
for i in (0..8).rev() {
bits.push((byte >> i) & 1);
}
}
bits
}
fn bits_to_bytes(bits: &[u8]) -> Vec<u8> {
let mut bytes = Vec::with_capacity((bits.len() + 7) / 8);
for chunk in bits.chunks(8) {
let mut byte = 0u8;
for (i, &bit) in chunk.iter().enumerate() {
byte |= bit << (7 - i);
}
bytes.push(byte);
}
bytes
}
// ─── Block selection ─────────────────────────────────────────────────────────
/// Compute the absolute pixel positions of embed blocks for a given image size.
/// Returns Vec<(px, py)> — top-left corners of 8×8 blocks.
fn compute_embed_positions(img_width: usize, img_height: usize) -> Vec<(usize, usize)> {
let region = compute_region(img_width, img_height);
let total_blocks = region.blocks_x * region.blocks_y;
if total_blocks < BLOCKS_PER_COPY * MIN_COPIES {
return Vec::new();
}
let num_copies = (total_blocks / BLOCKS_PER_COPY).min(50);
let target_count = num_copies * BLOCKS_PER_COPY;
let stride = (total_blocks / target_count).max(1);
let mut positions = Vec::with_capacity(target_count);
let mut idx = 0;
while positions.len() < target_count && idx < total_blocks {
let bx = idx % region.blocks_x;
let by = idx / region.blocks_x;
let px = region.x_offset + bx * BLOCK_SIZE;
let py = region.y_offset + by * BLOCK_SIZE;
positions.push((px, py));
idx += stride;
}
positions
}
fn select_embed_blocks(region: &EmbedRegion, target_count: usize) -> Vec<(usize, usize)> {
let total_blocks = region.blocks_x * region.blocks_y;
if total_blocks == 0 || target_count == 0 {
return Vec::new();
}
let stride = (total_blocks / target_count).max(1);
let mut blocks = Vec::with_capacity(target_count);
let mut idx = 0;
while blocks.len() < target_count && idx < total_blocks {
let bx = idx % region.blocks_x;
let by = idx / region.blocks_x;
blocks.push((bx, by));
idx += stride;
}
blocks
}
// ─── Reconstruct JPEG ────────────────────────────────────────────────────────
fn reconstruct_jpeg(original_jpeg: &[u8], y_modified: &YChannel) -> Result<Vec<u8>> {
let reader = ImageReader::new(Cursor::new(original_jpeg))
.with_guessed_format()
.map_err(|e| IdfotoError::ImgSecret(format!("failed to read image: {e}")))?;
let img = reader
.decode()
.map_err(|e| IdfotoError::ImgSecret(format!("failed to decode image: {e}")))?;
let rgb = img.to_rgb8();
let (width, height) = (rgb.width(), rgb.height());
let mut output = RgbImage::new(width, height);
for py in 0..height {
for px in 0..width {
let orig = rgb.get_pixel(px, py);
let r = orig[0] as f64;
let g = orig[1] as f64;
let b = orig[2] as f64;
let _y_orig = 0.299 * r + 0.587 * g + 0.114 * b;
let cb = -0.168736 * r - 0.331264 * g + 0.5 * b + 128.0;
let cr = 0.5 * r - 0.418688 * g - 0.081312 * b + 128.0;
let y_new = y_modified.get(px as usize, py as usize);
let r_new = y_new + 1.402 * (cr - 128.0);
let g_new = y_new - 0.344136 * (cb - 128.0) - 0.714136 * (cr - 128.0);
let b_new = y_new + 1.772 * (cb - 128.0);
output.put_pixel(
px,
py,
Rgb([
r_new.round().clamp(0.0, 255.0) as u8,
g_new.round().clamp(0.0, 255.0) as u8,
b_new.round().clamp(0.0, 255.0) as u8,
]),
);
}
}
let mut buf = Vec::new();
let encoder = JpegEncoder::new_with_quality(&mut buf, 92);
encoder
.write_image(output.as_raw(), width, height, image::ExtendedColorType::Rgb8)
.map_err(|e| IdfotoError::ImgSecret(format!("failed to encode JPEG: {e}")))?;
Ok(buf)
}
// ─── Public API ──────────────────────────────────────────────────────────────
/// Embed a 256-bit secret into a carrier JPEG. Returns modified JPEG bytes.
pub fn embed(carrier_jpeg: &[u8], secret: &[u8; 32]) -> Result<Vec<u8>> {
let mut y = extract_y_channel(carrier_jpeg)?;
if (y.width as u32) < MIN_DIMENSION || (y.height as u32) < MIN_DIMENSION {
return Err(IdfotoError::ImageTooSmall {
min_width: MIN_DIMENSION,
min_height: MIN_DIMENSION,
actual_width: y.width as u32,
actual_height: y.height as u32,
});
}
let region = central_region(&y);
let total_blocks = region.blocks_x * region.blocks_y;
if total_blocks < BLOCKS_PER_COPY * MIN_COPIES {
return Err(IdfotoError::ImageTooSmall {
min_width: MIN_DIMENSION,
min_height: MIN_DIMENSION,
actual_width: y.width as u32,
actual_height: y.height as u32,
});
}
let num_copies = (total_blocks / BLOCKS_PER_COPY).min(50);
let bits = bytes_to_bits(secret);
let blocks_needed = num_copies * BLOCKS_PER_COPY;
let embed_blocks = select_embed_blocks(&region, blocks_needed);
for copy in 0..num_copies {
for block_idx in 0..BLOCKS_PER_COPY {
let global_idx = copy * BLOCKS_PER_COPY + block_idx;
if global_idx >= embed_blocks.len() {
break;
}
let (bx, by) = embed_blocks[global_idx];
let mut block = read_block(&y, bx, by, &region);
let mut dct = dct2_8x8(&block);
for (pos_idx, &(row, col)) in EMBED_POSITIONS.iter().enumerate() {
let bit_idx = block_idx * BITS_PER_BLOCK + pos_idx;
if bit_idx >= SECRET_BITS {
break;
}
dct[row][col] = qim_embed(dct[row][col], bits[bit_idx], QUANT_STEP);
}
block = idct2_8x8(&dct);
write_block(&mut y, bx, by, &region, &block);
}
}
reconstruct_jpeg(carrier_jpeg, &y)
}
/// Extract a 256-bit secret from a (possibly re-encoded/cropped) JPEG.
pub fn extract(jpeg_bytes: &[u8]) -> Result<[u8; 32]> {
extract_with_crop_recovery(jpeg_bytes)
}
/// Try to extract using a specific assumed original image size and pixel offset.
/// `orig_w`/`orig_h` determine the block layout (which blocks, how many copies).
/// `dx`/`dy` shift all block positions when reading from the actual image.
fn try_extract_with_layout(
y: &YChannel,
orig_w: usize,
orig_h: usize,
dx: isize,
dy: isize,
) -> Result<[u8; 32]> {
let positions = compute_embed_positions(orig_w, orig_h);
if positions.is_empty() {
return Err(IdfotoError::ExtractionFailed);
}
let region = compute_region(orig_w, orig_h);
let total_blocks = region.blocks_x * region.blocks_y;
let num_copies = (total_blocks / BLOCKS_PER_COPY).min(50);
let mut votes_one = vec![0usize; SECRET_BITS];
let mut votes_total = vec![0usize; SECRET_BITS];
for copy in 0..num_copies {
for block_idx in 0..BLOCKS_PER_COPY {
let global_idx = copy * BLOCKS_PER_COPY + block_idx;
if global_idx >= positions.len() {
break;
}
let (orig_px, orig_py) = positions[global_idx];
let actual_px = orig_px as isize + dx;
let actual_py = orig_py as isize + dy;
if actual_px < 0 || actual_py < 0 {
continue;
}
let actual_px = actual_px as usize;
let actual_py = actual_py as usize;
let block = match read_block_abs(y, actual_px, actual_py) {
Some(b) => b,
None => continue, // block out of bounds (cropped away)
};
let dct = dct2_8x8(&block);
for (pos_idx, &(row, col)) in EMBED_POSITIONS.iter().enumerate() {
let bit_idx = block_idx * BITS_PER_BLOCK + pos_idx;
if bit_idx >= SECRET_BITS {
break;
}
let extracted_bit = qim_extract(dct[row][col], QUANT_STEP);
votes_total[bit_idx] += 1;
if extracted_bit == 1 {
votes_one[bit_idx] += 1;
}
}
}
}
// Majority vote with confidence check
let mut result_bits = vec![0u8; SECRET_BITS];
for i in 0..SECRET_BITS {
if votes_total[i] == 0 {
return Err(IdfotoError::ExtractionFailed);
}
let ones = votes_one[i];
let zeros = votes_total[i] - ones;
let majority = ones.max(zeros);
let confidence = majority as f64 / votes_total[i] as f64;
if confidence < 0.60 {
return Err(IdfotoError::ExtractionFailed);
}
result_bits[i] = if ones > zeros { 1 } else { 0 };
}
let result_bytes = bits_to_bytes(&result_bits);
let mut secret = [0u8; 32];
secret.copy_from_slice(&result_bytes[..32]);
Ok(secret)
}
fn extract_with_crop_recovery(jpeg_bytes: &[u8]) -> Result<[u8; 32]> {
let y = extract_y_channel(jpeg_bytes)?;
if (y.width as u32) < MIN_DIMENSION || (y.height as u32) < MIN_DIMENSION {
return Err(IdfotoError::ExtractionFailed);
}
// Try assuming the image is uncropped (original size = current size)
if let Ok(secret) = try_extract_with_layout(&y, y.width, y.height, 0, 0) {
return Ok(secret);
}
// The image may have been cropped. Search for the original dimensions.
// A crop removes pixels from edges. The central region was computed from the
// original dimensions. We need to figure out what those were.
//
// Strategy: try original widths from current_w to current_w * 1.20, stepping
// by 8 pixels (JPEG block alignment). For each candidate original width,
// the embed grid is fully determined. We then need to find dx (the pixel
// offset due to left-side cropping, which is 0 for right-only crop).
let max_orig_w = (y.width as f64 * 1.20) as usize;
let max_orig_h = (y.height as f64 * 1.20) as usize;
// Try width-only crops first (most common: crop from one side)
for orig_w in (y.width..=max_orig_w).step_by(BLOCK_SIZE) {
// Right-side crop: dx = 0 (left edge unchanged)
if let Ok(secret) = try_extract_with_layout(&y, orig_w, y.height, 0, 0) {
return Ok(secret);
}
}
// Try height-only crops
for orig_h in (y.height..=max_orig_h).step_by(BLOCK_SIZE) {
if let Ok(secret) = try_extract_with_layout(&y, y.width, orig_h, 0, 0) {
return Ok(secret);
}
}
// Try width crops with finer step (non-8-aligned crops)
for orig_w in (y.width..=max_orig_w).step_by(1) {
if orig_w % BLOCK_SIZE == 0 {
continue; // already tried
}
if let Ok(secret) = try_extract_with_layout(&y, orig_w, y.height, 0, 0) {
return Ok(secret);
}
}
Err(IdfotoError::ExtractionFailed)
}
// ─── Tests ───────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
fn make_test_jpeg(width: u32, height: u32) -> Vec<u8> {
use image::codecs::jpeg::JpegEncoder;
use image::{ImageBuffer, ImageEncoder, Rgb};
let img = ImageBuffer::from_fn(width, height, |x, y| {
Rgb([
((x * 7 + y * 13) % 256) as u8,
((x * 11 + y * 3) % 256) as u8,
((x * 5 + y * 17) % 256) as u8,
])
});
let mut buf = Vec::new();
let encoder = JpegEncoder::new_with_quality(&mut buf, 92);
encoder
.write_image(img.as_raw(), width, height, image::ExtendedColorType::Rgb8)
.unwrap();
buf
}
#[test]
fn dct2_idct2_round_trip() {
let block: [[f64; 8]; 8] = [
[52.0, 55.0, 61.0, 66.0, 70.0, 61.0, 64.0, 73.0],
[63.0, 59.0, 55.0, 90.0, 109.0, 85.0, 69.0, 72.0],
[62.0, 59.0, 68.0, 113.0, 144.0, 104.0, 66.0, 73.0],
[63.0, 58.0, 71.0, 122.0, 154.0, 106.0, 70.0, 69.0],
[67.0, 61.0, 68.0, 104.0, 126.0, 88.0, 68.0, 70.0],
[79.0, 65.0, 60.0, 70.0, 77.0, 68.0, 58.0, 75.0],
[85.0, 71.0, 64.0, 59.0, 55.0, 61.0, 65.0, 83.0],
[87.0, 79.0, 69.0, 68.0, 65.0, 76.0, 78.0, 94.0],
];
let dct = dct2_8x8(&block);
let recovered = idct2_8x8(&dct);
for row in 0..8 {
for col in 0..8 {
assert!(
(block[row][col] - recovered[row][col]).abs() < 1e-6,
"Mismatch at ({}, {}): {} vs {}",
row,
col,
block[row][col],
recovered[row][col]
);
}
}
}
#[test]
fn qim_embed_extract_single_bit() {
let coefficients = [-50.0, -10.0, 0.0, 10.0, 50.0, 100.0, -100.0];
for &coef in &coefficients {
for bit in 0..=1u8 {
let embedded = qim_embed(coef, bit, QUANT_STEP);
let extracted = qim_extract(embedded, QUANT_STEP);
assert_eq!(extracted, bit, "Failed for coef={}, bit={}", coef, bit);
}
}
}
#[test]
fn qim_survives_small_noise() {
let coefficients = [-50.0, 0.0, 30.0, 75.0, -75.0];
let noise_levels = [-10.0, -5.0, 5.0, 10.0]; // < QUANT_STEP/4 = 12.5
for &coef in &coefficients {
for bit in 0..=1u8 {
let embedded = qim_embed(coef, bit, QUANT_STEP);
for &noise in &noise_levels {
let noisy = embedded + noise;
let extracted = qim_extract(noisy, QUANT_STEP);
assert_eq!(
extracted, bit,
"Failed for coef={}, bit={}, noise={}",
coef, bit, noise
);
}
}
}
}
#[test]
fn extract_y_channel_from_synthetic_jpeg() {
let jpeg = make_test_jpeg(200, 150);
let y = extract_y_channel(&jpeg).unwrap();
assert_eq!(y.width, 200);
assert_eq!(y.height, 150);
assert_eq!(y.data.len(), 200 * 150);
}
#[test]
fn read_write_block_round_trip() {
let jpeg = make_test_jpeg(200, 150);
let mut y = extract_y_channel(&jpeg).unwrap();
let region = central_region(&y);
let original = read_block(&y, 0, 0, &region);
write_block(&mut y, 0, 0, &region, &original);
let after = read_block(&y, 0, 0, &region);
for row in 0..8 {
for col in 0..8 {
assert_eq!(original[row][col], after[row][col]);
}
}
}
#[test]
fn embed_extract_round_trip() {
let jpeg = make_test_jpeg(400, 300);
let secret: [u8; 32] = [
0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A,
0x0B, 0x0C, 0x0D, 0x0E, 0x0F, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18,
0x19, 0x1A, 0x1B, 0x1C,
];
let stego = embed(&jpeg, &secret).unwrap();
let extracted = extract(&stego).unwrap();
assert_eq!(extracted, secret);
}
#[test]
fn embed_extract_random_secret() {
use rand::RngCore;
let jpeg = make_test_jpeg(400, 300);
let mut secret = [0u8; 32];
rand::thread_rng().fill_bytes(&mut secret);
let stego = embed(&jpeg, &secret).unwrap();
let extracted = extract(&stego).unwrap();
assert_eq!(extracted, secret);
}
#[test]
fn extract_from_non_embedded_image_fails() {
let jpeg = make_test_jpeg(400, 300);
let result = extract(&jpeg);
assert!(result.is_err());
}
#[test]
fn image_too_small_fails() {
let jpeg = make_test_jpeg(32, 32);
let secret = [0u8; 32];
let result = embed(&jpeg, &secret);
assert!(result.is_err());
}
#[test]
fn embed_extract_survives_recompression_q85() {
let jpeg = make_test_jpeg(400, 300);
let secret: [u8; 32] = [
0xCA, 0xFE, 0xBA, 0xBE, 0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF, 0xFE, 0xDC,
0xBA, 0x98, 0x76, 0x54, 0x32, 0x10, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88,
0x99, 0xAA, 0xBB, 0xCC,
];
let stego = embed(&jpeg, &secret).unwrap();
// Re-encode at Q85
let reader = ImageReader::new(Cursor::new(&stego))
.with_guessed_format()
.unwrap();
let img = reader.decode().unwrap();
let rgb = img.to_rgb8();
let (w, h) = (rgb.width(), rgb.height());
let mut recompressed = Vec::new();
let encoder = JpegEncoder::new_with_quality(&mut recompressed, 85);
encoder
.write_image(rgb.as_raw(), w, h, image::ExtendedColorType::Rgb8)
.unwrap();
let extracted = extract(&recompressed).unwrap();
assert_eq!(extracted, secret);
}
#[test]
fn embed_extract_survives_10pct_crop() {
let jpeg = make_test_jpeg(400, 300);
let secret: [u8; 32] = [
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E,
0x0F, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x1B, 0x1C,
0x1D, 0x1E, 0x1F, 0x20,
];
let stego = embed(&jpeg, &secret).unwrap();
// Crop 10% from right edge
let reader = ImageReader::new(Cursor::new(&stego))
.with_guessed_format()
.unwrap();
let img = reader.decode().unwrap();
let (w, h) = img.dimensions();
let crop_pixels = (w as f64 * 0.10) as u32;
let cropped = img.crop_imm(0, 0, w - crop_pixels, h);
let rgb = cropped.to_rgb8();
let (cw, ch) = (rgb.width(), rgb.height());
let mut cropped_jpeg = Vec::new();
let encoder = JpegEncoder::new_with_quality(&mut cropped_jpeg, 92);
encoder
.write_image(rgb.as_raw(), cw, ch, image::ExtendedColorType::Rgb8)
.unwrap();
let extracted = extract(&cropped_jpeg).unwrap();
assert_eq!(extracted, secret);
}
}

View File

@@ -9,3 +9,5 @@ pub use entry::{generate_entry_id, Entry, Manifest, ManifestEntry};
pub mod vault;
pub use vault::{decrypt_entry, decrypt_manifest, encrypt_entry, encrypt_manifest};
pub mod imgsecret;