You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
backend-duoji-monitor/scripts/generate_box_dataset.py

427 lines
13 KiB
Python

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

"""
密集堆叠箱子 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()