前言
学习 UP 主 铁甲小宝之蜻蜓队长 的 Qwen2.5-VL 源码解读 视频,这篇文章主要是分析图像输入的其他预处理操作,包括 rescale、normalize 等操作还有核心的切分 patch,记录下个人学习笔记,仅供自己参考😄
1. 简述
Qwen2.5-VL 模型架构如下图所示:
模型细节可以查看对应的技术报告 Qwen2.5-VL Technical Report
2. 环境搭建及 demo 运行
在开始之前我们有必要配置下环境,Qwen2.5-VL 的环境可以通过 Qwen2.5-VL/README.md 文档来配置
博主这里准备了一个可以运行 demo 和调试的环境,大家可以按照这个环境来,也可以自己参考文档进行相关环境配置
博主的环境安装指令如下所示:
conda create -n qwen python=3.10
conda activate qwen
pip install transformers==4.51.3 accelerate
pip install qwen-vl-utils[decord]
pip install huggingface_hub[hf_xet]
pip install torch==2.7.0 torchvision==0.22.0 torchaudio==2.7.0 --index-url https://download.pytorch.org/whl/cu118
OK,环境准备好后我们就可以开始执行 demo,具体流程可以参照:https://github.com/QwenLM/Qwen2.5-VL/readme.md#using—transformers-to-chat
demo.py 具体实现代码如下:
from transformers import Qwen2_5_VLForConditionalGeneration, AutoProcessor
from qwen_vl_utils import process_vision_info
# default: Load the model on the available device(s)
model = Qwen2_5_VLForConditionalGeneration.from_pretrained(
"Qwen/Qwen2.5-VL-7B-Instruct", torch_dtype="auto", device_map="auto"
)
# We recommend enabling flash_attention_2 for better acceleration and memory saving, especially in multi-image and video scenarios.
# model = Qwen2_5_VLForConditionalGeneration.from_pretrained(
# "Qwen/Qwen2.5-VL-7B-Instruct",
# torch_dtype=torch.bfloat16,
# attn_implementation="flash_attention_2",
# device_map="auto",
# )
# default processor
processor = AutoProcessor.from_pretrained("Qwen/Qwen2.5-VL-7B-Instruct")
# The default range for the number of visual tokens per image in the model is 4-16384.
# You can set min_pixels and max_pixels according to your needs, such as a token range of 256-1280, to balance performance and cost.
# min_pixels = 256*28*28
# max_pixels = 1280*28*28
# processor = AutoProcessor.from_pretrained("Qwen/Qwen2.5-VL-7B-Instruct", min_pixels=min_pixels, max_pixels=max_pixels)
messages = [
{
"role": "user",
"content": [
{
"type": "image",
"image": "https://qianwen-res.oss-cn-beijing.aliyuncs.com/Qwen-VL/assets/demo.jpeg",
},
{"type": "text", "text": "Describe this image."},
],
}
]
# Preparation for inference
text = processor.apply_chat_template(
messages, tokenize=False, add_generation_prompt=True
)
image_inputs, video_inputs = process_vision_info(messages)
inputs = processor(
text=[text],
images=image_inputs,
videos=video_inputs,
padding=True,
return_tensors="pt",
)
inputs = inputs.to(model.device)
# Inference: Generation of the output
generated_ids = model.generate(**inputs, max_new_tokens=128)
generated_ids_trimmed = [
out_ids[len(in_ids) :] for in_ids, out_ids in zip(inputs.input_ids, generated_ids)
]
output_text = processor.batch_decode(
generated_ids_trimmed, skip_special_tokens=True, clean_up_tokenization_spaces=False
)
print(output_text)
执行如下指令即可进行推理运行:
conda activate qwen
python demo.py
Note:脚本会从 huggingface 上下载 Qwen2.5-VL-7B 的模型,大概十几个 G 大小,如果大家无法访问外网,可以先去 ModelScope 把模型下载到本地,然后指定本地路径就行
输出结果如下图所示:

demo.py 这个脚本所做的事情就是让 Qwen2.5-VL 读取一张图片后描述下该图片,读取的图片如下所示:

