简介:面向计算机视觉初学者的入门项目合集,整合了图像分割与图像增强两大方向中多种经典算法的Python源码复现,并配有详细代码注释,适合正在学习OpenCV与基础图像处理的读者对照实践。资源共28个文件,以17个Python脚本为核心,另含若干jpg测试图、png基准图、Markdown说明文档以及一份PDF实验报告,压缩包整体2.46MB,目录按“图像分割”“图像增强”分模块组织,便于按需查阅。其中图像分割部分覆盖迭代阈值分割、最大类间方差法(OTSU)、基于最大熵的阈值分割及马尔可夫遍历等方法;图像增强部分则包含单尺度SSR、多尺度MSR、直方图均衡、自适应直方图均衡(AHE/CLAHE)等实现,代码自带注释并附对比说明,方便理解算法原理与参数调优。已有584人学习下载,对于希望快速上手经典视觉算法原理与源码实现的读者来说,是一份轻量实用的参考资料。
1. 计算机视觉入门项目里的源码复现,到底在复现什么
打开一个名为“计算机视觉入门项目-包含图像分割-图像增强等图像处理算法的python源码复现.zip”的压缩包,第一反应往往是:这里面的代码跑通了,算不算入门了?我的看法是——跑通不算,能复现才算。这个标题真正值钱的部分不是“项目”,也不是“zip”,而是“源码复现”这四个字。图像分割和图像增强是计算机视觉入门阶段最好的两个练手域:前者让你接触像素级理解,后者让你理解图像退化与恢复的建模思路。新手需要的是一个能一步步跟下来的代码路线,从业者需要的是在看论文实现和迁移到自己的数据前,先拿传统算法打底。这份笔记按业界的落地动作来拆:先看包结构,再逐块复现增强和分割算法,最后把脚本整理成可复用的工具。
2. 先看懂压缩包里的工程结构:环境、目录与最小跑通
2.1 一个典型的CV入门源码包里有哪几类文件
这类入门源码包的结构通常不是拍脑袋定的,多数沿用了计算机视觉课程大作业和GitHub开源小项目的惯例布局。打开压缩包后,我一般先找下面几样东西,而不是急着运行:
data/或images/:存放测试图和样本数据集。很多包会附带lena、cameraman这类经典图像,低照度增强类的包还会放几张国网、夜间拍摄的实景图。src/或algorithms/:按算法拆分模块。图像增强模块下常见histogram.py、retinex.py、wavelet.py;图像分割模块下常见otsu.py、watershed.py、unet.py。utils/:图像读写、显示、指标计算、路径处理这些公共逻辑被抽在这里。这是源码复现包里最容易被忽略却最值得读的部分。scripts/或demo/:可执行入口,一般是从命令行直接跑的demo_enhancement.py、demo_segmentation.py。requirements.txt:依赖清单。这个文件往往比代码本身还能看出包的质量——如果连 requirements 都没有,那大概率是从某篇博客里东拼西凑的。
先说清一个常见误用:很多人下完包第一件事是把src/里的代码文件挨个点开,对着没有if __name__ == "__main__"的模块直接按运行。这是新手常见的翻车现场——模块文件设计时就是被 import 的,没有一个可执行入口时,直接运行只会看到“Process finished with exit code 0”,什么都没有发生。正确做法是先找入口脚本,再反推模块依赖关系。
2.2 Python环境准备:venv、pip与三方库版本
这部分看似基础,反而是源码复现项目里翻车率最高的一环。常见的错误是直接在系统Python里一股脑pip install,装到一半遇到 opencv-python 编译冲突,或者 scikit-image 与 numpy 版本不兼容,最后把系统环境搞得一团糟。我一般会先建一个独立的虚拟环境,再做安装:
python -m venv .venv # Windows PowerShell 用 .venv\Scripts\Activate.ps1,Linux/macOS 用 source .venv/bin/activate source .venv/bin/activate pip install --upgrade pip setuptools wheel pip install numpy opencv-python scikit-image matplotlib scipy对于包含深度学习分割算法的包,还需要补 tensorflow 或 pytorch。这块建议先看代码里 import 的是什么再做决定——如果是from tensorflow.keras.layers import Conv2D,装 tensorflow;如果是import torch,装 pytorch。装完之后跑一次环境自检:
python -c "import numpy, cv2, skimage, scipy; print(numpy.__version__, cv2.__version__, skimage.__version__, scipy.__version__)"这段命令的逻辑是验证四个核心库能否同时被导入。很多初学者会在环境里一次次重装,却没想到先确认“是否已有不可见的版本冲突”。参数说明上,我建议 numpy 用 1.23.5 左右、opencv-python 用 4.8 以上、scikit-image 用 0.21 以上,这三者在大多数源码复现项目里能和平共处。如果项目的 requirements.txt 里钉死了版本,则以它为准——源码复现的意义在于复现作者跑出的结果,而不是随手升级库导致结果对不上。
2.3 用一张测试图跑通全部算法的检查清单
环境配完后,我会建议用一张 512×512 左右的彩色测试图跑一次全流程,验证包的完整性。具体操作分三步:第一步,把测试图放到data/目录下;第二步,查看入口脚本里的输入路径是写死的还是通过命令行参数传入;第三步,从增强入口开始跑,再到分割入口。
检查项可以用这张表来对照:
| 检查步骤 | 操作命令 | 预期结果 |
|---|---|---|
| 增强流程 | python scripts/demo_enhancement.py --image data/test.jpg | 弹出增强前后对比窗或输出图片到output/ |
| 分割流程 | python scripts/demo_segmentation.py --image data/test.jpg | 输出带掩膜的叠加图,前景区域被标注 |
| 结果一致性 | 同一张图跑两次 | 两次输出的文件哈希一致,保证没有随机性污染 |
分水岭和带深度模型的分割流程通常有随机初始化,如果同一张图跑两次结果完全不同,很多新手会手足无措。这里可以先不深究,等第四章读一下代码,看是固定了随机种子还是用了可复现的初始化方式。
3. 图像增强算法复现:从直方图均衡化到Retinex与小波变换
3.1 直方图均衡化与自适应直方图均衡:纯OpenCV就能落地
直方图均衡化是图像增强里最基础的算法,几乎所有计算机视觉入门项目都会带上它。它的原理可以一句话讲完:把图像的灰度分布拉伸到整个灰度范围内,让对比度更大。问题在于,对整张图做全局均衡化时,亮度会明显漂移,暗部细节可能反而被吞掉。
如果你在源码包里的实现是直接对RGB三通道分别做cv2.equalizeHist,那这个代码多半是照搬国外旧教程的,效果往往偏色得厉害。更稳的做法是在 YUV 或 LAB 色彩空间里只处理亮度通道:
import cv2 import numpy as np def equalize_yuv(input_path, output_path): # 读取图像,注意 OpenCV 读进来是 BGR 排序 img = cv2.imread(input_path) if img is None: raise FileNotFoundError(f"无法读取图像: {input_path}") # 把 BGR 转为 YUV,Y 是亮度,U/V 是色度 img_yuv = cv2.cvtColor(img, cv2.COLOR_BGR2YUV) # 只对亮度通道做全局直方图均衡 img_yuv[:, :, 0] = cv2.equalizeHist(img_yuv[:, :, 0]) # 转回 BGR 并保存 result = cv2.cvtColor(img_yuv, cv2.COLOR_YUV2BGR) cv2.imwrite(output_path, result) return result if __name__ == "__main__": equalize_yuv("data/dark_scene.jpg", "output/enhanced_yuv.jpg")这段代码有两个关键参数值得新手注意:第一,cv2.imread读入后通道顺序是 BGR 而不是 RGB,直接转换到 YUV 时心里要有数;第二,equalizeHist要求输入是单通道 uint8 图像,所以先做了通道拆分处理。对比度分布极端的低照度图,全局均衡化往往还是会“糊”,这时就要换用 CLAHE(对比度受限的自适应直方图均衡)。
clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)) img_yuv[:, :, 0] = clahe.apply(img_yuv[:, :, 0])clipLimit控制对比度增强的上限,值越大幅噪越明显;tileGridSize决定局部区域划分的精细程度。低照度图像我一般从clipLimit=2.0起步,如果暗部过噪,降到 1.5;如果增强效果不明显,加到 3.0 但要注意噪声同步放大。
3.2 基于Retinex的低照度增强:照度分量估计是关键
Retinex 系列在热词里频繁现身,尤其是“基于retinex算法的雾天/低照度图像增强复原系统”这类完整项目名。Retinex 算法的核心假设是物体颜色由反射率决定,观测图像是照度与反射率的乘积。低照度场景下照度分量过低,增强就是估计照度并把它拉高,从而还原反射率。
源码复现包里常见的 Retinex 实现如下:
import cv2 import numpy as np def single_scale_retinex(img, sigma=80): # 转 float32 并加 1,避免取对数时出现 log(0) img_f = img.astype(np.float32) + 1.0 # 高斯模糊估计算照度分量 blur = cv2.GaussianBlur(img_f, (0, 0), sigmaX=sigma) # 反射率近似 = log(观测) - log(照度) log_reflectance = np.log(img_f) - np.log(blur) return log_reflectance def auto_stretch(img_src, low_per=2, high_per=98): # 剔除最亮最暗的极端像素,做百分位拉伸 p_low = np.percentile(img_src, low_per) p_high = np.percentile(img_src, high_per) stretched = np.clip((img_src - p_low) / (p_high - p_low), 0, 1) return stretched def retinex_enhance(input_path, output_path): img = cv2.imread(input_path) result = np.zeros_like(img, dtype=np.float32) # 对 BGR 三个通道分别做单尺度 Retinex 再融合 for i in range(3): ssr = single_scale_retinex(img[:, :, i], sigma=80) result[:, :, i] = auto_stretch(ssr) result_8bit = (result * 255).astype(np.uint8) cv2.imwrite(output_path, result_8bit)这段实现里,sigma是高斯滤波的尺度参数,也是最值得调试的旋钮。sigma偏小,照度估计过于精细,输出图像明暗变化剧烈,噪声被放大成色斑;sigma偏大,照度估计过于平滑,暗部细节又拉不回来。多尺度 Retinex(MSR)的思路就是同时取多个sigma加权融合,常见的组合是小中大三档,比如 15、80、250。不同sigma的权重可以用等权,也可以让中间尺度权重稍高。
另外提一句,auto_stretch里我用的是百分位拉伸而不是全网常见的 min-max 拉伸。原因是 min-max 拉伸对单点噪声极其敏感,一个 255 的亮斑就会把整个拉伸区间拉坏;百分位从 2% 和 98% 处取值,相当于主动回避了极端值,在低照度增强里几乎成了默认做法。建议读者拿到源码包后先检查它的自动拉伸怎么写——这块写得好不好,直接决定增强结果有没有“雾霾感”。
3.3 小波变换图像增强:频域操作的边界与参数
小波变换在计算机视觉入门包里出现频率不低,热搜词里就有“小波变换图像增强python”。小波变换天然适合做图像增强的原因是它把图像分解成低频近似分量和高频细节分量——低频代表整体亮度结构,高频代表边缘和噪声。增强的策略就是对不同分量区别处理。
import pywt import numpy as np import cv2 def wavelet_enhance_gray(gray): # 使用 db2 小波做一层分解 coeffs = pywt.dwt2(gray.astype(np.float32), 'db2') LL, (LH, HL, HH) = coeffs # 低频近似分量做轻微的对比度拉伸,提升整体明暗层次 LL_enhanced = cv2.normalize(LL, None, 0, 1, cv2.NORM_MINMAX) # 水平/垂直/对角细节分量分别乘以增益 gain_h, gain_v, gain_d = 1.2, 1.2, 0.8 LH_enhanced = LH * gain_h HL_enhanced = HL * gain_v HH_enhanced = HH * gain_d # 重构回图像 coeffs_enhanced = (LL_enhanced, (LH_enhanced, HL_enhanced, HH_enhanced)) restored = pywt.idwt2(coeffs_enhanced, 'db2') return np.clip(restored, 0, 255).astype(np.uint8)这里几个增益参数的设置逻辑值得单独说明:水平与垂直细节对应图像的边缘结构,给到 1.2 左右能让边缘更锐利;对角细节分量 HH 大多是噪声和斜向伪影,我习惯压到 0.8 以下而不是增强。这是实际调参里小波增强和教科书示例差异最大的一处。db2是小波基函数,换成haar会更简单但重构后容易产生方块效应,换sym4边缘更平滑但计算量略增。
一个很关键的提醒:小波增强在入门包里属于“看起来很高级、实际工程落地频率很低”的算法。在低照度图像上,小波增强往往干不过 CLAHE 和 Retinex;在遥感图像和医学图像上才真正发挥频域分解的价值。如果你拿到的是医学图像分割源码包,小波增强可能被用在前处理去噪环节——那才是它该出现的位置。
3.4 增强效果的评估:只看图不够,要看指标
图像增强是典型的“主观任务”,但计算机视觉入门项目里交作业、写报告、对比算法,总得有量化指标。我见过不少新手在源码包里找到 PSNR、SSIM 的实现就直接用,却没有想过:PSNR 和 SSIM 是有参考图的指标,低照度增强这类无参考任务根本拿不到“地面真值”。此时正确做法是用无参考指标来评估。
代码包里常见的评估函数是信息熵和平均梯度:
import math def image_entropy(image): gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) hist = cv2.calcHist([gray], [0], None, [256], [0, 256]).flatten() hist = hist / hist.sum() ent = -sum(p * math.log2(p) for p in hist if p > 0) return round(ent, 4) def average_gradient(image): gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY).astype(np.float32) gx = cv2.Sobel(gray, cv2.CV_32F, 1, 0, ksize=3) gy = cv2.Sobel(gray, cv2.CV_32F, 0, 1, ksize=3) gradient_magnitude = cv2.magnitude(gx, gy) return round(gradient_magnitude.mean(), 4)信息熵反映图像细节丰富程度,增强后熵值明显提升说明暗部细节被拉出来了;平均梯度反映边缘锐利度,梯度变大说明清晰度改善。这两个指标组合使用,可以避开“只看效果图觉得不错、写报告却拿不出数据”的尴尬。
4. 图像分割算法复现:从阈值、分水岭到简化UNet
4.1 传统分割:Otsu阈值、边缘与分水岭的组合用法
图像分割是计算机视觉入门项目里绕不开的第二个重头戏。传统分割方法在源码复现包里占据很大篇幅,原因很直接:实现简单、不依赖GPU、十分钟就能看到效果。Otsu 阈值是其中最基础的一个,它的思想是寻找一个灰度阈值,让前景和背景两类像素的类间方差最大。OpenCV 里一行就能跑:
import cv2 import numpy as np def otsu_segment(input_path, output_path): img = cv2.imread(input_path) gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 高斯去噪,避免噪声干扰阈值选择 blur = cv2.GaussianBlur(gray, (5, 5), 0) # 返回两个值:阈值本身和二值图 _, binary = cv2.threshold(blur, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) cv2.imwrite(output_path, binary)这段代码的“隐藏知识点”是thresh参数传入 0 而不是某个具体灰度值。当THRESH_OTSU标志被设置时,OpenCV 会自动忽略传入的阈值并计算最优阈值。新手常在这里犯迷糊,以为随便传个 127 就行——实际上这个值在 Otsu 模式下根本不会被使用。
但 Otsu 在复杂背景下效果有限,入门包里通常配合分水岭算法使用。分水岭的思路是把图像看作地形图,灰度值的高低代表海拔,从局部最低点注水,水漫到分界处形成堤坝。直接对梯度图跑分水岭的经典问题是严重的过分割,几乎每个物体都被切成碎片。源码复现包里的正确姿势是这样的:
def watershed_segment(img, binary): # 计算图像到零像素的欧氏距离 dist = cv2.distanceTransform(binary, cv2.DIST_L2, 5) # 对距离图做阈值,得到“确定是前景”的种子区域 _, foreground = cv2.threshold(dist, 0.5 * dist.max(), 255, cv2.THRESH_BINARY) foreground = np.uint8(foreground) # 标记连通域,每个连通域是分水岭注水的种子 _, markers = cv2.connectedComponents(foreground) # 分水岭函数要求在标记图上 +1,让背景为 1、前景从 2 开始 markers = markers + 1 # 未知区域 = 原二值图确定背景 + 确定前景之间的过渡带 unknown = cv2.subtract(binary, foreground) markers[unknown == 255] = 0 # 分水岭会在所有标记之间画分界线,原地修改 markers cv2.watershed(img, markers) return markers分水岭这里最大的坑在markers的构造逻辑。connectedComponents返回的标记从 0 开始,0 表示背景;watershed函数规定标记值 0 的区域会被当作“待分割的未知区域”,因此必须整体 +1,让有效前景从 2 开始编号。如果漏掉这一步,你会看到分水岭结果里整个画面糊成一片。另一个参数是0.5 * dist.max()这个比例,它决定“多远的前景才算确定前景”——比例调大,种子减少,分割更全局;调小,种子增多,局部细节更清晰,但杂散区域也更多。
4.2 基于深度学习的简化UNet:什么时候值得用
UNet 在热搜词里排得很靠前,“unet图像分割”和“医学图像分割”几乎是绑定出现的。UNet 的结构特点是一个编码器-解码器对称结构,加上跨层跳跃连接,能在下采样丢失空间信息后,通过拼接把高频细节补回来。对入门项目来说,跑通一个简化版 UNet 并不难,难的是判断“这个数据集有没有必要上深度模型”。
先看推理部分的核心代码骨架:
def unet_predict(model, image, input_size=(256, 256)): # 统一输入尺寸,保持和训练时一致 img_resized = cv2.resize(image, input_size) img_norm = img_resized.astype(np.float32) / 255.0 # 给通道维,模型期望的输入是 (batch, h, w, channels) input_tensor = np.expand_dims(img_norm, axis=0) pred = model.predict(input_tensor, batch_size=1)[0] # 输出是 (256, 256, 1) 的概率图,二值化后放大回原尺寸 mask = (pred[:, :, 0] > 0.5).astype(np.uint8) * 255 mask = cv2.resize(mask, (image.shape[1], image.shape[0]), interpolation=cv2.INTER_NEAREST) return mask这里有一个非常容易踩的坑:cv2.resize恢复掩膜尺寸时必须用INTER_NEAREST最近邻插值,不能用默认的INTER_LINEAR。前者保持掩膜的 0/255 二值属性不变,后者会在边缘产生灰度过度的锯齿带,导致后面计算 IoU 时出现大量非 0 非 255 的“幽灵像素”。
如果你拿到的源码包带了 UNet 的训练脚本,我建议先不要急着训练。先把推理脚本跑通,用预训练权重做一次预测;再决定是否有足够的数据量做微调。医学图像分割之类的场景通常数据量小,训练阶段常见的做法是随机裁剪成 128×128 或 256×256 的小块,配合旋转翻转做离线数据增强,再用早停法防止过拟合。如果你跑通推理后觉得效果可控,再动训练,这是源码复现的明智顺序。
4.3 分割结果的度量:IoU与Dice的前向计算
写清楚了分割算法,还得写清楚评价指标。许多入门包里把 IoU 和 Dice 混为一谈,实际上两者很接近但不完全等同。IoU 是交集比并集,Dice 是二倍交集比像素总数和。小目标场景下,Dice 会高于 IoU 约 5 到 10 个百分点,两者趋势一致但绝对数值不可比较。
def iou_score(pred_bin, mask_bin, eps=1e-7): # pred_bin 和 mask_bin 都是 uint8 的 0/255 或 0/1 二值图 pred = pred_bin > 0.5 mask = mask_bin > 0.5 intersection = np.logical_and(pred, mask).sum() union = np.logical_or(pred, mask).sum() return intersection / (union + eps) def dice_score(pred_bin, mask_bin, eps=1e-7): pred = pred_bin > 0.5 mask = mask_bin > 0.5 intersection = np.logical_and(pred, mask).sum() return (2.0 * intersection) / (pred.sum() + mask.sum() + eps)eps这个参数是为避免除零保护加上的,当预测和真实标签全是背景时,IoU 和 Dice 的分母都会变成零。很多人复现时忽略这一点,在纯背景图上测试直接报 ZeroDivisionError。数据检查时如果发现某张图的 mask 全为 0,我通常的处理是跳过这张图,不参与平均指标计算——否则这张图会拉低整体分数,产生误导。
5. 源码复现的常见问题排查:环境、依赖与图像尺寸
5.1 现象:cv2.imread 返回值是 None,但文件明明存在
这类问题在入门项目里出现频率很高,排查时让人恼火——文件路径没有任何拼写错误,print 出来的绝对路径也存在,但cv2.imread就是读不出来。常见原因之一,是路径里含有中文字符。OpenCV 的 imread 在 Windows 平台上对中文路径处理不友好,读取时会直接返回 None。另一个常见原因是当前工作目录不对,相对路径解析到错误位置。
解决思路分两步。第一步是路径里的中文问题,用cv2.imdecode配合np.fromfile绕开:
import numpy as np import cv2 def imread_unicode(path): data = np.fromfile(path, dtype=np.uint8) return cv2.imdecode(data, cv2.IMREAD_COLOR) img = imread_unicode("data/低照度测试图/图1.jpg") if img is None: raise RuntimeError(f"仍无法读取图像: {path}")第二步是检查当前工作目录,建议在代码入口处打印os.getcwd(),与文件所在目录做对比。源码包里的脚本常常假设你从项目根目录运行,如果你在src/目录下直接执行,所有相对路径都会错位。统一用pathlib.Path基于入口文件位置拼绝对路径,是治本的办法。
5.2 现象:图像显示出来是一片纯白或纯黑,数据却没问题
这个现象基本能在五分钟内定位:图像数组的 dtype 不匹配。cv2.imshow对 uint8 图像的像素值按 0 到 255 解释,而对 float32 图像要求像素值在 0 到 1 之间。当你把经过 Retinex 或小波变换处理的 float32 结果直接传给 imshow 时,大部分像素值大于 1,被统一截断成 255,整张图自然全白。
解决思路是对处理结果做类型转换和范围截断:
result_uint8 = np.clip(result, 0, 1) # 先把 float 结果截断到 [0, 1] result_uint8 = (result_uint8 * 255).astype(np.uint8) # 再转成 uint8这里有个细节:np.clip和astype是两步,顺序不能反过来。如果直接对未归一化的 float 数组执行astype(np.uint8),超出 255 的像素值会发生环绕,变成小灰度值,图像上出现大量雪花噪点。这也是源码包代码里最常见的“看起来实现了增强,效果却变差”的原因。
5.3 现象:UNet 训练或推理时显存溢出,笔记本直接卡死
深度学习分割脚本对硬件的要求比传统算法高一个量级。入门项目的 UNet 通常输入尺寸在 256×256 到 512×512 之间,批大小默认 8 以上。如果显存只有 6GB,喂 512×512×8 的输入很容易直接 OOM。即使显存够用,CPU 版 TensorFlow 或 PyTorch 跑一次推理也可能卡上几十秒,新手会误以为程序死循环了。
解决思路是缩小批大小和输入尺寸。常见做法是先把batch_size降到 2 或 1,确认前向推理能通过后,再逐步加大;显存仍然不足时,把输入从 512×512 降到 256×256。对于 UNet 这类全卷积网络,不同输入尺寸可以推理,但分割精度和小目标召回会明显下降。另一个平时用得少的技巧是开启混合精度:
# TensorFlow 2.x from tensorflow.keras import mixed_precision mixed_precision.set_global_policy("mixed_float16")混合精度能让显存占用下降约 40%,代价是训练结果存在微小不确定性。如果对指标精度有硬性要求,建议只在推理阶段开启,训练阶段保持全精度。另外,笔记本上跑 UNet 训练时,建议先用 CPU 跑几步确认数据管道没有卡住,再切回 GPU。很多人忽略这个步骤,结果等了半小时才发现是数据读取环节出了问题,而不是模型训练本身慢。
5.4 现象:分水岭分割结果支离破碎,边缘产生大量噪声
分水岭分割的过分割问题在源码复现里几乎是必现的,尤其是直接用梯度图作为输入时。图片纹理丰富时,每个微小的谷底都会注水成独立区域,分割结果像被随机打碎的玻璃。原因在于前景标记太少,背景标记也没有强制约束。
解决思路是增加一个“形态学平滑”前置步骤:
# 先用开运算去除小噪声区域 kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5)) opening = cv2.morphologyEx(binary, cv2.MORPH_OPEN, kernel, iterations=2) # 用腐蚀操作得到确定的背景区域 sure_bg = cv2.dilate(opening, kernel, iterations=3)iterations这个参数控制平滑力度。对细胞类图,腐蚀膨胀各 2 到 3 次就能得到干净的标记;对医学图像中粘连严重的结构,需要把迭代次数加到 5 左右。不过要提醒一句,膨胀过度会把细小目标吞掉,分割结果里目标个数变少,这时就需要回调参数。实际调参时建议把iterations做成一个可配置变量,分别打印不同值的标记数量,再结合可视化结果人工筛选。
5.5 现象:增强前后对比不明显,输出图与输入图几乎没区别
这个问题大多出在自适应拉伸或归一化环节。很多实现里对参考图做 percent stretch 时,拉伸出错或直接跳过,导致 Retinex 计算出的对数反射率按原值强转成 uint8,结果到 255 时被截断,整张图比输入更暗。还有一种情况是输入的图像本身动态范围已经很宽,灰度直方图接近均匀分布,此时增强算法的提升空间有限,视觉上没有明显变化是正常的。
解决思路有两层:第一层,检查算法输出在进行astype(np.uint8)之前有没有经过百分位拉伸,没有则补上;第二层,换一张动态范围较窄的测试图验证算法是否有效。比如室内暗光照片在傍晚拍摄,整张图的光线均匀且整体偏暗,这种图增强前后对比非常明显。如果暗图增强后依然没有变化,那问题几乎可以断定在代码逻辑而非图像数据上。写报告或交作业时,换图验证比反复调整算法参数更高效。
6. 把零散脚本整理成可复用的命令行工具
源码复现包跑通之后,下一步是把分散的 demo 脚本整合成一个入口统一调度的命令行工具。这个习惯能让你的“入门项目”落地成真正能用的工具,而不是某个 IDE 里才能运行的脚本集合。做法是用 argparse 接收子命令,区分增强和分割任务:
import argparse import sys def build_parser(): parser = argparse.ArgumentParser(description="CV 图像处理工具集") subparsers = parser.add_subparsers(dest="task", required=True) # 增强子命令 parser_enhance = subparsers.add_parser("enhance", help="图像增强") parser_enhance.add_argument("--image", required=True, help="输入图片路径") parser_enhance.add_argument("--algo", choices=["clahe", "retinex", "wavelet"], default="clahe") parser_enhance.add_argument("--output", default="output", help="输出目录") # 分割子命令 parser_seg = subparsers.add_parser("segment", help="图像分割") parser_seg.add_argument("--image", required=True, help="输入图片路径") parser_seg.add_argument("--method", choices=["otsu", "watershed", "unet"], default="otsu") parser_seg.add_argument("--output", default="output", help="输出目录") return parser if __name__ == "__main__": args = build_parser().parse_args() print(f"任务: {args.task}, 算法: {getattr(args, 'algo', None) or getattr(args, 'method', None)}")这个封装解决了一个实际问题:每次跑算法时都不用打开 IDE 找入口文件,直接在终端里执行python cv_tools.py enhance --image data/dark.jpg --algo retinex。目录层级也从此固定,输出统一写入output/目录,日志里不产生散落的临时文件。argparse 的choices参数还顺带做了输入合法性校验,输错算法名直接报错而不是跑完后发现结果不对。我自己做源码复现时一直是这个习惯:先跑通,再统一出入口。经历几次“算法调好了却忘了放在哪”的折腾后,就会明白命令行封装带来的清晰度有多值钱。希望这个工作流能帮你在后续的计算机视觉项目里少一点迷茫。
本文还有配套的精品资源,点击获取