Skip to main content

ffai_argus/
preprocess.rs

1//! Image -> `pixel_values`: the content path between a decoded file and the
2//! vision tower.
3//!
4//! Steps 3 and 4 deliberately fed the tower the reference's own
5//! `pixel_values`, so that a tensor mismatch could be attributed to the tower
6//! rather than to a resize. This module is the brick they isolated out, and it
7//! gets its own gate for the same reason.
8//!
9//! # The spec, pinned from `preprocessor_config.json`
10//!
11//! | step | value |
12//! |---|---|
13//! | convert to RGB | `do_convert_rgb: true` |
14//! | resize longest edge | **2048** |
15//! | resample filter | **`1` = LANCZOS** |
16//! | split into tiles | `max_image_size.longest_edge: 512` |
17//! | plus a global thumbnail | -> **17** tiles for a square image |
18//! | rescale | `1/255` |
19//! | normalize | mean 0.5, std 0.5 -> `[-1, 1]` |
20//!
21//! That arithmetic is what produces 17 tiles: the image is scaled UP to
22//! 2048x2048, cut into sixteen 512x512 tiles, and a thumbnail is appended.
23//!
24//! # Why Lanczos is written out rather than substituted
25//!
26//! The tree already has `carmenta::image::resize_bilinear` and a Catmull-Rom
27//! `resize_bicubic`, each chosen for a measured reason in its own path. Neither
28//! is this one. Substituting a different filter changes every `pixel_values`
29//! and therefore every tensor downstream — silently, because the output is
30//! still a plausible image. So the filter the reference declares is the filter
31//! implemented, and it is gated against the reference's own tensor.
32//!
33//! The convention matters as much as the kernel. PIL's resampler, which is
34//! what `resample: 1` means here:
35//!
36//! * maps output pixel `i` to input centre `(i + 0.5) * scale`, where
37//!   `scale = in / out` — a HALF-PIXEL-CENTRED mapping. Using `i * scale`
38//!   instead shifts the whole image by half a pixel and is the classic
39//!   off-by-half that survives visual inspection;
40//! * widens the kernel when DOWNSCALING (`filter_scale = max(1, scale)`) so
41//!   the filter low-passes rather than aliases, and leaves it at 1 when
42//!   upscaling;
43//! * normalises the weights to sum to 1, so brightness is preserved;
44//! * resamples horizontally then vertically — separable, and it is what makes
45//!   the cost `O(w*h*support)` instead of `O(w*h*support^2)`.
46
47use crate::par::prelude::*;
48
49/// Lanczos-3 kernel: `sinc(x) * sinc(x/3)`, zero outside `|x| < 3`.
50#[must_use]
51pub fn lanczos3(x: f32) -> f32 {
52    const A: f32 = 3.0;
53    if x == 0.0 {
54        return 1.0;
55    }
56    let ax = x.abs();
57    if ax >= A {
58        return 0.0;
59    }
60    let px = std::f32::consts::PI * x;
61    // sinc(x) * sinc(x/a), written as the product of two normalised sincs.
62    (px.sin() / px) * ((px / A).sin() / (px / A))
63}
64
65/// Precomputed weights for one output axis.
66struct Taps {
67    /// For each output index: the first input index its window touches.
68    starts: Vec<usize>,
69    /// Flattened weights, `width` per output index.
70    weights: Vec<f32>,
71    width: usize,
72}
73
74/// Build the resampling taps for one axis, PIL-style.
75fn build_taps(src: usize, dst: usize) -> Taps {
76    let scale = src as f32 / dst as f32;
77    // Widen the kernel only when shrinking. Upscaling keeps support 3.
78    let filter_scale = if scale < 1.0 { 1.0 } else { scale };
79    let support = 3.0 * filter_scale;
80    let width = (support.ceil() as usize) * 2 + 1;
81
82    let mut starts = Vec::with_capacity(dst);
83    let mut weights = vec![0.0f32; dst * width];
84    for i in 0..dst {
85        // HALF-PIXEL CENTRED — see the module docs. `i * scale` here is the
86        // half-pixel shift that looks fine and is wrong.
87        let center = (i as f32 + 0.5) * scale;
88        let xmin = ((center - support + 0.5).floor().max(0.0)) as usize;
89        let xmax = (((center + support + 0.5).floor()) as usize).min(src);
90        let n = xmax.saturating_sub(xmin);
91        let row = &mut weights[i * width..i * width + width];
92        let mut sum = 0.0f32;
93        for (k, slot) in row.iter_mut().take(n.min(width)).enumerate() {
94            let x = (xmin + k) as f32 - center + 0.5;
95            let w = lanczos3(x / filter_scale);
96            *slot = w;
97            sum += w;
98        }
99        // Normalise so the filter preserves brightness. A kernel that sums to
100        // 0.98 darkens the image by 2 % everywhere, which no visual check
101        // catches and every tensor comparison does.
102        if sum != 0.0 {
103            for w in row.iter_mut().take(n.min(width)) {
104                *w /= sum;
105            }
106        }
107        starts.push(xmin);
108    }
109    Taps {
110        starts,
111        weights,
112        width,
113    }
114}
115
116/// Separable Lanczos resize of an interleaved `channels`-plane image.
117///
118/// Input and output are `f32` in whatever units the caller uses; this does no
119/// rescaling of its own, so it can be applied before or after normalisation
120/// without changing meaning.
121#[must_use]
122pub fn resize_lanczos(
123    src: &[f32],
124    sw: usize,
125    sh: usize,
126    dw: usize,
127    dh: usize,
128    channels: usize,
129) -> Vec<f32> {
130    if sw == dw && sh == dh {
131        return src.to_vec();
132    }
133    // Horizontal pass: (sh, sw) -> (sh, dw).
134    let hx = build_taps(sw, dw);
135    let mut mid = vec![0.0f32; sh * dw * channels];
136    for y in 0..sh {
137        for x in 0..dw {
138            let start = hx.starts[x];
139            let row = &hx.weights[x * hx.width..x * hx.width + hx.width];
140            for c in 0..channels {
141                let mut acc = 0.0f32;
142                for (k, &w) in row.iter().enumerate() {
143                    if w == 0.0 {
144                        continue;
145                    }
146                    let sx = (start + k).min(sw - 1);
147                    acc += w * src[(y * sw + sx) * channels + c];
148                }
149                mid[(y * dw + x) * channels + c] = acc;
150            }
151        }
152    }
153    // Vertical pass: (sh, dw) -> (dh, dw).
154    let vy = build_taps(sh, dh);
155    let mut out = vec![0.0f32; dh * dw * channels];
156    for y in 0..dh {
157        let start = vy.starts[y];
158        let row = &vy.weights[y * vy.width..y * vy.width + vy.width];
159        for x in 0..dw {
160            for c in 0..channels {
161                let mut acc = 0.0f32;
162                for (k, &w) in row.iter().enumerate() {
163                    if w == 0.0 {
164                        continue;
165                    }
166                    let sy = (start + k).min(sh - 1);
167                    acc += w * mid[(sy * dw + x) * channels + c];
168                }
169                out[(y * dw + x) * channels + c] = acc;
170            }
171        }
172    }
173    out
174}
175
176/// The tile grid a given image size produces, before the thumbnail.
177///
178/// Returned as `(rows, cols)` so the caller can build the `<row_r_col_c>`
179/// markers — the prompt assembly and the pixel tiling MUST agree, and deriving
180/// both from one function is what keeps them agreeing.
181#[must_use]
182pub const fn tile_grid(width: usize, height: usize, max_edge: usize) -> (usize, usize) {
183    if width <= max_edge && height <= max_edge {
184        return (0, 0);
185    }
186    (height.div_ceil(max_edge), width.div_ceil(max_edge))
187}
188
189/// Longest-edge resize target, preserving aspect ratio.
190#[must_use]
191pub fn fit_longest_edge(width: usize, height: usize, longest: usize) -> (usize, usize) {
192    if width.max(height) == longest {
193        return (width, height);
194    }
195    let scale = longest as f32 / width.max(height) as f32;
196    (
197        ((width as f32 * scale).round() as usize).max(1),
198        ((height as f32 * scale).round() as usize).max(1),
199    )
200}
201
202/// Rescale to `[0,1]` then normalize to `[-1,1]`, matching
203/// `rescale_factor 1/255` with mean and std 0.5.
204#[must_use]
205pub fn normalize_u8(pixels: &[u8]) -> Vec<f32> {
206    // (x/255 - 0.5) / 0.5 == x/127.5 - 1. Written as the fused form because
207    // the two-step version rounds twice in f32 for no benefit.
208    pixels.iter().map(|&v| f32::from(v) / 127.5 - 1.0).collect()
209}
210
211
212/// PIL's PRECISION_BITS: `32 - 8 - 2`. Coefficients are held as `i32` scaled
213/// by `1 << 22`, which leaves room for a u8 sample times a full kernel without
214/// overflowing i32.
215const PRECISION_BITS: i32 = 32 - 8 - 2;
216
217/// Quantise one axis' normalised f64 weights to PIL's fixed-point form.
218///
219/// PIL rounds AWAY FROM ZERO (`+0.5` for positives, `-0.5` for negatives)
220/// rather than using `round()`'s banker-ish behaviour on ties. Lanczos weights
221/// are frequently negative — that is what makes it sharpen — so the negative
222/// branch is not a formality.
223fn quantise(w: f64) -> i32 {
224    let scaled = w * f64::from(1 << PRECISION_BITS);
225    if w < 0.0 {
226        (scaled - 0.5) as i32
227    } else {
228        (scaled + 0.5) as i32
229    }
230}
231
232/// Taps in PIL's fixed-point form, computed in f64 then quantised.
233struct FixedTaps {
234    starts: Vec<usize>,
235    lens: Vec<usize>,
236    k: Vec<i32>,
237    ksize: usize,
238}
239
240fn build_fixed_taps(src: usize, dst: usize) -> FixedTaps {
241    // f64 throughout, matching PIL: the coefficients are computed and
242    // NORMALISED in double, and only then quantised. Normalising after
243    // quantisation would make the weights sum to slightly off 1<<22 and tint
244    // the whole image.
245    let scale = src as f64 / dst as f64;
246    let filter_scale = if scale < 1.0 { 1.0 } else { scale };
247    let support = 3.0 * filter_scale;
248    let ksize = (support.ceil() as usize) * 2 + 1;
249
250    let mut starts = Vec::with_capacity(dst);
251    let mut lens = Vec::with_capacity(dst);
252    let mut k = vec![0i32; dst * ksize];
253    let inv = 1.0 / filter_scale;
254
255    for xx in 0..dst {
256        let center = (xx as f64 + 0.5) * scale;
257        // `(int)(v + 0.5)` in C truncates toward zero; both values are
258        // non-negative here after the clamp, so this matches.
259        let xmin = ((center - support + 0.5) as isize).max(0) as usize;
260        let xmax = (((center + support + 0.5) as isize).max(0) as usize).min(src);
261        let n = xmax.saturating_sub(xmin);
262
263        let mut w = vec![0.0f64; ksize];
264        let mut ww = 0.0f64;
265        for (x, slot) in w.iter_mut().enumerate().take(n) {
266            let v = lanczos3_f64(((x + xmin) as f64 - center + 0.5) * inv);
267            *slot = v;
268            ww += v;
269        }
270        if ww != 0.0 {
271            for slot in w.iter_mut().take(n) {
272                *slot /= ww;
273            }
274        }
275        for (x, &v) in w.iter().enumerate() {
276            k[xx * ksize + x] = quantise(v);
277        }
278        starts.push(xmin);
279        lens.push(n);
280    }
281    FixedTaps {
282        starts,
283        lens,
284        k,
285        ksize,
286    }
287}
288
289/// f64 Lanczos-3, for coefficient computation.
290fn lanczos3_f64(x: f64) -> f64 {
291    const A: f64 = 3.0;
292    if x == 0.0 {
293        return 1.0;
294    }
295    if x.abs() >= A {
296        return 0.0;
297    }
298    let px = std::f64::consts::PI * x;
299    (px.sin() / px) * ((px / A).sin() / (px / A))
300}
301
302/// `clamp(acc >> PRECISION_BITS, 0, 255)` — PIL's `clip8`, which is a lookup
303/// table over the same arithmetic.
304const fn clip8(acc: i32) -> u8 {
305    let v = acc >> PRECISION_BITS;
306    if v <= 0 {
307        0
308    } else if v >= 255 {
309        255
310    } else {
311        v as u8
312    }
313}
314
315/// **PIL-faithful** Lanczos resize on `u8` samples.
316///
317/// This is the one the content path uses. The `f32` [`resize_lanczos`] above
318/// remains as the readable scalar twin and as the thing that proves the
319/// CONVENTIONS (half-pixel centres, kernel widening, weight normalisation) —
320/// but it is not what the reference computes, and the difference is not
321/// academic: it left a residual of exactly one quantisation level, which
322/// **flipped a generated token at step 5**.
323///
324/// Three details carry that difference, and each is invisible in a
325/// floating-point idealisation:
326///
327/// 1. **Coefficients are quantised to `i32` at `1 << 22`** after being
328///    normalised in `f64`.
329/// 2. **Accumulation is integer**, seeded with `1 << (PRECISION_BITS - 1)` so
330///    the final shift rounds instead of truncating.
331/// 3. **The intermediate is `u8`.** PIL runs the horizontal pass into an
332///    8-bit image and then resamples THAT vertically — so the round-off
333///    happens twice, on purpose. Keeping f32 between the passes is "more
334///    accurate" and produces a different picture.
335#[must_use]
336pub fn resize_lanczos_u8(
337    src: &[u8],
338    sw: usize,
339    sh: usize,
340    dw: usize,
341    dh: usize,
342    channels: usize,
343) -> Vec<u8> {
344    if sw == dw && sh == dh {
345        return src.to_vec();
346    }
347    // Horizontal: (sh, sw) -> (sh, dw), quantised to u8.
348    let mid: Vec<u8> = if sw == dw {
349        src.to_vec()
350    } else {
351        let t = build_fixed_taps(sw, dw);
352        let mut out = vec![0u8; sh * dw * channels];
353        // One task per output row. Rows share only immutable inputs, so this
354        // is bit-identical to the serial loop — the arithmetic within a row is
355        // untouched and rows never interact.
356        out.par_chunks_mut(dw * channels)
357            .enumerate()
358            .for_each(|(y, orow)| {
359                for x in 0..dw {
360                    let (start, n) = (t.starts[x], t.lens[x]);
361                    let row = &t.k[x * t.ksize..x * t.ksize + t.ksize];
362                    for c in 0..channels {
363                        let mut acc: i32 = 1 << (PRECISION_BITS - 1);
364                        for (kk, &w) in row.iter().enumerate().take(n) {
365                            acc += i32::from(src[(y * sw + start + kk) * channels + c]) * w;
366                        }
367                        orow[x * channels + c] = clip8(acc);
368                    }
369                }
370            });
371        out
372    };
373    if sh == dh {
374        return mid;
375    }
376    // Vertical: (sh, dw) -> (dh, dw), on the u8 intermediate.
377    let t = build_fixed_taps(sh, dh);
378    let mut out = vec![0u8; dh * dw * channels];
379    out.par_chunks_mut(dw * channels)
380        .enumerate()
381        .for_each(|(y, orow)| {
382            let (start, n) = (t.starts[y], t.lens[y]);
383            let row = &t.k[y * t.ksize..y * t.ksize + t.ksize];
384            for x in 0..dw {
385                for c in 0..channels {
386                    let mut acc: i32 = 1 << (PRECISION_BITS - 1);
387                    for (kk, &w) in row.iter().enumerate().take(n) {
388                        acc += i32::from(mid[((start + kk) * dw + x) * channels + c]) * w;
389                    }
390                    orow[x * channels + c] = clip8(acc);
391                }
392            }
393        });
394    out
395}
396
397/// The vision encoder wants both dimensions to be exact multiples of the tile
398/// size, so the split is a clean grid rather than a grid plus an odd remainder.
399///
400/// This is `Idefics3ImageProcessor.resize_for_vision_encoder`, and its
401/// asymmetry is deliberate: the LONGER edge is rounded up first, the shorter
402/// edge is derived from the aspect ratio and THEN rounded up. Rounding both
403/// independently would distort a non-square image differently at each size.
404#[must_use]
405pub fn vision_encoder_size(width: usize, height: usize, tile: usize) -> (usize, usize) {
406    let aspect = width as f64 / height as f64;
407    if width >= height {
408        let w = width.div_ceil(tile) * tile;
409        let h = ((w as f64 / aspect) as usize).div_ceil(tile) * tile;
410        (w, h.max(tile))
411    } else {
412        let h = height.div_ceil(tile) * tile;
413        let w = ((h as f64 * aspect) as usize).div_ceil(tile) * tile;
414        (w.max(tile), h)
415    }
416}
417
418/// One image, preprocessed into what the tower and the prompt both need.
419///
420/// `rows`/`cols` are carried alongside the pixels precisely because the prompt
421/// assembly must emit `<row_r_col_c>` markers that agree with this tiling. Two
422/// independent derivations of the same grid is how they silently disagree.
423pub struct Preprocessed {
424    /// `(tiles, 3, tile, tile)` planar CHW, normalized to `[-1, 1]`.
425    pub pixel_values: Vec<f32>,
426    pub tiles: usize,
427    pub rows: usize,
428    pub cols: usize,
429    pub tile: usize,
430}
431
432/// The size an image is resized to before tiling — step 2 of the content path.
433///
434/// Reported rather than recomputed by callers because the two-step rule is not
435/// guessable from the output: the longest edge goes to 2048 FIRST, and only
436/// then is each edge rounded up to a tile multiple. A viewer told "17 tiles"
437/// and shown a 512x512 source has no way to see where the 2048 came from.
438#[must_use]
439pub fn resized_size(width: usize, height: usize) -> (usize, usize) {
440    let (aw, ah) = fit_longest_edge(width, height, 2048);
441    vision_encoder_size(aw, ah, 512)
442}
443
444/// The tile geometry an image WOULD get, without touching a pixel.
445///
446/// Returns `(tiles, rows, cols)`. Every step here is arithmetic on two
447/// integers, so it costs nothing — which is the point: the caller can price a
448/// prompt before committing to the resizes and the vision tower that would
449/// produce it. Finding out a prompt is too long *after* running the tower over
450/// two hundred frames is four minutes to learn something derivable up front.
451#[must_use]
452pub fn tile_geometry(width: usize, height: usize, split: bool) -> (usize, usize, usize) {
453    const LONGEST: usize = 2048;
454    const TILE: usize = 512;
455    let (aw, ah) = fit_longest_edge(width, height, LONGEST);
456    let (bw, bh) = vision_encoder_size(aw, ah, TILE);
457    let (rows, cols) = if split { tile_grid(bw, bh, TILE) } else { (0, 0) };
458    (rows * cols + 1, rows, cols)
459}
460
461/// Decoded RGB8 -> `pixel_values`, the whole Idefics3 content path.
462///
463/// The ORDER is the part worth stating, because it is not the obvious one and
464/// each step changes the pixels:
465///
466/// 1. resize so the longest edge is `longest` (2048) — this UPSCALES a small
467///    image, which is why a 512x512 input yields 17 tiles rather than 1;
468/// 2. resize again so both edges are multiples of `tile` — usually a no-op
469///    after step 1 for common aspect ratios, and never one for odd ones;
470/// 3. cut the exact `rows x cols` grid of tiles;
471/// 4. append a global thumbnail, which is the step-2 image resized DOWN to one
472///    tile — **not** the original. That distinction was found the expensive
473///    way: assuming the original left a residual of exactly one quantisation
474///    level, which is too small to look wrong and too large to be noise.
475///
476/// Rescale/normalize come last, on `u8`, so every resize happens in the domain
477/// PIL resizes in.
478#[must_use]
479pub fn preprocess_rgb8(rgb: &[u8], width: usize, height: usize) -> Preprocessed {
480    preprocess_rgb8_opts(rgb, width, height, true)
481}
482
483/// The same path, with tile splitting optional.
484///
485/// # Why video turns splitting OFF
486///
487/// Splitting is what makes a still image legible: seventeen 512x512 tiles at
488/// 64 tokens each is **1088 image tokens**, and the model reads fine print
489/// because it sees the page at 2048px. For video that arithmetic inverts. The
490/// text tower's `max_position_embeddings` is **8192**, so a split frame caps a
491/// window at seven frames before the prompt does not fit at all — and seven
492/// frames is not a window, it is a slideshow with a memory problem.
493///
494/// Unsplit, a frame is ONE tile: **64 tokens**. The same 8192 positions then
495/// hold a hundred frames. Sixteen frames of temporal context beats one frame
496/// of fine print when the question is "what happens in this clip", and the
497/// reference implementations make the same trade for the same reason.
498///
499/// The unsplit tile is **exactly the global thumbnail the split path already
500/// produces** — the same two resizes, the same final 512x512 — rather than a
501/// second, subtly different route to a small image. That matters: the
502/// thumbnail is gated bit-exactly against the reference (§16), so the video
503/// path inherits that gate instead of needing its own.
504#[must_use]
505pub fn preprocess_rgb8_opts(
506    rgb: &[u8],
507    width: usize,
508    height: usize,
509    split: bool,
510) -> Preprocessed {
511    const LONGEST: usize = 2048;
512    const TILE: usize = 512;
513
514    let (aw, ah) = fit_longest_edge(width, height, LONGEST);
515    let a = resize_lanczos_u8(rgb, width, height, aw, ah, 3);
516    let (bw, bh) = vision_encoder_size(aw, ah, TILE);
517    let b = resize_lanczos_u8(&a, aw, ah, bw, bh, 3);
518
519    let (rows, cols) = if split {
520        tile_grid(bw, bh, TILE)
521    } else {
522        // rows = cols = 0 is the shape `PromptLayout::image_block` already
523        // encodes as "thumbnail only, no grid" — so the prompt and the pixels
524        // agree here for the same reason they agree in the split case: one
525        // derivation, used by both.
526        (0, 0)
527    };
528    let per = TILE * TILE * 3;
529    let tiles = rows * cols + 1;
530    let mut pixel_values = vec![0.0f32; tiles * per];
531
532    let mut write_tile = |idx: usize, src: &[u8]| {
533        let norm = normalize_u8(src);
534        // Interleaved HWC -> planar CHW: the tower's layout.
535        let base = idx * per;
536        for c in 0..3 {
537            for i in 0..TILE * TILE {
538                pixel_values[base + c * TILE * TILE + i] = norm[i * 3 + c];
539            }
540        }
541    };
542
543    let mut tile_buf = vec![0u8; per];
544    for r in 0..rows {
545        for c in 0..cols {
546            for y in 0..TILE {
547                let s = ((r * TILE + y) * bw + c * TILE) * 3;
548                tile_buf[y * TILE * 3..(y + 1) * TILE * 3].copy_from_slice(&b[s..s + TILE * 3]);
549            }
550            write_tile(r * cols + c, &tile_buf);
551        }
552    }
553    // The thumbnail is LAST, matching the prompt's `<global-img>` placement
554    // after every `<row_r_col_c>` block.
555    let thumb = resize_lanczos_u8(&b, bw, bh, TILE, TILE, 3);
556    write_tile(rows * cols, &thumb);
557
558    Preprocessed {
559        pixel_values,
560        tiles,
561        rows,
562        cols,
563        tile: TILE,
564    }
565}
566
567#[cfg(test)]
568mod tests {
569    use super::*;
570
571    #[test]
572    fn the_kernel_is_one_at_zero_and_zero_at_the_integers() {
573        assert!((lanczos3(0.0) - 1.0).abs() < 1e-6);
574        // sinc has zeros at every non-zero integer inside the support.
575        for k in [1.0f32, 2.0] {
576            assert!(lanczos3(k).abs() < 1e-5, "lanczos3({k}) should vanish");
577            assert!(lanczos3(-k).abs() < 1e-5);
578        }
579        // …and nothing outside the support.
580        assert_eq!(lanczos3(3.0), 0.0);
581        assert_eq!(lanczos3(4.5), 0.0);
582    }
583
584    #[test]
585    fn weights_sum_to_one_at_every_output_position() {
586        // Brightness preservation, the property a visual check cannot see.
587        for (src, dst) in [(512, 2048), (2048, 512), (300, 512), (512, 300)] {
588            let t = build_taps(src, dst);
589            for i in 0..dst {
590                let s: f32 = t.weights[i * t.width..(i + 1) * t.width].iter().sum();
591                assert!(
592                    (s - 1.0).abs() < 1e-4,
593                    "{src}->{dst} position {i} sums to {s}"
594                );
595            }
596        }
597    }
598
599    #[test]
600    fn a_constant_image_survives_resizing_unchanged() {
601        // The strongest cheap invariant: normalised weights times a constant
602        // must give that constant back, at any scale, in both directions.
603        for (sw, sh, dw, dh) in [(16, 16, 64, 64), (64, 64, 16, 16), (10, 20, 33, 7)] {
604            let src = vec![0.37f32; sw * sh * 3];
605            let out = resize_lanczos(&src, sw, sh, dw, dh, 3);
606            assert_eq!(out.len(), dw * dh * 3);
607            let worst = out.iter().map(|v| (v - 0.37).abs()).fold(0.0f32, f32::max);
608            assert!(worst < 1e-4, "{sw}x{sh}->{dw}x{dh} drifted by {worst}");
609        }
610    }
611
612    #[test]
613    fn an_identity_resize_is_a_copy() {
614        let src: Vec<f32> = (0..48).map(|i| i as f32).collect();
615        assert_eq!(resize_lanczos(&src, 4, 4, 4, 4, 3), src);
616    }
617
618    #[test]
619    fn the_grid_is_what_produced_seventeen_tiles() {
620        // 512 -> upscaled to 2048 -> 4x4, plus the thumbnail = 17.
621        assert_eq!(fit_longest_edge(512, 512, 2048), (2048, 2048));
622        assert_eq!(tile_grid(2048, 2048, 512), (4, 4));
623        assert_eq!(4 * 4 + 1, 17);
624        // A small image is thumbnail-only, which the prompt assembly encodes
625        // as rows = cols = 0.
626        assert_eq!(tile_grid(400, 300, 512), (0, 0));
627    }
628
629    #[test]
630    fn normalisation_lands_on_minus_one_to_one() {
631        let v = normalize_u8(&[0, 255, 128]);
632        assert!((v[0] + 1.0).abs() < 1e-6);
633        assert!((v[1] - 1.0).abs() < 1e-6);
634        assert!(v[2].abs() < 0.01);
635    }
636
637    #[test]
638    fn the_fixed_point_kernel_preserves_a_constant_image() {
639        // The integer path has two extra ways to drift that the f32 twin does
640        // not: the coefficients are rounded to i32, and the accumulator is
641        // shifted. A constant image catches both — the quantised weights must
642        // still sum to 1<<22 closely enough that the shift lands on the same
643        // level.
644        for (sw, sh, dw, dh) in [(16, 16, 64, 64), (64, 64, 16, 16), (10, 20, 33, 7)] {
645            let src = vec![97u8; sw * sh * 3];
646            let out = resize_lanczos_u8(&src, sw, sh, dw, dh, 3);
647            assert_eq!(out.len(), dw * dh * 3);
648            let worst = out.iter().map(|&v| i32::from(v) - 97).map(i32::abs).max();
649            assert_eq!(worst, Some(0), "{sw}x{sh}->{dw}x{dh} drifted off 97");
650        }
651    }
652
653    #[test]
654    fn quantisation_rounds_away_from_zero_in_both_directions() {
655        // Lanczos weights go negative — that is what makes it sharpen — so the
656        // negative branch is load-bearing, not a formality. `as i32` alone
657        // truncates toward zero and would bias every negative lobe upward.
658        let unit = f64::from(1 << PRECISION_BITS);
659        assert_eq!(quantise(1.0), 1 << PRECISION_BITS);
660        assert_eq!(quantise(-1.0), -(1 << PRECISION_BITS));
661        assert_eq!(quantise(0.5 / unit), 1);
662        assert_eq!(quantise(-0.5 / unit), -1);
663    }
664
665    #[test]
666    fn clip8_clamps_rather_than_wrapping() {
667        // A sharpening kernel overshoots at edges: the accumulator genuinely
668        // goes below 0 and above 255. Wrapping there turns a bright edge into
669        // a black one, which is very visible and very hard to attribute.
670        assert_eq!(clip8(-5 << PRECISION_BITS), 0);
671        assert_eq!(clip8(300 << PRECISION_BITS), 255);
672        assert_eq!(clip8(128 << PRECISION_BITS), 128);
673        // The `1 << (PRECISION_BITS - 1)` seed makes the shift ROUND.
674        assert_eq!(clip8((1 << PRECISION_BITS) - 1 + (1 << (PRECISION_BITS - 1))), 1);
675    }
676
677    #[test]
678    fn an_identity_resize_is_a_copy_in_the_fixed_point_path_too() {
679        let src: Vec<u8> = (0..48).map(|i| i as u8).collect();
680        assert_eq!(resize_lanczos_u8(&src, 4, 4, 4, 4, 3), src);
681    }
682
683    #[test]
684    fn the_vision_encoder_size_rounds_the_long_edge_first() {
685        // Both edges land on a multiple of the tile, and the LONG edge is the
686        // one rounded first — rounding them independently distorts a
687        // non-square image differently at each size.
688        assert_eq!(vision_encoder_size(2048, 2048, 512), (2048, 2048));
689        assert_eq!(vision_encoder_size(2048, 1536, 512), (2048, 1536));
690        for (w, h) in [(2048, 1153), (1000, 700), (513, 511), (100, 3000)] {
691            let (a, b) = vision_encoder_size(w, h, 512);
692            assert_eq!(a % 512, 0, "{w}x{h} -> {a}x{b}: width not a tile multiple");
693            assert_eq!(b % 512, 0, "{w}x{h} -> {a}x{b}: height not a tile multiple");
694            assert!(a >= 512 && b >= 512, "{w}x{h} -> {a}x{b}: degenerate");
695        }
696    }
697
698    #[test]
699    fn preprocessing_fills_every_tile_it_promises() {
700        // The tile count, the buffer length and the grid must agree. A tile
701        // left at its zeroed initial value is mid-gray after normalisation —
702        // a plausible-looking image the tower would happily caption.
703        let (w, h) = (300usize, 700usize);
704        let px: Vec<u8> = (0..w * h * 3).map(|i| (i % 251) as u8).collect();
705        let out = preprocess_rgb8(&px, w, h);
706        assert_eq!(out.pixel_values.len(), out.tiles * 3 * out.tile * out.tile);
707        assert_eq!(out.tiles, out.rows * out.cols + 1);
708        let per = 3 * out.tile * out.tile;
709        for t in 0..out.tiles {
710            let tile = &out.pixel_values[t * per..(t + 1) * per];
711            assert!(
712                tile.iter().any(|&v| v.abs() > 1e-6),
713                "tile {t} is entirely zero — an unfilled buffer, not an image"
714            );
715        }
716    }
717}