news 2026/10/3 1:06:05

三维模型体素化:原理、实现方案与工程优化实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
三维模型体素化:原理、实现方案与工程优化实战

开头直接进入场景。

做三维重建和点云处理这几年,我踩过的最深的坑之一,就是数据量上来之后,算法跑不动。前阵子处理一片室外扫描的激光点云,地面、建筑立面、植被混在一起,单帧PCD文件拉到软件里还能流畅旋转,一旦多站拼接完,几千万个点直接让内存告急,滤波、配准、特征提取每步都卡得让人怀疑人生。后来转向体素化处理,把连续的点云映射到离散的三维网格上,一切才顺起来——不仅数据量被大幅压缩,后续很多算法的处理速度都上了一个台阶。

今天这篇就来聊透三维模型体素化这件事。作为系列第三篇,前面我们已经覆盖了点云预处理和常见特征,这篇单独把Voxelization拎出来,从数学原理、三种主流实现方案、工程性能优化,到体素尺寸选择经验,一次说清楚。

1. 为什么你的三维数据需要"方块化"

很多刚接触点云的朋友会有个疑问:明明原始点云已经很直观了,每个点带着XYZ坐标就能还原空间结构,为什么非要转成一个个方块?这背后其实是连续数据和离散计算之间的矛盾。

1.1 连续点云和离散网格的天然矛盾

点云是采样出来的离散点集,但它描述的空间本质上是连续的。激光扫描仪每隔几毫米打一个点,这些点之间原本不存在的区域,计算机需要"脑补"到底有没有物体。示意一下:扫描一面平整的墙体,如果你只拿点云做最近邻检索,哪怕只隔几十厘米的两个点,算法也可能判定它们是两个独立物体;但把墙体替换成边长5厘米的体素网格,每个格子要么被占据、要么为空,这面墙自然就成了一个连续的整体。

这就是体素化的核心价值——它把"无限可能"的连续三维空间,强制转换成了计算机擅长处理的有限网格。打个比方,点云是一张张随手拍的照片,模糊、冗余、彼此重叠;体素网格则是一张严格对齐的像素画,每一个格子都有明确归属,任何程序拿到都能直接处理。

1.2 体素化带来的三重收益

以我自己的项目经验,体素化带来的好处可以做三层拆解:

  • 数据量可控:原始点云可能有一亿个点,但真正描述空间结构的体素格子往往只有几十万到几百万个。数据规模降一两个数量级是常事,后续所有算法都在这个缩小后的空间里跑,效率自然不同。
  • 结构规则化:体素网格天然带拓扑关系,每个格子可以直接用(x, y, z)索引访问,不需要构建KD-Tree就能做邻域查询。做语义分割、目标检测时,一个体素就是一个样本,特征提取省去了大量邻域搜索的费用。
  • 多源数据统一:激光点云、CAD模型、深度相机生成的网格、毫米波雷达数据,这些格式完全不同的输入,一旦统一转成体素网格,就可以用同一套算法栈去处理。我做过一个项目,需要把测绘的激光点云和手工建模的厂区模型叠加,最终靠的就是两边同时体素化后对齐。

1.3 体素化与下采样、八叉树的边界关系

这里要理清一个容易混淆的概念。点云体素化滤波(VoxelGrid Filter)常用在PCL的预处理环节,它的做法是:把所有点按体素空间分组,每组保留一个重心点或中心点,从而实现降采样。这和本文讨论的体素化是两码事:

  • 体素化滤波做的是"点云 → 稀疏点云"的变换,输出还是点。它只是借用了体素分组的思路。
  • 体素化做的是"点云/网格 → 体素网格"的变换,输出的是三维栅格。每个栅格代表空间中的一个立方体,它占据还是不占据、以及在该占据格内的点分布信息,才是我们关心的。

八叉树和体素化也有渊源。八叉树是一种递归分割空间的数据结构,可以把空间细化到不同层级;体素化则是"定死"网格大小和层级的操作。八叉树常用于动态分配,体素网格则是静态划定。很多库里的体素化实现内部会先用八叉树加速索引,再生成固定尺寸的体素数组。

2. 体素化的数学本质:从连续坐标到离散网格的映射

讲完动机,进入正题。体素化的底层操作不复杂,一句话概括就是:给三维空间套一层均匀网格,把落在每个网格里的点或面归并成一个体素。但真正实现时,有几个关键步骤和数学细节决定最终效果。

2.1 坐标归一化的关键步骤

第一步是求取点云包围盒,然后确定体素网格的分辨率。设点云中所有点的坐标为 ( \mathbf{p}i = (x_i, y_i, z_i) ),包围盒范围是 ( [x{min}, x_{max}] \times [y_{min}, y_{max}] \times [z_{min}, z_{max}] )。

