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:
765
crates/idfoto-core/src/imgsecret.rs
Normal file
765
crates/idfoto-core/src/imgsecret.rs
Normal 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 4–15)
|
||||
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(®ion, 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, ®ion);
|
||||
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, ®ion, &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, ®ion);
|
||||
write_block(&mut y, 0, 0, ®ion, &original);
|
||||
let after = read_block(&y, 0, 0, ®ion);
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user