Goodnotes 在 Windows、Web 和 Android 上的涂抹擦除功能,由 ONNX Runtime 提供支持
作者:
Pedro Gómez, Emma Ning2024年11月18日
在过去的三年里,Goodnotes 工程团队一直致力于将这款在 iPad 上成功的笔记应用带到 Windows、Web 和 Android 等其他平台。本文将介绍这款 2022 年度 iPad 应用是如何同时在这三个平台上使用 ONNX Runtime 实现用户呼声最高的 AI 功能之一——涂抹擦除的。
📝 什么是涂抹擦除?
人非圣贤,孰能无过。涂抹擦除是一项简单的功能,它允许用户无需使用橡皮擦,只需在先前创建的内容上方随意涂画,即可删除内容。

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

得益于 ONNX Runtime(它为边缘 AI 提供跨平台的高性能模型推理),这是 Goodnotes 工程团队首次在 Windows、Web 和 Android 上发布使用人工智能的功能。该团队为本项目使用了内部训练的模型,并在三个不同的平台上进行了端侧评估。
🔍 如何检测涂抹手势?
对 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,由于该苹果技术无法在 iOS/MacOS SDK 之外使用,因此它与当前项目的技术栈不兼容。所以他们决定尝试一些不同的方法。

Goodnotes 用于 Windows、Web 和 Android 的技术栈基于 Web 技术。在底层,该应用使用了一个 渐进式网络应用。当从 Microsoft Store 或 Google Play 等其他商店安装该应用时,它会使用一个原生包装器,但归根结底,本项目是一个作为全屏原生应用运行的 Web 应用。这意味着团队用来评估 AI 模型的必须是与 Web 技术栈兼容的技术,并且性能必须足够满足他们的需求,在可能的情况下启用硬件运行时。因此,在评估了不同的替代方案后,团队发现了作为可移植格式的 ONNX 以及带 Web 解决方案的 ONNX Runtime,并决定尝试一下。经过一番实验以及在使用 ONNX Runtime 实现功能之前创建的原型后,团队认定这就是他们需要使用的正确技术!

团队决定使用 ONNX Runtime 而非其他技术的四个原因如下:
- 开发的原型表明,ONNX Runtime 的集成对我们来说非常简单,并且提供了我们所需的所有功能。
- ONNX 是一种可移植的格式,我们可以用它将现有的 CoreML 模型导出为能够在多种不同操作系统上进行评估的模型。
- ONNX Runtime 中的执行提供程序提供了特定于硬件的加速,这使我们在评估模型时能够获得最佳性能。具体针对 Web 解决方案,它拥有针对 CPU 执行的 WASM 执行提供程序,以及通过利用 GPU/NPU 进一步加速的 WebNN 和 WebGPU 执行提供程序,这对我方而言是非常有趣的例子。
- 与 AI 模型的 LSTM 设计兼容。
💻 我们的 ONNX Runtime 代码是什么样的?
Goodnotes 团队与 iOS/Mac 团队共享了 Goodnotes 应用的大部分业务逻辑代码。这意味着他们编译原始的 Swift 代码库来处理笔画,并通过 WebAssembly 从 Swift 对模型输出进行后处理。但在执行栈的某一点上,Goodnotes 团队必须评估模型,此时团队会使用 ONNX Runtime 将执行从 Swift 委托到 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 运行推理会话,因为我们应用中的这条路径处于关键的 UX 流程中,我们希望将对用户的性能影响降到最低。
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 解决方案所取得的技术成就感到无比自豪!