视频阴影在动态场景里是一个容易被忽略却影响很大的干扰源。无论是交通监控中的车辆检测、安防场景下的人员跟踪,还是自动驾驶对路面目标的识别,移动阴影经常被背景建模或分割算法误判为前景,导致计数错误、跟踪框漂移和场景理解偏差。阴影去除不能简单等同于图像增强,因为如果只是整体提亮,原本正常的亮部会过曝;正确思路是先估计光照分布,判断哪些像素处于阴影覆盖之下,再只对阴影区域进行亮度与颜色补偿。视频相比单帧图像多了一个重要优势,就是可以利用连续帧的时间一致性,减少单帧检测中的抖动和误检。整体流程可以概括为背景建模、阴影候选检测、掩码后处理、光影补偿四个步骤。

一、阴影去除的核心难点与光照估计思路
阴影并不是单纯把某个区域变暗。实际场景中至少存在硬阴影、软阴影和自阴影三类。硬阴影边缘锐利、明度下降剧烈,软阴影边界模糊且明度变化较缓,自阴影则是物体自身遮挡光线形成的暗部。不同类型的阴影对检测方法的要求差异很大,单一阈值很难同时处理。
光照估计在这类任务里并不是要精确重建光源位置或强度,而是要获得阴影区域与相邻非阴影区域之间的光照比例关系。常见的一种思路是Retinex模型,它假设图像可以分解为反射率与照度两个分量,阴影主要改变照度分量,反射率保持不变。基于这个假设,如果能从视频帧中估计背景反射率,再比较当前帧与背景帧的亮度差异,就能得到阴影候选区。
另一类更实用的方法直接利用颜色空间和梯度特征。阴影覆盖时,像素明度通常下降,饱和度也会出现变化,而色相相对稳定。梯度方向则对光照变化具有更好的鲁棒性,因为阴影主要削弱梯度幅值,纹理方向不会轻易改变。光照补偿的目标则是在阴影候选掩码基础上,把阴影区域的亮度恢复到无阴影状态,同时避免改变纹理和色相。最简单的补偿是线性增益,但线性增益容易放大噪声;更稳的做法会引入边缘保持滤波或局部直方图匹配。
二、基于HSV与形态学的经典补偿实现
在固定摄像头视频中,背景建模是最自然的入口。将当前帧与背景帧转换到HSV空间后,阴影覆盖区域通常表现为明度V下降、饱和度S略微下降、色相H基本不变。利用这三个特征可以设计一个轻量级掩码检测器。它的优点是计算量小,容易部署在实时监控系统上。
以下代码演示了基于HSV的阴影掩码检测与补偿流程。函数接收当前帧和背景帧,返回补偿后的图像以及阴影掩码。
import cv2
import numpy as np
def remove_shadow_hsv(frame, background, alpha=0.4, beta=0.9, sat_thresh=60, hue_thresh=15):
frame_hsv = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV)
bg_hsv = cv2.cvtColor(background, cv2.COLOR_BGR2HSV)
h, s, v = cv2.split(frame_hsv)
bh, bs, bv = cv2.split(bg_hsv)
# 阴影候选:V 分量下降明显,饱和度下降,色相接近
v_ratio = v / (bv + 1e-6)
s_diff = s.astype(np.int16) - bs.astype(np.int16)
hue_diff = np.abs(h.astype(np.int16) - bh.astype(np.int16))
hue_diff = np.minimum(hue_diff, 180 - hue_diff)
mask = (v_ratio >= alpha) & (v_ratio <= beta) & \
(s_diff <= 0) & (np.abs(s_diff) <= sat_thresh) & \
(hue_diff <= hue_thresh)
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
mask = cv2.morphologyEx(mask.astype(np.uint8), cv2.MORPH_CLOSE, kernel)
mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel)
# 补偿:非阴影区域保持原值,阴影区域按亮度增益恢复
gain = (bv + 1e-6) / (v + 1e-6)
gain = np.clip(gain, 1.0, 3.0)
result = frame.copy()
for c in range(3):
result[:, :, c] = np.where(mask > 0, np.clip(frame[:, :, c] * gain, 0, 255), frame[:, :, c])
return result.astype(np.uint8), mask
函数里alpha和beta控制亮度下降比例的接受范围,sat_thresh和hue_thresh用来排除彩色物体。形态学闭运算先把细碎阴影连接起来,开运算再去掉误检噪点。补偿部分使用bv与v的比值作为增益,并限制在1.0到3.0之间,避免阴影边界出现过度提亮。
这套实现适合固定相机、软阴影占主导的场景,例如室内监控和阴天交通视频。缺点是强烈阳光下的硬阴影边界清晰、内部明度下降剧烈,容易和其他暗色运动物体混淆;同时当背景更新不及时时,静止物体移动后留下的伪阴影也会被误检。实际使用中通常会把背景建模和阴影掩码更新放在同一个循环里,让背景缓慢适应光照变化。
三、结合梯度与帧间信息的改进方法
HSV空间对颜色偏移比较敏感,当阴影带有较强环境光染色时,色相条件容易失效。梯度方向特征则更鲁棒,因为阴影主要削弱梯度幅值,而纹理方向在阴影覆盖前后变化不大。通过比较当前帧与背景帧的梯度方向余弦相似度,再结合能量比值,可以把阴影与真正移动物体区分开。
以下代码利用Sobel算子计算两个帧的梯度方向,并输出阴影掩码。相比单纯基于亮度比值的方法,它在纹理较弱的区域也能给出更稳定的判断。
import cv2
import numpy as np
def gradient_shadow_mask(frame, background, grad_thresh=0.6):
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
bg_gray = cv2.cvtColor(background, cv2.COLOR_BGR2GRAY)
gx = cv2.Sobel(gray, cv2.CV_32F, 1, 0, ksize=3)
gy = cv2.Sobel(gray, cv2.CV_32F, 0, 1, ksize=3)
bgx = cv2.Sobel(bg_gray, cv2.CV_32F, 1, 0, ksize=3)
bgy = cv2.Sobel(bg_gray, cv2.CV_32F, 0, 1, ksize=3)
mag = np.sqrt(gx ** 2 + gy ** 2)
bg_mag = np.sqrt(bgx ** 2 + bgy ** 2)
# 用点积计算方向一致性,避免除零
dot = gx * bgx + gy * bgy
denom = mag * bg_mag + 1e-6
cos_sim = dot / denom
# 阴影区域:梯度方向一致,但当前梯度能量低于背景
energy_ratio = (mag + 1e-6) / (bg_mag + 1e-6)
mask = (cos_sim > 0.8) & (energy_ratio < 0.7)
mask = cv2.morphologyEx(mask.astype(np.uint8), cv2.MORPH_CLOSE, np.ones((7, 7), np.uint8))
return mask
这段代码先分别计算两帧的水平、垂直梯度,直接用点积得到方向一致性,避免除零。当方向相似度高于0.8且当前梯度能量不足背景的70%时,该像素被认为是阴影。形态学闭运算使用7乘7的矩形核,有助于连接阴影内部的小孔洞。需要注意,快速移动的物体会在梯度上表现为方向不一致,因此不会被误判为阴影,这是它优于纯HSV方法的地方。
对于视频序列,单帧掩码还会出现闪烁和边界抖动。常见做法是对连续帧的掩码做指数移动平均,或者对每个连通域跟踪并用卡尔曼滤波平滑面积与中心位置。补偿阶段如果直接使用线性增益,阴影边界可能出现明显光晕,换成边缘保持滤波或泊松重建能在保持纹理的同时平滑亮度过渡。帧间信息还可以帮助处理软阴影:软阴影边界在单帧里很难确定,但结合前后几帧的亮度变化趋势,可以更稳定地估计阴影扩散范围。
四、深度学习方案与工程落地注意事项
深度学习通常把视频阴影去除建模成图像到图像的翻译任务。输入至少包含当前帧和背景帧,部分模型还会显式输入阴影掩码或光照先验;输出是与当前帧对齐的无阴影图像。UNet结构用于保留空间细节,生成对抗网络用来增强视觉自然度,Transformer模块则能建模更大范围的上下文关系。训练数据常用ISTD、SRD等静态阴影数据集,视频场景还会引入时间维度。
对于视频处理,纯单帧推理会忽略时间一致性,出现相邻帧亮度跳变。可以在网络中插入ConvLSTM或光流对齐模块,让模型利用多帧信息稳定输出。以下是一个简化的推理函数示例,假设模型已经训练完成并接收当前帧和背景帧两个输入。
import torch
import numpy as np
def infer_shadow_removal(model, frame, background, device):
model.eval()
x = torch.from_numpy(frame).permute(2, 0, 1).float().unsqueeze(0) / 255.0
bg = torch.from_numpy(background).permute(2, 0, 1).float().unsqueeze(0) / 255.0
with torch.no_grad():
y = model(x.to(device), bg.to(device))
out = y.squeeze(0).permute(1, 2, 0).cpu().numpy()
return (out * 255).astype('uint8')
工程落地时需要考虑推理延迟、内存占用和模型泛化能力。如果场景光照相对稳定、摄像头固定,先用HSV或梯度方法产生候选掩码,再用轻量学习模型进行补偿,往往比端到端大模型更可控。对于强阴影、复杂纹理和动态背景并存的室外场景,学习方法的召回率更高,但需要大量标注数据,并且在边缘设备上要配合量化与TensorRT或ONNX Runtime加速。
还有一点容易忽略:阴影去除后的结果不能直接替代原始视频用于所有任务。若下游是目标检测,保留阴影信息有时反而有助于判断物体高度和接触面。是否需要去除阴影,应当根据后续算法对光照变化的敏感程度来决策。实际项目中更推荐把阴影掩码作为辅助通道送给下游模型,而不是在预处理阶段完全抹掉阴影。