【免费下载链接】Hulu-Med
A Transparent Generalist Model towards Holistic Medical Vision-Language Understanding
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 天然支持这种交错结构,靠的是两个机制的配合:
- Chat 模板按 content 顺序逐项展开:
DEFAULT_CHAT_TEMPLATE(processor.py#L35-L86)遍历 message 的content列表,每个image项恰好输出一个<image>占位符,文本项原样输出——图文相对顺序在文本层被完整保留; - 占位符按出现顺序消费 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 到底做了什么?
- Projector是可插拔的"视觉→语言"翻译器:一个工厂函数、四种实现(linear / MLP / 空间卷积 / identity),逐帧投影的设计让它通吃 2D、3D 与视频;
- Processor是"位置规划师":chat 模板按内容顺序输出占位符,grid 尺寸展开决定每个占位符的长度,占位符数量断言保证图文严格对齐;
- 模型侧再把视觉特征逐 token 覆写进文本 embedding,多图像交错输入因此在序列层面天然成立,视频还能额外享受帧间压缩带来的加速。
理解了这条"编码器 → 投影器 → 占位符展开 → 逐位替换"的完整链路,你就掌握了 Hulu-Med 多模态能力的核心骨架,也能快速读懂 Qwen-VL 系同类模型的实现思路。
【免费下载链接】Hulu-Med
A Transparent Generalist Model towards Holistic Medical Vision-Language Understanding
相关推荐
一文读懂Hulu-Med:统一医学文本、2D/3D影像与视频的开源视觉语言模型全景解析
一文读懂Hulu Med:统一医学文本、2D/3D影像与视频的开源视觉语言模型全景解析 Hulu Med 是由 ZJU AI4H 团队推出的完全开源的 医学视觉
rembg 一键抠图指南:3 分钟装好图片背景去除工具,附模型选型与集成示例
rembg 一键抠图指南:3 分钟装好图片背景去除工具,附模型选型与集成示例 还在手动抠图吗?电商做图、自媒体换背景,一张一张磨边缘太磨人。rembg 是一个基
人工智能计算机视觉图像处理AI 应用AutoCoder多模态代码生成探索:结合文本与图像输入
AutoCoder多模态代码生成探索:结合文本与图像输入 你是否遇到过这样的困境:想让AI生成代码却苦于无法清晰描述视觉需求?AutoCoder作为超越GPT
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考