这次趁周末把图像处理学习笔记的第二篇整理出来内容是形态学操作里最核心的四个概念核、腐蚀、膨胀以及由它们组合出的开运算和闭运算。上一篇笔记主要记录了图像读写、灰度转换和阈值分割这些基础操作这次就顺着往下走把二值图像预处理里天天要用的这套组合拳彻底讲清楚。先说结论腐蚀、膨胀、开运算、闭运算这套东西看起来只是让图形变大变小实际用起来远不止这么简单。去噪、提取边界、分离粘连目标、填补孔洞、连接断裂区域全都能干。我当时第一次在项目里做零部件计数图像二值化之后全是白点噪点和黑斑靠的就是开运算和闭运算把图像收拾干净才让后续的连通域分析稳定跑起来。这篇文章适合刚学完 OpenCV 基础、准备啃图像预处理的人也适合那些在项目里用了这几个函数但总觉得效果不稳定的朋友。1. 形态学操作到底是什么两个基础操作组合出的家族1.1 为什么要学形态学它解决的是图像预处理的底层问题形态学图像处理这个概念最早是从集合论和几何学里长出来的。在 OpenCV 里我们常用的腐蚀、膨胀、开运算、闭运算本质上是对二值图像做一种带规则的邻域扫描。很多初学者有一个误区觉得图像处理就是卷积所有操作都是核与像素做加权求和。形态学操作确实可以当成一种特殊的卷积来理解但它的决策逻辑不是加权求和而是基于“核覆盖区域内的像素值关系”做判断要么取最小值要么取最大值要么先取最小再取最大。这就是它和普通线性滤波最本质的区别。实际项目里形态学操作几乎出现在每一条图像处理流水线中。我举几个最常见的场景去除白色噪点阈值分割后的二值图背景上经常有细小的白色颗粒腐蚀一次或者做一次开运算基本能清干净。补内部孔洞物体内部因为光照不均或纹理干扰会出现黑色空洞闭运算就是干这个的。提取轮廓前的预处理找轮廓之前先用形态学把边缘磨平、把断裂处接上检测结果才会稳定。分割粘连物体显微镜细胞计数、零件计数这种场景相邻目标边缘会连在一起腐蚀几次可以把它们分开。如果你只是照着官方教程敲一遍代码看到的只是“图变大了”“图变小了”这种表面现象很难理解为什么项目里要这么配参数。我的体会是形态学操作不是独立的一步它永远是服务于“后面要做的事情”要么为了去噪要么为了恢复形状要么为了让轮廓更干净。脑子里带着这个目的去学接下来讲原理你就能对上号。1.2 核结构元素形态学操作的手术刀形态学操作里有个绕不开的概念叫结构元素OpenCV 里通常叫 kernel中文资料里也叫“核”。你可以把它想象成一把固定大小、固定形状的手术刀在图像上按顺序滑动每个位置做一次判断。OpenCV 提供了cv2.getStructuringElement()这个函数来创建核常用的有三种形状形状枚举值特点适用场景矩形核cv2.MORPH_RECT各向同性计算最快通用场景默认首选椭圆核cv2.MORPH_ELLIPSE边缘更圆滑圆形/弧形目标避免矩形棱角十字核cv2.MORPH_CROSS只沿水平和垂直方向扩展细化、骨架提取等方向性操作核尺寸一般是奇数比如(3, 3)、(5, 5)。为什么要奇数因为核的锚点默认在中心。奇数尺寸的核有正中心位置锚点落在核中心每次操作时锚点对应到图像上的那个像素就作为输出结果的位置。如果你用了偶数尺寸的核锚点会偏移到靠近左上角的位置整个图像处理结果会发生整体平移这是新手特别容易踩的坑。这里还有个隐藏参数容易被忽略锚点位置。cv2.erode()这些函数里有一个anchor参数默认是(-1, -1)表示锚点在核中心。绝大多数场景用默认值就行。我只有在做自定义结构元素、需要精确控制偏移方向时才会手动指定锚点平时不建议动。提示核尺寸不是越大越好。核越大参与判断的邻域范围越大处理效果越强但细节丢失也越严重。调参时建议从3×3起步效果不足再逐步增大不要一上来就上15×15的大核。2. 腐蚀与膨胀一收一放的两个基础操作2.1 腐蚀的原理白色区域被“吃掉”一圈腐蚀操作的规则一句话就能说清当核扫描到图像某个位置时只有当核覆盖范围内的所有像素都是白色时锚点所在的像素才保留白色只要覆盖范围内有一个像素是黑色锚点位置就变成黑色。你可以把腐蚀想象成往白色区域内部挖掉一圈。原本一个宽 10 像素的白色矩形条用3×3的矩形核做一次腐蚀四条边各被削掉 1 像素宽度变成 8 像素。如果是用5×5的核就会被削掉 2 像素宽度变成 6 像素。腐蚀在实际项目里最典型的用途有三个去掉孤立的小白点噪点噪点通常只有几个像素大小尺寸比核小腐蚀一次直接消失。分离粘连目标两个白色物体之间如果只有细窄的连接腐蚀可以把连接处断开。骨架化前置处理连续腐蚀可以让一个物体不断缩小配合其他操作可以求出物体的骨架结构。需要注意一个方向性问题腐蚀操作默认收缩的是白色区域。如果你二值图里的目标物体是黑色、背景是白色那腐蚀收缩的反而是背景目标反而“变大”了。所以每次做形态学操作之前一定要先确认你的前景是什么颜色。2.2 膨胀的原理白色区域向外“扩张”一圈膨胀和腐蚀正好相反当核扫描到某个位置时只要核覆盖范围内有一个像素是白色锚点位置就变成白色。这个规则意味着白色区域会向外扩展。同样是 10 像素宽的白色矩形条用3×3核做一次膨胀宽度会变成 12 像素。原来不相连的两条白色线段距离小于核尺寸时膨胀一次就可能连接成一条。膨胀的典型用途填补物体内部的小黑洞黑色空洞尺寸小于核的大小时会被白色“吞掉”。连接断裂的边缘边缘检测后的二值图经常有断点膨胀可以接上。融合离散碎片距离较近的碎片膨胀后可以连成一片方便后续分析。对比一下就能发现腐蚀和膨胀是一对互逆操作但这种“互逆”不是绝对的。一个正方形图像先腐蚀再膨胀最终结果不一定是原图。原因在于腐蚀过程中被删除的小细节比如孤立噪点已经不存在了膨胀没法把它们恢复过来。这个特性正是后面开运算、闭运算存在的意义。2.3 代码实操自己造一张带噪点的图来对比不搞理论空谈直接上代码。以下代码会生成一张带白色噪点和黑色孔洞的测试图然后分别做腐蚀和膨胀方便直观对比。import cv2 import numpy as np # 造一张 400x300 的黑色画布 canvas np.zeros((300, 400), dtypenp.uint8) # 画一个白色矩形作为目标 cv2.rectangle(canvas, (80, 100), (220, 200), 255, -1) # 画一个小圆作为另一个目标 cv2.circle(canvas, (300, 150), 25, 255, -1) # 用随机噪声模拟白色噪点和黑色孔洞 rng np.random.default_rng(42) white_noise (rng.random(canvas.shape) 0.97) * 255 black_noise (rng.random(canvas.shape) 0.03) * 255 test_img canvas.astype(np.uint8) # 叠加白色噪点 test_img cv2.bitwise_or(test_img, white_noise.astype(np.uint8)) # 挖掉黑色孔洞 test_img cv2.bitwise_and(test_img, (255 - black_noise).astype(np.uint8)) # 创建 3x3 矩形核 kernel cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3)) # 腐蚀/膨胀各做一次 eroded cv2.erode(test_img, kernel, iterations1) dilated cv2.dilate(test_img, kernel, iterations1) # 显示结果 cv2.imshow(original, test_img) cv2.imshow(eroded, eroded) cv2.imshow(dilated, dilated) cv2.waitKey(0) cv2.destroyAllWindows()运行之后你会看到腐蚀后的白色矩形和圆形明显小了一圈背景上的孤立白色噪点消失了一部分但目标内部的黑色孔洞反而变大了膨胀后的白色区域明显增大矩形和圆形有连成一片的趋势黑色孔洞变小甚至消失但背景上的白色噪点也变大了。这个对比能帮你建立直观认知腐蚀对“白点噪点”有清除作用但会放大“黑色孔洞”膨胀对“黑色孔洞”有填充作用但会放大“白点噪点”。所以说腐蚀和膨胀单独用效果都有点“偏科”组合起来才能同时解决两个问题。2.4 迭代次数为什么多处理一次差别很大OpenCV 的腐蚀和膨胀函数里都有一个iterations参数默认为 1。我遇到过不少朋友图省事直接加 iterations一上来就iterations10结果目标直接消失。这里要说明一个关键点多次腐蚀不是简单的线性叠加。第一次腐蚀后图像已经变了第二次腐蚀是在“上一轮结果”的基础上继续腐蚀。比如一个边长 15 像素的白色方块用3×3核腐蚀 1 次边长变 13腐蚀 2 次边长变 11。这个规律看起来有规律但到了第三、第四次如果方块小到一定程度一次腐蚀就直接消失了。迭代次数过大最常见的后果就是过度腐蚀目标区域被削得只剩一点甚至完全消失后续找不到轮廓。我的习惯是迭代次数先用 1配合核的大小一起调。如果噪点太多但目标很大我会优先增大核尺寸而不是疯狂加迭代次数。这两个参数效果类似但细节表现不同核变大影响的是“邻域判断范围”迭代次数影响的是“反复执行次数”多试几组对比就能找到手感。3. 开运算与闭运算两两组合的组合拳3.1 开运算先腐蚀后膨胀专治白色噪点开运算的定义是先腐蚀再膨胀两次操作使用同一个核。看到这里你可能会问既然腐蚀能去白点噪点膨胀能让目标恢复大小那组合起来不就能既去噪又保持尺寸吗没错这就是开运算的核心价值。举一个实际例子。假设你有一张二值图背景上有十几个孤立的小白点目标是一个大方块。直接腐蚀噪点清除了但方块也小了一圈。直接膨胀方块恢复了但噪点又变大了等于白干。而开运算的处理流程是先腐蚀——小白点直接消失大方块变小再膨胀——小方块的边缘往外扩回一圈恢复了接近原来的大小那些已经消失的小白点却回不来了。所以开运算的效果总结起来就是去除小的白色前景噪点、断开物体间细小的连接、平滑物体边缘同时尽可能保持大目标的形状和面积不变。在工业项目里开运算特别适合用在光照不均匀、背景有颗粒感的场景。二值化之后背景上全是小亮点先用开运算清理一遍连通域分析结果会干净很多。3.2 闭运算先膨胀后腐蚀专治黑色孔洞闭运算的定义是先膨胀再腐蚀同样使用同一个核。闭运算的处理流程先膨胀——目标内部的黑色孔洞被白色填上目标本身向外扩了一圈再腐蚀——目标边缘收回一圈接近原来的形状和大小但那些被填上的孔洞不会再出现。所以闭运算的效果是填充目标内部的黑色小孔洞、连接邻近的白色区域、平滑边缘同时尽可能保持目标的形状和面积不变。开运算和闭运算不是对立关系而是互补关系。开运算清理背景上的白点闭运算填平目标内部的黑洞。我在实际项目里经常两个连着用先开运算把外部噪点扫干净再闭运算把内部空洞补齐。这套组合下来二值图基本就非常干净了。3.3 代码实操morphologyEx 一行搞定OpenCV 提供了统一接口cv2.morphologyEx()用不同参数切换操作类型不需要手动先腐蚀再膨胀。import cv2 import numpy as np # 沿用上一节的 test_img canvas np.zeros((300, 400), dtypenp.uint8) cv2.rectangle(canvas, (80, 100), (220, 200), 255, -1) cv2.circle(canvas, (300, 150), 25, 255, -1) rng np.random.default_rng(42) white_noise (rng.random(canvas.shape) 0.97) * 255 black_noise (rng.random(canvas.shape) 0.03) * 255 test_img canvas.astype(np.uint8) test_img cv2.bitwise_or(test_img, white_noise.astype(np.uint8)) test_img cv2.bitwise_and(test_img, (255 - black_noise).astype(np.uint8)) kernel cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3)) opened cv2.morphologyEx(test_img, cv2.MORPH_OPEN, kernel) closed cv2.morphologyEx(test_img, cv2.MORPH_CLOSE, kernel) opened_then_closed cv2.morphologyEx(opened, cv2.MORPH_CLOSE, kernel) cv2.imshow(original, test_img) cv2.imshow(opened, opened) cv2.imshow(closed, closed) cv2.imshow(opened_then_closed, opened_then_closed) cv2.waitKey(0) cv2.destroyAllWindows()运行后仔细观察opened_then_closed这一张图它基本上去掉了背景的白点噪点同时填平了目标内部的黑色孔洞目标形状和原图非常接近。这就是形态学组合操作在日常项目中的真实使用方式。3.4 怎么记住开闭的区别不用背定义想一遍流程就记住很多教程总结的口诀是“开运算先去噪再恢复闭运算先填充再恢复”但光背口诀很容易搞混。我的记忆方法是自己推演一遍流程开运算字面意思里有“开”先开刀把多余的东西切掉也就是先腐蚀。腐蚀会清除小白点目标会缩小所以下一步膨胀把它扩回来。闭运算字面意思里有“闭”先闭合缺口也就是先膨胀。膨胀会填充小洞目标会变大所以下一步腐蚀把它缩回来。只要记住“先做的那一步决定了它能清除什么问题”后面的操作只是用来恢复尺寸就不会混淆了。还有一个从实际经验里总结的辅助记忆白点噪点属于“前景上的小凸起”需要通过先“收缩”来消除所以是开运算黑点洞属于“前景内部的小凹陷”需要通过先“扩张”来填补所以是闭运算。这种从形态变化去推导的方式比死记硬背更不容易出错。4. 实战案例用开闭运算完成带噪二值图的零件计数预处理4.1 需求描述与整体预处理流程假设现在要写一个零件计数的小程序输入一张零件图像二值化后背景有白色噪点零件内部有几个黑色斑块最终需要统计图像中有几个独立零件。如果不对二值图做任何处理直接找轮廓结果会非常难看背景噪点被当成小轮廓、零件内部的黑洞让轮廓变得残缺、边缘毛刺干扰边界判断。形态学操作在这里的定位就是“承上启下”在二值化和连通域分析之间把图像清理成一份接近完美的前景蒙版。我的处理流程如下读取图像并转为灰度图。可选高斯模糊抑制高频噪声。阈值分割得到二值图。开运算去除背景白色噪点。闭运算填补零件内部黑色孔洞。连通域分析或找轮廓统计零件数量。这个流程里第 4、5 步是形态学操作的典型用法缺一不可。4.2 从灰度图到干净二值图完整代码演示这里我用程序模拟了一张带噪点的零件图便于读者直接复现。你可以把其中“构造测试图像”的部分替换成cv2.imread()读取自己的实际图像。import cv2 import numpy as np # 构造测试图像 canvas np.zeros((300, 400), dtypenp.uint8) cv2.rectangle(canvas, (50, 60), (170, 200), 255, -1) cv2.circle(canvas, (250, 120), 35, 255, -1) cv2.rectangle(canvas, (200, 220), (340, 280), 255, -1) rng np.random.default_rng(7) white_noise (rng.random(canvas.shape) 0.96) * 255 black_noise (rng.random(canvas.shape) 0.04) * 255 test_img canvas.astype(np.uint8) test_img cv2.bitwise_or(test_img, white_noise.astype(np.uint8)) test_img cv2.bitwise_and(test_img, (255 - black_noise).astype(np.uint8)) # 正式处理 # 如果读取的是彩色图先灰度化 # gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 阈值分割目标为白背景为黑 _, binary cv2.threshold(test_img, 127, 255, cv2.THRESH_BINARY) # 形态学处理开运算去外部噪点闭运算补内部孔洞 kernel cv2.getStructuringElement(cv2.MORPH_RECT, (5, 5)) opened cv2.morphologyEx(binary, cv2.MORPH_OPEN, kernel) cleaned cv2.morphologyEx(opened, cv2.MORPH_CLOSE, kernel) # 连通域分析 num_labels, labels, stats, centroids cv2.connectedComponentsWithStats( cleaned, connectivity8 ) # 原始二值图中包含背景所以在查连通域时num_labels 包含背景区域 # 一般第 0 个是背景实际目标个数 num_labels - 1 print(检测到目标数量:, num_labels - 1) cv2.imshow(binary, binary) cv2.imshow(cleaned, cleaned) cv2.waitKey(0) cv2.destroyAllWindows()这段代码的核心思想很简单在binary里直接做连通域分析结果是完全不可信的在cleaned上做分析噪点没了、空洞填了每个独立的白色封闭区域就是一个零件数量统计自然就准确了。4.3 形态学参数怎么调核大小与迭代次数的经验法则每次写形态学代码核心调参就两个核大小和操作顺序/迭代次数。我总结了一套比较实用的调参节奏分享出来先固定迭代次数为 1从3×3核开始观察处理后噪声是否减少、目标是否有明显变形。去不掉噪点时优先增大核尺寸。比如从3×3换成5×5、7×7。不要急着加迭代次数大核处理一次比小核处理多轮更可控。核太大导致目标变形或消失时换形状。处理圆形颗粒时矩形核会把边缘弄出棱角换成椭圆核cv2.MORPH_ELLIPSE会更自然。先开运算还是先闭运算看噪声分布。如果背景噪点特别多先开运算如果目标内部孔洞特别多先闭运算。两者都要处理时我先开后闭这样能在最后一步保持目标边缘平滑。观察中间结果。不要直接跳到最终输出每一步都把中间结果弹出来看一眼。标注清楚哪张是开运算后、哪张是闭运算后问题出在哪一步一目了然。我在实际项目里犯过的最大错误就是为了追求“一次到位”把核调到15×15结果小尺寸零件直接被抹掉了。后来我给自己定了个规矩形态学操作尽量用最小能解决问题的核宁可多做一步组合操作也不要追求一个大核包打天下。5. 常见问题与避坑技巧5.1 九个新手经常踩的坑问题原因解决方案用了形态学但效果不明显核太小噪点尺寸大于核增大核尺寸或者增加迭代次数目标形状被严重破坏核太大缩小核或改用椭圆核白色目标整体消失迭代次数过多/核过大调小参数逐次看效果边缘出现奇怪棱角矩形核处理弧形目标改用cv2.MORPH_ELLIPSE印刷错误结果整体偏移核用了偶数尺寸统一用奇数尺寸核二值图方向反了前景颜色与预期不一致先np.unique()查看图像值确认前景颜色空洞和噪点同时变多单用腐蚀或单用膨胀改用开运算/闭运算组合处理有颜色图片时出现怪异颜色直接对彩色图做形态学操作先分离通道或转灰度再处理阈值选择不合理目标与背景灰度重叠先直方图分析再调整阈值或改用自适应阈值这里面第 4 点值得多说一句。很多人对椭圆核不敏感觉得矩形核也能用。但当你处理的是圆形零件、细胞这类目标时矩形核的锐利边缘会被“印”到处理结果上导致后续轮廓提取出一堆直角。这一点在工业视觉上很致命。所以我做圆形物体验收时一般直接用cv2.MORPH_ELLIPSE椭圆核计算量稍大但对形状的保护效果好很多。5.2 快速排障逻辑图像处理调试和写普通程序不一样报错信息少问题多数是“结果不符合预期”排查起来比较费劲。我一般按下面的顺序排查第一步打印图像的形状和唯一值。确保是单通道、值只有0和255。如果有中间灰度值说明二值化没有到位。第二步把二值图、开运算结果、闭运算结果分别展示出来确认问题出在哪个环节。第三步确认操作对象。前景是白还是黑开的闭的哪个影响最大第四步从3×3核、迭代 1 次开始逐步放大参数观察变化趋势。这套排障逻辑帮我省了很多时间。尤其是在现场处理实际问题时直接改代码不如先看图图像处理问题十有八九是“预期和现实不一致”看一眼中间结果比盯着代码猜要高效得多。提示如果你发现二值图里的目标内部孔洞特别多可以先做一次膨胀或者闭运算把孔洞填上再进行下一步如果你发现背景噪点特别多开运算或者腐蚀是最直接的解决办法。记住这个选择逻辑比背参数有用得多。最后再分享一个经验形态学操作里的cv2.morphologyEx函数不只支持开闭运算还有形态学梯度、礼帽、黑帽等更多玩法。我最早只用了开闭运算去处理噪声后来在边缘提取项目里试了形态学梯度发现它比某些边缘检测算子更稳定。这块内容我觉得值得单独写一篇笔记今天先把腐蚀、膨胀、开闭运算这四块基石讲透后面学更高阶的形态学操作就顺理成章了。