假设体素边长是 ( v ),那么三个轴向上的体素数量分别是:

$$ N_x = \left\lceil \frac{x_{max} - x_{min}}{v} \right\rceil, \quad N_y = \left\lceil \frac{y_{max} - y_{min}}{v} \right\rceil, \quad N_z = \left\lceil \frac{z_{max} - z_{min}}{v} \right\rceil $$

这里向上取整是必须的,因为包围盒尺寸往往不能被体素边长整除,最后一个格子允许不满。实际工程中,我习惯对包围盒做0.1%的padding,防止点正好卡在边界上导致索引越界。

接下来,任意点 ( \mathbf{p} ) 对应的体素索引为:

$$ i = \left\lfloor \frac{x - x_{min}}{v} \right\rfloor, \quad j = \left\lfloor \frac{y - y_{min}}{v} \right\rfloor, \quad k = \left\lfloor \frac{z - z_{min}}{v} \right\rfloor $$

这一组整数 ( (i, j, k) ) 就是体素坐标。注意这里用向下取整而非四舍五入,我和一些同行交流时发现,有人图省事用round,结果在体素边界处出现同一平面被劈成两半的问题,而且行不行全靠"运气"——点落在边界附近时,四舍五入的结果不稳定。向下取整保证了同一格子内的点必然映射到同一个体素。

2.2 体素索引与哈希映射:内存管理的第一道分水岭

得到 ( (i, j, k) ) 之后,最简单的做法是直接分配一个 ( N_x \times N_y \times N_z ) 的三维数组。但现实点云的分辨率往往不均衡——地面范围很大,高度方向却很窄。假设场景是100米×80米×20米,体素边长10厘米,那么数组规模就是1000×800×200,足足1.6亿个元素。就算每个元素只存一个布尔值,也要160MB;如果存平均值、法向量、点数这些信息,内存直接爆炸。

这个问题的解法主要有两种,对应不同场景:

  • 稠密体素化(Dense):如果场景小、体素大,比如机械臂抓取工件的场景,直接用三维数组。访问速度最快,O(1),而且方便上GPU做卷积等操作。这种场景下数组规模通常控制在几百万以内。
  • 稀疏体素化(Sparse):如果场景大、物体只占空间的很小一部分,用哈希表存被占据的体素。C++里可以用std::unordered_map,把 ( (i, j, k) ) 编码成uint64作为key,value可以是一个小型结构体,记录点数、坐标累加和、颜色累加和等。

哈希映射的一个编码技巧:如果已知三个轴的体素数量都小于 ( 2^{21} )(约209万),可以把 ( (i, j, k) ) 编码为一个64位整数:

$$ key = (i \ll 42) | (j \ll 21) | k $$

这比字符串拼接快得多。实际测试中,这种编码配合开放寻址哈希表,吞吐量能达到每秒几百万点。

2.3 空体素与占用状态的判定逻辑

体素化结果的"占用"判定,不同算法有不同定义。如果只是判断"这个格子有没有点",那只要任意一点落入即视为占用。但在实际项目中,这个判定往往会更复杂:

  • 点数阈值:激光雷达扫描时,边缘位置的点很稀疏,噪声也多。如果只凭一个点就判定占用,可能会出现大量孤立噪点体素。我通常要求一个体素至少包含 ( k ) 个点(( k ) 取3到5),才对体素标记为占用。最低3个点能过滤掉大部分飞行噪声。
  • 占据概率:在SLAM和占据栅格地图场景下,会用占据概率而不是二值占用。每次扫描命中体素,概率增加一个量;每次光线穿过体素,概率减少一个量。这种方式对动态物体和多帧融合更鲁棒。

这里要给新手提个醒:体素化的精度不由点的数量决定,而由体素边长决定。不管体素里落了1个点还是100个点,它都只贡献一个占位。所以真正影响占用判定的,不是点数,而是你设定的判定策略。

3. 三种主流体素化实现方案对比

从工程实践看,点云体素化的实现方案大致有三类。它们的核心差异在"一个体素里多个点时怎么合并"。

3.1 方案一:中心点贪心法

这是PCLVoxelGrid滤波的经典思路。每个体素内,只保留距离体素中心最近的那个原始点。实现步骤:

  1. 遍历所有点,计算体素索引 ( (i, j, k) )
  2. 对每个体素,维护一个"当前最近点"的索引和距离平方
  3. 新点落进来时,比较它和体素中心的距离,更近则替换
  4. 最后输出每个体素保留的那个点

