ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

用Transformers.js在浏览器中跑通Depth-Anything深度估计

用Transformers.js在浏览器中跑通Depth-Anything深度估计

1. 深度估计这个方向,以及Depth-Anything解决了什么

1.1 深度估计到底在算什么东西

所谓深度估计,就是给定一张图像,让程序算出画面里每个像素到摄像机的距离。做自动驾驶的人需要它,做AR辅助测量的需要它,做背景虚化和3D重建的也需要它。以前想拿到这种逐像素的深度信息,要么上双目相机做立体匹配,要么直接上激光雷达,代价都不小。后来大家发现,其实单张彩色照片里本身就藏着很多深度线索——近大远小、遮挡关系、物体边缘、明暗变化,这些信息足够让一个训练得好的神经网络猜出相当靠谱的深度图。

Depth-Anything这个模型之所以在圈里讨论度高,是因为它把"单目深度估计"这个任务推到了一个新高度。单目指的是只用一台普通相机的一帧画面,不需要额外传感器。它的输出是一张和原图分辨率对齐的深度图,每个像素的值表示该点离镜头的远近。用的时候你把它当成一个通用的视觉基础能力模块就好,前面接图像,后面吐出深度,中间的细节模型已经替你处理完了。这个名字也很直白——Anything,意思是它对各种场景都有较好的泛化能力,不是只在某个数据集里好用。

1.2 Depth-Anything凭什么火起来

说说Depth-Anything背后的逻辑。早期的单目深度估计模型,最头疼的问题就是数据量不够。要得到真实可靠的深度标注,通常得依靠激光雷达扫描或者密集的深度传感器采集,这种数据既贵又难获取,覆盖面也窄。很多模型在室内数据集上表现不错,一到户外、雨天、逆光、不规则物体,预测效果就明显下降。

Depth-Anything走了一条相当聪明的路线——它把大量无标注的普通图像用了起来。具体做法是先让一个训练好的教师模型给海量无标注图像打伪深度标签,然后用这些伪标签去训练一个更大的学生模型。听起来不复杂,但关键在于它没有直接把伪标签硬塞给学生,而是通过保持跨数据集的一致性来不断校正估计结果。换句话说,模型学会了在不同场景下都用同一套"深度语言"来理解画面。再加上它的编码器沿用了DINOv2这种自带丰富语义特征的预训练模型,所以模型从一开始就"认识"很多东西,训练出来的深度图不管在结构清晰度还是边缘保持上都相当能打。

这套设计带来一个直接结果:模型泛化能力强,拿到没见过的场景也能给出合理深度。你随便拍一张办公室照片、一张街景、一张桌子上的杂物,它都能输出稳定的深度结构。对于想快速做原型验证的人来说,这比自己去采集数据、标数据、训练一个特定场景模型要省太多事了。

1.3 把深度估计搬进浏览器,难点在哪里

实际动手的时候你会发现,真正让人头疼的往往不是模型本身,而是怎么把它跑起来。传统做法是在Python环境里加载PyTorch模型,接上GPU或者CPU推理,出图后存成文件再交给前端。这个流程对做研究、做离线工具没问题,但如果你要做的是一个纯网页工具,就是用户打开浏览器上传一张图马上出结果那种,中间这一层服务端就变得很碍事。

服务端推理有几个绕不开的麻烦:第一,你得维护一台带GPU的服务器,成本不低;第二,用户的图片要上传到服务器,涉及隐私和带宽问题;第三,高并发时队列一堵,用户体验直线下降。于是很自然就会想把推理放到浏览器本地,图片不出用户设备,模型在本地用WebAssembly或者WebGPU算。这样一来隐私问题解决了,交互延迟也因为省去网络传输而明显降低,服务器成本则直接变成零。

但浏览器端推理的坑在于工具链一直不算友好。早些年要在浏览器里跑一个视觉Transformer模型,得先把PyTorch权重转成通用中间格式,再写一堆胶水代码处理张量运算、归一化、图像编解码,甚至还要自己管理Web Worker线程。光是环境搭建就能劝退一片人。后来出现了Transformers.js这类封装库,情况才真正好转。它把模型加载、预处理、推理、后处理全部封装成类似Python版Transformers的接口,前端开发者不需要去碰底层那些脏活累活,几行代码就能把模型跑起来。

2. 技术选型解析:为什么用Transformers.js

2.1 浏览器端跑模型的几条路线

