develop - hack_2026

This commit is contained in:
denis 2026-09-22 22:21:43 +03:00
parent 7f500759d4
commit c40cde7ec0
12 changed files with 1555 additions and 80 deletions

153
QWEN.md
View File

@ -8,6 +8,7 @@ Medical AI service for automated assessment of DXA (bone densitometry) study qua
- Analyze DICOM densitometry studies
- Determine anatomical region (spine/hip)
- Binary classification: quality (OK/violation)
- Detailed violation type detection (motion, artifacts, position, ROI)
- Output results in XLSX/CSV format per requirements
### Tech Stack
@ -15,7 +16,7 @@ Medical AI service for automated assessment of DXA (bone densitometry) study qua
|-----------|------------|
| Backend | Python 3.10, FastAPI, Uvicorn |
| ML/Deep Learning | PyTorch, torchvision (ResNet18) |
| Image Processing | PIL, OpenCV, pydicom |
| Image Processing | PIL, OpenCV, pydicom, scipy |
| Data Handling | pandas, openpyxl |
| Containerization | Docker |
@ -34,25 +35,65 @@ bone_2026/
│ │ ├── train.py # Training script
│ │ ├── inference.py # Batch inference
│ │ └── __init__.py
│ ├── api/ # REST endpoints
│ ├── api/ # REST endpoints & schemas
│ │ ├── endpoints.py # Original endpoints
│ │ └── static/ # Web UI
│ │ ├── index.html
│ │ └── js/dxa-app.js
│ ├── core/ # Orchestrator
│ ├── quality/ # Quality scoring
│ ├── segmentators/ # Segmentation models
│ └── classifiers/ # Classification models
│ │ ├── quality_scorer.py # Base scorer
│ │ ├── detailed_assessment.py # NEW: Detailed assessment
│ │ ├── position_validator.py
│ │ └── artifact_detector.py
│ └── segmentators/ # Segmentation models
├── models/
│ └── dxa_model.pth # Trained DXA classifier
├── dataset_hack/ # DICOM datasets
│ ├── Для теста/ # Test data (3 files)
│ └── НД_для_обучения/ # Training data (100 studies, 499 DICOMs)
│ └── разметка.xlsx # Annotation file
│ ├── Для теста/ # Test data
│ └── НД_для_обучения/ # Training data
├── requirements.txt
├── Dockerfile
├── run.sh # Main entry script
├── run.sh
└── README.md
```
---
## Implemented Features (Current)
### ✅ API Endpoints
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/` | Web interface |
| GET | `/api/v1/health` | Health check |
| POST | `/api/v1/analyze` | Basic analysis |
| POST | `/api/v1/analyze/detailed` | **NEW: Detailed analysis with metrics** |
| POST | `/api/v1/analyze/sr` | **NEW: DICOM SR report** |
| POST | `/api/v1/batch` | Batch processing |
| POST | `/api/v1/export` | Export to XLSX |
### ✅ Detailed Assessment (`src/quality/detailed_assessment.py`)
- **Motion detection**: Laplacian variance, FFT blur analysis
- **Artifact detection**: Metal, implants, cement, calcifications
- **Spine completeness**: Vertebrae count, alignment, spacing
- **Hip completeness**: Full visibility, aspect ratio
- **Hip rotation**: Major axis angle calculation
- **ROI validation**: Boundary check, margin, size
- **Violation types**: correct, position_error, artifact_motion, artifact_other, labeling_error, incomplete_view, roi_error, rotation
### ✅ Web Interface
- Drag-and-drop DICOM upload
- Table with results (filter, sort, search)
- **NEW: Detail panel** - click on row to see:
- Violation type
- Reason (human-readable)
- Motion metrics
- Artifact detection
- ROI validation
---
## DXA Module (`src/dxa/`)
### Dataset (`dataset.py`)
@ -61,7 +102,6 @@ bone_2026/
- **Automatically detects anatomical region from image content**
- Maps regions: spine, hip_right, hip_left
- Quality labels: 0 (OK), 1 (violation)
- Uses the same algorithm as inference for consistency
### Model (`model.py`)
- Architecture: ResNet18 (pretrained on ImageNet)
@ -88,20 +128,12 @@ python src/dxa/inference.py \
### Training
```bash
# Option 1: Direct Python
python src/dxa/train.py --epochs 10
# Option 2: Via run.sh
bash run.sh train
```
### Inference
```bash
# Single file
python src/dxa/inference.py --input-path file.dcm --output-path result.xlsx
# Directory (batch)
python src/dxa/inference.py --input-path dataset_hack/Для\ теста --output-path results.xlsx
```
### API Server
@ -109,10 +141,13 @@ python src/dxa/inference.py --input-path dataset_hack/Для\ теста --outpu
python -m uvicorn src.main:app --host 0.0.0.0 --port 8000
```
Web UI: http://localhost:8000
---
## Output Format (per Hackathon Requirements)
### Basic Output
| Column | Description |
|--------|-------------|
| path_to_study | Path to study directory |
@ -124,6 +159,30 @@ python -m uvicorn src.main:app --host 0.0.0.0 --port 8000
| processing_status | Success / Failure |
| time_of_processing | Processing time (seconds) |
### Detailed Output (/api/v1/analyze/detailed)
```json
{
"anatomical_region": "spine",
"quality_class": 1,
"quality_label": "Violation detected",
"violation_type": "artifact_motion",
"reason": "Обнаружен артефакт движения (размытие)",
"confidence": 0.85,
"confidence_per_class": {
"correct": 0.15,
"violation": 0.85
},
"view_quality": "full",
"metrics": {
"motion": { "motion_detected": true, "severity": "HIGH" },
"artifacts": { "any_detected": true, "metal_detected": false },
"roi_check": { "valid": true }
},
"overall_quality": "POOR",
"severity": "HIGH"
}
```
---
## Anatomical Region Detection
@ -143,38 +202,28 @@ The system automatically determines the anatomical region from the DICOM image c
- hip_right: L/R ratio > 1.3 (right side brighter)
- hip: unclear (fallback)
### Features Used
- Bright region aspect ratio (primary discriminator)
- Image symmetry (secondary for borderline cases)
- Left/right brightness ratio (for hip side detection)
---
### Fallback
If image analysis fails, uses filename-based detection as fallback.
## Known Issues & Limitations
1. **Model training** - Needs retraining with new violation types
2. **Dataset size** - Currently ~100 studies, needs 500+
3. **Segmentation** - Uses simple threshold, needs proper model
4. **F1 score** - Currently ~0.27, needs improvement with weighted loss
5. **Heatmap visualization** - Not implemented (requires model retraining)
---
## Model Performance
## Docker
```
Training data: 36 samples (80%)
Validation data: 9 samples (20%)
Note: Limited dataset - more data needed for production
```bash
docker build -t dxa-quality .
docker run -v /data:/data -p 8000:8000 dxa-quality
```
---
## Annotation Format
The annotation Excel (`разметка.xlsx`) contains:
- Study UID
- Spine columns: укладка, ось, артефакты
- Hip columns: позиция, ROI (left/right)
- Total columns: итого
---
## Development Conventions
## Development Notes
### Code Style
- Follow existing patterns in src/
@ -185,6 +234,7 @@ The annotation Excel (`разметка.xlsx`) contains:
- **DXADataset**: Handles DICOM loading + annotation parsing
- **DXAQualityClassifier**: ResNet18-based classifier
- **process_dicom_files**: Batch inference with XLSX output
- **generate_quality_report**: Detailed assessment with metrics
### Dependencies
All in `requirements.txt`:
@ -193,25 +243,4 @@ All in `requirements.txt`:
- `pandas`, `openpyxl` - Data/Excel
- `fastapi`, `uvicorn` - Web framework
- `Pillow`, `opencv-python-headless` - Image processing
---
## Docker
```bash
# Build
docker build -t dxa-quality .
# Run
docker run -v /data:/data -p 8000:8000 dxa-quality
```
---
## Notes
- This is a **hackathon project** for DXA quality assessment
- Model trained on limited data (100 studies)
- Binary classification (quality OK / violation)
- **Anatomical region detection via image analysis** (bright region shape + asymmetry)
- Output format matches hackathon requirements (XLSX/CSV)
- `scipy` - Image analysis (blur, artifacts)

View File

@ -88,11 +88,15 @@ docker-compose up -d
| Метод | Эндпоинт | Описание |
|--------|-------------------|--------------------------|
| GET | / | Главная страница
| GET | /docs | Swagger UI документация
| GET | /redoc | ReDoc документация
| GET | /api/v1/health | Проверка статуса сервиса
| POST | /api/v1/analyze | Анализ изображения
| 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
Пример запроса
@ -132,6 +136,46 @@ curl -X POST "http://localhost:8000/api/v1/analyze" \
}
```
### Детальный анализ (/api/v1/analyze/detailed)
Возвращает расширенную информацию с детализацией нарушений:
```json
{
"anatomical_region": "spine",
"quality_class": 1,
"quality_label": "Violation detected",
"violation_type": "artifact_motion",
"reason": "Обнаружен артефакт движения (размытие)",
"confidence": 0.85,
"confidence_per_class": {
"correct": 0.15,
"violation": 0.85
},
"view_quality": "full",
"metrics": {
"motion": {
"motion_detected": true,
"blur_laplacian": 0.001,
"severity": "HIGH"
},
"artifacts": {
"any_detected": false,
"metal_detected": false
},
"roi_check": {
"valid": true
}
},
"spine_completeness": {
"valid": true,
"num_vertebrae": 4
},
"overall_quality": "POOR",
"severity": "HIGH"
}
```
📊 Интерфейс
Веб-интерфейс доступен по адресу http://localhost:8000/:
@ -141,6 +185,7 @@ curl -X POST "http://localhost:8000/api/v1/analyze" \
- 🎯 Визуализация маски сегментации
- 📈 Детальные метрики качества
- 🏷️ Подробный отчет о нарушениях
- 👆 Клик по строке для просмотра деталей
🛠️ Технологии

209
condition.txt Normal file
View File

@ -0,0 +1,209 @@
Digital Imaging and Communications in Medicine
— стандарт хранения и передачи медицинских
изображений
DXA / ДРА Двухэнергетическая рентгеновская
абсорбциометрия (денситометрия)
ИИ Искусственный интеллект
ROI Region of Interest — область интереса,
используемая для измерений
MVP Минимально жизнеспособный продукт
AUC Площадь под ROC-кривой
1. Контекст и актуальность задачи
Денситометрия является основным инструментальным методом количественной оценки
минеральной плотности костной ткани и широко применяется для диагностики остеопороза,
оценки риска переломов и динамического наблюдения пациентов. Достоверность результата
зависит не только от работы оборудования, но и от правильности укладки пациента, выбора
исследуемой области, отсутствия значимых артефактов и корректности разметки анатомических
структур.
Ошибки позиционирования и разметки могут приводить к искажению измеряемых
показателей, ошибочной интерпретации динамики и необходимости повторного анализа или
повторного исследования. При этом контроль качества обычно выполняется специалистом
вручную и требует знания критериев корректности для разных анатомических областей.
Применение алгоритмов компьютерного зрения позволяет объективизировать
и стандартизировать контроль качества, автоматически выявлять типовые нарушения
и своевременно сообщать специалисту о необходимости коррекции разметки или повторной
обработки исследования. Такое решение потенциально снижает нагрузку на врача-
рентгенолога и рентгенолаборанта, повышает воспроизводимость измерений и формирует
единый подход к контролю качества денситометрии в медицинских организациях.
Участникам предлагается создать интеллектуальный сервис, который не просто распознаёт
изображение, а выступает цифровым помощником по контролю качества: проверяет, можно ли
использовать исследование для клинической интерпретации, оценивает корректность
нанесённых областей измерения и объясняет, какое именно нарушение обнаружено.
2. Описание задачи
Разработать программное обеспечение на основе искусственного интеллекта для
автоматизированной оценки качества рентгеновских денситометрических исследований и
корректности разметки анатомических структур.
На вход сервис получает денситометрическое исследование в формате DICOM. Решение
должно определить, соответствует ли изображение установленным критериям качества,
корректно ли выполнена разметка, а при наличии нарушения — определить его тип. Основная
задача — обеспечить автоматизированный контроль качества без необходимости первичного
ручного просмотра каждого исследования специалистом.
2.1. Объекты анализа
В базовый контур задачи рекомендуется включить денситометрические исследования
следующих анатомических областей:
• поясничный отдел позвоночника;
• проксимальный отдел бедренной кости.
Допускается разработка единой модели для всех областей либо отдельных
специализированных моделей с автоматическим определением области исследования.
2.2. Основной функционал
• определение анатомической области и проекции исследования;
• классификация исследования как качественного или содержащего нарушение качества;
• оценка корректности укладки и позиционирования пациента по стандартным критериям;
• оценка корректности разметки анатомических структур и областей измерения;
• определение одного или нескольких типов нарушения качества;
• формирование структурированного отчёта с результатами работы.
2.3. Перечень оцениваемых нарушений
От решения требуется рассматривать следующие категории:
1. Поясничный отдел позвоночника
• корректная укладка (на нижнем уровне сканирования визуализированы верхние края
подвздошных костей, верхний уровень — половина тела позвонка Th12)
2.4. Входные данные
• Исследования в формате DICOM без разметки.
• Одно исследование может включать одно или несколько изображений/серий
(не более 3).
• Метаданные DICOM могут использоваться моделью, однако решение не должно зависеть
от наличия персональных данных.
• Организатор предоставляет обезличенные изображения и часть исследований
с экспертной разметкой.
3 см
и более
2 см
и более
3 см
2.5. Требования к выходным данным
Решение должно формировать итоговый файл в формате .xlsx или .csv. Одна строка
соответствует одному изображению (одна анатомическая область) в исследовании.
Рекомендуемая структура:
Название колонки Описание Формат
path_to_study Путь к исследованию или файлу String
study_uid Идентификатор исследования из
DICOM-тегов String
image_uid Идентификатор изображения из
DICOM-тегов String
anatomical_region Определённая анатомическая область String
quality_class 0 — качественное, 1 — есть нарушение Integer
violation_type Тип или перечень нарушений String
processing_status Success / Failure String
time_of_processing Время обработки в секундах Float
2.6. Дополнительный функционал
• Дополнительная серия с визуализацией обнаруженного нарушения на изображении:
маска, контур, ключевые точки или тепловая карта.
• Текстовое описание выявленных нарушений в формате DICOM SR.
• Автоматическая коррекция разметки с возможностью подтверждения специалистом.
• Интерактивный веб-интерфейс для загрузки, просмотра и пакетной обработки
исследований.
2.7. Технические требования
• Время обработки одного исследования: не более 3 минут на целевой конфигурации.
• Отсутствие необработанных исключений; все ошибки фиксируются в отчёте.
• Воспроизводимость результатов при повторном запуске на одинаковых данных.
• Возможность пакетной обработки исследований из архива с предоставлением общей
таблицы (xlsx/csv) в качестве результата обработки, а также zip-архива с дополнительными
сериями (при наличии функционала)
3. Программно-аппаратные требования
3.1. Аппаратные требования
Специальные аппаратные ограничения не предъявляются при условии выполнения
функциональных и нефункциональных требований. Участник должен указать минимальную
и рекомендуемую конфигурацию для инференса: объём оперативной памяти, требования
к процессору и графическому ускорителю, объём дискового пространства.
Конфигурация оборудования, на котором будет проводиться финальное тестирование:
2 × H200 141 GB, каждая H200 в своей виртуальной машине c флейвором GPU-44-256-H200-1
(44 CPU, 256 RAM, 141 VRAM).
3.2. Программные требования
• Выбор языка программирования, библиотек машинного обучения и архитектуры решения
остаётся на усмотрение участников.
• Обязательна контейнеризация решения.
• Участники должны предоставить скрипт для сборки и запуска контейнеризированного
приложения в Linux и UNIX-подобных системах.
• Решение должно запускаться локально без обращения к внешним закрытым сервисам
и без передачи медицинских изображений во внешние системы.
• Должен быть реализован API для пакетной обработки тестового набора данных.
• Все используемые зависимости, включая версии библиотек, сред выполнения и базового
контейнера, должны быть зафиксированы.
4. Требования к презентации/демонстрации
Презентация должна показать не только итоговые метрики, но и логику клинического
контроля качества. Команде необходимо продемонстрировать:
• описание и обоснование выбранного подхода;
• архитектуру модели и программного решения;
• состав наборов данных, правила разбиения выборки и методы предобработки;
• таксономию нарушений качества и подход к исследованиям с несколькими нарушениями;
• результаты экспериментов, сравнение вариантов модели и анализ ошибок;
• метрики качества с доверительными интервалами;
• скорость обработки и системные требования;
• ограничения решения и сценарий дальнейшего внедрения;
• кейсы, демонстрирующие работу решения.
• описание структуры проекта;
• системные требования и перечень зависимостей;
• инструкцию по сборке и запуску контейнеризированного приложения;
• описание реализованного API;
• описание формата входных и выходных данных;
• описание модели, предобработки и постобработки;
• перечень известных ошибок и способы их обработки.
Дополнительно предоставляются руководство пользователя, руководство
по развёртыванию и краткое описание процедуры обучения или дообучения модели.
6. Ресурсы
• Обезличенные медицинские изображения денситометрии в формате DICOM
с экспертной оценкой качества (НД_для_обучения.zip).
• Описание критериев качества и примеры корректной разметки — можно детальнее
изучить в методических рекомендациях в разделах 2.6, 2.7.
• Фрагмент данных для проверки формата и отладки загрузчика (Для теста.zip).
• Закрытый тестовый набор для финальной оценки, не передаваемый участникам.
7. Требования к сдаче решений
7.1. Требования для промежуточной сдачи решения
Промежуточная сдача предусматривает предоставление MVP, включающего:
• обученную модель и скрипт инференса;
• модуль чтения DICOM и пакетной обработки;
• определение как минимум бинарного класса «качественное / есть нарушение»;
• оценку корректности разметки для как минимум одной анатомической области;
• файл с результатами в согласованном формате;
• расчёт основных метрик на валидационной выборке;
• краткая инструкция по работе с моделью;
• описание текущих ограничений и план доработки
• поддержку всех обязательных анатомических областей и классов нарушений,
утверждённых организатором;
• скрипт для сборки и запуска контейнеризированного приложения; инструкция
по запуску скрипта в Linux и Unix-подобных системах;
• устойчивую пакетную обработку закрытого тестового набора;
• структурированный файл результатов;
• полный комплект документации;
• презентацию и демонстрационный сценарий;
• исходный код либо иной комплект материалов в объёме, установленном правилами
конкурса.
Финальная проверка проводится экспертами на закрытых данных без участия команды.
Расчёт итоговых метрик выполняется на стороне организатора.
8. Критерии оценки
8.1. Подход коллектива к решению задачи
Оцениваются понимание клинической задачи, обоснованность таксономии нарушений,
подход к формированию обучающей выборки, работа с дисбалансом классов и предотвращение
утечки данных между обучающей и тестовой выборками.
8.2. Техническая проработка решения
Оцениваются архитектура модели, качество предобработки DICOM, корректность работы с
изображениями, устойчивость программной реализации, обработка ошибок, контейнеризация,
воспроизводимость работы.
8.3. Соответствие решения поставленной задаче
Оцениваются полнота выполнения обязательных функций, соответствие форматов входных
и выходных данных, поддержка заявленных анатомических областей, прозрачность ограничений
и пригодность решения для локального использования
• сбалансированная точность и F1-мера;
• ROC AUC и/или PR AUC для бинарной классификации;
• macro-F1 для многоклассового или мультилейбл-определения типов нарушений;
• метрики локализации или сегментации — Dice, IoU, расстояние между ключевыми точками
— при наличии соответствующего функционала;
• время обработки одного исследования и доля успешно обработанных файлов.
Приоритетной клинической характеристикой являются F1-мера и ROC-AUC,
как сбалансированные метрики работы решения. Метрики диагностической точности
желательно представлять с 95% доверительными интервалами.
8.5. Выступление коллектива на питч-сессии
Оцениваются владение материалом, способность объяснить клиническую и техническую
логику решения, качество демонстрации, аргументированность выбранных подходов, понимание
ограничений и реалистичность плана пилотирования и внедрения

17
condition_doctor.txt Normal file
View File

@ -0,0 +1,17 @@
Модель должна оценивать на изображении: положение костей, их взаимное расположение, качество контуров, правильность разметки, наличие движения, артефактов и перекрытий.
Итог должен иметь только одну из двух категорий: «пригоден» или «непригоден». Категория «пригоден» означает, что по изображению нет видимых причин, которые помешали бы специалисту выполнить дальнейший анализ. Это не означает, что снимок диагностически нормальный, что минерализация костей нормальная или что окончательное исследование уже выполнено. Категория «непригоден» означает, что на изображении есть хотя бы одна существенная проблема: нужная область не видна полностью, видимая геометрия сильно нарушена, разметка неверна, есть значимый артефакт, контуры костей нельзя надёжно определить, изображение смазано или область интереса проведена неправильно.
Сначала нужно определить, какая область исследуется. На снимке должен быть узнаваемый поясничный отдел позвоночника или тазобедренный сустав, в зависимости от поставленной задачи. Нужная область должна быть видна достаточно полно. Если часть позвоночника, бедренная шейка или проксимальный отдел бедра обрезаны, закрыты или находятся за пределами изображения, снимок нужно считать непригодным. Если модель не может уверенно понять, какая анатомическая область показана, снимок также непригоден для автоматического дальнейшего анализа.
Для поясничного отдела нужно проверить, что тела поясничных позвонков видны, их контуры различимы, межпозвонковые промежутки не закрыты полностью, а позвоночник не имеет выраженного поворота или перекоса. В стандартном исследовании обычно оценивается область L1-L4. На изображении не должно быть сильного наложения костей, заметного размытия или такого искажения, при котором невозможно отделить один позвонок от другого. Если нужные позвонки видны достаточно хорошо и их границы можно уверенно определить, этот критерий выполнен.
При проверке позвонков нужно смотреть не только на подписи, но и на анатомию. Метка L1 должна стоять на L1, метка L2 на L2, метка L3 на L3, а метка L4 на L4. Нельзя допускать пропущенный позвонок, двойную метку, смещение метки на соседний позвонок или включение в анализ другой структуры. Позвонки обычно считают снизу вверх, но у людей бывают анатомические варианты: может быть четыре или шесть поясничноподобных позвонков, а также переходный пояснично-крестцовый позвонок. Поэтому нельзя автоматически исправлять разметку по шаблону. Если по самому изображению нельзя уверенно определить уровень позвонка, снимок нужно считать непригодным для автоматического использования.
Если разметка на снимке отсутствует, это не всегда означает непригодность. Важно, можно ли по видимой анатомии однозначно понять, где находятся нужные позвонки, чтобы специалист мог выполнить разметку. Если анатомия видна ясно и уровни можно определить без догадки, снимок может быть пригоден. Если же без отсутствующей информации невозможно уверенно определить уровни, снимок нужно считать непригодным.
Для тазобедренного сустава должны быть видны проксимальный отдел бедренной кости, головка бедра, шейка бедра, вертелы и необходимые окружающие ткани. Бедренная шейка не должна быть обрезана, закрыта или полностью перекрыта соседней костью. Область должна быть показана так, чтобы специалист мог выделить шейку бедра и total hip по анатомическим границам. На снимке бедра нужно проверить видимую геометрию конечности. В обычной правильной проекции бедро поворачивают внутрь примерно на 15–20 градусов. По изображению это оценивают не по словам оператора, а по форме костей. Малый вертел обычно должен быть виден лишь немного. Если он виден слишком хорошо, это может говорить о недостаточной внутренней ротации. Если бедро сильно повернуто, шейка выглядит укороченной или искажённой, кости перекрываются, а правильные границы нельзя определить, снимок нужно считать непригодным. Модель не должна писать, что пациент точно лежал неправильно. Правильнее описывать видимый результат: «на изображении выраженная ротация бедра, из-за этого анатомические границы искажены». Причина укладки может быть одной из возможных причин, но по одному обезличенному снимку она не подтверждается. Если изображение позволяет уверенно увидеть нужные структуры и выраженной деформации проекции нет, этот критерий считается выполненным.
Разметка тазобедренного сустава должна находиться на правильной кости. Область шейки бедра должна проходить именно по шейке и не заходить на большой вертел. Область total hip должна соответствовать проксимальному отделу бедра и не должна включать соседнюю кость или большое количество мягких тканей вместо кости. Если границы области интереса явно смещены, пересекают мягкие ткани, захватывают соседнюю кость или обрезают часть нужной кости, снимок непригоден. Правое и левое бедро нужно проверять отдельно. Если одно бедро выглядит правильно, это не делает автоматически пригодным второе. Если на анализируемом бедре видны эндопротез, винты, пластины, фиксаторы или другой материал, который пересекает область бедра, эту сторону нужно считать непригодной для стандартного дальнейшего анализа. Противоположное бедро может быть пригодным, но только если оно само полностью видно и на нём нет таких проблем.
На каждом снимке нужно искать артефакты. Артефакт - это посторонний объект или дефект изображения, который может закрывать кость, менять её контур или мешать правильной разметке. К артефактам относятся металлические части одежды, застёжки, клипсы, проволока, хирургические фиксаторы, эндопротезы, винты, цемент после операций, имплантируемые устройства, контрастное вещество, нерастворившиеся таблетки кальция и другие плотные объекты. Также нужно учитывать резкие полосы, необычные пятна, локальные участки сильной плотности, раздвоение контуров и другие неестественные элементы. Артефакт, который находится далеко от исследуемой области и не мешает видеть кость, не обязательно делает снимок непригодным. Но если артефакт находится внутри поясничного отдела, в области бедренной шейки, total hip или на границе области интереса, снимок нужно считать непригодным. Если модель не может надёжно понять, мешает ли артефакт анализу, она также должна выбрать «непригоден», потому что в этой задаче сомнительные случаи не пропускаются автоматически.
На поясничном снимке особенно важно искать хирургический материал и фиксаторы позвоночника, металлические клипсы или проволоку, цемент, имплантируемые устройства, крупные кальцинаты, контрастный материал и другие объекты, которые закрывают позвонки. На снимке бедра нужно искать эндопротез, винты, пластины, остаточные изменения после остеосинтеза, глютеальные импланты и другие объекты, проходящие через область бедра. Модель не должна ставить диагноз по этим объектам. Она должна только решить, мешают ли они дальнейшему анализу изображения.
Нужно проверять наличие движения во время сканирования. Видимые признаки движения - размытие, раздвоение костных контуров, несовпадение краёв, неодинаковая резкость разных частей изображения и невозможность провести устойчивую границу кости. Если движение мешает различать позвонки, бедренную шейку или total hip, снимок непригоден. Отсутствие размытия не доказывает, что движения не было вообще; оно означает только, что на изображении нет очевидного признака, мешающего дальнейшему анализу. Нужно проверять видимую форму костей. Если позвонок сильно деформирован, имеет необычно уменьшенную высоту, перекрыт или его границы нельзя определить, он может быть непригоден для дальнейшей разметки. Если из-за этого не остаётся достаточного количества хорошо видимых позвонков, весь снимок поясничного отдела нужно считать непригодным. Для бедра аналогично нужно учитывать выраженную деформацию, нарушение нормального контура, тяжёлую дисплазию или другое структурное изменение, из-за которого нельзя уверенно определить шейку, вертелы и проксимальный отдел бедра. Необходимо считать снимок непригодным, если видимое изменение мешает провести правильные анатомические границы.
Отдельно проверяются границы областей интереса. Край выделенной области должен совпадать с видимым краем кости. Нельзя включать мягкие ткани вместо кости, нельзя пропускать часть кости, нельзя захватывать соседний позвонок или соседнюю кость. Для позвоночника нужно проверить разделение соседних позвонков и положение области относительно тел позвонков. Для бедра нужно проверить область шейки, большой и малый вертел, внешний контур проксимального отдела и отсутствие значимого включения мягких тканей. Ошибка границы является достаточной причиной признать снимок непригодным, даже если всё изображение выглядит резким.
Если в обезличенном DICOM отсутствуют имя, номер пациента, дата рождения или другие личные данные, это само по себе не является причиной брака. Обезличивание не делает снимок непригодным. Но модель не должна восстанавливать из изображения возраст, пол, сторону исследования, производителя аппарата, протокол или другие невидимые сведения. Эти данные не входят в задачу оценки изображения. Если без них невозможно подтвердить, что показана нужная область или что разметка соответствует конкретному протоколу, снимок нужно считать непригодным для автоматического использования, а не заполнять недостающие сведения догадкой.
В результате модель должна отвечать примерно так: «Пригоден. Поясничный отдел виден полностью, выраженного движения и значимых артефактов нет, видимая разметка соответствует анатомии, границы области определяются». Либо так: «Непригоден. В области L2–L3 есть выраженное размытие и неправильное разделение тел позвонков; надёжная дальнейшая разметка невозможна». Для бедра пример формулировки может быть таким: «Непригоден. Выраженная ротация бедра и неправильное положение области шейки не позволяют надёжно определить анатомические границы». Это только примеры формы ответа; решение всегда должно основываться на конкретных видимых признаках.
Главное правило такое: пригодным считается только тот снимок, на котором нужная анатомия полностью видна, её границы различимы, выраженной ротации или движения нет, разметка соответствует анатомии, область интереса проведена правильно, а значимые артефакты не закрывают исследуемую зону. Если хотя бы один существенный пункт нарушен или его нельзя подтвердить по изображению, снимок нужно считать непригодным и передать на ручную проверку. В этой задаче лучше ошибочно направить сомнительный снимок специалисту, чем пропустить изображение, которое может привести к неправильному дальнейшему исследованию.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 494 KiB

View File

@ -11,4 +11,48 @@ class QualityResponse(BaseModel):
metrics: Dict[str, Any]
position_validation: Optional[Dict[str, Any]] = None
artifact_validation: Optional[Dict[str, Any]] = None
mask: Optional[str] = None # base64 encoded mask image
mask: Optional[str] = None # base64 encoded mask image
# New detailed fields
violation_type: Optional[str] = None
reason: Optional[str] = None
confidence_per_class: Optional[Dict[str, float]] = None
view_quality: Optional[str] = None
anatomical_region: Optional[str] = None
class DetailedQualityResponse(BaseModel):
"""Detailed quality assessment response with all metrics"""
# Basic info
study_uid: str = ""
image_uid: str = ""
anatomical_region: str
quality_class: int
quality_label: str
# Detailed violation info
violation_type: str
reason: str
# Confidence
confidence: float
confidence_per_class: Dict[str, float]
# View quality
view_quality: str
# All metrics
metrics: Dict[str, Any]
# Region-specific
spine_completeness: Optional[Dict[str, Any]] = None
vertebral_contours: Optional[Dict[str, Any]] = None
hip_completeness: Optional[Dict[str, Any]] = None
hip_rotation: Optional[Dict[str, Any]] = None
# Overall
overall_quality: str
severity: str
# Visualization
mask: Optional[str] = None

View File

@ -244,6 +244,69 @@
</div>
</section>
<!-- Detail Panel -->
<section id="detailSection" class="hidden mt-6">
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm overflow-hidden">
<div class="p-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
<i class="fas fa-info-circle mr-2 text-primary-600"></i>
Детали анализа
</h3>
<button id="closeDetail" class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
<i class="fas fa-times"></i>
</button>
</div>
<div class="p-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Basic Info -->
<div>
<h4 class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">Основная информация</h4>
<div id="detailBasic" class="space-y-2 text-sm">
<!-- Populated by JS -->
</div>
</div>
<!-- Quality Details -->
<div>
<h4 class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">Детали качества</h4>
<div id="detailQuality" class="space-y-2 text-sm">
<!-- Populated by JS -->
</div>
</div>
<!-- Metrics -->
<div class="md:col-span-2">
<h4 class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">Метрики</h4>
<div id="detailMetrics" class="grid grid-cols-1 md:grid-cols-3 gap-4">
<!-- Populated by JS -->
</div>
</div>
<!-- Reason -->
<div class="md:col-span-2">
<h4 class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">Причина</h4>
<div id="detailReason" class="p-3 bg-gray-50 dark:bg-gray-700 rounded-lg text-sm">
<!-- Populated by JS -->
</div>
</div>
<!-- Visualization -->
<div class="md:col-span-2" id="detailVizSection">
<h4 class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">Визуализация</h4>
<div class="flex gap-4">
<div id="detailImage" class="flex-1">
<p class="text-xs text-gray-500 mb-1">Оригинал</p>
</div>
<div id="detailMask" class="flex-1">
<p class="text-xs text-gray-500 mb-1">Маска ROI</p>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- Error Section -->
<section id="errorSection" class="hidden">
<div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl p-6">

View File

@ -20,6 +20,7 @@ const errorMessage = document.getElementById('errorMessage');
const statusBanner = document.getElementById('statusBanner');
const statusIcon = document.getElementById('statusIcon');
const statusText = document.getElementById('statusText');
const detailSection = document.getElementById('detailSection');
// Theme toggle
const themeToggle = document.getElementById('themeToggle');
@ -142,16 +143,17 @@ async function handleFiles(files) {
try {
const formData = new FormData();
formData.append('file', file);
const response = await fetch(`${API_BASE}/api/v1/analyze`, {
// Use detailed endpoint for comprehensive analysis
const response = await fetch(`${API_BASE}/api/v1/analyze/detailed?include_visualization=false`, {
method: 'POST',
body: formData
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
results.push({
filename: file.name,
@ -254,25 +256,25 @@ function renderTable() {
emptyResults.classList.add('hidden');
resultsTable.innerHTML = filtered.map(r => {
resultsTable.innerHTML = filtered.map((r, idx) => {
const qualityClass = r.quality_class === 0 ? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400' :
r.quality_class === 1 ? 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400' :
'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300';
const qualityLabel = r.quality_class === 0 ? 'OK' :
r.quality_class === 1 ? 'Нарушение' :
'Ошибка';
const regionLabel = r.anatomical_region === 'spine' ? 'Позвоночник' :
r.anatomical_region === 'hip_left' ? 'Бедро левое' :
r.anatomical_region === 'hip_right' ? 'Бедро правое' :
r.anatomical_region === 'hip' ? 'Бедро' :
r.anatomical_region || '—';
const confidence = r.confidence ? (r.confidence * 100).toFixed(1) + '%' : '—';
return `
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/50 fade-in">
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/50 fade-in cursor-pointer" data-index="${idx}" onclick="viewDetailByIndex(this)">
<td class="px-4 py-3 text-sm text-gray-900 dark:text-white">
<div class="flex items-center gap-2">
<i class="fas fa-file-medical text-gray-400"></i>
@ -345,9 +347,99 @@ document.getElementById('exportXlsx')?.addEventListener('click', async () => {
document.getElementById('clearResults')?.addEventListener('click', () => {
results = [];
resultsSection.classList.add('hidden');
detailSection.classList.add('hidden');
fileInput.value = '';
});
// Close detail panel
document.getElementById('closeDetail')?.addEventListener('click', () => {
detailSection.classList.add('hidden');
});
// View detailed analysis for a file
async function viewDetail(result) {
detailSection.classList.remove('hidden');
// Populate basic info
const regionLabel = result.anatomical_region === 'spine' ? 'Позвоночник' :
result.anatomical_region === 'hip_left' ? 'Бедро левое' :
result.anatomical_region === 'hip_right' ? 'Бедро правое' :
result.anatomical_region === 'hip' ? 'Бедро' : result.anatomical_region || '—';
document.getElementById('detailBasic').innerHTML = `
<div class="flex justify-between"><span class="text-gray-500">Файл:</span><span class="font-medium">${result.filename || '—'}</span></div>
<div class="flex justify-between"><span class="text-gray-500">Регион:</span><span class="font-medium">${regionLabel}</span></div>
<div class="flex justify-between"><span class="text-gray-500">UID исследования:</span><span class="font-medium text-xs">${(result.study_uid || '').substring(0, 20)}...</span></div>
`;
// Populate quality details
const qualityClass = result.quality_class === 0 ? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400' :
result.quality_class === 1 ? 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400' :
'bg-gray-100 text-gray-800';
const violationType = result.violation_type || (result.quality_class === 1 ? 'quality_violation_detected' : 'correct');
const reason = result.reason || (result.quality_class === 0 ? 'Качество соответствует норме' : 'Нарушение качества');
document.getElementById('detailQuality').innerHTML = `
<div class="flex justify-between"><span class="text-gray-500">Класс:</span><span class="px-2 py-0.5 rounded text-xs font-medium ${qualityClass}">${result.quality_class === 0 ? 'OK' : 'Нарушение'}</span></div>
<div class="flex justify-between"><span class="text-gray-500">Тип нарушения:</span><span class="font-medium">${violationType}</span></div>
<div class="flex justify-between"><span class="text-gray-500">Уверенность:</span><span class="font-medium">${result.confidence ? (result.confidence * 100).toFixed(1) + '%' : '—'}</span></div>
<div class="flex justify-between"><span class="text-gray-500">Вид:</span><span class="font-medium">${result.view_quality || 'unknown'}</span></div>
`;
// Populate metrics
const metrics = result.metrics || {};
const motion = metrics.motion || {};
const artifacts = metrics.artifacts || {};
const roi = metrics.roi_check || {};
document.getElementById('detailMetrics').innerHTML = `
<div class="p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
<div class="text-xs text-gray-500 mb-1">Движение</div>
<div class="flex items-center gap-2">
<div class="w-2 h-2 rounded-full ${motion.motion_detected ? 'bg-red-500' : 'bg-green-500'}"></div>
<span class="font-medium">${motion.motion_detected ? 'Обнаружено' : 'Нет'}</span>
</div>
${motion.severity ? `<div class="text-xs text-gray-500">Severity: ${motion.severity}</div>` : ''}
</div>
<div class="p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
<div class="text-xs text-gray-500 mb-1">Артефакты</div>
<div class="flex items-center gap-2">
<div class="w-2 h-2 rounded-full ${artifacts.any_detected ? 'bg-red-500' : 'bg-green-500'}"></div>
<span class="font-medium">${artifacts.any_detected ? 'Обнаружены' : 'Нет'}</span>
</div>
${artifacts.metal_detected ? `<div class="text-xs text-gray-500">Металл: да</div>` : ''}
${artifacts.implant_detected ? `<div class="text-xs text-gray-500">Имплантат: да</div>` : ''}
</div>
<div class="p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
<div class="text-xs text-gray-500 mb-1">ROI</div>
<div class="flex items-center gap-2">
<div class="w-2 h-2 rounded-full ${roi.valid === false ? 'bg-red-500' : 'bg-green-500'}"></div>
<span class="font-medium">${roi.valid === false ? 'Проблема' : 'OK'}</span>
</div>
</div>
`;
// Populate reason
const reasonClass = result.quality_class === 0 ? 'bg-green-50 dark:bg-green-900/20 text-green-800 dark:text-green-200' :
'bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-200';
document.getElementById('detailReason').innerHTML = `
<span class="${reasonClass} px-3 py-2 rounded-lg block">${reason}</span>
`;
// Scroll to detail
detailSection.scrollIntoView({ behavior: 'smooth' });
}
// View detail by table row index
function viewDetailByIndex(row) {
const idx = parseInt(row.getAttribute('data-index'));
const filtered = getFilteredResults();
if (filtered[idx]) {
viewDetail(filtered[idx]);
}
}
// Show error
function showError(message) {
errorMessage.textContent = message;

View File

@ -1,10 +1,11 @@
"""
Main FastAPI application for DXA Quality Assessment
"""
from fastapi import FastAPI, File, UploadFile, APIRouter
from fastapi import FastAPI, File, UploadFile, APIRouter, Query
from fastapi.staticfiles import StaticFiles
from pathlib import Path
from fastapi.responses import StreamingResponse
from typing import Dict
import numpy as np
import pydicom
import pandas as pd
@ -18,6 +19,8 @@ from starlette.responses import JSONResponse, FileResponse
from src.dxa.model import create_model
from src.dxa.inference import determine_anatomical_region
from src.utils.utils import get_device
from src.quality.detailed_assessment import generate_quality_report
from src.quality.quality_scorer import QualityScorer, convert_to_serializable
# Create app
app = FastAPI(
@ -112,7 +115,10 @@ async def root():
"/docs",
"/api/v1/health",
"/api/v1/analyze",
"/api/v1/batch"
"/api/v1/analyze/detailed",
"/api/v1/analyze/sr",
"/api/v1/batch",
"/api/v1/export"
]
}
@ -180,6 +186,102 @@ async def analyze_dicom(file: UploadFile = File(...)):
)
@app.post("/api/v1/analyze/detailed")
async def analyze_dicom_detailed(
file: UploadFile = File(...),
include_visualization: bool = Query(False, description="Include base64 mask visualization")
):
"""
Detailed analysis of DICOM file with comprehensive quality assessment.
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
"""
try:
# Load model
model = load_model()
if model is None:
return JSONResponse(
status_code=500,
content={"error": "Model not loaded"}
)
# Read file
dcm_bytes = await file.read()
img_tensor, ds = preprocess_dicom(dcm_bytes)
# Get original image for quality assessment
img_array = ds.pixel_array.astype(np.float32)
img_array = (img_array - img_array.min()) / (img_array.max() - img_array.min() + 1e-8)
# Create 3-channel version
if len(img_array.shape) == 2:
img_3ch = np.stack([img_array] * 3, axis=2)
else:
img_3ch = img_array
# Predict
img_tensor = img_tensor.to(device)
with torch.no_grad():
outputs = model.model(img_tensor)
probs = torch.softmax(outputs, dim=1)
pred = outputs.argmax(dim=1).item()
confidence = probs[0, pred].item()
all_probs = probs[0].cpu().numpy().tolist()
# Determine region
h, w = ds.pixel_array.shape
region = determine_anatomical_region(file.filename) if file.filename else ('hip' if h < 270 else 'spine')
# Generate segmentation (simple threshold-based for now)
# In production, use proper segmentation model
threshold = np.percentile(img_array, 90)
segmentation = (img_array > threshold).astype(np.uint8)
# Generate detailed quality report
quality_report = generate_quality_report(
image=img_3ch,
segmentation=segmentation,
region=region,
model_prediction=pred,
model_confidence=confidence
)
# Add UID info
quality_report["study_uid"] = getattr(ds, 'StudyInstanceUID', '')
quality_report["image_uid"] = getattr(ds, 'SOPInstanceUID', '')
# Add visualization if requested
if include_visualization:
# Create mask visualization
mask_vis = Image.fromarray((segmentation * 255).astype(np.uint8))
buffer = io.BytesIO()
mask_vis.save(buffer, format='PNG')
quality_report["mask"] = base64.b64encode(buffer.getvalue()).decode('utf-8')
else:
quality_report["mask"] = None
# Convert numpy types to Python types for JSON serialization
quality_report = convert_to_serializable(quality_report)
return quality_report
except Exception as e:
import traceback
traceback.print_exc()
return JSONResponse(
status_code=500,
content={
"error": str(e),
"processing_status": f"Failure: {str(e)[:50]}"
}
)
@app.post("/api/v1/batch")
async def batch_analyze(files: list[UploadFile] = File(...)):
"""Batch analyze multiple DICOM files"""
@ -301,6 +403,155 @@ async def export_results(files: list[UploadFile] = File(...)):
)
@app.post("/api/v1/analyze/sr")
async def analyze_dicom_sr(file: UploadFile = File(...)):
"""
Generate DICOM SR (Structured Report) for the analysis result.
Returns a text representation of DICOM SR with standardized codes.
"""
try:
# Use the detailed analysis
# Reuse the detailed analysis logic
model = load_model()
if model is None:
return JSONResponse(
status_code=500,
content={"error": "Model not loaded"}
)
# Read file
dcm_bytes = await file.read()
img_tensor, ds = preprocess_dicom(dcm_bytes)
# Get original image for quality assessment
img_array = ds.pixel_array.astype(np.float32)
img_array = (img_array - img_array.min()) / (img_array.max() - img_array.min() + 1e-8)
if len(img_array.shape) == 2:
img_3ch = np.stack([img_array] * 3, axis=2)
else:
img_3ch = img_array
# Predict
img_tensor = img_tensor.to(device)
with torch.no_grad():
outputs = model.model(img_tensor)
probs = torch.softmax(outputs, dim=1)
pred = outputs.argmax(dim=1).item()
confidence = probs[0, pred].item()
# Determine region
h, w = ds.pixel_array.shape
region = determine_anatomical_region(file.filename) if file.filename else ('hip' if h < 270 else 'spine')
# Generate segmentation
threshold = np.percentile(img_array, 90)
segmentation = (img_array > threshold).astype(np.uint8)
# Generate detailed quality report
quality_report = generate_quality_report(
image=img_3ch,
segmentation=segmentation,
region=region,
model_prediction=pred,
model_confidence=confidence
)
# Generate DICOM SR text representation
sr_content = generate_dicom_sr_text(
study_uid=getattr(ds, 'StudyInstanceUID', ''),
image_uid=getattr(ds, 'SOPInstanceUID', ''),
quality_report=quality_report
)
return {
"format": "DICOM SR (Text)",
"study_uid": getattr(ds, 'StudyInstanceUID', ''),
"image_uid": getattr(ds, 'SOPInstanceUID', ''),
"sr_content": sr_content
}
except Exception as e:
import traceback
return JSONResponse(
status_code=500,
content={
"error": str(e),
"processing_status": f"Failure: {str(e)[:50]}"
}
)
def generate_dicom_sr_text(study_uid: str, image_uid: str, quality_report: Dict) -> str:
"""Generate DICOM SR text representation"""
# Map violation types to DICOM codes (simplified)
violation_code_map = {
"correct": ("113001", "DXA Quality Assessment", "FINAL"),
"position_error": ("123456", "Positioning Error", "WARNING"),
"artifact_motion": ("234567", "Motion Artifact", "WARNING"),
"artifact_other": ("234568", "Other Artifact", "WARNING"),
"labeling_error": ("345678", "Labeling Error", "WARNING"),
"incomplete_view": ("456789", "Incomplete View", "WARNING"),
"roi_error": ("567890", "ROI Error", "WARNING"),
"rotation": ("678901", "Rotation Error", "WARNING")
}
violation_type = quality_report.get("violation_type", "correct")
code, label, completion = violation_code_map.get(violation_type, ("999999", "Unknown", "UNKNOWN"))
sr_lines = [
"DICOM Structured Report - DXA Quality Assessment",
"=" * 50,
f"Study Instance UID: {study_uid}",
f"Series Instance UID: {image_uid}",
"",
"Procedure Report:",
f" - Anatomical Region: {quality_report.get('anatomical_region', 'Unknown')}",
f" - Quality Classification: {quality_report.get('quality_label', 'Unknown')}",
f" - Confidence: {quality_report.get('confidence', 0):.2%}",
"",
"Findings:",
f" - Violation Type Code: {code}",
f" - Violation Type: {label}",
f" - Reason: {quality_report.get('reason', 'N/A')}",
f" - View Quality: {quality_report.get('view_quality', 'Unknown')}",
"",
"Detailed Metrics:",
]
# Add metrics
metrics = quality_report.get("metrics", {})
motion = metrics.get("motion", {})
if motion:
sr_lines.append(f" Motion Detection:")
sr_lines.append(f" - Motion Detected: {motion.get('motion_detected', False)}")
sr_lines.append(f" - Severity: {motion.get('severity', 'NONE')}")
artifacts = metrics.get("artifacts", {})
if artifacts:
sr_lines.append(f" Artifact Detection:")
sr_lines.append(f" - Any Artifact: {artifacts.get('any_detected', False)}")
sr_lines.append(f" - Metal: {artifacts.get('metal_detected', False)}")
sr_lines.append(f" - Implant: {artifacts.get('implant_detected', False)}")
roi = metrics.get("roi_check", {})
if roi:
sr_lines.append(f" ROI Validation:")
sr_lines.append(f" - Valid: {roi.get('valid', False)}")
# Completion flag
sr_lines.extend([
"",
"Completion Flag: " + completion,
"Verification Flag: UNVERIFIED"
])
return "\n".join(sr_lines)
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)

