:從OpenCV閾值法到Retinex與U-Net)
簡介這份資源面向數(shù)字圖像處理學(xué)習(xí)者、課程設(shè)計選題學(xué)生及需要處理陰影干擾的開發(fā)者聚焦陰影檢測與去除這一經(jīng)典難題。陰影普遍存在于自然圖像中會對特征提取、圖像識別與分割造成干擾資源通過Python實現(xiàn)從陰影檢測到去除的完整流程輸入一張圖片即可判斷是否存在陰影并嘗試消除。壓縮包共16個文件約10.13MB包含Python主程序與模塊、效果對比圖片、實驗報告文檔、演示幻燈片及說明文件覆蓋代碼、數(shù)據(jù)、報告與展示材料便于理解算法思路與實驗結(jié)論。目前已有435人學(xué)習(xí)下載適合作為課程設(shè)計參考或圖像處理入門實踐。讀者可從中獲得可運行的陰影檢測與去除腳本、SBU數(shù)據(jù)集相關(guān)實驗素材、效果對比圖與報告撰寫思路并借助幻燈片快速梳理方法框架為后續(xù)圖像預(yù)處理與視覺任務(wù)提供可復(fù)用的排錯與調(diào)參參考。1. 陰影檢測與去除為什么你的 OpenCV 閾值法一換場景就翻車做過數(shù)字圖像處理的人都遇到過這種場景一段戶外監(jiān)控視頻陽光把電線桿的影子投在路面上你想把陰影去掉還原路面紋理于是用 HSV 空間卡了個 V 通道閾值實驗室里效果不錯換到另一段視頻陰影沒去掉路面反光倒被當(dāng)成陰影抹掉了。陰影檢測與去除這件事難就難在陰影和暗色物體在像素層面幾乎無法區(qū)分——兩者都暗但一個是光照被遮擋一個是本身反射率低。這個方向在智能交通、遙感影像、視頻監(jiān)控、自動駕駛感知里都有實際需求。基于 Python 實現(xiàn)的好處是生態(tài)成熟OpenCV 做底層像素操作NumPy 做矩陣運算scikit-image 做形態(tài)學(xué)和區(qū)域分析整套鏈路不需要編譯 C 就能跑通。本文面向的是有 Python 基礎(chǔ)、想把這個方向真正落地到項目里的從業(yè)者從物理模型講到可復(fù)現(xiàn)的代碼再到參數(shù)怎么調(diào)、坑在哪。讀完你應(yīng)該能自己搭一條從陰影檢測到去除的完整流水線并且知道什么場景下這套方法會失效。2. 陰影的物理模型與檢測路線選型從光照反射到可計算特征2.1 陰影在像素上到底長什么樣要檢測陰影先得理解陰影是怎么形成的。光照模型里傳感器接收到的像素值可以近似寫成I(x, y) R(x, y) × L(x, y)其中 R 是物體表面的反射率L 是到達該點的光照強度。陰影區(qū)域的特點是 L 被遮擋物降低了但 R 沒變。暗色物體則是 R 本身就低。這就是為什么單純看亮度無法區(qū)分二者——你需要找到「R 沒變但 L 變了」的證據(jù)。實際可用的證據(jù)有幾類。第一類是色度不變性陰影通常不改變色相只是降低亮度和飽和度而暗色物體往往有自己獨立的色相分布。第二類是紋理一致性陰影覆蓋區(qū)域的紋理梯度和周圍非陰影區(qū)域應(yīng)該相似因為反射率沒變。第三類是邊界特征陰影邊界通常有較明顯的亮度躍變且邊界兩側(cè)色度接近。第四類是幾何先驗如果知道光源方向陰影方向應(yīng)該和光源一致。工程上不會只用一種特征常見做法是把色度、紋理、亮度三類特征組合成一個特征向量再用分類器或規(guī)則判斷。但組合特征會帶來維度災(zāi)難和調(diào)參困難所以選型時要根據(jù)場景取舍。2.2 三條主流技術(shù)路線的適用邊界目前基于 Python 能落地的陰影檢測路線大致三條路線一基于色彩空間的閾值法。在 HSV 或 Lab 空間設(shè)定亮度比和色度差閾值。優(yōu)點是快單幀處理在毫秒級缺點是對光照變化極其敏感閾值換場景就得重調(diào)。適合光照穩(wěn)定、場景固定的工業(yè)檢測。路線二基于紋理和區(qū)域的方法。用局部二值模式LBP或 Gabor 濾波提取紋理特征再結(jié)合區(qū)域生長或圖割做陰影區(qū)域分割。優(yōu)點是魯棒性比閾值法好能處理一定程度的暗色物體干擾缺點是計算量大參數(shù)多實時性差。適合離線處理或?qū)纫蟾叩倪b感影像。路線三基于深度學(xué)習(xí)的方法。用 U-Net 或 Mask R-CNN 做陰影分割再用 GAN 或 Retinex 做去除。優(yōu)點是泛化能力強缺點是需要標(biāo)注數(shù)據(jù)訓(xùn)練成本高部署時對硬件有要求。適合有數(shù)據(jù)積累、追求端到端效果的團隊。我一般會建議如果場景固定、實時性要求高先走路線一把參數(shù)調(diào)透如果場景多變但算力有限走路線二用 LBP 加形態(tài)學(xué)后處理如果有標(biāo)注數(shù)據(jù)且能接受 GPU 推理直接上路線三。下面兩章分別展開路線一和路線二的實現(xiàn)路線三在最后一章講進階思路。2.3 環(huán)境準備與最小可跑通示例先把環(huán)境搭起來。Python 3.8 以上裝 OpenCV、NumPy、scikit-image、matplotlibpip install opencv-python numpy scikit-image matplotlib如果你用 VSCode 或 PyCharm配好解釋器后直接跑下面的最小示例。這段代碼讀一張圖轉(zhuǎn) HSV用亮度比和飽和度比做初步陰影掩膜import cv2 import numpy as np def shadow_mask_hsv(img_bgr, brightness_ratio0.6, sat_ratio0.8): 基于 HSV 的陰影初步檢測 brightness_ratio: 陰影區(qū)亮度 / 背景亮度 的上限 sat_ratio: 陰影區(qū)飽和度 / 背景飽和度 的上限 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 # 形態(tài)學(xué)去噪 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)邏輯說明先轉(zhuǎn) HSV 分離亮度和飽和度通道用大核中值濾波估計「如果沒有陰影這個位置應(yīng)該有多亮/多飽和」然后算比值。陰影區(qū)亮度比和飽和度比都會偏低兩個條件同時滿足才判為陰影。參數(shù)說明brightness_ratio默認 0.6意思是陰影區(qū)亮度不到背景的 60%sat_ratio默認 0.8。這兩個值需要根據(jù)你的數(shù)據(jù)調(diào)后面避坑章節(jié)會講怎么調(diào)。提示中值濾波的核大小 31 是個經(jīng)驗值圖像分辨率高時要加大否則背景估計會跟著陰影走導(dǎo)致漏檢。3. 從掩膜到去除用 Retinex 和紋理填充把陰影區(qū)域還原3.1 陰影去除的兩條思路光照補償 vs 紋理合成檢測出陰影掩膜后去除有兩條路。第一條是光照補償認為陰影區(qū)只是光照被衰減把光照補回去就行。經(jīng)典方法是 Retinex 分解把圖像拆成反射率和光照兩部分只對光照做補償。第二條是紋理合成如果陰影區(qū)紋理丟失嚴重直接從周圍非陰影區(qū)采樣紋理填充類似圖像修復(fù)inpainting。實際工程里我一般先用 Retinex 做全局補償再用 inpainting 修補殘留的邊界偽影。單用 Retinex 在陰影邊緣容易產(chǎn)生光暈單用 inpainting 在陰影面積大時會出現(xiàn)紋理重復(fù)。兩者結(jié)合效果最穩(wěn)。3.2 Retinex 光照補償?shù)?Python 實現(xiàn)單尺度 Retinex 的核心是對數(shù)域做減法def retinex_shadow_removal(img_bgr, mask, sigma30): 對陰影區(qū)域做單尺度 Retinex 光照補償 sigma: 高斯核標(biāo)準差控制光照估計的平滑程度 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 # 對數(shù)域相減得到反射率 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) # 只在陰影區(qū)域用 Retinex 結(jié)果非陰影區(qū)保留原圖 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 假設(shè)圖像 反射率 × 光照取對數(shù)后變成加法減去高斯模糊估計的光照就得到反射率。這里對每個通道獨立做最后歸一化。關(guān)鍵在最后一步——只在陰影掩膜內(nèi)替換非陰影區(qū)保持原樣避免整圖色彩偏移。參數(shù)說明sigma控制光照估計的尺度值越大光照估計越平滑陰影補償越柔和但可能欠補償值越小補償越激進但容易在邊緣產(chǎn)生光暈。一般從 30 開始試圖像分辨率高時加到 50-80。3.3 邊界偽影修復(fù)與 inpainting 后處理Retinex 補償后陰影邊界往往還有一圈不自然的過渡。用 OpenCV 的 inpaint 對邊界帶做修復(fù)def refine_boundary(img_bgr, mask, dilate_iter2): 對陰影邊界做 inpainting 修復(fù) dilate_iter: 掩膜膨脹次數(shù)控制修復(fù)帶寬 kernel cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3)) # 膨脹掩膜得到邊界帶 mask_dilated cv2.dilate(mask, kernel, iterationsdilate_iter) # 邊界帶 膨脹后 - 原始 boundary cv2.subtract(mask_dilated, mask) # 用 Telea 算法修復(fù)邊界帶 result cv2.inpaint(img_bgr, boundary, 3, cv2.INPAINT_TELEA) return result邏輯說明先把掩膜膨脹幾圈減去原始掩膜就得到邊界帶。用 inpaint 對邊界帶做修復(fù)Telea 算法適合小區(qū)域快速修復(fù)。參數(shù)說明dilate_iter控制修復(fù)帶寬一般 2-3 就夠太大反而會把陰影內(nèi)部紋理也抹掉。inpaint的第三個參數(shù) 3 是修復(fù)半徑邊界帶窄時用 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 區(qū)域面積。如果要做視頻實時處理需要把中值濾波換成積分圖加速或者降分辨率處理后再上采樣。注意Retinex 對彩色圖像做逐通道處理會改變色相如果對色彩保真度要求高應(yīng)該在 Lab 空間只對 L 通道做補償a、b 通道保持不變。4. 避坑與排查陰影檢測去除中最容易翻車的五個地方4.1 現(xiàn)象陰影沒檢出來暗色物體反而被標(biāo)成陰影原因HSV 閾值法只看亮度和飽和度暗色物體比如黑色汽車、深色路面的亮度和飽和度本來就低和陰影特征重疊。解決加入色度一致性判斷。陰影區(qū)的色相應(yīng)該和周圍背景接近而暗色物體往往有獨立色相。在掩膜生成后加一步色相距離過濾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) # 色相距離環(huán)形距離 diff np.abs(h - h_bg) diff np.minimum(diff, 180 - diff) # 只保留色相接近的區(qū)域 mask cv2.bitwise_and(mask, (diff hue_thresh).astype(np.uint8) * 255) return maskhue_thresh默認 20OpenCV 的 H 通道范圍是 0-18020 大約對應(yīng) 40 度色相角。如果場景里陰影和背景色相差很大比如綠草地上的陰影這個值要放寬到 30。4.2 現(xiàn)象陰影邊緣有光暈過渡不自然原因Retinex 的高斯核尺度選擇不當(dāng)。核太小光照估計跟著陰影邊界走補償后在邊界產(chǎn)生亮環(huán)核太大光照估計過于平滑陰影內(nèi)部補償不足。解決用多尺度 RetinexMSR把多個 sigma 的結(jié)果加權(quán)平均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 分別覆蓋細節(jié)、中尺度、大尺度光照加權(quán)平均后光暈明顯減輕。代價是計算量翻三倍。4.3 現(xiàn)象視頻處理時幀間閃爍陰影掩膜跳變原因逐幀獨立檢測沒有利用時域信息。相鄰幀之間光照微小變化就會導(dǎo)致掩膜邊界跳動。解決加時域平滑。對掩膜做滑動窗口平均或者用卡爾曼濾波跟蹤陰影區(qū)域。簡單做法是維護一個掩膜隊列取最近 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 現(xiàn)象inpaint 后陰影區(qū)域紋理模糊像糊了一層原因inpaint 算法在區(qū)域較大時只能做擴散填充無法恢復(fù)真實紋理。Telea 算法適合小區(qū)域大區(qū)域會變成一片模糊。解決大陰影區(qū)域不要用 inpaint改用紋理合成。簡單做法是從陰影周圍非陰影區(qū)隨機采樣 patch粘貼到陰影區(qū)?;蛘哂?OpenCV 的cv2.inpaint配合cv2.INPAINT_NSNavier-Stokes算法在紋理區(qū)域效果比 Telea 好result cv2.inpaint(img_bgr, boundary, 5, cv2.INPAINT_NS)如果陰影面積超過圖像 10%建議直接上深度學(xué)習(xí)修復(fù)模型傳統(tǒng)方法很難做好。4.5 現(xiàn)象調(diào)好的參數(shù)換一段視頻就失效原因閾值法對光照條件敏感不同時段、不同天氣、不同相機的圖像亮度分布差異很大。解決做自適應(yīng)閾值。不要寫死 0.6而是根據(jù)圖像整體亮度分布動態(tài)計算。比如取圖像亮度直方圖的某個百分位數(shù)作為基準def adaptive_brightness_ratio(img_bgr, percentile30): hsv cv2.cvtColor(img_bgr, cv2.COLOR_BGR2HSV) v hsv[:, :, 2] # 用低百分位數(shù)估計陰影區(qū)亮度高百分位數(shù)估計背景亮度 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的初始值再根據(jù)實際效果微調(diào)。自適應(yīng)方法不能保證所有場景都完美但比固定閾值魯棒得多。5. 進階用輕量 U-Net 做陰影分割以及怎么驗證效果5.1 為什么傳統(tǒng)方法到頭了要轉(zhuǎn)深度學(xué)習(xí)前面講的閾值法加 Retinex在固定場景下能跑出不錯的效果但一旦場景變復(fù)雜——多光源、半透明陰影、軟陰影、彩色陰影——規(guī)則方法就捉襟見肘。深度學(xué)習(xí)的優(yōu)勢在于它能從數(shù)據(jù)里學(xué)到「陰影長什么樣」的高層語義而不是依賴人工設(shè)計的低層特征。但我不建議一上來就上大模型。陰影分割是個二分類密集預(yù)測任務(wù)輕量 U-Net 就夠了。輸入 RGB 三通道輸出單通道概率圖編碼器用 MobileNetV2 或自己搭 4 層下采樣解碼器對稱上采樣加跳連。參數(shù)量控制在 2M 以內(nèi)CPU 推理也能到 5-10 FPS。訓(xùn)練數(shù)據(jù)方面公開數(shù)據(jù)集有 SBU、UCF 陰影數(shù)據(jù)集但場景和你的實際數(shù)據(jù)可能不匹配。我的經(jīng)驗是先用公開數(shù)據(jù)預(yù)訓(xùn)練再用自己標(biāo)注的 200-500 張圖微調(diào)效果比純用公開數(shù)據(jù)好很多。標(biāo)注不用像素級精細用多邊形粗略框出陰影區(qū)域即可U-Net 對標(biāo)注噪聲有一定容忍度。5.2 陰影去除效果怎么量化驗證做去除不能只看「看起來好了」得有量化指標(biāo)。常用的三個指標(biāo)含義適用場景RMSE去除后圖像與無陰影參考圖的均方根誤差有配對參考圖時PSNR峰值信噪比越高越好有配對參考圖時SSIM結(jié)構(gòu)相似度衡量紋理結(jié)構(gòu)保留程度無配對參考圖時可用實際項目中很難拿到「同一場景無陰影」的參考圖所以 SSIM 更常用。做法是在陰影區(qū)域和周圍非陰影區(qū)域各取一個 patch算它們的 SSIM去除后這個值應(yīng)該升高。另外可以算陰影邊界的梯度一致性去除后邊界梯度應(yīng)該更平滑。from skimage.metrics import structural_similarity as ssim def evaluate_removal(original, removed, mask): # 在陰影區(qū)域取 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這個分數(shù)不是絕對標(biāo)準但可以用來對比不同參數(shù)或不同方法的效果。我一般會跑一組對比原始圖、閾值法去除、Retinex 去除、U-Net 去除看哪個 SSIM 最高。5.3 一個我踩過的坑別在 RGB 空間直接做 Retinex早期我圖省事直接在 RGB 三個通道上做 Retinex結(jié)果去除后陰影區(qū)域顏色偏紫。原因是 RGB 通道間有強相關(guān)性逐通道對數(shù)相減會破壞白平衡。后來改成在 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 版的區(qū)別就一處只處理 L 通道a、b 原樣保留。如果你做的是彩色圖像陰影去除我強烈建議用這個版本。做這個方向這幾年我最大的習(xí)慣是每換一個數(shù)據(jù)集先跑一遍直方圖分析看亮度和色相分布再決定閾值范圍。不要迷信任何一組「萬能參數(shù)」陰影檢測這件事場景適配比算法選型更重要。希望幫到你。本文還有配套的精品資源點擊獲取