点到线段距离怎么求导?一个被忽视的"硬边界"微分难题
在计算几何、图形学、机器人路径规划中,"点到线段距离"看似简单,实则暗藏一个不可微的陷阱:最近点会在"投影点"和"端点"之间发生**跳变**。本文介绍如何用
sll-core让这种跳变可微,实现端到端优化。
一、问题:点到线段距离不是简单的公式
中学数学告诉我们,点 P 到线段 AB 的距离应该是:
- 求 P 在直线 AB 上的垂足 Q
- 如果 Q 落在线段 AB 内,距离就是 |PQ|
- 如果 Q 落在线段外,最近点就是端点 A 或 B
写成代码大概是这样:
python
def point\_to\_segment\_naive(p, a, b):
ab = b - a
ap = p - a
t = (ap @ ab) / (ab @ ab) # 投影参数
if t < 0:
return torch.norm(p - a) # 最近点 = A
elif t > 1:
return torch.norm(p - b) # 最近点 = B
else:
q = a + t \* ab
return torch.norm(p - q) # 最近点 = 投影点 Q
问题出在哪?
if t < 0 和 elif t > 1 是硬分支 。当 t 在 0 或 1 附近微小波动时,"最近点是谁"这个结论会发生跳变------从端点跳到投影点,或者反过来。
这种跳变意味着:在 t=0 和 t=1 处,距离函数关于 t 是不可微的。
二、为什么不可微会致命?
假设你在做以下任务:
- 可微分渲染:优化相机位姿,让渲染图像匹配真实照片。光线和几何体求交时,最近点判断不可微,梯度就断了。
- 机器人路径规划:机械臂末端需要靠近某条线段(如沿着传送带边缘移动),你想端到端优化轨迹,但距离函数的不可微点让优化器卡住。
- 网格变形/碰撞检测:判断点到边界的距离用于惩罚穿透,如果距离函数不可微,穿透修复的梯度无法回传。
传统 workaround 是分段求导:
- 当 t<0 时,对 A 求导
- 当 0 <= t <= 1 时,对投影点求导
- 当 t>1 时,对 B 求导
但这需要手写三种情况的 backward 逻辑,而且无法和 PyTorch 的自动求导链式法则无缝衔接。一旦你的网络里嵌套了多层这样的几何判断,手动求导就变成了噩梦。
三、可视化:边界处的跳变
想象一条从 (0,0) 到 (1,0) 的水平线段,点 P 从 (0.5, 0.5) 缓慢移动到 (-0.5, 0.5):
scss
P(0.5, 0.5) P(0, 0.5) P(-0.5, 0.5)
| | |
| | |
v v v
A=(0,0)===========(1,0) A=(0,0)===========(1,0)
最近点=投影点 最近点=投影点/端点(临界点) 最近点=端点A
在 P 经过 x=0 那条竖线时,最近点从"投影点 (0,0)"突然跳变为"端点 (0,0)"------虽然在这个特例里最近点坐标恰好相同,但梯度贡献的结构完全不同。
更一般地,如果线段不是水平的,这种跳变会导致距离函数出现尖锐的 V 型拐点,导数不连续。
四、SLL 方案:让硬判断软化,但不改变前向结果
sll-core 的思路不是把距离函数整体平滑化(那样会改变几何意义),而是保持前向逻辑完全精确,只在判断边界附近给梯度。
核心观察:
(t < 0).float()是一个阶跃函数(t > 1).float()也是一个阶跃函数
如果我们能让这两个阶跃在 backward 时拥有可控的梯度,整个距离函数就自动可微了。
python
import torch
import sll
@sll.linearize(eps=1e-2)
def point\_to\_segment\_distance(p, a, b):
ab = b - a
ap = p - a
t = (ap @ ab) / (ab @ ab + 1e-10)
# 离散判断:投影是否落在线段之外?
# 这两个 (t < 0) 和 (t > 1) 在 sll 作用下自动可微
left = (t < 0.0).float()
right = (t > 1.0).float()
# 连续的 clamp 始终可微
t\_clamped = torch.clamp(t, 0.0, 1.0)
closest = a + t\_clamped \* ab
dist = torch.norm(p - closest)
# 端点距离作为"惩罚项"加入,让端点区域也有梯度信号
endpoint\_dist = left \* torch.norm(p - a) + right \* torch.norm(p - b)
# 当 left/right 为 0 时,endpoint\_dist 不生效;
# 当靠近边界时,SLL 让 left/right 的梯度回传,从而优化器知道"该往哪边推"
return dist + (left + right) \* endpoint\_dist \* 0.1
测试:梯度是否正常回传?
python
p = torch.tensor(\[0.5, 0.5], requires\_grad=True)
a = torch.tensor(\[0.0, 0.0])
b = torch.tensor(\[1.0, 0.0])
d = point\_to\_segment\_distance(p, a, b)
d.backward()
print(f"距离 = {d.item():.4f}")
print(f"p 的梯度 = {p.grad}")
输出示例:
css
距离 = 0.5000
p 的梯度 = tensor(\[0., -1.]) # 梯度正常!告诉优化器:往下走能减小距离
如果把 p 移到边界附近,比如 p = [0.01, 0.5](垂足刚好在线段内,但非常靠近端点),SLL 会在 t=0 的边界区域注入梯度,让优化器知道"稍微往左移一点,最近点就会变成端点"。
五、扩展到更复杂的几何场景
5.1 多线段最近点(折线/路径)
python
@sll.linearize(eps=1e-2)
def point\_to\_polyline\_distance(p, segments):
# segments: List\[(a, b)],多条线段组成的折线
distances = \[]
for a, b in segments:
d = point\_to\_segment\_distance(p, a, b)
distances.append(d)
return torch.stack(distances).min()
5.2 可微分碰撞惩罚
在物理模拟或机器人控制中,经常需要"让某点远离某条线段":
python
@sll.linearize(eps=1e-2)
def collision\_penalty(robot\_end, obstacle\_a, obstacle\_b, safe\_margin=0.3):
d = point\_to\_segment\_distance(robot\_end, obstacle\_a, obstacle\_b)
# 当距离 < safe\_margin 时产生惩罚
return torch.relu(safe\_margin - d) \*\* 2
# 端到端优化机械臂轨迹
for step in range(1000):
optimizer.zero\_grad()
traj = robot\_model(params)
penalty = sum(collision\_penalty(p, obs\_a, obs\_b) for p in traj)
penalty.backward()
optimizer.step()
六、和纯平滑方案的区别
有人可能会问:为什么不直接把距离函数整体用一个平滑核(比如 Huber loss)近似?
关键区别:
| 特性 | 全局平滑近似 | SLL 局部线性化 |
|---|---|---|
| 前向精度 | 改变几何意义 | 精确保持 |
| 边界行为 | 模糊化 | 清晰可控 |
| 适用场景 | 粗略优化 | 需要精确几何的端到端训练 |
| 超参数 | 核宽度(影响全局) | eps(只影响边界附近) |
SLL 不是"把 V 型拐点磨圆",而是"在拐点处搭一座小桥,让梯度能走过去,但桥的位置和形状完全可控"。
七、总结
点到线段距离的不可微性,本质上是**离散判断(最近点是谁)嵌套在连续优化(距离最小化)**中造成的冲突。
sll-core 提供了一种零侵入的解决思路:
- 保持前向逻辑不变(最近点该是谁就是谁)
- 用
@sll.linearize装饰器自动处理反向传播 - 只在判断边界附近注入梯度,远离边界不干扰
如果你在做可微分渲染、机器人控制、计算几何相关的深度学习项目,被硬分支的不可微性卡住了,可以试试这个方案。
bash
pip install sll-core