Skip to main content

resamplescope/
analyze.rs

1use imgref::ImgRef;
2
3use crate::pattern::{
4    BRIGHT, DARK, DOT_DST_HEIGHT, DOT_DST_WIDTH, DOT_HCENTER, DOT_HPIXELSPAN, DOT_NUM_STRIPS,
5    DOT_SRC_WIDTH, DOT_STRIP_HEIGHT, LINE_DST_HEIGHT, LINE_DST_WIDTH, LINE_SRC_WIDTH,
6};
7
8/// A reconstructed filter curve from analysis.
9#[derive(Debug, Clone)]
10pub struct FilterCurve {
11    /// (offset, weight) sample points. Offset is in source-pixel units
12    /// (distance from the filter center). Weight is the normalized filter value.
13    pub points: Vec<(f64, f64)>,
14    /// Integral of the filter (should be ~1.0 for a normalized filter).
15    pub area: f64,
16    /// Scale factor used: dst_width / src_width.
17    pub scale_factor: f64,
18    /// True for dot pattern (scatter), false for line pattern (connected).
19    pub is_scatter: bool,
20}
21
22fn srgb_to_linear(v: f64) -> f64 {
23    if v <= 0.04045 {
24        v / 12.92
25    } else {
26        ((v + 0.055) / 1.055).powf(2.4)
27    }
28}
29
30/// Read a pixel value, optionally applying sRGB correction.
31/// Returns a value in the range where DARK=50 and BRIGHT=250.
32fn read_pixel(img: &ImgRef<'_, u8>, x: usize, y: usize, srgb: bool) -> f64 {
33    let raw = img.buf()[y * img.stride() + x] as f64;
34    if srgb {
35        let srgb50_lin = srgb_to_linear(50.0 / 255.0);
36        let srgb250_lin = srgb_to_linear(250.0 / 255.0);
37        let v_lin = srgb_to_linear(raw / 255.0);
38        (v_lin - srgb50_lin) * ((BRIGHT as f64 - DARK as f64) / (srgb250_lin - srgb50_lin))
39            + DARK as f64
40    } else {
41        raw
42    }
43}
44
45/// Reconstruct the filter curve from a resized dot pattern image (downscale analysis).
46///
47/// The dot pattern has 25 strips, each with bright dots at phase-offset positions.
48/// By analyzing where each output pixel falls relative to the nearest dot,
49/// we reconstruct the filter kernel as a scatter plot.
50pub fn analyze_dot(img: &ImgRef<'_, u8>, srgb: bool) -> FilterCurve {
51    let w = img.width();
52    let h = img.height();
53    let scale_factor = w as f64 / DOT_SRC_WIDTH as f64;
54
55    assert_eq!(
56        h, DOT_DST_HEIGHT,
57        "dot image height must be {DOT_DST_HEIGHT}"
58    );
59
60    let mut points = Vec::new();
61
62    for strip in 0..DOT_NUM_STRIPS {
63        for dstpos in 0..w {
64            // Find nearest zero-point for this strip and output pixel.
65            let mut offset = 10000.0_f64;
66
67            let mut k = DOT_HCENTER + strip;
68            while k < DOT_SRC_WIDTH - DOT_HCENTER {
69                // Convert source dot position to target image coordinates.
70                let zp = scale_factor * (k as f64 + 0.5 - DOT_SRC_WIDTH as f64 / 2.0)
71                    + (w as f64 / 2.0)
72                    - 0.5;
73
74                let tmp_offset = dstpos as f64 - zp;
75
76                if tmp_offset.abs() < offset.abs() {
77                    offset = tmp_offset;
78                }
79
80                k += DOT_HPIXELSPAN;
81            }
82
83            // Skip points too far from any dot.
84            if offset.abs() > scale_factor * DOT_HCENTER as f64 {
85                continue;
86            }
87
88            // Sum vertically across the strip to undo vertical blur.
89            let mut tot = 0.0;
90            for row in 0..DOT_STRIP_HEIGHT {
91                let y = DOT_STRIP_HEIGHT * strip + row;
92                let v = read_pixel(img, dstpos, y, srgb);
93                tot += v - DARK as f64;
94            }
95
96            // Convert to normalized weight.
97            let mut weight = tot / (BRIGHT as f64 - DARK as f64);
98
99            if scale_factor < 1.0 {
100                // Downscale: compensate for pixel size reduction.
101                weight /= scale_factor;
102            } else {
103                // Upscale: convert offset to source-pixel units.
104                offset /= scale_factor;
105            }
106
107            points.push((offset, weight));
108        }
109    }
110
111    FilterCurve {
112        points,
113        area: 0.0, // Not well-defined for scatter data
114        scale_factor,
115        is_scatter: true,
116    }
117}
118
119/// Reconstruct the filter curve from a resized line pattern image (upscale analysis).
120///
121/// The line pattern is a single bright column that, when upscaled, directly reveals
122/// the filter kernel shape as a connected curve.
123pub fn analyze_line(img: &ImgRef<'_, u8>, srgb: bool) -> FilterCurve {
124    let w = img.width();
125    let h = img.height();
126    let scale_factor = w as f64 / LINE_SRC_WIDTH as f64;
127    let scanline = h / 2;
128
129    let mut points = Vec::new();
130    let mut tot = 0.0;
131
132    for i in 0..w {
133        // Read from cycling scanlines (±1 row) as a consistency check,
134        // matching the C source behavior.
135        let y = if h >= 3 {
136            let cycle_offset = (i % 3) as isize - 1;
137            (scanline as isize + cycle_offset).clamp(0, h as isize - 1) as usize
138        } else {
139            scanline
140        };
141
142        let v = read_pixel(img, i, y, srgb);
143        let mut weight = (v - DARK as f64) / (BRIGHT as f64 - DARK as f64);
144        tot += weight;
145
146        let mut offset = 0.5 + i as f64 - (w as f64 / 2.0);
147
148        if scale_factor < 1.0 {
149            weight /= scale_factor;
150        } else {
151            offset /= scale_factor;
152        }
153
154        points.push((offset, weight));
155    }
156
157    let area = tot / scale_factor;
158
159    FilterCurve {
160        points,
161        area,
162        scale_factor,
163        is_scatter: false,
164    }
165}
166
167/// Expected target dimensions for the dot pattern resize.
168pub fn dot_target() -> (usize, usize) {
169    (DOT_DST_WIDTH, DOT_DST_HEIGHT)
170}
171
172/// Expected target dimensions for the line pattern resize.
173pub fn line_target() -> (usize, usize) {
174    (LINE_DST_WIDTH, LINE_DST_HEIGHT)
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180    use crate::pattern;
181    use imgref::ImgVec;
182
183    /// Nearest-neighbor resize for testing.
184    fn nn_resize(src: ImgRef<'_, u8>, dst_w: usize, dst_h: usize) -> ImgVec<u8> {
185        let mut dst = vec![0u8; dst_w * dst_h];
186        for y in 0..dst_h {
187            let sy = ((y as f64 + 0.5) * src.height() as f64 / dst_h as f64 - 0.5)
188                .round()
189                .clamp(0.0, (src.height() - 1) as f64) as usize;
190            for x in 0..dst_w {
191                let sx = ((x as f64 + 0.5) * src.width() as f64 / dst_w as f64 - 0.5)
192                    .round()
193                    .clamp(0.0, (src.width() - 1) as f64) as usize;
194                dst[y * dst_w + x] = src.buf()[sy * src.stride() + sx];
195            }
196        }
197        ImgVec::new(dst, dst_w, dst_h)
198    }
199
200    #[test]
201    fn dot_analysis_produces_points() {
202        let dot = pattern::generate_dot_pattern();
203        let (tw, th) = dot_target();
204        let resized = nn_resize(dot.as_ref(), tw, th);
205        let curve = analyze_dot(&resized.as_ref(), false);
206        assert!(!curve.points.is_empty());
207        assert!(curve.scale_factor < 1.0);
208    }
209
210    #[test]
211    fn line_analysis_produces_curve() {
212        let line = pattern::generate_line_pattern();
213        let (tw, th) = line_target();
214        let resized = nn_resize(line.as_ref(), tw, th);
215        let curve = analyze_line(&resized.as_ref(), false);
216        assert_eq!(curve.points.len(), tw);
217        assert!(curve.scale_factor > 1.0);
218        // Area should be roughly 1.0 for a normalized filter
219        assert!((curve.area - 1.0).abs() < 0.5, "area = {}", curve.area);
220    }
221}