Goodnotes 涂抹擦除功能,由 ONNX Runtime 提供支持,适用于 Windows、Web 和 Android 平台
作者
Pedro Gómez, Emma Ning2024年11月18日

在过去的三年里,Goodnotes 工程团队一直致力于将成功的 iPad 笔记应用引入 Windows、Web 和 Android 等其他平台。本文将介绍这款 2022 年度 iPad 应用如何同时为这三个平台实现了最受欢迎的 AI 功能之一——涂抹擦除,并且所有功能都由 ONNX Runtime 提供支持。
📝 什么是涂抹擦除?
我们都是人,都会犯错。涂抹擦除是一个简单的功能,它允许用户无需使用橡皮擦,只需在已有的内容上涂抹一下即可删除。

用户之前写过的任何笔记,无论写了什么,都可以通过简单的涂抹手势删除。这个功能对用户来说可能看起来很简单,但从工程角度来看却相当复杂。

这是 Goodnotes 工程团队首次为 Windows、Web 和 Android 平台发布的使用人工智能的功能,这要归功于 ONNX Runtime,它为边缘 AI 提供了跨平台的高性能模型推理。该团队为这个项目使用了内部训练的模型,并在三个不同的平台上进行了设备端评估。
🔍 如何检测涂抹手势?
对于 Goodnotes 而言,涂抹手势不过是文档中按照特殊模式添加的另一笔画。笔画必须具备以下 2 个特征才会被视为涂抹手势:
- 笔画中的点数应足够多。
- 使用 ONNX Runtime 评估的 AI 模型应将这些笔画识别为涂抹手势。
对于工程团队来说,这意味着 Goodnotes 团队每次向文档添加笔记时,都必须评估其大小和 AI 模型,以确定此新添加的笔记是否为涂抹手势。
一旦预处理阶段检查到笔记大小超过给定阈值,接下来我们就可以遵循经典的 AI 模型评估流程,如下所示:
- 从点中提取笔记特征。
- 使用 ONNX Runtime 评估 AI 模型。
对于特征提取,Goodnotes 团队会规范化笔记区域中包含的点,并将用户手写笔生成的一系列点转换为浮点数组。此过程不过是所有 AI 模型为了将用户数据转换为 AI 模型能够理解的内容而遵循的经典特征提取过程。
该项目使用的 AI 模型是基于 LSTM 的监督模型,团队精心制作并将其部署到每个平台,因此所有用户即使没有互联网连接也可以在设备端进行评估。
一旦这些点被表示为 AI 模型可以处理的形式,就可以使用 ONNX Runtime 并读取模型输出作为一个分数,从而确定最近添加的笔记是否为涂抹手势。如果该笔画被认为是涂抹手势,则该笔画下面的所有笔记都将自动删除。

🤝 为什么选择 ONNX Runtime?
当 Goodnotes 团队需要评估此功能的实现时,他们需要做出一个决定:如何评估 AI 模型。这是该项目首次使用 AI,而且该产品的 iOS 版本使用的是 CoreML,该技术与当前的项目技术栈不兼容,因为这种 Apple 技术在 iOS/MacOS SDK 之外不可用。因此他们决定尝试一些不同的东西。

Goodnotes 用于 Windows、Web 和 Android 的技术栈基于 Web 技术。在底层,该应用程序使用一个 渐进式网页应用。当应用程序从 Microsoft Store 或 Google Play 等其他商店安装时,应用程序使用原生封装,但最终,该项目是一个作为全屏原生应用程序运行的 Web 应用程序。这意味着团队用于评估 AI 模型的技术必须与 Web 技术栈兼容,并且必须足够高性能以满足他们的需求,同时在可能的情况下启用硬件运行时。因此,在检查了各种替代方案后,团队发现 ONNX 是一种可移植格式,并且 ONNX Runtime 提供了 Web 解决方案,于是决定尝试一下。经过一些实验和在功能实现之前使用 ONNX Runtime 创建的 原型,团队决定这是正确的选择!

