Skip to main content

otf_pixels_ops/
filter.rs

1//! Resampling filters and the weight tables resize builds from them.
2//!
3//! A separable resize is two one-dimensional passes, and each pass is the same
4//! thing: for every output position, a short run of input samples multiplied by
5//! weights that sum to one. Everything specific to a filter lives in
6//! [`Filter::weight`]; everything specific to a *scale* lives in [`Weights`].
7//!
8//! # Why the weights are precomputed
9//!
10//! Evaluating `sinc` per pixel would dominate the cost and, worse, would put a
11//! transcendental function inside the loop we want vectorized. Computing the
12//! table once per pass costs `output_length` evaluations instead of
13//! `output_length × input_length`, and leaves an inner loop of nothing but
14//! multiply-accumulate.
15//!
16//! # Fixed point
17//!
18//! Per ADR-0011, eight-bit paths use `i32` fixed-point weights. Quantization
19//! happens once, here, and the residual is corrected so each run still sums to
20//! exactly [`ONE`]: an uncorrected table drifts the output brightness by a
21//! fraction of a level, which is visible as banding on a gradient.
22
23use otf_pixels_core::{PixelsError, Result};
24
25/// Fixed-point scale for quantized weights: one unit of `1.0`.
26///
27/// 14 bits leaves room for a full 8-bit sample (8 bits) times the largest
28/// plausible coefficient sum, accumulated over a Lanczos3 support of up to a
29/// few dozen taps, without leaving `i32`. Lanczos weights are signed and can
30/// overshoot, so the headroom is not merely the positive case.
31pub const ONE: i32 = 1 << 14;
32
33/// A resampling filter kernel (SPEC §Core ops).
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
35#[non_exhaustive]
36pub enum Filter {
37    /// Nearest neighbour. Fastest, blockiest; the only filter that preserves
38    /// exact sample values, which is why it is the right default for masks
39    /// and palettes rather than photographs.
40    Nearest,
41    /// Box average over the source footprint. The correct choice for large
42    /// downscales, where it is both fast and alias-free.
43    Box,
44    /// Linear interpolation between the two nearest samples.
45    Bilinear,
46    /// Catmull-Rom cubic. Sharper than Mitchell, mild ringing.
47    CatmullRom,
48    /// Mitchell-Netravali cubic (B=C=1/3). The usual compromise between
49    /// blurring and ringing.
50    Mitchell,
51    /// Lanczos windowed sinc, 2 lobes.
52    Lanczos2,
53    /// Lanczos windowed sinc, 3 lobes. The default: sharpest of these at the
54    /// cost of some ringing on hard edges.
55    #[default]
56    Lanczos3,
57}
58
59impl Filter {
60    /// The filter's radius in **output** units before scale is applied.
61    ///
62    /// The actual support in input pixels is this scaled by the downsampling
63    /// ratio, because downscaling must average over everything it discards or
64    /// it aliases.
65    #[must_use]
66    pub const fn support(self) -> f32 {
67        match self {
68            Self::Nearest => 0.5,
69            Self::Box => 0.5,
70            Self::Bilinear => 1.0,
71            Self::CatmullRom | Self::Mitchell => 2.0,
72            Self::Lanczos2 => 2.0,
73            Self::Lanczos3 => 3.0,
74        }
75    }
76
77    /// The filter's weight at signed distance `x` from the sample centre.
78    #[must_use]
79    pub fn weight(self, x: f32) -> f32 {
80        let t = x.abs();
81        match self {
82            Self::Nearest => {
83                if t <= 0.5 {
84                    1.0
85                } else {
86                    0.0
87                }
88            }
89            Self::Box => {
90                if t < 0.5 {
91                    1.0
92                } else {
93                    0.0
94                }
95            }
96            Self::Bilinear => {
97                if t < 1.0 {
98                    1.0 - t
99                } else {
100                    0.0
101                }
102            }
103            Self::CatmullRom => cubic(t, 0.0, 0.5),
104            Self::Mitchell => cubic(t, 1.0 / 3.0, 1.0 / 3.0),
105            Self::Lanczos2 => lanczos(t, 2.0),
106            Self::Lanczos3 => lanczos(t, 3.0),
107        }
108    }
109
110    /// A short, stable name for diagnostics and benchmark output.
111    #[must_use]
112    pub const fn as_str(self) -> &'static str {
113        match self {
114            Self::Nearest => "nearest",
115            Self::Box => "box",
116            Self::Bilinear => "bilinear",
117            Self::CatmullRom => "catmull-rom",
118            Self::Mitchell => "mitchell",
119            Self::Lanczos2 => "lanczos2",
120            Self::Lanczos3 => "lanczos3",
121        }
122    }
123}
124
125/// The Mitchell-Netravali cubic family, of which Catmull-Rom is `B=0, C=1/2`.
126fn cubic(t: f32, b: f32, c: f32) -> f32 {
127    let t2 = t * t;
128    let t3 = t2 * t;
129    if t < 1.0 {
130        ((12.0 - 9.0 * b - 6.0 * c) * t3 + (-18.0 + 12.0 * b + 6.0 * c) * t2 + (6.0 - 2.0 * b))
131            / 6.0
132    } else if t < 2.0 {
133        ((-b - 6.0 * c) * t3
134            + (6.0 * b + 30.0 * c) * t2
135            + (-12.0 * b - 48.0 * c) * t
136            + (8.0 * b + 24.0 * c))
137            / 6.0
138    } else {
139        0.0
140    }
141}
142
143/// A sinc windowed by a wider sinc — the Lanczos kernel.
144fn lanczos(t: f32, lobes: f32) -> f32 {
145    if t < f32::EPSILON {
146        return 1.0;
147    }
148    if t >= lobes {
149        return 0.0;
150    }
151    sinc(t) * sinc(t / lobes)
152}
153
154/// The normalized sinc, `sin(pi x) / (pi x)`.
155fn sinc(x: f32) -> f32 {
156    let pi_x = std::f32::consts::PI * x;
157    pi_x.sin() / pi_x
158}
159
160/// The input samples and weights contributing to one output position.
161#[derive(Debug, Clone, Copy, PartialEq, Eq)]
162pub struct Run {
163    /// First input index this output position reads.
164    pub start: u32,
165    /// How many consecutive input samples it reads.
166    pub len: u32,
167    /// Offset of this run's weights within [`Weights::quantized`].
168    pub at: usize,
169}
170
171/// Precomputed weights for one resize pass along one axis.
172///
173/// Built once per pass and shared by every row (or column) it is applied to,
174/// which is what turns a resize into a multiply-accumulate loop.
175#[derive(Debug, Clone)]
176pub struct Weights {
177    runs: Vec<Run>,
178    /// Fixed-point weights, concatenated run by run. Each run sums to [`ONE`].
179    quantized: Vec<i32>,
180    /// The same weights unquantized, for the 16-bit and float paths.
181    exact: Vec<f32>,
182    /// The longest run, which bounds the accumulator loop.
183    max_len: u32,
184}
185
186impl Weights {
187    /// Build the weight table mapping `input_len` samples onto `output_len`.
188    ///
189    /// # Errors
190    ///
191    /// Returns [`PixelsError::InvalidArgument`] if either length is zero, or
192    /// if the filter support at this scale would exceed what `u32` can index.
193    pub fn build(filter: Filter, input_len: u32, output_len: u32) -> Result<Self> {
194        if input_len == 0 || output_len == 0 {
195            return Err(PixelsError::invalid_argument(
196                "size",
197                format!("cannot resample {input_len} samples to {output_len}"),
198            ));
199        }
200
201        let scale = f64::from(output_len) / f64::from(input_len);
202        // Downscaling widens the kernel in input space: an output pixel must
203        // average everything that maps onto it, or the discarded samples alias
204        // back as moire. Upscaling leaves the kernel at its natural width.
205        let filter_scale = if scale < 1.0 { 1.0 / scale } else { 1.0 };
206        let support = f64::from(filter.support()) * filter_scale;
207
208        let mut runs = Vec::with_capacity(output_len as usize);
209        let mut quantized = Vec::new();
210        let mut exact = Vec::new();
211        let mut max_len = 0_u32;
212        let mut row: Vec<f32> = Vec::new();
213
214        for out in 0..output_len {
215            // Centre of this output pixel projected into input coordinates,
216            // measured between samples rather than at them — the half-pixel
217            // offset is what keeps the image from drifting by half a pixel.
218            let centre = (f64::from(out) + 0.5) / scale;
219            let first = ((centre - support) + 0.5).floor().max(0.0);
220            let last = ((centre + support) + 0.5).ceil().min(f64::from(input_len));
221            let start = first as u32;
222            let len = (last - first).max(1.0) as u32;
223            let len = len.min(input_len - start.min(input_len - 1));
224
225            row.clear();
226            let mut sum = 0.0_f32;
227            for i in 0..len {
228                let sample = f64::from(start + i) + 0.5;
229                // Distance measured in *filter* space, so a widened kernel
230                // still evaluates its own profile.
231                let distance = ((sample - centre) / filter_scale) as f32;
232                let w = filter.weight(distance);
233                row.push(w);
234                sum += w;
235            }
236
237            // A run whose weights cancel to nothing would divide by zero and
238            // produce a black pixel; fall back to a single nearest sample.
239            if sum.abs() < 1e-6 {
240                row.clear();
241                row.push(1.0);
242                sum = 1.0;
243            }
244
245            let at = quantized.len();
246            let mut total = 0_i32;
247            for w in &mut row {
248                *w /= sum;
249                exact.push(*w);
250                // Round half away from zero: weights are signed for Lanczos.
251                let q = if *w >= 0.0 {
252                    (*w * ONE as f32 + 0.5) as i32
253                } else {
254                    (*w * ONE as f32 - 0.5) as i32
255                };
256                quantized.push(q);
257                total += q;
258            }
259            // Quantization residue goes to the largest weight, so every run
260            // sums to exactly ONE. Without this the image drifts a fraction of
261            // a level darker or lighter, which shows as banding on gradients.
262            if total != ONE {
263                let biggest = quantized
264                    .get(at..)
265                    .and_then(|run| {
266                        run.iter()
267                            .enumerate()
268                            .max_by_key(|&(_, w)| *w)
269                            .map(|(i, _)| at + i)
270                    })
271                    .unwrap_or(at);
272                if let Some(slot) = quantized.get_mut(biggest) {
273                    *slot += ONE - total;
274                }
275            }
276
277            let len = row.len() as u32;
278            max_len = max_len.max(len);
279            runs.push(Run { start, len, at });
280        }
281
282        Ok(Self {
283            runs,
284            quantized,
285            exact,
286            max_len,
287        })
288    }
289
290    /// The table restricted to output positions `offset..offset + len`,
291    /// renumbered from zero: the window a cropping resize keeps. The weights
292    /// are shared, so nothing is recomputed and the kept positions resample
293    /// exactly as they would in the full table.
294    #[must_use]
295    pub fn window(mut self, offset: u32, len: u32) -> Self {
296        let start = (offset as usize).min(self.runs.len());
297        let end = start.saturating_add(len as usize).min(self.runs.len());
298        self.runs = self
299            .runs
300            .get(start..end)
301            .map(<[Run]>::to_vec)
302            .unwrap_or_default();
303        self.max_len = self.runs.iter().map(|run| run.len).max().unwrap_or(0);
304        self
305    }
306
307    /// The runs, one per output position.
308    #[must_use]
309    pub fn runs(&self) -> &[Run] {
310        &self.runs
311    }
312
313    /// The fixed-point weights of `run`.
314    #[must_use]
315    pub fn quantized(&self, run: &Run) -> &[i32] {
316        self.quantized
317            .get(run.at..run.at + run.len as usize)
318            .unwrap_or(&[])
319    }
320
321    /// The exact weights of `run`.
322    #[must_use]
323    pub fn exact(&self, run: &Run) -> &[f32] {
324        self.exact
325            .get(run.at..run.at + run.len as usize)
326            .unwrap_or(&[])
327    }
328
329    /// The longest run in the table.
330    #[must_use]
331    pub const fn max_len(&self) -> u32 {
332        self.max_len
333    }
334
335    /// The sub-table covering `out_len` output positions from `out_start`,
336    /// rebased so run starts are relative to input index `in_start`.
337    ///
338    /// This is how a tile uses the *image's* weights rather than its own.
339    /// Building a fresh table from the tile's dimensions would resample at the
340    /// tile's scale instead of the image's, so an output pixel would depend on
341    /// where the tile boundaries fell — which SPEC §Guarantees 2 forbids.
342    ///
343    /// # Errors
344    ///
345    /// Returns [`PixelsError::graph`] if the requested outputs fall outside
346    /// this table, or if a run would start before `in_start` — either means
347    /// the tile is not the footprint `input_regions` asked for.
348    pub fn for_tile(&self, out_start: u32, out_len: u32, in_start: u32) -> Result<Self> {
349        let from = out_start as usize;
350        let to = from + out_len as usize;
351        let slice = self.runs.get(from..to).ok_or_else(|| {
352            PixelsError::graph(format!(
353                "resize weights cover {} outputs, tile wants {from}..{to}",
354                self.runs.len()
355            ))
356        })?;
357
358        let mut runs = Vec::with_capacity(slice.len());
359        let mut quantized = Vec::new();
360        let mut exact = Vec::new();
361        let mut max_len = 0_u32;
362        for run in slice {
363            let start = run.start.checked_sub(in_start).ok_or_else(|| {
364                PixelsError::graph(format!(
365                    "resize tile starts at input {in_start} but a run needs {}",
366                    run.start
367                ))
368            })?;
369            let at = quantized.len();
370            quantized.extend_from_slice(self.quantized(run));
371            exact.extend_from_slice(self.exact(run));
372            max_len = max_len.max(run.len);
373            runs.push(Run {
374                start,
375                len: run.len,
376                at,
377            });
378        }
379        Ok(Self {
380            runs,
381            quantized,
382            exact,
383            max_len,
384        })
385    }
386
387    /// The first and last input index any output position reads.
388    ///
389    /// This is what `input_regions` needs: the footprint of a whole pass.
390    #[must_use]
391    pub fn footprint(&self, from: u32, len: u32) -> (u32, u32) {
392        let mut lo = u32::MAX;
393        let mut hi = 0_u32;
394        for run in self.runs.iter().skip(from as usize).take(len as usize) {
395            lo = lo.min(run.start);
396            hi = hi.max(run.start + run.len);
397        }
398        if lo == u32::MAX {
399            (0, 0)
400        } else {
401            (lo, hi - lo)
402        }
403    }
404}
405
406#[cfg(test)]
407#[allow(
408    clippy::unwrap_used,
409    clippy::expect_used,
410    clippy::indexing_slicing,
411    clippy::panic,
412    reason = "tests operate on known-good values and assert shapes directly"
413)]
414mod tests {
415    use super::*;
416
417    const ALL: [Filter; 7] = [
418        Filter::Nearest,
419        Filter::Box,
420        Filter::Bilinear,
421        Filter::CatmullRom,
422        Filter::Mitchell,
423        Filter::Lanczos2,
424        Filter::Lanczos3,
425    ];
426
427    #[test]
428    fn every_filter_peaks_at_the_centre_and_is_zero_past_its_support() {
429        // Deliberately *not* "is 1.0 at the centre": Mitchell peaks at 8/9,
430        // because a cubic with B=1/3 trades peak height for smoothness. What
431        // every kernel must share is that the centre is the maximum — a kernel
432        // peaking off-centre shifts the image — and that it vanishes outside
433        // its declared support, which is what makes the support honest.
434        for filter in ALL {
435            let centre = filter.weight(0.0);
436            for step in 1..40 {
437                let x = step as f32 * 0.1;
438                assert!(
439                    filter.weight(x) <= centre + 1e-6,
440                    "{} peaks at {x}, not at the centre",
441                    filter.as_str()
442                );
443            }
444            let past = filter.support() + 0.01;
445            assert!(
446                filter.weight(past).abs() < 1e-6,
447                "{} is non-zero past its support",
448                filter.as_str()
449            );
450        }
451    }
452
453    #[test]
454    fn every_filter_is_symmetric() {
455        // An asymmetric kernel shifts the image, which is the kind of bug that
456        // looks like "slightly soft" rather than like a failure.
457        for filter in ALL {
458            for step in 0..40 {
459                let x = step as f32 * 0.1;
460                let (l, r) = (filter.weight(-x), filter.weight(x));
461                assert!(
462                    (l - r).abs() < 1e-6,
463                    "{} is asymmetric at {x}: {l} vs {r}",
464                    filter.as_str()
465                );
466            }
467        }
468    }
469
470    #[test]
471    fn every_run_sums_to_exactly_one() {
472        // The property the residue correction exists for. A run that sums to
473        // ONE-1 darkens the image by a fraction of a level everywhere, which
474        // is invisible per pixel and obvious on a gradient.
475        for filter in ALL {
476            for (input, output) in [(100, 50), (50, 100), (7, 7), (1, 64), (64, 1), (999, 37)] {
477                let weights = Weights::build(filter, input, output).unwrap();
478                for (index, run) in weights.runs().iter().enumerate() {
479                    let sum: i32 = weights.quantized(run).iter().sum();
480                    assert_eq!(
481                        sum,
482                        ONE,
483                        "{} {input}->{output} run {index} sums to {sum}, not {ONE}",
484                        filter.as_str()
485                    );
486                }
487            }
488        }
489    }
490
491    #[test]
492    fn exact_weights_sum_to_one_too() {
493        for filter in ALL {
494            for (input, output) in [(100, 50), (50, 100), (33, 17)] {
495                let weights = Weights::build(filter, input, output).unwrap();
496                for run in weights.runs() {
497                    let sum: f32 = weights.exact(run).iter().sum();
498                    assert!(
499                        (sum - 1.0).abs() < 1e-4,
500                        "{} {input}->{output} exact run sums to {sum}",
501                        filter.as_str()
502                    );
503                }
504            }
505        }
506    }
507
508    #[test]
509    fn every_run_stays_inside_the_input() {
510        // An op reading outside its input is a defect (Op::input_regions), so
511        // clamping is this table's job and not the kernel's.
512        for filter in ALL {
513            for (input, output) in [(10, 1), (1, 10), (37, 999), (999, 37), (2, 3)] {
514                let weights = Weights::build(filter, input, output).unwrap();
515                for run in weights.runs() {
516                    assert!(
517                        run.start + run.len <= input,
518                        "{} {input}->{output} reads {}..{} of {input}",
519                        filter.as_str(),
520                        run.start,
521                        run.start + run.len
522                    );
523                    assert!(run.len > 0, "empty run");
524                }
525            }
526        }
527    }
528
529    #[test]
530    fn one_to_one_resize_is_the_identity() {
531        // The strongest single check on the half-pixel convention: at scale 1
532        // every output must read exactly its own input sample at full weight.
533        // Off-by-a-half-pixel shows up here and nowhere else so cleanly.
534        for filter in ALL {
535            let weights = Weights::build(filter, 64, 64).unwrap();
536            for (index, run) in weights.runs().iter().enumerate() {
537                let quantized = weights.quantized(run);
538                let peak = quantized
539                    .iter()
540                    .enumerate()
541                    .max_by_key(|&(_, w)| *w)
542                    .map(|(i, _)| run.start as usize + i)
543                    .unwrap();
544                assert_eq!(
545                    peak,
546                    index,
547                    "{} at 1:1 centres output {index} on input {peak}",
548                    filter.as_str()
549                );
550            }
551        }
552    }
553
554    #[test]
555    fn downscaling_widens_the_kernel() {
556        // Averaging over everything discarded is what stops a downscale
557        // aliasing. A kernel that stayed at its natural width would sample
558        // rather than filter.
559        let half = Weights::build(Filter::Bilinear, 100, 50).unwrap();
560        let same = Weights::build(Filter::Bilinear, 100, 100).unwrap();
561        assert!(
562            half.max_len() > same.max_len(),
563            "downscale support {} is not wider than 1:1 support {}",
564            half.max_len(),
565            same.max_len()
566        );
567    }
568
569    #[test]
570    fn the_footprint_covers_every_run_it_spans() {
571        let weights = Weights::build(Filter::Lanczos3, 200, 97).unwrap();
572        let (start, len) = weights.footprint(10, 20);
573        for run in weights.runs().iter().skip(10).take(20) {
574            assert!(run.start >= start, "run starts before the footprint");
575            assert!(
576                run.start + run.len <= start + len,
577                "run ends after the footprint"
578            );
579        }
580    }
581
582    #[test]
583    fn an_empty_footprint_is_reported_as_empty() {
584        let weights = Weights::build(Filter::Box, 10, 10).unwrap();
585        assert_eq!(weights.footprint(0, 0), (0, 0));
586    }
587
588    #[test]
589    fn a_zero_length_axis_is_an_error_not_a_panic() {
590        assert!(Weights::build(Filter::Box, 0, 10).is_err());
591        assert!(Weights::build(Filter::Box, 10, 0).is_err());
592    }
593
594    #[test]
595    fn building_a_table_is_deterministic() {
596        // SPEC §Guarantees 2. Float weights make this worth pinning: the same
597        // inputs must give the same bits, run to run.
598        for filter in ALL {
599            let a = Weights::build(filter, 1000, 173).unwrap();
600            let b = Weights::build(filter, 1000, 173).unwrap();
601            for (ra, rb) in a.runs().iter().zip(b.runs()) {
602                assert_eq!(a.quantized(ra), b.quantized(rb));
603                assert_eq!(a.exact(ra), b.exact(rb));
604            }
605        }
606    }
607}