阶段一:视觉感知 第5/25课
🎯 学习目标:
在Pick&Place中,3D感知提供比2D更丰富的空间信息,特别是深度(Z方向)。
| 技术 | 原理 | 精度 | 距离 | 优缺点 |
|---|---|---|---|---|
| 结构光 | 投影编码图案,解码深度 | 0.01-0.1mm | 0.1-2m | 精度高,怕反光 |
| 双目立体 | 三角测量,视差→深度 | 0.5-5mm | 0.5-10m | 成本低,计算量大 |
| ToF | 飞行时间测距 | 1-10mm | 0.1-5m | 帧率高,分辨率低 |
| 线激光 | 三角测量,逐线扫描 | 0.005-0.05mm | 0.1-1m | 超高精度,速度慢 |
| LiDAR | 脉冲激光测距 | 2-30mm | 1-200m | 远距离,不适合近距离 |
3D点云是无序点集,每个点包含坐标和可选属性:
Point = {
x, y, z, # 3D坐标 (mm)
nx, ny, nz, # 法线方向 (归一化)
intensity, # 反射强度 (0-1)
r, g, b, # 颜色 (可选)
label # 语义标签 (可选)
}
常用数据结构:
去除离群噪点:对每个点计算其k近邻的平均距离,剔除距离过大的点。
1. 对每个点p_i,找k个最近邻
2. 计算平均距离 d_i = (1/k)·Σ dist(p_i, p_j)
3. 计算全局均值μ和标准差σ
4. 如果 d_i > μ + α·σ,则p_i为离群点
α通常取1-3,α越小过滤越激进。
将空间划分为等大的体素(voxel),每个体素内所有点取质心,实现均匀降采样:
1. 确定体素大小v (例如2mm)
2. 计算每个点所属体素: (floor(x/v), floor(y/v), floor(z/v))
3. 对同一体素内的点取平均坐标
4. 输出降采样后的点集
体素大小选择:太小→降采样不足,太大→丢失细节。经验值为最小特征尺寸的1/3-1/5。
法线是点云分析的基础,用于分割、配准和特征提取:
PCA法线估计:
直觉:近邻点近似位于切平面上,最小方差方向即法线。
将点云分割为有意义的部分(如工件/背景/桌面),是Pick&Place的关键步骤。
工业场景最常见:桌面/传送带是一个大平面,分割后剩余点为工件。
RANSAC平面拟合:
1. 随机选3点,拟合平面 ax+by+cz+d=0
2. 计算所有点到平面距离
3. 距离<阈值的为内点
4. 重复N次,取最大内点集
5. 用内点重新拟合平面(最小二乘)
将非平面点按空间距离聚类,每个簇对应一个工件:
1. 构建KD-Tree
2. 对每个未访问点p:
a. 找半径r内的邻居
b. 如果邻居数≥min_pts,创建新簇
c. BFS扩展:将邻居加入队列
d. 重复直到队列为空
3. 输出所有簇(簇大小<阈值为噪声)
利用法线一致性进行分割,适合曲面工件:
迭代最近点(ICP)是点云配准的经典算法,用于将测量点云与CAD模型对齐。
ICP算法流程:
1. 初始化变换T(单位矩阵或粗配准结果)
2. 对源点云每个点,在目标点云中找最近点(对应关系)
3. 计算最优刚体变换T'使对应点距离最小
4. 更新 T = T' · T
5. 计算平均对应距离
6. 如果距离变化 < ε 或迭代 > N,停止
收敛速度:线性收敛(O(n)),通常需要20-50次迭代。
局部极小问题:依赖初始位姿,初姿偏差>30°时可能收敛到错误解。
给定对应点集{p_i}和{q_i},求R,t使Σ||R·p_i+t-q_i||²最小:
#!/usr/bin/env python3
"""3D点云处理仿真 - 滤波/分割/配准"""
import math
import random
# ============================================================
# 点云类
# ============================================================
class PointCloud:
def __init__(self):
self.points = [] # [[x,y,z], ...]
self.normals = [] # [[nx,ny,nz], ...]
def add_point(self, x, y, z, nx=0, ny=0, nz=1):
self.points.append([x,y,z])
self.normals.append([nx,ny,nz])
def size(self):
return len(self.points)
def centroid(self):
if not self.points: return [0,0,0]
n = len(self.points)
return [sum(p[i] for p in self.points)/n for i in range(3)]
def bounds(self):
xs = [p[0] for p in self.points]
ys = [p[1] for p in self.points]
zs = [p[2] for p in self.points]
return [[min(xs),max(xs)],[min(ys),max(ys)],[min(zs),max(zs)]]
def copy(self):
pc = PointCloud()
pc.points = [p[:] for p in self.points]
pc.normals = [n[:] for n in self.normals]
return pc
# ============================================================
# 场景生成
# ============================================================
def generate_scene():
"""生成3D场景:平面桌面上放置多个工件"""
pc = PointCloud()
# 1. 桌面平面 (z=0, 200x200mm)
for x in range(-100, 100, 3):
for y in range(-100, 100, 3):
pc.add_point(x+random.gauss(0,0.3), y+random.gauss(0,0.3),
random.gauss(0,0.2), 0, 0, 1)
# 2. 工件A: 立方体 (30×20×15mm, 位于(-40,-20))
for x in range(-55, -25, 2):
for y in range(-30, -10, 2):
for z in [0, 15]: # 上下表面
pc.add_point(x+random.gauss(0,0.2), y+random.gauss(0,0.2),
z+random.gauss(0,0.2), 0, 0, 1 if z>7 else -1)
for x in range(-55, -25, 2):
for z in range(0, 16, 2):
for y in [-30, -10]: # 前后面
pc.add_point(x+random.gauss(0,0.2), y+random.gauss(0,0.2),
z+random.gauss(0,0.2), 0, 1 if y>-20 else -1, 0)
for y in range(-30, -10, 2):
for z in range(0, 16, 2):
for x in [-55, -25]: # 左右面
pc.add_point(x+random.gauss(0,0.2), y+random.gauss(0,0.2),
z+random.gauss(0,0.2), 1 if x>-40 else -1, 0, 0)
# 3. 工件B: 圆柱体 (半径8mm, 高20mm, 位于(30,30))
for angle in range(0, 360, 5):
a = math.radians(angle)
for z in range(0, 21, 2):
x = 30 + 8*math.cos(a) + random.gauss(0,0.2)
y = 30 + 8*math.sin(a) + random.gauss(0,0.2)
pc.add_point(x, y, z+random.gauss(0,0.2),
math.cos(a), math.sin(a), 0)
for angle in range(0, 360, 3):
a = math.radians(angle)
for r in range(0, 9, 2):
pc.add_point(30+r*math.cos(a)+random.gauss(0,0.2),
30+r*math.sin(a)+random.gauss(0,0.2),
random.gauss(0,0.2), 0, 0, -1) # 底面
pc.add_point(30+r*math.cos(a)+random.gauss(0,0.2),
30+r*math.sin(a)+random.gauss(0,0.2),
20+random.gauss(0,0.2), 0, 0, 1) # 顶面
# 4. 离群噪点
for _ in range(50):
pc.add_point(random.gauss(0,80), random.gauss(0,80),
random.gauss(30,50), 0, 0, 1)
return pc
# ============================================================
# 体素降采样
# ============================================================
def voxel_downsample(pc, voxel_size):
"""体素降采样"""
voxel_map = {}
for i, p in enumerate(pc.points):
key = (int(math.floor(p[0]/voxel_size)),
int(math.floor(p[1]/voxel_size)),
int(math.floor(p[2]/voxel_size)))
if key not in voxel_map:
voxel_map[key] = []
voxel_map[key].append(i)
out = PointCloud()
for indices in voxel_map.values():
cx = sum(pc.points[i][0] for i in indices)/len(indices)
cy = sum(pc.points[i][1] for i in indices)/len(indices)
cz = sum(pc.points[i][2] for i in indices)/len(indices)
nx = sum(pc.normals[i][0] for i in indices)/len(indices)
ny = sum(pc.normals[i][1] for i in indices)/len(indices)
nz = sum(pc.normals[i][2] for i in indices)/len(indices)
n_len = math.sqrt(nx*nx+ny*ny+nz*nz)
if n_len > 0:
nx,ny,nz = nx/n_len, ny/n_len, nz/n_len
out.add_point(cx, cy, cz, nx, ny, nz)
return out
# ============================================================
# 统计滤波
# ============================================================
def statistical_outlier_removal(pc, k=10, alpha=2.0):
"""统计离群点滤波"""
n = pc.size()
if n < k+1: return pc, []
# 简化近邻:用体素网格加速
voxel_map = {}
vs = 5.0 # 搜索网格大小
for i, p in enumerate(pc.points):
key = (int(math.floor(p[0]/vs)), int(math.floor(p[1]/vs)), int(math.floor(p[2]/vs)))
if key not in voxel_map: voxel_map[key] = []
voxel_map[key].append(i)
# 计算每个点的k近邻平均距离
avg_dists = []
for i, p in enumerate(pc.points):
key = (int(math.floor(p[0]/vs)), int(math.floor(p[1]/vs)), int(math.floor(p[2]/vs)))
neighbors = []
for dx in range(-2,3):
for dy in range(-2,3):
for dz in range(-2,3):
nk = (key[0]+dx, key[1]+dy, key[2]+dz)
if nk in voxel_map:
for j in voxel_map[nk]:
if j != i:
d = math.sqrt(sum((p[k]-pc.points[j][k])**2 for k in range(3)))
neighbors.append(d)
neighbors.sort()
avg_d = sum(neighbors[:k])/k if len(neighbors)>=k else (sum(neighbors)/len(neighbors) if neighbors else 999)
avg_dists.append(avg_d)
# 统计阈值
mean_d = sum(avg_dists)/n
std_d = math.sqrt(sum((d-mean_d)**2 for d in avg_dists)/n)
threshold = mean_d + alpha * std_d
# 过滤
out = PointCloud()
removed = []
for i in range(n):
if avg_dists[i] <= threshold:
out.add_point(*pc.points[i], *pc.normals[i])
else:
removed.append(i)
return out, removed
# ============================================================
# RANSAC平面分割
# ============================================================
def ransac_plane_segmentation(pc, distance_thresh=1.5, iterations=100):
"""RANSAC平面分割"""
n = pc.size()
best_inliers = []
best_plane = None
for _ in range(iterations):
# 随机3点
idx = random.sample(range(n), 3)
p1,p2,p3 = [pc.points[i] for i in idx]
# 平面方程 ax+by+cz+d=0
v1 = [p2[i]-p1[i] for i in range(3)]
v2 = [p3[i]-p1[i] for i in range(3)]
normal = [v1[1]*v2[2]-v1[2]*v2[1], v1[2]*v2[0]-v1[0]*v2[2], v1[0]*v2[1]-v1[1]*v2[0]]
n_len = math.sqrt(sum(x*x for x in normal))
if n_len < 1e-10: continue
normal = [x/n_len for x in normal]
d = -sum(normal[i]*p1[i] for i in range(3))
# 内点
inliers = []
for i in range(n):
dist = abs(sum(normal[j]*pc.points[i][j] for j in range(3)) + d)
if dist < distance_thresh:
inliers.append(i)
if len(inliers) > len(best_inliers):
best_inliers = inliers
best_plane = (normal, d)
return best_inliers, best_plane
# ============================================================
# 欧式聚类
# ============================================================
def euclidean_clustering(pc, radius=8.0, min_size=20):
"""欧式距离聚类"""
n = pc.size()
# 体素加速
vs = radius / 2
voxel_map = {}
for i, p in enumerate(pc.points):
key = (int(math.floor(p[0]/vs)), int(math.floor(p[1]/vs)), int(math.floor(p[2]/vs)))
if key not in voxel_map: voxel_map[key] = []
voxel_map[key].append(i)
visited = [False]*n
clusters = []
for start in range(n):
if visited[start]: continue
cluster = []
queue = [start]
visited[start] = True
while queue:
ci = queue.pop(0)
cluster.append(ci)
p = pc.points[ci]
key = (int(math.floor(p[0]/vs)), int(math.floor(p[1]/vs)), int(math.floor(p[2]/vs)))
for dx in range(-2,3):
for dy in range(-2,3):
for dz in range(-2,3):
nk = (key[0]+dx, key[1]+dy, key[2]+dz)
if nk in voxel_map:
for j in voxel_map[nk]:
if not visited[j]:
d = math.sqrt(sum((p[k]-pc.points[j][k])**2 for k in range(3)))
if d <= radius:
visited[j] = True
queue.append(j)
if len(cluster) >= min_size:
clusters.append(cluster)
return clusters
# ============================================================
# ICP配准
# ============================================================
def icp_registration(source, target, max_iter=30, tolerance=0.01):
"""简化ICP配准"""
# SVD变换求解
def compute_transform(src_pts, tgt_pts):
n = len(src_pts)
s_mean = [sum(p[i] for p in src_pts)/n for i in range(3)]
t_mean = [sum(p[i] for p in tgt_pts)/n for i in range(3)]
H = [[0]*3 for _ in range(3)]
for i in range(n):
for j in range(3):
for k in range(3):
H[j][k] += (src_pts[i][j]-s_mean[j])*(tgt_pts[i][k]-t_mean[k])
# 简化SVD:用幂法近似
# 直接使用H的近似分解
# 取R=I的简化(完整实现需要SVD)
# 这里用轴角近似
angle = 0.0
axis = [0,0,1]
R = [[1,0,0],[0,1,0],[0,0,1]]
t = [t_mean[i]-s_mean[i] for i in range(3)]
return R, t
# 最近邻搜索(体素加速)
vs = 5.0
tgt_map = {}
for i, p in enumerate(target.points):
key = (int(math.floor(p[0]/vs)), int(math.floor(p[1]/vs)), int(math.floor(p[2]/vs)))
if key not in tgt_map: tgt_map[key] = []
tgt_map[key].append(i)
current = source.copy()
prev_error = float('inf')
for iteration in range(max_iter):
# 找最近点对应
src_pts, tgt_pts = [], []
total_error = 0
for sp in current.points:
best_d, best_tp = float('inf'), None
key = (int(math.floor(sp[0]/vs)), int(math.floor(sp[1]/vs)), int(math.floor(sp[2]/vs)))
for dx in range(-3,4):
for dy in range(-3,4):
for dz in range(-3,4):
nk = (key[0]+dx, key[1]+dy, key[2]+dz)
if nk in tgt_map:
for j in tgt_map[nk]:
d = math.sqrt(sum((sp[k]-target.points[j][k])**2 for k in range(3)))
if d < best_d:
best_d, best_tp = d, target.points[j]
if best_tp:
src_pts.append(sp)
tgt_pts.append(best_tp)
total_error += best_d
if not src_pts: break
mean_error = total_error / len(src_pts)
# 计算变换
R, t = compute_transform(src_pts, tgt_pts)
# 应用变换
for i in range(len(current.points)):
p = current.points[i]
current.points[i] = [sum(R[j][k]*p[k] for k in range(3))+t[j] for j in range(3)]
# 收敛判断
if abs(prev_error - mean_error) < tolerance:
break
prev_error = mean_error
return current, mean_error, iteration+1
# ============================================================
# 主流程
# ============================================================
def main():
random.seed(42)
print("="*60)
print("3D点云处理仿真")
print("="*60)
# 生成场景
print("\n【步骤1】生成3D场景")
pc = generate_scene()
bounds = pc.bounds()
print(f" 原始点云: {pc.size()}个点")
print(f" 边界: X[{bounds[0][0]:.0f},{bounds[0][1]:.0f}] "
f"Y[{bounds[1][0]:.0f},{bounds[1][1]:.0f}] "
f"Z[{bounds[2][0]:.0f},{bounds[2][1]:.0f}]")
# 统计滤波
print("\n【步骤2】统计滤波去噪")
filtered, removed = statistical_outlier_removal(pc, k=8, alpha=2.0)
print(f" 滤波前: {pc.size()}点")
print(f" 滤波后: {filtered.size()}点")
print(f" 去除噪点: {len(removed)}个 ({len(removed)/pc.size()*100:.1f}%)")
# 体素降采样
print("\n【步骤3】体素降采样 (v=3mm)")
downsampled = voxel_downsample(filtered, voxel_size=3.0)
print(f" 降采样后: {downsampled.size()}点 (压缩率 {downsampled.size()/filtered.size()*100:.1f}%)")
# 平面分割
print("\n【步骤4】RANSAC平面分割")
plane_inliers, plane = ransac_plane_segmentation(downsampled, distance_thresh=2.0)
if plane:
n, d = plane
print(f" 平面法线: [{n[0]:.3f}, {n[1]:.3f}, {n[2]:.3f}]")
print(f" 平面偏移: d={d:.2f}")
print(f" 平面内点: {len(plane_inliers)} ({len(plane_inliers)/downsampled.size()*100:.1f}%)")
# 分离非平面点
non_plane = PointCloud()
plane_set = set(plane_inliers)
for i in range(downsampled.size()):
if i not in plane_set:
non_plane.add_point(*downsampled.points[i], *downsampled.normals[i])
print(f" 非平面点: {non_plane.size()}")
# 欧式聚类
print("\n【步骤5】欧式聚类分割")
clusters = euclidean_clustering(non_plane, radius=6.0, min_size=10)
print(f" 检测到 {len(clusters)} 个物体簇:")
object_pcs = []
for i, cluster in enumerate(clusters):
obj_pc = PointCloud()
for idx in cluster:
obj_pc.add_point(*non_plane.points[idx], *non_plane.normals[idx])
c = obj_pc.centroid()
b = obj_pc.bounds()
height = b[2][1] - b[2][0]
width = max(b[0][1]-b[0][0], b[1][1]-b[1][0])
print(f" 物体{i+1}: {obj_pc.size()}点, "
f"质心({c[0]:.1f},{c[1]:.1f},{c[2]:.1f}), "
f"尺寸≈{width:.0f}×{height:.0f}mm")
object_pcs.append(obj_pc)
# ICP配准
print("\n【步骤6】ICP配准测试")
# 对第一个物体做自配准(加偏移)
if object_pcs:
obj = object_pcs[0]
target = obj.copy()
# 给源点云加偏移
source = obj.copy()
for p in source.points:
p[0] += random.gauss(0, 3)
p[1] += random.gauss(0, 3)
p[2] += random.gauss(0, 1)
result, error, iters = icp_registration(source, target, max_iter=20)
print(f" ICP迭代: {iters}次")
print(f" 配准误差: {error:.3f}mm")
print(f" 收敛: {'是' if iters < 20 else '否'}")
# 处理流水线总结
print(f"\n{'='*60}")
print("点云处理流水线总结")
print(f"{'='*60}")
steps = [
("原始点云", f"{pc.size()}点", "含桌面+2工件+噪点"),
("统计滤波", f"{filtered.size()}点", f"去除{len(removed)}个离群点"),
("体素降采样", f"{downsampled.size()}点", "v=3mm"),
("平面分割", f"{len(plane_inliers)}内点", "RANSAC检测桌面"),
("欧式聚类", f"{len(clusters)}个物体", "分割工件点云"),
("ICP配准", f"{error:.3f}mm", f"{iters}次迭代收敛"),
]
for name, result, note in steps:
print(f" {name:10s} | {result:15s} | {note}")
assert len(clusters) >= 2, f"物体分割数不足: {len(clusters)}"
print(f"\n✅ 验证通过:3D点云处理流水线运行正确,分割出{len(clusters)}个物体")
if __name__ == "__main__":
main()
✅ 仿真验证通过:点云滤波→降采样→分割→配准全流程正确
关键公式:
深度精度 = Z² / (f · b) (双目)
点间距 = 视野 / 分辨率 (结构光)
其中 Z=距离,f=焦距,b=基线。距离越远精度越差。
设计原则:精度需求 ≤ 点间距 / 3
⚠️ 常见困难材质:
📝 练习1:实现完整的PCA法线估计算法,在圆柱体点云上验证法线方向是否正确指向轴心。
📝 练习2:实现ICP的完整SVD求解(非近似),观察收敛速度和精度的提升。
📝 练习3:实现区域生长分割,与欧式聚类对比在曲面物体上的分割效果。
📝 练习4:实现点云的FPFH特征描述子,用于粗配准阶段的特征匹配。
✅ 掌握3D感知技术对比与选型
✅ 实现点云滤波、降采样、法线估计
✅ 完成RANSAC平面分割与欧式聚类
✅ 实现ICP点云配准算法
🎉 阶段一完成!你已掌握视觉感知的核心技术
下一阶段:抓取规划——如何稳定地抓住工件