干过图像标注的人都知道,一张复杂目标的精细多边形标注,从拉点、描边到逼近真实轮廓,少说五六分钟,多的能磨上十几分钟。后来我把 SAM3(第三代 Segment Anything 系列模型)和 FastAPI 搭成了一个智能标注服务,前端点一下或拉个框,后端几百毫秒就返回一个目标掩码,标注效率直接上了一个台阶。这篇就是我搭建这个工具的全过程记录,包含整体架构设计、后端推理 API 实现、前端交互细节、COCO 数据集导出,以及我自己踩过的坑。想给数据集提速、或者想把分割模型改造成 Web 服务的读者,可以照着思路直接复现。
1. 先想清楚:这个标注工具要解决什么问题
动手写代码之前,我花了不少时间琢磨一个基础问题:标注工具的核心价值到底是什么。答案很朴素,就是把“从零绘制目标轮廓”这件事,变成“对 AI 拟出的轮廓做确认与微调”,把人力从繁重的像素级劳动中解放出来。
1.1 传统标注流程的痛点
纯人工标注的瓶颈,不是“手不够快”,而是“精力被消耗在低价值操作上”。逐点描边看起来简单,实际上要求标注员长时间保持高度专注,尤其是遇到镂空结构、遮挡边缘、细长柄状物体时,多边形控制点的数量轻易就能上到几十个。我统计过,一个 2000 万像素的复杂目标,光描边补点至少 30 次点击,加上撤销、调整锚点、缩放画布,一张图耗时非常可观。
这种模式下还有两个隐蔽问题。第一是质量不一致,十个人标同一个物体,轮廓差异可能超过 5%,模型训练时吃进这种“充满个性”的标注,收敛效果可想而知。第二是人员流动成本,熟练标注员的培养周期长,一旦流失,质量就跟着波动。所以我在设计这个工具时,最优先的需求就是一致性——所有候选轮廓都由同一个模型生成,标注员只做确认或小范围修正,标注口径天然就统一了很多。
1.2 SAM 自动分割带来了什么变化
SAM 这类提示分割模型,核心能力是理解“你想抠哪个东西”:给它一个中心点,它能推断出整个连通目标;给它一个包围框,它能精确切出框内的主体。SAM3 在这条技术路线上的进步主要体现在速度和边界精细度上,特别是对小目标和复杂拓扑结构,分割结果更稳,少了很多以前需要手工补轮廓的活儿。
把 SAM 放进标注流程后,典型的操作节奏变成了:点一下目标中心,等掩码出现,如果局部不理想就再点几个背景点“戳”掉多余部分,或加一个框重新约束,前后不超过 10 次交互。相比逐个锚点抠边,效率提升不是一点半点。我记得有次补标一批产品零件的瑕疵数据,单张图从原来的 8 分钟缩到 2 分钟以内,而且掩码边缘比手工描的平滑得多。
这里要说明一下,SAM 的交互模型中有一个杀手级能力叫做“负样本提示”。也就是说,你不光能告诉模型“这个区域是目标”,还能告诉它“这块不是目标”。实际标注时,模型往往会在第一次点击时把相邻的两个同色物体一并抠出来,这时候补一个负样本点,模型就会把误包含的区域分割出去。这个交互方式,对手臂贴着身体、树叶互相遮挡这类场景特别有效。
1.3 为什么是 FastAPI 而不是 Flask 或 Django
我当时在选择 Web 框架时,其实先犹豫过 Flask,毕竟它简单,几行代码就能起服务。但真正做模型推理服务时,需求也就跟着变了:接口要有清晰的输入输出约束,避免前端传错参导致 500;图形重叠时最好能自动生成可调试的接口文档;同时还要支撑并发请求,而不是一个请求就能把服务拖死。
FastAPI 正好把这些都收了:基于 Pydantic 做请求体校验,参数错了直接返回 400 而不会让视图函数崩掉;自带的 OpenAPI 文档在开发和联调阶段非常实用,前端同事对照着 /docs 就能知道该传什么字段;异步路由配合并发控制可以做请求排队,让 GPU 在负载可控的情况下尽量跑满。对模型推理服务这场景,FastAPI 是我目前用过最顺手的选择。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 整体架构与数据流设计
这个工具的形态并不复杂,但拆开来看,模块之间的边界非常清晰。我在设计架构时给自己的原则很简单:前端尽量薄,后端尽量独立,模型推理不跟业务逻辑混在一起,避免改一个参数把整个服务搞崩。
2.1 系统分几个模块
整个系统我拆成了四个部分:标注前端、推理 API、模型服务、标注数据存储。标注前端是一个单页 HTML,负责展示图片、捕获点击/框选事件、渲染掩码叠加层;推理 API 由 FastAPI 提供,负责接收提示参数、调用模型、返回掩码结果;模型服务就是 SAM3 的推理封装,输入是图片和提示点,输出是掩码张量;标注数据存储则负责把最终确认的标注结果以 JSON 和 COCO 两种格式落盘。
图上化表示大概是这样的链路:浏览器 Canvas 捕获用户点击坐标,经过坐标系换算后,把归一化坐标请求到 FastAPI;FastAPI 校验参数无误后,调用 SAM 推理核心代码;模型返回低分辨率掩码,FastAPI 把掩码转成 PNG 或 Base64 返回给浏览器;浏览器叠加显示掩码,用户确认后把标注信息存入后端。
这个架构看起来平平无奇,但好在每一层的职责都很单纯。前端不用关心模型内部细节,后端也不用关心页面上“撤销”按钮怎么实现,模型推理更不关心标注数据最终是存成 JSON 还是 COCO,各模块之间的耦合度降到最低,后续替换模型版本或者换数据库都很方便。
2.2 一次完整标注请求的流转过程
流程从用户打开页面、选择一张图片开始。前端把图片加载到 Canvas,同时把归一化尺寸信息记录在内存中。用户点击目标中心时,前端会把点击处的像素坐标换算成 0 到 1 的归一化坐标,然后连同当前已存在的提示点列表一起 POST 给后端。
后端接收到请求后,首先检查这张图是否已经加载到缓存中,如果没有,就从磁盘读取并做一次预处理(包括尺寸适配、通道转换、统一归一化)。随后把提示点交给 SAM 模型的提示编码器,和图像编码器算出的嵌入特征一起送入掩码解码器,得到原始掩码。后端再对掩码做二值化、降噪、可选的轮廓提取,最后编码成 PNG 字节流,连同模型质量分数一起返回。
前端拿到掩码后,会把它绘制成一个半透明覆盖层,叠加在原图上,用户能立刻看到分割结果。如果满意,点击确认,后端把这条标注写入存储;如果不满意,用户再添加一个负样本提示点,后端会用完整的提示序列重新推理,直到结果满足要求。
2.3 API 契约设计
接口设计上,我一开始就定了两个核心接口。一个是 /segment,负责接收图片信息和提示点,返回掩码及质量分;另一个是 /annotation,负责提交最终确认的标注结果。辅助接口还包括 /image/list(获取待标注图片列表)、/image/{id}(返回图片字节流)。
参数方面,我设计了如下字段:图片用 image_id 标识,前端不需要每次都上传整张大图;提示点用 points 数组,每个点包含 x、y(均为 0 到 1 的浮点数)和 label(1 表示前景,0 表示背景);框用 boxes 数组,格式为归一化的 [x1, y1, x2, y2]。返回结果包含 mask_base64、质量分 score、耗时 duration。
| 字段 | 类型 | 说明 |
|---|---|---|
| image_id | string | 图片唯一标识 |
| points | list[Point] | 点提示,坐标归一化 |
| boxes | list[Box] | 框提示,坐标归一化 |
| mask_base64 | string | PNG 掩码的 Base64 编码 |
| score | float | 模型对该掩码的置信度 |
| duration | float | 推理耗时,毫秒 |
前端那边拿到这些字段就可以直接渲染,不需要额外解析复杂结构。
3. 搭建后端推理服务
后端是这套工具的心脏,尤其是 SAM 模型推理部分的封装方式,直接决定了接口的稳定性和性能。我在这里花的时间最多,也是踩坑最多的环节。
3.1 环境准备与工程目录
我用的环境是 Python 3.10 版本、PyTorch 2.x、FastAPI 0.110 以上版本,以及 SAM3 对应的推理依赖库。硬件上建议使用至少 8GB 显存的 NVIDIA 显卡,我实际用的是一款 12GB 显存的卡,跑中型 ViT-B 权重很宽松,跑大型权重也没太大压力。
工程目录我整理成了如下结构,代码和资源分离,后续打包部署也清晰:
text复制.
├── app/
│ ├── main.py # FastAPI 入口
│ ├── config.py # 全局配置
│ ├── models/
│ │ ├── sam_wrapper.py # SAM 推理封装
│ │ └── predictor.py # 提示编码 + 掩码解码
│ ├── routers/
│ │ ├── segment.py # 分割接口
│ │ └── annotation.py # 标注保存接口
│ ├── services/
│ │ ├── mask_processor.py # 掩码后处理
│ │ └── coco_exporter.py # COCO 导出
│ └── utils/
│ └── coordinates.py # 坐标转换
├── static/
│ └── annotator.html # 标注前端
├── data/
│ ├── images/ # 待标注图片
│ └── annotations/ # 标注结果输出
└── weights/
└── sam3_weights.pth # 模型权重
目录结构其实也反映了我对项目的分层思考:模型封装独立成模块,不直接暴露给路由层;掩码后处理单独拎出来,这样才能在不同环节(可视化、COCO 导出)复用同一套逻辑。
3.2 模型加载与单例管理
初学阶段最容易犯的错,是每次请求都重新加载模型。SAM 这类大模型的权重动辄几百 MB 到几 GB,冷加载一次就要几十秒,如果每个请求都这样搞,服务基本上没法用。正确的做法是在应用启动时加载一次,之后所有请求共用同一个推理实例。
FastAPI 的 lifespan 机制很适合做这个事。我在启动事件里初始化全局模型实例,在关闭事件里释放显存,确保服务生命周期内模型只加载一次。
python复制from contextlib import asynccontextmanager
from fastapi import FastAPI
from app.models.sam_wrapper import SAM3Wrapper
model_wrapper = None
@asynccontextmanager
async def lifespan(app: FastAPI):
global model_wrapper
model_wrapper = SAM3Wrapper(weights_path="weights/sam3_weights.pth")
model_wrapper.load()
yield
model_wrapper.unload()
app = FastAPI(title="Smart Annotator API", lifespan=lifespan)
这里值得注意的一个细节是,模型实例要用全局变量持有,但要注意 Python 的 GIL 问题。PyTorch 在 GPU 上执行推理时会释放 GIL,可一旦模型封装类里涉及 CPU 端的预处理、后处理操作,就可能阻塞事件循环。所以我在实现时,把 CPU 密集的预处理和后处理步骤也尽量放在子线程里,或者用 run_in_executor 执行,避免阻塞异步接口的响应。
3.3 提示编码与推理接口实现
这个接口是后端最核心的部分,我把它放在路由层,接收前端传过来的提示点、框,调用模型封装方法,返回处理后的掩码。实现的重点在于,SAM 的提示编码器要求输入是归一化的坐标,直接传像素坐标会导致模型完全“看不懂”。
python复制import io
import base64
import numpy as np
from fastapi import APIRouter
from pydantic import BaseModel
from app.main import model_wrapper
router = APIRouter()
class PromptPoint(BaseModel):
x: float
y: float
label: int
class SegmentRequest(BaseModel):
image_id: str
points: list[PromptPoint] = []
boxes: list[list[float]] = []
class SegmentResponse(BaseModel):
mask_base64: str
score: float
duration: float
@router.post("/segment", response_model=SegmentResponse)
async def segment(req: SegmentRequest):
import time
t0 = time.time()
points_array = [[p.x, p.y, p.label] for p in req.points]
boxes_array = req.boxes if req.boxes else None
mask, score, _ = await model_wrapper.predict(
image_id=req.image_id,
points=points_array,
boxes=boxes_array,
)
mask_bytes = mask_to_png_bytes(mask)
mask_b64 = base64.b64encode(mask_bytes).decode("utf-8")
return SegmentResponse(
mask_base64=mask_b64,
score=float(score),
duration=(time.time() - t0) * 1000,
)
这里有个容易被忽视的约定:列表是用 JSON 传输请求,FastAPI 的 Pydantic 模型会帮我们校验类型。如果前端传的坐标超出 0-1 范围,我还会在服务端做一次显式检查,避免模型收到非法输入。这个校验逻辑看起来多此一举,但实际协作时前端同事可能改了坐标系忘了同步,没有校验的话排查起来特别费劲。
模型封装类 SAM3Wrapper 的内部实现,核心是三步:拿到图片、编码提示、解码掩码。我把它写成异步配合线程池的方式,防止推理阻塞事件循环。
python复制import asyncio
import cv2
import numpy as np
class SAM3Wrapper:
def load(self):
# 伪代码:读取权重、初始化图像编码器/提示编码器/掩码解码器
self.processor = load_sam3_processor(weights_path)
self.model = load_sam3_model(weights_path)
self.image_cache = {}
async def predict(self, image_id, points, boxes):
def _infer():
if image_id not in self.image_cache:
image = read_image_by_id(image_id)
image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
self.image_cache[image_id] = preprocess_image(image)
image_emb = self.image_cache[image_id]
prompt_emb = self.processor.encode_prompts(points, boxes)
masks = self.model.decode_mask(image_emb, prompt_emb)
return masks
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, _infer)
图片缓存是这个实现里的一块重要拼图。因为标注时用户会在同一张图上反复点击、修正,如果每次请求都重新做图像编码,上百毫秒甚至几百毫秒的耗时会被成倍放大。把图像编码结果缓存到字典里后,后续请求只需要做轻量的提示编码和掩码解码,速度能快上一大截。当然这个缓存要记得在关闭服务时清理,否则内存会一直占着。
3.4 掩码后处理:从张量到可视化与 COCO 多边形
SAM 返回的掩码是低分辨率张量,直接送回前端是没法看的,需要做一系处理后处理。我的实现包括:把张量转成二值 mask、可选地做小面积连通域移除、把 mask 编码成 PNG、把 mask 转为多边形轮廓用于 COCO 导出。
python复制def mask_to_png_bytes(mask: np.ndarray):
mask = (mask > 0.5).astype(np.uint8) * 255
valid = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
# 过滤极小的噪声区域
for cnt in valid[0]:
if cv2.contourArea(cnt) < 20:
cv2.drawContours(mask, [cnt], -1, 0, -1)
is_success, buf = cv2.imencode(".png", mask)
return buf.tobytes()
这里有一个我在实际使用中踩过的细节坑:掩码张量从 GPU 拷回 CPU 时,数值范围不一定是 0-1,有时会有 0-255 的情况。所以我在后处理时统一做一次阈值和类型转换,避免出现“半透明花屏”或“显示成噪点”的诡异现象。另外,多类别标注时不要把整张 mask 直接画在一起,最好按类别分开存储,每个类别单独一个二值图。
4. 前端交互标注页面
后端接口就绪后,最烧脑的部分来了:前端交互。标注工具好不好用,前端占了 70% 的体验,而前端最核心的难点是坐标系的转换和可视化反馈的流畅度。
4.1 页面布局与交互设计
我把页面设计成左右两栏:左侧是大图预览区,用户在 Canvas 上直接点击、拖拽、绘制;右侧是图片列表、类别列表、已标注列表和撤销/重做按钮。这样一个页面就能完成“选图-标注-确认保存”的完整闭环。
Canvas 上的叠加层我分了三层绘制:底层是原图,中间是半透明掩码覆盖层,最上层是提示点和框的标记层。这样画掩码的时候不会覆盖掉用户已经点过的提示点,视觉反馈非常直观。掩码覆盖层用不同的颜色区分类别,默认前景掩码是红色半透明,背景提示用蓝点标出,目标框用绿色虚线。
为了让标注员在密集任务中不感到疲劳,我特意把“确认标注”的快捷键设置成空格键,把“删除最后一个提示点”设置成 Backspace。实践证明,快捷键对效率提升极其明显,不用在鼠标和键盘之间来回切换,肌肉记忆形成后,一张图 30 秒就能完事。
4.2 坐标系映射:UI 坐标到归一化坐标
坐标换算是前端最容易写错的地方。Canvas 的宽高跟显示尺寸保持一致,但原图的宽高可能更大或更小,图片在 Canvas 内是被等比缩放过的,还可能留有空白。直接把 clientX 相对 Canvas 的偏移量当成像素坐标传给后端,等于让模型拿错坐标去猜,结果必然偏得离谱。
我在实现中维护了一个 imgRect 对象,记录图片在 Canvas 内的实际显示矩形:
javascript复制function computeImageRect(canvasWidth, canvasHeight, imgW, imgH) {
const scale = Math.min(canvasWidth / imgW, canvasHeight / imgH);
const drawW = imgW * scale;
const drawH = imgH * scale;
return {
x: (canvasWidth - drawW) / 2,
y: (canvasHeight - drawH) / 2,
w: drawW,
h: drawH,
scale: scale
};
}
然后用户点击时,我先判断点击点是否在 imgRect 内,不在就直接忽略;在的话,把相对 imgRect 左上角的偏移量除以原图的宽高,得到归一化坐标。这个归一化坐标才是送给后端的值。
javascript复制function canvasToNorm(clientX, clientY) {
const rect = canvas.getBoundingClientRect();
const px = clientX - rect.left;
const py = clientY - rect.top;
if (px < imgRect.x || px > imgRect.x + imgRect.w) return null;
if (py < imgRect.y || py > imgRect.y + imgRect.h) return null;
const nx = (px - imgRect.x) / imgRect.w;
const ny = (py - imgRect.y) / imgRect.h;
return { x: nx, y: ny };
}
这个换算逻辑看着简单,但没做好的话,点击点和掩码位置会一直错位,而且是越靠近边缘偏得越厉害。我调试时打印过中间值,发现最容易出现的问题就是忘了减去 imgRect.x/y,或者用 Canvas 宽度而不是图片显示宽度做分母。把这些中间值打出来对比一次,问题基本就水落石出了。
4.3 掩码渲染与二次修正
拿到了后端的 mask_base64,前端渲染时先把 Base64 解码成 Image 对象,然后绘制到 Canvas 的掩码层。我额外加了一个坑:掩码图片的显示尺寸要与原图显示尺寸保持一致,而不是与 Canvas 尺寸一致,否则掩码会偏移或者被拉伸。
二次修正的交互流程可以描述成这样:用户点一个前景点,掩码出现,但模型把旁边一个干扰物也包含了进来。用户就在干扰物上再点一下,把 label 置为 0,后端带着完整提示点序列重算,模型会在新一轮解码中把背景点附近区域“挖掉”。这个能力在分割任务中属于典型的多物体选择性分割,也是 SAM 系列模型最吸引人的地方。
javascript复制async function segment() {
const normPoints = currentPoints.map(p => ({
x: p.nx, y: p.ny, label: p.label
}));
const resp = await fetch('/segment', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
image_id: currentImageId,
points: normPoints,
boxes: currentBox ? [currentBox] : []
})
});
const data = await resp.json();
renderMask(data.mask_base64);
displayScore(data.score);
}
交互层面的反馈也很关键。后端返回后,前端会高亮最近一次点击的提示点,掩码颜色会从“候选状态”的浅色变成“待确认”的实色。用户按空格键确认后,掩码就被固化到标注列表,同时清空交互提示点,准备标下一个目标。
5. 标注数据管理与 COCO 导出
标注工具要做到真正能用于模型训练,必须能把交互过程中的结果转换成标准数据集格式。COCO 是我默认的导出目标,因为它在目标检测、实例分割任务里兼容性最好,也是各种模型训练脚本默认接受的格式。
5.1 标注记录的存储结构
我在后端把每一条标注记录存成了一个 JSON 对象,包含以下字段:
json复制{
"annotation_id": "ann_0001",
"image_id": "img_001",
"image_path": "data/images/img_001.jpg",
"category_id": 1,
"category_name": "bottle",
"bbox": [120.3, 45.2, 210.8, 330.5],
"area": 62345.6,
"segmentation": [[x1, y1, x2, y2, ...]],
"quality_score": 0.95,
"prompts": [
{"x": 0.42, "y": 0.38, "label": 1},
{"x": 0.51, "y": 0.44, "label": 0}
],
"created_at": "2025-01-15T10:30:00Z"
}
之所以把 prompts 也存下来,而不是只存最终掩码,是为了后续追溯。如果之后发现标注结果有系统性问题,比如某个类别的掩码总是偏大,我们可以直接拿着原始提示重新推理,而不需要重新标注一遍。
在实际存储时,我按图片维度把这些记录组织在一起:每一张图片对应一个文件,里面包含该图片下的所有标注实例。这样导出、统计、分训练集/验证集都很方便。
5.2 生成 COCO 格式数据集
COCO 格式的实例分割标注,核心是三个数组:images、annotations、categories。生成时需要注意几个计算点:bbox 必须是从多边形外接矩形算出的 [x, y, width, height],与分割的多边形坐标保持同源;面积用多边形面积而不是外接矩形面积;iscrowd 字段置 0。
用 OpenCV 的 findContours 拿到的多边形点序列,需要把每个轮廓点展平成一维列表,这是 COCO 多边形的最基本格式要求。
python复制import json
import cv2
def mask_to_polygon(mask_binary: np.ndarray):
contours, _ = cv2.findContours(mask_binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
polygons = []
for cnt in contours:
if len(cnt) < 3:
continue
# 轮廓点展平为一维列表 [x1, y1, x2, y2, ...]
polygons.append(cnt.flatten().tolist())
return polygons
def convert_to_coco(records, images_meta):
coco = {
"images": [],
"annotations": [],
"categories": [],
}
cat_id_map = {}
for cat_idx, cat_name in enumerate(records["categories"]):
cat_id = cat_idx + 1
cat_id_map[cat_name] = cat_id
coco["categories"].append({"id": cat_id, "name": cat_name})
for ann_idx, rec in enumerate(records["annotations"]):
coco["images"].append({
"id": rec["image_id"],
"file_name": rec["image_path"],
"width": images_meta[rec["image_id"]]["width"],
"height": images_meta[rec["image_id"]]["height"]
})
coco["annotations"].append({
"id": ann_idx + 1,
"image_id": rec["image_id"],
"category_id": cat_id_map[rec["category_name"]],
"bbox": rec["bbox"],
"area": rec["area"],
"segmentation": rec["segmentation"],
"iscrowd": 0
})
return coco
导出后一定要做可视化验证。我写了一个简单的可视化脚本,把 COCO 格式里的多边形画在原图上,观察每个分割轮廓和目标的贴合度。这一步必须做,因为多边形点序错误、bbox 换算出边界等问题,只看 JSON 是发现不了的。
5.3 多类别与多实例的取舍
实际标注场景中,一张图往往有多个目标、多个类别。我在工具里支持了“一个图片文件对应多个标注实例”的结构,用户每确认一个目标,就新增一条 annotation 记录。类别管理通过右侧面板维护,类别名可以预先定义,也可以动态添加。
类别选择有一点值得注意:用户切换类别后,绘制的新掩码就自动归到新类别下,这时前端提示点列表会整体清空,防止用户在标注 A 类目标时带着 B 类的历史提示点去干扰模型。说白了就是每次“标注新实例”都是一次全新的交互会话,老提示点只对该实例生效。
6. 性能优化与部署实践
服务能跑起来和能扛住真实标注任务,完全是两回事。我在测试阶段遇到过多次 GPU 显存占用爆炸、接口响应变慢的情况,后来逐步优化,才让工具在多人同时标注时也能保持流畅。
6.1 并发控制与 GPU 利用率
SAM 推理是显存和算力密集型操作,如果每个请求都独立占资源,两块 GPU 也可能被瞬间打爆。我一开始直接让 FastAPI 并发处理所有请求,结果 8 个标注员同时点击,显存直接蹭到上限,部分请求因为 CUDA OOM 报错,体验极差。
后来我在推理封装前加了一个 asyncio.Semaphore,用于限制同时进入模型推理的请求数量。以 12GB 显存为例,把并发上限控制在 2 或 3,实测在保持 GPU 利用率较高的同时,单请求延迟也没有明显劣化。
python复制import asyncio
sem = asyncio.Semaphore(2)
async def predict_with_limit(*args, **kwargs):
async with sem:
return await model_wrapper.predict(*args, **kwargs)
这里多句嘴,并发数的选择不是越大越好。GPU 的算力是固定的,多个推理请求同时进行只会让它们互相排队,同时还增加显存压力。控制在能够“喂饱”GPU 的最小并发数,通常就是最佳配置。具体数值要配合显卡型号和显存实测,不要照抄别人的参数。
6.2 推理缓存与异步结果
除了显存控制,缓存是另一个关键优化点。我在后端实现了一个简单的 image_id -> 图像嵌入特征 缓存,第二次及之后的请求可以直接复用第一次的编码结果。对于交互式标注这种“同一张图多次请求”的场景,收益非常明显。
另外,掩码转 PNG 这类 IO 操作,我最初是同步做的,后来发现标注员在连接状态不好时,等待时间明显增加。于是我把掩码转换、Base64 编码这些耗时操作丢进线程池,让接口先返回 JSON 元信息模型再异步补一份结果。不过这里要注意,不要为了异步而异步,确认类操作(比如保存标注记录)还是得同步完成,保证标注不丢。
6.3 Docker 部署与接口压测小记
部署方式我选择了 Docker 加 docker compose,这样不管是内网一台机还是云主机,拉起环境都很方便。Dockerfile 里需要安装 PyTorch 的 GPU 版本,镜像体积会比较大,建议构建时把开箱即用的 CUDA 依赖打进镜像,运行时不指定 GPU 也能用 CPU 兜底。
dockerfile复制FROM python:3.10-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
部署后我做了一次粗粒度的压测:单张 1200x900 的图片,使用同一个提示点,连续请求 100 次。在并发数为 2 时,平均单次推理耗时约为 130 毫秒到 180 毫秒,每秒能完成约十几次分割请求,足够支撑 3 到 5 名标注员的日常工作。
7. 踩坑实录与排查方法
这部分我专门留出来写实战过程中遇到的最典型的几个问题,都是文档里找不到、只有跑起来才会碰见的。按“现象-原因-解法”的方式整理成速查表,后续排查时可以对照着看。
7.1 掩码是花的、位置偏移或颜色诡异
这是我调试阶段遇到最多的问题,通常有几种可能:一是图片通道顺序错误,OpenCV 读出来是 BGR,但 SAM 的预处理器要求 RGB,忘了转换就会导致分割质量直线下降;二是提示点坐标没有归一化,前端把实际像素坐标直接传给了后端,模型会在错误的位置做推理;三是归一化时除以了显示尺寸而不是原图尺寸,导致坐标比例不一致。
排查方式其实很简单:写一个测试请求,用一个已知中心点提示,观察返回掩码是否出现在原图对应位置。如果偏差超过 10% 像素,八九不离十是坐标换算问题;如果分割边界在但内容不对,优先查通道顺序和预处理。
7.2 显存一路涨不降
标注过程中发现显存占用不断攀升,最后 OOM,这是最让人头疼的问题之一。原因是 SAM 推理时如果没有在 torch.no_grad() 环境下执行,模型会自动累积计算图,梯度数据一直保留在显存中,越积越多。
解法是在推理函数内统一包裹 with torch.no_grad():,同时确认 model.eval() 已经调用。另外,图片缓存如果一直往字典里塞而不清理,即使不推理也会压满显存。因为每张图片的嵌入特征体积不小,缓存超过几十张图后,显存压力就会很明显。我的做法是使用 OrderedDict 做 LRU 缓存,限制最多缓存 20 张图的特征。
7.3 FastAPI 收到请求后长时间无响应
用户点击后前端转圈,后端那边看起来也没报错,就是一直不返回。问题出在事件循环被 CPU 密集操作阻塞了:如果模型封装里同步执行了 CPU 端的预处理、后处理,而路由使用 async def 直接调用,这些代码会阻塞住整个事件循环,导致其他请求全部排队。
解法是统一用 run_in_executor 把这类操作放到线程池执行,并给请求设置合理的超时时间。除了超时时间,FastAPI 层面还可以配置 limit_concurrency 和 timeout 中间件,把单请求最长时间限制住,避免个别卡死请求拖着整个服务。
| 问题现象 | 常见原因 | 排查方向 |
|---|---|---|
| 掩码位置偏移 | 坐标未归一化或除以错误宽高 | 打印前后端坐标,对比中间值 |
| 分割质量差/花斑 | BGR/RGB 通道顺序错误 | 转 RGB 再进模型 |
| 显存持续上升 | 未用 no_grad、缓存未清理 | 检查推理上下文与缓存长度 |
| 接口长时间不返回 | 事件循环被 CPU 操作阻塞 | 使用线程池执行 CPU 密集操作 |
| 掩码边缘有细碎空洞 | 低分辨率掩码直接放大 | 先做连通域过滤,再考虑插值 |
我个人做了一个多星期的标注工具实测,最大的感受是:半自动标注带来的效率提升不只体现在“快”,更体现在“稳”。不管是新手还是老手,用同一个模型生成候选掩码,标注结果的一致性好到超出预期。工具链跑通之后,后续还能往几个方向扩展:把 SAM 的图像编码换成更轻量的骨干网络,减少显存占用;接入文本提示,输入自然语言描述直接分割目标;或者结合主动学习,从历史标注中挑出最难的样本优先人工确认。如果你想在自己的数据上试试这套方案,我建议先跑通一个最小闭环:一张图、一个点击提示、一次分割返回,然后再逐步叠加框选、负样本修正和 COCO 导出,这样调试起来会轻松很多。
