Автоматизированная оценка качества денситометрических исследований

Техническое описание решения
ЗадачаКонтроль качества DXA-исследований: бинарная оценка «качественное / есть нарушение», определение анатомической области, тип нарушения, структурированный отчёт
Дата документа27 сентября 2026 г.
КомандаГрачев Денис — разработка; Грачев Татьяна — капитан
Версия решения1.0.0 (src/__init__.py)
Рабочий чекпоинтmodels/dxa_model.pth: ResNet18 + линейная голова, разметка table, seed 42, эпоха 39, порог логита −0.4930 (вероятность 0.379)
Ключевые метрикиROC-AUC 0.6764 [0.6309, 0.7218], F1 0.5676 [0.5270, 0.6082] — пять seed'ов на фиксированном разбиении по исследованиям
Время обработки0.021 с на изображение (медиана, CPU), 548 файлов за 12.1 с; требование «≤ 3 мин на исследование» выполняется с запасом

Документ описывает фактическое состояние репозитория на дату, указанную на титуле: все параметры, пороги, метрики и имена полей приведены по коду и по выполненным измерениям, а не по проектным намерениям. Там, где значение измерено на конкретной машине, указано, на какой. Расхождения и ограничения перечислены явно — разделы 15 и 16.

Содержание

  1. 1. Назначение и область применения
  2. 2. Соответствие требованиям задания
  3. 3. Архитектура решения
  4. 4. Состав данных и разметка
  5. 5. Предобработка изображений
  6. 6. Модель и процедура обучения
  7. 7. Определение анатомической области
  8. 8. Таксономия нарушений качества
  9. 9. Форматы входных и выходных данных
  10. 10. API сервиса
  11. 11. Веб-интерфейс
  12. 12. Метрики качества
  13. 13. Производительность и системные требования
  14. 14. Сборка, запуск и развёртывание
  15. 15. Тесты и проверки
  16. 16. Известные ошибки и их обработка
  17. 17. Ограничения и достоверность результатов
  18. 18. План развития
  19. Приложение А. Структура репозитория
  20. Приложение Б. Команды

1. Назначение и область применения

Сервис принимает денситометрическое исследование в формате DICOM и выполняет автоматизированный контроль его качества: определяет анатомическую область, решает бинарную задачу «изображение пригодно для клинической интерпретации / содержит нарушение качества», относит нарушение к категории и формирует структурированный отчёт (XLSX/CSV) с одной строкой на изображение.

Задача решается как цифровой помощник, а не как замена врача: результат предназначен для отбора исследований, требующих внимания специалиста, и для единообразной регистрации нарушений. Области анализа — поясничный отдел позвоночника и проксимальный отдел бедренной кости (левая и правая стороны рассматриваются как отдельные снимки, поскольку сторона влияет на трактовку критериев).

Решение работает полностью офлайн: изображения не передаются во внешние сервисы, все зависимости и веса фиксированы, доступ в сеть при инференсе не требуется. Это прямое требование методики (п. 3.2 задания) и одновременно условие применимости в закрытом контуре медицинской организации.

2. Соответствие требованиям задания

Таблица связывает пункты задания с реализацией и со способом проверки. Формулировки требований приведены по тексту методических рекомендаций.

ТребованиеРеализацияЧем подтверждается
Области: поясничный отдел позвоночника и проксимальный отдел бедренной кости Область определяется автоматически по кадру; бедро разделяется на левое и правое Разд. 7; на валидации 99/99 для позвоночника
Бинарная классификация «качественное / есть нарушение» ResNet18 (ImageNet) + линейная голова, порог по логиту, подобранный по F1 Разд. 6, 12; tests/test_preprocess_and_model.py
Определение типа нарушения Единый словарь кодов src/dxa/violations.py; отнесение к категории — по измеряемым признакам снимка Разд. 8; tests/test_violations.py
Оценка корректности разметки анатомических структур В наборе нет ROI-разметки в DICOM, поэтому оценивается геометрия видимой зоны измерения и её границы Разд. 8, 17 (ограничение 4)
Отчёт .xlsx/.csv, одна строка на изображение, столбцы из п. 2.5 CLI-инференс и POST /api/v1/export; обязательные восемь столбцов плюс три диагностических Разд. 9 (состав столбцов проверен на реальном прогоне)
Время обработки одного исследования ≤ 3 мин Один снимок — один прямой проход сети 224×224, без итеративных процедур Разд. 13: 0.021 с медиана на CPU, запас более чем трёхкратный
Отсутствие необработанных исключений; ошибки фиксируются в отчёте Ошибка на файле не прерывает пакет: строка получает processing_status = Failure: … Разд. 16
Воспроизводимость при повторном запуске Фиксированный seed, зафиксированное разбиение в файле, версии зависимостей, самодостаточный чекпоинт Разд. 6, 14; tests/test_labels.py
Пакетная обработка архива + общая таблица + zip с дополнительными сериями Обход каталога, лог прогресса, XLSX/CSV, опциональный zip с визуализацией Разд. 9, 13
API для пакетной обработки тестового набора FastAPI: восемь маршрутов, включая /api/v1/batch и /api/v1/export Разд. 10
Обязательная контейнеризация, скрипт сборки и запуска в Linux Dockerfile (python:3.11-slim), Dockerfile_cuda для GPU, run.sh Разд. 14; сборка проверена, см. 14
Фиксация зависимостей, включая базовый контейнер requirements.txt с точными версиями, базовый образ по тегу, torch с CPU-индексом Разд. 14
Работа без обращения к внешним сервисам Офлайн-ассеты фронтенда, отсутствие сетевых вызовов в коде предсказания tests/browser/ui_offline.js
Полный комплект документации README.md (обзор и воспроизведение), assets/labeling.md (разметка и её обоснование), docs/technical-description.html и .pdf (данный документ) —

