做识别类AR项目的时候,我遇到过一个很现实的需求:用户现场拍一张照片,马上就能把它当成识别图,让AR内容跟着这张图定位。一开始我按常规思路,把所有参考图都做成静态的XRReferenceImageLibrary,结果一改图就要重新出包,场景一多简直噩梦。后来才彻底搞明白,Unity的 AR Tracked Image 这套体系里,真正扛得住“动态添加相片到图像库”需求的,是运行时的MutableRuntimeReferenceImageLibrary。
这篇文章就围绕 Unity 2D 图像识别 + AR Tracked Image 展开,把我从静态库转型到动态库的完整过程、核心原理、代码链路和踩坑经历都记录下来。适合正在做 AR 识别、需要让用户在运行时自定义识别图的朋友参考。
1. 静态库的硬伤,以及“用户现场拍一张就能识别”的真实场景
1.1 静态 ReferenceImageLibrary 的工作方式
先说静态库。Unity AR Foundation 里的XRReferenceImageLibrary,是在编辑器里通过ARTrackedImageManager组件创建的图像库资源。你把图片拖进去,给每张图设置物理宽度,生成一个 Asset 文件,打包时这个库会跟着应用一起进包。运行时这个库是只读的,你不能在手机上往里面追加图片。
这套流程在固定识别图的场景里没什么问题。比如做一张桌子上的品牌卡片识别、展厅里的固定画作识别,所有图片在开发阶段就已经确定,静态库完全够用。
问题是,它太“静态”了。改一张识别图,你就要重新打开工程、替换资源、重新出包。如果这是一个给运营后台配置内容的应用,那运营每更新一次商品图,技术团队就得跟着构建一次安装包,这种节奏放到真实业务里完全跑不动。
1.2 动态添加真正解决的落地场景
后来我在实际项目里总结了一下,真正值得做“动态添加相片到图像库”的场景基本有这几类:
- 用户自定义识别:用户现场拍一张自己的物品、照片、手绘内容,马上作为触发识别的图,比如儿童教育卡片、互动营销物料。
- 运营后台配置:运营在后台更新一批识别图,App 启动时从服务器拉取并动态加入图像库,不需要发版。
- 巡检或盘点类业务:工作人员到现场给设备/货架拍照建档,之后通过 AR 识别这张照片找到对应信息。
- 临时会务物料:展会、活动现场临时增加的展示图片或二维码区域,直接现场拍照录入。
这些场景都有一个共同点:识别图的内容在出包之后才会出现。静态库做不到,能做的就是运行时动态添加图像到识别库。AR Foundation 从某个版本开始引入了MutableRuntimeReferenceImageLibrary,它的出现基本就是为了解决这个问题。
我最初以为这个 API 就是把图片塞进数组那么简单,实际用起来才发现里面涉及纹理格式、异步 Job、平台差异、特征质量一堆细节。后面几章我会把这条路完整走一遍。
2. 追踪系统到底在匹配什么:特征、尺寸和识别阈值
2.1 一次追踪会话里发生了什么
先别急着写代码,得先理解 AR Tracked Image 的底层工作逻辑。配合摄像头实现的 2D 图像识别,并不是把你给的整张图片当作模板去做像素比对,而是“提取特征点 -> 计算描述子 -> 与库中参考图逐一匹配”的流程。
系统从摄像头每帧画面里找到稳定的角点、边沿、纹理区域,把这些特征编码成描述子。然后在运行时图像库中查找哪张参考图的特征分布和当前画面最接近。如果匹配分数超过系统设定的阈值,就认为识别成功,并同时给出图片在三维空间中的位置和姿态。这个姿态直接决定了 AR 内容叠加的锚点。
这里有个很关键的点:系统识别的是“特征结构”,不是“图像内容”。一张纯红色的色卡,你用肉眼很轻松能看出“这是红色”,但对特征提取算法来说,它没有角点、没有边沿、没有纹理梯度,特征提取出来几乎为零,识别自然失败。
2.2 物理尺寸:它到底影响了什么
在XRReferenceImage里有一个非常容易忽略的字段——物理尺寸。这个尺寸的单位是米,它表示的是“这张参考图在真实世界中的宽度/高度是多少”。别小看这个值,它直接影响两个结果:
第一,如果尺寸设置合理,系统计算出位姿后,AR 内容可以按真实比例叠加在识别图上。比如识别一张 A4 纸大小的图纸,你设置宽度为 0.21m,那么一个和图纸真实大小等比例的模型也能精准贴合。
第二,如果尺寸设置错误,比如把 0.3m 写成了 3m,即使识别能成功,AR 内容的缩放、旋转、距离信息也会全部错乱,看起来就像模型悬在半空、大小失控。
所以动态添加图像时,如果你不知道这张照片对应的真实物理尺寸,可以给一个默认值,但一定要让用户输入或者根据业务场景约定一个统一值。我见过太多项目,识别一直不稳定,最后发现是尺寸传错了。
2.3 质量阈值和库容量的关系
还有一个容易踩的隐性坑:识别图像库里的图越多,系统做匹配的负担越大。平台底层为了控制性能,一般不会对每一帧画面都做全量匹配,而是有匹配周期、特征抽稀、质量优先等策略。
这带来一个现象:库里面图片风格越相近、纹理越相似,互相误识别和匹配不稳定的概率就越高。比如你把两张都是白底黑字的发票都放进库里,系统匹配时很可能来回震荡。
在动态添加场景下,千万不要往库里无脑塞图。要能识别,图像必须满足“特征足够丰富”这个前提。
3. MutableRuntimeReferenceImageLibrary:动态改库的底层机制
3.1 为什么需要 Mutable 版本
先说概念。MutableRuntimeReferenceImageLibrary是运行时图像库的可变版本。它的核心价值在于:你已经有一个运行中的ARTrackedImageManager,可以通过它动态创建一个可继续“加图”的库,然后把这个新库替换给管理器继续使用。
它和静态库的本质区别在于生命周期和可变性。静态库是打包前写死的资源,运行时只读;可变运行库是运行时在内存中构造出来的,允许你通过 Job 系统异步往里面添加新的参考图像。
我当时的第一个疑问是:为什么不直接往XRReferenceImageLibrary里 Add?因为这个类在运行时根本不提供公开的 Add 方法,设计上就是一个序列化容器。AR Foundation 专门把“可变的运行库”作为单独抽象,逻辑上就清晰了:静态库描述开发期内容,运行库描述运行时内容。
3.2 从哪来,又到哪里去
利用ARTrackedImageManager创建运行库有两条路:
- 从一个空库开始:
CreateRuntimeLibrary(),适合一开始完全不管静态库内容的场景。 - 从一个现有静态库初始化:
CreateRuntimeLibrary(staticLibrary),复制一份静态库内容作为起点,再在此基础上动态追加。
我个人更推荐第二种方式。因为就算用户要自定义图像,你最好还是保留几张基础识别图(比如 App 的启动引导图、固定展板),保证用户还没添加照片时,基础识别能力依然可用。
创建出来的运行库,本质上就是放进ARTrackedImageManager.referenceLibrary的“新血液”。赋值之后,管理器后续就拿着这个运行库去做识别匹配。
3.3 AtomicSafetyHandle:一个很多人看不懂的参数
这里必须单独提到AtomicSafetyHandle。我第一次看到ScheduleAddImageWithValidationJob(referenceImage, safetyHandle)这个 API 时,完全不知道第二个参数从哪里来。
简单理解,Unity 的 Job System 在操作原生内存时需要确认“这块内存现在没有别人在用”,这就是AtomicSafetyHandle存在的意义。它和纹理、NativeArray 的生命周期安全性绑定在一起,防止你在异步添加图片的过程中,纹理已经被回收或者正在被 GPU 读取导致崩溃。
你不需要自己创建它,直接从ARTrackedImageManager.subsystem上取就行。代码里我一般这样取:
var safetyHandle = trackedImageManager.subsystem.imageLibrarySafetyHandle;需要提醒的是,不同 AR 插件版本里,这个属性的名字有可能略有不同(见过叫runtimeLibrarySafetyHandle的)。如果编译报找不到属性,去包管理器里看当前版本 SDK 对应的接口定义即可。
4. 从拍照到入库的完整链路:纹理处理、异步Job与库切换
4.1 整体流程梳理
先把我实际使用的完整链路画一下:
- 获取用户图片的 Texture2D:
- 调用系统相册选取照片并加载为 Texture2D
- 或者调用摄像头拍照后转成 Texture2D
- 也可以用 WebCamTexture 实时捕捉画面帧
- 对 Texture2D 做预处理:
- 转为 RGBA32 格式
- 可选缩放,控制图片尺寸
- 可选增强对比度,提升特征点质量
- 创建
XRReferenceImage,设置纹理、物理尺寸、名字 - 创建或获取
MutableRuntimeReferenceImageLibrary - 调用
ScheduleAddImageWithValidationJob异步添加 - 等待 Job 完成
- 设置给
ARTrackedImageManager.referenceLibrary - 监听
trackedImagesChanged事件处理识别结果
这个顺序每一步都有讲究。很多人上来直接跳过预处理,一张几千万像素的照片原样塞进去,结果要么添加慢,要么纹理格式不兼容直接异常。
4.2 从相册取图并转成有效纹理
从系统相册取照片,我习惯用成熟插件封装好的接口返回Texture2D,省去自己写原生桥接。拿到之后第一件事不是直接入库,而是确认纹理格式。相册底层返回的纹理经常是各种压缩格式或 YUV 类格式,而XRReferenceImage对格式有明确要求,最稳妥的就是转成 RGBA32。
using System.IO; using UnityEngine; public static class TextureHelper { /// <summary> /// 将任意 Texture2D 转为 RGBA32,避免格式不兼容 /// </summary> public static Texture2D ToRGBA32(Texture2D source) { var rt = RenderTexture.GetTemporary(source.width, source.height, 0, RenderTextureFormat.ARGB32); Graphics.Blit(source, rt); var previous = RenderTexture.active; RenderTexture.active = rt; var result = new Texture2D(source.width, source.height, TextureFormat.RGBA32, false); result.ReadPixels(new Rect(0, 0, source.width, source.height), 0, 0); result.Apply(); RenderTexture.active = previous; RenderTexture.ReleaseTemporary(rt); return result; } /// <summary> /// 缩放纹理,避免超大图入库导致性能问题 /// </summary> public static Texture2D ScaleTexture(Texture2D source, int targetWidth, int targetHeight) { var rt = RenderTexture.GetTemporary(targetWidth, targetHeight, 0); Graphics.Blit(source, rt); var previous = RenderTexture.active; RenderTexture.active = rt; var result = new Texture2D(targetWidth, targetHeight, TextureFormat.RGBA32, false); result.ReadPixels(new Rect(0, 0, targetWidth, targetHeight), 0, 0); result.Apply(); RenderTexture.active = previous; RenderTexture.ReleaseTemporary(rt); return result; } }我为什么在入库前做缩放?因为识别系统并不需要原图那么高的分辨率。太高的分辨率只会增加 Job 耗时和内存占用,特征提取时间也会变长。实际测试里,长边控制在 512 到 1024 像素左右,识别效果和性能比较平衡。如果原图是 4000 万像素,直接丢进去,添加耗时能明显感觉到卡顿。
4.3 动态添加图像的核心代码
下面这段是核心逻辑。我在模拟项目 X 里实际用过的版本,注释写得比较详细:
using UnityEngine; using UnityEngine.XR.ARFoundation; using UnityEngine.XR.ARSubsystems; using Unity.Collections; public class RuntimeImageLibraryController : MonoBehaviour { [SerializeField] private ARTrackedImageManager trackedImageManager; [SerializeField] private XRReferenceImageLibrary fallbackStaticLibrary; private void OnEnable() { if (trackedImageManager != null) { trackedImageManager.trackedImagesChanged += OnTrackedImagesChanged; } } private void OnDisable() { if (trackedImageManager != null) { trackedImageManager.trackedImagesChanged -= OnTrackedImagesChanged; } } /// <summary> /// 将一张 Texture2D 动态添加到运行时图像库 /// </summary> public bool AddTextureToRuntimeLibrary(Texture2D rawTexture, float physicalWidthMeters, string imageName) { if (rawTexture == null || trackedImageManager == null) { Debug.LogError("[RuntimeImageLibrary] 纹理或管理器为空"); return false; } // 1. 格式统一,避免底层不支持 var processedTexture = TextureHelper.ToRGBA32(rawTexture); // 2. 建议做一次缩放,512~1024 长边足够 if (processedTexture.width > 1024 || processedTexture.height > 1024) { var scale = 1024f / Mathf.Max(processedTexture.width, processedTexture.height); var newWidth = Mathf.RoundToInt(processedTexture.width * scale); var newHeight = Mathf.RoundToInt(processedTexture.height * scale); processedTexture = TextureHelper.ScaleTexture(processedTexture, newWidth, newHeight); } // 3. 从静态库复制一个运行库作为起点,保留基础识别图 var runtimeLibrary = trackedImageManager.CreateRuntimeLibrary(fallbackStaticLibrary); // 4. 构建 XRReferenceImage,物理高度按宽度等比计算 var expectedHeight = physicalWidthMeters * processedTexture.height / processedTexture.width; var fallbackName = string.IsNullOrEmpty(imageName) ? $"RuntimeImage_{Time.time:F0}" : imageName; var referenceImage = new XRReferenceImage( new SerializableGuid(), processedTexture, new Vector2(physicalWidthMeters, expectedHeight), fallbackName); // 5. 获取安全句柄并调度异步添加 Job var safetyHandle = trackedImageManager.subsystem.imageLibrarySafetyHandle; var jobState = runtimeLibrary.ScheduleAddImageWithValidationJob(referenceImage, safetyHandle); // 6. 等待 Job 完成 jobState.jobHandle.Complete(); // 7. 切换管理器正在使用的运行库 trackedImageManager.referenceLibrary = runtimeLibrary; Debug.Log($"[RuntimeImageLibrary] 添加完成,当前库中图像数: {trackedImageManager.referenceLibrary.count}"); return true; } private void OnTrackedImagesChanged(ARTrackedImagesChangedEventArgs args) { foreach (var trackedImage in args.added) { Debug.Log($"[ImageTracked] 新识别到: {trackedImage.referenceImage.name}"); } foreach (var trackedImage in args.updated) { Debug.Log($"[ImageTracked] 更新: {trackedImage.referenceImage.name}, 状态: {trackedImage.trackingState}"); } foreach (var trackedImage in args.removed) { Debug.Log($"[ImageTracked] 丢失: {trackedImage.trackingState}"); } } }4.4 从摄像头拍照入库的另一种路径
除了相册选图,有些场景要求直接拍一张作为识别图。移动端上你既可以用WebCamTexture抓当前画面帧,也可以用原生相机拍照回调。用WebCamTexture的优点是链路短,但要注意几个细节:
- 相机权限要在调用前申请
- 截取帧要转成
Texture2D - 摄像头启动需要时间,不能马上取帧
using UnityEngine; using System.Collections; public class CameraCaptureHelper : MonoBehaviour { private WebCamTexture webCamTexture; public IEnumerator CapturePhoto(System.Action<Texture2D> onCompleted) { // 切到前置或后置摄像头,这里假设后置 var deviceName = GetRearCameraName(); webCamTexture = new WebCamTexture(deviceName, 1280, 720, 30); webCamTexture.Play(); // 等待首帧就绪 while (webCamTexture.width <= 16 || webCamTexture.height <= 16) { yield return null; } var texture = new Texture2D(webCamTexture.width, webCamTexture.height, TextureFormat.RGBA32, false); texture.SetPixels32(webCamTexture.GetPixels32()); texture.Apply(); webCamTexture.Stop(); onCompleted?.Invoke(texture); } private string GetRearCameraName() { foreach (var device in WebCamTexture.devices) { if (!device.isFrontFacing) { return device.name; } } return WebCamTexture.devices[0].name; } }WebCamTexture.GetPixels32()返回的就是Color32[],用它构造的 RGBA32 纹理在格式上是兼容的。之后你就可以把这张 texture 交给上一节的核心方法。
用原生相机拍出的照片一般分辨率更高,但像素格式五花八门,反而更有必要做一次 RGBA32 转换和缩放。
5. “添加成功但识别失败”的完整排查链路
动态加图最让人头疼的就是:代码全跑了,日志也打印了“添加完成”,但摄像头对着那张图就是不死活追踪不出来。这一章我把实际踩过的几类问题,按排查链路完整写出来。
5.1 图像特征不足:明明入库了,却不识别
这是我遇到最多的一类。用户在相册里选了一张纯白背景、只有少量文字的产品图,添加成功但识别完全失败。
原理上很好解释:系统需要特征点,纹理过于平坦的图像没有足够特征点用于匹配。正常光线下你看着很清楚,不代表特征提取算法觉得它“有内容”。
排查手段是可视化特征点。你可以把图片拿到电脑上用特征检测库处理一下(OpenCV 这类工具很常见),看看关键点分布。如果关键点集中在极少数区域,或者点数量太少,这张图基本识别不了。
应对策略有两个:一是前置提示用户选择纹理清晰的图;二是在入库前做图像增强,比如提高对比度、锐化边缘,让特征更明显。我测试下来,锐化和对比度增强对有轻微模糊的实拍照片帮助很大。
5.2 物理尺寸设错,识别后 AR 内容“悬浮”
添加成功了,也能识别,但 AR 内容大小完全不对。比如识别一张 10cm 宽的卡片,AR 模型却像巨人一样盖在上面。
根因通常出在XRReferenceImage的physicalWidthMeters上。我复盘过一次,原因是我给所有图默认设了 1m 宽度,但实际印刷品只有 0.15m,导致内容放大接近 7 倍。
排查链路很简单:识别成功后打印trackedImage.referenceImage.size,再拿一张已知尺寸的实物对比。如果发现和实物对不上,就是要改用真实物理宽度重新入库。
从相机或相册拿到的图片本身不携带物理尺寸信息,所以这里必须由业务层传入。存档时可以把物理宽度和业务 ID 一起存起来,下次启动直接取值。
5.3 识别后抖动、丢失、来回横跳
这种现象多发生在图像被部分遮挡、光照变化剧烈、或者摄像头移动太快的时候。
从系统角度说,图像识别不是恒定锁定的,每一帧都会重新评估匹配分数。一旦你把图转到很偏的角度,或者环境光让画面整体过曝/过暗,特征匹配质量就会下降,跟踪状态从Tracking掉到Limited甚至None。
排查时可以看trackedImagesChanged回调里的trackingState变化。如果频繁在 Tracking 和 Limited 之间切换,可以先排除环境问题,再做图库优化。
环境层面:避免高光反射、避免大面积遮挡、保持摄像头稳定。图像层面:尽量选表面纹理丰富、有清晰边界和对比度的图片作为参考图,不要选择大面积纯色,也不要选择玻璃、金属这类高反光物体表面。
5.4 库内图像风格相近,互相误识别
当库里有一组内容比较接近的图时,比如都是白色底、类似字体的海报,系统可能出现“识别到 A,下一秒又跳到 B”的问题。这和特征匹配的排序有关,相近的特征结构会互相竞争。
这类问题没有银弹,只能从业务规则层面缓解。我用的方法是:不同图像之间尽量保证主体清晰、色调差异明显;如果业务上必须很相似,建议加一个“识别后人工确认”的二次交互,而不是全自动切换 AR 内容。
5.5 诊断日志怎么打才有效
调试阶段我强烈建议加一套完整的诊断日志,每一条都要包含名称、跟踪状态、图像尺寸、事件类型。只打“发现一张图”对排查帮助不大。
可以在OnTrackedImagesChanged里把referenceImage.name、trackingState、时间戳完整打印出来。再配合在当前帧把识别到的图用Gizmos画一个锚点框,能非常直观地看到系统认为的识别区域在哪里。
我后来还把每张待入库图在入库前做了一次灰度化、直方图统计,如果整体方差太小,就直接在日志里提示“图像纹理过于单一,可能无法识别”。这个提示成本低,但能拦截很多用户侧的无效图片。
6. 落地时提醒自己注意的几个工程细节
6.1 图像预处理到底要不要做
有人觉得系统会自己做特征提取,预处理是不是多余?不是。预处理移动端的意义主要在三点:
- 格式统一:降低底层 API 因为纹理格式导致拒绝入库的概率。
- 尺寸控制:减少 Job 耗时,降低内存峰值。
- 质量提升:让特征提取更稳定。
但这不意味着预处理越重越好。过度锐化、过度增强饱和度反而可能引入伪特征,导致匹配不稳定。做适度的 RGBA32 转换、长边 1024 以内、轻度锐化就够了。
6.2 命名策略和管理
动态添加的图像会越来越多,referenceImage.name一定要和业务数据绑定。比如用户上传的是一张设备照片,命名可以是"equipment_{deviceId}_{timestamp}",这样在trackedImagesChanged回调里,你直接用名字就能映射到对应的业务数据,不需要额外维护一个数组去猜当前识别到的是哪张图。
同时要注意:向运行库并发添加多张图时,Job 调度是异步的。我一般把添加请求放到一个队列里串行执行,添加完给回调/事件结束通知。不要滥用jobHandle.Complete()在主线程频繁同步等待,如果单次添加的图片很大,会卡顿。
6.3 保留静态库作为基础识别能力
最后一个提醒:无论动态添加能力做得多好,保留几张静态库里的基础图是值得的。启动时,把管理器初始化为“静态库 + 动态运行库副本”的混合库,这样即使用户一次都还没添加任何相片,应用也能识别预设的引导图。
我经历过一次上线后的问题:某台设备第一次冷启动时,运行库创建和赋值之间有一个极短的空窗期,刚好用户拿着 AR 画面扫了预设图,结果没有任何响应。后来把所有流程统一为:先初始化静态库,再异步追加用户库,全部完成后再开启识别回调,问题就没有再出现过。
以上这些经验,都是我在实际做动态图像库功能时一点点积累的。如果你也在做 Unity 2D 图像识别 + AR Tracked Image 的动态添加相片功能,按这个链路走一遍,应该能少走不少弯路。最后再送一个小技巧:给ARTrackedImageManager.referenceLibrary赋新库之后,记得重新等待 3~5 帧再开始识别判断,给底层留出重新加载和预热的时间,识别稳定性会明显提升。