ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

R-FCN源码拆解:位置敏感池化与PSROIPooling实现

R-FCN源码拆解:位置敏感池化与PSROIPooling实现

简介:R-FCN 源代码包面向目标检测学习者与 Caffe 实践者,围绕 ResNet 主干、位置敏感得分图、分类与回归分支,提供从网络配置到训练测试的完整参考,适合理解区域全卷积检测流程并做二次配置应用。压缩包共 94 个文件、约 349KB,以 64 个 m 脚本、12 个 prototxt 网络配置为主,辅以 jpg 示例图、mat 数据、cpp/cu 扩展代码及说明文档,覆盖数据准备、模型构建、训练验证与演示等环节。已有 552 人学习下载,可帮助读者梳理 R-FCN 在 Caffe 下的目录组织、参数设置与实验复用思路,便于对照复现和适配不同场景。

1. R-FCN 能不能省掉那一层:从一次检测的耗时分布说起

拿一张 600×800 的图跑两阶段检测,分类头之前要按每个候选框抠一次特征,几百个框就对应几百次子网络前传,耗时随框数量线性上涨。这份 R-FCN 源代码要解决的就是这段重复计算:它把位置信息直接编码进卷积通道,让所有候选框共享同一张卷积特征图,框越多越能看出差别。代码栈是 Caffe + Python,主干网络用 ResNet,训练和测试沿用 py-faster-rcnn 那套脚手架。适合已经跑通过两阶段检测、想弄清楚“无 RoI 子网络”到底怎么落地的人,也适合手上有一个老 Caffe 工程、需要继续二次开发的场景。第一次翻这套代码容易懵:目录长得跟 Faster R-CNN 几乎一样,差别全藏在两个 1×1 卷积和一个自定义池化层里。

2. 目录与配置拆解:一份 R-FCN 工程的总账

把这套代码当成一个普通深度学习项目看,会很快迷路,因为它同时塞了三样东西:一个改过的 Caffe 分支、一份 Python 训练脚手架、一堆 prototxt 网络描述。三者的边界必须先划清楚,否则改错地方会白折腾一天。

2.1 五个目录各管一摊

根目录下的结构大致是这样,我按职责标注:

R-FCN/ ├── caffe/ # 改过的 Caffe 分支,多了 PSROIPooling 层 │ ├── include/caffe/layers/ # psroi_pooling_layer.hpp │ ├── src/caffe/layers/ # psroi_pooling_layer.cpp / .cu │ └── Makefile.config.example # 编译配置模板 ├── data/ # 数据集软链、ImageNet 预训练模型、下载脚本 ├── experiments/ │ ├── cfgs/rfcn_voc0712.yml # 训练/测试的全局配置入口 │ └── scripts/rfcn_end2end.sh # 一键训练脚本,内部调 tools/train_net.py ├── lib/ │ ├── datasets/ # imdb 注册表与 VOC/COCO 解析 │ ├── fast_rcnn/ # train.py / test.py / config.py │ ├── rpn/ # proposal / anchor_target / proposal_target 三个 Python 层 │ └── utils/ # 计时、绘图、blob 辅助 ├── models/ │ └── rfcn_end2end/ # train.prototxt / test.prototxt / solver.prototxt └── tools/ ├── train_net.py # 训练入口 ├── test_net.py # 评测入口 └── demo_rfcn.py # 单图推理演示

caffe/是唯一需要编译的部分,也是这套代码和上游 Caffe 的分叉点。位置敏感池化不是用 Python 层硬拼出来的,而是写成了 C++/CUDA 的 layer,注册名PSROIPooling,所以必须用仓库自带的这份 Caffe,系统里pip install的 caffe 用不了。

lib/rpn/里三个 Python 层负责候选框的生成和标签分配,lib/fast_rcnn/负责把它们串成训练循环和推理流程。models/rfcn_end2end/只放网络结构文本,一行代码都没有,但改网络全部在这里。

