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}