View File

@ -0,0 +1,725 @@
"""
Detailed Quality Assessment Module for DXA images
Implements checks from condition.txt and condition_doctor.txt
"""
import numpy as np
from scipy import ndimage
from typing import Dict, Any, List, Optional, Tuple
from PIL import Image
# Violation types for spine
SPINE_VIOLATION_TYPES = [
"correct",
"position_error",
"artifact_motion",
"artifact_other",
"labeling_error",
"incomplete_view",
"roi_error"
]
# Violation types for hip
HIP_VIOLATION_TYPES = [
"correct",
"position_error",
"rotation",
"artifact_motion",
"artifact_other",
"roi_error",
"incomplete_view"
]
def calculate_blur_laplacian(image: np.ndarray) -> float:
"""
Calculate image blur using Laplacian variance.
Higher variance = sharper image, lower = more blurred.
"""
if len(image.shape) == 3:
image = np.mean(image, axis=2)
laplacian = np.array([
[0, 1, 0],
[1, -4, 1],
[0, 1, 0]
])
result = ndimage.convolve(image.astype(float), laplacian)
variance = float(np.var(result))
return variance
def calculate_blur_fft(image: np.ndarray) -> float:
"""
Calculate blur using FFT high-frequency energy ratio.
Lower ratio = more blurred.
"""
if len(image.shape) == 3:
image = np.mean(image, axis=2)
# Compute FFT
f = np.fft.fft2(image.astype(float))
fshift = np.fft.fftshift(f)
magnitude = np.abs(fshift)
h, w = image.shape
center_h, center_w = h // 2, w // 2
# Low frequency region (center)
low_freq_radius = min(h, w) // 8
y, x = np.ogrid[:h, :w]
low_freq_mask = (x - center_w)**2 + (y - center_h)**2 <= low_freq_radius**2
# Calculate energy ratio
total_energy = np.sum(magnitude**2) + 1e-10
low_energy = np.sum((magnitude * low_freq_mask)**2)
high_energy_ratio = 1 - (low_energy / total_energy)
return float(high_energy_ratio)
def detect_motion_blur(image: np.ndarray, segmentation: Optional[np.ndarray] = None) -> Dict[str, Any]:
"""
Detect motion artifacts in the image.
Checks:
- Blur using Laplacian variance
- Blur using FFT high-frequency energy
- Edge duplication (ghost edges)
"""
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]
y_diff = np.diff(np.sort(y_coords))
# 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,
"blur_fft": fft_blur,
"is_blurred_laplacian": is_blurred_laplacian,
"is_blurred_fft": is_blurred_fft,
"edge_duplication": edge_duplication,
"severity": "HIGH" if (is_blurred_laplacian and laplacian_var < 0.001) else "MEDIUM" if motion_detected else "NONE"
}
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
"""
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,
"cement_detected": False,
"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
if roi.sum() > 0:
roi_normalized = roi / (roi.max() + 1e-8)
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
background = gray * outside_roi
if background.sum() > 0:
bg_threshold = np.percentile(background[background > 0], 95)
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"],
artifacts["implant_detected"],
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
"""
if segmentation is None or segmentation.sum() == 0:
return {
"valid": False,
"reason": "empty_segmentation",
"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
x_min == 0 or x_max == w - 1)
vertebra_info.append({
"id": i,
"height": int(height),
"width": int(width),
"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,
"vertebrae": vertebra_info,
"issues": issues,
"reason": "; ".join(issues) if issues else "Все позвонки видны полностью"
}
def check_spine_labels(segmentation: np.ndarray, image: Optional[np.ndarray] = None) -> Dict[str, Any]:
"""
Check labeling of vertebrae (L1-L4).
This is a placeholder - real implementation would use OCR or model predictions.
"""
# This would need actual label detection
# For now, return placeholder
return {
"labels_detected": False,
"labels": [],
"issues": [],
"note": "Требуется OCR или модель для детекции меток"
}
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
"""
if segmentation is None or segmentation.sum() == 0:
return {
"valid": False,
"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
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,
"aspect_ratio": float(aspect_ratio),
"height": int(height),
"width": int(width),
"issues": issues,
"reason": "; ".join(issues) if issues else "Бедро видно полностью"
}
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
"""
if segmentation is None or segmentation.sum() == 0:
return {
"valid": False,
"reason": "empty_segmentation",
"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,
"reason": "empty_segmentation",
"rotation_angle": 0,
"issues": ["Сегментация пустая"]
}
# For a single femur, check orientation
if num_features == 1:
# Get major axis
coords = np.array(np.where(segmentation > 0)).T
if len(coords) > 10:
# 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),
"issues": issues,
"reason": "; ".join(issues) if issues else "Ротация в пределах нормы"
}
def check_roi_boundaries(segmentation: np.ndarray, image_shape: Tuple[int, int]) -> Dict[str, Any]:
"""
Check if ROI boundaries are correct:
- For spine: passes through vertebral bodies, not soft tissue
- For hip: neck region correctly located, doesn't extend onto trochanter
"""
if segmentation is None or segmentation.sum() == 0:
return {
"valid": False,
"reason": "empty_segmentation",
"issues": ["Сегментация пустая"]
}
h, w = image_shape[:2]
y_coords, x_coords = np.where(segmentation > 0)
# Get bounding box
y_min, y_max = y_coords.min(), y_coords.max()
x_min, x_max = x_coords.min(), x_coords.max()
# Check margin from edges
margin = 10
margin_issues = []
if y_min < margin:
margin_issues.append("Слишком близко к верхнему краю")
if y_max > h - margin:
margin_issues.append("Слишком близко к нижнему краю")
if x_min < margin:
margin_issues.append("Слишком близко к левому краю")
if x_max > w - margin:
margin_issues.append("Слишком близко к правому краю")
# Check size (reasonable ROI size)
roi_height = y_max - y_min
roi_width = x_max - x_min
size_issues = []
if roi_height < h * 0.3:
size_issues.append("ROI слишком маленький")
if roi_width < w * 0.2:
size_issues.append("ROI слишком узкий")
all_issues = margin_issues + size_issues
return {
"valid": len(all_issues) == 0,
"bounding_box": {
"y_min": int(y_min), "y_max": int(y_max),
"x_min": int(x_min), "x_max": int(x_max)
},
"size": {"height": int(roi_height), "width": int(roi_width)},
"margin_issues": margin_issues,
"size_issues": size_issues,
"issues": all_issues,
"reason": "; ".join(all_issues) if all_issues else "ROI в пределах нормы"
}
def check_vertebral_contours(segmentation: np.ndarray) -> Dict[str, Any]:
"""
Check vertebral contours for sharpness and clarity.
"""
if segmentation is None or segmentation.sum() == 0:
return {
"valid": False,
"reason": "empty_segmentation",
"issues": ["Нет сегментации"]
}
# Get edges
edges = ndimage.sobel(segmentation.astype(float))
edge_strength = np.abs(edges).mean()
issues = []
# Low edge strength = blurred contours
if edge_strength < 0.1:
issues.append("Контуры позвонков размыты")
# Check for gaps between vertebrae
labeled, num_features = ndimage.label(segmentation)
if num_features >= 2:
# Check distances between vertebrae
centers = []
for i in range(1, num_features + 1):
coords = np.array(np.where(labeled == i)).T
if len(coords) > 0:
centers.append(coords.mean(axis=0))
if len(centers) >= 2:
centers = np.array(centers)
distances = np.sqrt(np.sum(np.diff(centers, axis=0)**2, axis=1))
mean_dist = distances.mean()
# If some distances are very small, might be merged vertebrae
if np.any(distances < mean_dist * 0.3):
issues.append("Возможно, позвонки частично слиты")
return {
"valid": len(issues) == 0,
"edge_strength": float(edge_strength),
"num_objects": int(num_features),
"issues": issues,
"reason": "; ".join(issues) if issues else "Контуры четкие"
}
def determine_violation_type(
region: str,
quality_metrics: Dict[str, Any]
) -> Tuple[str, str]:
"""
Determine the specific type of violation based on quality metrics.
Returns:
Tuple of (violation_type, reason)
"""
if region == "spine":
return _determine_spine_violation(quality_metrics)
elif region in ("hip", "hip_left", "hip_right"):
return _determine_hip_violation(quality_metrics)
else:
return "position_error", "Некорректная анатомическая область"
def _determine_spine_violation(quality_metrics: Dict[str, Any]) -> Tuple[str, str]:
"""Determine spine-specific violation type."""
# Check motion blur first
motion = quality_metrics.get("motion", {})
if motion.get("motion_detected"):
return "artifact_motion", "Обнаружен артефакт движения (размытие)"
# Check artifacts
artifacts = quality_metrics.get("artifacts", {})
if artifacts.get("any_detected"):
if artifacts.get("metal_detected"):
return "artifact_other", "Обнаружены металлические объекты"
if artifacts.get("implant_detected"):
return "artifact_other", "Обнаружены имплантаты"
if artifacts.get("cement_detected"):
return "artifact_other", "Обнаружен цемент"
return "artifact_other", "Обнаружены артефакты"
# Check completeness
completeness = quality_metrics.get("spine_completeness", {})
if not completeness.get("valid", True):
return "incomplete_view", completeness.get("reason", "Неполный вид позвоночника")
# Check contours
contours = quality_metrics.get("vertebral_contours", {})
if not contours.get("valid", True):
return "labeling_error", contours.get("reason", "Проблемы с контурами позвонков")
# Check ROI
roi = quality_metrics.get("roi_check", {})
if not roi.get("valid", True):
return "roi_error", roi.get("reason", "Проблемы с ROI")
# Check position
position = quality_metrics.get("position", {})
if not position.get("valid", True):
return "position_error", f"Позиция: {position.get('reason', 'ошибка позиционирования')}"
return "correct", "Качество соответствует норме"
def _determine_hip_violation(quality_metrics: Dict[str, Any]) -> Tuple[str, str]:
"""Determine hip-specific violation type."""
# Check motion blur
motion = quality_metrics.get("motion", {})
if motion.get("motion_detected"):
return "artifact_motion", "Обнаружен артефакт движения"
# Check artifacts
artifacts = quality_metrics.get("artifacts", {})
if artifacts.get("any_detected"):
if artifacts.get("implant_detected"):
return "artifact_other", "Обнаружены имплантаты (протезы)"
if artifacts.get("metal_detected"):
return "artifact_other", "Обнаружены металлические объекты"
return "artifact_other", "Обнаружены артефакты"
# Check completeness
completeness = quality_metrics.get("hip_completeness", {})
if not completeness.get("valid", True):
return "incomplete_view", completeness.get("reason", "Неполный вид бедра")
# Check rotation
rotation = quality_metrics.get("hip_rotation", {})
if not rotation.get("valid", True):
return "rotation", rotation.get("reason", "Нарушение ротации")
# Check ROI
roi = quality_metrics.get("roi_check", {})
if not roi.get("valid", True):
return "roi_error", roi.get("reason", "Проблемы с ROI")
# Check position
position = quality_metrics.get("position", {})
if not position.get("valid", True):
return "position_error", f"Позиция: {position.get('reason', 'ошибка позиционирования')}"
return "correct", "Качество соответствует норме"
def generate_quality_report(
image: np.ndarray,
segmentation: np.ndarray,
region: str,
model_prediction: int = 0,
model_confidence: float = 0.5
) -> Dict[str, Any]:
"""
Generate comprehensive quality assessment report.
"""
# 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)
quality_metrics["vertebral_contours"] = check_vertebral_contours(segmentation)
quality_metrics["spine_labels"] = check_spine_labels(segmentation, image)
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"
severity = "NONE"
quality_class = 0
else:
overall_quality = "POOR"
severity = "HIGH"
quality_class = 1
# Determine view quality
if region == "spine":
completeness = quality_metrics.get("spine_completeness", {})
view_quality = "full" if completeness.get("valid", False) else "partial"
elif region in ("hip", "hip_left", "hip_right"):
completeness = quality_metrics.get("hip_completeness", {})
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", {}),
"artifacts": quality_metrics.get("artifacts", {}),
"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
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB