☰
TensorFlow目标检测工程实战:从数据准备到端侧部署
2026/10/27 12:30:40 网站建设 项目流程

1. 目标检测这件事,TensorFlow 能帮你做到哪一步

做视觉项目的人大概都有这么一段经历:一开始只想让程序“认出图里有什么”,分类模型跑通了,准确率还挺好看;等到业务方把需求改成“告诉我它在哪、有几个、有没有被挡住”,分类模型立刻就不够用了。TensorFlow 目标检测这条线,解决的正是从“整张图一个标签”到“每个目标一个框加一个类别”的跨越。它输出的不只是一个概率,而是一组结构化的结果:边界框坐标、类别、置信度,以及每张图里目标的个数。

我把这套东西的适用人群分成三类。第一类是刚学完卷积网络、想找一个完整工程练手的学生或转行者,目标检测是最好的综合训练场,数据管道、模型训练、评估指标、模型导出、端侧部署,一条链路全走一遍,比看十篇论文都实在。第二类是有明确业务需求的开发者,比如要做货架商品计数、工业零件缺陷定位、停车场车位占用判断,这些人不需要从零推导公式,需要的是能跑起来、能改、能上线的方案。第三类是已经在用其他框架、想把模型迁移到 TensorFlow 生态做统一部署的工程师,重点在格式转换和推理性能。

这三类人的切入点完全不同,但有一个共同点:目标检测的坑,八成不在模型结构上,而在数据和工程细节上。我见过太多人花两周调网络结构,最后发现是标注框的坐标归一化方式搞错了;也见过模型在验证集上 mAP 挺漂亮,一上线就崩,原因是训练时的图像预处理和推理时的预处理不一致。所以下面的内容我会把重心放在“为什么这么做”和“哪里容易翻车”上,模型原理只讲到够用的程度。

1.1 检测任务的本质:分类加回归的组合拳

把一张图送进网络,检测模型要同时回答两个问题:这个位置有没有目标、是什么类别;以及这个目标的边界在哪。前者是分类问题,后者是回归问题,两路输出共享同一个特征提取骨干网络,这就是所有现代检测器的基本骨架。理解这一点很关键,因为它决定了损失函数的设计:分类分支用交叉熵或者 Focal Loss,回归分支用 Smooth L1、GIoU、DIoU 这类对框位置敏感的损失,两路损失的权重配比会直接影响训练稳定性。

再往下分,检测器有两条主要技术路线。两阶段方法(以 Faster R-CNN 为代表)先由区域提议网络粗筛出可能包含目标的候选框,再对每个候选框做精细分类和坐标修正,精度高但速度慢。单阶段方法(SSD、RetinaNet、EfficientDet、CenterNet 这些)直接在特征图上密集预测,省掉提议环节,速度快,适合实时场景。选哪条路线不取决于哪个“更先进”,而取决于你的延迟预算和目标尺寸分布。

还有一个容易被忽略的概念是锚框(anchor)。早期单阶段模型会在每个特征图位置上预设若干不同尺度和长宽比的参考框,网络预测的是真实框相对锚框的偏移量。锚框的尺寸设置必须覆盖你数据集中目标的实际分布,如果数据集里全是几十像素的小目标,而锚框最小也是 64 像素起步,那召回率怎么调都上不去。现在也有不少 anchor-free 的方案(CenterNet 用中心点加热力图),省掉了调锚框的麻烦,但对密集重叠目标的处理要更小心。

1.2 TensorFlow 生态里真正能干活的几块拼图

很多人对 TensorFlow 的印象还停留在“写个 Keras 模型”,实际上做目标检测要用到的是一整套组件,我按使用顺序列一下。

组件作用什么时候会用到
tf.data构建高效数据管道从第一天到最后一刻
Keras / tf.keras定义和加载模型自定义结构或改头时
Model Garden官方维护的检测模型库与训练脚本直接拿现成模型微调
TensorFlow Hub预训练权重分发加载骨干网络初始化
TensorBoard训练过程可视化调参和排查异常
TF Lite端侧推理与量化手机、嵌入式设备部署
TF Serving服务端模型托管在线 API 服务

这里重点说 Model Garden。它提供的价值不是几个模型文件,而是一套经过验证的配置驱动训练流程:pipeline config 里写清楚数据集路径、模型结构、优化器参数、数据增强策略,训练脚本读配置就跑。这意味着你换模型只需要换配置,不用重写训练循环。对个人项目来说,这能省掉至少一周的脚手架开发时间。代价是配置项多、版本耦合紧,不同版本之间字段名会变,这点后面会专门讲怎么避坑。

tf.data 则是另一个必须吃透的部分。检测任务的数据预处理比分类复杂得多:图像要缩放,框坐标要跟着同步变换;要做随机翻转,框的 xmin/xmax 要互换;要做随机裁剪,裁掉的框得过滤掉。这些操作如果写在 Python 生成器里,训练速度会被 CPU 卡死。用 tf.data 的 map 加并行调用,再把预处理后的数据缓存起来,吞吐量能差出好几倍。

1.3 不同基础的人该怎么切入

如果你完全没有检测经验,我的建议是先用官方预训练模型跑一遍推理,看到框画在图上,建立直观感受。这一步不需要训练,几行代码就能完成,但能帮你理解模型的输入输出格式。然后再拿一个小型公开数据集(几百张图就够)走一遍完整的微调流程,重点观察 loss 曲线和评估指标的变化。

如果你有分类模型的经验,可以直接从迁移学习切入,跳过从零训练。加载在通用数据集上预训练的检测模型,冻结骨干网络,只训练检测头,用较小的学习率跑几千步,通常就能得到一个可用的基线。这个阶段的重点是理解配置文件的每个字段在控制什么,而不是急着提升指标。

如果你已经在做工程部署,那注意力应该放在导出和量化上。训练一个 FP32 模型只是开始,真正决定项目成败的是 INT8 量化后的精度损失有多大、端侧单帧推理能不能压到 30 毫秒以内、服务端批量推理的吞吐能到多少。这些问题在训练阶段就要开始规划,等到模型训完再想量化,往往会发现某些结构不支持,得回头改。

2. 路线选型:模型、输入尺寸与部署目标的三方平衡

选模型这件事,新手最容易犯的错是“挑精度最高的”。实际上目标检测的选型是一个三元约束问题:精度、速度、部署成本,你最多同时满足两个。我一般建议先确定部署端,再倒推模型,最后定输入分辨率,这个顺序反了会反复返工。

2.1 主流检测模型在 TensorFlow 里的横向对比

下面这张表是我自己在几个项目里积累的量级参考,具体数值会随数据集和训练策略浮动,但相对关系基本稳定。

模型输入尺寸精度量级(通用数据集 mAP)端侧单帧延迟量级适合场景
SSD MobileNetV2 FPNLite320×32020~2315~30 ms手机实时检测、算力受限设备
SSD MobileNetV2 FPNLite640×64027~2960~100 ms移动端但要求较高精度
EfficientDet-D0512×51232~3480~150 ms边缘盒子、精度优先
EfficientDet-D1~D3640~89635~42200 ms 以上服务端或高性能边缘设备
Faster R-CNN ResNet50640×64035~37300 ms 以上离线批量处理、精度优先
CenterNet ResNet50512×51228~31100~200 ms需要 anchor-free 的场景

选型时别只看这张表,要问自己三个问题。第一,目标的最小像素尺寸是多少?如果图里有 20 像素的小目标,320 的输入基本没戏,因为特征图下采样 8 倍后,一个目标还占不到三个格子。第二,单帧延迟预算是多少?如果业务要求 30 fps,那模型推理就必须压在 30 毫秒以内,还得给前后处理留余量。第三,部署设备的算力如何?同样一个模型,在中端手机上跑和高性能边缘盒子上跑,延迟能差五倍。

2.2 为什么我不建议一上来自己搭网络

自己搭检测网络听起来很酷,实际上是把大量时间花在了已经被解决过无数次的问题上。检测头的设计、正负样本分配策略、损失函数的数值稳定性处理、多尺度特征融合方式,这些细节在成熟模型里都已经调好了。你自己搭一个,大概率是结构对了但训练不收敛,然后陷入漫长的调试。

更合理的做法是在成熟模型上做定向改造。比如你的数据集类别只有五类,那就把分类头的输出通道从 90 改到 5,重新初始化这一层的权重,其余部分加载预训练。再比如你的目标特别小,可以在特征金字塔里增加一个更高分辨率的层,但要同步调整锚框尺寸。这类改造有明确的起点和参照,改完能立刻对比效果,比从零开始可控得多。

当然,有一种情况值得自己写:你要做的任务和标准检测差异很大,比如需要输出旋转框、需要做实例分割、需要处理超大分辨率遥感图。这时候成熟模型的假设不成立了,改造的成本可能超过重写。判断标准很简单,如果你的需求能在现有模型的输出格式里表达出来,就改;表达不出来,再考虑自己搭。

2.3 输入分辨率的账要这么算

输入分辨率是影响精度和速度最直接的杠杆,但很多人调这个参数是靠试。其实可以算。假设骨干网络的总下采样倍数是 32,输入 320×320,那么最深层特征图是 10×10,每个格子对应原图 32×32 的区域。检测头在浅层特征图上做预测时,下采样倍数可能是 8,对应 40×40 的特征图,每个格子管 8 个像素。

一个经验法则:目标在特征图上的尺寸不应小于 3×3 个格子,否则特征信息在卷积过程中会被稀释掉。反推一下,如果下采样倍数是 8,最小可靠检测尺寸大约是 24 像素;下采样 16 倍则是 48 像素。这意味着 320 输入配 stride 8 的检测层,理论上能检测 24 像素以上的目标,但实际因为感受野和特征质量,通常要 32 像素以上才稳。

输入从 320 提到 640,边长翻倍,计算量大致翻四倍(卷积计算量和特征图面积成正比)。这就是为什么 640 模型在端侧跑不动。如果精度不够但又不能加分辨率,可以考虑的替代方案是:用更高分辨率的浅层特征做检测、对输入做切块推理再合并结果、或者针对小目标单独训一个模型做二次确认。

2.4 先定部署端,再回头定训练方案

我吃过这个亏。早期做一个嵌入式项目,模型训得很好,导出时才发现目标设备的推理引擎只支持有限算子,模型里的某些后处理操作得挪到 CPU 上做,延迟直接翻倍。从那以后我的流程固定为:先确认部署端的算子支持列表和算力,再选模型结构,最后才开始训练。

端侧部署要考虑的不只是模型大小,还有量化后的精度损失。同样是 INT8 量化,MobileNet 系列的深度可分离卷积对量化比较友好,精度掉一两个点;而某些结构在量化后会出现明显的精度塌陷。这个差异在训练阶段看不出来,只有实际转一遍才知道。所以我的建议是,在正式训练前先用一个小数据集训几千步,导出一版量化模型跑通全链路,确认没问题再投入长时间训练。

服务端部署相对宽松,算子支持基本不是问题,重点在吞吐。TF Serving 支持请求批处理,把多个请求攒成一批一起推理,能显著提升 GPU 利用率。但这要求模型的输入 batch 维度是动态的,导出时要把签名设好,否则批处理用不上。

3. 数据准备:标注、转换与增强的完整链路

我在多个项目里统计过,检测任务最终效果不达标的原因,数据问题占七成以上,模型和训练策略加起来才占三成。数据这部分值得单独花大篇幅讲,因为它不可逆——标错了重新标一遍,成本比调参高得多。

3.1 标注规范决定模型上限

标注这件事,最怕的是标准不统一。同一个目标,A 标注员框得紧贴边缘,B 标注员留了两像素余量,模型学到的边界就会模糊,定位精度上不去。所以开工前必须把规则写死,并且用几十张图做一致性校验。

我总结的几条硬规则:

  • 框要贴紧目标可见部分的边界,不要包含大片背景。对于被遮挡的目标,只标可见部分,不要脑补完整轮廓。
  • 极小目标要么统一标,要么统一不标。如果 10 像素以下的目标时标时不标,模型会学到矛盾的信号。建议设一个尺寸下限,低于这个尺寸的统一忽略。
  • 类别定义要不重叠。如果有“汽车”和“卡车”两个类,边界车型怎么算必须提前定好,否则同类目标会被标成不同类别,模型困惑。
  • 负样本要留。全是含目标的正样本,模型会倾向于在任意位置输出框。保留一定比例的空图或只有背景的图,能压低误检率。
  • 框不能越界。坐标超出图像范围会导致训练时的裁剪和缩放出现负坐标,轻则损失异常,重则 NaN。

还有一个细节:标注文件的坐标格式。有的工具存的是左上角加宽高,有的是 xmin/ymin/xmax/ymax,有的是归一化到 0~1,有的是绝对像素。转换到训练格式时一定要确认清楚,这是最容易出错的地方,而且错了之后 loss 曲线看起来可能“正常”,只是 mAP 一直上不去,非常难排查。

3.2 从 VOC/COCO 格式生成 TFRecord

TensorFlow 的官方训练脚本通常要求 TFRecord 格式,每条记录里包含编码后的图像字节和一组目标信息。核心是把标注转成 tf.train.Example,字段名必须和训练脚本里解析的键完全一致,这是新手最常翻车的地方。

下面是一个转换脚本的关键片段,字段命名参照官方检测管道的约定:

import tensorflow as tf import numpy as np from PIL import Image import os def create_example(image_path, boxes, classes, width, height): with open(image_path, 'rb') as f: encoded = f.read() # 坐标归一化到 0~1,注意顺序是 ymin, xmin, ymax, xmax xmins, ymins, xmaxs, ymaxs = [], [], [], [] for b in boxes: xmins.append(b[0] / width) ymins.append(b[1] / height) xmaxs.append(b[2] / width) ymaxs.append(b[3] / height) feature = { 'image/encoded': tf.train.Feature( bytes_list=tf.train.BytesList(value=[encoded])), 'image/height': tf.train.Feature( int64_list=tf.train.Int64List(value=[height])), 'image/width': tf.train.Feature( int64_list=tf.train.Int64List(value=[width])), 'image/filename': tf.train.Feature( bytes_list=tf.train.BytesList(value=[os.path.basename(image_path).encode()])), 'image/object/bbox/xmin': tf.train.Feature( float_list=tf.train.FloatList(value=xmins)), 'image/object/bbox/ymin': tf.train.Feature( float_list=tf.train.FloatList(value=ymins)), 'image/object/bbox/xmax': tf.train.Feature( float_list=tf.train.FloatList(value=xmaxs)), 'image/object/bbox/ymax': tf.train.Feature( float_list=tf.train.FloatList(value=ymaxs)), 'image/object/class/label': tf.train.Feature( int64_list=tf.train.Int64List(value=classes)), } return tf.train.Example(features=tf.train.Features(feature=feature)) def write_tfrecord(records, output_path): with tf.io.TFRecordWriter(output_path) as writer: for image_path, boxes, classes in records: with Image.open(image_path) as im: width, height = im.size ex = create_example(image_path, boxes, classes, width, height) writer.write(ex.SerializeToString())