3. Архитектура решения

3.1. Слои

СлойФайлыОтветственность
Прикладной ядерный слойsrc/dxa/ Чтение DICOM, предобработка, модель, метрики, определение области, разметка данных, обучение, пакетный инференс
Сервисsrc/main.py FastAPI: загрузка чекпоинта при старте, маршруты анализа, пакетной обработки, экспорта, здоровья и карточки решения
Веб-интерфейсsrc/api/static/ Загрузка файлов, таблица результатов, панель деталей, панель «О модели»; все подписи и метрики получает с сервера
Эвристики качестваsrc/quality/ Числовые измерения снимка (резкость, плотные включения, полнота и поворот области) для панели деталей
Инфраструктураrun.sh, Dockerfile, Dockerfile_cuda, Jenkinsfile Единая точка входа для сборки, обучения, инференса и тестов; сборка образа и выкладка

3.2. Поток обработки одного изображения

Ниже — фактический путь данных от файла до строки отчёта. Ключевое свойство: обучение и сервис используют один и тот же код предобработки и предсказания, поэтому расхождение между офлайн-оценкой и работой API невозможно по построению, а не по договорённости.

DICOM→ Чтение пикселей и метаданных→ Предобработка 224×224→ ResNet18 + линейная голова
Логит → порог→ Область по кадру→ Числовые измерения→ Строка отчёта / ответ API

Порядок операций и точка принятия решения:

  1. Чтение. Из файла берутся пиксельные данные и идентификаторы исследования и снимка (StudyInstanceUID, SOPInstanceUID). Персональные данные не используются: решение не зависит от их наличия.
  2. Предобработка. Приводится к виду, ожидаемому предобученным backbone (описание — разд. 5). Параметры предобработки хранятся в чекпоинте и берутся из него, а не из кода по умолчанию.
  3. Оценка качества. Один прямой проход сети даёт логит. Решение принимается по логиту: quality_class = 1, если логит выше порога. Порог подобран по F1 на валидации и сохранён в чекпоинте (текущее значение −0.4930, что соответствует вероятности 0.379).
  4. Анатомическая область. Определяется независимо от модели, по геометрии кадра (разд. 7), с оценкой уверенности.
  5. Измерения и тип нарушения. Для снимков с нарушением вычисляются числовые признаки снимка, по которым нарушение относится к категории словаря (разд. 8).
  6. Отчёт. Строка собирается со статусом обработки и временем; ошибка на файле не прерывает пакет.
В README.md приведён полный набор проектных диаграмм (компоненты, потоки данных, последовательность, развёртывание). Они описывают целевую архитектуру, включая блоки, которые в текущей реализации не используются, поэтому в настоящем документе поток описан текстом по коду.

4. Состав данных и разметка

4.1. Набор

ПоказательЗначениеПояснение
Файлов на диске544в обучающем наборе (dataset_hack/НД_для_обучения)
Уникальных снимков252по пиксельному содержимому; 292 файла — побайтные дубли
Исследований100разбиение выполняется по исследованиям, а не по снимкам
Позвоночник / бедро правое / бедро левое / не определено99 / 79 / 73 / 1голосование по именам файлов после склейки дублей
Нарушений по экспертной таблице74 (29.4 %)три снимка таблица не оценивала
Нарушений в рабочей разметке77 (30.6 %)74 по таблице плюс 3 снимка с пометкой в имени файла

Дубли не пересекают границы исследований, конфликтов меток при склейке не возникает. Два побайтных дубля названы по-разному, поэтому область определяется голосованием по именам файлов. Изучение набора выявило две особенности, которые пришлось учесть в разметке: один и тот же снимок встречается под несколькими именами (без склейки он попадал бы одновременно в оба класса), и имена файлов местами расходятся с оценкой эксперта.

4.2. Единица разметки и правило получения метки

Экспертная таблица описывает исследование, а не отдельный снимок. Однако каждая анатомическая область встречается в исследовании ровно один раз (после склейки дублей), поэтому вердикт исследования по области переносится на снимок однозначно — не требуется решать, какой из нескольких снимков «плохой». Так получены метки и типы нарушений (labels/labels_images.csv); каждый источник свидетельства сохранён в отдельном столбце, поэтому правило можно переиграть без повторного разбора данных.

Возможны были два правила: учитывать только экспертную таблицу либо дополнительно учитывать пометки _good/_bad, проставленные вручную в именах файлов. Пометки расходились с экспертом в 15 случаях из 252, поэтому правило выбиралось измерением: одно зафиксированное разбиение, пять seed'ов обучения, один эталон (табл. в разд. 12.3). Выбрано правило «только экспертная таблица».

4.3. Разбиение выборки

ЧастьСнимковИсследованийНарушенийФайл
Обучение1998161labels/split_expert_seed42.json
Валидация531916

Разбиение стратифицировано по эталону и фиксировано в файле: все сравниваемые варианты обучаются и оцениваются на одном и том же held-out наборе, что делает сравнения корректными. Снимки одного исследования не попадают одновременно в обе части — это исключает утечку, при которой метрики растут за счёт запоминания конкретных исследований. Наличие утечки и воспроизводимость разбиения проверяются тестами (tests/test_labels.py).

5. Предобработка изображений

Предобработка описана декларативно в src/dxa/preprocess.py (неизменяемая конфигурация PreprocessConfig) и используется и при обучении, и при работе API. Параметры хранятся в чекпоинте и восстанавливаются из него, поэтому вход модели не может разойтись между двумя режимами.

