1:前言

文章目录

  • 1:前言
  • 2:图片的预处理
  • 3:整体流程概述
  • 4:搭建特征提取网络
  • 4:anchors的形成
  • 5:RPN网络的搭建
  • 6:Proposal Layer
  • 7:创建标签
  • 8:ROIAlign
  • 9:创建Head部分
  • 10:LOSS的设定
  • 总结

Mask Rcnn作为目标分割的前驱,其原理与构思值得我们逐字揣摩。为此我用了两周多的事件搞清楚原理,两周左右的事件攻读代码。给我的感觉就是,如果你真的想一句一句看懂每一行着实不现实,尤其是无关原理,无关流程的代码,看起来完全一头雾水。在这里我很感谢CSDN平台中的一位大佬Cleo_Gao。巧合的是我们俩公用了同一套代码,他对这套代码的解释使我明白了很多原理和流程,可以说本篇文章的叙述有百分之60是对他注释的延续,当然,我也从中发现了他注释的一些瑕疵,但是瑕不掩瑜的是,他的三篇博客确实是非常优秀的作品,大家如果想节约时间去看大致流程的话可以移步他的博客观看。
mask rcnn 超详细代码解读
最后一点也是我的一些希冀,我希望有心人可以在探讨源码的同时指出我的不足或者理解错误的地方,所以附上源码的链接。
源码

2:图片的预处理

由于我们输入网络的图片规定大小比较大,达到了1024x1024,(这一点是我们选择square填充方式以后决定的,在config中是这么规定的)

    def __init__(self):"""Set values of computed attributes."""# Effective batch sizeself.BATCH_SIZE = self.IMAGES_PER_GPU * self.GPU_COUNT# Input image sizeif self.IMAGE_RESIZE_MODE == "crop":self.IMAGE_SHAPE = np.array([self.IMAGE_MIN_DIM, self.IMAGE_MIN_DIM,self.IMAGE_CHANNEL_COUNT])else:self.IMAGE_SHAPE = np.array([self.IMAGE_MAX_DIM, self.IMAGE_MAX_DIM,self.IMAGE_CHANNEL_COUNT])# Image meta data length# See compose_image_meta() for detailsself.IMAGE_META_SIZE = 1 + 3 + 3 + 4 + 1 + self.NUM_CLASSES

而我们的训练集图片大部分都小于这个尺寸,所以就必然导致了我们的图片都需要进行pading,其原理就是原图放中间,四周补0到都符合图片输入尺寸。
如果出现尺寸超过规定的范围的时候,就会对图片进行resize以后再用上述的方式继续填充。
当然整个网络还规定了其他的填充方式,这里就不赘述,主要针对不同的训练集而言。具体代码在utils.resize_image里面,该函数还会返回以下参数
①image:处理过后的图片
②window:原图在处理过后图片的位置,采用的是左下角和右上角的坐标表示方式。
这个window我们会在后面很关键的地方还会遇到,我给出的建议是,不要盲目相信这个window。
③scale:缩放比例,如果图片没有经过resize,那就始终为1,如果经过了resize,也就会有它自己的计算方法。
④padding : [(top_pad, bottom_pad), (left_pad, right_pad), (0, 0)],也就是我们进行padding的一个列表参数,四周填充0的行列数。

3:整体流程概述


这里借用之前那位大佬的流程图进行说明。其实就网络的搭建而言是比较简单直接的,其对于faster rcnn仅仅就原理而言的改动并不大(但是流程处理方面改动还真的不小)。其主要的改动就两点
①对于特征层的提取,其采用的是FPN原理形成了多个特征层,用多个特征层来绘制anchors
②在后面head部分增加对于masks的误差计算
这里我个人不太倾向于用过多的篇幅来详细地阐述流程,因为说再多都是纸上谈兵,我更倾向于从代码结构进行解释,所以这一个部分我不会做过多的说明。

4:搭建特征提取网络

其采用的是resnet101,当然就原理本身而言,任何一个成熟的网络都是可以的,就网络自身搭建而言并没有什么突出的亮点,就是创建了conv_block和identity_block,然后搭建了整体的网络并且输出了5个特征层C1,C2,C3,C4,C5,其中C1是仅仅经过一次普通卷积得到的输出层,C2到C5才是我们后面用来形成anchors的特征层。

def resnet_graph(input_image, architecture, stage5=False, train_bn=True):"""Build a ResNet graph.architecture: Can be resnet50 or resnet101stage5: Boolean. If False, stage5 of the network is not createdtrain_bn: Boolean. Train or freeze Batch Norm layers"""assert architecture in ["resnet50", "resnet101"]# Stage 1x = KL.ZeroPadding2D((3, 3))(input_image)x = KL.Conv2D(64, (7, 7), strides=(2, 2), name='conv1', use_bias=True)(x)x = BatchNorm(name='bn_conv1')(x, training=train_bn)x = KL.Activation('relu')(x)C1 = x = KL.MaxPooling2D((3, 3), strides=(2, 2), padding="same")(x)# Stage 2x = conv_block(x, 3, [64, 64, 256], stage=2, block='a', strides=(1, 1), train_bn=train_bn)x = identity_block(x, 3, [64, 64, 256], stage=2, block='b', train_bn=train_bn)C2 = x = identity_block(x, 3, [64, 64, 256], stage=2, block='c', train_bn=train_bn)# Stage 3x = conv_block(x, 3, [128, 128, 512], stage=3, block='a', train_bn=train_bn)x = identity_block(x, 3, [128, 128, 512], stage=3, block='b', train_bn=train_bn)x = identity_block(x, 3, [128, 128, 512], stage=3, block='c', train_bn=train_bn)C3 = x = identity_block(x, 3, [128, 128, 512], stage=3, block='d', train_bn=train_bn)# Stage 4x = conv_block(x, 3, [256, 256, 1024], stage=4, block='a', train_bn=train_bn)block_count = {"resnet50": 5, "resnet101": 22}[architecture]for i in range(block_count):x = identity_block(x, 3, [256, 256, 1024], stage=4, block=chr(98 + i), train_bn=train_bn)C4 = x# Stage 5if stage5:x = conv_block(x, 3, [512, 512, 2048], stage=5, block='a', train_bn=train_bn)x = identity_block(x, 3, [512, 512, 2048], stage=5, block='b', train_bn=train_bn)C5 = x = identity_block(x, 3, [512, 512, 2048], stage=5, block='c', train_bn=train_bn)else:C5 = Nonereturn [C1, C2, C3, C4, C5]

4:anchors的形成

形成anchors的特征层是C2,C3,C4,C5和P6,P6其实就是C5经过一次最大池化得到的,这五个特征层的分辨率(也可以说是尺寸)从小到大分别是[16,32,64,128,256],其深度均为256,其主要代码在utils.generate_pyramid_anchors.其输入的主要参数有
①:scales=[32,64,128,256,512],其表示的anchors的尺寸,小的anchors由大的特征层来检测小的目标,大的anchors由小的特征层来形成检测大的目标。其原理很好理解,因为小的目标你经过的卷积越多,其特征保留得越少,到最后可能都和环境融为一体无法检测了,所以越早检测越好。
②:ratios=[0.5,1,2]这是与faster rcnn不一样的地方,因为我规定死了每一个特征层产生的anchos的大小,所以它能改变的只有高宽比了,也就是所有的特征层的一个特征格只能产生3个anchors。
③:backbone_shapes=[[256,256],[128,128],[64,64],[32,32],[16,16]]表示的每一个特征层的尺寸
④:feature_strides=[4,8,16,32,64]其表示的是原图与特征层的缩放倍数,用来联系anchors与特征格
⑤:anchor_strides=1,表示每一个每一个特征格都会产生3个anchors,如果设置为2,就是隔一个选一个,依此类推。
这里我想多说一下的是,这里特征格和anchors之间的映射关系与faster rcnn不太一样。我们知道在faster rcnn中我们选取的是特征格的中心位置,也就是要加一个0.5,然后去乘放缩倍数就可以找到其在输入图片上的映射范围的中心点,但是在maks rcnn中直接用的特征图的原坐标,也就是没有加0.5,相当于用的特征格的左下角坐标来当anchors的中心位置,其实这并不影响什么,因为我们只是想要一种映射关系,到时候我们映射回来的时候也遵从一样的关系就可以了。

shifts_y = np.arange(0, shape[0], anchor_stride) * feature_stride
shifts_x = np.arange(0, shape[1], anchor_stride) * feature_stride

然后这里是重头戏,那就是mask rcnn网络在形成anchors以后会立马进行缩小映射,准确地说应该叫标准化。其代码

def norm_boxes(boxes, shape):"""Converts boxes from pixel coordinates to normalized coordinates.boxes: [N, (y1, x1, y2, x2)] in pixel coordinatesshape: [..., (height, width)] in pixelsNote: In pixel coordinates (y2, x2) is outside the box. But in normalizedcoordinates it's inside the box.Returns:[N, (y1, x1, y2, x2)] in normalized coordinates"""h, w = shapescale = np.array([h - 1, w - 1, h - 1, w - 1])shift = np.array([0, 0, 1, 1])return np.divide((boxes - shift), scale).astype(np.float32)

其作用从英文注释就看得出来,那就是如果一个anchors的右上角坐标或者某一个角的坐标在输入图像1024x1024的外面,那么这个anchors的坐标同时除以1024.那么就都在里面了,就不需要进行裁剪了,那么,这里的里面是指什么呢?其实就是1x1的格子。把所有的anchors的坐标全部压缩在1x1的格子里面。当然了,window,gt的坐标也会做一样的处理

 window = utils.norm_boxes(window, image_shape[:2])
gt_boxes = KL.Lambda(lambda x: norm_boxes_graph(x, K.shape(input_image)[1:3]))(input_gt_boxes)

我这里为什么特意提一下window,因为你会发现,这里的window的坐标基本不可能是(0,0,1,1),因为基本都会有元素0填充,后面有一个大坑我已经先替各位踩了无数遍了。

这里还需要强调的一点是,我们在model中的get_anchors函数里面会先得到一个特征层的anchors,然后通过循环获得一张图片的anchors,并不是一个batch,但是,通过原理我们制定,anchors的形成与这个图片长什么样子完全没有关系,所以我们会通过广播的形式,使每一张图都有一模一样的anchors,以达到batch的目的

anchors = self.get_anchors(image_shape)# Duplicate across the batch dimension because Keras requires it# TODO: can this be optimized to avoid duplicating the anchors?anchors = np.broadcast_to(anchors, (self.config.BATCH_SIZE,) + anchors.shape)

也就是说输入到下面Proposals Layer的anchors是一个batch的anchors,

5:RPN网络的搭建

一点心路历程:我当时花了点时间想了一下,RPN网络的里面的anchors到底用的是归一化后的还是归一化前的,按照原理来说应该都可以。后来我发现,人家RPN根本不会不会输入anchors,它和anchors本身并没有任何关系,我想半天不过是钻牛角尖而已,人家的作用是改变与筛选anchors,与你自身是否标准化无关。
其主要包含的函数为
(1)def rpn_graph(feature_map, anchors_per_location, anchor_stride):
输入:feature map的表示一种特征图,我为什么要说一种呢,因为它只能是P2到P6中的一种,在最后建立模型的时候会利用循环依次放进去。其shape[batch, height, width, depth],也就是说一个batch里面的所有图片的一类特征图。我在这里把循环获取的代也贴一下,不一定连贯,但是我只是想证明确实有这个操作,并不是我信口开河,并且各位看源码的时候可以节约时间。

rpn_feature_maps = [P2, P3, P4, P5, P6]
rpn = build_rpn_model(config.RPN_ANCHOR_STRIDE,len(config.RPN_ANCHOR_RATIOS), config.TOP_DOWN_PYRAMID_SIZE)
 for p in rpn_feature_maps:layer_outputs.append(rpn([p]))

②anchors_per_location:一个特征格产生多少个anchors,自然就是3了
③anchor_stride:就是1呗,每一个特征格都会产于产生anchors

其网络构造与faster rcnn一模一样,我这就不多赘述了,只是强调一下这个网路的作用,其目的是返回每一个anchors的是否存在物体的得分的两种表现形式,以及每一个anchors的回归系数。我甚至可以说其与anchors毫无关系,它只负责控制anchors的变化与筛选。

其返回的参数如下
①:rpn_class_logits: [batch, H * W * anchors_per_location, 2]
表示训练时这个特征层形成的anchors是否有目标的判别,这是未激活时的状态,用来计算误差
②:rpn_probs: [batch, H * W * anchors_per_location, 2]
这是①经过softmax以后的状态,用来提取存在物体的得分以便进入下一个阶段
③:[batch, H * W * anchors_per_location, (dy, dx, log(dh), log(dw))]
每一个anchors训练时的四个回归系数

这里唯一需要提醒一点的,也是那位大佬所提醒的那样,里面采用了KL. Lambda函数来进行最后shape的转化

rpn_bbox = KL.Lambda(lambda t: tf.reshape(t, [tf.shape(t)[0], -1, 4]))(x)

这里进行reshape也很好理解,我一开始一个特征格形成的3个anchors的信息是“合在一起的”,我需要把三个的信息全部拆开,然后以一个anchor为单位进行储存
这里为什么不直接使用tf.reshape进行处理,而一定要先用KL里面的Lambda函数来把tf.reshape“包”起来呢。这是因为keras作为tensorflow的前端,其是可以处理tf出来的数据流的,但是反过来,tf无法处理keras出来的数据流,也就是如果这里直接用tf.reshape得到RPN的特征层以后,以后再想用keras处理就不行了,所以这里必须用keras的函数lamda来对tf.reshape处理的函数进行一个类似转换数据流类型的操作,给它包装一下,以后如果遇到单独的tf操作,都要考虑使用类似的技巧。

(2)build_rpn_model(anchor_stride, anchors_per_location, depth):
看它的名字就看得出来,它是在建立模型,也就是把上面的网络组成一个model,设定它的输入和输出,然后用model包装起来。为什么它这里这么着急在这一步就要封装模型呢?这一步不应该等到网络搭建完毕以后再包装吗?这正是因为这个rpn网络只能一次处理一类的feature map,我们需要得到所有feature maps的处理结果,就需要反复对这个网络进行输入,所以必须在这里就把模型搭建好,可以理解为它自成一部分。另一个原因就是RPN阶段的损失也是从这里来的,所以我需要单独得到这部分的输出,才能利用它的输出来计算损失,所以我必须在它搭建完网络以后马上包装出来形成输出。

input_feature_map = KL.Input(shape=[None, None, depth],name="input_rpn_feature_map")outputs = rpn_graph(input_feature_map, anchors_per_location, anchor_stride)return KM.Model([input_feature_map], outputs, name="rpn_model")

KL中的Input层是不需要输入batch的,所以它自己只有三维,特征图的高宽不确定,所以设置为0。

6:Proposal Layer

它的作用是对anchors进行优先级的提取,找到最有可能存在物体的一定数量的anchors
其由三个辅助函数和一个类组成
(1)def apply_box_deltas_graph(boxes, deltas)
该函数的作用是对我们得到的所有的anchors进行初步的微调,也就是用rpn里面的回归系数来对anchors进行调整,但是它只能对一张图片的anchors进行微调,也就是它的两个输入的shape都是2维的[N,(y1,x1,y2,x2)]
(2) def clip_boxes_graph(boxes, window)
这一步是对微调以后的anchors进行裁剪,超出的部分直接减掉,这个window根据Proposals Layer的设定,它是专门设定的[0,0,1,1],所以这个window不可能是刚才我们规定的那个window,而仅仅是用来裁剪超出部分的作用。需要说明的是,它也只能用来处理一张图片的anchors
(3)batch_slice(inputs, graph_fn, batch_size, names=None):
既然(1)(2)都只能用来处理一张图片,所以我们必须要用一个辅助函数来先把一个batch的图片分成一张一张的形式,然后分别做处理以后再合并回一个batch
inputs的shape是三维的,因为有batch
graph fn表示每一张图片需要做的处理的函数
batch size自然就是一个batch里面有多少张图
(4)class ProposalLayer(KE.Layer)
这里说明一下为什么一定需要创建一个类来完成我们筛选proposals的步骤呢,为什么不直接使用代码呢
其实这个理由和上面为什么要使用lamda函数是一样的,我们在提取proposals的过程中避免不了使用tf中的接口
而我们自己定义的类,其是继承keras中的layer,也就是说我们创建的类其本身就会输出keras数据流,这样就可以把使用tf的接口得到的tf的数据流转化为
keras的数据流,如此以来类的输出就可以再用keras进行各种处理。
其输入为三个参数:
rpn_probs: [batch, num_anchors, (bg prob, fg prob)]
rpn_bbox: [batch, num_anchors, (dy, dx, log(dh), log(dw))]
anchors: [batch, num_anchors, (y1, x1, y2, x2)] anchors in normalized coordinates
其实就是rpn网路的两个输出,以及我们利用广播形成的一个batch的anchors。
我先贴一段init方法和call方法的代码,里面有我自己写的详细的注释。
我简略说一下下面call方法代码的流程:
首先我们会得到对我们的输入进行各自的提取。
然后得到一个batch中每个anchor的存在物体的得分。
继而我们会提取出一个batch中每一张图分最多的6000个anchors在一张图中所有anchors的坐标。
然后利用tf.gather,分别把这6000个anchors的存在物体得分,坐标,回归系数全部从我们的输入中提取出来,这里我们得到的依然是一个batch的三类信息。
之后利用我们的辅助函数,先进行微调,然后进行裁剪。
最后得到了我们的boxes,其shape为[b,6000,4]

    def __init__(self, proposal_count, nms_threshold, config=None, **kwargs):super(ProposalLayer, self).__init__(**kwargs)self.config = config#这里的config保存了很多可以一键修改的超参数,我尽量直接使用具体数字self.proposal_count = proposal_count#这是初步需要筛选的proposals以后再经过nms以后的最大proposals个数,这里应该是2000self.nms_threshold = nms_threshold#这是进行非极大值抑制的阈值def call(self, inputs):# Box Scores. Use the foreground class confidence. [Batch, num_rois, 1]scores = inputs[0][:, :, 1]#这里的分数其实就是我们得到的概率的第二列,因为0的标签是[1,0],1的标签是[0,1],所以第二列越大,分数越高# Box deltas [batch, num_rois, 4]deltas = inputs[1]#提取回归系数deltas = deltas * np.reshape(np.array([0.1, 0.1, 0.2, 0.2]), [1, 1, 4])#这里是现将回归系数的权重变成了3维,这样就可以每一组回归系数都需要用一样的权重,然后与原来的回归系数相乘,这也是fast rcnn不同的地方# Anchorsanchors = inputs[2]# Improve performance by trimming to top anchors by score# and doing the rest on the smaller subset.pre_nms_limit = tf.minimum(6000, tf.shape(anchors)[1])#这里规定了我根据得分需要初步拿到至少6000个proposals,还和总的anchor个数做了一个比较,避免极端情况没有6000个ix = tf.nn.top_k(scores, pre_nms_limit, sorted=True,name="top_anchors").indices#nn top k函数是得到score中最大的6000个数字,返回值是两个,一个是value,也就是这个数具体是多少,另一个是indices,表示其在scores中的下标#这里用来.indices,表示只返回下标就可以了,所以ix就是得分前6000个proposals在所有achors中的下标#但是一定要注意的是这个scores的shape是(b,number,1)scores = batch_slice([scores, ix], lambda x, y: tf.gather(x, y),self.config.IMAGES_PER_GPU)#tf.gather(x,y)是一种切片函数,说是切片,其实就是提取对应位置的元素,x表示从x中提取,y是需要提取的元素在x中的下标,就这么简单#这里解释一下,因为gather也只能处理二维的数据,也就是它本身也只能一次处理一张图片,所以也需要分开操作(b,6000,1)deltas = batch_slice([deltas, ix], lambda x, y: tf.gather(x, y),self.config.IMAGES_PER_GPU)#获取6000个proposals的回归系数[b,6000,4]pre_nms_anchors = batch_slice([anchors, ix], lambda a, x: tf.gather(a, x),self.config.IMAGES_PER_GPU,names=["pre_nms_anchors"])#获取6000个proposals在1x1个格子里面的具体坐标[b,6000,4],注意这里的顺序已经改成了根据各自得分的高低来排序'''之后我们的操作就清晰了,先把6000个proposals先经过各自的回归系数进行精修,之后进行超出范围的裁剪,然后再利用nms方法来寻找6000中“相隔甚远”的2000个proposals'''# Apply deltas to anchors to get refined anchors.# [batch, N, (y1, x1, y2, x2)]boxes =batch_slice([pre_nms_anchors, deltas],lambda x, y: apply_box_deltas_graph(x, y),self.config.IMAGES_PER_GPU,names=["refined_anchors"])#pre nms anchors 表示batch里面前6000个anchor在1x1格子里面的坐标,deltas表示batch里面这6000个的回归系数#返回的是经过回归后的这6000个proposals的在1x1格子里面的坐标[b,6000,4]#之后进行裁剪# Clip to image boundaries. Since we're in normalized coordinates,# clip to 0..1 range. [batch, N, (y1, x1, y2, x2)]window = np.array([0, 0, 1, 1], dtype=np.float32)boxes =batch_slice(boxes,lambda x: clip_boxes_graph(x, window),self.config.IMAGES_PER_GPU,names=["refined_anchors_clipped"])#经过裁剪后的坐标[b,6000,4]# Filter out small boxes# According to Xinlei Chen's paper, this reduces detection accuracy# for small objects, so we're skipping it.'''这里注释说的是,在fater rcnn中,我们对于proposals还有一步筛选,那就是去除掉非常小的proposals,但是论文作者认为这会减少对于小物体的识别准确度,所以在这里跳过了这一步,直接进行nms

我再强调一下我注释里面的重点
①tf.gather本身也不能处理三维的数据,所以也需要(3)的配合
②这个ix的shape的特点必须记住,那就是nn.top_k的它找到下标是进到输入tensor的最里面维度,也就是精确到每一个具体的元素,它会返回这个元素在所在图的维度的坐标,而不是batch维度的坐标,也就说它无法反应这个scores来自哪一个图,这种做法正好符合我们的要求。
③这个window并不是我们一开始定义的那个window,而只是一个边界,我们的操作对象依然是填充后的输入图片

当然我们的处理还没完,因为在init方法里面我们定义了一个nsm的阈值,就代表我们还需要对这batch*6000个anchors做进一步的筛选,那就是非极大值抑制。

需要提醒的是,nms里面的接口,也只能进行2维的操作,也就是也只能一次处理一张图片,还得要辅助函数的帮助,我这里只贴出来处理一张的代码

        def nms(boxes, scores):indices = tf.image.non_max_suppression(boxes, scores, self.proposal_count,self.nms_threshold, name="rpn_non_max_suppression")'''非极大值抑制其实就一个接口而已,boxes表示需要进行非极大值抑制的proposals的坐标(6000,4)scores是这6000个proposals的得分,它是进行非极大值抑制的依据(6000,1)self proposal count设置的是2000,也就是最大个数是2000个,到底多少个看情况,不一定,大部分都是2000其返回的indices表示这2000个proposals在6000个proposals里面的下标'''proposals = tf.gather(boxes, indices)#提取出来这2000个的坐标(2000,4)'''我们这里规定必须是2000个,因为这涉及到下一个阶段输入必须是固定的,不够怎么办我们的方法是如果不够,那么就在不够的proposals下面全部补4个0,也就是说坐标全是0,以此来补齐2000'''# Pad if neededpadding = tf.maximum(self.proposal_count - tf.shape(proposals)[0], 0)proposals = tf.pad(proposals, [(0, padding), (0, 0)])return proposals

针对这个方法我也做出几点的提醒:
①输入的boxes和scores实际也只是一张图6000个anchors的坐标和一张anchors图的得分
②我刚才一直在用6000,而没有说可能没有6000的情况,只是因为这样善于表达,并不代表真的每一个都有6000个anchors,就像这里的nms操作,它提取出来互不相干的proposals不一定够2000个,所以这里全部用0来填充不够的时候的proposals的坐标,以满足下一个阶段固定的一个输入,而不是变化的
③anchors名称的变化,我们初步得到的框就叫anchors,经过Proposals Layer以后筛选出来的anchors改了个名叫proposals,翻译过来就是建议框。之后还要进入ROIAlign Layer,进一步对proposals进行筛选,筛选出来的proposals又叫rois。其实大家本质都一样,没有任何区别,只是表示这是哪个阶段的框。

对于这个自创的类我也有一些作为初学者的一些总结性结论:
由于这是我们自己创建的类,那么使用自定义层需要注意什么呢
正常的自定义层一般会包含四个个def
init:其作用是对这个类进行初始化,其会使用super对其继承的类的init进行初始化,如果有自定义层独有的初始化变量,直接初始化就可以了,比如上面的三个
build:如果你这个层需要创建参数的时候,就需要利用build来创建参数并对其初始化,比如自定义的BN层,需要设置均值和方差,就需要build来创建参数
call:其是最重要的一个方法,其作用是告知这个层的具体操作步骤,也是接受类的输入方法,也就说类的输入其实是传到了call里面
compute_output_shape:如果输入经过这个类以后改变了形状,需要在这个方法里面指明最后输出的shape到底是什么

但是ProposalLayer这个类,你会发现这里面并没有build方法,这是因为这个层的作用是选取有物体的anchors,其本身并不会产生新的参数,所以build可以不用

7:创建标签

我们的网络明明还有大部分还没有搭建完毕,为什么现在就可以创建标签了呢?其实通过熟知原理可以知道,我们的损失有五部分组成,如下:

rpn_class_loss = KL.Lambda(lambda x: rpn_class_loss_graph(*x), name="rpn_class_loss")([input_rpn_match, rpn_class_logits])rpn_bbox_loss = KL.Lambda(lambda x: rpn_bbox_loss_graph(config, *x), name="rpn_bbox_loss")([input_rpn_bbox, input_rpn_match, rpn_bbox])class_loss = KL.Lambda(lambda x: mrcnn_class_loss_graph(*x), name="mrcnn_class_loss")([target_class_ids, mrcnn_class_logits, active_class_ids])bbox_loss = KL.Lambda(lambda x: mrcnn_bbox_loss_graph(*x), name="mrcnn_bbox_loss")([target_bbox, target_class_ids, mrcnn_bbox])mask_loss = KL.Lambda(lambda x: mrcnn_mask_loss_graph(*x), name="mrcnn_mask_loss")([target_mask, target_class_ids, mrcnn_mask])

也就是两个阶段的误差计算,一个是RPN阶段的,一个是head阶段的。在RPN阶段的两个损失,一个是判别是否存在物体的损失,这部分的损失可以通过形成的anchors与gt的iou来给予相应的标签,一个是anchors的四个回归系数,其同样可以根据形成的anchors与gt的位置关系,计算得到相应的标签。一句话来说就是,只有anchors形成的那一刻,这俩的标签就已经可以确定了。
然后是head部分的损失,一个物体种类的标签,其是根据anchors与gt最大的iou来直接赋予的,这个标签是训练集自带的,不过需要我们自己来一一赋值而已。一个是第二次边框回归的回归系数,其是对最后的rois进行与gt(和RPN一样的原理),gt已知,那么这个标签也可以直接计算。最后是masks的损失,同理,只要anchors已经生成,原图的masks的标签已经给出(我会在下面详细说明masks的标签结构)那么这部分的标签也是可以直接计算的。
故总结一句话就是,五类损失中的五个标签的所需要的所有元素,从在anchors生成以后就已经可以全部计算了,只不过是参与计算的anchors的个数还需要筛选而已。
我们总的流程需要两个辅助函数,一个类来共同完成
(1)def overlaps_graph(boxes1, boxes2)
其是用来计算一组框与另一组框的ious的,boxes1与boxes2均是2维的[N,(y1,x1,y2,x2)]
也就说单次调用的时候它只能一张图片的proposals与gt之间的iou运算。返回的shape为2维
[boxes1的个数,boxes2的个数]
(2)class DetectionTargetLayer(KE.Layer)
这一个自定义类的主要作用是做一个综合的结构。其输入为4个:
①proposals: [batch, N, (y1, x1, y2, x2)] in normalized coordinates. Might
be zero padded if there are not enough proposals.
这其实就是我们上一个环节的输出

②gt_class_ids: [batch, MAX_GT_INSTANCES] Integer class IDs.
输入的所有训练集的gt内的物种标签,其shape为(b,max_instances),max instances表示一张图内有多少个gt,是个二维的

③gt_boxes: [batch, MAX_GT_INSTANCES, (y1, x1, y2, x2)] in normalized
coordinates.
这些gt的坐标,已经归一化(b,max instances,4)是个三维的

④gt_masks: [batch, height, width, MAX_GT_INSTANCES] of boolean type
这就是我们的mask的标签,其是4维的,应该如何理解呢?
在解释之前我还需要说明一下masks标签的预处理,我们假设原图,就是没有进行paadding的图的高宽为H和W,那么这个原图的masks标签就是[H,W,5]这个5就是这个图上面物种的物体个数。然后其会进行:
resize_mask(mask, scale, padding, crop=None):这个函数在utils里面,其实际上就是在对masks的标签进行一样的padding,我们以最简单的为例,那就是这个原图的大小全都在1024x1024的范围里面,并且采用的是square的填充方式,那么crop=None,padding就是在对原图进行pad时候的padding列表,scale也是对原图的scale,这两个参数都是resize image的输出。也就是此时scale为(1,1),那么在resize_mask函数里面的scipy.ndimage.zoom接口里面的zoom,表示各个维度放缩的比例,就均为1,相当于啥事儿不干。然后利用pad函数在padding的位置用0填补,最后得到的mask标签的新的HW就是1024x1024.
然后我们回到这个结构中来,相当于一个物体用一层,宽高为1024x1024,其在该物体轮廓及其轮廓所包含的区域内的像素值为true,其他地方全部都是false,每一层带代表一个物体的这类信息,这就是mask标签的组成形式。所以上面的height和width绝大多数情况下都是1024.

其输出也为4个,分别是:
①rois: [batch, TRAIN_ROIS_PER_IMAGE, (y1, x1, y2, x2)] in normalized
coordinates
返回的就是一个batch的200个rois在1x1里面的坐标

② target_class_ids: [batch, TRAIN_ROIS_PER_IMAGE]. Integer class IDs.
一个batch里面,200个rois对应的物种标签

③target_deltas: [batch, TRAIN_ROIS_PER_IMAGE, (dy, dx, log(dh), log(dw)]
一个batch里面,200个rois的边框回归标签

④ target_mask: [batch, TRAIN_ROIS_PER_IMAGE, height, width]
Masks cropped to bbox boundaries and resized to neural network output size.
这是一个batch中200个rois的maks标签,这里的维度顺序和我们输入的时候不一样的一定要注意转化。

DetectionTargetLayer(KE.Layer)这个类本身的作用就是整合,因为从我们的(1)辅助函数就可以看出来,其只能做一张图的操作,所以不用猜都知道这个类里面也有batch slice函数来帮助处理。其本身也不产生参数,所以也只有init,call,compute三个方法。

(3)def detection_targets_graph(proposals, gt_class_ids, gt_boxes, gt_masks, config):
其本身只会处理一张图片的信息,返回的也是一张图片的结果,其输出为:
proposals: [POST_NMS_ROIS_TRAINING, (y1, x1, y2, x2)] in normalized coordinates. Might be zero padded if there are not enough proposals.

gt_class_ids: [MAX_GT_INSTANCES] int class IDs

gt_boxes: [MAX_GT_INSTANCES, (y1, x1, y2, x2)] in normalized coordinates.

gt_masks: [height, width, MAX_GT_INSTANCES] of boolean type.
提醒一点的是,这里的gt_masks并没有被归一化,HW依然为1024。

我先阐述一下整个函数的执行流程,我们一开始先是进行了一个proposals是否存在的一个判定。

 asserts = [tf.Assert(tf.greater(tf.shape(proposals)[0], 0), [proposals],name="roi_assertion"),]with tf.control_dependencies(asserts):proposals = tf.identity(proposals)

tf.Assert(condition(x),x)把x带进condition里面,如果满足条件,也就是为true,那就什么都没发生,可以当没有这句代码,如果不满足,会停止程序并且报错。

with tf.control_dependencies(asserts):表示需要先运行括号里面的asserts,才可以运行with下的代码,也就是proposals = tf.identity(proposals)。
而tf.identity也是简单的赋值运算,但是其比单纯的等号好处就在于其也为被赋值的变量创建一个节点,而等号则不会创建。

之后程序会对提供的proposals和gt的非零项进行剔除,并且会根据剔除的gt的下标同时将其物种标签已经mask标签也全部剔除掉。

  proposals, _ = trim_zeros_graph(proposals, name="trim_proposals")gt_boxes, non_zeros = trim_zeros_graph(gt_boxes, name="trim_gt_boxes")gt_class_ids = tf.boolean_mask(gt_class_ids, non_zeros,name="trim_gt_class_ids")gt_masks = tf.gather(gt_masks, tf.where(non_zeros)[:, 0], axis=2,name="trim_gt_masks")

然后会其会对所谓拥挤图片进行筛选,也就是一个gt里面出现了多个目标,这种gt的物种标签设置为了负数,之后把这种gt单独全部拿出来作为一个种类,记作crowd boxes。剩下的gt(称作单样本gt)就是符合我们计算要求的gt了,提取出它们的物种标签,位置坐标和mask标签。

  # Handle COCO crowds# A crowd box in COCO is a bounding box around several instances. Exclude# them from training. A crowd box is given a negative class ID.crowd_ix = tf.where(gt_class_ids < 0)[:, 0]''''''non_crowd_ix = tf.where(gt_class_ids > 0)[:, 0]crowd_boxes = tf.gather(gt_boxes, crowd_ix)gt_class_ids = tf.gather(gt_class_ids, non_crowd_ix)gt_boxes = tf.gather(gt_boxes, non_crowd_ix)gt_masks = tf.gather(gt_masks, non_crowd_ix, axis=2)

这里我提醒一点的是tf.where的用法,其一般形式为tf.where(conditon,x,y),其会输出y,但是在condition为true的地方用对应地方x的数值来代替y相应位置的值。但是如果只有一个condition,就像我们现在。这会返回true所在的下标,并且不管你输入的condition是一维还是二维的,返回来的都是二维的,所以要想提取出里面的元素,后面常用[:,0]忽略外面的维度才可以。

下面我们的思路就是,根据我们自己规定的阈值来从proposals中分出正样本和负样本,然后从正样本中随机取出一些,从负样本中随机选出一些,然后凑一块合并200个,组成最后的rios。
之后我们会计算proposals与单样本gt的iou,称作overlaps,然后计算proposals与多样本gt的iou,记作crowd_overlaps。
我们正样本规定,某一个proposal与单样本gt的iou们中最大超过0.5,就可以被认为是正样本。
我们负样本规定,某一个proposal与单样本gt的iou们中最大小于0.5并且其本身与多样本的gt的iou很小,甚至为0,就可以被认定为负样本,也就说负样本里面最好全是背景。然后从正样本里面随机找66个,co

  # Compute overlaps matrix [proposals, gt_boxes]overlaps = overlaps_graph(proposals, gt_boxes)# Compute overlaps with crowd boxes [proposals, crowd_boxes]crowd_overlaps = overlaps_graph(proposals, crowd_boxes)crowd_iou_max = tf.reduce_max(crowd_overlaps, axis=1)no_crowd_bool = (crowd_iou_max < 0.001)# Determine positive and negative ROIsroi_iou_max = tf.reduce_max(overlaps, axis=1)# 1. Positive ROIs are those with >= 0.5 IoU with a GT boxpositive_roi_bool = (roi_iou_max >= 0.5)positive_indices = tf.where(positive_roi_bool)[:, 0]# 2. Negative ROIs are those with < 0.5 with every GT box. Skip crowds.negative_indices = tf.where(tf.logical_and(roi_iou_max < 0.5, no_crowd_bool))[:, 0]# Subsample ROIs. Aim for 33% positive# Positive ROIspositive_count = int(config.TRAIN_ROIS_PER_IMAGE *config.ROI_POSITIVE_RATIO)positive_indices = tf.random_shuffle(positive_indices)[:positive_count]positive_count = tf.shape(positive_indices)[0]# Negative ROIs. Add enough to maintain positive:negative ratio.r = 1.0 / config.ROI_POSITIVE_RATIOnegative_count = tf.cast(r * tf.cast(positive_count, tf.float32), tf.int32) - positive_countnegative_indices = tf.random_shuffle(negative_indices)[:negative_count]# Gather selected ROIspositive_rois = tf.gather(proposals, positive_indices)negative_rois = tf.gather(proposals, negative_indices)

仅仅是上面的流程会产生一个问题,那就是如果正样本数目不够怎么办,从[:positive_count]中可知,如果不够的话其会有多少取多少,就比如正样本只有50个,那么我的正样本就全取,但是我的负样本依然选择取134个,同时,如果负样本也不够,那么我也是负样本全取,最后会把正负样本放一块,不够的数目用0来补齐。这在后面的代码中会体现。

经过上面的步骤,我们以理想状态下来举例,我们会得到66个正样本和134个负样本,之后我们会对head阶段的三个标签进行赋值,这里有一个很重要的变量

 positive_overlaps = tf.gather(overlaps, positive_indices)#取出正样本和gt的iou(66,number1)roi_gt_box_assignment = tf.cond(tf.greater(tf.shape(positive_overlaps)[1], 0),true_fn = lambda: tf.argmax(positive_overlaps, axis=1),false_fn = lambda: tf.cast(tf.constant([]),tf.int64))

roi_gt_box_assignment,其表示的是每一个被选中的正样本与哪个gt的iou最大,其shape为[66,]可以理解为66个男生最心仪的那个女生,当然可以多个男生心仪同一个女生。我们会利用这个变量首先对66个正样本进行物体种类的标签设定,然后通过计算也可以得到它们的回归系数的标签。这两个是比较容易的,我就不多赘述了。

roi_gt_boxes = tf.gather(gt_boxes, roi_gt_box_assignment)#“距离最近的gt”就是我们想要回归到的坐标,注意shape为(66,4)
roi_gt_class_ids = tf.gather(gt_class_ids, roi_gt_box_assignment)#并且最近的gt的物种标签就是我们的rois的标签(66,)# Compute bbox refinement for positive ROIsdeltas = box_refinement_graph(positive_rois, roi_gt_boxes)deltas /= np.array([0.1, 0.1, 0.2, 0.2])#在得到我们标准的回归系数标签以后记得要除以权重,因为我们当时预测时候的数据就除以了

最后是相对来说理解有一点难度的masks的标签,我们输入的gt masks的shape为(h,w,max _number),这个和我们网路输出的shape维度的排列顺序有些不一样,需要先进行维度的转化为(max,h,w),然后转为4维,也就是每一个单独的true转为[true],这么做是因为我们待会进行抠图的接口要求输入维度为4维,我们需要自己给它设置一个深度为1.
我们刚开始的时候依然会用roi_gt_box_assignment去提取每一个正样本对应的gt的mask标签,但是这并不是我们rois的标签,我们的标签是这么定义的,找到上面这个对应的标签,其物理形象应该一张黑白图,白色的地方就是我们物体的形状,然后用我们预测儿的正样本的rois的坐标放到这个黑白图里面,在黑白图上截取这个坐标的位置,把这个地方reshape成统一的形状,里面有多少true,我们就以多少为标签进行训练。而并不是训练全部的true。
这里为什么不用全部的true设为标签然后进行训练呢?那是我们的总体流程是先画框,然后对框内的物体进行识别和掩膜处理,如果我们的框一开始并没有框完整,而我们盲目取全部的true的部分当作标签,就像我们只框了一只手臂,我们非要让它以人的标签进行训练一样愚蠢,所以框多少,训练多少才是我们的目的。

box_ids = tf.range(0, tf.shape(roi_masks)[0])#这里是创建了一幅训练集图像中200个rois的一个标号,放入下一个函数使用masks = tf.image.crop_and_resize(tf.cast(roi_masks, tf.float32), boxes,box_ids,config.MASK_SHAPE)'''这个函数这里不细说,ROIAlign板块再说,其先是再masks中截取了rois的区域,然后把这个区域resize成了14x14,把这个作为各个rois的mask标签shape(66,14,14,1),应该是crop and resize这个接口,其只能接受4维的输入,然后boxes是对中间两个维度进行截取的作用,所以刚才为什么要给masks加一个维度,相当于增加了深度层,让这个接口以为这是深度'''# Remove the extra dimension from masks.masks = tf.squeeze(masks, axis=3)#去掉最后一个维度(66,14,14)

这里还有一个问题,crop and resize这个是进行抠图的接口。这个接口的特点有很多,我罗列一下
①需要进行的截取的图片也好,特征层也好,都是需要原尺寸的,也就是不可以归一化然而
需要截取的区域,也就是后面boxes却是需要归一化的。这一点我一开始不知道困扰了我很久,因为从后面网络的搭建可以看到,这个gt mask的宽高一直都是1024,我实在没找着对其进行归一化的步骤,而后面的rois的坐标却是归一化以后的,后来看了这个接口的解释才明白过来,前者是原图尺寸,后者是归一化的尺寸。
②输入的需要截取的图片必须是4维的(b,h,w,c),相当于把每一个rois对应的gt的mask叠到一块组成batch了,这里的c是1,我们刚才已经利用expand dim增加mask的维度。
③需要截取的区域只需要告诉在hw平面上面的坐标就可以了,其shape也就是[66,4]
④box ids用来指示不同的boxes应该截取一叠中的哪一个mask,其目的是达成一一对应进行截取的关系。

最后就只需要做一个填充就可以了,具体填充的思想就是,因为我们上面只求了正样本的三个标签,所以我们把负样本的三个标签全部置为0,同时返回的rois的坐标,如果不够200,也用0补齐。我想吐槽的就是这里使用的P这个字母和positive太混淆了,这个P其实就是不够200剩下需要填充的个数。

  rois = tf.concat([positive_rois, negative_rois], axis=0)N = tf.shape(negative_rois)[0]P = tf.maximum(config.TRAIN_ROIS_PER_IMAGE - tf.shape(rois)[0], 0)rois = tf.pad(rois, [(0, P), (0, 0)])roi_gt_boxes = tf.pad(roi_gt_boxes, [(0, N + P), (0, 0)])roi_gt_class_ids = tf.pad(roi_gt_class_ids, [(0, N + P)])deltas = tf.pad(deltas, [(0, N + P), (0, 0)])masks = tf.pad(masks, [[0, N + P], (0, 0), (0, 0)])

所以我们最后返回的是(我以理想情况下rois的组成来进行解释)
rois: [TRAIN_ROIS_PER_IMAGE, (y1, x1, y2, x2)] in normalized coordinates
一张图片上200个rois的归一化坐标,一个batch得到的结果就是下一个环节的输入
class_ids: [TRAIN_ROIS_PER_IMAGE]. Integer class IDs. Zero padded.
一张图片上200个rois的物体种类标签(其中66正样本为实际标签,134个负样本标签为0)
deltas: [TRAIN_ROIS_PER_IMAGE, (dy, dx, log(dh), log(dw))]
一张图片200个rois的回归系数,其中66个正样本为实际回归系数,134个负样本为0
masks: [TRAIN_ROIS_PER_IMAGE, height, width]. Masks cropped to bbox
boundaries and resized to neural network output size.hw均为1
一张图片200个rois的mask标签,其中66个正样本为实际的mask标签,134个负样本为0

特别提醒
我在这个类里面没有找到对于RPN阶段标签的设置,我看了一下model里面架构,里面有这么一段代码:

rpn_class_loss = KL.Lambda(lambda x: rpn_class_loss_graph(*x), name="rpn_class_loss")([input_rpn_match, rpn_class_logits])
input_rpn_match = KL.Input(shape=[None, 1], name="input_rpn_match", dtype=tf.int32)

我只举物体存在的例子。可以看到它的标签是输入的,在形成model以后我没有找到具体输入的是什么,如果有哪位大佬从源码中发现了其来源,我也会欣然请教。

8:ROIAlign

阶段总结:上面的所有步骤,都只和anchors本身有关,并没有涉及到任何特征层的关系,也就是说上面的内容与特征层的形成无关。甚至可以用faster rcnn的思路去理解上面的流程。但是这个板块就不一样了,它的原理是利用每一个rois大小的不同,来判断其应该是哪一个特征层所绘制的,然后在对应的特征层上面进行抠图,没错,还是刚才那个crop and resize接口。这就是这个环节的大方向。
该环节有一个辅助函数和一个类组成
(1)def log2_graph(x):这个函数的作用就是协助判断rois属于哪类feature map的
(2)class PyramidROIAlign(KE.Layer):
这个类的输出有三个

  • boxes: [batch, num_boxes, (y1, x1, y2, x2)] in normalized
    coordinates. Possibly padded with zeros if not enough
    boxes to fill the array.
    我们进行截取的rois(batch,200,4),就是DetectiveTargetLayer的输出之一

  • image_meta: [batch, (meta data)] Image details. See compose_image_meta()
    这里保存的是训练集图片的一些基本信息,我们调用它只是为了获得网络输入的尺寸

  • feature_maps: List of feature maps from different levels of the pyramid.
    Each is [batch, height, width, channels]
    这个feature map一共有4个,也就是P2到P5,其shape维度都是一样的
    也就是说我们的输入列表为[boxes,image_meta,P2,P3,P4,P5],一共六个

首先我会根据一个公式来直接确定rois属于哪一类特征层,属于P5则这给它标签为5,P2则为2,这个公式的原理我不知道具体的推导过程,但是从它的结构可以看得出来,由于我们构造anchors的时候是根据feature map的尺寸来决定的,并且我们知道,就算其已经经过一次边框回归了,其大小与位置也不会发生太大的变化,比较是微调。所以我们判断其归属的时候依然是该rois的面积。最后我们会得到一个经过降维的roi_level,其shape为[batch,200]。这里我要提醒的是,这个里面的200,也是按照我们前66个正样本,后134个负样本的顺序,得到各自的归属标签的,这一点是没变的。这一点好像理所应当,我为什么要在这里提醒这个呢,因为后面的步骤会把这个顺序给打乱。

boxes = inputs[0]# Image meta# Holds details about the image. See compose_image_meta()image_meta = inputs[1]# Feature Maps. List of feature maps from different level of the# feature pyramid. Each is [batch, height, width, channels]feature_maps = inputs[2:]# Assign each ROI to a level in the pyramid based on the ROI area.y1, x1, y2, x2 = tf.split(boxes, 4, axis=2)h = y2 - y1w = x2 - x1'''因为我们创建anchors的时候,就是按照大的特征图产生大的anchors,小的产生小的ahchors,并且经过回归以后的anchors其实变化也不是很大所以我们只需要根据rois的大小就可以确定它是哪一层的feature map产生的了'''# Use shape of first image. Images in a batch must have the same size.image_shape = parse_image_meta_graph(image_meta)['image_shape'][0]#获取训练集原图的大小(2,),但是hw是归一化的结果,故image shape为1x1更合理一点# Equation 1 in the Feature Pyramid Networks paper. Account for# the fact that our coordinates are normalized here.# e.g. a 224x224 ROI (in pixels) maps to P4image_area = tf.cast(image_shape[0] * image_shape[1], tf.float32)roi_level = log2_graph(tf.sqrt(h * w) / (224.0 / tf.sqrt(image_area)))roi_level = tf.minimum(5, tf.maximum(2, 4 + tf.cast(tf.round(roi_level), tf.int32)))'''这里使用的公式是:K=K0+log2(w*h/244),但是上面的公式在对数里面的分母还成了一个sqrt(image area),这是因为在归一化以后,hw均为1这sqrt(image area)则为1,才有了我们的公式,所以image shape应该是归一化的尺寸这里的KO指的是顶层的特征层,也就是C5,最小的那个,其值为5,下一行代码的数肯定会小于等于0,这样就能保证所有rois对应的数都在2到5之间(到现在还没有出现C6)'''roi_level = tf.squeeze(roi_level, 2)'''roi level的shape一开始为(b,200,1),然后这里把最后一个维度去掉了,就成了(b,200),这是为了方便后面调取rois的坐标

打乱的直接原因就是下面这个循环,我们以i=2为例,其会提出一个batch里面所有归属为2的rois的坐标,我们记一共有number_2个,也就是level_boxes,,其shape为2维[number_2,4],从这里你就会发现,这里面的rois是来自不同的图片的。所以我们最后还要改变其排列顺序变回去。而改回去的关键就在于这个box_to_level,它是每一次循环ix的集合体。这个ix是2维的,最小的维度里面有两个数,例如[4,6]表示这个roi位置为第4张图的第6个rois。
这里还有一个变量同样重要:box_indices,它取的是ix的最里面维度的第一个数,也就是它保存的rois属于哪一张图,其是1维的(number_2,)。

梯度截断的原因我在代码里面说明了。

在我们进行抠图的时候,我们的目标是feature maps[i],这个feature maps定义的是[P2,P3,P4,P5],第一次循环的时候用的是P2,而P2的shape为[batch,h,w,c]表示的一个batch里面的所有P2的集合。

tf.image.crop_and_resize(feature_maps[i], level_boxes, box_indices, self.pool_shape,method="bilinear"))

level boxes表示截取的区域,其数量为number_2。box_indices为每个区域应该在哪个特征图上截取,其数量也是number_2,也就是说,box_indices里面肯定有相同的数,表示这些rois是在同一张特征图上截取。并且feature maps没有归一化,而level boxes是归一化的,这也符合我们刚才的结论。

# Loop through levels and apply ROI pooling to each. P2 to P5.pooled = []#用来装rois经过pooling后的特征层box_to_level = []#用来装属于本层特征层的rois的下标for i, level in enumerate(range(2, 6)):ix = tf.where(tf.equal(roi_level, level))#以i为2举例,找到属于第二个特征层的rois在所有batch个,200个rois中的下标,这个下标是2维的#就算roi level是2维的,返回的ix也是2维的,只不过这里面的变成了[[0,0]],一个中括号塞两个数,表示行列的下标#其shape为(所有batch中同属于第i类的rois个数,2)level_boxes = tf.gather_nd(boxes, ix)'''!!!!!!!!!!!!!!!!!!这里一定要注意,由于ix是2维的,所以它是进到boxes中的第二维去找坐标,也就是说其是从所有的batch里面找到全部属于第二个特征层的rois的坐标(尽管rois对应的第二个特征层不尽相同,但是这些都是第i个特征层)所以其返回值是2维!!!!!不是3维!!!!所以其shape为[b*(各自的rois个数),4]'''# Box indices for crop_and_resize.box_indices = tf.cast(ix[:, 0], tf.int32)#这里并不仅是改成1维,而是取[[0,0]]中的第一个数[0],也就是这个rois属于batch中的哪一张#所以肯定box indices里面有相同的元素,因为很多rois要去截同一张特征图,所以其shape(batch*(每一张属于第2个特征层的rois个数),)#是一个一维tensor# Keep track of which box is mapped to which levelbox_to_level.append(ix)#这里塞进去的ix是2维的# Stop gradient propogation to ROI proposalslevel_boxes = tf.stop_gradient(level_boxes)box_indices = tf.stop_gradient(box_indices)'''这里把二者的梯度截断,主要是因为,后面head部分是做的识别任务,RPN部分以及proposals部分和ROIAlignn部分是做的筛选和截取任务也就是说,level boxes和box indices根据下面马上要执行的代码来看,它们俩是两个大部分唯一的交集,并且最要命的是它们可以进行梯度下降的任务,也就是说,后面的识别部分的梯度反馈会受到前面这部分的影响,更直接一点来说就是会影响损失。所以必须把二者唯二的这两个联系的梯度截断,如果一来,后方的梯度下降就不再经过这两个节点传递给前面了,也就不会受前面的影响了'''# Crop and Resize# From Mask R-CNN paper: "We sample four regular locations, so# that we can evaluate either max or average pooling. In fact,# interpolating only a single value at each bin center (without# pooling) is nearly as effective."## Here we use the simplified approach of a single value per bin,# which is how it's done in tf.crop_and_resize()# Result: [batch * num_boxes, pool_height, pool_width, channels]pooled.append(tf.image.crop_and_resize(feature_maps[i], level_boxes, box_indices, self.pool_shape,method="bilinear"))'''这个image crop and resize其实就是做的ROIAlign的功能,它有三个参数在何处截取,注意这里的feature map[i]并不是一张,而是batch张i层的特征图,shape(b,h,w,c)level boxes:截取的范围,也就是坐标,shape(b*(对应的rois属于特征层i的个数),4)box indices:截取的对应关系,比如如果是[0,1],则level boxes的第一张去截取feature map[i]的第一张,第二张去截取第二张如果是[1,0],则是level boxes的第二张去截取feature map的[i]的第二张,第一张再去截取第二张所以这里的box indices其实就是[0,0..1,1...]告诉我们要一一对应的截取.shape(b*(对应的roi属于特征层i的个数),)其会在feature map的hw上面截取reshape的尺寸为pool shape使用的方法为双线性插值最后得到的一组pooled的shape由于box indices是散的,所以最后的shape也是散的,为[b*(对应的rois属于特征层i的个数),7,7,c]是一个四维的结果'''

最后我们得到的pooled经过concat以后的shape为[batch*200,7,7,c]
之后我们的任务就像刚才所说的那样,这里面rois的排列顺序是乱的,并没有按照一张图的所有信息来排列,而是按照同属于一类特征图的方式。我们的思路就是,根据box_to_level的组成结构,其表示的是在pooled中每一个rois是哪张图的哪个rois。这里需要注意的一点反而是最里面维度的第二个数,也就是刚才举例中[4,6]中的6,我们在最初进行筛选的时候,就是正样本在前,负样本在后组成的rois,也就是说这个6表示的是其在原始rois中的位置。如果我们能够找到一张图分散的所有的rois,按照第二个数的大小排列就可以返回原来的组成顺序了。
这里的思路很简单,就是用box_to_level中的第一个数去乘10000,随便什么很大的数都可以。这样的话,按照大小就可以把一张图的所有rois居中在一起,然后再按照第二个数的大小排列就可以返回原来的顺序。最后只需要reshape一下就大功告成了。

在这里面它多了我刚才说的步骤,就是给box_to_level加了一个维度用来存放rois在pooled里面原始的下标位置,我个人认为没起到作用,原理我在代码注释里面已经详解了。同时为了方便各位能够清楚看到里面各个变量的结构,我根据别人的举例改进了一个便捷的实例,直接运行就可以看到里面的下标到底是什么结构了。

