简介:这份资源面向数字图像处理学习者、课程设计选题学生及需要处理阴影干扰的开发者,聚焦阴影检测与去除这一经典难题。阴影普遍存在于自然图像中,会对特征提取、图像识别与分割造成干扰,资源通过Python实现从阴影检测到去除的完整流程,输入一张图片即可判断是否存在阴影并尝试消除。压缩包共16个文件,约10.13MB,包含Python主程序与模块、效果对比图片、实验报告文档、演示幻灯片及说明文件,覆盖代码、数据、报告与展示材料,便于理解算法思路与实验结论。目前已有435人学习下载,适合作为课程设计参考或图像处理入门实践。读者可从中获得可运行的阴影检测与去除脚本、SBU数据集相关实验素材、效果对比图与报告撰写思路,并借助幻灯片快速梳理方法框架,为后续图像预处理与视觉任务提供可复用的排错与调参参考。
1. 阴影检测与去除:为什么你的 OpenCV 阈值法一换场景就翻车
做过数字图像处理的人都遇到过这种场景:一段户外监控视频,阳光把电线杆的影子投在路面上,你想把阴影去掉还原路面纹理,于是用 HSV 空间卡了个 V 通道阈值,实验室里效果不错,换到另一段视频,阴影没去掉,路面反光倒被当成阴影抹掉了。阴影检测与去除这件事,难就难在阴影和暗色物体在像素层面几乎无法区分——两者都暗,但一个是光照被遮挡,一个是本身反射率低。
这个方向在智能交通、遥感影像、视频监控、自动驾驶感知里都有实际需求。基于 Python 实现的好处是生态成熟:OpenCV 做底层像素操作,NumPy 做矩阵运算,scikit-image 做形态学和区域分析,整套链路不需要编译 C++ 就能跑通。本文面向的是有 Python 基础、想把这个方向真正落地到项目里的从业者,从物理模型讲到可复现的代码,再到参数怎么调、坑在哪。读完你应该能自己搭一条从阴影检测到去除的完整流水线,并且知道什么场景下这套方法会失效。
2. 阴影的物理模型与检测路线选型:从光照反射到可计算特征
2.1 阴影在像素上到底长什么样
要检测阴影,先得理解阴影是怎么形成的。光照模型里,传感器接收到的像素值可以近似写成:
I(x, y) = R(x, y) × L(x, y)
其中 R 是物体表面的反射率,L 是到达该点的光照强度。阴影区域的特点是 L 被遮挡物降低了,但 R 没变。暗色物体则是 R 本身就低。这就是为什么单纯看亮度无法区分二者——你需要找到「R 没变但 L 变了」的证据。
实际可用的证据有几类。第一类是色度不变性:阴影通常不改变色相,只是降低亮度和饱和度,而暗色物体往往有自己独立的色相分布。第二类是纹理一致性:阴影覆盖区域的纹理梯度和周围非阴影区域应该相似,因为反射率没变。第三类是边界特征:阴影边界通常有较明显的亮度跃变,且边界两侧色度接近。第四类是几何先验:如果知道光源方向,阴影方向应该和光源一致。
工程上不会只用一种特征,常见做法是把色度、纹理、亮度三类特征组合成一个特征向量,再用分类器或规则判断。但组合特征会带来维度灾难和调参困难,所以选型时要根据场景取舍。
2.2 三条主流技术路线的适用边界
目前基于 Python 能落地的阴影检测路线大致三条:
路线一:基于色彩空间的阈值法。在 HSV 或 Lab 空间设定亮度比和色度差阈值。优点是快,单帧处理在毫秒级;缺点是对光照变化极其敏感,阈值换场景就得重调。适合光照稳定、场景固定的工业检测。
路线二:基于纹理和区域的方法。用局部二值模式(LBP)或 Gabor 滤波提取纹理特征,再结合区域生长或图割做阴影区域分割。优点是鲁棒性比阈值法好,能处理一定程度的暗色物体干扰;缺点是计算量大,参数多,实时性差。适合离线处理或对精度要求高的遥感影像。
路线三:基于深度学习的方法。用 U-Net 或 Mask R-CNN 做阴影分割,再用 GAN 或 Retinex 做去除。优点是泛化能力强,缺点是需要标注数据,训练成本高,部署时对硬件有要求。适合有数据积累、追求端到端效果的团队。
我一般会建议:如果场景固定、实时性要求高,先走路线一,把参数调透;如果场景多变但算力有限,走路线二,用 LBP 加形态学后处理;如果有标注数据且能接受 GPU 推理,直接上路线三。下面两章分别展开路线一和路线二的实现,路线三在最后一章讲进阶思路。
2.3 环境准备与最小可跑通示例
先把环境搭起来。Python 3.8 以上,装 OpenCV、NumPy、scikit-image、matplotlib:
pip install opencv-python numpy scikit-image matplotlib如果你用 VSCode 或 PyCharm,配好解释器后直接跑下面的最小示例。这段代码读一张图,转 HSV,用亮度比和饱和度比做初步阴影掩膜:
import cv2 import numpy as np def shadow_mask_hsv(img_bgr, brightness_ratio=0.6, sat_ratio=0.8): """ 基于 HSV 的阴影初步检测 brightness_ratio: 阴影区亮度 / 背景亮度 的上限 sat_ratio: 阴影区饱和度 / 背景饱和度 的上限 """ hsv = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2HSV) h, s, v = cv2.split(hsv) v = v.astype(np.float32) s = s.astype(np.float32) # 用大核中值滤波估计背景亮度和饱和度 v_bg = cv2.medianBlur(v.astype(np.uint8), 31).astype(np.float32) s_bg = cv2.medianBlur(s.astype(np.uint8), 31).astype(np.float32) # 避免除零 v_bg[v_bg == 0] = 1 s_bg[s_bg == 0] = 1 mask_v = (v / v_bg) < brightness_ratio mask_s = (s / s_bg) < sat_ratio mask = (mask_v & mask_s).astype(np.uint8) * 255 # 形态学去噪 kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5)) mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel) mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel) return mask if __name__ == "__main__": img = cv2.imread("test.jpg") mask = shadow_mask_hsv(img) cv2.imshow("shadow", mask) cv2.waitKey(0)逻辑说明:先转 HSV 分离亮度和饱和度通道,用大核中值滤波估计「如果没有阴影,这个位置应该有多亮/多饱和」,然后算比值。阴影区亮度比和饱和度比都会偏低,两个条件同时满足才判为阴影。参数说明:brightness_ratio默认 0.6,意思是阴影区亮度不到背景的 60%;sat_ratio默认 0.8。这两个值需要根据你的数据调,后面避坑章节会讲怎么调。
提示:中值滤波的核大小 31 是个经验值,图像分辨率高时要加大,否则背景估计会跟着阴影走,导致漏检。
3. 从掩膜到去除:用 Retinex 和纹理填充把阴影区域还原
3.1 阴影去除的两条思路:光照补偿 vs 纹理合成
检测出阴影掩膜后,去除有两条路。第一条是光照补偿:认为阴影区只是光照被衰减,把光照补回去就行。经典方法是 Retinex 分解,把图像拆成反射率和光照两部分,只对光照做补偿。第二条是纹理合成:如果阴影区纹理丢失严重,直接从周围非阴影区采样纹理填充,类似图像修复(inpainting)。
实际工程里我一般先用 Retinex 做全局补偿,再用 inpainting 修补残留的边界伪影。单用 Retinex 在阴影边缘容易产生光晕,单用 inpainting 在阴影面积大时会出现纹理重复。两者结合效果最稳。
3.2 Retinex 光照补偿的 Python 实现
单尺度 Retinex 的核心是对数域做减法:
def retinex_shadow_removal(img_bgr, mask, sigma=30): """ 对阴影区域做单尺度 Retinex 光照补偿 sigma: 高斯核标准差,控制光照估计的平滑程度 """ img = img_bgr.astype(np.float32) + 1.0 # 避免 log(0) # 对每个通道分别做 Retinex channels = cv2.split(img) result = [] for ch in channels: # 高斯模糊估计光照 blur = cv2.GaussianBlur(ch, (0, 0), sigma) blur[blur == 0] = 1 # 对数域相减得到反射率 retinex = np.log10(ch) - np.log10(blur) result.append(retinex) retinex_img = cv2.merge(result) # 归一化到 0-255 retinex_img = cv2.normalize(retinex_img, None, 0, 255, cv2.NORM_MINMAX) retinex_img = retinex_img.astype(np.uint8) # 只在阴影区域用 Retinex 结果,非阴影区保留原图 mask_3ch = cv2.merge([mask, mask, mask]) / 255.0 output = (retinex_img * mask_3ch + img_bgr * (1 - mask_3ch)).astype(np.uint8) return output逻辑说明:Retinex 假设图像 = 反射率 × 光照,取对数后变成加法,减去高斯模糊估计的光照就得到反射率。这里对每个通道独立做,最后归一化。关键在最后一步——只在阴影掩膜内替换,非阴影区保持原样,避免整图色彩偏移。参数说明:sigma控制光照估计的尺度,值越大光照估计越平滑,阴影补偿越柔和但可能欠补偿;值越小补偿越激进但容易在边缘产生光晕。一般从 30 开始试,图像分辨率高时加到 50-80。
3.3 边界伪影修复与 inpainting 后处理
Retinex 补偿后,阴影边界往往还有一圈不自然的过渡。用 OpenCV 的 inpaint 对边界带做修复:
def refine_boundary(img_bgr, mask, dilate_iter=2): """ 对阴影边界做 inpainting 修复 dilate_iter: 掩膜膨胀次数,控制修复带宽 """ kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3)) # 膨胀掩膜得到边界带 mask_dilated = cv2.dilate(mask, kernel, iterations=dilate_iter) # 边界带 = 膨胀后 - 原始 boundary = cv2.subtract(mask_dilated, mask) # 用 Telea 算法修复边界带 result = cv2.inpaint(img_bgr, boundary, 3, cv2.INPAINT_TELEA) return result逻辑说明:先把掩膜膨胀几圈,减去原始掩膜就得到边界带。用 inpaint 对边界带做修复,Telea 算法适合小区域快速修复。参数说明:dilate_iter控制修复带宽,一般 2-3 就够,太大反而会把阴影内部纹理也抹掉。inpaint的第三个参数 3 是修复半径,边界带窄时用 3,宽时加到 5。
3.4 完整流水线串起来
把检测和去除串成一条流水线:
def shadow_removal_pipeline(img_path): img = cv2.imread(img_path) mask = shadow_mask_hsv(img) compensated = retinex_shadow_removal(img, mask) result = refine_boundary(compensated, mask) return result if __name__ == "__main__": out = shadow_removal_pipeline("test.jpg") cv2.imwrite("result.jpg", out)这条流水线在 1080p 图像上单帧大约 200-400ms,取决于中值滤波核大小和 inpaint 区域面积。如果要做视频实时处理,需要把中值滤波换成积分图加速,或者降分辨率处理后再上采样。
注意:Retinex 对彩色图像做逐通道处理会改变色相,如果对色彩保真度要求高,应该在 Lab 空间只对 L 通道做补偿,a、b 通道保持不变。
4. 避坑与排查:阴影检测去除中最容易翻车的五个地方
4.1 现象:阴影没检出来,暗色物体反而被标成阴影
原因:HSV 阈值法只看亮度和饱和度,暗色物体(比如黑色汽车、深色路面)的亮度和饱和度本来就低,和阴影特征重叠。
解决:加入色度一致性判断。阴影区的色相应该和周围背景接近,而暗色物体往往有独立色相。在掩膜生成后加一步色相距离过滤:
def filter_by_hue(img_bgr, mask, hue_thresh=20): hsv = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2HSV) h = hsv[:, :, 0].astype(np.float32) # 用大核中值滤波估计背景色相 h_bg = cv2.medianBlur(h.astype(np.uint8), 31).astype(np.float32) # 色相距离(环形距离) diff = np.abs(h - h_bg) diff = np.minimum(diff, 180 - diff) # 只保留色相接近的区域 mask = cv2.bitwise_and(mask, (diff < hue_thresh).astype(np.uint8) * 255) return maskhue_thresh默认 20,OpenCV 的 H 通道范围是 0-180,20 大约对应 40 度色相角。如果场景里阴影和背景色相差很大(比如绿草地上的阴影),这个值要放宽到 30。
4.2 现象:阴影边缘有光晕,过渡不自然
原因:Retinex 的高斯核尺度选择不当。核太小,光照估计跟着阴影边界走,补偿后在边界产生亮环;核太大,光照估计过于平滑,阴影内部补偿不足。
解决:用多尺度 Retinex(MSR),把多个 sigma 的结果加权平均:
def msr_shadow_removal(img_bgr, mask, sigmas=[15, 50, 100]): img = img_bgr.astype(np.float32) + 1.0 channels = cv2.split(img) msr_result = [] for ch in channels: retinex_sum = np.zeros_like(ch) for sigma in sigmas: blur = cv2.GaussianBlur(ch, (0, 0), sigma) blur[blur == 0] = 1 retinex_sum += np.log10(ch) - np.log10(blur) msr_result.append(retinex_sum / len(sigmas)) msr_img = cv2.merge(msr_result) msr_img = cv2.normalize(msr_img, None, 0, 255, cv2.NORM_MINMAX).astype(np.uint8) mask_3ch = cv2.merge([mask, mask, mask]) / 255.0 return (msr_img * mask_3ch + img_bgr * (1 - mask_3ch)).astype(np.uint8)三个 sigma 分别覆盖细节、中尺度、大尺度光照,加权平均后光晕明显减轻。代价是计算量翻三倍。
4.3 现象:视频处理时帧间闪烁,阴影掩膜跳变
原因:逐帧独立检测,没有利用时域信息。相邻帧之间光照微小变化就会导致掩膜边界跳动。
解决:加时域平滑。对掩膜做滑动窗口平均,或者用卡尔曼滤波跟踪阴影区域。简单做法是维护一个掩膜队列,取最近 N 帧的中值:
from collections import deque class TemporalMaskSmoother: def __init__(self, window=5): self.window = window self.buffer = deque(maxlen=window) def update(self, mask): self.buffer.append(mask.astype(np.float32)) if len(self.buffer) < self.window: return mask # 取中值 stacked = np.stack(self.buffer, axis=0) smoothed = np.median(stacked, axis=0).astype(np.uint8) return smoothedwindow默认 5,视频帧率高时加到 7-9,帧率低时减到 3。中值比平均好,因为中值对单帧异常不敏感。
4.4 现象:inpaint 后阴影区域纹理模糊,像糊了一层
原因:inpaint 算法在区域较大时只能做扩散填充,无法恢复真实纹理。Telea 算法适合小区域,大区域会变成一片模糊。
解决:大阴影区域不要用 inpaint,改用纹理合成。简单做法是从阴影周围非阴影区随机采样 patch,粘贴到阴影区。或者用 OpenCV 的cv2.inpaint配合cv2.INPAINT_NS(Navier-Stokes)算法,在纹理区域效果比 Telea 好:
result = cv2.inpaint(img_bgr, boundary, 5, cv2.INPAINT_NS)如果阴影面积超过图像 10%,建议直接上深度学习修复模型,传统方法很难做好。
4.5 现象:调好的参数换一段视频就失效
原因:阈值法对光照条件敏感,不同时段、不同天气、不同相机的图像亮度分布差异很大。
解决:做自适应阈值。不要写死 0.6,而是根据图像整体亮度分布动态计算。比如取图像亮度直方图的某个百分位数作为基准:
def adaptive_brightness_ratio(img_bgr, percentile=30): hsv = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2HSV) v = hsv[:, :, 2] # 用低百分位数估计阴影区亮度,高百分位数估计背景亮度 v_low = np.percentile(v, percentile) v_high = np.percentile(v, 90) if v_high == 0: return 0.6 return v_low / v_high这个比值作为brightness_ratio的初始值,再根据实际效果微调。自适应方法不能保证所有场景都完美,但比固定阈值鲁棒得多。
5. 进阶:用轻量 U-Net 做阴影分割,以及怎么验证效果
5.1 为什么传统方法到头了要转深度学习
前面讲的阈值法加 Retinex,在固定场景下能跑出不错的效果,但一旦场景变复杂——多光源、半透明阴影、软阴影、彩色阴影——规则方法就捉襟见肘。深度学习的优势在于它能从数据里学到「阴影长什么样」的高层语义,而不是依赖人工设计的低层特征。
但我不建议一上来就上大模型。阴影分割是个二分类密集预测任务,轻量 U-Net 就够了。输入 RGB 三通道,输出单通道概率图,编码器用 MobileNetV2 或自己搭 4 层下采样,解码器对称上采样加跳连。参数量控制在 2M 以内,CPU 推理也能到 5-10 FPS。
训练数据方面,公开数据集有 SBU、UCF 阴影数据集,但场景和你的实际数据可能不匹配。我的经验是:先用公开数据预训练,再用自己标注的 200-500 张图微调,效果比纯用公开数据好很多。标注不用像素级精细,用多边形粗略框出阴影区域即可,U-Net 对标注噪声有一定容忍度。
5.2 阴影去除效果怎么量化验证
做去除不能只看「看起来好了」,得有量化指标。常用的三个:
| 指标 | 含义 | 适用场景 |
|---|---|---|
| RMSE | 去除后图像与无阴影参考图的均方根误差 | 有配对参考图时 |
| PSNR | 峰值信噪比,越高越好 | 有配对参考图时 |
| SSIM | 结构相似度,衡量纹理结构保留程度 | 无配对参考图时可用 |
实际项目中很难拿到「同一场景无阴影」的参考图,所以 SSIM 更常用。做法是:在阴影区域和周围非阴影区域各取一个 patch,算它们的 SSIM,去除后这个值应该升高。另外可以算阴影边界的梯度一致性,去除后边界梯度应该更平滑。
from skimage.metrics import structural_similarity as ssim def evaluate_removal(original, removed, mask): # 在阴影区域取 patch ys, xs = np.where(mask > 0) if len(ys) == 0: return None y1, y2 = ys.min(), ys.max() x1, x2 = xs.min(), xs.max() patch_orig = original[y1:y2, x1:x2] patch_rem = removed[y1:y2, x1:x2] if patch_orig.size == 0: return None score = ssim(patch_orig, patch_rem, channel_axis=2, data_range=255) return score这个分数不是绝对标准,但可以用来对比不同参数或不同方法的效果。我一般会跑一组对比:原始图、阈值法去除、Retinex 去除、U-Net 去除,看哪个 SSIM 最高。
5.3 一个我踩过的坑:别在 RGB 空间直接做 Retinex
早期我图省事,直接在 RGB 三个通道上做 Retinex,结果去除后阴影区域颜色偏紫。原因是 RGB 通道间有强相关性,逐通道对数相减会破坏白平衡。后来改成在 Lab 空间只对 L 通道做补偿,a、b 通道完全不动,颜色就正常了。这个改动很小,但效果差异巨大。
def retinex_lab(img_bgr, mask, sigma=30): lab = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2LAB) l, a, b = cv2.split(lab) l = l.astype(np.float32) + 1.0 blur = cv2.GaussianBlur(l, (0, 0), sigma) blur[blur == 0] = 1 l_retinex = np.log10(l) - np.log10(blur) l_retinex = cv2.normalize(l_retinex, None, 0, 255, cv2.NORM_MINMAX).astype(np.uint8) mask_3ch = cv2.merge([mask, mask, mask]) / 255.0 l_final = (l_retinex * mask_3ch[:, :, 0] + l * (1 - mask_3ch[:, :, 0])).astype(np.uint8) lab_result = cv2.merge([l_final, a, b]) return cv2.cvtColor(lab_result, cv2.COLOR_LAB2BGR)这段代码和前面 HSV 版的区别就一处:只处理 L 通道,a、b 原样保留。如果你做的是彩色图像阴影去除,我强烈建议用这个版本。
做这个方向这几年,我最大的习惯是:每换一个数据集,先跑一遍直方图分析,看亮度和色相分布,再决定阈值范围。不要迷信任何一组「万能参数」,阴影检测这件事,场景适配比算法选型更重要。希望帮到你。
本文还有配套的精品资源,点击获取