简介:面向计算机视觉与目标检测方向的学习者和开发者,这份SSD caffe模型资源提供SSD300x300在Caffe框架下落地的配套文件,适合希望理解单阶段检测网络结构、复现训练与推理流程、并具备一定Caffe基础的中级读者。包内共6个文件,以4个prototxt网络与求解配置、1个caffemodel预训练权重和1个py脚本为主,覆盖部署网络定义、训练验证配置、超参数设置、模型权重及辅助处理等环节,压缩包约93.16MB,结构紧凑。已有715人学习,说明其在SSD与Caffe结合的学习场景中具有一定参考价值。资源可用于对照理解基础网络与检测层的组织方式,厘清训练配置、权重加载与推理部署之间的文件对应关系;也可为结合OpenCV进行图像预处理、前向推理、非极大值抑制和检测框映射时提供完整模型依据,便于快速搭建实验环境并排查配置问题。
1. 只有 prototxt 和 caffemodel 的 SSD 模型包,怎么让它出第一个检测框
上周有人发来一个压缩包,解开只有几个 prototxt、一个几百 MB 的 caffemodel 和一份 labelmap,问「这玩意儿怎么跑起来」。这是典型的 SSD(Single Shot MultiBox Detector)Caffe 模型交付形态:结构写在文本里,权重是二进制,没有 README,也没有训练脚本。SSD 是单阶段检测器,一次前向同时输出类别和位置,比两阶段方案轻得多,适合算力紧张的离线场景——工控机、老显卡、只装了旧版深度学习(DL)运行时的一体机都能跑。想搞明白 8732 个先验框是怎么被压成几十个结果的,或者想在自己的数据上微调,这个包是最好的教材。下面按「结构 → 后处理 → 跑通 → 排障 → 改造」一路拆下去。
2. 结构拆解:deploy.prototxt 里的六个检测头与 8732 个先验框
拿到 Caffe 模型包,第一件不该是装环境,而是把 deploy.prototxt 从头到尾读一遍。SSD 的所有设计意图都写在这份几百行的文本里:主干截到哪一层、额外层加了几组、每个检测头输出多少通道、先验框的尺寸怎么排布。读懂了,后面环境装不上、输出为空、框位置不对,你都能反推是哪一环出的问题。我一般会先分清哪些文件是推理必需的,再看特征图尺寸链,最后核对先验框数量和分类头通道数能不能对上——这三步对上了,模型结构基本就没暗坑了。
2.1 资源目录:哪些文件部署时要留,哪些是训练残留
一个完整的 SSD Caffe 交付包里,文件通常分三类:推理必需、训练/评估用、源码依赖。新手最容易犯的错是把 train.prototxt 当成推理网络去 load,然后被数据层报错卡住半天。
| 文件 | 作用 | 部署是否必需 |
|---|---|---|
| deploy.prototxt | 推理期网络定义,输入固定为 1×3×300×300 | 必需 |
| SSD_300x300.caffemodel | 权重文件,protobuf 二进制格式 | 必需 |
| labelmap.prototxt / labelmap.txt | 类别 id 到名称的映射,通常含 background | 必需(输出可读名称时用) |
| train.prototxt | 训练期网络,含 Data/AnnotatedData 层与数据增强 | 微调时才用 |
| solver.prototxt | 学习率、迭代次数、优化器超参 | 微调时才用 |
| test.prototxt | 带评估数据层的网络,末尾接多框评估层 | 评估 mAP 时才用 |
| bbox_util / detection_output_layer 源码 | DetectionOutput 层的实现,随 SSD 分支 Caffe 一起编译 | 自编译 Caffe 时才用 |
判断一份 prototxt 是推理还是训练,看三个特征就够了:入口是input/input_shape还是Data/AnnotatedData;有没有loss_weight和Accuracy类层;末尾是不是以DetectionOutput收尾。三者都对上,才是能直接喂图的 deploy 文件。
不装 Caffe 也能先扫一遍结构,用正则粗看层名和类型,十秒钟就能判断主干有多深:
import re text = open("deploy.prototxt").read() # 粗扫:按 layer 块切出 name 和 type,先看骨架,不解析嵌套参数 for block in re.findall(r"layer\s*\{(.*?)\n\}", text, re.S): name = re.search(r'name:\s*"([^"]+)"', block) typ = re.search(r'type:\s*"([^"]+)"', block) if name and typ: print(f"{typ.group(1):<20}{name.group(1)}")这段正则只适合快速浏览,遇到嵌套的convolution_param {}会因为大括号配对问题截断。要精确解析,正确做法是拿 caffe.proto 编译出 Python 桩,再用google.protobuf.text_format.Merge把文本读成 NetParameter 对象,那样每个字段都能按名字取。我平时偷懒就用上面的正则数层数,真要做自动化工具链了才上 protobuf。
2.2 主干与多尺度特征图:conv4_3 到 conv11_2 的尺寸链
SSD 的主干是一个被截断的分类网络:保留到 pool5 之前,原本的全连接层 fc6、fc7 换成卷积层 conv6、conv7(用空洞卷积撑大感受野而不增加参数量),再往后接 conv8_2、conv9_2、conv10_2、conv11_2 四组额外层。每经过一组 stride=2 的卷积,特征图边长减半,于是形成 38→19→10→5→3→1 的尺寸链。小特征图负责大目标,大特征图负责小目标,这就是「多尺度」三个字的全部含义。
| 检测头 | 特征图边长 | 每位置先验框 | 该层框数 | 主要职责 |
|---|---|---|---|---|
| conv4_3_norm_mbox | 38×38 | 4 | 5776 | 小目标 |
| conv7_mbox | 19×19 | 6 | 2166 | 中小目标 |
| conv8_2_mbox | 10×10 | 6 | 600 | 中等目标 |
| conv9_2_mbox | 5×5 | 6 | 150 | 中大目标 |
| conv10_2_mbox | 3×3 | 4 | 36 | 大目标 |
| conv11_2_mbox | 1×1 | 4 | 4 | 全局最大目标 |
| 合计 | — | — | 8732 | — |
conv4_3 有个特殊处理:它离输入太近,特征值量级比后面几层大一到两个数量级,直接接分类卷积会让训练发散。所以这里插一个 L2Norm 层做归一化,再乘一个可学习的 scale(初始值一般给 20),把尺度拉回可比范围。这一段在 prototxt 里很短,但漏了它,模型要么训不动,要么 mAP 莫名掉几个点。
layer { name: "conv4_3_norm" type: "L2Norm" bottom: "conv4_3" top: "conv4_3_norm" norm_param { across_spatial: false scale_filler { type: "constant" value: 20 } } } layer { name: "conv4_3_norm_mbox_loc" type: "Convolution" bottom: "conv4_3_norm" top: "conv4_3_norm_mbox_loc" convolution_param { num_output: 16 # 4 个先验框 × 4 个坐标偏移 = 16 kernel_size: 3 pad: 1 stride: 1 } } layer { name: "conv4_3_norm_mbox_conf" type: "Convolution" bottom: "conv4_3_norm" top: "conv4_3_norm_mbox_conf" convolution_param { num_output: 84 # 4 个先验框 × 21 类(20 类 + background)= 84 kernel_size: 3 pad: 1 stride: 1 } }这里的关键是num_output与「每位置先验框数」的乘法关系:定位头固定是先验框数 × 4,分类头是先验框数 × 类别数。改类别数的时候只动分类头的num_output,定位头一个字都不用改。同理,改先验框配置(比如加一个小尺寸)时,两个头的num_output都要按新的框数重算,漏一个就会在 Concat 处报维度不匹配。
2.3 先验框参数:min_size / max_size / aspect_ratio 怎么算出 8732
先验框不是网络学出来的,是按规则铺出来的,规则写在各检测头的PriorBox层里。每层给三个核心参数:min_size(基准边长)、max_size(下一层基准边长,用来补一个几何平均尺寸的框)、aspect_ratio(长宽比,每个比值还会自动补一个倒数)。铺法是平铺:特征图上每个像素点都铺同样一组框,所以总框数等于特征图面积乘以每位置框数。
把六层的配置抽出来算一遍,能立刻验证手里的 prototxt 有没有被改过:
import math # 六层配置:(特征图边长, min_size, max_size, 额外长宽比) layers = [ (38, 30.0, 60.0, [2]), (19, 60.0, 111.0, [2, 3]), (10, 111.0, 162.0, [2, 3]), (5, 162.0, 213.0, [2, 3]), (3, 213.0, 264.0, [2]), (1, 264.0, 315.0, [2]), ] total = 0 for size, smin, smax, ars in layers: # 一个 min 框 + 一个 sqrt(smin*smax) 框,再给每个长宽比补正反两个框 per = 1 + 1 + 2 * len(ars) total += per * size * size print(f"{size:>2}x{size:<2} 每位置 {per} 框 " f"边长 {smin:.0f}/{math.sqrt(smin * smax):.1f}/{smax:.0f} 小计 {per * size * size}") print("合计", total) # 300 输入下应为 8732这段代码同时输出了每层框的边长,方便对照图像尺度。有一点必须注意:min_size和max_size是相对输入边长的绝对像素值,不是比例。换成 512×512 输入时,六层的尺寸要按 512/300 ≈ 1.7067 等比放大(30 变成约 51.2),同时特征图边长也会变成 64/32/16/8/4/2。只改input_shape不改先验框,框和感受野就对不上,表现是 mAP 掉得莫名其妙,而且小目标几乎全丢——这类问题不打印先验框尺寸根本查不出来。
2.4 分类回归头的三条支路:flatten、concat、softmax 的顺序陷阱
六个检测头各输出一组定位偏移和一组类别分数,后处理前要先拼成全局张量。走法是三条平行支路:定位头全部 flatten 成二维后 Concat 到mbox_loc;分类头同样 flatten 后 Concat 到mbox_conf,再过一个 Softmax;先验框层全部 flatten 后 Concat 到mbox_priorbox。
# 分类支路:六个头依次拼接,顺序必须和 priorbox 的拼接顺序完全一致 layer { name: "mbox_conf" type: "Concat" bottom: "conv4_3_norm_mbox_conf_flat" bottom: "conv7_mbox_conf_flat" bottom: "conv8_2_mbox_conf_flat" bottom: "conv9_2_mbox_conf_flat" bottom: "conv10_2_mbox_conf_flat" bottom: "conv11_2_mbox_conf_flat" top: "mbox_conf" } layer { name: "mbox_conf_softmax" type: "Softmax" bottom: "mbox_conf" top: "mbox_conf_softmax" softmax_param { axis: 2 } # flatten 后形状是 N×框数×类别数,在类别维上做 softmax }axis: 2这个参数很容易写错。flatten 之后张量是三维的[batch, num_priors, num_classes],类别在第三维,所以 axis 填 2;如果误填 1,就变成在每个框的所有类别上做归一化,分数全乱。更隐蔽的问题在拼接顺序上:分类支路和先验框支路的 Concat 顺序必须逐层一致,一旦某一边顺序写反,分数和框就会错配,模型照样输出结果,但框会「乱飞」。这种 bug 不看代码根本发现不了,属于典型的黑匣子问题,所以改结构时我习惯把两边 bottom 列表并排贴在一起逐行比对。
3. 后处理链路:DetectionOutput 如何把 8732 个框压到几十个
8732 个框不可能全画出来。DetectionOutput 层承担的就是收尾工作:按置信度过滤、逐类做非极大值抑制、保留 top-k、输出归一化坐标。这一层只在 SSD 分支的 Caffe 里存在,官方主干版没有,所以它是整个链路里最需要搞清楚的定制模块。它的参数直接决定线上效果——阈值松一档,召回上去了误检也上去了;top_k设小了,密集场景里目标会成片消失。
3.1 参数逐项:confidence_threshold、nms_param、keep_top_k
layer { name: "detection_out" type: "DetectionOutput" bottom: "mbox_loc" bottom: "mbox_conf_softmax" bottom: "mbox_priorbox" top: "detection_out" include { phase: TEST } detection_output_param { num_classes: 21 # 类别数 + 1 个 background share_location: true # 所有类别共用同一套框回归 background_label_id: 0 # 第 0 类视为背景,不参与输出 nms_param { nms_threshold: 0.45 # IoU 超过该值的同类框被抑制 top_k: 400 # 每类 NMS 前最多保留的高分框数 } save_output_param { label_map_file: "labelmap.prototxt" # 输出可读类别名时使用 } code_type: CENTER_SIZE # 回归目标编码方式:中心点 + 宽高 keep_top_k: 200 # 全部类别最终保留的框数上限 confidence_threshold: 0.01 # 低于该分数的框直接丢弃 variance_encoded_in_target: false # 方差来自 priorbox,不编进回归目标 eta: 1.0 } }几个参数的实际调法:confidence_threshold在训练和评估时给 0.01 是为了算 mAP 时不漏框,部署时必须提到 0.3~0.5,否则满屏都是低分框;top_k限制的是 NMS 的输入规模,类别多、场景密的时候给 200 太小容易误杀,400 是常见折中;keep_top_k是最终输出上限,做批量视频检测时如果下游只画前 50 个,直接设成 50 能省一批画图开销。share_location: true意味着定位支路只需要一套坐标回归,这也是 SSD 参数量小的原因之一,改成 false 会让定位卷积的输出通道翻倍,模型结构就得重训了。
| 参数 | 单位 | 常见取值 | 调大后的影响 |
|---|---|---|---|
| confidence_threshold | 分数 | 0.01 训练 / 0.3~0.5 部署 | 误检减少,漏检增加 |
| nms_threshold | IoU | 0.4~0.5 | 密集目标保留更多,重叠误检增多 |
| top_k | 框数 | 400 | 内存与耗时上升,召回略升 |
| keep_top_k | 框数 | 200 | 输出变长,下游解析成本上升 |
3.2 解码:center-size 加 variance 的还原公式
网络输出的是相对偏移量,不是绝对坐标。真正的位置要拿先验框做基准反算,编码方式是中心点加宽高,再乘上方差项。方差的作用是把回归目标的数值范围拉平,让训练更稳;variance_encoded_in_target: false表示方差写在 priorbox 里,解码时要乘回去。
import numpy as np def decode_boxes(loc, prior, variance=(0.1, 0.1, 0.2, 0.2)): """loc: (N,4) 网络输出的偏移; prior: (N,4) [cx,cy,w,h] 归一化先验框""" p_cx, p_cy, p_w, p_h = prior[:, 0], prior[:, 1], prior[:, 2], prior[:, 3] v0, v1, v2, v3 = variance cx = p_w * loc[:, 0] * v0 + p_cx # 中心点 x 偏移 cy = p_h * loc[:, 1] * v1 + p_cy # 中心点 y 偏移 w = np.exp(loc[:, 2] * v2) * p_w # 宽用指数变换,保证恒正 h = np.exp(loc[:, 3] * v3) * p_h # 高同上 # 转成左上/右下角点,并裁剪到图像范围内 xmin = np.clip(cx - w / 2, 0, 1) ymin = np.clip(cy - h / 2, 0, 1) xmax = np.clip(cx + w / 2, 0, 1) ymax = np.clip(cy + h / 2, 0, 1) return np.stack([xmin, ymin, xmax, ymax], axis=1)这里的坐标全都是相对输入图的归一化值,跟原图分辨率无关。很多人以为要乘原图宽高才对,其实反了——先验框在归一化坐标系里铺,所以解码结果天然是 0~1,最后画图时再乘原图宽高。搞错这一步的表现是框全部挤在左上角,或者大得离谱。
3.3 输出张量的七列分别是什么
DetectionOutput 的输出形状是(1, 1, N, 7),七列依次是[image_id, label, score, xmin, ymin, xmax, ymax]。前三列是标量和类别,后四列是归一化坐标。批量推理时image_id才有意义,单图永远是 0。画框代码就是把归一化坐标乘回原图尺寸:
import cv2 def draw(det, img, label_names, score_thresh=0.4): """det: (N,7) 的检测输出; img: BGR 原图""" h, w = img.shape[:2] for row in det: _, label, score, xmin, ymin, xmax, ymax = row if float(score) < score_thresh: continue # 归一化坐标还原到像素坐标,注意转 int 再画 x1, y1 = int(xmin * w), int(ymin * h) x2, y2 = int(xmax * w), int(ymax * h) cv2.rectangle(img, (x1, y1), (x2, y2), (0, 255, 0), 2) text = f"{label_names.get(int(label), int(label))} {score:.2f}" cv2.putText(img, text, (x1, max(y1 - 6, 12)), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 1) return imglabel是整数类别 id,和 labelmap 里的下标一一对应。如果 labelmap 里含 background 且 id 从 0 开始,那么 label=1 对应第一类真实类别;如果 labelmap 不含 background,那就要整体减一。这一处差一位的问题,表现出来是「框画得对,但类别名全错」,排查时优先对 labelmap。
3.4 没有 DetectionOutput 层时的兜底路径
如果手里只有一个不带检测层的普通 Caffe,或者想把这套权重搬到别的运行时上,就得自己实现解码加 NMS。核心就是上面那段解码,加上逐类的非极大值抑制:
def nms_per_class(boxes, scores, labels, iou_thresh=0.45, score_thresh=0.3): """boxes: (N,4) 归一化角点; scores/labels: (N,)""" keep = [] for c in np.unique(labels): idx = np.where((labels == c) & (scores >= score_thresh))[0] order = idx[np.argsort(-scores[idx])] # 按分数从高到低 while order.size > 0: i = order[0] keep.append(i) if order.size == 1: break rest = order[1:] # 计算当前框与剩余框的 IoU xx1 = np.maximum(boxes[i, 0], boxes[rest, 0]) yy1 = np.maximum(boxes[i, 1], boxes[rest, 1]) xx2 = np.minimum(boxes[i, 2], boxes[rest, 2]) yy2 = np.minimum(boxes[i, 3], boxes[rest, 3]) inter = np.maximum(0, xx2 - xx1) * np.maximum(0, yy2 - yy1) area_i = (boxes[i, 2] - boxes[i, 0]) * (boxes[i, 3] - boxes[i, 1]) area_r = (boxes[rest, 2] - boxes[rest, 0]) * (boxes[rest, 3] - boxes[rest, 1]) iou = inter / (area_i + area_r - inter + 1e-9) order = rest[iou < iou_thresh] # 只留下 IoU 低的继续比 return keep手写版本和 Caffe 里的实现要一致,关键是三个细节不能漏:按类别分开做抑制、先按分数排序再贪心、IoU 分母加上一个极小值防止除零。自己实现一遍最大的好处是能往 NMS 里加业务规则,比如同类目标最小间距、特定类别放宽阈值,这些在原生层里改起来反而麻烦。
4. 跑通推理:三条路线与必须对齐的预处理
到这一步才是真正装环境。先给结论:如果只做推理,没必要去编译 Caffe。Caffe 的编译史基本是一本血泪账——protobuf 版本、boost、hdf5、OpenCV 大版本、gcc 版本,任何一项对不上都能让你在依赖里泡一整天。而 OpenCV 的 dnn 模块对 SSD 系列的 Caffe 模型做了专门兼容,直接读 caffemodel 就能出结果,几十行代码跑完一张图,适合先验证模型能不能用。真要训练或者要 GPU 加速,再回头编译带检测层的分支版本。
4.1 路线选择:OpenCV dnn、pycaffe、自编译 Caffe
| 路线 | 是否需要编译 | 是否支持 DetectionOutput | 适用场景 |
|---|---|---|---|
| OpenCV dnn | 不需要,pip 装 opencv-python 即可 | 内部兼容,输出直接是 N×7 | 纯推理、快速验证、边缘部署 |
| pycaffe | 需要编译,但可以用预编译包 | 取决于所装版本是否含该层 | 已有 Caffe 流水线、要改网络 |
| 自编译 SSD 分支 Caffe | 需要,且要带上检测层源码 | 完整支持 | 训练、微调、评估 mAP |
最容易踩的坑是:拿到 SSD 的 caffemodel,用官方主干版 Caffe 去 load,直接报Unknown layer type: DetectionOutput。这不是模型坏了,是这个层类型本来就在 SSD 分支里,官方版没有。所以要么换成带检测层的分支,要么绕开 Caffe 用 OpenCV dnn。
4.2 预处理对齐:mean、scale、BGR、resize 四件事
模型跑不出结果,九成是预处理没对齐。训练时的配置一般写在 train.prototxt 的数据层里,四个参数必须逐一对上:
mean_value:104、117、123,顺序是 BGR,不是 RGB。用错顺序不会报错,只会让分数整体偏低。scale:0.007843,等于 1/127.5,把 0~255 映射到 -1~1 附近。- 通道顺序:Caffe 用 BGR,OpenCV 读图也是 BGR,所以通常不用交换;如果用 PIL 读的 RGB 图,就必须换。
- resize:直接 warp 到 300×300,不保持长宽比。训练就是这么做的,推理也必须这么干,否则框会系统性偏移。
提示:先翻 deploy.prototxt 看有没有一个
Scale层把 0.007843 做进去了。如果已经做了,代码里再乘一次,分数会整体掉到 0.01 以下,看起来就像模型完全没检出东西。
4.3 OpenCV dnn 路线:四十行跑完一张图
import cv2 import numpy as np # 1) 加载模型,两个文件缺一不可 net = cv2.dnn.readNetFromCaffe("deploy.prototxt", "SSD_300x300.caffemodel") net.setPreferable <p> <a href="https://download.csdn.net/download/qq_29462849/10877589" style="color:#ec7500;font-size:14px;"> 本文还有配套的精品资源,点击获取 </a> <img alt="menu-r.4af5f7ec.gif" src="https://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif" style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;"> </p>