Skip to main content

resamplescope/
edge.rs

1use crate::pattern::{self, BRIGHT, DARK, LINE_DST_WIDTH, LINE_SRC_WIDTH};
2
3/// Detected edge handling mode.
4#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5pub enum EdgeMode {
6    /// Clamps to the nearest edge pixel.
7    Clamp,
8    /// Reflects across the edge.
9    Reflect,
10    /// Wraps around to the opposite edge.
11    Wrap,
12    /// Treats out-of-bounds as zero (black).
13    Zero,
14    /// Could not determine edge handling.
15    Unknown,
16}
17
18impl std::fmt::Display for EdgeMode {
19    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
20        match self {
21            Self::Clamp => f.write_str("Clamp"),
22            Self::Reflect => f.write_str("Reflect"),
23            Self::Wrap => f.write_str("Wrap"),
24            Self::Zero => f.write_str("Zero"),
25            Self::Unknown => f.write_str("Unknown"),
26        }
27    }
28}
29
30/// Detect the edge handling mode used by a resizer.
31///
32/// Generates a test image with a bright column at x=1 (near the left edge),
33/// resizes it, then analyzes the asymmetry of the filter response near the
34/// boundary to classify the edge handling strategy.
35pub fn detect(resize: &crate::ResizeFn) -> EdgeMode {
36    let edge_img = pattern::generate_edge_pattern();
37    let dst_w = LINE_DST_WIDTH;
38    let dst_h = edge_img.height();
39    let resized = resize(edge_img.as_ref(), dst_w, dst_h);
40
41    if resized.width() != dst_w || resized.height() != dst_h {
42        return EdgeMode::Unknown;
43    }
44
45    let scale_factor = dst_w as f64 / LINE_SRC_WIDTH as f64;
46    let scanline = resized.height() / 2;
47    let row = &resized.buf()[scanline * resized.stride()..][..dst_w];
48
49    // Convert to normalized weights.
50    let weights: Vec<f64> = row
51        .iter()
52        .map(|&v| (v as f64 - DARK as f64) / (BRIGHT as f64 - DARK as f64))
53        .collect();
54
55    // Find the peak (should be near x=1 * scale_factor).
56    let expected_peak = ((1.0 + 0.5) * scale_factor - 0.5) as usize;
57    let search_start = expected_peak.saturating_sub(5);
58    let search_end = (expected_peak + 6).min(dst_w);
59    let peak_idx = (search_start..search_end)
60        .max_by(|&a, &b| weights[a].partial_cmp(&weights[b]).unwrap())
61        .unwrap_or(expected_peak);
62
63    // Compute energy on each side of the peak.
64    // Left side: from pixel 0 to peak (edge-influenced).
65    // Right side: mirror of the left side, away from edge (clean interior).
66    let left_extent = peak_idx;
67    let right_extent = (dst_w - 1 - peak_idx).min(left_extent);
68
69    // Use matching extents for fair comparison.
70    let extent = left_extent.min(right_extent).min(dst_w / 4);
71
72    if extent < 3 {
73        return EdgeMode::Unknown;
74    }
75
76    let left_energy: f64 = (1..=extent).map(|d| weights[peak_idx - d].abs()).sum();
77    let right_energy: f64 = (1..=extent).map(|d| weights[peak_idx + d].abs()).sum();
78
79    // Check for wrap: energy at the far-right side of the image.
80    // If wrap is active, the bright column at x=1 wraps to near x=14,
81    // which maps to the far-right of the output.
82    let far_right_start = dst_w.saturating_sub((2.0 * scale_factor) as usize);
83    let far_energy: f64 = (far_right_start..dst_w)
84        .map(|i| weights[i].abs())
85        .sum::<f64>()
86        / (dst_w - far_right_start) as f64;
87
88    // Check for negative values on left side (indicator of zero padding).
89    let left_has_negative = (0..peak_idx).any(|i| weights[i] < -0.03);
90
91    // Energy ratio: left/right. Values close to 1.0 mean symmetric (clamp-like).
92    let energy_ratio = if right_energy > 1e-6 {
93        left_energy / right_energy
94    } else {
95        1.0
96    };
97
98    // Classify based on observed patterns:
99    if left_has_negative || energy_ratio < 0.5 {
100        // Zero padding creates missing contributions or negative artifacts.
101        EdgeMode::Zero
102    } else if far_energy > 0.02 {
103        // Wrap causes energy at the far end of the image.
104        EdgeMode::Wrap
105    } else if energy_ratio > 1.5 {
106        // Reflect doubles the bright column's contribution on the left side.
107        EdgeMode::Reflect
108    } else if energy_ratio > 0.7 {
109        // Clamp preserves the filter shape (dark background extends naturally).
110        EdgeMode::Clamp
111    } else {
112        EdgeMode::Unknown
113    }
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119    use imgref::{ImgRef, ImgVec};
120
121    /// Clamp-based nearest-neighbor resize.
122    fn nn_resize(src: ImgRef<'_, u8>, dst_w: usize, dst_h: usize) -> ImgVec<u8> {
123        let mut dst = vec![0u8; dst_w * dst_h];
124        for y in 0..dst_h {
125            let sy = ((y as f64 + 0.5) * src.height() as f64 / dst_h as f64 - 0.5)
126                .round()
127                .clamp(0.0, (src.height() - 1) as f64) as usize;
128            for x in 0..dst_w {
129                let sx = ((x as f64 + 0.5) * src.width() as f64 / dst_w as f64 - 0.5)
130                    .round()
131                    .clamp(0.0, (src.width() - 1) as f64) as usize;
132                dst[y * dst_w + x] = src.buf()[sy * src.stride() + sx];
133            }
134        }
135        ImgVec::new(dst, dst_w, dst_h)
136    }
137
138    #[test]
139    fn nn_produces_some_result() {
140        let mode = detect(&nn_resize);
141        // Nearest-neighbor has zero filter extent, so edge detection
142        // results are not meaningful. Just verify it doesn't panic.
143        let _ = mode;
144    }
145}