ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

多模态视觉模型识别不准?探测器链路让准确率从82%拉满到100%

多模态视觉模型识别不准?探测器链路让准确率从82%拉满到100%

最近接了个任务:"让 AI 替我看图"。听起来是不是很爽?把图往视觉模型接口一扔,型号、数量、金额自动出来。真做起来完全不是这回事。我上个月在测一套视觉 MaaS 平台的服务(涉及合作项目,平台名就不报了,下文统一叫"某平台"),目标是让多模态模型自动识别电商结算单里的规格字段、商品数量、合计金额,顺带给商品图做物体计数。第一次跑测试集的时候,裸调接口的准确率让我有点坐不住:字段识别约八成,物体计数只有六成多,而且模型出现了三四次"图上明明没有的信息被编造出来"的情况。也就是说,让 AI 看图,"看"只是第一步,"看对"才是真正要解决的问题。

后来我花了两个晚上把整个链路重做了一遍,核心就一个变化:在视觉模型前面加了一个"探测器"。这名字听着神秘,说白了就是先定位、再识别。探测器先把图中的关键区域框出来,裁剪放大之后再喂给视觉模型,最后把分块结果汇总做推理。改完之后,同一批测试集,字段准确率到了 99%,计数准确率 100%,幻觉率归零。这篇文章就把我整个实测过程、选型思路、代码骨架、踩坑记录全部摊开来讲,尤其是那个让"全对"成为可能的探测器,值得每一个做视觉落地的人认真看看。

1. 先说结论:裸调视觉大模型和加一个探测器,差距比想象中大

可能有人会问:视觉大模型不是号称能"看图说话"吗,为什么还要搞个前置探测器?答案很简单:任何多模态模型都是先切块再看图,它对"模糊的小目标"和"密集排布的细节"非常不擅长,而业务图恰恰总在刁难它。结算单上的小字、商品图里层层叠叠的箱子、仪表盘照片里反光的位置,全是模型的高危区域。

我搭了一套评测集:50 张电商 SKU 商品图、40 张模拟结算单截图、10 张仪表盘照片,总共 100 张,全部来自模拟项目 X 的内部数据。指标口径有三个:字段识别准确率(结算单上的关键字段是否完全匹配)、物体计数准确率(数量是否和人工标注一致)、幻觉率(模型输出了图上不存在的字段或对象的比例)。裸调某平台的多模态接口,结果如下:

方案字段准确率计数准确率幻觉率平均单张耗时
裸调多模态 API82%66%11%3.2s
叠加探测器链路99%100%0%4.8s

看到这个表,应该能理解我为什么说探测器才是"全对"的关键了。字段准确率从 82% 提到 99%,计数从 66% 直接拉满,幻觉从 11% 归零。代价仅仅是单张耗时多了 1.6 秒。在大多数业务场景里,这个耗时换准确率是绝对划算的。

这里先给探测器下一个可操作的定义:它不是另一个"神秘大模型",而是一个目标检测前置服务,接收"图中可能存在哪些对象"的描述词,返回每个对象的位置框(x1, y1, x2, y2)。拿到位置框之后,我们可以把原图裁剪成一个个小图,再交给视觉模型逐块识别。这个过程本质上就是给"远视眼"配了一副带放大功能的老花镜——模型还是那个模型,但输入从"一整张看不清的图"变成了"被放大的、干净的小图",识别难度直接降了一个量级。

1.1 为什么"先定位再识别"能涨这么多点

裸调时,视觉模型要在一张图上同时完成"找目标"和"读目标"两件事。找目标靠的是注意力机制,但注意力的资源是有限的,背景、装饰、光影都会分走注意力。加探测器之后,第一件事(找目标)被专用检测模型接管,视觉模型只需要做第二件事(读目标),任务纯度高了很多。就好比让一个人既当保安又当会计,容易两头不讨好;现在保安把可疑物品都搬到桌上了,会计只需要逐件记账,准确率自然上来了。

"同时完成找和读"最容易出问题的场景是计数。裸调模型面对五个箱子时,注意力可能被箱子上的巨大 logo 吸引,数成四个。而检测器给出的框天然带有空间位置信息,我们可以在汇总环节直接统计框的数量,再把每个框对应的区域内容交给视觉模型确认"这是什么箱子"。这样计数不再是模型的直觉,而是可校验的统计结果,100% 全对完全合理。

1.2 什么样的业务适合这套链路

我个人的判断是:只要你的图里有"需要精确读取的小区域"或者"需要严格统计的多个同类对象",就值得加探测器。典型场景包括:电商商品图属性抽取(型号、颜色、数量)、结算单/物流单字段提取、仪表盘读数识别、仓库货架盘点。反过来,如果是广告文案配图生成、粗略的图像理解问答,裸调大模型就够了,没必要为了几个点去引入额外延迟和成本。

2. 裸调为什么会翻车:三个典型的"AI 看错"现场

在讲探测器怎么用之前,我想把裸调翻车的具体案例摆出来。只有理解了模型的局限,你才会明白探测器每个环节的设计是有道理的。

2.1 数量数不对:注意力被大块背景抢走

第一张翻车图是一张仓库货架照片,图里摆着五箱饮料,箱子正面各有明显的品牌 logo。我让模型数一下一共有几个箱子,它回答"4 个"。我人眼又数了一遍,确实是 5 个。问题出在哪?模型把左下角两个紧挨着、部分重叠的箱子当成了同一个箱子。因为视觉模型对图片做切块(patch)之后,两个箱子的边缘特征混在同一批 token 里,注意力权重被箱子上的大 logo 吸走了,重叠边界反而被忽略。这种"粘连目标"的漏数,在纯视觉模型里几乎无解,除非你有外部工具给出每个目标的独立边界框。

2.2 小字串行:图像切块把细节打碎了

第二张翻车图是某电商结算单的截图。图片整体不算大,但表格里有十几列,型号、规格、单价挤在一起。模型把第二行的"规格:500ml"识别成了"规格:50ml",少了一位有效数字。原因是视觉模型的分辨率有限,图像被切成若干固定大小的块后,小字体的笔画被跨块切碎,重建时出现语义混淆。这其实和我们人眼盯着一串小字久了会串行是一个道理。后来加了检测器,检测器先框出表格列头区域,裁剪放大到两倍分辨率再送模型,这个问题就再没出现过。

2.3 无中生有的幻觉:解码器在强行圆场

最让我印象深刻的是一张产品包装图,标签上只印了品名和净含量,根本没有生产批号。我问模型"批号是多少",它给了我一个形如"20240412A"的编号,看起来非常正常,但包装上真的没有。这就是幻觉:模型在解码时发现自己应该输出一个字段,于是根据训练先验"编"了一个最像批号的值。这种错误在业务场景里很危险,因为它不是空值,而是一个错误值,下游系统会直接采信。探测器解决幻觉的方式是改变信息流:先把检测框和区域识别结果作为"事实清单"交给模型,并明确要求它只能引用清单里的内容,清单里没有的字段一律回答 null。

这三个案例串起来看,结论很清晰:视觉大模型不是不够强,而是它的输入粒度天然不适合"小、密、多"的业务图。Prompt 只能调整语言表达,改变不了模型感知层面的缺陷。要根治,就得在感知层面帮它一把——这就是探测器存在的意义。

3. 探测器到底是什么:一条"定位-裁剪-放大-再识别"的链路

这一节把探测器的选型和链路设计说透。很多朋友一听到"探测器"就往"是不是要自己训练一个检测模型"上面想,其实完全不用。现代 MaaS 平台通常已经提供了可用的检测服务,我们要做的是把它的能力编排进视觉链路,而不是从零炼丹。

3.1 三个候选方案对比

我第一反应是选型,当时对比了三个方向:固定类别检测、OCR 文本检测、开放词表目标检测。三者各有适用面:

方案优点缺点适用场景
固定类别检测速度快、成本低、稳定类目要预定义,新类目需重新训练单一品种质检
OCR 文本检测对单据/表格文字定位准只能框文字,框不了商品/障碍物结算单、证件识别
开放词表检测任意描述词都能检测,无需训练需要微调提示描述,偶尔漏框多品类商品图、混合场景

我们的测试集里既有商品图又有结算单,还有仪表盘,固定类别检测根本没法穷举对象,OCR 又只能管文字部分。最后选了开放词表检测作为主干探测器,再在结算单场景叠加 OCR 定位做兜底。这套组合的定位逻辑是:开放词表负责框出所有"业务关心的对象",OCR 负责把文字类区域单独切成高分辨率小块。

3.2 探测器输出什么,我们怎么用它

开放词表检测服务的输入是一张图和若干"目标描述词",输出是每个目标的位置框、类别置信度和类别名。例如输入描述词["纸箱", "饮料瓶", "表格区域"],返回类似这样的结构:

{ "detections": [ {"label": "纸箱", "box": [0.1, 0.2, 0.6, 0.7], "score": 0.92}, {"label": "饮料瓶", "box": [0.3, 0.5, 0.8, 0.9], "score": 0.88} ] }

注意这里的 box 是归一化坐标,范围在 0 到 1 之间。拿到这些框之后,我先把原图按照框的位置裁剪出来,每个框统一缩放成长边不超过 1024 或 1280 像素的小图,再逐块调用视觉模型。逐块识别的结果并不是最终答案,而是被当作"证据"喂回给汇总推理的模型。链路顺序是:原图 → 探测器 → 位置框列表 → 区域裁剪 → 逐块识别 → 证据合并 → 汇总推理。

3.3 平台侧的准备工作

我用的是某平台的 MaaS 服务,具体开通流程各平台大同小异:先在控制台申请一个 API Key,然后开通视觉理解模型和检测服务的额度。这里有一个容易忽略的点:要在同一个服务账号下配置好两个模型的访问权限,别只开通了视觉模型而漏掉检测服务。另外,强烈建议把 API Key 放到环境变量或密钥管理服务里,不要写死在代码中,尤其不要提交到仓库。我第一次调试时图省事写在了脚本里,后来整理代码时传出过一次告警,虽然没造成损失,但教训记住了。

4. 接入实操:让 AI"看对图"的完整代码骨架

下面我把核心代码骨架完整贴出来,你可以直接抄到自己的项目里改。整体我封装成了三个函数:调用检测器、裁剪放大、调用视觉模型做分块识别与汇总推理。为了方便演示,代码里用 requests 实现,实际生产环境建议改成异步并发。

4.1 公共参数与图像预处理

import base64 import os import io from PIL import Image import requests MAAS_ENDPOINT = os.environ["MAAS_ENDPOINT"] # 例如 https://maas.example.com/v1 API_KEY = os.environ["MAAS_API_KEY"] def image_to_base64(img: Image.Image) -> str: buf = io.BytesIO() img.save(buf, format="PNG") return base64.b64encode(buf.getvalue()).decode("utf-8")

这里有一个细节:有些平台的接口对图片大小有硬限制,比如单张图不能超过 20MB 或分辨率不超过 4096×4096。我的建议是在进入链路之前先把图统一缩放到最长边 2048 以内,既能避免超限,也能缩短网络传输时间。缩放不会明显影响探测器的效果,因为检测框本来就是归一化坐标。

4.2 调用探测器:先框出所有目标区域

def run_detector(img: Image.Image, labels: list[str]) -> list[dict]: payload = { "image": image_to_base64(img), "labels": labels, "threshold": 0.35 } headers = {"Authorization": f"Bearer {API_KEY}"} resp = requests.post(f"{MAAS_ENDPOINT}/detect", json=payload, headers=headers, timeout=30) resp.raise_for_status() detections = resp.json().get("detections", []) return [d for d in detections if d["score"] >= 0.35]

threshold 参数很关键。默认值如果设得太低,会出现大量漏检或误检;太高又会漏框。我最后用的是 0.35,区间其实是 0.3~0.5 之间需要根据实际数据调。选 0.35 的原因是,很多业务目标在遮挡、暗光下的置信度只有 0.4 左右,设 0.5 会把这些真目标过滤掉。这里的取舍后面专门讲。

4.3 坐标换算与裁剪放大

探测器返回的框是归一化坐标,裁剪前必须先换算成像素坐标,同时向外扩一点边距(padding),防止文字或边缘被切掉。

def crop_by_box(img: Image.Image, box: list[float], pad_ratio: float = 0.05) -> Image.Image: w, h = img.size x1, y1, x2, y2 = box # 归一化坐标转像素坐标 px1, py1, px2, py2 = x1 * w, y1 * h, x2 * w, y2 * h # 加边距,但不超过图像边界 pad_w, pad_h = (px2 - px1) * pad_ratio, (py2 - py1) * pad_ratio px1 = max(0, px1 - pad_w) py1 = max(0, py1 - pad_h) px2 = min(w, px2 + pad_w) py2 = min(h, py2 + pad_h) return img.crop((px1, py1, px2, py2))

裁剪后的小图建议统一缩放到一个合理尺寸,比如长边 720 或 1024。太小会丢失小字细节,太大又浪费 token。我在结算单场景用的是长边 1024,商品图场景用 768,实测效果都不错。

4.4 逐块调用视觉模型,把区域转成文字证据

def recognize_region(img: Image.Image, label: str) -> str: payload = { "image": image_to_base64(img), "prompt": ( f"这是从一张大图中根据探测器结果裁剪出的区域,对应类别是“{label}”。" "请只描述这张小图里的可见内容,包括所有文字、数字、颜色、布局。" "不要推测大图中其他位置的信息。" ), "max_tokens": 512 } headers = {"Authorization": f"Bearer {API_KEY}"} resp = requests.post(f"{MAAS_ENDPOINT}/vision", json=payload, headers=headers, timeout=30) resp.raise_for_status() return resp.json()["text"]

这一段的 Prompt 有两个要点:一是明说"这是裁剪出来的小图",让模型不要试图联系不存在的上下文;二是"只描述,不要推测",把模型的输出约束成客观证据,而不是推理结论。我发现很多人在这一步翻车,是因为 Prompt 写得过于开放,让模型自行总结,结果模型又发挥想象力把证据给"补全"了。

4.5 汇总推理:把证据清单交给模型做最终判断

def final_inference(evidence: list[dict], question: str) -> str: evidence_text = "\n".join( f"- 区域[{i}] 类别={item['label']}: {item['description']}" for i, item in enumerate(evidence) ) payload = { "prompt": ( f"以下是从一张图中检测并识别出的区域事实清单:\n{evidence_text}\n" f"请根据这份清单回答用户问题:{question}\n" "要求:只引用清单中提到的内容;清单里没有的字段,明确回答 null;" "输出 JSON 格式,字段为 {\"query\", \"answer\"}。" ), "max_tokens": 1024 } headers = {"Authorization": f"Bearer {API_KEY}"} resp = requests.post(f"{MAAS_ENDPOINT}/vision", json=payload, headers=headers, timeout=30) resp.raise_for_status() return resp.json()["text"]

主流程就是把上面几个函数串起来:

def process_image(img_path: str, labels: list[str], question: str) -> str: img = Image.open(img_path).convert("RGB") detections = run_detector(img, labels) evidence = [] for i, det in enumerate(detections): crop = crop_by_box(img, det["box"]) desc = recognize_region(crop, det["label"]) evidence.append({"label": det["label"], "description": desc}) return final_inference(evidence, question)

整个代码骨架很朴素,没用什么高深技巧,但信息流设计是核心:探测器负责"有哪些区域",逐块识别负责"每个区域里有什么",汇总模型负责"根据事实下结论"。每一层都只做自己擅长的事,幻觉自然被结构性消灭。

5. 评测与 Prompt 调优:准确率是怎么从 70% 一路推到 100% 的

代码跑通只是第一步,真正的工作量在评测和调优。我在这套链路上花了三个整天,下面把验证方法和关键发现写下来。

5.1 评测口径怎么定

我建议不要只看"整体准不准"这种模糊指标,而是拆成字段级准确率、对象级计数准确率、幻觉率三个维度。字段级准确率逐字段比对,必须完全一致才算对,防止"基本差不多"掩盖小数字错误;计数准确率看的是数量是否和人工标注一致;幻觉率则统计模型输出里有多少个字段或对象是图上根本不存在的。三个指标分别对应业务关心的三类风险:信息错误、数量错误、虚假数据。

5.2 影响准确率的四个变量

我做了四组对照实验,每次只改一个变量,结果非常直观:

配置字段准确率计数准确率幻觉率
只加探测器,不裁剪直接识别88%82%5%
探测器 + 裁剪放大95%96%1%
探测器 + 裁剪放大 + 逐块识别99%100%1%
探测器 + 裁剪放大 + 逐块识别 + 结构化汇总99%100%0%

只看这张表就知道每个环节的贡献:裁剪放大主要解决小字和小目标的问题,逐块识别解决了干扰项混杂的问题,结构化汇总则把最后的幻觉空间彻底堵死。如果你时间有限,至少要做到"探测器 + 裁剪放大"这一层,准确率就能过 95%。

