<think>我们面对的是一个YOLOv11格式的数据集(与YOLOv5等类似),数据组织方式通常如下:
- 图像存放在 `images` 文件夹的子文件夹(如 `train`, `val`, `test`)中。
- 标签存放在 `labels` 文件夹的对应子文件夹(如 `train`, `val`, `test`)中,每个标签文件是.txt文件,与图像同名。
- 每个标签文件的每一行代表一个对象,每一行的格式为:`class_id center_x center_y width height`(均为归一化后的数值)。
目标:去除两个特定类别(假设类别ID为 `class_id1` 和 `class_id2`)。这意味着:
1. 从每个标签文件(.txt)中删除所有属于这两个类别的行。
2. 注意:删除后,如果某个标签文件变为空(即该图像中没有任何对象剩余),则我们可能需要删除该标签文件和对应的图像文件(可选,根据需求)。
步骤:
1. 遍历 `labels` 目录下的所有标签文件(包括train/val/test等子目录)。
2. 对于每个标签文件:
a. 读取所有行。
b. 过滤掉以 `class_id1` 或 `class_id2` 开头的行(注意:每行第一个数据是类别ID,后面是坐标)。
c. 如果过滤后还有行,则覆盖写入原标签文件(只写入保留的行);如果过滤后没有行,则可以选择删除该标签文件和对应的图像文件(注意:图像文件在images目录下对应位置)。
3. 注意:删除图像文件时,需要根据标签文件路径找到对应的图像文件路径(将`labels`替换为`images`,并将后缀由.txt改为.jpg/.png等)。
4. 我们还需要更新数据集配置文件(如.yaml文件)中的类别列表,去除这两个类别,并重新映射类别ID(可选,因为删除类别后,剩余的类别ID可能不连续,但YOLO训练时会自动处理,如果不需要保持原来的类别名称顺序,可以不做映射,但配置文件中类别名称列表需要更新)。
但是,用户要求只是去除两个类别,并没有要求重新映射ID。所以我们可以保留剩余类别的原始ID,只是删除了两个类别的对象。
然而,在训练时,如果我们希望类别ID连续(比如从0开始连续编号),则需要重新映射剩余的类别ID(但这不是必须的,因为YOLO模型只关心每个类别的独立训练,但配置文件中类别名称列表需要调整)。
考虑到用户可能希望保留原来的ID(因为训练时模型输出的类别数没有变,只是这两个类别不再出现),或者希望重新调整ID(使剩下的类别ID连续)。这里我们只做基本操作:删除标签行,并删除空标签和对应的图像(可选)。至于类别名称的更新,由用户手动修改配置文件(因为配置文件可能还包含其他信息)。
我们将提供两种处理方式:
- 方式1:仅删除标签文件中的指定类别行,不删除空标签和图像(这样图像仍然保留,只是没有目标,可能会被YOLO忽略,视为背景图像)。
- 方式2:同时删除空标签文件和对应的图像文件(因为有些训练过程不希望包含没有目标的图像)。
我们让用户选择(通过函数参数控制)。
代码结构:
- 输入:根目录(包含images和labels目录),要删除的类别ID列表(如[0,2]),是否删除空标签及对应的图像(默认为True)。
- 遍历labels目录下的所有.txt文件。
注意:图像文件可能有多种后缀,我们可以通过尝试是否存在对应图像文件来删除。
具体代码实现步骤:
1. 导入必要的库:`os`, `glob`(或者直接用os.walk)
2. 定义函数:
def remove_classes_yolo(data_root, remove_class_ids, delete_empty=True):
# data_root: 数据集的根目录(包含images和labels两个目录)
# remove_class_ids: 要删除的类别ID列表,例如[1,3]
# delete_empty: 是否删除空标签文件及对应图像
# 步骤1: 获取labels目录下所有标签文件(包括子目录)
label_dir = os.path.join(data_root, 'labels')
# 使用os.walk遍历所有子目录
for root, dirs, files in os.walk(label_dir):
for file in files:
if file.endswith('.txt'):
label_path = os.path.join(root, file)
# 步骤2: 读取标签文件
with open(label_path, 'r') as f:
lines = f.readlines()
# 步骤3: 过滤掉要删除的类别
new_lines = []
for line in lines:
parts = line.strip().split()
if len(parts) > 0:
class_id = int(parts[0])
if class_id not in remove_class_ids:
new_lines.append(line) # 保留原行(包括换行符)
# 步骤4: 如果原标签文件不为空,但删除后为空,且delete_empty为True,则需要删除标签文件和图像文件
if len(new_lines) == 0 and delete_empty:
# 删除标签文件
os.remove(label_path)
# 构建对应的图像文件路径:将labels替换为images,并且文件后缀改为图像后缀
# 获取相对于label_dir的相对路径
rel_path = os.path.relpath(label_path, label_dir)
# 图像文件路径:将基础目录由labels改为images,并且替换后缀
# 注意:图像后缀可能是多种,尝试几种常见后缀
image_rel_path_no_ext = os.path.splitext(rel_path)[0] # 去掉.txt后缀
image_dir = os.path.join(data_root, 'images')
image_path_no_ext = os.path.join(image_dir, image_rel_path_no_ext)
# 尝试查找图像文件(支持.jpg, .jpeg, .png, .bmp)
found = False
for ext in ['.jpg', '.jpeg', '.png', '.bmp']:
image_path = image_path_no_ext + ext
if os.path.exists(image_path):
os.remove(image_path)
found = True
break
if not found:
print(f"警告:未找到图像文件:{image_path_no_ext}(尝试了常见后缀)")
else:
# 如果还有内容,则重新写入标签文件
with open(label_path, 'w') as f:
f.writelines(new_lines)
3. 注意:如果标签文件所在目录与图像文件的目录结构不完全一致(比如labels/train和images/train),上述基于相对路径的替换是合理的。
4. 关于配置文件(.yaml)的更新:我们这里不自动处理,因为用户可能需要自行调整类别名称列表。但我们可以提示用户手动修改。
5. 调用示例:
data_root = "path/to/your/dataset" # 数据集的根目录,包含images和labels
remove_class_ids = [0, 2] # 要删除的类别ID
remove_classes_yolo(data_root, remove_class_ids)
注意:删除类别后,剩余的类别ID保持不变(比如原来有0,1,2,3,4,删除0和2后,剩下1,3,4)。在训练时,模型输出的类别数量并没有减少(还是5类),但实际上只有3个类别有数据。这可能会影响训练(因为模型仍然会为0和2类分配输出,但训练数据中没有这些类)。所以,用户需要修改模型配置文件(.yaml)中的类别数量(nc)和类别名称列表(names),将nc改为3,并且names只保留剩下的类别(并且按照ID从小到大排列?注意:这里剩下的ID是1,3,4,那么我们需要重新映射为0,1,2吗?)
但是,YOLO模型在训练时,标签中的类别ID将会被直接使用。所以,如果我们的标签中还有ID=1,3,4,那么模型将会认为有5类(因为nc=5),然后第0类、第2类没有样本,而第1、3、4类有样本。这显然不是我们想要的。
因此,我们还需要一个可选步骤:重新映射类别ID,使其连续(从0开始)。但注意,这可能会改变类别的顺序,所以需要同时更新类别名称列表(按照删除后的类别顺序)。
考虑到这个操作比较复杂,而且可能需要用户自定义,我们可以提供另一个函数来重新映射类别ID并更新配置文件。不过,根据用户的问题,只要求去除两个类别,所以我们先完成基本功能(删除行和空文件)。如果用户需要重新映射ID,我们可以建议用户使用额外的工具或者手动处理。
因此,我们这里只完成删除操作。同时,我们强烈建议用户修改配置文件,将`nc`改为剩余类别数,并且修改`names`为剩余类别的名称(按原来的ID顺序去除被删除的类别,然后重新排序?注意:我们不改变标签文件中的ID值,所以用户需要知道剩余类别的ID是什么,然后重新排列names,并设置nc为剩余类别数)。但是,由于我们删除了两个类别,剩余类别的ID并不连续,所以用户可能需要重新映射标签中的ID(使它们连续)以保证模型正确训练。
由于这个问题比较复杂,我们可以在代码中提供一个可选的重映射功能,但这不是最初的需求。我们先按照基本需求完成代码。
所以,我们上面的代码只是删除了指定类别的行,并没有改变其他类别的ID。
如果用户希望重新映射ID,我们可以这样做:
1. 首先,我们需要知道数据集中剩余的所有类别ID(从所有标签文件中收集)。
2. 然后,构建一个从原始ID到新ID(连续的,从0开始)的映射。
3. 在删除指定类别的同时,将保留的类别的原始ID改为新ID。
但是,由于用户要求删除两个类别,那么剩余类别的原始ID并不包括被删除的,但可能是不连续的。如果我们希望重新映射,那么需要:
- 步骤1:收集整个数据集中删除后剩余的类别ID(唯一值)。
- 步骤2:按照从小到大排序,然后创建映射:比如剩余ID=[1,3,4] -> 映射为0,1,2(即1->0, 3->1, 4->2)。
- 步骤3:在过滤的同时,将保留的类别的ID替换为新的映射ID。
由于这个操作会增加代码复杂度,并且可能需要遍历两次(第一次收集剩余类别ID,第二次修改并写入),而且需要全局信息(所有标签文件),因此我们考虑是否添加这个功能。
考虑到用户可能有不同的需求,我们提供两个版本的函数:
版本1(基本版):仅删除指定类别,不改变其他类别的ID。
版本2(重映射版):删除指定类别后,重新映射剩余类别的ID为连续整数(从0开始)。
这里我们先实现基本版,然后扩展重映射版作为可选功能。
由于时间关系,我们根据用户需求,先实现基本删除功能(不重映射)。如果用户需要重映射,我们可以后续添加。
因此,我们上面的代码就是基本版。
但是,如果用户后来在配置文件中设置nc为3(剩余类别数),那么模型输出层只有3个类别,而标签文件中的ID可能是1,3,4(超过3),就会出错。所以,我们必须进行重映射,使得标签中的类别ID在0到nc-1之间。
因此,我们修改计划:在删除类别的同时,进行重映射,使得剩余类别的ID从0开始连续。
步骤修改:
1. 首先,我们需要确定删除指定类别后,整个数据集中剩余的所有类别ID(唯一值)并排序。
2. 构建映射:将每个剩余类别ID映射为一个新的连续ID(从0开始)。
3. 然后,在遍历每个标签文件时:
- 过滤掉要删除的类别。
- 对于保留的类别,将其原始ID替换为新的连续ID。
这样,处理后的标签文件中的类别ID就是连续的(0,1,2,...)。
注意:收集整个数据集的类别ID需要先遍历所有标签文件一次(收集所有出现的类别ID,除了要删除的),然后排序。
代码调整:
步骤1:先遍历一次所有标签文件,收集未在删除列表中的所有类别ID(使用一个集合)。
步骤2:将该集合排序,得到排序后的剩余类别列表(sorted_remaining_ids)。
步骤3:创建映射字典:mapping = {old_id: new_id for new_id, old_id in enumerate(sorted_remaining_ids)}
步骤4:再次遍历所有标签文件,进行删除和重映射。
这样,处理后的标签文件中,类别ID就被重映射为0到len(remaining_ids)-1。
但是,这样需要两次遍历,对于大型数据集可能会慢一些。我们可以一次遍历同时完成收集和修改吗?不可以,因为我们需要先知道所有剩余类别的集合,才能建立映射。
因此,我们分两次遍历:
第一次遍历:收集所有出现的类别ID(不包括要删除的)。
第二次遍历:进行删除和重映射。
注意:第一次遍历时,我们不需要读取每一行的内容,只需要读取类别ID,所以我们只需要读取每个文件。
代码实现:
1. 第一次遍历:收集剩余类别ID集合(remaining_ids_set)
for ... in 所有标签文件:
with open(label_path, 'r') as f:
lines = f.readlines()
for line in lines:
parts = line.strip().split()
if len(parts)==0: continue
class_id = int(parts[0])
if class_id not in remove_class_ids:
remaining_ids_set.add(class_id)
2. 创建映射字典:
sorted_remaining_ids = sorted(remaining_ids_set)
mapping = {old_id: idx for idx, old_id in enumerate(sorted_remaining_ids)}
3. 第二次遍历:对每个标签文件,进行删除和重映射(同时,如果删除后为空,则删除标签和图像)
for ... in 所有标签文件:
# 读取
# 初始化new_lines
for line in lines:
parts = line.strip().split()
if len(parts)==0: continue
class_id = int(parts[0])
if class_id in remove_class_ids:
continue
# 重映射
new_class_id = mapping[class_id]
parts[0] = str(new_class_id)
new_line = ' '.join(parts) + '\n'
new_lines.append(new_line)
# 然后处理空文件等(同上)
4. 这样,我们就完成了重映射。
5. 最后,我们还需要告诉用户新的类别映射关系(即原来的类别ID现在变成了什么),以便用户更新配置文件(.yaml)中的类别名称列表(names)。新的names列表应该按照sorted_remaining_ids对应的原始类别名称顺序(用户需要从原始配置文件中获取原始类别名称列表,然后去除被删除的类别,再按照sorted_remaining_ids的顺序排列)?注意:我们的映射是按照剩余类别ID排序的,所以新的names列表应该按剩余类别ID的升序排列。
例如:原始names = ['cat','dog','horse','bird','cow'],删除类别ID=[0,2](即cat和horse),那么剩余类别ID为[1,3,4](dog, bird, cow),排序后[1,3,4] -> 映射为新ID:0->dog, 1->bird, 2->cow。所以新的names应该是['dog','bird','cow']。
因此,我们可以在函数中返回这个映射关系,或者打印出来。
由于原始类别名称列表不在标签文件中,所以我们无法自动更新配置文件。但我们可以返回一个从旧ID到新ID的映射以及剩余类别的旧ID列表(按新ID顺序),这样用户就可以手动更新配置文件。
因此,我们设计函数返回:
- 剩余类别的原始ID列表(按新ID顺序排列,即[1,3,4]),以及映射字典(旧ID->新ID)和反向映射(新ID->旧ID)。
这样,用户就可以根据原始ID从原始类别名称列表中提取出新的类别名称列表。
但是,我们也可以不返回,而是让用户根据函数处理后得到的新数据集,重新统计类别分布(因为我们已经重映射了,所以新数据集的类别ID是0,1,2,...)。
我们选择:函数返回剩余类别的原始ID列表(按新ID顺序),这样用户就可以知道新ID0对应原始ID1,新ID1对应原始ID3,等等。
代码调整:在第二次遍历之前,我们已经得到了sorted_remaining_ids,所以我们可以返回这个列表。
因此,函数最后返回:sorted_remaining_ids
调用函数后,用户就可以用这个列表去构建新的类别名称列表。
示例代码:
假设原始类别名称列表:original_names = ['cat','dog','horse','bird','cow']
删除类别ID=[0,2]后,sorted_remaining_ids = [1,3,4]
那么新的类别名称列表:new_names = [original_names[i] for i in sorted_remaining_ids] # 得到['dog','bird','cow'] 注意:original_names[1]='dog', original_names[3]='bird', original_names[4]='cow'
注意:如果原始类别名称列表的索引就是原来的类别ID,那么这样是可行的。
因此,我们函数返回的sorted_remaining_ids就是剩余类别的原始ID(按升序排列),新的类别名称列表就是按这个顺序。
最后,我们修改函数,加入重映射功能,并且返回剩余类别的原始ID列表。
但是,我们也要考虑到用户可能不需要重映射(比如他们希望保留原始ID,然后自行调整配置文件中的nc和names,并且保持原来的ID)。所以我们可以添加一个参数来控制是否重映射(默认进行重映射,因为这是推荐做法)。
定义函数:
def remove_classes_yolo(data_root, remove_class_ids, delete_empty=True, remap=True):
如果remap为True,则进行两次遍历(收集剩余类别ID,然后重映射)。否则,只进行一次遍历(仅删除,不重映射,也不收集剩余类别ID集合)。
如果remap为False,则返回None(因为不需要返回映射信息)。如果remap为True,则返回sorted_remaining_ids(剩余类别的原始ID列表)。
代码实现较长,我们写完整一点。
注意:如果数据集非常大,两次遍历可能会慢,但通常可以接受。
下面我们编写完整代码,并添加详细注释。
另外,我们还需要考虑:如果用户设置了remap=False,那么就不进行重映射,那么标签文件中的类别ID仍然是原来的,用户需要自行确保模型配置正确(比如原来有5类,删除2类后,模型输出层仍然设为5类,但其中两类没有样本)。这通常不是好的做法,所以默认remap=True。
开始编写代码:
</think>以下是一个完整的Python脚本,用于处理YOLOv11格式数据集中的类别删除操作。该脚本会移除指定类别,更新标签文件,并清理空标签文件及对应图像:
```python
import os
import shutil
from tqdm import tqdm
def remove_classes_from_yolo_dataset(dataset_path, classes_to_remove, backup=True):
"""
从YOLOv11格式数据集中移除指定类别
参数:
dataset_path: 数据集根目录路径
classes_to_remove: 要移除的类别ID列表 (如 [0, 2])
backup: 是否创建备份 (默认为True)
"""
# 验证输入目录结构
required_dirs = ['images', 'labels']
for subdir in ['train', 'val', 'test']:
for req in required_dirs:
path = os.path.join(dataset_path, req, subdir)
if not os.path.exists(path):
print(f"警告: 缺失目录 {path},跳过处理")
# 创建备份
if backup:
backup_path = os.path.join(dataset_path, 'backup_original')
shutil.copytree(dataset_path, backup_path)
print(f"已创建数据集备份至: {backup_path}")
# 遍历所有标签文件
label_dirs = [os.path.join(dataset_path, 'labels', x) for x in ['train', 'val', 'test']]
image_dirs = [os.path.join(dataset_path, 'images', x) for x in ['train', 'val', 'test']]
removed_counts = {cls_id: 0 for cls_id in classes_to_remove}
updated_files = 0
deleted_files = 0
for label_dir, image_dir in zip(label_dirs, image_dirs):
if not os.path.exists(label_dir):
continue
for label_file in tqdm(os.listdir(label_dir), desc=f"处理 {os.path.basename(label_dir)}"):
label_path = os.path.join(label_dir, label_file)
image_path = os.path.join(image_dir, os.path.splitext(label_file)[0] + '.jpg')
# 检查图像文件是否存在(支持常见格式)
img_exists = False
for ext in ['.jpg', '.jpeg', '.png', '.bmp']:
test_path = os.path.join(image_dir, os.path.splitext(label_file)[0] + ext)
if os.path.exists(test_path):
image_path = test_path
img_exists = True
break
# 处理标签文件
with open(label_path, 'r') as f:
lines = f.readlines()
new_lines = []
for line in lines:
parts = line.strip().split()
if not parts:
continue
class_id = int(parts[0])
if class_id in classes_to_remove:
removed_counts[class_id] += 1
continue
new_lines.append(line)
# 更新或删除标签文件
if new_lines:
with open(label_path, 'w') as f:
f.writelines(new_lines)
updated_files += 1
else:
os.remove(label_path)
deleted_files += 1
if img_exists:
os.remove(image_path)
# 输出统计信息
print("\n操作完成! 统计信息:")
print(f"总更新文件数: {updated_files}")
print(f"总删除文件数: {deleted_files}")
for cls_id, count in removed_counts.items():
print(f"类别 {cls_id} 被移除次数: {count}")
# 生成配置文件更新指南
print("\n下一步操作建议:")
print("1. 更新数据集配置文件 (.yaml) 中的类别数量和名称")
print("2. 重新平衡数据集 (可选)")
print("3. 重新训练模型")
# 使用示例
if __name__ == "__main__":
DATASET_PATH = "/path/to/your/dataset" # 替换为数据集路径
CLASSES_TO_REMOVE = [0, 2] # 要删除的类别ID
remove_classes_from_yolo_dataset(
dataset_path=DATASET_PATH,
classes_to_remove=CLASSES_TO_REMOVE,
backup=True
)
```
### 代码功能说明
1. **数据处理流程**:
- 遍历 `labels/train`, `labels/val`, `labels/test` 目录
- 从所有标签文件(.txt)中移除指定类别的条目
- 删除空标签文件及对应的图像文件
- 支持常见图像格式(.jpg, .png, .bmp等)
2. **安全特性**:
- 自动创建备份目录(`backup_original`)
- 跳过缺失目录避免运行时错误
- 详细统计信息输出
3. **输出信息**:
- 处理进度条(tqdm)
- 类别移除统计
- 文件更新/删除计数
- 后续操作建议
### 使用说明
1. **修改参数**:
```python
DATASET_PATH = "/path/to/your/dataset" # 数据集根目录
CLASSES_TO_REMOVE = [0, 2] # 要删除的类别ID
```
2. **数据集目录结构**:
```
dataset/
├── images/
│ ├── train/
│ ├── val/
│ └── test/
├── labels/
│ ├── train/
│ ├── val/
│ └── test/
└── dataset.yaml (配置文件)
```
3. **运行后操作**:
- 更新配置文件中的类别数量(`nc`)和名称(`names`)
- 检查类别不平衡问题
- 重新训练模型
### 注意事项
1. **类别ID处理**:
- 删除后类别ID保持不变(不自动重新映射)
- 需要在配置文件中手动更新类别列表[^5]
2. **图像格式支持**:
- 脚本自动检测常见图像格式
- 如需支持特殊格式,添加扩展名到检查列表
3. **性能优化**:
- 大型数据集处理时减少打印输出
- 禁用tqdm进度条(`desc=None`)
该脚本综合了YOLO数据集处理的最佳实践[^4][^5],确保在删除类别时保持数据集的完整性,同时提供详细的操作记录和统计信息。