mtmd : lfm2 resize matches torchvision (#30943)

* mtmd : lfm2 bilinear resize matches torchvision

Lfm2VlImageProcessor and d1-omni vision.py resize uint8 images with
torchvision (antialias=True). That is Pillow's algorithm, except that the
weights are int16 with as many fractional bits as the largest weight allows,
where Pillow uses 22 bits. The Pillow port was 1-2 levels off on about 1% of
the values of the d1-omni and LFM2.5-VL crops.

RESIZE_ALGO_BILINEAR_TORCH rounds the weights of the Pillow port to that
precision and scales them back to 22 bits, which gives the output of
torchvision with the same passes, and "bilinear" maps to it for LFM2 and
d1-omni. Other models keep the Pillow algorithms.

* mtmd : lfm2 bicubic resize matches torchvision

Lfm2VlImageProcessor resizes with torchvision for the bicubic checkpoints too
(d1-3B, LFM2-VL), so "bicubic" maps to RESIZE_ALGO_BICUBIC_TORCH for LFM2: the
Pillow bicubic filter (a = -0.5, as torchvision with antialias) with the int16
weights of torchvision. The Pillow port was 1-2 levels off on about 0.25% of
the values of the d1-3B crops.

* use bitfield, add hparams setter

---------

Co-authored-by: Xuan Son Nguyen <son@huggingface.co>
This commit is contained in:
Yuri KhrustalevandXuan Son Nguyen authored and GitHub committed 2026-10-24 02:21:06 +02:00
1 parent 9ae73e68f5
commit edc353842c
5 files changed
+116 -8

No files matched your search

+76
View File
@@ -5,6 +5,7 @@
#include "mtmd-internal.h"
#include <cmath>
#include <cstdint>
#include <iostream>
#include <stdexcept>
#include <string>
@@ -95,6 +96,45 @@ static std::string image_size_to_str(const clip_image_size & size) {
return std::to_string(size.width) + "x" + std::to_string(size.height);
}
static std::string resize_algo_to_str(resize_algo algo) {
std::string str;
switch (algo & ~RESIZE_ALGO_FLAG_TORCH) {
case RESIZE_ALGO_BILINEAR: str = "bilinear"; break;
case RESIZE_ALGO_BICUBIC: str = "bicubic"; break;
case RESIZE_ALGO_LANCZOS: str = "lanczos"; break;
default: str = "unknown"; break;
}
if (algo & RESIZE_ALGO_FLAG_TORCH) {
str += "_torch";
}
return str;
}
// pseudo-random pixels: s = s * 1103515245 + 12345 from s = 1, one byte (s >> 16) & 255 per channel
static clip_image_u8 make_test_image(const clip_image_size & size) {
clip_image_u8 img;
img.set_size(size, false);
uint32_t s = 1;
auto next = [&s]() {
s = s * 1103515245u + 12345u;
return static_cast<uint8_t>(s >> 16);
};
for (int y = 0; y < size.height; y++) {
for (int x = 0; x < size.width; x++) {
img.set_pixel(x, y, { next(), next(), next() });
}
}
return img;
}
// FNV-1a of the pixels
static uint32_t hash_pixels(const clip_image_u8 & img, uint32_t hash = 2166136261u) {
for (uint8_t v : img.get_ro_buf()) {
hash = (hash ^ v) * 16777619u;
}
return hash;
}
MAKE_TEST(test_image_preprocessor_lfm2) {
clip_hparams hparams;
hparams.patch_size = 16;
@@ -158,6 +198,42 @@ MAKE_TEST(test_image_preprocessor_lfm2) {
std::string(mtmd_image_preprocessor_lfm2::should_tile(hparams, { 1024, 977 }) ? "tiled" : "single"));
}
MAKE_TEST(test_image_preprocessor_lfm2_torch_resize) {
clip_hparams hparams;
hparams.patch_size = 16;
hparams.n_merge = 2;
hparams.set_limit_image_tokens(64, 256);
hparams.preproc_min_tiles = 2;
hparams.preproc_max_tiles = 10;
hparams.preproc_tile_size = 512;
hparams.preproc_max_pixels_tolerance = 2.0f;
// Lfm2VlImageProcessor stretches the image to the tile grid
hparams.image_pad_rf = PAD_NONE;
// { resize algo, image size, expected hash of the tiles and the thumbnail from Lfm2VlImageProcessor (torchvision 0.29, native uint8 resize: arm64, or x86 with AVX2/AVX512) }
const std::vector<std::tuple<resize_algo, clip_image_size, uint32_t>> cases = {
{ RESIZE_ALGO_BILINEAR, { 620, 410 }, 0xf5e9c941 }, // 608x416
{ RESIZE_ALGO_BILINEAR, { 2000, 1500 }, 0x3331f56c }, // 3x2 tiles, 576x416 thumbnail
{ RESIZE_ALGO_BICUBIC, { 620, 410 }, 0x2b34eac9 },
{ RESIZE_ALGO_BICUBIC, { 2000, 1500 }, 0xb035d942 },
};
for (const auto & [base_algo, size, expected] : cases) {
const resize_algo algo = static_cast<resize_algo>(base_algo | RESIZE_ALGO_FLAG_TORCH);
hparams.image_resize_algo_rf = algo;
hparams.image_resize_algo_ov = algo;
const mtmd_image_preprocessor_lfm2 preproc(hparams);
const auto sliced = preproc.slice_image(make_test_image(size), preproc.get_slice_instructions(size));
uint32_t hash = 2166136261u;
for (const auto & slice : sliced.slices) {
hash = hash_pixels(slice, hash);
}
hash = hash_pixels(sliced.overview, hash);
t.assert_equal("pixels of " + std::to_string(size.width) + "x" + std::to_string(size.height) + " with resize algo " + resize_algo_to_str(algo), expected, hash);
}
}
//
// mtmd temporal merge
//
+9 -1
View File
@@ -29,11 +29,14 @@ enum patch_merge_type {
PATCH_MERGE_SPATIAL_UNPAD,
};
// all algos are Pillow-compatible (matching PIL.Image.resize output)
// the low bits select the algo, Pillow-compatible (matching PIL.Image.resize output)
// RESIZE_ALGO_FLAG_TORCH matches torchvision's antialiased resize of uint8 images on the CPU (int16 weights), bilinear and bicubic only
enum resize_algo {
RESIZE_ALGO_BILINEAR,
RESIZE_ALGO_BICUBIC,
RESIZE_ALGO_LANCZOS,
RESIZE_ALGO_FLAG_TORCH = 1 << 8,
};
// Padding style for img_tool::resize
@@ -207,6 +210,11 @@ struct clip_hparams {
int32_t custom_image_min_tokens = -1;
int32_t custom_image_max_tokens = -1;
void set_image_resize_algo(resize_algo algo, bool torch = false) {
GGML_ASSERT(!(torch && algo == RESIZE_ALGO_LANCZOS) && "torchvision has no lanczos");
image_resize_algo = static_cast<resize_algo>(torch ? algo | RESIZE_ALGO_FLAG_TORCH : algo);
}
void set_limit_image_tokens(int n_tokens_min, int n_tokens_max) {
const int patch_area = patch_size * patch_size * n_merge * n_merge;
image_min_pixels = (custom_image_min_tokens > 0 ? custom_image_min_tokens : n_tokens_min) * patch_area;
+4 -3
View File
@@ -1589,12 +1589,13 @@ struct clip_model_loader {
// default for older GGUFs
std::string resize_algo = "bilinear";
get_string(KEY_IMAGE_RESIZE_ALGO, resize_algo, false);
// Lfm2VlImageProcessor and d1-omni vision.py resize bilinear and bicubic with torchvision
if (resize_algo == "bilinear") {
hparams.image_resize_algo = RESIZE_ALGO_BILINEAR;
hparams.set_image_resize_algo(RESIZE_ALGO_BILINEAR, true);
} else if (resize_algo == "bicubic") {
hparams.image_resize_algo = RESIZE_ALGO_BICUBIC;
hparams.set_image_resize_algo(RESIZE_ALGO_BICUBIC, true);
} else if (resize_algo == "lanczos") {
hparams.image_resize_algo = RESIZE_ALGO_LANCZOS;
hparams.set_image_resize_algo(RESIZE_ALGO_LANCZOS);
} else {
throw std::runtime_error(string_format("%s: unsupported image resize algo: %s\n", __func__, resize_algo.c_str()));
}
+25 -4
View File
@@ -205,6 +205,8 @@ private:
// 1. Separable filtering: horizontal pass followed by vertical pass
// 2. Pre-computes normalized filter coefficients for each output pixel
// 3. Fixed-point integer arithmetic (22 fractional bits) for speed and determinism
//
// RESIZE_ALGO_FLAG_TORCH is the same, except for the int16 weights of torchvision (aten/src/ATen/native/cpu/UpSampleKernel.cpp)
static bool resize_pillow(
const clip_image_u8 & img,
clip_image_u8 & dst,
@@ -215,6 +217,13 @@ private:
// This allows encoding fractional weights as integers: weight * 2^22
const int PRECISION_BITS = 32 - 8 - 2;
const bool int16_weights = algo & RESIZE_ALGO_FLAG_TORCH;
algo = static_cast<resize_algo>(algo & ~RESIZE_ALGO_FLAG_TORCH);
if (int16_weights && algo == RESIZE_ALGO_LANCZOS) {
GGML_ABORT("unsupported resize algo: torchvision has no lanczos");
}
// Filter support radius
double filter_support;
switch (algo) {
@@ -222,11 +231,11 @@ private:
case RESIZE_ALGO_BICUBIC: filter_support = 2.0; break;
case RESIZE_ALGO_LANCZOS: filter_support = 3.0; break;
default:
throw std::runtime_error("Unsupported resize algorithm");
GGML_ABORT("unsupported resize algo: %d", (int) algo);
}
// Returns filter weight for distance x from pixel center
// Note: for bicubic, Pillow uses a = -0.5 while GGML/PyTorch use a = -0.75
// Note: for bicubic, Pillow (and PyTorch with antialias) uses a = -0.5 while GGML (and PyTorch without antialias) uses a = -0.75
auto resample_filter = [algo](double x) -> double {
if (algo == RESIZE_ALGO_LANCZOS) {
if (-3.0 <= x && x < 3.0) {
@@ -351,12 +360,24 @@ private:
// Formula: int32 = round(float * 2^PRECISION_BITS)
weights.resize((size_t) outSize * ksize);
const double fxp_scale = std::ldexp(1.0, PRECISION_BITS); // 1.0 * 2^PRECISION_BITS
// torchvision rounds the weights to int16, with as many fractional bits as the largest weight allows;
// scaled back to PRECISION_BITS, they give the same output in the passes below
int precision = PRECISION_BITS;
if (int16_weights) {
const double weight_max = *std::max_element(pre_weights.begin(), pre_weights.end());
for (precision = 0; precision < PRECISION_BITS; precision++) {
if (static_cast<int>(0.5 + weight_max * (1 << (precision + 1))) >= (1 << 15)) {
break;
}
}
}
const double fxp_scale = std::ldexp(1.0, precision); // 1.0 * 2^precision
for (size_t i = 0; i < (size_t) outSize * ksize; i++) {
// Pillow adds +/- 0.5 then truncates toward zero; std::round would round twice
const double rounded = pre_weights[i] * fxp_scale + (pre_weights[i] < 0 ? -0.5 : 0.5);
weights[i] = static_cast<int32_t>(rounded);
weights[i] = static_cast<int32_t>(rounded) * (1 << (PRECISION_BITS - precision));
}
return ksize;
+2
View File
@@ -31,6 +31,7 @@ struct mtmd_image_preprocessor {
const clip_hparams & hparams;
mtmd_image_preprocessor(const clip_ctx * ctx): hparams(*clip_get_hparams(ctx)) {}
mtmd_image_preprocessor(const clip_hparams & hparams): hparams(hparams) {}
virtual ~mtmd_image_preprocessor() = default;
virtual mtmd_image_preproc_out preprocess(const clip_image_u8 & img) const = 0;
@@ -59,6 +60,7 @@ struct mtmd_image_preprocessor {
*/
struct mtmd_image_preprocessor_llava_uhd : mtmd_image_preprocessor {
mtmd_image_preprocessor_llava_uhd(const clip_ctx * ctx) : mtmd_image_preprocessor(ctx) {}
mtmd_image_preprocessor_llava_uhd(const clip_hparams & hparams) : mtmd_image_preprocessor(hparams) {}
mtmd_image_preproc_out preprocess(const clip_image_u8 & img) const override;
struct slice_coordinates {