
做2D人體檢測做得久了難免會碰到一個靈魂拷問框裡的人到底站著還是坐著教室場景尤其明顯前排同學站起來回答問題後排同學正常坐著從攝影機畫面看過去兩個bounding box的長寬比可能幾乎一樣光靠2D資訊根本分不出來。我自己的解法是往3D計算機視覺的方向走——把每個人的關鍵點從像素座標還原成三維空間座標直接用物理世界的量測值來判斷姿態。這篇筆記是3D計算機視覺學習總結系列的第23篇記錄一個完整實戰案例利用3D座標系找出教室中站立的人。整個案例不需要重新訓練任何模型只需要把關鍵點檢測、深度圖反投影、平面擬合、座標系轉換這幾塊拼起來。適合已經會跑人體關鍵點檢測、但對深度相機與座標系變換還不熟的讀者跟著走一遍等於把3D視覺最基本的鏈路打通了。1. 整體設計思路為什麼用3D座標系來解決「找站立者」問題1.1 2D檢測的瓶頸框給不了姿態先說清楚為什麼2D資訊不夠用。目標檢測模型YOLO、Faster R-CNN這類輸出的是矩形框幾個數字而已中心點座標、寬、高。假如一個人站在那裡另一個人坐在高腳椅上恰好兩人的頭頂位置在畫面裡差不多高那麼這兩個框的幾何特徵可能幾乎一樣分類器再厲害也分不出來。就算退一步改用姿態估計網路拿到肩膀、手肘、膝蓋等關節點的2D像素座標還是會遇到透視變形的干擾同樣是「腳踝在髋部下方」的像素關係站著滿足坐在椅子上腿往前伸也滿足。我在早期實驗裡用2D關節點夾角做判定換個拍攝角度之後誤判率直接爆表從那時候起就下定決心引入第三個維度。加入深度之後問題的性質就變了。站立和坐著在物理世界裡是有明確尺度差異的髋部中心離地高度不同軀幹方向和重力方向的夾角不同這些都可以用公釐、用角度來描述而不是靠像素去猜。教室這種場景特別適合用3D座標系做判斷因為地面是相對平整的平面相機安裝位置也固定只要把座標系「校準」到地面每個人站在哪裡、頭多高、髋部多高全都可以直接量出來。1.2 方案選型深度相機與單目相機的取捨要做3D座標系首先要解決「深度從哪裡來」的問題。業界常見的做法有兩類我把優缺點整理在下面。方案原理精度成本適合場景深度相機RealSense D435i主動紅外結構光0.3m3m內精度較好中等室內、近距離、固定安裝深度相機Azure Kinect飛行時間法 ToF0.5m5m室內穩定偏高室內大範圍、多人場景單目相機單目深度估計神經網路從2D推3D尺度模糊絕對誤差大低只有普通攝影機的狀況我這次用的是RealSense D435i理由很簡單教室寬度大約79公尺攝影機裝在前方離最後一排學生大概68公尺D435i的可用測距範圍勉強覆蓋加上課堂場景不像戶外有強烈陽光干擾結構光方案在室內發揮比較穩定。需要強調的是單目深度估計不是不能用而是它給出的深度往往是「相對深度」缺少絕對尺度想拿來量髋部離地高度就比較吃力。如果手上只有普通監視器畫面可以用「已知人臉平均寬度」之類的先驗去做比例換算但誤差通常會到1020公分對站坐判定這種對高度很敏感的任務來說上限很明顯。1.3 系統流程總覽整個系統拆開來看其實就是一條資料處理鏈路RGB影像和深度圖同時進來先做人體關鍵點檢測再用關鍵點的像素位置去深度圖查深度反投影成三維點接著做地面擬合把座標系拉正最後根據世界座標下的幾何量判斷站姿。我習慣把這條鏈路分成五塊資料獲取使用RGB-D相機同步取得彩色圖和深度圖。關鍵點偵測用MediaPipe Pose或類似模型取得人體33個關節點的2D像素座標。反投影與座標重建結合相機內參把「像素座標深度」變成「相機座標系下的三維點」。座標系對齊用RANSAC擬合地面平面把相機座標系轉到以地面為參考的世界座標系。姿態判定計算髋部高度、軀幹角度等幾何量用閾值加狀態機輸出最終結果。每一步的輸出都是下一步的輸入前一塊不穩後面全垮。所以我建議讀者照著這個順序逐步驗證不要急著一口氣跑到最終判定。我最初就是跳過地面擬合直接拿相機座標系的Y軸當高度來用結果畫面左邊和右邊的人測出來高度差很多檢查半天才發現是相機俯仰角造成的這也直接導致我在後面的章節把座標系對齊當成重中之重。2. 核心技術點拆解從像素到三維座標的關鍵鏈路2.1 人體關鍵點檢測骨架是姿態判斷的基礎姿態判斷不可能對整張影像直接做回歸最常見的做法是先提取骨架關鍵點。我選擇MediaPipe Pose因為它輸出33個關節點包含鼻子、雙肩、雙肘、雙腕、雙髋、雙膝、雙踝對於站坐判斷來說綽綽有餘而且CPU上就能跑到即時。如果要應對更複雜的遮擋、更追求精度也可以換成MMPose配上HRNet等模型輸出格式類似後續邏輯不用大改。關鍵點索引要記住幾個0是鼻子11和12是左右肩23和24是左右髋25和26是左右膝27和28是左右踝。這裡有個很常見的誤區很多人以為有了關鍵點檢測模型姿態資訊就到手了其實MediaPipe給出的只是2D像素座標u, v座標單位是像素跟真實世界的米沒有直接關係。要得到物理高度還是要靠深度圖來補上第三個維度。所以在架構上關鍵點檢測的角色是「告訴我們該在深度圖的哪個位置取樣」而不是姿態判定的最終依據。2.2 相機座標系與反投影把像素變回三維點這裡要引進針孔相機模型。假設深度圖在像素座標(u, v)處的深度值是Z那麼這個像素對應的相機座標系三維點可以寫成X (u - cx) * Z / fxY (v - cy) * Z / fyZ depth(u, v)其中fx、fy是相機的焦距單位為像素cx、cy是光心在影像上的位置。這組內參可以從RealSense SDK直接讀取也可以用OpenCV的棋盤格標定自己求。公式背後的直覺很簡單深度Z就像一把尺子告訴我們這個點離相機多遠再配合畫面上的像素位置就能反推它在空間中的左右偏移和上下偏移。做過相機標定的朋友應該對這組式子很眼熟它其實就是常說的「反投影」。有件事一定要提醒反投影得到的座標還是在相機座標系底下這個座標系的X軸朝右、Y軸朝下、Z軸朝前而且會跟著相機的姿態傾斜。如果教室裡的相機是往下俯視安裝的相機座標系裡的Y軸就不是真正的「垂直方向」。此時如果直接拿Y值去比較身高畫面下方的人會比實際上「矮」畫面上方的人會比實際上「高」誤差隨距離而變化。克服這個問題就需要下一節的地面擬合與座標系對齊。2.3 地面平面檢測與座標系對齊讓Y軸真正代表「高度」要讓三維座標變成可直接量測的物理量我建議先把座標系「掰正」。具體做法是基於深度點雲用RANSAC演算法擬合出教室地面所在的平面。平面方程可以寫成 ax by cz d 0擬合完成後可以得到平面法向量 n (a, b, c) 和常數d。假設我們希望世界座標系裡「地面」剛好是XZ平面Y軸垂直向上就找一個旋轉矩陣R把相機座標系下的法向量對齊到理想向上向量(0, 1, 0)再把平面常數d挪到原點附近讓地面Y值約等於0。寫成公式就是P_world R * P_cam t其中R由法向量對齊求得t確保地面點在變換之後Y≈0。很多人會問為什麼不買一塊標定板用傳統的「相機-外參標定」來求R和t我的經驗是在教室這種大場景裡地面本身其實就是最好的標定參考物而且它一直在視野裡不需要每換一次位置就重新擺標定板。RANSAC擬合平面的過程也順便解決了「地面到底在哪裡」的問題一舉兩得。這一步完成之後「高度」這個詞才有物理意義後面用髋部離地多少公分來判斷站坐才是真正在量測真實世界而不是在猜測投影關係。3. 實作過程與關鍵環節實現3.1 相機安裝與內外參標定實作的第一步是選位置。教室場景我建議把相機裝在前方黑板正上方離地約2.53公尺往下俯視2030度。這樣的好處是頭部和肩部不容易被前排桌面遮擋深度相機能看到的有效區域也比較大。安裝時要避開窗戶直射光結構光方案在強烈紅外線干擾下會出現大量深度空洞這點在後面的常見問題會再詳細說。內參部分RealSense D435i可以透過SDK直接讀取intrinsics不需要額外做棋盤格標定。如果你用的是其他相機建議先拍2030張棋盤格照片用OpenCV的cv2.calibrateCamera求解這一步別偷懶。外參部分我沒有用傳統的RT標定板而是交給後面的地面擬合因為地面平面估計對「相機世界座標系」的定義更直接而且能自動適應相機安裝時的微小傾斜。3.2 關鍵點深度提取與雜訊濾波關鍵點在RGB影像上的座標是浮點數但深度圖的像素是離散的如果直接取round成整數後查深度值很容易碰到深度空洞或邊緣雜訊。我的做法是對每個關鍵點在它周圍取一個5×5的視窗收集視窗內所有有效的深度值排序後取中位數當作該點的深度Z。使用中位數而不是平均值是因為深度圖的雜訊通常表現為離群的「飛點」平均值會被這些極端值拉走中位數則穩健很多。取完深度之後建議再做一次時間平滑。我用的是指數移動平均EMAZ_smooth α * Z_current (1-α) * Z_smooth_previousα取0.3左右。這樣一個人在原地輕微晃動時三維座標不會跟著上下亂跳。這裡有個小原則寧願讓座標反應慢半拍也不要讓它抖得讓判定邏輯無所適從。實際測試中加了一階低通以後站坐判定的穩定性提升非常明顯。3.3 座標轉換與地面擬合實現我直接用Open3D的segment_plane做地面擬合。先把整張深度圖轉成點雲例如用前面的反投影公式對每個有效深度像素運算得到N×3的點雲陣列再做一次voxel downsampling體素大小0.02m減少計算量。然後呼叫import open3d as o3d pcd o3d.geometry.PointCloud() pcd.points o3d.utility.Vector3dVector(xyz_points) # 體素降採樣 pcd_down pcd.voxel_down_sample(voxel_size0.02) # RANSAC 擬合平面 plane_model, inliers pcd_down.segment_plane( distance_threshold0.02, ransac_n3, num_iterations500 ) # plane_model [a, b, c, d]對應 axbyczd0 a, b, c, d plane_model擬合完畢後把法向量(a, b, c)歸一化再計算旋轉矩陣。這裡可以用一個小技巧用NumPy的cross product先求旋轉軸再用Rodrigues公式或者簡單一點構造一個從法向量到(0, 1, 0)的旋轉矩陣。我習慣直接用scipy.spatial.transform.Rotationimport numpy as np from scipy.spatial.transform import Rotation as R normal np.array([a, b, c]) normal normal / np.linalg.norm(normal) target np.array([0.0, 1.0, 0.0]) # 理想向上方向 # 計算旋轉 axis np.cross(normal, target) axis axis / np.linalg.norm(axis) angle np.arccos(np.clip(np.dot(normal, target), -1.0, 1.0)) rot R.from_rotvec(axis * angle).as_matrix() # 對原點雲做旋轉 pcd_world np.dot(xyz_points, rot.T) # 每一列是相機系座標 pcd_world[:, 1] - np.median(pcd_world[:, 1]) # 把地面Y移到0附近這裡的平移我直接用地面Y值的中位數做校正原理是地面點變換後Y座標應該集中在0附近。這一步做完世界座標系就建立起來了每個人的髋部、膝蓋、鼻子都有了以「米」為單位的世界座標。3.4 站立/坐姿判定邏輯與閾值設計座標系對了之後判定邏輯反而簡單。我使用的是兩個核心幾何量髋部中心高度hip_Y和軀幹傾斜角torso_angle。髋部中心取左右髋關節點世界座標的平均值軀幹向量則由「鼻子或雙肩中心」指向「雙髋中心」再和世界座標的向上方向(0,1,0)求夾角。我用的判定規則如下狀態判定條件站立hip_Y 0.75m 且 torso_angle 30°坐姿hip_Y 0.60m或 knee_Y 與 hip_Y 的差小於 0.15m過渡/不確定其餘情況維持上一狀態為什麼選這些值成年人站立時髋部離地大約0.851.05公尺坐著時髋部離地大約0.450.60公尺視椅子高度而定。0.600.75公尺之間剛好是模糊帶我會讓系統「寧可慢不可錯」。膝蓋輔助條件是處理一種特殊情況學生半蹲或彎腰時髋部高度可能介於模糊帶但膝蓋與髋部幾乎在同一水平線上這個特徵可以協助排除半蹲姿態。多幀平滑我用了狀態機的思路只有連續3幀都判定為「站立」才把狀態切換為站立同樣要連續3幀都是「坐姿」才會切回去。中間的過渡幀一律不輸出新狀態。這樣做可以過濾掉起身、坐下過程中的短暫抖動也不會因為單幀深度空洞而把站立的人突然判成坐著。3.5 核心代碼框架Python下面給出一段濃縮版的核心程式碼框架涵蓋從深度圖到判定輸出的主要步驟。實際專案中還需要處理多人的關聯、視覺化顯示、資料記錄這裡先講主幹邏輯。import mediapipe as mp import pyrealsense2 as rs import numpy as np import open3d as o3d # 初始化RealSense pipeline rs.pipeline() config rs.config() config.enable_stream(rs.stream.color, 640, 480, rs.format.bgr8, 30) config.enable_stream(rs.stream.depth, 640, 480, rs.format.z16, 30) profile pipeline.start(config) depth_scale profile.get_device().first_depth_sensor().get_depth_scale() # 初始化MediaPipe Pose mp_pose mp.solutions.pose pose mp_pose.Pose(min_detection_confidence0.5, min_tracking_confidence0.5) def get_world_landmarks(rgb_image, depth_image, intrinsics, R, t): results pose.process(rgb_image) if not results.pose_landmarks: return None world_pts {} h, w depth_image.shape for idx, lm in enumerate(results.pose_landmarks.landmark): u, v int(round(lm.x * w)), int(round(lm.y * h)) # 取5x5視窗深度中位數 patch depth_image[max(0, v-2):v3, max(0, u-2):u3] valid patch[patch 0].astype(np.float32) * depth_scale if valid.size 5: continue z float(np.median(valid)) x (u - intrinsics.ppx) * z / intrinsics.fx y (v - intrinsics.ppy) * z / intrinsics.fy cam_point np.array([x, y, z]) world_point R cam_point t world_pts[idx] world_point return world_pts def judge_posture(world_pts): if 23 in world_pts and 24 in world_pts: hip_center (world_pts[23] world_pts[24]) / 2 hip_Y hip_center[1] else: return unknown if 0 in world_pts and shoulder_center in world_pts: # 實際可以用11/12平均代替shoulder_center pass # 計算軀幹角度 left_shoulder world_pts.get(11) right_shoulder world_pts.get(12) left_hip world_pts.get(23) right_hip world_pts.get(24) if None in (left_shoulder, right_shoulder, left_hip, right_hip): return unknown shoulder_center (left_shoulder right_shoulder) / 2 hip_center (left_hip right_hip) / 2 torso_vec shoulder_center - hip_center up np.array([0, 1, 0]) cos_angle np.dot(torso_vec, up) / (np.linalg.norm(torso_vec) 1e-6) torso_angle np.degrees(np.arccos(np.clip(cos_angle, -1, 1))) if hip_center[1] 0.75 and torso_angle 30: return standing elif hip_center[1] 0.60: return sitting else: return unknown while True: frames pipeline.wait_for_frames() color_frame frames.get_color_frame() depth_frame frames.get_depth_frame() if not color_frame or not depth_frame: continue color_image np.asanyarray(color_frame.get_data()) depth_image np.asanyarray(depth_frame.get_data()) intrinsics profile.get_stream(rs.stream.color).as_video_stream_profile().get_intrinsics() # R, t需由地面擬合求出此處省略 world_pts get_world_landmarks(color_image, depth_image, intrinsics, R, t) if world_pts: posture judge_posture(world_pts) print(Posture:, posture)這段程式把主要邏輯都放在get_world_landmarks和judge_posture兩個函數裡前者處理座標重建後者處理姿態判定。實際運行時R和t需要事先透過地面的RANSAC擬合得出可以在程式啟動時先跑一次之後每幀直接套用即可。4. 常見問題與避坑記錄4.1 深度圖空洞導致關鍵點Z值跳動現象是明明畫面中的人站得很穩輸出座標卻像心跳圖一樣上下跳。這多半是關鍵點落到了深度圖的空洞區域或者物體邊緣產生了飛點。處理方式就是我前面提到的5×5視窗取中位數如果有效深度值太少就寧可丟棄這個關鍵點也不要硬填一個不合理的值。另外如果空洞區域太大可以考慮把深度相機的雷射投影儀開啟到最佳模式或者調整相機距離讓目標不要落在測距範圍的臨界區。4.2 地面擬合被桌椅干擾教室裡有很多桌腳、椅腳RANSAC擬合地面時有可能擬到一個由桌面組成的平面導致整個座標系偏高。對策有兩個一是把平面擬合的distance_threshold調到0.02m強迫內點集中在同一高度層二是先對點雲做一次簡單的裁剪只保留深度圖下方三分之一區域的點去做擬合因為畫面下方的點通常是離相機最近的地面。實作時我會同時檢查擬合出來的平面法向量是否大致指向「上」如果角度偏差超過15度就自動重新擬合避免把牆面誤當地面。4.3 多人場景下的ID匹配與遮擋教室不是單人場景MediaPipe每次會輸出畫面中所有人的關鍵點但ID在不同幀之間會跳。一個實用的做法是用每個人髋部中心的3D世界座標做最近鄰追蹤下一幀每個人的髋部位置和上一幀所有已知目標的髋部位置算距離距離最近的且小於某個閾值例如0.3m就認為是同一個人。如果一個人的髋部關鍵點因為被桌子擋住而消失可以先保留上一幀的狀態不輸出判定結果避免瞬間誤判。多人互相靠近時靠3D座標追蹤比2D框追蹤穩定得多這也是導入3D座標系之後額外獲得的好處。4.4 閾值「一刀切」的坑與自適應思路0.75m和0.60m這兩個閾值不是萬能的。小學生的髋部高度和成年人差很多如果教室裡全是一年級學生絕對閾值可能會把所有站著的人判成「unknown」。比較好的做法是在系統部署時採集一小段資料讓幾個人分別站著和坐著統計髋部高度的分佈取兩座峰值的分界線當作動態閾值。更進一步還可以用每個人自己的身高來歸一化——用鼻子或頭頂的世界Y值估算身高髋部高度除以身高後得到一個比例值站坐的差異在比例空間裡更穩定。我自己的專案裡最終採用的是「固定基礎閾值自動標定偏移」的折衷方案既保證了開箱即用的效果又保留了場景自適應的餘地。4.5 反光與玻璃帶來的深度黑洞教室裡有窗戶、白板、電腦螢幕這些區域在深度圖裡很容易形成黑洞因為紅外光打到光滑表面後發生鏡面反射感測器收不到回波。解決方法比較有限安裝時盡量讓相機避開對面窗戶的直接視角或者對深度圖做一次簡單的形態學填洞把小的黑洞補上。我在實際測試中最有用的反而是「不補洞」——直接把無效深度區域當作遮擋處理寧可暫不判定也不要補出一個完全錯誤的高度值。這個思路和前面提到的關鍵點丟棄策略是一致的在物理量測裡錯誤的數值比缺失的數值危險得多。這個案例做下來我最深的一個體會是判斷一個人站著還是坐著關鍵往往不在模型多先進而在座標系穩不穩。只要座標系對了髋部離地0.9公尺就是0.9公尺這個物理量的說服力遠大於任何從像素裡硬學出來的特徵。如果你也正在3D計算機視覺的學習路上我建議從這種「一把尺子量天下」的小案例入手——先把座標系搞明白再回頭研究網路結構會順很多。後續這個案例可以直接擴展成課堂專注度分析、異常行為偵測或者整合多人追蹤做跨相機的連續識別不過那就是另一個故事了。