这个方案的好处是速度极快,内存占用小,由于不产生新的点,原始精度最高。坏处是:体素内的点分布可能不均匀,中心在物体边缘时,保留下来的点可能偏向一侧,丢失体素内的均值信息。

适合场景:只需要快速降采样且后续算法对点的位置精度敏感的场景。

3.2 方案二:重心聚合法

重心聚合是我个人项目里最常用的方案。处理逻辑是:体素内所有点的坐标取平均,作为新点的位置;颜色、法向量等属性也同步平均。

这个方案里每个体素输出的是一个实际不存在的"虚拟点",但它代表的是体素内所有点的统计中心。表面重建、法线估计这类算法对点云质量敏感,重心聚合的效果通常优于中心点贪心。

代码层面的实现,我一般这样组织:

# Python伪代码,演示重心聚合体素化 import numpy as np from collections import defaultdict def voxelize_centroid(points, voxel_size, colors=None): # points: Nx3 numpy数组 mins = np.min(points, axis=0) # 计算体素索引 indices = np.floor((points - mins) / voxel_size).astype(np.int64) # 用字典聚合:键是体素索引,值是点的累加和和计数 accumulator = defaultdict(lambda: [np.zeros(3), 0]) for idx, point in enumerate(points): key = tuple(indices[idx]) acc = accumulator[key] acc[0] += point acc[1] += 1 # 输出每个体素的重心 voxel_points = [] voxel_indices = [] for key, (sum_coords, count) in accumulator.items(): voxel_points.append(sum_coords / count) voxel_indices.append(key) return np.array(voxel_points), np.array(voxel_indices)

这个方案的缺点是:体素内属性差异大时,平均值会"磨平"细节。比如一个体素里同时扫到地面和地面上的小草,重心会悬在两者之间,产生悬浮点。解决方式是引入属性方差判断,或者缩小体素尺寸。

3.3 方案三:三线性插值法

前两种方案都是"选点"或"造点",三线性插值则是完全不同的思路——它用于把不规则分布的点云插值到规则的网格节点上,而不是输出一个代表点。

具体做法是:对每个体素网格节点 ( (x_g, y_g, z_g) ),找到它周围最近的8个邻近体素内的原始点,用距离加权的方式计算该节点的属性值:

$$ f(x_g, y_g, z_g) = \frac{\sum w_i f_i}{\sum w_i}, \quad w_i = \frac{1}{d_i^p} $$

其中 ( d_i ) 是邻近点到网格节点的距离,( p ) 是权重的幂次,通常取2。

三线性插值的优势在于输出的体素场是平滑的,适合做等值面提取(Marching Cubes)、符号距离场(SDF)等需要连续场的算法。缺点是计算量大,而且对点云密度有要求——如果局部点太稀疏,邻近点数量不足,插值结果会出现空洞。

在我参与的血管三维重建项目中,用的就是三线性插值法。原始CT切片重建出的血管点云密度不均匀,直接体素化会生成阶梯状表面,插值之后等值面平滑很多。

3.4 三种方案的实际效果与效率对比

为了给大家一个直观参考,我把三方案跑在同一个测试集上:一段室内扫描的点云,约200万个点,体素边长5厘米,i7处理器单线程。

方案输出点数耗时精度适用场景
中心点贪心8.7万0.9s高(保原始点)快速降采样、实时管线
重心聚合8.7万1.2s中高(统计平滑)表面重建、法线估计
三线性插值27万(网格节点)6.3s中(插值误差)连续场构建、SDF、等值面

从结果看,三种方案在占用体素数量上是一致的,区别在输出内容和质量。没有绝对的优劣,只有合不合适。实时性要求高的选方案一,质量优先选方案二,做场类算法选方案三。

4. 工程落地阶段必须处理的性能与精度问题

算法原理清楚后,真正的挑战在工程实现。一个能demo的体素化代码,和生产环境可用的代码差距很大。这节聊聊我在落地过程中遇到的问题和解决办法。

4.1 百万级点云的内存优化技巧

前面提到哈希表可以避开水密三维数组的内存爆炸,但哈希表本身也有内存开销。std::unordered_map每个元素大约有32字节左右的额外开销(桶指针、节点头、对齐),如果体素有几十万个,额外开销就是几MB到十几MB。这还能接受,但如果有上亿体素,哈希表的开销就非常可观了。

