
这段时间一直在整理3D计算机视觉的学习笔记今天是第23篇。这篇想分享一个很有意思的实战case利用3D坐标系判断教室场景里哪些人是站着的、哪些人是坐着的。听起来好像不难但真正落地时会发现纯2D图像做这件事有天然瓶颈而一旦引入3D坐标问题就变得清爽很多。这个需求在智慧教室、课堂行为分析、安防监控里都很常见适合正在学习3D视觉、或者想在真实场景里做人体姿态理解的同学参考。1. 为什么非要用3D坐标系来判断站/坐1.1 2D检测的先天不足先做个简单实验你把手机放在教室最后一排拍一张全景照片。画面里第一排坐着个一米八的大个子最后一排站着个一米六的同学。在2D图像里前者的头部像素位置可能比后者更高但你绝对不能凭“头顶像素高”就说前者是站着的。透视关系把真实三维信息压缩成了二维投影距离相机越近物体在图像里就越大、越高。这个问题的本质是2D图像只有x和y没有z。基于2D的人体检测、姿态估计模型再强也只能告诉你“这里是个人”、“这里有关键点”没办法告诉你这个人在物理空间里到底有多高、躯干是否垂直于地面。当然有人会说可以用关键点检测比如MediaPipe把人体的33个关键点都标出来然后看膝盖角度、看躯干角度。这在单人、正面、近距离的场景下确实可行但放到教室这种密集场景遮挡严重很多人的下半身是看不到的关键点会大量缺失或漂移单靠关键点角度容易误判。1.2 3D坐标带来的是什么引入3D坐标系后每个像素除了(x, y)之外还多了一个深度值z也就是这个点到相机的距离。这样一来图像里的每个人都对应着一团真实世界的三维点云。我可以通过这些点云直接计算这个人的真实头顶高度头顶位置到地面的垂直距离这个人的躯干主轴倾斜角度人体点云的主方向与重力方向的夹角这个人的三维体积分布比如上半身和下半身的点的比例这些物理量是厘米级的、真实的不受透视影响。哪怕第一个人离相机3米第二个人离相机8米只要相机标定做好了两个人在同一个坐标系下公平比较。站立的判定就从“看起来像”变成了“量出来的”。1.3 选定坐标系方案实际项目里最常用的组合是以相机坐标系为基础再做一次地面平面拟合得到相机到地面的高度从而把头顶高度换算成世界坐标下的物理高度。这里不推荐一上来就搞SLAM或者复杂的多相机标定。单目深度相机比如RealSense、Kinect本身就是自带深度感知的标定好内参之后每个像素都可以通过投影公式反算到相机坐标系下的3D坐标公式很简单X (u - cx) * Z / fx Y (v - cy) * Z / fy Z depth_value其中 (u, v) 是像素坐标(cx, cy, fx, fy) 是相机内参depth_value 是该像素的深度值单位毫米。有了这个每个人都是一堆三维点剩下的就是几何问题了。2. 数据采集与预处理设备选型、标定、人体检测2.1 深度相机怎么选教室场景的特点是范围大通常5-10米、光照复杂窗户、投影仪、灯管、人多且遮挡严重。我用过的方案主要有三种设备类型代表产品有效距离优点缺点结构光Kinect v1 / RealSense D435i0.3-3m近距离精度高强光下容易失效ToFKinect v2 / Azure Kinect0.5-6m中距离稳定速度块边缘深度噪声较大双目被动ZED / 自组双目0.5-20m室外可用距离远对纹理要求高教室这种室内场景如果相机装在教室中后部、覆盖距离3-6米我推荐Azure Kinect或者RealSense L515。如果是低成本方案RealSense D435i加上好的深度后处理也能用就是远距离超过4米的深度精度会明显下降需要把判定阈值放宽。2.2 相机安装与地面平面拟合相机的安装位置直接决定了后面算法的复杂度。我最建议的是安装在教室前上方略向下俯视大概高度2.5-3米俯角20-30度。这样可以看到大部分学生的头部和肩膀遮挡最少而且深度图里人跟人之间的重叠也最小。相机固定好之后第一步不是跑检测而是拟合地面平面。因为只有知道地面方程才能把“人在相机坐标系下的高度”换算成“头顶到地面的物理高度”。做法很简单采集一帧没有人的空教室深度图用RANSAC拟合一个平面 ax by cz d 0。正常情况下这个平面就是地面。如果场景里有讲台可以手动框选一个纯地面区域再做拟合避免讲台干扰。拟合出来后记录相机到地面的距离 h_cam后面计算头顶高度时直接head_height h_cam - (a * x_head b * y_head c * z_head d) / sqrt(a^2b^2c^2)2.3 人体检测与深度图对齐有了3D几何基础还需要知道“哪一团点云是一个人”。这一步我用的是两步走先用2D检测器找人体框再把2D检测框映射到深度图上提取框内的深度点云。2D检测器可以用YOLOv8或者RTMPose。教室场景推荐YOLOv8帧率高小目标检测不错而且能拿到干净的person类别的bounding box。如果你还想额外得到关键点RTMPose也挺好但核心的站/坐判定尽量别依赖关键点原因前面说过了遮挡会让下半身关键点直接消失。RGB图像和深度图的对齐是个容易踩坑的细节。RealSense的pyrealsense2库提供了rs.align接口几行代码就能把深度图对齐到RGB视角import pyrealsense2 as rs 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) align rs.align(rs.stream.color) frames pipeline.wait_for_frames() aligned_frames align.process(frames) depth_frame aligned_frames.get_depth_frame() color_frame aligned_frames.get_color_frame()对齐之后检测框从RGB图上取深度值从对齐后的深度图上取像素坐标一一对应不用手动做坐标变换。深度图本身是有噪声和空洞的尤其是黑色衣服、远距离物体、反光表面。预处理时我通常会做一个中值滤波5x5再对空洞区域做邻域填充。注意不要用太激进的闭运算否则会把两个人的点云糊在一起。3. 3D特征设计与站立/坐下的判定核心逻辑3.1 提取单人点云并过滤噪点拿到2D检测框后我在深度图上框出对应区域把区域内的有效深度像素反投影成3D点云。但检测框里不只有人还可能有一点背景比如后排的桌子、黑板所以必须过滤保留深度在 [0.3m, 8m] 之间的点去掉无效值用统计滤波去掉离群点每个点周围邻居太少就丢弃如果点云数量太少比如少于500个点说明检测框可能是误检或者目标太远直接跳过这里有个很实用的技巧检测框适当往外扩10%-15%。YOLO的框往往贴人体很紧但人的头发、脚部边缘有时会被切掉扩一下框能让点云更完整顶部和底部特征提取更准。3.2 三个核心特征头顶高度、主轴倾角、质心高度接下来是重头戏。基于单人点云我计算三个特征特征1头顶物理高度取点云中点的高度方向的最高点也就是y值最大或最小取决于坐标系的朝向。我把这个点作为头顶点结合地面平面方程算出头顶到地面的垂直距离。站立时这个值通常是1.5米到2.0米坐在椅子上时这个值通常在0.8米到1.2米。这个特征是最强的一个判别信号因为站和坐之间最本质的差别就是头顶高度的大幅度变化。特征2人体主轴倾斜角度把人体点云做PCA主成分分析第一主成分的方向就是人体主轴方向大致对应从脚到头。计算这个主轴方向与重力方向即地面平面法向量的夹角theta。站立的人主轴接近垂直theta一般小于20度坐着的人主轴可能会倾斜或者即便垂直但由于腿部弯曲点云形态与站立也不同。这个特征对“靠在墙边站立”和“坐直”有很好的区分作用——靠墙站的人头顶高度高坐直的人头顶高度低两者结合判断更稳。特征3点云质心高度点云质心的垂直方向坐标可以反映人体质量的分布位置。站立时质心高大约在0.8-1.1米坐着时质心低大约在0.4-0.6米。这个特征受上半身姿态影响较小作为辅助特征很合适。另外如果你只需要简单的规则判断还可以加一个“高点数占比”统计点云中高度大于1.3米的点数占总点数的比例。站立的人这个比例通常大于0.3坐下的人往往小于0.1。3.3 阈值设定与判定逻辑特征算出来之后我用一个非常直观的决策逻辑if head_height 1.4m and tilt_angle 20° and centroid_height 0.7m: status standing elif head_height 1.2m or centroid_height 0.6m: status sitting else: status unsure为什么阈值选1.4米因为教室里的课桌高度一般0.7米左右坐姿头顶高度根据身高不同大概在0.8到1.3米之间。取1.4米作为站坐分界可以留出一定冗余避免矮个子学生站立被误判成坐着。实际应用中不能只看单帧需要时序平滑。我用一个长度为5帧的滑动窗口取多数表决的结果作为最终状态。这样能避免一个人在起身过程中、深度值抖动导致的“站-坐-站”来回跳变。def smooth_status(status_history): from collections import Counter counter Counter(status_history) return counter.most_common(1)[0][0]4. 实操流程与核心代码实现4.1 总体流程拆解整个系统的运行流程可以用下面这几步概括初始化深度相机做RGB与深度对齐加载YOLOv8人体检测模型读取每一帧对RGB图做人体检测得到所有person检测框对每个检测框在深度图上提取对应区域的深度点云统计滤波、去离群点提取头顶高度、主轴倾角、质心高度基于规则判定站/坐时序平滑输出带状态标签的标注帧4.2 关键代码框架这里给出一段核心代码按上面的流程实现了主要的判定逻辑。我用的是Python pyrealsense2 ultralytics YOLO环境配置就不赘述了。import cv2 import numpy as np import pyrealsense2 as rs from ultralytics import YOLO # 加载人体检测模型 model YOLO(yolov8n.pt) # 相机初始化 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) align rs.align(rs.stream.color) # 获取深度内参 depth_profile profile.get_stream(rs.stream.depth) intrinsics depth_profile.as_video_stream_profile().get_intrinsics() fx, fy intrinsics.fx, intrinsics.fy cx, cy intrinsics.ppx, intrinsics.ppy # 这里用预先拟合好的地面平面参数 axbyczd0 ground_coeff np.array([0.0, -1.0, 0.0, 1.6]) # 示例相机高1.6m平面法向量朝上 def pixel_to_point(u, v, depth): if depth 0: return None x (u - cx) * depth / fx y (v - cy) * depth / fy z float(depth) return np.array([x, y, z]) def extract_person_pointcloud(depth_image, bbox): x1, y1, x2, y2 [int(v) for v in bbox] # 扩框10% w, h x2 - x1, y2 - y1 x1 max(0, x1 - int(0.1 * w)) y1 max(0, y1 - int(0.1 * h)) x2 min(depth_image.shape[1], x2 int(0.1 * w)) y2 min(depth_image.shape[0], y2 int(0.1 * h)) points [] for v in range(y1, y2, 2): # 步长2降低点数 for u in range(x1, x2, 2): d depth_image[v, u] if d 0 or d 6000: continue pt pixel_to_point(u, v, d) if pt is not None: points.append(pt) if len(points) 500: return None return np.array(points) def compute_features(points, ground_coeff): # 坐标系处理确保y朝下为正相机视角地面法向量把它转成正方向 # 头顶高度取点云中沿地面法向量方向投影的最大值 a, b, c, d ground_coeff norm np.sqrt(a*a b*b c*c) # 计算每个点到地面的距离 distances (points np.array([a, b, c]) d) / norm head_height distances.max() # PCA主轴 points_centered points - points.mean(axis0) cov np.cov(points_centered.T) eig_vals, eig_vecs np.linalg.eig(cov) principal_axis eig_vecs[:, np.argmax(eig_vals)] # 主轴与世界坐标y轴夹角假设地面平面法向量为[0,-1,0]时垂直方向即y正方向 vertical np.array([0.0, -1.0, 0.0]) cos_angle abs(np.dot(principal_axis, vertical)) / np.linalg.norm(principal_axis) tilt_angle np.degrees(np.arccos(np.clip(cos_angle, -1, 1))) # 质心高度 centroid_height distances.mean() return head_height, tilt_angle, centroid_height def infer_status(head_height, tilt_angle, centroid_height): if head_height 1.4 and tilt_angle 20 and centroid_height 0.7: return standing elif head_height 1.2 or centroid_height 0.6: return sitting return unsure # 主循环 while True: frames pipeline.wait_for_frames() aligned_frames align.process(frames) depth_frame aligned_frames.get_depth_frame() color_frame aligned_frames.get_color_frame() if not depth_frame or not color_frame: continue depth_image np.asanyarray(depth_frame.get_data()) color_image np.asanyarray(color_frame.get_data()) results model(color_image, verboseFalse) for box in results[0].boxes: cls int(box.cls[0]) if cls ! 0: # 0 是 person continue bbox box.xyxy[0].cpu().numpy() points extract_person_pointcloud(depth_image, bbox) if points is None: continue head_h, tilt, centroid_h compute_features(points, ground_coeff) status infer_status(head_h, tilt, centroid_h) x1, y1, x2, y2 [int(v) for v in bbox] color (0, 255, 0) if status standing else (0, 0, 255) if status sitting else (0, 255, 255) cv2.rectangle(color_image, (x1, y1), (x2, y2), color, 2) cv2.putText(color_image, f{status} H{head_h:.2f}, (x1, y1-8), cv2.FONT_HERSHEY_SIMPLEX, 0.6, color, 2) cv2.imshow(3D stand/sit detection, color_image) if cv2.waitKey(1) 0xFF ord(q): break4.3 代码里几个值得注意的设计上面的代码里我把extract_person_pointcloud里的扫描步长设成了2。这一步不是为了偷懒而是为了控制计算量。640x480的深度图在检测框区域内全分辨率遍历的话一个人可能产生上万个点PCA和距离计算都会被拖慢。步长2之后点数降到原来的1/4精度几乎不受影响。ground_coeff那一行我在示例里写了[0, -1, 0, 1.6]表示地面平面法向量是y轴负方向相机距离地面1.6米。实际项目中这个值一定要通过真实标定得到不能写死。具体标定方法前面提到了采集空场景深度图手动选地面区域做RANSAC拟合。如果相机朝下俯视PCA主轴和垂直方向的夹角判断要格外小心。因为相机视角下一个站立的人的主轴在相机坐标系里并不完全是垂直的会有透视倾斜。这就是为什么我建议先把所有点投影到地面坐标系或者用地面法向量来计算高度和夹角而不是直接用相机坐标系的y轴。5. 常见问题与排查技巧实录5.1 问题速查表这套方案在实际跑起来之后会遇到不少“看着对但就是不对”的情况。我把踩过的坑整理成了一张表问题现象常见原因解决办法远距离的人深度全是0相机测距超出有效范围换ToF相机或者把判定距离控制在有效测距的80%以内黑色衣服的人点云大面积缺失红外光被深色表面吸收开启深度空洞填充结合RGB检测框做区域补偿站立的人被判成坐着相机俯视角过大头顶点被遮挡降低安装角度扩框优先依赖检测框顶部区域的深度坐着的人被判成站着桌面上放的书包/人头重叠被算进点云增加距离滤波去掉高于2.2m的离群点两个人紧挨着时点云融合检测框重叠导致特征串扰用NMS后的检测框对重叠区域深度做聚类比如DBSCAN状态在站/坐之间来回跳深度噪声、检测抖动时序滑动窗口多数表决增加迟滞区间1.2-1.4m之间保持原状态5.2 现场调试的三个亲测经验排查这类问题时我最大的体会是先看数据再调算法。把每个检测框的头顶高度、倾角、质心高度这三个值实时打印出来或者写到一个日志里然后让不同身高的人在场景里走动、坐下对照数值变化。很多时候你觉得是判定逻辑错了实际是点云提取阶段就把头顶点搞丢了。第二个经验是相机高度必须进程序配置不要写死。我试过把相机从1.6米挪到1.8米整个阈值体系都变了。后来我把地面平面拟合做成点击选区域自动计算每次安装完相机就做一次标定程序启动时自动读取。第三个经验是多人场景下检测框抖动非常影响点云质量。YOLO的框在遮挡时会在相邻帧之间轻微跳动导致提取的点云忽多忽少。我的做法是加一个轻量级的tracking比如字节级关联或者简单的IoU匹配一个人分配一个稳定的track ID特征计算基于这个track ID对应的时间序列而不是对每一帧独立重新检测。6. 从站/坐检测到更多应用场景的扩展思路6.1 不止是“站/坐”而是结构化的人体状态这套基于3D坐标系的方案本质上不是在“检测站/坐”而是在建立人体在三维空间中的几何描述。一旦你得到了头顶高度、主轴倾角、质心高度这些物理特征就可以衍生出很多有用的东西。起立答题检测在教室场景中如果一个学生的状态从sitting变成standing且持续时间超过0.5秒就可以判断他是主动起立。这在课堂互动分析里很有用老师可以统计一节课里哪些学生主动回答过问题。坐姿健康提醒长时间低头、驼背在3D特征上表现为头部高度下降、主轴倾角增大。通过对每个坐姿持续监测质心高度和倾角的变化趋势可以在用户坐姿异常时给出提醒。现在很多人体工学椅厂商就在做类似的事。人流计数与区域占用分析把判定区域网格化用3D点云判断每个网格内是否有人、人是站着还是坐着可以直接得出教室里某一时刻的“站立密度”和“入座率”。这比基于2D检测的计数准确得多因为3D可以减少跨网格的误计。跌倒检测站在3D坐标系下看跌落事件本质上是“头顶高度在短时间内骤降、主轴倾角剧烈变化”。这个特征比单纯看2D框的长宽比变化稳健很多也是目前很多养老院智能监控方案的核心逻辑。6.2 我对这套方案的个人体会做了这么多期视觉学习的总结这个案例给我的感觉是计算机视觉不需要每个环节都上深度学习。人体检测用YOLO这是成熟且高效的但站/坐的判断我用的是PCA和平面几何简单、可解释、可调参部署到低功耗设备上毫无压力。很多问题一个合适的坐标系变换比一个更大的神经网络管用得多。当然这套方案也有它的边界。比如学生弯腰捡东西、趴在桌上、站在椅子旁边等过渡姿态单靠这三个特征还是会误判。如果要进一步优化可以引入人体骨架关键点的3D坐标结合关键点做更精细的姿势语义分析。但那样对算力和标注数据的要求都会上一个台阶得根据项目预算来权衡。如果你也想在自己的场景里复现这个方案我的建议是先别急着跑通整个pipeline花半小时把深度相机的安装角度调好、地面平面拟合做准这一步做扎实了后面的判定逻辑怎么写都不会差太多。反过来相机没固定好、地面参数不准再聪明的算法也白搭。这大概就是3D视觉和2D视觉最大的区别——坐标系就是一切。