'''我这里放一段测试代码,直接复制粘贴就可以了
import numpy as np
import tensorflow as tf# 其中N为本张图片中检测到的对象数量,在示例中假设N=6,即图片中共监测到6个物体
# num_class为所有训练数据中标记的类别种类总数,示例中假设总共有8种物体
def test():box_to_level = []# 假设 batch=1 num_boxes=5 在此基础上乱编一些数据:roi_level = [[[4],[3],[3],[2],[5]],[[4], [3], [3], [2], [5]]]# roi_level.shape=[batch,num_boxes,1]roi_level=np.squeeze(roi_level, 2)roi_level = np.array(roi_level)print(np.where(np.equal(roi_level,2)))print('roi_level.shape=', roi_level.shape)boxes = [[[0.1, 0.3, 0.13, 0.34],[0.5, 0.66, 0.67, 0.89],[0.4, 0.61, 0.7, 0.8],[0.2, 0.3, 0.4, 0.5],[0.23, 0.13, 0.43, 0.54]],[[0.1, 0.3, 0.13, 0.34],[0.5, 0.66, 0.67, 0.89],[0.4, 0.61, 0.7, 0.8],[0.2, 0.3, 0.4, 0.5],[0.23, 0.13, 0.43, 0.54]]]# [batch, num_boxes, (y1, x1, y2, x2)]boxes = np.array(boxes)print('boxes.shape=', boxes.shape)# ------------ 运行难理解的代码 --------------for i, level in enumerate(range(2, 6)):ix = tf.where(tf.equal(roi_level, level))level_boxes = tf.gather_nd(boxes, ix)box_indices = tf.cast(ix[:, 0], tf.int32)print('i=', i, '  level=', level, '  ---------------')with tf.Session() as sess:print('ix:', sess.run(ix))print('level_boxes:', sess.run(level_boxes))print('box_indices:', sess.run(box_indices))box_to_level.append(ix)print("box_to_level:", )with tf.Session() as sess:for i in box_to_level:print(sess.run(i))box_to_level = tf.concat(box_to_level, axis=0)box_range = tf.expand_dims(tf.range(tf.shape(box_to_level)[0]), 1)box_to_level = tf.concat([tf.cast(box_to_level, tf.int32), box_range],axis=1)sorting_tensor = box_to_level[:, 0] * 100000 + box_to_level[:, 1]ix = tf.nn.top_k(sorting_tensor, k=tf.shape(box_to_level)[0]).indices[::-1]print(sess.run(ix))ix = tf.gather(box_to_level[:, 2], ix)print(sess.run(ix))
if __name__ == '__main__':test()'''# Pack box_to_level mapping into one array and add another# column representing the order of pooled boxes#经过所有循环以后,我们需要把其全部concat在一起,最后的shape为(batch*200,2)box_to_level = tf.concat(box_to_level, axis=0)#(batch*200,2)#下面要做的事就是给这个坐标,就是box to level,屁股后面再加一个坐标,就是2变成3,其原意是记录上原来的排列顺序,方便调取box_range = tf.expand_dims(tf.range(tf.shape(box_to_level)[0]), 1)#box_range=[[0],[1],[2]......[batch*200-1]],shape为(bacth*200,1)box_to_level = tf.concat([tf.cast(box_to_level, tf.int32), box_range],axis=1)#最后就成了[[0,0,0],[0,1,1]....]最后一个数字是记录本来在pooled里面的顺序# Rearrange pooled features to match the order of the original boxes# Sort box_to_level by batch then box index# TF doesn't have a way to sort by two columns, so merge them and sort.'''这里使用的逻辑非常感人,我们可以从batch to level的元素的结构中可以知道,每一个rois的坐标的第一个数都是所在的batch中的哪一张并且第二个数表示的是在一张rois里面的下标,其本身是对一张图里面的rois进行的排序,所以只要确定了大家同属于一张图,然后直接按照第二个数就可以排序了所以可以用第一个数去乘一个很大的数,排除第二个数大小的干扰,然后用再加上第二个数,如此一来按照这个数的大小顺序排列的话,大小最小的数就在最前面,也就是同属于第一张图的rois们,最大的数自然属于最后一张图的rois们'''sorting_tensor = box_to_level[:, 0] * 100000 + box_to_level[:, 1]#这个就是在做我们刚才所说的那个步骤,shape为(bacth*200,)ix = tf.nn.top_k(sorting_tensor, k=tf.shape(box_to_level)[0]).indices[::-1]'''nn.top_k会返回两个,一个是由大到小的具体数值,一个是这些数的下标。但是使用了.indices,还倒序了,所以最后的ix就是sorting tensor中由小到大的数的下标,shape(batch*200,),它的维度由sorting tensor的维度决定'''ix = tf.gather(box_to_level[:, 2], ix)'''这行代码是我有点没搞懂的,因为最初的ix就是sorting tensor里面的顺序,而sorting tensor本来就是按照原来的pooled顺序计算的所以最初的ix本来就是用的原来的下标,现在再用原来的下标去提取原来的下标,前后没有发生改变,我在测试代码里面也是同样的结果'''pooled = tf.gather(pooled, ix)#这里实际就是在做利用我们从小到大的排序重新按照索引来提取,这样pooled的顺序就对上了,最后只需要改变shape就可以了# Re-add the batch dimensionshape = tf.concat([tf.shape(boxes)[:2], tf.shape(pooled)[1:]], axis=0)'''boxes的shape为[batch,200,4],取[:2]就是[batch,200]pooled的shape为[batch*200,7,7,c],取[1:]就是[7,7,c],这里concat以后就是[batch,200,7,7,c],是五个维度的'''pooled = tf.reshape(pooled, shape)return pooled

**特别提醒:**我们经过上面Layer以后我们会得到一个5维的tensor
[batch,200,7,7,c]其实c就是256

9:创建Head部分

整个Head部分由两个function来完成
fpn_classifier_graph负责对rios们进行边框回归和物种类别进行预测
其边框回归和物种类别在分路之前都会先共同通过两层1024的全连接层,之后分路进行各自的预测
(1)build_fpn_mask_graph赋值对mask结果进行预测
其会一路卷积卷积卷积,然后反卷积得到我们想要的预测结构

网络的搭建反而是说起来最没意思的部分。
fpn_classifier_graph的输入的参数一共有7个

  • rois: [batch, num_rois, (y1, x1, y2, x2)] Proposal boxes in normalized
    coordinates.
    这里的rois还没有经过ROIAlign处理,所以还是还只是刚提取出来的阶段,[batch,200,4]

  • feature_maps: List of feature maps from different layers of the pyramid,
    [P2, P3, P4, P5]. Each has a different resolution.
    四个特征层,用一个列表包起来

  • image_meta: [batch, (meta data)] Image details. See compose_image_meta()

  • pool_size: The width of the square feature map generated from ROI Pooling.
    7x7

  • num_classes: number of classes, which determines the depth of the results
    可以识别种类的个数,为100

  • train_bn: Boolean. Train or freeze Batch Norm layers
    用来控制BN层是在训练集当中还是测试集中

  • fc_layers_size: Size of the 2 FC layers
    表示两个全连接层的神经元个数

其主要流程为会先将我们筛选出来的rois放入我们创建的PyramidROIAlign类里面,得到上面我们上所说的五维度的输出[batch,200,7,7,256],然后利用时序函数来进行卷积和全连接的工作,因为单独的卷积不认识5维的输入,我们用时序函数来使得卷积的输入为一张一张的,也就是把batch分成一张一张放入网络最后再组合起来,也就是说后面的输出还是5维的,所有后面的操作还是带了时序函数的。这是最后reshape的代码

 # Classifier headmrcnn_class_logits = KL.TimeDistributed(KL.Dense(num_classes),name='mrcnn_class_logits')(shared)mrcnn_probs = KL.TimeDistributed(KL.Activation("softmax"),name="mrcnn_class")(mrcnn_class_logits)#感觉都没啥说的...mrcnn probs的shape为(batch,200,100)# BBox head# [batch, num_rois, NUM_CLASSES * (dy, dx, log(dh), log(dw))]x = KL.TimeDistributed(KL.Dense(num_classes * 4, activation='linear'),name='mrcnn_bbox_fc')(shared)#这里的shape为(batch,200,100*4),需要reshape一下形状为(batch,200,100,4)# Reshape to [batch, num_rois, NUM_CLASSES, (dy, dx, log(dh), log(dw))]s = K.int_shape(x)#K.int_shape(x)是用来查看x的shape的,其本身并不参与运算,是提供keras数据流的200mrcnn_bbox = KL.Reshape((s[1], num_classes, 4), name="mrcnn_bbox")(x)'''这里必须使用KL里面Reshape结构,但是里面只有三个维度,batch怎么不见了呢?这是因为Keras.layer.Reshape不用输入batch的维度,其会默认对所有batch做一样的处理,所以最后的mrcnn bbox的shape为(batch,200,100,4)'''

我们最后得到的预测的回归系数的维度是4维的,但是我们的标签是3维的。我们会选择预测的这个rois所属的物体种类所在的4个回归系数来进行损失的计算,也就是从200个rois里面找出其物种类别的标签所对应的那个回归系数。当然理想而又准确的说应该是66个正样本,所以最后参与损失计算的实际只有[batch,66,4]

(2)def build_fpn_mask_graph
这里只需要说明一点的就是,其对于上面分类和回归的标签计算而言,他们并不公用前面的全连接层,这是有同样的输入,也就是ROIAlign的输出,这里也只贴出后面的代码

x = KL.TimeDistributed(KL.Conv2D(num_classes, (1, 1), strides=1, activation="sigmoid"),name="mrcnn_mask")(x)#shape(batch,200,14,14,100),都已归一化

上面的mask与回归标签用比较形象的解释来说就是都加入了这个物种类别的特征,其只会训练该物种所在位置的相应标签。

10:LOSS的设定

loss一共分为五个部分,RPN阶段两个,head阶段三个

① RPN的物体存在损失
其输入只有两个,

  • rpn_match: [batch, anchors, 1]. Anchor match type. 1=positive,
    -1=negative, 0=neutral anchor.这是标签

  • rpn_class_logits: [batch, anchors, 2]. RPN classifier logits for BG/FG.这是预测值
    这里面的步骤应该和网络本身的特性有关,我们熟悉的是标签就是0和1,怎么又多出来一个-1呢。其实回顾一下我们刚才的说明,虽然我没有找到整个网络RPN部分的输出,但是这里的合理推测是,-1是crowd anchors的标签,所以后面的操作是将-1的标签也转为0。然后生成的所有anchors并非都会参与到这个误差的计算。而是只计算标签为1的anchors。
    这里需要提醒的一点就是,输入进取的rpn class logits是我们一开始根据循环最后得到的,其排列的顺序显示P2产生的anchors,然后是P3,P4,P5。其实排列顺序对于后面的影响不大,因为我们的筛选规则是iou,不管你到底在anchors的哪个位置。

def rpn_class_loss_graph(rpn_match, rpn_class_logits):"""RPN anchor classifier loss.anchors是所有的anchors,它根据iou划分了正样本,负样本和不参与训练样本,并且从正样本中抽取了部分保持标签不变,没被抽取到的部分标签为0负样本做同样的处理,也有部分被标记为不参与训练这里有一个问题就是我们一开始做了一个得分前6000的anchors的一个边框修正,这个6000是不是和上面的步骤有联系呢,我个人认为没有联系,因为如果上面的步骤训练得当的话,那么选取的前随便你多少个anchors,其确实都是大概率有物体的,所以其实你前面训练好了,后面自然就顺理成章了rpn_match: [batch, anchors, 1]. Anchor match type. 1=positive,-1=negative, 0=neutral anchor.这里也说明了,这里参与计算的只有1和-1.这是标签rpn_class_logits: [batch, anchors, 2]. RPN classifier logits for BG/FG.这是预测值"""# Squeeze last dim to simplifyrpn_match = tf.squeeze(rpn_match, -1)#shape(batch,anchors)# Get anchor classes. Convert the -1/+1 match to 0/1 values.anchor_class = K.cast(K.equal(rpn_match, 1), tf.int32)'''这里的K.equal返回是布尔类型,然后转为int类型,为0和1,所以anchor class=[[0,0,1,1...],[1,0,1,1,...]],shape(batch,anchors)把标签为1的保持不变,把其余标签全部变为0'''# Positive and Negative anchors contribute to the loss,# but neutral anchors (match value = 0) don't.indices = tf.where(K.not_equal(rpn_match, 0))#找到所有原来的标签里面不为0的下标,因为我们只会计算1或-1标签的误差,其shape为2维# Pick rows that contribute to the loss and filter out the rest.rpn_class_logits = tf.gather_nd(rpn_class_logits, indices)#从预测的数据集中提取出来不为0的部分anchor_class = tf.gather_nd(anchor_class, indices)#从修改过的标签当中各选出不为0的部分'''负样本用的-1,而不是0.这样的话,我们需要先把-1改为0,然后把原来是0不参与训练的拿点,以此达到负样本标签从-1到0的过程。'''# Cross entropy lossloss = K.sparse_categorical_crossentropy(target=anchor_class,output=rpn_class_logits,from_logits=True)loss = K.switch(tf.size(loss) > 0, K.mean(loss), tf.constant(0.0))'''keras.backend.switch(condition,a,b)其实就是一个if else的过程,如果condition为true或1,则运行a,false或0则运行btf.size不是计算尺寸,而是计算里面tensor的元素数量,也就是说,loss里面是空的,就返回一个0.0的常量,否则就计算其loss的平均值作为最后的loss'''return loss

而对于RPN阶段回归系数的误差,其也只会对标签为1的anchors进行误差的计算。
但是它设计的流程很耐人寻味。我们在faster rcnn里面,对于边框回归我们是计算了所有anchors的边框回归,不分正负样本。但是在mask rcnn里面。我们是对进行边框回归的anchors进行过一次简略的判断是否存在物体的。我举一个例子。在经过判断是否存在物体的时候,我们先是根据iou取了6000个,这个6000就可以作为上面target bbox里面的max positive anchors,然后后面的rpn match的设定是进一步筛选过后的,我们就当是2000.相当于我现在只需要计算这6000个anchors的回归系数,然后再从中选2000来计算。这里便又衍生出来一个问题,为什么不一开始就选2000个,而还要用6000过度呢?
我的理解是,如果正样本满足2000个上面确实可以省略6000的步骤,但是如果不足呢?不足的话我又不知道具体是多少个正样本,所以我只有先算6000个,然后有多少个正样本我取前多少个参与计算误差就行了。

def rpn_bbox_loss_graph(config, target_bbox, rpn_match, rpn_bbox):"""Return the RPN bounding box loss graph.config: the model config object.target_bbox: [batch, max positive anchors, (dy, dx, log(dh), log(dw))].Uses 0 padding to fill in unsed bbox deltas.rpn_match: [batch, anchors, 1]. Anchor match type. 1=positive,-1=negative, 0=neutral anchor.rpn_bbox: [batch, anchors, (dy, dx, log(dh), log(dw))]"""# Positive anchors contribute to the loss, but negative and# neutral anchors (match value of 0 or -1) don't.rpn_match = K.squeeze(rpn_match, -1)indices = tf.where(K.equal(rpn_match, 1))# Pick bbox deltas that contribute to the lossrpn_bbox = tf.gather_nd(rpn_bbox, indices)# Trim target bounding box deltas to the same length as rpn_bbox.batch_counts = K.sum(K.cast(K.equal(rpn_match, 1), tf.int32), axis=1)target_bbox = batch_pack_graph(target_bbox, batch_counts,config.IMAGES_PER_GPU)loss = smooth_l1_loss(target_bbox, rpn_bbox)loss = K.switch(tf.size(loss) > 0, K.mean(loss), tf.constant(0.0))return loss

之后的mask部分的损失我只提几点就可以了

  • 对于物种类别的损失,是所有的200个rois都参与了计算,里面还有细节源码都有注释我就不赘述了。
  • 回归与mask的算是均只对正样本进行了误差计算,流程我之前也已经说过了。

总结

说实话,后面的流程我阐述的比较快的原因也确实是可圈可点的地方不多了,我自己也没那么多精力去一个一个细讲,我也是写累了。这篇博客达到了四万字,也算是对我这段时间的一个小总结吧。如果后面还有时间的话,我还想再说说整个模型搭建的一个总概述。行,就这样吧,收~

Mask Rcnn代码与原理相结合解析相关推荐

  1. 先理解Mask R-CNN的工作原理,然后构建颜色填充器应用

    上年 11 月,matterport 开源了 Mask R-CNN 实现,它在 GitHub 已 fork1400 次,被用于很多项目,同时也获得了完善.作者将在本文中解释 Mask R-CNN 的工 ...

  2. Mask rcnn代码实现_pytorch版_适用30系列显卡

    Mask rcnn代码实现_pytorch版 由于科研需求,要做一个图像分割的项目,于是开始着手跑一下 mask rcnn.本以为很简单的事情,网上代码比较多,结果尝试了一下,遇到了各种问题. 主要是 ...

  3. keras Mask Rcnn代码走读(九)-detect方法介绍

    keras Mask Rcnn代码走读(八)-detect方法介绍,主要用于图片实体分割的推断时调用的. 一,首先对图像进行处理,调用self.mold_inputs()函数,把原图等比例resize ...

  4. faster rcnn 代码与原理结合详解

    文章目录 faster rcnn 原理概括 特征提取层的特点和其与feature mpa坐标映射的关系 RPN layer详解 ROI pooling详解 分类层与第二次边框回归 faster rcn ...

  5. Mask rcnn 代码复现

    首先去github上下载mask-rcnn源码 GitHub - matterport/Mask_RCNN: Mask R-CNN for object detection and instance ...

  6. Mask RCNN代码

    matterport/Mask rcnn model.py是网络主要构建的文件 utils.py中的anchor产生函数部分,主要是涉及函数: RPN部分 scales:(32, 64, 128, 2 ...

  7. [detectron2 ] Mask R-CNN代码笔记

    主要代码文件路径: 总架构文件: detectron2/detectron2/modeling/meta_arch/rcnn.py 默认配置:detectron2/detectron2/config/ ...

  8. 深度学习目标检测详细解析以及Mask R-CNN示例

    深度学习目标检测详细解析以及Mask R-CNN示例 本文详细介绍了R-CNN走到端到端模型的Faster R-CNN的进化流程,以及典型的示例算法Mask R-CNN模型.算法如何变得更快,更强! ...

  9. mask rcnn 超详细代码解读(一)

    mask r-cnn 代码解读(一) 文章目录 1 代码架构 2 model.py 的结构 3 train过程代码解析 3.1 Resnet Graph 3.2 Region Proposal Net ...

最新文章

  1. (十)mybatis之配置(mybatis-config.xml)
  2. Golang 的Gin框架入门教学
  3. linux有防火墙么,Linux防火墙Firewall和Iptables的使用
  4. deebot扫地机器人怎么清洁_扫地机器人清洁力拼杀,科沃斯机器人DEEBOT N3与小米1S对比评测...
  5. java上传csv文件上传_java处理csv文件上传示例详解
  6. Win10系统电脑不会一键还原系统怎么解决
  7. HTML5拖拽API的使用实例
  8. 【大规模图像检索的利器】Deep哈希算法介绍
  9. java jsonobject_Java调用groovy及如何使用springBean
  10. 【Hexo搭建个人博客】(八)添加背景效果(点击鼠标显示红心并浮现社会主义核心价值观)
  11. debian7开机启动
  12. CodeForces - 1077(div3) E.Thematic Contests(枚举+二分)
  13. 软件测试怎么测微信朋友圈,面试题:软件测试,如何测微信的朋友圈
  14. 专访罗杰斯公司市场发展经理杨熹 谈高频材料最新发展趋势
  15. Unity-VR | AR相关(更新中)
  16. Android软件安装工具-APK安装器
  17. fadeIn fadeOut
  18. 量子通信,永不陷落的安全堡垒?
  19. 逻辑综合重点解析(Design Compiler篇)
  20. Linux核心命令汇总(思维导图+实例讲解)

热门文章

  1. java的endorsed机制,java.endorsed.dirs 和 java.ext.dirs 系统属性说明 | 学步园
  2. 路由器有线无线上网优先级
  3. 【Web前端HTML5CSS3】03-字符实体与语义标签
  4. 如何利用技术提升微信营销效果
  5. 【南阳ACM】 喷泉装置(一)
  6. 巧用less循环加变量实现燃烧的蜡烛动画
  7. 固定于计算机主机箱中承载,一种计算机用主机箱的制作方法
  8. python应用程序无法正常启动0xc0000142_Win7系统应用程序无法启动提示0xc0000142的解决方法...
  9. ps学习 通道扣图——玻璃杯
  10. 建立个人网站经验分享