简介:面向移动端开发者的实战指南,基于YOLOv11在iOS、Android双平台实现实时AR物体识别,系统解决模型轻量化、格式转换、推理框架选型等部署难题,覆盖从环境搭建、模型适配到性能优化的完整链路。
资源包为单个PDF文档,共2.3MB、52页,支持目录章节跳转与左侧大纲快速定位,图文呈现完整,已有286人学习。
文档核心价值在于完整展示YOLOv11算法原理到工程落地的全流程,涵盖单阶段检测机制、Core ML与ARKit、TensorFlow Lite与ARCore集成细节、剪枝量化蒸馏优化、模型转ONNX与TensorRT加速,以及帧率、内存、推理时间等性能监测调试方法,并给出App Store与Google Play的部署发布流程,以教育、零售、旅游、游戏四类AR案例与经验总结收尾,对目标检测移动端落地极具参考价值。
1. 移动端跑 YOLOv11:不是把模型塞进手机就完事
掏手机对着办公桌扫一圈,屏幕上实时框出显示器、水杯、键盘,还叠着 3D 标签——这个效果看起来不复杂,但我拆过好几份所谓“移动端 YOLOv11 部署指南”后,发现真正能落地的不多。模型能跑通和能用是两码事:跑通是模型输出几个框,能用在手机上还得满足帧率、发热、AR 锚点贴合这三大约束。YOLOv11 作为 Ultralytics 系的最新目标检测模型,在精度和速度上比前代有实打实的提升,但移动端集成是个完整链路——模型导出、格式转换、平台 SDK 对接、AR 场景标定,每一步都有坑。这份指南面向的是想在 iOS、Android 上做实时 AR 物体识别的从业者,不管是做扫码类工具、智能家居控制还是工业巡检辅助,核心问题是一样的:怎么把 YOLOv11 的检测结果稳定地叠进摄像头画面里。
2. 模型出口:从 YOLOv11 权重到 TFLite 与 Core ML 的转换链路
2.1 先选推理格式:TFLite、Core ML 和 NCNN 各自卡在哪儿
移动端跑 YOLOv11,绕不开格式选型。Android 阵营的常见选择是 TFLite,Google 官方生态,CameraX、ML Kit 都能直接配合;iOS 阵营没得选,Core ML 是系统级框架,配合 Vision 框架做检测前置处理最顺。但实际项目里,我见过不少团队在 Android 上用 NCNN,理由是某些旧设备上 TFLite 的 GPU delegate 兼容性差,NCNN 的 Vulkan 加速反而稳定。这取决于目标用户机型,如果覆盖千元机,NCNN 值得一试;如果只要求近两年的中高端机型,TFLite 省事得多。
另外,YOLOv11 的官方仓库已经内置了导出脚本,常见做法是先导出 TorchScript 再转平台格式,或者直接用yolo export一步到位。但有一点要注意:YOLOv11 有n、s、m、l、x几个尺寸变体,移动端首推yolo11n,实测在骁龙 8 Gen 2 上 TFLite 的 FP16 版本能跑到 35~45ms 一帧的延迟,yolo11s就会掉到 60ms 以上,AR 场景已经感觉到卡顿。所以选型逻辑是:能上n就不上s,精度不够再加输入分辨率,而不是换大模型。
2.2 导出命令与关键参数:IMGSZ、量化、批次的取舍
以最常用的导出路径为例,先把 PyTorch 权重转成 TFLite。命令如下:
yolo export model=yolo11n.pt format=tflite imgsz=640 int8 half=False转出来的yolo11n_saved_model文件夹里会包含model.tflite。这里imgsz=640是输入尺寸,TFLite 固定输入 shape,运行时不能改。int8开启 PTQ 量化,但注意:它会把模型打成全整型,部分旧设备上 TFLite 的 CPU 支持没问题,GPU delegate 反而不支持 INT8,这点后面在避坑章节再展开。
如果是 iOS,导出 Core ML:
yolo export model=yolo11n.pt format=coreml imgsz=640 nms=Falsenms=False是我故意加的。YOLOv11 的 Core ML 导出支持内置 NMS,但内置 NMS 的参数不透明,调试起来很痛苦。我一般习惯把原始 tensor 输出拿到外部自己写 NMS,虽然多写几十行代码,但置信度阈值和 IoU 阈值都能直接调。导出时如果不加nms=False,iOS 端拿到的结果是VNRecognizedObjectObservation,看着方便,但置信度阈值在模型内部已经写死,实际项目里容易被小目标漏检坑到。
参数对照表如下:
| 参数 | TFLite 场景 | Core ML 场景 | 说明 |
|---|---|---|---|
imgsz | 固定 640 或 416 | 固定 640 或 416 | 越小越快,小目标越容易丢 |
int8 | 真机 CPU 可用 | 不推荐 | INT8 + GPU delegate 兼容性差 |
half | FP16 可开 | 建议开 | 内存减半,精度掉得最少 |
nms | 不涉及 | 建议 False | 外部做 NMS 便于调参 |
batch | 固定 1 | 固定 1 | 移动端没有 batch 概念 |
2.3 输出 tensor 的布局解析:8400 个候选框怎么读
YOLOv11 在 640×640 输入下,单尺度推理输出是84 × 8400还是1 × 84 × 8400,取决于导出通道顺序。TFLite 和 Core ML 对这点的处理不完全一样。TFLite 端常见输出 shape 是[1, 84, 8400],Core ML 端有人导出来是[1, 8400, 84]。我自己的习惯是拿到模型后先用测试图片跑一次 dump 出 shape,别靠猜。84 = 4(框坐标)+ 80(COCO 类别数),如果是自定义数据集换成 4 + 类别数。8400 是三个尺度 head 的候选框总和,YOLOv11 在这个尺寸下是 8400 个 anchor-free 候选框。
解码时需要注意 YOLOv11 的坐标格式,它的输出中心点坐标是基于输入尺寸归一化的,但不同导出方式对是否还原到原图坐标的处理不同。我踩过的一次坑是 TFLite 输出拿到手后,xy 坐标直接用了归一化值往 AR 坐标上映射,结果标签全偏到屏幕右下角。正确的做法是先乘回 640,再按相机帧和输入尺寸的缩放比做一次换算,后面 iOS 和 Android 代码里都会体现这一步。
3. iOS 侧集成:Core ML + Vision + ARKit 的实线接法
3.1 初始化模型与请求:VNCoreMLRequest 的正确姿势
iOS 端不直接拿 Core ML 模型做前处理,标准做法是包一层 Vision。Vision 帮你处理了缩放、颜色格式、裁剪策略,省掉不少脏活。关键代码这样写:
import CoreML import Vision import ARKit private var visionModel: VNCoreMLModel = { let config = MLModelConfiguration() config.computeUnits = .cpuAndGPU guard let model = try? YOLOv11n(configuration: config).model, let vnModel = try? VNCoreMLModel(for: model) else { fatalError("模型加载失败") } return vnModel }() private func detect(in pixelBuffer: CVPixelBuffer) { let request = VNCoreMLRequest(model: visionModel) { req, error in guard let results = req.results as? [VNRecognizedObjectObservation] else { return } DispatchQueue.main.async { self.updateARAnchors(with: results) } } request.imageCropAndScaleOption = .scaleFill request.confidenceThreshold = 0.35 let handler = VNImageRequestHandler(pixelBuffer: pixelBuffer, orientation: .right) try? handler.perform([request]) }computeUnits设为.cpuAndGPU,是 Core ML 在移动端最稳的组合,纯.all在部分 A12 芯片上触发神经引擎反而慢。scaleFill会把输入拉伸到 640×640,如果不想图片变形,可以用scaleFit但会留黑边,检测框坐标要额外校正。这里有个细节:置信度阈值 0.35 是检测的初始门槛,AR 场景里阈值太低会出现标签乱跳,我会在后面的锚点管理那层再设一道 0.5 的显示门槛,两层过滤。
3.2 ARKit 叠加:把 Vision 的 2D 框映射到 3D 场景
Vision 的输出坐标归一化到原图,原图来自 ARKit 的ARFrame.capturedImage,这个 pixel buffer 的尺寸是传感器原生分辨率,和屏幕显示分辨率不是一回事。映射时需要做一次比例换算:
extension ARSCNView { func convertVisionRect(_ rect: CGRect, to view: UIView) -> CGRect { let widthRatio = view.bounds.width / UIScreen.main.bounds.width let heightRatio = view.bounds.height / UIScreen.main.bounds.height // 参数:rect 是 Vision 归一化坐标,原点在左下 let x = rect.minX * widthRatio let y = (1 - rect.maxY) * heightRatio let w = rect.width * widthRatio let h = rect.height * heightRatio return CGRect(x: x, y: y, width: w, height: h) } }拿到 2D 框后,AR 标签不能直接贴在屏幕上,否则就不是 AR。常见做法是拿框中心点做射线检测,和 ARKit 的场景几何求交,把标签锚定在物理表面上:
let centerPoint = CGPoint(x: rect.midX, y: rect.midY) let hitResults = arSCNView.hitTest(centerPoint, types: [.existingPlaneUsingExtent, .featurePoint]) if let hit = hitResults.first { let anchorNode = SCNNode() anchorNode.position = SCNVector3(hit.worldTransform.columns.3.x, hit.worldTransform.columns.3.y, hit.worldTransform.columns.3.z) let textNode = createLabelNode(text: labelString) anchorNode.addChildNode(textNode) arSCNView.scene.rootNode.addChildNode(anchorNode) }射线检测的.featurePoint类型没法保证稳定性,所以标签会抖。倾向用.existingPlaneUsingExtent,但前提是场景里先有平面检测结果。我一般会在ARSessionDelegate里持续追踪平面锚点,而不是每次检测都现去 hitTest,这样标签位置能稳定不少。另外,AR 标签文字要用SCNText而不是SKLabelNode,前者是 3D 节点,能跟随场景光照,不会有飘在屏幕上的违和感。
3.3 帧率控制:不要每帧都跑检测
AR 会话默认 60fps 回调,但 YOLOv11n 在 iPhone 12 上单帧推理就要 30ms 左右,每帧都做检测会吃满 CPU,掉电飞快。我的做法是设置节流:lastDetectTime距离现在超过 100ms 才触发一次检测,同时把 AR 标签平滑更新和检测解耦。这样检测 10fps、AR 渲染 60fps,视觉上标签跟随很顺,又没有性能压力。
4. Android 侧集成:TFLite + CameraX + ARCore 的完整接线
4.1 CameraX 取帧与 ImageAnalysis 的背压处理
Android 端用 CameraX 比 Camera2 省心太多,ImageAnalysis自带背压模式,能自动丢帧防止积压。关键配置:
val analysisConfig = ImageAnalysis.Builder() .setTargetResolution(Size(640, 640)) .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) .build() analysisConfig.setAnalyzer(executor) { imageProxy -> val bitmap = imageProxy.toBitmap().copy(Bitmap.Config.ARGB_8888, false) val result = detect(bitmap) runOnUiThread { arFragment.setDetectedObjects(result) } imageProxy.close() }setTargetResolution(Size(640, 640))不是直接裁剪到 640,而是告诉 CameraX 优先输出接近这个分辨率的一帧,实际拿到的是 640×480 或 960×720 之类的尺寸,需要再做一次缩放。STRATEGY_KEEP_ONLY_LATEST是关键:如果检测线程处理不过来,CameraX 会主动丢弃旧帧,保证延时最低。imageProxy.close()一定不能漏,漏一次就少一个 buffer,跑几分钟后流就卡死了,这个我踩过。
4.2 TFLite Interpreter 的输入输出拼装与解码
YOLOv11 导出的 TFLite 模型输入是[1, 640, 640, 3]的 float 数组,需要把 Bitmap 缩放到 640×640、归一化到 0~1、再 RGB 排布:
private fun bitmapToInput(bitmap: Bitmap): FloatArray { val scaled = Bitmap.createScaledBitmap(bitmap, 640, 640, true) val input = FloatArray(640 * 640 * 3) val pixels = IntArray(640 * 640) scaled.getPixels(pixels, 0, 640, 0, 0, 640, 640) for (i in pixels.indices) { val r = (pixels[i] shr 16) and 0xFF val g = (pixels[i] shr 8) and 0xFF val b = pixels[i] and 0xFF input[i * 3] = r / 255.0f input[i * 3 + 1] = g / 255.0f input[i * 3 + 2] = b / 255.0f } return input }推理调用:
val interpreter = Interpreter(loadModelFile()) // 加载 assets 里的 .tflite val inputArray = arrayOf(bitmapToInput(bitmap)) val outputShape = intArrayOf(1, 84, 8400) val output = Array(1) { Array(84) { FloatArray(8400) } } interpreter.run(inputArray, output)把run放进单独线程池,Interpreter本身是线程安全的,但 GPU delegate 的部分实现不是,所以我的习惯是一个线程池串行跑推理。解码 NMS 的部分用标准实现,取output[0],前 4 行是 cx, cy, w, h,后面 80 行是类别分数。
4.3 ARCore 锚点与标签放置:平面检测优先于特征点
Android 端的 AR 叠加用的是 ARCore 的AnchorNode。检测到物体的 2D 框中心之后,用Frame.hitTest做射线求交:
val hits = frame.hitTest(centerX, centerY) if (hits.isNotEmpty()) { val hit = hits.first() // 优先平面命中 val anchor = frame.createAnchor(hit.hitPose) val anchorNode = AnchorNode(anchor) val label = createLabelNode(text) anchorNode.addChild(label) arSceneView.scene.addChild(anchorNode) }ARCore 的hitTest返回结果按深度排序,优先取HitTestResult.DepthPriority最高的那个,但要注意:如果场景没建立平面,命中结果可能落在特征点上,标签会漂。我的方案是开启Config.PlaneFindingMode.HORIZONTAL_AND_VERTICAL,并且在平面未稳定前不创建锚点,只显示 2D 检测框,这样用户体验不割裂。
5. 跨平台排查实录:量化、NMS、尺寸和线程的五个坑
5.1 现象:iOS 端检测框的位置整体偏移到右上角
原因:Vision 的坐标原点在左下,CVPixelBuffer的原始朝向和屏幕不一致,直接用归一化坐标映射到ARSCNView时没有做 Y 轴翻转和方向校正。解决:统一走VNImageRequestHandler的orientation参数,传.right或根据设备方向动态计算;拿到框后先做坐标翻转,再进convertVisionRect。
5.2 现象:Android 上 TFLite 推理 300ms 一帧,完全达不到实时
原因:默认走了 CPU 的 FP32 推理,YOLOv11n 在 640×640 输入下 CPU 就是这个量级,CPU 单线程跑大模型不现实。解决:加载模型时指定 GPU delegate:
val gpuDelegate = GpuDelegate() val options = Interpreter.Options().addDelegate(gpuDelegate) interpreter = Interpreter(loadModelFile(), options)但 GPU delegate 对算子有兼容限制,遇到不支持的算子会静默 fallback 回 CPU,所以性能还是上不去时,要加日志确认 delegate 真的生效了。
5.3 现象:INT8 量化后小物体全部丢失,大物体框也偏了
原因:YOLOv11 的检测头对量化误差很敏感,PTQ 全量化直接把输出分布压垮了。解决:改用 FP16 量化,iPhone 上精度几乎无损;Android 上可以试 per-channel INT8,但多数情况 FP16 精度已经够。真要 INT8 就得做 QAT,用 Ultralytics 的hDetect蒸馏分支,这个改动量大,非必要不碰。
5.4 现象:AR 标签在桌面上飘,像沾了胶水没贴稳
原因:直接用 2D 框中心点做射线检测,特征点命中导致锚点在空间里前后跳。解决:限定只和已追踪平面求交;另外加一个平滑滤波器,锚点位置用上一次的加权平均:
// 一阶低通滤波,alpha 越小越平滑但延迟越大 anchorScale = 0.25f smoothedPos = smoothedPos * (1 - anchorScale) + rawPos * anchorScale5.5 现象:App 切后台再回来,模型推理报错或 AR 黑屏
原因:GPU delegate 和 ARCore session 在后台被系统回收,回来没有重建。解决:在onPause里释放Interpreter,onResume时重新加载;ARKit 的ARSession用run带resetTracking重置,不要复用旧 session。Core ML 模型的MLModelConfiguration也要重新实例化。
6. 帧率调优的最后一公里:预热、双缓冲与低延迟 Trick
新模型第一次推理总是奇慢,因为 GPU 驱动要编译内核、缓存池要分配。实测 iPhone 上首次推理 300ms,第二次就回落到 35ms。所以 App 启动后要跑一次空帧预热:摄像头权限还没拿到时,就用纯黑色图片喂一帧,让 Core ML 和 GPU delegate 把预热走完。Android 上同理,TFLite 的interpreter.run之前先跑一次相同 shape 的输入,能省掉不少黑匣子时间。
双缓冲是另一个必做项。推理线程和渲染线程不能抢同一个 buffer:CameraX 的ImageAnalysis拿到帧后立刻拷贝出 Bitmap,把原始ImageProxy交还系统;推理完成后把结果投递到 AR 渲染线程的队列,渲染线程只管消费队列里最新的结果,不关心推理中间态。这样即使某一帧推理超时,渲染线程也不会等。检测结果连续两帧相同的情况下,第三帧自动跳过推理,直接用上一帧结果做插值,这是我调 AR 标签稳定性时发现的规律:语义上物体没变,检测就没必要重跑。
锚点平滑再细一层的话,要把运动趋势考虑进去。一阶低通滤波在物体快速移动时会拖尾,我的做法是加一个速度项:
// velocity-aided 平滑 val alpha = 0.18f val predicted = smoothedPos + velocity * 0.05f smoothedPos = predicted * (1 - alpha) + rawPos * alpha velocity = smoothedPos - predicted这套组合下来,iPhone 12 上 YOLOv11n 的端到端延迟能稳定在 45ms 左右,Android 骁龙 8 Gen 2 稍低一点,视觉上标签基本贴在物体上。
最后说一个工程习惯:每次改完模型或导出参数,强制在真机上跑一遍 30 分钟温升测试,记录帧率和发热曲线,不要只在模拟器上验证。我从一次真机发热降频导致 AR 卡成 PPT 的翻车里长记性之后,就再也没跳过这个环节。这这份 PDF 指南能省下不少自己摸索的时间,希望帮到你。
本文还有配套的精品资源,点击获取