之前接了一个图像数据标注重构的活儿,最上层的诉求一句话就能概括:别让我们再用多边形工具一格一格描边了。传统标注工具在简单矩形、圆形目标面前还顶得住,一旦面对不规则的零件边缘、医学影像里的组织轮廓、遥感图像里的密集目标,人工描边的成本就高到离谱。翻了很久的开源方案,最后把目标锁定在 SAM3 + FastAPI 上,做一个带分割模型推理能力的 智能图像标注工具。这套组合的好处非常直接:标注员只需要在目标上点一下,模型就能自动给出高质量的分割掩码,人从“画线”变成“校对”。
这篇文章不是复述官方文档,是我把这套工具从零搭起来、接到标注流程里跑通之后整理的实战经验。适合正在做数据标注平台、想接入 SAM 系列模型做辅助标注,或者单纯想用 FastAPI 包一个推理服务的开发者读。你会看到选型理由、模型封装、接口设计、前端交互,以及我实际踩过的显存泄漏、坐标偏移、并发排队这些坑。内容偏工程落地,代码可以直接抄。
1. 方案定型:为什么是 SAM3 + FastAPI,而不是别的搭配
1.1 传统标注流程的痛点
做标注工具的人心里都有数,传统流程一般分两类。第一类是矩形框,简单但粗糙,目标稍微不规则一点,框进去的背景区域比目标本身还大,后续训练模型等于喂了大量噪声。第二类是多边形逐点描边,精度可控,但效率极低,一个复杂轮廓几十个点,标一百张图手都能抽筋。更让人崩溃的是,同一个目标换一张图,轮廓形状都会发生变化,所有点全部要重新拖一遍,几乎没有复用性。
智能标注工具要解决的正是这个问题:把“人找边界”变成“人给提示”。SAM3 这代分割模型的特点是提示词驱动零样本分割,你在图上点击一个点,它返回对应的目标掩码。点击、返回、微调,整个过程从分钟级压缩到秒级。而且模型对类别没有预设,不需要先训练某个类别的分割模型,换一个数据集照样能用。这意味着标注平台不需要维护一堆针对不同目标类型训练的专用模型,一套服务就能覆盖绝大部分场景。
1.2 服务化框架为什么挑 FastAPI
我也不是没试过别的方案。第一反应是用现成的 Gradio 包一个 demo,但 Gradio 适合演示,不适合真正嵌入标注平台。标注工具有多用户、多标注员同时操作的场景,需要走统一的鉴权、数据校验、任务分发流程,Gradio 那种偏单体页面的交互模式很难拆分。
Flask 也能做,但同步模型推理会直接阻塞 worker。标注场景里模型推理动辄几百毫秒,同步架构下并发一高,请求全部排队,前端用户感知就是“点了没反应”。FastAPI 的核心优势在于它原生支持 async,配合 run_in_executor 可以把模型推理这类阻塞任务扔到线程池执行,接口本身不阻塞事件循环。再加上 Pydantic 的请求体校验、自动生成 OpenAPI 文档、对 WebSocket 的友好支持,它几乎是为“AI 模型封装 HTTP 服务”这个场景量身定做的。
这里需要说明一下,模型推理本身还是阻塞的,FastAPI 不是把 GPU 计算变快了,它只是让服务端能更高效地调度资源。实际生产里,真正扛并发靠的是模型实例管理和请求队列,接口框架只是第一步。
1.3 整体架构与数据流向
这套工具的架构拆开看其实很简单。浏览器端展示图片,标注员在图片上点击目标区域,前端拿到像素坐标后发送到后端接口。后端收到请求后,把原始图片和提示点一起送入 SAM3 模型,模型返回分割掩码和置信度。后端把掩码编码成紧凑的格式返回给前端,前端在图片上叠加半透明蒙层,标注员确认结果后提交保存。
整套链路里最关键的设计决策是:坐标传递必须基于原始图像分辨率,而不是浏览器显示区域的宽高。
前端 <img> 标签在有缩放或者 CSS 约束时,offsetX 拿到的是显示区域的坐标,直接传给后端会导致点击位置和真实图像像素对不上。我在后面实现部分会专门写这个换算,这个是标注工具最容易出怪问题的点。
另外,模型返回的掩码我不建议直接传 base64 原图。一张 1024x1024 的 mask,每个像素一个字节,base64 编完也有 1.3MB 左右,高频标注场景下网络开销太大。我的做法是服务端把二值掩码跑一遍 RLE 编码,同一个目标的连续像素段用 (起点, 长度) 表示,压缩率通常能达到 90% 以上。这条经验在后面也会展开。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 动手前必读:SAM3 的推理机制与封装要点
2.1 SAM3 怎么理解“提示点”
SAM3 的输入是“图像 + 提示”,输出是分割掩码。提示可以是点、框、或者文本描述,这套工具里我用的是点提示。一个点不能只传坐标,必须同时告诉模型这个点代表前景还是背景。前景点用数字 1 表示,背景点用 0 表示。
这里的逻辑也好理解。你点击目标的中心区域,模型会围绕这个点向外扩散,找出最符合目标边界的连通区域。但也会出现模型把目标周围粘在一起的东西也包进去的情况,这时候就需要加一个背景点,指定“这个位置不属于目标”,模型会把周围那一块从掩码里剔除掉。
还有一个容易被忽略的参数是 multimask_output。SAM 系列模型内部结构偏好生成多个候选掩码,默认开启时预测会返回 3 组 mask、score、logits。3 组里第一组通常是整体效果最好的,但有时第 2 组在某些边缘细节上更贴合。所以标注工具里比较好的体验是:一次预测返回全部候选掩码,前端把 3 个候选同时展示出来,标注员按快捷键 1、2、3 切换。这比每次都重新点击提示点调参快得多。
2.2 模型生命周期:常驻内存还是随用随载
模型加载涉及一个很实际的选择:启动时加载还是第一次请求时加载。
很多初学项目喜欢搞 lazy load,第一次请求时加载模型,看起来能缩短服务启动时间。但在标注场景下千万别这么做,实测下来教训很深刻。模型权重文件和初始化计算图的时间动辄十几秒,第一个请求的人会直接卡死等模型加载,前端没做超时处理的话,体验就是页面白转圈。
我的做法是启动时加载,并且只在进程生命周期内加载一次。模型加载完成前,服务不对外提供标注接口,或者直接返回一个 503 Service Unavailable,直到模型就绪后再开始接收请求。FastAPI 新版本里用 lifespan 上下文管理这个生命周期最干净,旧版的 @app.on_event("startup") 已经标记为废弃,别在新代码里用了。
code复制@asynccontextmanager
async def lifespan(app: FastAPI):
model_manager.load()
yield
model_manager.release()
这里强调一个细节:model_manager.load() 里面做的是 GPU 显存分配和权重拷贝。如果服务用了多个 worker 进程,每个进程都会在自己的显存空间里维护一份模型实例,显存占用会随 worker 数成倍上涨。标注平台这种后端推理任务,建议最多开 1 到 2 个 worker,不要盲目照搬普通 Web 服务“多 worker 提升并发”的经验。
2.3 异步接口下“阻塞”部分怎么处理
FastAPI 对异步有一套自己的规则,很多新手在这里栽跟头。接口函数如果用 async def 定义,FastAPI 会把它放到事件循环里跑;如果函数内部直接调用同步的模型推理,这个推理会阻塞整个事件循环,其他所有请求都会跟着卡住。
正确做法有两种。一种是把接口函数定义成普通 def,FastAPI 会自动把它丢到线程池执行,相当于隐式地不阻塞事件循环。另一种是 async def + await run_in_executor,把模型推理包一层。
code复制pred = await asyncio.get_running_loop().run_in_executor(
None, model_manager.predict, image, points, labels
)
我的实践是选第二种。因为第一种虽然简单,但你放弃了在接口内做更多异步控制的能力。比如我想加一个信号量,限制同一时刻最多只有 2 个推理请求在跑,防止显存被并发请求打爆。用信号量就必须在 async def 里执行 async with。
code复制semaphore = asyncio.Semaphore(2)
async def annotate(request: AnnotateRequest):
async with semaphore:
result = await run_in_executor(...)
return result
信号量这个细节在单用户 demo 阶段无所谓,一旦多人同时标注,并发请求涌进来,显存很容易被多个推理实例打爆。加一个信号量,多出来的请求排队等待,配合前端接口的超时时间,至少不会把服务端打挂。
3. 核心代码与实操过程:搭一个可用版本
3.1 环境依赖与工程骨架
我不喜欢一步到位的重型框架,先列一个最小可用的工程骨架,跑通后再逐步加功能。
code复制annotator/
├─ app.py # FastAPI 入口与路由注册
├─ schemas.py # 请求与响应的 Pydantic 模型
├─ model_manager.py # SAM3 模型封装、生命周期管理
├─ mask_utils.py # 掩码编码、坐标换算工具
├─ static/
│ └─ index.html # 浏览器端标注页面
└─ requirements.txt
依赖清单里最关键的是这几个:fastapi、uvicorn、torch、opencv-python-headless、numpy。模型部分如果你用的是 SAM3 官方权重仓库,按它的说明安装对应依赖包;如果用的是 transformers 集成版,则额外加 transformers。opencv 我推荐 headless 版本,不附带 GUI 依赖,部署在服务器上更轻量。
创建虚拟环境和安装依赖这一步没什么特殊的,唯一要提醒的是 torch 的版本要和本机 CUDA 版本配套。装错版本不一定会报错,但推理速度会退化成 CPU 模式,标一张图从 0.3 秒变 3 秒,前端体验天差地别。装完以后跑一个 torch.cuda.is_available(),先确认能用 GPU 再继续。
3.2 模型服务化封装
模型封装的要点是隔离。不要让模型相关代码散落在路由层里,否则后面换模型版本或者加推理日志的时候,到处都要改。我封装了 ModelManager 这个类,对外只暴露 load()、predict()、release() 几个薄接口。
code复制import cv2
import numpy as np
import torch
class SAM3ModelManager:
def __init__(self, checkpoint_path: str, device: str = "cuda"):
self.checkpoint_path = checkpoint_path
self.device = device
self.predictor = None
def load(self):
# 伪代码:根据官方仓库初始化模型
from sam3 import Sam3Predictor, sam3_model_registry
model = sam3_model_registry["sam3_base"](checkpoint=self.checkpoint_path)
model.to(self.device)
self.predictor = Sam3Predictor(model)
print("[model] SAM3加载完成")
def predict(self, image_bgr: np.ndarray, points: list, labels: list):
self.predictor.set_image(image_bgr)
masks, scores, _ = self.predictor.predict(
point_coords=np.array(points, dtype=float),
point_labels=np.array(labels, dtype=int),
multimask_output=True,
)
return masks, scores
def release(self):
self.predictor = None
torch.cuda.empty_cache()
注意 set_image 在每次预测前都要调用,不能只调用一次。因为每次传入的图片可能不一样,模型需要针对当前图片重新计算图像特征嵌入。不过在同一张图上反复点击标注时,可以复用 set_image 之后内部缓存的图像特征,不用每次请求都重新跑图像编码器。这个前提是后端把图像特征缓存到请求级上下文里,比如把多轮点击合并在一个请求内完成。我第一版没做这个优化,每次点击都重新编码整张图,后来看日志才发现 GPU 时间浪费得很冤枉。
3.3 标注接口与坐标传递细节
请求和响应结构用 Pydantic 定义,接口文档能自动生成,前端接入的时候直接看 OpenAPI 文档就行。
code复制from pydantic import BaseModel
class Point(BaseModel):
x: float
y: float
label: int # 1 前景, 0 背景
class AnnotateRequest(BaseModel):
image_id: str
points: list[Point]
multimask: bool = True
class MaskResult(BaseModel):
mask_rle: str
score: float
class AnnotateResponse(BaseModel):
masks: list[MaskResult]
这里我把一个请求设计成支持传入多个点,而不是一次只传一个点。原因是 SAM3 支持前景点 + 背景点的组合输入,一次请求把多轮点击的提示点都传过来,掩码质量会明显更好。前端交互也可以设计成“先点目标,再点背景,然后点确认”,最后统一发一次请求。
在模型推理前,我统一把坐标换算到原始图像像素坐标系。换算公式很简单:
code复制scale_x = image_width / display_width
scale_y = image_height / display_height
true_x = offset_x * scale_x
true_y = offset_y * scale_y
offsetX 是从前端拿到的事件坐标,displayWidth 是图片在浏览器里实际渲染出来的宽度,imageWidth 是图片自然宽度。不换算的话,只要图片被 CSS 缩放过,点击点就会落在错误位置。
响应里我没有直接返回 mask 的 numpy 数组,而是先做 RLE 编码。一个简单的 RLE 实现:
code复制def rle_encode(mask: np.ndarray) -> str:
pixels = mask.flatten(order="F")
pixels = np.concatenate([[0], pixels, [0]])
runs = np.where(pixels[1:] != pixels[:-1])[0] + 1
runs[1::2] -= runs[0::2]
return " ".join(str(r) for r in runs)
这个函数返回的是 Fortran 顺序的扁平像素数组的游程编码,在 COCO 标注格式里很常用。前端拿到后按同样顺序解码就能还原成掩码,网络传输量能省一个量级。
3.4 前端标注交互的最小实现
前端页面不搞复杂框架,直接用原生 HTML + JavaScript 也能把整条链路跑通。核心逻辑是:加载图片、监听点击、换算坐标、调用后端、渲染掩码。
code复制<img id="target" src="/static/sample.jpg" alt="">
<canvas id="overlay"></canvas>
<script>
let currentImage = document.getElementById("target");
let overlay = document.getElementById("overlay");
let points = [];
currentImage.addEventListener("click", async (e) => {
const rect = currentImage.getBoundingClientRect();
const scaleX = currentImage.naturalWidth / rect.width;
const scaleY = currentImage.naturalHeight / rect.height;
const x = (e.clientX - rect.left) * scaleX;
const y = (e.clientY - rect.top) * scaleY;
// 左键前景,右键背景
const label = e.button === 2 ? 0 : 1;
points.push({ x, y, label });
const response = await fetch("/v1/annotate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ image_id: "demo", points, multimask: true })
});
const result = await response.json();
renderMask(result.masks[0].mask_rle);
});
</script>
右键背景点这个交互一定要做。我最初版本只有左键前景点,遇到目标旁边有粘连物体时,模型经常把不该分割的东西也包进去,我只能在代码里硬设一个“缩小 mask 边界”的后处理,效果极差。加了右键背景点之后,模型理解了“这里不是目标”,分割效果立竿见影。
渲染掩码我用 Canvas 叠加半透明色块。RLE 解码在 JavaScript 里跑一个循环展开,数据量小的话性能没问题。为了显示更快,前端还可以把 mask 缩放到图片显示尺寸再绘制,毕竟用户看的是效果,不需要像素级预览,等确认保存时再拿完整掩码。
4. 排障实录与性能调优
4.1 显存占用异常与内存泄漏
最让我头疼的问题发生在连续标注几十张图之后,显存占用缓慢上涨,最终在某个节点直接 OOM 崩掉。第一反应是模型推理产生了中间变量没有被释放,于是我在每轮预测后调用 torch.cuda.empty_cache(),结果没有任何改善。后来定位到真正原因是代码里保留了每次预测的图像特征引用,列表不断增长,旧的 GPU 张量始终无法被回收。
用生命周期很快定位到了泄露点:我在一个全局字典里按 image_id 缓存了图像特征,但从来没清过,标注过的图片多了,显存就被这些历史特征吃光了。缓解方案是给缓存加容量上限,按 LRU 策略淘汰,同时每次写入前检查 key 是否存在,避免重复计算。对于标注工具来说,图像特征本来就是可重建的资源,不值得长期占用显存,设一个 50 条左右的缓存上限足够支撑“同一张图反复标注”的常见场景。
这里还要提醒一个坑:显存泄漏不一定每次都有报错,有时候只是推理一次比一次慢,那是显存碎片化导致可用显存不足,PyTorch 在申请新的显存块时反复做内存整理。遇到这种“变慢但不报错”的情况,先检查是不是同样的隐患。
4.2 坐标错位与提示词失效
坐标错位的问题我在前面已经预告过,它是标注工具最常见的隐性 bug。症状是:点击目标正中,返回的掩码总是偏的,而且偏移方向和幅度跟图片缩放比例成正比。排查思路也直接,打印前端真实传参和后端收到的坐标,对比原始图和显示尺寸的换算比例。
还有一个更隐蔽的情况:图片在 CSS 里用了 object-fit: contain,表现为图片实际渲染区域不等于 <img> 元素边框区域,四周有留白。这时候用 e.offsetX 换算出来的坐标还会偏。我的前端代码里修正方式是用 getBoundingClientRect() 拿到元素实际渲染框,再根据图片原图和渲染框的比例换算,而不是直接用 offsetX。
提示词失效通常是背景点距离目标太远导致的。SAM3 的提示点是有“影响半径”的,正常交互下用户在目标边缘附近点右键,它清楚地知道剔除哪块区域。但如果背景点点在图上空白区域、离目标轮廓太远,模型可能会误解你的意图,出现掩码大面积收缩甚至直接消失。前端交互层面,背景点点击位置应该尽量贴近目标区域边缘,这个使用习惯可以在操作说明里写清楚。
4.3 并发峰值下的排队与超时
标注平台一上线就是多人在线使用。单人测试一切正常,三四个标注员同时开工,接口开始出现大量超时报错。问题出在一个地方:模型是单实例的,请求并发进来的时候 GPU 计算是串行执行的,后到的请求全部排队,而前端 fetch 默认没有设置超时,等待时间一长连接被网关断开,前端报错。
解决方案是两层配合。后端加信号量限制并发推理数,前端给接口设置合理的超时时间,超时后提示“服务繁忙,请稍后再试”,而不是一直白屏等待。信号量的数值需要根据显存大小和单次推理显存占用实测。我这边实测下来 2 个并发上限比较稳定,排队时间最多 1 秒左右,用户感知不明显。
code复制async def annotate(request: AnnotateRequest):
async with semaphore:
loop = asyncio.get_running_loop()
masks, scores = await loop.run_in_executor(
None, model_manager.predict, image_bgr, request.points, request.labels
)
...
对比总结一下我踩过的坑:
| 现象 | 根因 | 解决方案 |
|---|---|---|
| 显存持续上涨最终 OOM | 图像特征缓存无上限 | 缓存设置 LRU 淘汰策略 |
| 掩码位置恒偏移 | 前端缩放坐标未换算 | 用自然尺寸/渲染尺寸比例换算 |
| 掩码整体消失 | 背景点离目标太远 | 提示点在目标边缘附近点击 |
| 多人同时标注接口超时 | 无并发控制 | 信号量限制推理并发数 |
| 权重下载中断无法启动 | 网络不稳定 | 下载后校验文件哈希,失败重试 |
| mask 网络传输太大 | 直接返回整矩阵 | 改用 RLE 游程编码 |
4.4 掩码返回体过大的工程处理
这一条单独拿出来说,是因为很多人前期不重视,等到图片分辨率一高就出事。如果直接返回二值掩码矩阵,一张 2048x2048 的图像光是掩码就是 4MB,在高频标注场景里带宽压力完全不可接受。我后来把接口统一改成 RLE 编码下发,同时在前端做了解码渲染,网络开销缩小了 90% 以上。
如果掩码需要保存到标注文件,建议同时保存标签名、分割类别、RLE 编码和目标框位置。目标框信息虽然可以从 RLE 推出来,但单独存下来后面做检测类任务回放时会省很多事。保存格式参考 COCO 的 segmentation 字段,业界工具链兼容性最好,之后做训练集导出、模型评测这些环节不用再做格式转换。
我个人在实际操作中的体会是:这个工具真正跑起来之后,标注效率的提升比预想的更明显。传统逐点描一张复杂零件轮廓,熟手也要 40 秒左右,SAM3 辅助标注平均 3 秒一次点击加确认,效率提升接近十倍。而且标注员从“机械描边”里解放出来,可以把更多注意力放在审核模型出错的地方,标注质量整体明显变好。
最后分享一个提升体验的小技巧:模型返回的 score 可以当作一个弱监督信号。前端把 3 个候选掩码按分数从高到低排列展示,标注员默认接受分数最高的那个,只有在目标特别刁钻、分数异常低的时候才手动切换候选。我们实测这个方案下,标注员对模型的确认通过率达到六成以上,剩下三四成是微调提示点或者切换候选后再确认。后续如果要继续扩展,可以考虑把置信度高的标注结果自动回灌给模型做轻量 finetune,形成一个越标越准的闭环,但这已经超出“工具搭建”的范畴了,是另一个故事了。
