diff --git a/.gitignore b/.gitignore index 6e9ac90..df6af72 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,6 @@ captures/ training/datasets/ training/venv/ *.pt + +# 文生图配置(含API key,不入库) +#training/gen_images_config.json diff --git a/docs/01-技术方案.md b/docs/01-技术方案.md index 5a7798e..5437a71 100644 --- a/docs/01-技术方案.md +++ b/docs/01-技术方案.md @@ -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% | | 负样本 | 补充无目标场景图,控制误检 | diff --git a/training/auto_label.py b/training/auto_label.py deleted file mode 100644 index ee32dc9..0000000 --- a/training/auto_label.py +++ /dev/null @@ -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() diff --git a/training/gen_images_config.json b/training/gen_images_config.json new file mode 100644 index 0000000..4fae866 --- /dev/null +++ b/training/gen_images_config.json @@ -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分钟的黄昏光线" + ] +} diff --git a/training/visualize_labels.py b/training/visualize_labels.py deleted file mode 100644 index 29e922f..0000000 --- a/training/visualize_labels.py +++ /dev/null @@ -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()