SAM3与FastAPI实战:搭建智能图像标注工具
基于SAM3与FastAPI构建智能图像标注工具,解决传统标注耗时痛点。SAM3支持开放词汇分割,输入文本即可自动分割。后端封装SAM3Service实现懒加载、特征缓存及多种分割模式,掩码可视化由后端生成PNGbase64。前端采用react-konva实现画布交互,API遵循RESTful风格,支持批量标注与导出。
为什么要做这个工具
在训练目标检测或实例分割模型时,最令人头疼的环节是什么?答案大概率不是模型训练本身,而是数据标注。传统标注工具,比如 LabelImg 或 CVAT,依然需要人工逐个框选和描边。随手标注 100 张图片,花上整整一天已经算是快的。这个痛点,几乎是所有从事计算机视觉项目开发者的共同记忆。

2025 年 11 月成了一个重要的转折点。Meta 发布的 SAM3(Segment Anything with Concepts)首次支持开放词汇分割:你只需输入一个文本短语,比如 "person"、"crack" 或 "cell",模型就能自动分割出图中所有匹配的实例。标注效率从“逐个描边”直接跃升到了“说一个词就全标好”。
但 SAM3 毕竟只是一个模型,并非顺手可用的标注工具——没有界面、没有标注管理、没有数据导出。正是在这样的背景下,基于 SAM3 源码搭建一个完整的 Web 端标注工具,成了一个很自然的实践方向。本文就完整记录了这个开发过程。
技术选型
| 层级 | 选型 | 理由 |
|---|---|---|
| AI 模型 | SAM3(本地部署) | 开放词汇分割 + 交互式分割,最适合标注场景 |
| 后端 | FastAPI | Python 生态,与 SAM3 同语言,异步高性能 |
| 前端 | React + TypeScript + Ant Design | 组件生态成熟,开发效率高 |
| 画布 | react-konva | Canvas 2D 渲染,支持图片叠加、鼠标交互、图形拖拽 |
| 掩码编解码 | pycocotools | COCO 标准 RLE 格式,兼容性优秀 |
整体架构
界面采用经典的三栏布局:左侧是图片列表,中间是画布区域,右侧则负责工具和标注管理。简单来说:
┌──────────────┬──────────────────────────┬──────────────────┐│图片列表 │ 画布区域│ 工具面板││││││ 批量上传││ 单张上传││ 批量自动标注│ 图片 + 掩码叠加 │ 文本/点击/框选││ 缩略图列表│ 点击标记 / 框选预览│ 分割结果列表││ 标注状态│ 多边形顶点编辑│ 已保存标注││││ 导出 YOLO/COCO │└──────────────┴──────────────────────────┴──────────────────┘
前后端通过 REST API 通信。掩码数据采用 RLE 编码压缩传输,掩码的可视化效果——半透明填充和轮廓描边——则由后端生成 PNG 图片并通过 base64 传给前端。
后端核心:SAM3 模型服务封装
后端的核心是一个名为 SAM3Service 的类,它统一负责模型加载、图像特征缓存和分割推理。
模型懒加载
SAM3 模型的体积比较大,加载一次要花好几秒时间。所以我们采用了懒加载策略:只有在接到首个请求时,才真正初始化模型。一次性加载好比慢工出细活,后续调用就像快捷键一按就出结果。
class SAM3Service:def __init__(self, max_cache_size=10):self._model = Noneself._processor = Noneself._lock = threading.Lock()self._state_cache = OrderedDict()# LRU 缓存self._max_cache_size = max_cache_sizedef _ensure_model(self):if self._processor is not None:returnwith self._lock:if self._processor is not None:returnself._model = build_sam3_image_model(enable_inst_interactivity=True,# 开启点击分割支持)self._processor = Sam3Processor(self._model, confidence_threshold=0.5)
需要特别留意的是 enable_inst_interactivity=True 这个参数。开启它之后,模型会额外加载一个兼容 SAM1 的交互式预测器,这样一来,点击分割和框选分割就都能支持了。
图像特征缓存
set_image() 是整个流程中最慢的一步——它需要完整运行一次视觉编码器。所以一旦特征计算好了,就必须放进缓存,后续的分割操作只需跑轻量的文本编码或解码头即可。
def load_image(self, image_id, image):self._ensure_model()with torch.autocast("cuda", dtype=torch.bfloat16), torch.inference_mode():state = self._processor.set_image(image)self._put_state(image_id, state)# LRU 缓存return {"image_id": image_id, "width": image.size[0], "height": image.size[1]}
缓存采用 LRU(最近最少使用)策略。当缓存超过上限时,自动淘汰最久未使用的 state,并主动释放 GPU 显存空间:
def _put_state(self, image_id, state):self._state_cache[image_id] = stateself._state_cache.move_to_end(image_id)while len(self._state_cache) > self._max_cache_size:_, evicted = self._state_cache.popitem(last=False)self._release_state_tensors(evicted)# 释放 GPU 张量
三种分割模式
文本分割:调用 Sam3Processor.set_text_prompt(),输入一个文本短语,模型就会返回所有匹配实例的掩码。
def text_prompt(self, image_id, text):state = self._get_or_load_state(image_id)state = self._processor.set_text_prompt(text, state)return self._format_result(state)
点击分割:调用 model.predict_inst()(它使用的就是 SAM1 兼容接口),传入正负点坐标即可。
def click_prompt(self, image_id, points, labels):state = self._get_or_load_state(image_id)# 归一化坐标 → 像素坐标point_coords = np.array([[p[0] * img_w, p[1] * img_h] for p in points])point_labels = np.array(labels)# 单点用 multimask 选最佳,多点用 single maskuse_multimask = len(points) == 1masks_np, scores_np, _ = self._model.predict_inst(state,point_coords=point_coords,point_labels=point_labels,multimask_output=use_multimask,)
关键细节在于:predict_inst 会从 Sam3Processor.set_image() 已经计算好的 backbone_out 中提取特征,不再需要重新跑视觉编码器。这才是效率提升的核心所在——第一次加载图片确实慢,要几秒,但后续点击分割的响应时间在毫秒级。
框选分割:同样调用 predict_inst,只不过这次传入的是 box 参数。
def box_prompt(self, image_id, box, label):state = self._get_or_load_state(image_id)cx, cy, w, h = boxbox_pixels = np.array([(cx - w/2) * img_w, (cy - h/2) * img_h,(cx + w/2) * img_w, (cy + h/2) * img_h,])masks_np, scores_np, _ = self._model.predict_inst(state, box=box_pixels, multimask_output=False,)
掩码可视化
掩码的可视化统一由后端生成,前端省去复杂的像素操作。核心函数是 _generate_overlay,它会生成一张结合半透明填充和轮廓描边的 PNG 图片:
def _generate_overlay(masks, img_h, img_w, colors=None):overlay = np.zeros((img_h, img_w, 4), dtype=np.uint8)for i, mask in enumerate(masks):color = colors[i % len(colors)]binary = mask > 0.5# 半透明填充overlay[binary, :3] = coloroverlay[binary, 3] = 80# 轮廓检测 + 膨胀edge = np.zeros_like(binary, dtype=bool)edge[1:, :] |= binary[1:, :] != binary[:-1, :]edge[:-1, :] |= binary[1:, :] != binary[:-1, :]edge[:, 1:] |= binary[:, 1:] != binary[:, :-1]edge[:, :-1] |= binary[:, 1:] != binary[:, :-1]thick_edge = binary_dilation(edge, iterations=1)overlay[thick_edge, :3] = coloroverlay[thick_edge, 3] = 255# 编码为 PNG base64img = PILImage.fromarray(overlay, 'RGBA')buf = io.BytesIO()img.sa ve(buf, format='PNG', optimize=True)return base64.b64encode(buf.getvalue()).decode('utf-8')
轮廓检测的原理很直观:如果一个像素是前景(mask=1),但其上下左右邻居中有背景像素(mask=0),那这个像素就被归类为边缘。接着用 binary_dilation 膨胀 1 个像素,让轮廓线显得更清晰。
最终生成的 PNG 通过 base64 编码放在 API 响应的 overlay 字段中,前端只需把它作为 Image 渲染到 Canvas 上即可。
API 设计
所有接口都遵循 RESTful 风格:
POST /api/image/upload# 单张上传(自动提取特征)POST /api/image/upload_batch# 批量上传(仅保存文件)GET/api/image/list# 图片列表(含标注数量)GET/api/image/{id}/thumbnail# 缩略图GET/api/image/{id}/file # 原始图片POST /api/prompt/text # 文本分割POST /api/prompt/click# 点击分割(累积正负点)POST /api/prompt/box# 框选分割POST /api/prompt/reset# 重置POST /api/annotation/sa ve # 保存标注GET/api/annotation/{image_id} # 查询标注DELETE /api/annotation/{id} # 删除标注GET/api/export/coco # 导出 COCO JSONGET/api/export/yolo # 导出 YOLO 格式 zipPOST /api/batch/auto_label# 批量自动标注(SSE 流式进度)
所有分割接口的响应格式是统一的:
{"masks": ["
前端核心:画布交互
画布组件基于 react-konva。最直接的挑战是:如何在同一个 Canvas 上同时叠加原始图片、掩码 overlay、点击标记、框选预览和多边形编辑。
图片自适应
画布需要自适应容器的宽度,同时还要限制最大高度,防止溢出:
const maxWidth = containerWidth - 16;const maxHeight = window.innerHeight * 0.85;const scaleByWidth = imageWidth > 0 ? maxWidth / imageWidth : 1;const scaleByHeight = imageHeight > 0 ? maxHeight / imageHeight : 1;const scale = Math.min(scaleByWidth, scaleByHeight, 1);const displayWidth = imageWidth * scale;const displayHeight = imageHeight * scale;
点击模式的左右键处理
这里有个实操中特别容易踩的坑:Canvas 的 onClick 事件并不响应鼠标右键。所以必须改用 onMouseUp 统一处理:
const handleMouseUp = useCallback((e) => {const isRightClick = e.evt.button === 2;if (toolMode === 'click') {const label = isRightClick ? 0 : 1;// 右键=负向,左键=正向onClickPrompt({ x: nx, y: ny, label });}if (toolMode === 'box' && boxStart) {onBoxPrompt([cx, cy, nw, nh], !isRightClick);}}, [...]);
同时还需要禁用浏览器的默认右键菜单:
const handleContextMenu = useCallback((e) => {e.evt.preventDefault();}, []);
多边形顶点编辑
已经保存的标注可以转换为多边形轮廓进行精细编辑。后端使用 OpenCV 提取轮廓并进行顶点简化:
def mask_to_polygon(mask, tolerance=2.0):contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)polygons = []for contour in contours:approx = cv2.approxPolyDP(contour, tolerance, True)if len(approx) >= 3:polygons.append(approx.reshape(-1).tolist())return polygons
前端用 react-konva 的 Line(多边形轮廓)和 Circle(顶点)进行渲染。顶点支持拖拽移动、双击删除,点击边中点还可以插入新的顶点。
批量自动标注
批量标注功能是整体效率提升的关键一环。用户上传一个文件夹的图片,输入文本 prompt 和类别名,后端会逐张处理,并通过 SSE(Server-Sent Events)实时推送进度:
async def batch_auto_label(req: dict):def generate():for idx, image_id in enumerate(image_ids):# 按需加载图片特征if sam3_service._get_state(image_id) is None:image = Image.open(file_path).convert("RGB")sam3_service.load_image(image_id, image)# 文本分割result = sam3_service.text_prompt(image_id, text)# 保存标注for i in range(result["count"]):_annotations.append({...})yield f"data: {json.dumps({'status': 'done', 'count': sa ved})}nn"return StreamingResponse(generate(), media_type="text/event-stream")
前端通过 Fetch API 读取 SSE 流,从而实时更新进度条。
数据导出
YOLO 格式
导出内容是一个 zip 包,里面包含 images/、labels/ 和 classes.txt:
# 将像素坐标的边界框转为 YOLO 归一化格式cx = ((box[0] + box[2]) / 2) / img_wcy = ((box[1] + box[3]) / 2) / img_hw = (box[2] - box[0]) / img_wh = (box[3] - box[1]) / img_hline = f"{class_id} {cx:.6f} {cy:.6f} {w:.6f} {h:.6f}"
COCO JSON 格式
掩码使用 pycocotools 的标准 RLE 编码,与 Detectron2、MMDetection 等主流框架直接兼容。
开发中踩过的坑
1. reset_all_prompts 没有返回值
SAM3 的 Sam3Processor.reset_all_prompts() 是原地修改 state,不返回新对象。一旦写成 state = processor.reset_all_prompts(state),state 就会变成 None,后续所有操作全报错。
2. 点击分割的 multimask 策略
SAM 的推荐做法是:单点用 multimask_output=True(返回 3 个候选,取最佳),多点用 multimask_output=False(返回 1 个综合结果)。如果多点用了 multimask,模型可能会选一个只覆盖局部的掩码。
3. 框选分割不能用 add_geometric_prompt
Sam3Processor.add_geometric_prompt 要求先有文本 prompt;没有文本时它会用 "visual" 占位,结果导致整张图里所有对象都被检测出来。框选正确做法是用 predict_inst 的 box 参数——它是独立的交互式分割,不依赖文本。
4. RLE 编解码的行列顺序
COCO 的 RLE 是按列展开的(Fortran order)。自己手写编解码时很容易搞错行列顺序,造成掩码显示为竖条纹。建议直接使用 pycocotools 的标准实现,不要再造轮子。
5. antd Upload 的 directory 模式重复触发
antd 的 Upload 组件在 directory 模式下,beforeUpload 会为每个文件触发一次,且每次都携带完整的 fileList。需要借助 ref 做防重复处理,否则同一批文件会被上传多次。
本文基于实际开发过程整理,SAM3 模型版本为 SAM 3.1(2026 年 3 月),前端使用 React 18 + Ant Design + react-konva,后端使用 FastAPI + PyTorch。
游乐网为非赢利性网站,所展示的游戏/软件/文章内容均来自于互联网或第三方用户上传分享,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系youleyoucom@outlook.com。
同类文章
SAM3与FastAPI实战:搭建智能图像标注工具
基于SAM3与FastAPI构建智能图像标注工具,解决传统标注耗时痛点。SAM3支持开放词汇分割,输入文本即可自动分割。后端封装SAM3Service实现懒加载、特征缓存及多种分割模式,掩码可视化由后端生成PNGbase64。前端采用react-konva实现画布交互,API遵循RESTful风格,支持批量标注与导出。
开源SKILL让公众号排版不再是噩梦
一个开源SKILL(gzh-design-skill)可将Markdown转为样式全内联的HTML,粘贴到公众号编辑器后格式不丢失。内置六套主题,支持主题生成器,通过组件库和校验脚本保障排版效果,不依赖特定AI模型,适配多种国产模型。
年AI Agent生态全面爆发:从MCP协议到智能体协作开发者必抓浪潮
2026年AIAgent生态全面爆发,MCP协议成为行业标准,推动工具统一调用。ClaudeCode等自主编码Agent实现多文件重构与自主排错。多Agent协作系统成熟,开发效率提升超400%。LangGraph、CrewAI等开源框架加速落地,Agent从概念转向生产力工具。
图片放大模糊原因:从插值到AI的超分辨率演进
图片放大模糊源于信息量固定,摊薄后需补像素。插值仅做数学过渡,无法恢复细节。深度学习超分通过数据学习先验,GAN生成逼真纹理但会编造细节。超分是合理推断,不可用于还原真相。
豆包AI对话数据高效导出与智能体资产迁移实战方案
针对豆包AI平台缺少批量导出功能的问题,提出一套技术方案:通过浏览器控制台脚本、自动化框架或社区扩展实现对话记录全量加载与结构化导出,支持JSON、Markdown等格式,并可应用于模型微调、行为分析、知识沉淀及跨平台迁移,同时强调数据隐私与平台合规要求。
- 热门数据榜
相关攻略
2026-07-11 14:38
2026-07-11 14:38
2026-07-11 14:37
2026-07-11 14:37
2026-07-11 14:37
2026-07-11 14:37
2026-07-11 14:37
2026-07-11 14:37
热门教程
- 游戏攻略
- 安卓教程
- 苹果教程
- 电脑教程

