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) 计算两个指标:

  1. 利用率:$U = \frac{\text{缩放后面积}}{\text{目标面积}}$——原图能填充 grid 的多少空间?高利用率意味着不浪费 visual token。
  2. 有效 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 种):

gridtiles目标分辨率适合场景
1×11384×384小图、图标
1×22768×384横长图
2×12384×768竖长图
2×24768×768中等方图
1×331152×384超宽图
3×13384×1152超高图
2×361152×768大横图
3×26768×1152大竖图
3×391152×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 × colsbase_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 拆分并拼接。VisionEncoderConnector 本身无需任何改动——它们天然支持变长 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 的整数索引对 TensorList 都有效,所以用一个循环就同时覆盖了两种模式。

3. Datasetcollate_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 数量暴增:

GridSub-imagesVisual Tokens相对单图的倍数
1×11729
1×232,187
2×132,187
2×253,645
1×342,916
2×375,103
3×3107,29010×

对于 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_tilesgrid 例visual tokens文本开销所需最小 seq_len
42×25 × 729 = 3645~100≥ 3800
93×310 × 729 = 7290~100≥ 7500

当条目的 visual token 占满 2048 长度后,system prompt 和 user 文本尚能保留一部分,但 assistant 回复被完全截断。截断后的 labels 数组中全部为 -100(忽略标记)。

HuggingFace 的 CrossEntropyLoss(reduction='mean') 在有效样本数为 0 时计算 0/0 = NaN。这不是模型数值问题,而是数据管线问题

修复

  1. 模型 forward 中增加防御:检测到 labels 全为 -100 时直接返回 loss=0,跳过本次梯度更新
  2. 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.6NanoVLM AnyRes
Vision EncoderSigLIP SO400M@384²SigLIP SO400M@384²(一致)
Grid 配置1×{2,3,4}, 2×2 等1×1 ~ 3×3(9 种)
缩略图
多尺度推理多种 grid 配置分别推理训练时单 grid,推理可切换
LLMHermes-Yi-34B / Mixtral-8x7BQwen2-0.5B
max_tiles 默认4可配置(训练建议 4)

参考资料