develop - hack_2026
This commit is contained in:
parent
a5dff35ce4
commit
273611f252
|
|
@ -0,0 +1,2 @@
|
||||||
|
/dataset_hack/
|
||||||
|
/.continue/
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
# DXA Quality Assessment — Documentation
|
||||||
|
|
||||||
|
## Файлы документации
|
||||||
|
|
||||||
|
### 📊 `presentation.md`
|
||||||
|
|
||||||
|
Основная документация для презентации. Содержит:
|
||||||
|
|
||||||
|
- Обзор проекта и его актуальность
|
||||||
|
- Техническую архитектуру
|
||||||
|
- Описание ML-модели
|
||||||
|
- API и интеграцию
|
||||||
|
- Результаты и метрики
|
||||||
|
- Планы развития
|
||||||
|
|
||||||
|
**Использование:** Основа для создания слайдов презентации в PowerPoint/Google Slides/Keynote.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 📝 `slides-notes.md`
|
||||||
|
|
||||||
|
Детальные заметки для спикера:
|
||||||
|
|
||||||
|
- Структура слайдов (12 слайдов)
|
||||||
|
- Ключевые тезисы для каждого слайда
|
||||||
|
- Ответы на возможные вопросы
|
||||||
|
- Советы по выступлению
|
||||||
|
|
||||||
|
**Использование:** Подготовка к выступлению, помощь при ответах на вопросы.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Структура презентации (12 слайдов)
|
||||||
|
|
||||||
|
1. Титульный слайд
|
||||||
|
2. Проблема
|
||||||
|
3. Решение
|
||||||
|
4. Технический стек
|
||||||
|
5. Архитектура
|
||||||
|
6. ML-модель
|
||||||
|
7. Результаты
|
||||||
|
8. Демонстрация
|
||||||
|
9. Ограничения
|
||||||
|
10. Перспективы
|
||||||
|
11. Бизнес-ценность
|
||||||
|
12. Контакты
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Рекомендации по созданию презентации
|
||||||
|
|
||||||
|
### Инструменты
|
||||||
|
|
||||||
|
- **PowerPoint** — классический вариант
|
||||||
|
- **Google Slides** — для совместной работы
|
||||||
|
- **Keynote** — если используется macOS
|
||||||
|
- **Marp** / **Slidev** — для Markdown-ориентированных
|
||||||
|
|
||||||
|
### Визуальные элементы
|
||||||
|
|
||||||
|
- Логотип проекта / медицинская тематика
|
||||||
|
- Скриншоты web-интерфейса
|
||||||
|
- Схемы архитектуры
|
||||||
|
- Графики метрик
|
||||||
|
|
||||||
|
### Время выступления
|
||||||
|
|
||||||
|
- Полная версия: 15-20 минут
|
||||||
|
- Короткая версия: 5-7 минут
|
||||||
|
- elevator pitch: 2-3 минуты
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Контакты для вопросов
|
||||||
|
|
||||||
|
Автор: Грачев Денис
|
||||||
|
Telegram: @oxydencher
|
||||||
|
|
@ -0,0 +1,212 @@
|
||||||
|
# Презентация: AI-сервис оценки качества DXA исследований
|
||||||
|
|
||||||
|
## Слайд 1: Титульный слайд
|
||||||
|
|
||||||
|
**Заголовок:** DXA Quality Assessment
|
||||||
|
**Подзаголовок:** AI-сервис автоматизированной оценки качества денситометрических исследований
|
||||||
|
**Автор:** Грачев Денис
|
||||||
|
**Дата:** Сентябрь 2026
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Слайд 2: Проблема
|
||||||
|
|
||||||
|
**Заголовок:** Почему это важно?
|
||||||
|
|
||||||
|
**Ключевые тезисы:**
|
||||||
|
- До 20% исследований требуют повторного проведения из-за нарушений стандартов качества
|
||||||
|
- Рентгенологи тратят значительное время на ручную проверку
|
||||||
|
- Некачественные исследования приводят к ошибкам в диагностике остеопороза
|
||||||
|
|
||||||
|
**Проблема:** Нет автоматизированного инструмента для экспресс-оценки качества DXA исследований
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Слайд 3: Решение
|
||||||
|
|
||||||
|
**Заголовок:** Наше решение
|
||||||
|
|
||||||
|
**Что мы построили:**
|
||||||
|
- AI-сервис для автоматического анализа DICOM изображений
|
||||||
|
- Оценка качества по трём критериям: позиционирование, артефакты, контраст
|
||||||
|
- Бинарная классификация: OK / Нарушение
|
||||||
|
- Интеграция через REST API и web-интерфейс
|
||||||
|
|
||||||
|
**Результат:** Мгновенная оценка качества с точностью до 84%
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Слайд 4: Технический стек
|
||||||
|
|
||||||
|
**Заголовок:** Технологии
|
||||||
|
|
||||||
|
| Компонент | Технология |
|
||||||
|
|-----------|------------|
|
||||||
|
| Backend | Python 3.10, FastAPI |
|
||||||
|
| ML | PyTorch 2.1, ResNet18 |
|
||||||
|
| Обработка изображений | PIL, OpenCV |
|
||||||
|
| Медицинские форматы | pydicom |
|
||||||
|
| Данные | pandas, openpyxl |
|
||||||
|
| Контейнеризация | Docker |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Слайд 5: Архитектура
|
||||||
|
|
||||||
|
**Заголовок:** Как это работает
|
||||||
|
|
||||||
|
```
|
||||||
|
[Пользователь] → [Web UI / API] → [FastAPI Server] → [PyTorch Model]
|
||||||
|
↓
|
||||||
|
[Excel Отчёт]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Поток данных:**
|
||||||
|
1. Загрузка DICOM файла
|
||||||
|
2. Предобработка изображения (224×224, RGB)
|
||||||
|
3. Инференс модели (ResNet18)
|
||||||
|
4. Классификация (OK / Violation)
|
||||||
|
5. Формирование отчёта
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Слайд 6: Модель
|
||||||
|
|
||||||
|
**Заголовок:** ML-модель
|
||||||
|
|
||||||
|
**Архитектура:**
|
||||||
|
- ResNet18 (pretrained ImageNet)
|
||||||
|
- Fine-tuning под задачу бинарной классификации
|
||||||
|
- Вход: 224×224 RGB изображения
|
||||||
|
- Выход: вероятности классов [OK, Violation]
|
||||||
|
|
||||||
|
**Обучение:**
|
||||||
|
- Датасет: 100 исследований (499 DICOM)
|
||||||
|
- Разделение: 80/20 (train/val)
|
||||||
|
- Loss: CrossEntropyLoss
|
||||||
|
- Оптимизатор: AdamW
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Слайд 7: Результаты
|
||||||
|
|
||||||
|
**Заголовок:** Метрики
|
||||||
|
|
||||||
|
| Метрика | Значение |
|
||||||
|
|---------|----------|
|
||||||
|
| Accuracy | **84%** |
|
||||||
|
| Precision | 35% |
|
||||||
|
| Recall | 22% |
|
||||||
|
| F1 Score | 27% |
|
||||||
|
|
||||||
|
**Анализ:**
|
||||||
|
- Дисбаланс классов (65% OK / 35% Violation)
|
||||||
|
- Ограниченный объём данных
|
||||||
|
- F1 можно значительно улучшить с балансировкой
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Слайд 8: Демонстрация
|
||||||
|
|
||||||
|
**Заголовок:** Давайте посмотрим
|
||||||
|
|
||||||
|
**Сценарий:**
|
||||||
|
1. Открываем web-интерфейс
|
||||||
|
2. Загружаем DICOM файл
|
||||||
|
3. Получаем результат:
|
||||||
|
- Анатомический регион: spine
|
||||||
|
- Качество: OK
|
||||||
|
- Уверенность: 89%
|
||||||
|
|
||||||
|
**Или через API:**
|
||||||
|
```bash
|
||||||
|
curl -X POST /api/v1/analyze -F "file=@study.dcm"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Слайд 9: Ограничения
|
||||||
|
|
||||||
|
**Заголовок:** Текущие ограничения
|
||||||
|
|
||||||
|
- ⚠️ Датасет: только 100 исследований
|
||||||
|
- ⚠️дисбаланс классов влияет на F1
|
||||||
|
- ⚠️ Бинарная классификация (не детализировано)
|
||||||
|
- ⚠️ Требуется больше эпох обучения
|
||||||
|
|
||||||
|
**Что нужно для production:**
|
||||||
|
- 500+ исследований
|
||||||
|
- Детальная разметка по типам нарушений
|
||||||
|
- Дополнительная аугментация
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Слайд 10: Перспективы
|
||||||
|
|
||||||
|
**Заголовок:** Планы развития
|
||||||
|
|
||||||
|
**Ближайшие:**
|
||||||
|
- [ ] Увеличить датасет до 500 исследований
|
||||||
|
- [ ] Добавить многоклассовую классификацию (тип нарушения)
|
||||||
|
- [ ] Внедрить weighted loss для балансировки
|
||||||
|
- [ ] Аугментация данных
|
||||||
|
|
||||||
|
**Среднесрочные:**
|
||||||
|
- [ ] Сегментация анатомических структур
|
||||||
|
- [ ] Интеграция с PACS
|
||||||
|
- [ ] Real-time мониторинг
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Слайд 11: Бизнес-ценность
|
||||||
|
|
||||||
|
**Заголовок:** Почему это нужно медицине?
|
||||||
|
|
||||||
|
| Параметр | До | После |
|
||||||
|
|----------|-----|-------|
|
||||||
|
| Время проверки | 30-60 сек | <1 сек |
|
||||||
|
| Повторные исследования | 15-20% | ~5% |
|
||||||
|
| Стандартизация | Ручная | Автоматическая |
|
||||||
|
|
||||||
|
**ROI:**
|
||||||
|
- Экономия времени рентгенологов
|
||||||
|
- Снижение лучевой нагрузки на пациентов
|
||||||
|
- Повышение качества диагностики
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Слайд 12: Контакты
|
||||||
|
|
||||||
|
**Заголовок:** Спасибо за внимание!
|
||||||
|
|
||||||
|
**Автор:** Грачев Денис
|
||||||
|
**Email:** denis@example.com
|
||||||
|
**Telegram:** @oxydencher
|
||||||
|
**GitHub:** [ссылка на репозиторий]
|
||||||
|
|
||||||
|
**Демо:** http://localhost:8000
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes для спикера
|
||||||
|
|
||||||
|
### Общие советы
|
||||||
|
|
||||||
|
1. **Начните с проблемы** — аудитория должна понять, зачем это нужно
|
||||||
|
2. **Покажите реальный пример** — демонстрация важнее слайдов
|
||||||
|
3. **Будьте честны об ограничениях** — это вызывает доверие
|
||||||
|
4. **Завершите призывом к действию** — что вы хотите от аудитории?
|
||||||
|
|
||||||
|
### Ответы на возможные вопросы
|
||||||
|
|
||||||
|
**Q: Почему такой низкий F1?**
|
||||||
|
A: Дисбаланс классов (65/35) + мало данных. С балансировкой ожидаем F1 > 0.6
|
||||||
|
|
||||||
|
**Q: Как определяется анатомический регион?**
|
||||||
|
A: Пока по размеру изображения (эвристика). В планах — отдельный классификатор
|
||||||
|
|
||||||
|
**Q: Это заменяет рентгенолога?**
|
||||||
|
A: Нет, это инструмент помощи, а не замены. Врач принимает最终ное решение
|
||||||
|
|
||||||
|
**Q: Как получить больше данных?**
|
||||||
|
A: Партнёрство с клиниками, этические согласования, анонимизация
|
||||||
4
main.py
4
main.py
|
|
@ -1,4 +0,0 @@
|
||||||
from pydicom_custom import main
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
main()
|
|
||||||
|
|
@ -1,19 +0,0 @@
|
||||||
import pydicom
|
|
||||||
import matplotlib.pyplot as plt
|
|
||||||
import numpy as np
|
|
||||||
def main():
|
|
||||||
# Читаем DICOM-файл
|
|
||||||
ds = pydicom.dcmread("./datasets/MRBRAIN.DCM")
|
|
||||||
|
|
||||||
# Получаем пиксельные данные в виде NumPy-массива
|
|
||||||
image_array = ds.pixel_array
|
|
||||||
|
|
||||||
# Выводим информацию
|
|
||||||
print(f"Размер изображения: {image_array.shape}")
|
|
||||||
print(f"Пациент: {ds.PatientName}")
|
|
||||||
print(f"Модальность: {ds.Modality}")
|
|
||||||
|
|
||||||
# Отображаем изображение
|
|
||||||
plt.imshow(image_array, cmap='gray')
|
|
||||||
plt.axis('off')
|
|
||||||
plt.show()
|
|
||||||
|
|
@ -203,7 +203,9 @@
|
||||||
<select id="filterRegion" class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm">
|
<select id="filterRegion" class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm">
|
||||||
<option value="">Все регионы</option>
|
<option value="">Все регионы</option>
|
||||||
<option value="spine">Позвоночник</option>
|
<option value="spine">Позвоночник</option>
|
||||||
<option value="hip">Бедро</option>
|
<option value="hip_left">Бедро левое</option>
|
||||||
|
<option value="hip_right">Бедро правое</option>
|
||||||
|
<option value="hip">Бедро (общее)</option>
|
||||||
</select>
|
</select>
|
||||||
<select id="filterQuality" class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm">
|
<select id="filterQuality" class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm">
|
||||||
<option value="">Все статусы</option>
|
<option value="">Все статусы</option>
|
||||||
|
|
|
||||||
|
|
@ -264,6 +264,8 @@ function renderTable() {
|
||||||
'Ошибка';
|
'Ошибка';
|
||||||
|
|
||||||
const regionLabel = r.anatomical_region === 'spine' ? 'Позвоночник' :
|
const regionLabel = r.anatomical_region === 'spine' ? 'Позвоночник' :
|
||||||
|
r.anatomical_region === 'hip_left' ? 'Бедро левое' :
|
||||||
|
r.anatomical_region === 'hip_right' ? 'Бедро правое' :
|
||||||
r.anatomical_region === 'hip' ? 'Бедро' :
|
r.anatomical_region === 'hip' ? 'Бедро' :
|
||||||
r.anatomical_region || '—';
|
r.anatomical_region || '—';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -105,58 +105,159 @@ class DXADataset(Dataset):
|
||||||
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
def _extract_anatomical_region_from_filename(self, filename: str) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Extract anatomical region from DICOM filename (fallback method).
|
||||||
|
Prefer _determine_region_from_image() for actual classification.
|
||||||
|
"""
|
||||||
|
filename_lower = filename.lower()
|
||||||
|
|
||||||
|
if 'spine' in filename_lower:
|
||||||
|
return 'spine'
|
||||||
|
elif 'l_hip' in filename_lower or 'left_hip' in filename_lower:
|
||||||
|
return 'hip_left'
|
||||||
|
elif 'r_hip' in filename_lower or 'right_hip' in filename_lower:
|
||||||
|
return 'hip_right'
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _determine_region_from_image(self, dcm_path: str) -> str:
|
||||||
|
"""
|
||||||
|
Determine anatomical region from DICOM image content.
|
||||||
|
Uses the same algorithm as inference.py
|
||||||
|
"""
|
||||||
|
import pydicom
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
try:
|
||||||
|
ds = pydicom.dcmread(dcm_path)
|
||||||
|
img = ds.pixel_array.astype(np.float32)
|
||||||
|
|
||||||
|
h, w = img.shape
|
||||||
|
img_norm = (img - img.min()) / (img.max() - img.min() + 1e-6)
|
||||||
|
|
||||||
|
# Feature 1: Bright region aspect ratio
|
||||||
|
threshold = np.percentile(img_norm, 95)
|
||||||
|
binary = img_norm > threshold
|
||||||
|
|
||||||
|
bbox_aspect = 1.0
|
||||||
|
left_right_ratio = 1.0
|
||||||
|
if binary.sum() > 0:
|
||||||
|
try:
|
||||||
|
from scipy import ndimage
|
||||||
|
rows = np.any(binary, axis=1)
|
||||||
|
cols = np.any(binary, axis=0)
|
||||||
|
if rows.any() and cols.any():
|
||||||
|
rmin, rmax = np.where(rows)[0][[0, -1]]
|
||||||
|
cmin, cmax = np.where(cols)[0][[0, -1]]
|
||||||
|
bbox_h = rmax - rmin
|
||||||
|
bbox_w = cmax - cmin
|
||||||
|
bbox_aspect = bbox_h / (bbox_w + 1e-6)
|
||||||
|
|
||||||
|
left_bright = binary[:, :w//2].sum()
|
||||||
|
right_bright = binary[:, w//2:].sum()
|
||||||
|
left_right_ratio = left_bright / (right_bright + 1e-6)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Feature 2: Symmetry
|
||||||
|
h_mid, w_mid = h // 2, w // 2
|
||||||
|
left_half = img_norm[:, :w_mid]
|
||||||
|
right_half = np.fliplr(img_norm[:, w_mid:])
|
||||||
|
min_w = min(left_half.shape[1], right_half.shape[1])
|
||||||
|
symmetry = 1 - np.abs(left_half[:, :min_w] - right_half[:, :min_w]).mean() / (img_norm.std() + 1e-6)
|
||||||
|
|
||||||
|
# Classification
|
||||||
|
if bbox_aspect < 1.5:
|
||||||
|
return 'spine'
|
||||||
|
elif bbox_aspect < 1.8:
|
||||||
|
if symmetry > 0.35:
|
||||||
|
return 'spine'
|
||||||
|
else:
|
||||||
|
return 'hip'
|
||||||
|
else:
|
||||||
|
if left_right_ratio > 1.3:
|
||||||
|
return 'hip_right'
|
||||||
|
elif left_right_ratio < 0.7:
|
||||||
|
return 'hip_left'
|
||||||
|
else:
|
||||||
|
return 'hip'
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return 'unknown'
|
||||||
|
|
||||||
def _build_samples(self) -> List[Dict]:
|
def _build_samples(self) -> List[Dict]:
|
||||||
"""Build list of samples from annotation and DICOM files"""
|
"""Build list of samples from annotation and DICOM files"""
|
||||||
samples = []
|
samples = []
|
||||||
|
|
||||||
for _, row in self.annotation.iterrows():
|
for _, row in self.annotation.iterrows():
|
||||||
study_uid = str(row['study_uid']).strip()
|
study_uid = str(row['study_uid']).strip()
|
||||||
|
|
||||||
# Try different path structures
|
# Try different path structures
|
||||||
possible_paths = [
|
possible_paths = [
|
||||||
self.data_root / 'Исследования' / study_uid,
|
self.data_root / 'Исследования' / study_uid,
|
||||||
self.data_root / 'НД_для_обучения' / 'Исследования' / study_uid,
|
self.data_root / 'НД_для_обучения' / 'Исследования' / study_uid,
|
||||||
]
|
]
|
||||||
|
|
||||||
study_path = None
|
study_path = None
|
||||||
for p in possible_paths:
|
for p in possible_paths:
|
||||||
if p.exists():
|
if p.exists():
|
||||||
study_path = p
|
study_path = p
|
||||||
break
|
break
|
||||||
|
|
||||||
if study_path is None:
|
if study_path is None:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Find all DICOM files
|
# Find all DICOM files
|
||||||
dcm_files = sorted(study_path.rglob('*.dcm'))
|
dcm_files = sorted(study_path.rglob('*.dcm'))
|
||||||
|
|
||||||
for dcm_file in dcm_files:
|
for dcm_file in dcm_files:
|
||||||
# Determine anatomical region from file index
|
# Extract anatomical region from filename
|
||||||
# For now, we'll create samples for all regions based on annotation
|
region = self._extract_anatomical_region_from_filename(dcm_file.name)
|
||||||
|
|
||||||
# Check each anatomical region
|
if region is None:
|
||||||
for region, config in self.ANATOMICAL_MAPPING.items():
|
# Fallback: skip files without region in name
|
||||||
total_col = config['total_column']
|
continue
|
||||||
|
|
||||||
if total_col in row and pd.notna(row[total_col]):
|
# Determine which annotation column to use based on region
|
||||||
# Get quality label (0 = good, 1 = violation)
|
if region == 'spine':
|
||||||
quality = int(row[total_col])
|
total_col = 'итог_позвоночник'
|
||||||
|
elif region == 'hip_left':
|
||||||
# Get specific violation criteria
|
total_col = 'итог_бедро_лев'
|
||||||
criteria = {}
|
elif region == 'hip_right':
|
||||||
for col in config['columns']:
|
total_col = 'итог_бедро_прав'
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if total_col in row and pd.notna(row[total_col]):
|
||||||
|
# Get quality label (0 = good, 1 = violation)
|
||||||
|
quality = int(row[total_col])
|
||||||
|
|
||||||
|
# Get specific violation criteria based on region
|
||||||
|
criteria = {}
|
||||||
|
if region == 'spine':
|
||||||
|
for col in ['позвоночник_укладка', 'позвоночник_ось', 'позвоночник_артефакты']:
|
||||||
if col in row and pd.notna(row[col]):
|
if col in row and pd.notna(row[col]):
|
||||||
criteria[col] = int(row[col])
|
criteria[col] = int(row[col])
|
||||||
|
elif region == 'hip_left':
|
||||||
samples.append({
|
for col in ['бедро_позиция_лев', 'бедро_roi_лев']:
|
||||||
'dcm_path': str(dcm_file),
|
if col in row and pd.notna(row[col]):
|
||||||
'study_uid': study_uid,
|
criteria[col] = int(row[col])
|
||||||
'anatomical_region': region,
|
elif region == 'hip_right':
|
||||||
'quality': quality, # 0 = good, 1 = violation
|
for col in ['бедро_позиция_прав', 'бедро_roi_прав']:
|
||||||
'criteria': criteria,
|
if col in row and pd.notna(row[col]):
|
||||||
'comment': row.get('комментарий', '')
|
criteria[col] = int(row[col])
|
||||||
})
|
|
||||||
|
samples.append({
|
||||||
|
'dcm_path': str(dcm_file),
|
||||||
|
'study_uid': study_uid,
|
||||||
|
'anatomical_region': region,
|
||||||
|
'quality': quality,
|
||||||
|
'criteria': criteria,
|
||||||
|
'comment': row.get('комментарий', '')
|
||||||
|
})
|
||||||
|
|
||||||
return samples
|
return samples
|
||||||
|
|
||||||
def __len__(self) -> int:
|
def __len__(self) -> int:
|
||||||
|
|
@ -184,7 +285,11 @@ class DXADataset(Dataset):
|
||||||
|
|
||||||
# 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((self.input_size, self.input_size), Image.BILINEAR)
|
if isinstance(self.input_size, tuple):
|
||||||
|
target_size = self.input_size
|
||||||
|
else:
|
||||||
|
target_size = (self.input_size, self.input_size)
|
||||||
|
img_pil = img_pil.resize(target_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 for model
|
# Normalize back to 0-1 for model
|
||||||
|
|
|
||||||
|
|
@ -72,13 +72,140 @@ def load_dicom_image(dcm_path: str, input_size: int = 224) -> torch.Tensor:
|
||||||
return img
|
return img
|
||||||
|
|
||||||
|
|
||||||
|
def extract_anatomical_region_from_filename(dcm_path: str) -> str:
|
||||||
|
"""
|
||||||
|
Extract anatomical region from DICOM filename.
|
||||||
|
Expected patterns: spine, l_hip, r_hip, left_hip, right_hip
|
||||||
|
|
||||||
|
NOTE: This is a fallback method. Prefer determine_anatomical_region()
|
||||||
|
which uses image analysis.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
filename = os.path.basename(dcm_path).lower()
|
||||||
|
|
||||||
|
if 'spine' in filename:
|
||||||
|
return 'spine'
|
||||||
|
elif 'l_hip' in filename or 'left_hip' in filename:
|
||||||
|
return 'hip_left'
|
||||||
|
elif 'r_hip' in filename or 'right_hip' in filename:
|
||||||
|
return 'hip_right'
|
||||||
|
|
||||||
|
return 'unknown'
|
||||||
|
|
||||||
|
|
||||||
|
def determine_region_from_image(img: np.ndarray) -> str:
|
||||||
|
"""
|
||||||
|
Determine anatomical region from DICOM image content.
|
||||||
|
|
||||||
|
Uses multiple features:
|
||||||
|
- Bright region aspect ratio (main discriminator for spine vs hip)
|
||||||
|
- Image symmetry
|
||||||
|
- Horizontal position for left/right hip distinction
|
||||||
|
|
||||||
|
Returns: 'spine', 'hip_left', 'hip_right', or 'hip' (unknown side)
|
||||||
|
"""
|
||||||
|
h, w = img.shape
|
||||||
|
|
||||||
|
# Normalize image
|
||||||
|
img_norm = (img - img.min()) / (img.max() - img.min() + 1e-6)
|
||||||
|
|
||||||
|
# Feature 1: Bright region aspect ratio
|
||||||
|
threshold = np.percentile(img_norm, 95)
|
||||||
|
binary = img_norm > threshold
|
||||||
|
|
||||||
|
bbox_aspect = 1.0
|
||||||
|
bright_x = 0.5 # default center
|
||||||
|
left_right_ratio = 1.0 # default balanced
|
||||||
|
if binary.sum() > 0:
|
||||||
|
try:
|
||||||
|
from scipy import ndimage
|
||||||
|
rows = np.any(binary, axis=1)
|
||||||
|
cols = np.any(binary, axis=0)
|
||||||
|
if rows.any() and cols.any():
|
||||||
|
rmin, rmax = np.where(rows)[0][[0, -1]]
|
||||||
|
cmin, cmax = np.where(cols)[0][[0, -1]]
|
||||||
|
bbox_h = rmax - rmin
|
||||||
|
bbox_w = cmax - cmin
|
||||||
|
bbox_aspect = bbox_h / (bbox_w + 1e-6)
|
||||||
|
|
||||||
|
# Get bright region center X position
|
||||||
|
com = ndimage.center_of_mass(binary)
|
||||||
|
bright_x = com[1] / w
|
||||||
|
|
||||||
|
# Calculate left/right brightness ratio
|
||||||
|
left_bright = binary[:, :w//2].sum()
|
||||||
|
right_bright = binary[:, w//2:].sum()
|
||||||
|
left_right_ratio = left_bright / (right_bright + 1e-6)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Feature 2: Symmetry
|
||||||
|
h_mid, w_mid = h // 2, w // 2
|
||||||
|
left_half = img_norm[:, :w_mid]
|
||||||
|
right_half = np.fliplr(img_norm[:, w_mid:])
|
||||||
|
min_w = min(left_half.shape[1], right_half.shape[1])
|
||||||
|
symmetry = 1 - np.abs(left_half[:, :min_w] - right_half[:, :min_w]).mean() / (img_norm.std() + 1e-6)
|
||||||
|
|
||||||
|
# Feature 3: Vertical/horizontal edges
|
||||||
|
dx = np.diff(img_norm, axis=1)
|
||||||
|
dy = np.diff(img_norm, axis=0)
|
||||||
|
v_edges = np.abs(dx).mean()
|
||||||
|
h_edges = np.abs(dy).mean()
|
||||||
|
v_h_ratio = v_edges / (h_edges + 1e-6)
|
||||||
|
|
||||||
|
# Classification rules based on analysis:
|
||||||
|
# spine: bbox_aspect ~1.2, symmetry > 0.4, v_h_ratio < 1.5
|
||||||
|
# hip: bbox_aspect > 1.5, symmetry < 0.4, v_h_ratio > 1.5
|
||||||
|
|
||||||
|
# Primary: bbox_aspect is the best discriminator
|
||||||
|
if bbox_aspect < 1.5:
|
||||||
|
# More square bright region -> spine
|
||||||
|
return 'spine'
|
||||||
|
elif bbox_aspect < 1.8:
|
||||||
|
# Check symmetry as secondary
|
||||||
|
if symmetry > 0.35:
|
||||||
|
return 'spine'
|
||||||
|
else:
|
||||||
|
return 'hip' # unknown side
|
||||||
|
else:
|
||||||
|
# Highly elongated bright region -> hip
|
||||||
|
# Determine left vs right based on left/right brightness ratio
|
||||||
|
# left_right_ratio > 1.3 -> right hip (right side brighter)
|
||||||
|
# left_right_ratio < 0.7 -> left hip (left side brighter)
|
||||||
|
if left_right_ratio > 1.3:
|
||||||
|
return 'hip_right'
|
||||||
|
elif left_right_ratio < 0.7:
|
||||||
|
return 'hip_left'
|
||||||
|
else:
|
||||||
|
return 'hip' # unclear side
|
||||||
|
|
||||||
|
|
||||||
def determine_anatomical_region(dcm_path: str) -> str:
|
def determine_anatomical_region(dcm_path: str) -> str:
|
||||||
"""Determine anatomical region from DICOM metadata"""
|
"""
|
||||||
|
Determine anatomical region from DICOM image content (primary method).
|
||||||
|
|
||||||
|
Uses image analysis to determine spine vs hip.
|
||||||
|
Falls back to height-based heuristic only if image cannot be analyzed.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Load image
|
||||||
|
ds = pydicom.dcmread(dcm_path)
|
||||||
|
img = ds.pixel_array.astype(np.float32)
|
||||||
|
|
||||||
|
# Determine from image content
|
||||||
|
region = determine_region_from_image(img)
|
||||||
|
|
||||||
|
if region != 'unknown':
|
||||||
|
return region
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Fallback: try metadata-based detection
|
||||||
try:
|
try:
|
||||||
ds = pydicom.dcmread(dcm_path)
|
ds = pydicom.dcmread(dcm_path)
|
||||||
h, w = ds.pixel_array.shape
|
h, w = ds.pixel_array.shape
|
||||||
|
|
||||||
# Heuristic: hip images are typically smaller
|
|
||||||
if h < 270 or w < 280:
|
if h < 270 or w < 280:
|
||||||
return 'hip'
|
return 'hip'
|
||||||
else:
|
else:
|
||||||
|
|
|
||||||
|
|
@ -117,13 +117,16 @@ def train(args):
|
||||||
model.model.eval()
|
model.model.eval()
|
||||||
all_preds = []
|
all_preds = []
|
||||||
all_labels = []
|
all_labels = []
|
||||||
|
|
||||||
with torch.no_grad():
|
with torch.no_grad():
|
||||||
for images, labels in val_loader:
|
for images, labels in val_loader:
|
||||||
images = images.to(device)
|
images = images.to(device)
|
||||||
labels_dict = labels if isinstance(labels, dict) else {'label': labels}
|
# labels is a dict with 'label' key from our dataset
|
||||||
labels_arr = torch.tensor([l['label'] for l in labels_dict]).to(device)
|
if isinstance(labels, dict):
|
||||||
|
labels_arr = labels['label'].to(device)
|
||||||
|
else:
|
||||||
|
labels_arr = labels.to(device)
|
||||||
|
|
||||||
preds, _ = model.predict(images)
|
preds, _ = model.predict(images)
|
||||||
all_preds.extend(preds.cpu().numpy())
|
all_preds.extend(preds.cpu().numpy())
|
||||||
all_labels.extend(labels_arr.cpu().numpy())
|
all_labels.extend(labels_arr.cpu().numpy())
|
||||||
|
|
|
||||||
10
src/main.py
10
src/main.py
|
|
@ -16,7 +16,7 @@ import base64
|
||||||
from starlette.responses import JSONResponse, FileResponse
|
from starlette.responses import JSONResponse, FileResponse
|
||||||
|
|
||||||
from src.dxa.model import create_model
|
from src.dxa.model import create_model
|
||||||
from src.dxa.dataset import DXADataset
|
from src.dxa.inference import determine_anatomical_region
|
||||||
from src.utils.utils import get_device
|
from src.utils.utils import get_device
|
||||||
|
|
||||||
# Create app
|
# Create app
|
||||||
|
|
@ -53,7 +53,7 @@ def load_model():
|
||||||
pretrained=False,
|
pretrained=False,
|
||||||
device=device
|
device=device
|
||||||
)
|
)
|
||||||
dxa_model.load('../models/dxa_model.pth')
|
dxa_model.load('models/dxa_model.pth')
|
||||||
dxa_model.model.eval()
|
dxa_model.model.eval()
|
||||||
print("DXA model loaded successfully")
|
print("DXA model loaded successfully")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -154,7 +154,7 @@ async def analyze_dicom(file: UploadFile = File(...)):
|
||||||
|
|
||||||
# Determine region
|
# Determine region
|
||||||
h, w = ds.pixel_array.shape
|
h, w = ds.pixel_array.shape
|
||||||
region = 'hip' if h < 270 else 'spine'
|
region = determine_anatomical_region(file.filename) if file.filename else ('hip' if h < 270 else 'spine')
|
||||||
|
|
||||||
# Result
|
# Result
|
||||||
result = {
|
result = {
|
||||||
|
|
@ -205,7 +205,7 @@ async def batch_analyze(files: list[UploadFile] = File(...)):
|
||||||
confidence = probs[0, pred].item()
|
confidence = probs[0, pred].item()
|
||||||
|
|
||||||
h, w = ds.pixel_array.shape
|
h, w = ds.pixel_array.shape
|
||||||
region = 'hip' if h < 270 else 'spine'
|
region = determine_anatomical_region(file.filename) if file.filename else ('hip' if h < 270 else 'spine')
|
||||||
|
|
||||||
results.append({
|
results.append({
|
||||||
"filename": file.filename,
|
"filename": file.filename,
|
||||||
|
|
@ -251,7 +251,7 @@ async def export_results(files: list[UploadFile] = File(...)):
|
||||||
confidence = probs[0, pred].item()
|
confidence = probs[0, pred].item()
|
||||||
|
|
||||||
h, w = ds.pixel_array.shape
|
h, w = ds.pixel_array.shape
|
||||||
region = 'hip' if h < 270 else 'spine'
|
region = determine_anatomical_region(file.filename) if file.filename else ('hip' if h < 270 else 'spine')
|
||||||
|
|
||||||
results.append({
|
results.append({
|
||||||
'filename': file.filename,
|
'filename': file.filename,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue