368 lines
15 KiB
Python
368 lines
15 KiB
Python
"""cui
|
||
数据集分割脚本
|
||
1. 将train目录下的图片按70:15:15分割到train/val/test
|
||
2. 对样本量过少的类别进行标记
|
||
"""
|
||
import os
|
||
import shutil
|
||
import random
|
||
from pathlib import Path
|
||
from typing import List, Tuple
|
||
|
||
|
||
def split_dataset(
|
||
dataset_root: str,
|
||
train_ratio: float = 0.70,
|
||
val_ratio: float = 0.15,
|
||
test_ratio: float = 0.15,
|
||
min_samples_threshold: int = 10,
|
||
mark_insufficient: bool = True,
|
||
max_total: int = 120,
|
||
seed: int = 42
|
||
):
|
||
"""
|
||
分割数据集
|
||
|
||
Args:
|
||
dataset_root: 数据集根目录(包含train目录)
|
||
train_ratio: 训练集比例(默认0.70)
|
||
val_ratio: 验证集比例(默认0.15)
|
||
test_ratio: 测试集比例(默认0.15)
|
||
min_samples_threshold: 最小样本数阈值,低于此值的类别会被标记(默认10)
|
||
mark_insufficient: 是否在文件夹名称后添加"样本数太少"标记(默认True)
|
||
max_total: 如果总数超过100,限制到max_total张(默认120)
|
||
seed: 随机种子(默认42)
|
||
"""
|
||
# 验证比例
|
||
if abs(train_ratio + val_ratio + test_ratio - 1.0) > 0.001:
|
||
raise ValueError(f"比例总和必须为1.0,当前为: {train_ratio + val_ratio + test_ratio}")
|
||
|
||
dataset_root = Path(dataset_root)
|
||
train_dir = dataset_root / 'train'
|
||
val_dir = dataset_root / 'val'
|
||
test_dir = dataset_root / 'test'
|
||
|
||
if not train_dir.exists():
|
||
raise ValueError(f"训练目录不存在: {train_dir}")
|
||
|
||
# 创建val和test目录
|
||
val_dir.mkdir(parents=True, exist_ok=True)
|
||
test_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
# 设置随机种子
|
||
random.seed(seed)
|
||
|
||
print("="*80)
|
||
print(f"数据集分割工具")
|
||
print("="*80)
|
||
print(f"数据集根目录: {dataset_root}")
|
||
print(f"分割比例: 训练集={train_ratio*100:.0f}%, 验证集={val_ratio*100:.0f}%, 测试集={test_ratio*100:.0f}%")
|
||
print(f"最小样本数阈值: {min_samples_threshold}")
|
||
print(f"样本不足标记: {'启用' if mark_insufficient else '禁用'}")
|
||
print("="*80 + "\n")
|
||
|
||
# 检查val和test目录是否已有数据
|
||
val_has_data = val_dir.exists() and any(val_dir.iterdir())
|
||
test_has_data = test_dir.exists() and any(test_dir.iterdir())
|
||
|
||
if val_has_data or test_has_data:
|
||
print("⚠ 检测到验证集或测试集已有数据")
|
||
print("将检查所有类别的比例,如果不符合70:15:15将重新分配\n")
|
||
|
||
# 获取所有类别文件夹
|
||
class_folders = [f for f in train_dir.iterdir() if f.is_dir()]
|
||
total_classes = len(class_folders)
|
||
|
||
print(f"找到 {total_classes} 个类别\n")
|
||
|
||
stats = {
|
||
'total_classes': total_classes,
|
||
'processed_classes': 0,
|
||
'skipped_classes': 0,
|
||
'sufficient_samples': 0,
|
||
'insufficient_samples': 0,
|
||
'total_images': 0,
|
||
'train_images': 0,
|
||
'val_images': 0,
|
||
'test_images': 0,
|
||
'marked_classes': []
|
||
}
|
||
|
||
# 处理每个类别
|
||
for class_folder in sorted(class_folders):
|
||
class_name = class_folder.name
|
||
|
||
# 收集所有图片(从train/val/test)
|
||
train_images = []
|
||
for ext in ['*.jpg', '*.jpeg', '*.png', '*.JPG', '*.JPEG', '*.PNG']:
|
||
train_images.extend(list(class_folder.glob(ext)))
|
||
|
||
val_class_dir = val_dir / class_name
|
||
val_images = []
|
||
if val_class_dir.exists():
|
||
for ext in ['*.jpg', '*.jpeg', '*.png', '*.JPG', '*.JPEG', '*.PNG']:
|
||
val_images.extend(list(val_class_dir.glob(ext)))
|
||
|
||
test_class_dir = test_dir / class_name
|
||
test_images = []
|
||
if test_class_dir.exists():
|
||
for ext in ['*.jpg', '*.jpeg', '*.png', '*.JPG', '*.JPEG', '*.PNG']:
|
||
test_images.extend(list(test_class_dir.glob(ext)))
|
||
|
||
# 合并所有图片
|
||
all_images = train_images + val_images + test_images
|
||
num_images = len(all_images)
|
||
stats['total_images'] += num_images
|
||
|
||
if num_images == 0:
|
||
print(f"⚠ {class_name}: 0张图片,跳过")
|
||
continue
|
||
|
||
# 检查当前比例是否正确
|
||
current_train = len(train_images)
|
||
current_val = len(val_images)
|
||
current_test = len(test_images)
|
||
|
||
# 计算期望的数量
|
||
expected_train = int(num_images * train_ratio)
|
||
expected_val = int(num_images * val_ratio)
|
||
expected_test = num_images - expected_train - expected_val
|
||
|
||
# 检查比例是否接近(允许1-2张的误差)
|
||
ratio_correct = (
|
||
abs(current_train - expected_train) <= 2 and
|
||
abs(current_val - expected_val) <= 2 and
|
||
abs(current_test - expected_test) <= 2
|
||
)
|
||
|
||
# 如果总数超过100,即使比例正确也需要限制
|
||
if ratio_correct and num_images > 0 and num_images <= 100:
|
||
# 比例正确且总数<=100,跳过
|
||
stats['skipped_classes'] += 1
|
||
is_insufficient = num_images < min_samples_threshold
|
||
if is_insufficient:
|
||
stats['insufficient_samples'] += 1
|
||
else:
|
||
stats['sufficient_samples'] += 1
|
||
print(f"✓ {class_name}: 总数={num_images}, 比例正确 (train={current_train}, val={current_val}, test={current_test}),跳过")
|
||
continue
|
||
|
||
# 如果比例正确但总数>100,需要限制
|
||
if ratio_correct and num_images > 100:
|
||
print(f"处理 {class_name}: 总数={num_images} > 100, 比例正确但需要限制到{max_total}张")
|
||
stats['processed_classes'] += 1
|
||
# 随机选择max_total张
|
||
random.shuffle(all_images)
|
||
image_files = all_images[:max_total]
|
||
num_images = len(image_files)
|
||
# 重新计算期望数量
|
||
expected_train = int(num_images * train_ratio)
|
||
expected_val = int(num_images * val_ratio)
|
||
expected_test = num_images - expected_train - expected_val
|
||
print(f" 将重新分配为: train={expected_train}, val={expected_val}, test={expected_test}")
|
||
elif not ratio_correct:
|
||
# 比例不正确,需要重新分配
|
||
stats['processed_classes'] += 1
|
||
print(f"处理 {class_name}: 总数={num_images}, 当前比例不正确 (train={current_train}, val={current_val}, test={current_test})")
|
||
|
||
# 如果总数超过100,限制到max_total
|
||
if num_images > 100:
|
||
print(f" 总数={num_images} > 100, 限制到{max_total}张")
|
||
random.shuffle(all_images)
|
||
image_files = all_images[:max_total]
|
||
num_images = len(image_files)
|
||
# 重新计算期望数量
|
||
expected_train = int(num_images * train_ratio)
|
||
expected_val = int(num_images * val_ratio)
|
||
expected_test = num_images - expected_train - expected_val
|
||
else:
|
||
image_files = all_images
|
||
print(f" 将重新分配为: train={expected_train}, val={expected_val}, test={expected_test}")
|
||
|
||
|
||
# 检查样本数是否足够
|
||
is_insufficient = num_images < min_samples_threshold
|
||
|
||
if is_insufficient:
|
||
stats['insufficient_samples'] += 1
|
||
if mark_insufficient:
|
||
# 标记样本不足的类别
|
||
marked_name = f"{class_name}_样本数太少"
|
||
stats['marked_classes'].append((class_name, num_images, marked_name))
|
||
else:
|
||
stats['sufficient_samples'] += 1
|
||
|
||
# 确定最终的类别名称(如果需要标记)
|
||
final_class_name = f"{class_name}_样本数太少" if (is_insufficient and mark_insufficient) else class_name
|
||
|
||
# 如果还没打乱(总数<=100的情况),现在打乱
|
||
if num_images == len(all_images):
|
||
random.shuffle(image_files)
|
||
|
||
# 计算分割点
|
||
n_train = int(num_images * train_ratio)
|
||
n_val = int(num_images * val_ratio)
|
||
# 剩余的全部给test,确保所有图片都被分配
|
||
|
||
# 分割图片(重新分配)
|
||
new_train_images = image_files[:n_train]
|
||
new_val_images = image_files[n_train:n_train + n_val]
|
||
new_test_images = image_files[n_train + n_val:]
|
||
|
||
# 创建目标目录
|
||
train_target = train_dir / final_class_name
|
||
val_target = val_dir / final_class_name
|
||
test_target = test_dir / final_class_name
|
||
|
||
train_target.mkdir(exist_ok=True)
|
||
val_target.mkdir(exist_ok=True)
|
||
test_target.mkdir(exist_ok=True)
|
||
|
||
# 先移动图片到正确的位置(在删除之前)
|
||
moved_to_train = 0
|
||
for img in new_train_images:
|
||
if not img.exists():
|
||
continue
|
||
target = train_target / img.name
|
||
if img.parent != train_target:
|
||
if target.exists():
|
||
target.unlink()
|
||
try:
|
||
shutil.move(str(img), str(target))
|
||
moved_to_train += 1
|
||
except Exception as e:
|
||
print(f" ⚠ 移动失败 {img}: {e}")
|
||
|
||
moved_to_val = 0
|
||
for img in new_val_images:
|
||
if not img.exists():
|
||
continue
|
||
target = val_target / img.name
|
||
if img.parent != val_target:
|
||
if target.exists():
|
||
target.unlink()
|
||
try:
|
||
shutil.move(str(img), str(target))
|
||
moved_to_val += 1
|
||
except Exception as e:
|
||
print(f" ⚠ 移动失败 {img}: {e}")
|
||
|
||
moved_to_test = 0
|
||
for img in new_test_images:
|
||
if not img.exists():
|
||
continue
|
||
target = test_target / img.name
|
||
if img.parent != test_target:
|
||
if target.exists():
|
||
target.unlink()
|
||
try:
|
||
shutil.move(str(img), str(target))
|
||
moved_to_test += 1
|
||
except Exception as e:
|
||
print(f" ⚠ 移动失败 {img}: {e}")
|
||
|
||
# 然后清理所有目录(删除不在新分配列表中的图片)
|
||
# 收集所有原始图片路径
|
||
original_images = set(train_images + val_images + test_images)
|
||
new_images = set(new_train_images + new_val_images + new_test_images)
|
||
images_to_delete = original_images - new_images
|
||
|
||
for img in images_to_delete:
|
||
if img.exists():
|
||
try:
|
||
img.unlink()
|
||
except Exception as e:
|
||
pass
|
||
|
||
# 处理样本不足标记(如果需要)
|
||
if mark_insufficient and is_insufficient:
|
||
# 需要重命名train目录的文件夹
|
||
if class_folder != train_target:
|
||
# 如果原文件夹还存在且不为空,需要移动剩余文件
|
||
if class_folder.exists() and any(class_folder.iterdir()):
|
||
for item in class_folder.iterdir():
|
||
if item.is_file():
|
||
shutil.move(str(item), train_target / item.name)
|
||
# 删除原文件夹(如果为空)
|
||
try:
|
||
if class_folder.exists() and not any(class_folder.iterdir()):
|
||
class_folder.rmdir()
|
||
except Exception as e:
|
||
print(f" 警告: 删除原文件夹失败: {e}")
|
||
|
||
# 更新统计
|
||
stats['train_images'] += len(new_train_images)
|
||
stats['val_images'] += len(new_val_images)
|
||
stats['test_images'] += len(new_test_images)
|
||
|
||
# 打印进度
|
||
status = "⚠样本不足" if is_insufficient else "✓"
|
||
print(f"{status} {class_name}: 总计={num_images}, 训练={len(new_train_images)}, 验证={len(new_val_images)}, 测试={len(new_test_images)}")
|
||
|
||
# 打印总结
|
||
print("\n" + "="*80)
|
||
print("分割完成!")
|
||
print("="*80)
|
||
print(f"总类别数: {stats['total_classes']}")
|
||
print(f"已处理: {stats['processed_classes']} 个类别(比例不正确,已重新分配)")
|
||
print(f"已跳过: {stats['skipped_classes']} 个类别(比例正确)")
|
||
print(f"样本充足: {stats['sufficient_samples']} 个类别")
|
||
print(f"样本不足: {stats['insufficient_samples']} 个类别")
|
||
print(f"\n总图片数: {stats['total_images']}")
|
||
print(f"训练集: {stats['train_images']} 张 ({stats['train_images']/stats['total_images']*100:.1f}%)")
|
||
print(f"验证集: {stats['val_images']} 张 ({stats['val_images']/stats['total_images']*100:.1f}%)")
|
||
print(f"测试集: {stats['test_images']} 张 ({stats['test_images']/stats['total_images']*100:.1f}%)")
|
||
|
||
if stats['marked_classes']:
|
||
print(f"\n已标记的类别(样本数 < {min_samples_threshold}):")
|
||
for old_name, count, new_name in stats['marked_classes']:
|
||
print(f" {old_name} ({count}张) -> {new_name}")
|
||
|
||
print("\n✓ 完成!")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
import argparse
|
||
|
||
parser = argparse.ArgumentParser(description='数据集分割工具')
|
||
parser.add_argument('--dataset', type=str,
|
||
default='dataset/WholeIngredientRecognition',
|
||
help='数据集根目录(默认: dataset/WholeIngredientRecognition)')
|
||
parser.add_argument('--train-ratio', type=float, default=0.70,
|
||
help='训练集比例(默认: 0.70)')
|
||
parser.add_argument('--val-ratio', type=float, default=0.15,
|
||
help='验证集比例(默认: 0.15)')
|
||
parser.add_argument('--test-ratio', type=float, default=0.15,
|
||
help='测试集比例(默认: 0.15)')
|
||
parser.add_argument('--min-samples', type=int, default=10,
|
||
help='最小样本数阈值,低于此值会被标记(默认: 10)')
|
||
parser.add_argument('--no-mark', action='store_true',
|
||
help='不标记样本不足的类别')
|
||
parser.add_argument('--seed', type=int, default=42,
|
||
help='随机种子(默认: 42)')
|
||
parser.add_argument('--max-total', type=int, default=120,
|
||
help='如果总数超过100,限制到max_total张(默认: 120)')
|
||
|
||
args = parser.parse_args()
|
||
|
||
# 获取项目根目录
|
||
script_dir = Path(__file__).parent
|
||
project_root = script_dir.parent
|
||
|
||
# 处理相对路径
|
||
if not os.path.isabs(args.dataset):
|
||
dataset_path = project_root / args.dataset
|
||
else:
|
||
dataset_path = Path(args.dataset)
|
||
|
||
split_dataset(
|
||
dataset_root=str(dataset_path),
|
||
train_ratio=args.train_ratio,
|
||
val_ratio=args.val_ratio,
|
||
test_ratio=args.test_ratio,
|
||
min_samples_threshold=args.min_samples,
|
||
mark_insufficient=not args.no_mark,
|
||
max_total=args.max_total,
|
||
seed=args.seed
|
||
)
|