bone_2026/QWEN.md

412 lines
33 KiB
Markdown
Raw Permalink 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)
├── manual_labels.py # вердикты специалиста: хранение, наложение, выгрузка (/label)
├── review_pack.py # автономный HTML-пакет разметки для врача (без сервера и сети)
├── 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`, 76 нарушений | Таблица описывает исследование, но каждая область встречается в нём один раз, поэтому вердикт переносится на снимок однозначно. Правило выбрано измерением: учёт ручных пометок из имён файлов дал ROC-AUC 0.6003 против 0.6726, хуже на всех 5 seed'ах. См. `assets/labeling.md` |
| Склейка побайтных дублей | 482 файла, но 251 уникальный снимок; без склейки снимок попадал в оба класса |
| Разбиение по исследованиям, не по снимкам | Исключение утечки: снимки одного исследования в одной части |
| Линейный зонд (замороженный 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` не калиброваны: `roi.valid` ложен для всех 251 снимка (краевой отступ срабатывает у 249 из 251), поэтому в панели показываются числовые измерения, а не вердикты «Да/Нет» |
### Проверка вклада модели
Не является ли модель просто детектором анатомии (нарушений около трети и в
позвоночнике, и у бёдер, а область почти однозначно определяется по ширине кадра):
```bash
python -m src.dxa.discriminator --model-path models/dxa_model.pth
```
Результат на рабочем чекпоинте `models/dxa_model.pth` (оценка на всём наборе,
включая обучающие снимки, поэтому значения смещены вверх):
| Предиктор | Общий AUC | spine | hip_right | hip_left |
|---|---|---|---|---|
| Модель | 0.885 | 0.954 | 0.875 | 0.880 |
| Правило «позвоночник = нарушение» | 0.534 | 0.500 | 0.500 | 0.500 |
Модель использует содержимое снимка: внутри областей она даёт 0.88–0.95.
Правило по области внутри области всегда 0.50 (подсказки нет). Честная оценка на
held-out — в `assets/labeling.md` §8: ROC-AUC 0.6726 [0.6367, 0.7086] по пяти seed'ам.
## Данные (`dataset_hack/`)
Структура:
```
dataset_hack/
├── Для теста/ # l_hip_01.dcm, r_hip_01.dcm, r_hip_01_bad.dcm, spine_01.dcm
└── НД_для_обучения/
├── разметка.xlsx # экспертная оценка на уровне ИССЛЕДОВАНИЯ
└── Исследования/<study_uid>/.../<region>_<n>[_good|_bad].dcm
```
Факты, важные для обучения:
- **482 файла `.dcm` на диске** (было 520: удалены 38 лишних побайтных копий и 10
файлов `.DS_Store`), но **251 уникальный снимок** (по пиксельному содержимому)
на 100 исследований: позвоночник 99, бедро R 78, бедро L 73, 1 с неопределённой
областью.
- Экспертная таблица отмечает нарушения у **73 снимков (29.1 %)**; три снимка
таблица область не оценивала.
- Рабочая разметка (правило `table`) — **76 нарушений из 251 (30.3 %)**: 73 по
таблице плюс 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`: они расходились с оценкой эксперта в 62 случаях из 251
(повторное переименование 2026-09-27 дописало пометки уже после сборки
разметки и с тех пор расхождений стало вчетверо больше).
- В 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 по умолчанию,
эпоха 57, порог логита −0.1370 → вероятность 0.466; val ROC-AUC 0.6689 при
честной оценке 0.6726 [0.6367, 0.7086] по пяти seed'ам). Переобучен 2026-09-27
после пересборки разметки. Это единственный чекпоинт в репозитории: прежние
версии и прогоны сравнения удалены, откатиться можно только переобучением.
---
## API (`src/main.py`)
| Метод | Путь | Назначение |
|---|---|---|
| GET | `/` | Веб-интерфейс |
| GET | `/label` | Интерфейс ручной разметки (подтверждение вердиктов специалистом) |
| 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 |
| GET | `/api/v1/labeling/items` | Снимки датасета для разбора: текущая метка, её источник, расхождения |
| GET | `/api/v1/labeling/image` | PNG снимка для просмотра (путь проверяется на выход за каталог датасета) |
| POST | `/api/v1/labeling/verdict` | Сохранить вердикт специалиста в `labels/manual_labels.csv` |
| DELETE | `/api/v1/labeling/verdict` | Снять вердикт и вернуть снимок к построенной разметке |
| GET | `/api/v1/labeling/export` | Выгрузка разметки: `scope=all` годится как `--labels-csv` |
Путь к модели — переменная окружения `DXA_MODEL_PATH` (по умолчанию
`models/dxa_model.pth`), чтобы контейнер не зависел от рабочего каталога.
API и CLI используют один код предсказания (`predict_from_bytes` /
`predict_from_array`), поэтому предобработка и порог совпадают.
---
## Тесты
```bash
./run.sh test
python -m pytest tests/ -q # 272 теста
```
- `tests/test_labels.py` — разбор имён, склейка дублей, отсутствие утечки при
разбиении, фиксация разбиения в файле (`export_split` / `load_split`).
- `tests/test_rename_files.py` — приведение имён: разбор, поиск свободного
номера при конфликте, отказ от угадывания области, цикл «применить → откатить».
- `tests/test_excel_labels.py` — разметка по экспертной таблице: чтение
критериев, «1 = нарушение», голосование по области, перенос оценки на
единственное бедро, три правила метки (`table` / `union` / `expert`) и их
согласованность, подключение к обучению, чтение CSV, сохранённого Excel с BOM.
- `tests/test_manual_labels.py` — ручная разметка: проверка вердикта (область,
метка, тип нарушения и его совместимость с областью), хранение и правка,
наложение поверх построенной разметки, подсчёт прогресса и выгрузка.
- `tests/test_violations.py` — единый словарь типов: коды и подписи, коды SR,
приведение устаревших значений, согласованность с критериями таблицы.
- `tests/test_preprocess_and_model.py` — предобработка, метрики, подбор порога,
контракт модели, BatchNorm при заморозке, roundtrip чекпоинта.
- `tests/test_api_contract.py` — поля ответов, которые читает `dxa-app.js`
(панель деталей ранее показывала прочерки из-за расхождения ключей),
различимость метрик между снимками, валидность PNG-визуализаций, отсутствие
некалиброванных вердиктов в ответе.
- `tests/test_labeling_api.py` — контракт `/api/v1/labeling/*`: отказ отдавать
файлы вне датасета (обход каталога, не-DICOM), проверка вердикта, выгрузка,
читаемая обучением как `--labels-csv`.
- `tests/test_review_pack.py` — пакет для врача: отпечаток набора, порядок
(расхождения первыми), встроенные данные разбираются как JSON, страница не
ссылается на сеть, слияние вердиктов с построенной разметкой и сообщение о
путях из чужого пакета.
### Проверка веб-интерфейса в браузере
`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_labeling.js` — интерфейс ручной разметки: список снимков с прогрессом,
расхождения первыми, снимок отрисовывается, вердикт сохраняется и переживает
перезагрузку страницы, фильтр «только расхождения», отсутствие внешних запросов.
Запускать с временным файлом вердиктов:
`DXA_MANUAL_LABELS=/tmp/manual_ui.csv python -m uvicorn src.main:app --port 8123`,
иначе проверка пишет в рабочий `labels/manual_labels.csv`.
- `review_pack.js` — автономный пакет разметки: открывает HTML прямо с диска
(`file://`, сервер не нужен), проверяет отрисовку встроенного снимка, вердикт,
его сохранение после перезагрузки, выгрузку CSV и восстановление прогресса из
этого же файла, а также отсутствие любых сетевых запросов. Запуск:
`PACK=review/doctor_review.html node tests/browser/review_pack.js`.
- `ui_offline.js` — страница не обращается к внешним хостам.
### Честность интерфейса
Веб-интерфейс не должен утверждать больше, чем известно решению, поэтому:
- тип нарушения показан с пометкой «эвристика» и пояснением, что модель решает
только бинарную задачу;
- ROC-AUC рабочего чекпоинта в баннере состояния помечена как завышенная (он
выбран лучшим из пяти seed'ов), а честная оценка лежит в панели «О модели»;
- плитка средней уверенности не называется точностью;
- подписи типов и метрики приходят с сервера (`/api/v1/model`), копий в JS нет.
### Офлайн-работа фронтенда
Tailwind и FontAwesome лежат локально (`src/api/static/vendor`,
`src/api/static/webfonts`), страница не обращается к CDN. Требование методики —
работа без внешних сервисов; CDN-версии ломали оформление в закрытом контуре.
Наличие ассетов проверяется на этапе сборки образа (см. Dockerfile).
---
## Известные ограничения
1. Разметка снимков выведена из таблицы, описывающей исследование: поштучной
экспертной оценки снимков в наборе нет. Оценка качества модели упирается в
качество этой разметки, а не только в объём данных. Для поштучной разметки есть
интерфейс `/label` (см. ниже) — им ещё не пользовались.
2. Мало данных: 251 снимок, 76 нарушений; доверительные интервалы широкие
(ROC-AUC 0.6726 [0.6367, 0.7086] по пяти seed'ам).
3. Эталон оценки — та же экспертная таблица, независимой истины нет; сравнение
правил разметки частично благоприятствует варианту «только таблица».
4. Тип нарушения определяется эвристиками, а не обученной моделью; 5 снимков
имеют только `unspecified`, потому что источник не указывает критерий.
5. Три снимка без экспертной оценки размечены по пометке в имени файла и помечены
`filename_fallback`.
6. Сторона бедра в 7 исследованиях с единственным снимком не проверяема: теги
`Laterality` пусты, оценка взята из единственного заполненного столбца.
7. Порог `SPINE_MIN_WIDTH` привязан к текущему оборудованию.
8. Разметка и датасет уже расходились: повторное переименование 2026-09-27
дописало суффиксы `_good`/`_bad` после сборки разметки, из-за чего 20 из 252
путей устарели, 6 снимков получали метку «нарушение» вопреки эксперту и
пайплайн отдавал 251/82 вместо 252/77. Расхождение устранено пересборкой
разметки и переобучением в тот же день; числа в этом файле — уже новые.
Разбор — в `assets/labeling.md` §7.
## Ручная разметка (`/label`)
Поштучной экспертной оценки снимков в наборе нет (ограничение №1), а раздел 2.6
задания просит «автоматическую коррекцию разметки с возможностью подтверждения
специалистом». Поэтому в сервисе есть отдельный интерфейс `/label`:
`src/dxa/manual_labels.py` — хранение вердиктов, эндпоинты `/api/v1/labeling/*` —
список снимков, просмотр и сохранение, страница `src/api/static/label.html` +
`js/labeling.js`.
Вердикты лежат в `labels/manual_labels.csv` (переменная `DXA_MANUAL_LABELS`) в
формате построенной разметки, поэтому файл читается тем же `load_labels_csv` и
принимается обучением как `--labels-csv`. Выгрузка `scope=all` накладывает ручные
вердикты на построенную разметку и годится как источник меток напрямую.
Оценка модели в интерфейсе намеренно не показывается: подсказка смещала бы
разметчика, а цель — независимое суждение человека. Список выводит первыми
снимки, где метка разметки расходится с пометкой в имени файла: там ошибка
возможна в любом из источников.
### Пакет для врача (разметка без сервиса)
`/label` требует запущенного сервиса и Python, а размечать должен врач — на своей
машине. Поэтому тот же сценарий собирается в **один HTML-файл**:
```bash
./run.sh review --out review/doctor_review.html # ~7 МБ, 251 снимок
./run.sh review --limit 20 --out review/pilot.html # пилот на выборке
./run.sh review --merge review/doctor.csv --out labels/labels_images_reviewed.csv
```
Снимки встроены как data-URI, вердикты лежат в `localStorage` браузера, выгрузка —
CSV в формате `manual_labels.csv` (плюс колонка `pack_id` — отпечаток набора,
чтобы различить пакеты). Файл открывается двойным щелчком, работает без сети и
без установки чего-либо; каталог `review/` в git не хранится.
Вердикты врача принимаются как есть: `load_verdicts` читает выгрузку, `--merge`
накладывает её на построенную разметку (и сообщает о путях, которых нет в
датасете — признак чужого пакета), а `--labels-csv` с этим файлом годится для
обучения. Оценка модели в пакет не попадает по той же причине, что и в `/label`.
## Удалённый устаревший код
Не подключённые к 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/` помнить, что пакет больше ничего не импортирует.
`docs/` после удаления появился снова — владелец вернул его под сдачу: там лежат
`technical-description.html` (сдаточный технический документ) и его PDF-версия,
а также презентация команды. Это не материалы README: корневой `README.md`
ссылается только на `assets/`. PDF печатается из HTML тем же Chrome, а не
отдельным конвертером:
```bash
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --headless \
--disable-gpu --no-pdf-header-footer --user-data-dir=/tmp/chrome-pdf-profile \
--print-to-pdf="$PWD/docs/technical-description.pdf" \
"file://$PWD/docs/technical-description.html"
```
---
## 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). Имя с `.txt` —
так файл переименован владельцем, `docker-compose.yml` на него и ссылается
(`dockerfile: Dockerfile_cuda`); собирать вручную:
`docker build -f Dockerfile_cuda -t dxa-quality:cuda .`. Оба образа
собираются из `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`, монтирование перекроет встроенный чекпоинт.