這篇是「3D電腦視覺學習總結」系列的第 23 篇,也是我最近一個小型案例的完整復盤。需求很簡單:教室裡面有十幾個人,有的人坐著、有的人站著,我要用 3D 座標系把人與人之間的空間關係量化,最後精準地把「站著的人」從背景和坐著的人之中抓出來。這個任務乍看像是一個物件檢測問題,但真正動手之後才發現,難點在於「站著」不是一個物體類別,它是由身體關鍵點在三維空間中的相對位置決定的高階語義,只有把每個人的骨骼關鍵點重建到同一個 3D 座標系下,判斷才真正有意義。
在這個案例中,我不會只丟一段最終的判斷代碼,而是把整個推理鏈路完整走一遍:先剖析為什麼 2D 方法在這種場景下會失效,接著講清楚教室環境怎麼建模、深度相機怎麼標定、人體關鍵點怎麼從像素座標轉成世界座標、最後用哪些三維幾何特徵來判斷站立狀態。如果你也在做室內人員分析,或者學 3D 電腦視覺想找一個不慍不火的小專案練手,這篇文章應該能幫你少走不少彎路。
1. 問題拆解:「找出站立的人」究竟難在哪裡
1.1 二維圖像方法的天花板
很多人接到這個需求時,第一個反應是用 YOLO 這類檢測器,或者用 OpenPose、MediaPipe 把人體關鍵點框出來,然後靠 2D 骨架的外觀去判斷。
我一開始也先試了最直觀的 2D 方案:用 MediaPipe 檢測出鼻子、肩膀、髖部、膝蓋、腳踝這些點,然後看「髖部到腳踝的像素距離」以及「膝蓋彎曲的角度」。在正面對著相機、人與人之間沒有遮擋的測試畫面裡,這個方法效果還行。但只要換一個角度,問題馬上浮出水面——教室裡的相機多半安裝在前方角落,視角是斜向下俯視的,坐著的人和站著的人會互相遮擋。當時我把一張課堂照片餵給 MediaPipe,發現站在後排的學生腳踝根本檢測不到,因為被前面坐著的同學擋住了;而前排站著的人,從斜上方看下去,大腿的投影長度和坐著的人幾乎一模一樣。
這就是 2D 方法的天花板:光線沿著相機光軸投影成像,深度資訊在投影過程中被丟失了。一個一米七的人,站著還是坐著,在特定角度下可以投影成幾乎一樣的 2D 骨架。更極端的情況是,一個人明明站著,但腳踝被桌椅擋住,2D 檢測器只能看到上半身,根本無法判斷他是站著還是坐在一張高腳椅上。
1.2 三維座標系把「猜」變成「算」
所以後來我把問題重新定義了一下:不再是「看骨架像不像站著」,而是「每個人的頭、肩、髖、膝、踝在一個真實的三維座標系中,具有什麼樣的幾何關係」。
在 3D 座標系下,判斷站立狀態就變成了幾個可以直接計算的幾何條件:
- 髖關節離地面的高度,站立時通常在 85~100 公分,坐著時通常只剩 40~55 公分;
- 髖關節到踝關節的向量,站立時近似垂直於地面,坐著時這個向量是彎曲的;
- 膝關節角度,站立時接近 180 度,坐著時大約 90 度;
- 頭部離地面的高度,站立時約 150~180 公分,坐著時約 110~130 公分。
這四個條件任何一個單獨拿出來都有反例,但組合在一起,判斷正確率就會高很多。關鍵是所有的判斷都基於可測量的三維量,而不是依賴外觀的「直覺」。
這裡最重要的基礎設施,就是先把每個人身上的關鍵點從 2D 像素座標轉換成 3D 世界座標。接下來我按實際操作的順序,一步步說明整個流程。
2. 教室場景的第一手觀察:環境建模與座標系統一
2.1 為什麼要重視教室的固定幾何
先講一個容易被忽略、但是非常關鍵的前提:教室不是任意場景,它有一個很穩定的幾何結構——地板大致水平、桌椅排列有規律、相機安裝位置一旦固定就不太會動。這個「場景先驗」可以大幅簡化問題。
我第一次做的時候,以為直接把深度相機的點雲丟給模型,讓模型自動找出站立的人就行,結果效果很差。因為教室裡有桌子、椅子、書包、投影幕,混在人的點雲裡,單純靠幾何分割根本分不出哪個點屬於人。後來我調整了策略:先用 2D 人體檢測框,把每個人從 RGB 影像中框出來,再在對應的深度區域內提取骨骼關鍵點的三維座標。這樣就把「找站立的人」拆成了兩個階段:先找人,再在 3D 座標系裡判斷姿態。找人用成熟的 2D 模型,判斷姿態用 3D 幾何,兩者各司其職。
另外,教室的地面是天然的「零高度」基準。把世界座標系的 Y 軸設為垂直地面向上,地面高度設為 0,那麼「站立」與「坐著」最核心的區別——髖部高度、頭部高度——就是相對於這個基準的可計算量。沒有這個基準,後面所有的「高度」特徵都只是相對值,無法跨人比較。
2.2 深度相機選型與安裝位置
我用的相機是 Intel RealSense D435i,原因是它在近距離(0.3~3 米)的深度精度表現穩定,剛好覆蓋教室前半部分的範圍。如果是大型階梯教室,可能要考慮視場角更大的型號,或者用多台相機拼接。
安裝位置我前後試過兩個:
- 教室正前方黑板頂部,高度約 2.5 米,俯視角度較大,能看到所有人的頭頂和肩部,但膝蓋、腳踝容易被課桌遮擋;
- 教室前方側角,離地 1.6 米左右,斜向 45 度看過去,遮擋少一些,但邊緣區域的人體變形比較嚴重。
最後我選了黑板頂部正中位置。理由是:俯視視角下,每個人頭部的 3D 座標最可靠,而只要拿到「頭部高度」和「肩部高度」這兩個上身體徵,配合髖部高度,就足以做高置信度的站坐判斷。膝蓋被遮擋時,最多讓「膝關節角度」這個特徵失效,不會全盤崩潰。這裡也提醒一下:在部署任何室內人體分析系統時,攝影機的拍攝角度會直接決定你能提取到哪些關鍵點,提早規劃比事後補救省事得多。
2.3 標定世界座標系:以地面為基準
相機內參我用標準的棋盤格標定法,外參則用「地面平面 + 一個世界座標原點」的方式來確定。具體步驟是:
- 在教室地面放一張棋盤格,讓棋盤格完全平貼地面;
- 用
cv2.findChessboardCorners找出棋盤格角點在像素座標系的位置; - 因為棋盤格就放在地面上,已知每個格子實際邊長(例如 3 公分),可以直接指定這些角點的世界座標:y=0,x 和 z 按照格子的排列順序遞增;
- 用
cv2.solvePnP算出相機到世界座標系的旋轉矩陣 R 和平移向量 t。
有了 R 和 t 之後,任何一個深度相機量測到的 3D 點,都可以透過一個剛體變換轉到世界座標系。這裡有一個坑必須提醒:RealSense SDK 給出的點雲座標是「相機座標系」,X 軸向右、Y 軸向下、Z 軸向前;而我在世界座標系裡習慣 Y 軸向上。如果不做座標軸轉換就直接算高度,所有高度值都會變成負的。我第一次跑出負數時還以為是深度資料壞了,花了不少時間排查,結果只是座標軸方向沒對齊。
標定完成後,我會在每幀深度圖上取地面區域的若干點,計算它們在世界座標系中的 Y 值標準差,作為「地面平整度」的驗證。標準差小於 1 公分,我才認為這次標定可信。這是個簡單但很有效的自檢手段。
3. 從像素到三維點:人體關鍵點的立體重建
3.1 姿態估計模型的選用考量
在 2D 姿態估計環節,我重新比較了幾個方案:
| 模型 | 優點 | 缺點 | 我的結論 |
|---|---|---|---|
| MediaPipe Pose | 速度快、CPU 可跑、提供 33 個關鍵點 | 多人場景需多次推理,遮擋下人體框易抖 | 適合原型驗證 |
| YOLOv8-Pose | 一次推理輸出多人,速度與精度平衡 | 關鍵點數量較少(COCO 17 點) | 實戰主力 |
| OpenPose | 多人效果穩定,關鍵點豐富 | 依賴 GPU,部署稍重 | 備選 |
這次案例最終用了 YOLOv8-Pose,理由是教室場景有十幾個目標,逐人跑 MediaPipe 會讓幀率掉到不可接受,而 YOLOv8-Pose 一次前向就能給出所有人的 17 個關鍵點,後續只需要做簡單的索引對齊。我另外把關鍵點的置信度一起保留下來,後面判斷特徵是否可用時會用到。
3.2 深度圖對齊與關鍵點深度提取
YOLOv8-Pose 給出的是 RGB 影像上的像素座標。要把像素座標轉成 3D 座標,我需要取出該像素位置上的深度值。D435i 的深度圖和 RGB 圖出廠就做了立體對齊,所以可以直接用:
import numpy as np import pyrealsense2 as rs align = rs.align(rs.stream.color) def extract_depth(frames, keypoints_px): aligned_frames = align.process(frames) depth_frame = aligned_frames.get_depth_frame() depth_image = np.asanyarray(depth_frame.get_data()) points_3d = [] for (x, y) in keypoints_px: # 取得該像素的深度,單位是毫米 depth_mm = depth_frame.get_distance(int(x), int(y)) points_3d.append([x, y, depth_mm]) return points_3d實際測試中會發現,關鍵點落在人體邊緣時,深度值往往會瞬間跳到背景,這是深度相機在物體邊界常見的「飛點」現象。我的處理方式是:不只取關鍵點單一像素的深度,而是以該點為中心取一個 5x5 的視窗,用視窗內有效深度值的中位數作為該點深度。中位數比平均值抗飛點能力強很多,這一段程式碼看似多寫了十幾行,但省下來的除錯時間遠超預期。
3.3 座標變換:從相機座標到世界座標
拿到每個關鍵點的像素座標與深度後,還不能直接當成三維座標。因為深度相機回傳的深度值是沿光軸的距離,要變成真正的三維點,需要用相機內參做反向投影:
x_cam = (u - cx) * depth / fx y_cam = (v - cy) * depth / fy z_cam = depth其中 (cx, cy) 是光心,fx、fy 是焦距。這一步是針孔相機模型的標準推導,公式不複雜,但座標順序很容易搞混。我的習慣是先寫一個小函數,把「像素座標 + 深度」統一轉成相機座標系下的 (x, y, z),然後再寫另一個函數,把相機座標用 R、t 轉成世界座標:
def camera_to_world(points_cam, R, t): # points_cam: (N, 3) 相機座標 # R: 3x3 旋轉矩陣, t: 平移向量 return (R @ points_cam.T).T + t.ravel()有的讀者可能會問:為什麼不直接買一台內建 3D 骨骼追蹤的相機,例如 Azure Kinect?Azure Kinect 確實可以直接輸出人體關節的三維位置,省掉上面所有步驟。但它的 Python SDK 在非 Windows 系統上的支援不夠友好,而且對同時追蹤人數有限制,教室這種密集型場景容易掉追蹤。自己把 2D 姿態和深度圖融合的優點是可控制性強,哪個環節出問題都能單獨調整。
4. 判斷邏輯:用哪些三維幾何特徵區分「站」與「坐」
4.1 單一特徵為何不靠譜
把每個人的關鍵點都轉到世界座標之後,接下來就是設計站立判斷規則。我一開始天真地以為只要看「頭部高度超過某個閾值」就夠了,結果被現實狠狠教育了一次:教室裡有一個一米九的學生,他坐著時頭部高度和一米六的同學站著時幾乎一樣。如果只設一個絕對高度閾值,要嘛漏掉一米九的站姿,要嘛把一米六的坐姿誤判成站著。
同樣的道理,只看「髖部高度」也會出問題:高個子坐在高腳椅上,髖部高度可能超過 70 公分;小個子站著時,髖部高度也可能只有 80 公分。界限模糊的情況下,任何單一特徵都是脆弱的。
4.2 特徵組合與投票機制
所以我把判斷邏輯改成多特徵加權投票。對每一個人,計算以下特徵(使用 COCO 17 個關鍵點的索引約定:0 鼻子、5 左肩、6 右肩、11 左髖、12 右髖、13 左膝、14 右膝、15 左踝、16 右踝):
- 特徵 A:髖部中心高度,取左右髖的平均值;
- 特徵 B:頭部高度,取鼻子或頸部的世界座標 Y 值;
- 特徵 C:髖部到踝部的向量與垂直方向的夾角餘弦值;
- 特徵 D:膝關節角度的餘弦值。
站立傾向的判斷規則如下:
| 特徵 | 站立時的典型值 | 坐著時的典型值 | 判定條件 |
|---|---|---|---|
| A 髖部高度 | > 0.8 m | < 0.6 m | A > 0.7 m 記 1 票 |
| B 頭部高度 | > 1.5 m | < 1.35 m | B > 1.4 m 記 1 票 |
| C 腿部垂直度 | > 0.9 | < 0.6 | C > 0.75 記 1 票 |
| D 膝關節角餘弦 | < -0.9(接近 180 度) | ≈ 0(接近 90 度) | D < -0.5 記 1 票 |
四項特徵裡面,至少拿到兩票才判定為「站立」,否則判「坐著」。這樣設計的依據是:站立與坐著在 2D 圖像上的外觀差異會被遮擋和投影扭曲,但在 3D 座標系下,A 和 B 是「絕對高程」特徵,C 和 D 是「骨架形狀」特徵。兩類特徵的物理意義不同,互為冗餘,某一類受到遮擋或雜訊影響時,另一類仍然可以撐住判斷。
實際跑下來的結果是,在光線良好、無遮擋的場景下,四特徵全中的準確率接近百分之百;在有遮擋的場景下,三特徵一致的個案也佔了九成以上。真正會引發爭議的只有極少數「半蹲」「倚靠桌子」的姿態,這種情況即使人眼也需要看前後幾幀才能判斷,機器單看一幀會猶豫是正常的。
4.3 多人同時判斷的處理策略
教室場景裡往往是十幾個人同時出現在畫面中,YOLOv8-Pose 會給出多個人的關鍵點陣列。我會在每幀中遍歷每個人,分別計算上述特徵,並把結果存成結構化資料:
person_status = { "bbox": [x1, y1, x2, y2], "keypoints_3d": kpts_world, # (17, 3) 世界座標 "features": {"hip_h": 0.82, "head_h": 1.63, ...}, "is_standing": True, "confidence": 0.87 }這裡有一個容易被忽略的工程細節:多人的 3D 座標必須基於同一組世界座標系,否則「高度」就失去了比較意義。好在我們已經完成標定,所有人在同一座標系下,直接比較即可。如果沒有做標定,每個人各自用自己的相機座標算高度,那前排的人和後排的人高度基準根本不同,後排的人明明站著,深度值卻更小,就會被誤判成坐下。
5. 實戰調優:我在這間教室踩過的坑
5.1 深度圖上的「人體邊緣黑洞」
第一次上線測試時,我發現一個奇怪的現象:靠近窗戶那一側的學生,肩膀的深度值會突然變成 0。排查了很久才意識到,是強烈的自然光中的紅外成分干擾了 RealSense 的紅外結構光投影,導致深度感測器在該區域出現大面積空洞,也就是俗稱的「飛點」與「邊緣洞」。
對策有兩個層面:
- 硬體層面:調整相機的曝光時間與雷射功率,或者加裝遮光罩,減少側面直射光;
- 軟體層面:用時間域濾波。RealSense 內建了
temporal_filter,我會把它打開,並且在後處理時把深度值為 0 的像素用臨近有效深度填補,再用 3x3 的中值濾波平滑。
經過這輪處理,深度空洞的覆蓋面積從約 15% 降到了 3% 以下,基本不影響關鍵點深度提取。
5.2 前後排遮擋與低置信度關鍵點
教室裡的前後排遮擋是躲不掉的。站在後排的人,腳踝和膝蓋經常藏在課桌底下,YOLOv8-Pose 在這種情況下會輸出置信度很低的關鍵點,甚至直接不輸出。
我的處理思路是:不要硬猜。當一個人只有上半身關鍵點可見,也就是 C、D 兩個「骨架形狀」特徵完全失效時,就只依靠 A、B 兩個高度特徵做判斷,同時把is_standing的置信度降低。在最終應用的顯示介面上,置信度低的人用虛線框標出,提示人工覆核。
這樣做損失了一點自動化率,但換來的是誤報率的顯著下降。在所有依賴視覺判斷的系統裡,「知道自己不知道」往往比「硬著頭皮猜一個答案」更有價值。
5.3 閾值的選擇與動態校正
特徵表格裡那些閾值(0.7 米、1.4 米、0.75、-0.5)不是拍腦袋定的,是基於一組標註資料統計出來的。我先手動標註了 200 幀樣本,每幀標記每個人是站著還是坐著,然後計算每個特徵的值,畫出正負樣本的特徵分佈,再選取能把兩類儘量分開的閾值。
但有個現實問題:不同教室的地面高度、桌椅高度不同,固定閾值換個場景可能失效。所以我把閾值設計成可設定的參數,並在每次新場景部署時,用一段開機畫面做「人群姿態普查」:讓教室保持正常上課狀態 5 分鐘,統計所有檢測到的人的髖部高度分佈。正常情況下會出現明顯的雙峰,低峰是坐著的人,高峰是站著的人,兩個峰之間的谷底就是自動標定出來的閾值。這個動態校正方式讓我後續換了兩間教室都不用重新手動標註。
最後再分享一個和主線無關、但很實用的小技巧:深度相機的「世界座標系基準」最好畫一條虛擬輔助線。我會在校準完成後,讓程式在地面位置渲染一條與講台平行的線,一旦相機被學生碰到、偏移幾釐米,這條線就會歪掉,我能立刻察覺並重新標定,而不是等到輸出資料全錯才回頭翻日誌。像這種小型監控式應用的長期穩定性,很多時候不是靠更先進的模型,而是靠這些「不起眼的防空措施」撐起來的。