Faster R-CNN TensorFlow代码笔记(1)--模型测试

版权声明:本文为博主原创文章,转载请注明出处。 https://blog.csdn.net/sinat_34474705/article/details/85458501

本文主要记录在看Faster R-CNNT的TensorFlow实现代码(模型测试部分),源码链接是:Faster-RCNN_TF,为了便于做笔记,我fork过来并添加了自己的注释和一些测试代码(目前只看了测试部分代码)。链接在:https://github.com/ZhouJiaHuan/Faster-RCNN_TF。 后期会不定期更新。

本文的思路是从demo.py开始的,它的功能是依次读取本地路径下的几张图片,并执行检测过程、显示检测结果。

配置信息

  • Tensorflow 1.4
  • GPU NVIDIA 1070

tools/_init_paths.py:代码段1

"this_dir = osp.dirname(__file__)" # 获取.py文件所在的当前路径,注意,智能在.py文件中运行,不能在终端运行(没有指定的.py文件)
# ......
# ......
# Add lib to PYTHONPATH
# 当前路径(tool)的上一层路径为工程的根目录,根目录下的lib文件夹下定义了代码执行需要的一些函数,所以
# 此处先添加到sys路劲,便于后面使用
lib_path = osp.join(this_dir, '..', 'lib')
add_path(lib_path)

测试:编写"file.py"文件:

import os
import sys
print(sys.argv[0]) # 第0个参数对应文件名
print(__file__) # '__file__',系统保留,表示当前的文件名
print(os.path.dirname(__file__)) # 返回当前文件所在的路径

'''输出结果
.\__file__.py
.\__file__.py
.
'''

tools/demo.py: 代码段2

cfg.TEST.HAS_RPN = True  # Use RPN for proposals

对应的config.py中主要使用了easydict库配置一些参数

easydict库举例

from easydict import EasyDict as edict 

# python自带的字典创建
test_dict1 = {"a": 1, "b": 2, "c": 3}
print("test_dict1 = ", test_dict1)
# 转化为easydict
test_dict2 = edict(test_dict1)
print("test_dict2 = ", test_dict2)

# 访问和添加easydict的元素
test_dict2.a = 2
test_dict2.b = 3
test_dict2.c = 4
test_dict2.d = 5
print("test_dict2 = ", test_dict2)

# 直接创建空的easydict
test_dict3 = edict()
print("test_dict3 = ", test_dict3)
test_dict3.a = 10
test_dict3.b = 20
print("test_dict3 = ", test_dict3)
# 直接赋值后,赋值后的变量发生变化,原变量也同时变化
test_dict3_copy = test_dict3
test_dict3_copy.a = 100
print("test_dict3_copy = ", test_dict3_copy)
print("test_dict3 = ", test_dict3)

'''输出结果
test_dict1 =  {'a': 1, 'b': 2, 'c': 3}
test_dict2 =  {'a': 1, 'b': 2, 'c': 3}
test_dict2 =  {'a': 2, 'b': 3, 'c': 4, 'd': 5}
test_dict3 =  {}
test_dict3 =  {'a': 10, 'b': 20}
test_dict3_copy =  {'a': 100, 'b': 20}
test_dict3 =  {'a': 100, 'b': 20}
'''

tools/demo.py: 代码段3

import argparse
# ...
def parse_args():
    """Parse input arguments."""
    parser = argparse.ArgumentParser(description='Faster R-CNN demo')
    parser.add_argument('--gpu', dest='gpu_id', help='GPU device id to use [0]',
                        default=0, type=int)
    parser.add_argument('--cpu', dest='cpu_mode',
                        help='Use CPU mode (overrides --gpu)',
                        action='store_true')
    parser.add_argument('--net', dest='demo_net', help='Network to use [vgg16]',
                        default='VGGnet_test')
    parser.add_argument('--model', dest='model', help='Model path',
                        default=' ')

    args = parser.parse_args()

# ...
# ...

args = parse_args()

用于解析输入参数,需要导入argparse模块

argparse举例,新建ex1.py文件如下:

import argparse

def parse_args():
	"""
	parse input arguments.
	"""
	parse = argparse.ArgumentParser(description="Faster R-CNN demo test") # 创建对象
	parse.add_argument("--gpu", dest="gpu_id",  # 添加一个参数
					   help="GPU device id to use [0]",
					   default=0, type=int)
	parse.add_argument("--cpu", dest="cpu_mode",
					   help="Use CPU mode(overrides --gpu)",
					   action="store_true")
	parse.add_argument("--net", dest="demo_net", 
					   help="Network to use [vgg16]",
					   default="VGGnet_test")
	parse.add_argument("--model", dest="model", 
					   help="Model path", default=" ")
	args = parse.parse_args() # 获取所有的参数
	return args
	
if __name__ == "__main__":
	args = parse_args()
	if args.model == " ": #
		raise IOError(("Error: Model not found.\n"))
	
	print("net: ", args.demo_net)
	print("gpu: ", args.gpu_id)
	print("cpu: ", args.cpu_mode)
	print("model path: ", args.model)

测试输出:

# python ex1.py --help
usage: ex1.py [-h] [--gpu GPU_ID] [--cpu] [--net DEMO_NET] [--model MODEL]

Faster R-CNN demo test

optional arguments:
  -h, --help      show this help message and exit
  --gpu GPU_ID    GPU device id to use [0]
  --cpu           Use CPU mode(overrides --gpu)
  --net DEMO_NET  Network to use [vgg16]
  --model MODEL   Model path
  
# python ex1.py --model "/home/"
net:  VGGnet_test
gpu:  0
cpu:  False
model path:  /home/

# python ex1.py --gpu 1 --model "/home/"
net:  VGGnet_test
gpu:  1
cpu:  False
model path:  /home/

# python ex1.py --cpu --model "/home/"
net:  VGGnet_test
gpu:  0
cpu:  True
model path:  /home/

# python ex1.py --cpu
Traceback (most recent call last):
  File "ex1.py", line 30, in <module>
    raise IOError(("Error: Model not found.\n"))
OSError: Error: Model not found.

tools/demo.py: 代码段4

# 创建会话的时候可以传入config参数,该参数描绘了会话的各项配置信息(如GPU配置参数,数据流图配置参数、
# 是否打印设备放置日志等),由ConfigProto数据结构定义。上面代码中,allow\_soft\_placement参数指在没
# 有GPU可用时,是否将操作放到CPU中运行,详细的介绍可以参考《深入理解TensorFlow架构设计与实现原理》56页。
sess = tf.Session(config=tf.ConfigProto(allow_soft_placement=True))

tools/demo.py: 代码段5

# load network
# 根据用户输入的网络名称获取指定的网络支持VOC的训练和测试网络,默认"VGGnet_test"
# 即下面一行代码在测试的时候创建了一个VGGnet_test对象
net = get_network(args.demo_net)

# load model
# 加载训练好的模型
# 新版本的saver会保存成三个后缀的形式(.data-00000-of-00001、.index和.meta)
# 旧版本的saver才是保存成.ckpt的形式
# 所以当需要加载或保存成旧版的模型文件时,需要指定write_version
saver = tf.train.Saver(write_version=tf.train.SaverDef.V1)
saver.restore(sess, args.model) # args.model = "VGGnet_fast_rcnn_iter_70000.ckpt"

小结1

到这里先总结一下,以上代码集中在demo.py文件中,主要实现了在测试前的一些准备工作,包括读取配置信息、准备命令行参数、加载训练好的模型到会话中。

fast_rcnn/test.py: 代码段6

从这里开始的代码主要集中在fast_rcnn/test.py中,根据demo的执行顺序,先看一下im_detect函数注释。

def im_detect(sess, net, im, boxes=None):
    """Detect object classes in an image given object proposals.
    Arguments:
        net (caffe.Net): Fast R-CNN network to use
        im (ndarray): color image to test (in BGR order)
        boxes (ndarray): R x 4 array of object proposals
    Returns:
        scores (ndarray): R x K array of object class scores (K includes
            background as object category 0)
        boxes (ndarray): R x (4*K) array of predicted bounding boxes
    """

函数注释已经写的很清楚了,输入是创建好的会话、加载的网络、图片数组、候选区域数组,输出是候选框包含的各个类别的得分和对应的位置信息。但我们在调用的地方发现,并没有提供候选区域信息。这是因为Faster R-CNN网络的候选区域由PRN网络自动生成,所以不再需要提供。

fast_rcnn/test.py: 代码段7

im_detect函数刚开始就调用了_get_blobs函数,忽略该函数中else后面的代码,可以发现,如果使用RPN生成候选区域,直接调用了_get_image_blob(im)函数并返回了处理后图像信息。

def _get_blobs(im, rois):
    """Convert an image and RoIs within that image into network inputs."""
    if cfg.TEST.HAS_RPN: # 用RPN生成候选区域
        blobs = {'data' : None, 'rois' : None}
        blobs['data'], im_scale_factors = _get_image_blob(im)
    else:
        # ...

def im_detect(sess, net, im, boxes=None):
    # 将图片(可能是多尺度)存放在blob["data"](4-d数组)中,并返回blobs和对应的缩放比例
    # 缩放比例是记录多尺度后的图片和原图shape的比值
    # 多尺度在config文件中设置:cfg.TEST.SCALES (元祖)
    # 详见'./test_codes/_get_blobs.py'
    blobs, im_scales = _get_blobs(im, boxes)

fast_rcnn/test.py: 代码段8

def _get_image_blob(im):
    """Converts an image into a network input.
    Arguments:
        im (ndarray): a color image in BGR order
    Returns:
        blob (ndarray): a data blob holding an image pyramid
        im_scale_factors (list): list of image scales (relative to im) used
            in the image pyramid
    """
    im_orig = im.astype(np.float32, copy=True) # 转换数据类型(返回一个新数组,原数组不变)
    im_orig -= cfg.PIXEL_MEANS # 去均值

    im_shape = im_orig.shape
    im_size_min = np.min(im_shape[0:2]) # 图像宽高中较小值
    im_size_max = np.max(im_shape[0:2]) # 图像宽高中较大值

    processed_ims = []
    im_scale_factors = []

    # 图像预处理(默认单尺度),可以在配置文件中设置多尺度测试
    for target_size in cfg.TEST.SCALES:
        im_scale = float(target_size) / float(im_size_min) # 短边缩放比例
        # Prevent the biggest axis from being more than MAX_SIZE
        # 当使用短边缩放比例溢出时,使用长边缩放比例
        if np.round(im_scale * im_size_max) > cfg.TEST.MAX_SIZE: 
            im_scale = float(cfg.TEST.MAX_SIZE) / float(im_size_max)
        # 对原图像使用合适的缩放比例和方法进行缩放
        im = cv2.resize(im_orig, None, None, fx=im_scale, fy=im_scale,
                        interpolation=cv2.INTER_LINEAR)
        # 保存当前的缩放比例和对应的缩放图像
        im_scale_factors.append(im_scale) 
        processed_ims.append(im)

    # Create a blob to hold the input images
    # 用一个4-d数组blob来存放所有尺度的图片,shape为(num_img, height, width, channels)
    # height和width由最大的高和宽确定,多余的位置用0填充
    blob = im_list_to_blob(processed_ims)
    # 返回blob和对应的缩放因子
    return blob, np.array(im_scale_factors)

fast_rcnn/test.py: 代码段9

这个函数主要实现了一种对多尺度图像的存储方法。

def im_list_to_blob(ims):
    """Convert a list of images into a network input.

    Assumes images are already prepared (means subtracted, BGR order, ...).
    """
    # 获取列表中图像最大的高和宽及图片的数量
    max_shape = np.array([im.shape for im in ims]).max(axis=0)
    num_images = len(ims)
    # 定义一个blob(numpy4维数组:[num_img, height, width, 3])来存放列表中的图片
    # blob的shape由列表中图像的最大高和宽确定,多余的部分为0
    blob = np.zeros((num_images, max_shape[0], max_shape[1], 3),
                    dtype=np.float32)
    for i in xrange(num_images):
        im = ims[i]
        blob[i, 0:im.shape[0], 0:im.shape[1], :] = im

    return blob

可以发现,代码段7、8、9主要实现了对输入的一张图片做尺度缩放(可以是单尺度也可以是多尺度),并做了去均值操作,最后把尺度缩放的结果(包括图片数组和缩放比例)保存在blobs中返回。对于多尺度的存储,代码中以最大的宽和高来定义一个数组存放所有的结果,空白的地方用0填充。为了更直观地理解这一块的代码,我把这边的代码单独拿出来写在了/test_codes/_get_blobs.py中。直接运行即可查看处理结果。

fast_rcnn/test.py: 代码段10

接着看im_test函数,在处理好输入数据后,通过feed_dict字典将数据送到网络中。

def im_detect(sess, net, im, boxes=None):
    # ...
    if cfg.TEST.HAS_RPN: # 用RPN生成候选区域
        im_blob = blobs['data'] # 获取blobs中的图像数据
        blobs['im_info'] = np.array(
            [[im_blob.shape[1], im_blob.shape[2], im_scales[0]]],
            dtype=np.float32) # 记录blobs["data"]中的图片信息:高、宽、数量(尺度数量)
    # forward pass
    if cfg.TEST.HAS_RPN:
        feed_dict={net.data: blobs['data'], net.im_info: blobs['im_info'], net.keep_prob: 1.0}
    else:
        feed_dict={net.data: blobs['data'], net.rois: blobs['rois'], net.keep_prob: 1.0}
    # ...
    cls_score, cls_prob, bbox_pred, rois = sess.run([net.get_output('cls_score'), net.get_output('cls_prob'), net.get_output('bbox_pred'),net.get_output('rois')],
                                                     feed_dict=feed_dict,
                                                     options=run_options,
                                                     run_metadata=run_metadata)