Qwen2.5-VL 给出的回答是:
The image depicts a serene beach scene during what appears to be either sunrise or sunset, as indicated by the warm, golden light illuminating the sky and casting long shadows on the sand. A woman is sitting on the sandy beach, wearing a plaid shirt and dark pants, with her legs crossed. She has long hair and is smiling warmly at a light-colored dog, possibly a Labrador Retriever, which is sitting in front of her. The dog is wearing a harness and is extending its paw towards the woman’s hand, suggesting a playful interaction between them. The ocean is visible in the background, with gentle waves rolling onto the shore
翻译成中文是:
这幅照片描绘的是宁静的海滩景象,当时正值日出或日落,温暖的金色光芒照亮天空,在沙滩上投下长长的影子,仿佛在诉说着这景象。一位身穿格子衬衫和深色裤子的女子坐在沙滩上,双腿交叉。她留着长发,正对着坐在她面前的一只浅色狗狗(可能是一只拉布拉多猎犬)露出温暖的笑容。这只狗戴着挽具,正向女子伸出爪子,暗示着它们之间正在嬉戏玩耍。背景中可以看到大海,轻柔的海浪拍打着海岸。
可以看到回答的还是比较准确的
OK,下面我们就来正式跟随 UP 主的步伐一起来阅读 Qwen2.5-VL 的源码,看看它具体是怎么做的
3. 图像预处理Qwen2VLImageProcessor
在上篇文章 Qwen2.5-VL源码解读-图片预处理process_vision_info 中,我们介绍了 Qwen2.5-VL 这个多模态大模型的图像预处理部分,输入图像后,它先经过了预处理这个部分(process_vision_info),把图像的宽高近似成 28 的倍数,然后整个分辨率 resize 到一个不大不小的程度,这就是一个合法的输入了
紧接着我们就要进入图像的 Qwen2VLImageProcessor 这个部分,其实这里面也有一部分预处理的内容,也就是上篇文章讲的 resize 部分这里面也做了一点,那 resize 部分我们就简要提一下,我们重点关注除 resize 部分额外的预处理操作。那我们还是跟之前一样,先把Qwen2VLImageProcessor 它完成的功能介绍一下,然后我们再到源代码里面去看看它具体是如何实现的
我们先说下 Qwen2.5-VL 的 ImageProcessor 其实是和 Qwen2-VL 是保持一致的,我们可以从模型配置文件 preprocessor_config.json 里面看到,如下图所示:

从这个文件里面可以看到 image_processor_type 和 Qwen2-VL 相同
Qwen2VLImageProcessor 的目的是把经过 resize 后(process_vision_info)的图像(高 1372、宽 2044)变成一个二维的像素值 pixel_value(shape = [14308, 1176]),然后再给到一个图像的栅格的时间、高、宽形状记录(image_grid_thw=[[1, 98, 146]])
我们可以看一下这个像素值 pixel_values 它的维度是 14308x1176,其中 14308 代表的是你把图像切分成 patch 以后,把它拉长一共有 14308 个 patch,然后每一个 patch 的维度是 1176,我们接下来可以进行一下数据验证看看为什么是 14308x1176
数据验证 14308==98*146, 98==1372/14, 146==2044/14。相当于将每个方格内(14x14)的像素变成一个 1176 维度的向量(1176==14*14*3*2)
上篇文章我们的图像经过 resize 之后的高度是 1372,宽度是 2044,而要切分的 patch 大小是 14x14,在高度这个维度上得到的 patch 数量是 1372/14=98,在宽度这个维度上得到的 patch 数量是 2044/14=146,也就是 image_grid_thw 记录的高有 98 个 patch,宽有 146 个 patch,然后时序是 1,因为是单张图片没有时间维度,所以总的 patch 数量就是 98*146=14308 个 patch,就对应了 shape 里的第一个维度 14308 个 patch
而每一个 patch 向量的长度是 1176,这又是怎么计算的呢?一个 patch 方格大小是 14*14,为什么后面还要 *3*2 呢?🤔
这是因为一个方格内它的通道数是 3,也就是一个像素包括 RGB 三个通道,所以一个方格的像素数就是 14*14*3。那 *2 的原因主要是为了和视频处理相统一,由于单张图像它在时间这个维度上是 1,它是没有时间维度的,而 Qwen 在处理视频的时候是把相邻的两帧给它组成一个 group,所以为了和视频处理保持一致,我们要将单张图片复制一份出来,相当于增加一个时间维度,所以这里要 *2
其实就是把一个方格内的像素(14*14*3)copy 了一份,最后总的像素数就是 14*14*3*2,得到 1176 维度的向量
OK,我们来整理下,Qwen2VLImageProcessor 整个功能就是把一张高是 1372,宽是 2044 的图片切分成 patch,然后把 patch 拉长,一共有 14308 个 patch,每个 patch 代表的向量维度是 1176,这就是它的主要功能,通过处理后就把一张图像拉成了一个二维的向量,这样我们就方便和后面的图像进行拼接
那在代码中是怎么完成上面这个操作的呢,首先 Qwen2VLImageProcessor 继承自 BaseImageProcessor,通过 __call__ 调用 preprocess 方法,实现的功能包括:
- (1) do_resize/do_rescale/do_normalize:根据配置决定是否要做这三个操作,分别表示调整图片大小/将像素值缩放到 0-1 之间(即乘上 1/255)/在每个 channel 上指定 mean 和 std 做 normalize
- 这边的 do_resize 其实在之前的
process_vision_info中就做过了,也就是这里的操作和之前是有点重复的
- 这边的 do_resize 其实在之前的
- (2) 把每张图片复制到
temporal_patch_size次(默认为 2)。这是为了在 image 数据上也增加 T 这个维度,保证 image 和 video 的处理逻辑一致(因为 video 也是把相邻的 2 帧组成一组) - (3) 切分 patch,这里 patch 不是按照一张图从左到右,从上到下的顺序排列的,而是按照 2 * 2 区域内的 4 个 patch 变成连续的 4 个 path 排列的。(可验证)
我们一个个来详细讲下,首先第一步中实现的三次变形,其中 do_resize 我们在上篇文章介绍过了,这里就不再介绍了,我们对后面两次变形方式着重介绍下:
图像缩放(rescale)
- 关键参数
rescale_factor=1/255:图像像素值一般是0~255的整数(比如 RGB 图像),scale=1/255会让像素值被缩放为0.0~1.0的浮点数(等价于 像素值除以 255)。这样做的目的是让数值更贴近模型训练要求(多数深度学习模型喜欢较小的输入范围,比如[0,1]或[-1,1])
图像归一化(normalize)
归一化后像素值 = (原像素值 - 均值 mean) / 标准差 std
这样一个操作是对 RGB 三个通道分别进行的,因此需要提供三组均值和标准差,我们在 Qwen2.5-VL 的配置文件 preprocessor_config.json 中可以知道对应的数值,如下所示:
{
"image_mean": [
0.48145466,
0.4578275,
0.40821073
],
"image_std": [
0.26862954,
0.26130258,
0.27577711
],
}
这样做可以让图像数据分布更稳定(比如让均值接近 0、标准差接近 1),帮助模型更快收敛、避免梯度异常。
Note:image_mean 和 image_std(图像均值和标准差)是通过对 训练数据集 的所有图像像素值进行统计计算得到的,目的是让预处理符合数据的真实分布特性
这三个操作(do_resize/do_rescale/do_normalize)做完之后,相当于第一步这个部分完成了,我们让这个图片更加的合法,更加的稳定,符合我们训练的时候的数据格式
第二步其实也可以归结为一个预处理的操作,针对第一步处理完的图片还需要整体复制一份,补上时间维度,和视频统一格式,在时间维度上合并
我们把第二步做完之后就可以把它切分 patch,切分的操作比较复杂,我们待会到代码里面再来详细看它的逻辑,但要注意的是,这里的 patch 并不是按照一张图切分了以后从左到右从上到下的顺序排列,而是把它按照 2x2 区域内的四个 patch 变成连续的四个 patch 进行排列的
这个怎么来理解呢,这边 UP 主画了一些图来方便大家去理解,我们一起来看下:

首先经过预处理后的图片高度是 1372、宽度是 2044,通道数是 3,然后我们需要把这张图片在时间维度上复制一次,然后得到一个高度是 1372,宽度是 2044,通道数是 3, 时间维度是 2 的这样一个向量,如上图所示

