1. 先搞清楚“万物识别”到底在识别什么
1.1 一张图不是只能给一个答案
很多刚接触 AI 识图的朋友,脑子里对图像识别的第一印象还停留在“这张图是猫还是狗”。真做起来就会发现,现实需求远比二分类复杂:一张办公桌的照片里,可能同时有笔记本电脑、马克杯、绿植、键盘、便利贴;一张街景里,能同时出现汽车、行道树、红绿灯、反光镜、路人。万物识别这类通用领域模型,输出的就不是单一答案,而是一串带分数的候选标签,本质上是多标签分类加开放词表的组合打法。
这决定了后面所有的工程细节:阈值怎么定、Top-K 取几个、结果怎么展示、标签体系怎么组织,全都跟“多标签”这三个字挂钩。如果你按照单标签的思路去接这类模型,很容易被满屏的低分标签吓到,误以为模型不准,其实只是没用对。
中文-通用领域这个限定词也很关键。通用领域意味着模型见过的类别覆盖面广,从动植物到数码产品、从食物到交通工具,不需要你为每个垂类单独训一个模型,一套权重先跑起来看效果;中文则意味着标签直接就是“鼠标垫”“保温杯”“落地灯”这类看得懂的词,不用自己再做一层英文到中文的映射表,对国内业务落地省了很大一块脏活。
这篇内容适合三类人:完全没跑过视觉模型、想先跑通一次看效果的新手;手上有个业务场景,想知道通用识别够不够用、要不要微调的开发者;以及需要把识别能力塞进现有系统、关心部署和性能的工程师。我会从方案选型讲到代码落地,再到踩过的坑,尽量把每一步“为什么这么干”说透。
1.2 通用领域识别和垂类识别的边界在哪
我见过太多项目一开始就想着“我要识别我们行业的所有东西”,然后一头扎进自建数据集,吭哧吭哧标了两万张图,训出来的模型在真实场景里还不如一个通用模型加后处理。原因很简单:通用模型是在海量、多样、长尾的数据上喂出来的,它对“什么是杯子”这件事的理解,比你自己标两千张杯子图要扎实得多。
通用模型的短板也很明确。它对细粒度不敏感,比如“中华田园犬”和“柴犬”它可能都给个“狗”;对行业专有物基本无感,比如某种特定型号的工业零件、某个品牌限定的包装;对抽象概念更是无能为力,你让它识别“看起来很贵”,它做不到。
所以我的建议是:先用通用模型做一轮基线,把准确率、召回率、时延这些数字量出来,再决定要不要投入微调。很多时候你会发现,通用识别加上一层业务侧的规则过滤,就已经能覆盖八成需求,剩下的两成长尾再单独处理,投入产出比完全不一样。
1.3 这类能力真正能落在什么场景
抛开那些听起来很虚的“智能赋能”,通用识图在业务里的落点其实很具体。内容侧,用户上传图片后自动打标签,用于搜索、推荐、相册聚类;审核侧,识别图片里是否出现特定品类的东西,做初筛,把人工审核的量压下来;检索侧,以图搜图之前先提取语义标签,做粗排;辅助侧,给视障用户做场景播报,或者给电商运营自动生成商品图的属性描述。
我做过一个内部素材库的项目,几万张图躺在那里没人愿意整理,靠人工打标签成本高得离谱。接入通用识别之后,批量跑一遍,人工只做审核和纠错,效率差了将近一个数量级。这类“脏活累活自动化”的场景,是通用识图性价比最高的用法,比追求极致准确率实在得多。
2. 方案选型:模型、标签、部署形态怎么定
2.1 三档模型方案的取舍逻辑
选模型这件事,最怕一上来就追求最大的那个。我的经验是按场景分三档,先明确你卡在哪个约束上。
| 档次 | 典型形态 | 单张推理耗时(参考) | 适用场景 | 主要代价 |
|---|---|---|---|---|
| 轻量档 | 小型卷积骨干,输入 224 左右 | 单核 CPU 上几十毫秒量级 | 移动端、边缘盒子、高并发接口 | 长尾类别召回偏弱 |
| 均衡档 | 中型卷积或小型视觉 Transformer | CPU 百毫秒级,GPU 个位数毫秒 | 服务端通用业务 | 显存占用中等 |
| 精度档 | 大型视觉 Transformer 或集成多模型 | GPU 上十几到几十毫秒 | 离线批处理、对准确率敏感 | 部署成本高,端侧基本跑不动 |
先问自己三个问题:有没有端侧约束、QPS 峰值多少、准确率不达标会不会直接导致业务不可用。这三个问题答完,档次基本就定了。我见过最典型的错误是,一个日请求量几千的服务,直接上了精度档模型,结果机器成本翻了几倍,准确率只提升了两个点,业务方根本感知不到。
注意:不要拿论文里的准确率排行榜直接做决策。那些数字是在标准测试集上测的,你的业务图片有模糊、有遮挡、有奇怪的角度,实际表现会明显低于榜单数字。一定要用你自己的图跑一遍再定。
2.2 标签体系是隐藏的成本大头
拿到模型权重之后,你会同时拿到一份标签文件,通常是几万个类别。这份文件怎么组织,直接决定了后续维护的痛苦程度。
第一件事是确认标签语言和顺序。中文标签文件一般是每行一个词,行号就是模型输出的索引。这个对应关系绝对不能错位,一旦错位,输出的就是完全离谱的结果,而且这种错误极其隐蔽,模型分数看起来很正常,只有人肉核对才发现。我的做法是写一个校验脚本,随机抽几十个标签,用对应的典型图片跑一遍,人工确认对得上。
第二件事是处理同义词和近义标签。通用模型的标签体系里经常出现“沙发”“长沙发”“三人沙发”并列的情况,业务侧其实只想要一个“沙发”。这时候需要建一张归并表,把细粒度标签映射到业务标签上,输出的时候做聚合,取最大分数或者做分数求和。这一步做完,结果的可读性会提升一大截。
第三件事是分层。几万个平铺标签,展示层根本没法用。我会按业务需要建一层粗分类,比如“动物”“植物”“食品”“电子设备”“家具”“交通工具”,把细标签挂到粗类下面。用户看到的先是粗类,点开再看细类,体验完全不一样。
2.3 端侧跑还是服务端跑
这个决策没有中间地带,要么把模型塞进客户端,要么放服务端走接口。端侧的好处是零网络延迟、不占带宽、隐私数据不出设备;坏处是包体积变大、机型兼容麻烦、模型更新要跟着发版。服务端的好处是模型可以随时换、算力不受限;坏处是多一跳网络、有并发成本。
我的实践判断标准是:如果识别是核心交互且对延迟敏感(比如拍照即时反馈),优先端侧;如果是后台批处理或者辅助功能,一律服务端。中间态也有,比如端侧跑轻量档做粗筛,服务端跑精度档做精排,但这个复杂度对大部分项目来说不值得,除非你已经把单档方案跑到瓶颈了。
另外提一句,现在主流的做法是把训练好的模型导出成通用中间格式,再针对目标平台做量化和图优化。这一步能带来实打实的收益,后面实操部分我会给具体做法。
3. 核心细节:预处理和后处理才是决定成败的地方
3.1 预处理那几个参数,错一个结果就崩
很多人跑模型跑不出效果,八成问题出在预处理。模型训练时用的是什么样的输入,推理时就必须一模一样,差一点都会掉点。
核心就四件事:尺寸、缩放方式、归一化、通道顺序。尺寸通常是 224、256、384 这类,必须跟模型训练配置对齐。缩放方式上,训练常用的是“短边缩放到目标尺寸,再中心裁剪”,如果你直接硬拉伸成正方形,长宽比一变,物体形变,识别率立刻下降。归一化用的是均值和标准差,不同模型不一样,必须照抄训练配置。通道顺序上,PIL 读出来是 RGB,OpenCV 默认是 BGR,混用就是灾难。
我给一个我一直在用的预处理写法,逻辑比较通用:
import numpy as np from PIL import Image MEAN = np.array([0.485, 0.456, 0.406], dtype=np.float32) STD = np.array([0.229, 0.224, 0.225], dtype=np.float32) def preprocess(path, size=224): img = Image.open(path).convert("RGB") w, h = img.size # 短边对齐,保持长宽比 scale = size / min(w, h) img = img.resize((int(round(w * scale)), int(round(h * scale))), Image.BICUBIC) w, h = img.size left, top = (w - size) // 2, (h - size) // 2 img = img.crop((left, top, left + size, top + size)) x = np.asarray(img, dtype=np.float32) / 255.0 x = (x - MEAN) / STD return x.transpose(2, 0, 1)[None, ...] # NCHW这段代码里有两个容易忽略的点。convert("RGB")必须显式调用,因为有些 PNG 带 alpha 通道,有些灰度图只有单通道,不转直接喂进去就是维度错误。Image.BICUBIC是有讲究的,训练时用的插值算法如果是双线性,你这里用最近邻,细微纹理的响应会有差异,虽然影响不大,但既然能对齐就对齐。
3.2 输出怎么变成标签:sigmoid、softmax 和阈值
不同模型的输出含义不一样,这个必须搞清楚,否则阈值怎么调都是瞎调。
如果模型是多类互斥的(一张图只属于一个类别),输出层通常是 softmax,所有分数加起来等于 1,取最大值即可。但万物识别这类通用模型绝大多数是多标签的,输出层是 sigmoid,每个类别独立判断,分数之间不互斥,加起来也不等于 1。这时候给“笔记本电脑 0.91、键盘 0.83、马克杯 0.42”是正常的,三个都该输出。
阈值怎么定?我一般分两步。先看一眼分数分布,把一批真实业务图跑一遍,统计每个分数段的命中情况,找一个人工判断为“明显错”和“明显对”的分界点。实践中 0.3 到 0.5 是个常见起点,但不能一刀切,热门类别分数普遍偏高,长尾类别普遍偏低,可以按类别分组设不同阈值。
Top-K 也要设,不然一张复杂的图能吐出几十个标签,展示层直接爆炸。我一般展示层取 Top-5 到 Top-8,接口层返回 Top-20 供调用方自己筛。
def decode(scores, labels, k=5, thr=0.3): idx = np.argsort(-scores)[:k] out = [] for i in idx: s = float(scores[i]) if s >= thr: out.append({"label": labels[i], "score": round(s, 4)}) return out提示:如果你发现某个类别几乎每张图都出现且分数不低,大概率是标签体系里的“背景类”或“杂物类”,直接在业务层屏蔽掉,不然它会持续污染结果。
3.3 中文标签的映射与展示细节
中文标签落地有几个具体的坑。编码问题是最常见的,标签文件如果是 GBK 存的,你用 UTF-8 读就是乱码,读之前在编辑器里确认一下编码。空格和全半角也要注意,“可乐 / 碳酸饮料”这种带分隔符的标签,展示前最好做一次清洗。长标签截断在 UI 上很常见,“便携式电子设备充电器”这种词,移动端展示必须截断或者做省略。
还有一点,中文标签的排序如果直接按 Unicode 码位排,会出现很奇怪的分组。如果要做标签选择器,建议按业务粗类分好再排。
4. 动手实操:从零跑通一条识别链路
4.1 环境搭建与依赖确认
我习惯用独立虚拟环境,避免跟系统里其他项目的依赖打架。Python 3.9 到 3.11 都能跑,不建议用太新的版本,有些推理库的轮子还没跟上。
python -m venv venv source venv/bin/activate # Windows 用 venv\Scripts\activate pip install --upgrade pip pip install pillow numpy onnxruntime # 如果走 GPU,换成 onnxruntime-gpu,并确认驱动和 CUDA 版本匹配onnxruntime的选择是有理由的:跨平台、依赖轻、CPU 上性能不错、支持量化模型。如果你更熟悉训练框架原生推理,也可以用,但生产环境我偏向中间格式,因为它的部署面更窄,出问题的概率更低。
装完之后先做个自检,确认版本和可用设备:
import onnxruntime as ort print(ort.__version__) print(ort.get_available_providers())如果输出里有CPUExecutionProvider,说明能跑;有CUDAExecutionProvider说明 GPU 可用。注意 provider 的顺序决定优先级,构造 session 的时候可以显式指定。
4.2 单张图片推理:把链路打通
先别急着批量,把单张跑通、结果肉眼可验证,这是所有后续工作的基础。
import numpy as np import onnxruntime as ort from preprocess import preprocess # 前面写好的预处理 def load_labels(path): with open(path, "r", encoding="utf-8") as f: return [line.strip() for line in f if line.strip()] def build_session(model_path): opts = ort.SessionOptions() opts.intra_op_num_threads = 4 # 按核数调 opts.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL return ort.InferenceSession(model_path, sess_options=opts, providers=["CPUExecutionProvider"]) if __name__ == "__main__": labels = load_labels("labels_zh.txt") sess = build_session("model.onnx") x = preprocess("test.jpg", size=224) input_name = sess.get_inputs()[0].name logits = sess.run(None, {input_name: x})[0] # 多标签用 sigmoid;如果是单标签模型,这里换成 softmax scores = 1.0 / (1.0 + np.exp(-logits[0])) print(decode(scores, labels, k=8, thr=0.25))跑通之后,第一件要做的事是拿十几张你自己场景里的图去验证,而不是拿网上的猫狗图。因为只有你自己的图,你才知道结果到底有没有用。我第一次跑的时候拿了一堆办公场景图,发现模型对“显示器”很准,对“工牌”完全没反应,这个认知直接影响了后面的方案设计。
输入输出名字不要写死成input、output,用sess.get_inputs()拿,因为不同导出工具起的名字五花八门。形状也要确认,有些模型导出时是动态 batch,有些固定为 1,后者你一次喂两张就报错。
4.3 批量处理:并发、落库与失败重试
单张跑通之后就是批量。批量处理的核心不是“能跑”,而是“跑得稳、可恢复、结果可追溯”。
我的一般结构是:生产者扫目录、推任务进队列,多个 worker 消费、推理、写结果,失败的任务进重试队列。简单的场景用线程池就够,因为推理本身算 CPU 密集,线程数设成核数左右比较合适,设太多反而互相抢。
from concurrent.futures import ThreadPoolExecutor, as_completed import json, os, traceback def infer_one(path): try: x = preprocess(path, size=224) logits = sess.run(None, {input_name: x})[0] scores = 1.0 / (1.0 + np.exp(-logits[0])) return {"path": path, "ok": True, "tags": decode(scores, labels, k=10, thr=0.25)} except Exception as e: return {"path": path, "ok": False, "err": repr(e)} def run_batch(paths, out_file, workers=4): results = [] with ThreadPoolExecutor(max_workers=workers) as pool: futures = {pool.submit(infer_one, p): p for p in paths} for i, fut in enumerate(as_completed(futures), 1): results.append(fut.result()) if i % 200 == 0: print(f"processed {i}/{len(paths)}") with open(out_file, "w", encoding="utf-8") as f: for r in results: f.write(json.dumps(r, ensure_ascii=False) + "\n")结果我强烈建议按行存 JSONL,而不是存成一个巨大的 JSON 数组。原因是 JSONL 可以流式读、可以增量追加、中途挂了也不会把整个文件写坏。落库的话,表结构至少要有:图片路径或哈希、标签列表、分数、模型版本、处理时间。模型版本这一列千万别省,后面换模型的时候你会感谢自己。
失败重试要区分错误类型。文件不存在、格式损坏这类错误重试多少次都没用,直接标记跳过;内存不足、超时这类可以重试,但要有上限,我一般设 3 次,指数退避。批量跑的时候最怕的是无限重试卡死整个队列。
4.4 导出与加速:让它在你的机器上跑得动
如果原始模型是训练框架格式,需要先导出成中间格式。导出时有两个参数必须确认:输入形状和是否固定 batch。动态形状更灵活但某些优化用不上,固定形状优化更好但没弹性。我的做法是导出两个版本,一个固定 batch=1 给在线服务,一个动态 batch 给离线批量。
量化是性价比最高的一步。把权重从浮点压到 8 位整数,模型体积能降到原来的四分之一左右,CPU 推理速度通常有明显提升,准确率掉得很少,一般在一个点以内。做量化需要一小批校准数据,从你的真实业务图里抽几百张就够,别用跟业务无关的图。
from onnxruntime.quantization import quantize_dynamic, QuantType quantize_dynamic( model_input="model.onnx", model_output="model_int8.onnx", weight_type=QuantType.QInt8, )量化完必须做一次对比验证:同一批测试图,分别跑浮点模型和量化模型,统计 Top-1 一致率和 Top-5 一致率。如果一致率掉得厉害,说明模型对这个量化比较敏感,那就退回去用浮点,或者在量化时排除某些层。这一步不能省,我吃过一次亏,量化后某个关键类别的召回掉了一大截,上线后才发现。
另一个提速点是图优化级别,前面代码里已经设了ORT_ENABLE_ALL。还有线程数,intra_op_num_threads设成物理核数左右,设成逻辑核数经常因为超线程反而变慢,这个要实测。
5. 效果调优:通用模型不够用的时候怎么办
5.1 先量化问题:是召回不够还是精度不够
调优之前先搞清楚问题在哪。我把问题分成四类:该识别出来的没识别出来(召回问题)、识别错了(精度问题)、分数不合理(校准问题)、结果不稳定(同一类图时好时坏)。
召回问题通常出在长尾类别和特殊视角。通用模型对常见类别很稳,但如果你业务里的核心物品比较小众,模型给的分数会普遍偏低。这时候可以把这类别的阈值单独调低,用召回换一点误报,再靠业务规则过滤。
精度问题要看具体错在哪。如果错在细粒度,比如把“帆布鞋”识别成“运动鞋”,其实业务上可能可以接受;如果错在完全不同的大类,那可能是图片质量太差或者物体占比太小。物体占比这个因素特别容易被忽略,一张 4000 像素的图里,目标物体只占 50 像素,模型基本看不见。解决办法是先做一次检测裁剪,把主体抠出来再识别,准确率会有明显提升。
5.2 阈值和 Top-K 的调参方法
调参不要凭感觉。我的做法是准备一个小的验证集,一百到三百张图,人工标注出每张图“至少应该出现哪些标签”,然后写个脚本扫不同阈值组合,算准确率和召回率。
def evaluate(preds, gts, thr): hit, total_pred, total_gt = 0, 0, 0 for p, g in zip(preds, gts): selected = {t["label"] for t in p if t["score"] >= thr} total_pred += len(selected) total_gt += len(g) hit += len(selected & set(g)) precision = hit / total_pred if total_pred else 0 recall = hit / total_gt if total_gt else 0 f1 = 2 * precision * recall / (precision + recall) if precision + recall else 0 return {"thr": thr, "p": round(precision, 3), "r": round(recall, 3), "f1": round(f1, 3)} for t in [0.2, 0.25, 0.3, 0.35, 0.4, 0.45, 0.5]: print(evaluate(preds, gts, t))跑完你会看到一条典型的曲线:阈值低,召回高精度低;阈值高反过来。选哪个点取决于业务,审核场景偏向高精度(宁可漏,不可错),检索和推荐偏向高召回(宁可多,用户能自己筛)。这个取舍没有标准答案,得业务方拍板。
5.3 用自有数据做微调的最小可行路径
如果通用模型在你的场景里确实差得远,那就考虑微调。但微调不是从零训,正确的做法是拿通用模型的权重做初始化,只改最后的分类头,用小学习率训几轮。
数据量上,每个类别有几十到上百张就能看到效果,前提是质量要好、多样性要够。标注的时候要注意,多标签场景下负样本也要标,不能只标出现的东西,不然模型学不到“什么不在图里”。
微调最大的风险是灾难性遗忘:学了你自己的类别之后,原来通用模型会的那些类别反而不认识了。缓解办法是混合训练,把你的数据和一部分通用数据混在一起训,或者在损失函数里对原始类别加权。
提示:微调之前一定要先跑通并记录通用模型的基线数字。没有基线,你永远不知道微调是提升了还是退步了。我见过有人微调完感觉变好了,一测发现整体指标其实是降的,只是在某几张图上看起来顺眼。
6. 常见问题与排查技巧实录
6.1 结果离谱时的排查顺序
第一步查标签映射。输出全是风马牛不相及的标签,八成是标签文件行号和模型索引对不上,或者标签文件本身是英文的你没换。这个错误概率最高,先排。
第二步查预处理。把预处理完的张量还原成图片存下来看一眼,是不是黑的、是不是只有物体一角、是不是颜色通道反了。我遇到过一次 OpenCV 读图没转 RGB,所有结果都偏,找了两个小时。
第三步查输出层类型。sigmoid 的分数直接当 softmax 用,或者反过来,分数分布会完全不对。判断方法很简单:把一张图的所有类别分数加起来,接近 1 说明是 softmax,明显大于 1 说明是 sigmoid。
第四步查输入尺寸。模型训练用 384,你喂 224,结果会明显变差但不会报错,这种“静默错误”最难查。
6.2 速度慢和内存爆的定位方法
慢的原因通常就三个:线程数设错、没开图优化、预处理成了瓶颈。定位方法是分段计时,预处理多久、推理多久、后处理多久,打印出来一看便知。我遇到过一次推理只占 20%,剩下 80% 全耗在图片解码上,换了更快的解码库之后整体快了三倍。
内存爆一般是 batch 设太大,或者图片没及时释放。批量处理时如果用列表把所有图片都读进内存再处理,几万张图直接把内存打满。正确做法是流式处理,读一张、处理一张、释放一张。
| 现象 | 最可能的原因 | 快速验证方法 |
|---|---|---|
| 结果完全离谱 | 标签索引错位 / 通道顺序反了 | 用一张典型图人工核对 Top-1 |
| 分数普遍偏低 | 输出层类型判断错 / 阈值设太高 | 检查分数总和是否接近 1 |
| 长尾类别从不出现 | 阈值一刀切 / 类别本身不在词表 | 单独把该类分数打印出来看 |
| 处理速度慢 | 线程数不合理 / 预处理成瓶颈 | 分段计时 |
| 内存持续上涨 | 图片对象未释放 / 结果全量缓存 | 观察处理过程中的常驻内存 |
| 同一张图结果抖动 | 随机预处理 / 多线程共享状态 | 关闭随机增强,固定种子 |
6.3 我踩过的几个具体的坑
图片带 alpha 通道。PNG 截图转成 RGB 之后,透明区域会变成黑色,模型看到一大片黑,识别率直接崩。处理办法是先把透明背景铺成白色再转。
EXIF 旋转。手机拍的图经常带旋转信息,PIL读出来是按 EXIF 自动转的,但如果中间经过别的库处理,可能又转回去了,结果就是横着的图。统一在一开始就做ImageOps.exif_transpose。
长图和小图。电商详情页那种超长图,按短边缩放再中心裁剪,裁出来的可能是一片空白。这类图要先做切分,分块识别再合并结果。
分数校准。sigmoid 输出的分数不是概率意义上的置信度,0.8 不代表 80% 正确。如果业务上要展示置信度,最好用验证集做一次温度缩放校准,否则用户会觉得“它说 90% 准怎么老是错”。
模型版本管理。换模型的时候如果没有版本号,出了问题根本没法回溯是哪个版本、哪批数据出的错。我现在所有结果表都强制带模型哈希和预处理配置的指纹。
并发下的 session 复用。推理 session 创建开销不小,每次请求都新建会拖垮性能。但同一个 session 在多线程下并发调用,要确认推理库本身是线程安全的,一般建议一个线程一个 session,或者用锁保护。我倾向于进程内维护一个 session 池,按需取用。
我在实际项目里最深的体会是:通用识图这件事,模型本身只占三成工作量,剩下七成全在预处理对齐、标签体系治理、阈值调优和工程稳定性上。很多人卡住不是因为模型不行,而是因为这七成没做扎实。所以如果你刚开始做,别急着换模型,先把一条链路端到端跑通、把自己的数据跑一遍、把每个环节的数字量出来,问题自然会浮出水面。
最后分享一个小技巧:给你的识别服务加一个“可疑结果采样”机制,按分数区间和类别分布做分层抽样,每天自动抽一批图和结果存下来,人工定期扫一眼。这比等用户投诉要主动得多,而且积累下来的这批数据,就是你后面做微调最值钱的素材。