Initial commit: project structure, scripts and documentation
This commit is contained in:
@@ -0,0 +1,593 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user