紧接着我们就要对它切分 patch 了,也就是上面提到的把方格内 14x14 区域的像素拉成一个 1176 维度的向量,怎么来的呢,首先它自己在通道数下有 3 个 patch,然后在时间维度上也有 3 个,所以就是 2*3 个 patch,然后乘以 patch 大小 14*14,就是它 patch 的像素数总共是 2*3*14*14=1176,我们把这六个 patch 给它压缩成一个一维向量,向量大小是刚刚计算的 1176,它就是一个 patch 所对应得到的向量,如上图所示

紧接着我们把所有的 patch 都按照这样的操作,给它排列起来,排列成一个 14308 长度的 patch 向量,那这个 14308 我们上面刚介绍过了,就是图片的高度是 1372,那么高度的 patch 数量是 1372/14=98,就是高度方向可以分 98 个 patch,而图片的宽度是 2044,宽度的 patch 数量是 2044/14=146,宽度方向可以分 146 个 patch,所以长乘宽一共就是 14308 个 patch,如上图所示
那我们上面所说的 patch 不是按照一张图从左到右,从上到下的顺序排列的,而是按照一个区域内变成连续的四个 patch 排列是什么意思呢?🤔
那这其实和我们上图的二维展平相关,一个 [98, 146, 1176] 维度的 tensor 展平成 [14308, 1176] 维度时,理论上像矩阵展平一样按照行来展开,如下图所示:

也就是我们按行的方式,先把第一行的 146 个 patch 向量依次排列,然后接着是第二行、第三行、…,以此类推,直至把 98 行的 patch 排列完成,组成一个 [14308, 1176] 的二维 tensor
而实际上并不是我们猜测的这样排列的,而是按照一个个 2x2 的方块这样排列的,如下图所示:

首先排列的是第一个 2x2 方块的 patch,分别是 patch[0][0]、patch[0][1]、patch[1][0]、patch[1][1],紧接着是第二个 2x2 方块的 patch,分别是 patch[0][2]、patch[0][3]、patch[1][2]、patch[1][3],然后是第三个 2x2 方块、第四个 2x2 方块,…,以此类推,直至所有 patch 排列完成
后面我们会给大家理论验证一下,那么为什么要这么展平呢,其实是为了后续方便再进行 shape 的改变,然后进行 window attention 窗口注意力这个计算,window attention 的话我们会在后面的课程进行介绍
大家只需要知道此时的 patch 排列不是按照行展开的,而是按照上述的 2x2 patch 块展开的就行,展开以后最终就得到了一个 14308*1176 的向量,就可以交给我们所说的 Vision Encoder 这个图像的大模型里进行训练了
所以我们的 Qwen2VLImageProcessor 是训练之前的一个步骤,它的功能我们上面也给大家介绍清楚了,下面我们到代码里面去看一下
4. Qwen2VLImageProcessor源码分析
我们调试的代码是第 2 小节提到的 demo.py 文件,调试的工具是 vscode,上篇文章我们讲完了 process_vision_info 预处理函数,得到了一个合法的图像输入:

我们接着看下面的 processor 函数,我们先看一下它的输入输出有一个整体的认识。我们先看一下进入到 processor 之前的是一个什么东西,首先是 text 也就是文本的输入,这里我们就不过多介绍文本相关的数据流了,因为我们主要介绍的是和图片相关的,我们就重点看一下图片的数据流,而文本和视频的数据流大家可以自己看看
我们可以看到图片的输入是 image_inputs,它是一个高度为 1372,宽度为 2044 的图片,而视频输入 video_inputs 为空
看完输入之后我们再接着看下这个函数的输出是什么:

输出 inputs 里面有很多内容,其中 input_id 就是把文本转换成对应的 id,attention_mask 是注意力计算时的掩码,pixel_values 就是我们上面提到的图像经过处理后得到的像素值,它是一个二维向量。维度大小是 [14308, 1176] 和我们前面分析的一样
image_grid_thw 用来记录这个图像的 patch 形状,它的 shape 是 [1, 98, 146],也就是高度上有 98 个 patch,宽度上有 146 个 patch,可以用于后续的一些计算
OK,我们把输入输出搞明白之后就到函数里面去看一下:

