微调LocateAnything-3B,实现当图像中有 300+ 个密集重叠目标、人工标注不可行时的实用方案。
假设手头有一批种子发芽托盘、谷物质检图像或植物学调查照片。每张图像包含 100–500+ 粒种子,许多彼此重叠,部分被遮挡。老板(或导师)要求模型能精确定位每一粒。
在每张图像里手动为 300 个互相重叠的目标画紧密边界框,人工标注是根本不可行。按每个框约 5 秒计算,一张图就要花 25 分钟;1000 张图下来,标注工时超过 400 小时。
本文介绍如何借助 NVIDIA 的 LocateAnything-3B——一个支持 Parallel Box Decoding(并行框解码)的视觉语言定位模型——以及一套半监督 Pipeline,将人工标注量压缩到最低。
为什么选择 LocateAnything-3B?
大多数视觉语言定位模型(如 Grounding DINO 或 OWL-ViT)采用自回归方式逐 Token 生成边界框,检测 300 个目标的速度极慢。
LocateAnything-3B 采用 Parallel Box Decoding(PBD),以并行块预测的方式一次性输出完整边界框,而非逐 Token 生成,特别适合包含数百目标的密集场景。
核心参数:
- 基础模型:Qwen2.5–3B-Instruct 语言模型 + MoonViT 视觉编码器
- 解码方式:并行块级预测(非自回归)
- 许可证:NVIDIA Open Model License
总体策略
整个方案分四个阶段,采用半监督 Pipeline:
- 最少量的点标注(非框标注)+ SAM2 生成初始边界框
- 在合成数据与伪标签数据上训练轻量级检测器
- 对全量未标注数据集生成伪标签
- 使用高置信度伪标签配合密集场景专用训练策略微调 LocateAnything-3B
阶段 1:最少量标注 + 点转框
点击目标中心点的速度比画边界框快约 10 倍。标注 300 粒种子,打点只需约 5 分钟,画框则需约 25 分钟。
具体做法:用 SAM 2(Segment Anything Model 2)将每个点扩展为分割掩码(segmentation mask),再从掩码推导出边界框。
依赖安装:
pip install sam2 torch torchvision transformers opencv-python
点转框 Pipeline:
import cv2
import numpy as np
import torch
from sam2.build_sam import build_sam2
from sam2.sam2_image_predictor import SAM2ImagePredictor
from PIL import Image
# 加载 SAM2
checkpoint = "./checkpoints/sam2.1_hiera_large.pt"
model_cfg = "configs/sam2.1/sam2.1_hiera_l.yaml"
predictor = SAM2ImagePredictor(build_sam2(model_cfg, checkpoint))
def points_to_boxes(image_path, seed_points):
"""
Args:
image_path: 原始种子图像路径
seed_points: (x, y) 元组列表——每粒种子中心点击一次
Returns:
boxes: [x1, y1, x2, y2] 边界框列表
"""
image = np.array(Image.open(image_path).convert("RGB"))
predictor.set_image(image)
# 将点转换为 numpy 数组
point_coords = np.array(seed_points, dtype=np.float32)
point_labels = np.ones(len(seed_points), dtype=np.int32) # 1 = 前景
# 批量预测(SAM2 支持多点输入)
masks, scores, logits = predictor.predict(
point_coords=point_coords,
point_labels=point_labels,
multimask_output=False, # 每个点输出单一掩码
)
# 将掩码转换为边界框
boxes = []
for mask in masks:
# mask 形状:(H, W)
ys, xs = np.where(mask > 0)
if len(xs) == 0:
continue
x1, y1, x2, y2 = int(xs.min()), int(ys.min()), int(xs.max()), int(ys.max())
boxes.append([x1, y1, x2, y2])
return boxes, masks
# 使用示例
seed_points = [
(120, 340), (145, 342), (180, 338), # ... 共 300 个点
]
boxes, masks = points_to_boxes("tray_001.jpg", seed_points)
SAM2 擅长处理边界分离。即使种子相互重叠,点击可见前景种子上的点,通常也能正确分割出该前景目标。若两粒种子严重粘连:
- 点击最上层种子的可见中心
- SAM2 会优先分割包含该点的目标
- 对于被遮挡的种子,点击其可见的边缘或角点
若 SAM2 将两粒相邻种子合并为一个掩码,可将点位下移后重试,或在提取边界框前对掩码进行 watershed 分割后处理。
阶段 2:构建初始检测器
在接触 LocateAnything-3B 之前,先基于 SAM2 生成的边界框训练一个快速的 YOLO11 或 RT-DETR 模型。训练完成后可以得到一个专针对种子的检测器,运行速度达 100+ FPS,适合对数千张图像批量生成伪标签。
from ultralytics import YOLO
# 在 SAM2 伪标签上训练 YOLO11
model = YOLO("yolo11n.pt") # 使用 Nano 版以提升速度,可按需换用 s/m/l
# 数据格式:YOLO 需要 .txt 文件,格式为 类别 x_center y_center width height(归一化)
# 转换边界框:
def box_to_yolo_line(box, img_w, img_h):
x1, y1, x2, y2 = box
x_c = ((x1 + x2) / 2) / img_w
y_c = ((y1 + y2) / 2) / img_h
w = (x2 - x1) / img_w
h = (y2 - y1) / img_h
return f"0 {x_c:.6f} {y_c:.6f} {w:.6f} {h:.6f}"
# 训练
model.train(
data="seed_pseudo_data.yaml",
epochs=50,
imgsz=640,
batch=16,
patience=10,
augment=True,
hsv_h=0.015, hsv_s=0.7, hsv_v=0.4,
degrees=15, translate=0.1, scale=0.5,
fliplr=0.5, mosaic=1.0, mixup=0.1,
)
为什么要设计这个中间步骤?YOLO11 训练只需几分钟,每个预测结果都带有置信度分数,可按置信度过滤伪标签,也能过滤掉 SAM2 产生的明显误检(如背景杂点)。
阶段 3:大规模生成伪标签
将 YOLO11 种子检测器应用于全部未标注图像,保留高置信度预测,过滤噪声。
import glob
import json
from ultralytics import YOLO
model = YOLO("runs/detect/seed_yolo11/weights/best.pt")
image_paths = glob.glob("raw_images/*.jpg")
pseudo_labels = []
CONF_THRESHOLD = 0.65 # 可调整——从 0.6 开始,若噪声过多可提高至 0.75
IOU_NMS_THRESHOLD = 0.5
for img_path in image_paths:
results = model(img_path, conf=CONF_THRESHOLD, iou=IOU_NMS_THRESHOLD)
boxes = results[0].boxes.xyxy.cpu().numpy().tolist()
confs = results[0].boxes.conf.cpu().numpy().tolist()
# 可选:跳过模型置信度较低的图像(平均置信度偏低)
if len(confs) > 0 and np.mean(confs) < 0.5:
print(f"标记为待人工复核:{img_path}")
continue
# 以 LocateAnything 对话格式存储
pseudo_labels.append({
"id": img_path.split("/")[-1].replace(".jpg", ""),
"image": img_path,
"conversations": [
{
"from": "human",
"value": "<image>\nLocate all seeds in this image."
},
{
"from": "gpt",
"value": format_boxes_for_locateany(boxes)
}
],
"meta": {"source": "yolo_pseudo", "mean_conf": float(np.mean(confs))}
})
def format_boxes_for_locateany(boxes):
"""将 xyxy 格式边界框转换为模型所需的特殊 Token 格式。"""
# LocateAnything 要求坐标归一化至 [0, 999] 并使用特殊 Token 包裹
# 具体 Token 化方式取决于 processor;此处展示逻辑结构
lines = []
for box in boxes:
x1, y1, x2, y2 = box
lines.append(f"<<box>>{int(x1)} {int(y1)} {int(x2)} {int(y2)}<<</box>>")
return "\n".join(lines)
# 保存 recipe
with open("data/pseudo_labels.jsonl", "w") as f:
for item in pseudo_labels:
f.write(json.dumps(item) + "\n")
基于置信度的过滤策略:
在密集场景中,标准 NMS(Non-Maximum Suppression,非极大值抑制)可能过度抑制相互重叠的种子。替代方案如下:
# 尺寸感知过滤:若两个框尺寸相近,则保留重叠框
# (种子大小大致均匀;若大框与小框重叠,大概率是误检)
def size_aware_filter(boxes, confs, size_tol=2.0):
areas = [(b[2]-b[0])*(b[3]-b[1]) for b in boxes]
median_area = np.median(areas)
filtered = []
for box, conf, area in zip(boxes, confs, areas):
if area > median_area * size_tol or area < median_area / size_tol:
continue # 可能是误检(杂质、背景团块)
filtered.append((box, conf))
return filtered
阶段 4:Fine-tune LocateAnything-3B
伪标签数据已准备就绪,开始对主模型进行 Fine-tuning。
LocateAnything 使用 recipe JSON 定义数据混合比例。针对密集种子场景,有意提高密集/重叠场景的数据权重:
{
"datasets": [
{
"name": "seed_pseudo_dense",
"path": "data/pseudo_labels.jsonl",
"root": "data/raw_images/",
"repeat": 3,
"length": 15000
},
{
"name": "seed_sam2_verified",
"path": "data/human_verified_sam2.jsonl",
"root": "data/raw_images/",
"repeat": 8,
"length": 2000
},
{
"name": "seed_counting_captions",
"path": "data/weak_count_captions.jsonl",
"root": "data/raw_images/",
"repeat": 2,
"length": 5000
}
]
}
注意:人工核验数据的 repeat 设为 8。数量虽少,但质量更高。提高重复比例可防止模型仅学习 YOLO 的偏差。
训练脚本:
# 脚本位置:eaglevl/train/locany_finetune_magi_stream.py
torchrun --nproc_per_node=4 \
eaglevl/train/locany_finetune_magi_stream.py \
--model_name_or_path nvidia/LocateAnything-3B \
--meta_path "./recipes/seed_finetune_recipe.json" \
--output_dir work_dirs/locany_seed_sft \
--max_steps 10000 \
--learning_rate 1e-5 \
--warmup_ratio 0.1 \
--lr_scheduler_type cosine \
--bf16 True \
--block_size 6 \
--attn_implementation magi \
--max_seq_length 8192 \
--per_device_train_batch_size 1 \
--gradient_accumulation_steps 8 \
--save_steps 2000 \
--logging_steps 10 \
--report_to tensorboard \
--deepspeed "deepspeed_configs/zero_stage2_config.json"
LoRA 替代方案(显存受限时):没有 4 张 A100 的话,可以走 LoRA。LocateAnything 支持
use_backbone_lora
和
use_llm_lora
:
from transformers import AutoModel, AutoConfig
config = AutoConfig.from_pretrained(
"nvidia/LocateAnything-3B",
trust_remote_code=True
)
# 启用 LoRA
config.use_llm_lora = 64 # 语言模型的秩
config.use_backbone_lora = 64 # 视觉骨干网络的秩
model = AutoModel.from_pretrained(
"nvidia/LocateAnything-3B",
config=config,
trust_remote_code=True,
torch_dtype=torch.bfloat16
)
LoRA 将可训练参数从约 38 亿压缩到约 2 亿,在单张 A100 或 2 张 RTX 4090 上即可完成 Fine-tuning。
进阶:弱监督计数辅助定位
如果连点标注都无法提供,只有图像级别的计数(例如"此托盘约有 320 粒种子"),可以通过以下方式引导定位:
# 基于计数注意力的弱监督定位
import torch.nn.functional as F
def attention_peaks_to_points(model, processor, image_path, prompt, n_peaks=300):
"""
利用模型中文本('seeds')与图像 patch 之间的交叉注意力,
估计种子中心的可能位置。需要对模型进行前向 hook。
"""
image = Image.open(image_path).convert("RGB")
inputs = processor(text=prompt, images=image, return_tensors="pt").to("cuda")
# 开启注意力输出的前向传播(如模型支持)
with torch.no_grad():
outputs = model(**inputs, output_attentions=True)
# 提取视觉-语言注意力(层次相关,通常取最后几层)
attn = outputs.attentions[-1] # [batch, heads, text_tokens, image_patches]
# 对多头取均值,并对种子相关的文本 Token 求和
seed_token_indices = find_seed_tokens(inputs.input_ids, processor)
seed_attn = attn[:, :, seed_token_indices, :].mean(dim=2) # [batch, heads, patches]
# 重塑为空间网格(MoonViT 使用合并后的 patch,448px 下约为 24x24)
H = W = 24
attn_map = seed_attn.mean(dim=1).reshape(H, W).cpu().numpy()
# 寻找局部极大值
from scipy.ndimage import maximum_filter
local_max = (attn_map == maximum_filter(attn_map, size=3))
peaks = np.argwhere(local_max)
# 将 patch 索引转换为图像坐标
img_w, img_h = image.size
points = [(p[1] * img_w / W, p[0] * img_h / H) for p in peaks[:n_peaks]]
return points
这些注意力极值点将作为伪点输入阶段 1 的 SAM2。不过这个结果存在一定噪声,但配合 NMS 和置信度过滤,足以为整个 Pipeline 提供初始数据(理论)。
使用 Fine-tune 后的模型进行推理
from transformers import AutoModel, AutoProcessor
import torch
model = AutoModel.from_pretrained(
"work_dirs/locany_seed_sft/checkpoint-10000",
trust_remote_code=True,
torch_dtype=torch.bfloat16
).cuda()
processor = AutoProcessor.from_pretrained(
"nvidia/LocateAnything-3B",
trust_remote_code=True
)
# 加载密集种子图像
image = "germination_tray_A7.jpg"
# Prompt 措辞很重要——明确说明对重叠情况的预期
prompts = [
"Locate every single seed in this image, including overlapping ones.",
"Detect all seeds. Do not miss any, even if they are partially covered.",
"Count and bound each individual seed. Return all bounding boxes.",
]
best_result = None
max_boxes = 0
for prompt in prompts:
inputs = processor(text=prompt, images=image, return_tensors="pt").to("cuda")
with torch.no_grad():
outputs = model.generate(
**inputs,
max_new_tokens=1024, # 允许输出大量边界框
do_sample=False
)
result = processor.batch_decode(outputs, skip_special_tokens=False)[0]
n_boxes = result.count("<<box>>")
if n_boxes > max_boxes:
max_boxes = n_boxes
best_result = result
print(f"检测到 {max_boxes} 粒种子")
print(best_result)
Ensemble Prompting
对于关键应用场景(如种子质检),可用多个 Prompt 分别推理,再通过 NMS 合并结果:
from collections import defaultdict
def merge_prompt_results(results_list, iou_thresh=0.5):
"""
不同 Prompt 会发现不同的种子,合并它们!
"""
all_boxes = []
for res in results_list:
all_boxes.extend(parse_boxes(res)) # 自行实现解析器
# 标准 NMS 或 soft-NMS
return weighted_boxes_fusion(all_boxes, iou_thresh)
不同 Prompt 的召回范围往往不完全重叠,融合后通常能找到更多目标。
参考资源
- 模型:nvidia/LocateAnything-3B
- 论文:LocateAnything: Fast and High-Quality Vision-Language Grounding with Parallel Box Decoding
- 代码:NVlabs/Eagle/Embodied
- SAM2:facebook/sam2.1
- 标注工具:CVAT(支持点标注),Label Studio
总结
密集重叠场景下的目标检测,不需要消耗数百小时手工标注。点监督、SAM2 自动分割、轻量级伪标签模型、LocateAnything-3B 的并行解码——四者结合,可以构建出生产级种子检测器,与传统框标注流程相比,标注时间减少约 90%。
让廉价模型和几何方法承担繁重工作,人力精力留给验证和边界案例。
by Yash M Gupta, PhD