想在前端跑深度估计,市面上大致有三条路线可以走。第一条是用TensorFlow的浏览器版本,但这类库长期以来对视觉Transformer这类新架构的支持偏弱,很多算子在JavaScript环境里要么没有实现,要么性能奇差,实际跑起来坑多到怀疑人生。第二条是直接对接ONNX格式的推理运行时,把模型转成ONNX后由底层引擎在浏览器里执行。这条路性能上限确实高,但你要自己负责模型导出、输入输出张量拼装、预处理后处理,每换一个模型都要重新写一遍适配代码,开发效率很低。

第三条就是今天要说的Transformers.js。它本质上是一个把前端推理框架和模型仓库都包进来的高层工具库,底层依赖WebAssembly或者WebGPU执行运算,API风格又很接近Python生态里大家熟悉的那个Transformers库。你用的时候只需要指定任务类型和模型名称,库会自己负责下载权重、解析配置、执行前处理和输出整理。这种封装粒度对做应用的人来说非常友好,因为它把"怎么跑模型"这件事简化成了配置项,把精力留给业务逻辑。

2.2 Transformers.js在深度估计场景下的设计优势

具体到深度估计这个任务,Transformers.js给我的体验是分工特别清晰。它内置了depth-estimation类型的推理管道,输入一张图像,输出的结果里既有原始深度张量,也有排版好的深度图RawImage。张量可以让你继续做数学处理,比如计算某个区域的平均深度、判断前后遮挡关系;深度图可以直接拿出来画到Canvas上。你不用自己写归一化、不用管怎么把模型预测的逆深度值映射回可显示范围,这些都替你处理好了。

另一个实际优势是模型权重按需下载并自动缓存。浏览器端跑模型,最怕的就是每次刷新都重新下载几十上百兆的权重文件。Transformers.js把模型文件缓存到了浏览器的IndexedDB里,第二次加载基本是秒开。再加上它还支持半精度和8位量化权重,同一个模型可以在性能和内存占用之间按需选择。对小团队和个人开发者来说,这种"开箱即用"的省心程度,是手搓一套推理管道的方案很难比的。

2.3 适用边界:什么时候该用、什么时候不该硬撑

不过我也得说句公道话,Transformers.js不是万金油。如果你的应用是面向高端用户的专业级实时视频深度估计,对帧率要求很高,那浏览器端本身就未必是最优解,就算用WebGPU也敌不过专用服务端的算力。另外,如果你需要在低端安卓机上跑超大模型,那不管什么前端方案都会吃力,这时候该考虑降级策略——比如用更小的模型、做分辨率裁剪、或者干脆退回服务端处理。

我的判断标准很简单:首先是看用户数据隐私敏感度高不高,其次是看应用需不需要离线或者即时响应,再次是看开发资源够不够。这三点里占了两点,就很值得把推理放到浏览器端。我实际做过一个AR量距的Demo,图片不上传服务器之后,用户心理负担小了,整个页面响应也明显变快。在这个场景下,Transformers.js就是那个把复杂度藏起来、让你专注做产品的工具。

3. 核心原理拆解与模型关键点

3.1 Depth-Anything内部由什么组成

Depth-Anything的骨架大体分两段:编码器负责把图像变成高维特征,解码器负责把这些特征还原成稠密的深度图。编码器用的是DINOv2预训练的Vision Transformer结构。Vision Transformer把图像切成固定大小的Patch,每个Patch经过自注意力机制和其他Patch交换信息,最后得到一组带语义信息的特征。选DINOv2而不是普通监督模型做初始化,关键差别就在这里——DINOv2的特征是通过自监督方式在超大尺度无标注数据上训练出来的,它的语义分层能力特别强,对深度估计这种需要理解"哪里是物体边界、哪里是同一平面"的任务,起点就高了一块。

解码器部分沿用了DPT这种专门为稠密预测设计的结构。DPT不会简单把特征图上采样回原尺寸,而是把不同层级的特征重新组装,通过一个类似特征金字塔的融合机制,把深层语义和浅层纹理信息揉在一起。这样做的好处是,深度预测既能在大结构上保持正确(比如墙面、地面、天空),又不会丢失细节边缘(比如一棵树的轮廓、一个人和背景的分界线)。如果你用过上个时代的深度估计模型就会发现,Depth-Anything输出的图很少出现那种整体结构对、边缘糊成一团的问题。

3.2 模型版本与精度的取舍

