项目背景将 Python 智能眼镜的盲道导航模型移植到 Android 手机端使用 YOLOv8-seg ONNX 模型 CameraX 相机 Jetpack Compose UI。问题现象模型能检测到盲道置信度 60%~92%但绿色掩码叠加层始终与实际盲道位置存在偏移经过四轮修复才最终解决。一、状态锁死 Bug已解决现象检测结果不随画面变化而更新。画面从无盲道变为有盲道时始终检测不到从有盲道变为无盲道时检测结果持续残留。根因相机帧回调以 ~30fps 的频率向 viewModelScope 发射协程但 ONNX推理单次耗时 200~1000ms。协程在 processMutex 上排队处理的永远是几十帧之前的旧画面导致结果锁死在某个历史状态。修复在 MainViewModel 中增加 Volatile isProcessing 标志位。当上一帧仍在推理时直接丢弃当前帧并回收 bitmap。配合 CameraX 的STRATEGY_KEEP_ONLY_LATEST下次回调自动拿到最新帧。教训高频回调 慢速推理 协程队列堆积。帧丢弃是移动端实时推理的标准做法不要试图处理每一帧。二、矩形框替换为分割掩码已解决现象盲道检测结果只显示矩形边界框无法体现盲道的真实形状。根因原来的 decodeMask() 只返回 bbox 四个角点作为多边形没有真正解码 YOLOv8-seg 的 proto mask 输出。修复在 YoloOnnxEngine 中实现完整的 YOLOv8-seg 掩码解码流水线提取 ONNX 第二个输出proto masks [1, 32, maskH, maskW]maskCoeffs[32] × protoMasks → sigmoid → 二值化 → maskMatrix将 bbox 映射到 mask 空间经过 letterbox 坐标变换裁剪 mask 到 bbox 区域双线性插值 resize 到 bbox 对应的原图像素尺寸下采样为 40×40 BooleanArray 网格用于像素级渲染射线法提取轮廓多边形作为后备渲染教训YOLOv8-seg 的 mask 输出不是直接的像素掩码而是 proto mask系数矩阵需要与每个检测的 mask coefficients 做矩阵乘法才能得到完整掩码。很多移植项目会忽略这一步。三、显示偏移问题四轮排查这是本次任务最棘手的问题经历了四轮修复每一轮都排除了一个可能的原因最终在第四轮找到真正的根因。【第一轮】多边形渲染 → 网格渲染现象绿色多边形覆盖区域与盲道位置不一致。尝试将多边形渲染改为 40×40 网格逐像素渲染排除多边形顶点插值误差。结果网格渲染更精细但整体偏移依然存在。排除的原因多边形渲染精度问题。【第二轮】CameraX ViewPort API现象网格渲染工作正常但仍然整体偏移。分析Preview 和 ImageAnalysis 可能有不同的 FOV视野范围导致同一个物体在两个 UseCase 中的位置不同。尝试使用 CameraX 的 ViewPort UseCaseGroup API 统一 Preview和 ImageAnalysis 的视野范围。结果偏移依然存在。排除的原因Preview/ImageAnalysis FOV 不匹配。【第三轮】FILL_CENTER 坐标映射现象ViewPort 修复后偏移仍在。分析PreviewView 使用 FILL_CENTER 缩放模式会将图像等比放大并裁剪超出视口的部分。但 Canvas 叠加层直接用 normX * canvasWidth映射坐标相当于假设图像被拉伸铺满视口两者坐标系不一致。举例480×640 竖屏图像在 1080×1920 屏幕上scale max(1080/480, 1920/640) 3.0缩放后图像宽 1440px左右各溢出 180px旧代码viewX normX × 1080错误正确viewX normX × 480 × 3.0 (-180)尝试在 drawDetections() 中计算 FILL_CENTER 的 scale 和 offset对所有检测坐标应用相同的变换。同时添加黄色虚线诊断框和中心十字标记用于目视验证。结果诊断框确认新代码已生效但黄色虚线框模型检测位置本身就不在盲道上——说明偏移不是渲染坐标的问题而是模型检测坐标本身就是错的。排除的原因Canvas 渲染坐标映射问题这个确实是 bug修了但不够。【第四轮】YUV 颜色通道错位真正的根因现象模型的 bbox 坐标本身就偏移了不是渲染问题。分析同一个模型在 Python 项目中工作正常说明模型本身没问题。问题出在 Android 端的图像预处理。排查发现自定义的 ImageProxy.toBitmap() 在做 YUV_420_888 → NV21转换时存在严重 bug// 旧代码有 bugval nv21 ByteArray(ySize uSize vSize)yBuffer.get(nv21, 0, ySize)vBuffer.get(nv21, ySize, vSize) // Plane 2 → 当作 VuBuffer.get(nv21, ySize vSize, uSize) // Plane 1 → 当作 U问题在于 CameraX 的 YUV_420_888 输出在不同硬件上有两种格式pixel stride 2半平面 NV21Plane 1 UV交织Plane 2 VU交织此时代码读 Plane 2 当 V、Plane 1 当 U但 Plane 2 的实际内容是VU 交织Plane 1 是 UV 交织等于把 U/V 搞反了pixel stride 1平面 I420Plane 1 UPlane 2 V此时代码先放 V 再放 U产生的是 NV12 数据却标记为 NV21两种情况下色度通道都是错的模型看到的图像颜色严重偏移黄色盲道变成偏蓝/偏绿导致空间预测不准。修复删除自定义转换改用 CameraX 1.3.1 内置的 ImageProxy.toBitmap()方法。该方法自动处理所有 YUV 格式变体不会出现通道错位。// 修复后fun ImageProxy.toRotatedBitmap(): Bitmap {val bitmap this.toBitmap() // CameraX 内置正确处理所有 YUV 格式// 然后应用旋转…}结果盲道检测位置准确绿色掩码完美覆盖在盲道上。教训永远不要手动做 YUV→RGB 转换除非你完全确定硬件输出的具体格式CameraX 1.3 已经内置了 ImageProxy.toBitmap()用它就行颜色通道错误不仅影响颜色好不好看还会影响模型的定位精度当所有坐标映射逻辑都验证正确但偏移仍在时应该回头检查模型输入四、ONNX 输出格式兼容附带修复YOLOv8-seg 的 ONNX 输出可能是 [1, 38, 8400] 或 [1, 8400, 38]取决于 Ultralytics 版本。如果格式假设错误bbox 坐标会全部错乱把类别分数当成坐标值。修复增加自动检测逻辑比较两个维度的大小来判断哪个是特征维度通常 128、哪个是检测数量通常 8400然后按需转置。五、调试技巧总结黄色诊断框在检测 bbox 边界画黄色虚线框一眼区分渲染偏移还是模型检测偏移中心十字标记在 Canvas 中心画白色十字确认画面中心位置诊断图像保存首次推理时将 640×640 letterbox 图像保存为 PNG通过 Android Studio Device File Explorer 导出检查Logcat 坐标链输出模型原始输出值 → 去 letterbox → 归一化坐标的完整映射过程逐步排查FILL_CENTER 日志首次绘制时输出 scale/offset 值确认坐标映射参数是否正确六、Python 与 Android 预处理对比PythonUltralytics 内部接收 BGR 图像OpenCV 格式BGR → RGBLetterbox resize 到 640×640灰色(114,114,114)填充归一化 /255.0 → float32 [0,1]HWC → CHW → [1, 3, 640, 640]Android修复后CameraX ImageProxy (YUV_420_888)内置 toBitmap() → ARGB Bitmap自动处理 YUV 格式旋转 90° 匹配竖屏方向Letterbox resize 到 640×640灰色(114,114,114)填充提取 RGB 像素归一化 /255.0 → float32 [0,1]CHW 排列 → [1, 3, 640, 640]两者的预处理流水线在语义上完全一致。关键差异就在第 2 步Python用 OpenCV 的 cv2.imdecode() 解码Android 用 CameraX 的 toBitmap()。两者都能正确产出 RGB 图像前提是 Android 端不要用有 bug 的手动YUV 转换。最终结论四轮排查的根因是 YUV 颜色通道错位。看似是坐标偏移的问题实际是模型输入图像颜色错误。这是一个典型的看到的不一定是真正的问题的案例。
用YOLOv8-seg 安卓手机盲道检测显示偏移问题
发布时间:2026/6/28 1:40:05
项目背景将 Python 智能眼镜的盲道导航模型移植到 Android 手机端使用 YOLOv8-seg ONNX 模型 CameraX 相机 Jetpack Compose UI。问题现象模型能检测到盲道置信度 60%~92%但绿色掩码叠加层始终与实际盲道位置存在偏移经过四轮修复才最终解决。一、状态锁死 Bug已解决现象检测结果不随画面变化而更新。画面从无盲道变为有盲道时始终检测不到从有盲道变为无盲道时检测结果持续残留。根因相机帧回调以 ~30fps 的频率向 viewModelScope 发射协程但 ONNX推理单次耗时 200~1000ms。协程在 processMutex 上排队处理的永远是几十帧之前的旧画面导致结果锁死在某个历史状态。修复在 MainViewModel 中增加 Volatile isProcessing 标志位。当上一帧仍在推理时直接丢弃当前帧并回收 bitmap。配合 CameraX 的STRATEGY_KEEP_ONLY_LATEST下次回调自动拿到最新帧。教训高频回调 慢速推理 协程队列堆积。帧丢弃是移动端实时推理的标准做法不要试图处理每一帧。二、矩形框替换为分割掩码已解决现象盲道检测结果只显示矩形边界框无法体现盲道的真实形状。根因原来的 decodeMask() 只返回 bbox 四个角点作为多边形没有真正解码 YOLOv8-seg 的 proto mask 输出。修复在 YoloOnnxEngine 中实现完整的 YOLOv8-seg 掩码解码流水线提取 ONNX 第二个输出proto masks [1, 32, maskH, maskW]maskCoeffs[32] × protoMasks → sigmoid → 二值化 → maskMatrix将 bbox 映射到 mask 空间经过 letterbox 坐标变换裁剪 mask 到 bbox 区域双线性插值 resize 到 bbox 对应的原图像素尺寸下采样为 40×40 BooleanArray 网格用于像素级渲染射线法提取轮廓多边形作为后备渲染教训YOLOv8-seg 的 mask 输出不是直接的像素掩码而是 proto mask系数矩阵需要与每个检测的 mask coefficients 做矩阵乘法才能得到完整掩码。很多移植项目会忽略这一步。三、显示偏移问题四轮排查这是本次任务最棘手的问题经历了四轮修复每一轮都排除了一个可能的原因最终在第四轮找到真正的根因。【第一轮】多边形渲染 → 网格渲染现象绿色多边形覆盖区域与盲道位置不一致。尝试将多边形渲染改为 40×40 网格逐像素渲染排除多边形顶点插值误差。结果网格渲染更精细但整体偏移依然存在。排除的原因多边形渲染精度问题。【第二轮】CameraX ViewPort API现象网格渲染工作正常但仍然整体偏移。分析Preview 和 ImageAnalysis 可能有不同的 FOV视野范围导致同一个物体在两个 UseCase 中的位置不同。尝试使用 CameraX 的 ViewPort UseCaseGroup API 统一 Preview和 ImageAnalysis 的视野范围。结果偏移依然存在。排除的原因Preview/ImageAnalysis FOV 不匹配。【第三轮】FILL_CENTER 坐标映射现象ViewPort 修复后偏移仍在。分析PreviewView 使用 FILL_CENTER 缩放模式会将图像等比放大并裁剪超出视口的部分。但 Canvas 叠加层直接用 normX * canvasWidth映射坐标相当于假设图像被拉伸铺满视口两者坐标系不一致。举例480×640 竖屏图像在 1080×1920 屏幕上scale max(1080/480, 1920/640) 3.0缩放后图像宽 1440px左右各溢出 180px旧代码viewX normX × 1080错误正确viewX normX × 480 × 3.0 (-180)尝试在 drawDetections() 中计算 FILL_CENTER 的 scale 和 offset对所有检测坐标应用相同的变换。同时添加黄色虚线诊断框和中心十字标记用于目视验证。结果诊断框确认新代码已生效但黄色虚线框模型检测位置本身就不在盲道上——说明偏移不是渲染坐标的问题而是模型检测坐标本身就是错的。排除的原因Canvas 渲染坐标映射问题这个确实是 bug修了但不够。【第四轮】YUV 颜色通道错位真正的根因现象模型的 bbox 坐标本身就偏移了不是渲染问题。分析同一个模型在 Python 项目中工作正常说明模型本身没问题。问题出在 Android 端的图像预处理。排查发现自定义的 ImageProxy.toBitmap() 在做 YUV_420_888 → NV21转换时存在严重 bug// 旧代码有 bugval nv21 ByteArray(ySize uSize vSize)yBuffer.get(nv21, 0, ySize)vBuffer.get(nv21, ySize, vSize) // Plane 2 → 当作 VuBuffer.get(nv21, ySize vSize, uSize) // Plane 1 → 当作 U问题在于 CameraX 的 YUV_420_888 输出在不同硬件上有两种格式pixel stride 2半平面 NV21Plane 1 UV交织Plane 2 VU交织此时代码读 Plane 2 当 V、Plane 1 当 U但 Plane 2 的实际内容是VU 交织Plane 1 是 UV 交织等于把 U/V 搞反了pixel stride 1平面 I420Plane 1 UPlane 2 V此时代码先放 V 再放 U产生的是 NV12 数据却标记为 NV21两种情况下色度通道都是错的模型看到的图像颜色严重偏移黄色盲道变成偏蓝/偏绿导致空间预测不准。修复删除自定义转换改用 CameraX 1.3.1 内置的 ImageProxy.toBitmap()方法。该方法自动处理所有 YUV 格式变体不会出现通道错位。// 修复后fun ImageProxy.toRotatedBitmap(): Bitmap {val bitmap this.toBitmap() // CameraX 内置正确处理所有 YUV 格式// 然后应用旋转…}结果盲道检测位置准确绿色掩码完美覆盖在盲道上。教训永远不要手动做 YUV→RGB 转换除非你完全确定硬件输出的具体格式CameraX 1.3 已经内置了 ImageProxy.toBitmap()用它就行颜色通道错误不仅影响颜色好不好看还会影响模型的定位精度当所有坐标映射逻辑都验证正确但偏移仍在时应该回头检查模型输入四、ONNX 输出格式兼容附带修复YOLOv8-seg 的 ONNX 输出可能是 [1, 38, 8400] 或 [1, 8400, 38]取决于 Ultralytics 版本。如果格式假设错误bbox 坐标会全部错乱把类别分数当成坐标值。修复增加自动检测逻辑比较两个维度的大小来判断哪个是特征维度通常 128、哪个是检测数量通常 8400然后按需转置。五、调试技巧总结黄色诊断框在检测 bbox 边界画黄色虚线框一眼区分渲染偏移还是模型检测偏移中心十字标记在 Canvas 中心画白色十字确认画面中心位置诊断图像保存首次推理时将 640×640 letterbox 图像保存为 PNG通过 Android Studio Device File Explorer 导出检查Logcat 坐标链输出模型原始输出值 → 去 letterbox → 归一化坐标的完整映射过程逐步排查FILL_CENTER 日志首次绘制时输出 scale/offset 值确认坐标映射参数是否正确六、Python 与 Android 预处理对比PythonUltralytics 内部接收 BGR 图像OpenCV 格式BGR → RGBLetterbox resize 到 640×640灰色(114,114,114)填充归一化 /255.0 → float32 [0,1]HWC → CHW → [1, 3, 640, 640]Android修复后CameraX ImageProxy (YUV_420_888)内置 toBitmap() → ARGB Bitmap自动处理 YUV 格式旋转 90° 匹配竖屏方向Letterbox resize 到 640×640灰色(114,114,114)填充提取 RGB 像素归一化 /255.0 → float32 [0,1]CHW 排列 → [1, 3, 640, 640]两者的预处理流水线在语义上完全一致。关键差异就在第 2 步Python用 OpenCV 的 cv2.imdecode() 解码Android 用 CameraX 的 toBitmap()。两者都能正确产出 RGB 图像前提是 Android 端不要用有 bug 的手动YUV 转换。最终结论四轮排查的根因是 YUV 颜色通道错位。看似是坐标偏移的问题实际是模型输入图像颜色错误。这是一个典型的看到的不一定是真正的问题的案例。