ПараметрЗначениеНазначение
normpercentileприведение интенсивностей к [0, 1]; допустимо также minmax
p_low / p_high0.5 / 99.5отсекаемые перцентили — устойчивость к выбросам и шуму
imagenet_normtrueстандартизация mean (0.485, 0.456, 0.406), std (0.229, 0.224, 0.225), как ожидает предобученный backbone
input_size224сторона квадратного входа сети

Порядок операций над одним файлом:

  1. Чтение. pydicom.dcmread и pixel_array в float32; многокадровое изображение усредняется по кадрам, иная размерность — ошибка.
  2. Коррекция интенсивностей. Учитываются RescaleSlope и RescaleIntercept; для MONOCHROME1 яркость инвертируется.
  3. Нормировка. По перцентилям 0.5 / 99.5 с обрезкой в [0, 1] и защитой от вырожденного диапазона (hi ≤ lo).
  4. Приведение к тензору. Умножение на 255 и uint8, дублирование одного канала в три, resize 224×224 билинейно, перевод в CHW, деление на 255, затем нормировка ImageNet.

Если в чекпоинте нет блока preprocess (старый файл), применяются значения по умолчанию — percentile с нормировкой ImageNet; устаревший профиль minmax без ImageNet доступен флагом --no-imagenet-norm.

Порядок «resize в uint8, затем нормировка» сохранён намеренно: он совпадает с историческим кодом проекта, поэтому смена реализации не сдвинула распределение входа и не обесценила ранее подобранный порог.

6. Модель и процедура обучения

6.1. Архитектура

ЭлементРеализация
Backbone ResNet18 с весами ImageNet (IMAGENET1K_V1), все слои кроме fc; признак — 512 чисел. Флагом --backbone допустим также ResNet34
Голова качества по умолчанию linear: Flatten + Linear(512, 2) — линейный зонд, 1026 обучаемых параметров. Режим mlp (флаг --head): Linear(512, 256) + ReLU + Dropout(0.3) + Linear(256, 2)
Вспомогательная голова области Flatten + Linear(512, 4): три анатомические области плюс «неизвестно». Обучается с весом 0.3 как дополнительная задача и заставляет backbone различать анатомию; логиты качества она не сдвигает
Стандартизация входа головы признаки нормируются по среднему и СКО обучающей выборки, зафиксированным в буферах feat_mean/feat_std. Без неё логиты смещены, вероятности скучены у нуля и подобранный порог теряет смысл

6.2. Гиперпараметры и процедура обучения

ПараметрЗначение по умолчаниюКомментарий
ОптимизаторAdamW lr 3e-4, weight_decay 5e-2. Заметный weight decay нужен не только против переобучения: без него логиты за 100 эпох насыщаются и порог вырождается
ПланировщикReduceLROnPlateauмножитель 0.5, терпение 3, следит за val_loss
Функция потерьCrossEntropy балансировка классов выключена (--balance none); доступны loss (вес n_good / n_bad) и sampler
Батч / эпохи16 / 100ранняя остановка после 25 эпох без улучшения
Отбор чекпоинтаокно 5 оценка эпохи — ROC-AUC, сглаженный по последним 5 эпохам; до заполнения окна чекпоинт не сохраняется, при равном AUC решает F1
Вес головы области0.3--region-loss-weight; снимки с неизвестной областью в её loss не входят
Обрезка градиентовнорма 5.0защита от выбросов
Заморозка backboneвсегда --freeze-epochs -1 по умолчанию (линейный зонд); при размораживании lr падает в 10 раз
Seed / устройство42 / автопорядок выбора устройства: CUDA → MPS → CPU

Почему линейный зонд. При ~250 уникальных снимках полный fine-tune ResNet18 за несколько эпох запоминает обучающую выборку: train F1 стремится к 1.0, а val AUC падает к 0.5. Поэтому по умолчанию backbone заморожен и обучается только голова на признаках ImageNet. При заморозке слои BatchNorm остаются в режиме eval: иначе бегущие статистики продолжали бы обновляться и признаки «уезжали» бы от тех, на которых рассчитывалась стандартизация.

Почему порог по логиту. Порог хранится и применяется в логитах (log-odds): после обучения линейный зонд разделяет обучающую выборку почти идеально, вероятности насыщаются в 0/1, и порог в единицах вероятности вырождается. Для человека порог переводится в вероятность через сигмоиду. ROC-AUC и PR-AUC считаются по вероятностям и от порога не зависят.

Как подбирается порог. На каждой эпохе по валидации: кандидаты — все наблюдаемые логиты плюс края диапазона, выбирается максимум F1; при равном F1 берётся меньший логит, чтобы не терять recall. Аргумент --min-recall позволяет вместо этого взять максимальный порог с recall не ниже заданного.

Рабочий чекпоинт. Лучшая эпоха — 39 из прогона в 64 эпохи (обучение остановлено по терпению), порог логита −0.4930 (вероятность 0.379). Метрики этой эпохи — в разделе 12, честная оценка варианта на пяти seed'ах — ROC-AUC 0.6764.

6.3. Чекпоинт и отчёты

Чекпоинт самодостаточен: кроме весов в нём лежат архитектура, параметры предобработки, порог и происхождение данных, поэтому инференс не может рассинхронизироваться с обучением.

ПолеСодержимое
model_state_dictвеса сети; format_version — 2
backbone, headархитектура; при явном расхождении с запрошенной загрузка завершается ошибкой
preprocessпараметры предобработки (раздел 5)
threshold, threshold_logitрабочий порог по логитам
labels_csv, split_fileна какой разметке и на каком разбиении обучена модель
epoch, val_metrics, selection_scoreэпоха, её метрики и сглаженная оценка, по которой выбран чекпоинт
history, optimizer_state_dictистория обучения и состояние оптимизатора