小结2

到这里为止的代码主要集中在test.py中,实现对输入数据的预处理(尺度变换、去均值等),并按格式保存好后送入到网络中。完成上述步骤后,自然而然地需要开始看网络模型部分。

networks/network.py: 代码段11

Network类中封装一些创建模型需要的函数,主要包含变量创建函数、层创建函数和一些辅助函数。在定义Network类之前,network.py中先定义了一个函数layer(op),然后在Network类中,网络层创建函数前都加了@layer。这种用法在Python中叫修饰器,它的作用是给已有的函数添加额外的功能。举个例子,假设我们想要给一个函数加一个计时功能,可以这样写:

""" 
An example of decoration.
Author: Meringue
Date: 2018/11/15
"""
import time

def time_it(fn):
    print("time_it is executed.")
    def new_fn(*args):
        start = time.time()
        result = fn(*args) # args中存放所有的输入参数列表
        end = time.time()
        dur = end - start
        print("%s seconds are consumed in executing function: %s%r" \
              %(dur, fn.__name__, args))
        return result
    return new_fn

@time_it
def acc(start, end):
    s = 0
    for i in range(start, end):
        s += i
    return s

if __name__ == "__main__":
    print(acc(1, 1000))

"""输出结果
time_it is executed.
0.0010001659393310547 seconds are consumed in executing function: acc(1, 1000)
499500
"""

从输出可以看出,在主函数中调用acc函数时,先进入了time_it函数,先输出"time_it is executed.",进入new_fn函数,运行result = fn(*args),最后返回结果。而fn是time_it函数的输入参数。所以fn到底是什么?其实fn在这里就是函数acc,而args就是函数所有的输入参数。总结一下,给acc函数加上修饰器time_it后,在调用acc时,相当于执行了time_it(acc),修饰器并没有修改函数acc的功能,只是给函数acc加了计时功能。

理解了上面的例子,我们再回过头来看这个layer函数,可以发现,在Network类中,layer函数修饰了各个层函数,如conv、relu等。也就是说,layer函数希望在每次生成一个新的层的时候做点什么。简单地讲,在每次添加一个新的层时,layer会给这个层起一个名字(如果新的层没有自己给定名字),记录该层的输出并添加到类的input列表中,从而用于下一层的计算。在下次添加另一个层时,会重复上述步骤。

def layer(op): # op: 具体的层函数,如relu()等
    def layer_decorated(self, *args, **kwargs):
        # Automatically set a name if not provided.
        # op.__name__: 获取函数op的函数名
        # 获取需要创建的层的名字,如conv, relu等
        # 获取kwargs字典中传入的name,如果没有传入,则给定默认名字
        # 默认名字由self.get_unique_name(op.__name__)确定
        name = kwargs.setdefault('name', self.get_unique_name(op.__name__))
        # Figure out the layer inputs.
        # 查看创建的层对应的输入(每个层至少有一个输入)
        if len(self.inputs)==0: # 没有输入,抛出异常
            raise RuntimeError('No input variables found for layer %s.'%name)
        elif len(self.inputs)==1: # 有一个输入(最常见),如前半部分VGG卷积层
            layer_input = self.inputs[0]
        else: # 多个输入,说明网络有分支,如VGG层后拓展出RPN层
            layer_input = list(self.inputs)
        # Perform the operation and get the output.
        layer_output = op(self, layer_input, *args, **kwargs)
        # Add to layer LUT.
        # 记录该层的输出
        self.layers[name] = layer_output
        # This output is now the input for the next layer.
        # 保存当前层的输出结果到self.inputs列表中,为下一层输入做准备
        self.feed(layer_output)
        # Return self for chained calls.
        return self # 每次调用完一个层函数都返回类本身
    return layer_decorated

networks/network.py: 代码段12

layer函数中有两个函数需要继续看一下,一个是get_unique_name(),用于给新的层起一个不重复的名字;另一个是feed(),用于把当前层的计算结果保存到类中的input列表中,用于下一层的计算。这两个函数都是在Network类中定义的。

class Network(object):
    # ...
    
    def get_unique_name(self, prefix):
        # t.startswitch(prefix)用于判断字符串t是否以prefix开头,返回True或False.
        # 检查已有的层中以prefix开头的层的名称的数量,方便命名新的层
        # 如假设已有的层中已经包含:conv_1, conv_2, conv_3, 
        # 则当prefix="conv"的时候,id = 3+1,此时返回的结果为"conv_4"
        id = sum(t.startswith(prefix) for t,_ in self.layers.items())+1
        return '%s_%d'%(prefix, id)
    # ...
    
    def feed(self, *args):
        # 根据层的名字从self.layers字典中提取该层数据
        # 并保存到self.input列表中
        # 输入是层的名字,保存在参数列表args中
        # 返回self,即类本身
        assert len(args)!=0 # 必须要有输入数据
        self.inputs = [] # 使用前置为空(为了只保存当前层结果)
        for layer in args:
            if isinstance(layer, basestring): # basestring只在python2中有,3中已移除
                try:
                    layer = self.layers[layer]
                    print layer
                except KeyError:
                    print self.layers.keys()
                    raise KeyError('Unknown layer name fed: %s'%layer)
            self.inputs.append(layer)
        return self

networks/VGGnet_test.py: 代码段13

VGGnet_test类是从Networks类继承出来的,类中setup()中定义了测试阶段的网络模型及参数。从结构上看,可以分成3块,依次为VGG模型特征提取、RPN网络候选区域提取、候选区域分类及边界狂回归。先看第一部分:

class VGGnet_test(Network):
    def __init__(self, trainable=True):
        # ...
        self.setup()

    def setup(self):
        # 所有层在定义时第一个参数为input,该参数在调用函数时在修饰符中指定,
        # (从self.inputs列表中获取),因此没有显示指定
        (self.feed('data') # data保存在self.inputs中
             .conv(3, 3, 64, 1, 1, name='conv1_1', trainable=False)
             .conv(3, 3, 64, 1, 1, name='conv1_2', trainable=False)
             .max_pool(2, 2, 2, 2, padding='VALID', name='pool1')
             .conv(3, 3, 128, 1, 1, name='conv2_1', trainable=False)
             .conv(3, 3, 128, 1, 1, name='conv2_2', trainable=False)
             .max_pool(2, 2, 2, 2, padding='VALID', name='pool2')
             .conv(3, 3, 256, 1, 1, name='conv3_1')
             .conv(3, 3, 256, 1, 1, name='conv3_2')
             .conv(3, 3, 256, 1, 1, name='conv3_3')
             .max_pool(2, 2, 2, 2, padding='VALID', name='pool3')
             .conv(3, 3, 512, 1, 1, name='conv4_1')
             .conv(3, 3, 512, 1, 1, name='conv4_2')
             .conv(3, 3, 512, 1, 1, name='conv4_3')
             .max_pool(2, 2, 2, 2, padding='VALID', name='pool4')
             .conv(3, 3, 512, 1, 1, name='conv5_1')
             .conv(3, 3, 512, 1, 1, name='conv5_2')
             .conv(3, 3, 512, 1, 1, name='conv5_3'))

