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}