bone_2026/QWEN.md

316 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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` (карта переименований), `assets/labeling.md`
(как построена разметка и как выбиралось правило). Чекпоинты прогонов
(`models/archive/`, `models/compare_rules/`, `models/compare_labels/`,
`models/excel_labels/`) удалены 2026-09-27 — остались только числа в
`assets/labeling.md`; пересобрать можно через `./run.sh split && ./run.sh compare`.
### Ключевые решения (проверены экспериментально)
| Решение | Причина |
|---|---|
| Единый словарь типов нарушений (`src/dxa/violations.py`) | Коды, подписи и коды SR были в трёх копиях (инференс, `main.py`, `dxa-app.js`) и не знали кодов экспертной таблицы. Теперь подписи отдаёт сервер, фронт копий не держит |
| Метки только из экспертной таблицы (правило `table`): `labels/labels_images.csv`, 77 нарушений | Таблица описывает исследование, но каждая область встречается в нём один раз, поэтому вердикт переносится на снимок однозначно. Правило выбрано измерением: учёт ручных пометок из имён файлов дал ROC-AUC 0.6199 против 0.6764, хуже на всех 5 seed'ах. См. `assets/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 — в `assets/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`); каждый источник свидетельства сохранён в отдельном
столбце, поэтому правило можно переиграть без повторного разбора. Почему выбрано
именно правило «только таблица» — в `assets/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 варианта и оценивает оба по этому же эталону; отчёты
(`comparison.md` / `comparison.json`) и чекпоинты прогонов пишутся в
`--output-root` (по умолчанию `models/compare_labels`) и в репозиторий не входят.
Возобновить без переобучения — `./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'ам). Это единственный
чекпоинт в репозитории: прежние версии и прогоны сравнения удалены, откатиться
можно только переобучением.
---
## 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 # 209 тестов
```
- `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` привязан к текущему оборудованию.
## Удалённый устаревший код
Не подключённые к API модули (`src/core/orchestrator.py`, `src/pipeline/`,
`src/model/{unet,segmentator}.py`, `src/models/*`, `src/classifiers/`,
`src/segmentators/`, `src/dataloaders/`, `src/training/`,
`src/api/{endpoints,annotation,root,schemas}`, `src/quality/{artifact_detector,
position_validator,universal_scorer,medical_quality}.py`) удалены 2026-09-27
вместе с неиспользуемыми чекпоинтами (`quality_classifier.pth`,
`region_detector.pth`, `violation_classifier.pth`, `dxa_model_final.pth`) и
папками `docs/`, `tools/`, `public/`.
Из живого кода их тянул только `src/__init__.py` — реэкспорт `Config`,
`FlexibleDataset`, `UNet`, `QualityScorer`; теперь в нём остался только
`__version__`, а `src/quality/__init__.py` — только докстрока. При добавлении
новых модулей в `src/quality/` помнить, что пакет больше ничего не импортирует.
---
## Docker
```bash
docker compose up -d # CPU, http://localhost:8000
docker compose --profile cuda up -d dxa-cuda # GPU (нужен nvidia-container-toolkit)
```
Образы: `Dockerfile` (python:3.11-slim, torch из CPU-индекса) и `Dockerfile_cuda`
(та же база, torch и torchvision из индекса `cu126` — под CUDA 11.8 колёс torch
2.8.0 нет, индекс заканчивается на 2.7.1). Оба собираются из `src/`,
`requirements.txt`, `run.sh` и **чекпоинта**: `models/dxa_model.pth` отслеживается
git и копируется в `/app/models` на этапе сборки, поэтому монтировать пути при
запуске не нужно (условие приёмки — модели уже внутри образа). Сборка проверяет
импорт приложения, наличие офлайн-ассетов фронтенда и читаемость чекпоинта,
образ имеет HEALTHCHECK. Данные, тесты и `labels/` в образ не попадают
(`.dockerignore`), поэтому `./run.sh train` внутри контейнера возьмёт метки из
имён файлов (с предупреждением); для обучения в контейнере смонтируйте `labels/`
или передайте свой `--labels-csv`. Если внешний `docker-swarm.yml` монтирует
каталог с моделями в `/app/models`, монтирование перекроет встроенный чекпоинт.