419 lines
18 KiB
Python
419 lines
18 KiB
Python
#!/usr/bin/env python3
|
||
"""qwen3.8-9b (LocalAI 192.168.3.210:18080) 多模态自动标注 → YOLO 格式
|
||
|
||
类别: pheasant(0) hare(1) dove(2) fish(3) cover(4, 生境区域)
|
||
|
||
标注两路:
|
||
- 动物框(红): 描述门控(2 采样, 均明确"无鸟"才判负) + 定位共识(3 采样, 中位数),
|
||
样本分歧大(两两 IoU < 0.5) 的图像进复核清单
|
||
- 生境框(黄, cover): 植被覆盖地带识别 + NMS 去重
|
||
|
||
经验(2026-08-20 实测):
|
||
- 模型对"有无野鸡"的布尔判断有"有"偏置, 全图 3/3 答"有", 不可用
|
||
- 描述模式能区分大部分正负样本(负样本会明确说"没有鸟类或动物"), 但个别图多次描述矛盾
|
||
- 模型对负样本图像会稳定幻觉出小框, 所以不能只用定位采样判断有无
|
||
- 定位提示词强调"完整包住/从头到尾"比"贴合身体"更准(原提示词框偏小偏右)
|
||
- 隐藏目标(只露头/尾巴)会被描述门控误判负, 判负后需定位保险(42 图实测)
|
||
- 模型可能多采样一致地错(系统性偏差, 41/44 图实测), 需双提示词交叉验证
|
||
- 框面积 <1% 多为局部误检, 但真目标(只露头)也可能很小, 一律进复核
|
||
|
||
用法:
|
||
venv/bin/python auto_label.py --input datasets/images --output datasets/labels
|
||
venv/bin/python auto_label.py --input datasets/images --output datasets/labels --habitat
|
||
venv/bin/python auto_label.py --input datasets/images --output datasets/labels --animal
|
||
venv/bin/python auto_label.py --input datasets/images --output datasets/labels --dry-run
|
||
"""
|
||
import argparse
|
||
import base64
|
||
import io
|
||
import json
|
||
import os
|
||
import re
|
||
import sys
|
||
import time
|
||
import urllib.request
|
||
from pathlib import Path
|
||
|
||
from PIL import Image
|
||
|
||
API_URL = os.environ.get("OBSERVER_AI_URL", "http://192.168.3.210:18080")
|
||
MODEL = os.environ.get("OBSERVER_AI_MODEL", "qwen3.8-9b")
|
||
MAX_SIDE = 800 # 预处理尺寸, 对齐 LocalAI 聊天界面(100K 上下文下约 600~700 tokens/图)
|
||
JPEG_QUALITY = 85
|
||
TIMEOUT = 300
|
||
MAX_RETRIES = 4
|
||
RETRY_BACKOFF = 3 # 秒, 指数退避
|
||
DESC_SAMPLES = 2 # 描述门控采样数
|
||
BOX_SAMPLES = 3 # 定位采样数
|
||
CROSS_SAMPLES = 2 # 交叉提示词采样数(双提示词交叉验证)
|
||
CONSENSUS_IOU = 0.5 # 定位共识: 低于此值=采样分歧大, 进复核
|
||
CONSENSUS_OK_IOU = 0.7 # 定位共识: 高于此值才算可信, 中间段=勉强一致, 进复核
|
||
CROSS_IOU = 0.5 # 双提示词共识框 IoU 低于此值=交叉不一致, 进复核
|
||
CENTER_AGREE = 0.08 # 小框对 IoU 敏感, 中心距离不超过此值也视为一致
|
||
MIN_ANIMAL_AREA = 0.002 # 动物框面积低于 0.2%=极小疑似误检, 进复核
|
||
|
||
# 项目范围: 仅野鸡 + 生境区域 cover(2026-08-20 确认, 不含野兔/斑鸠/鱼)
|
||
CLASSES = ["pheasant", "cover"]
|
||
CLASS_CN = {"pheasant": "野鸡(环颈雉)"}
|
||
|
||
PHEASANT_FEATURES = ("野鸡(环颈雉)识别特征(雄性个体):黑脑袋、红色脸颊、白色颈环、细长尾羽;"
|
||
"体型似鸡,站立或行走姿态")
|
||
|
||
DESC_PROMPT = ("请客观描述这张图片的内容(50字以内):画面里有什么?"
|
||
"是否有任何鸟类或动物?如果看到鸟类或动物,请明确说出来。"
|
||
f"注意:{PHEASANT_FEATURES}。"
|
||
"野鸡可能藏在草丛灌木中,只露出头部或尾巴,这样也算看到野鸡,要明确说出来。")
|
||
|
||
ANIMAL_PROMPT_TMPL = ("图片中有{cn}。注意识别特征:{feat}。"
|
||
"给出完整包住{cn}的边界框,尽量贴合,不要切掉身体任何部分(含尾巴)。"
|
||
"如果{cn}被遮挡、只露出部分(头部/尾巴/局部身体),也要框住可见部分,"
|
||
"不要因为没有全身就漏标。"
|
||
"每只{cn}一个框。如果图片中没有{cn},boxes 输出空数组。"
|
||
"只输出JSON: {{\"boxes\": [[ymin, xmin, ymax, xmax], ...]}}。"
|
||
"坐标必须是0到1之间的归一化小数,禁止输出像素坐标")
|
||
|
||
# 交叉验证提示词: 结构与主提示词不同, 用于拆穿"多采样一致的错"(系统性偏差)
|
||
ANIMAL_PROMPT2_TMPL = ("画面中可能有{cn}。先在脑海中定位:{cn}的头、身体、尾巴各在什么位置?"
|
||
"然后给出完整包住{cn}的边界框;如果只露出部分,框住可见部分。"
|
||
"只输出JSON: {{\"boxes\": [[ymin, xmin, ymax, xmax], ...]}},没有则空数组。"
|
||
"坐标0~1,禁止像素坐标")
|
||
|
||
HABITAT_PROMPT = """你是野生动物观察辅助工具。请找出野鸡最可能藏身或出现的具体位置。
|
||
|
||
规则:
|
||
- 只标注【具体的可疑位置】(如浓密草丛、植被边缘、沟渠边、倒木旁),不要框大片地带
|
||
- 每个位置一个小框,框住该可疑处即可,框的面积适中(约占画面 2%~15%)
|
||
- 按可疑度从高到低排列,最多 3 个
|
||
- 综合植被密度、地形、光线判断:植被浓密、能藏身、光线被遮挡处优先
|
||
- 不要把裸露地面、道路、天空框进去
|
||
- 只输出JSON: {"regions": [[ymin, xmin, ymax, xmax], ...]},没有则 {"regions": []}"""
|
||
|
||
NO_BIRD_RE = re.compile(r"(没有|未发现|未看到|没有任何|看不到|不见).{0,10}(鸟类|鸟|动物|野鸡|雉)")
|
||
|
||
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
|
||
|
||
|
||
def preprocess_image(path: Path) -> tuple[bytes, int, int]:
|
||
"""返回 (jpeg字节, 缩放后宽, 缩放后高)"""
|
||
with Image.open(path) as im:
|
||
im = im.convert("RGB")
|
||
w, h = im.size
|
||
if max(w, h) > MAX_SIDE:
|
||
scale = MAX_SIDE / max(w, h)
|
||
w, h = round(w * scale), round(h * scale)
|
||
im = im.resize((w, h), Image.LANCZOS)
|
||
buf = io.BytesIO()
|
||
im.save(buf, format="JPEG", quality=JPEG_QUALITY)
|
||
return buf.getvalue(), w, h
|
||
|
||
|
||
def call_vision(jpeg: bytes, prompt: str, max_tokens: int = 400,
|
||
temperature: float = 0.1) -> str:
|
||
b64 = base64.b64encode(jpeg).decode()
|
||
payload = {
|
||
"model": MODEL,
|
||
"messages": [{
|
||
"role": "user",
|
||
"content": [
|
||
{"type": "text", "text": prompt},
|
||
{"type": "image_url",
|
||
"image_url": {"url": "data:image/jpeg;base64," + b64}},
|
||
],
|
||
}],
|
||
"temperature": temperature,
|
||
"max_tokens": max_tokens,
|
||
}
|
||
req = urllib.request.Request(
|
||
f"{API_URL}/v1/chat/completions",
|
||
data=json.dumps(payload).encode(),
|
||
headers={"Content-Type": "application/json"},
|
||
)
|
||
last_err = None
|
||
for attempt in range(MAX_RETRIES):
|
||
try:
|
||
with opener.open(req, timeout=TIMEOUT) as resp:
|
||
data = json.load(resp)
|
||
return data["choices"][0]["message"]["content"]
|
||
except Exception as e:
|
||
last_err = e
|
||
time.sleep(RETRY_BACKOFF * (2 ** attempt))
|
||
raise RuntimeError(f"请求失败: {last_err}")
|
||
|
||
|
||
def parse_json_box_list(content: str, key: str, disp_w: int, disp_h: int):
|
||
"""解析 {"key": [[ymin,xmin,ymax,xmax],...]} 返回 [(ymin,xmin,ymax,xmax)]
|
||
|
||
模型偶发输出像素坐标(相对缩放后输入图), 自动按实际尺寸归一化
|
||
"""
|
||
text = re.sub(r"^```(?:json)?\s*|\s*```$", "", content.strip(), flags=re.MULTILINE)
|
||
m = re.search(r"\{.*\}", text, re.DOTALL)
|
||
if not m:
|
||
raise ValueError(f"响应中无 JSON: {content[:200]}")
|
||
data = json.loads(m.group(0))
|
||
boxes = []
|
||
for b in data.get(key, []):
|
||
ymin, xmin, ymax, xmax = (float(v) for v in b)
|
||
if max(ymin, xmin, ymax, xmax) > 1:
|
||
# 模型偶发输出像素坐标(相对缩放后输入图), 超出部分裁剪到边界
|
||
xmin, xmax = xmin / disp_w, xmax / disp_w
|
||
ymin, ymax = ymin / disp_h, ymax / disp_h
|
||
xmin, xmax = min(max(xmin, 0), 1), min(max(xmax, 0), 1)
|
||
ymin, ymax = min(max(ymin, 0), 1), min(max(ymax, 0), 1)
|
||
# 模型偶发输出坐标序颠倒的框
|
||
if xmin > xmax:
|
||
xmin, xmax = xmax, xmin
|
||
if ymin > ymax:
|
||
ymin, ymax = ymax, ymin
|
||
if xmin >= xmax or ymin >= ymax:
|
||
continue # 裁剪后退化(整体越界)的框直接丢弃
|
||
boxes.append((ymin, xmin, ymax, xmax))
|
||
return boxes
|
||
|
||
|
||
def iou(a, b):
|
||
ymin1, xmin1, ymax1, xmax1 = a
|
||
ymin2, xmin2, ymax2, xmax2 = b
|
||
iw = min(xmax1, xmax2) - max(xmin1, xmin2)
|
||
ih = min(ymax1, ymax2) - max(ymin1, ymin2)
|
||
if iw <= 0 or ih <= 0:
|
||
return 0.0
|
||
inter = iw * ih
|
||
union = (xmax1 - xmin1) * (ymax1 - ymin1) + (xmax2 - xmin2) * (ymax2 - ymin2) - inter
|
||
return inter / union if union > 0 else 0.0
|
||
|
||
|
||
def nms(boxes, thr=0.5):
|
||
"""按面积降序贪心去重(用于生境多框)"""
|
||
kept = []
|
||
for b in sorted(boxes, key=lambda x: (x[2] - x[0]) * (x[3] - x[1]), reverse=True):
|
||
if all(iou(b, k) < thr for k in kept):
|
||
kept.append(b)
|
||
return kept
|
||
|
||
|
||
def boxes_agree(a, b, iou_thr=CROSS_IOU, center_thr=CENTER_AGREE):
|
||
"""小框 IoU 敏感(同位置小框 IoU 可能很低), 中心距离足够近也视为一致"""
|
||
if iou(a, b) >= iou_thr:
|
||
return True
|
||
ca = ((a[1] + a[3]) / 2, (a[0] + a[2]) / 2)
|
||
cb = ((b[1] + b[3]) / 2, (b[0] + b[2]) / 2)
|
||
return max(abs(ca[0] - cb[0]), abs(ca[1] - cb[1])) <= center_thr
|
||
|
||
|
||
def median_box(boxes):
|
||
"""按坐标分量的中位数合成框(定位共识)"""
|
||
return tuple(
|
||
sorted(v)[len(v) // 2] for v in zip(*boxes)
|
||
)
|
||
|
||
|
||
def consensus_box(nonempty: list[list]) -> tuple[float, tuple]:
|
||
"""投票法共识: 选出其他采样中同意率最高的框, 返回 (同意率, 框)
|
||
|
||
同意 = boxes_agree(IoU 达标或中心距离足够近), 对小框友好(IoU 均值法会误伤小框)
|
||
"""
|
||
if len(nonempty) == 1:
|
||
return 0.0, nonempty[0][0]
|
||
best = None
|
||
for b in nonempty[0]:
|
||
agree = [any(boxes_agree(b, o) for o in s) for s in nonempty[1:]]
|
||
score = sum(agree) / len(agree)
|
||
if best is None or score > best[0]:
|
||
best = (score, b)
|
||
return best
|
||
|
||
|
||
def annotate_animal(jpeg: bytes, cls: str, disp_w: int, disp_h: int) -> tuple[bool, list, str, str]:
|
||
"""返回 (检出, 框列表[(ymin,xmin,ymax,xmax)], 状态 ok|review, 复核原因)"""
|
||
cn = CLASS_CN[cls]
|
||
prompt = ANIMAL_PROMPT_TMPL.format(cn=cn, feat=PHEASANT_FEATURES)
|
||
prompt2 = ANIMAL_PROMPT2_TMPL.format(cn=cn)
|
||
|
||
# 1. 描述门控: 2 采样, 均明确无鸟才判负
|
||
descs = [call_vision(jpeg, DESC_PROMPT, max_tokens=150) for _ in range(DESC_SAMPLES)]
|
||
no_birds = [bool(NO_BIRD_RE.search(d)) for d in descs]
|
||
gate_blocked = False
|
||
if all(no_birds):
|
||
# 门控保险: 判负后仍各跑 1 次主/交叉定位, 任一有框则门控不可信
|
||
ins1 = parse_json_box_list(
|
||
call_vision(jpeg, prompt, temperature=0.3), "boxes", disp_w, disp_h)
|
||
ins2 = parse_json_box_list(
|
||
call_vision(jpeg, prompt2, temperature=0.3), "boxes", disp_w, disp_h)
|
||
if not ins1 and not ins2:
|
||
return False, [], "ok", ""
|
||
gate_blocked = True
|
||
|
||
# 2. 定位: 主提示词 3 采样 + 交叉提示词 2 采样
|
||
samples = [parse_json_box_list(call_vision(jpeg, prompt, temperature=0.3),
|
||
"boxes", disp_w, disp_h) for _ in range(BOX_SAMPLES)]
|
||
cross = [parse_json_box_list(call_vision(jpeg, prompt2, temperature=0.3),
|
||
"boxes", disp_w, disp_h) for _ in range(CROSS_SAMPLES)]
|
||
|
||
nonempty = [s for s in samples if s]
|
||
if not nonempty:
|
||
cn2 = [s for s in cross if s]
|
||
if cn2 and (gate_blocked or len(cn2) >= CROSS_SAMPLES):
|
||
_, b2 = consensus_box(cn2)
|
||
return True, [b2], "review", "主提示词未检出但交叉提示词有框"
|
||
return False, [], "ok", ""
|
||
|
||
score, box = consensus_box(nonempty)
|
||
if len(nonempty) < BOX_SAMPLES:
|
||
return True, [box], "review", "定位采样检出不一致"
|
||
|
||
# 3. 双提示词交叉验证: 拆穿多采样一致的系统性偏差
|
||
cn2 = [s for s in cross if s]
|
||
if cn2:
|
||
_, b2 = consensus_box(cn2)
|
||
if not boxes_agree(box, b2):
|
||
return True, [box], "review", "双提示词交叉不一致"
|
||
|
||
# 4. 共识分级: <0.5 分歧大, 0.5~0.7 勉强一致, 均进复核
|
||
if score < CONSENSUS_IOU:
|
||
return True, [box], "review", "定位采样分歧大"
|
||
if score < CONSENSUS_OK_IOU:
|
||
return True, [box], "review", "定位采样勉强一致"
|
||
|
||
# 5. 小框复核: 面积 <1% 疑似局部误检
|
||
if (box[2] - box[0]) * (box[3] - box[1]) < MIN_ANIMAL_AREA:
|
||
return True, [box], "review", "框过小,疑似局部或误检"
|
||
if gate_blocked:
|
||
return True, [box], "review", "门控判负但定位有框"
|
||
return True, [box], "ok", ""
|
||
|
||
|
||
MAX_HABITAT_BOXES = 3 # 生境只保留可疑度最高的 3 个位置
|
||
MIN_HABITAT_AREA = 0.01 # 太小(碎点, <1% 面积)对训练无意义
|
||
MAX_HABITAT_AREA = 0.25 # 太大(大面积地带)不是"可疑点"
|
||
MAX_HABITAT_SIDE = 0.7 # 全宽/全高条带排除
|
||
|
||
def annotate_habitat(jpeg: bytes, disp_w: int, disp_h: int) -> list:
|
||
content = call_vision(jpeg, HABITAT_PROMPT, max_tokens=400)
|
||
boxes = parse_json_box_list(content, "regions", disp_w, disp_h)
|
||
boxes = nms(boxes, 0.5) # 去重(模型偶发输出重复框)
|
||
filtered = []
|
||
for ymin, xmin, ymax, xmax in boxes:
|
||
w, h = xmax - xmin, ymax - ymin
|
||
area = w * h
|
||
if area < MIN_HABITAT_AREA or area > MAX_HABITAT_AREA:
|
||
continue
|
||
if w > MAX_HABITAT_SIDE or h > MAX_HABITAT_SIDE:
|
||
continue
|
||
filtered.append((ymin, xmin, ymax, xmax))
|
||
return filtered[:MAX_HABITAT_BOXES]
|
||
|
||
|
||
def to_yolo(class_id: int, boxes) -> str:
|
||
lines = []
|
||
for ymin, xmin, ymax, xmax in boxes:
|
||
cx = min(max((xmin + xmax) / 2, 0), 1)
|
||
cy = min(max((ymin + ymax) / 2, 0), 1)
|
||
w = min(max(xmax - xmin, 0), 1)
|
||
h = min(max(ymax - ymin, 0), 1)
|
||
lines.append(f"{class_id} {cx:.6f} {cy:.6f} {w:.6f} {h:.6f}")
|
||
return "\n".join(lines)
|
||
|
||
|
||
def main():
|
||
ap = argparse.ArgumentParser(description="qwen 自动标注 → YOLO(动物+生境)")
|
||
ap.add_argument("--input", default="datasets/images")
|
||
ap.add_argument("--output", default="datasets/labels")
|
||
ap.add_argument("--review-file", default="datasets/review.txt")
|
||
ap.add_argument("--mode", choices=["both", "animal", "habitat"], default="both")
|
||
ap.add_argument("--dry-run", action="store_true")
|
||
ap.add_argument("--limit", type=int, default=0)
|
||
ap.add_argument("--force", action="store_true")
|
||
args = ap.parse_args()
|
||
|
||
img_dir = Path(args.input)
|
||
out_dir = Path(args.output)
|
||
review_file = Path(args.review_file)
|
||
if not img_dir.is_dir():
|
||
sys.exit(f"输入目录不存在: {img_dir}")
|
||
|
||
class_ids = {name: i for i, name in enumerate(CLASSES)}
|
||
images = sorted(p for p in img_dir.rglob("*")
|
||
if p.suffix.lower() in (".jpg", ".jpeg", ".png", ".webp", ".bmp"))
|
||
todo = []
|
||
for p in images:
|
||
rel = p.relative_to(img_dir)
|
||
cls = rel.parts[0] if len(rel.parts) > 1 else ""
|
||
if cls not in class_ids or cls == "cover":
|
||
print(f"跳过: 未知类别目录 {rel}", file=sys.stderr)
|
||
continue
|
||
label_path = out_dir / rel.with_suffix(".txt")
|
||
if not args.force and label_path.exists():
|
||
existing = label_path.read_text().splitlines()
|
||
if args.mode == "both":
|
||
continue # 完整模式,已有结果即跳过
|
||
if args.mode == "animal" and any(
|
||
not l.startswith(f"{class_ids['cover']} ") for l in existing if l):
|
||
continue # 已有动物标注
|
||
if args.mode == "habitat" and any(
|
||
l.startswith(f"{class_ids['cover']} ") for l in existing if l):
|
||
continue # 已有生境标注
|
||
todo.append((p, cls, label_path))
|
||
|
||
if args.limit > 0:
|
||
todo = todo[:args.limit]
|
||
if not todo:
|
||
print("没有待标注的图片。")
|
||
return
|
||
print(f"待标注 {len(todo)} 张 [模式: {args.mode}]")
|
||
if args.dry_run:
|
||
return
|
||
|
||
ok = no_target = fail = review = 0
|
||
t0 = time.time()
|
||
for i, (img_path, cls, label_path) in enumerate(todo, 1):
|
||
rel = img_path.relative_to(img_dir)
|
||
label_path.parent.mkdir(parents=True, exist_ok=True)
|
||
try:
|
||
jpeg, disp_w, disp_h = preprocess_image(img_path)
|
||
cover_id = class_ids["cover"]
|
||
status = "ok"
|
||
# 单模式重跑时保留另一类已有标注
|
||
existing = (label_path.read_text().splitlines()
|
||
if label_path.exists() else [])
|
||
animal_lines, cover_lines = [], []
|
||
if args.mode in ("both", "animal"):
|
||
found, boxes, status, reason = annotate_animal(jpeg, cls, disp_w, disp_h)
|
||
animal_lines = to_yolo(class_ids[cls], boxes).splitlines()
|
||
if args.mode in ("both", "habitat"):
|
||
hboxes = annotate_habitat(jpeg, disp_w, disp_h)
|
||
cover_lines = to_yolo(cover_id, hboxes).splitlines()
|
||
if args.mode == "animal":
|
||
cover_lines = [l for l in existing
|
||
if l.startswith(f"{cover_id} ")]
|
||
if args.mode == "habitat":
|
||
animal_lines = [l for l in existing
|
||
if not l.startswith(f"{cover_id} ")]
|
||
lines = animal_lines + cover_lines
|
||
label_path.write_text("\n".join(lines))
|
||
if status == "review":
|
||
review += 1
|
||
with review_file.open("a") as f:
|
||
f.write(f"{rel}\t{cls}\t{reason}\n")
|
||
tag = f"复核[{reason[:10]}]"
|
||
elif lines:
|
||
ok += 1
|
||
tag = "OK"
|
||
else:
|
||
no_target += 1
|
||
tag = "无目标"
|
||
print(f"[{i}/{len(todo)}] {rel}: {tag} ({len(lines)} 行)")
|
||
except Exception as e:
|
||
fail += 1
|
||
with review_file.open("a") as f:
|
||
f.write(f"{rel}\t{cls}\t失败: {e}\n")
|
||
print(f"[{i}/{len(todo)}] {rel}: 失败 - {e}", file=sys.stderr)
|
||
|
||
print(f"\n完成: 有目标 {ok} 张, 无目标 {no_target} 张, 需复核 {review} 张, "
|
||
f"失败 {fail} 张, 耗时 {time.time() - t0:.0f}s")
|
||
if review:
|
||
print(f"复核清单: {review_file}")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|