5.3 三个真正有效的 Prompt 写法

第一个是"先给事实,再给问题"。把探测器结果和逐块识别结果以清单形式放在 Prompt 前端,问题放在末尾。模型在解码时能看到完整上下文,不太容易跑偏。第二个是"只引用清单内容"。这句话比想象中重要,它能显著压制幻觉,模型在不确定时会倾向于从事实清单里找最接近的答案,而不是凭空生成。第三个是"不确定就写 null"。直接告诉模型允许留白,反而会让它在有把握的时候更自信。这三个写法本质上都是把模型的输出从"自由发挥"引导成"基于证据作答"。

5.4 漏框和误框怎么及时止损

探测器不是完美的,偶尔会漏框。我的处理方法是分层兜底:先给原图整体也调用一次视觉模型,但只在检测结果为空或置信度普遍偏低时才启用;同时增加一个"二次检测"步骤,用不同 threshold 再跑一遍,把两次检测的框做合并。实测中,分层兜底基本能把漏框率压到 1% 以内。另外,检测描述词的写法也影响漏框率:与其写"货物"这种宽泛词,不如写"纸箱、缠绕膜、托盘、饮料瓶"这种具体词,检测器对所有见过的类目召回率更高。

6. 避坑记录:坐标、重复框、限流和成本

最后这部分是踩坑经验,每条都是我实际碰到过的。如果你直接跑上面代码,大概率也会遇到。

6.1 坐标换算的坑:先用除法再裁剪

有个同事 A 第一次接这个链路时,把归一化坐标当成像素坐标直接用,结果他裁剪出的区域全部偏到左上角,模型识别出来一堆乱七八糟的东西。排查了半天才发现是坐标没换算。我的建议是,在 crop_by_box 函数里显式标注单位注释,并且写一个单测:用一个已知框验证裁剪结果的位置。另一个容易被忽略的是 padding 比例,我建议至少 5%,否则贴边框的文字经常被切到半个字。

6.2 重复框与重叠框:一个目标被识别两次

开放词表检测经常出现同一目标被多个相近类别框中的情况,比如"纸箱"和"货物"同时命中同一个纸箱。如果不处理,逐块识别会把这个纸箱描述两遍,汇总模型再老老实实写进证据清单,最终答案里物品数量就虚高了。我的做法是在拿到检测结果后做一轮简单的 IoU 合并:计算两两框的交并比,IoU 大于 0.5 的只保留置信度更高的一个。注意不要对所有类别做合并,否则两个真的挨得很近的商品会被误合并成一个。

6.3 并发限流:MaaS 接口不是无限量的

逐块识别会让单张图的 API 调用次数从 1 次变成"1 次检测 + N 次区域识别 + 1 次汇总",如果批量处理上千张图,很快会撞上平台的 QPS 限制。我写过的最蠢的一次是写完并发后一口气跑了 500 张图,结果收到一堆 429 限流报错,还白白消耗了额度。正确做法是控制并发数,比如每批最多 8 个任务,加指数退避重试。代码层面可以用一个简单的信号量控制,生产环境建议上消息队列。

6.4 成本与耗时估算:别让准确率把预算吃掉

我按某平台公开价估算(实际价格以你拿到的报价为准):检测服务约 0.02 元/张,视觉模型按 token 计费,逐块识别加汇总后单张图平均消耗约 3000 token,折算约 0.1 元/张,合计约 0.12 元/张。相比裸调的 0.04 元/张,成本翻了三倍,但考虑到字段准确率从 82% 到 99% 的收益,这笔投入非常值。如果预算吃紧,可以考虑只对"置信度不高的图"走完整链路,简单图直接裸调,用一张分类规则把成本降下来。

最后再说一个我个人的经验:探测器这个思路,不仅适用于我开头的结算单识别,它其实是一套通用的"视觉感知增强"方法论。任何 MaaS 视觉模型都会受限于输入分辨率,而探测器等于把分辨率花在刀刃上。如果你正在做类似的图像识别需求,我强烈建议先别急着换更大的模型,把预算花在一条"定位-裁剪-放大-再识别"的链路上,你可能也会像我一样,亲眼看着准确率从七八十分一路追到满分。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进