# 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 # экспертная оценка на уровне ИССЛЕДОВАНИЯ └── Исследования//.../_[_good|_bad].dcm ``` Факты, важные для обучения: - 544 файла на диске, но **252 уникальных снимка** (по пиксельному содержимому) на 100 исследований: позвоночник 99, бедро R 79, бедро L 73, 1 с неопределённой областью. - Экспертная таблица отмечает нарушения у **74 снимков (29.4 %)**; три снимка таблица область не оценивала. - Рабочая разметка (правило `table`) — **77 нарушений из 252 (30.6 %)**: 74 по таблице плюс 3 снимка без экспертной оценки, помеченных `filename_fallback`. - Дубли не пересекают границы исследований, конфликтов меток при склейке нет. Два побайтных дубля названы по-разному, поэтому область определяется голосованием по именам файлов. - Имена файлов приведены к виду `<область>_[_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`.