
简介这份资源面向数字图像处理学习者、课程设计选题学生及需要处理阴影干扰的开发者聚焦阴影检测与去除这一经典难题。阴影普遍存在于自然图像中会对特征提取、图像识别与分割造成干扰资源通过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、matplotlibpip install opencv-python numpy scikit-image matplotlib如果你用 VSCode 或 PyCharm配好解释器后直接跑下面的最小示例。这段代码读一张图转 HSV用亮度比和饱和度比做初步阴影掩膜import cv2 import numpy as np def shadow_mask_hsv(img_bgr, brightness_ratio0.6, sat_ratio0.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, sigma30): 对阴影区域做单尺度 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_iter2): 对阴影边界做 inpainting 修复 dilate_iter: 掩膜膨胀次数控制修复带宽 kernel cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3)) # 膨胀掩膜得到边界带 mask_dilated cv2.dilate(mask, kernel, iterationsdilate_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_thresh20): 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默认 20OpenCV 的 H 通道范围是 0-18020 大约对应 40 度色相角。如果场景里阴影和背景色相差很大比如绿草地上的阴影这个值要放宽到 30。4.2 现象阴影边缘有光晕过渡不自然原因Retinex 的高斯核尺度选择不当。核太小光照估计跟着阴影边界走补偿后在边界产生亮环核太大光照估计过于平滑阴影内部补偿不足。解决用多尺度 RetinexMSR把多个 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, window5): self.window window self.buffer deque(maxlenwindow) def update(self, mask): self.buffer.append(mask.astype(np.float32)) if len(self.buffer) self.window: return mask # 取中值 stacked np.stack(self.buffer, axis0) smoothed np.median(stacked, axis0).astype(np.uint8) return smoothedwindow默认 5视频帧率高时加到 7-9帧率低时减到 3。中值比平均好因为中值对单帧异常不敏感。4.4 现象inpaint 后阴影区域纹理模糊像糊了一层原因inpaint 算法在区域较大时只能做扩散填充无法恢复真实纹理。Telea 算法适合小区域大区域会变成一片模糊。解决大阴影区域不要用 inpaint改用纹理合成。简单做法是从阴影周围非阴影区随机采样 patch粘贴到阴影区。或者用 OpenCV 的cv2.inpaint配合cv2.INPAINT_NSNavier-Stokes算法在纹理区域效果比 Telea 好result cv2.inpaint(img_bgr, boundary, 5, cv2.INPAINT_NS)如果阴影面积超过图像 10%建议直接上深度学习修复模型传统方法很难做好。4.5 现象调好的参数换一段视频就失效原因阈值法对光照条件敏感不同时段、不同天气、不同相机的图像亮度分布差异很大。解决做自适应阈值。不要写死 0.6而是根据图像整体亮度分布动态计算。比如取图像亮度直方图的某个百分位数作为基准def adaptive_brightness_ratio(img_bgr, percentile30): 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_axis2, data_range255) return score这个分数不是绝对标准但可以用来对比不同参数或不同方法的效果。我一般会跑一组对比原始图、阈值法去除、Retinex 去除、U-Net 去除看哪个 SSIM 最高。5.3 一个我踩过的坑别在 RGB 空间直接做 Retinex早期我图省事直接在 RGB 三个通道上做 Retinex结果去除后阴影区域颜色偏紫。原因是 RGB 通道间有强相关性逐通道对数相减会破坏白平衡。后来改成在 Lab 空间只对 L 通道做补偿a、b 通道完全不动颜色就正常了。这个改动很小但效果差异巨大。def retinex_lab(img_bgr, mask, sigma30): 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 原样保留。如果你做的是彩色图像阴影去除我强烈建议用这个版本。做这个方向这几年我最大的习惯是每换一个数据集先跑一遍直方图分析看亮度和色相分布再决定阈值范围。不要迷信任何一组「万能参数」阴影检测这件事场景适配比算法选型更重要。希望帮到你。本文还有配套的精品资源点击获取