团队决定使用 ONNX Runtime 而非其他技术的原因有四个:
- 开发的原型 表明 ONNX Runtime 的集成对我们来说非常简单,并提供了我们所需的所有功能。
- ONNX 是一种可移植格式,我们可以用它将当前 CoreML 模型导出为可在多种不同操作系统上评估的格式。
- ONNX Runtime 中的执行提供程序 提供硬件加速,使我们能够在评估模型时获得最佳性能。具体到 Web 解决方案,它拥有针对 CPU 执行的 WSAM 执行提供程序,以及用于通过利用 GPU/NPU 进一步加速的 WebNN 和 WebGPU 执行提供程序,这些对我们来说都是非常有趣的例子。
- 与 AI 模型的 LSTM 设计兼容。
💻 我们的 ONNX Runtime 代码是什么样的?
Goodnotes 团队与 iOS/Mac 团队共享 Goodnotes 应用程序的大部分业务逻辑代码。这意味着他们会编译原始的 Swift 代码库,处理笔画,并通过 WebAssembly 对 Swift 的模型输出进行后处理。但在执行栈中有一个点,Goodnotes 团队必须评估模型,此时团队会将 Swift 的执行委托给使用 ONNX Runtime 的 Web 环境。
评估模型的 TypeScript ONNX Runtime 代码与以下代码片段类似:
export class OnnxScribbleToEraseAIModel extends OnnxAIModel<Array<Array<number>>, EvaluationResult>
implements ScribbleToEraseAIModel
{
getModelResource(): OnDemandResource {
return OnDemandResource.ScribbleToErase;
}
async evaluateModel(input: Array<Array<number>>): Promise<EvaluationResult> {
const startTime = performance.now();
const { tensor, initializeTensorTime } = this.initializeTensor(input);
const { evaluationScore, evaluateModelTime } = await this.runModel(tensor);
const result = {
score: evaluationScore ?? 0.0,
timeToInitializeTensor: initializeTensorTime,
timeToEvaluateTheModel: evaluateModelTime,
totalExecutionTime: performance.now() - startTime,
};
return result;
}
…..
如您所见,这个实现是任何 AI 功能都常见的经典代码。输入数据以特征数组的形式获取,我们稍后会使用张量将其输入到模型中。评估完成后,我们检查作为模型输出获得的分数,如果分数高于特定阈值,则认为输入是涂抹手势。
正如您在代码中看到的,除了初始化张量和评估模型外,我们还在跟踪执行时间,以验证我们的实现并更好地了解实际用户使用此功能时在生产环境中所需的资源。
private initializeTensor(input: number[][]) {
const prepareTensorStartTime = performance.now();
const modelInput = new Float32Array(input.flat());
const tensor = new Tensor(modelInputTensorType, modelInput, modelInputDimensions);
const initializeTensorTime = performance.now() - prepareTensorStartTime;
return { tensor, initializeTensorTime };
}
private async runModel(tensor: Tensor) {
const evaluateModelStartTime = performance.now();
const inferenceSession = this.session;
const outputMap = await inferenceSession.run({ x: tensor });
const outputTensor = outputMap[modelOutputName];
const evaluationScore = outputTensor?.data[0] as number | undefined;
const evaluateModelTime = performance.now() - evaluateModelStartTime;
return { evaluationScore, evaluateModelTime };
}
最重要的是,在这种情况下,Goodnotes 团队决定使用 Web Worker 加载和评估 ONNX Runtime AI 模型,并使用 Web Worker 运行推理会话,因为应用程序中的这一路径处于关键的用户体验流程中,我们希望最大限度地减少对用户性能的影响。
ort.env.logLevel = 'fatal';
ort.env.wasm.wasmPaths = '/onnx/providers/wasm/';
this.session = await InferenceSession.create(modelURL);
根据模型架构,本项目配置的执行提供程序是 CPU 提供程序。这是一个轻量级模型,我们可以通过默认的、由 WASM 提供支持的 CPU 执行提供程序获得相当快的执行时间。我们计划在新的 AI 场景中为更高级的模型提供 WebGPU 和 WebNN 执行。
🚀 部署与集成
由于团队使用的技术栈,Web 技术的使用使得 ONNX Runtime 的集成以及我们托管 AI 模型的方式值得一提。对于本项目,Goodnotes 使用 Vite 作为前端工具,因此他们不得不稍微修改 Vite 配置,以不仅分发我们的 AI 模型,还要分发 CPU 执行提供程序所需的资源。这对团队来说并不是一个大问题,因为 ONNX Runtime 文档已经涵盖了打包工具的使用,但这对于他们来说相当有趣,因为应用程序是可以在离线状态下使用的 PWA,所以这一更改增加了打包大小,不仅包括模型二进制文件,还包括 ONNX Runtime 所需的所有资源。
📈 生产环境运行数月后的成果
Goodnotes 在几个月前发布了此功能。从第一天起,所有用户就开始透明地使用涂抹擦除模型。其中一些用户主动开始使用涂抹手势来删除内容,而另一些用户则将此功能发现为一种自然手势。
自发布之日起,Goodnotes 团队使用 ONNX Runtime 评估 AI 模型已将近 20 亿次!使用 CPU 执行提供程序并在 Worker 中运行模型,团队获得了 P95 的评估时间低于 16 毫秒,P99 低于 27 毫秒的成果!世界各地的用户,来自不同的操作系统和平台,都已使用涂抹擦除功能修改了他们的笔记,团队为这项出色的 ONNX Runtime 解决方案所取得的技术成就感到非常自豪!