Initial commit: project structure, scripts and documentation

This commit is contained in:
2026-08-22 13:17:29 +03:00
commit 5042305df0
54 changed files with 14166 additions and 0 deletions
+353
View File
@@ -0,0 +1,353 @@
# scripts/ttv_freq2.py
"""
Версия 6: Читает predefined_classes.txt для сохранения полной схемы классов.
Это позволяет использовать общую схему 12 классов для всех экспериментов,
даже если в конкретном датасете присутствуют не все классы.
"""
import os
import shutil
import random
import argparse
from pathlib import Path
try:
import yaml
except ImportError:
raise SystemExit("Установите PyYAML: pip install pyyaml")
def load_predefined_classes(path: Path) -> dict:
"""
Читает predefined_classes.txt и возвращает маппинг:
{имя_класса: (id_1based, yolo_index_0based)}
Формат файла (одна строка = один класс):
right_kidney
right_kidney_upper_segment
...
"""
class_map = {}
with open(path, 'r', encoding='utf-8') as f:
for idx, line in enumerate(f):
name = line.strip()
if name:
class_map[name] = {
'id_1based': idx + 1,
'yolo_idx': idx,
}
return class_map
def clean_caches(output_dir: Path):
"""Удаляет старые .cache файлы."""
for cache in output_dir.glob('*.cache'):
try:
cache.unlink()
print(f" Удалён кэш: {cache.name}")
except OSError:
pass
def remap_labels(src_dir: Path, dst_dir: Path, id_to_yolo_idx: dict):
"""
Копирует .txt файлы с перенумерацией классов из 1-based в 0-based.
"""
if dst_dir.exists():
shutil.rmtree(dst_dir)
dst_dir.mkdir(parents=True, exist_ok=True)
copied = 0
skipped = 0
for txt_file in src_dir.glob('*.txt'):
with open(txt_file, 'r', encoding='utf-8') as f:
lines = f.readlines()
new_lines = []
for line in lines:
parts = line.strip().split()
if not parts:
continue
try:
old_class = int(parts[0])
if old_class in id_to_yolo_idx:
parts[0] = str(id_to_yolo_idx[old_class])
new_lines.append(' '.join(parts))
else:
skipped += 1
except ValueError:
continue
if new_lines:
with open(dst_dir / txt_file.name, 'w', encoding='utf-8') as f:
f.write('\n'.join(new_lines) + '\n')
copied += 1
return copied, skipped
def create_split(
images_dir: Path,
labels_dir: Path,
output_dir: Path,
task: str,
predefined_classes: dict,
train_ratio: float = 0.8,
val_ratio: float = 0.1,
seed: int = 42,
):
random.seed(seed)
images_dir = images_dir.resolve()
labels_dir = labels_dir.resolve()
output_dir = output_dir.resolve()
if not images_dir.exists():
raise FileNotFoundError(f"Папка с изображениями не найдена: {images_dir}")
if not labels_dir.exists():
raise FileNotFoundError(f"Папка с лейблами не найдена: {labels_dir}")
print("\nОчистка кэша...")
clean_caches(output_dir)
image_files = [f for f in os.listdir(images_dir)
if f.lower().endswith(('.png', '.jpg', '.jpeg'))]
if not image_files:
raise ValueError(f"В {images_dir} не найдено изображений.")
print(f"\n{'='*60}")
print(f"Задача: {task.upper()}")
print(f"{'='*60}")
print(f"Изображения: {images_dir}")
print(f"Лейблы: {labels_dir}")
print(f"Найдено изображений: {len(image_files)}")
# Собираем статистику по классам
class_to_images = {}
classes_found = set()
for image_file in image_files:
label_file = os.path.splitext(image_file)[0] + '.txt'
label_path = labels_dir / label_file
if label_path.exists():
with open(label_path, 'r', encoding='utf-8') as f:
for line in f:
parts = line.strip().split()
if not parts:
continue
try:
class_label = int(parts[0])
classes_found.add(class_label)
class_to_images.setdefault(class_label, set()).add(image_file)
except ValueError:
continue
# Маппинг 1-based ID -> 0-based YOLO index
id_to_yolo_idx = {info['id_1based']: info['yolo_idx']
for info in predefined_classes.values()}
print(f"\n📋 Схема классов (из predefined_classes.txt):")
print(f" Всего классов: {len(predefined_classes)}")
present = []
absent = []
for name, info in predefined_classes.items():
if info['id_1based'] in classes_found:
present.append((name, info, len(class_to_images[info['id_1based']])))
else:
absent.append((name, info))
print(f"\n ✓ Присутствуют в датасете ({len(present)}):")
for name, info, count in present:
print(f" [{info['id_1based']:2d}] → YOLO idx {info['yolo_idx']:2d} : {name:<40s} ({count} изобр.)")
if absent:
print(f"\n ○ Отсутствуют ({len(absent)}):")
for name, info in absent:
print(f" [{info['id_1based']:2d}] → YOLO idx {info['yolo_idx']:2d} : {name}")
# Разбиение по наборам (стратифицированное)
sorted_classes = sorted(class_to_images, key=lambda k: len(class_to_images[k]))
train_files, val_files, test_files = set(), set(), set()
print(f"\n--- Распределение по наборам ---")
for class_label in sorted_classes:
images = set(class_to_images[class_label])
total = len(images)
train_count = max(1, int(total * train_ratio))
val_count = max(1, int(total * val_ratio))
if train_count + val_count > total:
val_count = max(0, total - train_count)
train_selected = set(random.sample(list(images), min(train_count, len(images))))
images -= train_selected
val_selected = set(random.sample(list(images), min(val_count, len(images))))
test_selected = images - val_selected
train_files |= train_selected
val_files |= val_selected
test_files |= test_selected
# Имя класса из predefined
class_name = next((n for n, info in predefined_classes.items()
if info['id_1based'] == class_label), f"class_{class_label}")
print(f" [{class_label:2d}] {class_name[:30]:<30s}: "
f"Train={len(train_selected):3d}, Val={len(val_selected):3d}, Test={len(test_selected):3d}")
for img in train_selected | val_selected | test_selected:
for cls_set in class_to_images.values():
cls_set.discard(img)
print(f"\nИТОГО: Train={len(train_files)}, Val={len(val_files)}, Test={len(test_files)}")
# Подготовка labels с перенумерацией
labels_dir_dst = output_dir / 'labels'
# Временная папка
raw_labels = output_dir / '_raw_labels'
if raw_labels.exists():
shutil.rmtree(raw_labels)
raw_labels.mkdir(parents=True, exist_ok=True)
print(f"\nКопирование лейблов...")
for img in train_files | val_files | test_files:
label_file = labels_dir / (Path(img).stem + '.txt')
if label_file.exists():
dst = raw_labels / (Path(img).stem + '.txt')
if not dst.exists():
shutil.copy2(label_file, dst)
print(f" Копирование с перенумерацией в YOLO 0-based формат:")
copied, skipped = remap_labels(raw_labels, labels_dir_dst, id_to_yolo_idx)
print(f" ✓ Обработано {copied} файлов, пропущено строк: {skipped}")
shutil.rmtree(raw_labels)
# Запись txt-файлов с абсолютными путями
def write_split(file_path: Path, file_set: set):
with open(file_path, 'w', encoding='utf-8') as f:
for img in sorted(file_set):
abs_path = str(images_dir / img).replace('\\', '/')
f.write(abs_path + '\n')
train_txt = output_dir / f'train_{task}.txt'
val_txt = output_dir / f'val_{task}.txt'
test_txt = output_dir / f'test_{task}.txt'
write_split(train_txt, train_files)
write_split(val_txt, val_files)
write_split(test_txt, test_files)
# data.yaml — ВСЕГДА с полной схемой из predefined_classes.txt
nc = len(predefined_classes)
class_names = list(predefined_classes.keys())
data_yaml = {
'path': str(output_dir).replace('\\', '/'),
'train': str(train_txt).replace('\\', '/'),
'val': str(val_txt).replace('\\', '/'),
'test': str(test_txt).replace('\\', '/'),
'nc': nc,
'names': class_names,
}
yaml_name = f'data_{task}.yaml'
yaml_path = output_dir / yaml_name
with open(yaml_path, 'w', encoding='utf-8') as f:
yaml.safe_dump(data_yaml, f, sort_keys=False, allow_unicode=True)
print(f"\n✓ Создан {yaml_name}: {yaml_path}")
print(f"✓ nc={nc}, классов: {nc}")
print(f"✓ Классы (0..{nc-1}): {class_names}")
return yaml_path
def main():
parser = argparse.ArgumentParser(
description='Создание train/val/test сплита с учётом predefined_classes.txt'
)
parser.add_argument('--task', type=str, required=True,
choices=['detect', 'segment', 'both'])
parser.add_argument('--images-dir', type=str, required=True)
parser.add_argument('--labels-dir', type=str, required=True)
parser.add_argument('--polygons-dir', type=str, default=None)
parser.add_argument('--output-dir', type=str, required=True)
parser.add_argument('--predefined-classes', type=str, default='predefined_classes.txt',
help='Путь к файлу с полным списком классов (по умолчанию: predefined_classes.txt)')
parser.add_argument('--train-ratio', type=float, default=0.8)
parser.add_argument('--val-ratio', type=float, default=0.1)
parser.add_argument('--seed', type=int, default=42)
args = parser.parse_args()
# Загружаем полную схему классов
predefined_path = Path(args.predefined_classes).resolve()
if not predefined_path.exists():
raise FileNotFoundError(f"Файл классов не найден: {predefined_path}\n"
f"Создайте его (по одному классу на строку) или укажите путь через --predefined-classes")
predefined_classes = load_predefined_classes(predefined_path)
print(f"📖 Загружена схема классов: {predefined_path}")
print(f" Всего классов: {len(predefined_classes)}")
if args.task == 'both':
if not args.polygons_dir:
raise ValueError("Для task=both необходимо указать --polygons-dir")
create_split(
images_dir=Path(args.images_dir),
labels_dir=Path(args.labels_dir),
output_dir=Path(args.output_dir),
task='detect',
predefined_classes=predefined_classes,
train_ratio=args.train_ratio,
val_ratio=args.val_ratio,
seed=args.seed,
)
create_split(
images_dir=Path(args.images_dir),
labels_dir=Path(args.polygons_dir),
output_dir=Path(args.output_dir),
task='segment',
predefined_classes=predefined_classes,
train_ratio=args.train_ratio,
val_ratio=args.val_ratio,
seed=args.seed,
)
print(f"\n{'='*60}")
print("✅ ОБА SPLITS СОЗДАНЫ!")
print(f"{'='*60}")
else:
create_split(
images_dir=Path(args.images_dir),
labels_dir=Path(args.labels_dir),
output_dir=Path(args.output_dir),
task=args.task,
predefined_classes=predefined_classes,
train_ratio=args.train_ratio,
val_ratio=args.val_ratio,
seed=args.seed,
)
print(f"\n{'='*60}")
print("✅ SPLT СОЗДАН!")
print(f"{'='*60}")
yaml_name = f'data_{args.task}.yaml'
if args.task == 'detect':
print(f"Запуск: python scripts/train_yolo.py --data {Path(args.output_dir) / yaml_name} --workers 0")
else:
print(f"Запуск: python scripts/train_yolo_seg.py --data {Path(args.output_dir) / yaml_name} --workers 0")
if __name__ == '__main__':
main()