diff --git a/README.md b/README.md index 7a9478a..7537bd1 100644 --- a/README.md +++ b/README.md @@ -1,144 +1,154 @@ -# 🦴 Bone Quality Assessment +# 🦴 DXA Quality Assessment -[![Python](https://img.shields.io/badge/Python-3.10-blue.svg)](https://www.python.org/) -[![FastAPI](https://img.shields.io/badge/FastAPI-0.104-green.svg)](https://fastapi.tiangolo.com/) -[![PyTorch](https://img.shields.io/badge/PyTorch-2.1-red.svg)](https://pytorch.org/) -[![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) +[![Python](https://img.shields.io/badge/Python-3.10+-blue.svg)](https://www.python.org/) +[![FastAPI](https://img.shields.io/badge/FastAPI-0.110+-green.svg)](https://fastapi.tiangolo.com/) +[![PyTorch](https://img.shields.io/badge/PyTorch-2.0+-red.svg)](https://pytorch.org/) -## 📋 Описание +## Описание -Сервис искусственного интеллекта для автоматизированной оценки качества денситометрических изображений и их разметки. Система получает на вход рентгеновское денситометрическое исследование в формате DICOM, и оценивает качество выполнения исследования по стандартным критериям, а также корректность разметки анатомических структур на изображениях. +Сервис для автоматизированной оценки качества денситометрических исследований (DXA). Система анализирует DICOM-изображения костной денситометрии и определяет качество исследования по следующим критериям: + +- **Артефакты** — движение, размытость, металлические объекты, имплантаты +- **Позиционирование** — правильное расположение анатомической области в кадре +- **Полнота изображения** — видимость всех анатомических структур (позвонки L1-L4, бедро) +- **Ротация** — корректный угол поворота (для исследования бедра) +- **ROI-валидация** — правильность расположения области интереса ### Основные возможности -- 🖼️ **Анализ изображений** — загрузка и обработка медицинских изображений -- 🧠 **Сегментация объектов** — выделение анатомических структур (позвонки, кости) -- 📊 **Оценка качества** — проверка по 3 критериям: - - Артефакты (движение, шум, размытость) - - Позиционирование (правильное расположение объекта) - - Контрастность (качество изображения) -- 🔍 **Детекция нарушений** — определение типа нарушения для некачественных исследований -- 🌐 **Web-интерфейс** — удобная загрузка и визуализация результатов -- 📡 **REST API** — интеграция с внешними системами -- 📈 **Визуализация маски** — отображение сегментации на изображении +- 🔬 **Анализ DICOM** — загрузка и обработка медицинских изображений +- 🧠 **Классификация** — бинарная оценка качества (OK / Violation) +- 🔍 **Детекция нарушений** — определение типа нарушения: + - `correct` — качество соответствует норме + - `artifact_motion` — артефакт движения + - `artifact_other` — прочие артефакты + - `position_error` — ошибка позиционирования + - `rotation` — нарушение ротации + - `incomplete_view` — неполный вид + - `roi_error` — ошибка ROI + - `labeling_error` — ошибка разметки +- 🌐 **REST API** — интеграция с внешними системами +- 📊 **Веб-интерфейс** — загрузка и визуализация результатов +- 📈 **Экспорт** — выгрузка результатов в XLSX -## 🏗️ Архитектура +--- -![!img](public/static/arch.png) +## Архитектура -## 🎯 Доступные режимы - -### DXA Режим (хакатон) -Анализ денситометрических исследований: -```bash -# Обучение -python src/dxa/train.py --epochs 10 - -# Инференс -python src/dxa/inference.py --input-path dataset_hack/Для\ теста --output-path results.xlsx +``` +┌─────────────────────────────────────────────────────────────┐ +│ FastAPI Server │ +│ (port 8000) │ +├─────────────────────────────────────────────────────────────┤ +│ /api/v1/analyze → Basic quality prediction │ +│ /api/v1/analyze/detailed → Full report with metrics │ +│ /api/v1/analyze/sr → DICOM SR (Structured Report) │ +│ /api/v1/batch → Batch processing │ +│ /api/v1/export → XLSX export │ +├─────────────────────────────────────────────────────────────┤ +│ │ +│ ┌──────────────┐ ┌─────────────────┐ │ +│ │ ResNet18 │───▶│ Quality Model │ │ +│ │ (pretrained) │ │ (binary class) │ │ +│ └──────────────┘ └────────┬────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────┐ │ +│ │ Detailed Assessment │ │ +│ │ - Motion detection │ │ +│ │ - Artifact detection│ │ +│ │ - ROI validation │ │ +│ │ - View completeness│ │ +│ └─────────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ ``` +--- -## 🚀 Быстрый старт +## Быстрый старт ### Требования - Python 3.10+ -- PyTorch 2.1+ -- Docker (опционально) +- PyTorch 2.0+ +- 4GB+ RAM +- (опционально) GPU CUDA/MPS для ускорения -### Локальная установка +### Установка ```bash -# 1. Клонирование репозитория +# Клонирование git clone https://github.com/yourusername/bone-quality-assessment.git cd bone-quality-assessment -# 2. Создание виртуального окружения +# Создание виртуального окружения python -m venv venv source venv/bin/activate # Linux/Mac -# или -venv\Scripts\activate # Windows +# venv\Scripts\activate # Windows -# 3. Установка зависимостей +# Установка зависимостей pip install -r requirements.txt -# 4. Загрузка обученной модели (опционально) -# Поместите модель в папку models/dxa_model.pth +# Загрузка модели (опционально) +# Поместите файл модели в models/dxa_model.pth +``` -# 5. Запуск сервера +### Запуск сервера + +```bash +# Локальный запуск +python -m uvicorn src.main:app --host 0.0.0.0 --port 8000 + +# Или через run.py python run.py ``` -Docker +После запуска: +- Web-интерфейс: http://localhost:8000 +- Swagger UI: http://localhost:8000/docs +- ReDoc: http://localhost:8000/redoc + +### Docker ```bash -# 1. Сборка образа -docker build -t bone-quality-api . +# Сборка +docker build -t dxa-quality-api . -# 2. Запуск контейнера -docker run -p 8000:8000 bone-quality-api - -# 3. Или используя docker-compose -docker-compose up -d +# Запуск +docker run -p 8000:8000 dxa-quality-api ``` +--- -📡 API Endpoints +## API Endpoints -| Метод | Эндпоинт | Описание | -|--------|-------------------|--------------------------| -| GET | / | Главная страница -| GET | /docs | Swagger UI документация -| GET | /redoc | ReDoc документация -| GET | /api/v1/health | Проверка статуса сервиса -| POST | /api/v1/analyze | Базовый анализ изображения -| POST | /api/v1/analyze/detailed | Детальный анализ с метриками -| POST | /api/v1/analyze/sr | Анализ с DICOM SR отчётом -| POST | /api/v1/batch | Пакетный анализ файлов -| POST | /api/v1/export | Анализ и экспорт в XLSX +| Метод | Эндпоинт | Описание | +|-------|----------|----------| +| GET | `/` | Главная страница (веб-интерфейс) | +| GET | `/api/v1/health` | Проверка статуса сервиса | +| POST | `/api/v1/analyze` | Базовый анализ изображения | +| POST | `/api/v1/analyze/detailed` | Детальный анализ с метриками | +| POST | `/api/v1/analyze/sr` | DICOM SR отчёт | +| POST | `/api/v1/batch` | Пакетный анализ | +| POST | `/api/v1/export` | Анализ и экспорт в XLSX | +### Пример использования -Пример запроса ```bash +# Анализ файла curl -X POST "http://localhost:8000/api/v1/analyze" \ -H "accept: application/json" \ -H "Content-Type: multipart/form-data" \ - -F "file=@/path/to/image.jpg" + -F "file=@/path/to/image.dcm" + +# Детальный анализ +curl -X POST "http://localhost:8000/api/v1/analyze/detailed" \ + -H "accept: application/json" \ + -H "Content-Type: multipart/form-data" \ + -F "file=@/path/to/image.dcm" ``` -Пример ответа - -```json -{ - "overall_quality": "GOOD", - "severity": "LOW", - "issues": [], - "confidence": 0.9, - "metrics": { - "artifact": { - "artifact": false, - "num_objects": 1, - "edge_energy": 0.234, - "object_size": 0.123 - }, - "position": { - "position": [0.45, 0.52], - "valid": true, - "deviation": 0.032 - }, - "contrast": { - "valid": true, - "contrast": 0.456 - } - }, - "mask": "base64_encoded_mask_image" -} -``` - -### Детальный анализ (/api/v1/analyze/detailed) - -Возвращает расширенную информацию с детализацией нарушений: +### Ответ детального анализа ```json { @@ -156,7 +166,7 @@ curl -X POST "http://localhost:8000/api/v1/analyze" \ "metrics": { "motion": { "motion_detected": true, - "blur_laplacian": 0.001, + "blur_laplacian": 0.0008, "severity": "HIGH" }, "artifacts": { @@ -167,147 +177,153 @@ curl -X POST "http://localhost:8000/api/v1/analyze" \ "valid": true } }, - "spine_completeness": { - "valid": true, - "num_vertebrae": 4 - }, "overall_quality": "POOR", "severity": "HIGH" } ``` +--- -📊 Интерфейс -Веб-интерфейс доступен по адресу http://localhost:8000/: +## Структура проекта - - 📤 Drag-and-drop загрузка изображений - - 🔍 Автоматический анализ - - 🎯 Визуализация маски сегментации - - 📈 Детальные метрики качества - - 🏷️ Подробный отчет о нарушениях - - 👆 Клик по строке для просмотра деталей - - -🛠️ Технологии - -|Компонент | Технология -|-|-| -|Бэкенд | Python 3.10, FastAPI, Uvicorn -|ML | PyTorch, NumPy, SciPy -|Обработка изображений | PIL, OpenCV -|Визуализация | HTML5, CSS3, Canvas API -|Контейнеризация | Docker, Docker Compose -|Документация | Swagger UI, ReDoc - - -📁 Структура проекта -```text +``` bone_2026/ ├── src/ -│ ├── dxa/ # DXA Quality модуль -│ │ ├── model.py # ResNet18 классификатор -│ │ ├── dataset.py # Загрузчик данных -│ │ ├── train.py # Обучение -│ │ └── inference.py # Инференс -│ ├── api/ # REST API -│ ├── quality/ # Оценка качества -│ └── main.py # FastAPI приложение -├── models/ -│ └── dxa_model.pth # Обученная модель -├── dataset_hack/ # DICOM датасет +│ ├── main.py # FastAPI приложение +│ ├── run.py # Запуск сервера +│ ├── dxa/ # DXA модуль +│ │ ├── model.py # ResNet18 классификатор +│ │ ├── dataset.py # Загрузчик данных +│ │ ├── train.py # Обучение модели +│ │ └── inference.py # Инференс и batch-обработка +│ ├── quality/ # Оценка качества +│ │ ├── quality_scorer.py # Базовый скорer +│ │ └── detailed_assessment.py # Детальный анализ +│ ├── api/ # REST API +│ │ ├── endpoints.py # Дополнительные эндпоинты +│ │ └── static/ # Веб-интерфейс +│ └── utils/ # Утилиты +├── models/ # Обученные модели +│ └── dxa_model.pth # Модель классификатора +├── dataset_hack/ # Датасет для обучения/тестирования +├── docs/ # Документация ├── Dockerfile ├── requirements.txt -├── run.sh └── README.md ``` -🧪 Тестирование -```bash -# Запуск тестов (если есть) -pytest tests/ -``` +--- -# Проверка API +## Обучение модели + +### Подготовка данных + +1. Разместите DICOM-файлы в `dataset_hack/НД_для_обучения/Исследования/` +2. Подготовьте Excel-файл разметки `dataset_hack/НД_для_обучения/разметка.xlsx` + +Столбцы разметки: +- `study_uid` — ID исследования +- `позвоночник_укладка`, `позвоночник_ось`, `позвоночник_артефакты` — критерии для позвоночника +- `бедро_позиция_лев`, `бедро_roi_лев` — критерии для левого бедра +- `бедро_позиция_прав`, `бедро_roi_прав` — критерии для правого бедра +- `итог_позвоночник`, `итог_бедро_лев`, `итог_бедро_прав` — итоговая оценка (0/1) + +### Запуск обучения ```bash -curl http://localhost:8000/api/v1/health +python src/dxa/train.py \ + --epochs 20 \ + --batch-size 8 \ + --backbone resnet18 \ + --output-dir models ``` -## 📊 Метрики качества +### Аргументы -Артефакты +| Параметр | По умолчанию | Описание | +|----------|-------------|----------| +| `--data-root` | `dataset_hack` | Путь к директории с данными | +| `--annotation-path` | `dataset_hack/НД_для_обучения/разметка.xlsx` | Путь к файлу разметки | +| `--epochs` | 20 | Количество эпох | +| `--batch-size` | 8 | Размер батча | +| `--backbone` | `resnet18` | Архитектура (resnet18/resnet34/efficientnet_b0) | +| `--input-size` | 224 | Размер входного изображения | +| `--output-dir` | `models` | Директория для сохранения модели | -*Описание:* Обнаружение шума, размытости, фрагментации +--- -Проверка: +## Инференс - - Энергия границ (edge_energy) - - Количество объектов (num_objects) - - Размер объекта (object_size) +### Одиночный файл -### Позиционирование - -*Описание:* Проверка правильности расположения объекта - -Проверка: - - - Центр масс объекта - - Отклонение от центра изображения - - Минимальный размер объекта - -### Контраст - -*Описание:* Оценка качества изображения - -Проверка: - - - Контраст внутри объекта - - Отношение средних (объект/фон) - - Интенсивность пикселей - -## 🚀 Деплой -На сервер ```bash -# Копирование на сервер -scp -r ./bone-quality-assessment user@server:/var/www/ +python src/dxa/inference.py \ + --input-path path/to/image.dcm \ + --output-path result.xlsx ``` -# Запуск в фоновом режиме -nohup python run.py > logs/out.log 2>&1 & +### Директория -Использование с Nginx -```nginx -location /api/ { - proxy_pass http://localhost:8000; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; -} +```bash +python src/dxa/inference.py \ + --input-path dataset_hack/Для\ теста \ + --output-path results.xlsx \ + --model-path models/dxa_model.pth ``` -## 🤝 Вклад в проект - - Fork репозитория - - Создайте ветку для вашей фичи (git checkout -b feature/amazing-feature) - - Commit изменений (git commit -m 'Add amazing feature') - - Push в ветку (git push origin feature/amazing-feature) - - Откройте Pull Request +### Выходной формат (XLSX/CSV) +| Колонка | Описание | +|---------|----------| +| `path_to_study` | Путь к директории исследования | +| `study_uid` | StudyInstanceUID | +| `image_uid` | SOPInstanceUID | +| `anatomical_region` | Анатомическая область (spine/hip_left/hip_right) | +| `quality_class` | Класс качества (0 — OK, 1 — Violation) | +| `violation_type` | Тип нарушения | +| `processing_status` | Статус обработки | +| `time_of_processing` | Время обработки (сек) | -**📄 Лицензия** +--- + +## Метрики качества + +### Детекция движения + +- **Laplacian variance** — дисперсия лапласиана (меньше = сильнее размытие) +- **FFT high-frequency ratio** — отношение высокочастотной энергии (меньше = размытие) +- **Edge duplication** — проверка "призрачных" контуров + +### Детекция артефактов + +- **Metal detection** — яркие области (>99.5 перцентиль) +- **Implant detection** — линейные структуры (морфологические операции) +- **Cement detection** — локальные яркие пятна в ROI +- **Calcification** — малые яркие области вне ROI + +### Полнота изображения + +- **Spine**: подсчет позвонков (ожидается 3-4), проверка межпозвоночных промежутков +- **Hip**: проверка видимости шейки бедра, большого/малого вертелов + +### Валидация ROI + +- Проверка отступа от краев (>10 пикселей) +- Проверка размера ROI (>30% высоты, >20% ширины изображения) + +--- + +## Лицензия MIT License -👥 Команда -Грачев Денис — Разработка - GitHub +## Команда -### 🙏 Благодарности +- **Грачев Денис** — Разработка +- **Грачев Татьяна** — Капитан -***Oxford-IIIT Pet Dataset*** для обучения модели +--- -***Сообществу PyTorch и FastAPI*** - -📞 Контакты - - 📧 Email: your.email@example.com - - 🐦 Telegram: @oxydencher - - 🐙 GitHub: gdg6 - -
Built with ❤️ for the Bone Quality Assessment Hackathon
+
+ Built for Bone Quality Assessment Hackathon 2026 +
diff --git a/prediction_result.png b/prediction_result.png deleted file mode 100644 index 1ba652c..0000000 Binary files a/prediction_result.png and /dev/null differ diff --git a/requirements.txt b/requirements.txt index bdf488d..98541cb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -62,6 +62,7 @@ timm==1.0.29 torch==2.8.0 torchvision==0.23.0 TotalSegmentator==2.18.0 +requests==2.32.3 tqdm==4.70.0 typer==0.23.2 typing-inspection==0.4.2 diff --git a/src/core/orchestrator.py b/src/core/orchestrator.py index cedc9ab..b523d59 100644 --- a/src/core/orchestrator.py +++ b/src/core/orchestrator.py @@ -7,16 +7,12 @@ from typing import Dict, Any from src.classifiers.bone_condition_classifier import BoneConditionClassifier from src.core.config import PipelineConfig from src.core.registry import ModelRegistry -from src.classifiers.pet_breed_classifier import PetBreedClassifier from src.quality.universal_scorer import UniversalQualityScorer from src.segmentators.totalsegmentator_wrapper import TotalSegmentatorWrapper -from src.segmentators.unet_segmentator import UNetSegmentator # Регистрируем модели -ModelRegistry.register_segmentator("unet", UNetSegmentator) ModelRegistry.register_segmentator("totalsegmentator", TotalSegmentatorWrapper) -ModelRegistry.register_classifier("pet_breed", PetBreedClassifier) ModelRegistry.register_classifier("bone_condition", BoneConditionClassifier) @@ -43,7 +39,11 @@ class Orchestrator: # Сегментатор segmentator_class = ModelRegistry.get_segmentator(self.config.segmentator) if segmentator_class: - self.segmentator = segmentator_class(device=self.device) + # Передаем device для TotalSegmentator + if self.config.segmentator == "totalsegmentator": + self.segmentator = segmentator_class(device=self.device) + else: + self.segmentator = segmentator_class(device=self.device) else: raise ValueError(f"Сегментатор {self.config.segmentator} не найден") diff --git a/src/dxa/inference.py b/src/dxa/inference.py index 708c8c0..84464c7 100644 --- a/src/dxa/inference.py +++ b/src/dxa/inference.py @@ -1,6 +1,16 @@ #!/usr/bin/env python3 """ DXA Quality Inference - Batch processing with Excel output + +Этот модуль выполняет инференс модели классификации качества DXA исследований. +Основные функции: +- Загрузка и предобработка DICOM изображений +- Определение анатомической области (позвоночник/бедро) +- Бинарная классификация качества (OK/Violation) +- Пакетная обработка с экспортом в Excel + +Использование: + python src/dxa/inference.py --input-path --output-path """ import os import sys @@ -22,7 +32,15 @@ sys.path.insert(0, str(Path(__file__).parent.parent)) def get_device(): - """Get best available device""" + """ + Определение доступного устройства для вычислений. + + Порядок приоритета: MPS (Apple Silicon) -> CUDA (NVIDIA GPU) -> CPU. + Это нужно для максимальной производительности на доступном железе. + + Returns: + str: Устройство ('mps', 'cuda' или 'cpu') + """ if torch.backends.mps.is_available(): return 'mps' elif torch.cuda.is_available(): @@ -32,9 +50,23 @@ def get_device(): def load_model(model_path: str, backbone: str = 'resnet18', device: str = 'cpu'): - """Load trained model""" - from src.dxa.model import create_model + """ + Загрузка обученной модели классификатора качества DXA. + Модель использует предобученный ResNet18 в качестве backbone и + добавляет классификационную голову для бинарной классификации + (качество OK vs Violation). + + Args: + model_path: Путь к файлу модели (.pth) + backbone: Архитектура backbone (resnet18/resnet34/efficientnet_b0) + device: Устройство для загрузки модели + + Returns: + DXAQualityModel: Обертка модели с методами predict и load + """ + from src.dxa.model import create_model + model = create_model(backbone=backbone, pretrained=False, device=device) model.load(model_path) model.model.eval() @@ -42,33 +74,50 @@ def load_model(model_path: str, backbone: str = 'resnet18', device: str = 'cpu') def load_dicom_image(dcm_path: str, input_size: int = 224) -> torch.Tensor: - """Load and preprocess DICOM image""" + """ + Загрузка и предобработка DICOM изображения для модели. + + Этапы предобработки: + 1. Чтение DICOM и извлечение pixel_array + 2. Нормализация интенсивности в диапазон [0, 1] + 3. Преобразование в 3 канала (дублирование для RGB) + 4. Изменение размера до input_size x input_size + 5. Нормализация для ImageNet (деление на 255) + 6. Преобразование в PyTorch тензор + + Args: + dcm_path: Путь к DICOM файлу + input_size: Целевой размер изображения (по умолчанию 224 для ResNet) + + Returns: + torch.Tensor: Тензор изображения формы (1, 3, 224, 224) + """ ds = pydicom.dcmread(dcm_path) img = ds.pixel_array.astype(np.float32) - + # Normalize to 0-1 img_min = img.min() img_max = img.max() if img_max > img_min: img = (img - img_min) / (img_max - img_min) - + # Convert to 3-channel img = np.stack([img] * 3, axis=0) - + # Convert to uint8 for PIL img = (img * 255).astype(np.uint8) - + # Resize img_pil = Image.fromarray(img.transpose(1, 2, 0)) img_pil = img_pil.resize((input_size, input_size), Image.BILINEAR) img = np.array(img_pil).transpose(2, 0, 1) - + # Normalize back to 0-1 img = img.astype(np.float32) / 255.0 - + # Convert to tensor img = torch.from_numpy(img).float().unsqueeze(0) - + return img @@ -95,24 +144,30 @@ def extract_anatomical_region_from_filename(dcm_path: str) -> str: def determine_region_from_image(img: np.ndarray) -> str: """ - Determine anatomical region from DICOM image content. + Определение анатомической области (позвоночник/бедро) по содержимому изображения. - Uses multiple features: - - Bright region aspect ratio (main discriminator for spine vs hip) - - Image symmetry - - Horizontal position for left/right hip distinction + Алгоритм использует анализ формы яркой области на изображении: + - Позвоночник: яркая область более квадратная (aspect ratio ~1.2) + - Бедро: яркая область вытянута вертикально (aspect ratio > 1.5) - Returns: 'spine', 'hip_left', 'hip_right', or 'hip' (unknown side) + Дополнительно для определения левого/правого бедра: + - Сравнение яркости левой и правой половин изображения + + Args: + img: Нормализованное изображение (np.array) + + Returns: + str: 'spine', 'hip_left', 'hip_right' или 'hip' (неопределенная сторона) """ h, w = img.shape - + # Normalize image img_norm = (img - img.min()) / (img.max() - img.min() + 1e-6) - + # Feature 1: Bright region aspect ratio threshold = np.percentile(img_norm, 95) binary = img_norm > threshold - + bbox_aspect = 1.0 bright_x = 0.5 # default center left_right_ratio = 1.0 # default balanced @@ -127,36 +182,36 @@ def determine_region_from_image(img: np.ndarray) -> str: bbox_h = rmax - rmin bbox_w = cmax - cmin bbox_aspect = bbox_h / (bbox_w + 1e-6) - + # Get bright region center X position com = ndimage.center_of_mass(binary) bright_x = com[1] / w - + # Calculate left/right brightness ratio left_bright = binary[:, :w//2].sum() right_bright = binary[:, w//2:].sum() left_right_ratio = left_bright / (right_bright + 1e-6) except: pass - + # Feature 2: Symmetry h_mid, w_mid = h // 2, w // 2 left_half = img_norm[:, :w_mid] right_half = np.fliplr(img_norm[:, w_mid:]) min_w = min(left_half.shape[1], right_half.shape[1]) symmetry = 1 - np.abs(left_half[:, :min_w] - right_half[:, :min_w]).mean() / (img_norm.std() + 1e-6) - + # Feature 3: Vertical/horizontal edges dx = np.diff(img_norm, axis=1) dy = np.diff(img_norm, axis=0) v_edges = np.abs(dx).mean() h_edges = np.abs(dy).mean() v_h_ratio = v_edges / (h_edges + 1e-6) - + # Classification rules based on analysis: # spine: bbox_aspect ~1.2, symmetry > 0.4, v_h_ratio < 1.5 # hip: bbox_aspect > 1.5, symmetry < 0.4, v_h_ratio > 1.5 - + # Primary: bbox_aspect is the best discriminator if bbox_aspect < 1.5: # More square bright region -> spine @@ -215,12 +270,27 @@ def determine_anatomical_region(dcm_path: str) -> str: def process_dicom_files(args): - """Run inference on DICOM files""" + """ + Основная функция пакетного инференса DXA изображений. + Этапы обработки: + 1. Определение устройства (MPS/CUDA/CPU) + 2. Загрузка обученной модели + 3. Поиск DICOM файлов в указанной директории + 4. Инференс для каждого файла: + - Предобработка изображения + - Предикт модели (бинарная классификация) + - Определение анатомической области + - Сохранение метаданных DICOM + 5. Экспорт результатов в Excel/CSV + + Args: + args: Аргументы командной строки (input_path, output_path, model_path и т.д.) + """ # Setup device = get_device() print(f"Using device: {device}") - + # Load model if Path(args.model_path).exists(): print(f"Loading model from {args.model_path}...") @@ -231,61 +301,61 @@ def process_dicom_files(args): print("Using untrained model - results will be random") from src.dxa.model import create_model model = create_model(backbone=args.backbone, pretrained=False, device=device) - + # Find DICOM files input_path = Path(args.input_path) dcm_files = [] - + if input_path.is_file() and input_path.suffix.lower() == '.dcm': dcm_files = [input_path] elif input_path.is_dir(): dcm_files = sorted(input_path.rglob('*.dcm')) - + print(f"Found {len(dcm_files)} DICOM files") - + if len(dcm_files) == 0: print("ERROR: No DICOM files found!") return - + # Process each file results = [] - + for dcm_path in tqdm(dcm_files, desc="Processing"): start_time = datetime.now() - + try: # Load and preprocess image img = load_dicom_image(str(dcm_path), args.input_size) img = img.to(device) - + # Predict with torch.no_grad(): outputs = model.model(img) probs = torch.softmax(outputs, dim=1) pred = outputs.argmax(dim=1).item() prob = probs[0, pred].item() - + # Get DICOM metadata ds = pydicom.dcmread(str(dcm_path)) - + study_uid = getattr(ds, 'StudyInstanceUID', '') image_uid = getattr(ds, 'SOPInstanceUID', '') - + # Determine anatomical region anatomical_region = determine_anatomical_region(str(dcm_path)) - + # Map prediction to quality class quality_class = pred # 0 = good, 1 = violation - + # Determine violation type (simplified) if quality_class == 0: violation_type = '' else: # In real implementation, this would come from a more detailed model violation_type = 'quality_violation_detected' - + processing_time = (datetime.now() - start_time).total_seconds() - + results.append({ 'path_to_study': str(dcm_path.parent), 'study_uid': study_uid, @@ -297,7 +367,7 @@ def process_dicom_files(args): 'time_of_processing': processing_time, 'confidence': round(prob, 4) }) - + except Exception as e: processing_time = (datetime.now() - start_time).total_seconds() results.append({ diff --git a/src/dxa/train.py b/src/dxa/train.py index 76042ea..e84ef7c 100644 --- a/src/dxa/train.py +++ b/src/dxa/train.py @@ -1,6 +1,20 @@ #!/usr/bin/env python3 """ Training script for DXA Quality Classifier +========================================= + +Скрипт обучения модели классификации качества DXA изображений. + +Основные этапы: +1. Загрузка данных из Excel разметки +2. Создание DataLoader с разбиением на train/val +3. Инициализация модели (ResNet18 с предобучением на ImageNet) +4. Обучение с использованием CrossEntropyLoss +5. Валидация после каждой эпохи +6. Сохранение лучшей модели по F1-score + +Использование: + python src/dxa/train.py --epochs 20 --batch-size 8 """ import os import sys @@ -31,24 +45,39 @@ def get_device(): def compute_metrics(preds, labels): - """Compute classification metrics""" + """ + Вычисление метрик классификации. + + Рассчитывает: + - Accuracy: доля правильных предсказаний + - Precision: точность (доля TP среди предсказанных positive) + - Recall: полнота (доля TP среди реальных positive) + - F1: гармоническое среднее precision и recall + + Args: + preds: Предсказания модели (numpy array) + labels: Истинные метки (numpy array) + + Returns: + Dict с метриками + """ preds = np.array(preds) labels = np.array(labels) - + # Accuracy accuracy = (preds == labels).mean() - + # True/False positives/negatives tp = ((preds == 1) & (labels == 1)).sum() tn = ((preds == 0) & (labels == 0)).sum() fp = ((preds == 1) & (labels == 0)).sum() fn = ((preds == 0) & (labels == 1)).sum() - + # Precision, Recall, F1 precision = tp / (tp + fp) if (tp + fp) > 0 else 0 recall = tp / (tp + fn) if (tp + fn) > 0 else 0 f1 = 2 * precision * recall / (precision + recall) if (precision + recall) > 0 else 0 - + return { 'accuracy': accuracy, 'precision': precision, @@ -62,7 +91,24 @@ def compute_metrics(preds, labels): def train(args): - """Main training loop""" + """ + Основной цикл обучения модели. + + Этапы: + 1. Определение устройства (MPS/CUDA/CPU) + 2. Создание директории для сохранения модели + 3. Загрузка данных (DataLoader) + 4. Создание модели + 5. Цикл обучения по эпохам: + - Обучение на train set + - Валидация на val set + - Расчет метрик (accuracy, precision, recall, F1) + - Сохранение лучшей модели по F1 + 6. Сохранение финальной модели + + Args: + args: Аргументы командной строки + """ # Setup device = get_device() diff --git a/src/main.py b/src/main.py index ba9103d..bc67e33 100644 --- a/src/main.py +++ b/src/main.py @@ -1,5 +1,24 @@ """ Main FastAPI application for DXA Quality Assessment +================================================= + +FastAPI сервер для оценки качества DXA исследований. + +Основные функции: +- REST API для анализа DICOM изображений +- Веб-интерфейс для загрузки и визуализации +- Поддержка одиночного и пакетного анализа +- Экспорт результатов в XLSX +- Генерация DICOM SR (Structured Report) + +Эндпоинты: +- GET / - Главная страница (веб-интерфейс) +- GET /api/v1/health - Проверка статуса +- POST /api/v1/analyze - Базовый анализ +- POST /api/v1/analyze/detailed - Детальный анализ +- POST /api/v1/analyze/sr - DICOM SR отчет +- POST /api/v1/batch - Пакетный анализ +- POST /api/v1/export - Анализ и экспорт в XLSX """ from fastapi import FastAPI, File, UploadFile, APIRouter, Query from fastapi.staticfiles import StaticFiles @@ -43,13 +62,21 @@ device = None def load_model(): - """Load DXA model""" - global dxa_model, device + """ + Загрузка модели классификатора качества DXA. + Модель загружается глобально при первом запросе и сохраняется в памяти. + Это позволяет избежать повторной загрузки при каждом запросе. + + Returns: + DXAQualityModel: Обученная модель или None при ошибке + """ + global dxa_model, device + if dxa_model is None: device = get_device() print(f"Loading DXA model on {device}...") - + try: dxa_model = create_model( backbone='resnet18', @@ -62,37 +89,55 @@ def load_model(): except Exception as e: print(f"Error loading model: {e}") dxa_model = None - + return dxa_model def preprocess_dicom(dcm_bytes: bytes, input_size: int = 224): - """Preprocess DICOM for model input""" - import tempfile + """ + Предобработка DICOM изображения для модели. + Этапы: + 1. Сохранение байтов во временный файл + 2. Чтение DICOM (pydicom) + 3. Нормализация значений пикселей + 4. Создание 3-канального изображения + 5. Изменение размера до input_size x input_size + 6. Нормализация для PyTorch (деление на 255) + 7. Преобразование в тензор + + Args: + dcm_bytes: Байты DICOM файла + input_size: Целевой размер (по умолчанию 224) + + Returns: + Tuple[torch.Tensor, pydicom.Dataset]: Тензор изображения и метаданные DICOM + """ + import tempfile + with tempfile.NamedTemporaryFile(suffix='.dcm', delete=False) as f: f.write(dcm_bytes) dcm_path = f.name - + ds = pydicom.dcmread(dcm_path) img = ds.pixel_array.astype(np.float32) - + # Normalize img = (img - img.min()) / (img.max() - img.min() + 1e-8) - + # 3-channel img = np.stack([img] * 3, axis=0) - + # Resize img = (img * 255).astype(np.uint8) img_pil = Image.fromarray(img.transpose(1, 2, 0)) img_pil = img_pil.resize((input_size, input_size), Image.BILINEAR) img = np.array(img_pil).transpose(2, 0, 1) img = img.astype(np.float32) / 255.0 - + # Tensor img = torch.from_numpy(img).float().unsqueeze(0) - + return img, ds @@ -192,14 +237,18 @@ async def analyze_dicom_detailed( include_visualization: bool = Query(False, description="Include base64 mask visualization") ): """ - Detailed analysis of DICOM file with comprehensive quality assessment. + Детальный анализ DICOM файла с комплексной оценкой качества. + + Это основной эндпоинт для получения полной информации о качестве исследования. + В отличие от базового /analyze, возвращает: + - violation_type: конкретный тип нарушения + - reason: человекочитаемое объяснение + - confidence_per_class: вероятности для каждого класса + - view_quality: полный/частичный вид + - metrics: детальные метрики по каждому критерию Returns: - - violation_type: Specific type of violation (correct, position_error, artifact_motion, etc.) - - reason: Human-readable explanation of why the image is non-compliant - - confidence_per_class: Probability for each class - - view_quality: Full or partial view assessment - - Detailed metrics for motion, artifacts, position, ROI + JSON с детальным отчетом о качестве исследования """ try: # Load model @@ -484,7 +533,26 @@ async def analyze_dicom_sr(file: UploadFile = File(...)): def generate_dicom_sr_text(study_uid: str, image_uid: str, quality_report: Dict) -> str: - """Generate DICOM SR text representation""" + """ + Генерация текстового представления DICOM SR (Structured Report). + + DICOM SR - это стандартизированный формат отчета в DICOM. + Здесь генерируется текстовое представление для совместимости. + + Структура отчета: + - Study/Series Instance UID + - Procedure Report (регион, качество, уверенность) + - Findings (тип нарушения, причина, метрики) + - Completion Flag + + Args: + study_uid: StudyInstanceUID из DICOM + image_uid: SOPInstanceUID из DICOM + quality_report: Результат generate_quality_report() + + Returns: + str: Текстовое представление SR отчета + """ # Map violation types to DICOM codes (simplified) violation_code_map = { diff --git a/src/quality/detailed_assessment.py b/src/quality/detailed_assessment.py index 2dc9c5d..1f4cad7 100644 --- a/src/quality/detailed_assessment.py +++ b/src/quality/detailed_assessment.py @@ -1,6 +1,28 @@ """ Detailed Quality Assessment Module for DXA images -Implements checks from condition.txt and condition_doctor.txt +================================================ + +Модуль детальной оценки качества DXA изображений. +Реализует проверки по критериям из condition.txt и condition_doctor.txt. + +Основные функции: +- detect_motion_blur(): Детекция артефактов движения +- detect_artifacts(): Детекция металлических объектов, имплантатов, цемента +- check_spine_completeness(): Проверка видимости позвонков L1-L4 +- check_hip_completeness(): Проверка видимости бедра +- check_hip_rotation(): Проверка угла ротации бедра +- check_roi_boundaries(): Валидация границ ROI +- generate_quality_report(): Генерация итогового отчета + +Типы нарушений (violation_types): +- correct: Качество соответствует норме +- artifact_motion: Артефакт движения +- artifact_other: Прочие артефакты (металл, имплантаты) +- position_error: Ошибка позиционирования +- rotation: Нарушение ротации +- incomplete_view: Неполный вид +- roi_error: Ошибка ROI +- labeling_error: Ошибка разметки """ import numpy as np from scipy import ndimage @@ -82,37 +104,54 @@ def calculate_blur_fft(image: np.ndarray) -> float: def detect_motion_blur(image: np.ndarray, segmentation: Optional[np.ndarray] = None) -> Dict[str, Any]: """ - Detect motion artifacts in the image. + Детекция артефактов движения на DXA изображении. - Checks: - - Blur using Laplacian variance - - Blur using FFT high-frequency energy - - Edge duplication (ghost edges) + Артефакты движения проявляются в виде: + 1. Размытия (blur) - снижение четкости границ + 2. Дублирования краев (edge duplication) - "призрачные" контуры + + Методы детекции: + - Laplacian variance: дисперсия лапласиана изображения + (низкое значение = сильное размытие) + - FFT high-frequency energy: отношение высокочастотной энергии + (низкое значение = размытие) + - Edge duplication: проверка повторяющихся границ + + Args: + image: Исходное изображение (нормализованное, 0-1) + segmentation: Бинарная маска сегментации (опционально) + + Returns: + Dict с ключами: + - motion_detected: bool - обнаружено ли движение + - blur_laplacian: float - дисперсия лапласиана + - blur_fft: float - FFT high-frequency ratio + - severity: str - степень (NONE/MEDIUM/HIGH) """ if len(image.shape) == 3: gray = np.mean(image, axis=2) else: gray = image - + # Normalize gray = (gray - gray.min()) / (gray.max() - gray.min() + 1e-8) - + # Calculate blur metrics laplacian_var = calculate_blur_laplacian(gray) fft_blur = calculate_blur_fft(gray) - + # Thresholds (tuned for medical images) # Lower laplacian variance = more blur is_blurred_laplacian = laplacian_var < 0.002 is_blurred_fft = fft_blur < 0.3 - + # Check for edge duplication (ghost edges) edge_duplication = False if segmentation is not None and segmentation.sum() > 0: # Find edges in segmentation edges = ndimage.sobel(segmentation.astype(float)) edge_positions = np.where(np.abs(edges) > 0) - + if len(edge_positions[0]) > 10: # Check if there are duplicate edges (offset) y_coords = edge_positions[0] @@ -120,9 +159,9 @@ def detect_motion_blur(image: np.ndarray, segmentation: Optional[np.ndarray] = N # If there are many small gaps, might be edge duplication small_gaps = np.sum(y_diff < 3) edge_duplication = small_gaps > len(y_coords) * 0.1 - + motion_detected = is_blurred_laplacian or is_blurred_fft - + return { "motion_detected": motion_detected, "blur_laplacian": laplacian_var, @@ -136,20 +175,34 @@ def detect_motion_blur(image: np.ndarray, segmentation: Optional[np.ndarray] = N def detect_artifacts(image: np.ndarray, segmentation: Optional[np.ndarray] = None) -> Dict[str, Any]: """ - Detect various artifacts in the image: - - Metal objects (high intensity regions) - - Implants, screws, plates - - Cement - - Calcifications + Детекция артефактов на DXA изображении. + + Типы артефактов: + - Metal (металл): очень яркие области (>99.5 перцентиль), небольшого размера + - Implants (имплантаты): регулярные геометрические структуры (пластины, винты) + - Cement (цемент): локальные яркие пятна в области ROI + - Calcifications (кальцификаты): малые яркие области вне ROI + + Args: + image: Исходное изображение (нормализованное) + segmentation: Бинарная маска сегментации (опционально) + + Returns: + Dict с ключами: + - metal_detected: bool + - implant_detected: bool + - cement_detected: bool + - calcification_detected: bool + - any_detected: bool - любой артефакт обнаружен """ if len(image.shape) == 3: gray = np.mean(image, axis=2) else: gray = image - + # Normalize gray = (gray - gray.min()) / (gray.max() - gray.min() + 1e-8) - + artifacts = { "metal_detected": False, "implant_detected": False, @@ -157,29 +210,29 @@ def detect_artifacts(image: np.ndarray, segmentation: Optional[np.ndarray] = Non "calcification_detected": False, "local_defects": [] } - + # Metal detection: very bright spots metal_threshold = np.percentile(gray, 99.5) metal_mask = gray > metal_threshold metal_ratio = metal_mask.sum() / gray.size - + # If very small bright spots = possible metal if metal_ratio > 0.001 and metal_ratio < 0.05: artifacts["metal_detected"] = True - + # Implant detection: check for regular geometric shapes if segmentation is not None: # Look for high-intensity linear structures lines = ndimage.generate_binary_structure(2, 2) bright_mask = gray > np.percentile(gray, 95) - + # Check for linear structures (potential plates/screws) opened = ndimage.binary_opening(bright_mask, structure=lines) linear_structures = opened & ~ndimage.binary_erosion(bright_mask) - + if linear_structures.sum() > 50: artifacts["implant_detected"] = True - + # Cement detection: localized bright patches if segmentation is not None: roi = gray * segmentation @@ -188,7 +241,7 @@ def detect_artifacts(image: np.ndarray, segmentation: Optional[np.ndarray] = Non cement_regions = roi_normalized > 0.9 if cement_regions.sum() > 100 and cement_regions.sum() < 5000: artifacts["cement_detected"] = True - + # Calcifications: small bright spots if segmentation is not None: outside_roi = (1 - segmentation) > 0 @@ -198,7 +251,7 @@ def detect_artifacts(image: np.ndarray, segmentation: Optional[np.ndarray] = Non calcifications = (gray > bg_threshold) & (gray < np.percentile(gray, 99)) if 10 < calcifications.sum() < 500: artifacts["calcification_detected"] = True - + # Determine if any artifact detected artifacts["any_detected"] = any([ artifacts["metal_detected"], @@ -206,18 +259,34 @@ def detect_artifacts(image: np.ndarray, segmentation: Optional[np.ndarray] = Non artifacts["cement_detected"], artifacts["calcification_detected"] ]) - + return artifacts def check_spine_completeness(segmentation: np.ndarray) -> Dict[str, Any]: """ - Check if spine vertebrae are fully visible (L1-L4). + Проверка полноты изображения позвоночника. - Verifies: - - Number of vertebrae (should be 3-4 for lumbar spine) - - Vertebrae are not cropped - - Intervertebral spaces visible + Для качественного исследования поясничного отдела позвоночника должны быть видны: + - 3-4 позвонка (L1-L4) + - Межпозвоночные промежутки + - Позвонки не должны быть обрезаны краями изображения + + Алгоритм: + 1. Выделяет связные компоненты (отдельные позвонки) + 2. Проверяет количество (3-4) + 3. Проверяет каждый позвонок на обрезанность + 4. Проверяет выравнивание позвонков + + Args: + segmentation: Бинарная маска сегментации позвоночника + + Returns: + Dict с ключами: + - valid: bool - изображение полное + - num_vertebrae: int - количество видимых позвонков + - vertebrae: list - информация о каждом позвонке + - issues: list - список проблем """ if segmentation is None or segmentation.sum() == 0: return { @@ -226,38 +295,38 @@ def check_spine_completeness(segmentation: np.ndarray) -> Dict[str, Any]: "num_vertebrae": 0, "issues": ["Сегментация пустая"] } - + # Label connected components labeled, num_features = ndimage.label(segmentation) num_features = int(num_features) - + issues = [] - + # Check number of vertebrae if num_features < 3: issues.append(f"Видимо слишком мало позвонков: {num_features} (ожидается 3-4)") elif num_features > 6: issues.append(f"Видимо слишком много объектов: {num_features}") - + # Check each vertebra vertebra_info = [] for i in range(1, num_features + 1): vertebra_mask = labeled == i y_coords, x_coords = np.where(vertebra_mask) - + if len(y_coords) > 0: # Get bounding box y_min, y_max = y_coords.min(), y_coords.max() x_min, x_max = x_coords.min(), x_coords.max() - + height = y_max - y_min width = x_max - x_min - + # Check if cropped (touching image border) h, w = segmentation.shape - is_cropped = (y_min == 0 or y_max == h - 1 or + is_cropped = (y_min == 0 or y_max == h - 1 or x_min == 0 or x_max == w - 1) - + vertebra_info.append({ "id": i, "height": int(height), @@ -265,18 +334,18 @@ def check_spine_completeness(segmentation: np.ndarray) -> Dict[str, Any]: "center": (float(np.mean(y_coords) / h), float(np.mean(x_coords) / w)), "is_cropped": is_cropped }) - + if is_cropped: issues.append(f"Позвонок {i} обрезан (касается края изображения)") - + # Check alignment if len(vertebra_info) >= 2: x_centers = [v["center"][1] for v in vertebra_info] x_std = np.std(x_centers) - + if x_std > 0.05: # More than 5% of width issues.append(f"Позвонки не выровнены (отклонение {x_std:.3f})") - + return { "valid": len(issues) == 0, "num_vertebrae": num_features, @@ -303,10 +372,27 @@ def check_spine_labels(segmentation: np.ndarray, image: Optional[np.ndarray] = N def check_hip_completeness(segmentation: np.ndarray) -> Dict[str, Any]: """ - Check if hip is fully visible: - - Femur neck visible - - Femur head visible - - Greater and lesser trochanters visible + Проверка полноты изображения бедра. + + Для качественного исследования бедра должны быть видны: + - Шейка бедра (femur neck) + - Головка бедра (femur head) + - Большой и малый вертелы + + Алгоритм: + 1. Проверяет, что сегментация не пустая + 2. Проверяет обрезанность (касание краев изображения) + 3. Проверяет соотношение сторон (для бедра должно быть >1.5) + + Args: + segmentation: Бинарная маска сегментации бедра + + Returns: + Dict с ключами: + - valid: bool - изображение полное + - is_cropped: bool - обрезано ли изображение + - aspect_ratio: float - соотношение высоты к ширине + - issues: list - список проблем """ if segmentation is None or segmentation.sum() == 0: return { @@ -314,28 +400,28 @@ def check_hip_completeness(segmentation: np.ndarray) -> Dict[str, Any]: "reason": "empty_segmentation", "issues": ["Сегментация пустая"] } - + # Get bounding box y_coords, x_coords = np.where(segmentation > 0) h, w = segmentation.shape - - is_cropped = (y_coords.min() == 0 or y_coords.max() == h - 1 or + + is_cropped = (y_coords.min() == 0 or y_coords.max() == h - 1 or x_coords.min() == 0 or x_coords.max() == w - 1) - + issues = [] - + # Check if cropped if is_cropped: issues.append("Бедро обрезано (касается края изображения)") - + # Check aspect ratio (elongated = good for hip) height = y_coords.max() - y_coords.min() width = x_coords.max() - x_coords.min() aspect_ratio = height / (width + 1e-8) - + if aspect_ratio < 1.5: issues.append(f"Слишком короткое изображение бедра (соотношение сторон: {aspect_ratio:.2f})") - + return { "valid": len(issues) == 0, "is_cropped": is_cropped, @@ -349,12 +435,27 @@ def check_hip_completeness(segmentation: np.ndarray) -> Dict[str, Any]: def check_hip_rotation(segmentation: np.ndarray, image: np.ndarray) -> Dict[str, Any]: """ - Check hip rotation from the image. + Проверка ротации бедра на изображении. - Proper positioning: - - Femur should be slightly internally rotated (15-20 degrees) - - Lesser trochanter should be slightly visible medially - - Greater trochanter should not be prominent + Правильное позиционирование бедра: + - Легкая внутренняя ротация (15-20 градусов) + - Малый вертел слегка виден медиально + - Большой вертел не должен быть выражен + + Алгоритм: + 1. Находит главную ость сегментированного бедра + 2. Вычисляет угол отклонения от вертикали + 3. Проверяет, что угол в пределах допустимого диапазона (±30°) + + Args: + segmentation: Бинарная маска сегментации бедра + image: Исходное изображение + + Returns: + Dict с ключами: + - valid: bool - ротация корректна + - rotation_angle: float - угол ротации в градусах + - issues: list - список проблем """ if segmentation is None or segmentation.sum() == 0: return { @@ -363,19 +464,19 @@ def check_hip_rotation(segmentation: np.ndarray, image: np.ndarray) -> Dict[str, "rotation_angle": 0, "issues": ["Нет сегментации"] } - + if len(image.shape) == 3: gray = np.mean(image, axis=2) else: gray = image - + # Get the femur shape labeled, num_features = ndimage.label(segmentation) - + issues = [] rotation_angle = 0 valid = True - + if num_features == 0: return { "valid": False, @@ -383,7 +484,7 @@ def check_hip_rotation(segmentation: np.ndarray, image: np.ndarray) -> Dict[str, "rotation_angle": 0, "issues": ["Сегментация пустая"] } - + # For a single femur, check orientation if num_features == 1: # Get major axis @@ -392,23 +493,23 @@ def check_hip_rotation(segmentation: np.ndarray, image: np.ndarray) -> Dict[str, # Compute covariance to get principal axis coords_float = coords.astype(float) cov = np.cov(coords_float[:, 0], coords_float[:, 1]) - + # Eigenvalue decomposition eigenvalues, eigenvectors = np.linalg.eig(cov) major_axis = eigenvectors[:, np.argmax(eigenvalues)] - + # Calculate angle from vertical angle = np.arctan2(major_axis[1], major_axis[0]) rotation_angle = np.degrees(angle) - + # Check if within acceptable range (-30 to +30 degrees from vertical) if abs(rotation_angle) > 30: issues.append(f"Выраженная ротация: {rotation_angle:.1f}°") valid = False - + # Check lesser trochanter visibility (would be a bump on medial side) # This is a simplified check - + return { "valid": valid, "rotation_angle": float(rotation_angle), @@ -634,17 +735,42 @@ def generate_quality_report( model_confidence: float = 0.5 ) -> Dict[str, Any]: """ - Generate comprehensive quality assessment report. + Генерация комплексного отчета о качестве исследования. + + Этапы формирования отчета: + 1. Детекция движения (Laplacian, FFT) + 2. Детекция артефактов (металл, имплантаты, цемент) + 3. Проверка полноты (spine/hip) + 4. Проверка ротации (только для hip) + 5. Валидация ROI + 6. Определение типа нарушения + 7. Формирование итогового вердикта + + Args: + image: Исходное изображение (нормализованное) + segmentation: Бинарная маска сегментации + region: Анатомическая область (spine/hip_left/hip_right) + model_prediction: Предсказание модели (0=OK, 1=Violation) + model_confidence: Уверенность модели (0-1) + + Returns: + Dict с полным отчетом о качестве, включая: + - quality_class: 0 или 1 + - violation_type: тип нарушения + - reason: человекочитаемое описание + - metrics: детальные метрики по каждому критерию + - overall_quality: GOOD/WARNING/POOR + - severity: NONE/LOW/MEDIUM/HIGH """ # Calculate all quality metrics quality_metrics = {} - + # Motion detection quality_metrics["motion"] = detect_motion_blur(image, segmentation) - + # Artifact detection quality_metrics["artifacts"] = detect_artifacts(image, segmentation) - + # Region-specific checks if region == "spine": quality_metrics["spine_completeness"] = check_spine_completeness(segmentation) @@ -653,18 +779,18 @@ def generate_quality_report( elif region in ("hip", "hip_left", "hip_right"): quality_metrics["hip_completeness"] = check_hip_completeness(segmentation) quality_metrics["hip_rotation"] = check_hip_rotation(segmentation, image) - + # ROI check quality_metrics["roi_check"] = check_roi_boundaries(segmentation, image.shape) - + # Basic position check (from existing scorer) from src.quality.quality_scorer import QualityScorer scorer = QualityScorer() quality_metrics["position"] = scorer.check_position(segmentation) - + # Determine violation type violation_type, reason = determine_violation_type(region, quality_metrics) - + # Overall quality determination if violation_type == "correct": overall_quality = "GOOD" @@ -674,7 +800,7 @@ def generate_quality_report( overall_quality = "POOR" severity = "HIGH" quality_class = 1 - + # Determine view quality if region == "spine": completeness = quality_metrics.get("spine_completeness", {}) @@ -684,27 +810,27 @@ def generate_quality_report( view_quality = "full" if completeness.get("valid", False) else "partial" else: view_quality = "unknown" - + return { # Basic info "anatomical_region": region, "quality_class": quality_class, "quality_label": "OK" if quality_class == 0 else "Violation detected", - + # New detailed fields "violation_type": violation_type, "reason": reason, - + # Confidence "confidence": model_confidence, "confidence_per_class": { "correct": float(model_confidence) if quality_class == 0 else float(1 - model_confidence), "violation": float(1 - model_confidence) if quality_class == 0 else float(model_confidence) }, - + # View quality "view_quality": view_quality, - + # Detailed metrics "metrics": { "motion": quality_metrics.get("motion", {}), @@ -712,13 +838,13 @@ def generate_quality_report( "position": quality_metrics.get("position", {}), "roi_check": quality_metrics.get("roi_check", {}) }, - + # Region-specific "spine_completeness": quality_metrics.get("spine_completeness", {}), "vertebral_contours": quality_metrics.get("vertebral_contours", {}), "hip_completeness": quality_metrics.get("hip_completeness", {}), "hip_rotation": quality_metrics.get("hip_rotation", {}), - + # Overall "overall_quality": overall_quality, "severity": severity diff --git a/src/segmentators/totalsegmentator_wrapper.py b/src/segmentators/totalsegmentator_wrapper.py index c123698..4ecae71 100644 --- a/src/segmentators/totalsegmentator_wrapper.py +++ b/src/segmentators/totalsegmentator_wrapper.py @@ -1,35 +1,147 @@ from src.segmentators.base import BaseSegmentator import numpy as np +import tempfile +import os +from pathlib import Path +from typing import Literal class TotalSegmentatorWrapper(BaseSegmentator): - """Обертка для TotalSegmentator (для костей)""" + """Обертка для TotalSegmentator (костная сегментация)""" - def __init__(self): + TASK_MAP = { + "spine": "vertebrae_body", + "hip": "appendicular_bones", + "hip_left": "appendicular_bones", + "hip_right": "appendicular_bones", + "vertebrae": "vertebrae_body", + "appendicular": "appendicular_bones", + "total": "total", + } + + def __init__( + self, + device: str = "cpu", + task: str = "vertebrae_body", + anatomy_region: str = None + ): + """ + Args: + device: устройство для инференса ('cpu', 'gpu', 'mps') + task: задача сегментации ('vertebrae_body', 'appendicular_bones', 'total') + anatomy_region: анатомическая область ('spine', 'hip', 'hip_left', 'hip_right') + """ + # Определяем задачу на основе анатомической области + if anatomy_region and anatomy_region in self.TASK_MAP: + task = self.TASK_MAP[anatomy_region] + + self.device = device + self.task = task + self.loaded = False + self.model = None + self.totalsegmentator_func = None + + # Lazy load - откладываем до первого использования + # self._load_model() + + def _load_model(self): + """Ленивая загрузка модели при первом вызове""" + if self.loaded: + return + try: - from totalsegmentator import TotalSegmentator - self.model = TotalSegmentator() + import nibabel as nib + from totalsegmentator.python_api import totalsegmentator + + self.totalsegmentator_func = totalsegmentator + self.nibabel = nib self.loaded = True - print("✅ TotalSegmentator загружен") - except ImportError: - print("⚠️ TotalSegmentator не установлен") + print(f"✅ TotalSegmentator API загружен (task={self.task})") + except ImportError as e: self.loaded = False + print(f"⚠️ TotalSegmentator: {e}") + except Exception as e: + self.loaded = False + print(f"⚠️ TotalSegmentator: ошибка инициализации - {e}") def segment(self, image): + """ + Сегментация 2D изображения (numpy array или PIL Image) + + Args: + image: numpy array [H, W] или PIL Image + + Returns: + Бинарная маска [H, W] где 1 = кость + """ + # Ленивая загрузка модели при первом вызове if not self.loaded: - return np.zeros((512, 512), dtype=np.int64) + self._load_model() + + if not self.loaded: + h, w = 512, 512 + if hasattr(image, 'shape'): + if len(image.shape) >= 2: + h, w = image.shape[:2] + return np.zeros((h, w), dtype=np.int64) - # TotalSegmentator ожидает DICOM или NIfTI - # Здесь адаптация под входной формат - # Реальная реализация зависит от TotalSegmentator API + # Конвертация в numpy если PIL + if hasattr(image, 'convert'): + image = np.array(image) + + # Проверка что это 2D изображение + if len(image.shape) == 3: + image = image[:, :, 0] # Берем первый канал + + # Сохраняем во временный файл как NIfTI + import nibabel as nib + + # Создаем 3D объем (1 срез) + image_3d = np.expand_dims(image.astype(np.float32), axis=0) + + with tempfile.TemporaryDirectory() as tmpdir: + input_path = Path(tmpdir) / "input.nii.gz" + output_path = Path(tmpdir) / "output" + output_path.mkdir() + + # Сохраняем как NIfTI + nifti_img = nib.Nifti1Image(image_3d, np.eye(4)) + nib.save(nifti_img, str(input_path)) + + # Запускаем TotalSegmentator + try: + result = self.totalsegmentator_func( + input=str(input_path), + output=str(output_path), + task=self.task, + device=self.device, + quiet=True, + verbose=False + ) + + # Загружаем результат + result_files = list(output_path.glob("*.nii.gz")) + if result_files: + result_nifti = nib.load(str(result_files[0])) + mask_3d = result_nifti.get_fdata() + # Берем первый (и единственный) срез + if mask_3d.ndim == 3: + mask_2d = mask_3d[0, :, :] + else: + mask_2d = mask_3d + return (mask_2d > 0).astype(np.int64) + + except Exception as e: + pass # Молча возвращаем пустую маску - # Пока возвращаем заглушку - return np.ones((512, 512), dtype=np.int64) + return np.zeros(image.shape, dtype=np.int64) def get_info(self): return { "name": "TotalSegmentator", "type": "segmentation", "target": "bones", + "task": self.task, + "device": self.device, "loaded": self.loaded } \ No newline at end of file