代码层面上解读Florence2模型,专用于视觉任务的小体积语言模型

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_inputsself.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。唔。

相关推荐
AI大模型3 小时前
LangGraph官方文档笔记(4)——提示聊天机器人
程序员·langchain·llm
Baihai_IDP4 小时前
vec2text 技术已开源!一定条件下,文本嵌入向量可“近乎完美地”还原
人工智能·面试·llm
养心进行时5 小时前
为什么模型训练中会有“机器评分高,但人工评分却很差”的情况?
llm
养心进行时5 小时前
大模型微调后,可上线的标准是什么?
llm
阿星AI工作室10 天前
魔塔+LLaMa Factory 零基础体验模型微调
人工智能·后端·llm
玩转AGI10 天前
Coze篇-搭建智能助教智能体
人工智能·llm·coze
用户8009135524410 天前
让AI“越用越懂你”的秘密:我是如何从0到1设计一个AI置信度系统的?
人工智能·llm
代码里程碑10 天前
手把手构建TinyCodeRAG
llm
LLM大模型10 天前
LangGraph篇-ReAct应用
人工智能·程序员·llm