Kornia LAF 边界校验修复解析:`laf_is_inside_image` 像素范围约定与 `ScaleSpaceDetector` 检测行为修正
Kornia LAF 边界校验修复解析:laf_is_inside_image 像素范围约定与 ScaleSpaceDetector 检测行为修正
导读
本文围绕 Kornia 本地特征(Local Affine Frame,LAF)模块中的一次关键 bug 修复展开:laf_is_inside_image 原先以 (w, h) 而非 (w - 1, h - 1) 作为图像有效范围的上界,导致边界判断上下界不对称——下界拒绝 x = 0 左侧,上界却放行 x = w(超出最后一个有效列 w - 1 整整一个像素)。本次修复(issue #4064)统一了 Kornia 的像素坐标约定,并连带修正了 ScaleSpaceDetector._process_octave 中内联检查的角间距常数错误。读完本文,你将掌握 Kornia 中 LAF 边界校验的准确语义、0 .. w-1 像素约定的来龙去脉,以及这类底层修复对检测器输出的实际影响,并可直接定位到 kornia/feature/laf.py 与 kornia/feature/scale_space_detector.py 的对应实现。
问题背景:边界校验的“一个像素之遥”
laf_is_inside_image 是 Kornia 本地特征模块中判断“LAF 是否完全位于图像内部”的核心函数,其作用是返回一个掩码,标记哪些 LAF(局部仿射框)完全在图像内、因而有效。它被广泛用于特征检测的后处理、特征可视化以及测试中,例如 tests/feature/test_laf.py 中对该函数的形状、边界阈值与 JIT 导出均有覆盖。
该函数的基本思路是:把每个 LAF 采样为一组边界点(默认 12 个点,含中心点),然后检查这些点的 x/y 坐标是否全部落在图像有效范围内。问题恰恰出在“有效范围”的定义上。
修复前:(w, h) 造成的非对称边界
修复前的实现将边界上限设为图像的宽高 w 和 h 本身:
- 下界:拒绝任何
x < 0的点; - 上界:却接受
x = w的点。
而一张宽度为 w 的图像的像素列索引是 0 .. w-1。也就是说,上界放行了“最后一个有效列再往右一个像素”的位置,而下界却严格执行从 0 开始。这种不对称意味着:同样越界一个像素,靠左的被判为“外部”,靠右的却被判为“内部”,边界行为取决于方向。
修复后:统一采用 0 .. w-1 像素约定
本次修复(changelog 条目 +migration-129.fixed.md)将上界修正为 w - 1 - border 与 h - 1 - border,从而与 Kornia 全库的像素坐标约定保持一致。这个约定在源码中有多处明确证据:
get_laf_center的文档注释明确记录了这一约定;normalize_laf/denormalize_laf早已使用W - 1与H - 1作为归一化因子。见 kornia/feature/laf.py 中denormalize_laf的说明:“The convention is that center of 5-pixel image (coordinates from 0 to 4) is 2, and not 2.5”,即一个 5 像素宽的图像(坐标 0..4)的中心是 2 而非 2.5——这正是“有效坐标只到w-1”这一约定的直观体现。
修复后的实现位于 kornia/feature/laf.py:
def laf_is_inside_image(laf: torch.Tensor, images: torch.Tensor, border: int = 0) -> torch.Tensor:
"""Check if the LAF is touching or partly outside the image boundary.
Returns the mask of LAFs, which are fully inside the image, i.e. valid.
"""
KORNIA_CHECK_LAF(laf)
_, _, h, w = images.size()
pts = laf_to_boundary_points(laf, 12)
# Valid pixel coordinates run 0 .. w-1 and 0 .. h-1, matching the convention documented on
# `get_laf_center` and the `w - 1` / `h - 1` extent used by `normalize_laf` / `denormalize_laf`.
x_max = float(w - 1) - border
y_max = float(h - 1) - border
good_lafs_mask = (pts[..., 0] >= border) * (pts[..., 0] <= x_max) * (pts[..., 1] >= border) * (pts[..., 1] <= y_max)
# `.all` rather than `.min` on the bool mask: ONNX ReduceMin has no bool overload.
return good_lafs_mask.all(dim=2)
关键细节解读:
- 边界点采样:
laf_to_boundary_points(laf, 12)将每个 LAF 转换为 12 个边界点(含中心点),形状为(B, N, 12, 2)。采样实现见 kornia/feature/laf.py:在单位圆上以linspace(0, 2π, n_pts - 1)取角度采样(sin, cos),先放入原点(中心)作为方向指示,再通过torch.bmm与 LAF 矩阵相乘得到实际边界点坐标。 - border 参数:
border表示额外收缩的边界宽度,默认 0。校验时下界为>= border、上界为<= (size - 1) - border,即让边界检查整体向内收缩 border 个像素,常用于给检测留出安全边距。 - 聚合方式:所有边界点必须全部满足条件(
.all(dim=2)),任一越界即判为外部。注释还说明使用.all而非.min是为了兼容 ONNX(ReduceMin 没有 bool 重载),体现了对导出友好性的考量。
行为变化:边界点恰好到达最后一个有效像素坐标(w-1、h-1)的 LAF 仍然判定为内部;只有越过最后一个有效像素的 LAF 才会被新逻辑判为外部。严格位于内部的 LAF 行为完全不变。
连带修复:ScaleSpaceDetector 内联检查的两处偏差
ScaleSpaceDetector 是 Kornia 的尺度空间特征检测器(实现于 kornia/feature/scale_space_detector.py)。为了性能,它在 _process_octave 中对各向同性 LAF(rotmat = eye(2))使用了 laf_is_inside_image 的“内联等价检查”,以规避 scale_laf(涉及 torch.cat)和 laf_to_boundary_points(涉及 linspace/sin/cos 分配与启动开销以及一次小的 host→device 拷贝)带来的每调用开销。见 kornia/feature/scale_space_detector.py:
# Inline equivalent of laf_is_inside_image(scale_laf(current_lafs, 0.5), octave[:, 0], 5)
# for isotropic LAFs (rotmat = eye(2)). ...
# For the axis-aligned isotropic case the 12-pt boundary check reduces to:
# max x-extent = max|sin| * half_s; max y-extent = max|cos| * half_s = half_s
half_s = current_lafs[:, :, 0, 0] * 0.5
cx = current_lafs[:, :, 0, 2]
cy = current_lafs[:, :, 1, 2]
h, w = octave.shape[3], octave.shape[4]
# Valid pixel coordinates run 0 .. w-1 / 0 .. h-1, so the upper bound is (size - 1) - border.
x_max = float(w - 1) - 5
y_max = float(h - 1) - 5
good_mask = (
(cx - half_s * _MAX_ABS_SIN_12 >= 5)
& (cx + half_s * _MAX_ABS_SIN_12 <= x_max)
& (cy - half_s >= 5)
& (cy + half_s <= y_max)
)
偏差一:上界同样用了错误的 w / h
内联检查原来也以上界 w、h 判断,因此与 laf_is_inside_image 存在同样的不对称问题。修复后上界同步改为 w - 1 - border、h - 1 - border(此处 border=5)。这一变化会产生真实的检测行为差异:以前距离右边界或下边界 1 个像素以内的检测结果,曾经能通过 border=5 的过滤保留下来,现在会被丢弃。
偏差二:max|sin| 常数的角间距错误
内联检查还需要一个“12 点边界采样中 |sin| 的最大值”常数,用于把圆的半径投影到 x 方向。原实现用 2π/11 作为角间距计算该常数,而 laf_to_boundary_points(n_pts=12) 实际采样的角间距是 linspace(0, 2π, 11) 即 k * 2π/10(11 个点等分 2π,对应 10 个间隔)。正确的最大值出现在 k=2 与 k=3 处:sin(2π/5) ≈ 0.9511。修复后的定义见 kornia/feature/scale_space_detector.py:
# Max |sin| among the 11 boundary points sampled by laf_to_boundary_points(n_pts=12):
# angles = linspace(0, 2π, n_pts - 1) = linspace(0, 2π, 11) → k * 2π/10 for k=0..10
# max|sin| at k=2 and k=3: sin(2π/5) ≈ 0.9511; max|cos| at k=0: cos(0) = 1.0
_MAX_ABS_SIN_12: float = math.sin(2 * 2 * math.pi / 10) # ≈ 0.9511
原来的 2π/11 常数会高估 x 方向的检验范围约 4%(inflating the tested x-extent by 4%),使内联检查比它所声称复现的参考实现(laf_is_inside_image)更严格。修正后两者在数值上精确一致。
x 方向放宽带来的输出变化:由于这一修正在 x 方向是“放宽”,它可能单独改变 ScaleSpaceDetector 的输出——某个 x 方向范围落在新旧两个常数之间的检测,以前被丢弃、现在被保留。changelog 明确指出:这种情况罕见(检测必须落在宽度仅 0.039 * half_s 的条带内并紧贴 x 边界),但一旦触发,它可能把图像中最强的响应提升为被选中的特征,因此并非输出无关的纯数值修正。
修复的工程价值
- 语义统一:
laf_is_inside_image与normalize_laf/denormalize_laf/get_laf_center现在共享同一套0 .. w-1像素坐标约定,消除了“归一化往返后边界判定不一致”的隐患。 - 内联与参考实现严格等价:
ScaleSpaceDetector的性能优化路径(内联检查)与其语义参考(laf_is_inside_image)在修复后完全一致,避免了两者长期漂移带来的隐性行为分歧。 - 测试印证:tests/feature/test_laf.py 对边界阈值做了精确的“一个像素内/外”验证(如半径 29.0 判内、29.5 判外),并验证了
border参数的收缩效果;同时 tests/feature/test_scale_space_detector.py 覆盖检测器的整体输出行为,是回归测试的直接保障。 - 导出兼容性:
.all的选用兼顾了 ONNX 导出(ReduceMin 无 bool 重载),体现了 Kornia 在 docs/export_support 维度上对算子可导出性的持续要求。
小结
本次修复表面上只是把边界上界从 w/h 改为 w-1/h-1 的“一个像素”调整,但它统一了 Kornia 的像素坐标约定、修正了内联检查的采样常数误差,并真实改变了 ScaleSpaceDetector 在图像边缘处的特征筛选结果。对于在自己的管线中复现、迁移或调优 Kornia 特征检测的用户,理解这一约定(有效坐标 0 .. w-1、0 .. h-1)是正确解读检测结果与边界掩码的前提;相关实现与测试均可直接在上述源码路径中继续深入研读。