develop - hack_2026
This commit is contained in:
parent
c234193eb4
commit
1b82992a6d
420
README.md
420
README.md
|
|
@ -1,144 +1,154 @@
|
||||||
# 🦴 Bone Quality Assessment
|
# 🦴 DXA Quality Assessment
|
||||||
|
|
||||||
[](https://www.python.org/)
|
[](https://www.python.org/)
|
||||||
[](https://fastapi.tiangolo.com/)
|
[](https://fastapi.tiangolo.com/)
|
||||||
[](https://pytorch.org/)
|
[](https://pytorch.org/)
|
||||||
[](https://www.docker.com/)
|
|
||||||
|
|
||||||
## 📋 Описание
|
## Описание
|
||||||
|
|
||||||
Сервис искусственного интеллекта для автоматизированной оценки качества денситометрических изображений и их разметки. Система получает на вход рентгеновское денситометрическое исследование в формате DICOM, и оценивает качество выполнения исследования по стандартным критериям, а также корректность разметки анатомических структур на изображениях.
|
Сервис для автоматизированной оценки качества денситометрических исследований (DXA). Система анализирует DICOM-изображения костной денситометрии и определяет качество исследования по следующим критериям:
|
||||||
|
|
||||||
|
- **Артефакты** — движение, размытость, металлические объекты, имплантаты
|
||||||
|
- **Позиционирование** — правильное расположение анатомической области в кадре
|
||||||
|
- **Полнота изображения** — видимость всех анатомических структур (позвонки L1-L4, бедро)
|
||||||
|
- **Ротация** — корректный угол поворота (для исследования бедра)
|
||||||
|
- **ROI-валидация** — правильность расположения области интереса
|
||||||
|
|
||||||
### Основные возможности
|
### Основные возможности
|
||||||
|
|
||||||
- 🖼️ **Анализ изображений** — загрузка и обработка медицинских изображений
|
- 🔬 **Анализ DICOM** — загрузка и обработка медицинских изображений
|
||||||
- 🧠 **Сегментация объектов** — выделение анатомических структур (позвонки, кости)
|
- 🧠 **Классификация** — бинарная оценка качества (OK / Violation)
|
||||||
- 📊 **Оценка качества** — проверка по 3 критериям:
|
- 🔍 **Детекция нарушений** — определение типа нарушения:
|
||||||
- Артефакты (движение, шум, размытость)
|
- `correct` — качество соответствует норме
|
||||||
- Позиционирование (правильное расположение объекта)
|
- `artifact_motion` — артефакт движения
|
||||||
- Контрастность (качество изображения)
|
- `artifact_other` — прочие артефакты
|
||||||
- 🔍 **Детекция нарушений** — определение типа нарушения для некачественных исследований
|
- `position_error` — ошибка позиционирования
|
||||||
- 🌐 **Web-интерфейс** — удобная загрузка и визуализация результатов
|
- `rotation` — нарушение ротации
|
||||||
- 📡 **REST API** — интеграция с внешними системами
|
- `incomplete_view` — неполный вид
|
||||||
- 📈 **Визуализация маски** — отображение сегментации на изображении
|
- `roi_error` — ошибка ROI
|
||||||
|
- `labeling_error` — ошибка разметки
|
||||||
|
- 🌐 **REST API** — интеграция с внешними системами
|
||||||
|
- 📊 **Веб-интерфейс** — загрузка и визуализация результатов
|
||||||
|
- 📈 **Экспорт** — выгрузка результатов в XLSX
|
||||||
|
|
||||||
## 🏗️ Архитектура
|
---
|
||||||
|
|
||||||

|
## Архитектура
|
||||||
|
|
||||||
## 🎯 Доступные режимы
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
### DXA Режим (хакатон)
|
│ FastAPI Server │
|
||||||
Анализ денситометрических исследований:
|
│ (port 8000) │
|
||||||
```bash
|
├─────────────────────────────────────────────────────────────┤
|
||||||
# Обучение
|
│ /api/v1/analyze → Basic quality prediction │
|
||||||
python src/dxa/train.py --epochs 10
|
│ /api/v1/analyze/detailed → Full report with metrics │
|
||||||
|
│ /api/v1/analyze/sr → DICOM SR (Structured Report) │
|
||||||
# Инференс
|
│ /api/v1/batch → Batch processing │
|
||||||
python src/dxa/inference.py --input-path dataset_hack/Для\ теста --output-path results.xlsx
|
│ /api/v1/export → XLSX export │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ ┌──────────────┐ ┌─────────────────┐ │
|
||||||
|
│ │ ResNet18 │───▶│ Quality Model │ │
|
||||||
|
│ │ (pretrained) │ │ (binary class) │ │
|
||||||
|
│ └──────────────┘ └────────┬────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ ┌─────────────────────┐ │
|
||||||
|
│ │ Detailed Assessment │ │
|
||||||
|
│ │ - Motion detection │ │
|
||||||
|
│ │ - Artifact detection│ │
|
||||||
|
│ │ - ROI validation │ │
|
||||||
|
│ │ - View completeness│ │
|
||||||
|
│ └─────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 🚀 Быстрый старт
|
## Быстрый старт
|
||||||
|
|
||||||
### Требования
|
### Требования
|
||||||
|
|
||||||
- Python 3.10+
|
- Python 3.10+
|
||||||
- PyTorch 2.1+
|
- PyTorch 2.0+
|
||||||
- Docker (опционально)
|
- 4GB+ RAM
|
||||||
|
- (опционально) GPU CUDA/MPS для ускорения
|
||||||
|
|
||||||
### Локальная установка
|
### Установка
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Клонирование репозитория
|
# Клонирование
|
||||||
git clone https://github.com/yourusername/bone-quality-assessment.git
|
git clone https://github.com/yourusername/bone-quality-assessment.git
|
||||||
cd bone-quality-assessment
|
cd bone-quality-assessment
|
||||||
|
|
||||||
# 2. Создание виртуального окружения
|
# Создание виртуального окружения
|
||||||
python -m venv venv
|
python -m venv venv
|
||||||
source venv/bin/activate # Linux/Mac
|
source venv/bin/activate # Linux/Mac
|
||||||
# или
|
# venv\Scripts\activate # Windows
|
||||||
venv\Scripts\activate # Windows
|
|
||||||
|
|
||||||
# 3. Установка зависимостей
|
# Установка зависимостей
|
||||||
pip install -r requirements.txt
|
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
|
python run.py
|
||||||
```
|
```
|
||||||
|
|
||||||
Docker
|
После запуска:
|
||||||
|
- Web-интерфейс: http://localhost:8000
|
||||||
|
- Swagger UI: http://localhost:8000/docs
|
||||||
|
- ReDoc: http://localhost:8000/redoc
|
||||||
|
|
||||||
|
### Docker
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. Сборка образа
|
# Сборка
|
||||||
docker build -t bone-quality-api .
|
docker build -t dxa-quality-api .
|
||||||
|
|
||||||
# 2. Запуск контейнера
|
# Запуск
|
||||||
docker run -p 8000:8000 bone-quality-api
|
docker run -p 8000:8000 dxa-quality-api
|
||||||
|
|
||||||
# 3. Или используя docker-compose
|
|
||||||
docker-compose up -d
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
📡 API Endpoints
|
## API Endpoints
|
||||||
|
|
||||||
| Метод | Эндпоинт | Описание |
|
| Метод | Эндпоинт | Описание |
|
||||||
|--------|-------------------|--------------------------|
|
|-------|----------|----------|
|
||||||
| GET | / | Главная страница
|
| GET | `/` | Главная страница (веб-интерфейс) |
|
||||||
| GET | /docs | Swagger UI документация
|
| GET | `/api/v1/health` | Проверка статуса сервиса |
|
||||||
| GET | /redoc | ReDoc документация
|
| POST | `/api/v1/analyze` | Базовый анализ изображения |
|
||||||
| GET | /api/v1/health | Проверка статуса сервиса
|
| POST | `/api/v1/analyze/detailed` | Детальный анализ с метриками |
|
||||||
| POST | /api/v1/analyze | Базовый анализ изображения
|
| POST | `/api/v1/analyze/sr` | DICOM SR отчёт |
|
||||||
| POST | /api/v1/analyze/detailed | Детальный анализ с метриками
|
| POST | `/api/v1/batch` | Пакетный анализ |
|
||||||
| POST | /api/v1/analyze/sr | Анализ с DICOM SR отчётом
|
| POST | `/api/v1/export` | Анализ и экспорт в XLSX |
|
||||||
| POST | /api/v1/batch | Пакетный анализ файлов
|
|
||||||
| POST | /api/v1/export | Анализ и экспорт в XLSX
|
|
||||||
|
|
||||||
|
### Пример использования
|
||||||
|
|
||||||
Пример запроса
|
|
||||||
```bash
|
```bash
|
||||||
|
# Анализ файла
|
||||||
curl -X POST "http://localhost:8000/api/v1/analyze" \
|
curl -X POST "http://localhost:8000/api/v1/analyze" \
|
||||||
-H "accept: application/json" \
|
-H "accept: application/json" \
|
||||||
-H "Content-Type: multipart/form-data" \
|
-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
|
```json
|
||||||
{
|
{
|
||||||
|
|
@ -156,7 +166,7 @@ curl -X POST "http://localhost:8000/api/v1/analyze" \
|
||||||
"metrics": {
|
"metrics": {
|
||||||
"motion": {
|
"motion": {
|
||||||
"motion_detected": true,
|
"motion_detected": true,
|
||||||
"blur_laplacian": 0.001,
|
"blur_laplacian": 0.0008,
|
||||||
"severity": "HIGH"
|
"severity": "HIGH"
|
||||||
},
|
},
|
||||||
"artifacts": {
|
"artifacts": {
|
||||||
|
|
@ -167,147 +177,153 @@ curl -X POST "http://localhost:8000/api/v1/analyze" \
|
||||||
"valid": true
|
"valid": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"spine_completeness": {
|
|
||||||
"valid": true,
|
|
||||||
"num_vertebrae": 4
|
|
||||||
},
|
|
||||||
"overall_quality": "POOR",
|
"overall_quality": "POOR",
|
||||||
"severity": "HIGH"
|
"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/
|
bone_2026/
|
||||||
├── src/
|
├── src/
|
||||||
│ ├── dxa/ # DXA Quality модуль
|
│ ├── main.py # FastAPI приложение
|
||||||
│ │ ├── model.py # ResNet18 классификатор
|
│ ├── run.py # Запуск сервера
|
||||||
│ │ ├── dataset.py # Загрузчик данных
|
│ ├── dxa/ # DXA модуль
|
||||||
│ │ ├── train.py # Обучение
|
│ │ ├── model.py # ResNet18 классификатор
|
||||||
│ │ └── inference.py # Инференс
|
│ │ ├── dataset.py # Загрузчик данных
|
||||||
│ ├── api/ # REST API
|
│ │ ├── train.py # Обучение модели
|
||||||
│ ├── quality/ # Оценка качества
|
│ │ └── inference.py # Инференс и batch-обработка
|
||||||
│ └── main.py # FastAPI приложение
|
│ ├── quality/ # Оценка качества
|
||||||
├── models/
|
│ │ ├── quality_scorer.py # Базовый скорer
|
||||||
│ └── dxa_model.pth # Обученная модель
|
│ │ └── detailed_assessment.py # Детальный анализ
|
||||||
├── dataset_hack/ # DICOM датасет
|
│ ├── api/ # REST API
|
||||||
|
│ │ ├── endpoints.py # Дополнительные эндпоинты
|
||||||
|
│ │ └── static/ # Веб-интерфейс
|
||||||
|
│ └── utils/ # Утилиты
|
||||||
|
├── models/ # Обученные модели
|
||||||
|
│ └── dxa_model.pth # Модель классификатора
|
||||||
|
├── dataset_hack/ # Датасет для обучения/тестирования
|
||||||
|
├── docs/ # Документация
|
||||||
├── Dockerfile
|
├── Dockerfile
|
||||||
├── requirements.txt
|
├── requirements.txt
|
||||||
├── run.sh
|
|
||||||
└── README.md
|
└── README.md
|
||||||
```
|
```
|
||||||
|
|
||||||
🧪 Тестирование
|
---
|
||||||
```bash
|
|
||||||
# Запуск тестов (если есть)
|
|
||||||
pytest tests/
|
|
||||||
```
|
|
||||||
|
|
||||||
# Проверка API
|
## Обучение модели
|
||||||
|
|
||||||
|
### Подготовка данных
|
||||||
|
|
||||||
|
1. Разместите DICOM-файлы в `dataset_hack/НД_для_обучения/Исследования/`
|
||||||
|
2. Подготовьте Excel-файл разметки `dataset_hack/НД_для_обучения/разметка.xlsx`
|
||||||
|
|
||||||
|
Столбцы разметки:
|
||||||
|
- `study_uid` — ID исследования
|
||||||
|
- `позвоночник_укладка`, `позвоночник_ось`, `позвоночник_артефакты` — критерии для позвоночника
|
||||||
|
- `бедро_позиция_лев`, `бедро_roi_лев` — критерии для левого бедра
|
||||||
|
- `бедро_позиция_прав`, `бедро_roi_прав` — критерии для правого бедра
|
||||||
|
- `итог_позвоночник`, `итог_бедро_лев`, `итог_бедро_прав` — итоговая оценка (0/1)
|
||||||
|
|
||||||
|
### Запуск обучения
|
||||||
|
|
||||||
```bash
|
```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
|
```bash
|
||||||
# Копирование на сервер
|
python src/dxa/inference.py \
|
||||||
scp -r ./bone-quality-assessment user@server:/var/www/
|
--input-path path/to/image.dcm \
|
||||||
|
--output-path result.xlsx
|
||||||
```
|
```
|
||||||
|
|
||||||
# Запуск в фоновом режиме
|
### Директория
|
||||||
nohup python run.py > logs/out.log 2>&1 &
|
|
||||||
|
|
||||||
Использование с Nginx
|
```bash
|
||||||
```nginx
|
python src/dxa/inference.py \
|
||||||
location /api/ {
|
--input-path dataset_hack/Для\ теста \
|
||||||
proxy_pass http://localhost:8000;
|
--output-path results.xlsx \
|
||||||
proxy_set_header Host $host;
|
--model-path models/dxa_model.pth
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🤝 Вклад в проект
|
### Выходной формат (XLSX/CSV)
|
||||||
- Fork репозитория
|
|
||||||
- Создайте ветку для вашей фичи (git checkout -b feature/amazing-feature)
|
|
||||||
- Commit изменений (git commit -m 'Add amazing feature')
|
|
||||||
- Push в ветку (git push origin feature/amazing-feature)
|
|
||||||
- Откройте Pull Request
|
|
||||||
|
|
||||||
|
| Колонка | Описание |
|
||||||
|
|---------|----------|
|
||||||
|
| `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
|
MIT License
|
||||||
|
|
||||||
👥 Команда
|
## Команда
|
||||||
Грачев Денис — Разработка - GitHub
|
|
||||||
|
|
||||||
### 🙏 Благодарности
|
- **Грачев Денис** — Разработка
|
||||||
|
- **Грачев Татьяна** — Капитан
|
||||||
|
|
||||||
***Oxford-IIIT Pet Dataset*** для обучения модели
|
---
|
||||||
|
|
||||||
***Сообществу PyTorch и FastAPI***
|
<div align="center">
|
||||||
|
<sub>Built for Bone Quality Assessment Hackathon 2026</sub>
|
||||||
📞 Контакты
|
</div>
|
||||||
- 📧 Email: your.email@example.com
|
|
||||||
- 🐦 Telegram: @oxydencher
|
|
||||||
- 🐙 GitHub: gdg6
|
|
||||||
|
|
||||||
<div align="center"> <sub>Built with ❤️ for the Bone Quality Assessment Hackathon</sub> </div>
|
|
||||||
|
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 538 KiB |
|
|
@ -62,6 +62,7 @@ timm==1.0.29
|
||||||
torch==2.8.0
|
torch==2.8.0
|
||||||
torchvision==0.23.0
|
torchvision==0.23.0
|
||||||
TotalSegmentator==2.18.0
|
TotalSegmentator==2.18.0
|
||||||
|
requests==2.32.3
|
||||||
tqdm==4.70.0
|
tqdm==4.70.0
|
||||||
typer==0.23.2
|
typer==0.23.2
|
||||||
typing-inspection==0.4.2
|
typing-inspection==0.4.2
|
||||||
|
|
|
||||||
|
|
@ -7,16 +7,12 @@ from typing import Dict, Any
|
||||||
from src.classifiers.bone_condition_classifier import BoneConditionClassifier
|
from src.classifiers.bone_condition_classifier import BoneConditionClassifier
|
||||||
from src.core.config import PipelineConfig
|
from src.core.config import PipelineConfig
|
||||||
from src.core.registry import ModelRegistry
|
from src.core.registry import ModelRegistry
|
||||||
from src.classifiers.pet_breed_classifier import PetBreedClassifier
|
|
||||||
from src.quality.universal_scorer import UniversalQualityScorer
|
from src.quality.universal_scorer import UniversalQualityScorer
|
||||||
from src.segmentators.totalsegmentator_wrapper import TotalSegmentatorWrapper
|
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_segmentator("totalsegmentator", TotalSegmentatorWrapper)
|
||||||
|
|
||||||
ModelRegistry.register_classifier("pet_breed", PetBreedClassifier)
|
|
||||||
ModelRegistry.register_classifier("bone_condition", BoneConditionClassifier)
|
ModelRegistry.register_classifier("bone_condition", BoneConditionClassifier)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -43,7 +39,11 @@ class Orchestrator:
|
||||||
# Сегментатор
|
# Сегментатор
|
||||||
segmentator_class = ModelRegistry.get_segmentator(self.config.segmentator)
|
segmentator_class = ModelRegistry.get_segmentator(self.config.segmentator)
|
||||||
if segmentator_class:
|
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:
|
else:
|
||||||
raise ValueError(f"Сегментатор {self.config.segmentator} не найден")
|
raise ValueError(f"Сегментатор {self.config.segmentator} не найден")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,16 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
DXA Quality Inference - Batch processing with Excel output
|
DXA Quality Inference - Batch processing with Excel output
|
||||||
|
|
||||||
|
Этот модуль выполняет инференс модели классификации качества DXA исследований.
|
||||||
|
Основные функции:
|
||||||
|
- Загрузка и предобработка DICOM изображений
|
||||||
|
- Определение анатомической области (позвоночник/бедро)
|
||||||
|
- Бинарная классификация качества (OK/Violation)
|
||||||
|
- Пакетная обработка с экспортом в Excel
|
||||||
|
|
||||||
|
Использование:
|
||||||
|
python src/dxa/inference.py --input-path <path> --output-path <output.xlsx>
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|
@ -22,7 +32,15 @@ sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||||
|
|
||||||
|
|
||||||
def get_device():
|
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():
|
if torch.backends.mps.is_available():
|
||||||
return 'mps'
|
return 'mps'
|
||||||
elif torch.cuda.is_available():
|
elif torch.cuda.is_available():
|
||||||
|
|
@ -32,9 +50,23 @@ def get_device():
|
||||||
|
|
||||||
|
|
||||||
def load_model(model_path: str, backbone: str = 'resnet18', device: str = 'cpu'):
|
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 = create_model(backbone=backbone, pretrained=False, device=device)
|
||||||
model.load(model_path)
|
model.load(model_path)
|
||||||
model.model.eval()
|
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:
|
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)
|
ds = pydicom.dcmread(dcm_path)
|
||||||
img = ds.pixel_array.astype(np.float32)
|
img = ds.pixel_array.astype(np.float32)
|
||||||
|
|
||||||
# Normalize to 0-1
|
# Normalize to 0-1
|
||||||
img_min = img.min()
|
img_min = img.min()
|
||||||
img_max = img.max()
|
img_max = img.max()
|
||||||
if img_max > img_min:
|
if img_max > img_min:
|
||||||
img = (img - img_min) / (img_max - img_min)
|
img = (img - img_min) / (img_max - img_min)
|
||||||
|
|
||||||
# Convert to 3-channel
|
# Convert to 3-channel
|
||||||
img = np.stack([img] * 3, axis=0)
|
img = np.stack([img] * 3, axis=0)
|
||||||
|
|
||||||
# Convert to uint8 for PIL
|
# Convert to uint8 for PIL
|
||||||
img = (img * 255).astype(np.uint8)
|
img = (img * 255).astype(np.uint8)
|
||||||
|
|
||||||
# Resize
|
# Resize
|
||||||
img_pil = Image.fromarray(img.transpose(1, 2, 0))
|
img_pil = Image.fromarray(img.transpose(1, 2, 0))
|
||||||
img_pil = img_pil.resize((input_size, input_size), Image.BILINEAR)
|
img_pil = img_pil.resize((input_size, input_size), Image.BILINEAR)
|
||||||
img = np.array(img_pil).transpose(2, 0, 1)
|
img = np.array(img_pil).transpose(2, 0, 1)
|
||||||
|
|
||||||
# Normalize back to 0-1
|
# Normalize back to 0-1
|
||||||
img = img.astype(np.float32) / 255.0
|
img = img.astype(np.float32) / 255.0
|
||||||
|
|
||||||
# Convert to tensor
|
# Convert to tensor
|
||||||
img = torch.from_numpy(img).float().unsqueeze(0)
|
img = torch.from_numpy(img).float().unsqueeze(0)
|
||||||
|
|
||||||
return img
|
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:
|
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)
|
- Позвоночник: яркая область более квадратная (aspect ratio ~1.2)
|
||||||
- Image symmetry
|
- Бедро: яркая область вытянута вертикально (aspect ratio > 1.5)
|
||||||
- Horizontal position for left/right hip distinction
|
|
||||||
|
|
||||||
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
|
h, w = img.shape
|
||||||
|
|
||||||
# Normalize image
|
# Normalize image
|
||||||
img_norm = (img - img.min()) / (img.max() - img.min() + 1e-6)
|
img_norm = (img - img.min()) / (img.max() - img.min() + 1e-6)
|
||||||
|
|
||||||
# Feature 1: Bright region aspect ratio
|
# Feature 1: Bright region aspect ratio
|
||||||
threshold = np.percentile(img_norm, 95)
|
threshold = np.percentile(img_norm, 95)
|
||||||
binary = img_norm > threshold
|
binary = img_norm > threshold
|
||||||
|
|
||||||
bbox_aspect = 1.0
|
bbox_aspect = 1.0
|
||||||
bright_x = 0.5 # default center
|
bright_x = 0.5 # default center
|
||||||
left_right_ratio = 1.0 # default balanced
|
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_h = rmax - rmin
|
||||||
bbox_w = cmax - cmin
|
bbox_w = cmax - cmin
|
||||||
bbox_aspect = bbox_h / (bbox_w + 1e-6)
|
bbox_aspect = bbox_h / (bbox_w + 1e-6)
|
||||||
|
|
||||||
# Get bright region center X position
|
# Get bright region center X position
|
||||||
com = ndimage.center_of_mass(binary)
|
com = ndimage.center_of_mass(binary)
|
||||||
bright_x = com[1] / w
|
bright_x = com[1] / w
|
||||||
|
|
||||||
# Calculate left/right brightness ratio
|
# Calculate left/right brightness ratio
|
||||||
left_bright = binary[:, :w//2].sum()
|
left_bright = binary[:, :w//2].sum()
|
||||||
right_bright = binary[:, w//2:].sum()
|
right_bright = binary[:, w//2:].sum()
|
||||||
left_right_ratio = left_bright / (right_bright + 1e-6)
|
left_right_ratio = left_bright / (right_bright + 1e-6)
|
||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Feature 2: Symmetry
|
# Feature 2: Symmetry
|
||||||
h_mid, w_mid = h // 2, w // 2
|
h_mid, w_mid = h // 2, w // 2
|
||||||
left_half = img_norm[:, :w_mid]
|
left_half = img_norm[:, :w_mid]
|
||||||
right_half = np.fliplr(img_norm[:, w_mid:])
|
right_half = np.fliplr(img_norm[:, w_mid:])
|
||||||
min_w = min(left_half.shape[1], right_half.shape[1])
|
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)
|
symmetry = 1 - np.abs(left_half[:, :min_w] - right_half[:, :min_w]).mean() / (img_norm.std() + 1e-6)
|
||||||
|
|
||||||
# Feature 3: Vertical/horizontal edges
|
# Feature 3: Vertical/horizontal edges
|
||||||
dx = np.diff(img_norm, axis=1)
|
dx = np.diff(img_norm, axis=1)
|
||||||
dy = np.diff(img_norm, axis=0)
|
dy = np.diff(img_norm, axis=0)
|
||||||
v_edges = np.abs(dx).mean()
|
v_edges = np.abs(dx).mean()
|
||||||
h_edges = np.abs(dy).mean()
|
h_edges = np.abs(dy).mean()
|
||||||
v_h_ratio = v_edges / (h_edges + 1e-6)
|
v_h_ratio = v_edges / (h_edges + 1e-6)
|
||||||
|
|
||||||
# Classification rules based on analysis:
|
# Classification rules based on analysis:
|
||||||
# spine: bbox_aspect ~1.2, symmetry > 0.4, v_h_ratio < 1.5
|
# 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
|
# hip: bbox_aspect > 1.5, symmetry < 0.4, v_h_ratio > 1.5
|
||||||
|
|
||||||
# Primary: bbox_aspect is the best discriminator
|
# Primary: bbox_aspect is the best discriminator
|
||||||
if bbox_aspect < 1.5:
|
if bbox_aspect < 1.5:
|
||||||
# More square bright region -> spine
|
# More square bright region -> spine
|
||||||
|
|
@ -215,12 +270,27 @@ def determine_anatomical_region(dcm_path: str) -> str:
|
||||||
|
|
||||||
|
|
||||||
def process_dicom_files(args):
|
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
|
# Setup
|
||||||
device = get_device()
|
device = get_device()
|
||||||
print(f"Using device: {device}")
|
print(f"Using device: {device}")
|
||||||
|
|
||||||
# Load model
|
# Load model
|
||||||
if Path(args.model_path).exists():
|
if Path(args.model_path).exists():
|
||||||
print(f"Loading model from {args.model_path}...")
|
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")
|
print("Using untrained model - results will be random")
|
||||||
from src.dxa.model import create_model
|
from src.dxa.model import create_model
|
||||||
model = create_model(backbone=args.backbone, pretrained=False, device=device)
|
model = create_model(backbone=args.backbone, pretrained=False, device=device)
|
||||||
|
|
||||||
# Find DICOM files
|
# Find DICOM files
|
||||||
input_path = Path(args.input_path)
|
input_path = Path(args.input_path)
|
||||||
dcm_files = []
|
dcm_files = []
|
||||||
|
|
||||||
if input_path.is_file() and input_path.suffix.lower() == '.dcm':
|
if input_path.is_file() and input_path.suffix.lower() == '.dcm':
|
||||||
dcm_files = [input_path]
|
dcm_files = [input_path]
|
||||||
elif input_path.is_dir():
|
elif input_path.is_dir():
|
||||||
dcm_files = sorted(input_path.rglob('*.dcm'))
|
dcm_files = sorted(input_path.rglob('*.dcm'))
|
||||||
|
|
||||||
print(f"Found {len(dcm_files)} DICOM files")
|
print(f"Found {len(dcm_files)} DICOM files")
|
||||||
|
|
||||||
if len(dcm_files) == 0:
|
if len(dcm_files) == 0:
|
||||||
print("ERROR: No DICOM files found!")
|
print("ERROR: No DICOM files found!")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Process each file
|
# Process each file
|
||||||
results = []
|
results = []
|
||||||
|
|
||||||
for dcm_path in tqdm(dcm_files, desc="Processing"):
|
for dcm_path in tqdm(dcm_files, desc="Processing"):
|
||||||
start_time = datetime.now()
|
start_time = datetime.now()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Load and preprocess image
|
# Load and preprocess image
|
||||||
img = load_dicom_image(str(dcm_path), args.input_size)
|
img = load_dicom_image(str(dcm_path), args.input_size)
|
||||||
img = img.to(device)
|
img = img.to(device)
|
||||||
|
|
||||||
# Predict
|
# Predict
|
||||||
with torch.no_grad():
|
with torch.no_grad():
|
||||||
outputs = model.model(img)
|
outputs = model.model(img)
|
||||||
probs = torch.softmax(outputs, dim=1)
|
probs = torch.softmax(outputs, dim=1)
|
||||||
pred = outputs.argmax(dim=1).item()
|
pred = outputs.argmax(dim=1).item()
|
||||||
prob = probs[0, pred].item()
|
prob = probs[0, pred].item()
|
||||||
|
|
||||||
# Get DICOM metadata
|
# Get DICOM metadata
|
||||||
ds = pydicom.dcmread(str(dcm_path))
|
ds = pydicom.dcmread(str(dcm_path))
|
||||||
|
|
||||||
study_uid = getattr(ds, 'StudyInstanceUID', '')
|
study_uid = getattr(ds, 'StudyInstanceUID', '')
|
||||||
image_uid = getattr(ds, 'SOPInstanceUID', '')
|
image_uid = getattr(ds, 'SOPInstanceUID', '')
|
||||||
|
|
||||||
# Determine anatomical region
|
# Determine anatomical region
|
||||||
anatomical_region = determine_anatomical_region(str(dcm_path))
|
anatomical_region = determine_anatomical_region(str(dcm_path))
|
||||||
|
|
||||||
# Map prediction to quality class
|
# Map prediction to quality class
|
||||||
quality_class = pred # 0 = good, 1 = violation
|
quality_class = pred # 0 = good, 1 = violation
|
||||||
|
|
||||||
# Determine violation type (simplified)
|
# Determine violation type (simplified)
|
||||||
if quality_class == 0:
|
if quality_class == 0:
|
||||||
violation_type = ''
|
violation_type = ''
|
||||||
else:
|
else:
|
||||||
# In real implementation, this would come from a more detailed model
|
# In real implementation, this would come from a more detailed model
|
||||||
violation_type = 'quality_violation_detected'
|
violation_type = 'quality_violation_detected'
|
||||||
|
|
||||||
processing_time = (datetime.now() - start_time).total_seconds()
|
processing_time = (datetime.now() - start_time).total_seconds()
|
||||||
|
|
||||||
results.append({
|
results.append({
|
||||||
'path_to_study': str(dcm_path.parent),
|
'path_to_study': str(dcm_path.parent),
|
||||||
'study_uid': study_uid,
|
'study_uid': study_uid,
|
||||||
|
|
@ -297,7 +367,7 @@ def process_dicom_files(args):
|
||||||
'time_of_processing': processing_time,
|
'time_of_processing': processing_time,
|
||||||
'confidence': round(prob, 4)
|
'confidence': round(prob, 4)
|
||||||
})
|
})
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
processing_time = (datetime.now() - start_time).total_seconds()
|
processing_time = (datetime.now() - start_time).total_seconds()
|
||||||
results.append({
|
results.append({
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,20 @@
|
||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
Training script for DXA Quality Classifier
|
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 os
|
||||||
import sys
|
import sys
|
||||||
|
|
@ -31,24 +45,39 @@ def get_device():
|
||||||
|
|
||||||
|
|
||||||
def compute_metrics(preds, labels):
|
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)
|
preds = np.array(preds)
|
||||||
labels = np.array(labels)
|
labels = np.array(labels)
|
||||||
|
|
||||||
# Accuracy
|
# Accuracy
|
||||||
accuracy = (preds == labels).mean()
|
accuracy = (preds == labels).mean()
|
||||||
|
|
||||||
# True/False positives/negatives
|
# True/False positives/negatives
|
||||||
tp = ((preds == 1) & (labels == 1)).sum()
|
tp = ((preds == 1) & (labels == 1)).sum()
|
||||||
tn = ((preds == 0) & (labels == 0)).sum()
|
tn = ((preds == 0) & (labels == 0)).sum()
|
||||||
fp = ((preds == 1) & (labels == 0)).sum()
|
fp = ((preds == 1) & (labels == 0)).sum()
|
||||||
fn = ((preds == 0) & (labels == 1)).sum()
|
fn = ((preds == 0) & (labels == 1)).sum()
|
||||||
|
|
||||||
# Precision, Recall, F1
|
# Precision, Recall, F1
|
||||||
precision = tp / (tp + fp) if (tp + fp) > 0 else 0
|
precision = tp / (tp + fp) if (tp + fp) > 0 else 0
|
||||||
recall = tp / (tp + fn) if (tp + fn) > 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
|
f1 = 2 * precision * recall / (precision + recall) if (precision + recall) > 0 else 0
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'accuracy': accuracy,
|
'accuracy': accuracy,
|
||||||
'precision': precision,
|
'precision': precision,
|
||||||
|
|
@ -62,7 +91,24 @@ def compute_metrics(preds, labels):
|
||||||
|
|
||||||
|
|
||||||
def train(args):
|
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
|
# Setup
|
||||||
device = get_device()
|
device = get_device()
|
||||||
|
|
|
||||||
106
src/main.py
106
src/main.py
|
|
@ -1,5 +1,24 @@
|
||||||
"""
|
"""
|
||||||
Main FastAPI application for DXA Quality Assessment
|
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 import FastAPI, File, UploadFile, APIRouter, Query
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
@ -43,13 +62,21 @@ device = None
|
||||||
|
|
||||||
|
|
||||||
def load_model():
|
def load_model():
|
||||||
"""Load DXA model"""
|
"""
|
||||||
global dxa_model, device
|
Загрузка модели классификатора качества DXA.
|
||||||
|
|
||||||
|
Модель загружается глобально при первом запросе и сохраняется в памяти.
|
||||||
|
Это позволяет избежать повторной загрузки при каждом запросе.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
DXAQualityModel: Обученная модель или None при ошибке
|
||||||
|
"""
|
||||||
|
global dxa_model, device
|
||||||
|
|
||||||
if dxa_model is None:
|
if dxa_model is None:
|
||||||
device = get_device()
|
device = get_device()
|
||||||
print(f"Loading DXA model on {device}...")
|
print(f"Loading DXA model on {device}...")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
dxa_model = create_model(
|
dxa_model = create_model(
|
||||||
backbone='resnet18',
|
backbone='resnet18',
|
||||||
|
|
@ -62,37 +89,55 @@ def load_model():
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error loading model: {e}")
|
print(f"Error loading model: {e}")
|
||||||
dxa_model = None
|
dxa_model = None
|
||||||
|
|
||||||
return dxa_model
|
return dxa_model
|
||||||
|
|
||||||
|
|
||||||
def preprocess_dicom(dcm_bytes: bytes, input_size: int = 224):
|
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:
|
with tempfile.NamedTemporaryFile(suffix='.dcm', delete=False) as f:
|
||||||
f.write(dcm_bytes)
|
f.write(dcm_bytes)
|
||||||
dcm_path = f.name
|
dcm_path = f.name
|
||||||
|
|
||||||
ds = pydicom.dcmread(dcm_path)
|
ds = pydicom.dcmread(dcm_path)
|
||||||
img = ds.pixel_array.astype(np.float32)
|
img = ds.pixel_array.astype(np.float32)
|
||||||
|
|
||||||
# Normalize
|
# Normalize
|
||||||
img = (img - img.min()) / (img.max() - img.min() + 1e-8)
|
img = (img - img.min()) / (img.max() - img.min() + 1e-8)
|
||||||
|
|
||||||
# 3-channel
|
# 3-channel
|
||||||
img = np.stack([img] * 3, axis=0)
|
img = np.stack([img] * 3, axis=0)
|
||||||
|
|
||||||
# Resize
|
# Resize
|
||||||
img = (img * 255).astype(np.uint8)
|
img = (img * 255).astype(np.uint8)
|
||||||
img_pil = Image.fromarray(img.transpose(1, 2, 0))
|
img_pil = Image.fromarray(img.transpose(1, 2, 0))
|
||||||
img_pil = img_pil.resize((input_size, input_size), Image.BILINEAR)
|
img_pil = img_pil.resize((input_size, input_size), Image.BILINEAR)
|
||||||
img = np.array(img_pil).transpose(2, 0, 1)
|
img = np.array(img_pil).transpose(2, 0, 1)
|
||||||
img = img.astype(np.float32) / 255.0
|
img = img.astype(np.float32) / 255.0
|
||||||
|
|
||||||
# Tensor
|
# Tensor
|
||||||
img = torch.from_numpy(img).float().unsqueeze(0)
|
img = torch.from_numpy(img).float().unsqueeze(0)
|
||||||
|
|
||||||
return img, ds
|
return img, ds
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -192,14 +237,18 @@ async def analyze_dicom_detailed(
|
||||||
include_visualization: bool = Query(False, description="Include base64 mask visualization")
|
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:
|
Returns:
|
||||||
- violation_type: Specific type of violation (correct, position_error, artifact_motion, etc.)
|
JSON с детальным отчетом о качестве исследования
|
||||||
- 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
|
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Load model
|
# 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:
|
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)
|
# Map violation types to DICOM codes (simplified)
|
||||||
violation_code_map = {
|
violation_code_map = {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,28 @@
|
||||||
"""
|
"""
|
||||||
Detailed Quality Assessment Module for DXA images
|
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
|
import numpy as np
|
||||||
from scipy import ndimage
|
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]:
|
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
|
1. Размытия (blur) - снижение четкости границ
|
||||||
- Blur using FFT high-frequency energy
|
2. Дублирования краев (edge duplication) - "призрачные" контуры
|
||||||
- Edge duplication (ghost edges)
|
|
||||||
|
Методы детекции:
|
||||||
|
- 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:
|
if len(image.shape) == 3:
|
||||||
gray = np.mean(image, axis=2)
|
gray = np.mean(image, axis=2)
|
||||||
else:
|
else:
|
||||||
gray = image
|
gray = image
|
||||||
|
|
||||||
# Normalize
|
# Normalize
|
||||||
gray = (gray - gray.min()) / (gray.max() - gray.min() + 1e-8)
|
gray = (gray - gray.min()) / (gray.max() - gray.min() + 1e-8)
|
||||||
|
|
||||||
# Calculate blur metrics
|
# Calculate blur metrics
|
||||||
laplacian_var = calculate_blur_laplacian(gray)
|
laplacian_var = calculate_blur_laplacian(gray)
|
||||||
fft_blur = calculate_blur_fft(gray)
|
fft_blur = calculate_blur_fft(gray)
|
||||||
|
|
||||||
# Thresholds (tuned for medical images)
|
# Thresholds (tuned for medical images)
|
||||||
# Lower laplacian variance = more blur
|
# Lower laplacian variance = more blur
|
||||||
is_blurred_laplacian = laplacian_var < 0.002
|
is_blurred_laplacian = laplacian_var < 0.002
|
||||||
is_blurred_fft = fft_blur < 0.3
|
is_blurred_fft = fft_blur < 0.3
|
||||||
|
|
||||||
# Check for edge duplication (ghost edges)
|
# Check for edge duplication (ghost edges)
|
||||||
edge_duplication = False
|
edge_duplication = False
|
||||||
if segmentation is not None and segmentation.sum() > 0:
|
if segmentation is not None and segmentation.sum() > 0:
|
||||||
# Find edges in segmentation
|
# Find edges in segmentation
|
||||||
edges = ndimage.sobel(segmentation.astype(float))
|
edges = ndimage.sobel(segmentation.astype(float))
|
||||||
edge_positions = np.where(np.abs(edges) > 0)
|
edge_positions = np.where(np.abs(edges) > 0)
|
||||||
|
|
||||||
if len(edge_positions[0]) > 10:
|
if len(edge_positions[0]) > 10:
|
||||||
# Check if there are duplicate edges (offset)
|
# Check if there are duplicate edges (offset)
|
||||||
y_coords = edge_positions[0]
|
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
|
# If there are many small gaps, might be edge duplication
|
||||||
small_gaps = np.sum(y_diff < 3)
|
small_gaps = np.sum(y_diff < 3)
|
||||||
edge_duplication = small_gaps > len(y_coords) * 0.1
|
edge_duplication = small_gaps > len(y_coords) * 0.1
|
||||||
|
|
||||||
motion_detected = is_blurred_laplacian or is_blurred_fft
|
motion_detected = is_blurred_laplacian or is_blurred_fft
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"motion_detected": motion_detected,
|
"motion_detected": motion_detected,
|
||||||
"blur_laplacian": laplacian_var,
|
"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]:
|
def detect_artifacts(image: np.ndarray, segmentation: Optional[np.ndarray] = None) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Detect various artifacts in the image:
|
Детекция артефактов на DXA изображении.
|
||||||
- Metal objects (high intensity regions)
|
|
||||||
- Implants, screws, plates
|
Типы артефактов:
|
||||||
- Cement
|
- Metal (металл): очень яркие области (>99.5 перцентиль), небольшого размера
|
||||||
- Calcifications
|
- 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:
|
if len(image.shape) == 3:
|
||||||
gray = np.mean(image, axis=2)
|
gray = np.mean(image, axis=2)
|
||||||
else:
|
else:
|
||||||
gray = image
|
gray = image
|
||||||
|
|
||||||
# Normalize
|
# Normalize
|
||||||
gray = (gray - gray.min()) / (gray.max() - gray.min() + 1e-8)
|
gray = (gray - gray.min()) / (gray.max() - gray.min() + 1e-8)
|
||||||
|
|
||||||
artifacts = {
|
artifacts = {
|
||||||
"metal_detected": False,
|
"metal_detected": False,
|
||||||
"implant_detected": False,
|
"implant_detected": False,
|
||||||
|
|
@ -157,29 +210,29 @@ def detect_artifacts(image: np.ndarray, segmentation: Optional[np.ndarray] = Non
|
||||||
"calcification_detected": False,
|
"calcification_detected": False,
|
||||||
"local_defects": []
|
"local_defects": []
|
||||||
}
|
}
|
||||||
|
|
||||||
# Metal detection: very bright spots
|
# Metal detection: very bright spots
|
||||||
metal_threshold = np.percentile(gray, 99.5)
|
metal_threshold = np.percentile(gray, 99.5)
|
||||||
metal_mask = gray > metal_threshold
|
metal_mask = gray > metal_threshold
|
||||||
metal_ratio = metal_mask.sum() / gray.size
|
metal_ratio = metal_mask.sum() / gray.size
|
||||||
|
|
||||||
# If very small bright spots = possible metal
|
# If very small bright spots = possible metal
|
||||||
if metal_ratio > 0.001 and metal_ratio < 0.05:
|
if metal_ratio > 0.001 and metal_ratio < 0.05:
|
||||||
artifacts["metal_detected"] = True
|
artifacts["metal_detected"] = True
|
||||||
|
|
||||||
# Implant detection: check for regular geometric shapes
|
# Implant detection: check for regular geometric shapes
|
||||||
if segmentation is not None:
|
if segmentation is not None:
|
||||||
# Look for high-intensity linear structures
|
# Look for high-intensity linear structures
|
||||||
lines = ndimage.generate_binary_structure(2, 2)
|
lines = ndimage.generate_binary_structure(2, 2)
|
||||||
bright_mask = gray > np.percentile(gray, 95)
|
bright_mask = gray > np.percentile(gray, 95)
|
||||||
|
|
||||||
# Check for linear structures (potential plates/screws)
|
# Check for linear structures (potential plates/screws)
|
||||||
opened = ndimage.binary_opening(bright_mask, structure=lines)
|
opened = ndimage.binary_opening(bright_mask, structure=lines)
|
||||||
linear_structures = opened & ~ndimage.binary_erosion(bright_mask)
|
linear_structures = opened & ~ndimage.binary_erosion(bright_mask)
|
||||||
|
|
||||||
if linear_structures.sum() > 50:
|
if linear_structures.sum() > 50:
|
||||||
artifacts["implant_detected"] = True
|
artifacts["implant_detected"] = True
|
||||||
|
|
||||||
# Cement detection: localized bright patches
|
# Cement detection: localized bright patches
|
||||||
if segmentation is not None:
|
if segmentation is not None:
|
||||||
roi = gray * segmentation
|
roi = gray * segmentation
|
||||||
|
|
@ -188,7 +241,7 @@ def detect_artifacts(image: np.ndarray, segmentation: Optional[np.ndarray] = Non
|
||||||
cement_regions = roi_normalized > 0.9
|
cement_regions = roi_normalized > 0.9
|
||||||
if cement_regions.sum() > 100 and cement_regions.sum() < 5000:
|
if cement_regions.sum() > 100 and cement_regions.sum() < 5000:
|
||||||
artifacts["cement_detected"] = True
|
artifacts["cement_detected"] = True
|
||||||
|
|
||||||
# Calcifications: small bright spots
|
# Calcifications: small bright spots
|
||||||
if segmentation is not None:
|
if segmentation is not None:
|
||||||
outside_roi = (1 - segmentation) > 0
|
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))
|
calcifications = (gray > bg_threshold) & (gray < np.percentile(gray, 99))
|
||||||
if 10 < calcifications.sum() < 500:
|
if 10 < calcifications.sum() < 500:
|
||||||
artifacts["calcification_detected"] = True
|
artifacts["calcification_detected"] = True
|
||||||
|
|
||||||
# Determine if any artifact detected
|
# Determine if any artifact detected
|
||||||
artifacts["any_detected"] = any([
|
artifacts["any_detected"] = any([
|
||||||
artifacts["metal_detected"],
|
artifacts["metal_detected"],
|
||||||
|
|
@ -206,18 +259,34 @@ def detect_artifacts(image: np.ndarray, segmentation: Optional[np.ndarray] = Non
|
||||||
artifacts["cement_detected"],
|
artifacts["cement_detected"],
|
||||||
artifacts["calcification_detected"]
|
artifacts["calcification_detected"]
|
||||||
])
|
])
|
||||||
|
|
||||||
return artifacts
|
return artifacts
|
||||||
|
|
||||||
|
|
||||||
def check_spine_completeness(segmentation: np.ndarray) -> Dict[str, Any]:
|
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)
|
- 3-4 позвонка (L1-L4)
|
||||||
- Vertebrae are not cropped
|
- Межпозвоночные промежутки
|
||||||
- Intervertebral spaces visible
|
- Позвонки не должны быть обрезаны краями изображения
|
||||||
|
|
||||||
|
Алгоритм:
|
||||||
|
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:
|
if segmentation is None or segmentation.sum() == 0:
|
||||||
return {
|
return {
|
||||||
|
|
@ -226,38 +295,38 @@ def check_spine_completeness(segmentation: np.ndarray) -> Dict[str, Any]:
|
||||||
"num_vertebrae": 0,
|
"num_vertebrae": 0,
|
||||||
"issues": ["Сегментация пустая"]
|
"issues": ["Сегментация пустая"]
|
||||||
}
|
}
|
||||||
|
|
||||||
# Label connected components
|
# Label connected components
|
||||||
labeled, num_features = ndimage.label(segmentation)
|
labeled, num_features = ndimage.label(segmentation)
|
||||||
num_features = int(num_features)
|
num_features = int(num_features)
|
||||||
|
|
||||||
issues = []
|
issues = []
|
||||||
|
|
||||||
# Check number of vertebrae
|
# Check number of vertebrae
|
||||||
if num_features < 3:
|
if num_features < 3:
|
||||||
issues.append(f"Видимо слишком мало позвонков: {num_features} (ожидается 3-4)")
|
issues.append(f"Видимо слишком мало позвонков: {num_features} (ожидается 3-4)")
|
||||||
elif num_features > 6:
|
elif num_features > 6:
|
||||||
issues.append(f"Видимо слишком много объектов: {num_features}")
|
issues.append(f"Видимо слишком много объектов: {num_features}")
|
||||||
|
|
||||||
# Check each vertebra
|
# Check each vertebra
|
||||||
vertebra_info = []
|
vertebra_info = []
|
||||||
for i in range(1, num_features + 1):
|
for i in range(1, num_features + 1):
|
||||||
vertebra_mask = labeled == i
|
vertebra_mask = labeled == i
|
||||||
y_coords, x_coords = np.where(vertebra_mask)
|
y_coords, x_coords = np.where(vertebra_mask)
|
||||||
|
|
||||||
if len(y_coords) > 0:
|
if len(y_coords) > 0:
|
||||||
# Get bounding box
|
# Get bounding box
|
||||||
y_min, y_max = y_coords.min(), y_coords.max()
|
y_min, y_max = y_coords.min(), y_coords.max()
|
||||||
x_min, x_max = x_coords.min(), x_coords.max()
|
x_min, x_max = x_coords.min(), x_coords.max()
|
||||||
|
|
||||||
height = y_max - y_min
|
height = y_max - y_min
|
||||||
width = x_max - x_min
|
width = x_max - x_min
|
||||||
|
|
||||||
# Check if cropped (touching image border)
|
# Check if cropped (touching image border)
|
||||||
h, w = segmentation.shape
|
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)
|
x_min == 0 or x_max == w - 1)
|
||||||
|
|
||||||
vertebra_info.append({
|
vertebra_info.append({
|
||||||
"id": i,
|
"id": i,
|
||||||
"height": int(height),
|
"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)),
|
"center": (float(np.mean(y_coords) / h), float(np.mean(x_coords) / w)),
|
||||||
"is_cropped": is_cropped
|
"is_cropped": is_cropped
|
||||||
})
|
})
|
||||||
|
|
||||||
if is_cropped:
|
if is_cropped:
|
||||||
issues.append(f"Позвонок {i} обрезан (касается края изображения)")
|
issues.append(f"Позвонок {i} обрезан (касается края изображения)")
|
||||||
|
|
||||||
# Check alignment
|
# Check alignment
|
||||||
if len(vertebra_info) >= 2:
|
if len(vertebra_info) >= 2:
|
||||||
x_centers = [v["center"][1] for v in vertebra_info]
|
x_centers = [v["center"][1] for v in vertebra_info]
|
||||||
x_std = np.std(x_centers)
|
x_std = np.std(x_centers)
|
||||||
|
|
||||||
if x_std > 0.05: # More than 5% of width
|
if x_std > 0.05: # More than 5% of width
|
||||||
issues.append(f"Позвонки не выровнены (отклонение {x_std:.3f})")
|
issues.append(f"Позвонки не выровнены (отклонение {x_std:.3f})")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"valid": len(issues) == 0,
|
"valid": len(issues) == 0,
|
||||||
"num_vertebrae": num_features,
|
"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]:
|
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:
|
if segmentation is None or segmentation.sum() == 0:
|
||||||
return {
|
return {
|
||||||
|
|
@ -314,28 +400,28 @@ def check_hip_completeness(segmentation: np.ndarray) -> Dict[str, Any]:
|
||||||
"reason": "empty_segmentation",
|
"reason": "empty_segmentation",
|
||||||
"issues": ["Сегментация пустая"]
|
"issues": ["Сегментация пустая"]
|
||||||
}
|
}
|
||||||
|
|
||||||
# Get bounding box
|
# Get bounding box
|
||||||
y_coords, x_coords = np.where(segmentation > 0)
|
y_coords, x_coords = np.where(segmentation > 0)
|
||||||
h, w = segmentation.shape
|
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)
|
x_coords.min() == 0 or x_coords.max() == w - 1)
|
||||||
|
|
||||||
issues = []
|
issues = []
|
||||||
|
|
||||||
# Check if cropped
|
# Check if cropped
|
||||||
if is_cropped:
|
if is_cropped:
|
||||||
issues.append("Бедро обрезано (касается края изображения)")
|
issues.append("Бедро обрезано (касается края изображения)")
|
||||||
|
|
||||||
# Check aspect ratio (elongated = good for hip)
|
# Check aspect ratio (elongated = good for hip)
|
||||||
height = y_coords.max() - y_coords.min()
|
height = y_coords.max() - y_coords.min()
|
||||||
width = x_coords.max() - x_coords.min()
|
width = x_coords.max() - x_coords.min()
|
||||||
aspect_ratio = height / (width + 1e-8)
|
aspect_ratio = height / (width + 1e-8)
|
||||||
|
|
||||||
if aspect_ratio < 1.5:
|
if aspect_ratio < 1.5:
|
||||||
issues.append(f"Слишком короткое изображение бедра (соотношение сторон: {aspect_ratio:.2f})")
|
issues.append(f"Слишком короткое изображение бедра (соотношение сторон: {aspect_ratio:.2f})")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"valid": len(issues) == 0,
|
"valid": len(issues) == 0,
|
||||||
"is_cropped": is_cropped,
|
"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]:
|
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)
|
- Легкая внутренняя ротация (15-20 градусов)
|
||||||
- Lesser trochanter should be slightly visible medially
|
- Малый вертел слегка виден медиально
|
||||||
- Greater trochanter should not be prominent
|
- Большой вертел не должен быть выражен
|
||||||
|
|
||||||
|
Алгоритм:
|
||||||
|
1. Находит главную ость сегментированного бедра
|
||||||
|
2. Вычисляет угол отклонения от вертикали
|
||||||
|
3. Проверяет, что угол в пределах допустимого диапазона (±30°)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
segmentation: Бинарная маска сегментации бедра
|
||||||
|
image: Исходное изображение
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict с ключами:
|
||||||
|
- valid: bool - ротация корректна
|
||||||
|
- rotation_angle: float - угол ротации в градусах
|
||||||
|
- issues: list - список проблем
|
||||||
"""
|
"""
|
||||||
if segmentation is None or segmentation.sum() == 0:
|
if segmentation is None or segmentation.sum() == 0:
|
||||||
return {
|
return {
|
||||||
|
|
@ -363,19 +464,19 @@ def check_hip_rotation(segmentation: np.ndarray, image: np.ndarray) -> Dict[str,
|
||||||
"rotation_angle": 0,
|
"rotation_angle": 0,
|
||||||
"issues": ["Нет сегментации"]
|
"issues": ["Нет сегментации"]
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(image.shape) == 3:
|
if len(image.shape) == 3:
|
||||||
gray = np.mean(image, axis=2)
|
gray = np.mean(image, axis=2)
|
||||||
else:
|
else:
|
||||||
gray = image
|
gray = image
|
||||||
|
|
||||||
# Get the femur shape
|
# Get the femur shape
|
||||||
labeled, num_features = ndimage.label(segmentation)
|
labeled, num_features = ndimage.label(segmentation)
|
||||||
|
|
||||||
issues = []
|
issues = []
|
||||||
rotation_angle = 0
|
rotation_angle = 0
|
||||||
valid = True
|
valid = True
|
||||||
|
|
||||||
if num_features == 0:
|
if num_features == 0:
|
||||||
return {
|
return {
|
||||||
"valid": False,
|
"valid": False,
|
||||||
|
|
@ -383,7 +484,7 @@ def check_hip_rotation(segmentation: np.ndarray, image: np.ndarray) -> Dict[str,
|
||||||
"rotation_angle": 0,
|
"rotation_angle": 0,
|
||||||
"issues": ["Сегментация пустая"]
|
"issues": ["Сегментация пустая"]
|
||||||
}
|
}
|
||||||
|
|
||||||
# For a single femur, check orientation
|
# For a single femur, check orientation
|
||||||
if num_features == 1:
|
if num_features == 1:
|
||||||
# Get major axis
|
# 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
|
# Compute covariance to get principal axis
|
||||||
coords_float = coords.astype(float)
|
coords_float = coords.astype(float)
|
||||||
cov = np.cov(coords_float[:, 0], coords_float[:, 1])
|
cov = np.cov(coords_float[:, 0], coords_float[:, 1])
|
||||||
|
|
||||||
# Eigenvalue decomposition
|
# Eigenvalue decomposition
|
||||||
eigenvalues, eigenvectors = np.linalg.eig(cov)
|
eigenvalues, eigenvectors = np.linalg.eig(cov)
|
||||||
major_axis = eigenvectors[:, np.argmax(eigenvalues)]
|
major_axis = eigenvectors[:, np.argmax(eigenvalues)]
|
||||||
|
|
||||||
# Calculate angle from vertical
|
# Calculate angle from vertical
|
||||||
angle = np.arctan2(major_axis[1], major_axis[0])
|
angle = np.arctan2(major_axis[1], major_axis[0])
|
||||||
rotation_angle = np.degrees(angle)
|
rotation_angle = np.degrees(angle)
|
||||||
|
|
||||||
# Check if within acceptable range (-30 to +30 degrees from vertical)
|
# Check if within acceptable range (-30 to +30 degrees from vertical)
|
||||||
if abs(rotation_angle) > 30:
|
if abs(rotation_angle) > 30:
|
||||||
issues.append(f"Выраженная ротация: {rotation_angle:.1f}°")
|
issues.append(f"Выраженная ротация: {rotation_angle:.1f}°")
|
||||||
valid = False
|
valid = False
|
||||||
|
|
||||||
# Check lesser trochanter visibility (would be a bump on medial side)
|
# Check lesser trochanter visibility (would be a bump on medial side)
|
||||||
# This is a simplified check
|
# This is a simplified check
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"valid": valid,
|
"valid": valid,
|
||||||
"rotation_angle": float(rotation_angle),
|
"rotation_angle": float(rotation_angle),
|
||||||
|
|
@ -634,17 +735,42 @@ def generate_quality_report(
|
||||||
model_confidence: float = 0.5
|
model_confidence: float = 0.5
|
||||||
) -> Dict[str, Any]:
|
) -> 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
|
# Calculate all quality metrics
|
||||||
quality_metrics = {}
|
quality_metrics = {}
|
||||||
|
|
||||||
# Motion detection
|
# Motion detection
|
||||||
quality_metrics["motion"] = detect_motion_blur(image, segmentation)
|
quality_metrics["motion"] = detect_motion_blur(image, segmentation)
|
||||||
|
|
||||||
# Artifact detection
|
# Artifact detection
|
||||||
quality_metrics["artifacts"] = detect_artifacts(image, segmentation)
|
quality_metrics["artifacts"] = detect_artifacts(image, segmentation)
|
||||||
|
|
||||||
# Region-specific checks
|
# Region-specific checks
|
||||||
if region == "spine":
|
if region == "spine":
|
||||||
quality_metrics["spine_completeness"] = check_spine_completeness(segmentation)
|
quality_metrics["spine_completeness"] = check_spine_completeness(segmentation)
|
||||||
|
|
@ -653,18 +779,18 @@ def generate_quality_report(
|
||||||
elif region in ("hip", "hip_left", "hip_right"):
|
elif region in ("hip", "hip_left", "hip_right"):
|
||||||
quality_metrics["hip_completeness"] = check_hip_completeness(segmentation)
|
quality_metrics["hip_completeness"] = check_hip_completeness(segmentation)
|
||||||
quality_metrics["hip_rotation"] = check_hip_rotation(segmentation, image)
|
quality_metrics["hip_rotation"] = check_hip_rotation(segmentation, image)
|
||||||
|
|
||||||
# ROI check
|
# ROI check
|
||||||
quality_metrics["roi_check"] = check_roi_boundaries(segmentation, image.shape)
|
quality_metrics["roi_check"] = check_roi_boundaries(segmentation, image.shape)
|
||||||
|
|
||||||
# Basic position check (from existing scorer)
|
# Basic position check (from existing scorer)
|
||||||
from src.quality.quality_scorer import QualityScorer
|
from src.quality.quality_scorer import QualityScorer
|
||||||
scorer = QualityScorer()
|
scorer = QualityScorer()
|
||||||
quality_metrics["position"] = scorer.check_position(segmentation)
|
quality_metrics["position"] = scorer.check_position(segmentation)
|
||||||
|
|
||||||
# Determine violation type
|
# Determine violation type
|
||||||
violation_type, reason = determine_violation_type(region, quality_metrics)
|
violation_type, reason = determine_violation_type(region, quality_metrics)
|
||||||
|
|
||||||
# Overall quality determination
|
# Overall quality determination
|
||||||
if violation_type == "correct":
|
if violation_type == "correct":
|
||||||
overall_quality = "GOOD"
|
overall_quality = "GOOD"
|
||||||
|
|
@ -674,7 +800,7 @@ def generate_quality_report(
|
||||||
overall_quality = "POOR"
|
overall_quality = "POOR"
|
||||||
severity = "HIGH"
|
severity = "HIGH"
|
||||||
quality_class = 1
|
quality_class = 1
|
||||||
|
|
||||||
# Determine view quality
|
# Determine view quality
|
||||||
if region == "spine":
|
if region == "spine":
|
||||||
completeness = quality_metrics.get("spine_completeness", {})
|
completeness = quality_metrics.get("spine_completeness", {})
|
||||||
|
|
@ -684,27 +810,27 @@ def generate_quality_report(
|
||||||
view_quality = "full" if completeness.get("valid", False) else "partial"
|
view_quality = "full" if completeness.get("valid", False) else "partial"
|
||||||
else:
|
else:
|
||||||
view_quality = "unknown"
|
view_quality = "unknown"
|
||||||
|
|
||||||
return {
|
return {
|
||||||
# Basic info
|
# Basic info
|
||||||
"anatomical_region": region,
|
"anatomical_region": region,
|
||||||
"quality_class": quality_class,
|
"quality_class": quality_class,
|
||||||
"quality_label": "OK" if quality_class == 0 else "Violation detected",
|
"quality_label": "OK" if quality_class == 0 else "Violation detected",
|
||||||
|
|
||||||
# New detailed fields
|
# New detailed fields
|
||||||
"violation_type": violation_type,
|
"violation_type": violation_type,
|
||||||
"reason": reason,
|
"reason": reason,
|
||||||
|
|
||||||
# Confidence
|
# Confidence
|
||||||
"confidence": model_confidence,
|
"confidence": model_confidence,
|
||||||
"confidence_per_class": {
|
"confidence_per_class": {
|
||||||
"correct": float(model_confidence) if quality_class == 0 else float(1 - model_confidence),
|
"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)
|
"violation": float(1 - model_confidence) if quality_class == 0 else float(model_confidence)
|
||||||
},
|
},
|
||||||
|
|
||||||
# View quality
|
# View quality
|
||||||
"view_quality": view_quality,
|
"view_quality": view_quality,
|
||||||
|
|
||||||
# Detailed metrics
|
# Detailed metrics
|
||||||
"metrics": {
|
"metrics": {
|
||||||
"motion": quality_metrics.get("motion", {}),
|
"motion": quality_metrics.get("motion", {}),
|
||||||
|
|
@ -712,13 +838,13 @@ def generate_quality_report(
|
||||||
"position": quality_metrics.get("position", {}),
|
"position": quality_metrics.get("position", {}),
|
||||||
"roi_check": quality_metrics.get("roi_check", {})
|
"roi_check": quality_metrics.get("roi_check", {})
|
||||||
},
|
},
|
||||||
|
|
||||||
# Region-specific
|
# Region-specific
|
||||||
"spine_completeness": quality_metrics.get("spine_completeness", {}),
|
"spine_completeness": quality_metrics.get("spine_completeness", {}),
|
||||||
"vertebral_contours": quality_metrics.get("vertebral_contours", {}),
|
"vertebral_contours": quality_metrics.get("vertebral_contours", {}),
|
||||||
"hip_completeness": quality_metrics.get("hip_completeness", {}),
|
"hip_completeness": quality_metrics.get("hip_completeness", {}),
|
||||||
"hip_rotation": quality_metrics.get("hip_rotation", {}),
|
"hip_rotation": quality_metrics.get("hip_rotation", {}),
|
||||||
|
|
||||||
# Overall
|
# Overall
|
||||||
"overall_quality": overall_quality,
|
"overall_quality": overall_quality,
|
||||||
"severity": severity
|
"severity": severity
|
||||||
|
|
|
||||||
|
|
@ -1,35 +1,147 @@
|
||||||
from src.segmentators.base import BaseSegmentator
|
from src.segmentators.base import BaseSegmentator
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
import tempfile
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
|
||||||
class TotalSegmentatorWrapper(BaseSegmentator):
|
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:
|
try:
|
||||||
from totalsegmentator import TotalSegmentator
|
import nibabel as nib
|
||||||
self.model = TotalSegmentator()
|
from totalsegmentator.python_api import totalsegmentator
|
||||||
|
|
||||||
|
self.totalsegmentator_func = totalsegmentator
|
||||||
|
self.nibabel = nib
|
||||||
self.loaded = True
|
self.loaded = True
|
||||||
print("✅ TotalSegmentator загружен")
|
print(f"✅ TotalSegmentator API загружен (task={self.task})")
|
||||||
except ImportError:
|
except ImportError as e:
|
||||||
print("⚠️ TotalSegmentator не установлен")
|
|
||||||
self.loaded = False
|
self.loaded = False
|
||||||
|
print(f"⚠️ TotalSegmentator: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
self.loaded = False
|
||||||
|
print(f"⚠️ TotalSegmentator: ошибка инициализации - {e}")
|
||||||
|
|
||||||
def segment(self, image):
|
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:
|
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
|
# Конвертация в numpy если PIL
|
||||||
# Здесь адаптация под входной формат
|
if hasattr(image, 'convert'):
|
||||||
# Реальная реализация зависит от TotalSegmentator API
|
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.zeros(image.shape, dtype=np.int64)
|
||||||
return np.ones((512, 512), dtype=np.int64)
|
|
||||||
|
|
||||||
def get_info(self):
|
def get_info(self):
|
||||||
return {
|
return {
|
||||||
"name": "TotalSegmentator",
|
"name": "TotalSegmentator",
|
||||||
"type": "segmentation",
|
"type": "segmentation",
|
||||||
"target": "bones",
|
"target": "bones",
|
||||||
|
"task": self.task,
|
||||||
|
"device": self.device,
|
||||||
"loaded": self.loaded
|
"loaded": self.loaded
|
||||||
}
|
}
|
||||||
Loading…
Reference in New Issue