在图像标注上训练检测和分割模型
在研究自己的框上微调检测器,把预测的框作为预标签展示给标注者,用修正后的结果重新训练,并让运行适配玻片和细胞。
v2.14.0 新增
本教程在研究自己的边界框上训练目标检测器,并把检测器的框作为预标签展示给后续的标注者来修正。本教程面向已有 image_annotation 方案的研究负责人,他们读过训练你的第一个模型,或者熟悉管理员的训练页面。前半部分用 torchvision-detect 跑完一个完整的循环(图像上的框、一次训练运行、回写、修正,以及第二次运行),那里的每一步都在笔记本电脑的 CPU 上运行过。后半部分把这个循环适配到 YOLO、轮廓、SAM 精修、整图类别、全玻片、显微图像、地图、细胞、文档页面和视觉语言模型,每种情形各占一节。
安装视觉训练器
torchvision-detect 微调 torchvision 的 Faster R-CNN 检测器。该训练器需要 torch 和 torchvision。Potato 的默认安装不含这两个包,因为 torch 的下载有好几个 GB。请在负责训练的机器上安装视觉 extra:
pip install "potato-annotation[train-vision]"同一个 extra 也覆盖 torchvision-segment、hf-image-classification、sam-finetune、layoutlmv3 和 vlm-sft。缺少所需包的训练器会在训练页面上显示为“not installed”,并附上安装这些包的命令。
对于下面这个小项目,GPU 不是必需的。在 Apple silicon 的 Mac 上,torchvision-detect 和 torchvision-segment 在 CPU 上训练,而不是在 Metal(mps)上,运行日志会用警告“torchvision-detect does not run on Apple's Metal (mps) with this torch; using the CPU”记录这次切换。在 Potato 团队的测量中,默认图像尺寸下一个 Metal 训练步骤花的时间比一整个 CPU epoch 还长。对于更大的图像或更大的研究,请按远程训练的说明在 GPU 服务器上训练。
搭建图像项目
项目需要三样东西:图像、列出这些图像的数据文件,以及配置。把图像放进项目文件夹内名为 media 的文件夹,因为 Potato 默认把 <task_dir>/media/ 作为 URL 路径 /media/ 提供。配置用 media_directory 指明文件夹时,也可以用其他文件夹。
shapes/
config.yaml
data/images.jsonl
media/img_001.png
media/img_002.png
...data/images.jsonl 的每一行是一个条目,带有 id 和图像的 URL 路径:
{"id": "img_001", "image_url": "/media/img_001.png"}该配置定义一个带 bbox 工具和两个标签的 image_annotation 方案,并开启带回写的模型训练。该配置能通过 python -m potato.validate_cli --strict:
annotation_task_name: "Shape detection (model training)"
task_dir: "."
data_files:
- "data/images.jsonl"
item_properties:
id_key: "id"
text_key: "image_url"
output_annotation_dir: "annotation_output"
require_password: false
admin_api_key: "demo-admin-key"
num_annotators_per_item: 1
annotation_schemes:
- annotation_type: "image_annotation"
name: "objects"
description: "Draw a box around every circle and every square."
source_field: "image_url"
tools: ["bbox"]
labels:
- name: "circle"
color: "#e6194b"
- name: "square"
color: "#4363d8"
model_training:
enabled: true
writeback:
schemas: ["objects"]
include_unlabeled: true
min_confidence: 0.5四项设置决定了这个循环:
source_field: "image_url"告诉图像组件和训练器,哪个条目字段存放图像。num_annotators_per_item: 1让每张图像只有一位标注者,这样种子标签覆盖的图像就尽可能多样。writeback.include_unlabeled: true让每次运行在尚无人标注的图像上做预测。没有该设置时,运行只在留出的 val 和 test 图像上预测,而这些图像已经有人工标注的框。writeback.min_confidence: 0.5在一张图像的框的平均得分低于 0.5 时,扣下该图像的预测。这个下限作用于整张图像,因此标注者不会只看到一张图像里的一半框。
在项目文件夹内启动服务器:
potato start config.yaml -p 8000标注种子集
torchvision-detect 在 train 划分里少于 20 张已标注图像时拒绝拟合,而默认划分把 70% 的已标注图像放进 train。请至少标注 30 张图像,目标物体变化大时标注更多。在 http://localhost:8000 上手工画框。没有框的图像会被排除在训练之外,因为检测器分不清没有物体的图像和没有人标完的图像。提交的图像要画出其中的每个物体。
在任何人工标注之前,想先试跑这个循环的话,用户模拟器可以根据 gold 文件画框。每个 gold 条目给出图像 id 和方案的形状,坐标是归一化的:
[
{"id": "img_001", "objects": [
{"type": "bbox", "label": "circle",
"coordinates": {"x": 0.158, "y": 0.849, "width": 0.106, "height": 0.146}}
]}
]python -m potato.simulator --server http://localhost:8000 --users 3 \
--strategy gold_standard --competence good=1.0 --max-annotations 20 \
--gold-file data/gold.json --sequential --fast-mode --no-export模拟器会带几个像素的抖动重画每个 gold 框,在 good 水平上,模拟器还会漏掉或标错一些物体,就像认真的标注者偶尔也会那样。在我们对 80 张合成图像的测试中,三位模拟标注者在约一秒内标注了 60 张图像。
训练 torchvision-detect
打开 http://localhost:8000/admin,输入管理员密钥 demo-admin-key,然后打开 http://localhost:8000/admin/training。把 objects 选为方案,把 torchvision-detect 选为训练器,让 Parameters 留空,然后点击 Train now。同一次运行也可以用脚本启动:
curl -X POST -H "X-API-Key: demo-admin-key" -H "Content-Type: application/json" \
http://localhost:8000/admin/training/api/runs \
-d '{"trainer": "torchvision-detect", "schemas": ["objects"]}'运行从 torchvision 的 COCO 权重出发,并按方案的两个标签给检测器换上新的框头。第一次运行时,torch 会把 COCO 权重下载到 torch 的缓存里。默认值是小型的 fasterrcnn_mobilenet_v3_large_fpn 架构、15 个 epoch、每步 4 张图像,并在连续 4 个 epoch 没有更好的 val mAP 后提前停止。每种形状都按其边界框训练,因此标注者画的是多边形的研究,训练出来的仍是框检测器。每张图像有多位标注者时,运行在 model_training.aggregation 为该图像选定的那一份完整答案上训练,因为两个人的框很少完全吻合。
在我们使用的 Apple M4 Pro CPU 上,37 张 320 乘 240 像素的训练图像约用 106 秒训练完,运行在 5 个 epoch 后停止。运行的文件位于 annotation_output/training/runs/<run_id>/,拟合好的检测器在 artifacts/model/detector.pt。运行的设置和所用设备记录在 artifacts/model_card.json 中。
查看留出得分
每次运行都会在检测器从未训练过的图像上给检测器打分。image_annotation 方案的主得分是 map_50,即 COCO 平均精度均值,预测框与已标注物体的边界框的 IoU 为 0.5 或更高时算作匹配。运行页面还会记录 map_50_95,即 IoU 阈值从 0.5 到 0.95 的平均值。两位标注者对同一个物体画的框很少在 IoU 0.9 时重叠,所以更严格的阈值有一部分衡量的是人们画框的一致性。因此 Potato 以 0.5 阈值为主。衡量边界框上的一致性展示了如何直接检查这种一致性。
检测器保留 val 得分最好的那个 epoch 的检查点,因此 test 划分有图像时,主得分取自 test 划分。Runs 表在 Held-out score 列显示主得分。运行页面列出 n_train、n_boxes、每个划分的大小和覆盖率,以及在 val 和 test 上已标注和已预测的物体数。在我们对 80 张彩色圆形和方形合成图像的测试运行中,检测器在 test 的 map_50 上得到 0.99。合成形状比真实物体容易找得多,所以请在你自己的图像上判断得分。
检查预标签
一次运行成功之后,Potato 会把检测器的框写入每张符合条件的图像的 predictions 字段。GET /admin/training/api/status 在响应的 writeback 字段里报告结果,其中 n_written 是得到预标签的图像数,另外为每种扣下图像的原因给出一个计数,例如低于 min_confidence 的 n_skipped_low_confidence,以及运行已用于训练的图像的 n_skipped_train_split。train 划分的图像永远不会得到预标签,因为模型在自己的训练数据上的置信度说明不了新图像的情况。
用一个新用户名登录 http://localhost:8000。每张通过回写检查的未标注图像打开时,检测器的框已经画好。每个框都存储为 source: ai,并把检测器的得分存为 confidence。标注者可以移动或调整框的大小、更改标签、删除误检,并画出检测器漏掉的物体。标注者编辑这些框或转到下一张图像时,这些框就成为标注者的答案。标注者只是查看图像时,预标签绝不会被保存。模型审核队列按置信度从低到高提供带预标签的图像,审核者的 correct 判定会让该图像进入之后每次运行的 train 划分。
用修正后的框重新训练
每张被修正的图像都会成为下一次运行的人工标签。标注者处理完一批带预标签的图像之后,从训练页面再启动一次 torchvision-detect 运行。新的运行会替换上一次运行对每张图像的预测,新运行用于训练的图像上的预标签会被撤下。
要不用点击就重新训练,请添加 auto_retrain 块。这个片段扩展上面的 model_training 块,每新增 25 条标注就启动一次运行,最多每 10 分钟一次:
model_training:
enabled: true
auto_retrain:
enabled: true
trainer: torchvision-detect
schemas: [objects]
update_frequency: 25
min_interval_s: 600
writeback:
schemas: [objects]
include_unlabeled: true
min_confidence: 0.5预标签会把标注者锚定在模型的框上。等留出得分足以支持预标签之后,再为标注者开启回写,另见检测被不加思考盖章的预标签,了解检查标注者是否不看就接受框的方法。
用 Ultralytics YOLO 加快检测
ultralytics-detect 从 COCO 预训练检查点(默认是 yolo11n.pt)出发,在同样的框上训练 Ultralytics YOLO 检测器。YOLO 的训练和预测都比 torchvision-detect 快,预标签是同样可编辑的框。Ultralytics 采用 AGPL-3.0 许可,用 Ultralytics 训练并分发的模型适用 AGPL 的条款。Ultralytics 另售用于闭源场景的企业许可证。Potato 的 extra 不含 Ultralytics,因此请自行安装该包:
pip install ultralyticsPotato 会在每次运行之前请你确认许可证。训练页面会显示确认框,API 运行需要在请求体里写 "accept_licence": true,自动重新训练需要 auto_retrain.accept_licence: true。没有确认时,API 会以 HTTP 400、needs_licence_ack: true 和消息“ultralytics-detect is licensed AGPL-3.0”拒绝这次运行。下面的请求确认许可证并启动运行:
curl -X POST -H "X-API-Key: demo-admin-key" -H "Content-Type: application/json" \
http://localhost:8000/admin/training/api/runs \
-d '{"trainer": "ultralytics-detect", "schemas": ["objects"], "accept_licence": true}'运行期间,训练器会关闭 Ultralytics 的使用分析、Ultralytics HUB、实验跟踪器集成和自动安装包。这些改动只在运行进程的内存中生效,你的 Ultralytics 设置文件保持不变。检查点下载到 ~/.cache/potato/ultralytics。默认值是 640 像素下 50 个 epoch,并在 10 个 epoch 后提前停止。
用轮廓代替框
torchvision-segment 在多边形、椭圆、自由曲线和画笔掩码轮廓上训练 torchvision 的 Mask R-CNN。框会被排除在分割训练之外,因为框并不说明哪些像素属于该物体。请给标注者 polygon 或 brush 之类的轮廓工具。如何标注图像分割掩码介绍各种工具分别适用于什么场合。
每个预测的掩码都会被描成最多 64 个顶点的多边形,因此标注者逐点修正预标签。掩码被分成若干块时,保留最大的一块。默认的 score_threshold 是 0.5,而不是检测器的 0.25。Mask R-CNN 对同一个物体的第二猜测得分在 0.25 到 0.6 之间,每个猜测都会成为标注者还要删除的一个形状。留出得分把预测的多边形与已标注的轮廓作比较。在 Mac 上,torchvision-segment 在 CPU 上训练。使用 torch 2.8 时,Potato 团队测得 256 像素图像的一个训练步骤在 Metal 上用 57 秒,在 CPU 上用 2.3 秒。真实的研究请使用 GPU 服务器。ultralytics-segment 是 YOLO 对应的训练器(yolo11n-seg.pt),同样是 64 顶点的多边形,同样需要确认 AGPL-3.0 许可证。
用 sam-finetune 把框变成轮廓
框画起来快,轮廓画起来慢。sam-finetune 微调 Segment Anything(SAM)的掩码解码器,把一个框变成你的标注者会画出的轮廓。图像编码器保持冻结,每张图像的嵌入只计算一次,因此一个 epoch 的开销很小。训练时,用每个已标注轮廓的边界框作为该轮廓的提示。运行会把每个提示框移动或缩放最多 box_jitter(默认为 5%),让解码器从像检测器给出的框那样松散的框中学习。
sam-finetune 精修框,需要有框作为起点。在同一个方案上的常见顺序包含两次运行:
- 在设置
writeback.include_unlabeled: true的情况下运行torchvision-detect或ultralytics-detect,让未标注的图像带上框预标签。 - 在同一个方案上运行
sam-finetune。每个框预标签都会变成多边形预标签,多边形取代这些框。
方案需要有供 SAM 学习的轮廓,因此请在 bbox 之外再给标注者一个轮廓工具。在留出的图像上,标注者自己的框就是提示,所以 val_mask_iou 报告框正确时轮廓的平均 IoU。这些留出预测会被打分,但绝不会存储或发布,因为这些预测是根据另一位标注者的答案作出的。回写报告用 n_skipped_heldout_only 统计这些留出预测。默认检查点是 facebook/sam-vit-base(Apache-2.0)。微调后的解码器只用于提供预标签。图像工具的点击分割在浏览器中运行另外的 ONNX 权重,sam-finetune 运行不会替换这些权重。
对整张图像分类
对于关于整张图像的问题,例如询问照片中是哪种动物的 radio 方案,请使用分类器,而不是检测器。hf-image-classification 微调 Hugging Face 的 AutoModelForImageClassification 能加载的任何模型,例如 ViT、DeiT、BEiT、ConvNeXt、Swin 或 DINOv2。默认模型是 google/vit-base-patch16-224-in21k。该训练器默认随机翻转训练图像。翻转会改变标签时(例如图像中有文字,或涉及左右),请在 Parameters 中把 flip 设为 false。lora: true 训练 LoRA 适配器和分类头,而不是训练全部权重。
当一个选择型方案已标注条目中至少 80% 带有以 .jpg、.jpeg、.png、.bmp、.webp、.tif、.tiff 或 .gif 结尾的图像路径时,该方案算作图像语料。在这样的语料上,没有指定向量化器的 sklearn-text 会在 CLIP 图像嵌入上拟合线性分类器。CLIP 基线不需要 GPU,只需要 pip install "potato-annotation[embeddings]" pillow。
图像路径和远程图像
图像训练器读取方案的 source_field 所指明的字段。没有 source_field 时,训练器取 image_url、image、image_path、file_name、url、path 以及音频和视频的对应字段中,第一个非空的条目字段。图像必须是项目内的本地文件:
- 相对路径相对于
task_dir解析,在task_dir下找不到文件时,相对于media_directory解析。 - 像
/media/img_001.jpg这样的 URL 路径相对于media_directory解析。标注页面从同一个位置加载图像。 - 经由绝对路径、
../或符号链接而位于任务目录和media_directory之外的文件会被排除,并给出警告。 - 远程
http(s)图像绝不会被下载。运行会排除每个图像是远程的或缺失的条目,运行日志会说明有多少,这些条目得不到预测。
要在远程图像上训练,请把它们下载到 media_directory。视觉格式页面和 potato import介绍如何把现有的图像数据集带入项目。
全玻片和其他大图像
检测和分割训练器会把每张图像缩放到几百像素,10 万像素的病理玻片或卫星场景上的物体在这种尺寸下就消失了。因此对于 image_annotation、microscopy 或 geo_annotation 方案,Potato 在构建运行的训练数据时把大图像切成图块。玻片格式(.svs、.ndpi、.mrxs、.scn 等)和长边达到 4,096 像素或更多的图像会自动分块。玻片需要 openslide:
pip install "potato-annotation[wsi]"训练使用包含已标注物体的图块,外加一些空图块的样本,每个物体都会被裁剪到所在图块内。预测覆盖组织上方的每个图块,每张图像最多 max_predict_tiles 个。每张图像的划分在图像被切分之前就已决定,因此同一张玻片的图块绝不会落入两个划分。图块预测会被合并回整张图像,两个重叠图块都找到的物体只保留一次。预标签使用整张图像的坐标,与深度缩放查看器存储的坐标相同。留出得分是在整张图像上计算的。远程后端下载的是图块,而不是玻片。
相关设置位于 model_training.tiling 下。这个片段以 1,024 像素的图块、在 20x 下训练一张 40x 的玻片:
model_training:
enabled: true
tiling:
tile_size: 1024
overlap: 128
downsample: 2请让 overlap 至少与物体一样大,因为较大的物体会被分块找到,每个图块一块。对于记录了分辨率的玻片,mpp 设置每个图块像素对应的微米数,并覆盖 downsample。在有些玻片上,有两项设置需要小心。空的训练图块假定已标注图像上的每个物体都画过了,因此标注者只画了玻片的部分区域时,请设置 background: 0。组织由玻片缩略图上的 Otsu 阈值检测,所以没有空白玻璃的玻片会丢掉颜色较浅的那一半,tissue_threshold: 0 则在每个图块上预测。tiling 表列出每个键及其默认值。
显微图像和地图
microscopy 方案的 ROI 可以训练 torchvision-detect、ultralytics-detect、torchvision-segment、ultralytics-segment 和 cellpose。每个带 ROI 的 z 切片和时间点,都按方案的默认通道和对比度渲染,成为单独的训练图像。渲染器就是查看器所用的合成器。尚未画任何内容的图像,在查看器打开时所在的位置预测,即中间的 z 切片和第一个时间点。预测以显微 ROI 的形式返回,并带有每个 ROI 的 z 和 t。细胞分类指向的是标签图像而不是形状,训练器会跳过细胞分类。
geo_annotation 方案的要素,在条目的栅格底图上训练同样的检测和分割训练器。GeoTIFF 按文件自身标签里的变换和 EPSG 代码定位,投影后的则用 pyproj 重投影。带范围的普通图像按经度和纬度定位。多边形、矩形、线和点在栅格上变成形状,多边形的洞会被填满。预测以 EPSG:4326 的 GeoJSON 要素返回,带有 source: model。地图把这些要素作为建议提供,只有当标注者编辑或接受其中一个时,这些建议才算作答案。底图是瓦片服务器或 GeoJSON 轮廓的地图没有可供训练的栅格,运行会排除这些条目并给出警告。
用 cellpose 处理细胞
cellpose 在 image_annotation 或 microscopy 方案中的细胞或细胞核轮廓上微调 Cellpose。请另行安装 Cellpose 3 或 4:
pip install cellpose默认的基础模型是 cyto3。nuclei 和 cyto2 也适用于 Cellpose 3,cpsam 适用于 Cellpose 4。Cellpose 自己的估计不准时,请把 diameter 设为典型细胞直径(单位为像素)。Cellpose 的分割不带类别,因此每个预测的细胞都得到 label 参数的值,未设置 label 时得到方案中最常见的标签。各类别外观不同时,请为每个标签训练一个模型。每个预测的掩码都以多边形返回,细胞的平均概率作为预标签的置信度。cellpose 在 train 划分里只需要 5 张已标注图像,检测器则需要 20 张。Cellpose 的代码是 BSD-3-Clause,权重来自 Cellpose 的作者,适用作者的条款。
声谱图
spectrogram_annotation 方案的框,在每段渲染成声谱图图像的录音上训练 torchvision-detect 和 ultralytics-detect。音频模型教程从头到尾介绍声谱图检测,包括渲染设置和训练器读取的录音格式。
用 layoutlmv3 处理文档版面
layoutlmv3 微调 LayoutLMv3,根据页面上的词及其位置,找出标注者在页面图像上画的区域。区域是任何带标签的块,例如标题、表格或总额。Potato 不运行 OCR,所以每个条目都带有页面的词,框使用页面图像的像素坐标或 0 到 1 的坐标:
{"id": "page_001", "image": "pages/page_001.png",
"words": [{"text": "INVOICE", "bbox": [40, 52, 180, 82]},
{"text": "Total", "bbox": [300, 700, 380, 730]}]}regions 模式下的 PAGE XML 导入会根据文件的 Word 元素填写 words。来自 PDF 文本层或其他 OCR 工具的词也同样可用。每个词按其中心所落入的区域获得标签,预测的词会被合并回区域,每个区域变成围绕该区域的词的框预标签。留出得分是框 mAP。预测的框止于区域内的词,因此画得比词大得多的区域,得分会低于这些框应得的分数。
LayoutLMv3 权重的许可证是 CC BY-NC-SA 4.0,不可商用。与 YOLO 训练器一样,训练页面会在运行之前请求确认,API 运行需要 accept_licence: true,自动重新训练需要 auto_retrain.accept_licence: true。
用 vlm-sft 训练视觉语言模型
vlm-sft 在标注者关于图像的答案上微调视觉语言模型,默认使用 LoRA 适配器,默认模型是 Qwen/Qwen2.5-VL-3B-Instruct。该训练器从四种方案学习:
| 方案 | 每个训练样本对应 | 模型学习生成什么 |
|---|---|---|
图像条目上的 text | 条目 | 标注者对方案所提问题的回答 |
region_caption | 带说明的区域 | 以红框画出的区域的说明 |
grounding_eval | 已回答的表达 | 区域(JSON)、一个点,或 {"absent": true} |
gui_trajectory | 被判为 correct 且带点击的步骤 | 点击,形式为 {"point": [x, y]} |
坐标位于图像的 0 到 1,000 网格上,因此任何检查点都能从样本中学会这种格式。文本运行把生成的答案作为文本预标签发布。定位运行为每个表达发布一个区域或一个“不存在”,VLM 定位介绍 grounding_eval 方案。GUI 轨迹运行根据模型会点击的位置为每个步骤建议一个判定,评估计算机使用智能体介绍 GUI 轨迹。说明运行用 token F1 和 BLEU-4 打分,但绝不发布,因为带说明的区域是标注者的答案。
真实的检查点需要 GPU。Potato 团队在 Mac 的 Metal GPU 上,用 LoRA 在 26 张图像上训练了两个 epoch 的 Qwen/Qwen3-VL-2B-Instruct,用时 46 秒。在 Parameters 中写 {"lora": {"quantize": "4bit"}},会在 4 位基础权重上训练 QLoRA 适配器。请检查你所选模型的许可证。Qwen2.5-VL-3B 适用 Qwen 研究许可证,7B 模型是 Apache-2.0。LLM 微调教程介绍 LoRA、QLoRA,以及把模型发布到 Hugging Face Hub。
延伸阅读
- 模型训练概览,介绍循环和训练页面
- 训练器,按任务分组列出每个训练器
- 配置,介绍每个键、留出得分、回写和路由
- 循环中的模型标注,介绍审核、采集和自动重新训练
- 远程训练,介绍 ssh GPU 服务器、SLURM 和轮询智能体
- 图像标注,介绍该方案的工具和选项
- 模型库和许可证,介绍在浏览器和服务器上运行的视觉模型
- 上游参考:图像条目、全玻片、
torchvision-detect、sam-finetune和vlm-sft,附每个参数表
参考文献
- Ren, S., He, K., Girshick, R., and Sun, J. (2017). Faster R-CNN: Towards Real-Time Object Detection with Region Proposal Networks. IEEE Transactions on Pattern Analysis and Machine Intelligence, 39, 1137–1149. doi:10.1109/TPAMI.2016.2577031
- He, K., Gkioxari, G., Dollár, P., and Girshick, R. (2017). Mask R-CNN. 2017 IEEE International Conference on Computer Vision (ICCV), 2980–2988. doi:10.1109/ICCV.2017.322
- Kirillov, A., Mintun, E., Ravi, N., Mao, H., Rolland, C., Gustafson, L., Xiao, T., Whitehead, S., Berg, A. C., Lo, W.-Y., Dollár, P., and Girshick, R. (2023). Segment Anything. 2023 IEEE/CVF International Conference on Computer Vision (ICCV), 3992–4003. doi:10.1109/ICCV51070.2023.00371
- Stringer, C., Wang, T., Michaelos, M., and Pachitariu, M. (2021). Cellpose: a generalist algorithm for cellular segmentation. Nature Methods, 18, 100–106. doi:10.1038/s41592-020-01018-x
- Huang, Y., Lv, T., Cui, L., Lu, Y., and Wei, F. (2022). LayoutLMv3: Pre-training for Document AI with Unified Text and Image Masking. Proceedings of the 30th ACM International Conference on Multimedia, 4083–4091. doi:10.1145/3503161.3548112