Назад к блогу

LensVLM от Apple: как длинный контекст превращается в сжатые изображения страниц

LensVLM от Apple: как длинный контекст превращается в сжатые изображения страниц

Apple представила LensVLM — 9B-модель, которая работает с документами не как с текстом, а как с набором сжатых изображений страниц, разворачивая в полном виде только те из них, что действительно нужны для ответа. Разбор показывает, как устроен этот конвейер: от пресетов сжатия и расчёта визуальных токенов до загрузки модели через vLLM и логики многоходового инференса. Интересно здесь то, что итоговое поведение системы определяется стыками между слоями — сжатием, рендерингом, выбором страниц и метриками, — а не каким-то одним компонентом.

LensVLM — это Vision Language Model на 9B параметров, которая сначала просматривает документ в виде набора сжатых изображений страниц, а затем выборочно разворачивает только нужные страницы в несжатую форму с помощью обученных инструментов. Вопрос, который здесь разбирается, — как именно устроен этот конвейер: как текст превращается в сжатые страницы, как выбирается степень сжатия, как модель решает, какую страницу открыть, и как всё это оценивается. Нетривиальность в том, что сжатие, рендеринг, многоходовой инференс и метрики — это отдельные слои, и поведение системы определяется их стыками.

Зачем заменять текст изображениями

Идея в том, чтобы не подавать модели весь документ целиком в виде текста. Вместо этого документ рендерится в набор сжатых изображений страниц, модель сначала просматривает их все в компактном виде, а затем при необходимости разворачивает нужные страницы полностью. В примере из репозитория модель сканирует 15 сжатых изображений страниц, определяет страницу 10 как релевантную, читает её текстовое содержимое через инструмент read_page и связывает Shirley Temple → Corliss Archer → Chief of Protocol за 2 хода.

Что означают пресеты 5x, 10x, 15x

Пресет выбирается при подготовке данных через аргумент --compression (например, --compression 5x). В конфигурации рендеринга есть поле tokens_per_page, которое переопределяется пресетами сжатия — по умолчанию оно равно None, то есть используется значение по умолчанию.

Связь между числом страниц и визуальными токенами задаётся несколькими вычислениями. Строк на страницу подбирается так, чтобы попасть в целевое число токенов на страницу. Визуальные токены страницы считаются как ceil(h/32) * ceil(w/32). Средний расход визуальных токенов на страницу оценивается по средней высоте страницы. А максимальное число страниц под бюджет токенов получается так: из общего бюджета вычитаются текстовые токены, остаток делится на визуальные токены на страницу.

Как устроена модель и как она загружается

LensVLM-9B — это модель на 9B параметров с типом тензоров BF16. Загрузка идёт через функцию load_model, которая оборачивает класс LLM из vLLM с настройками по умолчанию. Параметр tensor_parallel_size=1 задаёт число устройств, между которыми распределяются слои модели: значение 1 означает, что модель целиком размещается на одном устройстве. Параметр dtype="bfloat16" задаёт формат хранения весов — 16-битный bfloat16. Флаг trust_remote_code=True разрешает выполнять код, поставляемый вместе с моделью, а не только встроенный в библиотеку. Параметр max_model_len=32768 ограничивает максимальную длину последовательности в токенах, которую модель может обработать за раз. Параметр gpu_memory_utilization=0.9 задаёт долю памяти графического процессора, которую разрешено занять под модель и кэш. Параметр allowed_local_media_path="/" определяет каталог, из которого разрешено читать локальные медиафайлы.

Аргумент mm_processor_kwargs управляет обработкой мультимодального входа — в частности, тем, в каких пределах масштабируются изображения перед подачей в модель. Если он не передан, используется значение {"min_pixels": 1, "max_pixels": 16777216}: min_pixels задаёт минимальное число пикселей, до которого изображение может быть уменьшено, а max_pixels — максимальное число пикселей, до которого изображение может быть увеличено. Функция возвращает экземпляр LLM из vLLM, готовый к инференсу.

