Skip to main content

phasm_core/codec/jpeg/
pixels.rs

1// Copyright (c) 2026 Christoph Gaffga
2// SPDX-License-Identifier: GPL-3.0-only
3// https://github.com/cgaffga/phasmcore
4
5//! Pixel-domain conversion for the Y (luminance) channel.
6//!
7//! Provides IDCT (coefficient → pixel) and forward DCT (pixel → coefficient)
8//! transforms for geometry-resilient steganography. Only operates on the
9//! Y channel — no RGB conversion needed since the DFT template is embedded
10//! in luminance.
11
12use std::sync::OnceLock;
13
14use crate::codec::jpeg::JpegImage;
15
16/// Pre-computed 8×8 cosine table for IDCT/DCT.
17/// `COSINE[u][x] = cos((2*x + 1) * u * PI / 16)`
18static COSINE: OnceLock<[[f64; 8]; 8]> = OnceLock::new();
19
20/// Normalization constants: C(0) = 1/sqrt(8), C(u>0) = 1/2.
21static NORM: OnceLock<[f64; 8]> = OnceLock::new();
22
23fn cosine_table() -> &'static [[f64; 8]; 8] {
24    COSINE.get_or_init(|| {
25        let mut table = [[0.0f64; 8]; 8];
26        for u in 0..8 {
27            for x in 0..8 {
28                table[u][x] = crate::det_math::det_cos((2 * x + 1) as f64 * u as f64 * std::f64::consts::PI / 16.0);
29            }
30        }
31        table
32    })
33}
34
35fn norm_table() -> &'static [f64; 8] {
36    NORM.get_or_init(|| {
37        let mut n = [0.5f64; 8];
38        n[0] = 1.0 / (8.0f64).sqrt();
39        n
40    })
41}
42
43/// Dequantize + 8×8 IDCT → 64 spatial-domain pixel values.
44///
45/// Input: quantized DCT coefficients in natural (row-major) order.
46/// Output: pixel values (approximately 0–255 after +128 shift).
47pub fn idct_block(quantized: &[i16; 64], qt: &[u16; 64]) -> [f64; 64] {
48    let cos = cosine_table();
49    let c = norm_table();
50
51    // Dequantize
52    let mut f = [0.0f64; 64];
53    for i in 0..64 {
54        f[i] = quantized[i] as f64 * qt[i] as f64;
55    }
56
57    // Separable IDCT: columns then rows.
58    // Step 1: IDCT on columns (transform over rows for each column).
59    let mut temp = [0.0f64; 64];
60    for col in 0..8 {
61        for y in 0..8 {
62            let mut sum = 0.0;
63            for v in 0..8 {
64                sum += c[v] * f[v * 8 + col] * cos[v][y];
65            }
66            temp[y * 8 + col] = sum;
67        }
68    }
69
70    // Step 2: IDCT on rows.
71    let mut pixels = [0.0f64; 64];
72    for row in 0..8 {
73        for x in 0..8 {
74            let mut sum = 0.0;
75            for u in 0..8 {
76                sum += c[u] * temp[row * 8 + u] * cos[u][x];
77            }
78            pixels[row * 8 + x] = sum + 128.0;
79        }
80    }
81
82    pixels
83}
84
85/// 8×8 forward DCT + quantize → 64 DCT coefficients.
86///
87/// Input: pixel values (expected ~0–255).
88/// Output: quantized DCT coefficients in natural (row-major) order.
89pub fn dct_block(pixels: &[f64; 64], qt: &[u16; 64]) -> [i16; 64] {
90    let cos = cosine_table();
91    let c = norm_table();
92
93    // Level shift: subtract 128
94    let mut shifted = [0.0f64; 64];
95    for i in 0..64 {
96        shifted[i] = pixels[i] - 128.0;
97    }
98
99    // Separable forward DCT: rows then columns.
100    // Step 1: DCT on rows.
101    let mut temp = [0.0f64; 64];
102    for row in 0..8 {
103        for u in 0..8 {
104            let mut sum = 0.0;
105            for x in 0..8 {
106                sum += shifted[row * 8 + x] * cos[u][x];
107            }
108            temp[row * 8 + u] = c[u] * sum;
109        }
110    }
111
112    // Step 2: DCT on columns.
113    let mut coeffs = [0.0f64; 64];
114    for col in 0..8 {
115        for v in 0..8 {
116            let mut sum = 0.0;
117            for y in 0..8 {
118                sum += temp[y * 8 + col] * cos[v][y];
119            }
120            coeffs[v * 8 + col] = c[v] * sum;
121        }
122    }
123
124    // Quantize
125    let mut quantized = [0i16; 64];
126    for i in 0..64 {
127        quantized[i] = (coeffs[i] / qt[i] as f64).round() as i16;
128    }
129    quantized
130}
131
132/// 8×8 forward DCT + divide by QT, but do NOT round.
133///
134/// Returns continuous f64 values (the "unquantized" coefficients).
135/// The rounding error for each position is:
136///   `unquantized[i] - dct_block(pixels, qt)[i] as f64`
137/// and lies in the range [-0.5, +0.5].
138///
139/// Used by SI-UNIWARD to exploit quantization rounding errors for
140/// more efficient embedding when the original pixels are available.
141pub fn dct_block_unquantized(pixels: &[f64; 64], qt: &[u16; 64]) -> [f64; 64] {
142    let cos = cosine_table();
143    let c = norm_table();
144
145    // Level shift: subtract 128
146    let mut shifted = [0.0f64; 64];
147    for i in 0..64 {
148        shifted[i] = pixels[i] - 128.0;
149    }
150
151    // Separable forward DCT: rows then columns.
152    let mut temp = [0.0f64; 64];
153    for row in 0..8 {
154        for u in 0..8 {
155            let mut sum = 0.0;
156            for x in 0..8 {
157                sum += shifted[row * 8 + x] * cos[u][x];
158            }
159            temp[row * 8 + u] = c[u] * sum;
160        }
161    }
162
163    let mut coeffs = [0.0f64; 64];
164    for col in 0..8 {
165        for v in 0..8 {
166            let mut sum = 0.0;
167            for y in 0..8 {
168                sum += temp[y * 8 + col] * cos[v][y];
169            }
170            coeffs[v * 8 + col] = c[v] * sum;
171        }
172    }
173
174    // Divide by QT but do NOT round — return continuous values.
175    let mut unquantized = [0.0f64; 64];
176    for i in 0..64 {
177        unquantized[i] = coeffs[i] / qt[i] as f64;
178    }
179    unquantized
180}
181
182/// Convert entire Y-channel DctGrid → row-major f64 pixel array.
183///
184/// Returns (pixels, width_in_pixels, height_in_pixels) where dimensions
185/// are the full block-aligned size (multiples of 8).
186///
187/// Returns `None` if the Y-channel quantization table is missing.
188pub fn jpeg_to_luma_f64(img: &JpegImage) -> Option<(Vec<f64>, usize, usize)> {
189    let grid = img.dct_grid(0);
190    let qt_id = img.frame_info().components[0].quant_table_id as usize;
191    let qt = img.quant_table(qt_id)?;
192
193    let bw = grid.blocks_wide();
194    let bh = grid.blocks_tall();
195    let width = bw * 8;
196    let height = bh * 8;
197    let mut pixels = vec![0.0f64; width * height];
198
199    for br in 0..bh {
200        for bc in 0..bw {
201            let block = grid.block(br, bc);
202            let quantized: [i16; 64] = block.try_into().unwrap();
203            let block_pixels = idct_block(&quantized, &qt.values);
204
205            for row in 0..8 {
206                for col in 0..8 {
207                    let py = br * 8 + row;
208                    let px = bc * 8 + col;
209                    pixels[py * width + px] = block_pixels[row * 8 + col];
210                }
211            }
212        }
213    }
214
215    Some((pixels, width, height))
216}
217
218/// Convert RGB pixel buffer to Y (luminance) 8×8 blocks.
219///
220/// Uses BT.601: `Y = 0.299*R + 0.587*G + 0.114*B`
221///
222/// Handles non-multiple-of-8 dimensions by edge-replicating the last row/column
223/// (matching typical JPEG encoder behavior).
224///
225/// # Arguments
226/// - `rgb`: Row-major RGB bytes (R,G,B,R,G,B,...), length = width * height * 3.
227/// - `width`, `height`: Image dimensions in pixels.
228///
229/// # Returns
230/// Vector of `[f64; 64]` blocks in raster order (left-to-right, top-to-bottom),
231/// with block count = `ceil(width/8) * ceil(height/8)`.
232pub fn rgb_to_luma_blocks(rgb: &[u8], width: u32, height: u32) -> Vec<[f64; 64]> {
233    let w = width as usize;
234    let h = height as usize;
235    let bw = w.div_ceil(8);
236    let bh = h.div_ceil(8);
237
238    let mut blocks = Vec::with_capacity(bw * bh);
239
240    for br in 0..bh {
241        for bc in 0..bw {
242            let mut block = [0.0f64; 64];
243            for row in 0..8 {
244                for col in 0..8 {
245                    // Edge-replicate if past the image boundary
246                    let py = (br * 8 + row).min(h - 1);
247                    let px = (bc * 8 + col).min(w - 1);
248                    let idx = (py * w + px) * 3;
249                    let r = rgb[idx] as f64;
250                    let g = rgb[idx + 1] as f64;
251                    let b = rgb[idx + 2] as f64;
252                    block[row * 8 + col] = 0.299 * r + 0.587 * g + 0.114 * b;
253                }
254            }
255            blocks.push(block);
256        }
257    }
258
259    blocks
260}
261
262/// Write f64 pixel array back into Y-channel DctGrid.
263///
264/// Performs forward DCT + quantization on each 8×8 block.
265///
266/// Returns `None` if the Y-channel quantization table is missing.
267pub fn luma_f64_to_jpeg(pixels: &[f64], width: usize, height: usize, img: &mut JpegImage) -> Option<()> {
268    let qt_id = img.frame_info().components[0].quant_table_id as usize;
269    let qt_values = img.quant_table(qt_id)?.values;
270    let grid = img.dct_grid_mut(0);
271    let bw = grid.blocks_wide();
272    let bh = grid.blocks_tall();
273
274    assert_eq!(width, bw * 8);
275    assert_eq!(height, bh * 8);
276
277    for br in 0..bh {
278        for bc in 0..bw {
279            let mut block_pixels = [0.0f64; 64];
280            for row in 0..8 {
281                for col in 0..8 {
282                    let py = br * 8 + row;
283                    let px = bc * 8 + col;
284                    block_pixels[row * 8 + col] = pixels[py * width + px];
285                }
286            }
287
288            let quantized = dct_block(&block_pixels, &qt_values);
289            let block = grid.block_mut(br, bc);
290            block.copy_from_slice(&quantized);
291        }
292    }
293    Some(())
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299
300    #[test]
301    fn idct_dct_roundtrip() {
302        // Create some DCT coefficients
303        let mut quantized = [0i16; 64];
304        quantized[0] = 100; // DC
305        quantized[1] = 10;
306        quantized[8] = -5;
307        quantized[9] = 3;
308
309        // Unity quantization table
310        let qt = [1u16; 64];
311
312        let pixels = idct_block(&quantized, &qt);
313        let recovered = dct_block(&pixels, &qt);
314
315        for i in 0..64 {
316            assert!(
317                (quantized[i] - recovered[i]).abs() <= 1,
318                "Mismatch at index {i}: expected {}, got {}",
319                quantized[i],
320                recovered[i]
321            );
322        }
323    }
324
325    #[test]
326    fn dc_only_block_produces_flat_pixels() {
327        let mut quantized = [0i16; 64];
328        quantized[0] = 16; // DC coefficient
329        let qt = [1u16; 64];
330
331        let pixels = idct_block(&quantized, &qt);
332
333        // All pixels should be approximately the same value
334        // DC contribution = C(0)_col * C(0)_row * F[0][0] = (1/sqrt(8))^2 * 16 = 16/8 = 2
335        let expected = 128.0 + 16.0 / 8.0;
336        let dc_val = pixels[0];
337        for i in 0..64 {
338            assert!(
339                (pixels[i] - dc_val).abs() < 1e-10,
340                "Pixel {i} = {}, expected uniform {}",
341                pixels[i],
342                dc_val
343            );
344        }
345        assert!((dc_val - expected).abs() < 1e-10);
346    }
347
348    #[test]
349    fn idct_dct_roundtrip_with_quant() {
350        // Typical JPEG quantization table
351        let qt = [
352            16, 11, 10, 16, 24, 40, 51, 61,
353            12, 12, 14, 19, 26, 58, 60, 55,
354            14, 13, 16, 24, 40, 57, 69, 56,
355            14, 17, 22, 29, 51, 87, 80, 62,
356            18, 22, 37, 56, 68, 109, 103, 77,
357            24, 35, 55, 64, 81, 104, 113, 92,
358            49, 64, 78, 87, 103, 121, 120, 101,
359            72, 92, 95, 98, 112, 100, 103, 99u16,
360        ];
361
362        let mut quantized = [0i16; 64];
363        quantized[0] = 50;
364        quantized[1] = -3;
365        quantized[8] = 2;
366
367        let pixels = idct_block(&quantized, &qt);
368        let recovered = dct_block(&pixels, &qt);
369
370        for i in 0..64 {
371            assert!(
372                (quantized[i] - recovered[i]).abs() <= 1,
373                "Mismatch at index {i}: expected {}, got {}",
374                quantized[i],
375                recovered[i]
376            );
377        }
378    }
379}