1. 总览
Florence2 是微软于 2024 年 6 月推出的专用于视觉任务的小体积语言模型,large 版 0.77B 大小,适用于目标检测、对象分割、提取文字等图片模态任务。只支持英文,对预训练范围外的任务效果差。
本文记录了调试 microsoft/Florence-2-large-ft 模型时的一些发现,包括如何使用模型、视觉编码 DaViT、模型结构等内容。
总之 Florence2 是个很有价值的研究,基于 Encoder-Decoder 的 transformer 模型,开放权重、示例完备,直接用和微调都挺好的。代码写得稍微不太考究(搞错了 _decoder_start_token_tensor
实属不应该,好在不大影响推理效果),好在够用,能出效果。
2. 改善极其缓慢的加载
由于未知原因,使用官方 HuggingFace 页面上提供的模型加载代码运行极其缓慢。
已经有人发出 issue,直到现在(20250702)仍未得到解决。好在 issue 里给出了另一种加载方式,速度快得多。需要提前下载模型仓库,并且由于 transformers 库没有内置 Florence_2,需要手动下载仓库中的 py 文件创建一个文件夹。
python
from pathlib import Path
from Florence_2.configuration_florence2 import Florence2Config
from Florence_2.modeling_florence2 import Florence2ForConditionalGeneration
import torch
from transformers import AutoProcessor
from PIL import Image
checkpoint = Path("/media/dolen/red_3t/models/Florence-2-large")
config_path = checkpoint / "config.json"
pretrained_model_path = checkpoint / "pytorch_model.bin"
florence2_config = Florence2Config.from_pretrained(config_path)
model = Florence2ForConditionalGeneration(florence2_config)
model.load_state_dict(torch.load(pretrained_model_path, map_location="cpu"))
model = model.to("cuda")
model = model.to(torch.float16)
processor = AutoProcessor.from_pretrained(checkpoint, trust_remote_code=True)
3. Florence2 适用的任务
大模型的一大好处是,只需要修改 prompt 就可以让模型适应不同的任务目标。Florence2 预训练了图像描述、OCR、目标检测等任务,只需要将任务标识输入到 processor 的 text 中去就可以指定对应任务。
任务标识会像是 <CAPTION>
<OD>
。实际输入到模型的文字不是标识本身,而是会在 processor 里预先转换为任务描述 。例如 <CAPTION>
对应 "What does the image describe?" 这句话。稍微有点脱裤子放屁,但毕竟是小模型没有那样强大的泛化能力,限定提示词保证性能倒也说得过去。标识与任务描述的对应字典存储在 processor 的 self.task_prompts_without_inputs
和 self.task_prompts_with_input
中。
图片描述:
<CAPTION>
,描述图片,会输出一段纯文字的描述'What does the image describe?'
<DETAILED_CAPTION>
,同<CAPTION>
,但描述更细致'Describe in detail what is shown in the image.'
<MORE_DETAILED_CAPTION>
,同<CAPTION>
,但会用一大段文本去描述图片'Describe with a paragraph what is shown in the image.'
目标检测(绘制方框):
<OD>
,目标检测,会输出所有能识别的物体,不能指定要检测目标'Locate the objects with category name in the image.'
<DENSE_REGION_CAPTION>
,类似<OD>
,但给出的物体标签更具体'Locate the objects in the image, with their descriptions.'
<REGION_PROPOSAL>
,定位物体,但不给出 label(label 为空)'Locate the region proposals in the image.'
<CAPTION_TO_PHRASE_GROUNDING>
,模型根据一句话中所描述的物体(可以不止一个),找出图中目标"Locate the phrases in the caption: {input}"
语义分割:
<REFERRING_EXPRESSION_SEGMENTATION>
,输出给定描述的物体的多边形遮罩(polygon mask)'Locate {input} in the image with mask'
<REGION_TO_SEGMENTATION>
,输出给定区域的多边形遮罩。区域需要使用 4 个<loc123>
特殊 token 指示 x1 y1 x2 y2'What is the polygon mask of region {input}'
指定区域描述:
<REGION_TO_CATEGORY>
,输出给定区域的物体类别。区域需要使用 4 个<loc123>
特殊 token 指示 x1 y1 x2 y2'What is the region {input}?'
<REGION_TO_DESCRIPTION>
,输出给定区域的文字描述。区域需要使用 4 个<loc123>
特殊 token 指示 x1 y1 x2 y2'What does the region {input} describe?'
OCR 文字识别:
<OCR>
,输出能识别的所有文字'What is the text in the image?'
<OCR_WITH_REGION>
,类似<OD>
会输出框和 label,而 label 内容会换为对应的文本内容'What is the text in the image, with regions?'
3.1. 任务:目标检测
使用 <OD>
标识,可以让模型进行目标检测。
Florence2 的 toekenizer 在 BartTokenizer
的基础上添加了一系列指示图像位置的特殊 token,像是 <loc_162>
<loc_11>
这种形式,从 0 到 999 总共一千个。推理出来的文本会是这个形式:
html
</s><s>car<loc_52><loc_334><loc_932><loc_774></s>
四个 <loc_xxx>
分别代表 x1 y1 x2 y2,即非常经典的标注格式:方框的左上角坐标、方框的右下角坐标。
模型输出的坐标无视图像原分辨率,长宽都统一归一化到 [0, 999],视原图为 1000×1000 的网格。具体来说,<loc_0><loc_0>
含义是第一格(最左上角那个网格)的中点,<loc_999><loc_999>
是最后一格(最右下角那个网格)的中点。
注意,processor.post_process_generation()
不仅能将模型输出处理为真实的标注框坐标,还会强制 label 为 ascii,即检测 bbox 模式下只能是英文 label,label 不能有中文。
python
phrase = phrase.encode('ascii',errors='ignore').decode('ascii')
只有这里会限死英文。其他任务,例如 OCR 就没这样的限制。挺奇怪的。
3.2. 任务:语义分割
使用 <REFERRING_EXPRESSION_SEGMENTATION>
标识,可以让模型进行语义分割。
语义分割用到的特殊 token 与目标检测用到的一样,都是 <loc_162>
<loc_11>
这种形式。推理出来的文本会是这个形式(太长了中间省略一部分):
html
'</s><s><loc_268><loc_383><loc_282><loc_375><loc_290><loc_370>···</s>'
每两个位置 token 代表一个点坐标,围出一个多边形区域。转换为数字并缩放坐标后是可以用 PIL.Image.polygon()
绘制出来的。
4. 推理过程
4.1. 输入预处理
模型的 processor 包含 image_processor(CLIPImageProcessor
)和 tokenizer(BartTokenizerFast
),复用的 transformers 库里的类。分别处理文本和图片
输入的像是 <CAPTION>
这种任务标识会先转换为任务文字描述再传给 tokenizer。注意 processor 要求输入的 text 必须是预制的任务标识之一,否则会报错。
图片不论原长宽比如何都会会强制 resize 到 768×768(虽说图像编码器是支持任意分辨率的)。图片数值从 [0, 255] 缩放到 [0, 1],之后使用 mean=[0.485, 0.456, 0.406] 和 std=[0.229, 0.224, 0.225] 进行归一化,
如此经过 tokenizer 和 image_processor 处理后,得到 input_ids 和 pixel_values。接下来就把两者输入到模型中去。
4.2. 模型推理:视觉模型
为了方便表达,本节结合伪代码描述模型。其中涉及的具体数值只对第一个层有效,请注意。
模型对象 Florence2ForConditionalGeneration
包含图像编码器 self.vision_tower
,是 DaViT
实例,编写在自己的脚本中而没集成在 timm 或者 transformers 库中。经过 self.vision_tower
处理后获得维度为 [batch, 577, 2048] 的 tensor。
4.2.1. DaViT
DaViT 提出于论文 DaViT: Dual Attention Vision Transformers (2022),微软家的。大致思路是在空间和通道分别施加注意力机制,形成双注意力。
DaViT 由一系列 ConvEmbed
实例 self.convs
和 attention 层 self.blocks
交错构成。传入 [b c h w] 维度的图像 x
,会经过如此流程:
python
for conv, block in zip(self.convs, self.blocks):
x = conv(x)
x = block(x)
每一次经过 conv
都会降低分辨率和增加维度。
- 分辨率:768 -> 192 -> 96 -> 48 -> 24
- 维度:3 -> 256 -> 512 -> 1024 -> 2048
conv()
流程伪代码:
python
conv = [
Conv2d(3, 256, kernel_size=7, stride=4, padding=3),
rearrange('b c h w -> b (h w) c'),
LayerNorm(256),
]
注意到会有 rearrange 压平 hw 维度的步骤。为了保留长宽信息
conv()
会额外返回input_size
指示 Conv2d 后、压平前的长宽,输入到block()
中。
blocks
层先后包括 SpatialBlock
实例和 ChannelBlock
实例。
python
block = [
SpatialBlock()
ChannelBlock()
]
两种 block 的大致结构如下,用 Residual()
表达残差。
python
# 整体 Block 直接写成 Residual 的序列
SpatialBlock = [
Residual(DepthWiseConv2d), # 相当于给每个通道一个 scale
Residual(
LayerNorm,
WindowAttention, # 空间 attention,在 patch 内部进行注意力
),
Residual(DepthWiseConv2d),
Residual(
LayerNorm,
Mlp,
),
]
ChannelBlock = [
Residual(DepthWiseConv2d),
Residual(
LayerNorm,
WindowAttention, # 通道 attention,qkv 正常获得但在计算 score 时获得的是 channel 的 score
DropPath(0.004), # 类似于 Dropout 的正则化方法,但会让部分像素的所有通道全为 0,而不是随机置 0。定义于 timm 库
),
Residual(DepthWiseConv2d),
Residual(
LayerNorm,
Mlp, # 很标准的 mlp 结构。没有使用 GLU(Gated Linear Unit)那一套
DropPath(0.004),
),
]
可见,两者基本流程都是 "通道 scale" -> attention -> "通道 scale" -> MLP。
结构细节:
python
DepthWiseConv2d = [Conv2d(256, 256, kernel_size=3, stride=1, padding=1, groups=256)]
WindowAttention = [ # window_size=12
F.pad, # 填充到 window_size 的整倍数
rearrange('b (h s1) (w s2) c -> (b h w) (s1 s2) c' s1=window_size, s2=window_size),
MultiHeadAttention(heads=8, bias=True),
x = x[:, :H, :W, :], # 若进行了 F.pad,此步骤用于还原 x
]
ChannelAttention = [
# 与标准 attention 的唯一区别是,计算 score 时不是 q @ k.transpose(-2, -1) 而是 q.transpose(-1, -2) @ k
MultiHeadAttention(heads=8, bias=True)
]
Mlp = [
Linear(256, 1024)
GELU(approximate='none')
Linear(1024, 256)
]
4.2.2. 图像编码后的处理
首先是位置编码。self.image_pos_embed
,两个可学习位置编码各自占据一半 channel,提供长和宽的位置信息。与 DaViT 的处理结果直接相加。
额外有 self.visual_temporal_embed
,一维正余弦编码,似乎是用来让模型能够分辨多张图的先后顺序。与上一步的处理结果直接相加。但代码硬编码写死了 T=1
,并没有输入多张图的途径。保留这个层也许只是为了维持兼容性。
然后,获得池化结果。spatial_avg_pool_x
是对像素维度取平均,temporal_avg_pool_x
是对时间维度取平均(如刚刚所说,由于只能有一张图,这就相当于复制了一份 x
)。然后令 x = torch.cat([spatial_avg_pool_x, temporal_avg_pool_x], dim=1)
,在像素维度拼接出一个新 x
。
随后经过一个没有 bias 的线性层将 x
维度从 2048 映射到 1024,作为最终编码结果输出出去。
4.3. 模型推理:语言模型
现有图像特征 tensor image_features
和文本提示词特征 tensor inputs_embeds
。两者按先图后文的顺序拼接后,传给语言模型作为编码器的输入,开始进行文本生成。
语言模型是 Florence2LanguageForConditionalGeneration
实例,调用其 .generate()
方法进行文本生成。
此时 transformers 库会给出警告,
Florence2LanguageForConditionalGeneration
没有继承于 transformers 库的GenerationMixin
,transformers 从 v4.50 开始若不继承GenerationMixin
则会失去.generate()
的能力。实际上这是因为 Florence2 选择继承自己脚本的
GenerationMixin
。理由可能是为了避免 transformers 库版本影响推理?
Florence2 选择使用 Encoder-Decoder 的 transformer 结构,hidden_size 为 1024,编解码器各自 12 层。
MLP 部分没有使用 GLU,而是很朴素的 Linear(1024, 4096) -> GELU -> Dropout -> Linear(4096, 1024);没有使用 Pre-Norm 方案,归一化层放在了注意力和前馈层的后面;使用可学习位置编码。
似乎是为了避免 float16 精度训练时 Inf 和 NaN 值影响训练稳定性,Encoder 的 forward 代码里额外有把这些无效值替换为 float16 表达上限减去 1000 的值的操作。记录一下万一能用上:
python
if hidden_states.dtype == torch.float16 and (
torch.isinf(hidden_states).any() or torch.isnan(hidden_states).any()
):
clamp_value = torch.finfo(hidden_states.dtype).max - 1000
hidden_states = torch.clamp(hidden_states, min=-clamp_value, max=clamp_value)
默认的 generation_config 似乎有问题,其中的
_decoder_start_token_tensor
是代表</s>
的2
而不是代表<s>
的0
,导致 decoder 会先生成一个<s>
再正式输出回答。最终就会形成</s><s>balabala</s>
这样奇怪的输出,虽说不影响结果吧但有点膈应。但不能随便修正,
processor.post_process_generation()
里似乎也有个 bug,而以</s>
开头刚好能跳过这个 bug。唔。