Пользовательский промпт собирает функция build_user_prompt: она принимает вопрос и число страниц и возвращает строку, состоящую только из тегов изображений и вопроса. Теги формируются повторением строки <image> столько раз, сколько страниц, а итог выглядит так:

<image><image>...\n\nQuestion: {question}\n

То есть сначала идут все теги изображений подряд, затем два перевода строки, метка Question: с текстом вопроса и завершающий перевод строки. Никаких других элементов в промпте нет.

Как текст превращается в страницы

Функция render_pages выполняет весь конвейер пошагово.

Сначала текст нормализуется. Если переданы evidence_spans, вызывается вариант нормализации с построением карты позиций, иначе — обычная нормализация. Если после нормализации ничего не осталось, рендеринг прекращается.

Нормализация разбивает текст на абзацы по шаблону \n\s*\n. Каждый абзац обрабатывается отдельно: последовательности пробельных символов сжимаются в один пробел, ведущие и замыкающие пробелы отбрасываются. Абзацы короче 80 символов присоединяются к предыдущему через пробел — это делается, чтобы не получать слишком мелкие фрагменты при рендеринге. Если первый абзац короче 80 символов и абзацев больше одного, он присоединяется ко второму. Затем все абзацы соединяются одним пробелом.

Дальше нормализованный текст разбивается на строки по словам с учётом доступной ширины (ширина минус двойной отступ). Слишком длинные слова режутся посимвольно на части, помещающиеся в эту ширину; пустые строки сохраняются как пустые. Хвостовые пустые строки удаляются, и если строк не осталось, рендеринг прекращается.

Затем вычисляются диапазоны строк для evidence-спанов, после чего рисуется одно высокое изображение со всеми строками. Оно режется на страницы по числу строк на страницу:

num_pages = math.ceil(len(lines) / target_lpp)

У каждой страницы обрезаются ведущие и хвостовые пустые строки, добавляется метка номера страницы, и результат возвращается в виде структуры с изображениями страниц, их текстами, числом страниц, ключом конфигурации и номерами страниц-доказательств.

Зачем пересчитывать evidence-спаны

Нормализация сдвигает позиции символов: пробелы схлопываются, короткие абзацы сливаются, абзацы соединяются через пробел. Поэтому диапазоны, заданные в координатах исходного текста, после нормализации указывали бы на неверные места. Функция _remap_evidence_spans переводит их в координаты нормализованного текста через карту позиций, где для каждого исходного индекса хранится соответствующий индекс в нормализованном тексте или -1, если символ был удалён. Для каждого диапазона берётся минимальное неотрицательное значение карты как начало и максимальное плюс один как конец, ограниченный длиной нормализованного текста. Диапазоны, все символы которых схлопнулись в пробелы, отбрасываются.

Метка номера страницы

Номер страницы рисуется в правом нижнем углу: изображение копируется, создаётся объект для рисования, берётся шрифт размера 10, формируется текст из номера, вычисляются его размеры, добавляются отступы 4 по ширине и 2 по высоте, задаётся смещение 1 от края, рисуется белый прямоугольник с чёрной обводкой, и текст центрируется в нём.

Параллельный рендеринг

При подготовке данных страницы рендерятся параллельно: если число воркеров не задано, оно ограничивается значением min(cpu_count(), 32). Конфигурация рендеринга передаётся в каждый процесс, а для каждого образца формируется отдельная задача. Задачи распределяются между процессами, результаты собираются по мере завершения, а сбой при рендеринге отдельного образца не прерывает обработку остальных — такой образец просто пропускается.

Повторный рендеринг из сохранённых текстов страниц

Повторный рендеринг из сохранённых текстов страниц рисует каждую страницу напрямую, пропуская нормализацию и перенос строк: предполагается, что тексты страниц — это уже перенесённые строки, как их выдаёт обычный рендеринг. Это гарантирует пиксельно идентичный результат при повторном рендеринге из сохранённых текстов страниц в данных оценки, независимо от настройки tokens_per_page. Для каждой страницы строки берутся разбиением по символу новой строки, пустые строки сверху и снизу обрезаются, высота считается как число строк, умноженное на высоту строки, плюс два отступа, затем добавляется метка страницы.

Обычный рендеринг, напротив, сначала нормализует текст, переносит строки, рендерит один высокий образ и режет его на страницы. При заданном tokens_per_page (то есть при использовании пресета сжатия) строки страницы рисуются на новом изображении — чтобы избежать «протекания» соседних строк при плотной высоте строки, близкой к размеру шрифта.

Как считаются размеры и токены

Высота строки — это максимум из 1 и целой части произведения размера шрифта на межстрочный интервал. Ширина текстовой области — максимум из 1 и ширины минус двойной отступ. Оценка символов на строку берётся так: ширина символа принимается равной размеру шрифта, умноженному на 0.55, а результат — максимум из 1 и целой части от деления ширины текстовой области на ширину символа.

Число строк на страницу вычисляется так:

tpp = self.tokens_per_page if self.tokens_per_page is not None else TARGET_TOKENS_PER_PAGE
return max(5, target_chars // chars_per_line)

где target_chars — это tpp, умноженное на число символов на токен. То есть при заданном пресете сжатия целевое число токенов на страницу берётся из пресета, иначе используется значение по умолчанию, а результат не может быть меньше 5 строк.

Визуальные токены страницы считаются как ceil(max(1, page_height)/32) * ceil(width/32).

Ограничение числа страниц под бюджет работает так: оценивается средний расход визуальных токенов на страницу (через среднюю высоту страницы), из общего бюджета вычитаются текстовые токены (число символов, делённое на число символов на токен), и остаток делится на средний расход. Если остаток не положителен, возвращается 1; иначе — максимум из 1 и целой части от деления. Так суммарные визуальные токены страниц плюс текстовые токены не превышают заданный бюджет.

Как выбирается конфигурация рендеринга

Функция sample_render_config не использует шрифт, размер, межстрочный интервал, отступы и цвета из сэмплеров: параметр темы документирован как игнорируемый, всегда используется тема document. Возвращаемая конфигурация жёстко задаёт шрифт DejaVuSans.ttf, белый фон, чёрный текст и тему document.

Размер шрифта, межстрочный интервал и отступы берутся из выбранного пресета сжатия. Если степень сжатия равна None или "random", пресет выбирается случайно из доступных.

Параметр dark_mode в этой функции не учитывается: тема игнорируется, цвета всегда белый фон и чёрный текст. Сэмплеры, где тёмная тема влияет на пул шрифтов, веса размеров, веса интервалов, диапазон отступов и выбор тёмных палитр, здесь не вызываются.

Многоходовой инференс: как модель открывает страницу

На первом ходу для каждого образца собирается пара сообщений — системное и пользовательское, причём пользовательское содержимое формируется как мультимодальное (текст плюс изображения). Модель запрашивается сразу в нескольких вариантах ответа: pass_k, что позволяет затем оценивать устойчивость результата по нескольким попыткам. Генерация останавливается, как только модель начинает закрывать вызов инструмента. Полученный текст каждого варианта очищается: удаляются служебные токены, и при необходимости добавляется закрывающий </tool_call>.

Детекция вызова инструмента — это просто проверка наличия подстроки <tool_call> в ответе. Если вызов есть, номер страницы извлекается регулярным выражением по полю "page", страница добавляется в список открытых страниц, и строится ответ инструмента. При включённом возврате изображения и попадании номера страницы в диапазон доступных изображений высокого разрешения ответ формируется как список частей: текст, изображение по file://-ссылке и закрывающий текст. Иначе возвращается текстовая строка с содержимым соответствующей страницы. Этот ответ добавляется в историю как сообщение пользователя. Если запрошенная страница оказалась вне доступного диапазона, траектория помечается завершённой. Если вызова инструмента не было, траектория сразу помечается завершённой, а финальный ответ извлекается отдельной функцией.

Как разбирается вызов и извлекается ответ

Разбор вызова ищет номер страницы с индексацией от 1; если разобрать не удалось, страница считается не найденной. Сначала применяется шаблон, требующий тега <tool_call> с JSON, содержащим поле "page" с числом. Если он не сработал, используется запасной поиск любого вхождения "page": <число> в ответе. Если ни один шаблон не найден, страница считается не найденной.

Извлечение финального ответа номер страницы не трогает: оно выделяет ответ модели из многошагового ответа с инструментами — сначала берётся последний блок <answer>...</answer>, а если таких блоков нет, последний сегмент после </tool_response>, с удалением блоков <think> и <tool_call>.

Подготовка изображений

Функция _ensure_min_image_size проверяет размеры изображения: если хотя бы одна сторона меньше 28 пикселей, создаётся дополненное белым фоном изображение с размерами не меньше 28 по каждой стороне, которое сохраняется во временный PNG-файл, и возвращается путь к нему; иначе возвращается исходный путь. Это нужно, чтобы маленькие изображения соответствовали минимальному размеру, требуемому GLM-4.1V.

Функция _build_multimodal_content разбирает текст с плейсхолдерами <image> и список путей к изображениям, поочерёдно заменяя каждый тег на часть контента типа image_url. Перед формированием части путь прогоняется через проверку минимального размера, затем преобразуется в абсолютный и оборачивается в file://-ссылку. Текст до и после плейсхолдеров добавляется как текстовые части. Если ни изображений, ни тегов <image> не встретилось, возвращается исходная строка текста.

Как оценивается качество

Эвристические метрики

Реализованы четыре метрики. exact_match сравнивает нормализованные строки на полное равенство; нормализация приводит текст к нижнему регистру, удаляет пунктуацию, выкидывает артикли a/an/the и схлопывает пробелы. substring_match проверяет, что нормализованный эталон целиком входит в нормализованное предсказание. f1_score считает токенный F1: берёт пересечение счётчиков токенов, вычисляет precision как отношение пересечения к числу токенов предсказания, recall как отношение пересечения к числу токенов эталона, и возвращает 2*prec*rec/(prec+rec); при пустых токенах возвращает float(pred_toks == gold_toks), при нулевом пересечении — 0.0. multiple_choice_match для эталона из одной буквы A/B/C/D берёт первую такую букву из предсказания и сравнивает её с эталоном, иначе откатывается к exact_match.

Оценщик выбирает метод через параметр (одно из exact_match, substring, f1, multiple_choice), а для F1 ответ считается верным при значении не ниже порога (по умолчанию 0.5), при этом само значение сохраняется как лучший результат.

Комбинирование с судейством LLM

Комбинированный оценщик выбирает метод по имени задачи: сначала ищет точное совпадение, затем частичное вхождение ключа в имя задачи, а при отсутствии совпадений возвращает llm_judge. По умолчанию задачи ruler, niah и multihop_kv оцениваются через substring, multiple_choice — через multiple_choice, а code, singlehop_qa, multihop_qa и qa — через llm_judge. Если выбран llm_judge, вызов делегируется лениво создаваемому оценщику-судье, который формирует промпт и просит отвечать только YES или NO; иначе метод присваивается эвристическому оценщику. В пакетном режиме образцы группируются по методу, каждая группа обрабатывается своим оценщиком, а результаты раскладываются обратно по исходным индексам. Итоговые метрики включают общую accuracy и разбивку по задачам.

Агрегация и pass@k

Агрегация эвристических метрик сравнивает предсказание с золотыми ответами: EM считается истиной, если ответ совпал хотя бы с одним из золотых, а F1 берётся как максимум по всем золотым. Затем метрики усредняются по всем предсказаниям и отдельно по каждому датасету.

compute_pass_at_k группирует предсказания по идентификатору образца (и дополнительно по датасету), считает для каждого образца число попыток и число правильных по полю judge_correct, и для каждого k от 1 до максимума вычисляет pass@k по формуле 1 - C(n-c, k) / C(n, k), пропуская образцы, где попыток меньше k. Итоговое значение для каждого k — среднее по образцам группы. Здесь EM — точное совпадение ответа с любым из золотых, а pass@k — вероятность того, что хотя бы одна из k попыток образца признана правильной.

Точность локализации страницы

Функция оценки локализации принимает результаты с эталонной и предсказанной рамками и вычисляет точность как долю случаев, где IoU не ниже 0.5, а также средний IoU. Координаты рамки приводятся к диапазону от 0 до 1000, некорректные рамки (где правая граница не правее левой или нижняя не ниже верхней) отбрасываются. Если после приведения хотя бы одна рамка некорректна, IoU равен 0.0. Пересечение вычисляется через максимум левых и верхних границ и минимум правых и нижних; если оно пустое, IoU равен 0.0. Иначе IoU — это отношение площади пересечения к объединению (0.0, если объединение не положительно). Возвращается число результатов, точность и средний IoU.

Запуск и воспроизведение

Главная функция оценки сначала создаёт выходной каталог, затем загружает тестовые данные из JSON-файла, делает пути к изображениям абсолютными относительно каталога тестовых данных, при необходимости ограничивает число примеров и логирует распределение по датасетам. Дальше загружает модель: если задан путь к адаптеру и не указан режим только базовой модели, выполняется слияние LoRA, после чего модель загружается; иначе — обычная загрузка. После этого запускается многоходовой инференс, модель выгружается, временный каталог слитой модели удаляется, считаются эвристические метрики и сохраняется predictions.json. Если указан флаг пропуска судейства, функция возвращает 0; иначе запускается судейство, агрегируются метрики (включая разбивку по датасетам, экономию токенов и pass@k) и сохраняется evaluation_results.json, после чего печатается сводка.

Подготовка данных идёт через prepare_data.py с параметрами датасета, каталога вывода, степени сжатия, числа образцов и сплита:

python scripts/prepare_data.py \
  --dataset hotpotqa \
  --output_dir ./data/hotpotqa_5x \
  --compression 5x \
  --max_samples 500 \
  --split train

Провайдеры датасетов строят длинные контексты, дополняя золотые пассажи отвлекающими параграфами из других образцов. Затем prepare_data.py рендерит их в сжатые страничные документы и записывает страницы-доказательства. Изображения страниц и результат сохраняются в каталоге вывода (по умолчанию ./demo_output). Доступны варианты сжатия 5x, 10x, 15x.

Что из этого следует на практике

Сжатие и рендеринг — это отдельный офлайн-этап. Пресет сжатия определяет не только степень ужатия, но и размер шрифта, межстрочный интервал и отступы, а значит, и то, сколько строк попадёт на страницу и сколько визуальных токенов она займёт. Менять пресет на лету при инференсе нельзя — он зашит в конфигурацию рендеринга.

Повторный рендеринг из сохранённых текстов страниц даёт пиксельно идентичный результат независимо от tokens_per_page. Это значит, что данные оценки можно перерисовывать без риска, что картинки разойдутся с теми, на которых считались метрики.

Нормализация текста необратимо сдвигает позиции символов, поэтому evidence-спаны без пересчёта через карту позиций указывали бы на неверные места. Если диапазоны-доказательства нужны, их обязательно надо прогонять через пересчёт.

Разбор вызова инструмента устроен снисходительно: сначала строгий шаблон с тегом, затем запасной поиск поля "page" в любом месте ответа. Это повышает устойчивость к неполному форматированию, но означает, что число может быть извлечено даже из ответа без корректного тега.

Число воркеров рендеринга ограничено 32, а исключения в отдельных задачах не роняют весь пакет — неудачный образец просто получает None. При оценке это стоит учитывать: пропуски неотличимы от честного отсутствия результата.

Где смотреть в коде

Источники

Похожее