33 KiB
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), поэтому в панели показываются числовые измерения, а не вердикты «Да/Нет» |
Проверка вклада модели
Не является ли модель просто детектором анатомии (нарушений около трети и в позвоночнике, и у бёдер, а область почти однозначно определяется по ширине кадра):
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.
Обучение
./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 наборе:
./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), поэтому предобработка и порог совпадают.
Тесты
./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).
Известные ограничения
- Разметка снимков выведена из таблицы, описывающей исследование: поштучной
экспертной оценки снимков в наборе нет. Оценка качества модели упирается в
качество этой разметки, а не только в объём данных. Для поштучной разметки есть
интерфейс
/label(см. ниже) — им ещё не пользовались. - Мало данных: 251 снимок, 76 нарушений; доверительные интервалы широкие (ROC-AUC 0.6726 [0.6367, 0.7086] по пяти seed'ам).
- Эталон оценки — та же экспертная таблица, независимой истины нет; сравнение правил разметки частично благоприятствует варианту «только таблица».
- Тип нарушения определяется эвристиками, а не обученной моделью; 5 снимков
имеют только
unspecified, потому что источник не указывает критерий. - Три снимка без экспертной оценки размечены по пометке в имени файла и помечены
filename_fallback. - Сторона бедра в 7 исследованиях с единственным снимком не проверяема: теги
Lateralityпусты, оценка взята из единственного заполненного столбца. - Порог
SPINE_MIN_WIDTHпривязан к текущему оборудованию. - Разметка и датасет уже расходились: повторное переименование 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-файл:
./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, а не
отдельным конвертером:
"/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
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`, монтирование перекроет встроенный чекпоинт.