Files
observer/training/auto_label_v2.py
T

235 lines
9.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""野鸡图片全自动标注 v2。
方案:Grounding DINO(开放词汇检测,多提示词)→ NMS 去重 →
CLIP 裁剪验证(剔除误检)→ 仍无框时 CLIP 滑动窗口兜底。
输出 YOLO 格式标注 + 可视化预览,覆盖 datasets/images/pheasant 下全部图片。
"""
import os
import sys
import torch
import numpy as np
from pathlib import Path
from PIL import Image, ImageDraw
from torchvision.ops import nms
from transformers import (
GroundingDinoProcessor,
GroundingDinoForObjectDetection,
CLIPProcessor,
CLIPModel,
)
BASE = Path(__file__).parent
IMG_DIR = BASE / "datasets" / "images" / "pheasant"
OUT_DIR = BASE / "datasets" / "yolo_format"
PREVIEW_DIR = BASE / "datasets" / "preview"
DINO_PROMPTS = [
"pheasant",
"ring-necked pheasant",
"wild pheasant",
"common pheasant",
"bird",
]
DINO_BOX_THRESHOLD = 0.13
DINO_TEXT_THRESHOLD = 0.15
NMS_IOU = 0.45
KEEP_THRESHOLD = 0.16 # 低于此分的框必须通过 CLIP 验证才保留
MIN_BOX_AREA = 0.002 # 框面积占比下限(排除过小误检)
CLIP_POSITIVE = ["a photo of a pheasant", "a photo of a wild bird"]
CLIP_NEGATIVE = [
"a photo of grass and leaves",
"a photo of rocks and soil",
"a photo of a landscape",
"a photo of a fence",
"a photo of trees",
]
CLIP_VERIFY_THRESHOLD = 0.40 # softmax(正例) >= 此值才算验证通过
FALLBACK_WINDOW_SIZES = [0.5, 0.35, 0.25] # 滑动窗口占图宽比例
class AutoLabeler:
def __init__(self):
print("加载 Grounding DINO...", flush=True)
self.proc = GroundingDinoProcessor.from_pretrained("IDEA-Research/grounding-dino-tiny")
self.dino = GroundingDinoForObjectDetection.from_pretrained("IDEA-Research/grounding-dino-tiny")
self.dino.eval()
print("加载 CLIP...", flush=True)
self.clip_proc = CLIPProcessor.from_pretrained("openai/clip-vit-base-patch32")
self.clip = CLIPModel.from_pretrained("openai/clip-vit-base-patch32")
self.clip.eval()
pos_tokens = self.clip_proc(text=CLIP_POSITIVE + CLIP_NEGATIVE, return_tensors="pt", padding=True)
with torch.no_grad():
out = self.clip.get_text_features(**pos_tokens)
if hasattr(out, "text_embeds"):
feats = out.text_embeds
elif hasattr(out, "pooler_output"):
feats = out.pooler_output
else:
feats = out
feats = feats / feats.norm(dim=-1, keepdim=True)
self.pos_feats = feats[: len(CLIP_POSITIVE)]
self.neg_feats = feats[len(CLIP_POSITIVE):]
def clip_score(self, region: Image.Image) -> float:
"""返回裁剪区域是野鸡的概率(softmax 归一化,0~1"""
inputs = self.clip_proc(images=region, return_tensors="pt")
with torch.no_grad():
out = self.clip.get_image_features(**inputs)
if hasattr(out, "pooler_output"):
img_feat = out.pooler_output
else:
img_feat = out
img_feat = img_feat / img_feat.norm(dim=-1, keepdim=True)
sim_pos = img_feat @ self.pos_feats.T # (1, P)
sim_neg = img_feat @ self.neg_feats.T # (1, N)
logits = torch.cat([sim_pos * 100, sim_neg * 100], dim=1)
prob = logits.softmax(dim=1)[0, : len(CLIP_POSITIVE)].max().item()
return prob
def dino_detect(self, img: Image.Image):
"""Grounding DINO 检测,返回 [x1,y1,x2,y2] 绝对坐标列表"""
inputs = self.proc(images=img, text=DINO_PROMPTS, return_tensors="pt")
with torch.no_grad():
out = self.dino(**inputs)
res = self.proc.post_process_grounded_object_detection(
out, input_ids=inputs["input_ids"],
threshold=DINO_BOX_THRESHOLD, text_threshold=DINO_TEXT_THRESHOLD,
target_sizes=[img.size[::-1]],
)[0]
w, h = img.size
boxes = res["boxes"].tolist()
scores = res["scores"].tolist()
valid = []
for (x1, y1, x2, y2), s in zip(boxes, scores):
bw, bh = (x2 - x1) / w, (y2 - y1) / h
if bw * bh < MIN_BOX_AREA:
continue
valid.append((x1, y1, x2, y2, s))
return valid
def clip_fallback(self, img: Image.Image):
"""滑动窗口找最像野鸡的区域(兜底),返回 [x1,y1,x2,y2] 或 None"""
w, h = img.size
base = 768
scale = base / max(w, h)
small = img.resize((max(1, int(w * scale)), max(1, int(h * scale))), Image.LANCZOS)
sw, sh = small.size
best = None
best_score = 0.0
for f in FALLBACK_WINDOW_SIZES:
ws, hs = int(sw * f), int(sh * f)
stride = max(int(ws * 0.5), 8)
for y in range(0, max(sh - hs, 1), stride):
for x in range(0, max(sw - ws, 1), stride):
region = small.crop((x, y, x + ws, y + hs))
s = self.clip_score(region)
if s > best_score:
best_score = s
best = (x, y, x + ws, y + hs)
if best is None:
return None, best_score
x1, y1, x2, y2 = [v / scale for v in best]
return (x1, y1, x2, y2), best_score
def label_image(self, img: Image.Image, name: str):
"""返回 (boxes, source, debug)boxes 为 [x1,y1,x2,y2] 绝对坐标列表"""
w, h = img.size
cands = self.dino_detect(img)
if cands:
boxes = torch.tensor([[c[0], c[1], c[2], c[3]] for c in cands], dtype=torch.float32)
scores = torch.tensor([c[4] for c in cands], dtype=torch.float32)
keep = nms(boxes, scores, NMS_IOU)
kept = [(cands[i][0], cands[i][1], cands[i][2], cands[i][3], cands[i][4]) for i in keep]
# CLIP 验证每个框
final = []
for x1, y1, x2, y2, s in kept:
pad_x, pad_y = (x2 - x1) * 0.2, (y2 - y1) * 0.2
region = img.crop((max(x1 - pad_x, 0), max(y1 - pad_y, 0),
min(x2 + pad_x, w), min(y2 + pad_y, h)))
prob = self.clip_score(region)
final.append((x1, y1, x2, y2, s, prob))
best_clip = max(p for _, _, _, _, _, p in final)
if best_clip < CLIP_VERIFY_THRESHOLD:
print(f" {name}: 整图框分均过低,改用 CLIP 兜底 (best_clip={best_clip:.2f})", flush=True)
box, score = self.clip_fallback(img)
if box is not None:
x1, y1, x2, y2 = box
return [(x1, y1, x2, y2, score, f"clip({score:.2f})")], "clip"
return [], "none"
kept_final = [f for f in final if f[5] >= 0.20]
if not kept_final:
kept_final = [max(final, key=lambda t: t[5])]
for x1, y1, x2, y2, s, p in final:
if (x1, y1, x2, y2, s, p) not in kept_final:
print(f" {name}: 剔除低分框 clip={p:.2f} [{x1:.0f},{y1:.0f},{x2:.0f},{y2:.0f}]", flush=True)
return [(x1, y1, x2, y2, s, f"dino(clip={p:.2f})")
for x1, y1, x2, y2, s, p in kept_final], "dino"
else:
print(f" {name}: DINO 无候选,使用 CLIP 兜底", flush=True)
# 兜底:CLIP 滑动窗口
box, score = self.clip_fallback(img)
if box is not None:
x1, y1, x2, y2 = box
return [(x1, y1, x2, y2, score, f"clip({score:.2f})")], "clip"
return [], "none"
def main():
(OUT_DIR / "images" / "train").mkdir(parents=True, exist_ok=True)
(OUT_DIR / "labels" / "train").mkdir(parents=True, exist_ok=True)
PREVIEW_DIR.mkdir(parents=True, exist_ok=True)
labeler = AutoLabeler()
files = sorted(f for f in IMG_DIR.iterdir() if f.suffix.lower() in {".png", ".jpg", ".jpeg"})
print(f"共 {len(files)} 张图片", flush=True)
summary = []
for p in files:
img = Image.open(p).convert("RGB")
w, h = img.size
boxes, src = labeler.label_image(img, p.name)
summary.append((p.name, len(boxes), src))
label_path = OUT_DIR / "labels" / "train" / (p.stem + ".txt")
img.save(OUT_DIR / "images" / "train" / p.name)
if boxes:
with open(label_path, "w") as f:
for x1, y1, x2, y2, s, _ in boxes:
cx, cy = (x1 + x2) / 2 / w, (y1 + y2) / 2 / h
bw, bh = (x2 - x1) / w, (y2 - y1) / h
f.write(f"0 {cx:.6f} {cy:.6f} {bw:.6f} {bh:.6f}\n")
# 预览图
draw = ImageDraw.Draw(img)
for x1, y1, x2, y2, s, tag in boxes:
draw.rectangle([x1, y1, x2, y2], outline="#E53935", width=4)
draw.text((x1 + 4, max(y1 - 18, 0)), f"pheasant {s:.2f} [{tag}]", fill="#E53935")
img.save(PREVIEW_DIR / (p.stem + ".jpg"), "JPEG", quality=85)
status = "✓" if boxes else "✗"
print(f"{status} {p.name}: {len(boxes)} 框 ({src})", flush=True)
# 生成 data.yaml
with open(OUT_DIR / "data.yaml", "w") as f:
f.write(f"path: {OUT_DIR}\n")
f.write("train: images/train\nval: images/train\n")
f.write("nc: 1\nnames: ['pheasant']\n")
labeled = sum(1 for _, n, _ in summary if n > 0)
print(f"\n完成: {labeled}/{len(summary)} 张已标注", flush=True)
for name, n, src in summary:
print(f" {name}: {n} 框 ({src})", flush=True)
if __name__ == "__main__":
sys.exit(main())