This commit is contained in:
2026-08-21 17:55:18 +08:00
parent 45d55028d7
commit d056f01965
5 changed files with 34 additions and 499 deletions
+3
View File
@@ -10,3 +10,6 @@ captures/
training/datasets/
training/venv/
*.pt
# 文生图配置(含API key,不入库)
#training/gen_images_config.json
+2 -2
View File
@@ -210,8 +210,8 @@ flowchart LR
| --- | --- |
| 数据量 | 1000~2000 张(首版可 500+ 起步,滚动补充) |
| 多样性 | 覆盖不同季节、晨昏/正午/逆光、远近距离、姿态、遮挡、背景(草丛/农田/林地/雪地) |
| 标注 | 本地 LocalAI qwen3.8-9b 多模态自动标注(192.168.3.210:18080),先过滤再标注YOLO 格式(class, cx, cy, w, h |
| 生境标注 | 同一模型标注可疑度最高的 3 个具体藏身点(cover 类,黄色框,约占画面 2%~15%),综合植被密度 / 地形 / 光线判断NMS 去重 + 尺寸过滤 |
| 标注 | 人工标注(LabelImg 等工具)YOLO 格式(class, cx, cy, w, h |
| 生境标注 | 人工标注可疑度最高的藏身点(cover 类,黄色框,约占画面 2%~15%),综合植被密度 / 地形 / 光线判断 |
| 数据增强 | Mosaic、MixUp、HSV 扰动、随机翻转、随机缩放裁剪 |
| 数据划分 | train 80% / val 10% / test 10% |
| 负样本 | 补充无目标场景图,控制误检 |
-418
View File
@@ -1,418 +0,0 @@
#!/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()
+29
View File
@@ -0,0 +1,29 @@
{
"api_format": "dashscope",
"endpoint": "https://ws-ptopj7gg4klw6oo9.cn-beijing.maas.aliyuncs.com",
"model": "qwen-image-3.0",
"api_key": "sk-ws-H.EDRYILD.wiak.MEQCICyn77OTzVRbTbDuC5mmDGqe1fgs3ICRfJfXUKFyWNYdAiBV0Rl7nMfD91ZLco3cWNO8NEg8DImXAAf6f1nkrCA2mQ",
"size": "1152*2048",
"count": 100,
"workers": 10,
"negative_prompt": "野鸡出现在画面下2/3位置的近景中景,野鸡在画面中央,野鸡大特写,野鸡身体暴露70%以上,野鸡大部分身体可见,野鸡露出大半身体少量遮挡,野鸡太大,野鸡占画面高度超过2%,野鸡高度超过26像素,野鸡羽毛纹理清晰,近距离拍摄",
"output_dir": "datasets/images/pheasant",
"timeout": 600,
"retries": 3,
"prompt_template": "生成一张野外观察者(仰角45度、俯视45度、平视)随机视角的照片,模拟等效35mm焦距相机拍摄。画面是开阔的{env},相机位于约100米外、离地约1.6米(站立人眼高度)的观察点,基本平视、略微下俯,地平线位于画面上部约1/3处(约683像素),地面透视平缓,远处田野占据画面主要部分,前景不出现近处地面或作物的特写。按真实物理尺寸换算:环颈雉(野鸡)整体高约0.7米,站在90-110米外,在1152*2048像素的画面中野鸡整体(含尾羽)只有约20~26像素高、身体约12~15像素,是远处很小的目标,绝不可画大、画清晰、特写。野鸡必须位于画面最上方1/3区域内、贴近地平线(野鸡中心y坐标必须小于675像素,即画面顶部676像素范围内),位于画面水平方向两侧区域内,绝不能在画面正中、绝不能在画面下半部。远景中的野鸡细节模糊、轮廓模糊,大部分身体被草丛/植被严密遮挡(遮挡70%以上)不可以是零星遮挡,多数雄性野鸡只露出头部和白色颈部。画面中既有雄性野鸡也有雌性野鸡,最多不超过4只。光线为{light}。本图用于手机端YOLO端侧模型训练:野鸡在图片中尽量保持小目标(高度不超过26像素)、尽量多遮挡(遮挡比例尽量达到70%以上),以模拟手机端真实识别场景。",
"envs": [
"水田(注满水的稻田,水面平静有倒影,被田埂分割成方块)",
"旱田(干燥的耕地,翻耕过的土垄,地面干裂无积水)",
"林田交界地带(画面一侧是茂密树林,另一侧是开阔农田,中间有草带过渡)",
"裸露土地(荒芜的裸土坡地,碎石和稀疏杂草,无庄稼)",
"雪地(积雪覆盖的田野,白茫茫一片,露出少量枯草杆)",
"玉米地(高大的玉米秆成行排列,叶子枯黄或翠绿,地面有玉米残株)"
],
"lights": [
"日出后约30分钟的清晨低角度光线",
"上午的明亮散射光",
"正午的强烈阳光",
"下午的斜射光线",
"日落前约30分钟的黄昏光线"
]
}
-79
View File
@@ -1,79 +0,0 @@
#!/usr/bin/env python3
"""标注预览图生成: 动物红色实线框 + 生境 cover 黄色虚线框
用法:
venv/bin/python visualize_labels.py --input datasets/images --labels datasets/labels --output datasets/pheasant_label
"""
import argparse
from pathlib import Path
from PIL import Image, ImageDraw
CLASSES = ["pheasant", "cover"]
LABELS = {"pheasant": "野鸡", "cover": "疑似区域"}
def draw_dashed(draw, box, outline, width, dash=12, gap=8):
x1, y1, x2, y2 = box
for (ax, ay, bx, by) in [(x1, y1, x2, y1), (x2, y1, x2, y2),
(x2, y2, x1, y2), (x1, y2, x1, y1)]:
length = max(abs(bx - ax), abs(by - ay))
steps = max(int(length / (dash + gap)), 1)
for i in range(steps):
s = i / steps
e = min((i * (dash + gap) + dash) / length, 1.0)
if e <= s:
continue
draw.line([ax + (bx - ax) * s, ay + (by - ay) * s,
ax + (bx - ax) * e, ay + (by - ay) * e],
fill=outline, width=width)
def main():
ap = argparse.ArgumentParser(description="标注预览图生成")
ap.add_argument("--input", default="datasets/images")
ap.add_argument("--labels", default="datasets/labels")
ap.add_argument("--output", default="datasets/pheasant_label")
args = ap.parse_args()
img_dir = Path(args.input)
lab_dir = Path(args.labels)
out_dir = Path(args.output)
out_dir.mkdir(parents=True, exist_ok=True)
count = 0
for img_path in sorted(img_dir.rglob("*")):
if img_path.suffix.lower() not in (".jpg", ".jpeg", ".png", ".webp", ".bmp"):
continue
rel = img_path.relative_to(img_dir)
lab_path = lab_dir / rel.with_suffix(".txt")
if not lab_path.exists():
continue
im = Image.open(img_path).convert("RGB")
W, H = im.size
d = ImageDraw.Draw(im)
for line in lab_path.read_text().splitlines():
parts = line.split()
if not parts:
continue
cid, cx, cy, w, h = map(float, parts)
box = ((cx - w / 2) * W, (cy - h / 2) * H,
(cx + w / 2) * W, (cy + h / 2) * H)
if int(cid) == 1: # cover 生境: 黄色虚线
draw_dashed(d, box, (255, 200, 0), width=5)
d.text((box[0] + 6, max(box[1] - 28, 4)), "疑似区域",
fill=(255, 200, 0))
else: # 动物: 红色实线
d.rectangle(box, outline=(255, 0, 0), width=6)
label = LABELS.get(CLASSES[int(cid)], CLASSES[int(cid)])
d.text((box[0] + 6, max(box[1] - 28, 4)), label,
fill=(255, 0, 0))
out = out_dir / (img_path.stem + ".jpg")
im.save(out, quality=92)
count += 1
print(out.name)
print(f"共生成 {count}")
if __name__ == "__main__":
main()