我在处理大地形点云时用的方案是分块+紧凑数组:

  1. 第一遍遍历点云,只统计每个体素的点数,建立"键 → 密集体素编号"的映射(这一步可以用std::unordered_map<uint64_t, uint32_t>,由于只存编号,内存相对可控)
  2. 根据映射结果,预先分配一个紧凑的float数组,每个体素对应一段连续空间
  3. 第二遍遍历点云,把点的坐标和属性累加到对应体素的数组段中
  4. 最终遍历体素数组,用累加值除以计数得到均值

这个方案把数据访问模式变得紧凑连续,CPU缓存命中率高不少。实测2000万点、5厘米体素,1.8亿个候选体素中实际占用约1200万个,内存占用从"直接哈希表"的约1.5GB降到约400MB,速度还快了20%。

4.2 八叉树加速与GPU并行化的取舍

当点云规模再上一个量级,到亿级时,即使哈希表方案也显得吃力。这时有两种主流加速思路:

八叉树加速:递归分割空间,把点云按空间位置组织成树。体素化时只需要在树的节点上做遍历,可以快速排除大块空区域。八叉树的深度决定了最小体素粒度,如果树深为N,最小体素边长大概是包围盒对角线除以 ( 2^N )。这种方法的工程复杂度中等,适合需要多分辨率表示的场景——浅层树快速浏览全局,深层树精细局部。

GPU并行化:如果项目里已经有GPU可用,体素化其实是一个极其适合并行的任务。每个点计算体素索引的操作相互独立,天然可并行。但GPU端的瓶颈在于"多个点写入同一个体素"的竞争。我常用的技巧是把体素索引排序,然后做分段归约:

  1. 每个线程处理一个点,算出体素索引,写到一个临时数组
  2. 对临时数组按体素索引排序
  3. 遍历有序数组,连续相同索引的段就是同一个体素的数据,做归约即可

这个思路在CUDA里实现非常高效,千万级点云的体素化能在几十毫秒内完成。不过我一般建议先做CPU版本验证逻辑,确定体素边长和属性处理规则没问题后,再上GPU优化。GPU的调试成本比CPU高得多,直接上GPU容易把人绕晕。

4.3 网格模型体素化与点云体素化的差异

标题里提到"三维模型体素化",这里的模型可能指网格模型(Mesh),也可能是点云。这两者的体素化处理逻辑有显著差异,值得单独说。

点云体素化处理的是离散点集,核心操作是"归类——合并"。网格模型体素化的核心操作则是"判断面片与格子的相交关系"。一个三角面片可能跨越多个体素格子,也可能只占一个格子的一部分。常用的做法是:

  • 先用面片的包围盒快速筛出候选体素
  • 再用三角形与AABB(轴对齐包围盒)的求交算法精确检测
  • 对相交的体素标记占用,并可以进一步计算体素内被三角形覆盖的体积比例,用于生成带权重的体素场

网格模型体素化比点云版本复杂不少,我早期用简单的方法——只对网格顶点做体素化,结果出现了大量漏检:一个大三角面片中间没有顶点穿过,但面片明明覆盖了整个区域,结果体素模型出现穿透空洞。后来换了三角形-AABB相交测试,问题才解决。

5. 体素尺寸选择与边界条件的工程经验

算法写得再快,参数选错照样白搭。体素边长是体素化最重要的参数,它直接影响精度、内存和后续算法效果。这节分享一些可复用的选择逻辑和边界处理技巧。

5.1 如何确定合适的体素边长

我的经验是:体素边长应该和点云的平均点间距挂钩,而不是拍脑袋定。

对激光扫描来说,点间距大约等于扫描距离乘以角度分辨率。假设某雷达的水平角分辨率是0.1°,垂直角分辨率是0.2°,扫描距离50米,那么地面点间距大约是:

$$ d_x = 50 \times \tan(0.1°) \approx 50 \times 0.001745 \approx 8.7 \text{ cm} $$

这个场景下,体素边长设到10~15厘米比较合适。如果设得太小(比如1厘米),大部分体素只有0~1个点,占用判定会很不稳定;设得太大(比如1米),物体细节直接被抹掉。

一个用于快速估算的规则:体素边长取点云平均点间距的1.5~2倍。点云平均点间距可以用KD-Tree计算每个点最近邻距离的均值得到,代价不高,值得跑一次。

5.2 点云密度不均时的自适应策略

现实中的点云很少均匀分布。近处点密,远处点疏;地面点密,墙面点稍疏。如果全场景用同一个体素边长,往往顾此失彼。

我的处理方式是做自适应体素化:

  • 把点云空间划分成若干子区域
  • 统计每个子区域内点云的平均间距
  • 根据间距为每个子区域设定不同体素边长,间距大的区域体素大,间距小的区域体素小
  • 相邻区域做过渡处理,避免体素尺寸突变导致裂缝

