大模型入門實戰 | 基于 YOLO 數據集微調 Qwen2.5-VL-3B-Instruct 的目標檢測任務
這篇就是新手向的“保姆級”實操文。你將把 YOLO 檢測數據 轉成 對話式 Grounding 數據,用 ms-swift 做 LoRA 微調,再用腳本 推理 + 可視化。
但值得注意的是,一般的檢測任務不推薦這么用哈,這僅僅是給大家學習使用,切勿“大炮打蚊子”。
0. Grounding 是什么?
Grounding(定位 / 指向):給一張圖 + 一段文字指令(如“找出 dog,并輸出框”),模型用語言理解來完成檢測式定位,并把結果以JSON形式吐出來。
區別于傳統檢測器(YOLO 等)只在固定類上訓練,Grounding 可以用自然語言描述做更開放的目標定位。
1. 環境準備(一次性)
Python & CUDA
- Python ≥ 3.9(3.10/3.11 更佳)
- 已裝好 PyTorch GPU 版本(
torch.cuda.is_available()
為 True)
安裝依賴
pip install "transformers>=4.43" accelerate peft pillow pyyaml
pip install ms-swift # 微調工具
# 可選(內存更省、速度更快;沒裝也能訓練):
# pip install flash-attn --no-build-isolation
模型權重
- 基座:
models/Qwen2.5-VL-3B-Instruct
(本地或 huggingface 路徑) - 也可用 AWQ 量化版:
Qwen/Qwen2.5-VL-3B-Instruct-AWQ
(更省顯存)
2. 數據準備:把 YOLO 數據變成“對話式 Grounding”JSONL
我們希望每一行樣本長這樣(ms-swift 能直接吃):
{"images": ["images/train/0001.jpg"],"messages": [{"role":"user", "content":"<image>\n請在圖中定位 camera,并以JSON返回 bbox_2d。只輸出JSON。"},{"role":"assistant", "content":"{\"bbox_2d\":[x1,y1,x2,y2]}"}]
}
2.1 你的 YOLO 數據結構(典型)
root/data.yaml # 里邊有 names: ["classA","classB",...]images/train/*.jpg|pngimages/val/*.jpg|pnglabels/train/*.txt # 每行: cls cx cy w h(歸一化)labels/val/*.txt
2.2 轉換腳本
將下面腳本存為 yolo2_ms_swift.py
:
-
支持兩種模式:
per-class
(默認):每圖每類產出一條樣本,問“只找這一類”all
:每圖一條樣本,讓模型“找出所有目標”
-
選項:
--include_negatives
:per-class
下該類不出現也產出,標成{"bbox_2d":[]}
(非常有助于減少“幻覺輸出”)--lang zh/en
:提示語中英文--image_path_prefix
:把images
字段寫成帶前綴的絕對路徑(NFS/共享盤常用)
#!/usr/bin/env python3
# -*- coding: utf-8 -*-"""
把 YOLO 檢測數據集轉成 ms-swift 可微調用的標準格式(JSONL):
每行形如:
{"images": ["images/train/0001.jpg"],"messages": [{"role":"user", "content":"<image>\n請在圖中定位 camera,并以JSON返回 bbox_2d。只輸出JSON。"},{"role":"assistant", "content":"{\"bbox_2d\":[x1,y1,x2,y2]}"} # 多框 -> {"bbox_2d":[[...],[...]]}]
}輸入(典型 YOLO):
root/data.yaml # names: [...]images/train/*.jpg|pngimages/val/*.jpg|pnglabels/train/*.txt # YOLO: cls cx cy w h (歸一化)labels/val/*.txt支持兩種生成模式:
- per-class(默認):每張圖、每個類別一條樣本(只讓模型找該類,答案只給該類的框)
- all:每張圖一條樣本(不區分類,返回所有框)可選:
- --include_negatives:per-class 時,即使該類不在圖中也產出(bbox_2d = [])
- --image_path_prefix:把 image 字段寫成絕對(NFS)前綴
- --lang zh/en:提示語語言
"""import argparse, json
from pathlib import Path
from typing import List, Tuple, Dict, Any, Optional
from PIL import Image
import yamldef load_class_names(p: Path) -> List[str]:meta = yaml.safe_load(p.read_text(encoding="utf-8"))names = meta.get("names") or meta.get("class_names")assert isinstance(names, list) and len(names) > 0, "data.yaml 里需要 names 列表"return [str(x) for x in names]def yolo_line_to_xyxy(line: str, W: int, H: int) -> Optional[Tuple[int, List[int]]]:ps = line.strip().split()if len(ps) != 5: return Nonetry:cid = int(float(ps[0])); cx, cy, w, h = map(float, ps[1:])except: return Nonebw, bh = w*W, h*Hx1 = int(round(cx*W - bw/2)); y1 = int(round(cy*H - bh/2))x2 = int(round(cx*W + bw/2)); y2 = int(round(cy*H + bh/2))x1 = max(0, min(x1, W-1)); y1 = max(0, min(y1, H-1))x2 = max(0, min(x2, W-1)); y2 = max(0, min(y2, H-1))if x2 <= x1 or y2 <= y1: return Nonereturn cid, [x1,y1,x2,y2]def iter_image_label_pairs_from_yaml(root: Path, split: str, yaml_path: Path):cfg = yaml.safe_load(yaml_path.read_text())img_dirs = cfg.get(split)if not img_dirs:return []if isinstance(img_dirs, str):img_dirs = [img_dirs]pairs = []exts = {".jpg",".jpeg",".png",".bmp",".webp"}for d in img_dirs:d = Path(d)for p in d.rglob("*"):if p.suffix.lower() in exts:# 找對應 labelrel = p.relative_to(d)lbl_dir = Path(str(d).replace("images", "labels"))lbl_path = (lbl_dir/rel).with_suffix(".txt")pairs.append((p, lbl_path))return pairsdef zh_user_prompt_one(label_name: str) -> str:return f"<image>\n請在圖中定位 {label_name},并以JSON返回 bbox_2d。只輸出JSON。"
def en_user_prompt_one(label_name: str) -> str:return f"<image>\nLocate {label_name} in this image and return bbox_2d as JSON. Output JSON only."
def zh_user_prompt_all() -> str:return "<image>\n請在圖中定位所有目標,并以JSON返回 bbox_2d。只輸出JSON。"
def en_user_prompt_all() -> str:return "<image>\nLocate all objects and return bbox_2d as JSON. Output JSON only."def gpt_value_from_boxes(boxes: List[List[int]]) -> str:# 緊湊 JSON(不縮進,減少訓練時token)if len(boxes) == 1:return json.dumps({"bbox_2d": boxes[0]}, ensure_ascii=False)else:return json.dumps({"bbox_2d": boxes}, ensure_ascii=False)def main():ap = argparse.ArgumentParser("YOLO -> ms-swift JSONL")ap.add_argument("--root", required=True, help="YOLO 數據集根目錄")ap.add_argument("--split", required=True, choices=["train","val","test"])ap.add_argument("--out", required=True, help="輸出 JSONL 路徑")ap.add_argument("--mode", default="per-class", choices=["per-class","all"])ap.add_argument("--include_negatives", action="store_true", help="per-class 下也產出缺類樣本(bbox_2d=[])")ap.add_argument("--lang", default="zh", choices=["zh","en"])ap.add_argument("--image_path_prefix", default="", help="把 image 字段寫成帶此前綴的路徑(絕對/NFS)")args = ap.parse_args()root = Path(args.root).resolve()names = load_class_names(root/"data.yaml")pairs = iter_image_label_pairs_from_yaml(root, args.split, root/"data.yaml")if not pairs:raise SystemExit(f"沒有找到圖片:{root}/images/{args.split}")outp = Path(args.out); outp.parent.mkdir(parents=True, exist_ok=True)if args.lang == "zh":prompt_one = zh_user_prompt_oneprompt_all = zh_user_prompt_allelse:prompt_one = en_user_prompt_oneprompt_all = en_user_prompt_alln = 0with outp.open("w", encoding="utf-8") as fout:for img_path, lbl_path in pairs:# 讀尺寸try:with Image.open(img_path) as im: W,H = im.sizeexcept: continue# 解析標簽cls2boxes: Dict[int, List[List[int]]] = {}if lbl_path.exists():for line in lbl_path.read_text(encoding="utf-8").splitlines():r = yolo_line_to_xyxy(line, W, H)if not r: continuecid, box = rcls2boxes.setdefault(cid, []).append(box)# image 字段:相對 root 或帶 prefix 的絕對路徑if args.image_path_prefix:try:image_field = str((Path(args.image_path_prefix)/img_path.relative_to(root)).resolve())except Exception:image_field = str((Path(args.image_path_prefix)/img_path.name).resolve())else:image_field = str(img_path.relative_to(root))if args.mode == "per-class":for cid, cname in enumerate(names):boxes = cls2boxes.get(cid, [])if not boxes and not args.include_negatives:continueuser = prompt_one(cname)asst = gpt_value_from_boxes(boxes)rec = {"images":[image_field],"messages":[{"role":"user","content":user},{"role":"assistant","content":asst}]}fout.write(json.dumps(rec, ensure_ascii=False)+"\n"); n += 1else: # allall_boxes: List[List[int]] = []for b in cls2boxes.values(): all_boxes.extend(b)user = prompt_all()asst = gpt_value_from_boxes(all_boxes)rec = {"images":[image_field],"messages":[{"role":"user","content":user},{"role":"assistant","content":asst}]}fout.write(json.dumps(rec, ensure_ascii=False)+"\n"); n += 1print(f"Done. wrote {n} samples -> {outp}")if __name__ == "__main__":main()
2.3 一鍵生成 JSONL
# 例:轉訓練集(每圖每類一條)
python yolo2_ms_swift.py \--root /home/q/nfs_share/datasets/your_yolo_dataset \--split train \--out /home/q/nfs_share/datasets/your_yolo_dataset/train_ms.jsonl \--mode per-class \--lang zh \--image_path_prefix /home/q/nfs_share/datasets/your_yolo_dataset# 轉驗證集
python yolo2_ms_swift.py \--root /home/q/nfs_share/datasets/your_yolo_dataset \--split val \--out /home/q/nfs_share/datasets/your_yolo_dataset/val_ms.jsonl \--mode per-class \--lang zh
我這個數據集是檢測的 狗
,所以我只有一類 dog
單類數據同樣可用:names: ["camera"]
時,每張圖就會得到“找 dog”的樣本;多實例會自動寫成 {"bbox_2d":[[...],[...]]}
。
樣例
{"images": ["/home/q/.../images/07_alldatasets/202506291136004523.jpg"],"messages": [{"role": "user", "content": "<image>\n請在圖中定位 dog,并以JSON返回 bbox_2d。只輸出JSON。"},{"role": "assistant", "content": "{\"bbox_2d\": [2738, 522, 2844, 616]}"}]}
3. 開始微調:用 ms-swift 做 LoRA
多卡訓練用環境變量控制可見 GPU,梯度累積可以在小 batch 下堆出更大的有效 batch。
命令行
CUDA_VISIBLE_DEVICES=1,2,3,4,5,6 swift sft \--model models/Qwen2.5-VL-3B-Instruct \--dataset train_ms.jsonl \--val_dataset val_ms.jsonl \--output_dir qwen25vl_camera_lora \--lora_rank 16 \--lora_alpha 32 \--lora_dropout 0.05 \--learning_rate 1e-4 \--num_train_epochs 3 \--per_device_train_batch_size 2 \--gradient_accumulation_steps 8 \--per_device_eval_batch_size 2 \--logging_steps 20 \--save_steps 500 \--eval_strategy steps \--eval_steps 500 \--bf16 true \--gradient_checkpointing true \--max_length 4096 \--max_pixels 921600 \--truncation_strategy delete
關鍵參數怎么調?
-
--lora_rank/alpha/dropout
:LoRA 容量與正則,16/32/0.05是不錯的起點。 -
--per_device_train_batch_size
+--gradient_accumulation_steps
:- 有效 batch ≈
batch_size × 累積步數 × GPU數
- 有效 batch ≈
-
--bf16 true
+--gradient_checkpointing true
:省顯存。 -
--max_pixels
:強相關顯存(圖像 token 數),小顯存就把它調低(如 512×512 ≈ 262k pixels)。 -
--eval_strategy steps / --save_steps
:定期評估與保存。
日志長這樣
{"loss": 0.9062, "token_acc": 0.7087, "memory(GiB)": 46.95, "global_step/max_steps": "20/3669", ...}
...
{"eval_loss": 0.6329, "eval_token_acc": 0.7492, "epoch": 3.0, "global_step/max_steps": "3669/3669", ...}
{"train_loss": 0.5660, "train_steps_per_second": 0.072, "epoch": 3.0, ...}
-
token_acc:生成時“下一個 token 預測正確”的比例,能反映模型是否學會按格式輸出 JSON(但它不是檢測 IoU 指標,下文會講怎么評估 IoU)。
-
訓練結束時 ms-swift 會告訴你:
best_model_checkpoint
(最優)last_model_checkpoint
(最后)
4. 推理:讓模型按你的“文字指令”輸出 JSON 框
把下面腳本存成 qwen_vl_grounding_infer.py
直接跑:
from transformers import Qwen2_5_VLForConditionalGeneration, AutoProcessor
from qwen_vl_utils import process_vision_info # 來自 Qwen-VL 項目# 1) 加載基座(或加載 AWQ 量化版)
model = Qwen2_5_VLForConditionalGeneration.from_pretrained("models/Qwen2.5-VL-3B-Instruct",torch_dtype="auto",device_map="auto",# attn_implementation="flash_attention_2", # 若已安裝 flash-attn 再開啟
)# 2) Processor(可用 min/max_pixels 控制視覺 token 開支)
processor = AutoProcessor.from_pretrained("models/Qwen2.5-VL-3B-Instruct")messages = [{"role": "user","content": [{"type": "image", "image": "https://qianwen-res.oss-cn-beijing.aliyuncs.com/Qwen-VL/assets/demo.jpeg"},{"type": "text", "text": "Locate dog in this image and output the bbox coordinates in JSON format."}
]}]# 3) 組批 & 上卡
text = processor.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
image_inputs, video_inputs = process_vision_info(messages)
inputs = processor(text=[text], images=image_inputs, videos=video_inputs,padding=True, return_tensors="pt").to("cuda")# 4) 生成
out_ids = model.generate(**inputs, max_new_tokens=128)
trimmed = [o[len(i):] for i, o in zip(inputs.input_ids, out_ids)]
print(processor.batch_decode(trimmed, skip_special_tokens=True, clean_up_tokenization_spaces=False))
預期輸出
['json\n[\n\t{"bbox_2d": [456, 587, 1183, 1214], "label": "dog"}\n]\n']
如果你訓練的是“只返回
bbox_2d
不帶 label”的格式,請確保推理時的文字提示與訓練保持一致,比如明確“只輸出 JSON,不要多余文本”。
5. 可視化:把預測框畫回圖片
把下面腳本存為 viz_simple.py
,用 PIL 畫框:
python viz_simple.py \--image demo.jpeg \--pred-str '[{"bbox_2d":[456,587,1183,1213],"label":"dog"}]' \--out demo_boxed.jpg
找不到
DejaVuSans.ttf
時腳本會自動回落默認字體,能正常出圖。
#!/usr/bin/env python3
# -*- coding: utf-8 -*-from PIL import Image, ImageDraw, ImageFont
import json, argparsedef main():parser = argparse.ArgumentParser()parser.add_argument("--image", required=True, help="原始圖片路徑")parser.add_argument("--pred-str", required=True,help="預測結果JSON字符串,例如 '[{\"bbox_2d\":[456,587,1183,1213],\"label\":\"dog\"}]'")parser.add_argument("--out", required=True, help="輸出圖片路徑")args = parser.parse_args()# 解析預測結果preds = json.loads(args.pred_str)im = Image.open(args.image).convert("RGB")draw = ImageDraw.Draw(im)# 嘗試加載字體try:font = ImageFont.truetype("DejaVuSans.ttf", 20)except:font = ImageFont.load_default()for obj in preds:bbox = obj.get("bbox_2d")label = obj.get("label", "")if not bbox or len(bbox) != 4:continuex1,y1,x2,y2 = bbox# 畫框draw.rectangle([x1,y1,x2,y2], outline="red", width=8)if label:# 獲取文本尺寸try:# 新Pillow推薦 textbboxtw, th = draw.textbbox((0,0), label, font=font)[2:]except:# 兼容舊版本tw, th = font.getsize(label)# 繪制文字背景+文字draw.rectangle([x1, y1-th-4, x1+tw+4, y1], fill="red")draw.text((x1+2, y1-th-2), label, fill="white", font=font)im.save(args.out)print(f"saved -> {args.out}")if __name__ == "__main__":main()'''
python viz_simple.py \--image demo.jpeg \--pred-str '[{"bbox_2d":[456,587,1183,1213],"label":"dog"}]' \--out demo_boxed.jpg
'''
6. 評估思路(從“會說 JSON”到“框得準”)
訓練日志里的 token_acc
說明模型學會了 JSON 話術,但定位效果還要看 IoU:
-
簡易評估:把模型輸出的
bbox_2d
與 YOLO 標簽(換算到xyxy
)做 IoU,統計 mAP@0.5 或 Recall/Precision。 -
實操要點:
- 解析 JSON 時做好容錯(有時模型會多給一層
json\n[...]
前綴,strip 再json.loads
)。 - 多框匹配用匈牙利匹配或貪心按 IoU 最大配。
- 負樣本(
bbox_2d=[]
)很關鍵,能明顯降低“幻覺預測”。
- 解析 JSON 時做好容錯(有時模型會多給一層
7. 常見坑與排查
data.yaml 里需要 names 列表
轉換腳本依賴names
。沒有就補上或改為class_names
。- 圖片路徑不對
訓練機和標注機不在一臺?用--image_path_prefix
寫絕對路徑(NFS 前綴)。 - 顯存爆掉 / OOM
降低--max_pixels
(如 921600 → 589824)、把per_device_train_batch_size
調 1、增大--gradient_accumulation_steps
,或上AWQ
基座。 - 沒裝 Flash-Attention
把attn_implementation="flash_attention_2"
去掉即可;想用就先裝兼容版本。 - 輸出不是純 JSON
提示詞務必包含“只輸出 JSON”;訓練數據和推理提示要風格統一。 - 字體報錯
viz_simple.py
已內置默認字體兜底,或你額外放一份DejaVuSans.ttf
。 - 多 GPU 沒生效
用CUDA_VISIBLE_DEVICES=0,1,2,...
控制卡,ms-swift 會自動并行。
有效 batch ≈per_device_bs × grad_acc × GPU數
。
8. 你可以直接復用的完整代碼
- yolo2_ms_swift.py:YOLO → JSONL 轉換(已包含中英提示、per-class/all、負樣本)
- qwen_vl_grounding_infer.py:推理腳本(按指令吐 JSON)
- viz_simple.py:可視化腳本(畫框/文字)
以上三個腳本你在題面都給了完整版本,按需直接落盤即可使用。
9. 參考訓練記錄(幫助你對齊預期)
你的一次訓練收斂片段(節選):
Train: ... 3669/3669
Eval: eval_loss=0.6329, eval_token_acc=0.7492
Train: train_loss=0.5660
best_model_checkpoint: .../checkpoint-3000
last_model_checkpoint: .../checkpoint-3669
這說明:
- 模型已穩定學會“按 JSON 說話”(token_acc ~ 0.75)
- 你可以用 best checkpoint 做下游評估與部署
10. 小結 & 你的下一步
-
你已經完成:數據轉換 → LoRA 微調 → 指令化推理 → 可視化與評估。
-
下一步建議:
- 多負樣本(
include_negatives
)與多風格提示詞混合,增強魯棒性。 - 引入困難樣本挖掘(高誤檢/漏檢圖)做二次微調。
- 用
min/max_pixels
做分辨率分級訓練,兼顧精度與速度。
- 多負樣本(
Grounding 的優勢在于“語言可控”。當你的業務只檢測“攝像頭”等少數類時,小規模 LoRA 就能把 Qwen2.5-VL-3B 調得很“乖”,而且有了語言接口,后續加類、改描述都更靈活。
祝你首訓即收斂,推理一路綠燈!如果需要,我可以把評估 IoU 的腳本也給你補上(含 mAP@0.5/0.5:0.95 計算與日志匯總)。