Skip to main content

phasm_core/stego/armor/
resample.rs

1// Copyright (c) 2026 Christoph Gaffga
2// SPDX-License-Identifier: GPL-3.0-only
3// https://github.com/cgaffga/phasmcore
4
5//! Bilinear resampling for undoing geometric transforms.
6//!
7//! Inverse-maps each output pixel through the estimated affine transform
8//! to find its source coordinates, then bilinear-interpolates from the
9//! four nearest source pixels.
10
11use crate::stego::armor::template::AffineTransform;
12
13/// Inverse-map bilinear resampling to undo a geometric transform.
14///
15/// For each output pixel, computes source coordinates by applying the
16/// inverse transform (rotate by -θ, scale by 1/s), then bilinear
17/// interpolates. Out-of-bounds pixels default to 128.0 (mid-gray).
18pub fn resample_bilinear(
19    pixels: &[f64],
20    src_w: usize,
21    src_h: usize,
22    transform: &AffineTransform,
23    dst_w: usize,
24    dst_h: usize,
25) -> Vec<f64> {
26    let mut result = vec![128.0f64; dst_w * dst_h];
27
28    // Inverse transform: rotate by -θ, scale by 1/s
29    let (sin_t, cos_t) = crate::det_math::det_sincos(transform.rotation_rad);
30    let inv_scale = if transform.scale.abs() > 1e-12 { 1.0 / transform.scale } else { 1.0 };
31
32    // Centers of source and destination
33    let src_cx = src_w as f64 / 2.0;
34    let src_cy = src_h as f64 / 2.0;
35    let dst_cx = dst_w as f64 / 2.0;
36    let dst_cy = dst_h as f64 / 2.0;
37
38    for dy in 0..dst_h {
39        for dx in 0..dst_w {
40            // Offset from destination center
41            let x = dx as f64 - dst_cx;
42            let y = dy as f64 - dst_cy;
43
44            // Inverse rotation (rotate by -θ)
45            let xr = x * cos_t + y * sin_t;
46            let yr = -x * sin_t + y * cos_t;
47
48            // Inverse scale
49            let xs = xr * inv_scale;
50            let ys = yr * inv_scale;
51
52            // Map to source coordinates
53            let sx = xs + src_cx;
54            let sy = ys + src_cy;
55
56            // Bilinear interpolation
57            result[dy * dst_w + dx] = bilinear_sample(pixels, src_w, src_h, sx, sy);
58        }
59    }
60
61    result
62}
63
64/// Sample a pixel from the image using bilinear interpolation.
65///
66/// Returns 128.0 (mid-gray) for out-of-bounds coordinates.
67fn bilinear_sample(pixels: &[f64], w: usize, h: usize, x: f64, y: f64) -> f64 {
68    let x0 = x.floor() as i64;
69    let y0 = y.floor() as i64;
70    let x1 = x0 + 1;
71    let y1 = y0 + 1;
72
73    let fx = x - x0 as f64;
74    let fy = y - y0 as f64;
75
76    let get = |px: i64, py: i64| -> f64 {
77        if px >= 0 && px < w as i64 && py >= 0 && py < h as i64 {
78            pixels[py as usize * w + px as usize]
79        } else {
80            128.0
81        }
82    };
83
84    let v00 = get(x0, y0);
85    let v10 = get(x1, y0);
86    let v01 = get(x0, y1);
87    let v11 = get(x1, y1);
88
89    v00 * (1.0 - fx) * (1.0 - fy)
90        + v10 * fx * (1.0 - fy)
91        + v01 * (1.0 - fx) * fy
92        + v11 * fx * fy
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98
99    #[test]
100    fn identity_transform_preserves_image() {
101        let w = 16;
102        let h = 16;
103        let pixels: Vec<f64> = (0..w * h).map(|i| (i as f64) * 1.5 + 10.0).collect();
104
105        let identity = AffineTransform {
106            rotation_rad: 0.0,
107            scale: 1.0,
108        };
109
110        let result = resample_bilinear(&pixels, w, h, &identity, w, h);
111
112        // Interior pixels should match closely (edges may differ due to interpolation)
113        for y in 1..h - 1 {
114            for x in 1..w - 1 {
115                let idx = y * w + x;
116                assert!(
117                    (pixels[idx] - result[idx]).abs() < 0.01,
118                    "Mismatch at ({x},{y}): expected {}, got {}",
119                    pixels[idx],
120                    result[idx]
121                );
122            }
123        }
124    }
125
126    #[test]
127    fn rotation_180_roundtrip() {
128        let w = 16;
129        let h = 16;
130        let pixels: Vec<f64> = (0..w * h).map(|i| (i % 7) as f64 * 30.0 + 50.0).collect();
131
132        // Rotate by 180° then rotate by 180° again
133        let rot180 = AffineTransform {
134            rotation_rad: std::f64::consts::PI,
135            scale: 1.0,
136        };
137
138        let rotated = resample_bilinear(&pixels, w, h, &rot180, w, h);
139        let roundtrip = resample_bilinear(&rotated, w, h, &rot180, w, h);
140
141        // Interior pixels should match
142        for y in 2..h - 2 {
143            for x in 2..w - 2 {
144                let idx = y * w + x;
145                assert!(
146                    (pixels[idx] - roundtrip[idx]).abs() < 1.0,
147                    "Mismatch at ({x},{y}): expected {}, got {}",
148                    pixels[idx],
149                    roundtrip[idx]
150                );
151            }
152        }
153    }
154
155    #[test]
156    fn scale_2x_then_half() {
157        let w = 16;
158        let h = 16;
159        let pixels: Vec<f64> = (0..w * h).map(|i| 128.0 + (i as f64 * 0.5).sin() * 40.0).collect();
160
161        let scale2 = AffineTransform {
162            rotation_rad: 0.0,
163            scale: 2.0,
164        };
165        let scale_half = AffineTransform {
166            rotation_rad: 0.0,
167            scale: 0.5,
168        };
169
170        let scaled_up = resample_bilinear(&pixels, w, h, &scale2, w, h);
171        let roundtrip = resample_bilinear(&scaled_up, w, h, &scale_half, w, h);
172
173        // Center region should roughly match
174        for y in 4..h - 4 {
175            for x in 4..w - 4 {
176                let idx = y * w + x;
177                assert!(
178                    (pixels[idx] - roundtrip[idx]).abs() < 5.0,
179                    "Large mismatch at ({x},{y}): expected {}, got {}",
180                    pixels[idx],
181                    roundtrip[idx]
182                );
183            }
184        }
185    }
186}