ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

图解 Hulu-Med 多模态 Projector 与多图像交错输入 Processor:从源码看懂视觉如何变成文本

图解 Hulu-Med 多模态 Projector 与多图像交错输入 Processor:从源码看懂视觉如何变成文本

【免费下载链接】Hulu-Med

A Transparent Generalist Model towards Holistic Medical Vision-Language Understanding

项目地址:https://gitcode.com/gh_mirrors/hu/Hulu-Med
点击查看免费下载

Hulu-Med 是一个统一的医疗多模态大模型,同时理解医学文本、2D 图像、3D 体数据与视频。本文通过源码解读,重点讲解 Hulu-Med 中多模态 Projector(视觉投影器)与多图像交错输入 Processor(处理器)的实现原理,带你快速看懂"图像是怎么变成模型能读懂的视觉 token"这条关键链路。

上图展示了 Hulu-Med 的整体能力版图:14 种医学成像模态、12 大解剖系统、从报告生成到手术阶段识别的下游任务。无论输入是 X-Ray、CT 还是内镜视频,最终都会汇入同一套"视觉 token 化"管线——这正是 Projector 与 Processor 两个组件的舞台。

一、先建立全局认知:视觉 token 化管线

多模态大模型处理一张图,通常走三步:

步骤组件职责源码位置
1️⃣ 特征提取Vision Encoder(视觉编码器)把像素切成 patch,输出特征向量序列encoder.py
2️⃣ 空间对齐Projector(投影器)把视觉特征"翻译"成 LLM 隐层维度的嵌入projector.py
3️⃣ 序列组装Processor(处理器)把<image>占位 token 展开、与图像特征按位置对齐processor.py

可以把它想象成"翻译流水线":视觉编码器产出的是"外语"特征,Projector 负责翻译成 LLM 的"母语",而 Processor 负责决定这些翻译好的"词"应该插在句子的哪个位置。Hulu-Med 的巧妙之处,正在于后两步如何优雅地支持任意顺序的多图像交错输入。

二、Projector 实现原理:四种可插拔的"翻译器"

工厂模式:一行配置决定投影器类型

构建入口是工厂函数 build_vision_projector:

projector_type = config.mm_projector_type # 缺省为 "linear"

它根据mm_projector_type配置项返回四种实现之一,全部在 projector.py 中定义:

  • linear(默认):单个全连接层,直接把视觉特征维度映射到 LLM 的hidden_size,最轻量;
  • mlpNx_gelu:由 MlpGeluProjector 实现,用正则解析mlp2x_gelu这类字符串得到层数 N,构建Linear → GELU → Linear堆叠,表达能力更强;
  • simp_spatial_conv:由 SimSpatialConv 实现,先用2×2卷积把 patch 网格做空间下采样(顺带把特征维度变为 4 倍),再用 MLP 读出到 LLM 维度,在压缩 token 数量的同时保留局部空间关系;
  • identity:IdentityMap 恒等映射,主要用于调试或对齐实验。

每种投影器都实现了cal_proj_size方法:输入网格尺寸,返回投影后输出的 token 数量。这个"输入尺寸→输出 token 数"的契约,是 Processor 后续展开<image>占位符的关键依据。

一个值得注意的设计:按帧独立投影

