develop - hack_2026

This commit is contained in:
denis 2026-09-22 21:46:59 +03:00
parent a5dff35ce4
commit 273611f252
12 changed files with 573 additions and 66 deletions

2
.gitignore vendored
View File

@ -0,0 +1,2 @@
/dataset_hack/
/.continue/

77
docs/README.md Normal file
View File

@ -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

212
docs/slides-notes.md Normal file
View File

@ -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: Партнёрство с клиниками, этические согласования, анонимизация

View File

@ -1,4 +0,0 @@
from pydicom_custom import main
if __name__ == '__main__':
main()

View File

@ -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()

0
run.sh Normal file → Executable file
View File

View File

@ -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">
<option value="">Все регионы</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 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>

View File

@ -264,6 +264,8 @@ function renderTable() {
'Ошибка';
const regionLabel = r.anatomical_region === 'spine' ? 'Позвоночник' :
r.anatomical_region === 'hip_left' ? 'Бедро левое' :
r.anatomical_region === 'hip_right' ? 'Бедро правое' :
r.anatomical_region === 'hip' ? 'Бедро' :
r.anatomical_region || '—';

View File

@ -105,58 +105,159 @@ class DXADataset(Dataset):
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]:
"""Build list of samples from annotation and DICOM files"""
samples = []
for _, row in self.annotation.iterrows():
study_uid = str(row['study_uid']).strip()
# Try different path structures
possible_paths = [
self.data_root / 'Исследования' / study_uid,
self.data_root / 'НД_для_обучения' / 'Исследования' / study_uid,
]
study_path = None
for p in possible_paths:
if p.exists():
study_path = p
break
if study_path is None:
continue
# Find all DICOM files
dcm_files = sorted(study_path.rglob('*.dcm'))
for dcm_file in dcm_files:
# Determine anatomical region from file index
# For now, we'll create samples for all regions based on annotation
# Extract anatomical region from filename
region = self._extract_anatomical_region_from_filename(dcm_file.name)
# Check each anatomical region
for region, config in self.ANATOMICAL_MAPPING.items():
total_col = config['total_column']
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
criteria = {}
for col in config['columns']:
if region is None:
# Fallback: skip files without region in name
continue
# Determine which annotation column to use based on region
if region == 'spine':
total_col = 'итог_позвоночник'
elif region == 'hip_left':
total_col = 'итог_бедро_лев'
elif region == 'hip_right':
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]):
criteria[col] = int(row[col])
samples.append({
'dcm_path': str(dcm_file),
'study_uid': study_uid,
'anatomical_region': region,
'quality': quality, # 0 = good, 1 = violation
'criteria': criteria,
'comment': row.get('комментарий', '')
})
elif region == 'hip_left':
for col in ['бедро_позиция_лев', 'бедро_roi_лев']:
if col in row and pd.notna(row[col]):
criteria[col] = int(row[col])
elif region == 'hip_right':
for col in ['бедро_позиция_прав', 'бедро_roi_прав']:
if col in row and pd.notna(row[col]):
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
def __len__(self) -> int:
@ -184,7 +285,11 @@ class DXADataset(Dataset):
# Resize
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)
# Normalize back to 0-1 for model

View File

@ -72,13 +72,140 @@ def load_dicom_image(dcm_path: str, input_size: int = 224) -> torch.Tensor:
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:
"""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:
ds = pydicom.dcmread(dcm_path)
h, w = ds.pixel_array.shape
# Heuristic: hip images are typically smaller
if h < 270 or w < 280:
return 'hip'
else:

View File

@ -117,13 +117,16 @@ def train(args):
model.model.eval()
all_preds = []
all_labels = []
with torch.no_grad():
for images, labels in val_loader:
images = images.to(device)
labels_dict = labels if isinstance(labels, dict) else {'label': labels}
labels_arr = torch.tensor([l['label'] for l in labels_dict]).to(device)
# labels is a dict with 'label' key from our dataset
if isinstance(labels, dict):
labels_arr = labels['label'].to(device)
else:
labels_arr = labels.to(device)
preds, _ = model.predict(images)
all_preds.extend(preds.cpu().numpy())
all_labels.extend(labels_arr.cpu().numpy())

View File

@ -16,7 +16,7 @@ import base64
from starlette.responses import JSONResponse, FileResponse
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
# Create app
@ -53,7 +53,7 @@ def load_model():
pretrained=False,
device=device
)
dxa_model.load('../models/dxa_model.pth')
dxa_model.load('models/dxa_model.pth')
dxa_model.model.eval()
print("DXA model loaded successfully")
except Exception as e:
@ -154,7 +154,7 @@ async def analyze_dicom(file: UploadFile = File(...)):
# Determine region
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 = {
@ -205,7 +205,7 @@ async def batch_analyze(files: list[UploadFile] = File(...)):
confidence = probs[0, pred].item()
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({
"filename": file.filename,
@ -251,7 +251,7 @@ async def export_results(files: list[UploadFile] = File(...)):
confidence = probs[0, pred].item()
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({
'filename': file.filename,