草庐IT

OpenCV中的图像处理 —— 霍夫线 / 圈变换 + 图像分割(分水岭算法) + 交互式前景提取(GrabCut算法)

香蕉道突破手牛爷爷 2023-04-03 原文

OpenCV中的图像处理 —— 霍夫线 / 圈变换 + 图像分割(分水岭算法) + 交互式前景提取(GrabCut算法)

🌎上一节我们介绍了OpenCV中傅里叶变换和模板匹配,这一部分我们来聊一聊霍夫线/圈变换的原理和应用、使用分水岭算法实现图像分割和使用GrabCut算法实现交互式前景提取

🏠哈喽大家好,这里是ErrorError!,一枚某高校大二本科在读的♂同学,希望未来在机器视觉领域能够有所成就,很荣幸能够在CSDN结识众多志同道合和在各方面都有所造诣的小伙伴,我们一起加油吧~💖

🚀上节内容:OpenCV中的图像处理 —— 傅里叶变换+模板匹配

目录🌻🌷

1. 霍夫线变换

1.1 HoughLines工作原理

经过上一节中”模板匹配”的了解,是不是发现我们有点儿目标检测的雏形了呢?这一部分说的霍夫线变换也是一个不断深入的关键点。如果可以如果可以用数学形式表示形状,则霍夫变换是一种检测任何形状的流行技术,即使形状有些破损或变形,也可以检测出形状,我们将看到它如何作用于一条线

🚀霍夫线眼中的线:通常一条线可以表示为y=mx+cy=mx+c或以参数形式表示为ρ=xcosθ+ysinθ,其中ρ是从原点到该线的垂直距离,而θ是由该垂直线和水平轴形成的角度以逆时针方向测量(该方向随我们如何表示坐标系而变化),因此,如果线在原点下方通过,则它将具有正的ρ且角度小于180,如果线在原点上方,则将角度取为小于180,而不是大于180的角度,ρ取负值,任何垂直线将具有0度,水平线将具有90度

🚀霍夫线怎么处理线:任何一条线都可以用(ρ,θ)这两个术语表示。因此,首先创建2D数组或累加器(以保存两个参数的值),并将其初始设置为0。让行表示ρ,列表示θ,阵列的大小取决于所需的精度。假设我们希望角度的精度为1度,则需要180列。对于ρ,最大距离可能是图像的对角线长度。因此,以一个像素精度为准,行数可以是图像的对角线长度

🚀放在实际图像中:假设有一个100*100的图像,中间有一条水平线。取直线的第一点,且我们知道它的坐标(x,y)值。现在在线性方程式中,将值θ= 0,1,2,… 180放进去,然后检查得到ρ。对于每对(ρ,θ),在累加器中对应的(ρ,θ)单元格将值增加1。

现在,对行的第二个点执行与上述相同的操作,递增(ρ,θ)对应的单元格中的值,这一次操作使单元格(50,90)=2。实际上,我们正在对(ρ,θ)值进行投票。我们对线路上的每个点都继续执行此过程。在每个点上,单元格(50,90)都会增加或投票,而其他单元格可能会或可能不会投票。这样一来,最后,单元格(50,90)的投票数将最高。因此,如果我们在累加器中搜索最大票数,则将获得(50,90)值,该值表示该图像中的一条线与原点的距离为50,角度为90度

1.2 OpenCV中的霍夫曼变换

OpenCV把上述所有的霍夫曼变换过程都封装在了函数cv.HoughLines()里,它返回的是一个math:(rho,theta)值的数组,ρ以像素为单位,θ以弧度为单位。这个函数包括4个参数,第一个即二进制原图,因此在使用霍夫曼变换之前我们会先使用阈值或Canny边缘检测,第二、第三个参数是ρ和θ的精度,第四个参数是阈值,它意味着行的最低投票,票数取决于线上的点数,因此这个阈值也表示检测到的最小线长

import cv2 as cv
import numpy as np

img = cv.imread(cv.samples.findFile(r'E:\image\test19.png'))
gray = cv.cvtColor(img, cv.COLOR_BGR2GRAY)
edges = cv.Canny(gray, 50, 150, apertureSize=3)
lines = cv.HoughLines(edges, 1, np.pi / 180, 100)
for line in lines:
    rho, theta = line[0]
    a = np.cos(theta)
    b = np.sin(theta)
    x0 = a * rho
    y0 = b * rho
    x1 = int(x0 + 1000 * (-b))
    y1 = int(y0 + 1000 * (a))
    x2 = int(x0 - 1000 * (-b))
    y2 = int(y0 - 1000 * (a))
    cv.line(img, (x1, y1), (x2, y2), (0, 0, 255), 2)
cv.imshow('houghlines.jpg', img)
cv.waitKey(0)

1.3 概率霍夫线变换

在霍夫线变换中即使对于带有两个参数的行,也需要大量计算,概率霍夫变换是我们看到的霍夫变换的优化,它没有考虑所有要点。取而代之的是,它仅采用随机的点子集,足以进行线检测,只是我们必须降低阈值

OpenCV的实现基于Matas,J.和Galambos,C.和Kittler, J.V.使用渐进概率霍夫变换对行进行的稳健检测[145]。使用的函数是cv.HoughLinesP()。它有两个新的属性:1. - minLineLength - 最小行长,小于此长度的线段将被拒绝;2. - maxLineGap - 线段之间允许将它们视为一条线的最大间隙

import cv2 as cv
import numpy as np

img = cv.imread(cv.samples.findFile(r'E:\image\test19.png'))
gray = cv.cvtColor(img, cv.COLOR_BGR2GRAY)
edges = cv.Canny(gray, 50, 150, apertureSize=3)
lines = cv.HoughLinesP(edges, 1, np.pi / 180, 100, minLineLength=100, maxLineGap=10)
for line in lines:
    x1, y1, x2, y2 = line[0]
    cv.line(img, (x1, y1), (x2, y2), (0, 255, 0), 2)
cv.imshow('houghlines.jpg', img)
cv.waitKey(0)

2. 霍夫圈变换

上面说完了霍夫线变换,现在挨到霍夫圈变换了,也就是说霍夫线变换面向的是图像中的线,而圈变换面向的就是圆咯

圆在数学上表示为(x−xcenter)^ 2+(y−ycenter)^2= r^2,其中(xcenter,ycenter)(xcenter,ycenter)是圆的中心,rr是圆的半径,从等式中,我们可以看到我们有3个参数,因此我们需要3D累加器进行霍夫变换,这将非常低效。因此,OpenCV使用更加技巧性的方法,即使用边缘的梯度信息的Hough梯度方法,我们在这里使用的函数是cv.HoughCircles()

这个函数参数有点儿多,我们有必要说说,它的原型是cv2.HoughCircles(image, method, dp, minDist, circles, param1, param2, minRadius, maxRadius),第一个参数为原图像(灰度图),第二个参数是检测方法,第三个参数为检测内侧圆心的累加器图像的分辨率于输入图像之比的倒数,如dp=1,累加器和输入图像具有相同的分辨率,如果dp=2,累计器便有输入图像一半那么大的宽度和高度,第四个参数表示两个圆之间圆心的最小距离

param1与param2有默认值100,它们是method设置的检测方法的对应的参数,对当前唯一的方法霍夫梯度法cv2.HOUGH_GRADIENT,param1表示传递给canny边缘检测算子的高阈值,而低阈值为高阈值的一半,param2表示在检测阶段圆心的累加器阈值,它越小,就越可以检测到更多根本不存在的圆,而它越大的话,能通过检测的圆就更加接近完美的圆形了

minRadius和maxRadius有默认值0,分别表示圆半径的最小值和最大值

import numpy as np
import cv2 as cv

img = cv.imread(r'E:\image\test20.png', 0)
img = cv.medianBlur(img, 5)
cimg = cv.cvtColor(img, cv.COLOR_GRAY2BGR)
circles = cv.HoughCircles(img, cv.HOUGH_GRADIENT, 1, 100, param1=100, param2=30, minRadius=100, maxRadius=200)
circles = np.uint16(np.around(circles))
for i in circles[0, :]:
    # 绘制外圆
    cv.circle(cimg, (i[0], i[1]), i[2], (0, 255, 0), 2)
    # 绘制圆心
    cv.circle(cimg, (i[0], i[1]), 2, (0, 0, 255), 3)
cv.imshow('detected circles', cimg)
cv.waitKey(0)

3. 图像分割与分水岭算法

3.1 分水岭算法

🚀算法思想:任何灰度图像都可以看作是一个地形表面,其中高强度表示山峰,低强度表示山谷,我们用不同颜色的**水(标签)**填充每个孤立的山谷(局部最小值)。随着水位的上升,根据附近的山峰(坡度),来自不同山谷的水明显会开始合并,颜色也不同。为了避免这种情况,我们要在水融合的地方建造屏障。继续填满水,建造障碍,直到所有的山峰都在水下。然后我们创建的屏障将返回你的分割结果

但是这种方法会由于图像中的噪声或其他不规则性而产生过度分割的结果。因此OpenCV实现了一个基于标记的分水岭算法,我们可以指定哪些是要合并的山谷点,哪些不是。这是一个交互式的图像分割。我们所做的是给我们知道的对象赋予不同的标签。用一种颜色(或强度)标记我们确定为前景或对象的区域,用另一种颜色标记我们确定为背景或非对象的区域,最后用0标记我们不确定的区域。这是我们的标记。然后应用分水岭算法。然后我们的标记将使用我们给出的标签进行更新,对象的边界值将为-1

3.2 图像分割的实现

有一张布满硬币的白纸,部分硬币之间相互接触,我们将这张图作为源图像,我们先从寻找硬币的近似估计开始,因此我们要使用阈值化(Otsu的二值化)

import numpy as np
import cv2 as cv
from matplotlib import pyplot as plt
img = cv.imread(r'E:\image\test21.png')
gray = cv.cvtColor(img,cv.COLOR_BGR2GRAY)
ret, thresh = cv.threshold(gray,0,255,cv.THRESH_BINARY_INV+cv.THRESH_OTSU)

然后由于分水岭算法对噪声非常敏感,所有我们要去除图像中的所有白点噪声,为此我们可以使用形态学扩张,如果要去除硬币对象中的小孔,我们可以使用形态学侵蚀,在进行完这些操作后,我们可以十分确信靠近对象中心的区域是前景,离对象中心很远的就是背景,现在我们唯一不确定的就是硬币的边界区域

接下来我们需要提取我们可确认为硬币的区域(因为侵蚀会去除边界像素),如果硬币之间不接触那么我们之前的操作完全没问题,但是事实是他们接触了,因此我们更好的选择是找到距离变换并应用适当的阈值。此时我们需要确定一定不是硬币的区域,形态学扩张可以满足我们的需求

剩下的区域是我们不知道的区域,无论是硬币还是背景。分水岭算法应该找到它。这些区域通常位于前景和背景相遇(甚至两个不同的硬币相遇)的硬币边界附近。可以通过从sure_bg区域中减去sure_fg区域来获得

# 噪声去除
kernel = np.ones((3,3),np.uint8)
opening = cv.morphologyEx(thresh,cv.MORPH_OPEN,kernel, iterations = 2)
# 确定背景区域
sure_bg = cv.dilate(opening,kernel,iterations=3)
# 寻找前景区域
dist_transform = cv.distanceTransform(opening,cv.DIST_L2,5)
ret, sure_fg = cv.threshold(dist_transform,0.7*dist_transform.max(),255,0)
# 找到未知区域
sure_fg = np.uint8(sure_fg)
unknown = cv.subtract(sure_bg,sure_fg)

✏️代码解析:第3行的cv.morphologyEx()函数是高级形态学转换函数,其功能取决于第二个参数的选取,具体内容请移步 OpenCV-Python——第13章:图像的形态学操作(腐蚀,膨胀,开运算,闭运算…)

第5行cv.dilate()函数即形态学膨胀功能函数

现在我们已经得到了硬币区域并且将它们分割了,在某些情况下,我们只对前景分割感兴趣,而对是否接触或分离接触并不感兴趣,所以这个时候我们不用使用距离变换,只需要侵蚀就可以满足我们的需求了(侵蚀是一种提取确定前景区域的重要方法)

现在我们可以创建标记了,使用cv.connectedComponents()就很不错,它用0标记图像的背景,然后其他对象用从1开始的整数标记,标记完成后使用分水岭方法cv.watershed()完成图像分割

# 类别标记
ret, markers = cv.connectedComponents(sure_fg)
# 为所有的标记加1,保证背景是0而不是1
markers = markers+1
# 现在让所有的未知区域为0
markers[unknown==255] = 0
markers = cv.watershed(img,markers) 
img[markers == -1] = [255,0,0]

标记完成后使用分水岭方法cv.watershed()完成图像分割

plt.subplot(241), plt.imshow(cv2.cvtColor(src, cv2.COLOR_BGR2RGB)),
plt.title('Original'), plt.axis('off')
plt.subplot(242), plt.imshow(thresh, cmap='gray'),
plt.title('Threshold'), plt.axis('off')
plt.subplot(243), plt.imshow(sure_bg, cmap='gray'),
plt.title('Dilate'), plt.axis('off')
plt.subplot(244), plt.imshow(dist_transform, cmap='gray'),
plt.title('Dist Transform'), plt.axis('off')
plt.subplot(245), plt.imshow(sure_fg, cmap='gray'),
plt.title('Threshold'), plt.axis('off')
plt.subplot(246), plt.imshow(unknown, cmap='gray'),
plt.title('Unknow'), plt.axis('off')
plt.subplot(247), plt.imshow(np.abs(markers), cmap='jet'),
plt.title('Markers'), plt.axis('off')
plt.subplot(248), plt.imshow(cv2.cvtColor(img, cv2.COLOR_BGR2RGB)),
plt.title('Result'), plt.axis('off')
plt.show()

代码资源参考自:OpenCV-Python——第22章:分水岭算法实现图像分割

4. 使用GrabCut算法实现交互式前景提取

4.1 GrabCut算法

🚀起源:GrabCut算法由英国微软研究院的Carsten Rother,Vladimir Kolmogorov和Andrew Blake设计,在他们的论文“GrabCut”中:使用迭代图割的交互式前景提取

🚀算法步骤最初用户在前景区域周围绘制一个矩形(前景区域应完全位于矩形内部),然后算法会对其进行迭代分割,以获得最佳结果。做完了但在某些情况下,分割可能不会很好,例如,可能已将某些前景区域标记为背景,反之亦然。在这种情况下,需要用户进行精修。只需在图像错误分割区域上画些笔画,笔画会对算法说: “嘿,该区域应该是前景,你将其标记为背景,在下一次迭代中对其进行校正”或与背景相反,然后在下一次迭代中,我们将获得更好的结果

🚀算法原理:用户输入矩形后,此矩形外部的所有内容都将作为背景(这是在矩形应包含所有对象之前提到的原因),而矩形内的所有内容都是未知的。任何指定前景和背景的用户输入都被视为硬标签,这意味着它们在此过程中不会更改。计算机根据我们提供的数据进行初始标记,它标记前景和背景像素(或对其进行硬标记)

  • 现在使用**高斯混合模型(GMM)**对前景和背景进行建模。 根据我们提供的数据,GMM可以学习并创建新的像素分布。也就是说,未知像素根据颜色统计上与其他硬标记像素的关系而被标记为可能的前景或可能的背景(就像聚类一样)
  • 根据此像素分布构建图形,图中的节点为像素。添加了另外两个节点,即“源”节点和“接收器”节点。每个前景像素都连接到源节点,每个背景像素都连接到接收器节点
  • 通过像素是前景/背景的概率来定义将像素连接到源节点/末端节点的边缘的权重。像素之间的权重由边缘信息或像素相似度定义。如果像素颜色差异很大,则它们之间的边缘将变低
  • 然后使用mincut算法对图进行分割。它将图切成具有最小成本函数的两个分离的源节点和宿节点。成本函数是被切割边缘的所有权重的总和。剪切后,连接到“源”节点的所有像素都变为前景,而连接到“接收器”节点的像素都变为背景
  • 继续该过程,直到分类收敛为止

4.2 使用OpenCV进行GrabCut算法

OpenCV提供了函数cv.grabCut(),这个函数的参数同样有些多,我们接着摊开聊聊,其中包括7个参数,第一个参数**- img -即源图像,第二个参数- mask -是掩码图像,在其中我们会指定哪些区域为背景,第三个参数- rect -是它的矩形坐标,格式为(x, y, w, h),其中包括前景对象,第四第五个参数 - bdgModel, fgdModel - 是算法内部使用的数组,我们只需要创建两个大小为(1,65)的np.float64类型零数组,第六个参数- iterCount -是算法应运行的迭代次数,第七个参数- model -**应该是cv.GC_INIT_WITH_RECT或cv.GC_INIT_WITH_MASK或两者结合,决定我们要绘制矩形还是最终的修饰笔触,废话不多说上实例

import numpy as np
import cv2 as cv
from matplotlib import pyplot as plt

img = cv.imread(r'E:\image\test22.png')
mask = np.zeros(img.shape[:2], np.uint8)
bgdModel = np.zeros((1, 65), np.float64)
fgdModel = np.zeros((1, 65), np.float64)
rect = (50, 50, 450, 290)
cv.grabCut(img, mask, rect, bgdModel, fgdModel, 5, cv.GC_INIT_WITH_RECT)
mask2 = np.where((mask == 2) | (mask == 0), 0, 1).astype('uint8')
img = img * mask2[:, :, np.newaxis]
plt.imshow(img), plt.colorbar(), plt.show()

有时候我们分割手部骨骼,发现少了一根手指头,而且个别手指还没显示完全,我们这时就需要用画笔精修了,在paint应用程序中打开输入图像,并在图像中添加了另一层,使用画笔中的画笔工具,在新图层上用白色标记了错过的前景,而用白色标记了不需要的背景(例如logo,地面等),然后用灰色填充剩余的背景,然后将该mask图像加载到OpenCV中,编辑我们在新添加的mask图像中具有相应值的原始mask图像

# newmask是我手动标记过的mask图像
newmask = cv.imread('newmask.png',0)
# 标记为白色(确保前景)的地方,更改mask = 1
# 标记为黑色(确保背景)的地方,更改mask = 0
mask[newmask == 0] = 0
mask[newmask == 255] = 1
mask, bgdModel, fgdModel = cv.grabCut(img,mask,None,bgdModel,fgdModel,5,cv.GC_INIT_WITH_MASK)
mask = np.where((mask==2)|(mask==0),0,1).astype('uint8')
img = img*mask[:,:,np.newaxis]
plt.imshow(img),plt.colorbar(),plt.show()

(注:文章内容参考OpenCV4.1中文官方文档)
如果文章对您有所帮助,记得一键三连支持一下哦👍+⭐️+📝

有关OpenCV中的图像处理 —— 霍夫线 / 圈变换 + 图像分割(分水岭算法) + 交互式前景提取(GrabCut算法)的更多相关文章

  1. ruby - 如何从 ruby​​ 中的字符串运行任意对象方法? - 2

    总的来说,我对ruby​​还比较陌生,我正在为我正在创建的对象编写一些rspec测试用例。许多测试用例都非常基础,我只是想确保正确填充和返回值。我想知道是否有办法使用循环结构来执行此操作。不必为我要测试的每个方法都设置一个assertEquals。例如:describeitem,"TestingtheItem"doit"willhaveanullvaluetostart"doitem=Item.new#HereIcoulddotheitem.name.shouldbe_nil#thenIcoulddoitem.category.shouldbe_nilendend但我想要一些方法来使用

  2. ruby - 其他文件中的 Rake 任务 - 2

    我试图在一个项目中使用rake,如果我把所有东西都放到Rakefile中,它会很大并且很难读取/找到东西,所以我试着将每个命名空间放在lib/rake中它自己的文件中,我添加了这个到我的rake文件的顶部:Dir['#{File.dirname(__FILE__)}/lib/rake/*.rake'].map{|f|requiref}它加载文件没问题,但没有任务。我现在只有一个.rake文件作为测试,名为“servers.rake”,它看起来像这样:namespace:serverdotask:testdoputs"test"endend所以当我运行rakeserver:testid时

  3. ruby-on-rails - Ruby net/ldap 模块中的内存泄漏 - 2

    作为我的Rails应用程序的一部分,我编写了一个小导入程序,它从我们的LDAP系统中吸取数据并将其塞入一个用户表中。不幸的是,与LDAP相关的代码在遍历我们的32K用户时泄漏了大量内存,我一直无法弄清楚如何解决这个问题。这个问题似乎在某种程度上与LDAP库有关,因为当我删除对LDAP内容的调用时,内存使用情况会很好地稳定下来。此外,不断增加的对象是Net::BER::BerIdentifiedString和Net::BER::BerIdentifiedArray,它们都是LDAP库的一部分。当我运行导入时,内存使用量最终达到超过1GB的峰值。如果问题存在,我需要找到一些方法来更正我的代

  4. ruby-on-rails - Rails 3 中的多个路由文件 - 2

    Rails2.3可以选择随时使用RouteSet#add_configuration_file添加更多路由。是否可以在Rails3项目中做同样的事情? 最佳答案 在config/application.rb中:config.paths.config.routes在Rails3.2(也可能是Rails3.1)中,使用:config.paths["config/routes"] 关于ruby-on-rails-Rails3中的多个路由文件,我们在StackOverflow上找到一个类似的问题

  5. ruby - 如何指定 Rack 处理程序 - 2

    Rackup通过Rack的默认处理程序成功运行任何Rack应用程序。例如:classRackAppdefcall(environment)['200',{'Content-Type'=>'text/html'},["Helloworld"]]endendrunRackApp.new但是当最后一行更改为使用Rack的内置CGI处理程序时,rackup给出“NoMethodErrorat/undefinedmethod`call'fornil:NilClass”:Rack::Handler::CGI.runRackApp.newRack的其他内置处理程序也提出了同样的反对意见。例如Rack

  6. ruby-on-rails - Rails - 一个 View 中的多个模型 - 2

    我需要从一个View访问多个模型。以前,我的links_controller仅用于提供以不同方式排序的链接资源。现在我想包括一个部分(我假设)显示按分数排序的顶级用户(@users=User.all.sort_by(&:score))我知道我可以将此代码插入每个链接操作并从View访问它,但这似乎不是“ruby方式”,我将需要在不久的将来访问更多模型。这可能会变得很脏,是否有针对这种情况的任何技术?注意事项:我认为我的应用程序正朝着单一格式和动态页面内容的方向发展,本质上是一个典型的网络应用程序。我知道before_filter但考虑到我希望应用程序进入的方向,这似乎很麻烦。最终从任何

  7. ruby-on-rails - Rails 3.2.1 中 ActionMailer 中的未定义方法 'default_content_type=' - 2

    我在我的项目中添加了一个系统来重置用户密码并通过电子邮件将密码发送给他,以防他忘记密码。昨天它运行良好(当我实现它时)。当我今天尝试启动服务器时,出现以下错误。=>BootingWEBrick=>Rails3.2.1applicationstartingindevelopmentonhttp://0.0.0.0:3000=>Callwith-dtodetach=>Ctrl-CtoshutdownserverExiting/Users/vinayshenoy/.rvm/gems/ruby-1.9.3-p0/gems/actionmailer-3.2.1/lib/action_mailer

  8. ruby-on-rails - Rails 应用程序中的 Rails : How are you using application_controller. rb 是新手吗? - 2

    刚入门rails,开始慢慢理解。有人可以解释或给我一些关于在application_controller中编码的好处或时间和原因的想法吗?有哪些用例。您如何为Rails应用程序使用应用程序Controller?我不想在那里放太多代码,因为据我了解,每个请求都会调用此Controller。这是真的? 最佳答案 ApplicationController实际上是您应用程序中的每个其他Controller都将从中继承的类(尽管这不是强制性的)。我同意不要用太多代码弄乱它并保持干净整洁的态度,尽管在某些情况下ApplicationContr

  9. ruby-on-rails - form_for 中不在模型中的自定义字段 - 2

    我想向我的Controller传递一个参数,它是一个简单的复选框,但我不知道如何在模型的form_for中引入它,这是我的观点:{:id=>'go_finance'}do|f|%>Transferirde:para:Entrada:"input",:placeholder=>"Quantofoiganho?"%>Saída:"output",:placeholder=>"Quantofoigasto?"%>Nota:我想做一个额外的复选框,但我该怎么做,模型中没有一个对象,而是一个要检查的对象,以便在Controller中创建一个ifelse,如果没有检查,请帮助我,非常感谢,谢谢

  10. ruby - rspec 需要 .rspec 文件中的 spec_helper - 2

    我注意到像bundler这样的项目在每个specfile中执行requirespec_helper我还注意到rspec使用选项--require,它允许您在引导rspec时要求一个文件。您还可以将其添加到.rspec文件中,因此只要您运行不带参数的rspec就会添加它。使用上述方法有什么缺点可以解释为什么像bundler这样的项目选择在每个规范文件中都需要spec_helper吗? 最佳答案 我不在Bundler上工作,所以我不能直接谈论他们的做法。并非所有项目都checkin.rspec文件。原因是这个文件,通常按照当前的惯例,只

随机推荐