☰
BLIP-2+SAM+ChatGPT:8G显存实现图像到段落生成
2026/10/4 11:04:18 网站建设 项目流程

把一张图片变成一段有细节、有逻辑的完整文本段落,这个需求真做起来比想象中难。我以前接过的类似项目,早期方案基本都是单模型 image captioning,输出永远是“一个人在沙滩上”“桌上放着几本书”这种一口气说完的干巴句子,想要物体位置、局部交互、环境氛围的多层次描写,基本指望不上。后来我把 BLIP-2、SAM 和 ChatGPT 三个模型串成一条流水线,做了一个叫 Image2Paragraph 的项目——整张图先被 SAM 拆成若干有语义的片段,每个片段交给 BLIP-2 做局部描述,最后把所有局部描述连同全局语义一起丢给 ChatGPT,让它组织成一段通顺的完整文字。全套流程实测跑在 8G 显存的卡上,不需要 A100,不需要多卡,个人开发者和学生都能玩。这篇文章把思路、原理、部署步骤、踩坑经历全部摊开讲,适合正在做多模态理解、图像描述或者只想低成本体验大模型链路的朋友。

1. 项目整体设计与思路拆解

1.1 单模型做图片描述,到底卡在哪

先说说为什么不能只用一个模型搞定。端到端的 image captioning 模型本质是从图像特征到文本序列的映射,训练目标是最大化条件概率 P(文本|图像)。这类模型对“这张图里有什么”这种整体性问题非常擅长,但一旦问题变成“画面左侧第三个物体是什么颜色”“前景人物和背景场景有什么关系”,它就会开始一本正经地编。原因也好理解:解码时注意力几乎都压在全局特征上,空间细节被压缩成少量 token,小物体的颜色、位置、相互位置关系在大尺度特征图上已经糊成一团。

我实测过当时效果最好的几个开源 caption 模型,一张多人聚会的照片,它们能正确报出人数和大致动作,但会把“红毛衣”说成“灰毛衣”,把“手机”说成“遥控器”。颜色和空间位置这类需要局部对齐的高频信息,错误率高得让人没法直接用。硬让 BLIP-2 之类的模型输出长段落也没救,max_new_tokens 拉到 200,前面几句还像样,后半段开始复读和跑题。

Image2Paragraph 的思路本质上是把一个难问题拆成三个易问题:先定位(SAM),再局部识别(BLIP-2),最后统一组织(ChatGPT)。每个环节都是当前领域里最成熟、容错率最高的能力,整体鲁棒性远高于端到端方案。

1.2 BLIP-2、SAM、ChatGPT 怎么分工

三个模型的角色,我用一句话概括:SAM 负责拆,BLIP-2 负责看,ChatGPT 负责写。

整张图先交给 BLIP-2 做一次全局描述,相当于先读个总览;同时 SAM 把图像切成若干片段,每一块对应一个物体或语义区域;每个片段裁出来,再丢给 BLIP-2 做局部描述。注意这里 BLIP-2 被复用了两次,一次看全局,一次看局部,两次输出的粒度完全不同。最后所有描述拼成一个结构化候选信息块,交给 ChatGPT,让它挑重点、理顺逻辑、补上过渡,输出一段干净的中文段落。

我最早也试过让单个模型直接干完所有事,效果都不理想。也试过在本地再挂一个更大的 LLM 来做聚合,但 8G 显存下根本没有空间给第二个大模型。ChatGPT 作为外部 API 不占本地显存,这是整条流水线能跑在消费级显卡上的关键前提之一。

1.3 为什么把门槛定在 8G 显存

为什么不是 4G、12G,偏偏是 8G?因为我手头测试用的卡恰好是 8G 的,而市面上保有量最大的 30 系卡(3060、3060Ti、3070)显存基本就在 8G 到 12G 这个区间,很多老牌的 2070、2080 也在这个档位。做项目如果让人为了复现去租一台 A100,那这个项目传播意义就小了一半。

我定下的硬指标有三条:BLIP-2 和 SAM 必须能同时常驻显存,推理过程中不换卡;单图处理时间接受 30 秒到 1 分钟;峰值显存不超过 7.2G,给系统和其它进程留喘息空间。后面所有优化都是围绕这三条来做的,事实证明这个约束反而逼出了不少好用的工程技巧。

2. 核心组件逐个拆解:各自的活儿与原理

2.1 BLIP-2:看图说话的主力,被复用两次

BLIP-2 全称 Bootstrapping Language-Image Pre-training,是 Salesforce 发布的多模态理解模型。它最有特点的设计是中间的 Q-Former 模块——用少量可学习的 query 向量,把冻结的 ViT 图像特征“蒸馏”成一组紧凑的视觉 token,再输入给冻结的预训练语言模型(OPT 或 T5)。因为视觉编码器和语言模型都被冻结,训练时只更新 Q-Former,所以它才能在很小的开销下获得不错的图文对齐能力。

放到我们的流程里,BLIP-2 承担两份活:对整图生成一句概括性的全局描述,以及对每个局部裁剪区域生成一句物体级描述。两份活用同一个 checkpoint 就能完成,不需要额外微调。

选型上我建议用 blip2-opt-2.7b,它在描述质量和显存占用之间最平衡。更大的 6.7B 版本 fp16 光权重就要 13G 左右,8G 卡直接放不下;T5 系列的输出风格偏翻译腔,组织长文本的能力也弱一些。OPT-2.7B 的 FP16 权重大约 5.4G,加上 ViT 编码器和 Q-Former 大约 0.8G,正好在 8G 卡的甜点位附近。

2.2 SAM:像素级的拆图工具

SAM 是 Meta 发布的分割一切模型,和传统分割模型最大的区别是支持 prompt 式输入——给一个点、一个框或者一句文本,它就能输出对应的分割 mask。我们在项目里用的是自动模式 SamAutomaticMaskGenerator,在图像上撒一个网格点阵,让模型对每个点预测对应区域的 mask,再合并重叠区域,最后输出一组按置信度排序的候选分割。

实测一张 1024x1024 的图,默认参数大概能出 30 到 80 个候选 mask,其中一半是背景、碎片或者重复区域,必须做后处理过滤。过滤规则就两条:面积占比过小的去掉(小于整图 1% 的 mask 基本是纹理碎片);被更大 mask 包含的也去掉,只保留最外层的物体轮廓。过滤完之后通常能剩下 5 到 15 个有效区域,这个数量对后续描述来说恰到好处。太多会让 ChatGPT 的输入变得冗长,太少又会漏掉关键细节。

2.3 ChatGPT:把碎片组织成段落的最后一道工序

ChatGPT 在整条流水线里不是用来“看”图的,它是被用来“写”字的。但别小看这一步,决定最终段落质量的往往是它。

BLIP-2 和 SAM 的输出都是碎片化的:全局一句话、区域若干句话,再加上每块 mask 的坐标位置。怎么把这些碎片变成一段有开头有结尾、详略得当的文字,本质上是 text generation 问题,ChatGPT 恰好是这个领域最成熟的 API 方案。设计 prompt 时我做了三件事:第一,把所有机器输出用 JSON 结构化,降低模型理解成本;第二,在 prompt 里明确写作要求——客观描述、禁止推测、突出关键物体、保持段落流畅;第三,给出输出格式约束,比如长度控制在 200-300 字。

我试过只把文本一股脑拼进去让 ChatGPT 自由发挥,效果差很多,经常出现“图中有一张图”这种套娃式废话。结构化输入之后,输出质量是肉眼可见的提升。

2.4 五个阶段的完整数据流

整条链路的数据流可以分成五个阶段,每个阶段输入输出我都用了固定的结构,方便后续排查和扩展:

{ "global_caption": "一个阳光明媚的公园草地,人们正在野餐和玩耍。", "regions": [ { "id": 0, "bbox": [210, 120, 540, 680], "position": "画面中下方偏左", "area_ratio": 0.16, "caption": "一家三口坐在野餐垫上,垫子上铺着红色格子布,堆着面包和水果。" }, { "id": 1, "bbox": [80, 40, 300, 380], "position": "画面左上角", "area_ratio": 0.08, "caption": "一个小孩正在追逐一只白色气球。" } ] }

这段 JSON 就是 ChatGPT 的输入。坐标和面积信息是 SAM 白送给我们的,转换成“画面左上方”“占比约 15%”这种自然语言位置提示,比直接喂 bbox 数字好用得多。我把 JSON 生成和坐标换算封装成一个函数之后,整条流水线的下游就完全不用关心像素了,后面想换任何分割模型都不影响整体架构。

3. 8G 显存部署前的准备:环境、选型、显存预算

3.1 硬件与软件环境清单

先亮硬件清单:一张 8G 显存的 NVIDIA 显卡(实测的 RTX 3060 和 3070),16G 以上内存,固态硬盘。模型文件总共 8G 多,机械盘也能跑,但首次加载的等待时间会让人怀疑人生。系统推荐 Ubuntu 22.04,Windows 10/11 在 bitsandbytes 4bit 量化这一步有兼容性坑,后面避坑章节会细说。

软件环境我固定用下面这套版本组合,是踩过坑确认稳定的:

组件版本
Python3.10
PyTorch2.1.2+cu118
transformers4.36.2
accelerate0.25.0
bitsandbytes0.43.0
segment-anythingmaster 分支
openai1.x

transformers 版本不建议追新,4.40 之后的版本改过 BLIP-2 的量化加载逻辑,跑 4bit 加载会报 KeyError。这个坑我专门写过一版笔记,感叹过“跑通靠的不是最新版,而是恰好能跑的版本”。

3.2 模型选型与显存算术

显存预算是部署前必须做的一道算术题。我按 8G 卡的实际可用额度算过一笔账:

组件量化方式占用显存
BLIP-2(OPT-2.7B)FP16约 5.4G
其中 ViT+Q-FormerFP16约 0.8G
SAM(ViT-B)FP16约 0.4G
临时激活值单图推理约 0.5G
PyTorch/cuDNN 预留-约 0.4G

合计大概 7.5G,已经贴着 8G 上限了,所以实际部署我准备了两套保险。第一套是把 BLIP-2 的 LLM 部分用 bitsandbytes 压到 4bit,权重立刻从 5.4G 降到 1.9G,总占用直接掉到 4G 以内,运行时稳定性大幅提升。第二套是把 SAM 和 BLIP-2 的加载阶段错开,先用 SAM 把 mask 全部算完并保存,然后彻底释放 SAM 再加载 BLIP-2。第二套方案在 8G 卡上跑得更放心,代价只是代码稍微啰嗦一点。

3.3 四个让显存稳在 7G 以内的经验

除了模型量化,还有四个细节能让显存使用稳定在 7G 以内。第一个是设置 PyTorch 的分段显存分配策略:

export PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True

第二个是控制输入图像分辨率。BLIP-2 的 processor 用默认的 224 或 364 就行,SAM 我习惯把长边缩到 1024,超过这个值对 mask 质量提升很有限,显存和耗时倒是实打实上升。第三个是每个阶段结束主动释放模型对象:

del sam, mask_generator torch.cuda.empty_cache()

第四个是生成时控制批大小,BLIP-2 的 batch 永远设 1,多张小图也一张张跑,8G 卡上没有商量余地。这四个点加起来,效果立竿见影。

4. 从图片到段落:完整实操流程与核心代码

4.1 安装依赖与模型权重准备

先装依赖,我按验证过的顺序贴在下面:

pip install torch==2.1.2 torchvision==0.16.2 --index-url https://download.pytorch.org/whl/cu118 pip install transformers==4.36.2 accelerate==0.25.0 bitsandbytes==0.43.0 pip install git+https://github.com/facebookresearch/segment-anything.git pip install openai pillow numpy

权重需要两份:BLIP-2 用 Salesforce/blip2-opt-2.7b 的 checkpoint,SAM 用官方仓库的 sam_vit_b_01ec64.pth,约 375M。下载完把文件摆成这种结构:

models/ blip2-opt-2.7b/ sam/ sam_vit_b_01ec64.pth

另外把 OpenAI 的 API Key 写进环境变量:

export OPENAI_API_KEY=sk-xxxx

然后在 Python 里用 openai.OpenAI() 初始化客户端就行。申请 Key 的过程官方文档写得很清楚,这里不展开。

4.2 核心 Pipeline 代码拆解

完整流程我写成了一段可以直接跑的主函数,关键部分都有注释:

import json import torch import numpy as np from PIL import Image from transformers import Blip2Processor, Blip2ForConditionalGeneration from segment_anything import sam_model_registry, SamAutomaticMaskGenerator import openai client = openai.OpenAI() def load_models(): torch.cuda.empty_cache() processor = Blip2Processor.from_pretrained("models/blip2-opt-2.7b") blip2 = Blip2ForConditionalGeneration.from_pretrained( "models/blip2-opt-2.7b", torch_dtype=torch.float16, device_map="cuda", ) blip2.eval() sam = sam_model_registry["vit_b"](checkpoint="models/sam/sam_vit_b_01ec64.pth") sam.to("cuda").half() mask_generator = SamAutomaticMaskGenerator( sam, points_per_side=24, pred_iou_thresh=0.86, stability_score_thresh=0.85, min_mask_region_area=2000, ) return processor, blip2, mask_generator def crop_by_bbox(img, bbox, margin=10): x0, y0, x1, y1 = bbox x0 = max(0, x0 - margin) y0 = max(0, y0 - margin) x1 = min(img.width, x1 + margin) y1 = min(img.height, y1 + margin) return img.crop((x0, y0, x1, y1)) @torch.no_grad() def describe_crop(processor, blip2, crop, prompt=""): inputs = processor(images=crop, text=prompt, return_tensors="pt").to("cuda", torch.float16) out = blip2.generate( **inputs, max_new_tokens=64, num_beams=3, no_repeat_ngram_size=3, do_sample=False, ) return processor.decode(out[0], skip_special_tokens=True).strip() def position_text(cx, cy, W, H): h_pos = "左" if cx < W / 3 else ("右" if cx > 2 * W / 3 else "中") v_pos = "上" if cy < H / 3 else ("下" if cy > 2 * H / 3 else "中") mapping = { "中中": "画面中央", "中上": "画面中上方", "中下": "画面中下方", "左中": "画面左方", "左上": "画面左上角", "左下": "画面左下角", "右中": "画面右方", "右上": "画面右上角", "右下": "画面右下角", } return mapping.get(v_pos + h_pos, "画面中央") def image_to_paragraph(image_path, max_regions=8): img = Image.open(image_path).convert("RGB") img.thumbnail((1024, 1024)) arr = np.array(img) processor, blip2, mask_generator = load_models() global_caption = describe_crop( processor, blip2, img, prompt="Give a concise summary of the whole image." ) masks = mask_generator(arr) masks.sort(key=lambda m: m["area"], reverse=True) total_area = arr.shape[0] * arr.shape[1] masks = [m for m in masks if m["area"] > 0.01 * total_area][:max_regions] regions = [] for i, m in enumerate(masks): bbox = [int(v) for v in m["bbox"]] crop = crop_by_bbox(img, bbox) cap = describe_crop( processor, blip2, crop, prompt="Describe the main object in this photo region in one sentence." ) x0, y0, w, h = bbox pos = position_text(x0 + w / 2, y0 + h / 2, arr.shape[1], arr.shape[0]) regions.append({ "id": i, "bbox": bbox, "position": pos, "area_ratio": round(m["area"] / total_area, 3), "caption": cap, }) resp = client.chat.completions.create( model="gpt-4o-mini", messages=[ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": json.dumps( {"global_caption": global_caption, "regions": regions}, ensure_ascii=False )}, ], temperature=0.4, max_tokens=500, ) return resp.choices[0].message.content

这里有一个很关键的工程细节:SAM 输出的 bbox 是 [x, y, width, height],而图像裁剪需要的是 [x0, y0, x1, y1],我一开始没注意,导致所有局部描述都在裁错位置,描述内容和图像对不上。后来把这块统一成坐标转换函数,一次性解决。

4.3 Prompt 设计与生成参数调优

SYSTEM_PROMPT 是整个段落质量的最后一道闸门,我的版本是:

SYSTEM_PROMPT = """你是一个专业的图像描述撰写人。请根据提供的全局描述和区域描述列表,输出一段200-300字的中文段落。 要求: 1. 以全局描述为总起句; 2. 按空间顺序描述主要区域,优先写面积占比大、描述更具体的物体; 3. 只描述客观可见内容,不推测人物心理或事件因果; 4. 用自然的中文衔接,不要分点罗列; 5. 如果某个区域描述明显与全局场景冲突,以下一个区域描述为准。"""

BLIP-2 的生成参数我固定用 num_beams=3 和 no_repeat_ngram_size=3。贪心解码在局部小图上经常出现“a dog a dog”这类复读,加 no_repeat_ngram_size 基本能压住。ChatGPT 的 temperature 我放在 0.4,太低会变成模板作文,太高容易自由发挥过头。max_tokens 设 500 给输出留足余量。

4.4 一次真实运行的效果记录

拿一张公园野餐的照片跑完整流程:SAM 筛出 7 块有效区域,全局描述是“一个阳光明媚的公园草地,人们正在野餐和玩耍”,7 块区域依次是野餐垫、红白格纹布、面包、苹果、小孩、气球、树。ChatGPT 最终输出一段约 260 字的中文段落,按面积排序从垫子写到食物再写到玩闹的孩子,末尾补一句环境氛围,整段直接可用。

耗时分布大约是:SAM 分割 4 秒、全局描述 2 秒、7 个区域描述共 16 秒、ChatGPT 请求 3 秒,总共 25 秒出头。8G 卡全程显存稳定在 6.8G 左右,这就是前面所有优化叠加的结果。

5. 常见问题与排查技巧实录

5.1 显存爆掉怎么办

最常见的 RuntimeError: CUDA out of memory 出现在两个地方。第一个是加载 BLIP-2 的瞬间,FP16 直连会爆,换成 4bit 加载立刻解决:

blip2 = Blip2ForConditionalGeneration.from_pretrained( "models/blip2-opt-2.7b", load_in_4bit=True, bnb_4bit_compute_dtype=torch.float16, bnb_4bit_quant_type="nf4", device_map="auto", )

第二个是 BLIP-2 生成时爆,通常是输入分辨率太大或者 max_new_tokens 太长。生成时把输入图长边压到 512,max_new_tokens 降到 64,基本都能兜住。遇到之前先 nvidia-smi 看一圈,确认没有别的进程占着显存。

5.2 SAM 分割质量不稳定怎么办

mask 过多、过碎、漏检是三个典型症状。过碎就把 points_per_side 从 32 降到 16,同时把 pred_iou_thresh 提到 0.9;过多就把 min_mask_region_area 从 2000 提到 5000,过滤小碎片;漏检则反过来,降 iou 阈值或提高 points_per_side。这些参数可以按图片风格做成预设,室内场景和室外场景各存一套,切换时直接读配置。

5.3 BLIP-2 描述空洞或张冠李戴怎么办

局部显示“a person”“a table”这种泛化描述,通常是因为 prompt 太短。我把局部描述的前置文本改成了“Describe the dominant object, including its color, material and action in one sentence”,输出立刻具体了很多。另一个常见问题是小物体被裁切后特征太少,导致描述错误,这时可以把 area_ratio 过滤阈值从 0.01 提到 0.03,宁缺毋滥。

5.4 问题速查表

现象原因处理
CUDA out of memoryFP16 加载占用过高换 4bit,释放 SAM 后再加载 BLIP-2
生成出现重复语句解码缺少重复惩罚no_repeat_ngram_size=3,num_beams=3
输出全是英文BLIP-2 默认英文输出交给 ChatGPT 翻译,或换中文 caption 模型
SAM 出上百个 maskpoints_per_side 过高降到 16,提高 iou 和 stability 阈值
ChatGPT 输出变成列表prompt 未约束格式system prompt 明确要求连贯段落
中文乱码控制台编码问题export PYTHONIOENCODING=utf-8
API 请求偶发超时网络波动加 3 次重试逻辑,指数退避 1s/2s/4s

6. 我的实操体会与后续扩展方向

最后说点实际操作中的体会。这个项目我前后迭代了三个版本,第一版用更老式的检测框代替 SAM,小物体和交叠物体处理得一塌糊涂;第二版用单模型超长 caption,输出后半段完全没法看;到 BLIP-2 + SAM + ChatGPT 这套组合才算稳定。8G 显存这个限制听起来是约束,实际上逼着我做了很多更合理的工程取舍——显存不够,才想到阶段化加载;担心量化掉点,才反复测试 nf4 和 fp16 的差异。如果显卡更强,我可能反而懒得做这些优化,产出未必更好。

后续想扩展的方向有三个:把局部描述换成带 OCR 能力的模型,把图片里的文字也识别出来;用 SAM 的层级 mask 输出组织成“先整体、后局部、再细节”的三层结构段落;以及接入本地部署的开源大模型,把整条链路彻底变成离线方案。已经有人在后面两个方向上跑通了,效果并不输云端 API 版本。这个项目的价值不在于模型多新,而在于用最朴素的组合,解决了一个实际到不能再实际的问题。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询