Files
UTILITY_AI_ANNOTATION_TOOL/yolo_annotation_modify.py

974 lines
38 KiB
Python
Raw Normal View History

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
YOLO Annotation Utility Tool
YOLO format annotation file manipulation utility
Usage examples:
# Interactive mode (recommended)
python yolo_annotation_modify.py
# Remove classes
python yolo_annotation_modify.py remove --dataset dataset/yolo --classes "0,1"
# Merge classes
python yolo_annotation_modify.py merge --dataset dataset/yolo --source "0,1,2" --target "vehicle"
# Rename classes
python yolo_annotation_modify.py rename --dataset dataset/yolo --mapping "0:vehicle,1:person"
# Show info
python yolo_annotation_modify.py info --dataset dataset/yolo
"""
import yaml
import argparse
from pathlib import Path
from typing import Dict, List, Set, Union
import shutil
from datetime import datetime
import sys
import os
from collections import defaultdict
class YOLOAnnotationEditor:
"""Class for editing YOLO format annotations"""
def __init__(self, dataset_path: str, dry_run: bool = False):
"""
Args:
dataset_path: Path to YOLO dataset directory (containing data.yaml)
dry_run: If True, only simulate changes without writing files
"""
self.dataset_path = Path(dataset_path)
self.dry_run = dry_run
if self.dry_run:
print("[DRY RUN] 시뮬레이션 모드 - 파일은 수정되지 않습니다\n")
# Validate dataset path exists
if not self.dataset_path.exists():
raise FileNotFoundError(f"Dataset directory not found: {dataset_path}")
if not self.dataset_path.is_dir():
raise NotADirectoryError(f"Path is not a directory: {dataset_path}")
self.yaml_path = self.dataset_path / "data.yaml"
if not self.yaml_path.exists():
raise FileNotFoundError(f"data.yaml not found in {dataset_path}")
self.config = self._load_yaml()
self.labels_path = self.dataset_path / "labels"
# Validate labels directory exists
if not self.labels_path.exists():
raise FileNotFoundError(f"labels directory not found in {dataset_path}")
# Find all label files
self.label_files = list(self.labels_path.rglob("*.txt"))
print(f"[+] Found {len(self.label_files)} label files")
def _load_yaml(self) -> dict:
"""Load YOLO data.yaml file"""
try:
with open(self.yaml_path, 'r', encoding='utf-8') as f:
data = yaml.safe_load(f)
# Validate YAML structure
if not isinstance(data, dict):
raise ValueError(f"Invalid YAML format: Expected dictionary, got {type(data)}")
if 'names' not in data:
raise ValueError("data.yaml must contain 'names' field")
if not isinstance(data['names'], dict):
raise ValueError("'names' field must be a dictionary")
return data
except yaml.YAMLError as e:
raise ValueError(f"Failed to parse YAML file: {e}")
except Exception as e:
raise RuntimeError(f"Error loading data.yaml: {e}")
def backup_labels(self) -> Path:
"""
Backup entire labels folder
Returns:
Path to backup folder
"""
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
backup_path = self.dataset_path / f"labels_backup_{timestamp}"
if self.dry_run:
print(f"[DRY RUN] labels 폴더 백업이 생성될 예정입니다: {backup_path}")
return backup_path
try:
print(f"[*] labels 폴더 백업 중...")
shutil.copytree(self.labels_path, backup_path)
print(f"[+] 백업 생성됨: {backup_path}")
return backup_path
except PermissionError:
raise PermissionError(f"Permission denied creating backup at {backup_path}")
except OSError as e:
raise OSError(f"Failed to create backup: {e}")
def save_yaml(self, backup: bool = True):
"""
Save modified data.yaml
Args:
backup: Whether to backup original file
"""
if self.dry_run:
print(f"[DRY RUN] data.yaml이 저장될 예정입니다: {self.yaml_path}")
if backup:
print(f"[DRY RUN] 백업이 생성될 예정입니다")
return
try:
if backup and self.yaml_path.exists():
backup_path = self.yaml_path.parent / f"data_backup_{datetime.now().strftime('%Y%m%d_%H%M%S')}.yaml"
shutil.copy2(self.yaml_path, backup_path)
print(f"[+] data.yaml 백업 생성됨: {backup_path}")
with open(self.yaml_path, 'w', encoding='utf-8') as f:
yaml.dump(self.config, f, allow_unicode=True, sort_keys=False)
print(f"[+] data.yaml 저장 완료")
except PermissionError:
raise PermissionError(f"Permission denied writing to {self.yaml_path}")
except Exception as e:
raise RuntimeError(f"Failed to save data.yaml: {e}")
def get_class_info(self) -> Dict:
"""Get class information and count annotations"""
names = self.config.get('names', {})
# Count annotations per class
class_counts = defaultdict(int)
total_annotations = 0
for label_file in self.label_files:
try:
with open(label_file, 'r', encoding='utf-8') as f:
for line_num, line in enumerate(f, 1):
line = line.strip()
if line:
parts = line.split()
if len(parts) < 5:
print(f"[!] Warning: Invalid annotation format in {label_file}:{line_num} (expected 5 values, got {len(parts)})")
continue
try:
class_id = int(parts[0])
class_counts[class_id] += 1
total_annotations += 1
except ValueError:
print(f"[!] Warning: Invalid class ID in {label_file}:{line_num}")
except UnicodeDecodeError:
print(f"[!] Error: Cannot decode file {label_file} (invalid encoding)")
except PermissionError:
print(f"[!] Error: Permission denied reading {label_file}")
except Exception as e:
print(f"[!] Error reading {label_file}: {e}")
info = {}
for class_id, class_name in names.items():
info[class_id] = {
'id': class_id,
'name': class_name,
'annotation_count': class_counts.get(class_id, 0)
}
return info
def print_info(self):
"""Print annotation information"""
print("\n" + "="*60)
print(f"Dataset: {self.dataset_path}")
print("="*60)
names = self.config.get('names', {})
print(f"\n전체 통계:")
print(f" - 라벨 파일 수: {len(self.label_files)}")
print(f" - 클래스 수: {len(names)}")
print(f"\n클래스 상세:")
print(f"{'ID':<6} {'클래스명':<35} {'어노테이션 수':<15}")
print("-" * 60)
class_info = self.get_class_info()
total_annotations = 0
for class_id in sorted(class_info.keys()):
info = class_info[class_id]
ann_count = info['annotation_count']
total_annotations += ann_count
print(f"{info['id']:<6} {info['name']:<35} {ann_count:<15}")
print("-" * 60)
print(f"{'합계':<42} {total_annotations:<15}")
print("="*60 + "\n")
def remove_classes(self, class_ids: List[Union[int, str]]) -> 'YOLOAnnotationEditor':
"""
Remove specific classes
Args:
class_ids: List of class IDs or names to remove
"""
names = self.config.get('names', {})
# Convert names to IDs
name_to_id = {name: cid for cid, name in names.items()}
ids_to_remove = set()
print("\n[변경 예정]")
for item in class_ids:
try:
# Try as ID first
class_id = int(item)
if class_id in names:
ids_to_remove.add(class_id)
print(f" - 제거: {names[class_id]} (ID: {class_id})")
else:
print(f"[!] ID {class_id}를 찾을 수 없습니다")
except ValueError:
# Try as name
if item in name_to_id:
class_id = name_to_id[item]
ids_to_remove.add(class_id)
print(f" - 제거: {item} (ID: {class_id})")
else:
print(f"[!] 이름 '{item}'을 찾을 수 없습니다")
if not ids_to_remove:
print("[!] 제거할 클래스를 찾을 수 없습니다")
return self
# Store statistics before change
class_count_before = len(names)
# Remove from data.yaml
new_names = {cid: name for cid, name in names.items() if cid not in ids_to_remove}
self.config['names'] = new_names
# Remove annotations from label files
total_removed = 0
failed_files = []
total_files = len(self.label_files)
print(f"[*] {total_files}개 파일 처리 중...")
for idx, label_file in enumerate(self.label_files, 1):
if idx % 100 == 0 or idx == total_files:
print(f"[*] 진행: {idx}/{total_files} ({idx*100//total_files}%)")
try:
with open(label_file, 'r', encoding='utf-8') as f:
lines = f.readlines()
new_lines = []
for line in lines:
if line.strip():
parts = line.split()
if len(parts) < 5:
print(f"[!] Warning: Skipping invalid line in {label_file}")
continue
try:
class_id = int(parts[0])
if class_id not in ids_to_remove:
new_lines.append(line)
else:
total_removed += 1
except ValueError:
print(f"[!] Warning: Skipping line with invalid class ID in {label_file}")
new_lines.append(line)
if not self.dry_run:
with open(label_file, 'w', encoding='utf-8') as f:
f.writelines(new_lines)
except PermissionError:
print(f"[!] Error: Permission denied writing to {label_file}")
failed_files.append(label_file)
except Exception as e:
print(f"[!] Error processing {label_file}: {e}")
failed_files.append(label_file)
if failed_files:
print(f"[!] Warning: {len(failed_files)} files failed to process")
# Print summary
print(f"\n{'='*60}")
print(f"작업 요약 - 클래스 제거")
print(f"{'='*60}")
print(f"제거된 클래스 수: {len(ids_to_remove)}")
print(f"클래스 수 변화: {class_count_before}{len(self.config['names'])}")
print(f"제거된 어노테이션 수: {total_removed}")
if failed_files:
print(f"실패한 파일 수: {len(failed_files)}")
print(f"{'='*60}\n")
if self.dry_run:
print(f"[DRY RUN] 위 변경사항이 적용될 예정입니다")
else:
print(f"[+] 작업이 완료되었습니다")
return self
def merge_classes(self, source_ids: List[Union[int, str]], target_name: str) -> 'YOLOAnnotationEditor':
"""
Merge multiple classes into one
Args:
source_ids: List of source class IDs or names to merge
target_name: Target class name after merge
"""
names = self.config.get('names', {})
name_to_id = {name: cid for cid, name in names.items()}
# Find source class IDs
source_class_ids = set()
for item in source_ids:
try:
class_id = int(item)
if class_id in names:
source_class_ids.add(class_id)
print(f" - 통합 대상: {names[class_id]} (ID: {class_id})")
else:
print(f"[!] ID {class_id}를 찾을 수 없습니다")
except ValueError:
if item in name_to_id:
class_id = name_to_id[item]
source_class_ids.add(class_id)
print(f" - 통합 대상: {item} (ID: {class_id})")
else:
print(f"[!] 이름 '{item}'을 찾을 수 없습니다")
if not source_class_ids:
print("[!] 통합할 클래스를 찾을 수 없습니다")
return self
# Use the smallest ID as the new class ID
new_class_id = min(source_class_ids)
# Store statistics before change
class_count_before = len(names)
# Update data.yaml
new_names = {cid: name for cid, name in names.items() if cid not in source_class_ids}
new_names[new_class_id] = target_name
self.config['names'] = new_names
# Update label files
total_merged = 0
failed_files = []
total_files = len(self.label_files)
print(f"[*] {total_files}개 파일 처리 중...")
for idx, label_file in enumerate(self.label_files, 1):
if idx % 100 == 0 or idx == total_files:
print(f"[*] 진행: {idx}/{total_files} ({idx*100//total_files}%)")
try:
with open(label_file, 'r', encoding='utf-8') as f:
lines = f.readlines()
new_lines = []
for line in lines:
if line.strip():
parts = line.split()
if len(parts) < 5:
print(f"[!] Warning: Skipping invalid line in {label_file}")
continue
try:
class_id = int(parts[0])
if class_id in source_class_ids:
# Replace with new class ID
parts[0] = str(new_class_id)
new_lines.append(' '.join(parts) + '\n')
total_merged += 1
else:
new_lines.append(line)
except ValueError:
print(f"[!] Warning: Skipping line with invalid class ID in {label_file}")
new_lines.append(line)
if not self.dry_run:
with open(label_file, 'w', encoding='utf-8') as f:
f.writelines(new_lines)
except PermissionError:
print(f"[!] Error: Permission denied writing to {label_file}")
failed_files.append(label_file)
except Exception as e:
print(f"[!] Error processing {label_file}: {e}")
failed_files.append(label_file)
if failed_files:
print(f"[!] Warning: {len(failed_files)} files failed to process")
# Print summary
print(f"\n{'='*60}")
print(f"작업 요약 - 클래스 통합")
print(f"{'='*60}")
print(f"통합된 클래스 수: {len(source_class_ids)}")
print(f"클래스 수 변화: {class_count_before}{len(self.config['names'])}")
print(f"새 클래스: {target_name} (ID: {new_class_id})")
print(f"변경된 어노테이션 수: {total_merged}")
if failed_files:
print(f"실패한 파일 수: {len(failed_files)}")
print(f"{'='*60}\n")
if self.dry_run:
print(f"[DRY RUN] 위 변경사항이 적용될 예정입니다")
else:
print(f"[+] 작업이 완료되었습니다")
return self
def rename_classes(self, mapping: Dict[Union[int, str], str]) -> 'YOLOAnnotationEditor':
"""
Rename classes
Args:
mapping: Dictionary of {old_id_or_name: new_name}
"""
names = self.config.get('names', {})
name_to_id = {name: cid for cid, name in names.items()}
renamed_count = 0
for old_key, new_name in mapping.items():
try:
# Try as ID first
class_id = int(old_key)
if class_id in names:
old_name = names[class_id]
names[class_id] = new_name
print(f" - 변경: '{old_name}''{new_name}' (ID: {class_id})")
renamed_count += 1
else:
print(f"[!] ID {class_id}를 찾을 수 없습니다")
except ValueError:
# Try as name
if old_key in name_to_id:
class_id = name_to_id[old_key]
names[class_id] = new_name
print(f" - 변경: '{old_key}''{new_name}' (ID: {class_id})")
renamed_count += 1
else:
print(f"[!] 이름 '{old_key}'를 찾을 수 없습니다")
self.config['names'] = names
if self.dry_run:
print(f"[DRY RUN] {renamed_count}개 클래스 이름이 변경될 예정입니다")
else:
print(f"[+] {renamed_count}개 클래스 이름이 변경되었습니다")
return self
def reindex_classes(self, start_id: int = 0) -> 'YOLOAnnotationEditor':
"""
Reindex class IDs sequentially
Args:
start_id: Starting ID (default: 0 for YOLO)
"""
names = self.config.get('names', {})
# Create ID mapping: old ID -> new ID
id_mapping = {}
new_id = start_id
for old_id in sorted(names.keys()):
id_mapping[old_id] = new_id
print(f" - ID 변경: {old_id}{new_id} ({names[old_id]})")
new_id += 1
# Update data.yaml
new_names = {id_mapping[old_id]: name for old_id, name in names.items()}
self.config['names'] = new_names
# Update label files
total_updated = 0
failed_files = []
total_files = len(self.label_files)
print(f"[*] {total_files}개 파일 처리 중...")
for idx, label_file in enumerate(self.label_files, 1):
if idx % 100 == 0 or idx == total_files:
print(f"[*] 진행: {idx}/{total_files} ({idx*100//total_files}%)")
try:
with open(label_file, 'r', encoding='utf-8') as f:
lines = f.readlines()
new_lines = []
for line in lines:
if line.strip():
parts = line.split()
if len(parts) < 5:
print(f"[!] Warning: Skipping invalid line in {label_file}")
continue
try:
old_class_id = int(parts[0])
if old_class_id in id_mapping:
parts[0] = str(id_mapping[old_class_id])
new_lines.append(' '.join(parts) + '\n')
total_updated += 1
else:
new_lines.append(line)
except ValueError:
print(f"[!] Warning: Skipping line with invalid class ID in {label_file}")
new_lines.append(line)
if not self.dry_run:
with open(label_file, 'w', encoding='utf-8') as f:
f.writelines(new_lines)
except PermissionError:
print(f"[!] Error: Permission denied writing to {label_file}")
failed_files.append(label_file)
except Exception as e:
print(f"[!] Error processing {label_file}: {e}")
failed_files.append(label_file)
if failed_files:
print(f"[!] Warning: {len(failed_files)} files failed to process")
if self.dry_run:
print(f"[DRY RUN] 클래스 ID가 재할당될 예정입니다 (시작: {start_id})")
print(f"[DRY RUN] {total_updated}개의 어노테이션이 업데이트될 예정입니다")
else:
print(f"[+] 클래스 ID가 재할당되었습니다 (시작: {start_id})")
print(f"[+] {total_updated}개의 어노테이션이 업데이트되었습니다")
return self
def interactive_mode():
"""Interactive mode for user-friendly editing"""
try:
_interactive_mode_impl()
except KeyboardInterrupt:
print("\n\n[*] 사용자에 의해 중단되었습니다.")
print("[*] 변경사항이 저장되지 않았습니다.")
def _interactive_mode_impl():
"""Internal implementation of interactive mode"""
print("\n" + "="*60)
print(" YOLO 어노테이션 편집기 - 대화형 모드")
print("="*60)
# 1. Select dataset
print("\n[1/3] 데이터셋 선택")
print("-" * 60)
# Find YOLO datasets
from glob import glob
yaml_files = glob("**/data.yaml", recursive=True)
dataset_path = None
editor = None
while not editor:
if yaml_files:
print("\n발견된 YOLO 데이터셋:")
for idx, f in enumerate(yaml_files, 1):
dataset_dir = str(Path(f).parent)
print(f" {idx}. {dataset_dir}")
print(f" 0. 직접 경로 입력")
print(f" q. 종료")
choice = input("\n데이터셋 번호 선택 (직접 입력은 0, 종료는 q): ").strip().lower()
if choice == 'q':
print("[*] 종료합니다.")
return
if choice == '0':
dataset_path = input("데이터셋 디렉토리 경로 입력 (취소: q): ").strip()
if dataset_path.lower() == 'q':
print("[*] 종료합니다.")
return
else:
try:
idx = int(choice)
if 1 <= idx <= len(yaml_files):
dataset_path = str(Path(yaml_files[idx - 1]).parent)
else:
print(f"[!] 1에서 {len(yaml_files)} 사이의 숫자를 입력해주세요.")
continue
except ValueError:
print("[!] 잘못된 입력입니다. 숫자를 입력해주세요.")
continue
else:
dataset_path = input("데이터셋 디렉토리 경로 입력 (종료: q): ").strip()
if dataset_path.lower() == 'q':
print("[*] 종료합니다.")
return
if not dataset_path:
print("[!] 경로를 입력해주세요.")
continue
# Try to load dataset
try:
print(f"\n[+] 로딩 중: {dataset_path}")
editor = YOLOAnnotationEditor(dataset_path)
editor.print_info()
except FileNotFoundError as e:
print(f"[!] 오류: {e}")
print("[!] 다시 시도해주세요.")
dataset_path = None
except Exception as e:
print(f"[!] 데이터셋 로딩 실패: {e}")
print("[!] 다시 시도해주세요.")
dataset_path = None
# 2. Select operations
print("\n[2/3] 작업 선택")
print("-" * 60)
operations = []
while True:
print("\n사용 가능한 작업:")
print(" 1. 클래스 제거")
print(" 2. 클래스 통합 (병합)")
print(" 3. 클래스 이름 변경")
print(" 4. 클래스 ID 재할당")
print(" 5. 현재 정보 표시")
print(" 0. 완료 (저장 단계로)")
print(" h. 도움말")
print(" q. 저장하지 않고 종료")
choice = input("\n작업 선택 (0-5, h, q): ").strip().lower()
if choice == 'q':
confirm = input("저장하지 않고 종료하시겠습니까? (y/N): ").strip().lower()
if confirm == 'y':
print("[*] 종료합니다.")
return
continue
if choice == '0':
if not operations:
print("[!] 선택된 작업이 없습니다.")
confirm = input("그래도 종료하시겠습니까? (y/N): ").strip().lower()
if confirm == 'y':
print("[*] 종료합니다.")
return
continue
break
elif choice == '1': # Remove
print("\n현재 클래스:")
for cid, name in sorted(editor.config['names'].items()):
print(f" {cid}: {name}")
classes = input("\n제거할 클래스 입력 (ID 또는 이름, 쉼표로 구분, 취소: q): ").strip()
if classes.lower() == 'q':
print("[*] 작업을 취소했습니다.")
continue
if not classes:
print("[!] 입력이 비어있습니다. 작업을 건너뜁니다.")
continue
class_list = [c.strip() for c in classes.split(',') if c.strip()]
if not class_list:
print("[!] 유효한 클래스가 없습니다.")
continue
confirm = input(f"\n정말로 {len(class_list)}개의 클래스를 제거하시겠습니까? (y/N): ").strip().lower()
if confirm != 'y':
print("[*] 작업을 취소했습니다.")
continue
print(f"\n[*] 클래스 제거 중: {class_list}")
editor.remove_classes(class_list)
operations.append(f"제거: {', '.join(class_list)}")
elif choice == '2': # Merge
print("\n현재 클래스:")
for cid, name in sorted(editor.config['names'].items()):
print(f" {cid}: {name}")
source = input("\n통합할 클래스 입력 (ID 또는 이름, 쉼표로 구분, 취소: q): ").strip()
if source.lower() == 'q':
print("[*] 작업을 취소했습니다.")
continue
if not source:
print("[!] 입력이 비어있습니다. 작업을 건너뜁니다.")
continue
source_list = [c.strip() for c in source.split(',') if c.strip()]
if len(source_list) < 2:
print("[!] 최소 2개 이상의 클래스를 입력해야 합니다.")
continue
target = input("통합 후 클래스 이름 입력 (취소: q): ").strip()
if target.lower() == 'q':
print("[*] 작업을 취소했습니다.")
continue
if not target:
print("[!] 클래스 이름을 입력해주세요.")
continue
confirm = input(f"\n{len(source_list)}개의 클래스를 '{target}'로 통합하시겠습니까? (y/N): ").strip().lower()
if confirm != 'y':
print("[*] 작업을 취소했습니다.")
continue
print(f"\n[*] '{target}'로 통합 중: {source_list}")
editor.merge_classes(source_list, target)
operations.append(f"통합: {', '.join(source_list)}{target}")
elif choice == '3': # Rename
print("\n현재 클래스:")
for cid, name in sorted(editor.config['names'].items()):
print(f" {cid}: {name}")
print("\n매핑 입력 (형식: 기존:새이름,기존:새이름 - 쉼표로 구분)")
print("예시: 0:vehicle,1:person 또는 truck:vehicle,person:human")
mapping_input = input("\n입력 (취소: q): ").strip()
if mapping_input.lower() == 'q':
print("[*] 작업을 취소했습니다.")
continue
if not mapping_input:
print("[!] 입력이 비어있습니다. 작업을 건너뜁니다.")
continue
mapping = {}
invalid_pairs = []
for pair in mapping_input.split(','):
pair = pair.strip()
if ':' in pair:
parts = pair.split(':', 1)
old, new = parts[0].strip(), parts[1].strip()
if old and new:
mapping[old] = new
else:
invalid_pairs.append(pair)
else:
invalid_pairs.append(pair)
if invalid_pairs:
print(f"[!] 잘못된 형식의 매핑: {', '.join(invalid_pairs)}")
if not mapping:
print("[!] 유효한 매핑이 없습니다.")
continue
print(f"\n변경 예정:")
for old, new in mapping.items():
print(f" {old}{new}")
confirm = input(f"\n{len(mapping)}개의 클래스 이름을 변경하시겠습니까? (y/N): ").strip().lower()
if confirm != 'y':
print("[*] 작업을 취소했습니다.")
continue
print(f"\n[*] 클래스 이름 변경 중")
editor.rename_classes(mapping)
operations.append(f"이름 변경: {len(mapping)}개 클래스")
elif choice == '4': # Reindex
while True:
start_id_input = input("\n시작 ID 입력 (기본값: 0, 취소: q): ").strip().lower()
if start_id_input == 'q':
print("[*] 작업을 취소했습니다.")
start_id = None
break
if not start_id_input:
start_id = 0
break
try:
start_id = int(start_id_input)
if start_id < 0:
print("[!] 0 이상의 숫자를 입력해주세요.")
continue
break
except ValueError:
print("[!] 유효한 숫자를 입력해주세요.")
if start_id is None:
continue
confirm = input(f"\n클래스 ID를 {start_id}부터 재할당하시겠습니까? (y/N): ").strip().lower()
if confirm != 'y':
print("[*] 작업을 취소했습니다.")
continue
print(f"\n[*] 클래스 ID 재할당 중 (시작: {start_id})")
editor.reindex_classes(start_id=start_id)
operations.append(f"ID 재할당: {start_id}부터")
elif choice == '5': # Show info
editor.print_info()
elif choice == 'h': # Help
print("\n" + "="*60)
print(" 도움말")
print("="*60)
print("\n1. 클래스 제거")
print(" - 지정한 클래스와 해당 어노테이션을 완전히 제거합니다")
print(" - 예: '0,1' 또는 'person,car'")
print("\n2. 클래스 통합 (병합)")
print(" - 여러 클래스를 하나로 합칩니다")
print(" - 예: car,truck,bus → vehicle")
print("\n3. 클래스 이름 변경")
print(" - 클래스 이름만 변경합니다 (ID는 유지)")
print(" - 예: 0:vehicle,1:person")
print("\n4. 클래스 ID 재할당")
print(" - 모든 클래스 ID를 순차적으로 재할당합니다")
print(" - 예: 시작 ID 0 → 0,1,2,3...")
print("\n5. 현재 정보 표시")
print(" - 데이터셋의 현재 상태를 확인합니다")
print("\n팁:")
print(" - 모든 작업은 적용 전 확인 메시지가 표시됩니다")
print(" - 'q'를 입력하여 언제든지 작업을 취소할 수 있습니다")
print(" - 저장 전 백업이 자동으로 생성됩니다")
print("="*60)
else:
print("[!] 잘못된 선택입니다")
# 3. Save
print("\n[3/3] 결과 저장")
print("-" * 60)
print("\n수행된 작업:")
for idx, op in enumerate(operations, 1):
print(f" {idx}. {op}")
# Ask for labels backup
print("\n[!] 주의: labels 폴더의 모든 .txt 파일이 수정됩니다.")
create_labels_backup = input("labels 폴더 백업 생성? (Y/n): ").strip().lower()
if create_labels_backup != 'n':
editor.backup_labels()
create_yaml_backup = input("data.yaml 백업 생성? (Y/n): ").strip().lower()
yaml_backup = create_yaml_backup != 'n'
print(f"\n[*] 저장 중...")
editor.save_yaml(backup=yaml_backup)
print("\n" + "="*60)
print(" 작업이 성공적으로 완료되었습니다!")
print("="*60)
print(f"\n데이터셋: {dataset_path}")
print(f"설정 파일: {editor.yaml_path}")
print(f"라벨 파일: {len(editor.label_files)}개 업데이트됨")
print()
def main():
parser = argparse.ArgumentParser(
description='YOLO Annotation Editing Utility',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__
)
subparsers = parser.add_subparsers(dest='command', help='Available commands')
# info command
info_parser = subparsers.add_parser('info', help='Show annotation info')
info_parser.add_argument('-d', '--dataset', required=True, help='Dataset directory')
# remove command
remove_parser = subparsers.add_parser('remove', help='Remove classes')
remove_parser.add_argument('-d', '--dataset', required=True, help='Dataset directory')
remove_parser.add_argument('-c', '--classes', required=True, help='Classes to remove (comma-separated IDs or names)')
remove_parser.add_argument('--no-backup', action='store_true', help='Do not create backup for labels and yaml')
remove_parser.add_argument('--no-labels-backup', action='store_true', help='Do not create backup for labels folder')
# merge command
merge_parser = subparsers.add_parser('merge', help='Merge classes')
merge_parser.add_argument('-d', '--dataset', required=True, help='Dataset directory')
merge_parser.add_argument('-s', '--source', required=True, help='Source classes to merge (comma-separated)')
merge_parser.add_argument('-t', '--target', required=True, help='Target class name after merge')
merge_parser.add_argument('--no-backup', action='store_true', help='Do not create backup for labels and yaml')
merge_parser.add_argument('--no-labels-backup', action='store_true', help='Do not create backup for labels folder')
# rename command
rename_parser = subparsers.add_parser('rename', help='Rename classes')
rename_parser.add_argument('-d', '--dataset', required=True, help='Dataset directory')
rename_parser.add_argument('-m', '--mapping', required=True,
help='Name mapping (old:new,old2:new2 format)')
rename_parser.add_argument('--no-backup', action='store_true', help='Do not create backup for labels and yaml')
rename_parser.add_argument('--no-labels-backup', action='store_true', help='Do not create backup for labels folder')
# reindex command
reindex_parser = subparsers.add_parser('reindex', help='Reindex class IDs')
reindex_parser.add_argument('-d', '--dataset', required=True, help='Dataset directory')
reindex_parser.add_argument('--start', type=int, default=0, help='Starting ID (default: 0)')
reindex_parser.add_argument('--no-backup', action='store_true', help='Do not create backup for labels and yaml')
reindex_parser.add_argument('--no-labels-backup', action='store_true', help='Do not create backup for labels folder')
# If no arguments provided, enter interactive mode
if len(sys.argv) == 1:
interactive_mode()
return
args = parser.parse_args()
if not args.command:
parser.print_help()
return
# Execute command
if args.command == 'info':
editor = YOLOAnnotationEditor(args.dataset)
editor.print_info()
elif args.command == 'remove':
classes = [c.strip() for c in args.classes.split(',')]
editor = YOLOAnnotationEditor(args.dataset)
# Backup labels if requested
if not args.no_backup and not args.no_labels_backup:
editor.backup_labels()
print(f"\n[*] 클래스 제거 중...")
editor.remove_classes(classes)
editor.save_yaml(backup=not args.no_backup)
elif args.command == 'merge':
source_classes = [c.strip() for c in args.source.split(',')]
editor = YOLOAnnotationEditor(args.dataset)
# Backup labels if requested
if not args.no_backup and not args.no_labels_backup:
editor.backup_labels()
print(f"\n[*] 클래스 통합 중...")
editor.merge_classes(source_classes, args.target)
editor.save_yaml(backup=not args.no_backup)
elif args.command == 'rename':
mapping = {}
for pair in args.mapping.split(','):
old, new = pair.split(':')
mapping[old.strip()] = new.strip()
editor = YOLOAnnotationEditor(args.dataset)
# Backup labels if requested
if not args.no_backup and not args.no_labels_backup:
editor.backup_labels()
print(f"\n[*] 클래스 이름 변경 중...")
editor.rename_classes(mapping)
editor.save_yaml(backup=not args.no_backup)
elif args.command == 'reindex':
editor = YOLOAnnotationEditor(args.dataset)
# Backup labels if requested
if not args.no_backup and not args.no_labels_backup:
editor.backup_labels()
print(f"\n[*] 클래스 ID 재할당 중...")
editor.reindex_classes(start_id=args.start)
editor.save_yaml(backup=not args.no_backup)
if __name__ == '__main__':
main()