Рядом с чекпоинтом обучение пишет train_report.md и train_report.json: гиперпараметры, метрики на валидации, метрики по областям и историю по эпохам. Артефакты кладутся в --output-dir (по умолчанию models).

7. Определение анатомической области

Область определяется по содержимому снимка, а не по имени файла: на закрытом наборе соглашение об именах может отсутствовать. Решение принимается по геометрии кадра (resolve_region в src/dxa/inference.py) независимо от модели качества; обученная вспомогательная голова области привлекается только как уточнение.

Признаки считает функция heuristic_signals по маске яркой (костной) ткани с порогом по 95-му перцентилю:

ПризнакЧто измеряет
widthширина кадра в пикселях — основной признак области на этом оборудовании (у позвоночника 300 px, у бедра 280 px)
bbox_aspectотношение высоты яркой области к её ширине (вытянутость)
left_right_ratioперевес светимости левой половины над правой — наклон в сторону бедра
symmetryсимметрия изображения относительно вертикальной оси

Порог ширины задан константой SPINE_MIN_WIDTH = 295. Порядок решений:

  1. если ширина кадра ≥ 295 — spine, уверенность 0.8;
  2. иначе (бедро) — сторона по перевесу светимости: left_right_ratio > 1.3 даёт hip_right, < 0.7 — hip_left, уверенность 0.6;
  3. при промежуточном перевесе — предсказание головы области, если её уверенность ≥ 0.5;
  4. если и оно неуверенно — общая область hip с уверенностью 0.4.

Если ширина кадра неизвестна, используется предсказание головы, а затем форма яркой области (bbox_aspect и symmetry).

Точность. Сопоставление с областью из рабочей разметки (252 снимка, раздел 4):

Что проверялосьРезультат
Позвоночник99 / 99
Сторона бедра (правое / левое)135 / 152 (88.8 %)
Итого по всем областям234 / 251 (93.2 %)

Различение позвоночника и бедра по ширине кадра работает безошибочно, а сторона бедра определяется менее надёжно: перевес светимости путает левое и правое в 17 случаях из 152. Сторона не проверялась по тегам DICOM — Laterality в наборе пуст, — поэтому это ограничение (раздел 17, п. 7), а не измеренная ошибка модели.

Признак «ширина кадра» привязан к текущему аппарату. Он безошибочен на этом наборе, но при смене оборудования порог потребует калибровки — раздел 17, ограничение 8.

8. Таксономия нарушений качества

Коды типов нарушений собраны в одном модуле src/dxa/violations.py. Раньше словарь был свой в трёх местах (инференс, main.py и веб-интерфейс), и ни одна копия не знала кодов экспертной таблицы; теперь коды, русские подписи и пояснения заданы один раз, а сервер отдаёт их клиенту.

КодПодписьОбластьИсточник
positioningНекорректная укладкаspineэкспертная таблица
axis_deviationОтклонение осиspineэкспертная таблица
artifactАртефакты и имплантылюбаяэкспертная таблица
rotationРотация / позиционированиеhipэкспертная таблица
roi_incorrectНекорректная область интересалюбаяэкспертная таблица
motionДвижение, размытиелюбаяcondition_doctor.txt
incomplete_anatomyАнатомия видна не полностьюлюбаяcondition_doctor.txt
labeling_errorОшибка разметкиspinecondition_doctor.txt
unspecifiedНарушение без уточнениялюбаясистемный код

У каждого кода есть пояснение, чем нарушение мешает измерению (например, для rotation — что малый вертел виден слишком хорошо и шейка бедра кажется укороченной). Функция catalogue() отдаёт словарь через /api/v1/model, поэтому подписи в интерфейсе и в выгрузке не могут разойтись с кодами.

Для текстового отчёта DICOM SR у каждого кода заданы условный числовой код и английская формулировка. Полноценного справочника SNOMED/DICOM для контроля качества DXA в наборе нет, поэтому коды условные и всегда идут рядом с текстом: пригодный снимок кодируется 113001 (DXA image quality acceptable) с флагом FINAL, нарушение — с флагом WARNING. Устаревшие значения внешних источников (artifact_motion, roi_error, position_error, quality_violation_detected и другие) приводит к канону функция canon_type.

8.1. Тип нарушения в разметке

Экспертная таблица кодирует не все девять кодов, а пять: positioning, axis_deviation, artifact, rotation, roi_incorrect. Распределение в рабочей разметке (в четырёх из 77 нарушений указано по два критерия, поэтому сумма больше 77):

КодСнимков
rotation36
artifact17
axis_deviation10
roi_incorrect7
positioning6
unspecified5

Коды motion, incomplete_anatomy и labeling_error в разметке не встречаются: таблица их не кодирует, хотя словарь их знает ради критериев condition_doctor.txt. Пять снимков имеют только unspecified, потому что источник не указывает критерий.

8.2. Тип нарушения при инференсе

Модель решает только бинарную задачу, поэтому тип для снимка с нарушением выбирает функция classify_violation_type по дешёвым признакам: размытие (laplacian_variance), доля плотных пикселей (bright_fraction) и вытянутость яркой области (bbox_aspect для бедра). При отсутствии выраженного признака возвращается unspecified.

На текущем чекпоинте эта ветка практически вырождена. Пороги motion_threshold и artifact_threshold в вызов не передаются, поэтому действуют значения по умолчанию (0.0 и 1.0), которых признаки достичь не могут, а условие ротации (bbox_aspect вне диапазона 0.4–3.0) на наборе не срабатывает. Прогон по всем 548 файлам даёт одинаковый результат: все 341 решение с нарушением помечены unspecified. Для содержательного типа нужна разметка типов на уровне снимка и обученный мультилейбл-классификатор (раздел 18), поэтому в ответе API тип всегда идёт с флагом violation_type_is_heuristic = true.

9. Форматы входных и выходных данных

9.1. Вход

Один файл DICOM (для API — один HTTP-загрузкой, для CLI — файл или каталог с рекурсивным обходом). Исследование может содержать несколько снимков — каждый обрабатывается отдельно и даёт отдельную строку отчёта. Перед обработкой файл читается целиком; при ошибке чтения строка получает статус сбоя, а пакет продолжается.

9.2. Выходной файл

Состав столбцов проверен на реальных прогонах — и через CLI, и через /api/v1/export. Обязательные восемь столбцов задания совпадают в обоих путях по составу и порядку; дополнительно пишутся confidence и violation_reason, а CLI-инференс добавляет одиннадцатый столбец region_confidence. Таблица ниже отражает CLI-вывод; в ответе /api/v1/export последнего столбца нет.

№СтолбецТипСодержимое
1path_to_studyстрокапуть к исследованию (для загрузки через API — upload://имя)
2study_uidстрокаStudyInstanceUID из DICOM
3image_uidстрокаSOPInstanceUID из DICOM
4anatomical_regionстрокаspine / hip_left / hip_right / hip
5quality_classцелое0 — качественное, 1 — есть нарушение
6violation_typeстрокакод нарушения или пусто
7processing_statusстрокаSuccess либо Failure: <причина>
8time_of_processingвещественноевремя обработки снимка, секунды
9confidenceвещественноеуверенность модели в решении
10violation_reasonстрокатекстовое пояснение к отнесённой категории
11region_confidenceвещественноеуверенность определения области
Время обработки отдаётся в столбце файла результатов; одиночный ответ POST /api/v1/analyze этого столбца не содержит — при интеграции время измеряется на стороне вызывающей системы (см. разд. 10 и 13).
Расхождение ровно в один столбец — единственное различие двух путей выгрузки; оно перечислено в разделе 16. Обязательные столбцы и их порядок при этом одинаковы, поэтому автоматический разбор результатов не зависит от того, каким путём получен файл.

10. API сервиса

Сервис на FastAPI. Маршруты и состав полей ответов ниже приведены по фактическим ответам на запросах к тестовым файлам, а не по схеме из документации кода.

МетодПутьНазначение
GET/веб-интерфейс
GET/api/v1/healthстатус сервиса; поля ответа: status, device, model_loaded, model
GET/api/v1/modelкарточка решения: model, labels, dataset, comparison, violations, limitations, sources, snapshot_date, loaded
POST/api/v1/analyzeанализ одного файла (multipart-загрузка)
POST/api/v1/analyze/detailedрасширенный отчёт, опционально с маской
POST/api/v1/analyze/srтекстовое представление отчёта DICOM SR: format, sr_content, UID
POST/api/v1/batchпакетная обработка нескольких файлов
POST/api/v1/exportпакетная обработка и выгрузка в XLSX

10.1. Состав ответа /api/v1/analyze

Поля: filename, study_uid, image_uid, anatomical_region, region_confidence, quality_class, quality_label, overall_quality, severity, confidence, confidence_per_class, threshold_probability, violation_type, violation_type_label, violation_type_is_heuristic, violation_type_note, reason, metrics, samples, processing_status.

Два поля заслуживают пояснения при интеграции:

10.2. Расширенный ответ /api/v1/analyze/detailed

Дополнительно к предыдущему набору: image (визуализация снимка), mask (маска зоны измерения, при запросе), reasons, metrics_note, view_quality, а также измерения по областям: spine_completeness, hip_completeness, hip_rotation.

Измерения в расширенном ответе сопровождаются пометкой о том, что их пороги не калиброваны (разд. 17), и не превращаются в вердикты «да/нет»: интерфейс показывает числа.

11. Веб-интерфейс

Интерфейс предназначен для ручной проверки работы сервиса и для демонстрации: загрузка файлов, таблица результатов со статистикой, панель деталей по выбранной строке и панель «О модели» с источником загруженного чекпоинта.

Таблица результатов: полоса состояния, статистика, строки снимков
Рис. 1. Результаты обработки: полоса состояния показывает, какой чекпоинт отвечает (разметка, эпоха, порог), рядом — статистика и таблица снимков.
Панель деталей: снимок с нарушением
Рис. 2а. Нарушение: уровень, заключение с вероятностью и порогом, тип нарушения с пометкой «эвристика».
Панель деталей: качественный снимок
Рис. 2б. Качественный снимок: измерения подписаны как справочные.

Отдельный принцип — интерфейс не утверждает больше, чем известно решению:

Панель «О модели»: чекпоинт, метрики с интервалами, данные, словарь нарушений
Рис. 3. Панель «О модели»: источник чекпоинта, метрики с доверительными интервалами, состав данных, словарь нарушений и список ограничений. Данные приходят из /api/v1/model.

12. Метрики качества

12.1. Основная оценка

Оценка получена на фиксированном разбиении по исследованиям (обучение 199 снимков / 81 исследование, валидация 53 снимка / 19 исследований, 16 нарушений), пять seed'ов обучения, эталон — вердикт эксперта из таблицы. Приоритетные по заданию метрики приведены с 95 % доверительными интервалами.

МетрикаЗначение95 % ДИКомментарий
ROC-AUC0.6764[0.6309, 0.7218]приоритетная метрика задания
PR-AUC0.4759[0.4141, 0.5377]базовый уровень при доле нарушений 30 % — около 0.30
F10.5676[0.5270, 0.6082]порог подбирался по F1 на той же валидации, поэтому значение смещено вверх
Recall / Precision0.700 / 0.486—рабочая точка выбранного порога

12.2. Оценка по областям и контрольные проверки

Что измереноЗначениеКак получено
ROC-AUC: позвоночник / бедро правое / бедро левое0.943 / 0.576 / 0.550рабочий чекпоинт, собственная валидация
Контрольная задача «позвоночник / бедро»AUC 1.00проверка работоспособности пайплайна, а не клиническая метрика
Модель против правила «позвоночник = нарушение»0.854 против 0.529оценка на всём наборе, включая обучающие снимки, поэтому смещена вверх

Разбивка по областям нужна потому, что нарушения распределены неравномерно: в позвоночнике 33 из 99 снимков, у бёдер 21–22 из 73–79, а сама область почти однозначно определяется по ширине кадра. Поэтому общий AUC частично отражает различение области, а не только распознавание дефекта. Чтобы отделить одно от другого, выполнена проверка: правило «позвоночник = нарушение» даёт внутри областей 0.50 (подсказки нет), модель — 0.85–0.93. Это означает, что модель использует содержимое снимка, а не только область.

12.3. Выбор правила разметки

Метрика (эталон)только таблицас суффиксами имён
ROC-AUC0.6764 [0.6309, 0.7218]0.6199 [0.5840, 0.6559]
PR-AUC0.4759 [0.4141, 0.5377]0.4046 [0.3702, 0.4391]
F10.5676 [0.5270, 0.6082]0.5426 [0.5073, 0.5778]
Recall / Precision0.700 / 0.4860.863 / 0.404

Парная разница (только таблица минус с суффиксами): ROC-AUC +0.0564 [+0.0403, +0.0725], PR-AUC +0.0713 [+0.0398, +0.1028] — знаки +++++, то есть преимущество на всех пяти seed'ах. Принято правило «только экспертная таблица». Оговорка, важная для интерпретации: эталон — та же таблица, поэтому вариант, обучавшийся на ней, находится в выигрышном положении; значимо здесь то, что добавление ненадёжных пометок согласие с экспертом снижает, а не повышает.

13. Производительность и системные требования

13.1. Измерения

Измерения выполнены на рабочей машине разработчика (macOS, Apple Silicon) на полном наборе данных — 548 файлов в dataset_hack (544 обучающих плюс 4 тестовых). Инференс принудительно переведён на CPU параметром --device cpu, чтобы числа не зависели от наличия ускорителя.

ПоказательЗначениеУсловия
Время на изображение (медиана)0.021 сCPU, включает чтение DICOM, предобработку и проход сети
Время на изображение (максимум)0.131 стот же прогон; первый снимок включает прогрев
Пакет целиком (548 файлов)12.1 сот запуска процесса до записи XLSX, включая загрузку чекпоинта
Доля успешно обработанных файлов548 / 548 (100 %)ошибок чтения на этом наборе нет
Пиковая память процесса≈ 640 МБmaximum resident set size, пакетный CPU-инференс
Ответ API на один файл16–18 мсвызов внутри процесса, после прогрева; первый запрос 390 мс. Здесь устройство выбрано автоматически (MPS), на CPU значение того же порядка — см. медиану пакетного прогона выше
Время старта сервиса≈ 3 симпорт библиотек и загрузка чекпоинта

Отсюда следует прямой ответ на требование «не более 3 минут на исследование»: даже если исследование содержит максимальные по заданию три снимка, обработка занимает доли секунды, а трёхминутный бюджет расходуется практически только на передачу файлов и накладные расходы интеграции. Запас более чем трёхкратный, поэтому узким местом решение не ограничивает.

13.2. Минимальная и рекомендуемая конфигурация

РесурсМинимальная конфигурацияРекомендуемая
Процессор2 ядра x86-64 или ARM644 ядра и более; инференс векторизован и масштабируется по кадрам
Оперативная память1.5 ГБ (измеренный пик ≈ 640 МБ + запас на ОС и веб-слой)4 ГБ
Графический ускорительНе требуется: torch устанавливается из CPU-индекса, инференс на CPU укладывается в требование по времениЛюбой GPU с поддержкой CUDA 11.8 — вариант сборки Dockerfile_cuda
Дисковое пространствобазовый образ python:3.11-slim (150 МБ) + зависимости и код; чекпоинт 43 МБ монтируется отдельноплюс место под входные архивы DICOM и результаты
СетьНе требуется при работе: образ и веса фиксированы, обращений во внешние сервисы нетНужна только для сборки образа и загрузки базовых зависимостей

Размер образа (измерено): 1.41 ГБ (1 412 385 143 байт) для сборки под linux/arm64. Базовый образ python:3.11-slim занимает 150 МБ, остальное — зависимости из requirements.txt, включая torch и torchvision из CPU-индекса; CUDA-колёса в образ не попадают. Данные, тесты, labels/ и assets/ в образ не входят, чекпоинт (43 МБ) монтируется отдельно.

14. Сборка, запуск и развёртывание

14.1. Состав образа

ЭлементЗначение
Базовый образpython:3.11-slim (фиксированный тег)
Зависимостиrequirements.txt с точными версиями; torch и torchvision — из CPU-индекса PyTorch, чтобы в образ не попали CUDA-колёса
Что копируется в образтолько src/, requirements.txt и run.sh
Что не копируетсяданные, тесты, labels/, assets/, docs/ (см. .dockerignore), а также чекпоинт
Чекпоинтмонтируется при запуске в /app/models; путь задаётся переменной DXA_MODEL_PATH (по умолчанию /app/models/dxa_model.pth)
Проверки на этапе сборкиимпорт приложения (import src.main) и наличие офлайн-ассетов фронтенда; сборка падает при их отсутствии
Контроль состоянияHEALTHCHECK обращается к /api/v1/health каждые 30 с
Точка входаuvicorn src.main:app на порту 8000

Если чекпоинт не смонтирован, сервис всё равно поднимается: /api/v1/health сообщает model_loaded: false, а запросы анализа возвращают ошибку вместо тихой неверной оценки. Это сознательный выбор: отсутствие модели должно быть заметно сразу, а не проявляться как «странные» результаты.

Что проверено на самом образе. Сборка прошла обе внутренние проверки (app import ok, frontend assets ok). Образ запущен и проверен в двух режимах: без смонтированного чекпоинта /api/v1/health отдаёт model_loaded: false и exists: false, а запрос анализа — HTTP 500 с телом {"error": "Model not loaded"}; с примонтированным каталогом моделей /api/v1/health сообщает model_loaded: true, device: cpu, эпоху 39 и файл разметки labels/labels_images.csv, запрос анализа отвечает корректной строкой результата, а /api/v1/export возвращает XLSX с ожидаемым набором столбцов. Сквозная проверка выполнялась в контейнере на той же машине, где снимались измерения производительности.

14.2. Воспроизводимость

14.3. Сборочный конвейер

Jenkinsfile выполняет три шага: сборка образа, публикация в реестр и выкладка в кластер. Скрипт run.sh — единая точка входа для разработки и эксплуатации: обучение, разметка, приведение имён файлов, инференс, сервис, сравнение вариантов разметки и тесты (перечень команд — приложение Б).

15. Тесты и проверки

Тесты — pytest, 209 проверок в шести файлах; запуск — ./run.sh test или python -m pytest tests/ -q. Сверка выполнена на дату документа: 209 passed.

ФайлТестовЧто проверяет
tests/test_labels.py38 разбор имён, определение области и метки по имени, стратифицированное разбиение по исследованиям, отсутствие утечки, фиксация и переиспользование разбиения в файле, сверка с реальным датасетом
tests/test_excel_labels.py50 разметка по экспертной таблице: чтение критериев, правило «1 = нарушение», голосование по области, перенос оценки на единственное бедро, три правила метки (table / union / expert), их согласованность и подключение к обучению
tests/test_rename_files.py36 приведение имён DICOM: разбор и канонизация, поиск свободного номера при конфликте, отказ угадывать область, цикл «применить → откатить»
tests/test_preprocess_and_model.py32 конфигурация и нормировка, сборка тензора и нормировка ImageNet, метрики, подбор порога, контракт модели, BatchNorm при заморозке, roundtrip чекпоинта, проверка на реальных снимках
tests/test_violations.py28 единый словарь типов: приведение устаревших значений, подписи, коды SR и каталог для API, согласованность с критериями экспертной таблицы
tests/test_api_contract.py25 поля ответов, которые читает веб-интерфейс: базовый и детальный анализ, здоровье и карточка модели, выгрузка XLSX, обработка ошибок; различимость метрик между снимками и валидность PNG-визуализаций

Отдельный контур — браузерные проверки интерфейса (tests/browser/*.js, Node и playwright-core): они открывают интерфейс во временном профиле Chrome, загружают DICOM, кликают по строкам таблицы и снимают содержимое панели деталей. Требуется запущенный сервер на 127.0.0.1:8123; профиль пользователя не затрагивается.

СценарийЧто проверяет
ui_check.js подсказку о кликабельности строк и её скрытие при пустом фильтре; открытие панели деталей из строки; изменение значений при переключении строк; наполнение панели «О модели» метриками и словарём
ui_violation.js ветку «нарушение»: бейдж, POOR, HIGH, текст заключения
ui_offline.js отсутствие обращений страницы к внешним хостам

16. Известные ошибки и их обработка

Требование задания — отсутствие необработанных исключений: сбой на одном файле фиксируется в отчёте и не прерывает пакет. Ниже — фактические режимы отказа и способ обработки.

СитуацияПоведениеГде обрабатывается
Ошибка чтения или обработки файла (CLI) строка получает quality_class = -1, область unknown, пустые UID и processing_status = Failure: <Тип>: <сообщение> (до 120 символов); остальные файлы обрабатываются дальше process_one, src/dxa/inference.py
Неподдерживаемая форма pixel_array (не 2D и не 3D) ValueError, превращается в Failure: согласно строке выше preprocess.py
Чекпоинт отсутствует или не читается сервис поднимается, /api/v1/health отдаёт model_loaded: false, а запросы анализа — HTTP 500 {"error": "Model not loaded"} load_model(), src/main.py
Архитектура чекпоинта не совпала с запрошенной ValueError с указанием сохранённых backbone / head load_model, src/dxa/inference.py
Чекпоинт — «сырой» state_dict без метаданных ValueError: файл не содержит model_state_dict DXAQualityModel.load
Сбой в одиночном запросе /analyze, /analyze/detailed, /analyze/sr HTTP 500 с телом {"error": <текст>, "processing_status": "Failure: <первые 50 символов>"}; детальный эндпоинт дополнительно печатает стек в лог сервера обработчики маршрутов
Сбой одного файла в /api/v1/batch элемент становится {"filename", "error", "processing_status": "Failure"}, остальные файлы возвращаются нормально batch_analyze
Сбой одного файла в /api/v1/export строка со статусом Failure: <до 80 символов> и quality_class = -1 попадает в XLSX, выгрузка не прерывается export_results
Файл разметки не найден при обучении предупреждение в лог и откат на метки из имён файлов; откат залогирован явно, потому что вместе с источником меток меняются метрики resolve_labels_csv, src/dxa/train.py
Пустое разбиение (нет обучающих или валидационных снимков) RuntimeError с числами train / val до начала обучения train()
Сервис без чекпоинта намеренно не пытается «угадывать»: он поднимается, но честно сообщает model_loaded: false и отклоняет запросы анализа. Молчаливая неверная оценка вместо явной ошибки была бы опаснее в медицинском контуре.

17. Ограничения и достоверность результатов

Ограничения сформулированы так, чтобы читатель мог оценить границы применимости решения, а не только увидеть итоговые метрики.

  1. Разметка выведена из оценки исследования, а не снимка. Экспертная таблица описывает исследование; перенос вердикта на снимок однозначен (область встречается один раз), но поштучной экспертной оценки снимков в наборе нет. Оценка качества модели упирается в качество этой разметки, а не только в объём данных.
  2. Мало данных. 252 уникальных снимка, 77 нарушений. Доверительные интервалы широкие (ROC-AUC 0.6764 [0.6309, 0.7218] по пяти seed'ам), поэтому оценка на закрытом наборе может отличаться.
  3. Эталон — та же таблица. Независимой истины нет, поэтому сравнение правил разметки частично благоприятствует варианту «только таблица» (раздел 12.3).
  4. В DICOM нет разметки областей интереса. Ни overlay, ни graphic annotation в файлах нет, поэтому корректность нанесённых областей измерения нельзя проверить прямым сравнением с эталоном: оценивается геометрия видимой зоны.
  5. Тип нарушения определяется признаками снимка, а не обученной моделью. Модель решает только бинарную задачу, поэтому в выгрузке тип содержателен только для размеченных снимков, а на инференсе эвристика на текущих данных всегда даёт «не уточнён» (раздел 8.2). Для честного мультикласса нужна разметка типов на уровне снимка; пять снимков имеют только код «не уточнён», потому что источник не указывает критерий.
  6. Три снимка размечены по пометке в имени файла — экспертная таблица их область не оценивала. Такие строки помечены признаком filename_fallback.
  7. Сторона бедра в семи исследованиях с единственным снимком не проверяема: теги Laterality пусты, оценка взята из единственного заполненного столбца таблицы.
  8. Порог определения области привязан к текущему оборудованию. Признак «ширина кадра» безошибочно работает на этом наборе, но при смене аппарата потребует калибровки.
  9. Числовые эвристики панели деталей не калиброваны. Их пороги рассчитаны на другой масштаб интенсивностей, поэтому в интерфейсе показываются измерения, а не вердикты.
  10. Рабочая точка порога даёт высокий recall при умеренной точности. На обучающем наборе при пороге, подобранном по F1, модель относит к нарушениям 341 строку из 548 (≈ 62 %) при фактической доле нарушений 30.6 %, что согласуется с precision 0.486. Порог выбран в пользу полноты: пропустить непригодное исследование дороже, чем показать лишнее.

18. План развития

Приложение А. Структура репозитория

bone_2026/
├── src/
│   ├── main.py                     FastAPI: маршруты и загрузка модели
│   ├── dxa/                        действующий модуль оценки качества
│   │   ├── preprocess.py           DICOM -> тензор (общий путь для обучения и API)
│   │   ├── model.py                сеть, метрики, подбор порога, чекпоинт
│   │   ├── train.py                обучение и отчёт (md/json)
│   │   ├── dataset.py              DXADataset, DataLoader
│   │   ├── inference.py            пакетный инференс, определение области, визуализация
│   │   ├── labels.py               разбор имён, метки, склейка дублей, разбиение
│   │   ├── excel_labels.py         разметка снимков по экспертной таблице
│   │   ├── rename_files.py         приведение имён DICOM к единому виду
│   │   ├── violations.py           единый словарь типов нарушений
│   │   ├── model_card.py           карточка решения для API и интерфейса
│   │   ├── compare_labels.py       сравнение источников разметки
│   │   ├── discriminator.py        проверка вклада содержимого снимка
│   │   └── render.py               рендер снимков и контактных листов
│   ├── quality/                    эвристики: измерения снимка
│   ├── api/static/                 веб-интерфейс (офлайн-ассеты)
│   └── utils/                      выбор устройства
├── labels/                         размеченные данные, разбиение, карта переименований
├── assets/                         схемы, скриншоты, обоснование разметки
├── models/dxa_model.pth            рабочий чекпоинт и отчёт обучения
├── tests/                          pytest + браузерные проверки интерфейса
├── docs/                           настоящий документ (HTML и PDF)
├── run.sh, Dockerfile, Dockerfile_cuda, Jenkinsfile, requirements.txt

Приложение Б. Команды

# обучение и разметка
./run.sh label                          разметить датасет по экспертной таблице
./run.sh rename                         план приведения имён файлов (--apply для применения)
./run.sh train                          обучить модель (режим меток по умолчанию)
./run.sh split && ./run.sh compare       сравнить варианты разметки на одном разбиении

# инференс и сервис
./run.sh infer "dataset_hack/Для теста" results.xlsx
./run.sh serve                          API и веб-интерфейс на порту 8000
python -m src.dxa.discriminator         проверка вклада содержимого снимка

# проверки
./run.sh test                           pytest (209 тестов)
node tests/browser/ui_check.js          проверки интерфейса (нужен сервер на 127.0.0.1:8123)

# контейнер
docker build -t dxa-quality .
docker run -v /path/to/data:/data -v /path/to/models:/app/models -p 8000:8000 dxa-quality

Документ подготовлен по фактическому состоянию репозитория; машинные отчёты обучения — models/train_report.json, обоснование разметки — assets/labeling.md.