几个必须注意的点。类别标签在官方管道里通常从 1 开始,0 是保留的背景类,如果你的标注从 0 开始,要统一加一,否则类别会整体偏移一位,表现为“所有目标都识别成相邻类别”。坐标归一化要用图像真实尺寸,不是缩放后的尺寸。还有图片编码格式,官方脚本默认按 JPEG 解码,如果你存的是 PNG 或者灰度图,管道里要相应调整解码函数,否则会在训练时报通道数不匹配。

3.3 tf.data 管道的正确打开方式

数据管道写得好不好,直接决定 GPU 利用率。一个典型的低效管道是:每个 batch 都从磁盘读图、解码、做增强,然后在 Python 里转成 numpy,GPU 大部分时间在等数据。正确做法是把能并行的都并行,能缓存的都缓存。

AUTOTUNE = tf.data.AUTOTUNE def parse_fn(serialized): features = { 'image/encoded': tf.io.FixedLenFeature([], tf.string), 'image/object/bbox/xmin': tf.io.VarLenFeature(tf.float32), 'image/object/bbox/ymin': tf.io.VarLenFeature(tf.float32), 'image/object/bbox/xmax': tf.io.VarLenFeature(tf.float32), 'image/object/bbox/ymax': tf.io.VarLenFeature(tf.float32), 'image/object/class/label': tf.io.VarLenFeature(tf.int64), } parsed = tf.io.parse_single_example(serialized, features) image = tf.image.decode_jpeg(parsed['image/encoded'], channels=3) boxes = tf.stack([ tf.sparse.to_dense(parsed['image/object/bbox/ymin']), tf.sparse.to_dense(parsed['image/object/bbox/xmin']), tf.sparse.to_dense(parsed['image/object/bbox/ymax']), tf.sparse.to_dense(parsed['image/object/bbox/xmax']), ], axis=1) labels = tf.sparse.to_dense(parsed['image/object/class/label']) return image, boxes, labels def augment(image, boxes, labels): # 水平翻转,框坐标同步交换 if tf.random.uniform(()) > 0.5: image = tf.image.flip_left_right(image) w = tf.cast(tf.shape(image)[1], tf.float32) boxes = tf.stack([ boxes[:, 0], w - boxes[:, 2], boxes[:, 2], w - boxes[:, 1], ], axis=1) boxes = tf.clip_by_value(boxes, 0.0, w) image = tf.image.random_brightness(image, 0.2) image = tf.image.random_contrast(image, 0.8, 1.2) return image, boxes, labels dataset = tf.data.TFRecordDataset(files, num_parallel_reads=AUTOTUNE) dataset = dataset.map(parse_fn, num_parallel_calls=AUTOTUNE) dataset = dataset.map(augment, num_parallel_calls=AUTOTUNE) dataset = dataset.cache() dataset = dataset.shuffle(1000) dataset = dataset.batch(batch_size, drop_remainder=True) dataset = dataset.prefetch(AUTOTUNE)

这里的顺序有讲究。cache()放在增强之后、shuffle 之前,意味着增强只做一次然后缓存,适合小数据集;如果数据集大到内存放不下,就把 cache 去掉,靠 prefetch 和并行 map 撑吞吐。shuffle的缓冲区要足够大,否则样本顺序仍然近似固定,影响收敛。drop_remainder=True是为了避免最后一个不满的 batch 导致某些层报形状错误。

注意:增强操作里凡是改变几何位置的,框坐标必须同步变换,而且变换后要裁剪到图像范围内。漏了裁剪,框坐标可能出现负值或超出宽高,训练时算 IoU 会出异常。

3.4 小目标与类别不平衡的补救手段

小目标检测是检测任务里最难啃的部分,没有之一。根本原因是小目标经过多次下采样后,在特征图上只剩一两个像素的信息,卷积核根本提取不到有效特征。补救手段有几种,我按性价比排序。

提高输入分辨率是最直接的,代价是计算量平方级增长。使用特征金字塔,在浅层高分辨率特征图上加检测头,代价是显存占用增加。切块推理,把大图切成重叠的小块分别检测再合并,适合超大分辨率场景,但要处理跨块目标的合并问题。专门的小目标数据增强,比如随机把小目标复制粘贴到图像其他位置,增加正样本密度,这个技巧在一些竞赛里效果明显,但要注意粘贴位置不能和目标重叠。

类别不平衡的处理相对成熟。如果某些类别样本极少,最有效的办法是补数据,其次是重采样(让稀有类别更频繁出现),再其次是损失函数层面用 Focal Loss 压制易分负样本的权重。我一般不建议一上来就上复杂的损失函数,先把每个类别的样本数统计出来,差一个数量级以内的话,重采样基本够用;差两个数量级以上,光靠损失函数救不回来,必须补数据。

还有一个常被忽略的问题是背景样本比例。单阶段检测器在训练时会在大量候选位置里采样,如果正负样本比例极端失衡,模型会退化成“全部预测背景”。多数官方实现里有采样策略来处理这个,但你如果用自定义训练循环,要自己控制正负样本比例,经验值在 1:3 到 1:10 之间。

4. 训练实操:从预训练权重到稳定收敛

走到这一步,数据准备好了,模型选定了,接下来是把训练跑起来并且跑稳。这部分内容看起来是“按脚本执行”,实际上细节最多,一个参数不对就可能浪费几天。

4.1 环境搭建与版本对齐

TensorFlow 版本和 Model Garden 代码版本必须匹配,这是硬约束。不同的代码分支对应不同的 TF 版本,混用会报各种奇怪的导入错误。我的做法是先确定要用的代码版本,再看它要求的 TF 版本,然后用虚拟环境隔离安装。

依赖里最容易出问题的是目标检测 API 的编译,如果要用到某些自定义算子,需要先编译再使用,编译失败通常是因为编译器版本或者 CUDA 版本不匹配。纯 Python 的模型(比如 SSD 系列)不需要编译,能省掉很多麻烦,这也是我推荐新手从 SSD 起步的原因之一。

GPU 环境还要确认三件事:驱动版本支持当前 CUDA、cuDNN 版本对应、显存够不够。显存不够不会在导入时报错,而是在训练第一步就抛资源耗尽,看起来像是代码问题,实际是硬件边界。

4.2 配置文件里必须盯住的字段

配置文件项很多,但真正影响结果的只有那么十几个,我按重要性列出来。

配置类别关键字段常见错误
数据集训练集和验证集 TFRecord 路径、标签映射文件路径写错、标签映射的 id 和标注不一致
模型骨干网络类型、是否加载预训练、输入尺寸预训练权重路径失效但没报错
锚框各尺度的锚框尺寸与长宽比和数据集目标尺寸分布严重不匹配
优化器学习率、动量、衰减策略微调时学习率设成从头训练的量级
批大小batch size、是否用多卡超过显存导致 OOM
数据增强随机裁剪、翻转、颜色抖动的概率增强过强导致小目标被裁没
训练步数num_steps、评估间隔、保存间隔评估太频繁拖慢训练

标签映射文件特别值得说一句。它是一个把类别名映射到数字 id 的文本文件,训练时按这个映射把标注的 id 转成类别索引,评估和推理时又按这个映射把索引转回名字。如果映射文件里的 id 顺序和标注文件不一致,训练出来的模型会把类别张冠李戴,而且 loss 曲线完全正常,只有看混淆矩阵才能发现。

4.3 迁移学习的冻结与解冻节奏

从预训练权重开始微调,核心是控制“改多少”。一开始把骨干网络冻结,只训练检测头,让新初始化的分类层先收敛到一个合理的状态。这个阶段学习率可以稍微大一点,因为只更新少量参数,不容易破坏已有特征。

等 loss 稳定后,解冻骨干网络的后半部分,用更小的学习率联合训练。为什么要分层设置学习率?因为浅层学到的是通用特征(边缘、纹理),这些特征对几乎所有视觉任务都有效,没必要大改;深层特征更任务相关,需要调整。统一用大学习率会把浅层的好特征冲掉,表现为训练前期 loss 下降很快,后期验证指标反而变差。

解冻的节奏我一般分三段:第一阶段冻结全部骨干,跑总步数的 20%;第二阶段解冻最后两个 stage,学习率降到原来的十分之一,跑 40%;第三阶段全部解冻,学习率再降一个量级,跑剩下 40%。这个比例不是金科玉律,但比“一上来全解冻”稳得多。

4.4 学习率、batch size 与显存的量化估算

显存估算有个粗略方法:先算参数量,FP32 下每个参数占 4 字节,训练时权重、梯度、优化器状态各一份,大概是参数量的 12 倍。但这只是静态部分,真正吃显存的是激活值,和 batch size、输入分辨率成正比。

举个例子,一个 500 万参数的检测模型,静态显存约 60 MB,看起来很小,但输入 640×640、batch 16 的时候,激活值可能占到 8 GB 以上。所以调 batch size 的时候,如果 OOM,优先降 batch 或者降分辨率,别去纠结参数量。

学习率和 batch size 的关系,经验规则是线性缩放:batch 翻倍,学习率也翻倍。但这个规则在微调场景要打折,因为预训练权重已经在一个较好的位置,学习率太大会直接跳出去。我微调时的起点通常是 1e-4 到 5e-4 之间,batch 在 8 到 16,先用小学习率跑几百步看 loss 是否平稳下降,再决定要不要放大。

还有一个细节是warmup。训练最开始几十步,模型参数还没稳定,用正常学习率容易震荡。加一个线性的 warmup,让学习率从很小逐渐升到设定值,能明显改善前期稳定性。多数官方配置里都有这个字段,别把它关掉。

4.5 训练监控与断点续训

训练启动后,TensorBoard 是必须开的。我重点看四条曲线:总 loss、分类 loss、回归 loss、学习率。正常情况是总 loss 快速下降然后缓慢收敛,分类和回归两条曲线同步下降。如果分类 loss 降但回归 loss 不降,说明分类学得动但定位学不动,可能是锚框和框编码方式有问题;如果回归 loss 降但分类不降,可能是类别不平衡太严重。

