我第一次在NYU Depth V2数据集上下载预处理脚本时,看到里面有个函数叫computeHHA,第一反应是“哈哈”——以为作者随手起了个卖萌的名字。直到我把数据跑进网络、又去翻了Saurabh Gupta那篇ECCV 2014论文,才意识到HHA是三个几何通道的缩写:Height above ground(离地高度)、Horizontal disparity(水平视差)、Angle(表面法向量与重力方向的夹角)。它不是花活,而是把“深度图”翻译成神经网络更容易消化的“几何语言”。
这篇内容想聊清楚三件事:HHA到底是什么、为什么当年的RGB-D识别几乎绕不开它、以及今天做检测/分割/显著性任务时你还该不该用它。适合刚接触RGB-D感知的入门者,也适合在工程里踩过深度图预处理坑、想把手头数据换一种编码方式的老手。
1. RGB-D感知到底在感知什么:从“深度图能用就行”说起
1.1 RGB-D里的“D”不是一张普通的灰度图
很多人对深度图有一个直觉:它跟灰度图差不多,只是每个像素记录的不是亮度而是距离。这句话对了一半。深度图确实可以当成单通道图像去卷积,但它和自然图像有个本质区别——自然图像里像素值的大小、比值、纹理方向都有稳定的语义规律,而深度图里的数值是“绝对距离”,单位是毫米或米,并且这个距离跟相机位置、相机朝向强相关。
同一个杯子放在0.5米处和放在1.5米处,它在深度图里的像素值差了3倍;但在RGB图里,它的外观几乎不变。也就是说,把原始depth直接送给卷积网络,网络学到的其实是“离相机多远的杯子”,而不是“杯子长什么样”。这一点在物体检测和分割任务上特别致命,因为模型很难在不同距离下对同一物体形成统一特征。
深度图还会有各种传感器噪声:结构光在黑色物体上容易丢点,ToF在透明玻璃上会产生飞点,双目在无纹理墙面会算出差很多的视差。这些无效点和噪声如果直接进网络,卷积核会被带偏。我之前在RealSense上采集数据,把深度图直接可视化成灰度图,发现边缘处有一圈一圈的“花边”,那些都是深度跳变造成的假轮廓。
1.2 把深度图直接丢给卷积网络,会遇到三个麻烦
第一个麻烦是尺度。深度值动辄几十厘米到几米,直接归一化到0到1之后,远处目标的像素值几乎被压成同一个常数。比如在室内场景里,1.5米和3米的物体在归一化后差距很小,但它们的视觉轮廓完全不同。CNN又不是真正的“人眼”,它只能从数值梯度里去猜边界,结果就是远处物体学不出细节。
第二个麻烦是噪声。深度图的无效点通常置0或NaN,可卷积网络不知道“0是无效”这回事,它会把0当成一个真实的距离值去参与计算。结果在深度边界一圈会出现一个奇怪的“暗边”,严重影响分割和检测。早期很多论文里“深度图边缘补洞”“膨胀腐蚀”之类的预处理,本质上就是在跟这个问题搏斗。
第三个麻烦是相对信息。深度图是相对相机坐标系的距离,而不是物体的固有属性。相机稍微绕光轴转一下,原本同一朝向的表面在深度图里就变了;相机俯仰角变了,地板的梯度也变了。识别任务想要的是“物体相对重力是什么姿态”,而不是“物体相对相机是什么距离”。这时候就需要一种把深度图从“相机坐标系”搬到“重力坐标系”的编码——HHA就是干这个的。
2. HHA三个字母拆开讲:高度、视差、夹角分别是干什么的
2.1 HHA的出身:从NYU Depth V2里长出来的编码
HHA出自Gupta、Girshick、Arbelaez和Malik在ECCV 2014年发表的论文《Learning Rich Features from RGB-D Images for Object Detection and Segmentation》。当时的背景是:RGB-D数据已经有了,但怎么把深度信息喂给深度卷积网络还没有标准做法。直接把depth当第四通道,效果很差;把depth转成伪彩色图,也只是换个颜色通道,没有物理意义。
他们提出的方案是对深度图做一次“以地心为参照”的几何变换,得到三个通道,每个通道都有明确的物理含义:离地高度、水平视差、表面法向量与重力方向的夹角。这个编码后来被简称为HHA。严格按顺序,H是Height,第二个H是Horizontal disparity,A是Angle。有人会把第二个H误会成“Height”,其实不是,它是视差。
为什么这样做有效?他们的核心洞察是:卷积网络擅长从局部纹理梯度里学特征,但前提是输入特征的统计规律相对稳定。把深度从相机坐标系转到重力坐标系,相当于先把传感器在位姿上的变化“消掉”了一部分,再让网络去学几何结构,难度自然就下降了。
2.2 Height above ground:离地高度,帮网络建立“层”的概念
离地高度,字面意思就是每个三维点在重力方向上离地面有多远,单位是米。你可以把它理解成给整个场景做了一次“楼层分层”。室内场景里,地板高度约等于0,桌面在0.7米左右,桌面上的杯子可能到1米,安装在墙上的开关面板在1.2到1.4米,天花板上的灯在2.5米以上。当网络看到某个像素的高度值在0.8米附近,它就已经有了“这可能是桌面平面上的物体”的预期。
这个信息是原始深度图很难直接给的。原始depth给的是离相机距离,同一个桌面倾斜一点,各像素距离就完全不同;但桌面在重力方向上的高度,无论相机怎么摆,都在同一个数值附近。所以H通道其实是在帮网络建立“物体在空间中处于哪个垂直层级”的不变量。
离地高度不是简单的“相机高度减深度”。它依赖于重力和地平面的估计,如果地面不在视野里,或者相机高度发生变化,这一通道就会出问题。这一点后面的实操部分会展开说。
2.3 Horizontal disparity:水平视差,比深度更适合作为网络输入
第二个H是水平视差。在立体视觉里,视差就是同一个三维点在左右相机图像里水平位置的偏差。视差和深度成反比,公式是d = f·b/Z,其中f是焦距(像素),b是双目基线长度,Z是深度。对单目深度传感器来说,并没有真实基线,但可以设定一个虚拟基线,把它当作对深度做一个单调变换。
那为什么不直接用深度Z?因为在感知任务里,我们更关心的是“近处物体的微小距离变化”,而不是“远处物体的绝对距离”。深度Z在近处变化很剧烈,远处却很平缓;而视差恰好反过来,它放大了近处的差异,压缩了远处的差异。这对网络提取近处物体的轮廓非常有利。另一个原因是传感器的噪声特性:ToF和双目深度传感器的误差通常随距离平方增大,但在视差域基本是均匀的。让输入特征域的噪声统计更平稳,网络学起来就更稳。
视差通道还有一个好处:它和深度是单调映射关系,理论上可以从视差反推回深度,也就是说这个通道保存了绝对距离信息。HHA并不是把距离丢了,而是用了一种更利于学习的形式保留它。
2.4 Angle:表面法向量与重力方向的夹角,局部朝向的描述
第三个通道是表面法向量和重力方向的夹角,单位是弧度,范围在0到π之间。表面法向量描述的是局部平面的朝向。地板的法向量如果朝上,和重力方向的夹角是0°(或180°,取决于法向量朝向定义);墙面的法向量是水平的,夹角接近90°;桌面的法向量朝向也接近90°。
这个通道给网络的是一种“局部表面朝向”信息。有了它,网络可以区分一个像素是墙、地板还是桌面,即使RGB纹理上看不出明显差别。比如一个纯白色的桌子平面和一个纯白色的墙面,在RGB里很难分,但如果知道其中一个表面法向量与重力夹角是90°、另一个也是90°,再加上高度不同,就能区分:桌子高度在0.7米附近,墙面则延伸整个高度。
夹角通道的计算依赖法向量估计的精度。在深度边界处,法向量估计通常很不稳定,所以HHA整体对深度图的“孔洞修复”有一定要求。如果深度图有大量缺失,法向量通道会出现大片噪声,反而是拖累。这也是为什么工业落地时,大家一般会先做深度补全再转HHA。
3. 深度图到HHA:一步步数学变换拆解
3.1 先把深度图还原成三维点云
HHA的所有计算都发生在三维空间,第一步是把深度图反投影到相机坐标系下的三维点云。假设你有相机内参fx、fy、cx、cy,对图像每个像素(u, v)和它的深度值Z(以米为单位),相机坐标是:
X = (u - cx) * Z / fx Y = (v - cy) * Z / fy Z = Z这其实是在做“针孔相机模型”的逆变换。注意深度单位必须转成米,很多传感器原始数据是毫米,直接算会得到大得离谱的点云坐标。我在早期实现里就犯过这个错,把毫米当米,结果后面所有高度计算都错了三个数量级,训练损失直接不收敛。
这一步得到的点云,X轴向右,Y轴向下,Z轴朝前——这是OpenCV和多数RGB-D相机的约定坐标系。做HHA之前,务必先确认你的深度图和RGB图像的对齐关系。如果深度图和RGB图分辨率不一致,要么先做对齐,要么就用深度图自带的内参单独反投影,不要在未对齐的图上直接计算。
3.2 重力方向与地平面估计:H通道的地基
HHA的“以重力为参照”不是玄学,它需要一个明确的重力方向向量,通常由地平面的法向量来定义。在纽约大学NYU Depth V2数据集的官方工具箱里,地平面是从标注或传感器记录里获得的;对一般数据集,常见做法有几种:
- 用RANSAC在点云里拟合最大的水平平面,把平面法向量作为重力方向。
- 用IMU直接获取重力方向,再把相机坐标系下的点云旋转到重力对齐的坐标系。
- 假设相机光轴大致水平、地平面在相机下方固定高度,用于一些固定安装的室内摄像头。
官方实现里,高度计算依赖“相机离地高度”和“重力方向”。假设你已经把重力方向单位向量g(指向上方)求出来了,地平面过某个点P_ground,那么任意三维点P的离地高度就是:
height = (P - P_ground) · g如果地平面法向量正好是g,这个式子非常简洁。从动捕或IMU拿到重力方向后,也可以把点云整体旋转到“重力方向朝上”的坐标系,再算高度。很多人会在这里混淆:H通道不是“相机高度减去深度”,而是把三维点投影到重力方向上,得到一个绝对高度。相机自身的高度变化会直接改变所有点的高度值,所以同一个场景如果相机高度不同,H通道的分布是完全不同的。
3.3 计算三个通道
拿到点云和重力方向后,三个通道的计算可以依次完成。
高度通道:如上所述,高度 = (P - P_ground)·g。实际操作中,地面可能不在视野范围,无法直接得到地面点。一个折中方案是用“相机高度加上点的相对高度”。假设相机位于重力方向上的高度为h_cam,那么某点的离地高度可以写成h_cam减去该点相对相机在重力方向上的分量。这时候h_cam是相机离地面的高度,如果相机装在机器人顶部,这个值是固定的;如果相机随云台旋转,就需要实时更新。
视差通道:直接对深度做倒数,再乘一个尺度常数。官方工具箱里通常用类似disparity = min(f * b / Z, max_val)的形式。如果你用的是单目ToF,b设为1或按经验设一个值;如果你想保持和深度图相同的方向,也可以用1 / Z,再缩放到合适范围。重点不是具体常数,而是保持训练和推理时参数一致,且数值范围稳定。
角度通道:先用点云估计每个像素的表面法向量,再计算该法向量与重力方向的夹角。法向量估计的常用方法包括:对局部邻域做平面拟合(用协方差矩阵的最小特征向量),或者用相邻像素差值的叉积。后者的实现很快,但噪声也大。我会在下面的代码示例里给一个简化版本。
总的来说,HHA三个通道的计算虽然各有各的细节,但核心逻辑都围绕“把相机坐标系的深度信息变换到重力坐标系的特征表示”。这也是它比原始深度图更“好读”的根本原因。
3.4 归一化与存储:数值范围决定了训练稳定性
原始HHA计算出来之后,数值范围其实很乱。高度可能从0到几米,视差从0到几百像素,角度则是0到π。如果不做归一化直接当一个三通道图喂给卷积网络,训练初期梯度会被大数值的特征主导。
常见的处理方法是:高度截断到一个合理范围(比如0到2米),再除以最大值得到0到1;视差截断到0到某个阈值,再归一化;角度直接除以π。有些工具箱会把这些通道缩放到0到255存成uint8的图像。这个做法在当年是为了节省磁盘、兼容ImageNet预训练模型的输入习惯,但也带来了量化误差。我个人更推荐在工程里存float16的npy或HDF5,避免把高度0.73米和0.74米之间的差异抹掉。
归一化参数必须在训练集上统计出来,然后固定,不能每个batch重新算。否则网络相当于每次都在跟一个数值分布不断变化的输入作斗争,收敛速度会明显下降。
4. 同样的网络,输入HHA和原始depth差距到底有多大?
4.1 我在目标检测和显著性任务上的实测印象
我在做RGB-D显著性目标检测的时候,用过一段时间的Faster R-CNN和后来的三分支网络。一个很直观的感受是:在相同网络结构下,把Depth分支的输入从原始depth换成HHA,在PASCAL VOC风格的RGB-D检测任务上,mAP大约有1到3个百分点的提升;在显著性检测上,边界质量指标(比如BF score)提升更明显。它不是那种“换了网络结构才有的质变”,而是“同样的模型却更稳了”的量变。
原因出在预训练上。那时候大家普遍用ImageNet预训练模型做初始化,而ImageNet模型是为三通道自然图像设计的。原始depth单通道要复制三次才能凑成三通道,但它的数值分布和自然图像差太远;HHA虽然也不是自然图像,但它的三个通道数值都在0到1附近的连续范围,空间梯度分布也更接近自然图像的纹理分布,预训练模型迁移过去的特征会更有效。
4.2 HHA为什么更适合迁移学习和预训练
HHA在结构上有一个先天优势:它的三个通道都来自同一个几何变换,但各自“看”的是不同的物理量——高度、视差、朝向。这和RGB的“红色通道、绿色通道、蓝色通道”有某种结构相似性,卷积网络在预训练阶段学到的“通道间相关性”能力可以被迁移到HHA上。
相比之下,很多自制的“深度伪彩色图”只是把单通道深度用colormap映射成三通道,本质上还是一张单通道图套了三层皮,通道间的信息冗余极高,预训练模型迁移过去的效果自然差。HHA每个通道的梯度模式不同,网络反而能在早期卷积层学到更丰富的几何特征。
4.3 不是每个任务都需要HHA
也不是说所有RGB-D任务都非得用HHA。如果你做的是三维重建、点云分类,直接用点云坐标和法向量本身效果更好。如果你的深度数据质量极差,比如大量黑色物体导致结构光深度缺失,HHA的高度和角度通道会充满噪声,这时候直接用原始depth可能还好一些。
我的经验是:HHA最适合“把RGB和Depth都当图像模态处理”的识别类任务,特别是目标检测、语义分割、显著性检测。对于逐点几何推理的任务,它反而是降维了。
5. 踩坑记录:从官方工具箱到自己的Python实现
5.1 官方工具箱里的隐藏假设
网上流传最广的HHA实现是Saurabh Gupta的Matlab工具箱(包含在rcnn-depth项目里)。这个工具箱有几个隐藏假设,不太好发现。
第一,它的内参默认是Kinect v1的,fx/fy/cx/cy都是NYU Depth V2的固定值。你换成RealSense或者Kinect v2的数据,如果不管内参直接跑,出来的视差通道尺度会不对。第二,它默认深度图已经做了对齐,深度图尺寸和RGB一致。如果你的深度图是640x480而RGB是1920x1080,要先做对齐。第三,它把重力方向当作已知量传入,如果你从数据里估计地平面的方法不够稳,H通道会在局部区域出现不连续。
5.2 内参不一致导致视差通道失真
我第一次换到自采数据时,用的是D435i,视差通道出来的图明显比NYU上看到的“扁”很多。查了半天发现是fx不一样。Kinect v1的fx约等于570像素,D435i的fx根据分辨率不同可能只有300多,视差归一化时阈值没改,结果近处物体全部被截断到同一个值。这个坑很容易出在“换相机”之后。
解决方式:不要用固定常数,直接从相机内参读取fx,把视差阈值也根据数据集统计出来。离线预处理时,把统计好的归一化参数存成JSON,训练和测试用同一份。
5.3 重力方向估计失败的常见场景
重力方向是HHA的“坐标轴原点”,一旦估计错,整个HHA就废了。我遇到过几种典型场景:
- 机器人下坡或云台俯仰角变化,IMU外参没标定,导致重力方向在相机坐标里有偏差。
- 室内场景里地板被桌子大面积遮挡,RANSAC拟合地平面时拟合到了桌面,于是“离地高度”变成了“离桌面高度”。
- 无人机俯拍场景,地面根本不在视野范围内,地平面估计完全失效。
处理办法也分几种:如果固定安装,手动标一次外参;如果场景里有明显大面积地面,RANSAC要限制平面面积阈值;如果既没有地面又没有IMU,坦白说HHA在这类场景里不合适,不如换用表面法向量或直接点云输入。
5.4 保存格式对HHA精度的影响
HHA的视差通道在近处数值变化剧烈,你在uint8里保存时,可能把0.5米到0.6米之间的视差压到几个灰度级里,网络根本区分不出来。高度通道在接近地面时变化也很细微,同样容易量化损失。
建议保存方式分两种:如果只是训练基线,可以存uint8 PNG,磁盘占用小,跑起来快;如果对精度有要求,尤其做缺陷检测、分割任务,存float16的npy或者HDF5。不要用JPEG压缩,HHA不是给人看的图,JPEG的块效应会直接污染表面法向量和高度信息。
5.5 一个可参考的简化实现
这里给一个思路清晰的Python简化版本,方便理解,不保证直接投入生产。它省略了法向量估计的精细优化和地面RANSAC,重点展示流程。
import numpy as np def depth_to_hha(depth, fx, fy, cx, cy, g=np.array([0.0, -1.0, 0.0]), h_cam=1.2, baseline=1.0, max_disparity=2.0): """ depth: HxW float, 单位米, 0表示无效 g: 重力方向单位向量(相机坐标系),这里默认相机水平朝前、重力向下 h_cam: 相机离地高度,单位米 """ h, w = depth.shape u, v = np.meshgrid(np.arange(w), np.arange(h)) # 反投影到相机坐标 Z = depth.astype(np.float32) X = (u - cx) * Z / fx Y = (v - cy) * Z / fy points = np.stack([X, Y, Z], axis=-1) # HxWx3 # 高度通道:相机高度 - 点沿重力方向的分量(简化) height = h_cam - np.sum(points * g, axis=-1) # 注意符号取决于g方向 # 视差通道 disparity = (fx * baseline) / np.maximum(Z, 1e-3) disparity = np.clip(disparity, 0, max_disparity) # 表面法向量(简化:按行差和列差的叉积) dz_dx = np.zeros_like(Z) dz_dy = np.zeros_like(Z) dz_dx[:, 1:-1] = Z[:, 2:] - Z[:, :-2] dz_dy[1:-1, :] = Z[2:, :] - Z[:-2, :] # 法向量近似:(-dz_dx*fx, -dz_dy*fy, 1) 再归一化 normals = np.stack([-dz_dx * fx, -dz_dy * fy, np.ones_like(Z)], axis=-1) norm = np.linalg.norm(normals, axis=-1, keepdims=True) normals = normals / np.maximum(norm, 1e-6) # 夹角通道:法向量与重力方向夹角 angle = np.arccos(np.clip(np.sum(normals * g, axis=-1), -1.0, 1.0)) # 简单归一化 height = np.clip(height / 2.0, 0, 1) disparity = disparity / max_disparity angle = angle / np.pi hha = np.stack([height, disparity, angle], axis=-1) return hha.astype(np.float32)这段代码没有做无效点掩膜、没有精细的地面估计,法向量也只是梯度近似,但它能让你直观感受到HHA不是某种不可复现的“黑盒预处理”。投入生产前,一定要补上深度补全、无效点抑制和更稳的法向量估计。
6. 现在做RGB-D识别,还值得用HHA吗?
6.1 从三分支网络看HHA的生态位
近两年的RGB-D显著性检测、缺陷检测论文里,经常能看到“分支1是RGB,分支2是Depth,分支3是HHA”这样的设计。像“三分支交叉模式交互网络”这类结构,本质上是在用不同语义层次的输入做互补:RGB提供纹理和颜色,Depth提供绝对距离,HHA提供几何属性。HHA在这种框架下不是一个被淘汰的旧东西,反而因为计算成熟、物理意义清晰,成了一个稳定的“几何特征基准分支”。
很多新方法其实可以把HHA换成任何其他几何编码,比如表面法向量、点云坐标、深度补全后的距离变换,但替换之后性能不一定更好。原因就在于HHA的视差通道保留了绝对距离,高度和角度提供了结构信息,三者之间信息重叠小、互补性强。这种“低成本、高信息密度”的特点,让它至今仍是一个强基线。
6.2 和其他深度编码方式比,HHA的优势与劣势
我这里把常见的几种深度编码放在一起比过。
| 编码方式 | 信息维度 | 优点 | 缺点 |
|---|---|---|---|
| 原始depth | 距离 | 信息无损,直观 | 尺度敏感,噪声大 |
| HHA | 高度+视差+角度 | 几何语义清晰,利于预训练 | 依赖重力方向,地面估计有风险 |
| 表面法向量 | 局部朝向 | 对姿态稳健 | 没有绝对距离信息 |
| HAA | 高度+角度 | 去掉了视差通道 | 近处细节丢失 |
| 深度补全+距离变换 | 距离+距离梯度 | 边界清晰 | 补全误差会放大 |
HHA的优势在于它是三者兼顾,劣势则是它至少需要“相机相对地平面的姿态”这一额外假设。如果你的传感器是固定安装、姿态稳定,HHA几乎是白赚的收益;如果你的相机装在四足机器人或无人机上,姿态随时在变,HHA的重力估计就会成为系统中最脆弱的一环。
6.3 我的选择:什么时候继续用HHA,什么时候换掉
回到我自己做项目的习惯,我会这样选:如果任务是把深度当“第二张图像”来用,比如RGB-D检测、显著性检测、语义分割,我优先用HHA,而且把它直接作为三分支或双分支结构的几何输入。主要原因是省心,它不需要PointNet那样的点云分支,也不需要在训练时处理稀疏点云,整个pipeline在图像域里就能跑通。
如果任务是抓取位姿估计、三维重建、机器人操作这类需要精细几何理解的任务,HHA就不够用了。这时候我更倾向用点云或TSDF,让网络直接感知三维结构,HHA的2.5D编码会丢失太多几何细节。
另一个判断依据是数据量。HHA像是给CNN的一副“拐杖”,在小数据集、预训练迁移场景下特别管用。数据量真正大到可以端到端学出来的时候,模型自己也能够从原始depth里学会类似的特征,HHA的优势就没那么明显了。可现实是,很多项目根本没有那么多标注数据,所以我仍然会在pipeline里保留HHA——它是我能花最小代价换来稳定提升的一个预处理步骤。