这个方案能显著提升远距离区域的占用完整性,但对代码复杂度要求较高。如果项目初期,建议先用均匀体素化跑通,确认瓶颈后再加上自适应逻辑。

5.3 边界对齐与坐标系变换

体素化过程中最容易出错的地方在边界。拿前面提到的向下取整来说,如果点正好落在体素的角点或边界面上,取整结果取决于浮点误差。这导致同一个点在不同运行里可能归入相邻体素。

为了解决这个问题,我通常在计算体素索引前,把坐标先加一个极小偏移量,比如 ( 10^{-6} ) 倍的体素边长。这个偏移量远小于点云精度,不会影响结果,但能把挂在边界上的点稳定地拉入其中一个体素。

另外,体素网格的坐标原点应该和点云对齐,而不是强制设成(0,0,0)。如果点云包围盒最小值偏离原点较远,直接除以体素边长会产生巨大的索引值。正确做法是先做坐标平移——减去包围盒最小值,再计算索引。这样体素坐标范围始终从 ( (0,0,0) ) 开始,方便后续做数组索引或哈希。

6. 实测对比:PCL、Open3D与自研实现的取舍

聊了这么多算法细节,最后做个工具层面的对比。目前最常用的点云处理库PCL和Open3D都内置了体素化功能,自研实现依然有它的价值。

6.1 常用库的体素化能力速览

PCL的pcl::VoxelGrid是多数C++用户的首选。它实现的是中心点贪心法,在不改变点云数据类型的前提下做下采样。用法很简单:

pcl::VoxelGrid<pcl::PointXYZ> voxel_grid; voxel_grid.setInputCloud(cloud); voxel_grid.setLeafSize(0.05f, 0.05f, 0.05f); voxel_grid.filter(*cloud_filtered);

Open3D的voxel_down_sample也是类似功能,Python接口对快速原型验证友好:

import open3d as o3d pcd = o3d.io.read_point_cloud("scene.pcd") downpcd = pcd.voxel_down_sample(voxel_size=0.05)

这两个库的优点是稳定、开箱即用,缺点也很明显:

  • 它们默认只输出降采样后的点,不直接输出体素网格(占用栅格)。如果你想要真正的体素数组,还得自己从输出点反推网格。
  • 权重控制不够灵活,比如上面提到的点数阈值判定、属性方差判断,都需要自己重写。

6.2 自研方案的适用场景与我的选择逻辑

结合我的项目经验,工具选择的逻辑可以归纳如下:

  • 如果只是预处理阶段快速降采样,PCL或Open3D足够,省时省力。
  • 如果要做SLAM占据栅格、碰撞检测、语义分割模型输入,需要真实的体素网格和灵活占用判定策略,我会选择自研。因为库函数输出的"降采样点"不够直接,转成网格还要二次开发,不如一开始就用自研方案把逻辑掌握在手里。
  • 如果点云规模达到亿级,无论哪个库的默认实现都吃力,必须做分块或GPU优化,这时必然要走自研路线。

我个人习惯是先写一个不超过100行的Python原型,确认体素边长和判定逻辑没问题,再决定要不要换C++重写。原型阶段的灵活性比性能重要得多,这种"先验证、后优化"的顺序帮我避过很多坑。


实际做体素化这一年多,最大的体会是:这个算法看起来简单,但工程里到处是细节。光是向下取整和四舍五入这一个差别,就足以让两个版本的结果产生肉眼可见的差异。体素边长的选择更是直接决定下游算法的成败,事前花时间统计点云间距,比事后调参数高效得多。

我后来在项目里固化了一套流程:拿到点云先算包围盒和平均间距,再决定体素边长;每次体素化前,一定会检查输出结果的体素数是否在预期范围内,如果偏差过大,先查坐标平移和索引计算的代码,而不是怀疑数据。这套流程虽然朴素,但帮我省下了大量排查时间。

希望这篇能把体素化讲透,也欢迎在评论区聊聊你处理点云时的体素化经验——尤其是边界处理和组织数据,不同场景的解法差异真的很大。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/3 1:05:39

Prometheus生产落地八大核心环节:从数据采集到告警治理

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/3 1:05:34

DRV8818PWPR+STM32F437ZG工业级步进电机闭环控制设计

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/3 1:05:12

CANoe报文解析核心原理:DBC映射与信号解码全链路

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/3 1:04:19

四大AI搜索引擎横评:360、秘塔、GenSpark、天工谁更适合当默认?

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/3 1:03:00

Java云上香云祭祀线上祭祀小程序系统设计与实现全解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/3 1:02:47

启发式算法入门:原理、分类与TSP实战应用

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华