评估指标也要定期看,但不能太频繁,因为评估本身要跑一遍验证集,很耗时。我一般设成每训练几千步评估一次,评估结果和训练 loss 对照着看。如果训练 loss 持续下降但验证 mAP 停滞甚至下降,那就是过拟合了,该加增强或者早停。

断点续训这块,官方脚本一般支持从 model_dir 里最新的检查点恢复,但要注意优化器状态是否一起恢复。只恢复权重不恢复优化器状态,续训初期 loss 会有个跳变,需要几百步才能恢复,这属于正常现象,不用慌。

5. 评估与调优:mAP 不达标时的排查路径

模型训完了,评估指标出来了,接下来是所有人最焦虑的环节:mAP 只有 0.3,到底哪里出了问题。这一章我把评估指标的原理和排查顺序讲清楚,避免盲目调参。

5.1 IoU、NMS 与 mAP 的计算逻辑

IoU(交并比)是检测任务的基石,计算的是预测框和真实框的交集面积除以并集面积。两个框完全重合是 1,完全不重叠是 0。一般判定预测正确要求 IoU 大于某个阈值,常用 0.5。

NMS(非极大值抑制)解决的是重复检测问题。模型在同一个目标周围会输出几十个框,NMS 按置信度排序,保留最高的,然后把和它 IoU 超过阈值的其他框删掉,重复这个过程。阈值设太高(比如 0.9)会留下一堆重复框,设太低(比如 0.3)会把相邻的两个真实目标误删一个。密集场景下这个参数很敏感,我通常从 0.5 开始试,根据实际效果微调。

mAP的计算稍微绕一点。对每个类别,把所有预测按置信度排序,逐个判定是真正例还是假正例,算出不同召回率下的精确率,画出 PR 曲线,曲线下面积就是这一类别的 AP。所有类别的 AP 取平均就是 mAP。常见的 mAP@0.5 是 IoU 阈值取 0.5 时的结果,mAP@[0.5:0.95] 是阈值从 0.5 到 0.95 每隔 0.05 算一次再平均,后者更严格,也更能反映定位精度。

手动算一个小例子帮助理解。假设某类别有两个真实框,模型输出了三个预测,按置信度从高到低:

预测序号置信度与真实框最大 IoU判定(阈值 0.5)累计 TP累计 FP精确率召回率
10.950.82TP101.000.50
20.800.61TP201.001.00
30.400.30FP210.671.00

PR 曲线上的点就是(召回率,精确率),这个例子里曲线在召回 0.5 到 1.0 区间精确率保持 1.0,之后掉到 0.67,AP 大约 0.83。理解这个过程后,你就能明白为什么“置信度阈值调高”会让召回率下降、“NMS 阈值调低”会让密集目标漏检。

5.2 评估流程与结果解读

官方评估脚本一般会输出每个类别的 AP 和总体 mAP,还会给出不同尺度目标的分项结果(小、中、大)。这个分项结果非常有用,能快速定位问题方向。如果小目标 AP 明显低于大目标,说明输入分辨率或者特征金字塔有问题;如果所有类别都低,说明训练本身有问题;如果只有某一类低,说明这类样本不足或者标注质量差。

还有一个指标值得关注:AR(平均召回率)。mAP 低但 AR 高,说明模型能找到目标但定位不准或者分类错,问题在回归头或分类头;mAP 和 AR 都低,说明模型根本没找全目标,问题在特征提取或锚框匹配。

评估时最容易忽略的一点是预处理一致性。训练时图像做了归一化(比如减均值除方差),评估和推理时也必须做同样的处理。我见过一个案例,训练时用了归一化,导出模型时忘了把归一化层包含进去,导致线上推理的输入分布和训练时差了一个量级,模型输出全是垃圾。这种问题在离线评估时发现不了,因为评估脚本自己做了归一化。

5.3 精度调优的优先级清单

mAP 不达标时,按下面的顺序排查,别跳步,跳步会浪费大量时间。

优先级排查项判断方法处理方式
1标注质量随机抽 50 张图可视化标注框修正错标、漏标、类别错误
2数据与标签映射检查类别 id 是否偏移统一映射文件与标注 id
3预处理一致性对比训练与评估的输入分布统一归一化和缩放逻辑
4锚框匹配度统计真实框尺寸分布与锚框对比重新聚类生成锚框尺寸
5学习率与训练步数看 loss 是否收敛调整学习率、延长训练
6数据增强强度关闭增强对比效果降低裁剪、翻转概率
7模型容量换更大骨干对比升级模型或增加分辨率
8NMS 与置信度阈值在验证集上扫参数选最优组合

这个顺序的逻辑是:从成本低、影响大的开始。改标注虽然麻烦,但它是根因,标注错了后面怎么调都是白费。锚框重聚类是个被低估的技巧,用 k-means 把训练集所有真实框的宽高聚成 6 到 9 类,替换掉默认锚框,很多场景下能直接涨几个点。

6. 导出与部署:从 SavedModel 到端侧与服务端

训练出模型只是完成了一半,能不能在目标设备上稳定跑起来才是项目成败的关键。这一章讲导出、量化和两种典型部署形态。

6.1 导出 SavedModel 与签名检查

训练脚本一般会在训练结束后自动导出,也可以手动调导出脚本。导出的产物是一个包含计算图和权重的目录,关键是要确认签名(signature)正确。检测模型的输入通常是编码后的图像字节或者预处理后的张量,输出是一组框、分数、类别和数量。

导出后一定要做一次加载验证:加载模型,喂一张训练集里的图,看输出的框画在图上是否合理。这一步能抓出大部分导出问题,比如输入尺寸写死导致无法处理不同大小的图、后处理参数没保存导致 NMS 失效、输出张量顺序和文档不一致。

import tensorflow as tf import numpy as np loaded = tf.saved_model.load('exported_model/saved_model') infer = loaded.signatures['serving_default'] image = tf.io.read_file('test.jpg') image = tf.image.decode_jpeg(image, channels=3) image = tf.image.resize(image, (320, 320)) image = tf.cast(image, tf.uint8)[tf.newaxis, ...] result = infer(input_tensor=image) boxes = result['detection_boxes'].numpy()[0] scores = result['detection_scores'].numpy()[0] classes = result['detection_classes'].numpy()[0] for box, score, cls in zip(boxes, scores, classes): if score < 0.5: continue # box 格式为 [ymin, xmin, ymax, xmax],已归一化 print(f'class={int(cls)}, score={score:.3f}, box={box}')

注意:不同模型导出的输出键名可能不同,有的是 detection_boxes,有的是 detection_boxes_raw。加载后先打印签名里的输入输出名,别照抄网上的代码。

6.2 训练后量化与量化感知训练

端侧部署绕不开量化。FP32 模型转 INT8,模型体积大约缩到四分之一,推理速度通常能提升两到三倍,代价是精度会有不同程度下降。

训练后量化最简单的做法是提供一批代表性样本,让转换器统计激活值的动态范围:

import tensorflow as tf def representative_dataset(): for _ in range(100): # 必须是未归一化的原始输入或与训练一致的预处理输入 sample = next(iter(calib_dataset)) yield [tf.cast(sample, tf.float32)] converter = tf.lite.TFLiteConverter.from_saved_model('exported_model/saved_model') converter.optimizations = [tf.lite.Optimize.DEFAULT] converter.representative_dataset = representative_dataset converter.target_spec.supported_ops = [tf.lite.OpsSet.TFLITE_BUILTINS_INT8] converter.inference_input_type = tf.uint8 converter.inference_output_type = tf.float32 tflite_model = converter.convert() with open('model_int8.tflite', 'wb') as f: f.write(tflite_model)

代表性样本的选择很关键。要用真实场景的图片,覆盖不同光照、不同目标密度,100 到 500 张比较合适。如果只用几张图,统计出来的范围偏窄,遇到亮度差异大的输入就会溢出,表现为某些图完全检测不到目标。

如果训练后量化的精度掉得太多(比如 mAP 掉了 5 个点以上),可以考虑量化感知训练。在训练图里插入伪量化节点,让模型在训练时就适应量化误差,代价是要重新训练一遍,但精度损失通常能控制在 1 个点以内。

6.3 端侧推理的完整代码

端侧推理的典型流程是:加载解释器、预处理输入(缩放、归一化、类型转换)、调用推理、后处理(按分数过滤、坐标反算)。每一步都有坑。

import tensorflow as tf import numpy as np from PIL import Image interpreter = tf.lite.Interpreter(model_path='model_int8.tflite') interpreter.allocate_tensors() input_details = interpreter.get_input_details()[0] output_details = interpreter.get_output_details() # 输入尺寸从解释器读取,不要写死 _, h, w, _ = input_details['shape'] img = Image.open('test.jpg').convert('RGB') orig_w, orig_h = img.size img_resized = img.resize((w, h)) arr = np.array(img_resized, dtype=np.uint8)[np.newaxis, ...] interpreter.set_tensor(input_details['index'], arr) interpreter.invoke() boxes = interpreter.get_tensor(output_details[0]['index'])[0] scores = interpreter.get_tensor(output_details[1]['index'])[0] count = int(interpreter.get_tensor(output_details[3]['index'])[0]) for i in range(count): if scores[i] < 0.4: continue ymin, xmin, ymax, xmax = boxes[i] # 反算到原图坐标 print(xmin * orig_w, ymin * orig_h, xmax * orig_w, ymax * orig_h)

几个实测中遇到的问题。第一,量化模型的输入类型是 uint8,不要再除以 255,模型内部已经包含了归一化,重复处理会让输入全部变成接近 0 的值,输出什么框都没有。第二,输入张量的维度顺序在不同转换器版本里可能是 NHWC 也可能是 NCHW,一定要从 input_details 里读形状,别默认。第三,输出张量的顺序不是固定的,先打印 output_details 里每个张量的名字和形状,确认哪个是框、哪个是分数。

6.4 服务端批量推理的吞吐优化

服务端部署一般用 TF Serving,配置里有一个批处理参数值得关注。默认情况下一个请求推理一次,GPU 利用率很低。开启请求批处理后,服务端会把短时间窗口内的多个请求合并成一个 batch,吞吐能提升几倍。

配置大概长这样:

tensorflow_model_server \ --rest_api_port=8501 \ --model_name=detector \ --model_base_path=/models/detector \ --enable_batching=true \ --batching_parameters_file=batching.config

批处理配置文件里最关键的是批大小上限和超时时间。批越大吞吐越高但单请求延迟越大,超时时间决定了服务端最多等多久来凑一批。实时性要求高的场景,超时设几毫秒;离线批量处理,可以设到几十毫秒换更高吞吐。

另一个优化点是推理前的解码。如果客户端传的是 JPEG 原始字节,服务端每次都要解码,这部分是 CPU 密集的。可以考虑在客户端解码并缩放好再传,或者用支持 JPEG 直接输入的模型签名。实测下来,把解码挪到客户端能让服务端 QPS 提升不少。

7. 踩坑实录:这些问题我几乎每次都遇到

这一章不按流程走,按症状走,方便你直接对号入座。

7.1 训练阶段的典型异常

loss 变成 NaN。原因通常有三个:学习率太大、框坐标没归一化、除法里出现零。先降学习率十倍试,如果还 NaN,检查输入数据的框坐标范围,有没有超出图像边界或者宽高为负。再检查损失函数里的除零保护,比如算宽高比时有没有加一个极小值。

loss 完全不降。先确认预训练权重到底加载成功了没有。有个隐蔽的情况是权重路径写错但代码没报错,静默地用了随机初始化,训练当然不动。再确认标签 id 有没有整体偏移,这个前面提过。还有一个可能是数据管道里的图像和框没对齐,比如图像做了 resize 但框没跟着缩放,模型看到的框和图像内容完全对不上。

loss 下降但评估指标不涨。这种情况多半是训练集和验证集分布不一致,或者评估时的预处理和训练不一致。可以拿训练集里的图做一次评估,如果训练集上指标很高、验证集很低,就是过拟合或者分布差异;如果训练集上也很低,那是评估流程本身有问题。

训练速度异常慢。先看 GPU 利用率,如果一直在 20% 以下,瓶颈在数据管道。把 map 的并行数调大,加 cache,确认没有在 Python 里做重活。还有一种可能是用了 tf.function 但里面有 Python 侧的条件分支,导致每次都重新追踪计算图。

7.2 精度与速度的异常

mAP 比预期低很多,但 loss 正常。优先怀疑类别映射和 NMS 参数。类别映射错了表现为某些类别的 AP 是 0,而其他类别正常。NMS 阈值太低,密集目标会被大量误删,表现为召回率上不去。

小目标全部漏检。这是结构性问题,调参救不回来。要么提高输入分辨率,要么用更高分辨率的特征层,要么切块推理。先算一下你的最小目标在特征图上占几个格子,小于 3 个格子就别挣扎了。

误检特别多。检查负样本够不够,检查置信度阈值是不是设得太低,检查训练集里有没有把背景区域也标成了目标。还有一种情况是数据增强太强,模型把增强产生的伪影当成了目标特征。

推理速度比预期慢一倍。先确认是不是在跑量化模型,再确认有没有把后处理放在 Python 循环里。检测输出可能有几百个框,逐个用 Python 算坐标变换非常慢,尽量用 numpy 向量化。还有,第一次推理会包含图初始化开销,测速度要跑多次取平均。

7.3 部署阶段的坑

量化后精度断崖式下跌。先检查代表性样本是否覆盖真实场景,再检查输入归一化有没有重复做。某些结构(比如带 SE 模块或者特殊激活函数的)对量化不友好,需要换成量化感知训练。

端侧推理结果和 PC 上不一致。逐层对比输出,找到第一个偏差大的层。常见原因是某个算子的实现有细微差异,或者量化参数在不同设备上不一致。也可以先把量化关掉,用 FP32 模型在端侧跑一遍,如果一致,说明问题出在量化;如果不一致,说明是算子实现差异。

服务端并发上来后延迟飙升。检查批处理的超时设置和最大批大小,可能是一批里混了太多请求。也可能是模型输入尺寸是固定的,客户端传了不同大小的图导致服务端频繁重设形状。解决办法是在模型签名里固定输入尺寸,客户端负责缩放。

模型更新后线上服务不生效。TF Serving 加载模型是按版本号递增的,新模型目录的版本号必须比旧的大,否则不会自动加载。改完模型记得确认版本号,别只覆盖文件。

我个人在多个项目里最深的体会是:目标检测的工程量集中在“看不见的地方”。模型结构是公开的,配置文件是模板化的,真正拉开差距的是标注质量、数据管道效率、预处理一致性、量化后的实际表现这些细节。每一次项目做完,我都会把踩过的坑记到一张清单里,下次开工先过一遍,能省掉大量重复调试。另一个小心得是,任何改动都只改一个变量,然后立刻在验证集上看结果,同时改三四个参数再跑,出了问题根本不知道是哪个引起的。训练一次可能要几个小时,但盲目调参浪费的是几天。

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

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

立即咨询