304 lines
23 KiB
Markdown
304 lines
23 KiB
Markdown
# Bone Quality Assessment Project
|
||
|
||
## Project Overview
|
||
|
||
Медицинский ИИ-сервис для автоматизированной оценки качества денситометрических
|
||
исследований (DXA). Принимает DICOM, определяет анатомическую область, решает
|
||
бинарную задачу «качественное изображение / есть нарушение» и формирует отчёт.
|
||
|
||
### Ключевые требования (condition.txt)
|
||
|
||
- Области: поясничный отдел позвоночника и проксимальный отдел бедренной кости.
|
||
- Обязательна контейнеризация и скрипт сборки/запуска в Linux.
|
||
- Результат: XLSX/CSV, одна строка на изображение, столбцы
|
||
`path_to_study, study_uid, image_uid, anatomical_region, quality_class,
|
||
violation_type, processing_status, time_of_processing`.
|
||
- Приоритетные метрики: F1 и ROC-AUC (с 95 % доверительными интервалами).
|
||
- Работа офлайн, без передачи изображений во внешние сервисы.
|
||
- Время обработки одного исследования — не более 3 минут.
|
||
|
||
### Технологии
|
||
|
||
| Компонент | Технология |
|
||
|---|---|
|
||
| Backend | Python, FastAPI, Uvicorn |
|
||
| ML | PyTorch, torchvision (ResNet18) |
|
||
| Изображения | pydicom, Pillow, OpenCV, SciPy |
|
||
| Данные | pandas, openpyxl, scikit-learn |
|
||
| Тесты | pytest |
|
||
|
||
---
|
||
|
||
## Действующая архитектура
|
||
|
||
Всё, что реально работает, находится в `src/dxa/` и `src/main.py`.
|
||
|
||
```
|
||
src/dxa/
|
||
├── labels.py # имена -> метки, склейка дублей, разбиение, фиксация сплита
|
||
├── excel_labels.py # разметка снимков по экспертной таблице (labels_images.csv)
|
||
├── rename_files.py # приведение имён DICOM к виду область_NN[_метка]
|
||
├── violations.py # единый словарь типов нарушений (коды, подписи, коды SR)
|
||
├── model_card.py # карточка решения: разметка, данные, метрики, ограничения
|
||
├── compare_labels.py # сравнение источников разметки на одном held-out наборе
|
||
├── render.py # рендер DICOM в PNG и контактные листы (для ручной проверки)
|
||
├── preprocess.py # DICOM -> CHW-тензор (единый путь для обучения и API)
|
||
├── dataset.py # DXADataset, DataLoader
|
||
├── model.py # сеть, метрики, подбор порога, сохранение/загрузка
|
||
├── train.py # обучение + отчёт (md/json)
|
||
└── inference.py # пакетный инференс, определение области, визуализация
|
||
```
|
||
|
||
Артефакты вне кода: `labels/labels_images.csv|.xlsx` (официальная разметка),
|
||
`labels/labels_images_{table,union,expert}.csv` (варианты правила и эталон),
|
||
`labels/split_expert_seed42.json` (зафиксированное разбиение),
|
||
`labels/rename_map.csv` (карта переименований), `docs/labeling.md`
|
||
(как построена разметка и как выбиралось правило), `models/archive/`
|
||
(прежние чекпоинты), `models/compare_rules/` (чекпоинты и отчёт сравнения правил).
|
||
|
||
### Ключевые решения (проверены экспериментально)
|
||
|
||
| Решение | Причина |
|
||
|---|---|
|
||
| Единый словарь типов нарушений (`src/dxa/violations.py`) | Коды, подписи и коды SR были в трёх копиях (инференс, `main.py`, `dxa-app.js`) и не знали кодов экспертной таблицы. Теперь подписи отдаёт сервер, фронт копий не держит |
|
||
| Метки только из экспертной таблицы (правило `table`): `labels/labels_images.csv`, 77 нарушений | Таблица описывает исследование, но каждая область встречается в нём один раз, поэтому вердикт переносится на снимок однозначно. Правило выбрано измерением: учёт ручных пометок из имён файлов дал ROC-AUC 0.6199 против 0.6764, хуже на всех 5 seed'ах. См. `docs/labeling.md` |
|
||
| Склейка побайтных дублей | 544 файла, но 252 уникальных снимка; без склейки снимок попадал в оба класса |
|
||
| Разбиение по исследованиям, не по снимкам | Исключение утечки: снимки одного исследования в одной части |
|
||
| Линейный зонд (замороженный backbone) | Полный fine-tune при ~250 снимках переобучается (val AUC → 0.5) |
|
||
| Порог по логиту, подбор по F1 | При доле нарушений около 30 % порог 0.5 даёт почти нулевой recall; вероятности насыщаются |
|
||
| Аугментация выключена по умолчанию | Яркость и положение сами являются признаками качества: AUC 0.87 → 0.56 |
|
||
| Область по ширине кадра | Позвоночник 300 px, бедро 280 px; 99/99 для позвоночника |
|
||
| Панель деталей показывает измерения, а не вердикты | Эвристики `detailed_assessment` не калиброваны: `motion_detected`/`any_detected` истинны почти всегда, ROI-отступы срабатывают для 227/252 снимков |
|
||
|
||
### Проверка вклада модели
|
||
|
||
Не является ли модель просто детектором анатомии (нарушений около трети и в
|
||
позвоночнике, и у бёдер, а область почти однозначно определяется по ширине кадра):
|
||
|
||
```bash
|
||
python -m src.dxa.discriminator --model-path models/dxa_model.pth
|
||
```
|
||
|
||
Результат на рабочем чекпоинте `models/dxa_model.pth` (оценка на всём наборе,
|
||
включая обучающие снимки, поэтому значения смещены вверх):
|
||
|
||
| Предиктор | Общий AUC | spine | hip_right | hip_left |
|
||
|---|---|---|---|---|
|
||
| Модель | 0.854 | 0.928 | 0.861 | 0.853 |
|
||
| Правило «позвоночник = нарушение» | 0.529 | 0.500 | 0.500 | 0.500 |
|
||
|
||
Модель использует содержимое снимка: внутри областей она даёт 0.85–0.93.
|
||
Правило по области внутри области всегда 0.50 (подсказки нет). Честная оценка на
|
||
held-out — в `docs/labeling.md` §8: ROC-AUC 0.6764 [0.6309, 0.7218] по пяти seed'ам.
|
||
|
||
## Данные (`dataset_hack/`)
|
||
|
||
Структура:
|
||
|
||
```
|
||
dataset_hack/
|
||
├── Для теста/ # bad.dcm, l_hip.dcm, r_hip.dcm, spine.dcm
|
||
└── НД_для_обучения/
|
||
├── разметка.xlsx # экспертная оценка на уровне ИССЛЕДОВАНИЯ
|
||
└── Исследования/<study_uid>/.../<region>_<n>[_good|_bad].dcm
|
||
```
|
||
|
||
Факты, важные для обучения:
|
||
|
||
- 544 файла на диске, но **252 уникальных снимка** (по пиксельному содержимому)
|
||
на 100 исследований: позвоночник 99, бедро R 79, бедро L 73, 1 с неопределённой
|
||
областью.
|
||
- Экспертная таблица отмечает нарушения у **74 снимков (29.4 %)**; три снимка
|
||
таблица область не оценивала.
|
||
- Рабочая разметка (правило `table`) — **77 нарушений из 252 (30.6 %)**: 74 по
|
||
таблице плюс 3 снимка без экспертной оценки, помеченных `filename_fallback`.
|
||
- Дубли не пересекают границы исследований, конфликтов меток при склейке нет.
|
||
Два побайтных дубля названы по-разному, поэтому область определяется
|
||
голосованием по именам файлов.
|
||
- Имена файлов приведены к виду `<область>_<NN>[_good|_bad].dcm` (`spine`,
|
||
`l_hip`, `r_hip`); инструмент — `src/dxa/rename_files.py`, карта отката —
|
||
`labels/rename_map.csv`. Суффиксы `_good`/`_bad` проставлялись вручную, в
|
||
метках **не участвуют** — только как диагностический столбец
|
||
`quality_from_filename`: они расходились с оценкой эксперта в 15 случаях из 252.
|
||
- В DICOM **нет** разметки ROI (ни OverlayData, ни GraphicAnnotationSequence) и
|
||
пусты теги `Laterality`/`ImageLaterality`, поэтому ни корректность областей, ни
|
||
сторону бедра нельзя проверить по метаданным.
|
||
- Столбец `study` в таблице — имя каталога исследования, а **не**
|
||
StudyInstanceUID из DICOM (в датасете они разные, соответствие 1:1).
|
||
|
||
### Единица разметки
|
||
|
||
Таблица описывает исследование, а не снимок. Однако каждая анатомическая область
|
||
встречается в исследовании ровно один раз (после склейки дублей), поэтому вердикт
|
||
исследования по области переносится на снимок однозначно — не нужно решать, какой
|
||
из нескольких снимков «плохой». Так получены метки и типы нарушений
|
||
(`labels/labels_images.csv`); каждый источник свидетельства сохранён в отдельном
|
||
столбце, поэтому правило можно переиграть без повторного разбора. Почему выбрано
|
||
именно правило «только таблица» — в `docs/labeling.md` §4.
|
||
|
||
---
|
||
|
||
## Обучение
|
||
|
||
```bash
|
||
./run.sh label # построить разметку снимков по Excel
|
||
./run.sh rename # план приведения имён файлов (--apply)
|
||
./run.sh train # режим по умолчанию (метки из таблицы)
|
||
python -m src.dxa.train --dry-run # проверить данные без обучения
|
||
python -m src.dxa.train --labels-csv "" --dry-run # режим меток из имён файлов
|
||
python -m src.dxa.train --head mlp --freeze-epochs 0 --epochs 30
|
||
```
|
||
|
||
Источник меток — `--labels-csv` (по умолчанию `labels/labels_images.csv` с правилом
|
||
`table`; пустая строка возвращает метки из имён файлов, отсутствующий файл — откат
|
||
к ним с предупреждением). Разбиение фиксируется (`--export-split` / `--split-file`),
|
||
чтобы сравнивать варианты на одном held-out наборе:
|
||
|
||
```bash
|
||
./run.sh split && ./run.sh compare # выбор правила разметки
|
||
```
|
||
|
||
`split` стратифицирует по эталону (`labels/labels_images_expert.csv`), `compare`
|
||
прогоняет 5 seed'ов × 2 варианта и оценивает оба по этому же эталону; отчёт —
|
||
`models/compare_rules/rule_comparison.md`. Возобновить без переобучения —
|
||
`./run.sh compare --skip-training`.
|
||
|
||
Артефакты в `--output-dir`: `dxa_model.pth` (веса, порог, параметры
|
||
предобработки), `train_report.md`, `train_report.json`.
|
||
|
||
Чекпоинт самодостаточен: `backbone`, `head`, `preprocess`, `threshold`, а также
|
||
`labels_csv` и `split_file` хранятся внутри, поэтому инференс не может
|
||
рассинхронизироваться с обучением, а по файлу видно, на какой разметке он обучен.
|
||
|
||
Рабочий чекпоинт — `models/dxa_model.pth` (правило `table`, seed 42 по умолчанию,
|
||
эпоха 39, порог логита −0.4930 → вероятность 0.379; val ROC-AUC 0.6706 при
|
||
честной оценке 0.6764 [0.6309, 0.7218] по пяти seed'ам). Прежние чекпоинты —
|
||
в `models/archive/` (см. README внутри), откат одной командой `cp`.
|
||
|
||
---
|
||
|
||
## API (`src/main.py`)
|
||
|
||
| Метод | Путь | Назначение |
|
||
|---|---|---|
|
||
| GET | `/` | Веб-интерфейс |
|
||
| GET | `/api/v1/health` | Статус, признак загрузки модели и её происхождение (разметка, разбиение, порог, эпоха) |
|
||
| GET | `/api/v1/model` | Карточка решения: разметка, данные, метрики с интервалами, словарь нарушений, ограничения |
|
||
| 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 |
|
||
|
||
Путь к модели — переменная окружения `DXA_MODEL_PATH` (по умолчанию
|
||
`models/dxa_model.pth`), чтобы контейнер не зависел от рабочего каталога.
|
||
API и CLI используют один код предсказания (`predict_from_bytes` /
|
||
`predict_from_array`), поэтому предобработка и порог совпадают.
|
||
|
||
---
|
||
|
||
## Тесты
|
||
|
||
```bash
|
||
./run.sh test
|
||
python -m pytest tests/ -q # 208 тестов
|
||
```
|
||
|
||
- `tests/test_labels.py` — разбор имён, склейка дублей, отсутствие утечки при
|
||
разбиении, фиксация разбиения в файле (`export_split` / `load_split`).
|
||
- `tests/test_rename_files.py` — приведение имён: разбор, поиск свободного
|
||
номера при конфликте, отказ от угадывания области, цикл «применить → откатить».
|
||
- `tests/test_excel_labels.py` — разметка по экспертной таблице: чтение
|
||
критериев, «1 = нарушение», голосование по области, перенос оценки на
|
||
единственное бедро, три правила метки (`table` / `union` / `expert`) и их
|
||
согласованность, подключение к обучению.
|
||
- `tests/test_violations.py` — единый словарь типов: коды и подписи, коды SR,
|
||
приведение устаревших значений, согласованность с критериями таблицы.
|
||
- `tests/test_preprocess_and_model.py` — предобработка, метрики, подбор порога,
|
||
контракт модели, BatchNorm при заморозке, roundtrip чекпоинта.
|
||
- `tests/test_api_contract.py` — поля ответов, которые читает `dxa-app.js`
|
||
(панель деталей ранее показывала прочерки из-за расхождения ключей),
|
||
различимость метрик между снимками, валидность PNG-визуализаций, отсутствие
|
||
некалиброванных вердиктов в ответе.
|
||
|
||
### Проверка веб-интерфейса в браузере
|
||
|
||
`tests/browser/*.js` (Node + playwright-core из bundled Browser Use) открывают
|
||
интерфейс, загружают DICOM, кликают по строкам таблицы и снимают содержимое
|
||
панели деталей. Используется временный профиль Chrome, профиль пользователя не
|
||
затрагивается. Требуется запущенный сервер на `127.0.0.1:8123`.
|
||
|
||
- `ui_check.js` — подсказка о кликабельности строк видна и скрывается, когда
|
||
фильтр не оставил строк; кнопка «Открыть» в строке открывает панель деталей;
|
||
значения панели меняются при переключении строк; панель «О модели» наполняется
|
||
метриками и словарём (иначе раздел остался бы пустым каркасом).
|
||
- `ui_violation.js` — ветка «нарушение» (бейдж, POOR, HIGH, заключение).
|
||
- `ui_offline.js` — страница не обращается к внешним хостам.
|
||
|
||
### Честность интерфейса
|
||
|
||
Веб-интерфейс не должен утверждать больше, чем известно решению, поэтому:
|
||
|
||
- тип нарушения показан с пометкой «эвристика» и пояснением, что модель решает
|
||
только бинарную задачу;
|
||
- ROC-AUC рабочего чекпоинта в баннере состояния помечена как завышенная (он
|
||
выбран лучшим из пяти seed'ов), а честная оценка лежит в панели «О модели»;
|
||
- плитка средней уверенности не называется точностью;
|
||
- подписи типов и метрики приходят с сервера (`/api/v1/model`), копий в JS нет.
|
||
|
||
### Офлайн-работа фронтенда
|
||
|
||
Tailwind и FontAwesome лежат локально (`src/api/static/vendor`,
|
||
`src/api/static/webfonts`), страница не обращается к CDN. Требование методики —
|
||
работа без внешних сервисов; CDN-версии ломали оформление в закрытом контуре.
|
||
Наличие ассетов проверяется на этапе сборки образа (см. Dockerfile).
|
||
|
||
---
|
||
|
||
## Известные ограничения
|
||
|
||
1. Разметка снимков выведена из таблицы, описывающей исследование: поштучной
|
||
экспертной оценки снимков в наборе нет. Оценка качества модели упирается в
|
||
качество этой разметки, а не только в объём данных.
|
||
2. Мало данных: 252 снимка, 77 нарушений; доверительные интервалы широкие
|
||
(ROC-AUC 0.6764 [0.6309, 0.7218] по пяти seed'ам).
|
||
3. Эталон оценки — та же экспертная таблица, независимой истины нет; сравнение
|
||
правил разметки частично благоприятствует варианту «только таблица».
|
||
4. Тип нарушения определяется эвристиками, а не обученной моделью; 5 снимков
|
||
имеют только `unspecified`, потому что источник не указывает критерий.
|
||
5. Три снимка без экспертной оценки размечены по пометке в имени файла и помечены
|
||
`filename_fallback`.
|
||
6. Сторона бедра в 7 исследованиях с единственным снимком не проверяема: теги
|
||
`Laterality` пусты, оценка взята из единственного заполненного столбца.
|
||
7. Порог `SPINE_MIN_WIDTH` привязан к текущему оборудованию.
|
||
8. Grad-CAM (`src/models/visualization/gradcam.py`) есть, но не подключён.
|
||
|
||
## Устаревший код (не подключён к API)
|
||
|
||
Эти модули не импортируются из `src/main.py` и `src/dxa/*`; их зависимости
|
||
закомментированы в `requirements.txt`:
|
||
|
||
- `src/api/endpoints.py` — падает при импорте, роутер не монтируется.
|
||
- `src/api/annotation.py` — маршруты под `/api/annotation`, не монтируются.
|
||
- `src/core/orchestrator.py`, `src/pipeline/pipeline.py` — веса не загружаются.
|
||
- `src/model/unet.py`, `src/model/segmentator.py` — UNet-заглушки.
|
||
- `src/quality/artifact_detector.py`, `position_validator.py`, `medical_quality.py` — заглушки.
|
||
- `src/dataloaders/pet_dataset.py` — остаток прототипа (Oxford-IIIT Pet).
|
||
|
||
---
|
||
|
||
## Docker
|
||
|
||
```bash
|
||
docker build -t dxa-quality .
|
||
docker run -v /path/to/data:/data -p 8000:8000 dxa-quality
|
||
```
|
||
|
||
Dockerfile ставит зафиксированные версии, копирует только `src/` и `run.sh`,
|
||
проверяет импорт приложения и наличие офлайн-ассетов фронтенда на этапе сборки и
|
||
имеет HEALTHCHECK. Чекпоинт в образ не копируется — он монтируется в `/app/models`
|
||
при запуске (`DXA_MODEL_PATH`). Данные, тесты и `labels/` в образ не попадают
|
||
(`.dockerignore`), поэтому `./run.sh train` внутри контейнера возьмёт метки из
|
||
имён файлов (с предупреждением); для обучения в контейнере смонтируйте `labels/`
|
||
или передайте свой `--labels-csv`.
|