提示:很多人第一反应是把lib/里的 Python 文件复制到自己的工程里复用,可以,但要把caffe/一起带上,否则PSROIPooling这一层解析不了。

2.2 yml 配置怎么应用到训练

这套代码的配置分两层:yml 管超参和开关,prototxt 管网络拓扑。lib/fast_rcnn/config.py里维护了一个全局cfg字典,默认值写死在代码里,启动时用 yml 覆盖。典型的experiments/cfgs/rfcn_voc0712.yml:

EXP_DIR: rfcn_end2end # 输出目录名,快照和日志都落在 output/ 下 TRAIN: HAS_RPN: True # 用 RPN 出候选框,False 则退回外部 proposal 文件 BATCH_SIZE: 1 # 每张卡一次迭代用几张图,OHEM 很吃显存 SCALES: [600,] # 短边缩放到 600,长边由 MAX_SIZE 截断 SNAPSHOT_ITERS: 10000 # 每 1w 次迭代存一次 caffemodel RPN_POSITIVE_OVERLAP: 0.7 # RPN 里判正样本的 IoU 阈值 RPN_BATCHSIZE: 256 # RPN 一个 batch 采样的 anchor 数 TEST: HAS_RPN: True SCALES: [600,] NMS: 0.3 # 测试阶段 NMS 阈值 MAX_SIZE: 1000 POOLING_SIZE: 6 # RoI 输出特征的空间尺寸

加载逻辑在lib/fast_rcnn/config.py里,核心是cfg_from_file加_merge_a_into_b的递归合并:

def cfg_from_file(filename): with open(filename, 'r') as f: yaml_cfg = edict(yaml.safe_load(f)) _merge_a_into_b(yaml_cfg, __C) # 递归把 yml 的值压进全局 __C def _merge_a_into_b(a, b): for k, v in a.items(): if k not in b: raise KeyError('{} is not a valid config key'.format(k)) # 拼错键直接报错 if isinstance(v, dict): _merge_a_into_b(v, b[k]) else: b[k] = v

edict是支持属性访问的字典,所以后面代码里可以直接写cfg.TRAIN.SCALES而不用cfg['TRAIN']['SCALES']。_merge_a_into_b里那句raise KeyError很关键:yml 里写错一个键名,程序启动时就炸,而不是静默用默认值跑完 8 小时你才发现学习率根本没改。

配置应用的实际顺序是:tools/train_net.py先cfg_from_file(args.cfg_file),再cfg_from_list(args.set_cfgs)。后者允许命令行临时覆盖,比如追加--set TRAIN.BATCH_SIZE 2,跑小规模试验时省得改文件。我一般会把固定不变的写进 yml,把要对比的几组超参放到--set里,配一层 shell 循环就能刷一轮实验。

2.3 prototxt 里的三条主线

models/rfcn_end2end/train.prototxt是网络全貌,往下拆可以分成三条主线,改网络的时候按线找位置最省事。

第一条是主干:data→ 若干conv→res4f→res5a/res5b/res5c,ResNet-101 在这里输出 stride 为 16 的特征图。R-FCN 的取舍是把 stride 停在 16,不再往下做 stride 32,因为位置敏感分数图对空间分辨率敏感,再降采样位置信息就糊了。

第二条是 RPN 头:在res4f之后接一个 3×3 卷积,然后分两路 1×1 卷积出rpn_cls_score和rpn_bbox_pred。训练时接AnchorTargetLayer打标签,测试时接ProposalLayer出框,和 Faster R-CNN 一模一样。

第三条是位置敏感头,也是这套代码区别于别的两阶段检测的地方:

# 分类分支:输出通道 = (类别数 + 1) * k * k,VOC 上 (20+1)*9 = 189 layer { name: "rfcn_cls" type: "Convolution" bottom: "res5c" top: "rfcn_cls" convolution_param { num_output: 189 kernel_size: 1 pad: 0 weight_filler { type: "gaussian" std: 0.01 } bias_filler { type: "constant" value: 0 } } } # 位置敏感池化:group_size=3 表示把 RoI 切成 3x3 九宫格, # 每个格子只从对应的 1/9 通道组里取值 layer { name: "psroi_pool_cls" type: "PSROIPooling" bottom: "rfcn_cls" bottom: "rois" top: "psroi_pool_cls" psroi_pooling_param { spatial_scale: 0.0625 # 1/16,特征图相对原图的缩放比 output_dim: 21 # 每组通道数,等于类别数+背景 group_size: 3 # k,九宫格边长 } }

spatial_scale: 0.0625是从 stride 16 来的,改主干网络的降采样倍数就得同步改这个值,这是最容易漏的一处。output_dim必须等于类别数加一,num_output必须等于output_dim * group_size * group_size,三个数之间是乘法关系,改一个就得改另外两个。

回归分支结构相同,只是num_output: 36(即 4×9)、output_dim: 4。两条分支池化完之后各接一个 3×3 的 AVE 池化把九宫格压成一个标量,再 reshape、softmax 出分类概率,回归分支直接接 SmoothL1 损失。训练版还会多出proposal_target_layer和OHEM两个非学习层,测试版则把这两个换成proposal_layer,其余部分共用。

3. 核心代码链路:位置敏感分数图与 PS-RoI 池化

理解了配置,下一步是把一次前传真正跟下来。R-FCN 的思路说穿了就一句话:不让每个 RoI 单独过子网络,而是让卷积图自己“知道”自己负责目标的哪个部位。

3.1 位置敏感分数图怎么来

普通分类卷积输出的第 c 个通道,表示“这个位置像不像第 c 类”。R-FCN 把它拆细:第 c 类的判断被分摊到 9 个通道上,每个通道只负责 RoI 里 3×3 九宫格中的一个格子。左上角的通道只管“目标左上角应该长什么样”,右下角的通道只管右下角。

有了这张图,一个 RoI 的分类分数就不需要额外的全连接或卷积,只要按格子去对应通道组里取值、求平均即可。好处是显而易见的:所有 RoI 共享同一张rfcn_cls特征图,卷积只算一次,RoI 数量从几十涨到几百,计算量几乎不变。代价是定位精度对group_size敏感,k 取太小位置信息不够细,取太大通道数按平方增长,显存吃不消。工程上默认 k=3,是个折中。

rfcn_cls的通道排布也有讲究:Caffe 的 blob 是 NCHW,通道索引c对应的组是c // (k*k),组内的位置偏移是c % (k*k)。所以同一个类别的 9 个位置通道是连续排布的,池化层才能用整除的方式直接切分,不需要额外的索引表。

3.2 PSROIPooling 的正向与反向

池化层的实现在caffe/src/caffe/layers/psroi_pooling_layer.cu,逻辑不复杂,但索引计算容易写错。下面是正向传播的关键部分,去掉边界检查后大致长这样:

// 每个线程处理一个 (roi, 类别组, 格子) 的输出单元 template <typename Dtype> __global__ void PSROIPoolForward(const int nthreads, const Dtype* bottom_data, const Dtype spatial_scale, const int channels, const int height, const int width, const int pooled_height, const int pooled_width, const Dtype* bottom_rois, const int output_dim, const int group_size, Dtype* top_data, int* mapping_channel) { CUDA_KERNEL_LOOP(index, nthreads) { // index 的拆解顺序:n -> roi 序号, c -> 输出通道(类别), ph/pw -> 格子坐标 int pw = index % pooled_width; int ph = (index / pooled_width) % pooled_height; int c = (index / pooled_width / pooled_height) % output_dim; int n = index / pooled_width / pooled_height / output_dim; // 取出第 n 个 RoI 的坐标并映射回特征图尺度 bottom_rois += n * 5; int roi_batch_ind = bottom_rois[0]; Dtype roi_start_w = bottom_rois[1] * spatial_scale; Dtype roi_start_h = bottom_rois[2] * spatial_scale; Dtype roi_end_w = bottom_rois[3] * spatial_scale; Dtype roi_end_h = bottom_rois[4] * spatial_scale; // 把 RoI 在特征图上切成 group_size x group_size 个格子 Dtype roi_width = roi_end_w - roi_start_w + 1; Dtype roi_height = roi_end_h - roi_start_h + 1; Dtype bin_size_w = roi_width / group_size; Dtype bin_size_h = roi_height / group_size; // 当前格子 (ph, pw) 的实际范围 int hstart = floor(ph * bin_size_h + roi_start_h); int wstart = floor(pw * bin_size_w + roi_start_w); int hend = ceil((ph + 1) * bin_size_h + roi_start_h); int wend = ceil((pw + 1) * bin_size_w + roi_start_w); // 关键一步:只从本格子对应的那组通道里取值 int gw = pw, gh = ph; int ic = (c * group_size + gh) * group_size + gw; bottom_data += (roi_batch_ind * channels + ic) * height * width; // 格子内做平均池化 Dtype out_sum = 0; for (int h = hstart; h < hend; ++h) for (int w = wstart; w < wend; ++w) out_sum += bottom_data[h * width + w]; int bin_area = (hend - hstart) * (wend - wstart); top_data[index] = out_sum / bin_area; mapping_channel[index] = ic; // 反向传播要靠它找回原通道 } }

逐段解释参数和逻辑。bottom_rois每 5 个数是一组[batch_idx, x1, y1, x2, y2],坐标是原图尺度,乘spatial_scale才落到特征图上。group_size同时决定了两件事:把 RoI 切成几乘几的格子,以及通道分成几组,两者必须一致,这也是为什么这套设计叫“位置敏感”。

ic那一行是整个层的灵魂。输出第 c 个通道的第 (ph, pw) 个格子,输入只能来自通道(c * k + ph) * k + pw,也就是“这一类 + 这一格”。卷积核在训练中会学到:负责左上角的通道看到目标左上角就激活,别的位置不响应。位置信息就这样被固化进了权重,而不是靠额外的坐标回归去补。

mapping_channel是反向传播的索引表,池化本身没有参数,梯度只是按原路散射回去。反向 kernel 里对每个输出单元,把梯度除以bin_area之后累加到mapping_channel[index]指向的输入位置。注意这里是累加不是赋值,同一个输入通道可能被多个 RoI 的同一个格子引用。

注意:分数图上的坐标如果越界,正向要 clamp 到[0, height-1],否则会读到别的 batch 的显存,症状是大 batch 训练时 loss 突然跳变,很难查。

3.3 train_net.py 到 im_detect 的一次完整前传

训练入口在tools/train_net.py,它做的事情很薄:解析参数、加载配置、拿到 imdb、交给lib/fast_rcnn/train.py的SolverWrapper。

def parse_args(): parser = argparse.ArgumentParser() parser.add_argument('--gpu', dest='gpu_id', default='0') parser.add_argument('--solver', dest='solver', default='models/rfcn_end2end/solver.prototxt') parser.add_argument('--weights', dest='pretrained_model', default='data/imagenet_models/ResNet-101.caffemodel') parser.add_argument('--imdb', dest='imdb_name', default='voc_2007_trainval') parser.add_argument('--cfg', dest='cfg_file', default='experiments/cfgs/rfcn_voc0712.yml') parser.add_argument('--set', dest='set_cfgs', nargs=argparse.REMAINDER) return parser.parse_args()

几个参数值得说清楚。--weights是 ImageNet 预训练权重,不是检测模型,_init_solver_weights会遍历solver.net.params按名字匹配,形状不一致的直接跳过——分类头的通道数从 1000 变成了 189,必然不匹配,跳过是正常行为。如果这里报“没有匹配到任何层”,八成是权重文件的层命名和 prototxt 对不上。

solver.prototxt里控制迭代节奏的几个值:base_lr一般设 0.001 量级,stepsize配合gamma做阶梯衰减,momentum0.9、weight_decay0.0005 是老 Caffe 的常见配方。iter_size做梯度累积,显存不够又想凑大 batch 时用它。

推理侧的im_detect在lib/fast_rcnn/test.py,流程是预处理、reshape、前传、取输出:

def im_detect(net, im, boxes=None): blobs, im_scales = _get_blobs(im, boxes) im_blob = blobs['data'] # im_info 打包成 [高, 宽, 缩放比],网络内部据此把框映射回原图 blobs['im_info'] = np.array( [[im_blob.shape[2], im_blob.shape[3], im_scales[0]]], dtype=np.float32) net.blobs['data'].reshape(*(blobs['data'].shape)) net.blobs['im_info'].reshape(*(blobs['im_info'].shape)) forward_kwargs = {'data': blobs['data'].astype(np.float32, copy=False)} forward_kwargs['im_info'] = blobs['im_info'].astype(np.float32, copy=False) blobs_out = net.forward(**forward_kwargs) scores = blobs_out['cls_prob'] # 形状 [num_rois, num_classes] boxes = blobs_out['bbox_pred'] # 已是原图尺度,需要除以缩放比 return scores, boxes, im_scales

cls_prob是 softmax 之后的类别概率,bbox_pred是回归分支输出的偏移量已经解码成绝对坐标(解码在 prototxt 里由bbox_transform层完成)。这里有个细节容易被忽略:测试阶段test.prototxt的输入 blob 是动态尺寸,必须显式reshape,否则换一张不一样大的图就报维度错误。这也是demo_rfcn.py每跑一张图都要重新 reshape 的原因。

4. 从零把它跑起来:编译 Caffe 到 demo 出框

代码读得再明白,编译不过都是零。这一套是 2016 年前后的 Caffe 工程,在新环境上翻车概率不低,按顺序走能少踩几个。

4.1 编译带自定义层的 Caffe

先处理依赖,主流发行版上大概这几样:libprotobuf-dev、libleveldb-dev、libsnappy-dev、libopencv-dev、libhdf5-serial-dev、libboost-all-dev、libgflags-dev、libgoogle-glog-dev、liblmdb-dev,加上 CUDA 和对应版本的 cuDNN。Python 侧要numpy、protobuf、scikit-image、matplotlib。

然后复制配置模板并改三处:

cp caffe/Makefile.config.example caffe/Makefile.config

打开后重点看这几行:

# CUDA 路径,按实际安装位置改 CUDA_DIR := /usr/local/cuda # cuDNN 开关,没有装就注释掉,但训练会慢很多 USE_CUDNN := 1 # Python 头文件路径,用 python2 -c "import sysconfig; ..." 查 PYTHON_INCLUDE := /usr/include/python2.7 \ /usr/lib/python2.7/dist-packages/numpy/core/include # 关键:把自带的 python 层加进来,否则 proposal 层解析失败 PYTHONPATH := $(HOME)/R-FCN/python:$(PYTHONPATH)

最后那条PYTHONPATH是我见过最容易漏的。lib/rpn/proposal_layer.py这些是 Python 层,Caffe 在解析 prototxt 时要能 import 到,路径没配好,训练脚本一启动就报Unknown layer type: Python。

编译本身:

cd caffe make -j$(nproc) # 先跑 CPU 部分,报错信息更易读 make pycaffe # 生成 Python 绑定 make test -j$(nproc) # 可选,验证层有没有编坏

编完之后把caffe/python加进PYTHONPATH,把caffe/build/tools加进PATH,再验证:

export PYTHONPATH=$HOME/R-FCN/caffe/python:$HOME/R-FCN/lib:$PYTHONPATH python -c "import caffe; print(caffe.__file__)"

能打印出仓库里的路径就对了。如果打印的是site-packages下的路径,说明装过别的 caffe,先卸掉或者调换PYTHONPATH顺序。

4.2 数据集与预训练权重就位

数据目录靠软链组织,脚本不会替你下载数据集,只负责建链接。VOC 的话,先把VOCdevkit放到data/下面,再跑:

cd data ln -sf $PWD/VOCdevkit VOCdevkit2007 ./scripts/fetch_imagenet_models.sh # ResNet-101 的 ImageNet 预训练权重

fetch_imagenet_models.sh里会调tools/download_model.py,网络不通的时候这个脚本会卡很久,我一般手动把权重放到data/imagenet_models/下,跳过下载步骤。文件名必须和lib/fast_rcnn/train.py里默认的名字对上,否则--weights那一关过不去。

数据集的注册在lib/datasets/factory.py,启动时__sets字典里会预先塞好一批:

# 只截取 VOC 相关部分 for year in ['2007', '2012']: for split in ['train', 'val', 'trainval', 'test']: name = 'voc_{}_{}'.format(year, split) __sets[name] = (lambda split=split, year=year: datasets.pascal_voc(split, year))

所以--imdb voc_2007_trainval这种写法能直接找到对应数据集。自己加了新数据集,就得在这个文件里补一行注册,否则报Unknown dataset。

4.3 demo、test、train 三条命令

先跑 demo 验证整条链路通不通,这一步不需要数据集:

cd $HOME/R-FCN python ./tools/demo_rfcn.py --gpu 0 \ --def models/rfcn_end2end/test.prototxt \ --net output/rfcn_end2end/voc_2007_trainval/rfcn_end2end_iter_80000.caffemodel \ --cfg experiments/cfgs/rfcn_voc0712.yml

--def是网络结构文件,--net是训练出来的权重,两个必须配套:用 21 类的test.prototxt配 81 类的 COCO 权重,会在 reshape 阶段直接崩。demo 脚本默认读data/demo/下的图片,输出检测结果图。

评测:

python ./tools/test_net.py --gpu 0 \ --def models/rfcn_end2end/test.prototxt \ --net output/rfcn_end2end/voc_2007_trainval/rfcn_end2end_iter_80000.caffemodel \ --imdb voc_2007_test \ --cfg experiments/cfgs/rfcn_voc0712.yml \ --comp standard

--comp standard表示用 VOC 官方的 11 点或全点 AP 计算,输出会写进output/下的结果目录,一个.pkl加一份 txt 报告。

训练:

python ./tools/train_net.py --gpu 0 \ --solver models/rfcn_end2end/solver.prototxt \ --weights data/imagenet_models/ResNet-101.caffemodel \ --imdb voc_2007_trainval \ --cfg experiments/cfgs/rfcn_voc0712.yml \ --iters 80000

--iters覆盖 solver 里的max_iter,做短周期试验时很方便。训练日志里重点盯两个数:rpn_cls_loss和rfcn_cls_loss,前者不降说明 anchor 尺度不合适,后者不降多半是 OHEM 的阈值设得不合理。

5. 避坑与排查:五个编译过之后才暴露的问题

5.1 报错 Unknown layer type: Python

现象:训练脚本一启动就退出,提示 prototxt 里有无法识别的层类型。

原因:Caffe 编的时候没带 Python 层支持,或者运行时PYTHONPATH里找不到lib/rpn/。

解决:确认Makefile.config里WITH_PYTHON_LAYER := 1已经打开并重新编译;再确认环境变量里同时包含caffe/python和仓库的lib/。这两个路径缺一不可,只加前者会找不到自定义的 proposal 层。

5.2 训练几个 iter 后 loss 变 nan

现象:前几百次迭代正常,之后 loss 突然变成 nan,权重全部失效。

原因:九宫格切分时 RoI 的宽高可能小于group_size,bin_size算出小于 1 的值,hstart和hend相等,除以 0。另外学习率过大也会在 OHEM 层放大难样本梯度。

解决:在池化层里对bin_size做下限保护,或者把spatial_scale与最小 RoI 尺寸对齐;学习率从 0.001 往下调,同时把iter_size提到 2 让梯度更稳。这类问题真的是玄学,同一个配置换张卡就复现不了。

5.3 PSROIPooling 形状不匹配

现象:Check failed: channels == output_dim * group_size * group_size。

原因:改了类别数却只改了num_output,没同步改output_dim,或者两个都没改。

解决:记住三者的乘法关系。VOC 20 类时num_output = 21 * 3 * 3 = 189,COCO 80 类时是81 * 3 * 3 = 729,回归分支固定4 * 3 * 3 = 36。改数据集的时候两个卷积层的num_output和两个池化层的output_dim要一起改。

5.4 GPU 显存不够

现象:Check failed: error == cudaSuccess (2 vs. 0) out of memory。

原因:TRAIN.BATCH_SIZE加SCALES决定的输入尺寸,再乘以 OHEM 采样量,显存占用是指数级涨的。

解决:把BATCH_SIZE降到 1,SCALES从 600 降到 480 先跑通;不行再开iter_size做梯度累积,用时间换显存。降尺度会影响小目标召回,最终还是要回到能承受的最大尺寸。

5.5 评测结果和论文差好几个点

现象:能跑通,AP 也正常,但明显偏低。

原因:test.prototxt里的 NMS 阈值和MAX_SIZE跟训练配置不一致;或者用了voc_2007_trainval训出来的权重去评voc_2007_test之外的数据。

解决:对齐TEST.NMS、TEST.SCALES、TEST.MAX_SIZE三项,和训练时保持同一套缩放逻辑。还有一点常被忽略:评测要在原图尺度上做,im_detect返回的框已经除了缩放比,如果外面又除了一次,框全部缩在角落,AP 会掉得莫名其妙。

6. 二次开发:换数据集、换主干的改造路径

真正落地时,改的地方集中在四个位置,我整理成一张表,照着找比翻目录快:

改造目标改动文件具体动作
换类别数models/rfcn_end2end/*.prototxt改rfcn_cls的num_output与池化的output_dim
换数据集lib/datasets/factory.py+lib/datasets/新增解析类并在__sets注册
换主干models/*.prototxt+lib/fast_rcnn/config.py替换 conv 段,同步改spatial_scale
跑单图服务tools/demo_rfcn.py抽出im_detect封装成常驻进程

换数据集是最常见的需求。假设手上一批标注是自定义格式,做法是照lib/datasets/pascal_voc.py抄一个类,实现image_path_at、_load_annotations两个方法,然后在factory.py里加注册:

# lib/datasets/factory.py 追加 from datasets.mydataset import my_dataset # 自己写的解析类 # 注册名就是命令行 --imdb 后面跟的字符串 __sets['my_train'] = lambda: my_dataset('train') __sets['my_test'] = lambda: my_dataset('test')

注册名一旦写进--imdb,train_net.py就会去__sets里查,查不到直接抛异常。这套注册机制其实挺方便,多个数据集并存时不用改任何主流程代码。新增类里要注意类别列表的顺序必须和 prototxt 的num_output对上,背景不算在内,类的顺序变了旧的权重就废了。

换主干的坑更多。R-FCN 对 stride 很敏感,如果用了一个总降采样 32 倍的主干,spatial_scale要改成 1/32,同时九宫格覆盖的区域会变粗,小目标基本没戏。我的做法是先只换主干的前几段、保留 res5 的结构,训一轮看rpn_cls_loss的收敛速度,能收敛再往下深调。验证是否改对,最直接的办法是把test.prototxt的第一层输出维度打印出来,和预期的手算值逐个比对,比看 loss 曲线快得多。

还有一个容易被忽略的点:OHEM 的thresh参数和类别数相关。类别少的时候难样本集中,阈值可以设高一点;类别多了之后前景样本被打散,同样的阈值可能采不到足够的难例,loss 会一直震荡。这个我有过血泪经验,换到 80 类的数据集上直接沿用 21 类的配置,白了半张卡的时间。

从那以后我每次动 prototxt,都强制先跑一遍test_net.py用一张图出结果,确认框能画在正确位置再开训——后悔药这东西,还是在训练前吃比较便宜。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

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