# 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 # экспертная оценка на уровне ИССЛЕДОВАНИЯ └── Исследования//.../_[_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`. - Дубли не пересекают границы исследований, конфликтов меток при склейке нет. Два побайтных дубля названы по-разному, поэтому область определяется голосованием по именам файлов. - Имена файлов приведены к виду `<область>_[_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). Оба собираются из `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`, монтирование перекроет встроенный чекпоинт.