#!/usr/bin/env python3 # ============================================================================= # FeatureService 3D — Многоканальная радиомика Brain Tumour # РЕЗУЛЬТАТЫ СОХРАНЯЮТСЯ РЯДОМ С ДАННЫМИ # ============================================================================= import os import sys import time import logging import json import numpy as np import pandas as pd from pathlib import Path from tqdm import tqdm import matplotlib.pyplot as plt import seaborn as sns # PyRadiomics import radiomics from radiomics import featureextractor import SimpleITK as sitk import nibabel as nib # ============================================================================= # КОНФИГУРАЦИЯ # ============================================================================= # --- Путь к данным --- DATA_ROOT = Path(r"D:\Radiomics_Data\Experiments\experiment-17-06-2026\Task01_BrainTumour\Task01_BrainTumour") # --- Пути к данным внутри DATA_ROOT --- PATH_IMAGES_TRAIN = DATA_ROOT / "imagesTr" PATH_IMAGES_TEST = DATA_ROOT / "imagesTs" PATH_LABELS_TRAIN = DATA_ROOT / "labelsTr" # --- !!! НОВЫЕ ПУТИ ДЛЯ РЕЗУЛЬТАТОВ (ВНУТРИ DATA_ROOT) !!! --- OUTPUT_ROOT = DATA_ROOT / "radiomics_output" # ← Теперь рядом с данными! PATH_FEATURES = OUTPUT_ROOT / "features" PATH_VISUALIZATION = OUTPUT_ROOT / "visualization" PATH_LOGS = OUTPUT_ROOT / "logs" PATH_MODELS = OUTPUT_ROOT / "models" # --- Проект (только для справки) --- PROJECT_ROOT = Path(__file__).parent.resolve() FILE_EXTENSION = ".nii.gz" MAX_PATIENTS = 2 # Названия модальностей (каналов) CHANNEL_NAMES = ['T1', 'T1c', 'T2', 'FLAIR'] # ============================================================================= # СОЗДАНИЕ ПАПОК # ============================================================================= def ensure_directories(): """Создаёт все необходимые директории внутри папки с данными""" print("\n📁 СОЗДАНИЕ ПАПОК ДЛЯ РЕЗУЛЬТАТОВ:") for path in [PATH_FEATURES, PATH_VISUALIZATION, PATH_LOGS, PATH_MODELS]: path.mkdir(parents=True, exist_ok=True) print(f" ✅ {path}") print() ensure_directories() # ============================================================================= # НАСТРОЙКА ЛОГГИРОВАНИЯ # ============================================================================= logging.basicConfig( level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s', handlers=[ logging.FileHandler(PATH_LOGS / 'radiomics_3d.log', encoding='utf-8'), logging.StreamHandler() ] ) logger = logging.getLogger(__name__) # ============================================================================= # НАСТРОЙКИ RADIOMICS # ============================================================================= RADIOMICS_SETTINGS = { 'minimumROIDimensions': 2, 'minimumROISize': None, 'normalize': False, 'normalizeScale': 1, 'removeOutliers': None, 'resampledPixelSpacing': None, 'interpolator': 'sitkBSpline', 'preCrop': True, 'padDistance': 5, 'distances': [1], 'force2D': False, 'force2Ddimension': 0, 'resegmentRange': None, 'label': 1 } FEATURE_CLASSES = [ 'firstorder', 'shape', 'glcm', 'glrlm', 'glszm', 'gldm', 'ngtdm' ] ENABLED_IMAGE_TYPES = { 'Original': {}, 'Wavelet': {}, 'Square': {}, 'SquareRoot': {}, 'Logarithm': {}, 'Exponential': {}, 'Gradient': {} } # ============================================================================= # ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ # ============================================================================= def print_structure(): """Выводит структуру проекта и данных""" print("\n" + "=" * 70) print("🧠 3D РАДИОМИКА — BRAIN TUMOUR (МНОГОКАНАЛЬНАЯ)") print("=" * 70) print(f" 📂 Папка с данными: {DATA_ROOT}") print(f" 📂 Изображения: {PATH_IMAGES_TRAIN}") print(f" 📂 Маски: {PATH_LABELS_TRAIN}") print(f" 📂 РЕЗУЛЬТАТЫ: {OUTPUT_ROOT}") print(f" ├── features/ {PATH_FEATURES}") print(f" ├── visualization/{PATH_VISUALIZATION}") print(f" └── logs/ {PATH_LOGS}") print("=" * 70 + "\n") def check_data(): """Проверяет наличие данных""" print("🔍 ПРОВЕРКА ДАННЫХ:") print("-" * 50) for name, path in [ ("Обучающие изображения", PATH_IMAGES_TRAIN), ("Тестовые изображения", PATH_IMAGES_TEST), ("Маски", PATH_LABELS_TRAIN) ]: if path.exists(): files = [f for f in path.glob(f'*{FILE_EXTENSION}') if not f.name.startswith('._')] print(f" ✅ {name}: {len(files)} файлов") else: print(f" ❌ {name}: папка не найдена") # Проверяем, создалась ли папка для результатов if OUTPUT_ROOT.exists(): print(f" ✅ Папка результатов: {OUTPUT_ROOT}") else: print(f" ❌ Папка результатов не создана") print("-" * 50 + "\n") def is_valid_nifti_file(filepath): name = filepath.name if name.startswith('._'): return False if not name.endswith('.nii.gz'): return False if filepath.stat().st_size < 1000: return False return True def load_nifti_channels(filepath): """Загружает NIfTI и возвращает все каналы""" try: if not Path(filepath).exists(): return None, None img = nib.load(str(filepath)) data = img.get_fdata() if data.ndim == 4: num_channels = data.shape[3] logger.info(f" 📊 Найдено каналов: {num_channels}") channels = [] for i in range(num_channels): channel_data = data[:, :, :, i] if np.isnan(channel_data).any(): channel_data = np.nan_to_num(channel_data) channels.append(channel_data) return channels, num_channels elif data.ndim == 3: logger.info(f" 📊 Одноканальное изображение") if np.isnan(data).any(): data = np.nan_to_num(data) return [data], 1 else: logger.error(f"❌ Неподдерживаемая размерность: {data.ndim}") return None, None except Exception as e: logger.error(f"❌ Ошибка загрузки {filepath}: {e}") return None, None def create_sitk_image_from_array(data): """Создаёт SimpleITK изображение из массива""" if np.isnan(data).any(): data = np.nan_to_num(data) return sitk.GetImageFromArray(data) # ============================================================================= # ОСНОВНОЙ КЛАСС # ============================================================================= class MultiChannelRadiomicsExtractor: def __init__(self, settings=None, feature_classes=None, image_types=None): self.settings = settings or RADIOMICS_SETTINGS self.feature_classes = feature_classes or FEATURE_CLASSES self.image_types = image_types or ENABLED_IMAGE_TYPES self.channel_names = CHANNEL_NAMES logger.info("🔧 Инициализация многоканального экстрактора...") try: self.extractor = featureextractor.RadiomicsFeatureExtractor() for key, value in self.settings.items(): self.extractor.settings[key] = value for feature_class in self.feature_classes: try: self.extractor.enableFeatureClassByName(feature_class) logger.info(f" ✅ Включён класс: {feature_class}") except Exception as e: logger.warning(f" ⚠️ Не удалось включить {feature_class}: {e}") self.extractor.enableImageTypes(**self.image_types) logger.info("✅ Экстрактор инициализирован") except Exception as e: logger.error(f"❌ Ошибка инициализации: {e}") raise def extract_features_for_channel(self, image_data, mask_data, patient_id, channel_name): """Извлекает признаки для одного канала""" try: image_sitk = create_sitk_image_from_array(image_data) mask_sitk = create_sitk_image_from_array(mask_data) mask_array = sitk.GetArrayFromImage(mask_sitk) if mask_array.sum() == 0: logger.warning(f" ⚠️ Пустая маска для {channel_name}") return None result = self.extractor.execute(image_sitk, mask_sitk) features = {} for key, value in result.items(): if isinstance(value, (int, float, str, bool, np.number)): new_key = f"{channel_name}_{key}" features[new_key] = value elif isinstance(value, np.ndarray): new_key = f"{channel_name}_{key}" features[new_key] = value.tolist() return features except Exception as e: logger.error(f" ❌ Ошибка для {channel_name}: {e}") return None def extract_all_channels(self, image_path, mask_path, patient_id): """Извлекает признаки для всех каналов""" logger.info(f"🔄 Обработка: {patient_id}") channels, num_channels = load_nifti_channels(image_path) if channels is None: logger.error(f"❌ Не удалось загрузить изображение") return None mask_channels, _ = load_nifti_channels(mask_path) if mask_channels is None: logger.error(f"❌ Не удалось загрузить маску") return None mask_data = mask_channels[0] channel_names = self.channel_names[:len(channels)] if len(channel_names) < len(channels): channel_names = [f"Channel_{i}" for i in range(len(channels))] all_features = { 'patient_id': patient_id, 'image_path': str(image_path), 'mask_path': str(mask_path), 'num_channels': len(channels) } start_time = time.time() for i, (channel_data, ch_name) in enumerate(zip(channels, channel_names)): logger.info(f" 📊 Канал {i + 1}: {ch_name}") features = self.extract_features_for_channel( channel_data, mask_data, patient_id, ch_name ) if features: all_features.update(features) else: logger.warning(f" ⚠️ Не удалось извлечь признаки для {ch_name}") elapsed = time.time() - start_time logger.info(f"✅ {patient_id}: {len(all_features)} признаков ({elapsed:.2f}с)") return all_features def process_patient(self, image_path, mask_path, output_dir=None): if output_dir is None: output_dir = PATH_FEATURES patient_id = Path(image_path).stem.replace('.nii', '') features = self.extract_all_channels(image_path, mask_path, patient_id) if features is None: return None output_path = Path(output_dir) / f"{patient_id}_multichannel_features.csv" try: output_path.parent.mkdir(parents=True, exist_ok=True) pd.DataFrame([features]).to_csv(output_path, index=False, encoding='utf-8') logger.info(f"💾 Сохранено: {output_path}") return str(output_path) except Exception as e: logger.error(f"❌ Ошибка сохранения: {e}") return None def process_all_patients(self, images_dir, masks_dir, output_dir=None): if output_dir is None: output_dir = PATH_FEATURES if not Path(images_dir).exists(): logger.error(f"❌ Папка не найдена: {images_dir}") return [] if not Path(masks_dir).exists(): logger.error(f"❌ Папка не найдена: {masks_dir}") return [] all_files = list(Path(images_dir).glob(f'*{FILE_EXTENSION}')) image_files = [f for f in all_files if is_valid_nifti_file(f)] if MAX_PATIENTS: image_files = image_files[:MAX_PATIENTS] logger.info(f"📁 Найдено пациентов: {len(image_files)}") if not image_files: logger.warning("⚠️ Нет файлов") return [] results = [] for img_path in tqdm(image_files, desc="Обработка пациентов"): patient_id = img_path.stem.replace('.nii', '') mask_path = Path(masks_dir) / f"{patient_id}{FILE_EXTENSION}" if not mask_path.exists(): logger.warning(f"⚠️ Нет маски для {patient_id}") continue if not is_valid_nifti_file(mask_path): logger.warning(f"⚠️ Маска системный файл") continue result = self.process_patient(img_path, mask_path, output_dir) if result: results.append(result) logger.info(f"✅ Обработано: {len(results)} пациентов") return results # ============================================================================= # ВИЗУАЛИЗАЦИЯ # ============================================================================= def visualize_features(features_df, output_dir=None): if output_dir is None: output_dir = PATH_VISUALIZATION output_dir = Path(output_dir) output_dir.mkdir(parents=True, exist_ok=True) if features_df is None or len(features_df) == 0: logger.warning("⚠️ Нет данных для визуализации") return key_features = [ 'original_shape_Volume', 'original_shape_SurfaceArea', 'original_shape_Sphericity', 'original_firstorder_Mean', 'original_firstorder_Entropy', 'original_firstorder_Skewness', 'original_glcm_Contrast', 'original_glcm_Correlation', 'original_glrlm_RunEntropy', 'original_glszm_ZoneEntropy' ] available_features = [col for col in features_df.columns if any(kf in col for kf in key_features)] if not available_features: logger.warning("⚠️ Нет доступных признаков для визуализации") return print("\n" + "=" * 60) print("📊 ВИЗУАЛИЗАЦИЯ РЕЗУЛЬТАТОВ") print("=" * 60) print(f" 📂 Сохранение в: {output_dir}") # Распределения fig, axes = plt.subplots(3, 4, figsize=(16, 12)) axes = axes.flatten() for i, feature in enumerate(available_features[:12]): if i < len(axes): sns.histplot(features_df[feature].dropna(), kde=True, ax=axes[i]) axes[i].set_title(feature[:40], fontsize=9) axes[i].set_xlabel('') for j in range(i + 1, len(axes)): axes[j].set_visible(False) plt.tight_layout() plt.savefig(output_dir / 'feature_distributions.png', dpi=300, bbox_inches='tight') plt.close() print(f" ✅ Распределения: {output_dir / 'feature_distributions.png'}") # Корреляции top_features = available_features[:10] if len(top_features) > 1: plt.figure(figsize=(12, 10)) corr_matrix = features_df[top_features].corr() sns.heatmap(corr_matrix, annot=True, fmt='.2f', cmap='coolwarm', square=True, annot_kws={'size': 8}) plt.title('Корреляционная матрица признаков') plt.tight_layout() plt.savefig(output_dir / 'correlation_matrix.png', dpi=300, bbox_inches='tight') plt.close() print(f" ✅ Корреляции: {output_dir / 'correlation_matrix.png'}") # Сравнение каналов channel_cols = [col for col in features_df.columns if any(f'_{ch}_' in col for ch in ['T1', 'T1c', 'T2', 'FLAIR'])] if channel_cols: feature_types = set() for col in channel_cols: for ft in ['firstorder_Mean', 'glcm_Contrast', 'glcm_Entropy']: if ft in col: feature_types.add(ft) for ft in feature_types: ft_cols = [col for col in channel_cols if ft in col] if len(ft_cols) > 1: plt.figure(figsize=(10, 6)) data_to_plot = [features_df[col].dropna() for col in ft_cols] channels = [col.split('_')[0] for col in ft_cols] plt.boxplot(data_to_plot, labels=channels) plt.title(f'Сравнение каналов: {ft}') plt.ylabel(ft) plt.tight_layout() plt.savefig(output_dir / f'channel_comparison_{ft}.png', dpi=300, bbox_inches='tight') plt.close() print(f" ✅ Сравнение каналов: {output_dir / f'channel_comparison_{ft}.png'}") # ============================================================================= # ОБЪЕДИНЕНИЕ РЕЗУЛЬТАТОВ # ============================================================================= def merge_features(features_dir=None, output_path=None): if features_dir is None: features_dir = PATH_FEATURES if output_path is None: output_path = features_dir / "combined_multichannel_features.csv" features_dir = Path(features_dir) csv_files = list(features_dir.glob("*_multichannel_features.csv")) if not csv_files: logger.warning("⚠️ Нет CSV файлов для объединения") return None logger.info(f"🔗 Объединяю {len(csv_files)} файлов...") all_dfs = [] for csv_file in tqdm(csv_files, desc="Объединение"): try: df = pd.read_csv(csv_file) all_dfs.append(df) except Exception as e: logger.error(f"❌ Ошибка чтения {csv_file}: {e}") if not all_dfs: return None combined_df = pd.concat(all_dfs, ignore_index=True) combined_df.to_csv(output_path, index=False, encoding='utf-8') logger.info(f"💾 Объединённый файл: {output_path}") logger.info(f" Пациентов: {len(combined_df)}") logger.info(f" Признаков: {len(combined_df.columns)}") return combined_df # ============================================================================= # ГЛАВНАЯ ФУНКЦИЯ # ============================================================================= def main(): print_structure() check_data() if not DATA_ROOT.exists(): print("\n❌ Папка с данными не найдена!") return try: extractor = MultiChannelRadiomicsExtractor() print("\n" + "=" * 60) print("📊 ИЗВЛЕЧЕНИЕ ПРИЗНАКОВ (ВСЕ КАНАЛЫ)") print("=" * 60) results = extractor.process_all_patients( images_dir=PATH_IMAGES_TRAIN, masks_dir=PATH_LABELS_TRAIN, output_dir=PATH_FEATURES ) if results: print("\n" + "=" * 60) print("📊 ОБЪЕДИНЕНИЕ РЕЗУЛЬТАТОВ") print("=" * 60) combined_df = merge_features(PATH_FEATURES) if combined_df is not None: visualize_features(combined_df, PATH_VISUALIZATION) print("\n" + "=" * 70) print("✨ ГОТОВО!") print(f" 📂 Данные: {DATA_ROOT}") print(f" 📂 Результаты: {OUTPUT_ROOT}") print(f" ├── features/ {PATH_FEATURES}") print(f" ├── visualization/{PATH_VISUALIZATION}") print(f" └── logs/ {PATH_LOGS}") print("=" * 70) except Exception as e: logger.error(f"❌ Ошибка: {e}") import traceback traceback.print_exc() if __name__ == "__main__": try: main() except KeyboardInterrupt: print("\n⚠️ Прервано") except Exception as e: print(f"\n❌ Ошибка: {e}") import traceback traceback.print_exc()