首先我们进去之后发现调用的是 Qwen2_5_VLProcessor 这个类的 __call__ 方法,它也是调用了 self.image_processor 这个函数,我们往下走:

进来之后发现它调用的是 transformers 库中 BaseImageProcessor 这个类的 __call__ 方法,返回的是 self.preprocess 这个函数,我们继续往下走:

紧接着我们就进入到了 Qwen2VLImageProcessor 这个类的 preprocess 函数,我们往下走可以发现这个方法里面都是做了一些参数的调整和设置,核心的是下面的 self.preprocess 函数:

self.preprocess 函数就是我们要关注的核心函数,self.preprocess 输出的东西就是这张图片的 pixel_values 和 image_grid_thw,所以我们来看一下这个函数,看每传入一张图像在这个函数里面会做什么样的操作:

首先我们会把输入的 images 变成一个列表方便后续计算,然后转成 RGB 色彩,接着把它变成 numpy 的数组格式,然后进行之前提到的那些预处理操作:

注意这里的 do_resize/do_rescale/do_normalize 三个操作都是打开的,首先通过上篇文章提到的 smart_resize 函数,把它变成一个长宽都能被 28 整除的,然后整体分辨率不大不小的一个图片
紧接着做了一个 do_rescale 操作,也就是把原本 0~255 的像素值给它缩放到 0~1 之间,然后再进行一个 do_normalize 归一化操作,在 RGB 三个通道数减去对应通道的均值并除以对应通道的标准差
最后做了一个通道的变换,将之前的 h,w,c 通道交换变成 c,h,w,方便后续切分 patch,我们可以看到到目前为止这张图片变成了一个 numpy 的数组,其 shape 大小为 (3, 1372, 2044)

接着我们做预处理中的第二步,把每张图片沿时间维度复制 temporal_patch_size 次,最终得到的 patches 就是需要切分前的 tensor,其维度是 (2, 3, 1372, 2044),其中 2 是时间维度,3 是通道维度,1372 是高度维度,2044 是宽度维度
下面我们就要做切分 patch 操作了:

那这一系列操作比较复杂,它先 reshape 成了一个 9 个维度的向量,然后做了一个通道数调整之后再进行了一次 reshape,也就是不断的图像变换来得到这样一个结果,具体给大家讲 reshape 操作也不太现实,我们直接给它验证下正确性,首先我们可以看到它最终的输出 flatten_patches 的维度是 (14308, 1176) 和我们之前分析的结果一样
接下来就是看怎么来验证它的正确性呢🤔
我们只想验证这么一件事:flatten_patches 的 空间 patch 的排列顺序 到底是:
- 按 行序(先一整行
146个 patch,再下一行…) - 还是按 2x2 小块顺序(每次先吐出一个 2x2 小块里的 4 个 patch,然后移动到下一个小块)
我们的方法是给每个 空间 patch(14x14 的区域)一个 唯一的整数 ID(按行从左到右、从上到下,行序编号 pid(i, j) = i * grid_w + j),然后把这个 patch 覆盖的 所有像素(跨越所有时间 T=2 帧、所有 C=3 通道、14x14 像素)都涂成同一个 ID
这样,无论有多少通道、多少时间帧、以及内部 14x14 的像素,该 patch 展平后的 1176 维向量里的每个元素都等于 这个 ID。随后,我们只需要跑上面这段 reshape->transpose->reshape 代码,看 flattent_patches 每一行(每个 patch 向量) 里任取一个元素(比如第 0 个元素),它的值就等于 “该行到底是哪个空间 patch”。这样一眼就能看出到底是行序展开,还是按 2x2 小块顺序展开。
博主让 ChatGPT 帮忙写了这么一个验证脚本,代码如下:
import torch
# ---- 你的设定 ----
T = 2 # 时间维(= temporal_patch_size)
C = 3 # 通道数
H, W = 1372, 2044 # 高宽
patch_size = 14
merge_size = 2 # 2x2 分块
temporal_patch_size = T # 与你的变量名一致
# ---- 派生网格大小 ----
grid_h = H // patch_size # 98
grid_w = W // patch_size # 146
grid_t = 1 # 因为 T // temporal_patch_size = 1
# 1) 构造原始 patches: [T, C, H, W]
patches = torch.zeros((T, C, H, W), dtype=torch.int64)
# 2) 给每个空间 patch 赋唯一 ID(行序 i*grid_w + j)
for i in range(grid_h):
for j in range(grid_w):
pid = i * grid_w + j
hs, he = i * patch_size, (i + 1) * patch_size
ws, we = j * patch_size, (j + 1) * patch_size
patches[:, :, hs:he, ws:we] = pid
# 保存一份原图素(若你想做进一步比对)
patches_original = patches.clone()
# 3) 按你的流程 reshape → permute → reshape
channel = patches.shape[1]
assert patches.shape == (T, C, H, W)
patches = patches.reshape(
grid_t, # 1
temporal_patch_size, # 2
channel, # 3
grid_h // merge_size, # 98//2 = 49
merge_size, # 2
patch_size, # 14
grid_w // merge_size, # 146//2 = 73
merge_size, # 2
patch_size, # 14
)
# 关键修正:用 permute,而不是 transpose
patches = patches.permute(0, 3, 6, 4, 7, 2, 1, 5, 8)
flatten_patches = patches.reshape(
grid_t * grid_h * grid_w, # 1*98*146 = 14308
channel * temporal_patch_size * patch_size * patch_size # 3*2*14*14 = 1176
)
# 4) 读取每个展平向量的第一个元素(整行都一样)
ids = flatten_patches[:, 0].tolist()
print("flatten_patches 形状:", tuple(flatten_patches.shape))
print("前12个 patch 的空间ID:", ids[:12])
# 5) 关键断言:第3个(index=2)应是 p[1][0] 的 ID = grid_w
print("grid_w =", grid_w, " flatten_patches[2] 的ID =", ids[2])
assert ids[2] == grid_w, f"期望 {grid_w}, 实际 {ids[2]},说明并非 2x2 分块顺序"
# 6) 进一步打印前两个 2x2 分块的 ID 模式,方便肉眼检查
# 第一个 2x2 方块应为: [0, 1, grid_w, grid_w+1]
# 第二个 2x2 方块应为: [2, 3, grid_w+2, grid_w+3]
first_block = ids[0:4]
second_block = ids[4:8]
print("第一个 2x2 方块的ID:", first_block)
print("第二个 2x2 方块的ID:", second_block)
执行后输出如下图所示:

从输出可以看出 flatten_patches 是按照 2x2 方块顺序展开的,因为如果是行序展开的话,对应的 ID 应该是 [0, 1, 2, 3, ...],而我们拿到的是 [0, 1, 146, 147, 2, 3, 148, 149, ...],其中 [0, 1, 146, 147] 恰好对应 2x2 方块左上角的四个位置,也就是第 3 小节绘制的二维展平的示意图的 patch[0][0]、patch[0][1]、patch[1][0]、patch[1][1]
这样的话,Qwen2VLImageProcessor 这个部分我们就讲完了,我们拿到 pixel_values 就可以送入到 Vision Encoder 中进行训练了,所以后面我们会讲一下 ViT 中的一些层,然后着重讲一下 windows attention 窗口注意力是怎么计算的
OK,以上就是本篇文章的全部内容了
结语
这篇文章我们继续跟随 UP 主学习了 Qwen2.5-VL 中图像流的一些预处理操作,那其实和视觉模型(例如 YOLO)的图像预处理大同小异,都包括 resize、/255.0、减均值除标准差等等操作
不过这里我们重点学习了切分 patch 的操作,如何把一个 4 维的 tensor 展平成 2 维向量。展平时并不是按照行序展开的,而是按照 2x2 小块顺序展开,最后我们调试的对应预处理源码并进行了一些测试验证
整个讲解非常通俗易懂,大家感兴趣的可以多关注关注,多看看 UP 的视频
下篇文章我们将来学习 Vision Encoder 中的窗口注意力,敬请期待🤗

4073

被折叠的 条评论
为什么被折叠?



