SAM 这个词这两年出现的频率太高了,高到什么程度呢——很多时候它已经不是"一个模型"的意思,而是变成了"分割"这个动作的代名词。我自己第一次接触 segment anything 是在做一个遥感地块提取的小项目,当时想的是拿它当个免费的预标注工具,结果从下载权重到跑通第一张图,前后折腾了大半天,踩的坑基本都是环境、显存、通道顺序这三类。后来陆陆续续在几台不同配置的机器上做过 SAM 本地部署,从 8G 显存的笔记本到 24G 的工作站都试过,也把手里的流程整理成了一套能复用的东西。这篇就把整个本地部署过程拆开讲清楚,包括模型结构里那几个容易混淆的概念(比如经常有人问的 PVS)、版本怎么选、参数怎么调、8G 卡怎么救,以及在实际业务场景里它到底能干什么、干不了什么。不管你是刚听说 SAM 想跑个 demo,还是已经在做分割类项目想找个稳定的本地推理方案,下面这些内容应该都能直接用上。
1. 先说清楚:SAM 本地部署到底解决什么问题
1.1 SAM 和普通分割模型的本质区别
传统的语义分割模型,比如 DeepLab、UNet 那一类,思路是"训练一个模型 → 固定输出某几类结果"。你要分割道路,就得有道路的标注数据;你要分割耕地,就得有耕地的标注数据。类别一旦定死,模型的能力边界也就定死了,想加一个新类别,不好意思,重新标注、重新训练。
SAM 走的是另一条路。它把"分割"这件事从"分类问题"重新定义成了"提示响应问题"——你给它一张图,再给它一个提示(点、框、粗略掩码),它把提示对应的物体轮廓吐出来。它不知道这是个什么东西,也不需要知道,它只负责"边界"。这种能力在论文里叫 promptable segmentation,也就是"可提示分割"。
这个转变带来的实际价值非常直接。假设你手上有五千张图片要做标注,传统流程是一张张手动画多边形,一个人一天能画几十张就不错了。用 SAM 的话,你只需要在每个目标上点一下,掩码就出来了,剩下的是人工修边,效率能提高好几倍。这就是为什么 SAM 在数据标注团队里普及得这么快——它不是一个业务模型,它是一个生产力工具。
1.2 本地部署 vs 在线调用的取舍账
很多人第一反应是"我直接调接口不就行了",确实,云端调用上手最快,写几行代码就能出结果。但真到了项目里,问题会一个个冒出来。
第一是数据问题。做遥感、医疗、工业质检这些方向的,手上跑的影像往往不方便往外传,有些还有明确的授权范围限制,能不出本地就不出本地。第二是成本问题,SAM 这种模型单张推理的计算量不小,尤其是批量跑自动分割,图片一多,按量计费的账算下来很吓人。第三是稳定性,接口有并发限制、有超时、有版本变更,你的流水线跑到一半突然挂掉,排查起来非常难受。
本地部署一次搞定之后,后面就是纯电费和显卡折旧了。批量跑一万张图,机器开着跑一晚上,成本基本可以忽略。而且你能完全控制预处理、后处理、批大小、精度,想怎么优化就怎么优化。代价是前期要花时间把环境弄对,这也是本文的主要篇幅所在。
1.3 显存和硬件门槛的真实账单
先把最现实的问题摆出来:SAM 有三个官方版本,ViT-B、ViT-L、ViT-H,权重文件分别是 375MB、1.2GB、2.4GB 左右。显存占用不是跟着权重文件走的,而是跟着图像编码器的前向计算走的,因为输入被固定成了 1024×1024。
我在几台机器上实测过单张图推理的峰值显存(fp32,batch=1):ViT-B 大约 2.5GB 到 3GB,ViT-L 大约 4.5GB 到 5.5GB,ViT-H 大约 6.5GB 到 7.5GB。注意这只是"跑一张图"的占用,如果开自动全图分割,points_per_batch 设成 64,还会再往上加个一两 G。所以结论很清晰:8G 显存可以用 ViT-H 做单点交互式分割,但做自动全图分割会紧;6G 显存建议 ViT-B 起步;4G 显存就得考虑半精度或者换 MobileSAM 这类轻量版本了。
CPU 也能跑,但速度嘛,ViT-H 在普通桌面 CPU 上编码一张 1024 的图要十几秒到几十秒,交互体验基本没了。如果只是离线批处理、数量不大,CPU 也能凑合,但心理预期得放低。
2. SAM 模型结构拆解:搞懂三件套再动手
2.1 Image Encoder:真正吃显存的那一环
SAM 的结构可以粗暴地理解成三个零件拼起来:Image Encoder、Prompt Encoder、Mask Decoder。这三个的分工和开销差异巨大,搞清楚了,后面优化才有方向。
Image Encoder 是一个 MAE 预训练过的 ViT。输入图像先被缩放到长边 1024,短边补齐到 1024,变成一张 1024×1024×3 的图。ViT-H 用的 patch 大小是 16,所以切出来是 64×64 个 patch,也就是 4096 个 token,每个 token 的嵌入维度是 1280(ViT-L 是 1024,ViT-B 是 768)。ViT-H 有 24 层 Transformer block,ViT-B 只有 12 层。
这个环节是整个模型里最贵的。参数量上 ViT-H 的编码器大约 6.3 亿参数,ViT-B 大约 9000 万,差了七倍。耗时上,在消费级显卡上 ViT-H 编码一张图大概 0.5 到 1 秒,ViT-B 大概是它的三分之一到四分之一。
但关键的一点是:对于同一张图,Image Encoder 只需要跑一次。跑完之后得到的那个 64×64×256 的图像嵌入,可以被反复使用。你在这张图上点一百次、画十个框,编码器都不用再算一遍。这个特性决定了交互式标注场景下的体验非常流畅——第一次点击慢一点,后面几乎瞬间出结果。
2.2 Prompt Encoder:点和框怎么变成模型能懂的向量
Prompt Encoder 做的事情很轻量,它负责把你的提示信息(点坐标、框坐标、已有掩码)编码成向量,塞给后面的解码器。
点提示的处理方式是位置编码加上一个可学习的前景/背景嵌入。你给的每个点有两个属性:坐标和标签(1 表示前景、0 表示背景)。代码里就是你传point_labels数组,1 和 0 混着来,告诉模型"这个点是我要的东西,那个点是背景"。框提示则是把它拆成左上角和右下角两个角点,用一对特殊的学习嵌入来表示"这是框的起点""这是框的终点",再加位置编码。掩码提示走的是另一条路,用卷积把低分辨率掩码下采样后跟图像嵌入相加。论文里还提到了文本提示,用的是 CLIP 的文本编码器,但官方开源版本并没有放出文本提示的接口,所以实际用不到。
这个模块本身几乎不占显存,计算量也小到可以忽略,但它是整个交互逻辑的入口,坐标格式、标签含义搞错了,结果就会很奇怪。
2.3 Mask Decoder:为什么一次要吐三个掩码
Mask Decoder 是一个很轻的 Transformer,只有两层,但它有个设计非常关键:一次输出三个候选掩码,附带三个预测 IoU 分数。
原因很简单:歧义。你在一件衣服上点一下,这个点可能指的是整件衣服,也可能是衣服上的一个口袋,或者衣服上的一块印花。人看到提示的时候心里有明确指向,但模型没有这个上下文。所以 SAM 干脆把几种可能的解释都输出出来,用预测 IoU 排个序,让使用者自己挑,或者通过多轮交互(加正负点)逐步收敛。
这三个掩码是从低分辨率的 logits 上采样来的,原始输出是 256×256,上采样 4 倍回到 1024×1024,再裁掉前面的 padding 区域,恢复成原图尺寸的二值掩码。multimask_output=True时返回三个,False时只返回一个(这个是三个里综合得分最高的那个的变体,通常在框提示下更准)。
理解了这个"三个掩码"的机制,你在写代码时就不会困惑于"为什么我拿到了三个结果",也知道该怎么按分数挑选。
2.4 PVS 到底是什么,和图像分割有什么关系
经常有人在讨论里问 PVS 是什么意思,这里单独说一下。
PVS 是 Promptable Visual Segmentation 的缩写,中文一般译成"可提示视觉分割"。它是 SAM 论文里提出的一个任务设定,但不是图像上的任务,是视频上的任务。做法是:在视频的第一帧上给提示(点、框、掩码都行),模型生成一个初始掩码,然后把这个掩码作为下一帧的提示传播下去,逐帧生成分割结果。如果某一帧分错了,你可以暂停,在这一帧上补一个点,模型会基于这个修正重新把后续帧刷一遍。
所以 PVS 更像是 SAM 从静态图像往视频方向的一个能力延伸,官方开源的那版代码并没有直接提供视频 PVS 的完整实现,真正把这个方向做成可用产品的是后来的 SAM 2,它在架构里加了记忆机制,能实时处理视频流。如果你手上的任务只涉及单张图片,PVS 这个概念知道就行,不影响你部署和使用;如果要做视频分割,直接看 SAM 2 而不是原始 SAM。
3. 版本选型:ViT-B / ViT-L / ViT-H 怎么选
3.1 三档权重的硬指标对比
选版本本质上是在精度、速度、显存三者之间做取舍。下面这张表是我自己整理的实际参考值,测试环境是一张 12G 显存的卡,输入 1024×1024,batch=1,fp32 精度:
| 版本 | 权重文件 | 编码器参数量 | 单图编码耗时 | 峰值显存 | 自动分割耗时(32×32 点) |
|---|---|---|---|---|---|
| ViT-B | 约 375MB | 约 0.9 亿 | 约 0.2-0.3s | 2.5-3GB | 约 25-40s |
| ViT-L | 约 1.2GB | 约 3.1 亿 | 约 0.5-0.7s | 4.5-5.5GB | 约 50-70s |
| ViT-H | 约 2.4GB | 约 6.3 亿 | 约 0.8-1.2s | 6.5-7.5GB | 约 90-150s |
精度上的差距,在常规自然图像上 ViT-H 确实明显更好,尤其是小目标、细长结构(比如栏杆、电线、树枝)的边界更干净。但到了专业领域影像上,比如遥感图或者工业检测图,三者的差距会被拉小——因为训练数据分布本来就偏自然图像,谁的泛化优势都有限。
3.2 生态里的几个替代方案
官方 SAM 之外,社区里冒出了不少变体,实际项目里值得关注的有这几个。
MobileSAM 是把图像编码器换成了一个轻量化的 TinyViT,权重只有 40MB 左右,编码速度比 ViT-H 快十几倍,显存占用降到 1G 以内。代价是细粒度边界会糊一些。如果你的场景是"大致圈出目标区域",比如遥感地块粗提取,MobileSAM 完全够用。
FastSAM 走的是完全不同的路线,它基于 YOLOv8-seg,为了速度牺牲了提示的灵活性,不支持任意的点提示交互,但对"全图自动分割所有物体"这类任务速度极快。
EfficientSAM 是另一套轻量化思路,用掩码图像预训练来蒸馏,精度和速度的平衡做得不错。
SAM 2 则是在原版基础上加了视频能力,图像分割的精度也有提升,如果你的任务里带视频或者时序影像,建议直接上 SAM 2。缺点是显存开销比原版更大。
3.3 我的选型建议
给一个实操层面的决策路径:先用手上的显卡跑一遍 ViT-H 的自动分割,如果能过,就用它;如果 OOM 或者慢到不能接受,退到 ViT-B 再试;如果 ViT-B 也不行,或者要做交互式标注需要极致响应速度,换 MobileSAM。
还有一个容易被忽略的点:提示方式不同,对模型版本的要求也不同。框提示下 ViT-B 和 ViT-H 的差距很小,因为框已经把空间范围框死了,模型只需要在里面找边界。点提示下差距才明显,因为所有判断都得靠模型自己。所以如果你的流程里主要用框做提示(比如从检测模型拿检测框,再送进 SAM 精修边界),用 ViT-B 是性价比最高的选择。
4. 环境准备与依赖安装:从驱动到 PyTorch
4.1 显卡驱动与 CUDA 的对齐
这一步是翻车重灾区,一定要按顺序来。
先在终端里确认驱动情况,用nvidia-smi看输出里的 Driver Version 和 CUDA Version。注意这里显示的 CUDA Version 是驱动支持的最高运行时版本,不是已经装好的版本,很多人在这里理解错了。
驱动版本决定了你最多能用哪个 CUDA 版本的 PyTorch。比如驱动显示支持 CUDA 12.1,那你就不能装 cu124 版本的 PyTorch,装了会在 import 时报错或者直接回退到 CPU。要么升级驱动,要么选低版本的 PyTorch 预编译包。
最省事的做法是不管系统 CUDA,直接用 conda 或 pip 安装 PyTorch 官方提供的预编译 wheel,它自带所需的 CUDA 运行时库,跟你系统里装没装 CUDA Toolkit 关系不大。系统 CUDA 只在你要自己编译某些扩展时才需要。
我遇到过一次很典型的问题:机器上装了两个 CUDA 版本的 PyTorch,其中一个还是 CPU 版,结果 import torch 之后torch.cuda.is_available()一直是 False。排查办法就是打印torch.__version__和torch.version.cuda,看一眼torch.version.cuda是不是 None,是 None 就是装成 CPU 版了。
4.2 创建独立环境并安装 PyTorch
强烈建议用 conda 建独立环境,不要往系统 Python 里装。SAM 对 PyTorch 版本不算特别挑,1.7 以上的版本基本都能跑,但版本太新可能会撞上一些 API 变更。
conda create -n sam python=3.10 -y conda activate sam然后按你的 CUDA 版本选对应的安装命令。以 CUDA 11.8 为例:
pip install torch torchvision --index-url https://download.pytorch.org/whl/cu118CUDA 12.1 的话把 cu118 换成 cu121。装完之后验证一下:
python -c "import torch; print(torch.__version__, torch.version.cuda, torch.cuda.is_available())"三个输出分别是版本号、CUDA 版本、True,才算对了。如果最后一个是 False,先别往下走,把驱动和 PyTorch 版本对齐了再说。
4.3 安装 segment-anything 与依赖清单
官方仓库可以直接从 GitHub 装:
git clone https://github.com/facebookresearch/segment-anything.git cd segment-anything pip install -e .-e是开发模式安装,好处是后面你想改仓库里的代码(比如调参、加日志)可以直接改源码,不用重新安装。
另外还需要几个常用库:
pip install opencv-python numpy matplotlib pillow pycocotools这里要单独说pycocotools。自动掩码生成器里有个min_mask_region_area参数,用来过滤掉面积过小的碎掩码,这个过滤逻辑依赖 pycocotools。不装的话,只要这个参数不为 0 就会报 ImportError。而且 pycocotools 在 Windows 上编译容易出问题,Linux 上一般直接 pip 装就好。
4.4 权重下载与目录规划
权重文件不在 pip 包里,要单独下。官方提供三个下载地址,对应的文件名是:
sam_vit_h_4b8939.pth(ViT-H)sam_vit_l_0b3195.pth(ViT-L)sam_vit_b_01ec64.pth(ViT-B)
下完之后建议放一个统一的目录,我自己习惯是这样组织的:
project/ ├── weights/ │ ├── sam_vit_h_4b8939.pth │ └── sam_vit_b_01ec64.pth ├── images/ ├── outputs/ └── scripts/结构简单但有个好处:脚本里可以用相对路径引用权重,换机器的时候整个目录拷过去就能跑,不用改代码。文件名后面那串十六进制不要改,sam_model_registry加载的时候虽然不校验文件名,但保留原名字方便你以后确认版本。
注意:权重文件里的 model_type 必须和加载时指定的类型匹配。用
sam_model_registry["vit_h"]去加载 ViT-B 的权重,会在加载时报一堆 key 不匹配的警告,要么某些层随机初始化,要么直接报错。改文件名不会解决问题,得改成对应的类型字符串。
5. 最小可用推理:点提示和框提示跑通
5.1 图像预处理:最容易翻车的地方
在写推理代码之前,必须把通道顺序这件事说清楚,这是新手最常踩的坑,而且症状很隐蔽——不会报错,只是分割结果莫名其妙。
OpenCV 的cv2.imread读进来是 BGR 三通道,而 SAM 和 PyTorch 的所有图像处理都默认 RGB。如果你直接把 cv2 读的结果喂给 SAM,模型会看到一张颜色错乱的图,边界结果可能还是大差不差,但在颜色相近的区域会明显变差。所以一定要转:
image_bgr = cv2.imread("images/test.jpg") image = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2RGB)另外一个细节是数据类型。SAM 期望输入是 uint8 的 HWC 格式 numpy 数组,取值 0 到 255。如果你用的是 PIL 读图,同样要np.array()转成 numpy。如果做浮点运算之后忘了转回 uint8,输入会变成 0 到 1 的小数,模型会得到一张几乎全黑的图,分割结果基本是空的或者一团糊。
还有一点:SAM 内部会自己做 resize 和 padding(ResizeLongestSide,长边缩到 1024,短边补零),所以你不需要在外部再缩放,直接喂原图就行。这一点很多人会多此一举地先 resize 一遍,结果造成二次缩放,边界精度反而下降。
5.2 点提示分割的完整代码
下面这份代码是最小可运行版本,我自己在好几个项目里都从它改起:
import numpy as np import torch import cv2 from segment_anything import sam_model_registry, SamPredictor DEVICE = "cuda" if torch.cuda.is_available() else "cpu" CKPT = "weights/sam_vit_h_4b8939.pth" sam = sam_model_registry["vit_h"](checkpoint=CKPT) sam.to(device=DEVICE) sam.eval() predictor = SamPredictor(sam) bgr = cv2.imread("images/test.jpg") image = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB) with torch.inference_mode(): predictor.set_image(image) masks, scores, logits = predictor.predict( point_coords=np.array([[640, 360]]), point_labels=np.array([1]), multimask_output=True, ) print("scores:", scores) print("mask shapes:", [m.shape for m in masks])几个要点解释一下。
sam.eval()必须调,不调的话模型处于训练模式,虽然 SAM 里没有 BatchNorm 和 Dropout 之类的层,理论上影响不大,但养成习惯总是好的。
set_image是那个"贵"的操作,它内部跑了图像编码器,把self.features缓存下来。同一张图后面所有的predict调用都复用这个缓存,所以如果你的应用是在一张图上做多次交互,set_image只调用一次就够了。
point_coords的格式是[[x, y]],注意是 x 在前 y 在后,跟 numpy 图像的[row, col]索引刚好相反。这个也很容易搞反,搞反了会出现"我点的是物体 A,结果分割出了物体 B"这种诡异现象。
point_labels里 1 是前景、0 是背景。多轮交互的时候,你可以把第一轮点错的地方标成 0 加进数组,告诉模型"这里不要"。
返回值里masks是一个形状为(3, H, W)的布尔数组,scores是三个预测 IoU。挑哪个用取决于你的下游逻辑,做一个可视化对比的话,通常第一个(分数最高的)就是最合理的那个。
5.3 框提示与组合提示的写法
框提示在工程里用得比点提示更多,因为它更稳定、更可自动化。典型场景是你先用一个检测模型(YOLO 之类的)把目标框出来,再把框送进 SAM 精修边界,这样就能把"检测 + 分割"串成一条流水线。
with torch.inference_mode(): predictor.set_image(image) masks, scores, logits = predictor.predict( box=np.array([120, 80, 640, 520]), multimask_output=True, )框的格式是[x0, y0, x1, y1],左上角和右下角。
框和点还能一起用。有个小技巧:把框的两个角点坐标放进point_coords,标签分别给 2 和 3,再加上你自己的正负点,就能在一次调用里同时传达"大致范围在这里"和"这几个像素肯定是/肯定不是"。这在框不准的时候特别有用——框给个大概,点用来纠偏。
with torch.inference_mode(): masks, scores, logits = predictor.predict( point_coords=np.array([[320, 240], [500, 400], [200, 150]]), point_labels=np.array([1, 1, 0]), box=np.array([120, 80, 640, 520]), multimask_output=True, )另外,如果你要做多轮交互(用户点一下、看一下、再补一个点),可以把上一轮的logits作为mask_input传进去。注意要先挑一个 logits(三个里选一个),形状是(1, 256, 256),这样模型能利用上一轮的信息,收敛更快。
5.4 掩码后处理与结果导出
拿到布尔掩码之后,导出的格式取决于你的下游工具。要送进标注工具做 COCO 数据集,就用 pycocotools 的 RLE 编码,体积小、无损。要给人看或者做进一步图像处理,存成 PNG 最省事:
def save_masks(masks, out_dir, prefix): import os os.makedirs(out_dir, exist_ok=True) for i, m in enumerate(masks): png = (m.astype(np.uint8)) * 255 cv2.imwrite(f"{out_dir}/{prefix}_{i:03d}.png", png)想叠在原图上看效果,建议用带透明度的叠加,而不是硬贴上纯色块,因为边缘的对错在透明叠加下一眼就能看出来:
def overlay(image, mask, color=(0, 255, 0), alpha=0.5): out = image.copy() layer = np.zeros_like(out) layer[mask] = color return cv2.addWeighted(out, 1 - alpha, layer, alpha, 0)这里再提醒一个细节:cv2.imwrite写 PNG 的时候不会管通道顺序,你如果是 RGB 的图直接写,颜色会反。稳妥做法是在导出前转回 BGR。
6. 全图自动分割:Automatic Mask Generator 实战
6.1 自动分割和交互分割的区别
SamPredictor是"你给提示,它出结果"。SamAutomaticMaskGenerator是"你不给提示,它把图里所有能分的东西都分出来"。后者是很多离线任务的主力工具,比如给一批图片做无监督的候选区域提取。
它的工作原理是这样的:在图像上打一个均匀的点阵,每个点当成一个独立的点提示去调模型,然后把所有结果按质量过滤、去重,最后输出一堆掩码。因为图像嵌入只需要算一次,所以真正的耗时大头在 mask decoder 上——点阵有 1024 个点,批大小 64 的话要跑 16 批解码。这也是为什么很多人以为自动分割慢在编码器,其实不是,编码器只跑了一次。
6.2 参数逐个讲清楚
from segment_anything import SamAutomaticMaskGenerator mask_generator = SamAutomaticMaskGenerator( model=sam, points_per_side=32, points_per_batch=64, pred_iou_thresh=0.88, stability_score_thresh=0.92, stability_score_offset=1.0, box_nms_thresh=0.7, crop_n_layers=1, crop_nms_thresh=0.7, crop_overlap_ratio=0.34, crop_n_points_downscale_factor=2, min_mask_region_area=100, )points_per_side是点阵的边长,32 表示 32×32 一共 1024 个点。这是影响结果密度和耗时最直接的参数。调到 64,点数变成 4096,耗时大约翻四倍,小目标能分出来更多,但重复掩码也更多,需要 NMS 兜住。日常我用 32;做精细的小目标提取时用 48 或 64。
points_per_batch控制解码阶段一次处理多少个点提示。它跟显存直接相关,显存紧张就调到 16 或 32,速度慢一点但不会 OOM。
pred_iou_thresh是预测 IoU 的过滤阈值,低于这个值的掩码会被丢掉。默认 0.88,设得太高会漏掉一些边界复杂的目标,设得太低会混进大量噪声掩码。
stability_score_thresh衡量的是掩码的稳定性,做法是把 logits 在阈值 ±offset 的范围内变动,看掩码变化的 IoU 有多大。分数越高说明边界越稳定。默认 0.95 偏严格,实践中我一般放到 0.90 到 0.92,因为有些边缘模糊的目标(比如树冠、烟尘)稳定性天然就低。
box_nms_thresh是框级别的 NMS 阈值。因为同一片区域的多个点会生成高度重叠的掩码,需要按 IoU 去重。默认 0.7,如果你的目标是"尽量多保留不同尺度的层次结构"(比如既要有整棵树,也要有树叶),可以调高一点到 0.8。
crop_n_layers是裁剪层数,用来处理小目标。设成 1 时,图片会被裁成若干块,每块单独跑一遍点阵,所以点数大致翻五倍,耗时代价很大。但如果你图里有大量小物体,不裁剪的话点阵密度根本覆盖不到。设成 2 会再翻,一般不建议超过 2。
crop_overlap_ratio是裁剪块之间的重叠比例,默认约 0.34,是为了避免正好切在物体中间导致漏检。crop_n_points_downscale_factor控制每个裁剪层里点阵的稀疏程度,设成 2 表示裁剪层里的点阵密度是原始的一半。
min_mask_region_area是后处理过滤,把面积小于这个像素数的掩码扔掉。注意这个值必须配合 pycocotools 才有用。
6.3 一套可复用的调参起点
与其一个个试,不如从我常用的两套配置里挑一个改:
| 场景 | points_per_side | crop_n_layers | stability_score_thresh | min_mask_region_area | 说明 |
|---|---|---|---|---|---|
| 常规物体提取 | 32 | 0 | 0.92 | 100 | 速度快,适合主体目标较大的图 |
| 小目标密集场景 | 48 | 1 | 0.88 | 50 | 耗时长,适合细碎目标多的图 |
跑完之后每个掩码是一个字典,字段包括segmentation(布尔掩码)、area(像素面积)、bbox(xywh 格式的边界框)、predicted_iou、stability_score、point_coords(触发这个掩码的点)、crop_box。
按面积排序取前 N 个,或者按 bbox 面积过滤掉过大过小的,是最常用的后处理手段:
masks = mask_generator.generate(image) masks = sorted(masks, key=lambda x: x["area"], reverse=True) keep = [m for m in masks if 500 < m["area"] < 200000]批量处理的时候有个实践建议:把 mask generator 建一次,在循环里反复用,不要在循环里重复构造。构造本身开销不大,但如果每次都重新加载模型就完了。另外记得处理大图时先把图缩到合理尺寸(比如长边不超过 2000),否则编码器的 resize 会把细节压掉,而且点阵的单个点覆盖的原始像素范围会变得很大,小目标全被吞掉。
7. 小显存优化:8G 卡也能跑起来的几种办法
7.1 精度选择和推理模式
最简单的降显存手段是控制计算精度。有几个层次:
torch.inference_mode()比torch.no_grad()更彻底,它连中间激活的版本计数都不保存,显存和速度都有收益。养成习惯,推理代码一律用 inference_mode。
半精度是真正能省一半显存的做法。但这里有个坑必须说清楚:官方库的SamPredictor.predict内部会把坐标张量固定成torch.float32,如果你把模型.half()了,权重是 half 而输入是 float32,会直接报expected scalar type Half but found Float。要绕过这个,可以自己手动构造 prompt 并转换成 half,或者干脆不用半精度,换个更小的模型版本。
更稳妥的半精度方案是走 ONNX 或者 TensorRT,把整条链路按半精度导出,就没有这种前后 dtype 不一致的问题了。
torch.compile在较新的 PyTorch 上对编码器加速效果不错,尤其是 ViT 这种规整结构,实测有 20% 到 40% 的提速。首次编译会花几十秒,适合长驻服务。
7.2 分块推理和图像预缩放
如果显存实在不够,还有一个思路是让输入变小。但要注意,SAM 的ResizeLongestSide把长边固定到 1024,你如果在外部分块处理,每块都要各自补齐到 1024,反而会引入更多计算。
真正能省显存的分块做法是:把大图切成有重叠的小块,每块单独跑自动分割,最后把所有结果合并、跨块去重。这么做的好处不只是显存——因为每块在图内被放大到 1024,等效于提高了局部的分辨率,小目标的检出率会明显提升。代价是耗时和重复结果增多。
重叠比例建议设成块尺寸的 10% 到 20%,太小会漏掉跨边界的物体,太大会产生大量重复。跨块去重可以用掩码的 IoU 做,也可以用 bbox 的 NMS,后者更快。
还有一个常被忽略的省显存点:处理完一张图之后,把中间变量显式释放。Python 的垃圾回收在显存上不一定及时,循环里处理大批图片的时候容易累积泄漏:
import gc import torch for path in image_paths: with torch.inference_mode(): predictor.set_image(load(path)) ... del predictor.features gc.collect() torch.cuda.empty_cache()torch.cuda.empty_cache()会释放缓存分配器持有的空闲块,频繁调用会有一点性能代价,但在批处理循环里每张图调一次是可以接受的。
7.3 导出 ONNX 做工程化部署
如果你要把它做成一个服务,PyTorch 原生推理的启动开销和依赖体积都偏大。导出 ONNX 能解决两个问题:一是可以脱离 Python 环境部署,二是能用 ONNX Runtime 或者 TensorRT 做更激进的图优化。
编码器最容易导出,因为它的输入尺寸是固定的:
dummy = torch.randn(1, 3, 1024, 1024, device=DEVICE) torch.onnx.export( sam.image_encoder, dummy, "sam_vit_b_encoder.onnx", input_names=["input"], output_names=["embedding"], opset_version=17, )解码器导出麻烦一些,因为它内部有对提示数量和类型的动态处理。常见做法是把点提示和解码器绑成一个固定接口,比如"最多 8 个点 + 1 个框",这样形状就固定了,导出会顺利很多。
导出之后有个必须注意的点:预处理和后处理要自己实现,不能依赖原库。具体来说,你要自己写 resize 长边到 1024、padding 到 1024×1024、归一化(减均值除以标准差,均值是 123.675/116.28/103.53,标准差是 58.395/57.12/57.375)、以及输出的掩码上采样回原尺寸。这部分逻辑如果写错,输出会是一堆没意义的噪声,而且不报错。建议先用同一张图对比 PyTorch 和 ONNX 的输出,确认数值接近(一般允许 1e-3 量级的差异)再上线。
7.4 显存实测对照表
把前面这些优化手段的效果整理成一张表,方便你按自己的硬件直接对号入座:
| 配置 | 模型 | 精度 | 峰值显存 | 单图编码耗时 |
|---|---|---|---|---|
| 基准 | ViT-H | fp32 | 约 7GB | 约 1.0s |
| 降精度 | ViT-H | fp16 导出 | 约 3.8GB | 约 0.6s |
| 换小模型 | ViT-B | fp32 | 约 2.8GB | 约 0.25s |
| 编译加速 | ViT-H | fp32 + compile | 约 7GB | 约 0.65s |
| 轻量方案 | MobileSAM | fp32 | 约 1GB | 约 0.08s |
从这张表能看出来,最有效的降显存手段其实是换模型版本,而不是各种优化技巧。8G 卡上想跑 ViT-H 做自动分割,与其折腾半精度,不如老老实实用 ViT-B,省下的时间可以做别的事。
8. 踩坑记录与排查速查
8.1 安装和运行期的典型报错
下面这几个报错我在不同机器上都遇到过,按出现频率排序。
Could not run 'torchvision::nms' with arguments from the 'CUDA' backend
出现在自动掩码生成器做 NMS 的时候。原因是 torch 和 torchvision 版本不匹配,或者 torchvision 装成了 CPU 版。解决办法是卸载重装,两者用同一条命令一起装,让 pip 自己解析依赖。
CUDA out of memory
先看是什么阶段 OOM。如果是set_image就炸,说明编码器太大,退到 ViT-B。如果是生成掩码时炸,先把points_per_batch从 64 降到 16。如果是处理了一批图之后才炸,大概率是没释放缓存,加上前面说的del和empty_cache。
RuntimeError: input type (torch.cuda.FloatTensor) and weight type (torch.cuda.HalfTensor) should be the same
模型用了 half 但输入还是 float32。要么统一精度,要么别用 half。
分割结果是一团噪声或者全空
九成是图像格式问题。检查三件事:是不是 RGB、是不是 uint8、取值范围是不是 0 到 255。用print(image.shape, image.dtype, image.min(), image.max())三秒钟定位。
ImportError: pycocotools
min_mask_region_area不为 0 但没装 pycocotools。装一下,或者把参数设成 0。
8.2 分割质量不达预期的排查顺序
遇到"模型分出来的东西不对"的时候,别急着怀疑模型,按这个顺序查一遍,基本能定位到原因。
第一步看提示。点打在哪了?有没有落在背景上?坐标的 x y 有没有写反?框是不是把目标切掉了一半?我遇到过好几次是框的下边界正好压在物体底部,导致模型只能看到半个物体,出来的掩码自然也是半个。
第二步看输入。颜色通道对不对,缩放是不是做了两次,padding 有没有问题。有一个很隐蔽的情况是图片本身带 EXIF 旋转信息,PIL 读的时候会自动应用旋转,OpenCV 不会,两边处理同一张图会得到不同朝向的结果,提示坐标就全错位了。
第三步换提示类型。点提示效果差的时候试试框,框提示效果差的时候加上正负点。同一个目标,不同提示方式的结果差异可能很大。
第四步换模型版本。如果 ViT-B 效果明显不行,换 ViT-H 试一下,能区分是"模型能力不够"还是"数据本身有歧义"。
第五步才是考虑领域适配。如果确认是数据分布差异导致的(比如遥感影像、显微镜图像、工业 X 光片),那就得上微调了,零样本能力在这类数据上就是有限的。
8.3 常用操作速查表
| 问题 | 快速处理 |
|---|---|
| 想确认 CUDA 是否可用 | python -c "import torch;print(torch.cuda.is_available())" |
| 生成掩码时 OOM | points_per_batch降到 16,或points_per_side降到 16 |
| 小目标分不出来 | 开crop_n_layers=1,或points_per_side提到 48 |
| 掩码太碎太多 | min_mask_region_area提到 200,stability_score_thresh提到 0.93 |
| 层次结构丢太多 | box_nms_thresh提到 0.8,或stability_score_thresh降到 0.85 |
| 批量处理崩在中间 | 循环里加del features+empty_cache |
| 结果和 PyTorch 对不上(ONNX) | 检查预处理归一化的均值和标准差 |
9. 落地场景:从标注提效到耕地识别
9.1 数据标注流水线里的用法
这是 SAM 目前最成熟、最容易见效的用法。流程大致是这样:用SamAutomaticMaskGenerator对一批图片做粗分割,得到一堆候选掩码;再按业务规则筛选,比如面积范围、长宽比、位置;然后把筛选后的掩码转成标注工具能读的格式(COCO JSON 或者 LabelMe JSON),给标注员做初稿;人工只做修正,不做从零开始描边。
我们实际测下来,效率提升的幅度取决于场景。轮廓规整、对比度高的目标(比如瓶子、箱子、车辆)提升最明显,标注速度能到原来的三到五倍;边缘极不规则的(比如树叶、裂隙)提升有限,因为人工修边的时间跟重画差不了太多。
有几个工程细节值得注意。自动分割输出的掩码数量经常远超实际需要,一定要做过滤,不然标注员面对几百个碎片会崩溃。另外掩码是像素级的,转成多边形的时候要简化顶点,不然一个掩码几千个点,标注工具直接卡死。OpenCV 的approxPolyDP是常用的简化手段,epsilon设成周长的 0.5% 到 1% 比较合适。
9.2 遥感与耕地识别里的用法和局限
耕地识别这个方向我做过一段时间,可以聊聊实际感受。
遥感影像和自然图像的差异很大,主要表现为:分辨率跨度大、目标尺度差异悬殊、季节和光照变化剧烈、纹理特征比形状特征更重要。SAM 在自然图像上预训练的,到了遥感上,零样本效果会明显打折。
比较靠谱的做法是把 SAM 当成"候选区域生成器"而不是"分类器"。先用自动分割把影像切成一块块形状规整的图斑,然后对这些图斑提取特征——光谱特征、时序 NDVI 曲线、纹理统计量——再送进一个轻量的分类模型或者走规则判断,判断哪些图斑是耕地。这条链路里 SAM 只负责"划边界",它的强项刚好在这里,弱项(识别语义类别)交给后面的环节,各司其职。
局限也要说清楚。一是 SAM 对地块边界的分割精度受影像分辨率影响很大,分辨率低的时候边界会很糊;二是相邻同类地块之间的田埂、小路,SAM 经常识别不出来,会把两块地连成一片,这对面积统计是致命的;三是无纹理的大面积区域(比如水面、裸地)容易分出一堆无意义的碎片。所以整个流程后面必须有人工抽检环节。
还有一个方向是利用时序信息。同一块地在不同季节的影像,用 SAM 分别分割再取交集,可以过滤掉很多季节性的干扰(比如作物生长导致的地块内部变化),让边界更稳定。
9.3 从零样本到微调,什么情况下该动手
大部分场景下,零样本的 SAM 已经够用。但如果你确认是数据分布差异导致的效果落后,微调是值得投入的。
微调的思路通常是冻结图像编码器,只训 Mask Decoder,因为编码器占了绝大部分参数,全量微调显存和时间都吃不消,而且容易在小数据集上过拟合。冻结编码器之后,可训练参数只有几百万,单卡就能跑,几百到几千张标注图就能看到明显改善。
训练数据的构造方式跟传统分割不太一样:你需要的是"图像 + 提示 + 目标掩码"这样的三元组,提示可以是从真实掩码里随机采样的点,也可以是从掩码算出来的框。这样训练出来的模型才跟推理时的输入形式一致。
还有一种更省事的适配方式是在输入侧做文章,比如对遥感影像做增强(对比度拉伸、波段组合),让输入更接近自然图像的统计分布。这种办法没有任何训练成本,效果不确定但值得先试一下,毕竟成本几乎为零。
回过头看,SAM 本地部署这件事的技术门槛其实不高——真正花时间的是环境对齐和参数梳理这两块,代码本身很短。一旦跑通,它能提供的东西远超一个分割模型该有的价值,本质上它是一个可编程的图像理解原语。我自己的体会是,把它当成流水线里的一个通用零件,而不是一个端到端的解决方案,用起来会顺很多。