""" 密集堆叠箱子 YOLO 实例分割数据集生成器 v3 ============================================== 整体流程: 1. 在临时画布上【正交拼接】所有箱子 → 无间隙、无黑色底色 2. 对整张拼图做【一次全局透视变换】→ 统一的俯视畸变 3. 贴到背景图【中间 60% 区域】 4. labels: 整个堆垛的 外轮廓多边形(所有箱子角点的凸包) 修复: - 拼图画布用第一个箱子纹理铺底,零间隙 → 无黑色 - label 为整体堆垛多边形(如 3 箱合并为一个 6+ 点多边形) """ import cv2 import numpy as np from pathlib import Path import random import os from concurrent.futures import ThreadPoolExecutor, as_completed # ============================================================ # 配置 # ============================================================ class Config: # ---- 必填 ---- BOX_DIR = r"" # 箱子正面图文件夹 BG_DIR = r"" # 背景环境图文件夹 # ---- 输出 ---- OUTPUT_DIR = r"./box_dataset" NUM_IMAGES = 500 TRAIN_RATIO = 0.8 IMG_WIDTH = 1280 IMG_HEIGHT = 960 # ---- 画面占比 ---- STACK_CENTER_RATIO = 0.60 # 堆垛占画面中间的比例(宽高各60%) STACK_Y_OFFSET = 0.08 # 堆垛整体下移 # ---- 堆垛结构 ---- MIN_COLS = 2 MAX_COLS = 5 MIN_BASE_ROWS = 1 MAX_BASE_ROWS = 4 MAX_STACK_HEIGHT = 4 # ---- 箱子尺寸(正交画布上) ---- BOX_W_MIN = 80 BOX_W_MAX = 180 BOX_H_MIN = 100 BOX_H_MAX = 240 # ---- 箱子间距(设为0=零间隙,无黑色缝隙) ---- GAP_X = 0 GAP_Y = 0 # ---- 全局透视 (上边/下边) ---- PERSPECTIVE_TOP_RATIO = 1.05 PERSPECTIVE_VAR = 0.02 # ---- 箱子外观 ---- BRIGHTNESS_RANGE = (0.80, 1.25) SATURATION_RANGE = (0.75, 1.20) HUE_SHIFT_RANGE = (-12, 12) # ---- 背景 ---- BG_BLUR_RANGE = (0, 3) # ---- 多线程 ---- WORKERS = 4 # ============================================================ # 工具 # ============================================================ def load_images(directory: str) -> list: exts = ('.jpg', '.jpeg', '.png', '.bmp', '.webp') imgs = [] p = Path(directory) if not p.exists(): return imgs for f in sorted(p.iterdir()): if f.suffix.lower() in exts: img = cv2.imread(str(f)) if img is not None: imgs.append(img) return imgs def adjust_color(img, brightness=1.0, saturation=1.0, hue_shift=0): hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV).astype(np.float32) hsv[:, :, 0] = (hsv[:, :, 0] + hue_shift) % 180 hsv[:, :, 1] = np.clip(hsv[:, :, 1] * saturation, 0, 255) hsv[:, :, 2] = np.clip(hsv[:, :, 2] * brightness, 0, 255) return cv2.cvtColor(hsv.astype(np.uint8), cv2.COLOR_HSV2BGR) # ============================================================ # 堆垛布局 # ============================================================ def build_stack_layout(cfg): """ 正交坐标系中构建多层堆垛。 返回: boxes, comp_w, comp_h """ n_cols = random.randint(cfg.MIN_COLS, cfg.MAX_COLS) n_base_rows = random.randint(cfg.MIN_BASE_ROWS, cfg.MAX_BASE_ROWS) col_widths = [random.randint(cfg.BOX_W_MIN, cfg.BOX_W_MAX) for _ in range(n_cols)] row_heights = [random.randint(cfg.BOX_H_MIN, cfg.BOX_H_MAX) for _ in range(n_base_rows)] # 零间距计算坐标 col_x = [0] for w in col_widths: col_x.append(col_x[-1] + w) row_y = [0] for h in row_heights: row_y.append(row_y[-1] + h) boxes = [] for r in range(n_base_rows): for c in range(n_cols): boxes.append({ 'row': r, 'col': c, 'layer': 0, 'x1': col_x[c], 'y1': row_y[r], 'x2': col_x[c + 1], 'y2': row_y[r + 1], }) # 上层随机叠高 for layer in range(1, cfg.MAX_STACK_HEIGHT): for r in range(n_base_rows): for c in range(n_cols): if random.random() < 0.5: continue below = [b for b in boxes if b['row'] == r and b['col'] == c and b['layer'] == layer - 1] if not below: continue base = below[0] shrink = random.uniform(0.82, 0.96) bw, bh = base['x2'] - base['x1'], base['y2'] - base['y1'] nw = max(20, int(bw * shrink)) nh = max(20, int(bh * shrink)) nx1 = base['x1'] + (bw - nw) // 2 ny1 = base['y1'] - nh boxes.append({ 'row': r, 'col': c, 'layer': layer, 'x1': nx1, 'y1': ny1, 'x2': nx1 + nw, 'y2': ny1 + nh, }) # 获取画布范围 xs = [b['x1'] for b in boxes] + [b['x2'] for b in boxes] ys = [b['y1'] for b in boxes] + [b['y2'] for b in boxes] comp_w = int(max(xs) - min(xs) + 2) comp_h = int(max(ys) - min(ys) + 2) dx = min(xs) - 1 dy = min(ys) - 1 for b in boxes: b['x1'] -= dx b['x2'] -= dx b['y1'] -= dy b['y2'] -= dy return boxes, comp_w, comp_h # ============================================================ # 拼图画布(无黑色底色) # ============================================================ def build_composition(boxes_ortho, comp_w, comp_h, box_imgs, cfg): """ 用第一个箱子纹理铺满整张画布,再在上面画各个箱子。 全程零间隙,无黑色出现。 """ # 拿一个箱子纹理铺满画布(消除黑色底色) base_tex = random.choice(box_imgs).copy() base_tex = adjust_color( base_tex, brightness=random.uniform(*cfg.BRIGHTNESS_RANGE), saturation=random.uniform(*cfg.SATURATION_RANGE), hue_shift=int(random.uniform(*cfg.HUE_SHIFT_RANGE)), ) comp = cv2.resize(base_tex, (comp_w, comp_h), interpolation=cv2.INTER_AREA) # 按层排序(底层先画) boxes_sorted = sorted(boxes_ortho, key=lambda b: (b['layer'], b['y1'])) for bi in boxes_sorted: x1 = int(max(0, round(bi['x1']))) y1 = int(max(0, round(bi['y1']))) x2 = int(min(comp_w, round(bi['x2']))) y2 = int(min(comp_h, round(bi['y2']))) bw, bh = x2 - x1, y2 - y1 if bw < 3 or bh < 3: continue tex = random.choice(box_imgs).copy() tex = adjust_color( tex, brightness=random.uniform(*cfg.BRIGHTNESS_RANGE), saturation=random.uniform(*cfg.SATURATION_RANGE), hue_shift=int(random.uniform(*cfg.HUE_SHIFT_RANGE)), ) tex = cv2.resize(tex, (bw, bh), interpolation=cv2.INTER_AREA) comp[y1:y2, x1:x2] = tex # 箱间细微阴影(只画底层底部) for bi in boxes_ortho: if bi['layer'] != 0: continue x1 = int(max(0, round(bi['x1']))) x2 = int(min(comp_w, round(bi['x2']))) y2 = int(min(comp_h, round(bi['y2']))) y1 = int(max(0, round(bi['y1']))) if y2 - y1 < 20: continue shadow_h = max(1, (y2 - y1) // 30) for si in range(shadow_h): sy = y2 - shadow_h + si a = 0.20 * (1 - si / shadow_h) row = comp[sy, x1:x2].astype(np.float32) * (1 - a) comp[sy, x1:x2] = np.clip(row, 0, 255).astype(np.uint8) return comp # ============================================================ # 单张生成 # ============================================================ def generate_one(idx, box_imgs, bg_imgs, cfg, out_img_dir, out_lbl_dir): img_w, img_h = cfg.IMG_WIDTH, cfg.IMG_HEIGHT # ---- 1. 正交布局 ---- boxes_ortho, comp_w, comp_h = build_stack_layout(cfg) if not boxes_ortho: return f"box_{idx:05d}.jpg", 0 # ---- 2. 拼图(零黑色) ---- comp = build_composition(boxes_ortho, comp_w, comp_h, box_imgs, cfg) # ---- 3. 全局透视 warp ---- top_ratio = cfg.PERSPECTIVE_TOP_RATIO + random.uniform( -cfg.PERSPECTIVE_VAR, cfg.PERSPECTIVE_VAR) stack_w = int(img_w * cfg.STACK_CENTER_RATIO) stack_h = int(img_h * cfg.STACK_CENTER_RATIO) mid_x = img_w / 2.0 offset_y = int(img_h * cfg.STACK_Y_OFFSET) top_y = int(img_h / 2 - stack_h / 2 + offset_y) bot_y = top_y + stack_h src_corners = np.float32([[0, 0], [comp_w, 0], [comp_w, comp_h], [0, comp_h]]) dst_corners = np.float32([ [mid_x - (stack_w / 2) * top_ratio, top_y], [mid_x + (stack_w / 2) * top_ratio, top_y], [mid_x + stack_w / 2, bot_y], [mid_x - stack_w / 2, bot_y], ]) M = cv2.getPerspectiveTransform(src_corners, dst_corners) warped = cv2.warpPerspective( comp, M, (img_w, img_h), borderMode=cv2.BORDER_CONSTANT, borderValue=(0, 0, 0), ) mask = cv2.warpPerspective( np.ones((comp_h, comp_w), dtype=np.uint8) * 255, M, (img_w, img_h), borderMode=cv2.BORDER_CONSTANT, borderValue=0, ) # ---- 4. 贴到背景 ---- bg = random.choice(bg_imgs).copy() bg = cv2.resize(bg, (img_w, img_h)) bk = random.randint(*cfg.BG_BLUR_RANGE) if bk >= 3: if bk % 2 == 0: bk += 1 bg = cv2.GaussianBlur(bg, (bk, bk), 0) canvas = bg.copy() valid = mask > 0 for ch in range(3): canvas[:, :, ch] = np.where(valid, warped[:, :, ch], canvas[:, :, ch]) # ---- 5. 标签:整体轮廓多边形 ---- # 收集所有箱子角点在最终图像中的坐标 all_corners_final = [] for bi in boxes_ortho: corners_comp = np.float32([ [bi['x1'], bi['y1']], [bi['x2'], bi['y1']], [bi['x2'], bi['y2']], [bi['x1'], bi['y2']], ]) ones = np.ones((4, 1), dtype=np.float32) pts_h = np.hstack([corners_comp, ones]) pts_f = (M @ pts_h.T).T pts_f[:, 0] /= pts_f[:, 2] pts_f[:, 1] /= pts_f[:, 2] all_corners_final.append(pts_f[:, :2]) all_pts = np.vstack(all_corners_final) # (N, 2) # 凸包 → 整个堆垛的外轮廓 hull = cv2.convexHull(all_pts.astype(np.float32)) hull = hull.squeeze(1) # (N, 2) # 简化(每隔1个点取一个,避免过多点) if len(hull) > 12: keep = np.arange(0, len(hull), 2) hull = hull[keep] norm_parts = [] for pt in hull: norm_parts.append(f"{pt[0] / img_w:.6f}") norm_parts.append(f"{pt[1] / img_h:.6f}") label_line = "0 " + " ".join(norm_parts) # ---- 保存 ---- fname = f"box_{idx:05d}.jpg" cv2.imwrite(os.path.join(out_img_dir, fname), canvas, [cv2.IMWRITE_JPEG_QUALITY, 95]) lbl_path = os.path.join(out_lbl_dir, f"box_{idx:05d}.txt") with open(lbl_path, 'w') as f: f.write(label_line + "\n") return fname, 1 # 每张图1个标签(整体) # ============================================================ # 主函数 # ============================================================ def main(): c = Config if not c.BOX_DIR or not Path(c.BOX_DIR).exists(): raise FileNotFoundError("请设置 BOX_DIR") if not c.BG_DIR or not Path(c.BG_DIR).exists(): raise FileNotFoundError("请设置 BG_DIR") box_imgs = load_images(c.BOX_DIR) bg_imgs = load_images(c.BG_DIR) if len(box_imgs) < 2: raise ValueError(f"箱子纹理太少({len(box_imgs)}),至少2张") if len(bg_imgs) < 1: raise ValueError("背景图为空") print("=" * 55) print(f"[纹理] 箱子: {len(box_imgs)}张 | 背景: {len(bg_imgs)}张") print(f"[输出] {c.IMG_WIDTH}x{c.IMG_HEIGHT} | {c.NUM_IMAGES}张") print(f"[布局] {c.MIN_COLS}-{c.MAX_COLS}列 x " f"{c.MIN_BASE_ROWS}-{c.MAX_BASE_ROWS}行 | " f"最高{c.MAX_STACK_HEIGHT}层") print(f"[标签] 整体堆垛轮廓多边形(凸包)") print("=" * 55) out = Path(c.OUTPUT_DIR) for split in ['train', 'val']: (out / 'images' / split).mkdir(parents=True, exist_ok=True) (out / 'labels' / split).mkdir(parents=True, exist_ok=True) n_train = int(c.NUM_IMAGES * c.TRAIN_RATIO) n_val = c.NUM_IMAGES - n_train print(f"\n[生成] train={n_train} val={n_val}") jobs = [] for i in range(c.NUM_IMAGES): split = 'train' if i < n_train else 'val' jobs.append((i, str(out / 'images' / split), str(out / 'labels' / split))) done = 0 with ThreadPoolExecutor(max_workers=c.WORKERS) as pool: futures = { pool.submit(generate_one, idx, box_imgs, bg_imgs, c, idir, ldir): idx for idx, idir, ldir in jobs } for fut in as_completed(futures): fname, _ = fut.result() done += 1 if done % 50 == 0 or done == c.NUM_IMAGES: print(f" [{done}/{c.NUM_IMAGES}] {fname}") yaml_text = f"""# YOLO Instance Segmentation - Box Stack path: {Path(c.OUTPUT_DIR).absolute()} train: images/train val: images/val nc: 1 names: 0: box_stack """ with open(out / 'data.yaml', 'w') as f: f.write(yaml_text) print(f"\n{'=' * 55}") print(f"完成! train={n_train} val={n_val}") print(f"每图1个整体堆垛标签") print(f"输出: {out.absolute()}") print("=" * 55) if __name__ == '__main__': main()