Depth-Anything提供了几个不同体量的模型,名字上一般用vit系列的后缀区分。最小的那个模型参数量大约两千万级别,中等的大约一亿,最大的能到三亿多。选哪个完全取决于你部署的目标环境。在浏览器里跑的时候,我强烈建议先从小模型开始跑通全流程,确认效果和性能后再试着往上升级。我做过对比测试,小模型体感速度能比大模型快三四倍,但深度图在细小物体边缘的锐利度上确实会差一些。如果你是做预处理工具或者给后续算法提供辅助信息,小模型完全够用,别一上来就贪大。

还要提一句,这个系列后来推出了改进版本,在小模型上用了一种更高效的特征融合方式,整体质量又往上走了一截。动手前可以去模型仓库里翻翻版本标签,注意选带推荐标记的稳定版本,避免误用中间实验版本。命名里的v2、v3这类标号,通常意味着架构和训练细节有实质更新,表现在深度图上往往就是细节更干净、跨场景更稳定。

3.3 输入输出规范和那张"深度图"

模型输入方面,它接的是标准的三通道RGB图,推理前内部会自动缩放到模型训练时使用的分辨率,并做一遍ImageNet风格的颜色归一化。这些预处理细节在Transformers.js里都是自动完成的,你不太需要操心。但有一个概念必须理清楚:模型输出的深度图,并不是真实距离的毫米或者米,而是一种相对深度,而且多数情况下是逆深度。逆深度的特点是很近的物体会得到很亮的像素,背景和远处会偏暗,整体取值范围被压缩到0到1之间。

为什么用逆深度而不是实际距离?因为训练数据来源不同,各个数据集的度量尺度并不统一,直接对齐"真实米数"很困难。Depth-Anything的策略是让模型学习一种相对稳定的深度结构,而不是去拟合绝对尺度。换句话说,它输出的深度图体现的是"哪些物体更近、哪些更远、相对远近比例如何",而不是"离我的距离精确是几米"。用到实际业务里如果你想算出绝对距离,就需要额外标定或者利用已知物体的真实尺寸来反推比例系数。这一点特别容易被刚接触的人忽略,一上来就拿着深度值当真实距离用,结果自然会出错。

4. 实操全流程:Transformers.js跑通深度估计

4.1 环境准备与依赖安装

动手之前先把环境理清楚。Transformers.js本质上是面向浏览器环境的,但如果你先用Node环境做验证也可以,它兼容主流的打包工具和运行环境,只是Node环境里图片加载的方式略有区别。最省事的做法是直接用原生ES Module的方式在HTML页面里引入,不需要打包器,新建一个HTML文件就够跑起来。不过我个人还是推荐用npm配合Vite这类工具做工程化管理,因为后面调试、研究代码、处理依赖缓存都更顺手。

npm create vite@latest depth-demo cd depth-demo npm install @huggingface/transformers

装完之后有个小配置要注意:浏览器环境下,Transformers.js会尝试从默认的模型仓库按需拉取权重文件。如果你的页面运行在本地开发服务器上,跨域问题一般不会遇到。但如果打包后部署到静态站点,或者你希望完全离线运行,就需要预先处理好模型文件的托管和缓存策略。我建议给模型配置加一个本地代理地址,把权重文件放到自己的CDN上,这样加载速度和稳定性都可控,还能避开公共仓库偶尔的访问波动。

4.2 深度估计管道代码怎么写

核心代码其实短得很。下面这段就是用Transformers.js加载深度估计模型并处理一张图片的基本骨架。

import { pipeline, env } from '@huggingface/transformers'; // 让模型走本地或者CDN代理,避免每次联网拉公网仓库 env.allowLocalModels = false; env.modelRoot = 'https://your-cdn.example.com/models'; // 创建深度估计管道,模型名称这里用占位符,实际按你选的版本填 const depthEstimator = await pipeline('depth-estimation', 'your-org/depth-anything-small'); async function runDepthEstimation(imageSource) { const output = await depthEstimator(imageSource); // output.depth 是 RawImage 对象,可以直接绘制 // output.predicted_depth 是原始深度张量 console.log('深度张量形状:', output.predicted_depth.dims); return output; }

这段代码里,pipeline那个函数是关键入口。它不光负责加载模型,还负责建立默认的图像预处理链路,包括缩放、归一化、通道调整和输出整理。第一次运行pipeline的时候会比较慢,因为要下载模型权重和关联配置文件。建议在页面初始化阶段就调用一次,做一个预热,别等用户真正点击处理时才去加载,不然首帧等待时间会特别长。

处理图片源的时候要注意传参格式。你可以传图片URL字符串、一个HTMLImageElement、一个imageData对象,也可以直接传文件路径。在浏览器里如果传的是本地文件地址,要确保跨域和CORS问题处理好了。我实际碰到过一个很隐蔽的坑:用Canvas重绘过的图片,如果不走toBlob而是直接用base64字符串传进去,在一些浏览器上反而会多一次额外解码损耗,速度明显变慢。这块没有绝对标准,建议在目标浏览器里多做几次实测。

4.3 把深度值画成可视化结果

模型输出的predicted_depth是一个多维度张量,深度图可视化需要你把它转换成普通图像数据。Transformers.js同时已经给了一个output.depth,它是包装好的RawImage对象,可以直接往Canvas上画。但默认的深度图是灰度效果,人眼看不太出层次感,稍微装饰一下效果立刻不同。

最常见的方式是做伪彩色映射。你可以把深度张量的数据读出来,根据像素值映射到一组颜色上,再绘制到Canvas的画布上。深度图本质上是一张单通道灰度图,颜色查找表用的是0到1区间的逆深度值。说来也简单,就看你对哪一段深度范围最感兴趣,要突出近距离物体就把近距离区间映射得鲜艳一些,要做全局展示就用类似热力图那种渐变。绘制的时候记得把数据按行顺序铺开,别把宽和高搞反了。我试过好几种配色,最后的经验是:场景展示选暖色渐变,近处的物体偏红橙色,远处偏蓝紫,人眼感知最舒服;做算法调试则直接看灰度,灰度图不会被颜色误导,能更直观看到深度值的绝对大小。

5. 性能优化与浏览器的真实体验

5.1 CPU上的表现与Web Worker策略

先把预期摆正:即便用最小的Depth-Anything模型,在普通笔记本电脑的CPU上跑一张常见分辨率照片,也需要好几秒,手机上的低功耗CPU就更慢。很多人的第一反应是抱怨模型太慢,其实问题的根源往往出在主线程被占死了。图像解码、模型推理、Canvas绘制这些操作如果不做线程隔离,页面就会像卡死一样,光标转圈,用户疯狂刷新。

我的做法是把整套推理流程丢进Web Worker。Worker里负责加载模型、执行pipeline、返回处理后的图像数据,主线程只负责收集用户上传的图片、显示加载状态、把结果画到Canvas上。这样推理期间页面依然可以流畅滚动、加载进度条也能实时更新。要注意的是,Web Worker里使用pipeline没问题,但传递数据时要避免把大的张量直接结构化克隆,最好把深度图转成ImageBitmap或者把像素数据转成可转移对象,通过transferable的方式传回主线程,这样能省掉一次大对象的拷贝时间,实测下来明显更丝滑。

5.2 WebGPU加速能提升多少

如果你的浏览器和机器支持WebGPU,推理速度会有质的提升。Transformers.js会在运行时自动检测环境,优先走WebGPU推理路线,其次落到WebAssembly。在我用的一台普通台式机上,同样的模型跑同样大小的图片,WebGPU相对CPU路线大约能快四五倍,从好几秒降到一秒左右。这中间的差距主要来自GPU可以并行处理大量矩阵运算,而CPU端即便是WebAssembly也要受限于单核性能。

但WebGPU也有烦人的地方。一是不是所有浏览器都默认开启支持,二是驱动栈不稳定,偶发报错不好排查。所以我在工程上做了两套降级逻辑:先尝试用WebGPU加载模型,如果初始化失败或者中途报错,就自动切回CPU推理,同时给用户一个提示说明当前跑的是兼容模式。处理深度估计这种体积不小的模型,多一套降级就多一层保险,别把宝全押在一个新特性上。

5.3 量化模型的代价与收益

Transformers.js的另一个常用优化手段是加载量化版本的权重。8位整数量化能把模型权重体积压缩到原来的四分之一左右,内存占用、首屏下载耗时都明显下降。对于要部署到公网的静态网站,这往往是决定"能不能用"而不是"快不快"的问题。我第一次尝试时发现,量化模型加载快了一半都不止,推理速度也和全精度差不远,而深度图的肉眼质量几乎没有明显劣化。

但量化不是没有代价。当我放大图片对比细节时才发现,量化模型在物体边缘和细长结构上偶尔会出现轻微的抖动,个别像素深度值会出现毛刺。如果你的应用需要拿深度图去做精细的三维重建,这些误差会被放大,那就不建议用量化权重了。反过来如果只是做人像背景分割、视觉SLAM的前置辅助信息,量化带来的体积收益非常划算。建议把量化与否做成一个运行时选项,让用户按需开启。

