上个月我接了一个三维数字化相关的私活:对方用结构光扫描仪给某位同事扫了一份高精度人脸点云,希望我把眼睛、鼻子、嘴巴这些区域单独切出来,好给后续的局部形变算法提供蒙版。我第一反应是,这不就是聚类嘛,Open3D里现成函数一堆,直接跑不就完了。结果折腾了一整天,切出来的结果完全没法看:鼻梁连着半张脸颊,嘴巴断成上下两截,眼睛和眉毛糊成一大团。后来我才意识到,“人脸五官划分”这个需求的本质,不是把空间上相近的点聚在一起,而是在连续的人脸曲面上找出符合人眼认知的语义边界。这篇文章就是我用Open3D把这个需求一点点啃下来的完整记录,包括思路、代码、参数调整,以及几个我花了很多时间才排查掉的坑。
1. 为什么人脸五官划分不能直接套用通用聚类方法
1.1 点云的无序性与“五官”是两种维度的问题
点云本质上是一堆无结构的空间点,每个点只携带坐标、可能的颜色和法线。聚类算法像K-means、DBSCAN,它们擅长的是发现“密度连续”或者“距离相近”的组。但人脸的五官并不是以“密度”区分出来的,整个面部的点云密度一般都很均匀。真正区分鼻子和脸颊的,是局部曲面的弯曲程度和凹凸方向。
换句话说,鼻子和脸颊之间没有物理裂缝,甚至在几何上都是平滑过渡的。如果只用欧氏距离聚类,大概率会把鼻子、脸颊、嘴唇全部并成同一个大区域,因为它们在三维空间中是连通的。这就像你要在一张纸上用颜色笔画出“山脉”和“平原”,但纸上根本没有等高线,只有连续变化的坡面,你直接泼颜料显然分不出来。
1.2 网格表面与点云的几何信息不对等
很多人拿到扫描模型后,先转成三角网格再看。Open3D对TriangleMesh和PointCloud的处理方式并不完全一样。网格有顶点、边、面,可以很方便地计算顶点法线和邻接关系;而纯点云只能靠最近邻搜索模拟邻域。反过来,如果你手里只有点云,却急着去调用网格聚类API,就会遇到数据格式不匹配的问题。
这个项目最初的输入是PLY点云,不是网格。我一开始试图直接把它读成TriangleMesh再分割,结果发现模型可能是单层扫描,mesh重建出来有很多错误连接。最后我决定老老实实在点云层面做几何特征分割,等区域划分明确后,再考虑重建曲面。
1.3 实际业务场景里的“五官”到底指什么
我在这个项目里需要的分割标签包括:左眼区域、右眼区域、鼻梁加鼻翼、上嘴唇区域、下嘴唇区域,以及下巴区域。客户还要求每个区域边界尽量平滑,不能出现大量锯齿状小尖角。
注意,这里“鼻梁加鼻翼”是一个整体,“下巴”也被单独列为五官之一。这个语义划分并不是严格的解剖学分区,而是根据后续美化算法的形变需求定的。这意味着,分割算法必须能理解“哪些曲面凸起属于鼻子”“哪些凹陷属于眼窝”,而不是单纯把表面分成几块。对这类问题,通用聚类只是工具,真正决定成败的是你如何提炼面部的几何特征。
2. 预处理:让原始人脸扫描变成可下刀的面团
2.1 裁剪与去噪:先去掉头发、脖子和支架
原始扫描通常包含大量非人脸区域:头发、脖子、衣领,甚至扫描用的旋转台底座。这些无关点如果不提前清掉,会严重干扰后续的曲率计算和聚类范围。实际操作中,我第一步是观察原始点云的包围盒,然后用直通滤波把z轴方向超过合理范围的点全部删掉。
import open3d as o3d import numpy as np pcd = o3d.io.read_point_cloud("face_raw.ply") print("原始点数:", len(pcd.points)) # 手动观察后设置一个大致裁剪范围 bbox = o3d.geometry.AxisAlignedBoundingBox(min_bound=(-100, -100, -30), max_bound=(100, 100, 120)) pcd = pcd.crop(bbox)这里的数值单位是毫米,不同扫描设备的结果差异很大,裁剪前最好用draw_geometries可视化检查一下坐标轴方向。裁剪完再用统计滤波去掉离群点:
pcd, ind = pcd.remove_statistical_outlier(nb_neighbors=20, std_ratio=2.0)统计滤波的原理是计算每个点到其k近邻的平均距离,全局平均距离和标准差都统计出来后,距离超过mean + std_ratio * std的点被认为是离群点。扫描中的飞尘、反射噪声基本都能被滤掉。
2.2 体素降采样:曲率计算的性价比平衡点
原始人脸扫描的点数往往在一百万到三百万之间。如果直接对每个点搜索30个近邻并计算协方差,Python循环可能要跑到天荒地老。这时候必须降采样。Open3D的voxel_down_sample会把一个边长为voxel_size的立方体内的所有点合并成一个重心点,既降低了点数,又能保持整体几何形状。
voxel_size = 0.5 # 单位毫米,根据扫描精度调节 pcd = pcd.voxel_down_sample(voxel_size=voxel_size) print("降采样后点数:", len(pcd.points))体素大小的选择很有讲究。如果扫描点间距是0.2mm,你用1mm的体素会严重抹平鼻翼和嘴唇的棱线;如果扫描点间距是0.8mm,你用0.2mm的体素,降采样效果不明显,计算量还是很大。我习惯先计算一下原始点云的平均最近邻距离,然后取该距离的两到三倍作为体素大小。平均最近邻距离可以用Open3D自身的函数估算:
distances = pcd.compute_nearest_neighbor_distance() # 注意Open3D版本差异,早期版本需要自己用KDTree算 avg_dist = np.mean(distances)这里提个醒,不同版本的Open3D对compute_nearest_neighbor_distance的接口定义略有不同,实在不行就自己用KDTree循环采样几百个点求平均也行。降采样之后,原始的三百万点变成四五十万点,后续所有计算都能流畅跑起来。
2.3 姿态归一化:让鼻子朝向正z轴
分割算法的很多参数都和人脸朝向强相关。比如“眼睛在上方,嘴巴在下方”这个先验,只有在人脸姿态摆正后才是对的。扫描仪随意扫出来的模型,人脸可能歪着、斜着、甚至倒扣着。
网上很多教程直接用PCA求三个主轴,然后按特征值大小把点云旋转到坐标轴对齐。这个方法能用,但有个麻烦:PCA完全不知道哪条轴是上下、哪条是前后,经常出现翻转。更稳妥的办法是手动拾取三个参考点:鼻尖、左耳屏前点、右耳屏前点。用这三个点构造一个局部坐标系。
# 伪代码示意:用三个参考点构建旋转矩阵 # p_nose, p_left, p_right 是手动选择的三个点 # x轴 = p_right - p_left 归一化 # z轴 = (p_left + p_right) / 2 - p_nose 的负方向或正方向视约定而定 # y轴 = z轴叉乘x轴 # 再用3x3旋转矩阵作用到所有点上这部分实现细节不复杂,关键是要在可视化界面里把点选准确。如果觉得手点太麻烦,也可以对点云做对称面拟合,找出近似对称平面后,把对称轴对齐到世界坐标y轴。我更推荐手动选点,因为这个项目的业务方对“鼻梁区域”的起始位置有明确偏好,自动对齐算法不一定能理解这种语义。
2.4 法线估计:后面判断凹凸全靠它
Open3D对点云做法线估值非常方便,一行调用即可:
pcd.estimate_normals(search_param=o3d.geometry.KDTreeSearchParamHybrid( radius=2.0, max_nn=30))法线方向默认是非一致的,有的点法线朝外,有的可能因为扫描缺失而翻转向内。后续如果要用区域生长,就必须保证法线方向朝向一致。orient_normals_consistent_tangent_plane这个函数可以按照切平面来统一朝向,但它的参数k很关键,设置太小会导致方向没统一完,设置太大会让细节被磨平。我在这份人脸数据上通常用orient_normals_consistent_tangent_plane(k=20)。
法线本身不直接用于区分五官,但它是计算曲率和凸凹性的地基。没有可靠的法线,后面判断“眼窝是凹的、鼻尖是凸的”都是空谈。
2.5 计算表面变化量:简易曲率的实现
Open3D没有直接提供“曲率”函数,但我们可以用邻域协方差矩阵的特征值来估计一个等价量——表面变化量。对每个点,找到k个最近邻,计算这些邻居的协方差矩阵,特征值从大到小排列为λ0 ≤ λ1 ≤ λ2,表面变化量定义成 λ0 / (λ0+λ1+λ2)。这个值越接近于0,说明该点附近的邻域越像一个平面;越大,说明这个点落在尖锐转角或高曲率区域。
def compute_surface_change(pcd, k=30): pcd_tree = o3d.geometry.KDTreeFlann(pcd) points = np.asarray(pcd.points) num_points = len(points) surface_change = np.zeros(num_points) for i in range(num_points): [_, idx, _] = pcd_tree.search_knn_vector_3d(points[i], k) neighbors = points[idx, :] cov = np.cov(neighbors.T) eigvals = np.linalg.eigvalsh(cov) eigvals = np.sort(eigvals) if eigvals.sum() > 1e-12: surface_change[i] = eigvals[0] / eigvals.sum() else: surface_change[i] = 0.0 return surface_change这个循环在五十万点的点云上跑确实不快,我实际测试大概需要一两分钟。如果点更多,建议先降采样到二十万点左右,或者用numpy对邻域批量操作。理解原理之后,你可以用更高效的方式重写,这里只是展示计算思路。
计算完表面变化量后,用伪彩色映射一下就能看到,鼻翼边缘、眼窝内缘、嘴唇轮廓等高曲率区域会被点亮,而脸颊、额头这些相对平坦的区域基本是暗的。这些高亮点就是后续分割的种子。
3. 五官划分的三条技术路线对比
3.1 区域生长:适合边界明显的曲面区域
区域生长算法从一组种子点出发,沿着相邻点不断吸收“属性相似”的点。属性可以是法线方向夹角、曲率差、欧氏距离等。在人脸上,鼻翼两侧的法线方向变化剧烈,眼窝的曲率也比额头高很多,所以理论上区域生长能沿着高曲率边界“停止”,从而把五官区域圈出来。
但这个方案有个致命弱点:种子点选哪里、停止阈值设多少,都会使结果剧烈变化。而且人脸表面是平滑连续曲面,从鼻尖出发生长时,如果没有一个明确的边界条件,很容易一路长到额头上。区域生长更适合已经被明显边缘分割开的机械零件,不太适合生物表面。
3.2 曲率阈值二值化后做连通域分析:最容易出成果的路线
这是我在这个项目里最终采用的路线。思路很简单:先把所有表面变化量大于某一阈值的点标记为“特征点”,这些点大概率属于五官的棱线;然后对特征点做欧氏聚类,把距离相近的特征点合并成若干连通团块;最后根据每个团块的位置、大小、凹凸方向,把它归类到某个五官。
这条路线的好处是每一步都可控、可解释。参数就三个:曲率阈值、聚类半径、最小点数。虽然需要手动调,但调参方向明确。我强烈建议入门者先走通这个流程,再考虑更复杂的算法。
3.3 深度学习的语义分割:精度上线高,但体量也高
如果手上有几百个不同人脸的扫描模型,且每一帧都手工标注了眼睛、鼻子、嘴巴的标签,那么用PointNet++或者基于图神经网络的分割模型,能得到比几何方法稳定得多的结果。毕竟神经网络直接学习的是“语义”而不是“几何”。
但代价也很明显:你需要造数据集、标数据、训练调参,硬件要求不低,还要处理不同扫描设备的域差异。如果你的项目只是切一两个模型,直接上深度学习属于杀鸡用牛刀。我在这个项目开始前也评估过,最终放弃,因为业务方就给了一个模型,样本量根本撑不起训练。
下表是三条路线的实际对比:
| 方案 | 优点 | 缺点 | 适合场景 |
|---|---|---|---|
| 区域生长 | 速度快、可解释性强 | 种子点敏感、边界易溢出 | 有明显折边的表面 |
| 曲率阈值+连通域 | 步骤简单、参数直观 | 需要手动调参、对噪声敏感 | 单个或少数模型 |
| 深度学习语义分割 | 泛化性强、语义准确 | 需要大量标注、训练成本高 | 批量处理同类型扫描 |
4. 基于曲率的“粗分割+精细修正”实战实现
4.1 整体流程设计
我在最终代码里把整个流程拆成四步:预处理、特征提取、候选区生成、后处理修正。预处理已经在上文讲过。特征提取就是计算表面变化量。候选区生成依靠cluster_dbscan。后处理修正是这个项目的精髓,专门对付“嘴巴断裂”和“眼眉连体”这两类问题。
4.2 特征提取与候选区域生成代码
假设我们已经有经过预处理的点云pcd和表面变化量数组surface_change。阈值threshold我一开始取0.05,这个数值的含义是协方差最小特征值占所有特征值总和的5%。接下来用阈值筛选特征点:
threshold = 0.05 feature_indices = np.where(surface_change > threshold)[0] feature_pcd = pcd.select_by_index(feature_indices) print("特征点数:", len(feature_pcd.points))然后对特征点云做欧氏聚类。Open3D的cluster_dbscan需要设定eps(两个点归为同一簇的最大距离)和min_points(簇的最少点数)。min_points设得太小会产生大量碎片,太大又会漏掉小区域。
labels = feature_pcd.cluster_dbscan(eps=3.0, min_points=200) labels = np.asarray(labels)聚类返回的标签中,-1表示噪声点,其余从0开始递增。我们写一个辅助函数把每个聚类单独取出来:
def extract_cluster(pcd, labels, cluster_id): indices = np.where(labels == cluster_id)[0] cluster = o3d.geometry.PointCloud() cluster.points = o3d.utility.Vector3dVector(np.asarray(pcd.points)[indices]) return cluster clusters = [] for label_id in np.unique(labels): if label_id == -1: continue clusters.append(extract_cluster(feature_pcd, labels, label_id))这时候你会得到一堆点云团块。用draw_geometries可视化一下,你会看到类似这样的现象:鼻翼两侧各有一个大团块,眼窝附近有几个团块,嘴部有上下两个团块,还有一些小碎片散布在脸颊皱纹和下巴皮肤上。
4.3 如何将候选团块归类为具体五官
聚类本身不知道哪块是眼睛哪块是嘴,需要根据人脸姿态先验做筛选。在姿态归一化后的坐标系里,我定义如下规则:
- 眼睛区域:质心y坐标偏上,区域长度在水平方向较长而竖直方向较短,且区域内部点云整体呈凹性。
- 鼻子区域:位于面部中央,z坐标(前后方向)相对于两侧明显凸起,区域从鼻根延伸到鼻翼下方。
- 嘴部区域:位于眼睛下方、下巴上方,左右方向宽度适中,上下嘴唇分别有两个团块。
代码上,用get_center获取每个团块的中心,再比较中心坐标。例如:
centers = [np.asarray(cluster.get_center()) for cluster in clusters]然后根据centers的x、y、z范围做筛选。对于眼睛来说,聚类结果经常会把左眼和右眼合并成一个区域,或者把眼睛和眉毛连在一起,这时不能用简单的质心筛选,需要进一步用凸凹性拆分。这部分内容放在后文踩坑部分详细说。
4.4 粗分割结果的后处理:孔洞填充与边缘平滑
粗分割得到的区域是很多离散的点,不是连续的点云块。因为曲率阈值严格,眉毛内侧可能有小凹陷没被选成特征点,导致眼睛区域里出现空洞。最简单的填充方法是,把区域点云转换成一个二进制体素网格,然后做形态学闭运算。
# 将点云转换为体素网格 voxel_grid = o3d.geometry.VoxelGrid.create_from_point_cloud(cluster, voxel_size=0.5) # 闭运算:先膨胀再腐蚀 # 但Open3D没有直接提供VoxelGrid的形态学操作,通常用二进制数组手动实现我在项目里没有引入额外库,而是用另一个更轻量的办法:对每个已标记为五官区域的点,找到它在原始点云中的所有近邻,如果近邻中有超过半数的点也属于该区域,把这个近邻点也吸纳进区域。反复迭代几次,空洞自然被填上。
def dilate_region(pcd, region_indices, k=20, max_iter=5): pcd_tree = o3d.geometry.KDTreeFlann(pcd) region_flags = np.zeros(len(pcd.points), dtype=bool) region_flags[region_indices] = True for _ in range(max_iter): new_flags = region_flags.copy() for i in np.where(region_flags)[0]: [_, idx, _] = pcd_tree.search_knn_vector_3d(np.asarray(pcd.points)[i], k) neighbor_ratio = np.mean(region_flags[idx]) if neighbor_ratio > 0.5: new_flags[idx] = True region_flags = new_flags return np.where(region_flags)[0]注意,这个循环一旦点数超过几万,速度会非常慢。实际应用时可以把已标记区域的点做体素降采样,然后用numpy的广播计算近邻,或者直接使用open3d.t.geometry.PointCloud支持并行。教学代码只求逻辑清晰,性能需要你自己优化。
5. 踩坑实录:为什么结果会出现“嘴巴断成两半”“眼睛连着眉毛”
5.1 嘴巴断成两半的完整排查链路
现象:第一次跑聚类,嘴唇区域被分成了上下两个团块,中间有一条清晰的间隙。客户说“我要的是一整张嘴唇,不是上嘴唇和下嘴唇分开”。
我一开始以为是曲率阈值太高,导致唇线附近低曲率区域被漏掉。于是我降低阈值到0.03,结果整个嘴唇连带周围的皮肤全都变成了一个巨大的团块。这说明问题不在阈值,而在聚类参数。
继续排查:cluster_dbscan的eps是3mm,而上下嘴唇之间的中间缝隙——也就是闭口时嘴唇相接的那条线,曲率确实极高,特征点非常密集,但它们之间的距离并不远,理论上不应该被分开。我又检查了特征点分布,发现嘴缝处的点云在扫描时就存在一小段缺失,形成了宽度约2mm的空洞。DBSCAN的连通性判断基于点间距离,一旦空洞宽度超过eps,上下嘴唇自然就断开了。
修复方案有两个。第一,把eps从3mm提高到5mm,让空洞两侧的点能越过间隙连上;第二,如果空洞太大,就不能只靠聚类,需要在后处理阶段做形态学连接。我在这次项目里采用了后者,因为eps调太大,会把附近的下巴皱纹也并进来。
5.2 眼睛和眉毛连成一片:凸凹性才是分水岭
现象:眼睛聚类和眉毛聚类合并成了一个U形大团块,左眼区域包含了左眉毛,右眼区域包含了右眉毛。为什么曲率阈值无法区分它们?因为眼窝内缘和眉毛都位于高曲率区域,而且相距不到5mm。如果只想靠曲率分割,除非阈值刷得非常高,否则它们永远连在一起。
后来我换了个角度:眼睛区域在几何上是凹陷的,眉毛是凸起的。即使两个区域曲率相近,它们沿法线方向的空间凹凸性质完全不同。具体做法是把候选团块内的每个点,沿法线方向投影到该团块的拟合平面上。眼窝点的投影距离是负值(向内部凹陷),眉毛点的投影距离是正值(向外凸起)。
我用一个简单的邻域深度差来判断:
def compute_local_depth(pcd, indices, k=20): pcd_tree = o3d.geometry.KDTreeFlann(pcd) depths = [] points = np.asarray(pcd.points) normals = np.asarray(pcd.normals) for i in indices: [_, idx, _] = pcd_tree.search_knn_vector_3d(points[i], k) # 计算邻域平均位置 centroid = points[idx].mean(axis=0) # 沿法线方向的偏移:当前点相对邻域中心在法线上的投影 n = normals[i] depth = np.dot(points[i] - centroid, n) depths.append(depth) return np.array(depths)如果深度值整体为正,说明该团块相对邻域向外凸,标记为眉毛/鼻梁;如果整体为负,说明是凹坑,标记为眼窝。利用这个特征,我成功把连体团块从中间拆开。具体的拆分方法是对团块内部再做一次简单的K-means二分,但特征选择不是坐标,而是局部深度值。
5.3 阈值参数对扫描分辨率极其敏感
同一个threshold=0.05,在A设备扫出来的0.3mm间距点云上效果很好,换到B设备的0.8mm间距点云上就完全失效。原因是表面变化量计算依赖k近邻数量,而k近邻的实际物理半径会随点间距变化。0.8mm间距下,30个近邻覆盖的半球面积更大,局部特征的曲率被平滑掉,特征点大量减少。
解决办法是自适应参数:先计算点云的平均最近邻距离d_avg,然后设置搜索半径radius = d_avg * k_scale,其中k_scale取5到10。这样就可以让邻域在物理空间中的大小保持稳定。以下是一段自适应曲率计算的片段:
def adaptive_surface_change(pcd, k_scale=6.0, k=30): points = np.asarray(pcd.points) # 采样计算平均最近邻距离 sample_idx = np.random.choice(len(points), min(1000, len(points)), replace=False) tree = o3d.geometry.KDTreeFlann(pcd) dists = [] for i in sample_idx: [_, idx, _] = tree.search_knn_vector_3d(points[i], 2) dists.append(np.linalg.norm(points[i] - points[idx[1]])) d_avg = np.mean(dists) radius = d_avg * k_scale # 然后用radial search计算邻居而不是固定k # ...在实际项目中,我遇到过一次点云密度突然变成原来两倍的情况,用固定参数跑出来的分割结果直接少了一个鼻子。后来加入自适应半径之后,至少不会出现“换个设备结果完全不可用”的尴尬局面。
6. 结果验证与后续扩展
6.1 定性验证:把分割结果渲染到同一张点云上
分割完成后,我习惯给每个五官区域赋予不同的颜色,然后整体渲染一遍。Open3D里可以直接在原始点云上复制一份,把对应区域的点赋予RGB值:
pcd_colored = copy.deepcopy(pcd) colors = np.zeros((len(pcd.points), 3)) # 0: 背景 1: 左眼 2: 右眼 3: 鼻 4: 上唇 5: 下唇 6: 下巴 color_map = { 0: [0.8, 0.8, 0.8], 1: [1.0, 0.0, 0.0], 2: [0.0, 1.0, 0.0], 3: [0.0, 0.0, 1.0], 4: [1.0, 1.0, 0.0], 5: [1.0, 0.0, 1.0], 6: [0.0, 1.0, 1.0], } for idx in range(len(pcd.points)): if idx in left_eye_indices: colors[idx] = color_map[1] elif ... else: colors[idx] = color_map[0] pcd_colored.colors = o3d.utility.Vector3dVector(colors) o3d.visualization.draw_geometries([pcd_colored])同一个渲染窗口里,我还会并排显示原始点云和分割后的点云,方便调整参数。项目尾声,为了给客户看效果,我会把五个角度的截图合成一张图。这个步骤虽然简单,但千万不能省,因为很多边界问题只有在连续转动视角时才能暴露出来。
6.2 定量评估分割质量:不仅仅是“看得顺眼”
如果需要量化的验收标准,我建议计算分割结果与手工标注标签的IoU(交并比)。在项目里我先让标注人员在点云上人工框出每小时五官区域的大致范围,生成了粗略的真值标签。
IoU的计算公式是:交集点云数除以并集点云数。考虑到手工标签本身就有主观性,IoU达到0.7以上就可以接受。如果是边界精确度要求很高的场景,可以再计算表面距离误差——从分割区域边界点到真值区域边界点的平均最近距离。
def compute_iou(set_a, set_b): intersection = len(set_a & set_b) union = len(set_a | set_b) return intersection / union if union != 0 else 0值得注意的是,手工标注的边界本身就不清晰,所以评估时不要只看一个指标。我通常把IoU、边界平均距离、过分割和欠分割区域面积结合起来看。
6.3 把一整套流程移植到三角网格上
如果你的输入是完整的三角网格,而不是散乱点云,处理方式会更优雅。网格自带顶点邻接关系,区域生长时不需要再做KDTree近邻搜索,直接沿着边展开就行。Open3D里TriangleMesh有现成的compute_vertex_normals,你可以计算每个顶点的离散曲率,然后用图算法做区域生长。
网格分割的好处是边界天然是闭合的折线,后期直接提取轮廓线作为形变控制线非常方便。缺点是需要先做一次网格重建,而重建过程中可能会把扫描噪声变成细长三角形,那些区域的几何特征会被污染。我个人的建议是:如果只想快速出结果,就在点云上做分割;如果对边界质量有较高要求,再升级到网格。
6.4 后续扩展的思路
分割结果不只是用来给美化算法提供蒙版。我在这之后顺手做了一件小事:把鼻子区域单独抠出来,用一个球面拟合它的表面,然后根据拟合误差给客户提供一个“鼻梁挺拔度”的粗略量化指标。这个思路可以推广到其他五官,例如用椭球拟合眼窝,用平面拟合颊区。
另外,如果点云有纹理图,可以把RGB信息加入曲率特征中一起聚类。肤色、唇色和眉毛颜色本身就有明显差异,多一个模态信息能显著降低眼睛和眉毛混在一起的概率。不需要改太多代码,只要在特征向量里拼上一维颜色距离。
我在实际运行中发现,最后这个办法对浅色皮肤和深色眉毛的人脸特别有效。如果你正在做的项目也困于几何特征无法区分五官,不妨先试试把颜色作为一个弱特征加进去,而不是直接跳到数值优化。
以上就是我在Open3D里完成人脸五官划分的完整复盘。整个项目的核心收获其实就一句话:先把几何特征算扎实,再谈聚类和语义。单纯靠某个现成API一步到位是不存在的,Open3D给的是积木,怎么搭成五官分割的城墙,还是要靠对几何本身的理解。