AnyRes(动态高分辨率)是 LLaVA-NeXT 1.6 的核心创新,解决了固定分辨率下细节丢失的问题:将图像按纵横比自适应切分为多个 tile,每个 tile 独立编码后和全局缩略图一起送入 LLM。NanoVLM 在保留原始单图模式的基础上新增了 AnyRes 能力,两个模式通过一个布尔值切换。
问题:固定分辨率的瓶颈
NanoVLM 的基础版本将每张图 resize 到 384×384,经 SigLIP 编码为 729 个 visual token。这对大多数自然图像够用——但遇到高分辨率图像(文档、OCR、大场景)时,细节会被压缩到不可辨认。
一个具体的例子:
原始图像: 1920×1080 的截图(包含文字)
↓ resize 到 384×384
丢失了 (1920×1080) / (384×384) = 14× 的像素信息
↓
文字模糊到 LLM 无法识别
AnyRes 的思路很直接——不要压缩整张图,而是把大图切成多个小图,每个小图保持清晰。
核心算法
处理流程
原图 (如 1200×800, AR=1.5)
│
├─ Step 1: 选 grid → 2×2(4 tiles)
│
├─ Step 2: 生成缩略图
│ resize → 384×384 → 729 tokens(全局上下文)
│
└─ Step 3: 切分 + 编码 tiles
原图 → resize_to_fit(768×768) → 切成 4 个 384×384 tile
tile[0,0] → SigLIP → 729 tokens
tile[0,1] → SigLIP → 729 tokens
tile[1,0] → SigLIP → 729 tokens
tile[1,1] → SigLIP → 729 tokens
─────────
总计: 5 × 729 = 3645 tokens
关键细节:缩略图保留全局上下文,tiles 保留局部细节。两者互补——缩略图告诉 LLM"这是一张包含文字和图表的信息图",tiles 告诉 LLM 文字具体写了什么。
Grid 选择算法
不是所有图都适合 2×2。Grid 的选择取决于原图的尺寸和宽高比:
宽图 (1200×600, AR=2.0) → 1×3(3列,1行)→ 充分利用宽度
高图 (600×1200, AR=0.5) → 3×1(3行,1列)→ 充分利用高度
方图 (800×800, AR=1.0) → 2×2(2行2列) → 均衡切分
小图 (200×200) → 1×1(单图) → 不需要切分,退化为原始模式
算法对每个候选 grid (rows, cols) 计算两个指标:
- 利用率:$U = \frac{\text{缩放后面积}}{\text{目标面积}}$——原图能填充 grid 的多少空间?高利用率意味着不浪费 visual token。
- 有效 tile 数:$E = \frac{\text{缩放后面积}}{\text{base\_size}^2}$——原图实际携带了几个 tile 的信息量?
最终分数:$\text{score} = U + 0.005 \times E$,选分数最高的 grid。
数学上这是在对两个矛盾目标做权衡:
- 利用率高 → 每个 visual token 都承载了有效信息,不浪费
- 有效 tile 数大 → 保留了更多原图细节,分辨率更高
$0.005 \times E$ 是一个微小的 tiebreaker:两个 grid 利用率相同时,倾向保留更多细节的那个。系数很小,不会因为细节多而选择一个利用率极低的 grid。
候选 grid 列表(按 tile 数递增,共 9 种):
| grid | tiles | 目标分辨率 | 适合场景 |
|---|---|---|---|
| 1×1 | 1 | 384×384 | 小图、图标 |
| 1×2 | 2 | 768×384 | 横长图 |
| 2×1 | 2 | 384×768 | 竖长图 |
| 2×2 | 4 | 768×768 | 中等方图 |
| 1×3 | 3 | 1152×384 | 超宽图 |
| 3×1 | 3 | 384×1152 | 超高图 |
| 2×3 | 6 | 1152×768 | 大横图 |
| 3×2 | 6 | 768×1152 | 大竖图 |
| 3×3 | 9 | 1152×1152 | 超大图 |
核心代码
AnyResProcessor
整个 AnyRes 逻辑封装在 anyres_processor.py 中。入口方法 process() 接收 PIL Image,返回处理好的 pixel_values 和 sub-image 数量:
def process(self, image: Image.Image) -> Tuple[torch.Tensor, int]:
if not self.enabled:
return self._process_single(image) # 退化为原始单图模式
orig_w, orig_h = image.size
# 1. 选择最佳 grid
rows, cols = self.select_best_grid(orig_w, orig_h)
if rows == 1 and cols == 1:
return self._process_single(image) # 1×1 也退化为单图
# 2. 生成缩略图(全局上下文)
thumbnail = image.resize((self.base_size, self.base_size), resample=Image.BICUBIC)
# 3. 将原图 resize 到 grid 目标尺寸并切分
target_w = cols * self.base_size
target_h = rows * self.base_size
resized = self.resize_and_pad(image, target_w, target_h)
tiles = self.split_to_tiles(resized, rows, cols)
# 4. 预处理所有 sub-image → pixel_values
all_images = [thumbnail] + tiles # 缩略图放在第一个位置
pixel_values_list = []
for sub_img in all_images:
processed = self.image_processor(images=sub_img, return_tensors="pt")
pixel_values_list.append(processed["pixel_values"].squeeze(0))
pixel_values = torch.stack(pixel_values_list, dim=0) # (1+G², 3, 384, 384)
return pixel_values, 1 + rows * cols
resize_and_pad() 在保持宽高比的前提下将图像缩放到目标尺寸,不足的部分用灰色 (127,127,127) 填充:
def resize_and_pad(self, image, target_w, target_h):
orig_w, orig_h = image.size
scale = min(target_w / orig_w, target_h / orig_h) # 保持宽高比
new_w, new_h = int(orig_w * scale), int(orig_h * scale)
resized = image.resize((new_w, new_h), resample=Image.BICUBIC)
# 居中填充到精确的目标尺寸
result = Image.new("RGB", (target_w, target_h), (127, 127, 127))
paste_x, paste_y = (target_w - new_w) // 2, (target_h - new_h) // 2
result.paste(resized, (paste_x, paste_y))
return result
split_to_tiles() 按行优先顺序将填充后的图像切为 rows × cols 个 base_size × base_size 的 tile:
def split_to_tiles(self, image, rows, cols):
tiles = []
for r in range(rows):
for c in range(cols):
x1, y1 = c * self.base_size, r * self.base_size
x2, y2 = x1 + self.base_size, y1 + self.base_size
tiles.append(image.crop((x1, y1, x2, y2)))
return tiles
和AnyRes无关-保留原本的单图处理逻辑
AnyRes 对 NanoVLM 核心的侵入很小。三个关键改动:
1. forward() — 拆分拼接 visual embeddings
def forward(self, pixel_values=None, ..., image_counts=None, **kwargs):
if pixel_values is not None:
visual_embeddings = self.encode_images(pixel_values)
# 所有 sub-image 一次编码: (total, 729, 896)
if image_counts is not None and self.config.anyres.enabled:
# AnyRes 路径:按 image_counts 拆分为每个样本的 visual tokens
vis_emb_list = []
start = 0
for count in image_counts:
sample_vis = visual_embeddings[start:start + count]
sample_vis = sample_vis.reshape(-1, sample_vis.shape[-1])
# (count, 729, 896) → (count*729, 896)
vis_emb_list.append(sample_vis)
start += count
visual_embeddings = vis_emb_list # List[(N_v1, 896), ...]
# else: visual_embeddings 保持为 (B, 729, 896) — 原始单图模式
核心逻辑:所有 sub-image(缩略图 + 各 tile)被 encode_images 当作一个 batch 一次处理,然后在 forward 中按 image_counts 拆分并拼接。VisionEncoder 和 Connector 本身无需任何改动——它们天然支持变长 batch。
2. prepare_inputs_embeds() — 支持不等长 visual token
def prepare_inputs_embeds(self, input_ids, visual_embeddings, attention_mask=None):
# visual_embeddings 支持两种格式:
# Tensor (B, N_v, hidden) — 原始单图,每样本等长
# List[Tensor] — AnyRes,每样本可以不等长
for b in range(batch_size):
vis_emb = visual_embeddings[b] # 对 Tensor 和 List 都适用
image_positions = (input_ids[b] == self.image_token_id).nonzero(...)
# 截断/填充/替换逻辑不变
text_embeds[b, image_positions] = vis_emb
Python 的整数索引对 Tensor 和 List 都有效,所以用一个循环就同时覆盖了两种模式。
3. Dataset 和 collate_fn — 数据管线适配
数据集 __getitem__ 中分支处理:
if self._use_anyres:
pixel_values, num_sub_images = self.anyres_processor.process(image)
num_vis_tokens = num_sub_images * self.num_image_tokens # (1+G²) × 729
else:
pixel_values = self.process_image(image) # (3, 384, 384)
num_vis_tokens = self.num_image_tokens # 729
collate_fn 中展平不同样本的 tile:
if is_anyres:
# (5,3,384,384) + (1,3,384,384) + (9,3,384,384) → cat → (15,3,384,384)
collated["pixel_values"] = torch.cat(values, dim=0)
collated["image_counts"] = [5, 1, 9]
else:
# (3,384,384) × 3 → stack → (3,3,384,384)
collated["pixel_values"] = torch.stack(values)
Visual Token 爆炸
AnyRes 换取了更高的分辨率,但代价是 visual token 数量暴增:
| Grid | Sub-images | Visual Tokens | 相对单图的倍数 |
|---|---|---|---|
| 1×1 | 1 | 729 | 1× |
| 1×2 | 3 | 2,187 | 3× |
| 2×1 | 3 | 2,187 | 3× |
| 2×2 | 5 | 3,645 | 5× |
| 1×3 | 4 | 2,916 | 4× |
| 2×3 | 7 | 5,103 | 7× |
| 3×3 | 10 | 7,290 | 10× |
对于 2048 的 max_seq_length,3×3 grid 的 visual token 数量(7290)就已经远超上限。因此训练时需要通过 max_tiles 限制最大 grid,通常设为 4(即最多 5×729=3645 tokens)。
这也是为什么 AnyRes 在 Stage 1(纯 Connector 训练,batch_size 可以更大)通常关闭,只在 Stage 2 开启——Stage 2 的 LoRA 只增加少量训练参数,显存余量可以分给更长的 visual 序列。
训练策略
Stage 1 (anyres_enabled=False):
单图模式训练 Connector
→ Connector 学会将 729 个 visual token 从 1152 维投影到 896 维
→ 这个能力与 token 数量无关——Connector 逐 token 独立处理
→ 因此在NanoVLM-LLaVA中的Stage1 Checkpoint 可以直接复用到 AnyRes 的 Stage 2。
Stage 2 (anyres_enabled=True):
加载 Stage 1 的 Connector checkpoint
→ Connector 已经学会投影,直接复用到更多 token
→ LoRA 微调 LLM 适应更长的 visual 序列和 tile 间的交互
不需要重新训练 Stage 1——Connector 是 per-token 的无状态映射 f: R^1152 → R^896,处理 1 个 token 和处理 5000 个 token 对它来说是一样的矩阵乘法。
Stage 2 必须重新训练——LoRA 适配的 attention 层在 729 个 visual token 的序列布局上学到了固定的注意力模式。AnyRes 下 visual token 增加到 3645+,token 间的空间关系变了(缩略图 token 要"统领"所有 tile token,tile token 之间需要建立空间相邻关系),需要重新学习。
实际操作:
# 直接用 Stage 1 checkpoint 开始 AnyRes 的 Stage 2
python scripts/train.py \
--stage stage2 \
--resume_from_checkpoint path/to/stage1_checkpoint \
--anyres --anyres_max_tiles 4
使用方式
训练:添加 --anyres 标志。
# 原始单图模式(默认,LLaVA 1.0/1.5)
python scripts/train.py --data_path ./data/llava_instruct.json
# AnyRes 模式(LLaVA-NeXT 1.6)
python scripts/train.py --data_path ./data/llava_instruct.json --anyres --anyres_max_tiles 4
推理:由 checkpoint 中的 config.json 自动决定。用 AnyRes 训练的模型自动走 AnyRes 路径,反之走原始路径。无需额外参数。
局限与后续
均匀切分的一个问题是对某些图像浪费 token:
均匀切分 2×2: 理想切分:
┌──┬──┐ ┌──────────┐
│ │ │ │ target │
├──┼──┤ └──────────┘
│🦒│ │ 🦒
└──┴──┘ ┌──────────┐
│ target │
三个 tile 只有草地或天空 └──────────┘
更合理的做法是按内容显著性切分(显著性裁剪、目标检测引导、可学习采样器),只对信息密集区域使用高分辨率。这是从 AnyRes 继续向前的方向,已经有很多相关工作做了,但是我不是这个方向的,不知道以后会不会继续深入。
实战踩坑:从 NaN Loss 到数值稳定
AnyRes 的实现过程遇到了两个训练中暴露的问题,记录如下。
问题一:Connector 输出值域爆炸
现象:训练中频繁出现 loss=NaN,在 Connector 输出端检测到最大值达到 5700。
排查:Qwen2 的 token embedding 值域大约在 ±1 左右。Connector(2 层 MLP)的输出未经约束,在 Stage 1 以 lr=2e-3 训练后,权重被推到很大,视觉 embedding 的幅度达到 token embedding 的数千倍。进入 LLM 后:
$$Q \cdot K^T \approx 5700 \times 5700 / \sqrt{64} \approx 4 \times 10^6$$远超 FP16 的上限 65504,attention 计算直接溢出 → logits 含 Inf → softmax 输出 NaN → loss=NaN。
修复:在 Connector 尾部加 RMSNorm。MLP 的输出经 RMS 归一化后,每个 visual token 的特征向量 RMS≈1,与 LLM 的 token embedding 同量级。
之前: Linear → GELU → Linear → 输出 (可达 ±5700)
之后: Linear → GELU → Linear → RMSNorm → 输出 (RMS ≈ 1)
RMSNorm 是 Qwen2 内部使用的归一化层,不会引入额外的表示空间不匹配。旧 checkpoint 通过 strict=False 兼容——缺失的 norm.weight 自动用默认值(全 1 向量)。
问题二:序列长度不足导致全 -100 labels
现象:加了 RMSNorm 后 NaN 仍然间歇出现。诊断发现日志 [DIAG] All labels are -100 but loss is NaN/Inf。
排查:AnyRes 的 visual token 数量远超默认 max_seq_length=2048:
| max_tiles | grid 例 | visual tokens | 文本开销 | 所需最小 seq_len |
|---|---|---|---|---|
| 4 | 2×2 | 5 × 729 = 3645 | ~100 | ≥ 3800 |
| 9 | 3×3 | 10 × 729 = 7290 | ~100 | ≥ 7500 |
当条目的 visual token 占满 2048 长度后,system prompt 和 user 文本尚能保留一部分,但 assistant 回复被完全截断。截断后的 labels 数组中全部为 -100(忽略标记)。
HuggingFace 的 CrossEntropyLoss(reduction='mean') 在有效样本数为 0 时计算 0/0 = NaN。这不是模型数值问题,而是数据管线问题。
修复:
- 模型 forward 中增加防御:检测到 labels 全为 -100 时直接返回 loss=0,跳过本次梯度更新
train.py在启用 AnyRes 时自动调整 max_seq_length,最低保证(1+max_tiles)×729+256,默认至少 4096
关键认知
这两个问题本质上是同一个根因的两种表现形式:Visual Token 爆炸。从 729 到 3645(5×),不仅是显存开销增加,还会连锁触发:
visual token 5× 膨胀
├─→ Connector 输出幅度 5× 放大(梯度尺度变化)
├─→ 序列长度超出 max_seq_length → labels 截断
└─→ attention 计算量 25× 增长(平方关系)
AnyRes 不是"加个图像切分就行"。它改变了整个 pipeline 的数值特性,需要在归一化、序列管理、学习率三个层面同步适配。
关于 max_seq_length 的疑问–这两天脑子烧坏了一时间没反应过来,我chovy啊
Q:扩展 max_seq_length(如 2048→4096)需要重新训练模型吗?
A:不需要。Qwen2-0.5B 使用 RoPE 位置编码(rope_theta=10^6),原生预训练长度为 32K。2048→4096 只是使用了它已经见过的位置编码范围。Connector 是 per-token 映射,与序列长度无关。LoRA 学的是 token 间交互的低秩偏移,在不同序列长度下泛化良好。
只有当超过预训练长度 32K 时,才需要考虑 NTK-aware 缩放等方法外推 RoPE。
与 LLaVA-NeXT 1.6 的对照
| 维度 | LLaVA-NeXT 1.6 | NanoVLM AnyRes |
|---|---|---|
| Vision Encoder | SigLIP SO400M@384² | SigLIP SO400M@384²(一致) |
| Grid 配置 | 1×{2,3,4}, 2×2 等 | 1×1 ~ 3×3(9 种) |
| 缩略图 | ✓ | ✓ |
| 多尺度推理 | 多种 grid 配置分别推理 | 训练时单 grid,推理可切换 |
| LLM | Hermes-Yi-34B / Mixtral-8x7B | Qwen2-0.5B |
| max_tiles 默认 | 4 | 可配置(训练建议 4) |
参考资料
- LLaVA-NeXT — 官方博客
- LLaVA-NeXT 论文 — Improved Baselines with Visual Instruction Tuning