Skip to main content

phasm_core/stego/armor/
template.rs

1// Copyright (c) 2026 Christoph Gaffga
2// SPDX-License-Identifier: GPL-3.0-only
3// https://github.com/cgaffga/phasmcore
4
5//! DFT template generation, embedding, detection, and transform estimation.
6//!
7//! Embeds K=32 peaks at passphrase-derived positions in the DFT magnitude
8//! spectrum. These peaks survive geometric transforms (rotation, scaling, crop)
9//! and allow the decoder to estimate and undo the transform.
10
11use crate::stego::armor::fft2d::Complex32;
12use rand::SeedableRng;
13use rand::Rng;
14use rand_chacha::ChaCha20Rng;
15
16use crate::stego::armor::fft2d::Spectrum2D;
17use crate::stego::crypto::derive_template_key;
18use crate::stego::error::StegoError;
19
20/// Number of template peaks.
21const K: usize = 32;
22
23/// Embedding strength: peaks are `ALPHA * local_magnitude`.
24const ALPHA: f32 = 0.4;
25
26/// Minimum radius factor (fraction of min dimension).
27const R_MIN_FACTOR: f64 = 0.05;
28
29/// Maximum radius factor (fraction of min dimension).
30const R_MAX_FACTOR: f64 = 0.25;
31
32/// Detection threshold: sigma above local noise.
33const DETECTION_THRESHOLD: f64 = 3.0;
34
35/// Minimum peaks needed for transform estimation.
36const MIN_PEAKS_FOR_ESTIMATION: usize = 8;
37
38/// Search radius in frequency bins for peak detection.
39const SEARCH_RADIUS: i32 = 5;
40
41/// A template peak position and amplitude.
42#[derive(Debug, Clone)]
43pub struct TemplatePeak {
44    /// Frequency coordinate u (can be fractional for generation, integer bin for embedding).
45    pub u: f64,
46    /// Frequency coordinate v.
47    pub v: f64,
48    /// Embedding amplitude (set during embedding).
49    pub amplitude: f64,
50}
51
52/// A detected peak with expected vs actual position.
53#[derive(Debug, Clone)]
54pub struct DetectedPeak {
55    pub expected_u: f64,
56    pub expected_v: f64,
57    pub detected_u: f64,
58    pub detected_v: f64,
59    pub confidence: f64,
60}
61
62/// Estimated affine transform parameters.
63#[derive(Debug, Clone)]
64pub struct AffineTransform {
65    pub rotation_rad: f64,
66    pub scale: f64,
67}
68
69/// Generate K=32 peak positions from passphrase via ChaCha20 PRNG.
70///
71/// Peak positions are in the mid-frequency band (between R_MIN and R_MAX
72/// from the center) to avoid both DC and high-frequency noise.
73pub fn generate_template_peaks(passphrase: &str, width: usize, height: usize) -> Result<Vec<TemplatePeak>, StegoError> {
74    let key = derive_template_key(passphrase)?;
75    let mut rng = ChaCha20Rng::from_seed(key);
76
77    let min_dim = width.min(height) as f64;
78    let r_min = R_MIN_FACTOR * min_dim;
79    let r_max = R_MAX_FACTOR * min_dim;
80
81    let mut peaks = Vec::with_capacity(K);
82    for _ in 0..K {
83        // Generate random angle and radius in mid-frequency band
84        let angle: f64 = rng.gen_range(0.0..std::f64::consts::TAU);
85        let radius: f64 = rng.gen_range(r_min..r_max);
86
87        let (sin_a, cos_a) = crate::det_math::det_sincos(angle);
88        let u = radius * cos_a;
89        let v = radius * sin_a;
90
91        peaks.push(TemplatePeak {
92            u,
93            v,
94            amplitude: 0.0, // Set during embedding
95        });
96    }
97
98    Ok(peaks)
99}
100
101/// Add peaks to DFT magnitude, preserving phase + Hermitian symmetry.
102///
103/// For each peak at (u,v), adds `ALPHA * local_magnitude` along the existing
104/// phase direction. Mirrors at conjugate position (-u,-v) for Hermitian symmetry
105/// so the IFFT result remains real-valued.
106///
107/// P1b: Works with f32 spectrum data.
108pub fn embed_template(spectrum: &mut Spectrum2D, peaks: &[TemplatePeak]) {
109    let w = spectrum.width;
110    let h = spectrum.height;
111    let cx = w as f64 / 2.0;
112    let cy = h as f64 / 2.0;
113
114    for peak in peaks {
115        // Convert centered frequency to spectrum index
116        let su = (cx + peak.u).round() as isize;
117        let sv = (cy + peak.v).round() as isize;
118
119        // Skip out-of-bounds peaks
120        if su < 0 || su >= w as isize || sv < 0 || sv >= h as isize {
121            continue;
122        }
123        let idx = sv as usize * w + su as usize;
124
125        // Compute local magnitude for scaling
126        let local_mag = local_mean_magnitude(spectrum, su as usize, sv as usize);
127        let add_mag = ALPHA * local_mag.max(1.0);
128
129        // Add along existing phase direction
130        let existing = spectrum.data[idx];
131        let enorm = crate::det_math::det_hypot(existing.re as f64, existing.im as f64) as f32;
132        let phase = if enorm > 1e-6 {
133            Complex32::new(existing.re / enorm, existing.im / enorm)
134        } else {
135            Complex32::new(1.0, 0.0)
136        };
137        spectrum.data[idx] += phase * add_mag;
138
139        // Hermitian conjugate: (-u, -v) maps to (w-su, h-sv)
140        let cu = (w as isize - su) % w as isize;
141        let cv = (h as isize - sv) % h as isize;
142        if cu >= 0 && cv >= 0 {
143            let conj_idx = cv as usize * w + cu as usize;
144            if conj_idx != idx {
145                let conj_existing = spectrum.data[conj_idx];
146                let cnorm = crate::det_math::det_hypot(conj_existing.re as f64, conj_existing.im as f64) as f32;
147                let conj_phase = if cnorm > 1e-6 {
148                    Complex32::new(conj_existing.re / cnorm, conj_existing.im / cnorm)
149                } else {
150                    Complex32::new(1.0, 0.0)
151                };
152                spectrum.data[conj_idx] += conj_phase * add_mag;
153            }
154        }
155    }
156}
157
158/// Search for peaks in DFT magnitude near expected positions.
159///
160/// For each expected peak, searches a `SEARCH_RADIUS`-bin neighborhood
161/// for the maximum magnitude. Computes confidence as (peak - noise_mean) / noise_std.
162///
163/// P1b: Works with f32 magnitude spectrum, computes stats in f64 for precision.
164pub fn detect_template(spectrum: &Spectrum2D, peaks: &[TemplatePeak]) -> Vec<DetectedPeak> {
165    let w = spectrum.width;
166    let h = spectrum.height;
167    let cx = w as f64 / 2.0;
168    let cy = h as f64 / 2.0;
169
170    let magnitudes = super::fft2d::magnitude_spectrum(spectrum);
171
172    let mut detected = Vec::new();
173
174    for peak in peaks {
175        let su = (cx + peak.u).round() as isize;
176        let sv = (cy + peak.v).round() as isize;
177
178        if su < 0 || su >= w as isize || sv < 0 || sv >= h as isize {
179            continue;
180        }
181
182        // Search for maximum in neighborhood
183        let mut best_mag = 0.0f64;
184        let mut best_u = su;
185        let mut best_v = sv;
186        let mut noise_sum = 0.0f64;
187        let mut noise_sq_sum = 0.0f64;
188        let mut noise_count = 0usize;
189
190        for dv in -SEARCH_RADIUS..=SEARCH_RADIUS {
191            for du in -SEARCH_RADIUS..=SEARCH_RADIUS {
192                let nu = su + du as isize;
193                let nv = sv + dv as isize;
194                if nu < 0 || nu >= w as isize || nv < 0 || nv >= h as isize {
195                    continue;
196                }
197                let mag = magnitudes[nv as usize * w + nu as usize] as f64;
198
199                if mag > best_mag {
200                    best_mag = mag;
201                    best_u = nu;
202                    best_v = nv;
203                }
204
205                // Collect noise stats from the ring (exclude center 1-bin radius)
206                if du.abs() > 1 || dv.abs() > 1 {
207                    noise_sum += mag;
208                    noise_sq_sum += mag * mag;
209                    noise_count += 1;
210                }
211            }
212        }
213
214        if noise_count < 2 {
215            continue;
216        }
217
218        let noise_mean = noise_sum / noise_count as f64;
219        let noise_var = (noise_sq_sum / noise_count as f64) - noise_mean * noise_mean;
220        // sqrt() is IEEE 754 correctly-rounded — deterministic across all platforms
221        let noise_std = noise_var.max(0.0).sqrt().max(1e-12);
222
223        let confidence = (best_mag - noise_mean) / noise_std;
224
225        if confidence >= DETECTION_THRESHOLD {
226            detected.push(DetectedPeak {
227                expected_u: peak.u,
228                expected_v: peak.v,
229                detected_u: best_u as f64 - cx,
230                detected_v: best_v as f64 - cy,
231                confidence,
232            });
233        }
234    }
235
236    detected
237}
238
239/// Least-squares rotation+scale estimation from peak displacement.
240///
241/// Given pairs of (expected, detected) peak positions, estimates the
242/// rotation angle and scale factor. Returns None if fewer than
243/// `MIN_PEAKS_FOR_ESTIMATION` peaks were detected.
244///
245/// Uses the closed-form solution:
246/// ```text
247/// a = Σ(u·u' + v·v') / Σ(u² + v²)
248/// b = Σ(u·v' - v·u') / Σ(u² + v²)
249/// θ = atan2(b, a),  s = sqrt(a² + b²)
250/// ```
251pub fn estimate_transform(detected: &[DetectedPeak]) -> Option<AffineTransform> {
252    if detected.len() < MIN_PEAKS_FOR_ESTIMATION {
253        return None;
254    }
255
256    let mut num_a = 0.0f64;
257    let mut num_b = 0.0f64;
258    let mut denom = 0.0f64;
259
260    for peak in detected {
261        let u = peak.expected_u;
262        let v = peak.expected_v;
263        let u_prime = peak.detected_u;
264        let v_prime = peak.detected_v;
265
266        num_a += u * u_prime + v * v_prime;
267        num_b += u * v_prime - v * u_prime;
268        denom += u * u + v * v;
269    }
270
271    if denom < 1e-12 {
272        return None;
273    }
274
275    let a = num_a / denom;
276    let b = num_b / denom;
277
278    let rotation_rad = crate::det_math::det_atan2(b, a);
279    // sqrt() is IEEE 754 correctly-rounded — deterministic across all platforms
280    let scale = (a * a + b * b).sqrt();
281
282    Some(AffineTransform {
283        rotation_rad,
284        scale,
285    })
286}
287
288/// Compute mean magnitude in a 3x3 neighborhood around (u, v).
289///
290/// P1b: Works with f32 spectrum data, returns f32.
291fn local_mean_magnitude(spectrum: &Spectrum2D, u: usize, v: usize) -> f32 {
292    let w = spectrum.width;
293    let h = spectrum.height;
294    let mut sum = 0.0f64;
295    let mut count = 0;
296
297    for dv in -1i32..=1 {
298        for du in -1i32..=1 {
299            let nu = u as i32 + du;
300            let nv = v as i32 + dv;
301            if nu >= 0 && nu < w as i32 && nv >= 0 && nv < h as i32 {
302                let c = spectrum.data[nv as usize * w + nu as usize];
303                sum += crate::det_math::det_hypot(c.re as f64, c.im as f64);
304                count += 1;
305            }
306        }
307    }
308
309    if count > 0 { (sum / count as f64) as f32 } else { 1.0 }
310}
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315    use crate::stego::armor::fft2d;
316
317    #[test]
318    fn generate_deterministic() {
319        let p1 = generate_template_peaks("test_pass", 256, 256).unwrap();
320        let p2 = generate_template_peaks("test_pass", 256, 256).unwrap();
321        assert_eq!(p1.len(), K);
322        assert_eq!(p2.len(), K);
323        for i in 0..K {
324            assert_eq!(p1[i].u, p2[i].u);
325            assert_eq!(p1[i].v, p2[i].v);
326        }
327    }
328
329    #[test]
330    fn different_passphrases_differ() {
331        let p1 = generate_template_peaks("pass1", 256, 256).unwrap();
332        let p2 = generate_template_peaks("pass2", 256, 256).unwrap();
333        // At least some peaks should differ
334        let mut all_same = true;
335        for i in 0..K {
336            if (p1[i].u - p2[i].u).abs() > 1e-10 || (p1[i].v - p2[i].v).abs() > 1e-10 {
337                all_same = false;
338                break;
339            }
340        }
341        assert!(!all_same, "Different passphrases should produce different peaks");
342    }
343
344    #[test]
345    fn peaks_in_mid_frequency_band() {
346        let w = 256;
347        let h = 256;
348        let peaks = generate_template_peaks("test", w, h).unwrap();
349        let min_dim = w.min(h) as f64;
350        let r_min = R_MIN_FACTOR * min_dim;
351        let r_max = R_MAX_FACTOR * min_dim;
352
353        for (i, peak) in peaks.iter().enumerate() {
354            let r = (peak.u * peak.u + peak.v * peak.v).sqrt();
355            assert!(
356                r >= r_min - 0.01 && r <= r_max + 0.01,
357                "Peak {i} at radius {r} outside band [{r_min}, {r_max}]"
358            );
359        }
360    }
361
362    #[test]
363    fn embed_detect_roundtrip() {
364        let width = 128;
365        let height = 128;
366        // Create a synthetic image with some content
367        let pixels: Vec<f64> = (0..width * height)
368            .map(|i| {
369                let x = (i % width) as f64;
370                let y = (i / width) as f64;
371                128.0 + 50.0 * (x * 0.1).sin() * (y * 0.05).cos()
372            })
373            .collect();
374
375        let mut spectrum = fft2d::fft2d(&pixels, width, height);
376        let peaks = generate_template_peaks("embed_test", width, height).unwrap();
377        embed_template(&mut spectrum, &peaks);
378
379        let detected = detect_template(&spectrum, &peaks);
380
381        assert!(
382            detected.len() >= K / 2,
383            "Should detect at least half of {} peaks, got {}",
384            K,
385            detected.len()
386        );
387    }
388
389    #[test]
390    fn transform_estimation_identity() {
391        // If detected positions match expected, transform should be identity
392        let detected: Vec<DetectedPeak> = (0..16)
393            .map(|i| {
394                let angle = i as f64 * std::f64::consts::TAU / 16.0;
395                let r = 20.0;
396                let u = r * angle.cos();
397                let v = r * angle.sin();
398                DetectedPeak {
399                    expected_u: u,
400                    expected_v: v,
401                    detected_u: u,
402                    detected_v: v,
403                    confidence: 10.0,
404                }
405            })
406            .collect();
407
408        let transform = estimate_transform(&detected).unwrap();
409        assert!(
410            transform.rotation_rad.abs() < 0.01,
411            "Expected ~0 rotation, got {}",
412            transform.rotation_rad.to_degrees()
413        );
414        assert!(
415            (transform.scale - 1.0).abs() < 0.01,
416            "Expected ~1.0 scale, got {}",
417            transform.scale
418        );
419    }
420
421    #[test]
422    fn transform_estimation_rotation() {
423        // Simulate 15 degree rotation
424        let angle_deg = 15.0;
425        let angle_rad = angle_deg * std::f64::consts::PI / 180.0;
426        let cos_a = angle_rad.cos();
427        let sin_a = angle_rad.sin();
428
429        let detected: Vec<DetectedPeak> = (0..20)
430            .map(|i| {
431                let a = i as f64 * std::f64::consts::TAU / 20.0;
432                let r = 25.0;
433                let u = r * a.cos();
434                let v = r * a.sin();
435                // Rotated positions
436                let u_rot = u * cos_a - v * sin_a;
437                let v_rot = u * sin_a + v * cos_a;
438                DetectedPeak {
439                    expected_u: u,
440                    expected_v: v,
441                    detected_u: u_rot,
442                    detected_v: v_rot,
443                    confidence: 10.0,
444                }
445            })
446            .collect();
447
448        let transform = estimate_transform(&detected).unwrap();
449        assert!(
450            (transform.rotation_rad - angle_rad).abs() < 0.01,
451            "Expected {angle_deg} deg rotation, got {} deg",
452            transform.rotation_rad.to_degrees()
453        );
454        assert!(
455            (transform.scale - 1.0).abs() < 0.01,
456            "Expected ~1.0 scale, got {}",
457            transform.scale
458        );
459    }
460
461    #[test]
462    fn transform_estimation_scale() {
463        // Simulate 0.8x scale
464        let scale_factor = 0.8;
465
466        let detected: Vec<DetectedPeak> = (0..16)
467            .map(|i| {
468                let a = i as f64 * std::f64::consts::TAU / 16.0;
469                let r = 20.0;
470                let u = r * a.cos();
471                let v = r * a.sin();
472                DetectedPeak {
473                    expected_u: u,
474                    expected_v: v,
475                    detected_u: u * scale_factor,
476                    detected_v: v * scale_factor,
477                    confidence: 10.0,
478                }
479            })
480            .collect();
481
482        let transform = estimate_transform(&detected).unwrap();
483        assert!(
484            transform.rotation_rad.abs() < 0.01,
485            "Expected ~0 rotation, got {} deg",
486            transform.rotation_rad.to_degrees()
487        );
488        assert!(
489            (transform.scale - scale_factor).abs() < 0.01,
490            "Expected {scale_factor} scale, got {}",
491            transform.scale
492        );
493    }
494
495    #[test]
496    fn too_few_peaks_returns_none() {
497        let detected: Vec<DetectedPeak> = (0..3)
498            .map(|i| DetectedPeak {
499                expected_u: i as f64,
500                expected_v: i as f64,
501                detected_u: i as f64,
502                detected_v: i as f64,
503                confidence: 10.0,
504            })
505            .collect();
506
507        assert!(estimate_transform(&detected).is_none());
508    }
509}