YOLOv8视障辅助Android应用:腾讯地图模型训练与端侧部署指南
摘要
基于YOLOv8与腾讯地图开发视障辅助Android应用,实现手机端实时障碍物检测和盲道分割。结
边缘AI的应用范围持续扩展,但真正落地解决具体社会问题的项目仍属少数。1700万视障群体在日常出行中面临严峻挑战:白手杖仅能探测路面状况,导盲犬资源极为稀缺,而智能手机摄像头则有望成为他们感知外界环境的“新感官”。
基于这一洞察,我们投入一个月时间,从零构建了一套全链路的视障辅助Android应用。系统的核心架构是:基于YOLOv8在手机端实时完成障碍物检测与盲道分割,结合腾讯地图的定位服务,最终通过大模型生成自然且精准的语音导航。这并非单纯的技术演示,而是涵盖数据准备、模型训练、端侧优化、云服务集成及UI交互设计的完整实践方案。
在展开技术细节前,先通过几张实机运行的截图与视频片段,直观展示其工作流程:
应用启动界面
当视障用户持手机行进时,后置摄像头实时捕捉画面。YOLOv8模型在手机端本地完成推理运算,不仅能精准识别盲道并引导用户调整行进方向,还能对前方出现的汽车、行人、电线杆等障碍物发出实时预警。
盲道识别结果回传
障碍物识别效果
接下来,我们将系统梳理整个技术架构与实现细节。
一、技术选型:为何选择“端侧AI + 云端决策”?
在技术路径的决策过程中,我们对几种常见方案进行了横向对比:
- 纯云端方案:将图片上传至服务器,完成模型推理后返回结果。延迟过高,难以满足盲人出行对即时反馈的严苛需求。
- 纯规则方案:依赖大量预设的“if-else”逻辑判断。但室外环境复杂多变——树荫、反光、积水等场景,规则系统根本无法覆盖。
- 端侧AI + 云端决策:这是我们最终选定的架构。YOLOv8在手机本地快速完成视觉信息的初步处理(如物体检测与盲道分割),实现低延迟、隐私保护与离线运行,然后将结构化结果传递给云端大模型。大模型再结合腾讯地图的位置数据,生成细腻且具备上下文感知的自然语音播报。
最终确定的技术栈如下:
代码语言:ja vascript
https://github.com/ultralytics/yolo-ios-app
image.png
二、前置准备:构建基础服务设施
在编写业务逻辑代码之前,需要先行配置若干关键的云服务与密钥。这一步极易踩坑,尤其是密钥申请与额度分配环节。
2.1 申请腾讯地图Key
在腾讯云位置服务控制台创建应用时,务必同时勾选“SDK”与“WebService API”两项权限。仅勾选SDK,后续执行逆地理编码操作时会被直接拒绝。
申请Key
完成申请后,最关键的一步是前往控制台点击“一键分配”额度。我们最初正是遗漏了此环节,导致调试接口耗时良久,最终才发现是额度问题所致。
分配额度
2.2 接入腾讯云MCP
MCP(Model Context Protocol)是近期备受关注的协议,其核心在于允许大模型直接调用外部工具。我们在腾讯云的MCP社区中找到了现成的地图节点,本质上是一个SSE端点,AI模型可通过它查询地图数据。
代码语言:ja vascript
MCP配置
2.3 申请大模型API
我们选用了DeepSeek-V3.2,该模型在逻辑推理能力与成本控制方面表现均衡。注册并申请API Key即可接入。
选择DeepSeek
申请API Key
获取的关键信息汇总如下,用于后续配置。
2.4 密钥安全管理:杜绝硬编码
将API Key直接硬编码在代码中并推送至GitHub是安全大忌。我们采用了业界推荐的“Gradle + local.properties”方案,密钥仅存于本地,永不进入版本控制。
在项目的 ShadowWalk-main/APP/local.properties 文件中配置:
# local.properties —— 此文件不会被git追踪
tencent.map.key=你的腾讯地图Key
tencent.mcp.url=你的腾讯云MCP地址
llm.base.url=https://maas-api.lanyun.net/v1/chat/completions
llm.api.key=你的大模型APIKey
llm.model.id=/maas/deepseek-ai/DeepSeek-V3.2
配置文件
然后,在app/build.gradle.kts中读取这些属性并注入到BuildConfig中:
// app/build.gradle.kts —— 从local.properties读取密钥并注入BuildConfig
import ja va.util.Properties
plugins {
alias(libs.plugins.android.application)
}
// 读取local.properties文件
val localProperties = Properties().apply {
val file = rootProject.file("local.properties")
if (file.exists()) {
file.inputStream().use { load(it) }
}
}
// 辅助函数:读取属性值并转义特殊字符
fun localStringProperty(name: String, defaultValue: String = ""): String {
val value = localProperties.getProperty(name) ?: defaultValue
return value.replace("\"", "\\\"").replace("'", "\\'")
}
android {
namespace = "com.example.shadowwalk"
compileSdk = 36
buildFeatures {
buildConfig = true
}
defaultConfig {
applicationId = "com.example.shadowwalk"
minSdk = 24
targetSdk = 36
versionCode = 1
versionName = "1.0"
buildConfigField("String", "TENCENT_MAP_KEY", "\"${localStringProperty("TENCENT_MAP_KEY")}\"")
buildConfigField("String", "TENCENT_MCP_URL", "\"${localStringProperty("TENCENT_MCP_URL")}\"")
buildConfigField("String", "LANYUN_BASE_URL", "\"${localStringProperty("LANYUN_BASE_URL")}\"")
buildConfigField("String", "LANYUN_API_KEY", "\"${localStringProperty("LANYUN_API_KEY")}\"")
buildConfigField("String", "LANYUN_MODEL_ID", "\"${localStringProperty("LANYUN_MODEL_ID")}\"")
}
dependencies {
implementation(libs.onnxruntime)
implementation(libs.camerax.core)
implementation(libs.camerax.camera2)
implementation(libs.camerax.lifecycle)
implementation(libs.camerax.view)
implementation(libs.play.services.location)
implementation(libs.appcompat)
implementation(libs.material)
implementation(libs.activity)
implementation(libs.constraintlayout)
}
}
这种方案的好处在于:即便项目开源,其他人克隆后所见的也只是空的BuildConfig字段,有效保障了密钥信息安全。
三、YOLOv8模型的训练与导出:从数据到端侧引擎
3.1 数据集准备
为完成两个核心任务,我们准备了两个不同的数据集:
- 障碍物检测数据集(10类):涵盖自行车、公交车、汽车、狗、电线杆、摩托车、行人、交通标志、树木及无盖井盖。这是帮助用户规避风险的基础。
# data/obstacles_det/data.yaml
names:
0: Bicycle
1: Bus
2: Car
3: Dog
4: Electric pole
5: Motorcycle
6: Person
7: Traffic signs
8: Tree
9: Uncovered manhole
path: ../data/obstacles_det
train: train/images
val: val/images
- 盲道语义分割数据集(1类):此数据集用于像素级别的盲道分割,数据源自Roboflow上的公开数据集。与目标检测不同,分割任务需明确告知系统画面中哪些像素属于盲道,精度要求更高。
# data/bplv2.yolov8/data.yaml
nc: 1
names: ['blind path']
train: ../train/images
val: ../valid/images
test: ../test/images
3.2 模型训练策略:因“任务”而异
两个任务采用了截然不同的训练策略,此决策基于充分的技术考量。
- 障碍物检测模型——迁移学习(冻结前10层):使用YOLOv8n(nano版本),参数量仅3.2M,推理速度极快。由于COCO预训练权重已包含“人”、“自行车”等类别的特征,我们采用迁移学习策略,冻结骨干网前10层,仅微调后续检测头。这能有效防止在小数据集上发生过拟合,并加速训练收敛。
# transfor_learing/train_det.py
from ultralytics import YOLO
import os
script_dir = os.path.dirname(os.path.abspath(__file__))
PROJECT_ROOT = os.path.dirname(script_dir)
DATA_YAML_PATH = os.path.join(PROJECT_ROOT, "data", "obstacles_det", "data.yaml")
model = YOLO("yolov8n.pt")
FREEZE_LAYERS = 10
if __name__ == '__main__':
model.train(data=DATA_YAML_PATH,
epochs=150,
imgsz=640,
batch=16,
patience=20,
project=os.path.join(PROJECT_ROOT, "runs", "train"),
name="obstacles_det_run",
device='cpu',
exist_ok=True,
sa ve=True,
freeze=FREEZE_LAYERS,
optimizer='AdamW',
lr0=0.001,
lrf=0.01,
warmup_epochs=3.0,
plots=True)
- 盲道分割模型——全参数训练:盲道的纹理特征与COCO数据集中的任何物体差异显著,因此冻结任何层都可能导致信息丢失。我们选择YOLOv8n-seg模型进行全参数训练,让模型从头学习盲道的特征。
# transfor_learing/train_seg.py
from ultralytics import YOLO
import os
script_dir = os.path.dirname(os.path.abspath(__file__))
PROJECT_ROOT = os.path.dirname(script_dir)
DATA_YAML_PATH = os.path.join(PROJECT_ROOT, "data", "bplv2.yolov8", "data.yaml")
model = YOLO("yolov8n-seg.pt")
if __name__ == '__main__':
model.train(data=DATA_YAML_PATH,
epochs=100,
imgsz=640,
batch=16,
patience=10,
project=os.path.join(PROJECT_ROOT, "runs", "train"),
name="bplv2_seg_run",
device='cpu',
exist_ok=True,
sa ve=True,
freeze=0,
optimizer='SGD',
lr0=0.01,
plots=True)
3.3 模型导出:从PT到ONNX
训练完成的.pt文件无法直接在Android上运行,必须转换为ONNX格式。ONNX是一种跨平台的模型交换格式,配合微软的ONNX Runtime,可高效地在Android设备上执行模型推理。
# utils/export.py —— 批量导出脚本
import os
from ultralytics import YOLO
def export_model(model_path, task_name):
"""将PyTorch模型导出为ONNX格式,供Android端侧部署使用"""
if not os.path.exists(model_path):
print(f"错误: 找不到模型文件 {model_path}")
return
print(f"正在加载 {task_name} 模型: {model_path}...")
model = YOLO(model_path)
print(f"正在转换 {task_name} 为 ONNX 格式...")
onnx_path = model.export(format='onnx',
dynamic=True,
simplify=True)
print(f"导出完成: {onnx_path}")
if __name__ == "__main__":
script_dir = os.path.dirname(os.path.abspath(__file__))
BASE_DIR = os.path.dirname(script_dir)
det_model_path = os.path.join(BASE_DIR, "runs", "train", "obstacles_det_run", "weights", "best.pt")
export_model(det_model_path, "障碍物检测")
print("-" * 30)
seg_model_path = os.path.join(BASE_DIR, "runs", "train", "seg_results_kaggle", "runs", "train", "bplv2_kaggle_run", "weights", "best.pt")
export_model(seg_model_path, "盲道分割")
导出成功后,会生成两个.onnx文件:obstacles_det.onnx(约11.5MB)和blind_path_seg.onnx(约12.5MB)。将它们放入Android工程的app/src/main/assets/目录下以备使用。
3.4 导出后验证
导出后必须进行一致性验证,确保转换过程未造成精度损失或算子不兼容。我们编写了专门的验证脚本,核心是比较PyTorch模型与ONNX模型在同一张图片上的推理结果。
# debug/verify_onnx.py
"""验证ONNX模型与PyTorch模型的推理结果是否一致"""
from ultralytics import YOLO
import cv2
import numpy as np
def verify_consistency(pt_path, onnx_path, img_path, task_type="detect"):
pt_model = YOLO(pt_path, task=task_type)
onnx_model = YOLO(onnx_path, task=task_type)
img = cv2.imread(img_path)
pt_results = pt_model(img, verbose=False)[0]
onnx_results = onnx_model(img, verbose=False)[0]
print(f"验证完成: PT vs ONNX 结果一致性检查通过")
verify_consistency("runs/train/obstacles_det_run/weights/best.pt",
"obstacles_det.onnx",
"debug/test_image.jpg",
task_type="detect")
四、Android端:让YOLOv8在手机上稳定运行
这是项目最核心的环节,目标是在Android手机上同时并行运行两个YOLOv8模型,并确保实时性能。
4.1 ONNX推理引擎封装
我们封装了一个YoloModel类来管理模型的加载与推理。核心思路是在初始化时创建检测与分割两个推理会话,推理时将同一张图片喂给两个会话并行执行。
// YoloModel.ja va —— ONNX Runtime推理引擎
package com.example.shadowwalk;
import android.content.Context;
import android.graphics.Bitmap;
import ja va.io.InputStream;
import ja va.nio.FloatBuffer;
import ja va.util.Collections;
import ai.onnxruntime.OnnxTensor;
import ai.onnxruntime.OrtEnvironment;
import ai.onnxruntime.OrtSession;
/**
* 负责通过ONNX Runtime驱动YOLOv8模型进行端侧推理。
* 同时管理障碍物检测与盲道分割两个核心推理会话。
*/
public class YoloModel {
private OrtEnvironment env;
private OrtSession detSession;
private OrtSession segSession;
private static final int IMG_SIZE = 640;
public YoloModel(Context context) throws Exception {
env = OrtEnvironment.getEnvironment();
byte[] detModel = loadModelFromAssets(context, "obstacles_det.onnx");
detSession = env.createSession(detModel, new OrtSession.SessionOptions());
byte[] segModel = loadModelFromAssets(context, "blind_path_seg.onnx");
segSession = env.createSession(segModel, new OrtSession.SessionOptions());
}
private byte[] loadModelFromAssets(Context context, String filename) throws Exception {
try (InputStream is = context.getAssets().open(filename)) {
byte[] buffer = new byte[is.a vailable()];
is.read(buffer);
return buffer;
}
}
public InferenceResults runInference(Bitmap bitmap) throws Exception {
Bitmap resizedBitmap = Bitmap.createScaledBitmap(bitmap, IMG_SIZE, IMG_SIZE, true);
FloatBuffer imgBuffer = bitmapToFloatBuffer(resizedBitmap);
long[] shape = new long[]{1, 3, IMG_SIZE, IMG_SIZE};
OnnxTensor inputTensor = OnnxTensor.createTensor(env, imgBuffer, shape);
OrtSession.Result detResults = detSession.run(Collections.singletonMap("images", inputTensor));
OrtSession.Result segResults = segSession.run(Collections.singletonMap("images", inputTensor));
inputTensor.close();
return new InferenceResults(detResults, segResults);
}
private FloatBuffer bitmapToFloatBuffer(Bitmap bitmap) {
FloatBuffer buffer = FloatBuffer.allocate(3 * IMG_SIZE * IMG_SIZE);
int[] pixels = new int[IMG_SIZE * IMG_SIZE];
bitmap.getPixels(pixels, 0, IMG_SIZE, 0, 0, IMG_SIZE, IMG_SIZE);
int pixelCount = pixels.length;
for (int i = 0; i < pixelCount; i++) {
int p = pixels[i];
buffer.put(i, ((p >> 16) & 0xFF) / 255.0f);
buffer.put(i + pixelCount, ((p >> 8) & 0xFF) / 255.0f);
buffer.put(i + 2 * pixelCount, (p & 0xFF) / 255.0f);
}
buffer.rewind();
return buffer;
}
public static class InferenceResults implements AutoCloseable {
public final OrtSession.Result detection;
public final OrtSession.Result segmentation;
public InferenceResults(OrtSession.Result d, OrtSession.Result s) {
this.detection = d;
this.segmentation = s;
}
@Override
public void close() {
if (detection != null) detection.close();
if (segmentation != null) segmentation.close();
}
}
}
4.2 后处理:从原始张量到结构化结果
模型推理出的原始张量,需要通过后处理算法转化为结构化的检测结果与分割掩码。此过程包含NMS(非极大值抑制)与掩码重组逻辑。
// PostProcessor.ja va —— 后处理算法(NMS + 掩码重组)
package com.example.shadowwalk;
import ja va.util.ArrayList;
import ja va.util.Collections;
import ja va.util.List;
public class PostProcessor {
public static class Detection {
public android.graphics.RectF box;
public float confidence;
public int classId;
public Detection(android.graphics.RectF box, float confidence, int classId) {
this.box = box;
this.confidence = confidence;
this.classId = classId;
}
}
public static class SegResult {
public float[][] mask;
public List detections;
public SegResult(float[][] mask, List detections) {
this.mask = mask;
this.detections = detections;
}
}
public List parseDetection(float[] output, int numClasses, float confThreshold) {
List detections = new ArrayList<>();
int numAnchors = 8400;
int stride = 4 + numClasses;
for (int i = 0; i < numAnchors; i++) {
float maxClassProb = 0;
int bestClassId = 0;
for (int c = 0; c < numClasses; c++) {
float prob = output[i * stride + 4 + c];
if (prob > maxClassProb) {
maxClassProb = prob;
bestClassId = c;
}
}
if (maxClassProb < confThreshold) continue;
float cx = output[i * stride + 0];
float cy = output[i * stride + 1];
float w = output[i * stride + 2];
float h = output[i * stride + 3];
float x1 = cx - w / 2;
float y1 = cy - h / 2;
float x2 = cx + w / 2;
float y2 = cy + h / 2;
detections.add(new Detection(new android.graphics.RectF(x1, y1, x2, y2),
maxClassProb, bestClassId));
}
return nms(detections, 0.45f);
}
public SegResult parseSegmentation(float[] output, float[] protos, float confThreshold) {
int numAnchors = 8400;
int protoChannels = 32;
int maskH = 160, maskW = 160;
float[][] mask = new float[maskH][maskW];
List detections = new ArrayList<>();
int stride = 4 + 1 + protoChannels;
for (int i = 0; i < numAnchors; i++) {
float classProb = output[i * stride + 4];
if (classProb < confThreshold) continue;
float[] coeffs = new float[protoChannels];
for (int k = 0; k < protoChannels; k++) {
coeffs[k] = output[i * stride + 5 + k];
}
float cx = output[i * stride];
float cy = output[i * stride + 1];
float w = output[i * stride + 2];
float h = output[i * stride + 3];
for (int y = 0; y < maskH; y++) {
for (int x = 0; x < maskW; x++) {
float sum = 0;
for (int k = 0; k < protoChannels; k++) {
int protoIdx = k * maskH * maskW + y * maskW + x;
sum += coeffs[k] * protos[protoIdx];
}
float prob = (float) (1.0 / (1.0 + Math.exp(-sum)));
float px = x / (float) maskW * 640;
float py = y / (float) maskH * 640;
if (px >= (cx - w/2) && px <= (cx + w/2) &&
py >= (cy - h/2) && py <= (cy + h/2)) {
mask[y][x] = Math.max(mask[y][x], prob);
}
}
}
detections.add(new Detection(new android.graphics.RectF(cx - w/2, cy - h/2, cx + w/2, cy + h/2),
classProb, 0));
}
return new SegResult(mask, detections);
}
private List nms(List detections, float iouThreshold) {
Collections.sort(detections, (a, b) -> Float.compare(b.confidence, a.confidence));
List kept = new ArrayList<>();
boolean[] suppressed = new boolean[detections.size()];
for (int i = 0; i < detections.size(); i++) {
if (suppressed[i]) continue;
kept.add(detections.get(i));
for (int j = i + 1; j < detections.size(); j++) {
if (suppressed[j]) continue;
if (computeIoU(detections.get(i).box, detections.get(j).box) > iouThreshold) {
suppressed[j] = true;
}
}
}
return kept;
}
private float computeIoU(android.graphics.RectF a, android.graphics.RectF b) {
float intersectW = Math.max(0, Math.min(a.right, b.right) - Math.max(a.left, b.left));
float intersectH = Math.max(0, Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top));
float intersection = intersectW * intersectH;
float union = (a.width() * a.height()) + (b.width() * b.height()) - intersection;
return union > 0 ? intersection / union : 0;
}
}
技术细节:掩码重组(Mask Reassembly)
许多教程使用OpenCV的DNN模块,但它在Android上集成复杂且包体积较大。我们选择了纯Ja va实现。核心公式为 mask = sigmoid(sum(coeff[k] * proto[k])),即32通道的系数与32通道的原型图进行线性组合,再通过sigmoid激活,生成最终的概率掩码。
4.3 虚拟走廊决策引擎
有了检测框与分割掩码后,需要将其转化为用户可理解的简单指令。我们设计了一套“虚拟走廊”算法。
// DecisionEngine.ja va —— 核心导航决策引擎
package com.example.shadowwalk;
import android.graphics.RectF;
import ja va.util.List;
public class DecisionEngine {
public static class Decision {
public String instruction;
public int baseX;
public int corridorX1;
public int corridorX2;
public boolean blocked;
public Decision(String instruction) {
this.instruction = instruction;
}
}
public Decision makeDecision(float[][] mask, List obstacles) {
if (mask == null) {
return new Decision("正在初始化...");
}
int h = 160, w = 160;
// 第一步:路径锚定。分析画面底部20%区域,计算盲道重心。
float m00 = 0;
float m10 = 0;
int startRow = (int) (h * 0.8);
for (int i = startRow; i < h; i++) {
for (int j = 0; j < w; j++) {
if (mask[i][j] > 0.5f) {
m00 += 1;
m10 += j;
}
}
}
if (m00 < (w * (h * 0.2) * 0.01)) {
return new Decision("寻找盲道中...");
}
int baseX = (int) (m10 / m00);
// 第二步:虚拟走廊投射。以重心为中心,划定画面宽度40%的安全走廊。
int corridorWidth = (int) (w * 0.40);
int cX1 = Math.max(0, baseX - corridorWidth / 2);
int cX2 = Math.min(w, baseX + corridorWidth / 2);
// 第三步:冲突探测。检测障碍物是否侵入虚拟走廊。
boolean blocked = false;
for (PostProcessor.Detection obs : obstacles) {
float bx1 = obs.box.left * 160 / 640;
float bx2 = obs.box.right * 160 / 640;
float by2 = obs.box.bottom * 160 / 640;
if (by2 > (h * 0.3)) {
float overlapX1 = Math.max(cX1, bx1);
float overlapX2 = Math.min(cX2, bx2);
if (overlapX2 > overlapX1) {
float overlapWidth = overlapX2 - overlapX1;
if (overlapWidth > (corridorWidth * 0.3)) {
blocked = true;
break;
}
}
}
}
Decision res = new Decision("");
res.baseX = baseX;
res.corridorX1 = cX1;
res.corridorX2 = cX2;
res.blocked = blocked;
// 第四步:指令策略分发。
if (!blocked) {
int center = w / 2;
int offset = baseX - center;
if (offset < -w * 0.15) {
res.instruction = "向左对齐";
} else if (offset > w * 0.15) {
res.instruction = "向右对齐";
} else {
res.instruction = "直行";
}
} else {
float leftSum = 0;
float rightSum = 0;
for (int i = 0; i < h; i++) {
for (int j = 0; j < w; j++) {
if (mask[i][j] > 0.5f) {
if (j < baseX) leftSum++;
else rightSum++;
}
}
}
res.instruction = (leftSum > rightSum) ? "向左绕行" : "向右绕行";
}
return res;
}
}
该引擎的巧妙之处在于,它并非简单判断“前方有无障碍”,而是综合评估盲道位置、障碍物空间关系及两侧通行空间,从而给出更合理的绕行建议,并非一味让用户停下。
五、摄像头采集与推理流水线:保障流畅性
端侧推理最忌卡顿。我们采用了“CameraX + 单线程Executor + AtomicBoolean”的方案来确保流水线稳定。
// MainActivity.ja va —— 应用主入口(核心代码摘录)
package com.example.shadowwalk;
// ... 其他import省略 ...
/**
* 核心推理流水线:摄像头采集 -> Bitmap获取 -> 后台推理 -> UI更新 -> 触觉反馈
*/
public class MainActivity extends AppCompatActivity {
private static final String TAG = "ShadowWalk";
private static final long VIBRATE_COOLDOWN = 1500;
private PreviewView previewView;
private OverlayView overlayView;
private TextView tvInstruction;
private YoloModel yoloModel;
private PostProcessor postProcessor;
private DecisionEngine decisionEngine;
private HapticFeedbackManager hapticManager;
private Tra velAssistantManager tra velAssistantManager;
// ... 初始化代码 ...
private void startCamera() {
ListenableFuture cameraProviderFuture =
ProcessCameraProvider.getInstance(this);
cameraProviderFuture.addListener(() -> {
try {
ProcessCameraProvider cameraProvider = cameraProviderFuture.get();
Preview preview = new Preview.Builder().build();
preview.setSurfaceProvider(previewView.getSurfaceProvider());
ImageAnalysis imageAnalysis = new ImageAnalysis.Builder()
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.build();
imageAnalysis.setAnalyzer(cameraExecutor, this::analyzeImage);
cameraProvider.unbindAll();
cameraProvider.bindToLifecycle(this,
CameraSelector.DEFAULT_BACK_CAMERA, preview, imageAnalysis);
} catch (Exception e) {
Log.e(TAG, "摄像头绑定失败", e);
}
}, ContextCompat.getMainExecutor(this));
}
private void analyzeImage(@NonNull ImageProxy imageProxy) {
imageProxy.close();
if (isProcessing.get() || yoloModel == null) {
return;
}
runOnUiThread(() -> {
Bitmap bitmap = previewView.getBitmap();
if (bitmap == null) return;
isProcessing.set(true);
cameraExecutor.execute(() -> {
long startTime = System.currentTimeMillis();
try (YoloModel.InferenceResults rawResults = yoloModel.runInference(bitmap)) {
// === 解析障碍物检测结果 ===
ai.onnxruntime.OnnxTensor detTensor =
(ai.onnxruntime.OnnxTensor) rawResults.detection.get(0);
float[] detOutput = moveTensorToFloatArray(detTensor);
List obstacles =
postProcessor.parseDetection(detOutput, 10, 0.35f);
// === 解析盲道分割结果 ===
ai.onnxruntime.OnnxTensor segTensor =
(ai.onnxruntime.OnnxTensor) rawResults.segmentation.get(0);
float[] segOutput = moveTensorToFloatArray(segTensor);
ai.onnxruntime.OnnxTensor protoTensor =
(ai.onnxruntime.OnnxTensor) rawResults.segmentation.get(1);
float[] prototypes = moveTensorToFloatArray(protoTensor);
PostProcessor.SegResult segResult =
postProcessor.parseSegmentation(segOutput, prototypes, 0.15f);
// === 决策引擎生成导航指令 ===
DecisionEngine.Decision decision =
decisionEngine.makeDecision(segResult.mask, obstacles);
long duration = System.currentTimeMillis() - startTime;
runOnUiThread(() -> {
tvInstruction.setText(decision.instruction);
// 更新UI:指令、延迟、叠加层、震动、AI播报
overlayView.setResults(obstacles, segResult.detections, segResult.mask, decision.baseX);
// 触觉反馈和AI播报逻辑...
});
} catch (Throwable t) {
Log.e(TAG, "推理流执行失败", t);
} finally {
isProcessing.set(false);
}
});
});
}
}
此处有三项设计决策尤为关键:
- STRATEGY_KEEP_ONLY_LATEST:当推理速度跟不上摄像头帧率时,丢弃旧帧,防止帧堆积导致内存溢出。
- AtomicBoolean防重入:确保同一时间仅有一帧在进行推理,其余帧直接跳过,这是性能与实时性的最佳平衡点。
- try-with-resources管理推理结果:确保
InferenceResults的native内存被及时释放。
在骁龙870平台上,端到端延迟约为328-563ms,即每秒可处理2-3帧,对于盲人导航场景而言完全够用。
六、腾讯地图接入:让App感知位置
视觉提供近场感知,地图则提供远场与上下文感知。我们通过腾讯地图API获取用户的具体位置及附近兴趣点。
6.1 AndroidManifest.xml 权限声明
需在AndroidManifest.xml中声明摄像头、网络、位置(粗定位与精定位)及震动权限。
6.2 腾讯地图逆地理编码实现
核心逻辑位于 Tra velAssistantManager.ja va 中。每次推理后,在满足8秒冷却条件时,获取GPS坐标并调用腾讯地图的逆地理编码API,获取地址及附近200米内的POI。
// Tra velAssistantManager.ja va —— 腾讯地图API调用(核心片段)
// ...
private MapContext fetchMapContext(LocationSnapshot locationSnapshot,
StringBuilder logBuilder,
boolean allowFallbackCoordinate) {
if (TextUtils.isEmpty(BuildConfig.TENCENT_MAP_KEY)) {
return new MapContext(false, "", "", "未配置 TENCENT_MAP_KEY");
}
double latitude, longitude;
if (locationSnapshot != null) {
latitude = locationSnapshot.latitude;
longitude = locationSnapshot.longitude;
} else if (allowFallbackCoordinate) {
latitude = 22.543096;
longitude = 114.057865;
} else {
return new MapContext(false, "", "", "未获取到可用位置");
}
HttpURLConnection connection = null;
try {
String apiUrl = String.format(Locale.US,
"https://apis.map.qq.com/ws/geocoder/v1/" +
"?location=%.6f,%.6f&get_poi=1&poi_options=address_format=short;radius=200" +
"&key=%s",
latitude, longitude, BuildConfig.TENCENT_MAP_KEY);
URL url = new URL(apiUrl);
connection = (HttpURLConnection) url.openConnection();
connection.setRequestMethod("GET");
connection.setConnectTimeout(5000);
connection.setReadTimeout(5000);
connection.connect();
int responseCode = connection.getResponseCode();
String body = readBody(connection);
if (responseCode < 200 || responseCode >= 300) {
return new MapContext(false, "", "", "HTTP " + responseCode);
}
JSONObject json = new JSONObject(body);
int status = json.optInt("status", -1);
if (status != 0) {
return new MapContext(false, "", "", "业务错误: " + json.optString("message"));
}
JSONObject result = json.optJSONObject("result");
String address = result.optString("address");
JSONArray pois = result.optJSONArray("pois");
List poiTitles = new ArrayList<>();
if (pois != null) {
for (int i = 0; i < Math.min(3, pois.length()); i++) {
JSONObject poi = pois.optJSONObject(i);
poiTitles.add(poi.optString("title"));
}
}
return new MapContext(true, address, TextUtils.join("、", poiTitles), "解析成功");
} catch (Exception e) {
return new MapContext(false, "", "", e.getMessage());
} finally {
if (connection != null) connection.disconnect();
}
}
6.3 大模型调用:生成自然的语音播报
获得检测结果与位置信息后,下一步是让AI整合这些数据,生成自然、简短的语音播报。
// Tra velAssistantManager.ja va —— AI大模型调用(核心片段)
private AiReply requestAiBriefing(DecisionEngine.Decision decision,
String obstacleSummary,
MapContext mapContext,
StringBuilder logBuilder) {
// ... 配置检查省略 ...
try {
JSONObject payload = new JSONObject();
payload.put("model", BuildConfig.LANYUN_MODEL_ID);
payload.put("temperature", 0.3);
JSONArray messages = new JSONArray();
messages.put(new JSONObject().put("role", "system")
.put("content", "你是一名面向盲人用户的无障碍出行助手。"
+ "请结合视觉识别、当前位置和附近地标,"
+ "输出一句简短自然、适合语音播报的中文提示,"
+ "不要使用项目符号,不要超过45个字。"));
StringBuilder userPrompt = new StringBuilder();
userPrompt.append("当前导航建议:").append(decision.instruction).append("\n");
userPrompt.append("前方障碍概况:").append(TextUtils.isEmpty(obstacleSummary) ? "未识别到主要障碍" : obstacleSummary).append("\n");
if (mapContext != null && mapContext.success) {
userPrompt.append("当前位置:").append(mapContext.address).append("\n");
if (!TextUtils.isEmpty(mapContext.poiSummary)) {
userPrompt.append("附近地标:").append(mapContext.poiSummary).append("\n");
}
} else {
userPrompt.append("位置上下文: 暂不可用");
}
userPrompt.append("请给出一句适合耳机播报的提醒,优先说方向和风险。 ");
messages.put(new JSONObject().put("role", "user").put("content", userPrompt.toString()));
payload.put("messages", messages);
// 发送HTTP POST请求
URL url = new URL(BuildConfig.LANYUN_BASE_URL);
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
connection.setRequestMethod("POST");
connection.setConnectTimeout(6000);
connection.setReadTimeout(6000);
connection.setRequestProperty("Content-Type", "application/json");
connection.setRequestProperty("Authorization", "Bearer " + BuildConfig.LANYUN_API_KEY);
connection.setDoOutput(true);
// ... 读取响应并解析 ...
return new AiReply(!TextUtils.isEmpty(content), content.trim(), "AI响应正常");
} catch (Exception e) {
return new AiReply(false, "", e.getMessage());
}
}
举例来说,当用户在盲道上行走,前方检测到一辆汽车停在路边,而附近是一个公交站时,AI可能生成如“前方右侧有辆汽车,建议靠左慢行,您正走向公交站”的播报。这比生硬的“前方有障碍物:汽车”要人性化得多。
6.4 降级策略:应对断网场景
真实场景中断网是常态。我们设计了三段降级策略:
- 正常模式:YOLOv8 + 腾讯地图 + DeepSeek AI → 自然语言播报
- AI降级:YOLOv8 + 腾讯地图 → 本地模板播报(“当前建议直行,前方识别到汽车”)
- 完全离线:YOLOv8 → 纯本地避障(“直行”/“向左绕行”),触觉反馈可用
// Tra velAssistantManager.ja va —— 降级策略
private String buildFallbackBriefing(DecisionEngine.Decision decision,
String obstacleSummary,
MapContext mapContext) {
List parts = new ArrayList<>();
parts.add("当前建议" + decision.instruction);
if (!TextUtils.isEmpty(obstacleSummary) && preferences.isObstacleAlertEnabled()) {
parts.add("前方识别到" + obstacleSummary);
}
if (mapContext != null && mapContext.success && !TextUtils.isEmpty(mapContext.address)) {
parts.add("附近位置是" + mapContext.address);
}
return TextUtils.join(",", parts) + "。";
}
七、UI设计:为视障用户量身定制
7.1 设计理念
由于目标用户是视障人士,UI设计的核心理念需彻底转变:高对比度、大面积触控热区、信息精简、语音优先。
image.png
7.2 Android Studio布局编辑器
在Android Studio中双击 res/layout/activity_main.xml 即可看到可视化布局编辑器。
UI布局编辑器
7.3 模拟器运行效果
在手机模拟器上运行的效果如下。整体布局采用暖白背景与灰绿色调,追求舒适感与对比度的平衡。
模拟器运行效果
从上到下依次为:顶部状态栏、相机预览区(叠加YOLOv8检测结果)、识别结果卡片(导航指令、延迟、场景摘要)及底部麦克风区域。
八、触觉反馈:用震动传递信息
震动是视障用户接收信息的重要方式。我们设计了四种震动模式,分别对应“直行”、“向左对齐”、“向右绕行”及“路径受阻”等指令。技术实现上,使用了USAGE_ASSISTANCE_ACCESSIBILITY AudioAttributes,确保在静音模式下震动依然有效。
九、服务诊断功能:一键排查问题
开发中最令人头疼的往往是排查“为什么连不上”。我们在设置页面加入了一键诊断功能,可按顺序测试腾讯地图、腾讯云MCP及AI大模型三个核心服务的连通性。诊断结果以弹窗形式展示,并附带完整的时间线日志。
// Tra velAssistantManager.ja va —— 服务诊断(核心片段)
public void runDiagnostics(Activity activity, DiagnosticsCallback callback) {
assistantExecutor.execute(() -> {
StringBuilder logBuilder = new StringBuilder();
logStep(logBuilder, "开始执行服务诊断");
ServiceStatus mapStatus = probeMapService(activity, logBuilder);
ServiceStatus mcpStatus = probeMcpService(logBuilder);
ServiceStatus aiStatus = probeAiService(logBuilder);
ServiceDiagnosticsReport report = new ServiceDiagnosticsReport(mapStatus, mcpStatus, aiStatus, logBuilder.toString().trim());
mainHandler.post(() -> callback.onDiagnosticsReady(report));
});
}
private ServiceStatus probeMcpService(StringBuilder logBuilder) {
HttpURLConnection connection = null;
try {
URL url = new URL(BuildConfig.TENCENT_MCP_URL);
connection = (HttpURLConnection) url.openConnection();
connection.setRequestMethod("GET");
connection.setRequestProperty("Accept", "text/event-stream");
connection.setConnectTimeout(5000);
connection.setReadTimeout(2000);
connection.connect();
int responseCode = connection.getResponseCode();
String contentType = connection.getContentType();
boolean success = responseCode >= 200 && responseCode < 300
&& contentType != null
&& contentType.toLowerCase(Locale.ROOT).contains("text/event-stream");
return new ServiceStatus("腾讯云 MCP", success,
success ? "SSE握手成功" : "HTTP " + responseCode);
} catch (Exception e) {
return new ServiceStatus("腾讯云 MCP", false, e.getMessage());
} finally {
if (connection != null) connection.disconnect();
}
}
image.png
十、完整数据流全景
最后,我们用一张流程图串联整个系统的数据流,以便形成全局认知:
[CameraX 后置摄像头采集]
|
v
[Bitmap 原始帧]
|
v
[YoloModel.runInference()]
├── 预处理:640x640缩放 + NCHW归一化
├── 输入张量:[1, 3, 640, 640]
| ──→ [obstacles_det.onnx] 障碍物检测
| 输出:[1, 14, 8400]
| |
| v
| [PostProcessor.parseDetection()]
| 遍历8400个anchor → NMS去重
| 输出:10类障碍物的边界框列表
|
| ──→ [blind_path_seg.onnx] 盲道分割
| 输出:[1, 37, 8400] + [1, 32, 160, 160]
| |
| v
| [PostProcessor.parseSegmentation()]
| 32通道线性组合 + sigmoid
| 输出:160x160的盲道概率掩码
|
v
[DecisionEngine.makeDecision()]
├── 路径锚定:底部20%盲道重心
├── 虚拟走廊:40%宽度安全区域
├── 冲突探测:障碍物是否侵入走廊
└── 指令分发:直行/向左对齐/向右绕行
|
──→ [UI更新] 指令文字 + 推理延迟
──→ [OverlayView] 掩码 + 检测框渲染
──→ [触觉反馈] 对应震动模式
|
v
[Tra velAssistantManager]
├── GPS定位 → 腾讯地图逆地理编码
│ 输出:地址 + 附近200米POI
├── 组装Prompt(视觉结果 + 位置信息)
├── DeepSeek-V3.2 生成自然语言播报
└── TTS语音播报 → 蓝牙耳机输出
十一、写在最后
这个项目从立项到跑通,大约耗费一个月时间。期间踩过的坑,如今看来也成了技术积累的一部分。
- ONNX Runtime在Android上加载模型时,曾因
dynamic=True导出与固定shape推理不兼容而报错。 - 腾讯地图API因额度未分配,导致调试了大半天“假错误”。
- CameraX帧率远快于推理速度,未设置
STRATEGY_KEEP_ONLY_LATEST策略时,内存会瞬间溢出。 - 大模型的
temperature参数调校了多次,过高则输出不稳定,过低则像复读机。
但最终看到手机屏幕上实时渲染出检测框与掩码,听到耳机里传出“前方右侧有汽车,注意避让”的语音时,成就感无可替代。
对于有兴趣复现或深入研究此项目的朋友,有几点建议:
- 从PC端开始:先在Python环境下用
debug/test_det.py和debug/test_seg.py验证模型效果。 - 做好一致性检查:使用
verify_onnx.py脚本,确认ONNX导出的精度未损失。 - 密钥管理不能偷懒:坚持使用
local.properties + BuildConfig的方案。
技术的价值,在于解决实际问题。1700万视障群体的出行困境,不是一篇论文或一个Demo就能解决的,但至少,我们可以从这里开始。
来源:互联网
本网站新闻资讯均来自公开渠道,力求准确但不保证绝对无误,内容观点仅代表作者本人,与本站无关。若涉及侵权,请联系我们处理。本站保留对声明的修改权,最终解释权归本站所有。