直观上看,这就是一个VGG16模型的卷积部分(去掉了最后一个池化层)。可能有疑惑的地方就是为什么所有的层可以用.运算符连在一起。这要归功于Networks.py中的修饰符和Networks类中的feed()函数。可以回过去看一下这两个函数的返回值,都是self,而self在两个函数中表示的就是类自己。这里可以举个简单的例子,如下:

class Test_Class():
    def __init__(self, a, b):
        self.a = a
        self.b = b
    
    def add(self, temp):
        self.a += temp
        return self
    
    def multiply(self, temp):
        self.b = self.b * temp
        return self

if __name__ == "__main__":
    test_class = Test_Class(0, 1)
    test_class.add(1).add(2).add(3)
    test_class.multiply(2).multiply(3).multiply(4) 
    print("test_class.a = ", test_class.a) # 6
    print("test_class.b = ", test_class.b) # 24

上面的测试代码中,首先定义了一个类,类中有两个成员变量a和b。主函数中先创建了这样一个类,并给a, b初始化。之后用.运算符连续调用了三次add()和三次multiply()函数,而每次调用完都返回类本身,相当于更新了一下类的信息并返回了。最后输出类中的a和b,不出所料,依次为6和24。因此,当我们在某些场景下需要在类的成员函数中返回类自己的时候,就可以用return self。

所以回过头再看测试模型代码的第一部分,不断在网络中添加新的层,并更新类中的数据,更具体地说,每添加一层,就把该层的名字和输出存放到self.layers字典中,把当前层的输出结果存放到self.input列表中,用于下一层的输入。

networks/VGGnet_test.py: 代码段14

紧接着VGG特征提取网络后面的是RPN网络,该网络基于VGG特征提取网络最后一个卷积层(即代码中的conv5_3),通过一个全卷机网络生成候选区域信息,包括候选区域的类别信息(目标/背景)和位置信息。其中类别信息为softmax概率,需要2个变量来分别表示属于目标和背景的概率;位置信息由4个变量来描述。

    def setup(self):
        # VGG特征提取网络
        # ...
        
        # 论文中说明:在RPN网络中,每一个滑动窗口被映射到一个低维空间,
        # 如果使用VGG网络,维度为512,如果使用ZF网络,维度为256。
        # 低维特征后接两个分支,一个分支用于预测每一个anchor对应的窗口
        # 中的类别得分(即属于目标和背景的得分);另一个分支用于预测每个
        # anchor对应的窗口的位置信息(4个值表示)
        # 用3*3的卷积核在特征图上滑动,每个位置生成9个bbx(3个尺度,3个比例)
        # 9个bbx中心即为3*3区域中心,根据不同的尺度和比例,可以映射到原图中(SPP)
        # 所以基于特征图共生成w*h*9个bbx。其中w和h分别表示特征图的宽和高

        # rpn_cls_score层得到特征图的每个位置对应的9个bbx的类别概率(目标or背景)
        (self.feed('conv5_3')
             .conv(3,3,512,1,1,name='rpn_conv/3x3')
             .conv(1,1,len(anchor_scales)*3*2,1,1,padding='VALID',relu = False,name='rpn_cls_score'))
        # rpn_bbox_pred层得到特征图的每个位置对应的9个bbx的位置信息(4个值确定bbx位置)
        (self.feed('rpn_conv/3x3')
             .conv(1,1,len(anchor_scales)*3*4,1,1,padding='VALID',relu = False,name='rpn_bbox_pred'))
        # 将所有bbox的类别概率转化为softmax概率
        # 转化之前需要把数据进行一些变化以方便使用softmax函数
        (self.feed('rpn_cls_score')
             .reshape_layer(2,name = 'rpn_cls_score_reshape')
             .softmax(name='rpn_cls_prob'))
        # 转化后再把数据变换为原来的格式
        (self.feed('rpn_cls_prob')
             .reshape_layer(len(anchor_scales)*3*2,name = 'rpn_cls_prob_reshape'))

上面代码中的前半部分结合注释看应该很直观,可能有疑惑的地方在于后半部分的softmax层。可以发现,softmax层前后各有一个reshape_layer,它们实现对变换前后数据格式的转换。第一个reshape_layer将rpn_cls_score层数据reshape以最后一个维度代表类别信息的张量格式(便于softmax函数基于最后一个维度变换);后一个reshape_layer负责把softmax转化后的概率信息再转化成原来的shape。

下面具体看在代码中是怎么实现的:首先是第一个reshape_layer,输入是rpn_cls_score。

""""
self.feed('rpn_cls_score').
    reshape_layer(2,name = 'rpn_cls_score_reshape')
"""
class Network(object):
    # ...
    @layer
    def reshape_layer(self, input, d,name):
        input_shape = tf.shape(input)
        if name == 'rpn_cls_prob_reshape':
             return tf.transpose(tf.reshape(tf.transpose(input,[0,3,1,2]),[input_shape[0],
                    int(d),tf.cast(tf.cast(input_shape[1],tf.float32)/tf.cast(d,tf.float32)*tf.cast(input_shape[3],tf.float32),tf.int32),input_shape[2]]),[0,2,3,1],name=name)
        else:
             return tf.transpose(tf.reshape(tf.transpose(input,[0,3,1,2]),[input_shape[0],
                    int(d),tf.cast(tf.cast(input_shape[1],tf.float32)*(tf.cast(input_shape[3],tf.float32)/tf.cast(d,tf.float32)),tf.int32),input_shape[2]]),[0,2,3,1],name=name)

乍一看,这么长的几行代码,有点慌。。。别急,我们下面来一行行拆开看:

首先,获取输入的shape,对于单张测试图片假设rpn_cls_score输出的维度为(1, h, w, 18),这里的18=9*2,对应每个位置生成的9个anchor的类别信息。接着,根据输入参数提供的name = ‘rpn_cls_score_reshape’,进入else语句块,里面依次进行了transpose、reshape、transpose操作,tf.cast用于计算过程中类型转换的,理解代码的时候先忽略,这样代码可以理解为:

 return tf.transpose(tf.reshape(tf.transpose(input,[0,3,1,2]),[input_shape[0],
                    int(d),input_shape[1]*input_shape[3]/d,input_shape[2]]),[0,2,3,1],name=name)

继续拆,可以得到:

temp = tf.transpose(input,[0,3,1,2])
temp = tf.reshape(temp,[input_shape[0],int(d),input_shape[1]*input_shape[3]/d,input_shape[2]])
temp = tf.transpose(temp, [0,2,3,1], name=name)

还记得第一行代码得到的输入shape吗?把它代入到代码里:

# input的shape = [1, h, w, 18]
temp = tf.transpose(input,[0,3,1,2]) # shape = [1, 18, h, w]
temp = tf.reshape(temp,[1, 2, 18h/2,w]) # shape = [1, 2, 9h, w]
temp = tf.transpose(temp, [0,2,3,1], name=name) # shape = [1, 9h, w, 2]
return temp

最后返回的数据shape为(1, 9h, w, 2),前面3个维度先不用管,最后一个维度刚好就对应每个anchor属于目标和背景的概率。

接下来看softmax层:

"""
# .softmax(name='rpn_cls_prob')
"""

class Network(object):
    # ...
    
    def softmax(self, input, name):
        input_shape = tf.shape(input)
        if name == 'rpn_cls_prob':
            return tf.reshape(tf.nn.softmax(tf.reshape(input,[-1,input_shape[3]])),[-1,input_shape[1],input_shape[2],input_shape[3]],name=name)
        else:
            return tf.nn.softmax(input,name=name)

由于给定输出参数name=‘rpn_cls_prob’,代码进入if语句块。看到的依然是一个复合语句,一样的套路,拆开来看:

temp = tf.reshape(input,[-1,input_shape[3]])
temp = tf.nn.softmax(temp)
temp = tf.reshape(temp, [-1,input_shape[1],input_shape[2],input_shape[3]],name=name)

上面代码input的shape为(1, 9h, w, 2),代入到代码中:

