1. 介绍在提出 ResNet 网络之后,很多模型都会拿 ResNet 网络作为基准和比对。而ResNeXt 网络可以被视作对 ResNet 的小幅升级,其实不难发现其也参考了 Inception 的思想。其原始论文为 《Aggregated Residual Transformations for Deep Neural Network》,发表于 2017 年的 CVPR。但是这篇论文没有当初的 ResNet 那么惊艳了。论文最大的贡献点就在于更新了 Residual Block,采用 split-transform-merge 策略,本质是分组卷积,但是不需要像 Inception 一样人工设计复杂的结构,也不像 Inception 一样结合不同尺寸感受野的信息,拓扑结构一致的 ResNeXt 对 GPU 等硬件也更友好 (所以这个结构跑得更快)。值得指出的是,split-transform-merge 策略其实在 VGG 堆叠的思想和 Inception 的思想中都有体现,只不过 VGG split 的是变换函数本身,ResNeXt 和 Inception 都是 split 输入特征。2. 模型2.1 组卷积在讲述 ResNeXt 之前,需要先了解一下什么是组卷积 (Group Convolution)。而 分组卷积是介于普通卷积和深度可分离卷积(MobileNet)的一种折中方案,不是彻底的将每个 channel 都要单独赋予一个独立的卷积核,也不是整个 Feature Map 使用同一个卷积核。这就像之前说的 Group Normalization 一样。2.2 ResNeXt—block作者在论文中给出了三种 block 模块,注意,他们在数学计算上完全等价!首先看从 ( c ) 到 ( b )。在 ( c ) 中上面 256 通道特征图通过 1 × 1 卷积变为 128 个通道,每个卷积核大小为 1 × 1 × 256,一共 128 个卷积核。我们考虑将 128 个卷积核 4 个一组,那么就可以成为 32 组。因为卷积核之间是没什么关联的,所以完全可以独立分开,就对应于 ( b ) 的第一行。因为在 ( c ) 中第二行是组卷积,其实也是把 1 × 1 卷积变为 128 个通道独立拆分为 32 组,每组 4 通道,就和 ( b ) 中第二层的输入是一致的。 ( b ) 的第二层其实就是把组卷积给画开了而已。所以 ( b ) 的第二层与 ( c ) 的第二层一致。因此( b ) 和 ( c ) 是完全等价的。然后我们看从 ( b ) 到 ( a )。重点在于为什么 concatenate 之后通过 256 个 1 × 1 × 128 卷积和直接使用 32 组 256 个 1 × 1 × 4 卷积后直接相加是等价的。其实这非常自然,让我们想象一下,最终输出的某个通道的某个元素,其实就是之前 128 个通道那个元素位置元素的加权求和,权就是 1 × 1 × 128 卷积核的参数。那么他可以把 128 个求和元素拆开成先加 4 个,再加 4 个,这样加 32 下,最后再把这 32 个元素加起来。本质就是 256 个 1 × 1 × 128 卷积核可以拆成 32 组 256 个 1 × 1 × 4 卷积核。因此 ( b ) 和 ( a ) 也是等价的。所以为了搭建 ResNeXt 网络,只需简单地将搭建 ResNet 网络中的 block 进行替换就行了。2.3 为什么 group = 32组数 (作者取名为 Cardinality),可以计算出组卷积通道数,使得和原始 ResNet 计算量基本一致(原论文提到尽可能减少训练过程中超参数个数)。参数量计算很简单,参考如下公式,当 C = 32 , d = 4时计算可得参数量为 70k:实践是检验真理的唯一标准,在没有理论的支撑下,作者干脆就是根据实验发现,这样性能好,所以设置为 32。这里,C为设置的组数,d为每组的维度,width of group conv为组卷积的输出维度,也就是C*d2.4 注意最后补充一下,上述 block 都是针对 ResNet50 及以上的网络进行替换的。如果对于浅层的例如 ResNet18 和 ResNet34 怎么替换呢?可以参考下图的结构进行替换即可。————————————————
1. 介绍传统卷积神经网络,内存需求大、运算量大导致无法在移动设备以及嵌入式设备上运行。现阶段,在建立小型高效的神经网络工作中,通常可分为两类工作:压缩预训练模型。获得小型网络的一个办法是减小、分解或压缩预训练网络,例如量化压缩(product quantization)、哈希(hashing )、剪枝(pruning)、矢量编码( vector quantization)和霍夫曼编码(Huffman coding)等;此外还有各种分解因子(various factorizations )用来加速预训练网络;还有一种训练小型网络的方法叫蒸馏(distillation) 蒸馏方法解读,使用大型网络指导小型网络,这是对论文的方法做了一个补充,后续有介绍补充。直接训练小型模型。 例如Flattened networks利用完全的因式分解的卷积网络构建模型,显示出完全分解网络的潜力;Factorized Networks引入了类似的分解卷积以及拓扑连接的使用;Xception network显示了如何扩展深度可分离卷积到Inception V3 networks;Squeezenet 使用一个bottleneck用于构建小型网络。本文MobileNet网络架构属于上述第二种,允许模型开发人员专门选择与其资源限制(延迟、大小)匹配的小型模型,MobileNets主要注重于优化延迟同时考虑小型网络,从深度可分离卷积的角度重新构建模型。MobileNet网络是由google团队在2017年提出的,专注于移动端或者嵌入式设备中的轻量级CNN网络。相比传统卷积神经网络,在准确率小幅降低的前提下大大减少模型参数与运算量。(相比VGG16准确率减少了0.9%,但模型参数只有VGG的1/32), 网络主要体现两方面:Depthwise Convolution(大大减少运算量和参数数量)增加了两个超参数α,β,其中 α 控制卷积核个数的超参数,β 控制输入图像大小,这两个参数是人为设定的,并不是网络学习到的。MobileNet是Google提出的一种小巧而高效的CNN模型,MobileNets基于流线型架构(streamlined),使用深度可分离卷积(depthwise separable convolutions, 即Xception变体结构)来构建轻量级深度神经网络。论文测试在多个参数量下做了广泛的实验,并在ImageNet分类任务上与其他先进模型做了对比,显示了强大的性能。论文验证了模型也在其他领域(对象检测,人脸识别,大规模地理定位等)使用的有效性。2. 模型2.1 基本单元:深度可分离卷积(depthwise separable convolution)mobileNet的基本单元是深度可分离卷积(depthwise separable convolution)。深度可分离卷积其实是一种可分解卷积操作(factorized convolutions),其可以分解为两个更小的操作:depthwise convolution(DW 卷积)和pointwise convolution(PW卷积)。2.1.1 DW卷积Depthwise Convolution和传统卷积不同,对于传统卷积其卷积核是用在所有的输入通道上(input channels),而depthwise convolution针对每个输入通道采用不同的卷积核,就是说一个卷积核对应一个输入通道,所以说depthwise convolution是depth级别的操作。传统卷积卷积核的channel=输入特征channel输出特征矩阵的channel=卷积核个数DW卷积卷积核的channel=1输入特征channel=卷积核个数=输出特征channel2.1.2 PW卷积pointwise convolution其实就是普通的卷积,只不过其采用1x1的卷积核。对于depthwise separable convolution,其首先是采用depthwise convolution对不同输入通道分别进行卷积,然后采用pointwise convolution将上面的输出再进行结合,这样其实整体效果和一个标准卷积是差不多的,但是会大大减少计算量和模型参数量。使用depthwise separable convolution(深度可分离卷积),能减少多少参数呢?请看下图对比说明:2.2 网络结构前面讲述了depthwise separable convolution,这是MobileNet的基本组件,但是在真正应用中会加入BN层,并使用ReLU激活函数,所以depthwise separable convolution的基本结构如图4右边所示。标准卷积和MobileNet中使用的深度分离卷积结构对比如下:MobieNet V1网络结构细节如下:mobienet v1 网络结构中第一行Conv/s2表示普通卷积,步距为2.Filter Shape为3x3x3x32表示卷积核大小为3x3,输入为彩色图片3通道,输出为32通道Conv dw/s1表示 DW卷积,步距为1,Filter Shape为3 x 3 x 32表示卷积核大小3x3,dw卷积的channel为1,卷积核的个数为32由于可分离卷积是mobienet v1基本组件,可分离卷积表示为dw +pw,因此dw 和1x1的pw是成对出现的mobienet v1的模型结构有点类似于VGG结构,简单的将一系列卷积进行串行链接。3. 实验3.1 对比大模型对比了 MobieNet 和GoogleNet、VGG16在imageNet数据集上的准确率、运算量和模型参数,可以看到MobieNet相对于VGG16 它的准确率只减少了0.9%,但它的模型参数大概只有VGG网络的1/32。3.2 对比小模型对比了 MobieNet 和squeezenet、AlexNet在imageNet数据集上的准确率、运算量和模型参数,可以看到MobieNet准确率高且模型参数少。3.3 超参数 α 和 βα 是卷积核个数的倍率,控制卷积过程中采用的卷积核个数。看看取不同 α 时网络的效果。α 取1.0的时候,它的准确率是70.6%,当α 取0.75时,即卷积个数缩减为原来的0.75倍的时候,它的准确率为68.4%,同时计算量和参数也不同程度的减少;当α 取0.5时,准确率为63.7%,计算量和参数同时也在减少。可以发现将我们卷积核个数减少之后,准确率上没有太大的变化,但模型参数大幅减少。可以根据自己的项目需求去选择合适的α值。β 是分辨率的参数,输入图像的尺寸对网络的分类准确率,模型计算量、模型参数的对比。可以发现通过适当减低图片尺寸大小,能够保证准确率降低很小的情况下,来大幅减少我们的运算量。根据你自己项目的需求来设置β 参数。4. 总结在mobienet v1网络的实际使用中,很多人发现dw卷积,它在训练完之后部分卷积核容易废掉,即卷积核参数大部分为0。因为你观察DW卷积的参数时,你会发现它的大部分参数都是等于0的,DW卷积核是没有起到作用的。针对这个问题 在mobienet v2中会得到改善。
1. 介绍在使用opencv做图像处理的时候,我们经常会需要用到一些基础的图像形态学操作腐蚀、膨胀。通过这些基本的形态学操作我们可以实现去噪以及图像的切割等。形态学变换是基于图像形状的基础变换,它只能在二值图像上做处理。形态学操作需要两个输入:输入图像和structuring element(kernel),structuring element(kernel)决定我们做何种形态学处理的操作。腐蚀和膨胀是形态学处理的基础操作,而开/闭运算、礼帽黑帽是基于腐蚀和膨胀的变种操作。2. 形态学操作2.1 腐蚀和膨胀腐蚀和膨胀是最基本的形态学操作,腐蚀和膨胀都是针对白色部分(高亮部分)而言的。 膨胀就是使图像中高亮部分扩张,效果图拥有比原图更大的高亮区域; 腐蚀是原图中的高亮区域被蚕食,效果图拥有比原图更小的高亮区域。 总结就是:膨胀是求局部最大值的操作,腐蚀是求局部最小值的操作。2.1.1 腐蚀用一个结构元素扫描图像中的每一个像素,用结构元素中的每一个像素与其覆盖的对应像素做“与"操 作,如果都为1,则该像素为1,否则为0。如下图所示,结构A被结构B腐蚀后:作用:消除物体边界点,使目标缩小,可以消除小于结构元素的噪声点。函数:cv.erode(img,kernel,iterations)参数:img:要处理的图像kernel:核结构(上图结构B)iterations:腐蚀的次数,默认是1。2.1.2 膨胀用一个结构元素扫描图像中的每一个像羹,用结构元素中的每一个像素与其覆盖的对应像素做 “与"操作,如果都为0,则该像素为0,否则为1。如下图所示,结构A被结构B膨胀后:作用:将与物体接触的所有背景点合并到物体中,使目标增大,可填补目标中的孔洞。函数:cv.dilate(img,kernel,iterations)参数:img:要处理的图像kernel:核结构iterations:腐蚀的次数,默认是1。2.1.3 代码示例import numpy as np import cv2 as cv img = cv.imread("./img/1.jpg") # 创建核结构 kernel = np.ones((5,5),np.uint8) # 腐蚀和膨胀 erosion = cv.erode(img,kernel) #腐蚀 dilate = cv.dilate(img,kernel) #膨胀 cv2.imshow('1', img) # 原图 cv2.imshow('2', erosion) # 腐蚀后 cv2.imshow('3', dilate) # 膨胀后2.2 开/闭操作开运算和闭运算是将腐蚀和膨胀按照一定的次序进行处理。但这两者并不是可逆的,即先开后闭(先闭后开)并不能得到原来的图像。2.2.1 开操作先腐蚀后膨胀。作用:分离物体,消除小区域。特点:消除噪点,去除小的干扰块,而不影响原来的图像。2.2.2 闭操作先膨胀后腐蚀。作用:消除"闭合"物体里面的孔洞。特点:可以填充闭合区域。2.2.3 代码示例函数:cv.morphologyEx(img, op, kernel)参数:img:要处理的图像。op(处理方式):若进行开运算,则设为cv.MORPH_OPEN,若进行闭运算,则设为cv.MORPH_CLOSEKernel(核结构):按照该核结构(模板)去进行运算,上述的结构B。import numpy as np import cv2 as cv import matplotlib.pyplot as plt img = cv.imread("./img/1.jpg") # 创建核结构 kernel = np.ones((5,5),np.uint8) #图像的开闭运算 cvOPen = cv.morphologyEx(img,cv.MORPH_OPEN,kernel) #开运算 cvClose = cv.morphologyEx(img,cv.MORPH_CLOSE,kernel) #闭运算 #图像展示 fig,axes=plt.subplots(nrows=1,ncols=3,figsize=(15, 10)) axes[0].imshow(img[:,:,::-1]), axes[0].set_title("原图") axes[1].imshow(cvOPen[:,:,::-1]), axes[1].set_title("开运算") axes[2].imshow(cvClose[:,:,::-1]), axes[2].set_title("闭运算") plt.show()2.3 礼帽和黑帽2.3.1 礼帽运算原图像与开运算结果之差,如下式计算:由于开运算可能会放大了裂缝或者局部低亮度的区域。因此,从原图中减去开运算后的图,得到的效果图突出了比原图轮廓周围的区域更明亮的区域,且这一操作和选择的核的大小相关。礼帽运算用来分离比邻近点亮一些的斑块。当一幅图像具有大幅的背景的时候,而微小物品比较有规律的情况下,可以使用顶帽运算进行背景提取。2.3.2 黑帽运算“闭运算“的结果图与原图像之差,如下式计算:黑帽运算后的效果图突出了比原图轮廓周围的区域更暗的区域,且这一操作和选择的核的大小相关。黑帽运算可以用来分离比邻近点暗一些的斑块。2.3.3 函数cv.morphologyEx(img, op, kernel) 参数: img:要处理的图像 op:处理方式 闭运算:cv.MORPH_CLOSE 开运算:cv.MORPH_OPEN 礼帽运算:cv.MORPH_TOPHAT 黑帽运算:cv.MORPH_BLACKHAT Kernel:核结构3. 总结腐蚀和膨胀∶ 腐蚀:求局部最大值 膨胀:求局部最小值开闭运算: 开:先腐蚀后膨胀 闭:先膨胀后腐蚀礼帽和黑帽: 礼帽:原图像与开运算之差 黑帽:闭运算与原图像之差4. 参考【1】https://blog.csdn.net/qq_50620084/article/details/124526983
1. 介绍1.1 背景介绍:自2015年以来,在生物医学图像分割领域,U-Net得到了广泛的应用,目前已达到四千多次引用。至今,U-Net已经有了很多变体。目前已有许多新的卷积神经网络设计方式,但很多仍延续了U-Net的核心思想,加入了新的模块或者融入其他设计理念。编码和解码,早在2006年就发表在了nature上.当时这个结构提出的主要作用并不是分割,而是压缩图像和去噪声,后来把这个思路被用在了图像分割的问题上,也就是现在我们看到的FCN或者U-Net结构,在它被提出的三年中,有很多很多的论文去讲如何改进U-Net或者FCN,不过这个分割网络的本质的结构是没有改动的, 即下采样、上采样和跳跃连接。1.2 医学图像特点图像语义较为简单、结构较为固定。我们做脑的,就用脑CT和脑MRI,做胸片的只用胸片CT,做眼底的只用眼底OCT,都是一个固定的器官的成像,而不是全身的。由于器官本身结构固定和语义信息没有特别丰富,所以高级语义信息和低级特征都显得很重要。数据量少。医学影像的数据获取相对难一些,很多比赛只提供不到100例数据。所以我们设计的模型不宜多大,参数过多,很容易导致过拟合。原始UNet的参数量在28M左右、上采样带转置卷积的UNet参数量在31M左右,而如果把channel数成倍缩小,模型可以更小。缩小两倍后,UNet参数量在7.75M。缩小四倍,可以把模型参数量缩小至2M以内)非常轻量。个人尝试过使用Deeplab v3+和DRN等自然图像语义分割的SOTA网络在自己的项目上,发现效果和UNet差不多,但是参数量会大很多。多模态。相比自然影像,医疗影像是具有多种模态的。以ISLES脑梗竞赛为例,其官方提供了CBF,MTT,CBV,TMAX,CTP等多种模态的数据。比如CBF是脑血流量,CBV用于检测巨细胞病毒的。1.3 图像分割是什么简单的来讲就是给一张图像,图像分割出一个物体的准确轮廓。也这样考虑,给出一张图像 I,这个问题就是求一个函数,从I映射到Mask。求这个函数有很多方法,但是第一次将深度学习结合起来的是全卷积网络(FCN),利用深度学习求这个函数。2. Unet发展历程(FCN、Unet、Unet++)2.1 全卷积网络-FCN很多分割网络都是基于FCN做改进,我们先介绍FCN的内容。2.1.1 FCN介绍:FCN是深度学习在图像分割的开山之作。在此之前深度学习一般用在分类和检测问题上。由于用到CNN,所以最后提取的特征的尺度是变小的。和我们分割任务要求的函数不一样,我们要求的函数是输入多大,输出有多大。经典的CNN在卷积层之后使用全连接层得到固定长度的特征向量进行分类(全联接层+softmax输出)。基于此,FCN的设计使得可以接受任意尺寸的输入图像,采用反卷积层对最后一个卷积层的feature map进行上采样, 使它恢复到输入图像相同的尺寸,最后在上采样的特征图上进行逐像素分类。解决了语义级别的图像分割问题。由于网络中只有卷积没有全连接,所以这个网络又叫全卷积网络。2.1.2 FCN框架输入原图,得到五次池化后的特征图,然后将特征map上采样回去。再将预测结果和ground truth每个像素一一对应分类,做像素级别分类。也就是说将分割问题变成分类问题,而分类问题正好是深度学习的强项。如果只将特征map直接上采样或者反卷积,明显会丢失很多信息。FCN采取解决方法是将pool4、pool3、和特征map融合起来,由于pool3、pool4、特征图大小尺寸是不一样的,所以融合前应该上采样到同一尺寸。这里的融合对应元素相加。2.1.3 反卷积层反卷积层也是卷积层。乍看一下好像反卷积和卷积的工作过程差不多,主要的区别在于反卷积输出图片的尺寸会大于输入图片的尺寸,通过增加padding来实现这一操作。这里可以参考:卷积和逆卷积(转置卷积)详解卷积和逆卷积-nn.Conv2d与nn.ConvTranspose2d参数理解2.1.4 输入图像大小FCN的输入图片大小任意。首先,我们来看传统CNN为什么需要固定输入图片大小。对于CNN,一幅输入图片在经过卷积和 pooling 层时,这些层是不关心图片大小的。比如对于一个卷积层,它并不关心inputsize多大,pooling层同理。但是在进入全连接层时,feature map(假设大小为n×n)要拉成一条向量,而向量中每个元素(共n×n个)作为一个结点都要与下一个层的所有结点(假设4096个)全连接,这里的权值个数是4096×n×n,而我们知道神经网络结构一旦确定,它的权值个数都是固定的,所以这个n不能变化,n是conv5的outputsize,所以层层向回看,每个outputsize都要固定,因此输入图片大小要固定。而FCN的全卷积是没有全连接层的,所以不要求输入图片大小固定。2.1.5 分割效果FCN优点是实现端到端分割。输入是原始数据输出是最终结果,缺点是分割结果细节不够好,可以看到FCN8s是上面讲的pool4、pool3和特征图融合,FCN16s是pool4和特征map融合,FCN32s是只有特征map,得出结果都是细节不够好,具体可以看自行车。此外,作者还尝试了结合pool2发现效果并没有提升。2.2 UNET2.2.1 网络架构Unet包括两部分:第一部分,特征提取,VGG类似。第二部分,上采样部分。由于网络结构像U型,所以叫Unet网络。特征提取部分,每经过一个池化层就一个尺度,包括原图尺度一共有5个尺度。上采样部分,每上采样一次,就和特征提取部分对应的通道数相同尺度融合,但是融合之前要将其crop。这里的融合是拼接。可以看到,输入是572x572的,但是输出变成了388x388,这说明经过网络以后,输出的结果和原图不是完全对应的。图中,蓝色箭头代表3x3的卷积操作,并且stride是1,padding策略是vaild,因此,每个该操作以后,featuremap的大小会减2。红色箭头代表2x2的maxpooling操作,需要注意的是,此时的padding策略也是vaild,这就会导致如果pooling之前featuremap的大小是奇数,会损失一些信息 。所以要选取合适的输入大小,因为2*2的max-pooling算子适用于偶数像素点的图像长宽。绿色箭头代表2x2的反卷积操作,这个只要理解了反卷积操作,就没什么问题,操作会将featuremap的大小乘2。- 卷积和逆卷积(转置卷积)详解、 卷积和逆卷积-nn.Conv2d与nn.ConvTranspose2d参数理解灰色箭头表示复制和剪切操作,可以发现,在同一层左边的最后一层要比右边的第一层要大一些,这就导致了,想要利用浅层的feature,就要进行一些剪切。输出的最后一层,使用了1x1的卷积层做了分类。最后输出了两层是前景和背景。2.2.2 valid卷积这三种不同模式是对卷积核移动范围的不同限制。full mode,橙色部分为image,蓝色部分为filter。full模式的意思是,从filter和image刚相交开始做卷积,白色部分为填0。same mode,当filter的中心(K)与image的边角重合时,开始做卷积运算,可见filter的运动范围比full模式小了一圈。注意:这里的same还有一个意思,卷积之后输出的feature map尺寸保持不变(相对于输入图片)。当然,same模式不代表完全输入输出尺寸一样,也跟卷积核的步长有关系。same模式也是最常见的模式,因为这种模式可以在前向传播的过程中让特征图的大小保持不变,调参不需要精准计算其尺寸变化(因为尺寸根本就没变化)。valid mode,当filter全部在image里面的时候,进行卷积运算,可见filter的移动范围较same更小了。2.2.3 overlap-tile策略医学图像是一般相当大,分割时候不可能将原图直接输入网络。所以需要用一个滑动窗口把原图扫一遍,使用原图的切片进行训练或测试。如下图,红框是要分割区域。但是在切图时要包含周围区域,overlap另一个重要原因是周围overlap部分可以为分割区域边缘部分提供纹理等信息。这样的策略会带来一个问题,图像边界的图像块没有周围像素,卷积会使图像边缘处的信息丢失。因此作者对周围像素采用了镜像扩充。下图中红框部分为原始图片,其周围扩充的像素点均由原图沿白线对称得到。这样,边界图像块也能得到准确的预测。另一个问题是,这样的操作会带来图像重叠问题,即第一块图像周围的部分会和第二块图像重叠。因此作者在卷积时只使用有效部分。可能的解释是使用valid卷积和crop裁剪,最终传到下一层的只有中间原先图像块(黄色框内)的部分。2.2.4 弹性变换由于深度神经网络具有非常强的学习能力,如果没有大量的训练数据,会造成过拟合,训练出的模型难以应用。因此对于一些没有足够样本数量的问题,可以通过已有的样本,对其进行变换,人工增加训练样本。常用的增加训练样本的方法主要有对图像进行旋转、位移等仿射变换,也可以使用镜像变换。这里介绍弹性变换。该算法最开始应用在mnist手写体数字识别数据集中,发现对原图像进行弹性变换的操作扩充样本以后,对于手写体数字的识别效果有明显的提升。因为unet论文的数据集是细胞组织的图像,细胞组织的边界每时每刻都会发生不规则的畸变,所以采用弹性变形的增广是非常有效的。下面来详细介绍一下算法流程:弹性变化是对像素点各个维度产生(-1,1)区间的随机标准偏差,用高斯滤波对各维度的偏差矩阵进行滤波,最后用放大系数控制偏差范围。因而由A(x,y)得到的A’(x+delta_x,y+delta_y)。A‘的值通过在原图像差值得到,A’的值充当原来A位置上的值。下图显示的是在固定的n下不同高斯标准差的结果,第二个图的形变效果是最合适的。2.4.5 损失函数损失函数首先是用了个pixel-wise softmax,就是每个像素对应的输出单独做softmax,也就是做了w*h个softmax。其中,x可以看作是某一个像素点, l(x)表示x这个点对应的类别label,pk(x)表示在x这个点的输出在类别k的softmax的激活值。那么pl(x)(x)代表什么呢?根据前面的说明就可以推断出来:点x在对应的label给出的那个类别的输出的激活值。正常的交叉熵定义如第一个公式,可以发现两个公式的意义其实是相同的,后面的公式在外面把非label对应的结果乘0了。然后是w(x)定义的式子,d1,d2分别是像素点最近和第二近的细胞的距离。这个权重可以调整图像中某个区域的重要程度。细胞组织图像的一大特点是,多个同类的细胞会紧紧贴合在一起,其中只有细胞壁或膜组织分割。因此,作者在计算损失的过程中,给两个细胞重合的边缘部分增加了损失的权重,以此让网络更加注重这类重合的边缘信息。实际情况中,是需要自己根据应用情况来设计或调整这个权重的。下图中的a是raw image,b是ground truth segmentation,c 是生成的分割掩码,白色是前景,黑色是背景。d是增加了像素级的loss权重后,让网络对边界像素进行了更好的学习的结果。2.3 UNET++2.3.1 存在的问题第一个问题:既然输入和输出都是相同大小的图,为什么要折腾去降采样一下再上采样呢?降采样的理论意义是,它可以增加对输入图像的一些小扰动的鲁棒性,比如图像平移,旋转等,减少过拟合的风险,降低运算量,增加感受野的大小。上采样的最大的作用其实就是把抽象的特征再还原解码到原图的尺寸,最终得到分割结果。对于特征提取阶段,浅层结构可以抓取图像的一些简单的特征,比如边界,颜色,而深层结构因为感受野大了,而且经过的卷积操作多了,能抓取到图像的一些抽象特征。第二个问题:既然unet每一层抓取的特征都很重要,为什么非要降四次之后才开始上采样回去呢?2.3.2 网络深度提出疑问后, 为了验证多深才好,每加一个深度就训练一个网络,分别用了两个数据集:Electron Microscopy 和 Cell然后测它们各自的分割表现,先不要看后两个UNet++,就看这个不同深度的U-Net的表现(黄色条形图)。我们可以看出,不是越深越好,它背后的传达的信息就是,不同层次特征的重要性对于不同的数据集是不一样的,并不是说设计一个原论文给出的那个结构,就一定对所有数据集的分割问题都最优。最终得出的结论是,不同数据集的最优的深度是不一样的, 但是总不能把所有不同深度的U-Net都训练一遍,太耗时间了,于是提出unet++ 。2.3.3 模型架构这个综合长连接和短连接的架构就是UNet++。UNet++的优势是可以抓取不同层次的特征,将它们通过特征叠加的方式整合,加入更浅的U-Net结构,使得融合时的特征图尺度差异更小。UNet++同时也引进了很多参数,占用内存也变大。3. 参考【1】https://blog.csdn.net/a8039974/article/details/109645616
图像增广与扩充—带有噪声的黑白裂缝图像扩充,用于裂缝检测训练1. 初始数据获得初始的裂缝数据,可以从github上搜索crack以及其他资源去获得。我这里只在github上找了一些就够了。然后对这些图像基于opencv去转化为黑白图像。然后截图处理,使裂缝可以占满它所在的图像。因为到时候还要贴图,为之后获得标签(位置信息)做铺垫。然后初始的数据就获得了。注意此时的数据是,黑白的图像,并且裂缝占满他所在的图像,每张图像的像素大小大概率是不一样的。2. 传统图像扩充、基于深度学习的图像扩充(GAN网络)传统方法:这里只使用了旋转和尺寸变换(resize)。裁剪等其他的方法没用的必要其实。GAN深度扩充:生成器使用反卷积,判别器使用卷积即可。详细可见: 使用GAN(生成对抗网络)进行图像生成3. 将生成的裂缝图像和黑色背景融合融合时,也可以对裂缝图像(假设原尺寸为256*256)尺寸变换和旋转,之后直接融合到黑色背景(1024*1024)上去即可。def add_obj(background, img, x, y): '''' 将img融合到background上去 background: 背景 img: 要融合的裂缝图像 x,y: 要融合的位置 ''' bg = background.copy() h_bg, w_bg = bg.shape[0], bg.shape[1] h, w = img.shape[0], img.shape[1] # Calculating coordinates of the top left corner of the object image to the background x_tl = x - int(w/2) y_tl = y - int(h/2) # Calculating coordinates of the bottom right corner of the object image to the background x_br = x + int(w/2) y_br = y + int(h/2) w1 = x_br - x_tl h1 = y_br - y_tl if (x_tl >= 0 and y_tl >= 0) and (x_br < w_bg and y_br < h_bg): a = bg[y_tl:y_br, x_tl:x_br] b = img[0:h1, 0:w1] c = np.where(b>50, b, a) bg[y_tl:y_br, x_tl:x_br] = c bg[bg>50] = 255 bg[bg<=50] = 0 return bg, x_tl, y_tl, x_br, y_br else: return None4. 添加噪声''' 生成黑白背景并随机生成噪声 ''' def add_small(w, h): root_path = 'noise/small' files_list = os.listdir(root_path) n = len(files_list) k = np.random.randint(0, n) img = cv2.imread(os.path.join(root_path, files_list[k]), 0) img = cv2.resize(img, (h, w)) return img def add_big(w, h): root_path = 'noise/big' files_list = os.listdir(root_path) n = len(files_list) k = np.random.randint(0, n) img = cv2.imread(os.path.join(root_path, files_list[k]), 0) img = cv2.resize(img, (h, w)) return img def get_xy(w, h, w1, h1): a = w - w1 - 1 b = h - h1 - 1 x = np.random.randint(0, a) y = np.random.randint(0, b) return x, y def add_noise(img): w = img.shape[1] h = img.shape[0] # 小斑点 count = np.random.randint(100, 300) for _ in range(0, count): w1 = np.random.randint(10, 15) h1 = np.random.randint(10, 15) x, y = get_xy(w, h, w1, h1) img[x:x+w1, y:y+h1] = add_small(w1, h1) # 方形 count = np.random.randint(3, 5) for _ in range(0, count): w1 = np.random.randint(30, 50) h1 = np.random.randint(30, 50) x, y = get_xy(w, h, w1, h1) img[x:x+w1, y:y+h1] = add_small(w1, h1) # 长形 count = np.random.randint(3, 5) for _ in range(0, count): w1 = np.random.randint(10, 15) h1 = np.random.randint(30, 50) x, y = get_xy(w, h, w1, h1) img[x:x+w1, y:y+h1] = add_small(w1, h1) # 扁形 count = np.random.randint(3, 5) for _ in range(0, count): w1 = np.random.randint(30, 50) h1 = np.random.randint(10, 15) x, y = get_xy(w, h, w1, h1) img[x:x+w1, y:y+h1] = add_small(w1, h1) return img5. 最终得到裂缝图像以及对应的label(记录着裂缝的位置)最终得到crack文件夹,img是扩充生成的图片,label是对应的裂缝位置信息(保存为xml格式),可以用来训练。生成图像示例:对应label示例(xml文件):
1. 介绍论文地址:ShuffleNet: An Extremely Efficient Convolutional Neural Network for Mobile DevicesShuffleNet是Face++(旷视)的一篇关于降低深度网络计算量的论文,号称是可以在移动设备上运行的深度网络。它主要采用channel shuffle、pointwise group convolutions 和 depthwise separable convolution来修改原来的ResNet单元。ShuffleNet可以和MobileNet、Xception和ResNeXt 结合来看,因为有类似的思想。卷积的group操作从AlexNet就已经有了,当时主要是解决模型在双GPU上的训练。ResNeXt借鉴了这种group操作改进了原本的ResNet。MobileNet则是采用了depthwise separable convolution代替传统的卷积操作,在几乎不影响准确率的前提下大大降低计算量。Xception和MobileNet类似,也是主要采用depthwise separable convolution 来 改进Inception v3的结构。上述内容解读,可以参考:ResNeXt:ResNeXt 详细解析MobileNet:MobileNet V1MobileNet V2MobileNet V3Xception:Xception 详细解析2. ShuffleNet 模型ShuffleNet 主要采用channel shuffle、pointwise group convolutions 和 depthwise separable convolution来修改原来的ResNet单元,接下来依次讲解。2.1 channel shuffle其思想如上图 Figure 1。先从group操作说起,一般卷积操作中比如输入feature map的数量是N,该卷积层的filter数量是M,那么M个filter中的每一个filter都要和N个feature map的某个区域做卷积,然后相加作为一个卷积的结果。假设你引入group操作,设group为 g,那么N个输入feature map就被分成g个group,M个filter就被分成g个group,然后在做卷积操作的时候,第一个group的M/g个filter中的每一个都和第一个group的N/g个输入feature map做卷积得到结果,第二个group同理,直到最后一个group,如上图(a)。不同的颜色代表不同的group,图中有三个group。这种操作可以大大减少计算量,因为你每个filter不再是和输入的全部feature map做卷积,而是和一个group的feature map做卷积。但是如果多个group操作叠加在一起,如Figure1(a)的两个卷积层都有 group 操作,显然就会产生边界效应。什么意思呢?就是某个输出channel仅仅来自输入channel的一小部分。这样肯定是不行的,学出来的特征会非常局限。于是就有了channel shuffle来解决这个问题,先看Figure1(b),在进行GConv2之前,对其输入feature map做一个分配,也就是每个group分成几个subgroup,然后将不同group的subgroup作为GConv2的一个group的输入,使得GConv2的每一个group都能卷积输入的所有group的feature map,这和Figure1(c)的channel shuffle的思想是一样的。2.2 pointwise group convolutionspointwise group convolutions 其实就是带group的卷积核为11的卷积,也就是说 pointwise convolution是卷积核为11的卷积。在ResNeXt中主要是对33的卷积做group操作,但是在ShuffleNet中,作者是对11的卷积做group的操作,因为作者认为1*1的卷积操作的计算量不可忽视。可以看Figure2(b)中的第一个11卷积是GConv,表示group convolution。Figure2(a)是ResNet中的bottleneck unit,不过将原来的33 Conv改成3*3 DWConv,作者的ShuffleNet主要也是在这基础上做改动。首先用带group的11卷积代替原来的11卷积,同时跟一个channel shuffle操作,这个前面也介绍过了。然后是3*3 DWConv 表示depthwise separable convolution。其实就是将传统的卷积操作分成两步,假设原来是3 * 3的卷积,那么 depthwise separable convolution就是先用M个3 * 3卷积核一对一卷积输入的M个feature map,不求和,生成M个结果,然后用N个1 * 1的卷积核正常卷积前面生成的M个结果,求和,最后得到N个结果。Figure2(c)添加了一个Average pooling和设置了stride=2,另外原来Resnet最后是一个Add操作,也就是元素值相加,而在(c)中是采用concat的操作,也就是按channel合并,类似googleNet的Inception操作。2.3 depthwise separable convolution可以参考:MobileNet v1 中的解析。2.4 结构 & 实验结果3. 总结ShuffleNet的核心就是用 pointwise group convolution,channel shuffle和depthwise separable convolution 来改进 ResNet block的相应层进而构成了ShuffleNet uint,达到了减少计算量和提高准确率的目的。其中最核心的是 channel shuffle解决了多个group convolution叠加出现的边界效应,而pointwise group convolution和depthwise separable convolution主要减少了计算量。————————————————
1. 介绍1.1 关于v1和v2MobileNet-v1的主要思想就是深度可分离卷积,大大减少了参数量和计算量。可以参考 MobileNet V1网络解析。深度可分离卷积 可理解为 深度卷积 + 逐点卷积。深度卷积:深度卷积只处理长宽方向的空间信息;逐点卷积只处理跨通道方向的信息。能大大减少参数量,提高计算效率。 一个卷积核只处理一个通道,即每个卷积核只处理自己对应的通道。输入特征图有多少个通道就有多少个卷积核。将每个卷积核处理后的特征图堆叠在一起。输入和输出特征图的通道数相同。逐点卷积: 是使用1x1卷积对跨通道维度处理,有多少个1x1卷积核就会生成多少个特征图。用于跨通道扩充维度。MobileNet-v2 使用了逆转残差模块和最后一层采用线性层(而不是relu)。可以参考 MobileNet V2网络解析。输入图像,先使用1x1卷积提升通道数;然后在高维空间下使用深度卷积;再使用1x1卷积下降通道数,降维时采用线性激活函数(y=x)。当步长等于1且输入和输出特征图的shape相同时,使用残差连接输入和输出;当步长=2(下采样阶段)直接输出降维后的特征图。对比 ResNet 的残差结构。输入图像,先使用1x1卷积下降通道数;然后在低维空间下使用标准卷积,再使用1x1卷积上升通道数,激活函数都是ReLU函数。当步长等于1且输入和输出特征图的shape相同时,使用残差连接输入和输出;当步长=2(下采样阶段)直接输出降维后的特征图。1.2 v3の介绍相对于v2,主要有3个变化:block结构发生改变,在v2的bottleneck block里加入了Squeeze-and-Excitation block。算法内部微结构变化,把部分relu6使用hard-swish替换,把全部sigmoid使用hard-sigmoid替换。使用Platform-Aware Neural Architecture Search(NAS)来形成网络结构,并利用NetAdapt技术进一步筛选网络层结构。2. 模型主要有以下改进:(1)添加SE注意力机制;(2)使用新的激活函数;(3)重新设计耗时层结构2.1 添加SE注意力机制先将特征图进行全局平均池化,特征图有多少个通道,那么池化结果(一维向量)就有多少个元素,[h, w, c]==>[None, c]。然后经过两个全连接层得到输出向量。第一个全连接层的输出通道数等于原输入特征图的通道数的1/4;第二个全连接层的输出通道数等于原输入特征图的通道数。即先降维后升维。全连接层的输出向量可理解为,向量的每个元素是对每张特征图进行分析得出的权重关系。比较重要的特征图就会赋予更大的权重,即该特征图对应的向量元素的值较大。反之,不太重要的特征图对应的权重值较小。第一个全连接层使用ReLU激活函数,第二个全连接层使用 hard_sigmoid 激活函数。经过两个全连接层得到一个由channel个元素组成的向量,每个元素是针对每个通道的权重,将权重和原特征图的对应相乘,得到新的特征图数据。以下图为例,特征图经过两个全连接层之后,比较重要的特征图对应的向量元素的值就较大。将得到的权重和对应特征图中的所有元素相乘,得到新的输出特征图。2.2 使用不同的激活函数swish激活函数公式为: ,尽管提高了网络精度,但是它的计算、求导复杂,对量化过程不友好,尤其对移动端设备的计算。h_sigmoid激活函数公式为: ,ReLU6激活函数公式为:激活函数公式为: ,替换之后网络的推理速度加快,对量化过程比较友好。2.3 重新设计耗时层结构减少第一个卷积层的卷积核个数。将卷积核个数从32个降低到16个之后,准确率和降低之前是一样的。减少卷积核个数可以减少计算量,节省2ms时间。简化最后的输出层。删除多余的卷积层,在准确率上没有变化,节省了7ms执行时间,这7ms占据了整个推理过程的11%的执行时间。明显提升计算速度。2.4 总体流程图像输入,先通过1x1卷积上升通道数;然后在高维空间下使用深度卷积;再经过SE注意力机制优化特征图数据;最后经过1x1卷积下降通道数(使用线性激活函数)。当步长等于1且输入和输出特征图的shape相同时,使用残差连接输入和输出;当步长=2(下采样阶段)直接输出降维后的特征图。2.5 网络结构图网络模型结构如图所示。exp size 代表11卷积上升的通道数;#out 代表11卷积下降的通道数,即输出特征图数量;SE 代表是否使用注意力机制;NL 代表使用哪种激活函数;s 代表步长;bneck 代表逆残差结构;NBN 代表不使用批标准化。
像素要实现图像识别,最离不开的,就是像素。其实我们都知道,图像是由像素组成的。实际上,神经网络计算,算的就是像素之间的关系,以及这些关系背后可能隐藏的图片信息。相机摄像头像素2000万,拍出来的照片肯定比像素1000万的要清晰。我们更容易看到图片中的物体是什么?这是为什么?因为像素越多,像素之间的关系(色彩,轮廓)越丰富,我们所能看到的信息就越多,自然而然获取到的信息就多。(一张1080p的图片,我们可以更容易辨别出图像中的物体是山还是水。这是因为更多的像素会给眼睛更丰富的图片细节)但是,你有没有发现。当我们去看一张图片时,我们绝对不是盯着某一个像素或某几个像素看,而是看了整个图像的大部分区域,或者说,大部分像素!因为只有看到了大部分的图片,才能知道图中是座山。正所谓,聚沙成山!绝不是少了一粒沙,山就不是山,多了一粒沙,就变成了山。上图哆啦A梦,虽然不是很清晰,像素点数也很少,但一眼望去,依然可以分清是哆啦A梦,甚至,用手捂住一半的图像,依然可以。像素局部性这是因为人们对于图像信息的识别,是建立在对像素局部性分析的基础上的。所谓局部性,通俗点说,就是眼睛或大脑会将相邻的像素或大片的像素连接起来分析,从而组合成嘴巴,然后是耳朵,最后是哆啦A梦。神经网络识别图片大致就是这样的原理。它模拟的,就是人们看到图片之后的信息处理过程。当我们盯着一个图片看时,我们首先会获取到图片的细节特征。比如哆啦A梦红色的大嘴巴。但是如果仅仅盯着大嘴巴,又反而让人有一种“只缘身在此山中”的感觉。因此还需要看一下图像的轮廓。于是,眼睛看图片大致有以下两个过程:瞳孔大,盯着某一处细节(如大嘴巴)看瞳孔缩小,模糊的看一张图片的大致轮廓两个过程获取的信息叠加。Bingo,看清楚了,是哆啦A梦!那么神经网络是否可以模拟这种瞳孔放大、缩小的方式呢?很幸运,可以!(卷积:convolution)卷积核的设计,就可以很直观的模拟这种获取图片信息的方法。人们通过调整卷积核的大小,来达到瞳孔张开、缩小的目的。并且大量的实验和论文表明,卷积这一针对图像局部性识别的算法,可以非常有效的模拟人眼识别物体的过程关于卷积算法以及卷积核的设计,后面会专门写几篇文章来聊聊。因为卷积这一算法,在图像处理领域,实在是,太重要了啊!色彩分量RGB回到像素这一话题。你有没有想过,为什么一张图片会是彩色的。学过摄影的小明同学可能这时会回答:因为图片是由RGB三种颜色来表示的,每个像素实际是不同的R/G/B分量的叠加,混合起来,就表示成了不同的颜色。回答正确。(三张分别表示R/G/B分量的图片,合成一张彩色图片)我们人眼可以很直观的看到红色和蓝色,可以察觉到一张图片的色彩和轮廓。那么,如果让计算机来处理图片,他又是如何知道色彩和轮廓的呢?其实对于计算机来说,一张图片只是一堆数据,计算机是无法知道这堆数据代表的是什么。这就需要人为的给这堆数据一种表示方法,让计算机知道,哦,这1/3的数据是红色分量,这1/3的是蓝色分量这些数据(像素)组合起来,可能代表的是个“帽子”。怎么做数据在计算机的存储中,最常见的存储方式是连续存储的。比如C语言,定义一个数组,那么数组在内存中的位置是连续的。int data[10] = {0,1,2,3,4,5,6,7,8,9};内存怎么理解,它就是一排连着的门牌号的公寓宿舍。门牌号为101里面住着的,是data的第一个数据0。门牌号102里面住着的,是data的第二个数据1,...,以此类推。(女贞路4号)只不过,在计算机存储器中,没有门牌号,有的都是地址。这个时候,计算机根本就不关心数据是啥,计算机用到的时候,就把数据从内存对应的地址中取出来用。如何取数据这就需要人们为数据存储设计一种格式,告诉计算机,这堆数据大概是什么样的。只有这样,通过这种人为约定的方式,计算机才能正确的取到R分量或者B分量。对于一张图片来说,最常见的两个参数是长和宽,一般用H(height) 和 W(width) 来表示,那么RGB三个分量,看作是3个通道(channel),一般用 C 来表示。如此一来,一张长宽分别是224像素*224像素的RGB图像,就可以用 HWC = [224, 224, 3]来表示。两张类似的图片就用 NHWC = [2, 224, 224, 3]表示,其中N代表图片张数。(一张图片的抽象数据表示)友好的数据表示方法,可以减少大量的计算复杂度。虽然这样表示不太利于人们的直观理解,但是计算机处理这种数据是十分方便的。在目前主流的深度学习框架中,对于图片的数据格式,基本都支持了NHWC或NCHW这种数据摆放格式。说到底,都是为了更高效地进行图片数据的处理和运算。总结一下今天就先开个头,从像素说起,说到了像素具有局部连接性的,人眼识别图像也是通过获取像素的局部连接性信息来完成的。幸运的是,卷积这一算法,可以很好的模拟这一过程。最后,为了使计算机更高效的处理图片数据,引出NHWC的图片数据表示方法,所以,之后我们说图片,不仅仅局限于图片的长和宽,还多了一个维度信息,那就是channel。
【Opencv】cv2.connectedComponentsWithStats 计算不规则连通区域0. 介绍计算不规则连通区域是指在图像中提取出不规则的、由像素点连接而成的连通区域。OpenCV提供了一种函数叫做cv2.connectedComponents(),用于实现这个功能。原理:首先,将二值化图像中的前景部分(像素值为1)与背景部分(像素值为0)进行标记。然后,使用基于连通性的算法,将像素点进行分组,并给每个组分配一个唯一的标签。最终得到的结果是连通区域的数量和每个像素点所属的标签。应用:目标检测和跟踪:通过计算不规则连通区域,可以实现对目标对象的检测和跟踪,进而进行目标识别、运动分析等应用。图像分割:将图像分割成不同的不规则连通区域,用于图像分析、对象提取和图像处理等任务。图像分析和特征提取:通过计算不规则连通区域的属性,如面积、周长、重心等,可用于分析图像中的形状、纹理、边缘等特征。图像处理和修复:通过计算不规则连通区域,可以定位和修复图像中的缺陷、噪声或损坏部分,如去除图像中的小斑点或孔洞。计算机视觉和机器学习:不规则连通区域的计算可以作为计算机视觉和机器学习算法的预处理步骤,用于提取图像中的特征,构建模型等。需要注意的是,计算不规则连通区域在处理复杂图像时可能会受到噪声和边缘模糊等因素的影响,因此在使用时可能需要进行图像预处理、参数调整等措施来优化结果。1. 函数num, labels, stats, centroids = cv2.connectedComponentsWithStats(image, connectivity=8)输入值: image : 是要处理的图片,官方文档要求是8位单通道的图像。connectivity : 可以选择是4连通还是8连通。 输出值:num : 返回值是连通区域的数量。labels : labels是一个与image一样大小的矩形(labels.shape = image.shape),其中每一个连通区域会有一个唯一标识,标识从0开始。stats :stats会包含5个参数分别为x,y,h,w,s。分别对应每一个连通区域的外接矩形的起始坐标x,y;外接矩形的wide,height;s为对应的连通区域的像素个数(也就是 不规则连通区域的面积)。centroids : 返回的是连通区域的质心。
一、亮出效果最近在线教育行业遭遇一点小波折,一些搜题、智能批改类的功能要下线。退1024步讲,要不要自己做一个自动批改的功能啊?万一哪天孩子要用呢!昨晚我做了一个梦,梦见我实现了这个功能,如下图所示:功能简介: 作对了,能打对号;做错了,能打叉号;没做的,能补上答案。醒来后,我环顾四周,赶紧再躺下,希望梦还能接上。二、实现步骤今天主要讲如何切分图片、计算结果,并将结果反馈出来。往期回顾2.1 准备数据 2.2 训练数据2.3 预测数据之前我们准备了数据,训练了数据,并且拿图片进行了识别,识别结果正确。到目前为止,看来问题不大……没有大问题,有问题也大不了。下面就是把图片进行切割识别了。2.4 切割图像上帝说要有光,就有了光。于是,当光投过来时,物体的背后就有了影。我们就知道了,有影的地方就有东西,没影的地方是空白。这就是投影。这个简单的道理放在图像切割上也很实用。我们把文字的像素做个投影,这样我们就知道某个区间有没有文字,并且知道这个区间文字是否集中。下面是示意图:2.4.1 投影大法最有效的方法,往往都是用循环实现的。要计算投影,就得一个像素一个像素地数,查看有几个像素,然后记录下这一行有N个像素点。如此循环。首先导入包:import numpy as np import cv2 from PIL import Image, ImageDraw, ImageFont import PIL import matplotlib.pyplot as plt import os import shutil from numpy.core.records import array from numpy.core.shape_base import block import time 比如说要看垂直方向的投影,代码如下:# 整幅图片的Y轴投影,传入图片数组,图片经过二值化并反色 def img_y_shadow(img_b): ### 计算投影 ### (h,w)=img_b.shape # 初始化一个跟图像高一样长度的数组,用于记录每一行的黑点个数 a=[0 for z in range(0,h)] # 遍历每一列,记录下这一列包含多少有效像素点 for i in range(0,h): for j in range(0,w): if img_b[i,j]==255: a[i]+=1 return a 最终得到是这样的结构:[0, 79, 67, 50, 50, 50, 109, 137, 145, 136, 125, 117, 123, 124, 134, 71, 62, 68, 104, 102, 83, 14, 4, 0, 0, 0, 0, 0, 0, 0, 0, 0, ……38, 44, 56, 106, 97, 83, 0, 0, 0, 0, 0, 0, 0]表示第几行总共有多少个像素点,第1行是0,表示是空白的白纸,第2行有79个像素点。如果我们想要从视觉呈现出来怎么处理呢?那可以把它立起来拉直画出来。# 展示图片 def img_show_array(a): plt.imshow(a) plt.show() # 展示投影图, 输入参数arr是图片的二维数组,direction是x,y轴 def show_shadow(arr, direction = 'x'): a_max = max(arr) if direction == 'x': # x轴方向的投影 a_shadow = np.zeros((a_max, len(arr)), dtype=int) for i in range(0,len(arr)): if arr[i] == 0: continue for j in range(0, arr[i]): a_shadow[j][i] = 255 elif direction == 'y': # y轴方向的投影 a_shadow = np.zeros((len(arr),a_max), dtype=int) for i in range(0,len(arr)): if arr[i] == 0: continue for j in range(0, arr[i]): a_shadow[i][j] = 255 img_show_array(a_shadow) 我们来试验一下效果:我们将上面的原图片命名为question.jpg放到代码同级目录。# 读入图片 img_path = 'question.jpg' img=cv2.imread(img_path,0) thresh = 200 # 二值化并且反色 ret,img_b=cv2.threshold(img,thresh,255,cv2.THRESH_BINARY_INV) 二值化并反色后的变化如下所示:上面的操作很有作用,通过二值化,过滤掉杂色,通过反色将黑白对调,原来白纸区域都是255,现在黑色都是0,更利于计算。计算投影并展示的代码:img_y_shadow_a = img_y_shadow(img_b) show_shadow(img_y_shadow_a, 'y') # 如果要显示投影 下面的图是上面图在Y轴上的投影从视觉上看,基本上能区分出来哪一行是哪一行。2.4.2 根据投影找区域最有效的方法,往往还得用循环来实现。上面投影那张图,你如何计算哪里到哪里是一行,虽然肉眼可见,但是计算机需要规则和算法。# 图片获取文字块,传入投影列表,返回标记的数组区域坐标[[左,上,右,下]] def img2rows(a,w,h): ### 根据投影切分图块 ### inLine = False # 是否已经开始切分 start = 0 # 某次切分的起始索引 mark_boxs = [] for i in range(0,len(a)): if inLine == False and a[i] > 10: inLine = True start = i # 记录这次选中的区域[左,上,右,下],上下就是图片,左右是start到当前 elif i-start >5 and a[i] < 10 and inLine: inLine = False if i-start > 10: top = max(start-1, 0) bottom = min(h, i+1) box = [0, top, w, bottom] mark_boxs.append(box) return mark_boxs 通过投影,计算哪些区域在一定范围内是连续的,如果连续了很长时间,我们就认为是同一区域,如果断开了很长一段时间,我们就认为是另一个区域。通过这项操作,我们就可以获得Y轴上某一行的上下两个边界点的坐标,再结合图片宽度,其实我们也就知道了一行图片的四个顶点的坐标了mark_boxs存下的是[坐,上,右,下]。如果调用如下代码:(img_h,img_w)=img.shape row_mark_boxs = img2rows(img_y_shadow_a,img_w,img_h) print(row_mark_boxs) 我们获取到的是所有识别出来每行图片的坐标,格式是这样的:[[0, 26, 596, 52], [0, 76, 596, 103], [0, 130, 596, 155], [0, 178, 596, 207], [0, 233, 596, 259], [0, 282, 596, 311], [0, 335, 596, 363], [0, 390, 596, 415]]2.4.3 根据区域切图片最有效的方法,最终也得用循环来实现。这也是计算机体现它强大的地方。# 裁剪图片,img 图片数组, mark_boxs 区域标记 def cut_img(img, mark_boxs): img_items = [] # 存放裁剪好的图片 for i in range(0,len(mark_boxs)): img_org = img.copy() box = mark_boxs[i] # 裁剪图片 img_item = img_org[box[1]:box[3], box[0]:box[2]] img_items.append(img_item) return img_items 这一步骤是拿着方框,从大图上用小刀划下小图,核心代码是img_org[box[1]:box[3], box[0]:box[2]]图片裁剪,参数是数组的[上:下,左:右],获取的数据还是二维的数组。如果保存下来:# 保存图片 def save_imgs(dir_name, imgs): if os.path.exists(dir_name): shutil.rmtree(dir_name) if not os.path.exists(dir_name): os.makedirs(dir_name) img_paths = [] for i in range(0,len(imgs)): file_path = dir_name+'/part_'+str(i)+'.jpg' cv2.imwrite(file_path,imgs[i]) img_paths.append(file_path) return img_paths # 切图并保存 row_imgs = cut_img(img, row_mark_boxs) imgs = save_imgs('rows', row_imgs) # 如果要保存切图 print(imgs) 图片是下面这样的:2.4.4 循环可去油腻还是循环。 横着行我们掌握了,那么针对每一行图片,我们竖着切成三块是不是也会了,一个道理。横着的时候,字与字之间本来就是有空隙的,然后块与块也有空隙,这个空隙的度需要掌握好,以便更好地区分出来是字的间距还是算式块的间距。幸好,有种方法叫膨胀。膨胀对人来说不积极,但是对于技术来说,不管是膨胀(dilate),还是腐蚀(erode),只要能达到目的,都是好的。kernel=np.ones((3,3),np.uint8) # 膨胀核大小 row_img_b=cv2.dilate(img_b,kernel,iterations=6) # 图像膨胀6次 膨胀之后再投影,就很好地区分出了块。根据投影裁剪之后如下图所示:同理,不膨胀可截取单个字符。这样,这是一块区域的字符。一行的,一页的,通过循环,都可以截取出来。有了图片,就可以识别了。有了位置,就可以判断识别结果的关系了。下面提供一些代码,这些代码不全,有些函数你可能找不到,但是思路可以参考,详细的代码可以去我的github去看。def divImg(img_path, save_file = False): img_o=cv2.imread(img_path,1) # 读入图片 img=cv2.imread(img_path,0) (img_h,img_w)=img.shape thresh = 200 # 二值化整个图,用于分行 ret,img_b=cv2.threshold(img,thresh,255,cv2.THRESH_BINARY_INV) # 计算投影,并截取整个图片的行 img_y_shadow_a = img_y_shadow(img_b) row_mark_boxs = img2rows(img_y_shadow_a,img_w,img_h) # 切行的图片,切的是原图 row_imgs = cut_img(img, row_mark_boxs) all_mark_boxs = [] all_char_imgs = [] # ===============从行切块====================== for i in range(0,len(row_imgs)): row_img = row_imgs[i] (row_img_h,row_img_w)=row_img.shape # 二值化一行的图,用于切块 ret,row_img_b=cv2.threshold(row_img,thresh,255,cv2.THRESH_BINARY_INV) kernel=np.ones((3,3),np.uint8) #图像膨胀6次 row_img_b_d=cv2.dilate(row_img_b,kernel,iterations=6) img_x_shadow_a = img_x_shadow(row_img_b_d) block_mark_boxs = row2blocks(img_x_shadow_a, row_img_w, row_img_h) row_char_boxs = [] row_char_imgs = [] # 切块的图,切的是原图 block_imgs = cut_img(row_img, block_mark_boxs) if save_file: b_imgs = save_imgs('cuts/row_'+str(i), block_imgs) # 如果要保存切图 print(b_imgs) # =============从块切字==================== for j in range(0,len(block_imgs)): block_img = block_imgs[j] (block_img_h,block_img_w)=block_img.shape # 二值化块,因为要切字符图片了 ret,block_img_b=cv2.threshold(block_img,thresh,255,cv2.THRESH_BINARY_INV) block_img_x_shadow_a = img_x_shadow(block_img_b) row_top = row_mark_boxs[i][1] block_left = block_mark_boxs[j][0] char_mark_boxs,abs_char_mark_boxs = block2chars(block_img_x_shadow_a, block_img_w, block_img_h,row_top,block_left) row_char_boxs.append(abs_char_mark_boxs) # 切的是二值化的图 char_imgs = cut_img(block_img_b, char_mark_boxs, True) row_char_imgs.append(char_imgs) if save_file: c_imgs = save_imgs('cuts/row_'+str(i)+'/blocks_'+str(j), char_imgs) # 如果要保存切图 print(c_imgs) all_mark_boxs.append(row_char_boxs) all_char_imgs.append(row_char_imgs) return all_mark_boxs,all_char_imgs,img_o 最后返回的值是3个,all_mark_boxs是标记的字符位置的坐标集合。[左,上,右,下]是指某个字符在一张大图里的坐标,打印一下是这样的:[[[[19, 26, 34, 53], [36, 26, 53, 53], [54, 26, 65, 53], [66, 26, 82, 53], [84, 26, 101, 53], [102, 26, 120, 53], [120, 26, 139, 53]], [[213, 26, 229, 53], [231, 26, 248, 53], [249, 26, 268, 53], [268, 26, 285, 53]], [[408, 26, 426, 53], [427, 26, 437, 53], [438, 26, 456, 53], [456, 26, 474, 53], [475, 26, 492, 53]]], [[[20, 76, 36, 102], [38, 76, 48, 102], [50, 76, 66, 102], [67, 76, 85, 102], [85, 76, 104, 102]], [[214, 76, 233, 102], [233, 76, 250, 102], [252, 76, 268, 102], [270, 76, 287, 102]], [[411, 76, 426, 102], [428, 76, 445, 102], [446, 76, 457, 102], [458, 76, 474, 102], [476, 76, 493, 102], [495, 76, 511, 102]]]]它是有结构的。它的结构是:all_char_imgs这个返回值,里面是上面坐标结构对应位置的图片。img_o就是原图了。2.5 识别循环,循环,还是TM循环!对于识别,2.3 预测数据已经讲过了,那次是对于2张独立图片的识别,现在我们要对整张大图切分后的小图集合进行识别,这就又用到了循环。翠花,上代码!all_mark_boxs,all_char_imgs,img_o = divImg(path,save) model = cnn.create_model() model.load_weights('checkpoint/char_checkpoint') class_name = np.load('class_name.npy') #遍历行 for i in range(0,len(all_char_imgs)): row_imgs = all_char_imgs[i] # 遍历块 for j in range(0,len(row_imgs)): block_imgs = row_imgs[j] block_imgs = np.array(block_imgs) results = cnn.predict(model, block_imgs, class_name) print('recognize result:',results) 上面代码做的就是以块为单位,传递给神经网络进行预测,然后返回识别结果。针对这张图,我们来进行裁剪和识别。看底部的最后一行recognize result: ['1', '0', '12', '2', '10'] recognize result: ['8', '12', '6', '10'] recognize result: ['1', '0', '12', '7', '10'] 结果是索引,不是真实的字符,我们根据字典10: '=', 11: '+', 12: '-', 13: '×', 14: '÷'转换过来之后结果是:recognize result: ['1', '0', '-', '2', '='] recognize result: ['8', '-', '6', '='] recognize result: ['1', '0', '-', '7', '='] 和图片是对应的:2.6 计算并反馈循环……我们获取到了10-2=、8-6=2,也获取到了他们在原图的位置坐标[左,上,右,下],那么怎么把结果反馈到原图上呢?往往到这里就剩最后一步了。再来温习一遍需求:作对了,能打对号;做错了,能打叉号;没做的,能补上答案。实现分两步走:计算(是作对做错还是没错)和反馈(把预期结果写到原图上)。2.6.1 计算python有个函数很强大,就是eval函数,能计算字符串算式,比如直接计算eval("5+3-2")。所以,一切都靠它了。# 计算数值并返回结果 参数chars:['8', '-', '6', '='] def calculation(chars): cstr = ''.join(chars) result = '' if("=" in cstr): # 有等号 str_arr = cstr.split('=') c_str = str_arr[0] r_str = str_arr[1] c_str = c_str.replace("×","*") c_str = c_str.replace("÷","/") try: c_r = int(eval(c_str)) except Exception as e: print("Exception",e) if r_str == "": result = c_r else: if str(c_r) == str(r_str): result = "√" else: result = "×" return result 执行之后获得的结果是:recognize result: ['8', '×', '4', '='] calculate result: 32 recognize result: ['2', '-', '1', '=', '1'] calculate result: √ recognize result: ['1', '0', '-', '5', '='] calculate result: 5 2.6.2 反馈有了结果之后,把结果写到图片上,这是最后一步,也是最简单的一步。但是实现起来,居然很繁琐。得找坐标吧,得计算结果呈现的位置吧,我们还想标记不同的颜色,比如对了是绿色,错了是红色,补齐答案是灰色。下面代码是在一个图img上,把文本内容text画到(left,top)位置,以特定颜色和大小。# 绘制文本 def cv2ImgAddText(img, text, left, top, textColor=(255, 0, 0), textSize=20): if (isinstance(img, np.ndarray)): # 判断是否OpenCV图片类型 img = Image.fromarray(cv2.cvtColor(img, cv2.COLOR_BGR2RGB)) # 创建一个可以在给定图像上绘图的对象 draw = ImageDraw.Draw(img) # 字体的格式 fontStyle = ImageFont.truetype("fonts/fangzheng_shusong.ttf", textSize, encoding="utf-8") # 绘制文本 draw.text((left, top), text, textColor, font=fontStyle) # 转换回OpenCV格式 return cv2.cvtColor(np.asarray(img), cv2.COLOR_RGB2BGR) 结合着切图的信息、计算的信息,下面代码提供思路参考:# 获取切图标注,切图图片,原图图图片 all_mark_boxs,all_char_imgs,img_o = divImg(path,save) # 恢复模型,用于图片识别 model = cnn.create_model() model.load_weights('checkpoint/char_checkpoint') class_name = np.load('class_name.npy') #遍历行 for i in range(0,len(all_char_imgs)): row_imgs = all_char_imgs[i] # 遍历块 for j in range(0,len(row_imgs)): block_imgs = row_imgs[j] block_imgs = np.array(block_imgs) # 图片识别 results = cnn.predict(model, block_imgs, class_name) print('recognize result:',results) # 计算结果 result = calculation(results) print('calculate result:',result) # 获取块的标注坐标 block_mark = all_mark_boxs[i][j] # 获取结果的坐标,写在块的最后一个字 answer_box = block_mark[-1] # 计算最后一个字的位置 x = answer_box[2] y = answer_box[3] iw = answer_box[2] - answer_box[0] ih = answer_box[3] - answer_box[1] # 计算字体大小 textSize = max(iw,ih) # 根据结果设置字体颜色 if str(result) == "√": color = (0, 255, 0) elif str(result) == "×": color = (255, 0, 0) else: color = (192, 192,192) # 将结果写到原图上 img_o = cv2ImgAddText(img_o, str(result), answer_box[2], answer_box[1],color, textSize) # 将写满结果的原图保存 cv2.imwrite('result.jpg', img_o) 结果是下面这样的: