構(gòu)光三維重建:12步相移與互補(bǔ)格雷碼全流程)
簡介這份資源是一套基于雙目視覺與結(jié)構(gòu)光投影的三維重建完整項(xiàng)目面向計(jì)算機(jī)視覺學(xué)習(xí)者、課程設(shè)計(jì)或科研入門者解決從相機(jī)標(biāo)定到點(diǎn)云生成的工程落地問題。項(xiàng)目采用12步相移法與互補(bǔ)格雷碼實(shí)現(xiàn)高精度相位解包裹通過雙目標(biāo)定獲取內(nèi)外參數(shù)并完成極線校正再結(jié)合相位匹配與視差計(jì)算輸出三維點(diǎn)云模型覆蓋結(jié)構(gòu)光與立體視覺兩條技術(shù)路線。壓縮包共89個(gè)文件約104.39MB以66張bmp采集圖像和6張png說明圖為主輔以xml標(biāo)定參數(shù)、py與ipynb源碼腳本、docx與md說明文檔便于對(duì)照代碼與數(shù)據(jù)復(fù)現(xiàn)流程。已有149人學(xué)習(xí)下載。讀者可獲得可運(yùn)行的main.py主流程、explain.ipynb逐步講解、標(biāo)定與相位計(jì)算模塊及結(jié)果圖適合按目錄結(jié)構(gòu)逐模塊調(diào)試快速理解相位解包裹、極線校正與視差計(jì)算的實(shí)現(xiàn)細(xì)節(jié)。1. 雙目結(jié)構(gòu)光三維重建從12步相移到點(diǎn)云落地的完整路徑雙目視覺加結(jié)構(gòu)光投影做三維重建是工業(yè)檢測、逆向工程和文物數(shù)字化里非常成熟的一條路線。它解決的核心問題是單目相機(jī)只能拍到紋理拍不到深度純雙目立體匹配在弱紋理或高反光表面會(huì)大面積翻車而結(jié)構(gòu)光投影主動(dòng)往物體表面打編碼條紋讓每個(gè)像素都有可解碼的相位信息再配合雙目標(biāo)定得到的極線約束就能把亞毫米級(jí)的深度算出來。12步相移法負(fù)責(zé)把相位精度推到極致互補(bǔ)格雷碼負(fù)責(zé)把相位從[-π, π]展開到全局唯一兩者配合是目前高精度面結(jié)構(gòu)光系統(tǒng)里最穩(wěn)的組合之一。這套方案適合做精密零件檢測、葉片型面測量、PCB焊點(diǎn)高度分析的工程師也適合想從零搭一套結(jié)構(gòu)光系統(tǒng)的研究生。整條鏈路的關(guān)鍵不在算法多新而在標(biāo)定、解碼、匹配三個(gè)環(huán)節(jié)的誤差控制。2. 雙目標(biāo)定與極線校正把兩個(gè)相機(jī)變成一臺(tái)虛擬相機(jī)2.1 為什么標(biāo)定精度直接決定重建上限雙目標(biāo)定的本質(zhì)是求兩個(gè)相機(jī)各自的內(nèi)參矩陣和兩者之間的外參關(guān)系。內(nèi)參包括焦距、主點(diǎn)、畸變系數(shù)外參是右相機(jī)相對(duì)左相機(jī)的旋轉(zhuǎn)矩陣R和平移向量T。標(biāo)定誤差會(huì)以乘性方式傳播到最終點(diǎn)云如果重投影誤差在0.3像素在1米工作距離、5毫米基線條件下深度誤差大約在0.5到1毫米量級(jí)。很多人標(biāo)定完只看重投影誤差就收工忽略了極線校正后的殘差結(jié)果后面視差匹配怎么調(diào)都不對(duì)。常見做法是用棋盤格或圓點(diǎn)標(biāo)定板采集15到25對(duì)圖像覆蓋畫面中心和四角。標(biāo)定板姿態(tài)要多樣傾斜角度至少覆蓋正負(fù)30度否則外參中的旋轉(zhuǎn)分量約束不足。我一般會(huì)額外檢查校正后左右圖像的極線對(duì)齊程度用一張帶水平紋理的板子拍一張校正后左右圖同一特征點(diǎn)應(yīng)該在同一行偏差超過0.5像素就要重新標(biāo)。2.2 用OpenCV做雙目標(biāo)定與校正的完整命令import cv2 import numpy as np import glob # 棋盤格參數(shù)內(nèi)角點(diǎn)數(shù)量根據(jù)實(shí)際標(biāo)定板修改 chessboard_size (11, 8) square_size 3.0 # 毫米 # 生成棋盤格世界坐標(biāo) objp np.zeros((chessboard_size[0] * chessboard_size[1], 3), np.float32) objp[:, :2] np.mgrid[0:chessboard_size[0], 0:chessboard_size[1]].T.reshape(-1, 2) objp * square_size objpoints [] imgpoints_left [] imgpoints_right [] left_images sorted(glob.glob(calib/left/*.png)) right_images sorted(glob.glob(calib/right/*.png)) for lpath, rpath in zip(left_images, right_images): img_l cv2.imread(lpath, cv2.IMREAD_GRAYSCALE) img_r cv2.imread(rpath, cv2.IMREAD_GRAYSCALE) ret_l, corners_l cv2.findChessboardCorners(img_l, chessboard_size, None) ret_r, corners_r cv2.findChessboardCorners(img_r, chessboard_size, None) if ret_l and ret_r: criteria (cv2.TERM_CRITERIA_EPS cv2.TERM_CRITERIA_MAX_ITER, 30, 0.001) corners_l cv2.cornerSubPix(img_l, corners_l, (5, 5), (-1, -1), criteria) corners_r cv2.cornerSubPix(img_r, corners_r, (5, 5), (-1, -1), criteria) objpoints.append(objp) imgpoints_left.append(corners_l) imgpoints_right.append(corners_r) # 單目標(biāo)定獲取初始內(nèi)參 ret_l, mtx_l, dist_l, _, _ cv2.calibrateCamera(objpoints, imgpoints_left, img_l.shape[::-1], None, None) ret_r, mtx_r, dist_r, _, _ cv2.calibrateCamera(objpoints, imgpoints_right, img_r.shape[::-1], None, None) # 雙目標(biāo)定 flags cv2.CALIB_FIX_INTRINSIC criteria_stereo (cv2.TERM_CRITERIA_EPS cv2.TERM_CRITERIA_MAX_ITER, 100, 1e-5) ret, mtx_l, dist_l, mtx_r, dist_r, R, T, E, F cv2.stereoCalibrate( objpoints, imgpoints_left, imgpoints_right, mtx_l, dist_l, mtx_r, dist_r, img_l.shape[::-1], criteriacriteria_stereo, flagsflags ) # 極線校正 R1, R2, P1, P2, Q, roi1, roi2 cv2.stereoRectify( mtx_l, dist_l, mtx_r, dist_r, img_l.shape[::-1], R, T, alpha0, # alpha0裁剪黑邊alpha1保留全部像素 newImageSizeimg_l.shape[::-1] ) # 保存映射表用于后續(xù)實(shí)時(shí)校正 map1_l, map2_l cv2.initUndistortRectifyMap(mtx_l, dist_l, R1, P1, img_l.shape[::-1], cv2.CV_32FC1) map1_r, map2_r cv2.initUndistortRectifyMap(mtx_r, dist_r, R2, P2, img_r.shape[::-1], cv2.CV_32FC1) np.savez(stereo_params.npz, mtx_lmtx_l, dist_ldist_l, mtx_rmtx_r, dist_rdist_r, RR, TT, R1R1, R2R2, P1P1, P2P2, QQ, map1_lmap1_l, map2_lmap2_l, map1_rmap1_r, map2_rmap2_r)這段代碼先做單目標(biāo)定拿到初始內(nèi)參再用CALIB_FIX_INTRINSIC固定內(nèi)參只優(yōu)化外參避免雙目標(biāo)定在圖像對(duì)不足時(shí)把內(nèi)參也帶偏。alpha0會(huì)裁掉校正后的無效區(qū)域適合后續(xù)做稠密匹配如果要做全視場重建就設(shè)alpha1但邊緣會(huì)有黑邊需要掩膜。Q矩陣是后面從視差圖轉(zhuǎn)三維點(diǎn)云的關(guān)鍵必須保存。2.3 校正效果怎么驗(yàn)證校正完不要直接往下走先做兩件事。第一用cv2.remap把左右原圖校正疊加顯示看同一物理點(diǎn)在左右圖中是否在同一水平線。第二用cv2.stereoRectify返回的roi1和roi2裁剪有效區(qū)域記錄裁剪后的圖像尺寸和主點(diǎn)偏移后面視差轉(zhuǎn)深度時(shí)要用到。如果校正后極線偏差大于1像素檢查標(biāo)定板圖像是否有運(yùn)動(dòng)模糊或者標(biāo)定板平面度是否夠。3. 12步相移與互補(bǔ)格雷碼把相位解包裹做扎實(shí)3.1 相移法為什么選12步而不是4步相移法的基本原理是投影N幅相位間隔2π/N的正弦條紋通過N幅圖的灰度值解出包裹相位。4步相移的相位誤差對(duì)灰度噪聲敏感信噪比低的時(shí)候相位波動(dòng)能到0.1弧度以上。12步相移把相位誤差降到4步的約三分之一代價(jià)是采集時(shí)間變成3倍。在靜態(tài)測量場景下這個(gè)代價(jià)完全值得。12步的相位解算公式是φ atan2( Σ I_k · sin(2πk/12), Σ I_k · cos(2πk/12) )其中I_k是第k幅圖的灰度值。這個(gè)公式對(duì)二次諧波有很好的抑制投影儀非線性帶來的誤差會(huì)明顯小于4步法。3.2 互補(bǔ)格雷碼怎么編解碼格雷碼負(fù)責(zé)把包裹相位展開成全局相位。普通二進(jìn)制碼在碼字邊界處容易因?yàn)樵肼暜a(chǎn)生跳變錯(cuò)誤格雷碼相鄰碼字只差一位邊界容錯(cuò)更好?;パa(bǔ)格雷碼是同時(shí)投影正碼和反碼解碼時(shí)用正反碼的差值做閾值判斷能進(jìn)一步壓低誤碼率。具體做法對(duì)投影儀的每一列根據(jù)條紋周期數(shù)分配格雷碼字。假設(shè)用8位格雷碼可以區(qū)分256個(gè)周期。每個(gè)周期對(duì)應(yīng)包裹相位從-π到π。解碼時(shí)先解出包裹相位再根據(jù)格雷碼值確定周期序號(hào)k全局相位Φ φ 2πk。import numpy as np def decode_phase(images): images: 12幅相移圖shape(12, H, W) H, W images.shape[1], images.shape[2] I images.astype(np.float64) sin_sum np.zeros((H, W)) cos_sum np.zeros((H, W)) for k in range(12): angle 2 * np.pi * k / 12 sin_sum I[k] * np.sin(angle) cos_sum I[k] * np.cos(angle) phase np.arctan2(sin_sum, cos_sum) # 包裹相位范圍[-pi, pi] return phase def decode_graycode(gray_images, threshold0.5): gray_images: 正反格雷碼圖shape(2*N, H, W)前N張正碼后N張反碼 N len(gray_images) // 2 H, W gray_images.shape[1], gray_images.shape[2] code np.zeros((H, W), dtypenp.int32) for i in range(N): pos gray_images[i].astype(np.float64) neg gray_images[i N].astype(np.float64) # 正反碼差值判斷比單閾值更穩(wěn) bit (pos - neg) 0 code (code 1) | bit.astype(np.int32) # 格雷碼轉(zhuǎn)二進(jìn)制 binary code.copy() for shift in range(1, N): binary ^ (code shift) return binary def unwrap_phase(wrapped_phase, binary_code, periods256): 全局相位 包裹相位 2*pi*k k binary_code.astype(np.float64) / periods global_phase wrapped_phase 2 * np.pi * k return global_phasedecode_phase里用atan2解包裹相位注意sin_sum和cos_sum同時(shí)接近零的區(qū)域是無效點(diǎn)要加掩膜。decode_graycode用正反碼差值做判斷比直接對(duì)單幅圖取閾值抗噪能力強(qiáng)很多。unwrap_phase里的periods要和投影條紋總周期數(shù)一致這個(gè)參數(shù)設(shè)錯(cuò)會(huì)導(dǎo)致全局相位整體偏移點(diǎn)云直接變形。3.3 相位解包裹的邊界處理格雷碼解碼在物體邊緣和陰影區(qū)域容易出錯(cuò)表現(xiàn)為相位跳變或噪點(diǎn)。常見做法是加一個(gè)置信度掩膜正反碼差值絕對(duì)值小于某個(gè)閾值的像素標(biāo)記為不可靠不參與后續(xù)匹配。另外格雷碼的碼字邊界和相移條紋的周期邊界要對(duì)齊否則在周期切換處會(huì)出現(xiàn)半周期錯(cuò)位。我一般會(huì)在投影圖案生成階段就把兩者的相位零點(diǎn)對(duì)齊省去后面調(diào)。4. 相位匹配與視差計(jì)算從全局相位到三維點(diǎn)云4.1 相位匹配為什么比灰度匹配穩(wěn)傳統(tǒng)雙目立體匹配用灰度或梯度做代價(jià)在弱紋理區(qū)域基本失效。結(jié)構(gòu)光把每個(gè)像素編碼成全局相位值左右圖中同一物點(diǎn)的全局相位理論上相等匹配就變成在極線上找相位最接近的點(diǎn)。這個(gè)約束比灰度強(qiáng)得多因?yàn)橄辔皇沁B續(xù)量對(duì)光照變化和表面顏色不敏感。匹配策略一般有兩種一是對(duì)左圖每個(gè)像素在右圖極線上搜索相位差最小的點(diǎn)二是先做粗匹配用格雷碼碼字定位周期再在周期內(nèi)用包裹相位精定位。第二種效率高很多因?yàn)樗阉鞣秶鷱恼麠l極線縮小到一個(gè)周期內(nèi)。4.2 視差轉(zhuǎn)三維坐標(biāo)的完整實(shí)現(xiàn)import numpy as np import cv2 def phase_to_disparity(global_phase_left, global_phase_right, mask): 基于全局相位的視差計(jì)算 H, W global_phase_left.shape disparity np.zeros((H, W), dtypenp.float32) for y in range(H): valid_x np.where(mask[y])[0] if len(valid_x) 0: continue phase_row global_phase_right[y] for x in valid_x: target global_phase_left[y, x] # 在右圖同一行搜索相位最接近的點(diǎn) diff np.abs(phase_row - target) diff[~mask[y]] np.inf best_x np.argmin(diff) if diff[best_x] 0.1: # 相位差閾值 disparity[y, x] x - best_x return disparity def disparity_to_pointcloud(disparity, Q): 用Q矩陣把視差圖轉(zhuǎn)成三維點(diǎn)云 points_3d cv2.reprojectImageTo3D(disparity, Q) mask disparity 0 cloud points_3d[mask] colors None # 如果有紋理圖可以在這里取顏色 return cloud, maskphase_to_disparity里逐行搜索相位最接近的點(diǎn)0.1弧度的閾值是經(jīng)驗(yàn)值相位噪聲大的系統(tǒng)可以放寬到0.2。disparity_to_pointcloud直接用OpenCV的reprojectImageTo3D它內(nèi)部用Q矩陣做齊次變換。注意視差圖里無效區(qū)域要置零或負(fù)值否則會(huì)生成飛點(diǎn)。4.3 點(diǎn)云后處理濾波與平滑原始點(diǎn)云一定有飛點(diǎn)和噪聲。常見做法是先用統(tǒng)計(jì)濾波去掉離群點(diǎn)再用半徑濾波平滑。PCL里對(duì)應(yīng)StatisticalOutlierRemoval和RadiusOutlierRemoval。如果做精密測量還要做平面擬合和基準(zhǔn)對(duì)齊把點(diǎn)云轉(zhuǎn)到工件坐標(biāo)系。這一步的精度直接決定最終測量結(jié)果不要跳過。5. 避坑與排查結(jié)構(gòu)光三維重建最常見的5個(gè)翻車點(diǎn)5.1 標(biāo)定重投影誤差很小但重建出來是彎的現(xiàn)象標(biāo)定報(bào)告重投影誤差0.2像素但重建一個(gè)標(biāo)準(zhǔn)球點(diǎn)云明顯橢化。原因通常是標(biāo)定板圖像覆蓋范圍不夠外參中的旋轉(zhuǎn)分量約束不足或者標(biāo)定板本身平面度差。解決重新采集標(biāo)定圖確保標(biāo)定板出現(xiàn)在畫面中心和四個(gè)角傾斜角度覆蓋正負(fù)30度以上換一塊高精度陶瓷標(biāo)定板。5.2 相位解包裹在物體邊緣出現(xiàn)大面積跳變現(xiàn)象格雷碼解碼后物體輪廓附近全局相位出現(xiàn)2π跳變點(diǎn)云在邊緣撕裂。原因是格雷碼碼字邊界和相移周期邊界沒對(duì)齊或者邊緣處正反碼差值太小導(dǎo)致誤判。解決在投影圖案生成時(shí)把格雷碼跳變沿對(duì)齊到相移條紋的相位零點(diǎn)解碼時(shí)對(duì)正反碼差值小于閾值的像素加掩膜。5.3 視差圖在弱紋理區(qū)域出現(xiàn)空洞現(xiàn)象白色陶瓷或金屬表面相位匹配找不到對(duì)應(yīng)點(diǎn)視差圖大片空洞。原因是這些表面反射率高相移圖過曝相位解算失效。解決降低投影亮度或縮短曝光時(shí)間讓相移圖灰度落在相機(jī)線性響應(yīng)區(qū)間也可以噴顯影劑或啞光粉但會(huì)引入厚度誤差。5.4 點(diǎn)云整體偏移或尺度不對(duì)現(xiàn)象重建的點(diǎn)云和實(shí)際工件尺寸差一個(gè)固定比例或者整體平移。原因是Q矩陣用的校正后主點(diǎn)和圖像尺寸不匹配或者標(biāo)定板方格尺寸設(shè)錯(cuò)。解決檢查stereoRectify返回的roi是否被正確應(yīng)用確認(rèn)標(biāo)定板實(shí)際方格尺寸和代碼里square_size一致重新生成Q矩陣。5.5 12步相移采集時(shí)間太長導(dǎo)致運(yùn)動(dòng)模糊現(xiàn)象靜態(tài)測量沒問題但產(chǎn)線上工件有微小振動(dòng)重建結(jié)果出現(xiàn)條紋狀噪聲。原因是12步相移需要采集12幅圖總曝光時(shí)間乘以12。解決用全局快門相機(jī)縮短單幅曝光時(shí)間提高投影亮度補(bǔ)償或者改用6步相移加互補(bǔ)格雷碼犧牲一點(diǎn)相位精度換速度。6. 進(jìn)階技巧用多頻外差和GPU加速把精度和速度同時(shí)拉滿12步相移加互補(bǔ)格雷碼已經(jīng)能覆蓋大部分高精度靜態(tài)測量場景但如果要兼顧速度和精度有兩個(gè)方向值得投入。第一個(gè)是多頻外差投影三種不同頻率的條紋用外差原理逐級(jí)展開相位可以只用較少步數(shù)達(dá)到和12步接近的精度。常見組合是3頻3步總共9幅圖比12步少3幅相位精度損失在可接受范圍內(nèi)。第二個(gè)是GPU加速相位解算和視差匹配都是逐像素操作天然適合并行。用CUDA或OpenCL把decode_phase和phase_to_disparity重寫在1080p分辨率下能把處理時(shí)間從秒級(jí)壓到毫秒級(jí)。我自己在產(chǎn)線項(xiàng)目里的習(xí)慣是先用12步相移把精度基準(zhǔn)打出來確認(rèn)整個(gè)鏈路誤差在指標(biāo)內(nèi)再逐步減步數(shù)或換多頻方案做加速。每次改完都要用同一個(gè)標(biāo)準(zhǔn)件復(fù)測看點(diǎn)云偏差是否還在公差帶內(nèi)。標(biāo)定參數(shù)和Q矩陣一定要版本化管理換鏡頭或動(dòng)相機(jī)之后必須重新標(biāo)定不要偷懶復(fù)用舊參數(shù)。這套系統(tǒng)沒有黑匣子每個(gè)環(huán)節(jié)的誤差都能追溯到具體參數(shù)把標(biāo)定、解碼、匹配三個(gè)環(huán)節(jié)的邊界條件摸清楚后面就是調(diào)參和驗(yàn)證的體力活。希望幫到你。本文還有配套的精品資源點(diǎn)擊獲取