源码中有一行注释非常关键(projector.py#L146):

# hulumed projector only support image-wise operation now, # i.e., prohibit the temporal aggregation

即 Projector只对单帧图像做逐帧处理,禁止跨帧的时间聚合。这看似"保守",实则让同一套投影器可以无缝复用于三种场景:单张 2D 图、视频的每一帧、以及 3D CT 的每一层切片——Hulu-Med 正是把视频与 3D 体数据统一建模为"沿时间/空间维度的多帧序列",再交由 LLM 的注意力机制去理解帧间关系。

权重加载:独立的 mm_projector.bin

投影器权重单独存放于模型目录的mm_projector.bin中,加载逻辑见 load_mm_projector:优先查找本地路径,否则解析 Hugging Face 缓存目录(parse_snapshot_folder),必要时触发snapshot_download在线下载,并统一转为 float16。训练侧则由 initialize_vision_modules 负责装配:记录mm_projector_type、视觉隐层尺寸mm_hidden_size,并支持通过pretrain_mm_projector参数热加载已有投影器权重。

三、Processor 实现原理:多图像交错输入是怎么处理的

核心流程:一次调用的分工

Processor 主类是 HulumedProcessor(源自 Qwen2VLProcessor 改造),一次processor(...)调用内部只干两件事:

process_images(images) → pixel_values + grid_sizes + merge_sizes process_text(text, image_inputs) → input_ids(<image> 已按位置展开) 最后合并为 BatchFeature 返回

关键在于process_text(processor.py#L233-L258)拿到图像侧输出的grid_sizes后,做了一个**"帧展开"**:

  • 对单帧图像(grid_size[0] == 1):token 数 = 网格 H×W ÷ merge_size²,追加一份;
  • 对多帧输入(grid_size[0] > 1,即视频或 3D 体):把同一网格尺寸复制 t 份,grid_sizes.extend([grid_size[1:] / merge_size] * grid_size[0])。

这样 Processor 内部始终只需关心"一帧一份",视频和 3D 只是"帧数更多"的特例。

占位符替换:把 1 个<image>变成 N 个

在推理路径_process_text_without_label(processor.py#L211-L231)中:

while DEFAULT_IMAGE_TOKEN in text[i]: thw = grid_sizes[image_idx] text[i] = text[i].replace(DEFAULT_IMAGE_TOKEN, "<placeholder>" * thw.prod(), 1) image_idx += 1

每遇到一个<image>(定义于 constants.py),就按当前图像网格尺寸替换成H×W个占位符,再统一还原为<image>token 后交给 tokenizer 编码。末尾的断言len(grid_sizes) == image_idx保证文本中的图像占位符数量与真实图像数量严格一致,错位会在预处理阶段就暴露出来,而不是在模型深处报出难以排查的错误。

交错输入为何"零成本"支持?

README 中的 Interleaved 示例里,用户消息按文本 A → 图1 → 文本 B → 图2 → 问题的顺序组织。之所以 Hulu-Med 天然支持这种交错结构,靠的是两个机制的配合:

  1. Chat 模板按 content 顺序逐项展开:DEFAULT_CHAT_TEMPLATE(processor.py#L35-L86)遍历 message 的content列表,每个image项恰好输出一个<image>占位符,文本项原样输出——图文相对顺序在文本层被完整保留;
  2. 占位符按出现顺序消费 grid:替换循环中image_idx随占位符递增,第 k 个<image>严格对应第 k 张图的grid_sizes[k]。

最终 token 序列形如:

<|im_start|>user\nImage A: <image>×N1 Image B: <image>×N2 Which image...<|im_end|>

图 1 的 N1 个视觉 token 出现在 "Image A:" 之后,图 2 的 N2 个出现在 "Image B:" 之后——位置信息内嵌在序列中,模型无需任何额外的"第几张图"标注。

训练路径的额外细节

训练用的_process_text_with_label(processor.py#L145-L209)除同样展开占位符外,还为 assistant 回复生成 loss labels,并对stream(流式帧)角色做样本均衡采样,这些与推理路径共享同一套 grid 展开逻辑,保证了训练与推理时视觉 token 布局的一致性。

四、模型侧对齐:视觉 token 如何"落位"

Processor 交付的input_ids里,每个<image>token 都有一个固定 id(image_token_index)。前向时 prepare_inputs_labels_for_multimodal 完成最后的"落位":

mm_features = encode_images(pixel_values, grid_sizes, merge_sizes) # 编码器→Projector inputs_embeds = embed_tokens(input_ids) # 文本 embedding inputs_embeds[image_selected] = mm_features # 逐位替换
  • token 数量天然吻合:batched_num_patches = grid_sizes.prod(1) / merge_sizes²,正好等于 Processor 展开出的<image>个数;
  • modals参数(image/video/text等,见 hulumed_qwen2.py)让模型区分输入类型,_get_valid_visual_tokens据此过滤纯文本样本的"特征";
  • 替换采用"先取文本 embedding 再覆写视觉位"的方式,交错序列中文字与视觉 token 各就各位。

五、两个进阶优化

视频 token 压缩:跳帧不动的帧

对长视频,逐帧全量编码代价很高。_get_compression_mask 会计算相邻帧特征的平均像素差,差值低于阈值(threshold=0.1)的"静帧" token 被直接剔除,并同步从input_ids、attention_mask、position_ids、labels中删除对应占位(_compress_visual_tokens)——在不损失动态信息的前提下显著缩短序列长度。

3D CT 的统一帧建模

3D 体数据被按指定轴切成切片序列后,以merge_size=2的"视频"方式送入(每 2×2 个 patch 合并为一个 token),与视频共享同一套编码与压缩逻辑。这也是为什么 Projector 坚持逐帧处理却能同时服务 2D / 3D / 视频三类输入的设计前提。

六、核心文件导航

想继续深入源码,建议按以下路径阅读:

  • 投影器实现:src/hulumed/model/projector.py
  • 处理器与 chat 模板:src/hulumed/model/processor.py
  • 特征对齐与 token 压缩:src/hulumed/model/hulumed_arch.py
  • 视觉编码器构建(CLIP / SigLIP / Navit 自研编码器):src/hulumed/model/encoder.py
  • 图像预处理(smart resize 与网格划分):image_processing_hulumed.py
  • 视觉编码器模型:modeling_hulumed_encoder.py
  • 多模态常量与 token 定义:src/hulumed/constants.py
  • Qwen2 / Qwen3 两版实现(结构一致):src/hulumed_qwen3/model/
  • 更多使用示例(多图像、交错输入、3D、视频):README.md

总结

回到开头的问题——Hulu-Med 的 Projector 与 Processor 到底做了什么?

  1. Projector是可插拔的"视觉→语言"翻译器:一个工厂函数、四种实现(linear / MLP / 空间卷积 / identity),逐帧投影的设计让它通吃 2D、3D 与视频;
  2. Processor是"位置规划师":chat 模板按内容顺序输出占位符,grid 尺寸展开决定每个占位符的长度,占位符数量断言保证图文严格对齐;
  3. 模型侧再把视觉特征逐 token 覆写进文本 embedding,多图像交错输入因此在序列层面天然成立,视频还能额外享受帧间压缩带来的加速。

理解了这条"编码器 → 投影器 → 占位符展开 → 逐位替换"的完整链路,你就掌握了 Hulu-Med 多模态能力的核心骨架,也能快速读懂 Qwen-VL 系同类模型的实现思路。

【免费下载链接】Hulu-Med

A Transparent Generalist Model towards Holistic Medical Vision-Language Understanding

项目地址:https://gitcode.com/gh_mirrors/hu/Hulu-Med
点击查看免费下载

相关推荐

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进