6. 常见问题与排查经验实录

6.1 模型加载失败和缓存问题

工作量最大的调试场景往往是模型加载失败。我用表格式的方式整理一下我踩过的坑,方便你对照排查。

现象最可能的原因解决思路
控制台报CORS错误权重文件所在的静态资源服务没有开放跨域头给模型托管目录加跨域响应头,或者改用本地代理转发
一直卡在Downloading进度条公共模型仓库访问不通,或者被网络环境阻断配置env.modelRoot指向自有CDN,把模型文件提前下载好
明明上传过模型却每次重新下载浏览器IndexedDB被清理,或者模型路径每次变更固定模型版本号,避免URL不稳定;确认缓存策略未禁用
个别端口稳定复现加载中断文件不完整,来自中断下载留下的坏缓存手动清IndexedDB里对应的模型缓存记录,重新触发下载

这里想多提一句,env.allowLocalModels这个配置很容易被忽略。开发调试时如果你本地其实没有放模型文件,这个参数必须设为false,否则库会优先找本地模型目录,找不到再跑去联网,结果就是在本地路径上白白浪费一次请求。生产环境则反过来,如果想强制用本地离线模型,就设成true并保证本地文件真有对应内容。

6.2 输出全黑、全白或形状不对

深度估计跑通了但输出的图不对,这种情况多半不是模型的问题,而是你处理输出数据的姿势有问题。全黑全白最常见的原因是数据范围映射错了。模型输出的逆深度值默认在0到1附近,但不同版本的模型可能对数值范围的处理有细微差异。如果你直接把某个切片的数值当成了0到255的灰度值去画,就会得到一片接近全黑的图像;反过来如果拿来画的是归一化之前的原始logits,很容易溢出变成全白。正确做法是先查看predicted_depth张量的数值分布,再决定映射范围,别假设它一定落在某个区间。

形状不对则几乎总是张量拆解环节出了问题。深度张量的维度顺序一般是batch、channels、height、width,如果你的代码里按width、height去遍历,画出来的图就是旋转扭曲的。很多库在返回张量时会做维度整理,但你在调用的过程中如果自己做了转置或者打平操作,就容易搞混。我建议在写可视化逻辑前,先打印一次张量的dims数组,把所有维度打印出来对照着写循环,比凭记忆猜要省太多事。

6.3 交互过程中的卡顿和内存泄漏

深度估计模型在浏览器里驻留内存是逃不掉的,一个中等体量的模型加载进内存后,可能就要占用几百MB。如果你的页面在单页应用里反复切换路由,模型实例又没有做单例复用,每进一次页面就重新pipeline一次,那内存很容易被耗尽。最直接的解法是全局只保留一个pipeline实例,组件卸载时不要销毁它,而是把它挂到全局生命周期里复用。这样虽然首次进入页面慢一点,但后续进入都是秒响应,也不会越用越卡。

还有一类卡顿来自数据流的拷贝。深度图这种大尺寸图像,如果每一步操作都做一次克隆,Canvas绘制又频繁,性能就会指数级往下掉。用transferable对象把数据的所有权直接移交,让旧数据不再复制,是比什么都有效的优化。这点在移动端上尤其明显,我实测发现同样一段处理流程,改成transferable传递后,掉帧率下降了七成以上。遇到卡顿先别急着怀疑模型,把数据在Worker和主线程之间的传递路径检查一遍,往往药到病除。

7. 最后补充几个实战经验

再往后想扩展的话,入口其实挺多。你可以把Depth-Anything输出的深度图送到一个分割模型里做前景背景融合,也可以在浏览器里做基于深度的人像虚化。我个人的经验是,先用小模型配合降采样分辨率把产品原型跑通,确认算法流程和交互体验,再逐步升级模型和分辨率。反过来一开始就上大模型硬扛,往往没等效果验证完,性能和内存问题已经把你淹没了。

还有一个小技巧是给模型做预热。首次推理往往比后续推理慢不少,因为涉及懒加载和缓存构建。在页面加载后、用户还没上传图片前,可以先扔一张小尺寸占位图跑一次推理,把模型权重激活、缓冲建立好。等用户真正放图进来时,响应速度会舒服很多。这个看起来不起眼的操作,实际上是提升首图体验性价比最高的手段。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进