""" 将标注的 55 组样本转换为标准 YOLO 目标检测数据集 生成: tcaptcha/data/yolo/images/train/ tcaptcha/data/yolo/images/val/ tcaptcha/data/yolo/labels/train/ tcaptcha/data/yolo/labels/val/ tcaptcha/data/yolo/dataset.yaml """ import json import logging import random import shutil from pathlib import Path import cv2 import numpy as np logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") logger = logging.getLogger("prepare_yolo") RAW_DIR = Path("tcaptcha/data/raw") LABEL_FILE = Path("tcaptcha/data/labels.jsonl") YOLO_DIR = Path("tcaptcha/data/yolo") IMG_W = 672 IMG_H = 480 BOX_SIZE = 54 # 图标大致大小 (约 54x54 像素) def find_all_icons_in_bg(bg_path: Path, labeled_points: list) -> list: """ 结合人工标注点与轮廓补充检测,找到大图中所有图标(包含 3 个目标 + 2~3 个干扰图标) 返回: [(cx, cy, w, h), ...] 绝对像素坐标 """ img = cv2.imread(str(bg_path)) if img is None: return [] gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 提取暗色轮廓找干扰图标 edges = cv2.Canny(gray, 40, 120) kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (9, 9)) closed = cv2.morphologyEx(edges, cv2.MORPH_CLOSE, kernel) contours, _ = cv2.findContours(closed, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) all_boxes = [] # 1. 先加入人工标注的 3 个目标(绝对精准) for p in labeled_points: cx, cy = p["x"], p["y"] all_boxes.append((cx, cy, BOX_SIZE, BOX_SIZE)) # 2. 补充检测未标注的干扰图标 for cnt in contours: x, y, w, h = cv2.boundingRect(cnt) if 26 <= w <= 85 and 26 <= h <= 85 and (w * h) >= 650: cx, cy = x + w // 2, y + h // 2 # 必须远离已有图标 if all(np.hypot(cx - lx, cy - ly) > 42 for lx, ly, _, _ in all_boxes): # 限制尺寸 bw = min(68, max(46, w + 8)) bh = min(68, max(46, h + 8)) all_boxes.append((cx, cy, bw, bh)) return all_boxes def prepare_dataset(val_ratio: float = 0.2, seed: int = 42): random.seed(seed) if not LABEL_FILE.exists(): logger.error("标签文件不存在: %s", LABEL_FILE) return # 清理并创建目录 if YOLO_DIR.exists(): shutil.rmtree(YOLO_DIR) for split in ["train", "val"]: (YOLO_DIR / "images" / split).mkdir(parents=True, exist_ok=True) (YOLO_DIR / "labels" / split).mkdir(parents=True, exist_ok=True) with open(LABEL_FILE, "r", encoding="utf-8") as f: records = [json.loads(line) for line in f if line.strip()] random.shuffle(records) val_count = max(5, int(len(records) * val_ratio)) train_records = records[val_count:] val_records = records[:val_count] logger.info("总标注样本: %d (训练集: %d, 验证集: %d)", len(records), len(train_records), len(val_records)) def process_split(record_list, split_name): total_boxes = 0 for r in record_list: sid = r["sample_id"] src_img = RAW_DIR / f"{sid}_bg.png" if not src_img.exists(): continue dst_img = YOLO_DIR / "images" / split_name / f"{sid}.png" shutil.copy(src_img, dst_img) # 提取全部图标坐标 boxes = find_all_icons_in_bg(src_img, r["points"]) total_boxes += len(boxes) # 写入 YOLO 格式标注: class_id x_center y_center width height (归一化到 0~1) dst_label = YOLO_DIR / "labels" / split_name / f"{sid}.txt" lines = [] for cx, cy, bw, bh in boxes: # 边界截断保护 x1 = max(0, cx - bw / 2) y1 = max(0, cy - bh / 2) x2 = min(IMG_W, cx + bw / 2) y2 = min(IMG_H, cy + bh / 2) norm_cx = (x1 + x2) / 2.0 / IMG_W norm_cy = (y1 + y2) / 2.0 / IMG_H norm_w = (x2 - x1) / IMG_W norm_h = (y2 - y1) / IMG_H lines.append(f"0 {norm_cx:.6f} {norm_cy:.6f} {norm_w:.6f} {norm_h:.6f}") dst_label.write_text("\n".join(lines), encoding="utf-8") logger.info(" [%s] 处理完成: %d 张图片, 共 %d 个图标标注", split_name, len(record_list), total_boxes) process_split(train_records, "train") process_split(val_records, "val") # 生成 dataset.yaml yaml_content = f"""path: {YOLO_DIR.resolve()} train: images/train val: images/val names: 0: icon """ yaml_path = YOLO_DIR / "dataset.yaml" yaml_path.write_text(yaml_content, encoding="utf-8") logger.info("✓ YOLO 数据集生成完毕: %s", yaml_path) if __name__ == "__main__": prepare_dataset()