Files
llama.cpp/tests/test-mtmd-impl.cpp
Yuri KhrustalevandXuan Son Nguyen edc353842c 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>
2026-10-24 02:21:06 +02:00

371 lines
13 KiB
C++

#include "testing.h"
#include "mtmd-audio.h"
#include "mtmd-image.h"
#include "mtmd-internal.h"
#include <cmath>
#include <cstdint>
#include <iostream>
#include <stdexcept>
#include <string>
#include <tuple>
#include <utility>
#include <vector>
// this test file contains:
// 1. test cases for mtmd helpers
// 2. test cases for internal mtmd components
// internal headers can be included here
struct test_registry {
using fn_t = void (*)(testing &);
struct entry {
std::string name;
fn_t fn;
};
static std::vector<entry> & all() {
static std::vector<entry> entries;
return entries;
}
test_registry(const char * name, fn_t fn) {
all().push_back({ name, fn });
}
};
#define MAKE_TEST(name) \
static void name(testing & t); \
static const test_registry test_registry_ ## name(#name, &name); \
static void name(testing & t)
//
// mtmd_image
//
MAKE_TEST(test_image_preprocessor_longest_edge) {
clip_hparams hparams;
hparams.image_longest_edge = 1540;
hparams.patch_size = 14;
hparams.n_merge = 2;
const std::vector<std::pair<clip_image_size, clip_image_size>> cases = {
{ { 463, 463 }, { 476, 476 } },
{ { 463, 300 }, { 476, 308 } },
{ { 448, 280 }, { 448, 280 } },
{ { 2000, 1000 }, { 1540, 784 } },
};
for (const auto & [input, expected] : cases) {
const clip_image_size actual = mtmd_image_preprocessor_longest_edge::get_target_size(hparams, input);
const std::string name = std::to_string(input.width) + "x" + std::to_string(input.height);
t.assert_equal(name + " width", expected.width, actual.width);
t.assert_equal(name + " height", expected.height, actual.height);
}
}
MAKE_TEST(test_image_preprocessor_dyn_size) {
// { patch_size, image size, expected size }
// a side that is an even number plus .5 merged patches rounds down, like Python round() in smart_resize
const std::vector<std::tuple<int, clip_image_size, clip_image_size>> cases = {
{ 16, { 768, 400 }, { 768, 384 } },
{ 16, { 800, 336 }, { 800, 320 } },
{ 16, { 400, 400 }, { 384, 384 } },
{ 16, { 640, 240 }, { 640, 256 } },
{ 14, { 966, 560 }, { 952, 560 } },
};
for (const auto & [patch_size, input, expected] : cases) {
clip_hparams hparams;
hparams.patch_size = patch_size;
hparams.n_merge = 2;
hparams.image_round_half_even = true;
hparams.set_limit_image_tokens(8, 4096);
const clip_image_size actual = mtmd_image_preprocessor_dyn_size::get_target_size(hparams, input);
const std::string name = std::to_string(input.width) + "x" + std::to_string(input.height);
t.assert_equal(name + " width", expected.width, actual.width);
t.assert_equal(name + " height", expected.height, actual.height);
}
}
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;
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;
// { image size, expected tiling }
const std::vector<std::pair<clip_image_size, bool>> cases = {
{ { 704, 704 }, false },
// 720 / (patch_size * n_merge) is exactly 22.5, so this only matches HF
// if round_by_factor rounds half to even (22) instead of away from zero (23)
{ { 720, 720 }, false },
{ { 736, 736 }, true },
{ { 1024, 977 }, true },
{ { 1056, 384 }, false },
};
for (const auto & [size, expected] : cases) {
const bool actual = mtmd_image_preprocessor_lfm2::should_tile(hparams, size);
t.assert_equal(
"tiling for " + std::to_string(size.width) + "x" + std::to_string(size.height),
std::string(expected ? "tiled" : "single"),
std::string(actual ? "tiled" : "single"));
}
// { image size, expected size from HF smart_resize }
const std::vector<std::pair<clip_image_size, clip_image_size>> resize_cases = {
{ { 400, 600 }, { 384, 608 } }, // 400 / 32 = 12.5 rounds half to even
{ { 480, 336 }, { 480, 320 } },
{ { 64, 169 }, { 160, 416 } }, // 169 * beta / 32 is exactly 13 in double
{ { 1024, 768 }, { 576, 416 } },
};
for (const auto & [size, expected] : resize_cases) {
t.assert_equal("smart_resize for " + image_size_to_str(size), image_size_to_str(expected),
image_size_to_str(mtmd_image_preprocessor_lfm2::smart_resize(hparams, size)));
}
// { image size, expected grid from HF }
const std::vector<std::pair<clip_image_size, clip_image_size>> grid_cases = {
{ { 720, 1600 }, { 2, 4 } }, // 0.45 is an exact tie between 2/4 and 2/5 in double, not in float
{ { 420, 1440 }, { 1, 3 } },
{ { 255, 2160 }, { 1, 8 } },
{ { 1024, 768 }, { 3, 2 } },
};
for (const auto & [size, expected] : grid_cases) {
t.assert_equal("grid for " + image_size_to_str(size), image_size_to_str(expected),
image_size_to_str(mtmd_image_preprocessor_lfm2::get_grid_layout(hparams, size.height, size.width)));
}
// do_image_splitting=false
hparams.preproc_min_tiles = 1;
hparams.preproc_max_tiles = 1;
t.assert_equal("tiling for 1024x977 with max_tiles=1", std::string("single"),
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
//
MAKE_TEST(test_temporal_merge_grouping) {
std::vector<mtmd::bitmap_ptr> pool; // keeps the bitmaps alive until the end of the test
// spec chars:
// v = video frame, w = video frame of another size, a = audio, i = plain image, t = text
auto make_parts = [&pool](const std::string & spec) {
std::vector<mtmd_internal_part> parts;
for (char c : spec) {
if (c == 't') {
parts.push_back({ "hello", nullptr });
continue;
}
mtmd_bitmap * bm = nullptr;
switch (c) {
case 'v': bm = mtmd_bitmap_init(100, 100, nullptr); break;
case 'w': bm = mtmd_bitmap_init(200, 200, nullptr); break;
case 'a': bm = mtmd_bitmap_init_from_audio(100, nullptr); break;
case 'i': bm = mtmd_bitmap_init(100, 100, nullptr); break;
default: throw std::runtime_error(std::string("unknown spec char: ") + c);
}
mtmd_bitmap_set_mergeable(bm, c != 'i');
pool.emplace_back(bm);
parts.push_back({ "", bm });
}
return parts;
};
// { parts, n_merge, expected size of each group }
const std::vector<std::tuple<std::string, int, std::string>> cases = {
{ "vv", 2, "2" },
{ "vvv", 2, "21" },
{ "vvvv", 2, "22" },
{ "vvi", 2, "21" },
{ "tvvt", 2, "2" },
{ "vtv", 2, "11" }, // text in between breaks the merge
{ "vw", 2, "11" }, // different sizes cannot be merged
{ "aa", 2, "11" }, // audio is never merged
{ "ii", 2, "11" }, // two unrelated images must stay separated
{ "iv", 2, "11" },
{ "vi", 2, "11" },
{ "vv", 1, "11" }, // model without temporal merge
};
for (const auto & [spec, n_merge, expected] : cases) {
auto parts = make_parts(spec);
auto groups = mtmd_group_mergeable_bitmaps(parts, n_merge);
std::string actual;
for (const auto & group : groups) {
actual += std::to_string(group.size());
}
const std::string name = "\"" + spec + "\" with n_merge=" + std::to_string(n_merge);
t.assert_equal("groups for " + name, expected, actual);
size_t n_bitmap_parts = 0;
for (const auto & p : parts) {
n_bitmap_parts += p.bitmap != nullptr ? 1 : 0;
}
t.assert_equal("remaining bitmap parts for " + name, groups.size(), n_bitmap_parts);
}
}
//
// mtmd_audio
//
MAKE_TEST(test_audio_preprocessor_conformer) {
clip_hparams hparams;
hparams.n_mel_bins = 128;
hparams.audio_sample_rate = 16000;
hparams.audio_n_fft = 512;
hparams.audio_window_len = 400;
hparams.audio_hop_len = 160;
// 0.4 s of tones, 0.2 s of silence, 0.4 s of a quiet tone
const double pi = 3.14159265358979323846;
const int sr = hparams.audio_sample_rate;
std::vector<float> samples(sr, 0.0f);
for (int i = 0; i < sr; i++) {
const double ts = (double) i / sr;
if (i < 0.4 * sr) {
samples[i] = (float) (0.3 * std::sin(2 * pi * 300 * ts) + 0.2 * std::sin(2 * pi * 1200 * ts) + 0.1 * std::sin(2 * pi * 3500 * ts));
} else if (i >= 0.6 * sr) {
samples[i] = (float) (1e-3 * std::sin(2 * pi * 800 * ts));
}
}
mtmd_audio_preprocessor_conformer preproc(hparams);
preproc.initialize();
std::vector<mtmd_audio_mel> mels;
if (!t.assert_true("preprocess", preproc.preprocess(samples.data(), samples.size(), mels))) {
return;
}
const mtmd_audio_mel & mel = mels[0];
t.assert_equal("n_len", (int64_t) 101, mel.n_len);
// reference values from NeMo AudioToMelSpectrogramPreprocessor (liquid-audio 1.3.0)
// { mel bin, frame, value }
const std::vector<std::tuple<int, int, float>> cases = {
{ 8, 0, 3.6104f }, // tones
{ 24, 50, -0.6371f }, // silence
{ 24, 60, -0.0476f }, // silence -> quiet tone
{ 100, 20, -0.0913f }, // tones, high bin
};
for (const auto & [bin, frame, expected] : cases) {
const float actual = mel.data[bin * mel.n_len + frame];
t.assert_true("mel[" + std::to_string(bin) + "][" + std::to_string(frame) + "] = " + std::to_string(actual) + ", expected " + std::to_string(expected), std::fabs(actual - expected) < 1e-3f);
}
}
//
// main
//
int main(int argc, char ** argv) {
testing t(std::cout);
t.verbose = true;
// usage: test-mtmd-impl [filter_regex]
for (int i = 1; i < argc; i++) {
t.set_filter(argv[i]);
}
for (const auto & e : test_registry::all()) {
t.test(e.name, e.fn);
}
return t.summary();
}