temp = tf.reshape(input,[-1,2) # shape = [9hw, 2]
temp = tf.nn.softmax(temp) # shape = [9w, 2]
temp = tf.reshape(temp, [-1,9h,w,2],name=name) shape = [1, 9h, w, 2]

可以发现,softmax函数内部先把输入转化为一个2维张量,然后对最后一个维度进行操作(tf.nn.softmax函数默认在最后一个维度上操作,具体可查看源码),之后再reshape成输入的形状。

networks/VGGnet_test.py: 代码段15

下面还剩下测试模型部分的最后一块代码,即候选区域生成和ROI池化:

class VGGnet_test(Network):
    # ...
    
    def setup(self):
        # VGG特征提取网络
        # ...
        # RPN网络前半部分
        # ...
        
        (self.feed('rpn_cls_prob_reshape','rpn_bbox_pred','im_info')
             .proposal_layer(_feat_stride, anchor_scales, 'TEST', name = 'rois'))
        
        (self.feed('conv5_3', 'rois')
             .roi_pool(7, 7, 1.0/16, name='pool_5')
             .fc(4096, name='fc6')
             .fc(4096, name='fc7')
             .fc(n_classes, relu=False, name='cls_score')
             .softmax(name='cls_prob'))

        (self.feed('fc7')
             .fc(n_classes*4, relu=False, name='bbox_pred'))

proposal_layer_tf: 代码段16

经过上面的代码,网络一共得到了9hw个bbx,但这些bbx并不都是我们需要的。对于目标检测,我们更希望得到少数包含了目标的bbx。因此,下面需要对这些bbx进行筛选,也就是接下来的proposal_layer。

(self.feed('rpn_cls_prob_reshape','rpn_bbox_pred','im_info')
      .proposal_layer(_feat_stride, anchor_scales, 'TEST', name = 'rois'))

进入proposal_layer的定义,可以看到如下代码:

 @layer
def proposal_layer(self, input, _feat_stride, anchor_scales, cfg_key, name):
    if isinstance(input[0], tuple):
        input[0] = input[0][0]
    return tf.reshape(tf.py_func(proposal_layer_py,[input[0],input[1],input[2], cfg_key, _feat_stride, anchor_scales], [tf.float32]),[-1,5],name =name)

这里调用了TensorFlow的tf.py_func函数。简单的讲,该函数可以把普通的Python函数转化成输入输出都是张量的形式。因此我们接着看proposal_layer_py函数。这个函数的代码量较多,不过作者比较给出了很好的注释。另外我自己也添加了一些注释,如下:

def proposal_layer(rpn_cls_prob_reshape,rpn_bbox_pred,im_info,cfg_key,_feat_stride = [16,],anchor_scales = [8, 16, 32]):
    # Algorithm:
    #
    # for each (H, W) location i
    #   generate A anchor boxes centered on cell i
    #   apply predicted bbox deltas at cell i to each of the A anchors
    # clip predicted boxes to image
    # remove predicted boxes with either height or width < threshold
    # sort all (proposal, score) pairs by score from highest to lowest
    # take top pre_nms_topN proposals before NMS
    # apply NMS with threshold 0.7 to remaining proposals
    # take after_nms_topN proposals after NMS
    # return the top proposals (-> RoIs top, scores top)
    #layer_params = yaml.load(self.param_str_)
    """
    生成候选区域

    Args:
        rpn_cls_prob_reshape [4-d array]: 所有rpn属于前景/背景的类别概率,shape=(1, h, w, 18)
        rpn_bbox_pred [4-d array]: 所有rpn对应的偏移量(用于修正bbx)
        im_info [2-d array]: 记录blobs["data"]中的图片信息:高、宽、数量(尺度数量), shape=(1,3)
        cfg_key: 'train' or 'test'
        _feat_stride: 从特征图到原图的缩放大小(经过4个最大池化,每次缩小1/2,16=2**4)
        anchor_scales: anchor的尺度
    Return:
        blob [2-d array]: 最终选出的候选区域信息(位置信息), shape=(bbx_num, 1+4)

    """

    # 生成anchors(2维数组,每行代表一个anchor,用左上角和右下角坐标表示)
    # 先生成在特征图上(0,0)位置对应的9个anchor
    # 其他所有位置的anchors都根据(0,0)位置的anchors沿x和y方向平移得到
    _anchors = generate_anchors(scales=np.array(anchor_scales))
    _num_anchors = _anchors.shape[0] # 每个位置生成的anchor数量
    # shape = (1, h, w, 18) -> (1, 18, h, w)
    rpn_cls_prob_reshape = np.transpose(rpn_cls_prob_reshape,[0,3,1,2])
    rpn_bbox_pred = np.transpose(rpn_bbox_pred,[0,3,1,2])
    #rpn_cls_prob_reshape = np.transpose(np.reshape(rpn_cls_prob_reshape,[1,rpn_cls_prob_reshape.shape[0],rpn_cls_prob_reshape.shape[1],rpn_cls_prob_reshape.shape[2]]),[0,3,2,1])
    #rpn_bbox_pred = np.transpose(rpn_bbox_pred,[0,3,2,1])

    # im_info记录了blobs["data"]中的图片信息:高、宽、数量(尺度数量)
    # 注意,im_info是一个1行3列的二维数组,因此im_info[0]取出第1行变成了一维数组
    im_info = im_info[0]
    # 只支持单张图片测试,多张图片会抛出异常
    assert rpn_cls_prob_reshape.shape[0] == 1, \
        'Only single item batches are supported'
    # cfg_key = str(self.phase) # either 'TRAIN' or 'TEST'
    #cfg_key = 'TEST'
    pre_nms_topN  = cfg[cfg_key].RPN_PRE_NMS_TOP_N # 保留6000个得分最高的候选区域(NMS之前)
    post_nms_topN = cfg[cfg_key].RPN_POST_NMS_TOP_N # NMS之后保留300个候选区域
    nms_thresh    = cfg[cfg_key].RPN_NMS_THRESH # NMS阈值设为0.7
    min_size      = cfg[cfg_key].RPN_MIN_SIZE # 对应到原始图片上的候选区域宽高至少为16

    # the first set of _num_anchors channels are bg probs
    # the second set are the fg probs, which we want
    # scores.shape = (1, 18, h, w)
    # 第2个维度的前9个值代表9个anchor属于背景(background)的概率
    # 第2个维度的后9个值代表9个anchor属于前景(foreround)的概率
    # 因此下面代码取出了9个anchors属于前景的概率
    scores = rpn_cls_prob_reshape[:, _num_anchors:, :, :]
    bbox_deltas = rpn_bbox_pred # 预测的是相对于anchor的偏移量
    #im_info = bottom[2].data[0, :] 

    if DEBUG:
        print 'im_size: ({}, {})'.format(im_info[0], im_info[1])
        print 'scale: {}'.format(im_info[2])

    # 1. Generate proposals from bbox deltas and shifted anchors
    height, width = scores.shape[-2:]

    if DEBUG:
        print 'score map size: {}'.format(scores.shape)

    # Enumerate all shifts
    # 先把特征图上所有的位置映射到原图中(经过4个池化层,每个池化层缩小1/2)
    # 映射得到的位置即为生成该位置的所有anchors的中心
    # 在代码实现上,每个anchor中心位置由与(0, 0)位置的偏移量记录
    # 如:(0, 0)位置的一个anchor为(-8, -8, 8, 8),则原图中(16, 16)位置对应的一个anchor
    # 可表示为(16-0, 16-0, 16-0, 16-0) = (16, 16, 16, 16),即所有的坐标都平移了16个位置
    # 最终得到的这个anchor为(-8+16, -8+16, 8+16, 8+16) = (8, 8, 24, 24),其他的anchor同理
    # 下面的这段代码就是计算所有anchors相对于(0, 0)位置anchors的偏移,并保存在shifts中

    # 生成原图上所有anchor中心对应的相对于原点的偏移量,并分别保存在shift_x和shift_y中
    # 最后统一保存在shifts中(每行代表一个位置的4个值的偏移量)
    shift_x = np.arange(0, width) * _feat_stride # shape = (w,)
    shift_y = np.arange(0, height) * _feat_stride # shape = (h,)
    shift_x, shift_y = np.meshgrid(shift_x, shift_y) # shape = (w, h)
    shifts = np.vstack((shift_x.ravel(), shift_y.ravel(),
                        shift_x.ravel(), shift_y.ravel())).transpose() # shape = (wh, 4)

    # Enumerate all shifted anchors:
    #
    # add A anchors (1, A, 4) to
    # cell K shifts (K, 1, 4) to get
    # shift anchors (K, A, 4)
    # reshape to (K*A, 4) shifted anchors
    # 有了原点位置的9个anchors和其他所有位置相对于原点的偏移量shifts
    # 还需要分别进行reshape以便于broadcast计算
    # _anchors: shape=(A, 4) -> (1, A, 4), A表示每个位置anchor数量,这里为9
    # shifts: shape=(K, 4) -> (K, 1, 4), K表示有多少个位置,即K=wh
    # anchors: shape = (K, A, 4), broadcast计算,最后reshape为(K*A, 4)
    # 即一共得到K*A个anchors, 每一行代表一个anchor (x1, y1, x2, y2)
    A = _num_anchors
    K = shifts.shape[0]
    anchors = _anchors.reshape((1, A, 4)) + \
              shifts.reshape((1, K, 4)).transpose((1, 0, 2))
    anchors = anchors.reshape((K * A, 4))

    # Transpose and reshape predicted bbox transformations to get them
    # into the same order as the anchors:
    #
    # bbox deltas will be (1, 4 * A, H, W) format
    # transpose to (1, H, W, 4 * A)
    # reshape to (1 * H * W * A, 4) where rows are ordered by (h, w, a)
    # in slowest to fastest order
    # 根据anchors的格式把reshape边界框预测的偏移量:shape = (K*A, 4)
    bbox_deltas = bbox_deltas.transpose((0, 2, 3, 1)).reshape((-1, 4))

    # Same story for the scores:
    #
    # scores are (1, A, H, W) format
    # transpose to (1, H, W, A)
    # reshape to (1 * H * W * A, 1) where rows are ordered by (h, w, a)
    # 根据anchors的格式把reshape边界框包含目标的概率: shape = (K*A, 1)
    scores = scores.transpose((0, 2, 3, 1)).reshape((-1, 1))

    # Convert anchors into proposals via bbox transformations
    # 根据预测的偏移量修正边界框
    proposals = bbox_transform_inv(anchors, bbox_deltas)

    # 2. clip predicted boxes to image
    # 裁剪超出图像边界的bbx
    proposals = clip_boxes(proposals, im_info[:2])

    # 3. remove predicted boxes with either height or width < threshold
    # (NOTE: convert min_size to input image scale stored in im_info[2])
    # in_fo[2]是目标size/原图size,因为原图经过了尺度变换,所以对应的min_size也要变换
    # keep中保存了满足条件的行索引,因此根据keep可以筛选出对应的bbx位置信息和包含目标的概率
    keep = _filter_boxes(proposals, min_size * im_info[2]) 
    proposals = proposals[keep, :]
    scores = scores[keep]

    # 4. sort all (proposal, score) pairs by score from highest to lowest
    # 5. take top pre_nms_topN (e.g. 6000)
    # 对所有bbx的包含目标的概率从高到低排序,对应的索引保存在order中
    order = scores.ravel().argsort()[::-1]
    if pre_nms_topN > 0:
        order = order[:pre_nms_topN] # 保留前6000个bbx
    proposals = proposals[order, :] # 取出前6000个bbx的位置信息
    scores = scores[order] # 取出前6000个bbx的包含目标概率信息

    # 6. apply nms (e.g. threshold = 0.7)
    # 7. take after_nms_topN (e.g. 300)
    # 8. return the top proposals (-> RoIs top)
    keep = nms(np.hstack((proposals, scores)), nms_thresh)
    if post_nms_topN > 0:
        keep = keep[:post_nms_topN]
    proposals = proposals[keep, :]
    scores = scores[keep]
    # Output rois blob
    # Our RPN implementation only supports a single input image, so all
    # batch inds are 0
    batch_inds = np.zeros((proposals.shape[0], 1), dtype=np.float32)
    blob = np.hstack((batch_inds, proposals.astype(np.float32, copy=False)))
    return blob
    #top[0].reshape(*(blob.shape))
    #top[0].data[...] = blob

    # [Optional] output scores blob
    #if len(top) > 1:
    #    top[1].reshape(*(scores.shape))
    #    top[1].data[...] = scores
    
def _filter_boxes(boxes, min_size):
    """Remove all boxes with any side smaller than min_size."""
    ws = boxes[:, 2] - boxes[:, 0] + 1
    hs = boxes[:, 3] - boxes[:, 1] + 1
    # np.where返回满足条件的索引,注意,返回的是tuple
    keep = np.where((ws >= min_size) & (hs >= min_size))[0]
    return keep

概括地讲,这个函数的输入是所有的bbx信息,接着根据预测的偏移量对bbx位置进行修正、对超出图像边界的bbx进行裁剪、删除置信度低于指定阈值的bbx、并选出包含目标概率最大的若干个bbx并排序、执行非极大值抑制,最终返回剩下的bbx。

roi_pooling_op.cc: 代码段17

模型的最后一部分就是roi池化和输出了。输出依然是基于全连接层和softmax,很直观。

# 根据RPN生成的候选区域
# 'conv5_3'的输出维度为:(1, w, h, 512)
# 'rois'的输出维度为:(bbx_num, 5)
(self.feed('conv5_3', 'rois')
     .roi_pool(7, 7, 1.0/16, name='pool_5')
     .fc(4096, name='fc6')
     .fc(4096, name='fc7')
     .fc(n_classes, relu=False, name='cls_score') # shape=(bbx_num, 21)
     .softmax(name='cls_prob'))

(self.feed('fc7')
     .fc(n_classes*4, relu=False, name='bbox_pred')) # shape=(bbx_num, 21*4)

所以主要看roi池化部分的代码。常规的卷积神经网络层TensorFlow都给我们提供了写好的OP。而对于TensorFlow中没有提供的OP,TensorFlow也支持用户自己定义OP(需要用C++编写)。在具体的实现上分为两个步骤:

  • 调用REGISTER_OP宏来定义Op接口:定义的时候可以制定输入(Input)、输出(Output)、属性(Attr)
  • 为Op借口提供1个或多个Op的实现:也就是具体的实现代码,常常分CPU和GPU等不同的实现代码。而这些代码都是基于TensorFlow提供的OpKernel类:
    • 创建1个类,继承自OpKernel类
    • 重写OpKernel类的Compute方法
  • 将定义的Op接口和Kernel关联起来,并配置运行环境

为了便于理解,我参考了网上的一些教程写了一个例子在test_codes\test_op文件夹下。对于ROI池化,相关的代码在roi_pooling_op.cc中。首先是定义Op:

#include <stdio.h>
#include <cfloat>

#include "third_party/eigen3/unsupported/Eigen/CXX11/Tensor"
#include "tensorflow/core/framework/op.h"
#include "tensorflow/core/framework/op_kernel.h"
#include "tensorflow/core/framework/tensor_shape.h"
#include "work_sharder.h"

using namespace tensorflow;
typedef Eigen::ThreadPoolDevice CPUDevice;

// RoiPoing的输入输出和属性定义
REGISTER_OP("RoiPool")
    .Attr("T: {float, double}")
    .Attr("pooled_height: int")
    .Attr("pooled_width: int")
    .Attr("spatial_scale: float")
    .Input("bottom_data: T")
    .Input("bottom_rois: T")
    .Output("top_data: T")
    .Output("argmax: int32");
// RoiPoing(梯度)的输入输出和属性定义
REGISTER_OP("RoiPoolGrad")
    .Attr("T: {float, double}")
    .Attr("pooled_height: int")
    .Attr("pooled_width: int")
    .Attr("spatial_scale: float")
    .Input("bottom_data: T")
    .Input("bottom_rois: T")
    .Input("argmax: int32")
    .Input("grad: T")
    .Output("output: T");

接着就是重写OpKernel类的Compute方法来实现ROI过程,因为定义了2个Op,分别是ROI池化和对应的梯度传播过程,而每个Op都分CPU和GPU两种实现。因此,代码中一共重写了4个类并重写Compute方法。C++基础好的可以自己去看。(我自己是个C++渣渣,期待大神的指导)

// 此处省略Op的实现

最后就是需要把定义的Op和实现方法关联起来:

// 将定义的OP和Kernel关联起来
REGISTER_KERNEL_BUILDER(Name("RoiPool").Device(DEVICE_CPU).TypeConstraint<float>("T"), RoiPoolOp<CPUDevice, float>);
REGISTER_KERNEL_BUILDER(Name("RoiPoolGrad").Device(DEVICE_CPU).TypeConstraint<float>("T"), RoiPoolGradOp<CPUDevice, float>);
#if GOOGLE_CUDA
REGISTER_KERNEL_BUILDER(Name("RoiPool").Device(DEVICE_GPU).TypeConstraint<float>("T"), RoiPoolOp<Eigen::GpuDevice, float>);
REGISTER_KERNEL_BUILDER(Name("RoiPoolGrad").Device(DEVICE_GPU).TypeConstraint<float>("T"), RoiPoolGradOp<Eigen::GpuDevice, float>);
#endif

test.py: 代码段17

网络部分看完后,再继续回到im_detect函数,还剩下几个if语句块,我们只看test阶段为True的几个if语句块:

def im_detect(sess, net, im, boxes=None):
    """Detect object classes in an image given object proposals.
    Arguments:
        net (caffe.Net): Fast R-CNN network to use
        im (ndarray): color image to test (in BGR order)
        boxes (ndarray): R x 4 array of object proposals
    Returns:
        scores (ndarray): R x K array of object class scores (K includes
            background as object category 0)
        boxes (ndarray): R x (4*K) array of predicted bounding boxes
    """
    # ...
    # ...
    if cfg.TEST.HAS_RPN:
        # 只支持单尺度
        # im_scales存放了所有尺度的缩放比例
        # len(im_scale)==1表明只有一种缩放尺度
        assert len(im_scales) == 1, "Only single-image batch implemented"
        # rois中存放了所有的候选区域位置信息
        # 需要根据图片缩放比例映射到原图中
        boxes = rois[:, 1:5] / im_scales[0]
        
    scores = cls_prob
    
    if cfg.TEST.BBOX_REG: # True for demo
        # Apply bounding-box regression deltas
        # 边界框回归,并对超出边界的部分进行裁剪
        box_deltas = bbox_pred
        pred_boxes = bbox_transform_inv(boxes, box_deltas)
        pred_boxes = _clip_boxes(pred_boxes, im.shape)
        
    return scores, pred_boxes

可以发现,这边的代码主要是对预测的结果做进一步处理后并返回。首先将box映射到原图上,之后执行边界框回归并裁剪超出图像的bbx,使最后的bbx更加准确。先看一下边界框回归的实现代码:

def bbox_transform_inv(boxes, deltas):
    """
    根据预测的边界框偏移量修正anchors(边界框回归)
    Args:
        boxes [2-d array]: 修正前的anchors,shape=[K*A, 4], 每行代表一个box的位置,
                           分别对应[x1, y1, x2, y2]
        deltas [2-d array]: 所有anchors对应的偏移量,shape=[K*A, 4],每行代表一个box的偏移量
    Return:
        pred_boxes [2-d array]: 修正后的anchors, shape=[K*A, 4], 每行代表一个修正后的box的位置,
                                分别对应[x1, y1, x2, y2]
        
    """
    # boxees: [box_num, 4] [x1, y1, x2 , y2]
    if boxes.shape[0] == 0:
        return np.zeros((0, deltas.shape[1]), dtype=deltas.dtype)

    boxes = boxes.astype(deltas.dtype, copy=False)
    # 获取box宽高、中心点坐标
    widths = boxes[:, 2] - boxes[:, 0] + 1.0 # x2-x1 > 1
    heights = boxes[:, 3] - boxes[:, 1] + 1.0 # y2-y1 > 1
    ctr_x = boxes[:, 0] + 0.5 * widths
    ctr_y = boxes[:, 1] + 0.5 * heights

    # 从下标0开始,每4个元素取一个,即取出所有的dx, dy, dw, dh
    # 对于anchors对应的deltas,每行包含4个值,因此只取出一个值
    dx = deltas[:, 0::4] 
    dy = deltas[:, 1::4]
    dw = deltas[:, 2::4]
    dh = deltas[:, 3::4]
    # 论文中提到,边界框回归中, 网络预测的是tx, ty, tw, th,也就是代码中的dx, dy, dw, dh,
    # 其中,tx=(x-xa)/wa, ty=(y-ya)/ha, tw=log(w/wa), th=log(h/ha)。
    # 因此,x=tx*wa+xa, y=ty*ha+ya, w=exp(tw)*wa, h=exp(th)*ha,对应下面4行代码
    pred_ctr_x = dx * widths[:, np.newaxis] + ctr_x[:, np.newaxis]
    pred_ctr_y = dy * heights[:, np.newaxis] + ctr_y[:, np.newaxis]
    pred_w = np.exp(dw) * widths[:, np.newaxis]
    pred_h = np.exp(dh) * heights[:, np.newaxis]

    # 把修正后的bbx转化为[x1, y1, x2, y2]表示形式并保存在pred_boxes中
    pred_boxes = np.zeros(deltas.shape, dtype=deltas.dtype)
    # x1
    pred_boxes[:, 0::4] = pred_ctr_x - 0.5 * pred_w
    # y1
    pred_boxes[:, 1::4] = pred_ctr_y - 0.5 * pred_h
    # x2
    pred_boxes[:, 2::4] = pred_ctr_x + 0.5 * pred_w
    # y2
    pred_boxes[:, 3::4] = pred_ctr_y + 0.5 * pred_h

    return pred_boxes

边界框回归的代码是严格按照论文中的公式来的,因此更多的细节可以对照论文的边界框回归看。

最后一个裁剪bbx的函数就很直观了,如下:

def _clip_boxes(boxes, im_shape):
    """Clip boxes to image boundaries."""
    # x1 >= 0
    boxes[:, 0::4] = np.maximum(boxes[:, 0::4], 0)
    # y1 >= 0
    boxes[:, 1::4] = np.maximum(boxes[:, 1::4], 0)
    # x2 < im_shape[1]
    boxes[:, 2::4] = np.minimum(boxes[:, 2::4], im_shape[1] - 1)
    # y2 < im_shape[0]
    boxes[:, 3::4] = np.minimum(boxes[:, 3::4], im_shape[0] - 1)
    return boxes

小结3

到这里为止,代码主要实现了数据输入测试网络后的过程,包括第一部分的卷积神经网络特征提取、RPN网络、ROI池化、模型的输出及处理。最后得到的是检测出的目标类别及位置信息。

demo.py: 代码段18

看完im_detect函数,又回到了demo.py。此时,检测的bbx目标类别和位置信息分别存放在scores和boxes中。下面就是可视化的代码,即把检测结果呈现在原图上。

# Visualize detections for each class
    im = im[:, :, (2, 1, 0)] # BGR -> RGB
    fig, ax = plt.subplots(figsize=(12, 12))
    ax.imshow(im, aspect='equal')

    CONF_THRESH = 0.8
    NMS_THRESH = 0.3
    for cls_ind, cls in enumerate(CLASSES[1:]):
        cls_ind += 1 # because we skipped background
        cls_boxes = boxes[:, 4*cls_ind:4*(cls_ind + 1)] # 获取当前类别对应的bbx位置, shape=(R, 4)
        cls_scores = scores[:, cls_ind] # 获取当前类别对应的概率, shape=(R, )
        dets = np.hstack((cls_boxes,
                          cls_scores[:, np.newaxis])).astype(np.float32) # shape=(R, 4+1)
        keep = nms(dets, NMS_THRESH) # 非极大值抑制
        dets = dets[keep, :]
        # 画出当前类别对应的bbx的位置(置信度大于制定阈值)
        vis_detections(im, cls, dets, ax, thresh=CONF_THRESH)
        
def vis_detections(im, class_name, dets,ax, thresh=0.5):
    """Draw detected bounding boxes.
    Args:
        im [3-d array]: 原图(RGB格式)
        class_name: 类别名称
        dets [2-d array]: 最后留下来的bbx信息, shape=(N, 5), N表示bbx数量, 
                          5=4+1,分别代表bbx的位置信息和置信度
        ax: 坐标轴
        thresh: 置信度阈值
    """
    # 返回置信度大于制定阈值的对应的bbx索引
    inds = np.where(dets[:, -1] >= thresh)[0]
    if len(inds) == 0:
        return

    for i in inds: # 遍历满足条件的索引
        bbox = dets[i, :4] # 位置
        score = dets[i, -1] # 置信度
        # 添加位置矩形框
        ax.add_patch(
            plt.Rectangle((bbox[0], bbox[1]),
                          bbox[2] - bbox[0],
                          bbox[3] - bbox[1], fill=False,
                          edgecolor='red', linewidth=3.5)
            )
        # 添加目标信息(类别+置信度,写在矩形框左上角外侧)
        ax.text(bbox[0], bbox[1] - 2,
                '{:s} {:.3f}'.format(class_name, score),
                bbox=dict(facecolor='blue', alpha=0.5),
                fontsize=14, color='white')

    ax.set_title(('{} detections with '
                  'p({} | box) >= {:.1f}').format(class_name, class_name,
                                                  thresh),
                  fontsize=14)
    plt.axis('off')
    plt.tight_layout()
    plt.draw()

到这里为止,Faster R-CNN的测试过程就看完了。毕竟是个人学习笔记,有的地方可能理解有误,还希望多多指教。

猜你喜欢

转载自blog.csdn.net/sinat_34474705/article/details/85458501
今日推荐