develop - hack_2026

This commit is contained in:
denis 2026-09-27 00:28:34 +03:00
parent ddfeb01174
commit ae3d4f4330
49 changed files with 6090 additions and 177 deletions

165
QWEN.md
View File

@ -35,7 +35,13 @@
```
src/dxa/
├── labels.py # имена -> метки, склейка дублей, разбиение по исследованиям
├── 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 # сеть, метрики, подбор порога, сохранение/загрузка
@ -43,37 +49,47 @@ src/dxa/
└── 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/` (чекпоинты и отчёт сравнения правил).
### Ключевые решения (проверены экспериментально)
| Решение | Причина |
|---|---|
| Метки из имён файлов: `_bad` > `_good` > нет метки (=good) | Явная оценка в имени файла; отсутствие метки означает «хорошее» |
| Единый словарь типов нарушений (`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 | При 15 % нарушений порог 0.5 даёт нулевой recall; вероятности насыщаются |
| Порог по логиту, подбор по 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 снимков |
### Проверка вклада модели
Не является ли модель просто детектором анатомии (в позвоночнике ~29 % нарушений
против ~4–5 % у бёдер, а область почти однозначно определяется по ширине кадра):
Не является ли модель просто детектором анатомии (нарушений около трети и в
позвоночнике, и у бёдер, а область почти однозначно определяется по ширине кадра):
```bash
python -m src.dxa.discriminator --model-path models/dxa_model.pth
```
Результат на чекпоинте `models/dxa_model.pth`:
Результат на рабочем чекпоинте `models/dxa_model.pth` (оценка на всём наборе,
включая обучающие снимки, поэтому значения смещены вверх):
| Предиктор | Общий AUC | spine | hip_right | hip_left |
|---|---|---|---|---|
| Модель | 0.822 | 0.838 | 0.895 | 0.948 |
| Правило «позвоночник = нарушение» | 0.724 | 0.500 | 0.500 | 0.500 |
| Модель | 0.854 | 0.928 | 0.861 | 0.853 |
| Правило «позвоночник = нарушение» | 0.529 | 0.500 | 0.500 | 0.500 |
Модель использует содержимое снимка: внутри областей она даёт 0.84–0.95.
Правило по области внутри области всегда 0.50 (подсказки нет).
Модель использует содержимое снимка: внутри областей она даёт 0.85–0.93.
Правило по области внутри области всегда 0.50 (подсказки нет). Честная оценка на
held-out — в `docs/labeling.md` §8: ROC-AUC 0.6764 [0.6309, 0.7218] по пяти seed'ам.
## Данные (`dataset_hack/`)
@ -89,37 +105,75 @@ dataset_hack/
Факты, важные для обучения:
- 544 файла на диске, но **252 уникальных снимка** (по пиксельному содержимому).
- 86 файлов имеют явную метку; после склейки дублей — **37 нарушений из 252 (14.7 %)**.
- 544 файла на диске, но **252 уникальных снимка** (по пиксельному содержимому)
на 100 исследований: позвоночник 99, бедро R 79, бедро L 73, 1 с неопределённой
областью.
- Экспертная таблица отмечает нарушения у **74 снимков (29.4 %)**; три снимка
таблица область не оценивала.
- Рабочая разметка (правило `table`) — **77 нарушений из 252 (30.6 %)**: 74 по
таблице плюс 3 снимка без экспертной оценки, помеченных `filename_fallback`.
- Дубли не пересекают границы исследований, конфликтов меток при склейке нет.
- Имена неоднородны: `spine_01`, `Spine`, `r_spine`, `spine-1`, `l_hip`,
`l_hip-2`, `r_hip`, `r_hop`.
- В DICOM **нет** разметки ROI (ни OverlayData, ни GraphicAnnotationSequence),
поэтому корректность нанесённых областей нельзя проверить прямым сравнением.
- Метка в Excel относится к исследованию и раздаётся его снимкам; имена файлов
имеют приоритет. Excel используется только для предупреждения о расхождениях.
Два побайтных дубля названы по-разному, поэтому область определяется
голосованием по именам файлов.
- Имена файлов приведены к виду `<область>_<NN>[_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).
### Единица разметки — источник шума
### Единица разметки
Один снимок в исследовании помечен `_bad`, остальные не размечены. Метка снимка
считается унаследованной от исследования, поэтому часть меток заведомо шумная.
Это главное ограничение текущего качества модели.
Таблица описывает исследование, а не снимок. Однако каждая анатомическая область
встречается в исследовании ровно один раз (после склейки дублей), поэтому вердикт
исследования по области переносится на снимок однозначно — не нужно решать, какой
из нескольких снимков «плохой». Так получены метки и типы нарушений
(`labels/labels_images.csv`); каждый источник свидетельства сохранён в отдельном
столбце, поэтому правило можно переиграть без повторного разбора. Почему выбрано
именно правило «только таблица» — в `docs/labeling.md` §4.
---
## Обучение
```bash
./run.sh train # режим по умолчанию
./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_logit`
хранятся внутри, поэтому инференс не может рассинхронизироваться с обучением.
Чекпоинт самодостаточен: `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`.
---
@ -128,7 +182,8 @@ python -m src.dxa.train --head mlp --freeze-epochs 0 --epochs 30
| Метод | Путь | Назначение |
|---|---|---|
| GET | `/` | Веб-интерфейс |
| GET | `/api/v1/health` | Статус, признак загрузки модели |
| GET | `/api/v1/health` | Статус, признак загрузки модели и её происхождение (разметка, разбиение, порог, эпоха) |
| GET | `/api/v1/model` | Карточка решения: разметка, данные, метрики с интервалами, словарь нарушений, ограничения |
| POST | `/api/v1/analyze` | Базовый анализ файла |
| POST | `/api/v1/analyze/detailed` | Расширенный отчёт, опционально маска |
| POST | `/api/v1/analyze/sr` | Текстовый отчёт DICOM SR |
@ -146,11 +201,19 @@ API и CLI используют один код предсказания (`predi
```bash
./run.sh test
python -m pytest tests/ -q # 79 тестов
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`
@ -165,10 +228,24 @@ python -m pytest tests/ -q # 79 тестов
панели деталей. Используется временный профиль Chrome, профиль пользователя не
затрагивается. Требуется запущенный сервер на `127.0.0.1:8123`.
- `ui_check.js` — значения панели меняются при переключении строк.
- `ui_check.js` — подсказка о кликабельности строк видна и скрывается, когда
фильтр не оставил строк; кнопка «Открыть» в строке открывает панель деталей;
значения панели меняются при переключении строк; панель «О модели» наполняется
метриками и словарём (иначе раздел остался бы пустым каркасом).
- `ui_violation.js` — ветка «нарушение» (бейдж, POOR, HIGH, заключение).
- `ui_offline.js` — страница не обращается к внешним хостам.
### Честность интерфейса
Веб-интерфейс не должен утверждать больше, чем известно решению, поэтому:
- тип нарушения показан с пометкой «эвристика» и пояснением, что модель решает
только бинарную задачу;
- ROC-AUC рабочего чекпоинта в баннере состояния помечена как завышенная (он
выбран лучшим из пяти seed'ов), а честная оценка лежит в панели «О модели»;
- плитка средней уверенности не называется точностью;
- подписи типов и метрики приходят с сервера (`/api/v1/model`), копий в JS нет.
### Офлайн-работа фронтенда
Tailwind и FontAwesome лежат локально (`src/api/static/vendor`,
@ -180,11 +257,21 @@ Tailwind и FontAwesome лежат локально (`src/api/static/vendor`,
## Известные ограничения
1. Разметка на уровне исследования → шум в метках снимков.
2. Мало данных: 252 снимка, 37 нарушений; доверительные интервалы широкие.
3. Тип нарушения определяется эвристиками, а не обученной моделью.
4. Порог `SPINE_MIN_WIDTH` привязан к текущему оборудованию.
5. Grad-CAM (`src/models/visualization/gradcam.py`) есть, но не подключён.
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)
@ -207,6 +294,10 @@ docker build -t dxa-quality .
docker run -v /path/to/data:/data -p 8000:8000 dxa-quality
```
Dockerfile ставит зафиксированные версии, копирует только `src/`, `models/` и
`run.sh`, проверяет чекпоинт на этапе сборки и имеет HEALTHCHECK. Данные и тесты
в образ не попадают (`.dockerignore`).
Dockerfile ставит зафиксированные версии, копирует только `src/` и `run.sh`,
проверяет импорт приложения и наличие офлайн-ассетов фронтенда на этапе сборки и
имеет HEALTHCHECK. Чекпоинт в образ не копируется — он монтируется в `/app/models`
при запуске (`DXA_MODEL_PATH`). Данные, тесты и `labels/` в образ не попадают
(`.dockerignore`), поэтому `./run.sh train` внутри контейнера возьмёт метки из
имён файлов (с предупреждением); для обучения в контейнере смонтируйте `labels/`
или передайте свой `--labels-csv`.

224
README.md
View File

@ -4,6 +4,10 @@
принимает DICOM, определяет анатомическую область, оценивает, пригодно ли изображение
для клинической интерпретации, и формирует структурированный отчёт.
<div align="center">
<img src="assets/img/ui-results.png" alt="Результаты обработки в веб-интерфейсе: полоса состояния, статистика и таблица снимков" width="90%">
</div>
## Что делает решение
| Шаг | Реализация |
@ -13,7 +17,7 @@
| Тип нарушения | Общая категория для снимков с нарушением; детальный тип требует разметки типов на уровне снимка |
| Отчёт | XLSX/CSV со столбцами из требований; опционально zip с визуализацией зоны интереса |
| API | FastAPI: анализ, детальный анализ, пакетная обработка, экспорт, DICOM SR (текст) |
| Веб-интерфейс | Загрузка DICOM, таблица результатов, панель деталей с визуализацией |
| Веб-интерфейс | Загрузка DICOM, таблица результатов, панель деталей с визуализацией, панель «О модели» |
## Установка и запуск
@ -21,9 +25,12 @@
python3 -m venv venv && source venv/bin/activate
pip install -r requirements.txt
./run.sh label # разметить датасет по Excel
./run.sh rename # план приведения имён файлов
./run.sh train # обучить модель качества
./run.sh infer "dataset_hack/Для теста" results.xlsx # пакетная обработка
./run.sh serve # API и веб-интерфейс на :8000
./run.sh split && ./run.sh compare # сравнить варианты разметки
./run.sh test # тесты
```
@ -60,6 +67,12 @@ docker run -v /path/to/data:/data -p 8000:8000 dxa-quality
## Архитектура
<div align="center">
<img src="assets/img/architecture-pipeline.png" alt="Пайплайн обработки DXA: загрузка DICOM, разбор метаданных, предобработка, инференс, определение области, метрики качества, отчёт" width="52%">
</div>
Схема работающего пути в терминах решения:
```
DICOM ──▶ предобработка ──▶ ResNet18 (заморожен) ──▶ линейная голова ──▶ логит
│ │
@ -77,9 +90,15 @@ DICOM ──▶ предобработка ──▶ ResNet18 (замороже
≈ 0.7–0.85. Режим `--head mlp --freeze-epochs 0` оставлен для экспериментов
на большем объёме данных.
2. **Метки из имён файлов.** Суффикс `_good`/`_bad` — экспертная оценка снимка;
отсутствие суффикса означает «изображение хорошее». Приоритет:
`_bad` > `_good` > нет метки.
2. **Метки на уровне снимка.** Источник — `labels/labels_images.csv`, построенный
из экспертной таблицы командой `./run.sh label` (разбор — в
`assets/labeling.md`): в таблице отмечены критерии качества по каждому
исследованию, а каждая область встречается в нём ровно один раз, поэтому
вердикт переносится на снимок однозначно. Такой разметки — 77 нарушений из
252 (30.6 %). Правило выбрано измерением: учёт ручных пометок из имён файлов
дал худший результат на held-out наборе, поэтому в метках они не участвуют.
Резервный режим `--labels-csv ""` берёт метку из суффикса `_good`/`_bad` и
оставлен для совместимости.
3. **Склейка побайтных дублей.** В датасете 544 файла, но 252 уникальных снимка:
один и тот же кадр сохранён многократно под разными именами (часть — с меткой,
@ -97,6 +116,25 @@ DICOM ──▶ предобработка ──▶ ResNet18 (замороже
Порог подбирается по F1 на валидации и сохраняется в чекпоинт; решение
принимается по логиту (численно устойчиво при насыщении вероятностей).
### Схемы системы
| Компоненты | Поток данных |
|---|---|
| <img src="assets/img/architecture-components.png" width="100%" alt="Диаграмма компонентов: веб-клиент, FastAPI, модели DXA, оценка качества, файловая система"> | <img src="assets/img/architecture-dataflow.png" width="100%" alt="Поток данных: вход, предобработка, модели, анализ качества, выход"> |
| Последовательность детального анализа | Развёртывание |
|---|---|
| <img src="assets/img/architecture-sequence.png" width="100%" alt="Диаграмма последовательности: запрос /analyze/detailed, предобработка, инференс, регион, сегментация, отчёт"> | <img src="assets/img/architecture-deployment.png" width="100%" alt="Диаграмма развёртывания: клиент, приложение, ML-пайплайн, инфраструктура"> |
| Определение анатомической области | Основные сущности |
|---|---|
| <img src="assets/img/architecture-region-detection.png" width="100%" alt="Алгоритм определения области: порог по перцентилю яркости, bounding box, соотношение сторон, сторона бедра"> | <img src="assets/img/architecture-classes.png" width="100%" alt="Диаграмма классов: классификатор, модель, детальная оценка, определение области"> |
Схемы взяты из проектного документа и показывают целевую архитектуру: блоки
`Orchestrator`, UNet-сегментации и готовых вердиктов качества в API не подключены.
Работающий путь описан выше, неподключённые модули перечислены в разделе
«Структура проекта».
---
## Формат выходных данных
@ -122,7 +160,8 @@ DICOM ──▶ предобработка ──▶ ResNet18 (замороже
| Метод | Путь | Назначение |
|---|---|---|
| GET | `/` | Веб-интерфейс |
| GET | `/api/v1/health` | Статус и признак загрузки модели |
| GET | `/api/v1/health` | Статус, признак загрузки модели и её происхождение |
| GET | `/api/v1/model` | Карточка решения: разметка, данные, метрики с интервалами, словарь нарушений |
| POST | `/api/v1/analyze` | Базовый анализ одного файла |
| POST | `/api/v1/analyze/detailed` | Расширенный отчёт, опционально маска |
| POST | `/api/v1/analyze/sr` | Текстовое представление отчёта DICOM SR |
@ -139,37 +178,136 @@ curl -X POST http://localhost:8000/api/v1/analyze -F "file=@study/spine.dcm"
"image_uid": "1.2.643...",
"anatomical_region": "spine",
"quality_class": 1,
"quality_label": "Violation detected",
"violation_type": "quality_violation_detected",
"reason": "Выявлено нарушение качества изображения",
"quality_label": "Есть нарушение качества",
"violation_type": "artifact",
"violation_type_label": "Артефакты и импланты",
"violation_type_is_heuristic": true,
"violation_type_note": "Тип нарушения определён эвристикой по метрикам снимка, а не моделью...",
"reason": "Посторонние включения или артефакты в зоне интереса",
"confidence": 0.72,
"threshold_probability": 0.6154,
"threshold_probability": 0.5103,
"processing_status": "Success"
}
```
Коды `violation_type` берутся из единого словаря `src/dxa/violations.py`
(`positioning`, `axis_deviation`, `artifact`, `rotation`, `roi_incorrect`,
`motion`, `incomplete_anatomy`, `labeling_error`, `unspecified`) — первые пять
кодирует экспертная таблица, остальные приходят из критериев пригодности. Поле
`violation_type_is_heuristic` показывает, что тип определён эвристикой, а не
моделью: модель решает только бинарную задачу. Подписи для интерфейса отдаёт
сервер, своей копии словаря фронтенд не держит.
---
## Веб-интерфейс
Полоса состояния в шапке показывает, **какая** модель сейчас работает: имя
чекпоинта, источник разметки, эпоху и порог. По интерфейсу сразу видно, какая
версия решения отвечает, — без обращения к логам.
<div align="center">
<img src="assets/img/ui-banner.png" alt="Полоса состояния: загруженная модель, разметка, эпоха, порог" width="90%">
</div>
Строки таблицы результатов кликабельны, но об этом надо сказать прямо — иначе
не догадываются. Над таблицей висит подсказка «Нажмите на любую строку», в конце
каждой строки есть кнопка «Открыть», а клик по строке прокручивает страницу к
панели деталей. Подсказка скрывается, когда фильтр не оставил ни одной строки.
Кнопка «Открыть» — настоящая кнопка, поэтому панель доступна и с клавиатуры
(Tab + Enter), а не только мышью. Сама таблица со статистикой и подсказкой — на
первом скриншоте.
Панель деталей открывается по клику на строку: заключение, измерения и
визуализация снимка. Специально показаны обе ветки — «нарушение» и «норма»:
<table>
<tr>
<td width="50%"><img src="assets/img/ui-detail-violation.png" width="100%" alt="Панель деталей для снимка с нарушением: красный бейдж, уровень HIGH, заключение с вероятностью и порогом, визуализация"></td>
<td width="50%"><img src="assets/img/ui-detail-clean.png" width="100%" alt="Панель деталей для качественного снимка: зелёный бейдж, справочные измерения"></td>
</tr>
<tr>
<td><sub><b>Нарушение.</b> Бейдж «Нарушение», уровень HIGH, заключение с вероятностью и порогом, тип нарушения с пометкой «эвристика».</sub></td>
<td><sub><b>Норма.</b> Зелёный бейдж. Измерения (резкость, границы области) подписаны как справочные — их пороги не калиброваны.</sub></td>
</tr>
</table>
Панель **«О модели»** (кнопка в шапке): что за чекпоинт работает, на какой
разметке он обучен, с каким порогом решает, метрики сравнения с 95 % интервалами,
состав данных, словарь типов нарушений и список ограничений.
<div align="center">
<img src="assets/img/ui-model-panel.png" alt="Панель «О модели»: сведения о чекпоинте, метрики с интервалами, состав данных, словарь нарушений" width="90%">
</div>
Источник всех этих значений — сервер (`/api/v1/health`, `/api/v1/model`).
Фронтенд намеренно не держит собственных копий: раньше подписи типов нарушений
были продублированы в `dxa-app.js` и разошлись с серверными, из-за чего коды
экспертной таблицы показывались как есть.
Что интерфейс теперь не утверждает:
- **тип нарушения помечен как «эвристика»** с пояснением — модель решает только
бинарную задачу и тип не предсказывает;
- **метрика рабочего чекпоинта помечена как завышенная** прямо в баннере
состояния и в карточке: чекпоинт выбран лучшим из пяти seed'ов, честная
оценка варианта — среднее по seed'ам;
- **плитка «Ср. уверенность модели»** больше не называется точностью: точность
требует эталонных меток, которых для произвольного файла нет;
- числовые метрики в панели деталей по-прежнему идут под дисклеймером о
некалиброванности порогов.
---
## Метрики
Метрики зависят от выбранного разбиения по исследованиям, поэтому приводятся
с разбросом. Оценка на валидационной части (19 исследований, 51 снимок,
8 нарушений), разбиение по исследованиям:
с разбросом. Основная оценка — на **фиксированном** разбиении по исследованиям
(обучение 199 снимков / 81 исследование, валидация 53 снимка / 19 исследований,
16 нарушений), пять seed'ов обучения, эталон — вердикт эксперта:
| Что измерено | Значение | Как измерено |
|---|---|---|
| ROC-AUC, 5 разбиений | **0.76 ± 0.08** (0.64 – 0.84) | обучение по seed 0..4, порог по F1 |
| PR-AUC, 5 разбиений | 0.48 ± 0.17 | там же; базовый уровень при 15 % нарушений — 0.15 |
| F1, 5 разбиений | 0.54 ± 0.11 | там же (порог подобран на той же валидации — смещено вверх) |
| ROC-AUC, 5-фолдовая CV | **0.81 ± 0.08** | линейный зонд на тех же признаках, разбиение по исследованиям |
| ROC-AUC | **0.6764** [0.6309, 0.7218] | 5 seed'ов на фиксированном разбиении |
| PR-AUC | 0.4759 [0.4141, 0.5377] | там же; базовый уровень при 30 % нарушений — 0.30 |
| F1 | 0.5676 [0.5270, 0.6082] | там же; порог подобран на той же валидации — смещено вверх |
| ROC-AUC по областям | позвоночник 0.943, бедро R 0.576, бедро L 0.550 | рабочий чекпоинт, собственная валидация |
| Контрольная задача «позвоночник / бедро» | AUC 1.00 | проверка работоспособности пайплайна |
| Перестановка меток (нулевая гипотеза) | AUC 0.64 | вклад случайных корреляций |
| Модель использует снимок, а не область | AUC 0.854 против 0.529 у правила области | `discriminator` на всём наборе, включая обучающие снимки |
Метрики по областям — в `models/train_report.md`, он создаётся при обучении.
Разбивка важна, потому что нарушения распределены крайне неравномерно: в
позвоночнике ~29 % снимков с нарушением против ~4–5 % у бёдер, а область почти
однозначно определяется по ширине кадра. Поэтому общий AUC частично отражает
различение области, а не только распознавание дефекта.
Разбивка важна, потому что нарушения распределены неравномерно: в позвоночнике
33 из 99, у бёдер 21–22 из 73–79, а область почти однозначно определяется по
ширине кадра. Поэтому общий AUC частично отражает различение области, а не только
распознавание дефекта.
### Выбор правила разметки
Метку можно было строить только из экспертной таблицы или дополнительно
учитывать пометки, проставленные вручную в именах файлов (суффикс `_bad`).
Пометки расходились с оценкой эксперта в 15 случаях из 252, поэтому правило
выбиралось измерением: одно разбиение, пять seed'ов, один эталон.
| Метрика (эталон) | только таблица | с суффиксами имён |
|---|---|---|
| ROC-AUC | **0.6764** [0.6309, 0.7218] | 0.6199 [0.5840, 0.6559] |
| PR-AUC | **0.4759** [0.4141, 0.5377] | 0.4046 [0.3702, 0.4391] |
| F1 | **0.5676** [0.5270, 0.6082] | 0.5426 [0.5073, 0.5778] |
| Recall / Precision | 0.700 / 0.486 | 0.863 / 0.404 |
Парная разница (только таблица − с суффиксами): ROC-AUC **+0.0564**
[+0.0403, +0.0725], PR-AUC **+0.0713** [+0.0398, +0.1028] — знаки `+++++`, то
есть преимущество на всех пяти seed'ах.
**Принято правило «только экспертная таблица».** Пометки в именах файлов
проставлялись вручную и оказались ненадёжными: они ухудшали согласие модели с
экспертом на невиданных исследованиях. Оговорка: эталон — та же таблица, поэтому
вариант, обучавшийся на ней, в выигрышном положении; значимо то, что добавление
ненадёжных пометок согласие **снижает**.
Отчёт с полными числами и разбором ограничений — `assets/labeling.md`;
машинные отчёты — `models/compare_rules/rule_comparison.md` и
`models/train_report.json`.
Время обработки одного снимка — порядка 0.02–0.05 с на CPU (ResNet18 с
замороженным backbone), то есть требование «не более 3 минут на исследование»
@ -179,11 +317,12 @@ curl -X POST http://localhost:8000/api/v1/analyze -F "file=@study/spine.dcm"
## Ограничения (важно для интерпретации)
1. **Разметка исходных данных — на уровне исследования, а не снимка.**
В наборе один снимок помечен `_bad`, остальные снимки того же исследования
не размечены. Метка снимка считается унаследованной от исследования, поэтому
часть меток заведомо шумная.
2. **Мало данных.** 252 уникальных снимка, 37 нарушений. Доверительные интервалы
1. **Разметка выведена из оценки исследования.** Экспертная таблица описывает
исследование, а не снимок; перенос однозначен (область встречается один раз),
но поштучной экспертной оценки снимков в наборе нет. Происхождение каждой
строки зафиксировано в `labels/labels_images.csv`. Разбор — в
`assets/labeling.md`.
2. **Мало данных.** 252 уникальных снимка, 77 нарушений. Доверительные интервалы
широкие; оценка на закрытом наборе может отличаться.
3. **Тип нарушения определяется эвристиками, а не обученной моделью.** Для
честного мультикласса нужна разметка типов на уровне снимка.
@ -209,11 +348,15 @@ curl -X POST http://localhost:8000/api/v1/analyze -F "file=@study/spine.dcm"
| Проверка | Команда | Результат |
|---|---|---|
| Модель использует снимок, а не только область | `python -m src.dxa.discriminator` | AUC 0.82 против 0.72 у правила «позвоночник = нарушение»; внутри областей у модели 0.84–0.95, у правила 0.50 |
| Контракт API для веб-интерфейса | `python -m pytest tests/test_api_contract.py` | 15 тестов: поля панели деталей, различимость метрик, PNG-визуализации, отсутствие некалиброванных вердиктов |
| Разметка и разбиение данных | `python -m pytest tests/test_labels.py` | метки из имён, склейка дублей, отсутствие утечки между train/val |
| Модель использует снимок, а не только область | `python -m src.dxa.discriminator` | AUC 0.854 против 0.529 у правила «позвоночник = нарушение»; внутри областей у модели 0.85–0.93, у правила 0.50 |
| Контракт API для веб-интерфейса | `python -m pytest tests/test_api_contract.py` | поля панели деталей, различимость метрик, PNG-визуализации, отсутствие некалиброванных вердиктов, канонические коды типов нарушений, карточка модели |
| Единый словарь нарушений | `python -m pytest tests/test_violations.py` | коды, подписи, коды SR, приведение устаревших значений, согласованность с таблицей |
| Разметка и разбиение данных | `python -m pytest tests/test_labels.py` | склейка дублей, разбор имён, фиксация разбиения, отсутствие утечки между train/val |
| Имена DICOM-файлов | `python -m pytest tests/test_rename_files.py` | разбор имён, поиск свободного номера при конфликте, отказ от угадывания области, цикл «применить → откатить» |
| Разметка по экспертной таблице | `python -m pytest tests/test_excel_labels.py` | чтение критериев, голосование по области, перенос на единственное бедро, правила `table`/`union`/`expert`, подключение к обучению |
| Выбор правила метки | `./run.sh split && ./run.sh compare` | ROC-AUC 0.6764 против 0.6199 по эталону, парная Δ +0.0564 [+0.0403, +0.0725], 5/5 seed'ов в пользу экспертной таблицы |
| Метрики и порог | `python -m pytest tests/test_preprocess_and_model.py` | подбор порога при дисбалансе, roundtrip чекпоинта, BatchNorm |
| Веб-интерфейс в браузере | `node tests/browser/ui_check.js` | значения панели меняются при переключении строк |
| Веб-интерфейс в браузере | `node tests/browser/ui_check.js` | подсказка о кликабельности строк видна и скрывается на пустом фильтре; кнопка «Открыть» открывает панель; значения панели меняются при переключении строк; панель «О модели» наполняется метриками и словарём |
| Работа без сети | `node tests/browser/ui_offline.js` | ноль внешних запросов, стили и иконки на месте |
Браузерные проверки требуют запущенного сервера:
@ -244,6 +387,13 @@ bone_2026/
│ ├── main.py # FastAPI: маршруты и загрузка модели
│ ├── dxa/ # действующий модуль оценки качества
│ │ ├── labels.py # разбор имён, метки, склейка дублей, сплит
│ │ ├── excel_labels.py # разметка снимков по экспертной таблице
│ │ ├── rename_files.py # приведение имён DICOM к единому виду
│ │ ├── violations.py # единый словарь типов нарушений (коды, подписи)
│ │ ├── model_card.py # карточка решения для /api/v1/model и интерфейса
│ │ ├── compare_labels.py # сравнение источников разметки на одном наборе
│ │ ├── discriminator.py # проверка вклада содержимого снимка
│ │ ├── render.py # рендер снимков и контактных листов
│ │ ├── preprocess.py # DICOM -> тензор (общий для обучения и API)
│ │ ├── dataset.py # Dataset и DataLoader
│ │ ├── model.py # сеть, метрики, подбор порога
@ -252,8 +402,18 @@ bone_2026/
│ ├── quality/ # эвристики (частично используются API)
│ ├── api/static/ # веб-интерфейс
│ └── model/, core/, pipeline/ # устаревшие модули, не подключены к API
├── models/dxa_model.pth # чекпоинт (+ train_report.md)
├── tests/ # pytest: метки, сплит, метрики, модель
├── labels/labels_images.csv # разметка снимков: официальная (+ .xlsx)
├── labels/labels_images_table.csv # вариант «только таблица» (то же, что выше)
├── labels/labels_images_union.csv # вариант «таблица или суффикс имени»
├── labels/labels_images_expert.csv # эталон для оценки: только снимки с оценкой
├── labels/split_expert_seed42.json # зафиксированное разбиение (19 исследований)
├── labels/rename_map.csv # карта переименований файлов (для отката)
├── assets/img/ # схемы архитектуры и скриншоты интерфейса
├── assets/labeling.md # как построена разметка и как сравнивались варианты
├── models/dxa_model.pth # рабочий чекпоинт (+ train_report.md)
├── models/archive/ # прежние чекпоинты (см. README внутри)
├── models/compare_rules/ # чекпоинты и отчёт сравнения правил метки
├── tests/ # pytest: метки, сплит, метрики, модель, API
├── dataset_hack/ # данные (в git не хранятся)
├── Dockerfile
├── requirements.txt
@ -270,6 +430,8 @@ python -m src.dxa.train --epochs 100 --output-dir models
|---|---|---|
| `--data-root` | `dataset_hack` | Каталог датасета |
| `--annotation-path` | `dataset_hack/НД_для_обучения/разметка.xlsx` | Excel с разметкой (только отчёт о расхождениях) |
| `--labels-csv` | `labels/labels_images.csv` | Разметка снимков из `./run.sh label`; пустая строка — метки из имён файлов; отсутствующий файл — откат к именам с предупреждением |
| `--split-file` | — | Зафиксированное разбиение из `./run.sh split`; одинаковый held-out набор для сравнения вариантов разметки |
| `--backbone` | `resnet18` | `resnet18` / `resnet34` |
| `--head` | `linear` | `linear` (линейный зонд) / `mlp` |
| `--freeze-epochs` | `-1` | `-1` — backbone заморожен всегда; `0` — обучать всю сеть |

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

BIN
assets/img/ui-banner.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

BIN
assets/img/ui-results.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

214
assets/labeling.md Normal file
View File

@ -0,0 +1,214 @@
# Разметка датасета и выбор правила метки
Документ описывает, откуда берутся метки снимков, как проверялось правило
разметки и какие результаты из этого следуют. Числа проверяемы: ссылки на
источники приведены рядом.
## 1. Задача разметки
Экспертная оценка в наборе сделана на уровне **исследования**: в таблице
`разметка.xlsx` по каждому исследованию отмечены критерии качества, а не по
отдельному снимку. Модель же работает со снимками, поэтому вердикт исследования
нужно было перенести на каждый снимок — без догадок и без потери информации о
том, откуда метка взялась.
## 2. Экспертная таблица
Столбцы (две строки заголовков, данные с третьей):
| Столбец | Смысл | Тип нарушения |
|---|---|---|
| 2 | Позвоночник: укладка | `positioning` |
| 3 | Позвоночник: ось | `axis_deviation` |
| 4 | Позвоночник: артефакты, наложения | `artifact` |
| 5, 6 | Бедро R: позиционирование/ротация, область интереса | `rotation`, `roi_incorrect` |
| 7, 8 | Бедро L: то же | `rotation`, `roi_incorrect` |
| 9, 10, 11 | Итог по области | — |
| 12 | Комментарий эксперта | — |
**Семантика значений** установлена по данным, а не по формулировкам заголовков:
- `1` в столбце критерия означает **нарушение**, хотя часть заголовков
сформулирована положительно («корректная укладка»);
- итог области равен логическому ИЛИ критериев: бёдра 72/72 и 78/78,
позвоночник 96/99;
- три расхождения позвоночника (два случая «итог без критериев», один «критерий
без итога») трактуются как нарушение — по правилу «хотя бы один существенный
пункт нарушен»;
- заполненность: позвоночник 99/100, бедро R 72/100, бедро L 78/100; у 23
исследований есть комментарий.
## 3. Перенос вердикта на снимок
| Шаг | Что делается | Почему так |
|---|---|---|
| Ключ склейки | имя каталога исследования | таблица ссылается на каталог (`2.25…`), а в DICOM лежит другой идентификатор (`1.2.643…`); соответствие каталог → тег 100/100 |
| Склейка дублей | по хешу пиксельных данных | 544 файла — это 252 уникальных снимка |
| Область снимка | голосование по именам файлов группы | один снимок назван и как позвоночник, и как бедро; при равенстве голосов область остаётся неопределённой (такой снимок один) |
| Вердикт | оценка области переносится на её снимок | **каждая область встречается в исследовании ровно один раз**, поэтому не нужно решать, какой из нескольких снимков «плохой» |
| Сторона бедра | при единственном снимке бедра берётся единственный заполненный столбец | в 71 из 72 исследований с двумя бёдрами столбцы совпадают с именами файлов; в 7 исследованиях с одним снимком заполнена противоположная сторона. Факт переноса фиксируется флагом `laterality_mirrored`; теги `Laterality` в DICOM пусты |
| Тип нарушения | только из структурированных критериев | комментарии («сколиоз», «эндопротезирование ТБС») сохранены дословно: перекладывать свободный текст в код — догадка |
## 4. Правило метки и почему оно такое
Метка могла строиться двумя способами: только из таблицы или с добавлением
пометок, которые вручную проставлялись в именах файлов (суффикс `_bad`). Пометки
в именах оказались ненадёжными: они расходились с оценкой эксперта в **15 случаях
из 252**. Выбор сделан измерением, а не по вкусу.
**Постановка.** Разбиение по исследованиям зафиксировано один раз, оба варианта
обучены пятью seed'ами на нём, оценены по одному эталону — вердикту эксперта на
снимках валидации. Снимки валидации не участвуют в обучении ни в одном варианте.
Воспроизведение: `python -m src.dxa.excel_labels --label-rule {table,union,expert}`
и `python -m src.dxa.compare_labels --split-file labels/split_expert_seed42.json`.
**Результат** (53 снимка валидации, 16 нарушений по эталону, 5 seed'ов):
| Метрика (эталон) | только таблица | таблица или суффикс `_bad` |
|---|---|---|
| ROC-AUC | **0.6764** [0.6309, 0.7218] | 0.6199 [0.5840, 0.6559] |
| PR-AUC | **0.4759** [0.4141, 0.5377] | 0.4046 [0.3702, 0.4391] |
| F1 | **0.5676** [0.5270, 0.6082] | 0.5426 [0.5073, 0.5778] |
| Recall | 0.7000 | 0.8625 |
| Precision | 0.4857 | 0.4043 |
Парная разница («только таблица» − «с суффиксами»):
| Метрика | Δ, среднее [95 % ДИ] | Знаки по seed'ам |
|---|---|---|
| ROC-AUC | **+0.0564** [+0.0403, +0.0725] | `+++++` |
| PR-AUC | **+0.0713** [+0.0398, +0.1028] | `+++++` |
| F1 | +0.0251 [−0.0056, +0.0558] | `+++-+` |
| Precision | +0.0815 [+0.0590, +0.1039] | `+++++` |
| Recall | −0.1625 [−0.2715, −0.0535] | `--0--` |
**Решение: принято правило «только экспертная таблица».** Дополнительные пометки
из имён файлов ухудшали согласие модели с экспертом на невиданных
исследованиях: вариант с ними чаще срабатывал (recall 0.86), но за счёт
точности, а по беспороговым метрикам проигрывал на всех пяти seed'ах.
Оговорка: эталон — та же экспертная таблица, поэтому вариант, обучавшийся
непосредственно на ней, находится в выигрышном положении. Значимо здесь другое:
добавление ненадёжных пометок **снижает** согласие с экспертом, что и служит
аргументом против них.
## 5. Словарь типов нарушений
| Код | Подпись | Область | Источник |
|---|---|---|---|
| `positioning` | Некорректная укладка | позвоночник | таблица |
| `axis_deviation` | Отклонение оси | позвоночник | таблица |
| `artifact` | Артефакты и импланты | любая | таблица |
| `rotation` | Ротация, позиционирование | бедро | таблица |
| `roi_incorrect` | Некорректная область интереса | любая | таблица |
| `motion` | Движение, размытие | любая | критерии методики |
| `incomplete_anatomy` | Анатомия видна не полностью | любая | критерии методики |
| `labeling_error` | Ошибка разметки | позвоночник | критерии методики |
| `unspecified` | Нарушение без уточнения | любая | служебный |
Распределение в разметке: `rotation` 36, `artifact` 17, `axis_deviation` 10,
`roi_incorrect` 7, `positioning` 6, `unspecified` 5.
Словарь один на всё решение (`src/dxa/violations.py`): коды используют инференс,
отчёт DICOM SR и веб-интерфейс, подписи отдаёт сервер, копий в JavaScript нет.
## 6. Результат разметки
`labels/labels_images.csv` (и XLSX) — по одной строке на уникальный снимок:
| | позвоночник | бедро R | бедро L | неопред. | всего |
|---|---|---|---|---|---|
| качественных | 66 | 58 | 51 | 0 | 175 |
| с нарушением | 33 | 21 | 22 | 1 | 77 |
| **итого** | **99** | **79** | **73** | **1** | **252** |
Помимо метки в CSV сохранено происхождение: `quality_from_excel` (вердикт
эксперта), `quality_from_filename` (пометка из имени файла), `sources_conflict`,
`label_rule`, `filename_fallback`, `laterality_mirrored`, `region_ambiguous`,
`expert_comment`. Поэтому правило можно переиграть без повторного разбора.
Рядом лежат варианты для воспроизведения сравнения:
`labels_images_table.csv`, `labels_images_union.csv`, `labels_images_expert.csv`
(эталон: только снимки с экспертной оценкой, 249 строк) и
`split_expert_seed42.json` (зафиксированное разбиение).
## 7. Гигиена данных: имена файлов
Имена проставлялись вручную и разошлись: `spine_1` и `spine_01`, `Spine_01`,
`r_spine_03`, `r_hip03`, `r_hop_02` (опечатка), `spine-1`. Они приведены к виду
`<область>_<NN>[_good|_bad].dcm` инструментом `src/dxa/rename_files.py`;
переименовано 344 файла из 548, карта отката — `labels/rename_map.csv`.
Переименование сделано **после** того, как разметка и метрики были посчитаны, и
проверено, что оно на них не влияет: набор из 252 пиксельных групп идентичен до и
после, разметка не изменилась ни в одной строке. Суффиксы `_good`/`_bad`
сохранены как были и в метках не участвуют — только как диагностический столбец.
## 8. Оценка качества модели
Разбиение по исследованиям (по умолчанию seed 42): обучение 199 снимков /
81 исследование, валидация 53 снимка / 19 исследований, 16 нарушений.
| Метрика | Значение | Как получено |
|---|---|---|
| ROC-AUC | **0.6764** [0.6309, 0.7218] | 5 seed'ов на фиксированном разбиении, эталон — вердикт эксперта |
| PR-AUC | 0.4759 [0.4141, 0.5377] | там же |
| F1 | 0.5676 [0.5270, 0.6082] | там же; порог подобран по F1 на валидации, поэтому смещён вверх |
| ROC-AUC по областям | позвоночник 0.943, бедро R 0.576, бедро L 0.550 | рабочий чекпоинт, его собственная валидация |
Рабочий чекпоинт — обычный прогон с seed по умолчанию (эпоха 39, порог логита
−0.4930 → вероятность 0.379), его собственная валидационная ROC-AUC 0.6706
близка к среднему по seed'ам, то есть результат не отобран по удачности.
Проверка, что модель смотрит на снимок, а не угадывает анатомию: внутри областей
она даёт AUC 0.85–0.93, правило «позвоночник значит нарушение» — ровно 0.50.
Числа считаются на всём наборе, включая обучающие снимки, поэтому смещены вверх и
отвечают на вопрос «есть ли вклад содержимого», а не «каково качество на новых
данных».
## 9. Ограничения
1. **Разметка унаследована от исследования.** Таблица оценивает исследование, а
не снимок; перенос однозначен, потому что область встречается один раз, но
исходная оценка всё равно не поштучная.
2. **Мало данных:** 252 снимка, 77 нарушений. Интервалы широкие.
3. **Эталон — та же таблица.** Независимой истины нет; вариант, обучавшийся на
таблице, в сравнении в выигрышном положении.
4. **Тип нарушения — эвристика**, а не вывод модели: 5 снимков имеют только
`unspecified`, у остальных тип приходит из критериев таблицы.
5. **Три снимка без экспертной оценки** размечены по пометке в имени файла и
помечены `filename_fallback`.
6. **Сторона бедра в 7 исследованиях не проверяема:** теги латеральности пусты.
7. **Корректность областей интереса наследуется из таблицы:** разметки ROI в
DICOM нет, сравнить её напрямую не с чем.
## 10. Отрицательный результат: локальная vision-модель
Планировалась визуальная разметка локальной vision-моделью (9 млрд параметров,
офлайн), чтобы не зависеть от таблицы. На калибровке по 14 снимкам, из которых 8
заведомо с нарушениями, модель вынесла «непригоден» всем 14, включая все
качественные, с шаблонными формулировками и выдуманными имплантами. Разделяющая
способность — на уровне случайной, поэтому как разметчик модель непригодна.
Вывод, который стоит зафиксировать: разделяющую способность инструмента нужно
проверять **до** того, как строить на нём пайплайн. Инструменты рендера снимков и
контактных листов остались в `src/dxa/render.py` — они полезны для выборочной
ручной проверки.
## 11. Воспроизведение
```bash
./run.sh label # разметка по экспертной таблице
./run.sh rename # имена файлов (план; --apply)
./run.sh split && ./run.sh compare # выбор правила метки, 5 seed'ов
python -m src.dxa.excel_labels --label-rule union --out-name labels_images_union
python -m src.dxa.render --by-region --out dataset_hack/_preview
```
## 12. Что дальше
- Поштучная разметка снимков специалистом — снимет ограничение №1.
- Мультилейбл по типам нарушений вместо эвристики.
- Больше исследований (500+), чтобы сузить интервалы.
- Калибровка порога определения области под конкретное оборудование.

BIN
docs/deck.pptx Normal file

Binary file not shown.

BIN
docs/img/ui-banner.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

BIN
docs/img/ui-model-panel.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

BIN
docs/img/ui-results.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

214
docs/labeling.md Normal file
View File

@ -0,0 +1,214 @@
# Разметка датасета и выбор правила метки
Документ описывает, откуда берутся метки снимков, как проверялось правило
разметки и какие результаты из этого следуют. Числа проверяемы: ссылки на
источники приведены рядом.
## 1. Задача разметки
Экспертная оценка в наборе сделана на уровне **исследования**: в таблице
`разметка.xlsx` по каждому исследованию отмечены критерии качества, а не по
отдельному снимку. Модель же работает со снимками, поэтому вердикт исследования
нужно было перенести на каждый снимок — без догадок и без потери информации о
том, откуда метка взялась.
## 2. Экспертная таблица
Столбцы (две строки заголовков, данные с третьей):
| Столбец | Смысл | Тип нарушения |
|---|---|---|
| 2 | Позвоночник: укладка | `positioning` |
| 3 | Позвоночник: ось | `axis_deviation` |
| 4 | Позвоночник: артефакты, наложения | `artifact` |
| 5, 6 | Бедро R: позиционирование/ротация, область интереса | `rotation`, `roi_incorrect` |
| 7, 8 | Бедро L: то же | `rotation`, `roi_incorrect` |
| 9, 10, 11 | Итог по области | — |
| 12 | Комментарий эксперта | — |
**Семантика значений** установлена по данным, а не по формулировкам заголовков:
- `1` в столбце критерия означает **нарушение**, хотя часть заголовков
сформулирована положительно («корректная укладка»);
- итог области равен логическому ИЛИ критериев: бёдра 72/72 и 78/78,
позвоночник 96/99;
- три расхождения позвоночника (два случая «итог без критериев», один «критерий
без итога») трактуются как нарушение — по правилу «хотя бы один существенный
пункт нарушен»;
- заполненность: позвоночник 99/100, бедро R 72/100, бедро L 78/100; у 23
исследований есть комментарий.
## 3. Перенос вердикта на снимок
| Шаг | Что делается | Почему так |
|---|---|---|
| Ключ склейки | имя каталога исследования | таблица ссылается на каталог (`2.25…`), а в DICOM лежит другой идентификатор (`1.2.643…`); соответствие каталог → тег 100/100 |
| Склейка дублей | по хешу пиксельных данных | 544 файла — это 252 уникальных снимка |
| Область снимка | голосование по именам файлов группы | один снимок назван и как позвоночник, и как бедро; при равенстве голосов область остаётся неопределённой (такой снимок один) |
| Вердикт | оценка области переносится на её снимок | **каждая область встречается в исследовании ровно один раз**, поэтому не нужно решать, какой из нескольких снимков «плохой» |
| Сторона бедра | при единственном снимке бедра берётся единственный заполненный столбец | в 71 из 72 исследований с двумя бёдрами столбцы совпадают с именами файлов; в 7 исследованиях с одним снимком заполнена противоположная сторона. Факт переноса фиксируется флагом `laterality_mirrored`; теги `Laterality` в DICOM пусты |
| Тип нарушения | только из структурированных критериев | комментарии («сколиоз», «эндопротезирование ТБС») сохранены дословно: перекладывать свободный текст в код — догадка |
## 4. Правило метки и почему оно такое
Метка могла строиться двумя способами: только из таблицы или с добавлением
пометок, которые вручную проставлялись в именах файлов (суффикс `_bad`). Пометки
в именах оказались ненадёжными: они расходились с оценкой эксперта в **15 случаях
из 252**. Выбор сделан измерением, а не по вкусу.
**Постановка.** Разбиение по исследованиям зафиксировано один раз, оба варианта
обучены пятью seed'ами на нём, оценены по одному эталону — вердикту эксперта на
снимках валидации. Снимки валидации не участвуют в обучении ни в одном варианте.
Воспроизведение: `python -m src.dxa.excel_labels --label-rule {table,union,expert}`
и `python -m src.dxa.compare_labels --split-file labels/split_expert_seed42.json`.
**Результат** (53 снимка валидации, 16 нарушений по эталону, 5 seed'ов):
| Метрика (эталон) | только таблица | таблица или суффикс `_bad` |
|---|---|---|
| ROC-AUC | **0.6764** [0.6309, 0.7218] | 0.6199 [0.5840, 0.6559] |
| PR-AUC | **0.4759** [0.4141, 0.5377] | 0.4046 [0.3702, 0.4391] |
| F1 | **0.5676** [0.5270, 0.6082] | 0.5426 [0.5073, 0.5778] |
| Recall | 0.7000 | 0.8625 |
| Precision | 0.4857 | 0.4043 |
Парная разница («только таблица» − «с суффиксами»):
| Метрика | Δ, среднее [95 % ДИ] | Знаки по seed'ам |
|---|---|---|
| ROC-AUC | **+0.0564** [+0.0403, +0.0725] | `+++++` |
| PR-AUC | **+0.0713** [+0.0398, +0.1028] | `+++++` |
| F1 | +0.0251 [−0.0056, +0.0558] | `+++-+` |
| Precision | +0.0815 [+0.0590, +0.1039] | `+++++` |
| Recall | −0.1625 [−0.2715, −0.0535] | `--0--` |
**Решение: принято правило «только экспертная таблица».** Дополнительные пометки
из имён файлов ухудшали согласие модели с экспертом на невиданных
исследованиях: вариант с ними чаще срабатывал (recall 0.86), но за счёт
точности, а по беспороговым метрикам проигрывал на всех пяти seed'ах.
Оговорка: эталон — та же экспертная таблица, поэтому вариант, обучавшийся
непосредственно на ней, находится в выигрышном положении. Значимо здесь другое:
добавление ненадёжных пометок **снижает** согласие с экспертом, что и служит
аргументом против них.
## 5. Словарь типов нарушений
| Код | Подпись | Область | Источник |
|---|---|---|---|
| `positioning` | Некорректная укладка | позвоночник | таблица |
| `axis_deviation` | Отклонение оси | позвоночник | таблица |
| `artifact` | Артефакты и импланты | любая | таблица |
| `rotation` | Ротация, позиционирование | бедро | таблица |
| `roi_incorrect` | Некорректная область интереса | любая | таблица |
| `motion` | Движение, размытие | любая | критерии методики |
| `incomplete_anatomy` | Анатомия видна не полностью | любая | критерии методики |
| `labeling_error` | Ошибка разметки | позвоночник | критерии методики |
| `unspecified` | Нарушение без уточнения | любая | служебный |
Распределение в разметке: `rotation` 36, `artifact` 17, `axis_deviation` 10,
`roi_incorrect` 7, `positioning` 6, `unspecified` 5.
Словарь один на всё решение (`src/dxa/violations.py`): коды используют инференс,
отчёт DICOM SR и веб-интерфейс, подписи отдаёт сервер, копий в JavaScript нет.
## 6. Результат разметки
`labels/labels_images.csv` (и XLSX) — по одной строке на уникальный снимок:
| | позвоночник | бедро R | бедро L | неопред. | всего |
|---|---|---|---|---|---|
| качественных | 66 | 58 | 51 | 0 | 175 |
| с нарушением | 33 | 21 | 22 | 1 | 77 |
| **итого** | **99** | **79** | **73** | **1** | **252** |
Помимо метки в CSV сохранено происхождение: `quality_from_excel` (вердикт
эксперта), `quality_from_filename` (пометка из имени файла), `sources_conflict`,
`label_rule`, `filename_fallback`, `laterality_mirrored`, `region_ambiguous`,
`expert_comment`. Поэтому правило можно переиграть без повторного разбора.
Рядом лежат варианты для воспроизведения сравнения:
`labels_images_table.csv`, `labels_images_union.csv`, `labels_images_expert.csv`
(эталон: только снимки с экспертной оценкой, 249 строк) и
`split_expert_seed42.json` (зафиксированное разбиение).
## 7. Гигиена данных: имена файлов
Имена проставлялись вручную и разошлись: `spine_1` и `spine_01`, `Spine_01`,
`r_spine_03`, `r_hip03`, `r_hop_02` (опечатка), `spine-1`. Они приведены к виду
`<область>_<NN>[_good|_bad].dcm` инструментом `src/dxa/rename_files.py`;
переименовано 344 файла из 548, карта отката — `labels/rename_map.csv`.
Переименование сделано **после** того, как разметка и метрики были посчитаны, и
проверено, что оно на них не влияет: набор из 252 пиксельных групп идентичен до и
после, разметка не изменилась ни в одной строке. Суффиксы `_good`/`_bad`
сохранены как были и в метках не участвуют — только как диагностический столбец.
## 8. Оценка качества модели
Разбиение по исследованиям (по умолчанию seed 42): обучение 199 снимков /
81 исследование, валидация 53 снимка / 19 исследований, 16 нарушений.
| Метрика | Значение | Как получено |
|---|---|---|
| ROC-AUC | **0.6764** [0.6309, 0.7218] | 5 seed'ов на фиксированном разбиении, эталон — вердикт эксперта |
| PR-AUC | 0.4759 [0.4141, 0.5377] | там же |
| F1 | 0.5676 [0.5270, 0.6082] | там же; порог подобран по F1 на валидации, поэтому смещён вверх |
| ROC-AUC по областям | позвоночник 0.943, бедро R 0.576, бедро L 0.550 | рабочий чекпоинт, его собственная валидация |
Рабочий чекпоинт — обычный прогон с seed по умолчанию (эпоха 39, порог логита
−0.4930 → вероятность 0.379), его собственная валидационная ROC-AUC 0.6706
близка к среднему по seed'ам, то есть результат не отобран по удачности.
Проверка, что модель смотрит на снимок, а не угадывает анатомию: внутри областей
она даёт AUC 0.85–0.93, правило «позвоночник значит нарушение» — ровно 0.50.
Числа считаются на всём наборе, включая обучающие снимки, поэтому смещены вверх и
отвечают на вопрос «есть ли вклад содержимого», а не «каково качество на новых
данных».
## 9. Ограничения
1. **Разметка унаследована от исследования.** Таблица оценивает исследование, а
не снимок; перенос однозначен, потому что область встречается один раз, но
исходная оценка всё равно не поштучная.
2. **Мало данных:** 252 снимка, 77 нарушений. Интервалы широкие.
3. **Эталон — та же таблица.** Независимой истины нет; вариант, обучавшийся на
таблице, в сравнении в выигрышном положении.
4. **Тип нарушения — эвристика**, а не вывод модели: 5 снимков имеют только
`unspecified`, у остальных тип приходит из критериев таблицы.
5. **Три снимка без экспертной оценки** размечены по пометке в имени файла и
помечены `filename_fallback`.
6. **Сторона бедра в 7 исследованиях не проверяема:** теги латеральности пусты.
7. **Корректность областей интереса наследуется из таблицы:** разметки ROI в
DICOM нет, сравнить её напрямую не с чем.
## 10. Отрицательный результат: локальная vision-модель
Планировалась визуальная разметка локальной vision-моделью (9 млрд параметров,
офлайн), чтобы не зависеть от таблицы. На калибровке по 14 снимкам, из которых 8
заведомо с нарушениями, модель вынесла «непригоден» всем 14, включая все
качественные, с шаблонными формулировками и выдуманными имплантами. Разделяющая
способность — на уровне случайной, поэтому как разметчик модель непригодна.
Вывод, который стоит зафиксировать: разделяющую способность инструмента нужно
проверять **до** того, как строить на нём пайплайн. Инструменты рендера снимков и
контактных листов остались в `src/dxa/render.py` — они полезны для выборочной
ручной проверки.
## 11. Воспроизведение
```bash
./run.sh label # разметка по экспертной таблице
./run.sh rename # имена файлов (план; --apply)
./run.sh split && ./run.sh compare # выбор правила метки, 5 seed'ов
python -m src.dxa.excel_labels --label-rule union --out-name labels_images_union
python -m src.dxa.render --by-region --out dataset_hack/_preview
```
## 12. Что дальше
- Поштучная разметка снимков специалистом — снимет ограничение №1.
- Мультилейбл по типам нарушений вместо эвристики.
- Больше исследований (500+), чтобы сузить интервалы.
- Калибровка порога определения области под конкретное оборудование.

BIN
docs/lct_temppalte.pptx Normal file

Binary file not shown.

611
docs/pitch.html Normal file
View File

@ -0,0 +1,611 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Материалы к презентации — контроль качества DXA</title>
<!--
Страница самодостаточна: CSS и JS внутри, внешних ресурсов нет.
Требование методики — работа без обращения к внешним сервисам, поэтому
страницу можно открывать в закрытом контуре и печатать.
-->
<style>
:root {
--fg: #1f2933;
--muted: #616e7c;
--line: #e4e7eb;
--accent: #1d6fb8;
--accent-soft: #eef6fd;
--warn: #8a5a00;
--warn-soft: #fdf6e3;
--ok: #0f6b3f;
--ok-soft: #eefaf3;
--code-bg: #f5f7fa;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: #f7f8fa;
color: var(--fg);
font: 16px/1.6 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
}
.wrap { max-width: 1060px; margin: 0 auto; padding: 28px 20px 80px; }
header.page {
background: #fff; border: 1px solid var(--line); border-radius: 12px;
padding: 22px 26px; margin-bottom: 20px;
}
header.page h1 { margin: 0 0 6px; font-size: 26px; line-height: 1.25; }
header.page p { margin: 0; color: var(--muted); }
.tabs { display: flex; flex-wrap: wrap; gap: 8px; margin: 20px 0 18px; }
.tabs button {
font: inherit; cursor: pointer; border: 1px solid var(--line);
background: #fff; color: var(--fg); padding: 10px 16px; border-radius: 999px;
}
.tabs button[aria-selected="true"] { background: var(--accent); border-color: var(--accent); color: #fff; }
.tabs button:hover:not([aria-selected="true"]) { background: var(--accent-soft); }
.panel { display: none; }
.panel.active { display: block; }
section.card {
background: #fff; border: 1px solid var(--line); border-radius: 12px;
padding: 22px 26px; margin-bottom: 18px;
}
h2 { font-size: 22px; margin: 0 0 4px; }
h2 .sub { display: block; font-size: 14px; font-weight: 400; color: var(--muted); margin-top: 4px; }
h3 { font-size: 17px; margin: 26px 0 8px; }
h3:first-of-type { margin-top: 12px; }
h4 { font-size: 15px; margin: 18px 0 6px; color: var(--muted); text-transform: uppercase; letter-spacing: .04em; }
p, li { margin: 8px 0; }
ul, ol { padding-left: 22px; }
.say {
background: var(--accent-soft); border-left: 4px solid var(--accent);
padding: 12px 16px; border-radius: 0 8px 8px 0; margin: 12px 0;
}
.say strong { display: block; font-size: 13px; text-transform: uppercase; letter-spacing: .05em; color: var(--accent); margin-bottom: 4px; }
.warn { background: var(--warn-soft); border-left: 4px solid var(--warn); padding: 12px 16px; border-radius: 0 8px 8px 0; margin: 12px 0; }
.ok { background: var(--ok-soft); border-left: 4px solid var(--ok); padding: 12px 16px; border-radius: 0 8px 8px 0; margin: 12px 0; }
table { width: 100%; border-collapse: collapse; margin: 12px 0; font-size: 15px; }
th, td { border: 1px solid var(--line); padding: 8px 10px; text-align: left; vertical-align: top; }
th { background: #f2f5f8; font-weight: 600; }
td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }
code, .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 14px; }
code { background: var(--code-bg); padding: 1px 5px; border-radius: 4px; }
pre { background: var(--code-bg); border: 1px solid var(--line); border-radius: 8px; padding: 12px 14px; overflow-x: auto; }
pre code { background: none; padding: 0; }
dl.defs { margin: 0; }
dl.defs dt { font-weight: 600; margin-top: 16px; }
dl.defs dd { margin: 4px 0 0; color: #33414e; }
dl.defs dd .why { display: block; color: var(--muted); font-size: 14px; margin-top: 3px; }
.tag { display: inline-block; font-size: 12px; padding: 1px 7px; border-radius: 999px; background: #eef1f4; color: #4a5560; margin-left: 6px; vertical-align: middle; }
.tag.table { background: #e8f4ea; color: #23633a; }
.tag.cond { background: #fdf1e3; color: #8a5a00; }
.src { font-size: 13px; color: var(--muted); }
footer.page { color: var(--muted); font-size: 14px; text-align: center; margin-top: 30px; }
@media print {
body { background: #fff; }
.tabs { display: none; }
.panel { display: block !important; page-break-before: always; }
.panel:first-of-type { page-break-before: avoid; }
section.card { border: none; padding: 0; margin-bottom: 24px; }
.wrap { max-width: none; padding: 0; }
}
</style>
<noscript>
<style>.tabs { display: none; } .panel { display: block !important; }</style>
</noscript>
</head>
<body>
<div class="wrap">
<header class="page">
<h1>Контроль качества денситометрических исследований (DXA)</h1>
<p>Материалы к презентации в трёх вариантах: краткий спич, словарь терминов к нему и полная техническая выкладка. Все числа проверяемы, источник указан рядом.</p>
</header>
<div class="tabs" role="tablist">
<button role="tab" aria-selected="true" data-panel="p1">1. Краткий спич</button>
<button role="tab" aria-selected="false" data-panel="p2">2. Определения</button>
<button role="tab" aria-selected="false" data-panel="p3">3. Техническая выкладка</button>
</div>
<!-- ============================ ВАРИАНТ 1 ============================ -->
<div class="panel active" id="p1">
<section class="card">
<h2>Краткий спич
<span class="sub">Около трёх минут. Каждый блок — один абзац для произнесения; ниже — что показать на слайде.</span>
</h2>
<h3>1. Задача</h3>
<div class="say"><strong>Произнести</strong>
Мы делали цифрового помощника по контролю качества денситометрии. По снимку DXA нужно решить, пригоден ли он для дальнейшего анализа, и объяснить, что именно не так. Вход — DICOM, выход — таблица XLSX или CSV, одна строка на снимок. Работает офлайн, в контейнере, не дольше трёх минут на исследование. Области — поясничный отдел позвоночника и проксимальный отдел бедра.
</div>
<p class="src">На слайд: колонки результата и требования — офлайн, контейнер, три минуты.</p>
<h3>2. Данные</h3>
<div class="say"><strong>Произнести</strong>
В наборе 544 файла, но это 252 уникальных снимка в ста исследованиях: остальное — те же кадры, сохранённые повторно. Экспертная оценка сделана в таблице, по каждому исследованию и с разбивкой на критерии: укладка, ось, артефакты, позиционирование, область интереса.
</div>
<h3>3. Как из оценки исследования получилась метка снимка</h3>
<div class="say"><strong>Произнести</strong>
Оценка в таблице относится к исследованию, а модель работает со снимками — вердикт нужно было перенести. Здесь помогло свойство набора: после склейки дублей каждая анатомическая область встречается в исследовании ровно один раз. Значит, не нужно угадывать, какой из снимков «плохой»: вердикт области переносится на её снимок однозначно. Так размечены все 252 снимка, из них 77 с нарушениями.
</div>
<p class="src">На слайд: 544 файла → 252 снимка → 100 исследований; одна область на исследование.</p>
<h3>4. Выбор правила разметки делали измерением</h3>
<div class="say"><strong>Произнести</strong>
Метку можно было строить только из экспертной таблицы или дополнительно учитывать служебные пометки, которые проставлялись при подготовке набора. Они расходились с оценкой эксперта в пятнадцати случаях из двухсот пятидесяти двух, поэтому мы не стали верить на слово ни одному варианту: зафиксировали разбиение, обучили оба пятью seed'ами и сравнили по одному эталону. Победило правило «только таблица»: ROC-AUC 0.68 против 0.62, преимущество на всех пяти seed'ах. Служебные пометки в метках не участвуют.
</div>
<h3>5. Модель</h3>
<div class="say"><strong>Произнести</strong>
Архитектура намеренно простая: ResNet18 с весами ImageNet как замороженный экстрактор признаков и линейная голова. Полное дообучение на двухстах снимках переобучается — train-метрика уходит в единицу, а качество на валидации падает до случайного. Аугментацию отключили: яркость и положение снимка сами являются признаками качества, и её включение роняло площадь под ROC-кривой с 0.87 до 0.56. Порог решения подбираем по логиту, максимизируя F1: вероятности насыщаются, и порог 0.5 даёт почти нулевой recall.
</div>
<h3>6. Результат</h3>
<div class="say"><strong>Произнести</strong>
На фиксированном разбиении, пять seed'ов, оценка по вердикту эксперта: ROC-AUC 0.676 с интервалом от 0.63 до 0.72, PR-AUC 0.476, F1 0.568. Рабочий чекпоинт — обычный прогон с seed по умолчанию, его собственная оценка 0.671, то есть близка к среднему: результат не отобран по удачности.
</div>
<p class="src">На слайд: три строки метрик с интервалами и одна оговорка про эталон.</p>
<h3>7. Модель смотрит на снимок, а не на область</h3>
<div class="say"><strong>Произнести</strong>
Область исследования почти однозначно определяется шириной кадра, поэтому есть риск, что модель просто угадывает анатомию. Мы это проверили отдельно: внутри областей модель даёт AUC от 0.85 до 0.93, а правило «позвоночник — значит нарушение» — ровно 0.5, потому что внутри области подсказки нет. Значит, модель работает с содержимым снимка.
</div>
<h3>8. Скорость</h3>
<div class="say"><strong>Произнести</strong>
Обработка одного снимка — около пятнадцати миллисекунд, на процессоре двадцать. При бюджете три минуты на исследование запас более чем тысячекратный. Чекпоинт занимает 43 мегабайта, ускоритель для этой задачи не обязателен: время уходит на декодирование снимка, а не на сеть.
</div>
<h3>9. Чего мы не утверждаем</h3>
<div class="say"><strong>Произнести</strong>
Модель бинарная: она не определяет тип нарушения. Тип, который вы видите в интерфейсе, посчитан эвристикой по метрикам снимка и помечен соответствующей пометкой — мы намеренно не выдаём предположение за заключение. Эталон, по которому мы мерили, — та же экспертная таблица, независимой истины у нас нет. Данных мало: 252 снимка, в валидации шестнадцать нарушений, интервалы широкие. И отдельно: локальная vision-модель на девять миллиардов параметров, которой мы хотели размечать снимки визуально, вынесла «непригоден» всем четырнадцати снимкам калибровки, включая заведомо качественные. От этого пути отказались, проверив его одним прогоном, а не неделей разработки.
</div>
<h3>10. Что дальше</h3>
<div class="say"><strong>Произнести</strong>
Три направления: поштучная разметка снимков специалистом, чтобы снять главное ограничение; мультилейбл-модель для типов нарушений вместо эвристики; больше исследований, чтобы сузить интервалы.
</div>
</section>
<section class="card">
<h2>Шпаргалка: цифры, которые будут спрашивать</h2>
<table>
<thead><tr><th>Вопрос</th><th>Ответ</th><th>Откуда</th></tr></thead>
<tbody>
<tr><td>Сколько снимков?</td><td>252 уникальных в 100 исследованиях (544 файла на диске)</td><td class="src">склейка дублей по пикселям</td></tr>
<tr><td>Сколько нарушений?</td><td>77 из 252 (30.6 %): 74 по экспертной таблице + 3 без экспертной оценки</td><td class="src">labels/labels_images.csv</td></tr>
<tr><td>ROC-AUC</td><td>0.6764 [0.6309, 0.7218], 5 seed'ов</td><td class="src">models/compare_rules/rule_comparison.md</td></tr>
<tr><td>PR-AUC / F1</td><td>0.4759 [0.4141, 0.5377] / 0.5676 [0.5270, 0.6082]</td><td class="src">там же</td></tr>
<tr><td>Почему такое правило разметки?</td><td>замер: +0.0564 ROC-AUC против варианта со служебными пометками, 5 из 5 seed'ов</td><td class="src">там же</td></tr>
<tr><td>Вклад содержимого снимка</td><td>внутри областей AUC 0.85–0.93 против 0.50 у правила области</td><td class="src">discriminator</td></tr>
<tr><td>Скорость</td><td>≈15 мс на снимок на ускорителе, 20 мс на CPU</td><td class="src">замер на 544 файлах</td></tr>
<tr><td>Размер модели</td><td>42.8 МБ, 11.2 млн параметров, обучается только голова</td><td class="src">models/dxa_model.pth</td></tr>
<tr><td>Тесты</td><td>208</td><td class="src">./run.sh test</td></tr>
</tbody>
</table>
</section>
</div>
<!-- ============================ ВАРИАНТ 2 ============================ -->
<div class="panel" id="p2">
<section class="card">
<h2>Определения
<span class="sub">Расширение краткого спича: термины, которыми придётся отвечать на вопросы. Формулировки привязаны к тому, как эти слова используются в решении.</span>
</h2>
<h4>Данные и формат</h4>
<dl class="defs">
<dt>DXA / ДРА <span class="tag">двухэнергетическая рентгеновская абсорбциометрия</span></dt>
<dd>Метод измерения минеральной плотности костной ткани. От качества укладки и разметки зависит не «красивость» снимка, а само число.
<span class="why">Задача — контроль качества измерения, а не диагностика перелома.</span></dd>
<dt>DICOM</dt>
<dd>Стандарт хранения и передачи медицинских изображений: пиксельные данные плюс метаданные.
<span class="why">Вход решения; из метаданных берём идентификаторы, но решение не зависит от персональных данных.</span></dd>
<dt>StudyInstanceUID и SOPInstanceUID</dt>
<dd><code>StudyInstanceUID</code> идентифицирует исследование, <code>SOPInstanceUID</code> — конкретный снимок. Оба идут в выходной файл: <code>study_uid</code> и <code>image_uid</code>.
<span class="why">Подводный камень: имя каталога исследования в наборе не совпадает с этим тегом, а экспертная таблица ссылается на каталог. Склейка идёт по каталогу, в отчёт попадает тег.</span></dd>
<dt>ROI <span class="tag">region of interest</span></dt>
<dd>Область интереса: рамка или контур, по которой считают плотность. Правильность её проведения — самостоятельный критерий качества.
<span class="why">В предоставленных DICOM разметка ROI отсутствует, сравнить её не с чем: корректность областей оценивается экспертом в таблице, и это открытое ограничение решения.</span></dd>
<dt>Побайтный дубль</dt>
<dd>Файлы с идентичным пиксельным содержимым под разными именами. В наборе 544 файла против 252 уникальных снимков.
<span class="why">Зачем склеивать: иначе один и тот же снимок попадал и в обучение, и в валидацию.</span></dd>
<dt>Анатомическая область</dt>
<dd>В решении три области: <code>spine</code> (поясничный отдел), <code>hip_right</code> и <code>hip_left</code> (проксимальный отдел бедра).
<span class="why">Область определяется по геометрии кадра, а не по метаданным: ширина кадра у позвоночника около 300 пикселей, у бедра около 280. Порог привязан к текущему оборудованию — это ограничение.</span></dd>
</dl>
<h4>Разметка</h4>
<dl class="defs">
<dt>Экспертная таблица</dt>
<dd>Файл <code>разметка.xlsx</code>: по каждому исследованию отмечены критерии — укладка, ось, артефакты для позвоночника; позиционирование и область интереса для каждого бедра; плюс итог по области и комментарий.
<span class="why">Значение 1 в критерии означает нарушение, хотя часть заголовков сформулирована положительно. Проверено: итог области равен логическому ИЛИ критериев (бёдра 72/72 и 78/78, позвоночник 96/99).</span></dd>
<dt>Единица разметки</dt>
<dd>То, к чему относится метка. Таблица описывает исследование, решение работает со снимками.
<span class="why">Ключевое свойство набора: после склейки дублей каждая область встречается в исследовании ровно один раз, поэтому вердикт переносится на снимок области однозначно.</span></dd>
<dt>Правило метки <span class="tag">table / union</span></dt>
<dd>Как из оценки получается метка снимка. <code>table</code> — только экспертная таблица (принято), <code>union</code> — таблица и служебные пометки, проставленные при подготовке набора.
<span class="why">Выбрано измерением: <code>table</code> дал ROC-AUC 0.6764 против 0.6199, преимущество на всех пяти seed'ах. Есть третье правило, <code>expert</code>: только снимки с экспертной оценкой — оно служит эталоном при оценке, а не для обучения.</span></dd>
<dt>quality_class</dt>
<dd>Бинарный класс: 0 — качественное изображение, 1 — есть нарушение. Это итоговое решение модели.</dd>
<dt>Пригоден / непригоден</dt>
<dd>Формулировка того же решения словами. «Пригоден» означает, что нет видимых причин мешать специалисту выполнить дальнейший анализ; это не значит, что снимок диагностически нормален.
<span class="why">Правило методики: если хотя бы один существенный пункт нарушен или не подтверждается по изображению, снимок непригоден. Сомнительные случаи не пропускаются.</span></dd>
<dt>violation_type</dt>
<dd>Тип нарушения — строка, перечень через точку с запятой, словарь канонический и единый для решения, отчёта DICOM SR и интерфейса.
<span class="why">Пять кодов кодирует экспертная таблица: positioning, axis_deviation, artifact, rotation, roi_incorrect. Ещё три задаёт методика: motion, incomplete_anatomy, labeling_error. Плюс unspecified — нарушение без уточнения.</span></dd>
<dt>Эвристика</dt>
<dd>Правило, а не обученная модель. Тип нарушения определяется эвристикой по метрикам снимка, потому что модель бинарная.
<span class="why">В интерфейсе это помечено пометкой «эвристика» с пояснением: показывать предположение как заключение нельзя.</span></dd>
<dt>Зеркалирование стороны бедра</dt>
<dd>Ситуация, когда в исследовании снят один снимок бедра, а в таблице заполнен столбец противоположной стороны — 6 исследований из 7 с одним бедром.
<span class="why">Логика: снимок один и заполненный столбец один — они соответствуют друг другу. Факт переноса фиксируется флагом; теги латеральности в DICOM пусты, поэтому сторону иначе не проверить.</span></dd>
</dl>
<h4>Модель и обучение</h4>
<dl class="defs">
<dt>Линейный зонд <span class="tag">linear probe</span></dt>
<dd>Режим, при котором свёрточная часть сети заморожена и обучается только линейный слой поверх её признаков.
<span class="why">На двухстах снимках полное дообучение переобучается: train F1 уходит в единицу при случайном AUC на валидации. Обучаемых параметров остаются тысячи вместо миллионов.</span></dd>
<dt>Frozen backbone и BatchNorm</dt>
<dd>При заморозке отключается не только градиент, но и train-режим слоёв нормализации: иначе бегущие статистики продолжают меняться на обучающих батчах и входной слой получает не те данные, на которых калибровался.</dd>
<dt>Препроцессинг</dt>
<dd>Один путь для обучения и API: прочитать DICOM с учётом наклона и инверсии, привести к диапазону по перцентилям 0.5–99.5, увеличить до 224×224, повторить в три канала, нормировать по статистикам ImageNet.
<span class="why">Перцентили вместо минимума-максимума: одиночные яркие пиксели (металл, метка оператора) иначе сжимают весь диапазон.</span></dd>
<dt>Порог по логиту</dt>
<dd>Решающее правило сравнивает логит с порогом, который подобран по F1 на валидации и хранится в чекпоинте вместе с весами (у рабочего — логит −0.493, вероятность 0.379).
<span class="why">Почему не 0.5: вероятности насыщаются, и фиксированный порог давал почти нулевой recall при доле нарушений около 30 %.</span></dd>
<dt>Аугментация</dt>
<dd>Случайные искажения при обучении. Здесь отключена по умолчанию.
<span class="why">Причина содержательная: яркость и положение снимка сами являются признаками качества, поэтому искажать их — значит уничтожать целевую информацию. Проверено: включение роняло AUC с 0.87 до 0.56.</span></dd>
<dt>Разбиение по исследованиям</dt>
<dd>Все снимки одного исследования попадают только в одну часть — обучение или валидацию.
<span class="why">Иначе получается утечка: снимки одного пациента похожи, и качество на валидации оказывается завышенным.</span></dd>
<dt>Зафиксированное разбиение</dt>
<dd>Файл со списком исследований валидации, который не пересчитывается при смене правил разметки.
<span class="why">Разбиение стратифицируется по наличию нарушений, а оно зависит от меток. Без фиксации варианты сравнивались бы на разных наборах.</span></dd>
<dt>Эталон оценки</dt>
<dd>Разметка, по которой считается качество: в нашем случае — вердикт эксперта, взятый только на снимках с экспертной оценкой.
<span class="why">Оценивать вариант на его же метках — круговое сравнение. Эталон один и тот же для всех вариантов.</span></dd>
</dl>
<h4>Метрики</h4>
<dl class="defs">
<dt>ROC-AUC</dt>
<dd>Вероятность, что случайно взятый снимок с нарушением получит более высокий балл, чем случайно взятый качественный. Не зависит от порога, поэтому это основная метрика сравнения.</dd>
<dt>PR-AUC</dt>
<dd>Площадь под кривой точность-полнота. Чувствительна к доле положительного класса: при 30 % нарушений базовый уровень — 0.30, поэтому сравнивать её между наборами с разной долей нельзя.</dd>
<dt>F1, precision, recall</dt>
<dd>F1 — среднее гармоническое точности и полноты. Recall — какая доля нарушений найдена, precision — какая доля тревог подтверждается.
<span class="why">В нашей задаче пропустить нарушение дороже, чем отправить снимок на ручную проверку.</span></dd>
<dt>Доверительный интервал 95 %</dt>
<dd>Диапазон, в котором с вероятностью 95 % лежит истинное значение. Здесь интервалы считаются по пяти seed'ам обучения через распределение Стьюдента.
<span class="why">Чего он не покрывает: неопределённость самой разметки. Поэтому интервал по seed'ам не заменяет независимый тест на закрытом наборе.</span></dd>
<dt>Парная разница</dt>
<dd>Разность метрик двух вариантов, посчитанная для каждого seed'а отдельно и затем усреднённая.
<span class="why">Варианты обучались на одном разбиении и одном seed'е, поэтому разброс обучения вычитается и видно эффект самого правила.</span></dd>
</dl>
<h4>Инженерия и эксплуатация</h4>
<dl class="defs">
<dt>Контейнеризация</dt>
<dd>Решение поставляется образом с зафиксированными версиями зависимостей; запуск — скриптом в Linux и UNIX-подобных системах. Веса монтируются при запуске, данные в образ не попадают.</dd>
<dt>Офлайн-работа</dt>
<dd>Медицинские изображения не покидают контур: ни внешних сервисов, ни обращений к CDN. Стили и шрифты интерфейса лежат локально.
<span class="why">Это требование методики, а не оптимизация; оно ограничило и выбор способа разметки.</span></dd>
<dt>processing_status</dt>
<dd>Поле отчёта: <code>Success</code> или <code>Failure</code>. Необработанных исключений быть не должно — любая ошибка фиксируется в строке результата.</dd>
<dt>DICOM SR</dt>
<dd>Structured Report — текстовое структурированное представление результата.
<span class="why">Оговорка: полноценного справочника SNOMED для контролёра качества DXA в наборе нет, поэтому числовые коды условные, и рядом всегда идёт текстовая формулировка.</span></dd>
<dt>Чекпоинт</dt>
<dd>Файл с весами, порогом, параметрами препроцессинга и метаданными: на какой разметке обучен, по какому разбиению, с каким seed'ом.
<span class="why">Самодостаточность важна: инференс не может рассинхронизироваться с обучением, а по файлу видно, что именно работает.</span></dd>
</dl>
</section>
</div>
<!-- ============================ ВАРИАНТ 3 ============================ -->
<div class="panel" id="p3">
<section class="card">
<h2>Полная техническая выкладка
<span class="sub">Для вопросов «а как именно» и для инженера, который будет разворачивать решение.</span>
</h2>
<h3>1. Требования и что именно решается</h3>
<ul>
<li><strong>Объект:</strong> поясничный отдел позвоночника и проксимальный отдел бедренной кости.</li>
<li><strong>Решение:</strong> бинарная классификация — пригоден (0) или есть нарушение (1) — плюс определение анатомической области и типа нарушения.</li>
<li><strong>Вход:</strong> DICOM без разметки; в исследовании до трёх изображений.</li>
<li><strong>Выход:</strong> XLSX или CSV, одна строка на снимок: <code>path_to_study, study_uid, image_uid, anatomical_region, quality_class, violation_type, processing_status, time_of_processing</code>.</li>
<li><strong>Приоритетные метрики:</strong> F1 и ROC-AUC с 95 % доверительными интервалами.</li>
<li><strong>Ограничения:</strong> офлайн, контейнеризация, до трёх минут на исследование, воспроизводимость, отсутствие необработанных исключений.</li>
</ul>
<h3>2. Данные: состав</h3>
<table>
<thead><tr><th>Показатель</th><th class="num">Значение</th></tr></thead>
<tbody>
<tr><td>Файлов DICOM на диске</td><td class="num">544</td></tr>
<tr><td>Уникальных снимков (по пикселям)</td><td class="num">252</td></tr>
<tr><td>Исследований</td><td class="num">100</td></tr>
<tr><td>Снимков: позвоночник / бедро R / бедро L / неопределено</td><td class="num">99 / 79 / 73 / 1</td></tr>
<tr><td>Нарушений по экспертной таблице</td><td class="num">74 (29.4 %)</td></tr>
<tr><td>Нарушений в обучающей разметке</td><td class="num">77 (30.6 %)</td></tr>
<tr><td>Снимков без экспертной оценки</td><td class="num">3</td></tr>
</tbody>
</table>
<h4>Аномалии, повлиявшие на решения</h4>
<ul>
<li><strong>Дубли.</strong> Один кадр сохранён многократно; склейка по хешу пиксельных данных.</li>
<li><strong>Конфликт имён внутри одной группы дублей.</strong> Два файла одного и того же снимка названы как разные области, поэтому область определяется голосованием по именам; при равенстве голосов остаётся неопределённой (такой снимок один).</li>
<li><strong>Имя каталога ≠ StudyInstanceUID.</strong> Таблица ссылается на каталог, в DICOM лежит другой идентификатор; соответствие один к одному, 100 из 100.</li>
<li><strong>Пустые теги латеральности.</strong> Метаданных о стороне бедра нет.</li>
<li><strong>Разметки ROI нет.</strong> Ни <code>OverlayData</code>, ни <code>GraphicAnnotationSequence</code>.</li>
<li><strong>Служебные пометки в данных.</strong> Метки из них не строятся — правило выбрано измерением (§5); имена приведены к единому виду отдельным инструментом (§7).</li>
</ul>
<h3>3. Семантика экспертной таблицы</h3>
<p>Две строки заголовков, данные с третьей. Столбцы 2–4 — критерии позвоночника (укладка, ось, артефакты), 5–6 и 7–8 — позиционирование и область интереса для правого и левого бедра, 9–11 — итоги по областям, 12 — комментарий.</p>
<div class="ok">
<strong>Значение <code>1</code> в критерии означает нарушение</strong>, хотя часть заголовков сформулирована положительно («корректная укладка»). Проверено на данных: итог области равен логическому ИЛИ критериев — для бёдер 72/72 и 78/78, для позвоночника 96/99. Три расхождения позвоночника трактуются как нарушение, что соответствует правилу «хотя бы один существенный пункт нарушен».
</div>
<p>Заполненность: позвоночник 99 из 100 исследований, бедро R 72, бедро L 78; комментарий есть у 23 исследований.</p>
<h3>4. Как построена разметка на уровне снимка</h3>
<ol>
<li><strong>Ключ склейки</strong> — имя каталога исследования, а не тег DICOM.</li>
<li><strong>Склейка дублей</strong> по хешу пиксельных данных: 544 файла → 252 снимка.</li>
<li><strong>Область</strong> — голосование по именам файлов одной группы изображений.</li>
<li><strong>Перенос вердикта</strong> — оценка области переносится на её снимок. Основание: каждая область встречается в исследовании ровно один раз.</li>
<li><strong>Зеркалирование стороны.</strong> В 71 из 72 исследований с двумя бёдрами столбцы совпадают с именами файлов. В 7 исследованиях снят один снимок бедра, и в 6 из них заполнен столбец противоположной стороны: раз снимок один и заполненный столбец один, они соответствуют друг другу. Перенос фиксируется флагом <code>laterality_mirrored</code>.</li>
<li><strong>Тип нарушения</strong> берётся только из структурированных критериев. Комментарии («сколиоз», «эндо протезирование ТБС») сохранены дословно и в код не перекладываются.</li>
<li><strong>Три снимка</strong>, по которым таблица область не оценивала, получают метку из служебной пометки и помечены <code>filename_fallback</code> — иначе они остались бы без метки вообще.</li>
</ol>
<p>Результат: <code>labels/labels_images.csv</code> (и XLSX) — 252 строки, 175 качественных и 77 с нарушениями.</p>
<h3>5. Выбор правила метки: измерение</h3>
<p>Правило «только таблица» против «таблица плюс служебные пометки при подготовке набора». Пометки расходились с оценкой эксперта в 15 случаях из 252, поэтому выбор делался измерением: одно фиксированное разбиение, пять seed'ов, один эталон (вердикт эксперта на снимках валидации).</p>
<table>
<thead><tr><th>Метрика (эталон)</th><th class="num">только таблица</th><th class="num">со служебными пометками</th></tr></thead>
<tbody>
<tr><td>ROC-AUC</td><td class="num"><strong>0.6764</strong> [0.6309, 0.7218]</td><td class="num">0.6199 [0.5840, 0.6559]</td></tr>
<tr><td>PR-AUC</td><td class="num"><strong>0.4759</strong> [0.4141, 0.5377]</td><td class="num">0.4046 [0.3702, 0.4391]</td></tr>
<tr><td>F1</td><td class="num"><strong>0.5676</strong> [0.5270, 0.6082]</td><td class="num">0.5426 [0.5073, 0.5778]</td></tr>
<tr><td>Recall / Precision</td><td class="num">0.700 / 0.486</td><td class="num">0.863 / 0.404</td></tr>
</tbody>
</table>
<table>
<thead><tr><th>Парная разница (таблица − с пометками)</th><th class="num">Δ, среднее [95 % ДИ]</th><th>Знаки по seed'ам</th></tr></thead>
<tbody>
<tr><td>ROC-AUC</td><td class="num"><strong>+0.0564</strong> [+0.0403, +0.0725]</td><td><code>+++++</code></td></tr>
<tr><td>PR-AUC</td><td class="num"><strong>+0.0713</strong> [+0.0398, +0.1028]</td><td><code>+++++</code></td></tr>
<tr><td>F1</td><td class="num">+0.0251 [−0.0056, +0.0558]</td><td><code>+++-+</code></td></tr>
<tr><td>Precision</td><td class="num">+0.0815 [+0.0590, +0.1039]</td><td><code>+++++</code></td></tr>
</tbody>
</table>
<pre><code>./run.sh split # зафиксировать разбиение (стратификация по эталону)
./run.sh compare # 5 seed'ов × 2 варианта, отчёт models/compare_rules/rule_comparison.md</code></pre>
<div class="warn">
<strong>Оговорка.</strong> Эталон — та же экспертная таблица, на которой обучался победивший вариант, поэтому сравнение частично ему благоприятствует. Значимый вывод другой: добавление служебных пометок <em>снижает</em> согласие модели с экспертом на невиданных исследованиях.
</div>
<h3>6. Словарь типов нарушений</h3>
<table>
<thead><tr><th>Код</th><th>Подпись</th><th>Область</th><th>Источник</th></tr></thead>
<tbody>
<tr><td><code>positioning</code></td><td>Некорректная укладка</td><td>позвоночник</td><td><span class="tag table">таблица</span></td></tr>
<tr><td><code>axis_deviation</code></td><td>Отклонение оси</td><td>позвоночник</td><td><span class="tag table">таблица</span></td></tr>
<tr><td><code>artifact</code></td><td>Артефакты и импланты</td><td>любая</td><td><span class="tag table">таблица</span></td></tr>
<tr><td><code>rotation</code></td><td>Ротация, позиционирование</td><td>бедро</td><td><span class="tag table">таблица</span></td></tr>
<tr><td><code>roi_incorrect</code></td><td>Некорректная область интереса</td><td>любая</td><td><span class="tag table">таблица</span></td></tr>
<tr><td><code>motion</code></td><td>Движение, размытие</td><td>любая</td><td><span class="tag cond">методика</span></td></tr>
<tr><td><code>incomplete_anatomy</code></td><td>Анатомия видна не полностью</td><td>любая</td><td><span class="tag cond">методика</span></td></tr>
<tr><td><code>labeling_error</code></td><td>Ошибка разметки</td><td>позвоночник</td><td><span class="tag cond">методика</span></td></tr>
<tr><td><code>unspecified</code></td><td>Нарушение без уточнения</td><td>любая</td><td><span class="tag">служебный</span></td></tr>
</tbody>
</table>
<p class="src">Распределение в разметке: rotation 36, artifact 17, axis_deviation 10, roi_incorrect 7, positioning 6, unspecified 5.</p>
<div class="warn">
<strong>Словарь раньше существовал в трёх копиях</strong> — в модуле инференса, в API и в JavaScript интерфейса, — и ни один из них не знал кодов экспертной таблицы: в интерфейсе они показывались как есть. Теперь словарь один (<code>src/dxa/violations.py</code>), подписи отдаёт сервер, старые значения приводятся к канону.
</div>
<h3>7. Гигиена данных: имена файлов</h3>
<p>Отдельный инструмент приводит имена файлов к единому виду: область, две цифры номера, при наличии — пометка качества. Приведено 344 файла из 548, карта отката сохранена, разметка и метрики при этом не менялись.</p>
<pre><code>./run.sh rename # план правки, без изменений на диске
./run.sh rename --apply # выполнить; карта отката labels/rename_map.csv
./run.sh rename --rollback --apply # вернуть прежние имена</code></pre>
<p>Правка сделана после того, как разметка и метрики были посчитаны, и проверено, что она на них не влияет: набор из 252 пиксельных групп идентичен до и после, разметка не изменилась ни в одной строке. Пометки качества в именах в метках не участвуют — только как диагностический столбец.</p>
<h3>8. Предобработка</h3>
<pre><code>DICOM → pixel_array (RescaleSlope/Intercept, MONOCHROME1)
→ нормализация по перцентилям 0.5–99.5 → [0, 1]
→ ×255, uint8
→ повтор в 3 канала, билинейный resize 224×224
→ /255, нормировка по статистикам ImageNet
→ тензор (3, 224, 224) float32</code></pre>
<p>Один и тот же путь используется при обучении и в API. Обучение и API вызывают общий код предсказания, поэтому порог и препроцессинг совпадают по построению.</p>
<h3>9. Модель</h3>
<table>
<thead><tr><th>Компонент</th><th>Значение</th></tr></thead>
<tbody>
<tr><td>Backbone</td><td>ResNet18, веса ImageNet, <strong>заморожен</strong> (заморозка на всё обучение)</td></tr>
<tr><td>Классификационная голова</td><td>линейный слой, 2 класса; обучаемых параметров — тысячи</td></tr>
<tr><td>Вспомогательная голова области</td><td>вес в функции потерь 0.3</td></tr>
<tr><td>Вход</td><td>224×224, 3 канала; BatchNorm в eval-режиме</td></tr>
<tr><td>Порог</td><td>−0.4930 по логиту (вероятность 0.379), подбор по F1 на валидации</td></tr>
<tr><td>Размер чекпоинта</td><td>42.8 МБ, 11.2 млн параметров сети</td></tr>
<tr><td>Определение области</td><td>эвристика по ширине кадра (позвоночник ≈300 px, бедро ≈280 px)</td></tr>
</tbody>
</table>
<h4>Гиперпараметры по умолчанию</h4>
<ul>
<li>Эпох 100, batch 16, early stopping с терпением 25, отбор эпохи по сглаженному (окно 5) ROC-AUC.</li>
<li>Оптимизатор AdamW, lr 3e-4, weight decay 0.05 (decoupled).</li>
<li>Балансировка классов и аугментация: выключены.</li>
<li>Валидационная доля 0.2, разбиение по исследованиям, seed 42.</li>
</ul>
<h3>10. Оценка качества</h3>
<p>Фиксированное разбиение: обучение 199 снимков / 81 исследование, валидация 53 снимка / 19 исследований, 16 нарушений по эталону. Пять seed'ов.</p>
<table>
<thead><tr><th>Метрика</th><th class="num">Значение</th></tr></thead>
<tbody>
<tr><td>ROC-AUC</td><td class="num"><strong>0.6764</strong> [0.6309, 0.7218]</td></tr>
<tr><td>PR-AUC</td><td class="num">0.4759 [0.4141, 0.5377]</td></tr>
<tr><td>F1</td><td class="num">0.5676 [0.5270, 0.6082]</td></tr>
</tbody>
</table>
<table>
<thead><tr><th>Рабочий чекпоинт</th><th class="num">Значение</th></tr></thead>
<tbody>
<tr><td>Обучение / валидация</td><td class="num">199 снимков / 81 исследование — 53 снимка / 19 исследований, 16 нарушений</td></tr>
<tr><td>Эпоха / порог</td><td class="num">39 / логит −0.4930 (вероятность 0.379)</td></tr>
<tr><td>ROC-AUC / PR-AUC</td><td class="num">0.6706 / 0.4585</td></tr>
<tr><td>F1 / recall / precision</td><td class="num">0.5600 / 0.875 / 0.412</td></tr>
<tr><td>ROC-AUC по областям</td><td class="num">позвоночник 0.943, бедро R 0.576, бедро L 0.550</td></tr>
</tbody>
</table>
<div class="ok">
<strong>Это обычный прогон с seed по умолчанию, а не лучший из выборки.</strong> Его собственная валидационная ROC-AUC 0.6706 близка к среднему по пяти seed'ам 0.6764 — результат не отобран по удачности.
</div>
<h3>11. Проверка вклада модели: смотрит ли она на снимок</h3>
<p>В позвоночнике нарушений треть, у бёдер около трети, а область почти однозначно определяется шириной кадра. Отсюда вопрос: не выучила ли модель просто «область вместо качества». Проверка — сравнение с правилом «позвоночник значит нарушение».</p>
<table>
<thead><tr><th>Предиктор</th><th class="num">Общий AUC</th><th class="num">spine</th><th class="num">hip_right</th><th class="num">hip_left</th></tr></thead>
<tbody>
<tr><td>Модель</td><td class="num">0.854</td><td class="num">0.928</td><td class="num">0.861</td><td class="num">0.853</td></tr>
<tr><td>Правило «позвоночник = нарушение»</td><td class="num">0.529</td><td class="num">0.500</td><td class="num">0.500</td><td class="num">0.500</td></tr>
</tbody>
</table>
<p>Внутри областей правило не имеет подсказки и даёт ровно 0.5; модель — 0.85–0.93. Значит, она использует содержимое снимка. Оговорка: проверка считается на всём наборе, включая обучающие снимки, поэтому значения смещены вверх; она отвечает на вопрос «есть ли вклад содержимого», а не «каково качество на новых данных».</p>
<h3>12. Инференс, API и формат результата</h3>
<table>
<thead><tr><th>Метод</th><th>Путь</th><th>Назначение</th></tr></thead>
<tbody>
<tr><td>GET</td><td><code>/api/v1/health</code></td><td>статус и происхождение загруженной модели</td></tr>
<tr><td>GET</td><td><code>/api/v1/model</code></td><td>карточка решения: разметка, данные, метрики, словарь нарушений, ограничения</td></tr>
<tr><td>POST</td><td><code>/api/v1/analyze</code></td><td>анализ одного файла</td></tr>
<tr><td>POST</td><td><code>/api/v1/analyze/detailed</code></td><td>расширенный отчёт, при необходимости с маской</td></tr>
<tr><td>POST</td><td><code>/api/v1/analyze/sr</code></td><td>текстовое представление отчёта DICOM SR</td></tr>
<tr><td>POST</td><td><code>/api/v1/batch</code></td><td>пакетный анализ</td></tr>
<tr><td>POST</td><td><code>/api/v1/export</code></td><td>пакетный анализ и выгрузка XLSX</td></tr>
</tbody>
</table>
<p>Ответ анализа по одному файлу, сверх обязательных колонок: <code>confidence</code>, <code>threshold_probability</code>, <code>region_confidence</code>, <code>violation_type_label</code>, <code>violation_type_is_heuristic</code>, <code>violation_type_note</code>, <code>reason</code>, <code>metrics</code>, <code>samples</code>.</p>
<p>Ошибки не приводят к исключению: строка получает <code>processing_status = Failure</code>. Путь к чекпоинту задаётся переменной <code>DXA_MODEL_PATH</code>, чтобы контейнер не зависел от рабочего каталога.</p>
<h3>13. Скорость и системные требования</h3>
<table>
<thead><tr><th>Показатель</th><th class="num">Ускоритель (Apple MPS)</th><th class="num">CPU, 8 потоков</th></tr></thead>
<tbody>
<tr><td>Обработка одного снимка, медиана</td><td class="num">15 мс</td><td class="num">20 мс</td></tr>
<tr><td>Весь набор (544 файла)</td><td class="num">8 с</td><td class="num">—</td></tr>
<tr><td>На одно исследование (до 3 снимков)</td><td class="num">≈0.05 с</td><td class="num">≈0.06 с</td></tr>
<tr><td>Запас к бюджету 3 минуты</td><td class="num">&gt;3000×</td><td class="num">&gt;2500×</td></tr>
</tbody>
</table>
<p>Основное время уходит на декодирование DICOM и препроцессинг, а не на сеть: разница между ускорителем и процессором в пределах 1.4×. Значит, ускоритель для этой задачи не является узким местом. Минимальная конфигурация — CPU; чекпоинт занимает 43 МБ, память ограничена накладными расходами среды исполнения. Образ содержит только код и фронтенд; веса монтируются при запуске.</p>
<h3>14. Тесты и воспроизводимость</h3>
<ul>
<li><strong>208 тестов</strong> в <code>tests/</code>: разбор имён и склейка дублей, отсутствие утечки при разбиении, фиксация разбиения, разметка по таблице и три правила метки, единый словарь типов, приведение имён файлов с откатом, препроцессинг и метрики, контракт API для интерфейса.</li>
<li><strong>Три браузерных теста</strong> на живом сервере: переключение строк меняет панель деталей, ветка «нарушение» показывает пометку «эвристика», страница не обращается к внешним хостам.</li>
<li><strong>Воспроизводимость:</strong> фиксированные seed'ы, гиперпараметры в отчёте, состав разбиения в файле, происхождение модели внутри чекпоинта.</li>
<li><strong>Команды:</strong> <code>./run.sh label | rename | split | compare | train | infer | serve | test</code>.</li>
</ul>
<h3>15. Ограничения и что с ними делать</h3>
<table>
<thead><tr><th>Ограничение</th><th>Следствие</th><th>Что планируется</th></tr></thead>
<tbody>
<tr><td>Разметка выведена из оценки исследования</td><td>поштучной экспертной оценки снимков нет</td><td>разметка отдельных снимков специалистом</td></tr>
<tr><td>Мало данных: 252 снимка, 77 нарушений</td><td>широкие интервалы, закрытый набор может дать другие цифры</td><td>500+ исследований</td></tr>
<tr><td>Тип нарушения — эвристика</td><td>5 снимков имеют только «нарушение без уточнения»</td><td>мультилейбл-модель по типам</td></tr>
<tr><td>Эталон — та же таблица</td><td>сравнение правил частично благоприятствует таблице</td><td>независимая экспертная оценка снимков</td></tr>
<tr><td>Область определяется по ширине кадра</td><td>порог привязан к текущему оборудованию</td><td>калибровка по метаданным аппарата</td></tr>
<tr><td>Теги латеральности пусты</td><td>сторона бедра в 7 исследованиях не проверяема</td><td>запрос тега у источника данных</td></tr>
<tr><td>Разметки ROI нет в DICOM</td><td>корректность областей наследуется из таблицы</td><td>проверка на данных с экспортной разметкой</td></tr>
</tbody>
</table>
<h3>16. Негативный результат, который стоит упомянуть</h3>
<p>Планировалась визуальная разметка снимков локальной vision-моделью (9 млрд параметров, офлайн): это сняло бы зависимость от экспертной таблицы. На калибровке по 14 снимкам, из которых 8 заведомо с нарушениями, модель вынесла «непригоден» всем 14, включая все качественные, с шаблонными формулировками и выдуманными имплантами. Разделяющая способность — на уровне случайной.</p>
<div class="warn">
Вывод: разделяющую способность инструмента нужно проверять <em>до</em> того, как строить на нём пайплайн. Инструменты рендера снимков и контактных листов остались в проекте — они полезны для выборочной ручной проверки.
</div>
<h3>17. Быстрые ответы на неудобные вопросы</h3>
<dl class="defs">
<dt>Почему ROC-AUC 0.68 — это не много?</dt>
<dd>Двести пятьдесят снимков и разметка, выведенная из оценки исследования. На таком объёме это ожидаемый порядок; выше 0.8 означало бы утечку или подгонку.</dd>
<dt>Почему не дообучали всю сеть?</dt>
<dd>Пробовали: train уходит в единицу, валидация — к случайной. Замороженный backbone и линейная голова дают устойчивый результат.</dd>
<dt>Почему не использовать все доступные пометки?</dt>
<dd>Проверили: служебные пометки расходились с экспертом в 15 случаях из 252, и обучение с ними дало ROC-AUC 0.6199 против 0.6764 — хуже на всех пяти seed'ах.</dd>
<dt>Почему тип нарушения не от модели?</dt>
<dd>Разметки типов на уровне снимка мало, а мультилейбл на 77 нарушениях дал бы ещё более широкие интервалы. Честнее показать эвристику с пометкой, чем модель, которой нельзя верить.</dd>
<dt>Что если на вход придёт область, которой не было?</dt>
<dd>Область определяется эвристикой; при неуверенности снимок относится к неизвестной области, а решение всё равно формируется и помечается в отчёте.</dd>
<dt>Можно ли доверять метрике на закрытом наборе?</dt>
<dd>Ожидаемо близко к валидационной, но с оговорками: порог подобран на валидации, объём мал, эталон — та же таблица. Ориентир — 0.68, а не выше.</dd>
</dl>
</section>
</div>
<footer class="page">
Все числа получены из артефактов проекта. Источники: <code>labels/labels_images.csv</code>, <code>models/compare_rules/rule_comparison.md</code>, <code>models/train_report.json</code>, <code>docs/labeling.md</code>, <code>README.md</code>, <code>QWEN.md</code>.
Страница самодостаточна и не обращается к внешним ресурсам.
</footer>
</div>
<script>
// Переключение вариантов. Без JavaScript виден только первый вариант,
// поэтому в <head> есть <noscript> с показом всех трёх подряд.
(function () {
var tabs = Array.prototype.slice.call(document.querySelectorAll('.tabs button'));
var panels = Array.prototype.slice.call(document.querySelectorAll('.panel'));
tabs.forEach(function (tab) {
tab.addEventListener('click', function () {
tabs.forEach(function (t) { t.setAttribute('aria-selected', String(t === tab)); });
panels.forEach(function (p) { p.classList.toggle('active', p.id === tab.dataset.panel); });
window.scrollTo({ top: 0, behavior: 'smooth' });
});
});
})();
</script>
</body>
</html>

855
docs/slides.html Normal file

File diff suppressed because one or more lines are too long

View File

@ -50,3 +50,8 @@ pytest==8.4.2
# simpleitk==2.5.6
# pydicom-seg==0.4.1
# timm==1.0.29
# --- Сборка презентации (приложению не нужна) ---
# tools/build_deck.py собирает docs/deck.pptx из шаблона организаторов.
# В образ инференса эту зависимость ставить не нужно.
# python-pptx==1.0.2

77
run.sh
View File

@ -6,6 +6,10 @@
# ./run.sh train [доп. аргументы для src.dxa.train]
# ./run.sh infer <вход> <выход.xlsx|.csv> [доп. аргументы]
# ./run.sh serve [порт]
# ./run.sh label
# ./run.sh split
# ./run.sh rename [--apply]
# ./run.sh compare
# ./run.sh test
#
# Скрипт намеренно не содержит своей реализации обучения и инференса: вся
@ -19,6 +23,11 @@ PYTHON=${PYTHON:-python3}
DATA_ROOT=${DATA_ROOT:-dataset_hack}
ANNOTATION_PATH=${ANNOTATION_PATH:-"dataset_hack/НД_для_обучения/разметка.xlsx"}
MODEL_PATH=${MODEL_PATH:-models/dxa_model.pth}
LABELS_DIR=${LABELS_DIR:-labels}
LABELS_CSV=${LABELS_CSV:-labels/labels_images.csv}
LABELS_REFERENCE_CSV=${LABELS_REFERENCE_CSV:-labels/labels_images_expert.csv}
SPLIT_FILE=${SPLIT_FILE:-labels/split_expert_seed42.json}
RENAME_MAP=${RENAME_MAP:-labels/rename_map.csv}
PORT=${PORT:-8000}
command=${1:-help}
@ -27,9 +36,11 @@ shift || true
case "$command" in
train)
echo -e "${YELLOW}Обучение классификатора качества DXA${NC}"
echo " метки: $LABELS_CSV"
"$PYTHON" -m src.dxa.train \
--data-root "$DATA_ROOT" \
--annotation-path "$ANNOTATION_PATH" \
--labels-csv "$LABELS_CSV" \
--output-dir "$(dirname "$MODEL_PATH")" \
"$@"
echo -e "${GREEN}Готово. Чекпоинт: ${MODEL_PATH}${NC}"
@ -56,6 +67,54 @@ case "$command" in
"$PYTHON" -m uvicorn src.main:app --host 0.0.0.0 --port "$PORT"
;;
label)
echo -e "${YELLOW}Разметка датасета на уровне снимков${NC}"
echo " таблица: $ANNOTATION_PATH"
"$PYTHON" -m src.dxa.excel_labels \
--data-root "$DATA_ROOT" \
--excel "$ANNOTATION_PATH" \
--out-dir "$LABELS_DIR" \
"$@"
;;
split)
echo -e "${YELLOW}Фиксация разбиения для сравнения правил разметки${NC}"
echo " стратификация по эталону: $LABELS_REFERENCE_CSV"
"$PYTHON" -m src.dxa.train \
--data-root "$DATA_ROOT" \
--annotation-path "$ANNOTATION_PATH" \
--labels-csv "$LABELS_REFERENCE_CSV" \
--export-split "$SPLIT_FILE" \
"$@"
;;
rename)
echo -e "${YELLOW}Приведение имён DICOM к единому виду${NC}"
if [ "${1:-}" = "--apply" ]; then
echo -e "${RED}Правка имён. Карта отката: $RENAME_MAP${NC}"
else
echo " режим плана; для правки добавьте --apply"
fi
"$PYTHON" -m src.dxa.rename_files \
--data-root "$DATA_ROOT" \
--mapping "$RENAME_MAP" \
"$@"
;;
compare)
echo -e "${YELLOW}Сравнение источников разметки на одном held-out наборе${NC}"
echo " разбиение: $SPLIT_FILE"
if [ ! -f "$SPLIT_FILE" ]; then
echo -e "${RED}Нет файла разбиения. Сначала: ./run.sh split${NC}" >&2
exit 1
fi
"$PYTHON" -m src.dxa.compare_labels \
--data-root "$DATA_ROOT" \
--annotation-path "$ANNOTATION_PATH" \
--split-file "$SPLIT_FILE" \
"$@"
;;
test)
echo -e "${YELLOW}Запуск тестов${NC}"
"$PYTHON" -m pytest tests/ -q
@ -72,19 +131,35 @@ DXA Quality Assessment — управление запуском
Пример: ./run.sh infer "dataset_hack/Для теста" results.xlsx
Дополнительно: --zip-out masks.zip
serve [порт] Запустить HTTP API и веб-интерфейс.
label [аргументы] Разметить датасет на уровне снимков по Excel.
Результат: labels/labels_images.csv|.xlsx
split [аргументы] Зафиксировать разбиение по исследованиям.
Результат: labels/split_expert_seed42.json
rename [--apply] Привести имена DICOM к виду область_NN[_метка].
Без --apply — только план; карта отката пишется
до правки (labels/rename_map.csv)
compare [аргументы] Сравнить источники разметки на одном held-out
наборе (нужен сначала `./run.sh split`).
test Запустить тесты.
Переменные окружения:
DATA_ROOT Каталог датасета (по умолчанию dataset_hack)
ANNOTATION_PATH Excel с разметкой (только для отчёта о расхождениях)
ANNOTATION_PATH Excel с экспертной разметкой исследований
MODEL_PATH Путь к чекпоинту (по умолчанию models/dxa_model.pth)
LABELS_DIR Каталог с результатом разметки (по умолчанию labels)
LABELS_CSV Разметка снимков для обучения (пустая строка — метки из имён файлов)
SPLIT_FILE Файл фиксированного разбиения (labels/split_expert_seed42.json)
LABELS_REFERENCE_CSV Эталон для стратификации разбиения (labels/labels_images_expert.csv)
RENAME_MAP Карта переименований для отката (по умолчанию labels/rename_map.csv)
PORT Порт API (по умолчанию 8000)
PYTHON Интерпретатор (по умолчанию python3)
Типовой порядок работы:
./run.sh label # разметить датасет по Excel
./run.sh train # обучить модель
./run.sh infer dataset_hack results.xlsx
./run.sh serve # веб-интерфейс на http://localhost:8000
./run.sh split && ./run.sh compare # сравнить варианты разметки
USAGE
;;
esac

View File

@ -88,6 +88,10 @@
<i class="fas fa-moon dark:hidden"></i>
<i class="fas fa-sun hidden dark:inline"></i>
</button>
<button id="modelInfoToggle" class="flex items-center gap-2 px-4 py-2 bg-gray-100 dark:bg-gray-700 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600 transition">
<i class="fas fa-circle-info"></i>
О модели
</button>
<a href="/docs" target="_blank" class="flex items-center gap-2 px-4 py-2 bg-gray-100 dark:bg-gray-700 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600 transition">
<i class="fas fa-book"></i>
API Docs
@ -181,8 +185,8 @@
<i class="fas fa-percentage text-purple-600 dark:text-purple-400"></i>
</div>
<div>
<p class="text-2xl font-bold text-gray-900 dark:text-white" id="accuracy">-</p>
<p class="text-sm text-gray-500 dark:text-gray-400">Ср. уверенность</p>
<p class="text-2xl font-bold text-gray-900 dark:text-white" id="meanConfidence">-</p>
<p class="text-sm text-gray-500 dark:text-gray-400">Ср. уверенность модели</p>
</div>
</div>
</div>
@ -200,6 +204,15 @@
</button>
</div>
<!-- Подсказка: строки кликабельны. Видна, пока есть результаты. -->
<div id="tableHint" class="flex items-start gap-3 px-4 py-3 bg-primary-50 dark:bg-primary-900/20 border-b border-primary-100 dark:border-primary-900/40 text-primary-900 dark:text-primary-200 text-sm">
<i class="fas fa-hand-pointer mt-1" aria-hidden="true"></i>
<span>
<b>Нажмите на любую строку</b> — ниже откроется панель деталей: измерения снимка, тип нарушения и визуализация.
<span class="block mt-1 opacity-80">Заголовки столбцов сортируют таблицу, а кнопка «Открыть» в конце строки делает то же самое, что и клик по ней.</span>
</span>
</div>
<!-- Table -->
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm overflow-hidden">
<div class="p-4 border-b border-gray-200 dark:border-gray-700">
@ -235,6 +248,7 @@
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600" data-sort="confidence">
Уверенность <i class="fas fa-sort ml-1"></i>
</th>
<th class="px-2 py-3 w-8"><span class="sr-only">Открыть детали</span></th>
</tr>
</thead>
<tbody id="resultsTable" class="divide-y divide-gray-200 dark:divide-gray-700">
@ -378,6 +392,56 @@
</div>
</section>
<!-- Model info panel -->
<section id="modelSection" class="hidden mt-6">
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm overflow-hidden">
<div class="p-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
<i class="fas fa-circle-info mr-2 text-primary-600"></i>
О модели и разметке
</h3>
<button id="closeModelInfo" class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
<i class="fas fa-times"></i>
</button>
</div>
<div class="p-4 max-h-[80vh] overflow-y-auto space-y-6">
<div id="modelSummary" class="text-sm space-y-2">
<!-- Populated by JS -->
</div>
<div>
<h4 class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">
<i class="fas fa-chart-line mr-1"></i> Качество: выбор правила разметки
</h4>
<div id="modelMetrics" class="overflow-x-auto"></div>
</div>
<div>
<h4 class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">
<i class="fas fa-database mr-1"></i> Данные
</h4>
<div id="modelDataset" class="grid grid-cols-2 md:grid-cols-4 gap-3"></div>
</div>
<div>
<h4 class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">
<i class="fas fa-list-check mr-1"></i> Словарь типов нарушений
</h4>
<div id="modelViolations" class="overflow-x-auto"></div>
</div>
<div>
<h4 class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">
<i class="fas fa-triangle-exclamation mr-1"></i> Ограничения
</h4>
<ul id="modelLimitations" class="list-disc list-inside space-y-1 text-sm text-gray-700 dark:text-gray-300"></ul>
</div>
<div>
<h4 class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">
<i class="fas fa-file-lines mr-1"></i> Источники
</h4>
<ul id="modelSources" class="list-disc list-inside space-y-1 text-xs text-gray-500 dark:text-gray-400"></ul>
</div>
</div>
</div>
</section>
<!-- Error Section -->
<section id="errorSection" class="hidden">
<div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl p-6">

View File

@ -4,6 +4,11 @@ const API_BASE = '';
let results = [];
let sortColumn = null;
let sortDirection = 'asc';
// Сведения о решении, полученные с /api/v1/model: карточка модели и единый
// словарь типов нарушений. Интерфейс не держит своей копии словаря, иначе она
// расходится с серверной — так и было раньше.
let modelCard = null;
let violationDictionary = {};
// DOM Elements
const dropZone = document.getElementById('dropZone');
@ -21,6 +26,8 @@ const statusBanner = document.getElementById('statusBanner');
const statusIcon = document.getElementById('statusIcon');
const statusText = document.getElementById('statusText');
const detailSection = document.getElementById('detailSection');
const modelSection = document.getElementById('modelSection');
const tableHint = document.getElementById('tableHint');
// Theme toggle
const themeToggle = document.getElementById('themeToggle');
@ -42,9 +49,25 @@ async function checkHealth() {
const response = await fetch(`${API_BASE}/api/v1/health`);
const data = await response.json();
if (data.status === 'ok') {
if (data.status === 'ok' && data.model_loaded) {
statusIcon.className = 'w-3 h-3 rounded-full bg-green-500';
statusText.textContent = `Сервис готов • Модель: ${data.model_loaded ? 'загружена' : 'не загружена'} • Устройство: ${data.device}`;
const model = data.model || {};
// Показываем происхождение модели: на какой разметке обучена и с
// каким порогом решает. Без этого по интерфейсу нельзя понять,
// какая версия модели сейчас работает.
const labelsName = model.labels_csv ? 'экспертная таблица' : 'имена файлов';
const quality = model.val_metrics?.roc_auc;
statusText.innerHTML =
`Модель загружена • устройство: <span class="font-mono">${data.device}</span>` +
` • разметка: ${labelsName}` +
(model.epoch != null ? ` • эпоха ${model.epoch}` : '') +
(model.threshold_probability != null ? ` • порог ${(model.threshold_probability * 100).toFixed(1)}%` : '') +
(quality != null
? ` • ROC-AUC на своей валидации ${quality.toFixed(3)} <span class="text-amber-700 dark:text-amber-400">(честная оценка — в «О модели»)</span>`
: '');
} else if (data.status === 'ok') {
statusIcon.className = 'w-3 h-3 rounded-full bg-yellow-500';
statusText.textContent = 'Сервис доступен, но модель не загружена — анализ вернёт ошибку';
} else {
statusIcon.className = 'w-3 h-3 rounded-full bg-yellow-500';
statusText.textContent = 'Проблемы с сервисом';
@ -55,6 +78,138 @@ async function checkHealth() {
}
}
// Load model card: provenance, honest metrics, violation dictionary, limits.
async function loadModelCard() {
try {
const response = await fetch(`${API_BASE}/api/v1/model`);
if (!response.ok) return;
modelCard = await response.json();
violationDictionary = {};
(modelCard.violations || []).forEach(v => { violationDictionary[v.code] = v; });
renderModelCard();
} catch (e) {
// Панель необязательна: без неё интерфейс продолжает работать.
}
}
const SCORE_SOURCE_LABELS = {
expert_table: 'экспертная таблица',
condition_doctor: 'критерии пригодности',
system: 'служебный код',
};
function renderModelCard() {
if (!modelCard || !modelSection) return;
const info = modelCard;
const deployed = info.model || {};
const loaded = info.loaded || {};
// Сводка: что за модель работает и на чём обучена.
const summary = [];
summary.push(`<div class="flex justify-between gap-4"><span class="text-gray-500 shrink-0">Рабочий чекпоинт:</span><span class="font-mono text-xs break-all text-right">${loaded.path || deployed.checkpoint || '—'}</span></div>`);
summary.push(`<div class="flex justify-between gap-4"><span class="text-gray-500 shrink-0">Архитектура:</span><span class="text-right">${loaded.backbone || deployed.backbone || '—'} + ${loaded.head || deployed.head || '—'} (линейный зонд, backbone заморожен)</span></div>`);
summary.push(`<div class="flex justify-between gap-4"><span class="text-gray-500 shrink-0">Разметка:</span><span class="text-right font-mono text-xs">${loaded.labels_csv ?? deployed.labels_csv ?? '—'}</span></div>`);
summary.push(`<div class="flex justify-between gap-4"><span class="text-gray-500 shrink-0">Разбиение:</span><span class="text-right font-mono text-xs">${loaded.split_file ?? deployed.split_file ?? 'по умолчанию, стратифицированное по исследованиям'}</span></div>`);
if (loaded.checkpoint_mtime) {
summary.push(`<div class="flex justify-between gap-4"><span class="text-gray-500 shrink-0">Файл чекпоинта изменён:</span><span class="text-right">${loaded.checkpoint_mtime}</span></div>`);
}
summary.push(`<div class="flex justify-between gap-4"><span class="text-gray-500 shrink-0">Порог решения:</span><span class="text-right">логит ${loaded.threshold_logit ?? deployed.threshold_logit} → вероятность ${((loaded.threshold_probability ?? deployed.threshold_probability) * 100).toFixed(1)}%</span></div>`);
if (deployed.caveat) {
summary.push(`<div class="mt-2 px-3 py-2 rounded-lg bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 text-amber-800 dark:text-amber-300 text-xs"><i class="fas fa-triangle-exclamation mr-1"></i>${deployed.caveat}</div>`);
}
if (info.snapshot_date) {
summary.push(`<div class="text-xs text-gray-500 dark:text-gray-400">Снимок сведений: ${info.snapshot_date}</div>`);
}
document.getElementById('modelSummary').innerHTML = summary.join('');
// Метрики сравнения правил разметки: среднее по seed'ам с 95 % ДИ.
const cmp = info.comparison || {};
const metrics = cmp.metrics || {};
const variantNames = Object.keys(cmp.variants || metrics.roc_auc || {});
const METRIC_NAMES = { roc_auc: 'ROC-AUC', pr_auc: 'PR-AUC', f1: 'F1' };
const formatCI = (triple) => {
if (!triple) return '—';
const [value, lo, hi] = triple;
return `${value.toFixed(4)} <span class="text-gray-500">[${lo.toFixed(4)}, ${hi.toFixed(4)}]</span>`;
};
let metricsHtml = `<table class="w-full text-sm">
<thead class="bg-gray-50 dark:bg-gray-700/50 text-xs text-gray-500 dark:text-gray-400 uppercase">
<tr>
<th class="px-3 py-2 text-left">Метрика</th>
${variantNames.map(v => `<th class="px-3 py-2 text-left">${(cmp.variants || {})[v] || v}</th>`).join('')}
</tr>
</thead><tbody class="divide-y divide-gray-200 dark:divide-gray-700">`;
Object.keys(METRIC_NAMES).forEach(key => {
const row = metrics[key] || {};
metricsHtml += `<tr>
<td class="px-3 py-2 font-medium">${METRIC_NAMES[key]}</td>
${variantNames.map(v => `<td class="px-3 py-2">${formatCI(row[v])}</td>`).join('')}
</tr>`;
});
const deltas = cmp.paired_delta_table_minus_union || {};
Object.keys(METRIC_NAMES).forEach(key => {
const triple = deltas[key];
if (!triple) return;
const [value, lo, hi] = triple;
const sign = value > 0 ? '+' : '';
metricsHtml += `<tr class="text-xs">
<td class="px-3 py-2 text-gray-500">Δ ${METRIC_NAMES[key]}</td>
<td class="px-3 py-2" colspan="${Math.max(variantNames.length, 1)}">${sign}${value.toFixed(4)} <span class="text-gray-500">[${lo.toFixed(4)}, ${hi.toFixed(4)}]</span></td>
</tr>`;
});
metricsHtml += '</tbody></table>';
metricsHtml += `<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
${cmp.question || ''} Валидация: ${cmp.val_images ?? '—'} снимков,
${cmp.val_violations ?? '—'} нарушений по эталону; seed'ы: ${(cmp.seeds || []).join(', ')}.
${cmp.paired_wins || ''} ${cmp.decision || ''}
Эталон — ${cmp.reference || '—'}.
</p>`;
document.getElementById('modelMetrics').innerHTML = metricsHtml;
// Данные
const ds = info.dataset || {};
const perRegion = ds.per_region || {};
document.getElementById('modelDataset').innerHTML = [
card('Уникальных снимков', ds.unique_images ?? '—'),
card('Исследований', ds.studies ?? '—'),
card('Нарушений по экспертной таблице', ds.violations_expert ?? '—'),
card('Нарушений в обучающей разметке', ds.violations_labeled ?? '—'),
card('Снимков без экспертной оценки', ds.images_without_expert_verdict ?? '—'),
card('Позвоночник / бедро R / бедро L', `${perRegion.spine ?? '—'} / ${perRegion.hip_right ?? '—'} / ${perRegion.hip_left ?? '—'}`),
card('Файлов на диске', ds.files_on_disk ?? '—'),
card('Доля нарушений', ds.violation_share != null ? (ds.violation_share * 100).toFixed(1) + '%' : '—'),
].join('');
// Словарь типов нарушений
let dictHtml = `<table class="w-full text-sm">
<thead class="bg-gray-50 dark:bg-gray-700/50 text-xs text-gray-500 dark:text-gray-400 uppercase">
<tr>
<th class="px-3 py-2 text-left">Подпись</th>
<th class="px-3 py-2 text-left">Код</th>
<th class="px-3 py-2 text-left">Область</th>
<th class="px-3 py-2 text-left">Источник</th>
</tr>
</thead><tbody class="divide-y divide-gray-200 dark:divide-gray-700">`;
(info.violations || []).forEach(v => {
dictHtml += `<tr>
<td class="px-3 py-2">
<div class="font-medium">${v.label}</div>
<div class="text-xs text-gray-500 dark:text-gray-400">${v.note || ''}</div>
</td>
<td class="px-3 py-2 font-mono text-xs">${v.code}</td>
<td class="px-3 py-2 text-xs">${getRegionLabel(v.scope)}</td>
<td class="px-3 py-2 text-xs">${SCORE_SOURCE_LABELS[v.source] || v.source}</td>
</tr>`;
});
dictHtml += '</tbody></table>';
document.getElementById('modelViolations').innerHTML = dictHtml;
document.getElementById('modelLimitations').innerHTML =
(info.limitations || []).map(text => `<li>${text}</li>`).join('');
document.getElementById('modelSources').innerHTML =
(info.sources || []).map(text => `<li>${text}</li>`).join('');
}
// Drag and drop
dropZone.addEventListener('click', () => fileInput.click());
@ -196,7 +351,9 @@ function updateStats() {
document.getElementById('totalCount').textContent = total;
document.getElementById('okCount').textContent = ok;
document.getElementById('violationCount').textContent = violation;
document.getElementById('accuracy').textContent = `${avgConfidence}%`;
// Это средняя уверенность модели, а не точность: точность требует
// эталонных меток, которых для произвольного загруженного файла нет.
document.getElementById('meanConfidence').textContent = `${avgConfidence}%`;
}
// Filter and sort results
@ -251,6 +408,8 @@ function getFilteredResults() {
// Render table
function renderTable() {
const filtered = getFilteredResults();
// Подсказка «нажмите на строку» бессмысленна, когда строк нет.
if (tableHint) tableHint.classList.toggle('hidden', filtered.length === 0);
if (filtered.length === 0) {
resultsTable.innerHTML = '';
@ -304,6 +463,13 @@ function renderTable() {
<span class="text-xs">${confidence}</span>
</div>
</td>
<td class="px-4 py-3 text-right whitespace-nowrap">
<button type="button"
class="inline-flex items-center gap-1 text-primary-600 dark:text-primary-400 text-xs font-medium hover:underline focus:outline-none focus:ring-2 focus:ring-primary-500 rounded"
onclick="event.stopPropagation(); viewDetailByIndex(this.closest('tr'))">
Открыть <i class="fas fa-chevron-right" aria-hidden="true"></i>
</button>
</td>
</tr>
`;
}).join('');
@ -375,6 +541,22 @@ document.getElementById('closeDetail')?.addEventListener('click', () => {
detailSection.classList.add('hidden');
});
// Model info panel: открывается по кнопке в шапке; если карточка не
// загрузилась при старте (например, сервис только поднялся), повторяем запрос.
document.getElementById('modelInfoToggle')?.addEventListener('click', async () => {
if (modelSection.classList.contains('hidden')) {
if (!modelCard) await loadModelCard();
modelSection.classList.remove('hidden');
modelSection.scrollIntoView({ behavior: 'smooth' });
} else {
modelSection.classList.add('hidden');
}
});
document.getElementById('closeModelInfo')?.addEventListener('click', () => {
modelSection.classList.add('hidden');
});
// Region label helper
function getRegionLabel(region) {
const labels = {
@ -387,21 +569,17 @@ function getRegionLabel(region) {
return labels[region] || region || '—';
}
// Violation type label
function getViolationLabel(type) {
const labels = {
'': 'Корректно',
'correct': 'Корректно',
'position_error': 'Ошибка позиционирования',
'artifact_motion': 'Артефакт движения',
'artifact_other': 'Другие артефакты',
'labeling_error': 'Ошибка маркировки',
'incomplete_view': 'Неполный вид',
'roi_error': 'Ошибка ROI',
'rotation': 'Неправильный поворот',
'quality_violation_detected': 'Нарушение качества'
};
return labels[type] || type || '—';
// Violation type label.
// Подпись приходит с сервера (`violation_type_label`) из единого словаря
// `src/dxa/violations.py`; своей копии словаря интерфейс не держит. Раньше она
// здесь была и разошлась с серверной: коды экспертной таблицы (`artifact`,
// `roi_incorrect`, `positioning`) в интерфейсе показывались как есть.
function getViolationLabel(result) {
if (!result) return '—';
if (result.violation_type_label) return result.violation_type_label;
const entry = violationDictionary[result.violation_type];
if (entry) return entry.label;
return result.violation_type || 'Корректно';
}
// Small helpers used by the detail panel
@ -448,11 +626,20 @@ async function viewDetail(result) {
<div class="flex justify-between"><span class="text-gray-500">Полнота (справ.) :</span><span class="font-medium">${viewLabels[result.view_quality] || result.view_quality || '—'}</span></div>
`;
// Quality details
// Quality details.
// Тип нарушения подписан как эвристика: модель бинарная и тип не
// предсказывает, поэтому показывать его как заключение нельзя.
const heuristicBadge = result.violation_type_is_heuristic
? '<span class="ml-2 px-1.5 py-0.5 rounded bg-amber-100 dark:bg-amber-900/30 text-amber-800 dark:text-amber-300 text-xs" title="Определено эвристикой по метрикам снимка, а не моделью">эвристика</span>'
: '';
const heuristicNote = result.violation_type_note
? `<div class="mt-2 text-xs text-gray-500 dark:text-gray-400">${result.violation_type_note}</div>`
: '';
document.getElementById('detailQuality').innerHTML = `
<div class="flex justify-between"><span class="text-gray-500">Тип нарушения:</span><span class="font-medium">${getViolationLabel(result.violation_type)}</span></div>
<div class="flex justify-between items-center gap-2"><span class="text-gray-500 shrink-0">Тип нарушения:</span><span class="font-medium text-right">${getViolationLabel(result)}${heuristicBadge}</span></div>
<div class="flex justify-between"><span class="text-gray-500">Класс:</span><span class="font-medium">${result.quality_class}</span></div>
<div class="flex justify-between"><span class="text-gray-500">Порог решения:</span><span class="font-medium">${pct(result.threshold_probability)}</span></div>
${heuristicNote}
`;
// Confidence per class (computed on the server)
@ -653,3 +840,4 @@ function showError(message) {
// Initialize
checkHealth();
loadModelCard();

383
src/dxa/compare_labels.py Normal file
View File

@ -0,0 +1,383 @@
#!/usr/bin/env python3
"""
Сравнение правил разметки на одном held-out наборе.
Вопрос: какое правило построения метки даёт модель, которая лучше предсказывает
вердикт эксперта — только экспертная таблица (`labels/labels_images_table.csv`)
или таблица вместе с пометками, которые вручную проставлялись в именах файлов
(`labels/labels_images_union.csv`, суффикс `_bad`)?
Постановка эксперимента. Оба варианта обучаются на **одном и том же** разбиении
по исследованиям (`--split-file`), отличаются только метки класса. Оцениваются
они тоже одинаково — по **одному эталону**: вердикту эксперта, взятому на
снимках валидации (`labels/labels_images_expert.csv`). Иначе сравнение было бы
круговым («насколько хорошо модель, обученная на метках X, предсказывает метки
X»), и выигрывал бы вариант, который проще запомнить.
Почему разбиение нужно фиксировать: `stratified_group_split` стратифицирует по
наличию нарушений, а оно зависит от меток. Пересчёт разбиения для каждого
варианта дал бы разные валидационные наборы и несравнимые метрики.
Снимки валидации не участвуют в обучении ни в одном из вариантов, поэтому
эталонная оценка на них не утечка. Порог для F1 подбирается на эталоне отдельно
для каждой модели; ROC-AUC и PR-AUC порога не требуют и являются основными
метриками сравнения.
Запуск:
./run.sh split # зафиксировать разбиение (стратификация по эталону)
./run.sh compare # 5 seed'ов × 2 варианта
./run.sh compare --skip-training # пересобрать отчёт по готовым чекпоинтам
"""
from __future__ import annotations
import argparse
import json
import logging
import statistics
import sys
from pathlib import Path
from typing import Dict, List, Optional, Sequence, Tuple
from torch.utils.data import DataLoader
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from src.dxa.dataset import DXADataset, build_records
from src.dxa.labels import ImageRecord, load_split, split_by_studies
from src.dxa.model import create_model, per_region_metrics, select_threshold
from src.dxa.preprocess import PreprocessConfig
from src.dxa.train import build_parser, get_device, train
logger = logging.getLogger("dxa.compare_labels")
#: Варианты разметки по умолчанию: имя -> значение для `--labels-csv`.
#: `table` — метка только из экспертной таблицы (принято по результату сравнения),
#: `union` — таблица или суффикс `_bad` в имени файла (прежнее поведение).
DEFAULT_VARIANTS: Dict[str, str] = {
"table": "labels/labels_images_table.csv",
"union": "labels/labels_images_union.csv",
}
#: Эталон для оценки — чистый вердикт эксперта, без меток из имён файлов.
DEFAULT_REFERENCE = "labels/labels_images_expert.csv"
_METRICS = ("roc_auc", "pr_auc", "f1", "recall", "precision")
# Критическое значение t для 95 % доверительного интервала (двусторонний).
_T95 = {1: 12.706, 2: 4.303, 3: 3.182, 4: 2.776, 5: 2.571, 6: 2.447,
7: 2.365, 8: 2.306, 9: 2.262, 10: 2.228}
def _t_critical(n: int) -> float:
if n <= 1:
return float("nan")
if n in _T95:
return _T95[n]
try:
from scipy.stats import t as student_t
return float(student_t.ppf(0.975, n - 1))
except Exception:
return 1.96
def mean_ci(values: Sequence[Optional[float]]) -> Dict[str, float]:
"""Среднее и 95 % доверительный интервал для малой выборки."""
clean = [float(v) for v in values if v is not None]
n = len(clean)
if n == 0:
return {"n": 0, "mean": float("nan"), "lo": float("nan"), "hi": float("nan")}
mean = statistics.fmean(clean)
if n == 1:
return {"n": 1, "mean": mean, "lo": mean, "hi": mean}
half = _t_critical(n) * statistics.stdev(clean) / (n ** 0.5)
return {"n": n, "mean": mean, "lo": mean - half, "hi": mean + half}
def parse_variants(spec: Optional[str]) -> Dict[str, str]:
"""
Разобрать описание вариантов: `имя=путь` через запятую.
Пустой путь означает метки из имён файлов (режим совместимости).
"""
if not spec:
return dict(DEFAULT_VARIANTS)
variants: Dict[str, str] = {}
for chunk in spec.split(","):
chunk = chunk.strip()
if not chunk:
continue
name, _, path = chunk.partition("=")
name = name.strip()
if not name:
raise ValueError(f"Некорректный вариант: {chunk!r}")
variants[name] = path.strip()
if len(variants) < 2:
raise ValueError("Для сравнения нужно минимум два варианта")
return variants
def reference_val_records(
data_root: str,
annotation_path: str,
split_file: str,
reference_csv: str = DEFAULT_REFERENCE,
) -> List[ImageRecord]:
"""
Снимки валидации с эталонными метками.
Снимки, которых нет в эталонном CSV (эксперт не давал оценки), из эталона
исключаются: `apply_excel_labels` для них подставил бы метку из имени файла,
и эталон перестал бы быть чисто экспертным.
"""
import csv as _csv
with Path(reference_csv).open(newline="", encoding="utf-8") as fh:
scored = {row["path_to_image"] for row in _csv.DictReader(fh)}
records = [r for r in build_records(data_root, annotation_path, labels_csv=reference_csv)
if str(r.path) in scored]
_, val_records = split_by_studies(records, load_split(split_file))
return val_records
def evaluate_on_reference(
checkpoint: str | Path,
reference: Sequence[ImageRecord],
backbone: str = "resnet18",
head: str = "linear",
device: Optional[str] = None,
batch_size: int = 16,
) -> Tuple[Dict, Dict]:
"""
Метрики чекпоинта на эталонной разметке (только валидационные снимки).
Порог подбирается по F1 на эталоне, поэтому метрики одинаково смещены вверх
у всех вариантов и сравнимы между собой. ROC-AUC и PR-AUC от порога не
зависят.
"""
device = device or get_device()
model = create_model(backbone=backbone, pretrained=False, device=device, head=head)
metadata = model.load(checkpoint)
cfg = PreprocessConfig.from_dict(metadata.get("preprocess", {}))
dataset = DXADataset(reference, preprocess=cfg, train=False)
loader = DataLoader(dataset, batch_size=batch_size, shuffle=False)
logits, labels, regions = model.predict_logits_with_regions(loader)
threshold, metrics = select_threshold(logits, labels)
region_metrics = per_region_metrics(logits, labels, regions, threshold)
return metrics, region_metrics
def summarize(rows: Sequence[Dict], variants: Sequence[str]) -> Dict:
"""Сводка: средние с 95 % ДИ по seed'ам и парная разница первого варианта со вторым."""
by_variant = {v: [r for r in rows if r["variant"] == v] for v in variants}
summary: Dict = {"variants": {}, "paired": {}}
for variant, items in by_variant.items():
summary["variants"][variant] = {
"runs": len(items),
"val_size": items[0]["val_size"] if items else None,
"reference_positive": items[0]["reference_positive"] if items else None,
"own_label_positive": items[0]["own_label_positive"] if items else None,
"metrics": {name: mean_ci([r.get(name) for r in items]) for name in _METRICS},
}
seeds = sorted({r["seed"] for r in rows})
for metric in _METRICS:
deltas = []
for seed in seeds:
a = next((r.get(metric) for r in by_variant[variants[0]] if r["seed"] == seed), None)
b = next((r.get(metric) for r in by_variant[variants[1]] if r["seed"] == seed), None)
if a is not None and b is not None:
deltas.append(a - b)
summary["paired"][metric] = {
"direction": f"{variants[0]} - {variants[1]}",
"deltas": deltas,
**mean_ci(deltas),
}
summary["seeds"] = seeds
return summary
def _fmt(ci: Dict[str, float]) -> str:
if ci["n"] == 0:
return "n/a"
return f"{ci['mean']:.4f} [{ci['lo']:.4f}, {ci['hi']:.4f}]"
def format_report(summary: Dict, variants: Sequence[str]) -> str:
"""Markdown-отчёт сравнения."""
info = summary["variants"]
reference_positive = next((v["reference_positive"] for v in info.values() if v["runs"]), None)
val_size = next((v["val_size"] for v in info.values() if v["runs"]), None)
lines = [
"# Сравнение правил разметки на одном held-out наборе",
"",
f"Файл разбиения: `{summary.get('split_file')}`. Seed'ы: {summary['seeds']}. "
f"Валидация: {val_size} снимков, {reference_positive} нарушений по эталону.",
f"Эталон: `{summary.get('reference_csv')}` — вердикт эксперта, снимки без экспертной "
"оценки в эталон не входят.",
"",
"Оба варианта обучены на одном разбиении и оценены по одному и тому же эталону. "
"Снимки валидации не участвуют в обучении ни в одном варианте.",
"",
"| Метрика (эталон) | " + " | ".join(variants) + " |",
"|---" * (len(variants) + 1) + "|",
]
for metric in _METRICS:
cells = [_fmt(info[v]["metrics"][metric]) for v in variants]
lines.append(f"| {metric} | " + " | ".join(cells) + " |")
lines.append(
"| нарушений в своих метках | "
+ " | ".join(str(info[v]["own_label_positive"]) for v in variants)
+ " |"
)
lines += [
"",
"## Парная разница по seed'ам",
"",
"| Метрика | Δ (среднее [95 % ДИ]) | Знаки по seed'ам |",
"|---|---|---|",
]
for metric, data in summary["paired"].items():
signs = "".join("+" if d > 0 else ("-" if d < 0 else "0") for d in data["deltas"])
lines.append(f"| {metric} | {_fmt(data)} | {signs} |")
lines += [
"",
f"> Δ считается как «{variants[0]} − {variants[1]}»: положительное значение "
f"означает преимущество варианта `{variants[0]}`.",
"> Интервал ДИ по 5 seed'ам показывает разброс обучения, но не заменяет "
"независимый тест на закрытом наборе: он не учитывает неопределённость "
"самой эталонной разметки.",
"",
]
return "\n".join(lines)
def main(argv: Optional[List[str]] = None) -> int:
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
parser = argparse.ArgumentParser(description="Сравнение правил разметки DXA")
parser.add_argument("--data-root", default="dataset_hack")
parser.add_argument("--annotation-path", default="dataset_hack/НД_для_обучения/разметка.xlsx")
parser.add_argument("--split-file", default="labels/split_expert_seed42.json",
help="Зафиксированное разбиение из `./run.sh split`")
parser.add_argument("--seeds", default="0,1,2,3,4", help="Seed'ы через запятую")
parser.add_argument("--variants", default=None,
help="Варианты в виде имя=путь_к_csv через запятую. По умолчанию: "
+ ", ".join(f"{k}={v}" for k, v in DEFAULT_VARIANTS.items())
+ ". Пустое значение пути — метки из имён файлов")
parser.add_argument("--reference", default=DEFAULT_REFERENCE,
help="CSV эталона для оценки (вердикт эксперта без имён файлов)")
parser.add_argument("--epochs", type=int, default=100)
parser.add_argument("--backbone", default="resnet18")
parser.add_argument("--head", default="linear")
parser.add_argument("--output-root", default="models/compare_labels")
parser.add_argument("--report", default=None, help="Куда записать markdown-отчёт")
parser.add_argument("--skip-training", action="store_true",
help="Не обучать заново, а взять готовые чекпоинты из output-root "
"(возобновление после прерывания)")
args = parser.parse_args(argv)
if not Path(args.split_file).is_file():
logger.error(
"Split file %s not found. Create it once with: "
"python -m src.dxa.train --export-split %s",
args.split_file, args.split_file,
)
return 1
seeds = [int(s) for s in args.seeds.split(",") if s.strip()]
variants = parse_variants(args.variants)
output_root = Path(args.output_root)
device = get_device()
reference = reference_val_records(
args.data_root, args.annotation_path, args.split_file, args.reference
)
reference_positive = sum(1 for r in reference if r.label == 1)
logger.info(
"Reference validation set: %d images, %d violations", len(reference), reference_positive
)
logger.info("Variants: %s", ", ".join(f"{k}={v or 'filename markers'}" for k, v in variants.items()))
rows: List[Dict] = []
for variant in variants:
for seed in seeds:
out_dir = output_root / variant / f"seed_{seed}"
logger.info("=== %s, seed %d ===", variant, seed)
checkpoint = out_dir / "dxa_model.pth"
if args.skip_training:
if not checkpoint.is_file():
logger.error("Нет чекпоинта %s для возобновления", checkpoint)
return 1
report = json.loads((out_dir / "train_report.json").read_text())
logger.info("Пропускаю обучение, беру готовый чекпоинт")
else:
train_args = build_parser().parse_args([
"--data-root", args.data_root,
"--annotation-path", args.annotation_path,
"--labels-csv", variants[variant],
"--output-dir", str(out_dir),
"--split-file", args.split_file,
"--seed", str(seed),
"--epochs", str(args.epochs),
"--backbone", args.backbone,
"--head", args.head,
])
report = train(train_args)
metrics, region_metrics = evaluate_on_reference(
checkpoint,
reference,
backbone=args.backbone,
head=args.head,
device=device,
)
rows.append({
"variant": variant,
"seed": seed,
"best_epoch": report["best_epoch"],
"epochs_run": report["epochs_run"],
"val_size": report["val_size"],
"own_label_positive": int(
report["best_val_metrics"]["tp"] + report["best_val_metrics"]["fn"]
),
"reference_positive": reference_positive,
"own_val_metrics": {k: report["best_val_metrics"].get(k) for k in _METRICS},
"reference_val_metrics": {k: metrics.get(k) for k in _METRICS},
"reference_threshold": metrics.get("threshold_logit"),
"reference_per_region": region_metrics,
**{k: metrics.get(k) for k in _METRICS},
})
logger.info(
"%s seed %d: эталон roc_auc %s, pr_auc %s, f1 %.4f",
variant, seed,
f"{metrics['roc_auc']:.4f}" if metrics.get("roc_auc") is not None else "n/a",
f"{metrics['pr_auc']:.4f}" if metrics.get("pr_auc") is not None else "n/a",
metrics["f1"],
)
summary = summarize(rows, list(variants))
summary["split_file"] = args.split_file
summary["reference_csv"] = args.reference
summary["rows"] = rows
report_text = format_report(summary, list(variants))
print("\n" + report_text)
output_root.mkdir(parents=True, exist_ok=True)
(output_root / "comparison.json").write_text(
json.dumps(summary, ensure_ascii=False, indent=2, default=str)
)
report_path = Path(args.report) if args.report else output_root / "comparison.md"
report_path.parent.mkdir(parents=True, exist_ok=True)
report_path.write_text(report_text)
logger.info("Report: %s", report_path)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -1,12 +1,15 @@
"""
Датасет DXA для обучения классификатора качества.
Метки формируются из имён DICOM-файлов (см. `src.dxa.labels`), где суффикс
`_good`/`_bad` кодирует экспертную оценку; отсутствие суффикса — «хорошее»
изображение. Одинаковые по содержимому файлы склеиваются в один пример.
Источник меток по умолчанию — имена DICOM-файлов (суффикс `_good`/`_bad`; см.
`src.dxa.labels`). Альтернативный, более полный источник — разметка, построенная
по экспертной таблице (`labels/labels_images.csv`, см. `src.dxa.excel_labels`):
она переносит оценку исследования на снимок и фиксирует типы нарушений.
Передайте `labels_csv`, чтобы использовать её.
Разбиение на train/val выполняется по исследованиям, чтобы снимки одного
исследования не попадали одновременно в обучение и валидацию.
Одинаковые по содержимому файлы склеиваются в один пример, а разбиение на
train/val выполняется по исследованиям, чтобы снимки одного исследования не
попадали одновременно в обучение и валидацию.
"""
from __future__ import annotations
@ -18,11 +21,13 @@ import numpy as np
import torch
from torch.utils.data import DataLoader, Dataset
from src.dxa.excel_labels import apply_excel_labels
from src.dxa.labels import (
REGIONS,
ImageRecord,
format_summary,
scan_dataset,
split_by_studies,
stratified_group_split,
)
from src.dxa.preprocess import PreprocessConfig, preprocess_dicom, with_input_size
@ -119,9 +124,17 @@ def build_records(
data_root: str | Path,
annotation_path: Optional[str | Path] = None,
dedup: bool = True,
labels_csv: Optional[str | Path] = None,
) -> List[ImageRecord]:
"""Найти и разметить все уникальные снимки датасета."""
"""
Найти и разметить все уникальные снимки датасета.
Если задан `labels_csv`, метки и области берутся из него, а не из имён
файлов (см. `src.dxa.excel_labels.apply_excel_labels`).
"""
records = scan_dataset(data_root, with_pixel_dedup=dedup, annotation_path=annotation_path)
if labels_csv:
records = apply_excel_labels(records, labels_csv)
logger.info("Dataset scan complete:\n%s", format_summary(records))
return records
@ -134,11 +147,25 @@ def make_datasets(
seed: int = 42,
dedup: bool = True,
preprocess: Optional[PreprocessConfig] = None,
labels_csv: Optional[str | Path] = None,
val_studies: Optional[Sequence[str]] = None,
) -> Tuple[DXADataset, DXADataset, PreprocessConfig]:
"""Собрать train/val датасеты с разбиением по исследованиям."""
"""
Собрать train/val датасеты с разбиением по исследованиям.
Если передан `val_studies`, используется зафиксированное разбиение из файла
(`src.dxa.labels.load_split`), а не пересчёт по текущим меткам. Это нужно для
сравнения вариантов разметки на одном held-out наборе.
"""
cfg = with_input_size(preprocess or PreprocessConfig(), input_size)
records = build_records(data_root, annotation_path, dedup=dedup)
train_records, val_records = stratified_group_split(records, val_fraction=val_fraction, seed=seed)
records = build_records(data_root, annotation_path, dedup=dedup, labels_csv=labels_csv)
if val_studies is None:
train_records, val_records = stratified_group_split(
records, val_fraction=val_fraction, seed=seed
)
else:
train_records, val_records = split_by_studies(records, val_studies)
logger.info("Fixed split: %d studies in validation", len(set(val_studies)))
logger.info(
"Split: train=%d images / %d studies, val=%d images / %d studies",
@ -162,6 +189,8 @@ def create_dataloaders(
val_fraction: float = 0.2,
seed: int = 42,
preprocess: Optional[PreprocessConfig] = None,
labels_csv: Optional[str | Path] = None,
val_studies: Optional[Sequence[str]] = None,
) -> Tuple[DataLoader, DataLoader, PreprocessConfig]:
"""
Создать train/val DataLoader.
@ -177,6 +206,8 @@ def create_dataloaders(
val_fraction=val_fraction,
seed=seed,
preprocess=preprocess,
labels_csv=labels_csv,
val_studies=val_studies,
)
pin = torch.cuda.is_available()

View File

@ -31,7 +31,7 @@ from src.dxa.dataset import build_records
from src.dxa.labels import REGIONS, QUALITY_BAD
from src.dxa.model import create_model
from src.dxa.preprocess import PreprocessConfig, preprocess_dicom
from src.dxa.train import get_device
from src.dxa.train import get_device, resolve_labels_csv
logger = logging.getLogger("dxa.discriminator")
@ -98,17 +98,30 @@ def evaluate(records, scores: np.ndarray) -> Dict:
return out
def run_model_comparison(model_path: str, device: Optional[str] = None) -> Dict:
"""Сравнение модели с эвристикой области на всём наборе."""
def run_model_comparison(
model_path: str,
device: Optional[str] = None,
labels_csv: Optional[str] = None,
data_root: str = "dataset_hack",
) -> Dict:
"""
Сравнение модели с эвристикой области на всём наборе.
Метки берутся из того же источника, что и при обучении (`labels_csv`),
иначе сравнение шло бы против другой истины.
"""
device = device or get_device()
records = build_records(
"dataset_hack", "dataset_hack/НД_для_обучения/разметка.xlsx"
data_root,
"dataset_hack/НД_для_обучения/разметка.xlsx",
labels_csv=labels_csv,
)
logger.info("Device: %s, images: %d", device, len(records))
report = {
"model_path": str(model_path),
"device": device,
"labels_csv": labels_csv,
"n_images": len(records),
"n_violations": int(sum(r.label == QUALITY_BAD for r in records)),
"model": evaluate(records, model_logits(records, model_path, device)),
@ -157,6 +170,8 @@ def main(argv: Optional[List[str]] = None) -> int:
parser = argparse.ArgumentParser(description="Сравнение модели с эвристикой области")
parser.add_argument("--model-path", default="models/dxa_model.pth")
parser.add_argument("--device", default=None)
parser.add_argument("--labels-csv", default="labels/labels_images.csv",
help="Тот же источник меток, что при обучении; пустая строка — метки из имён файлов")
parser.add_argument("--json-out", default=None, help="Куда сохранить отчёт в JSON")
args = parser.parse_args(argv)
@ -164,7 +179,11 @@ def main(argv: Optional[List[str]] = None) -> int:
logger.error("Checkpoint not found: %s", args.model_path)
return 1
report = run_model_comparison(args.model_path, args.device)
report = run_model_comparison(
args.model_path,
args.device,
labels_csv=resolve_labels_csv(args.labels_csv),
)
print(_format_report(report))
if args.json_out:
Path(args.json_out).write_text(json.dumps(report, ensure_ascii=False, indent=2))

566
src/dxa/excel_labels.py Normal file
View File

@ -0,0 +1,566 @@
"""
Разметка снимков DXA на уровне изображения по экспертной таблице `разметка.xlsx`.
Зачем. Экспертная оценка в наборе сделана на уровне исследования, а модель
работает со снимками: вердикт нужно перенести. Перенос однозначен, потому что
каждая анатомическая область встречается в исследовании ровно один раз
(проверено: позвоночник 99, правое бедро 79, левое бедро 73 на 100
исследований) — не нужно решать, какой из нескольких снимков «плохой».
Правило метки выбирается параметром `--label-rule` (см. `LABEL_RULES`): по
умолчанию `table` — только экспертная таблица. Вариант `union` дополнительно
учитывает пометки, которые вручную проставлялись в именах файлов; он проиграл
сравнение на held-out наборе (ROC-AUC 0.6199 против 0.6764) и оставлен, чтобы
результат можно было воспроизвести. Вариант `expert` служит эталоном при оценке.
Соглашения таблицы (проверены по данным, см. `docs/labeling.md`):
* значение 1 в столбце критерия означает нарушение, хотя часть заголовков
сформулирована положительно («корректная укладка»). Итог области равен
логическому ИЛИ критериев: для бёдер 72/72, для позвоночника 96/99;
* столбец `study` — имя каталога исследования, а не DICOM StudyInstanceUID;
* разметка относится к исследованию целиком, а не к отдельному снимку.
Правило метки согласовано с condition_doctor.txt («если хотя бы один
существенный пункт нарушен, снимок непригоден»): нарушение ставится, если его
фиксирует экспертная таблица ИЛИ имя файла. Вклад каждого источника хранится
отдельно, поэтому правило можно переиграть без повторного разбора.
"""
from __future__ import annotations
import argparse
import csv
import logging
from collections import Counter, defaultdict
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, Iterable, List, Optional, Sequence, Tuple
import numpy as np
import pydicom
from src.dxa.labels import (
QUALITY_BAD,
QUALITY_GOOD,
ImageRecord,
region_from_filename,
scan_dataset,
)
from src.dxa.violations import (
ARTIFACT,
AXIS_DEVIATION,
POSITIONING,
ROI_INCORRECT,
ROTATION,
TABLE_TYPES,
UNSPECIFIED,
VIOLATION_TYPES,
)
logger = logging.getLogger(__name__)
# Единый словарь типов нарушений живёт в `src.dxa.violations`; здесь имена
# только реэкспортируются, чтобы таблица и остальное решение не разошлись.
__all__ = [
"ARTIFACT",
"AXIS_DEVIATION",
"POSITIONING",
"ROI_INCORRECT",
"ROTATION",
"UNSPECIFIED",
"VIOLATION_TYPES",
"TABLE_TYPES",
]
# Порядок критериев совпадает с порядком столбцов в листе «разметка.xlsx».
SPINE_CRITERIA: Tuple[str, ...] = (POSITIONING, AXIS_DEVIATION, ARTIFACT)
HIP_CRITERIA: Tuple[str, ...] = (ROTATION, ROI_INCORRECT)
# Индексы столбцов (0-based) в файле разметки; строки 0-1 — заголовки.
_COL_STUDY = 1
_COL_SPINE_CRITERIA = (2, 3, 4)
_COL_HIP_RIGHT_CRITERIA = (5, 6)
_COL_HIP_LEFT_CRITERIA = (7, 8)
_COL_SPINE_TOTAL = 9
_COL_HIP_RIGHT_TOTAL = 10
_COL_HIP_LEFT_TOTAL = 11
_COL_COMMENT = 12
_HEADER_ROWS = 2
#: Правила построения метки.
#: `union` — нарушение, если его фиксирует таблица ИЛИ имя файла (суффикс `_bad`);
#: `table` — только экспертная таблица; имя файла служит лишь резервом там, где
#: таблица область не оценивала (таких снимков три), и это отмечается флагом;
#: `expert` — чистый вердикт эксперта: снимки без оценки в файл не попадают.
#: Правило `expert` предназначено для эталона при оценке, а не для обучения.
LABEL_RULES = ("union", "table", "expert")
#: По умолчанию метка берётся только из экспертной таблицы: суффиксы имён файлов
#: проставлялись вручную и оказались ненадёжными. На held-out наборе правило
#: `table` дало ROC-AUC 0.6764 против 0.6199 у `union` (парная разница +0.0564,
#: 5 из 5 seed'ов) — см. `models/compare_rules/rule_comparison.md`.
DEFAULT_LABEL_RULE = "table"
HIP_REGIONS = ("hip_right", "hip_left")
@dataclass(frozen=True)
class RegionCriteria:
"""Экспертная оценка одной анатомической области в исследовании."""
violations: Dict[str, Optional[int]]
total: Optional[int]
@property
def known(self) -> bool:
"""Есть ли хоть какое-то экспертное свидетельство по области."""
return self.total is not None or any(v is not None for v in self.violations.values())
@property
def bad(self) -> bool:
"""Нарушение, если отмечен итог или хотя бы один критерий."""
if self.total == 1:
return True
return any(v == 1 for v in self.violations.values())
def violated(self) -> List[str]:
"""Типы нарушений по отмеченным критериям."""
types = [name for name, value in self.violations.items() if value == 1]
if not types and self.total == 1:
types.append(UNSPECIFIED)
return types
@dataclass(frozen=True)
class StudyCriteria:
"""Экспертная разметка исследования: критерии по областям плюс комментарий."""
study: str
regions: Dict[str, RegionCriteria]
comment: Optional[str] = None
def for_region(self, region: str) -> Optional[RegionCriteria]:
return self.regions.get(region)
def hip_variants(self) -> List[RegionCriteria]:
return [c for r, c in self.regions.items() if r in HIP_REGIONS and c.known]
@dataclass
class ImageLabel:
"""Метка одного уникального снимка с указанием источника свидетельства."""
record: ImageRecord
region: Optional[str]
quality: int
violations: Tuple[str, ...]
quality_excel: Optional[int]
quality_filename: int
laterality_mirrored: bool = False
region_ambiguous: bool = False
comment: Optional[str] = None
dicom_study_uid: str = ""
dicom_image_uid: str = ""
label_rule: str = DEFAULT_LABEL_RULE
@property
def conflict(self) -> bool:
"""Источники противоречат друг другу (таблица «хорошо», имя файла «плохо» и наоборот)."""
return self.quality_excel is not None and self.quality_excel != self.quality_filename
@property
def used_filename_fallback(self) -> bool:
"""Метка взята из имени файла, потому что таблица область не оценивала."""
return self.quality_excel is None
def as_row(self) -> Dict[str, object]:
return {
"path_to_image": str(self.record.path),
"file": self.record.path.name,
"study": self.record.study,
"study_uid": self.dicom_study_uid,
"image_uid": self.dicom_image_uid,
"anatomical_region": self.region or "",
"quality_class": self.quality,
"violation_type": ";".join(self.violations),
"quality_from_excel": "" if self.quality_excel is None else self.quality_excel,
"quality_from_filename": self.quality_filename,
"sources_conflict": int(self.conflict),
"laterality_mirrored": int(self.laterality_mirrored),
"region_ambiguous": int(self.region_ambiguous),
"label_rule": self.label_rule,
"filename_fallback": int(self.used_filename_fallback),
"filename_marker": self.record.marker or "",
"expert_comment": self.comment or "",
}
CSV_FIELDS: Tuple[str, ...] = tuple(
ImageLabel(
record=ImageRecord(path=Path("_"), study="_", region=None, label=0),
region=None,
quality=0,
violations=(),
quality_excel=None,
quality_filename=0,
).as_row().keys()
)
def _as_int(value) -> Optional[int]:
"""Привести ячейку Excel к 0/1/None, не роняя разбор на тексте и NaN."""
if value is None:
return None
try:
if isinstance(value, float) and np.isnan(value):
return None
except TypeError:
pass
try:
number = float(value)
except (TypeError, ValueError):
return None
if np.isnan(number):
return None
return int(number)
def load_study_criteria(excel_path: str | Path) -> Dict[str, StudyCriteria]:
"""
Прочитать лист разметки и вернуть критерии по каждому исследованию.
Ключ — имя каталога исследования (столбец `study`), а не StudyInstanceUID:
в таблице используются именно имена каталогов датасета.
"""
import pandas as pd
raw = pd.read_excel(excel_path, header=None)
if raw.shape[0] <= _HEADER_ROWS:
raise ValueError(f"Unexpected annotation layout in {excel_path}: shape={raw.shape}")
# pandas отбрасывает целиком пустые хвостовые столбцы, а разбор обращается
# к столбцу комментария по индексу. Дополняем таблицу до нужной ширины.
for column in range(raw.shape[1], _COL_COMMENT + 1):
raw[column] = np.nan
data = raw.iloc[_HEADER_ROWS:]
criteria: Dict[str, StudyCriteria] = {}
for _, row in data.iterrows():
study = row.iloc[_COL_STUDY]
if study is None or (isinstance(study, float) and np.isnan(study)):
continue
study = str(study).strip()
if not study:
continue
regions = {
"spine": RegionCriteria(
violations=dict(zip(SPINE_CRITERIA, (row.iloc[c] for c in _COL_SPINE_CRITERIA))),
total=_as_int(row.iloc[_COL_SPINE_TOTAL]),
),
"hip_right": RegionCriteria(
violations=dict(
zip(HIP_CRITERIA, (row.iloc[c] for c in _COL_HIP_RIGHT_CRITERIA))
),
total=_as_int(row.iloc[_COL_HIP_RIGHT_TOTAL]),
),
"hip_left": RegionCriteria(
violations=dict(zip(HIP_CRITERIA, (row.iloc[c] for c in _COL_HIP_LEFT_CRITERIA))),
total=_as_int(row.iloc[_COL_HIP_LEFT_TOTAL]),
),
}
regions = {
name: RegionCriteria(
violations={k: _as_int(v) for k, v in cell.violations.items()},
total=cell.total,
)
for name, cell in regions.items()
}
comment = row.iloc[_COL_COMMENT]
criteria[study] = StudyCriteria(
study=study,
regions=regions,
comment=None if pd.isna(comment) else str(comment).strip(),
)
return criteria
def resolve_region(sources: Sequence[Path]) -> Tuple[Optional[str], bool]:
"""
Определить область по именам всех файлов одного изображения (голосование).
Побайтные дубли иногда названы по-разному (`spine_03_bad.dcm` и
`l_hip_01.dcm` — один снимок). Побеждает область, встречающаяся чаще;
при равенстве голосов область считается неопределённой.
"""
votes = Counter(
region for region in (region_from_filename(p.stem) for p in sources) if region is not None
)
if not votes:
return None, True
if len(votes) == 1:
return next(iter(votes)), False
top = votes.most_common()
if top[0][1] == top[1][1]:
return None, True
return top[0][0], False
def _read_dicom_uids(path: Path) -> Tuple[str, str]:
"""StudyInstanceUID и SOPInstanceUID снимка (пустые строки, если тегов нет)."""
ds = pydicom.dcmread(str(path), stop_before_pixels=True)
return str(getattr(ds, "StudyInstanceUID", "") or ""), str(getattr(ds, "SOPInstanceUID", "") or "")
def _pick_criteria(
study: StudyCriteria,
region: Optional[str],
hip_image_count: int,
) -> Tuple[Optional[RegionCriteria], bool]:
"""
Выбрать критерии области. Для бёдер допускается зеркальный перенос.
В 6 исследованиях с единственным снимком бедра столбцы «правое»/«левое» в
таблице заполнены зеркально относительно имён файлов (в 71 из 72
исследований с двумя бёдрами они совпадают, то есть сдвиг не глобальный).
Если снимок бедра в исследовании один, а заполнен столбец противоположной
стороны, это и есть его оценка; факт переноса фиксируется флагом.
"""
if region is None:
return None, False
cell = study.for_region(region)
if cell is not None and cell.known:
return cell, False
if region in HIP_REGIONS and hip_image_count == 1:
variants = study.hip_variants()
if len(variants) == 1:
return variants[0], True
return cell, False
def label_dataset(
data_root: str | Path,
excel_path: str | Path,
label_rule: str = DEFAULT_LABEL_RULE,
) -> List[ImageLabel]:
"""
Построить метки всех уникальных снимков датасета.
`label_rule="union"` — нарушение, если его фиксирует таблица ИЛИ имя файла.
`label_rule="table"` — только таблица: суффикс `_bad` в метке не участвует,
он остаётся лишь диагностическим столбцом. Там, где таблица область не
оценивала (три снимка), метка берётся из имени файла и это отмечается
флагом `filename_fallback`, потому что иначе снимок остался бы без метки.
"""
if label_rule not in LABEL_RULES:
raise ValueError(f"Unknown label_rule={label_rule!r}, expected one of {LABEL_RULES}")
records = scan_dataset(data_root)
criteria = load_study_criteria(excel_path)
hip_counts: Dict[str, int] = defaultdict(int)
for rec in records:
region, _ = resolve_region(rec.sources)
if region in HIP_REGIONS:
hip_counts[rec.study] += 1
labels: List[ImageLabel] = []
for rec in records:
region, ambiguous = resolve_region(rec.sources)
study = criteria.get(rec.study)
cell, mirrored = _pick_criteria(study, region, hip_counts[rec.study]) if study else (None, False)
quality_excel = None if cell is None or not cell.known else (QUALITY_BAD if cell.bad else QUALITY_GOOD)
quality_filename = QUALITY_BAD if rec.marker == "bad" else QUALITY_GOOD
if quality_excel is None:
if label_rule == "expert":
# Снимок без экспертной оценки в эталон не входит.
continue
quality = quality_filename
violations: Tuple[str, ...] = (UNSPECIFIED,) if quality == QUALITY_BAD else ()
elif label_rule in ("table", "expert"):
quality = quality_excel
violations = tuple(cell.violated())
else:
quality = max(quality_excel, quality_filename)
violations = tuple(cell.violated())
if quality == QUALITY_BAD and not violations:
violations = (UNSPECIFIED,)
study_uid, image_uid = _read_dicom_uids(rec.path)
labels.append(
ImageLabel(
record=rec,
region=region,
quality=quality,
violations=violations,
quality_excel=quality_excel,
quality_filename=quality_filename,
laterality_mirrored=mirrored,
region_ambiguous=ambiguous,
comment=study.comment if study else None,
dicom_study_uid=study_uid,
dicom_image_uid=image_uid,
label_rule=label_rule,
)
)
return labels
def label_summary(labels: Sequence[ImageLabel]) -> Dict[str, Dict[str, object]]:
"""Сводка распределения меток по областям, классам и типам нарушений."""
summary: Dict[str, Dict[str, object]] = {}
for region in (*("spine",), *HIP_REGIONS, ""):
subset = [l for l in labels if (l.region or "") == region]
if not subset:
continue
violations = Counter(t for l in subset for t in l.violations)
summary[region or "unknown"] = {
"total": len(subset),
"good": sum(1 for l in subset if l.quality == QUALITY_GOOD),
"bad": sum(1 for l in subset if l.quality == QUALITY_BAD),
"conflicts": sum(1 for l in subset if l.conflict),
"violations": dict(violations.most_common()),
}
return summary
def write_csv(labels: Sequence[ImageLabel], out_path: str | Path) -> Path:
"""Записать метки в CSV (по одной строке на снимок)."""
path = Path(out_path)
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", newline="", encoding="utf-8") as fh:
writer = csv.DictWriter(fh, fieldnames=list(CSV_FIELDS))
writer.writeheader()
for label in labels:
writer.writerow(label.as_row())
return path
def load_labels_csv(path: str | Path) -> Dict[str, Dict[str, str]]:
"""Прочитать построенную разметку; ключ — путь к снимку из `path_to_image`."""
with Path(path).open(newline="", encoding="utf-8") as fh:
rows = list(csv.DictReader(fh))
if not rows:
raise ValueError(f"Empty labels file: {path}")
missing = {"path_to_image", "quality_class"} - set(rows[0])
if missing:
raise ValueError(f"Labels file {path} lacks columns: {sorted(missing)}")
return {row["path_to_image"]: row for row in rows}
def apply_excel_labels(
records: Sequence[ImageRecord],
labels_csv: str | Path,
) -> List[ImageRecord]:
"""
Подменить метки и области снимков на построенные по экспертной таблице.
Группировка снимков берётся из живого сканирования датасета
(`scan_dataset`), а не из файла: так устаревшая разметка не сможет
незаметно разойтись с данными. Если CSV описывает не этот датасет,
подстановка не делается вовсе; частичное несовпадение — предупреждение.
"""
table = load_labels_csv(labels_csv)
matched = 0
unknown: List[str] = []
updated: List[ImageRecord] = []
for rec in records:
row = table.get(str(rec.path))
if row is None:
unknown.append(str(rec.path))
updated.append(rec)
continue
matched += 1
region = row.get("anatomical_region") or None
updated.append(
ImageRecord(
path=rec.path,
study=rec.study,
region=region,
label=int(row["quality_class"]),
marker=rec.marker,
sources=rec.sources,
)
)
if matched == 0:
raise ValueError(
f"None of the {len(records)} dataset images are present in {labels_csv}; "
"regenerate it with `./run.sh label`"
)
if unknown:
logger.warning(
"%d/%d images are absent from %s and keep filename labels (first: %s)",
len(unknown), len(records), labels_csv, unknown[0],
)
logger.info("Labels taken from %s: %d/%d images matched", labels_csv, matched, len(records))
return updated
def write_xlsx(labels: Sequence[ImageLabel], out_path: str | Path) -> Path:
"""Записать метки в XLSX (тот же набор столбцов, что и CSV)."""
import pandas as pd
path = Path(out_path)
path.parent.mkdir(parents=True, exist_ok=True)
pd.DataFrame([label.as_row() for label in labels], columns=list(CSV_FIELDS)).to_excel(
path, index=False
)
return path
def format_summary(labels: Sequence[ImageLabel]) -> str:
"""Человекочитаемая сводка разметки для логов и отчёта."""
summary = label_summary(labels)
rule = labels[0].label_rule if labels else DEFAULT_LABEL_RULE
lines = [f"Unique images: {len(labels)} (label rule: {rule})"]
total_good = total_bad = 0
for region, stats in summary.items():
total_good += int(stats["good"])
total_bad += int(stats["bad"])
lines.append(
f" {region:10} good={stats['good']:4} bad={stats['bad']:4} "
f"total={stats['total']:4} conflict={stats['conflicts']:3}"
)
if stats["violations"]:
lines.append(f" violations: {stats['violations']}")
share = total_bad / max(total_bad + total_good, 1)
lines.append(f" {'ALL':10} good={total_good:4} bad={total_bad:4} (bad share={share:.1%})")
fallback = sum(1 for label in labels if label.used_filename_fallback)
if fallback:
lines.append(f" without expert verdict (label from filename): {fallback}")
return "\n".join(lines)
def main(argv: Iterable[str] | None = None) -> None:
parser = argparse.ArgumentParser(
description="Разметить снимки DXA на уровне изображения по экспертной таблице"
)
parser.add_argument("--data-root", default="dataset_hack")
parser.add_argument(
"--excel",
default=None,
help="путь к разметке.xlsx (по умолчанию ищется в data-root)",
)
parser.add_argument("--out-dir", default="labels")
parser.add_argument("--out-name", default="labels_images",
help="Базовое имя файлов результата (без расширения)")
parser.add_argument("--label-rule", choices=LABEL_RULES, default=DEFAULT_LABEL_RULE,
help="union — таблица или имя файла; table — только экспертная таблица")
parser.add_argument("--no-xlsx", action="store_true", help="не писать XLSX")
args = parser.parse_args(list(argv) if argv is not None else None)
excel = Path(args.excel) if args.excel else Path(args.data_root) / "НД_для_обучения" / "разметка.xlsx"
labels = label_dataset(args.data_root, excel, label_rule=args.label_rule)
out_dir = Path(args.out_dir)
csv_path = write_csv(labels, out_dir / f"{args.out_name}.csv")
print(format_summary(labels))
print(f"\nCSV: {csv_path}")
if not args.no_xlsx:
print(f"XLSX: {write_xlsx(labels, out_dir / f'{args.out_name}.xlsx')}")
if __name__ == "__main__":
main()

View File

@ -52,23 +52,19 @@ from src.dxa.preprocess import (
load_dicom_array,
preprocess_from_array,
)
from src.dxa.violations import (
ARTIFACT,
MOTION,
ROTATION,
UNSPECIFIED,
reason as violation_reason,
)
logger = logging.getLogger("dxa.inference")
# Предсказание вспомогательной головы -> анатомическая область
REGION_BY_ID = {1: "spine", 2: "hip_right", 3: "hip_left"}
# Человекочитаемые причины для типа нарушения
REASON_BY_TYPE = {
"position_error": "Геометрия или укладка области исследования нарушены",
"artifact_motion": "Признаки артефактов движения (размытие, раздвоение контуров)",
"artifact_other": "Посторонние включения или артефакты в зоне интереса",
"incomplete_view": "Нужная анатомическая область видна не полностью",
"roi_error": "Границы области интереса не совпадают с анатомическими",
"rotation": "Выраженная ротация, искажающая анатомические границы",
"quality_violation_detected": "Выявлено нарушение качества изображения",
}
OUTPUT_COLUMNS = [
"path_to_study", "study_uid", "image_uid", "anatomical_region",
"quality_class", "violation_type", "processing_status", "time_of_processing",
@ -411,24 +407,27 @@ def classify_violation_type(
samples: Optional[Dict[str, float]],
) -> Tuple[str, str]:
"""
Определить тип нарушения по эвристическим метрикам изображения.
Предположить тип нарушения по эвристическим признакам изображения.
Возвращает (тип, пояснение). Тип выбирается по наиболее выраженному
признаку; при отсутствии сигналов возвращается общая категория.
Возвращает (код, пояснение) с кодами из `src.dxa.violations`. Это НЕ вывод
модели: модель решает только бинарную задачу «пригоден / есть нарушение», а
тип выбирается по наиболее выраженному признаку. При отсутствии признаков
возвращается общая категория `unspecified`, поэтому в интерфейсе тип
помечен как эвристика, а не как заключение.
"""
motion = samples.get("laplacian_variance") if samples else None
bright_frac = samples.get("bright_fraction") if samples else None
if motion is not None and motion < metrics.get("motion_threshold", 0.0):
return "artifact_motion", REASON_BY_TYPE["artifact_motion"]
return MOTION, violation_reason(MOTION)
if bright_frac is not None and bright_frac > metrics.get("artifact_threshold", 1.0):
return "artifact_other", REASON_BY_TYPE["artifact_other"]
return ARTIFACT, violation_reason(ARTIFACT)
if region in ("hip_left", "hip_right") and samples:
aspect = samples.get("bbox_aspect", 1.0)
if aspect < 0.4 or aspect > 3.0:
return "rotation", REASON_BY_TYPE["rotation"]
return ROTATION, violation_reason(ROTATION)
return "quality_violation_detected", REASON_BY_TYPE["quality_violation_detected"]
return UNSPECIFIED, violation_reason(UNSPECIFIED)
def image_samples(img: np.ndarray) -> Dict[str, float]:

View File

@ -16,6 +16,7 @@
from __future__ import annotations
import hashlib
import json
import logging
import re
from dataclasses import dataclass
@ -327,3 +328,66 @@ def iter_dicom_files(data_root: str | Path) -> Iterable[Path]:
yield root
return
yield from sorted(root.rglob("*.dcm"))
def export_split(
records: Sequence[ImageRecord],
out_path: str | Path,
val_fraction: float = 0.2,
seed: int = 42,
) -> Path:
"""
Зафиксировать разбиение по исследованиям в файле.
Нужно, чтобы сравнивать варианты разметки на одном и том же held-out
наборе: `stratified_group_split` стратифицирует по наличию нарушений, а оно
зависит от источника меток. Без общего файла смена меток меняла бы и
валидационный набор, и метрики становились бы несравнимыми.
"""
_, val = stratified_group_split(records, val_fraction=val_fraction, seed=seed)
path = Path(out_path)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
json.dumps(
{
"val_studies": sorted({r.study for r in val}),
"val_fraction": val_fraction,
"seed": seed,
"train_images": len(records) - len(val),
"val_images": len(val),
},
ensure_ascii=False,
indent=2,
)
)
return path
def load_split(path: str | Path) -> set:
"""Прочитать список исследований валидации из файла разбиения."""
data = json.loads(Path(path).read_text())
studies = data.get("val_studies")
if not isinstance(studies, list) or not studies:
raise ValueError(f"Split file {path} has no non-empty 'val_studies' list")
return set(studies)
def split_by_studies(
records: Sequence[ImageRecord],
val_studies: Iterable[str],
) -> Tuple[List[ImageRecord], List[ImageRecord]]:
"""
Разделить примеры по заранее заданному списку исследований.
Исследования из файла разбиения попадают в валидацию; исследования, которых
в файле нет, относятся к обучению. Такой же контракт у
`stratified_group_split`, поэтому варианты сравнимы.
"""
val_set = set(val_studies)
train = [r for r in records if r.study not in val_set]
val = [r for r in records if r.study in val_set]
if not train or not val:
raise ValueError(
f"Split by studies degenerated: train={len(train)}, val={len(val)}"
)
return train, val

156
src/dxa/model_card.py Normal file
View File

@ -0,0 +1,156 @@
"""
Актуальные сведения о решении: разметка, модель, метрики, ограничения.
Модуль — источник данных для эндпоинта `/api/v1/model` и панели «О модели» в
веб-интерфейсе. Значения зафиксированы на дату снимка и снабжены ссылками на
источник, потому что API в контейнере не имеет доступа ни к `docs/labeling.md`,
ни к артефактам экспериментов: в образ копируется только `src/`.
Честность чисел важнее полноты:
* метрики сравнения — среднее по пяти seed'ам с 95 % доверительным интервалом
(`models/compare_rules/rule_comparison.md`);
* рабочий чекпоинт — обычный прогон с seed по умолчанию, не лучший из выборки,
поэтому его собственная валидационная оценка близка к среднему по seed'ам;
* эталон оценки — вердикт эксперта из таблицы, независимой истины нет, поэтому
интервал не покрывает неопределённость самой разметки.
"""
from __future__ import annotations
from typing import Dict, List
from src.dxa.violations import catalogue as violations_catalogue
#: Дата, на которую верны числа ниже.
SNAPSHOT_DATE = "2026-09-26"
#: Источник чисел — чтобы их можно было перепроверить, а не принимать на веру.
SOURCES: List[str] = [
"docs/labeling.md — как построена разметка и как выбиралось правило",
"models/compare_rules/rule_comparison.md — машинный отчёт сравнения правил",
"models/train_report.json — отчёт обучения рабочего чекпоинта",
]
DATASET: Dict = {
"files_on_disk": 544,
"unique_images": 252,
"studies": 100,
"per_region": {"spine": 99, "hip_right": 79, "hip_left": 73, "unknown": 1},
"violations_expert": 74,
"violations_labeled": 77,
"images_without_expert_verdict": 3,
"violation_share": 0.3056,
}
LABELS: Dict = {
"csv": "labels/labels_images.csv",
"rule": (
"Метка и тип нарушения берутся только из экспертной таблицы: по каждому "
"исследованию отмечены критерии (укладка, ось, артефакты, позиционирование, "
"область интереса), а каждая область встречается в исследовании ровно один "
"раз, поэтому вердикт переносится на снимок однозначно."
),
"columns": [
"path_to_image", "study", "study_uid", "image_uid", "anatomical_region",
"quality_class", "violation_type", "quality_from_excel",
"quality_from_filename", "sources_conflict", "laterality_mirrored",
"region_ambiguous", "label_rule", "filename_fallback", "filename_marker",
"expert_comment",
],
"violation_types": ["rotation", "artifact", "axis_deviation", "roi_incorrect",
"positioning", "unspecified"],
}
#: Сравнение правил разметки на одном held-out наборе: `table` — метка только из
#: экспертной таблицы (принято), `union` — таблица плюс служебные пометки,
#: которые проставлялись при подготовке набора. Оценка по эталону — вердикту
#: эксперта; 5 seed'ов.
COMPARISON: Dict = {
"title": "Выбор правила разметки",
"question": (
"Метку можно брать только из экспертной таблицы или дополнительно "
"учитывать служебные пометки, проставленные при подготовке набора. "
"Пометки расходились с оценкой эксперта в 15 случаях из 252, поэтому "
"правило нужно было выбрать измерением."
),
"variants": {"table": "только экспертная таблица", "union": "таблица и служебные пометки"},
"seeds": [0, 1, 2, 3, 4],
"val_images": 53,
"val_violations": 16,
"reference": "вердикт эксперта из labels/labels_images_expert.csv",
"metrics": {
"roc_auc": {"table": [0.6764, 0.6309, 0.7218], "union": [0.6199, 0.5840, 0.6559]},
"pr_auc": {"table": [0.4759, 0.4141, 0.5377], "union": [0.4046, 0.3702, 0.4391]},
"f1": {"table": [0.5676, 0.5270, 0.6082], "union": [0.5426, 0.5073, 0.5778]},
},
"paired_delta_table_minus_union": {
"roc_auc": [0.0564, 0.0403, 0.0725],
"pr_auc": [0.0713, 0.0398, 0.1028],
"f1": [0.0251, -0.0056, 0.0558],
},
"paired_wins": "5 из 5 seed'ов в пользу экспертной таблицы",
"decision": "Принято правило `table`: служебные пометки в метках не участвуют.",
}
#: Рабочий чекпоинт. Собственные метрики валидации берутся из чекпоинта и
#: продублированы здесь только для отображения.
DEPLOYED: Dict = {
"checkpoint": "models/dxa_model.pth",
"backbone": "resnet18",
"head": "linear",
"labels_csv": "labels/labels_images.csv",
"label_rule": "table",
# Рабочий прогон использует штатное стратифицированное разбиение, а не
# зафиксированный файл — поэтому поле пустое, и это ожидаемо.
"split_file": None,
"seed": 42,
"epoch": 39,
"threshold_logit": -0.4930129051208496,
"threshold_probability": 0.3792,
"train_images": 199,
"val_images": 53,
"val_violations": 16,
"val_roc_auc": 0.6706,
"val_pr_auc": 0.4585,
"val_f1": 0.5600,
"val_recall": 0.8750,
"val_precision": 0.4118,
"caveat": (
"Это обычный прогон с seed по умолчанию, а не лучший из выборки. "
"Незавышенная ожидаемая оценка того же варианта разметки — среднее по пяти "
"seed'ам: ROC-AUC 0.6764 [0.6309, 0.7218]. Его собственная валидационная "
"ROC-AUC 0.6706 близка к этому среднему."
),
}
#: Что модель не делает. Показывается в интерфейсе рядом с результатом.
LIMITATIONS: List[str] = [
"Модель бинарная: она отвечает «пригоден / есть нарушение» и НЕ определяет тип нарушения.",
"Тип нарушения в ответе — эвристика по метрикам снимка; на этом наборе она чаще "
"всего даёт общую категорию `unspecified`.",
"Мало данных: 252 снимка, 77 нарушений; доверительные интервалы широкие.",
"Эталон — экспертная таблица, независимой истины нет; интервалы не покрывают "
"неопределённость самой разметки.",
"Для 3 снимков таблица область не оценивала — метка взята из служебной пометки "
"(столбец `filename_fallback`).",
"Сторона бедра в 7 исследованиях с единственным снимком не проверяема: теги "
"`Laterality` в DICOM пусты.",
"Область определяется по ширине кадра: порог `SPINE_MIN_WIDTH` привязан к текущему "
"оборудованию.",
"Корректность нанесённых областей интереса наследуется из таблицы: разметки ROI "
"в DICOM нет, сравнить её напрямую не с чем.",
]
def model_card() -> Dict:
"""Полная карточка решения для `/api/v1/model` и панели «О модели»."""
return {
"snapshot_date": SNAPSHOT_DATE,
"labels": LABELS,
"dataset": DATASET,
"model": DEPLOYED,
"comparison": COMPARISON,
"violations": violations_catalogue(),
"limitations": LIMITATIONS,
"sources": SOURCES,
}

289
src/dxa/rename_files.py Normal file
View File

@ -0,0 +1,289 @@
"""
Приведение имён DICOM-файлов датасета к единому виду.
Проблема: имена в датасете писались вручную и разошлись — `spine_1` и `spine_01`,
`Spine_01`, `r_spine_03`, `r_hip03`, `r_hop_02` (опечатка), `spine-1`. Искать
файл по всему набору из-за этого неудобно.
Целевой вид: `<область>_<NN>[_good|_bad].dcm`, где область — `spine`, `l_hip`
или `r_hip`, номер — две цифры. Суффикс оценки сохраняется ровно таким, каким
был: модуль намеренно не дописывает `_good` файлам без метки, иначе источник
«метка в имени файла» перестал бы быть независимым свидетельством о качестве.
Безопасность:
* по умолчанию выполняется только разбор и печать плана (`--dry-run` по
умолчанию), правка требует явного `--apply`;
* карта переименований пишется в CSV **до** правки, поэтому откат возможен даже
при прерывании: `--rollback --mapping <файл>`;
* файлы без распознаваемой области не переименовываются;
* переименование выполняется через временные имена, поэтому цепочки вида
`spine_1 → spine_01` при занятом `spine_01` не портят данные;
* разбор области в проекте терпим к этим написаниям (`r_hop_02` и `r_spine_03`
уже дают верную область), поэтому переименование не меняет ни склейку
дублей, ни разметку — это проверяется сравнением `labels_images.csv` до и
после.
Примеры:
python -m src.dxa.rename_files --dry-run # только план
python -m src.dxa.rename_files --apply # выполнить
python -m src.dxa.rename_files --rollback --mapping labels/rename_map.csv
"""
from __future__ import annotations
import argparse
import csv
import logging
import os
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, Iterable, List, Optional, Sequence, Tuple
from src.dxa.labels import marker_from_filename, region_from_filename
logger = logging.getLogger(__name__)
#: Соответствие кода области (как в остальном коде) префиксу в имени файла.
#: В именах исторически используются `l_hip` и `r_hip`, а не `hip_left` /
#: `hip_right`: словарь имён не меняем, чтобы не ломать привычный поиск.
REGION_PREFIX: Dict[str, str] = {
"spine": "spine",
"hip_left": "l_hip",
"hip_right": "r_hip",
}
#: Разделитель, к которому приводится номер.
_NUMBER_RE = re.compile(r"(\d+)")
_MAPPING_FIELDS = ("old_path", "new_path", "old_name", "new_name", "reason")
@dataclass(frozen=True)
class Rename:
"""Одно переименование: что было, что станет и почему."""
source: Path
target: Path
reason: str
@property
def is_change(self) -> bool:
# На регистронезависимой файловой системе (macOS) смена только регистра
# тоже требует переименования, поэтому сравниваем имена буквально.
return self.source.name != self.target.name
def parse_stem(stem: str) -> Optional[Tuple[str, int, Optional[str]]]:
"""
Разобрать имя на (код области, номер, метка).
Возвращает None, если область не распознаётся: такой файл переименовать
нельзя, не угадывая область, а угадывание в имени файла недопустимо.
"""
region = region_from_filename(stem)
if region not in REGION_PREFIX:
return None
label = marker_from_filename(stem)
match = _NUMBER_RE.search(stem)
return region, int(match.group(1)) if match else 1, label
def _format_name(region: str, number: int, label: Optional[str], suffix: str) -> str:
"""`<префикс области>_<NN>[_метка]<расширение>`."""
name = f"{REGION_PREFIX[region]}_{number:02d}"
if label:
name += f"_{label}"
return name + suffix
def canonical_stem(stem: str) -> Optional[str]:
"""Каноническое имя без расширения или None, если область не распознана."""
parsed = parse_stem(stem)
return None if parsed is None else _format_name(*parsed, "")
def plan_renames(files: Sequence[Path]) -> List[Rename]:
"""
План переименований с разрешением конфликтов имён.
Конфликт возможен, когда каноническое имя уже занято другим файлом той же
папки: например, `spine_1.dcm` и `spine_01.dcm` лежат рядом. Тогда номер
сдвигается к ближайшему свободному, а не перезаписывает соседа. Имена
сравниваются без учёта регистра: файловые системы macOS и Windows
регистронезависимы, и `Spine_01` конфликтует с `spine_01`.
"""
by_dir: Dict[Path, List[Path]] = {}
for path in files:
by_dir.setdefault(path.parent, []).append(path)
plan: List[Rename] = []
for directory, paths in sorted(by_dir.items()):
occupied = {p.name.lower() for p in paths}
assigned: Dict[str, Path] = {}
for path in sorted(paths, key=lambda p: p.name):
parsed = parse_stem(path.stem)
if parsed is None:
continue
region, number, label = parsed
def taken(name: str) -> bool:
key = name.lower()
if key == path.name.lower():
return False
return key in occupied or (key in assigned and assigned[key] != path)
target_name = _format_name(region, number, label, path.suffix)
while taken(target_name):
number += 1
target_name = _format_name(region, number, label, path.suffix)
assigned[target_name.lower()] = path
if target_name != path.name:
plan.append(
Rename(source=path, target=path.parent / target_name,
reason=_reason(path.name, target_name))
)
return plan
def _reason(old: str, new: str) -> str:
"""Короткая причина правки — для читаемости плана и CSV."""
if old.lower() == new.lower():
return "регистр"
if "-" in old:
return "дефис вместо подчёркивания"
if "hop" in old.lower():
return "опечатка hop"
if re.match(r"^[rl]_spine", old):
return "порядок: сторона перед областью"
if re.match(r"^[a-z_]+[a-z]\d", old):
return "нет разделителя перед номером"
if not re.search(r"\d", old):
return "нет номера"
return "ширина номера"
def write_mapping(plan: Sequence[Rename], path: str | Path) -> Path:
"""Записать карту переименований (основа для отката)."""
out = Path(path)
out.parent.mkdir(parents=True, exist_ok=True)
with out.open("w", newline="", encoding="utf-8") as fh:
writer = csv.DictWriter(fh, fieldnames=list(_MAPPING_FIELDS))
writer.writeheader()
for item in plan:
writer.writerow(
{
"old_path": str(item.source),
"new_path": str(item.target),
"old_name": item.source.name,
"new_name": item.target.name,
"reason": item.reason,
}
)
return out
def apply_renames(plan: Sequence[Rename], dry_run: bool = True) -> int:
"""
Выполнить переименования через временные имена.
Двухфазная схема нужна из-за цепочек (`A → B`, где `B` пока занят другим
файлом) и регистронезависимых файловых систем: сначала всё уводится в
уникальные временные имена, затем ставится на место.
"""
changes = [item for item in plan if item.is_change]
if dry_run or not changes:
return 0
staged: List[Tuple[Path, Path]] = []
for index, item in enumerate(changes):
if not item.source.exists():
raise FileNotFoundError(f"Нет исходного файла: {item.source}")
temp = item.source.parent / f".__rename_tmp_{index}__"
os.rename(item.source, temp)
staged.append((temp, item.target))
for temp, target in staged:
if target.exists() and target.name.lower() != temp.name.lower():
raise FileExistsError(f"Целевое имя занято: {target}")
os.rename(temp, target)
return len(staged)
def rollback(mapping_path: str | Path, dry_run: bool = True) -> int:
"""
Вернуть прежние имена по карте переименований.
Возвращает число файлов, к которым вернётся прежнее имя: при `dry_run`
— сколько бы вернулось, иначе — сколько вернулось фактически.
"""
with Path(mapping_path).open(newline="", encoding="utf-8") as fh:
rows = list(csv.DictReader(fh))
plan = [
Rename(source=Path(row["new_path"]), target=Path(row["old_path"]), reason="откат")
for row in rows
]
if dry_run:
return sum(1 for item in plan if item.is_change)
return apply_renames(plan, dry_run=False)
def main(argv: Iterable[str] | None = None) -> int:
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
parser = argparse.ArgumentParser(description="Приведение имён DICOM к единому виду")
parser.add_argument("--data-root", default="dataset_hack")
parser.add_argument("--mapping", default="labels/rename_map.csv")
parser.add_argument("--apply", action="store_true", help="Выполнить правку (по умолчанию только план)")
parser.add_argument("--dry-run", action="store_true", help="Только план; режим по умолчанию")
parser.add_argument("--rollback", action="store_true", help="Вернуть имена по карте и выйти")
parser.add_argument("--limit", type=int, default=0, help="Показать первые N строк плана (0 — все)")
args = parser.parse_args(list(argv) if argv is not None else None)
if args.rollback:
count = rollback(args.mapping, dry_run=not args.apply)
print(f"{'Откатано' if args.apply else 'Будет откатано'} файлов: {count}")
return 0
root = Path(args.data_root)
files = sorted(root.rglob("*.dcm"))
if not files:
logger.error("В %s не найдено ни одного .dcm", root)
return 1
plan = plan_renames(files)
skipped = [p for p in files if canonical_stem(p.stem) is None]
reasons: Dict[str, int] = {}
for item in plan:
reasons[item.reason] = reasons.get(item.reason, 0) + 1
print(f"Всего файлов: {len(files)}")
print(f"Требуют правки: {len(plan)}")
for reason, count in sorted(reasons.items(), key=lambda kv: -kv[1]):
print(f" {count:5} {reason}")
print(f"Без распознанной области (не тронуты): {len(skipped)}")
for path in skipped:
print(f" {path}")
to_show = plan if not args.limit else plan[: args.limit]
print("\nПлан (было -> станет):")
for item in to_show:
print(f" {item.source.parent.name[-12:]}/{item.source.name:22} -> {item.target.name:22} [{item.reason}]")
if args.limit and len(plan) > args.limit:
print(f" ... и ещё {len(plan) - args.limit}")
if args.apply:
written = write_mapping(plan, args.mapping)
count = apply_renames(plan, dry_run=False)
logger.info("Переименовано файлов: %d", count)
logger.info("Карта для отката: %s", written)
else:
print("\nЭто план. Для выполнения: --apply (карта отката будет записана в "
f"{args.mapping})")
return 0
if __name__ == "__main__":
raise SystemExit(main())

180
src/dxa/render.py Normal file
View File

@ -0,0 +1,180 @@
"""
Рендер DICOM-снимков датасета в PNG и контактные листы для визуальной разметки.
Модуль не участвует в обучении/инференсе. Он нужен, чтобы размечать датасет
глазами: DXA-файлы датасета — 8-битные изображения 280–320 px, поэтому
несколько десятков снимков помещаются на один лист и просматриваются разом.
Нормализация та же, что при обучении (`PreprocessConfig`), чтобы эксперт видел
ровно то, что увидит модель.
"""
from __future__ import annotations
import argparse
import math
from pathlib import Path
from typing import Iterable, List, Sequence
import numpy as np
from PIL import Image, ImageDraw
from src.dxa.labels import ImageRecord, scan_dataset
from src.dxa.preprocess import PreprocessConfig, load_dicom_array, normalize_array
_PAD = 6
_LABEL_H = 34
_BG = (24, 24, 24)
_FG = (235, 235, 235)
def record_to_image(record: ImageRecord, cfg: PreprocessConfig, scale: int = 2) -> Image.Image:
"""Прочитать снимок и вернуть нормализованное PNG-изображение RGBA."""
arr01 = normalize_array(load_dicom_array(record.path), cfg)
img = np.clip(arr01 * 255.0, 0, 255).astype(np.uint8)
pil = Image.fromarray(img).convert("RGB")
if scale != 1:
pil = pil.resize((pil.width * scale, pil.height * scale), Image.NEAREST)
return pil
def _cell(record: ImageRecord, index: int, pill: Image.Image, cell_w: int, cell_h: int) -> Image.Image:
"""Одна ячейка листа: изображение, глобальный номер и имя файла с хвостом study."""
canvas = Image.new("RGB", (cell_w, cell_h), _BG)
x = (cell_w - pill.width) // 2
y = _LABEL_H + (cell_h - _LABEL_H - pill.height) // 2
canvas.paste(pill, (max(x, 0), max(y, _LABEL_H)))
draw = ImageDraw.Draw(canvas)
draw.text((4, 3), f"#{index}", fill=_FG)
# Имена файлов повторяются между исследованиями, поэтому нужен хвост study_uid.
tag = f"{record.stem}~{record.study[-6:]}"
if len(tag) > 30:
tag = tag[:27] + "..."
draw.text((4, 17), f"{tag} [{record.region or '?'}]", fill=(150, 190, 240))
draw.rectangle([0, 0, cell_w - 1, cell_h - 1], outline=(70, 70, 70))
return canvas
def contact_sheet(
records: Sequence[ImageRecord],
out_path: Path,
cfg: PreprocessConfig,
cols: int = 6,
scale: int = 2,
start_index: int = 0,
) -> Path:
"""Собрать лист из нескольких снимков; под каждым — глобальный номер и имя файла."""
if not records:
raise ValueError("contact_sheet requires at least one record")
pills = [record_to_image(r, cfg, scale) for r in records]
cell_w = max(p.width for p in pills) + _PAD * 2
cell_h = max(p.height for p in pills) + _LABEL_H + _PAD * 2
rows = math.ceil(len(records) / cols)
sheet = Image.new("RGB", (cell_w * cols, cell_h * rows), _BG)
for i, (record, pill) in enumerate(zip(records, pills)):
r, c = divmod(i, cols)
sheet.paste(_cell(record, start_index + i, pill, cell_w, cell_h), (c * cell_w, r * cell_h))
out_path.parent.mkdir(parents=True, exist_ok=True)
sheet.save(out_path)
return out_path
def render_all(
data_root: str | Path,
out_dir: str | Path,
per_sheet: int = 36,
cols: int = 6,
scale: int = 2,
) -> List[Path]:
"""Отрендерить все уникальные снимки и нарезать их на контактные листы."""
cfg = PreprocessConfig()
out = Path(out_dir)
records = scan_dataset(data_root)
write_index(records, out / "index.csv")
singles = out / "images"
singles.mkdir(parents=True, exist_ok=True)
for i, rec in enumerate(records):
record_to_image(rec, cfg, scale).save(singles / f"{i:03d}_{rec.stem}_{rec.study[-6:]}.png")
sheets: List[Path] = []
for start in range(0, len(records), per_sheet):
chunk = records[start : start + per_sheet]
path = out / f"sheet_{start // per_sheet:02d}_{start:03d}-{start + len(chunk) - 1:03d}.png"
sheets.append(contact_sheet(chunk, path, cfg, cols=cols, scale=scale, start_index=start))
return sheets
def contact_sheets_by_region(
data_root: str | Path,
out_dir: str | Path,
per_sheet: int = 36,
cols: int = 6,
scale: int = 2,
) -> dict:
"""Отдельные наборы листов по анатомическим областям, чтобы сравнивать похожее."""
cfg = PreprocessConfig()
out = Path(out_dir)
records = scan_dataset(data_root)
write_index(records, out / "index.csv")
index_of = {str(rec.path): i for i, rec in enumerate(records)}
groups: dict[str, list[ImageRecord]] = {}
for rec in records:
groups.setdefault(rec.region or "unknown", []).append(rec)
result = {}
for region, items in sorted(groups.items()):
for start in range(0, len(items), per_sheet):
chunk = items[start : start + per_sheet]
path = out / region / f"{region}_{start:03d}-{start + len(chunk) - 1:03d}.png"
contact_sheet(
chunk, path, cfg, cols=cols, scale=scale, start_index=index_of[str(chunk[0].path)]
)
result.setdefault(region, []).append(path)
return result
def write_index(records: Sequence[ImageRecord], out_path: Path) -> Path:
"""CSV-реестр: глобальный номер -> исследование, файл, область, метка из имени."""
import csv
out_path.parent.mkdir(parents=True, exist_ok=True)
with out_path.open("w", newline="", encoding="utf-8") as fh:
writer = csv.writer(fh)
# study_dir — имя каталога исследования, оно же ключ в разметка.xlsx;
# это не StudyInstanceUID из DICOM (они в датасете разные).
writer.writerow(["id", "study_dir", "path", "file", "region", "filename_marker"])
for i, rec in enumerate(records):
writer.writerow([i, rec.study, str(rec.path), rec.path.name, rec.region or "", rec.marker or ""])
return out_path
def main(argv: Iterable[str] | None = None) -> None:
parser = argparse.ArgumentParser(description="Рендер DICOM датасета в PNG для визуальной разметки")
parser.add_argument("--data-root", default="dataset_hack")
parser.add_argument("--out", default="dataset_hack/_preview")
parser.add_argument("--per-sheet", type=int, default=36)
parser.add_argument("--cols", type=int, default=6)
parser.add_argument("--scale", type=int, default=2)
parser.add_argument("--by-region", action="store_true", help="листы отдельно по областям")
args = parser.parse_args(list(argv) if argv is not None else None)
if args.by_region:
result = contact_sheets_by_region(
args.data_root, args.out, args.per_sheet, args.cols, args.scale
)
for region, paths in result.items():
print(f"{region}: {len(paths)} sheet(s)")
for p in paths:
print(f" {p}")
else:
for p in render_all(args.data_root, args.out, args.per_sheet, args.cols, args.scale):
print(p)
if __name__ == "__main__":
main()

View File

@ -4,16 +4,19 @@
================================================
Модель решает бинарную задачу: годен снимок (0) или есть нарушение (1).
Метки берутся из имён DICOM-файлов, где `_good`/`_bad` — экспертная оценка,
а отсутствие суффикса означает «изображение хорошее» (см. `src/dxa/labels.py`).
Метки берутся из `labels/labels_images.csv` — разметки, построенной из
экспертной таблицы (правило `table`, 77 нарушений). Режим с метками из имён
файлов (`_good`/`_bad`) сохранён через `--labels-csv ""`.
Особенности, отличающие этот скрипт от прежней версии:
1. **Метки из имён файлов.** Раньше метка бралась из Excel по исследованию и
раздавалась всем снимкам этого исследования; файлы с явной меткой (`_bad`)
при этом игнорировались либо, наоборот, не сопоставлялись со своими
дублями. Теперь источник метки — имя файла, а Excel используется только для
предупреждения о расхождениях.
1. **Метки на уровне снимка.** Источник — `labels/labels_images.csv`,
построенный из экспертной таблицы (`./run.sh label`, см.
`src/dxa/excel_labels.py` и `docs/labeling.md`): оценка исследования
переносится на снимок области. Ручные пометки из имён файлов в метках не
участвуют — они оказались ненадёжными (расходились с экспертом в 15 случаях
из 252) и дали худшее качество на held-out наборе; см. §4 в `docs/labeling.md`.
Резервный режим `--labels-csv ""` берёт метку из суффикса `_good`/`_bad`.
2. **Склейка дублей.** В датасете 544 файла, но всего 252 уникальных снимка:
один и тот же кадр сохранён многократно под разными именами. Без склейки
модель заучивала бы конкретные снимки как разные примеры.
@ -54,8 +57,8 @@ from torch.utils.data import WeightedRandomSampler
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from src.dxa.dataset import DXADataset, make_datasets
from src.dxa.labels import QUALITY_BAD, format_summary
from src.dxa.dataset import DXADataset, build_records, make_datasets
from src.dxa.labels import QUALITY_BAD, export_split, format_summary, load_split, split_by_studies
from src.dxa.model import create_model, per_region_metrics, select_threshold
from src.dxa.preprocess import PreprocessConfig
@ -82,6 +85,27 @@ def set_seed(seed: int) -> None:
torch.cuda.manual_seed_all(seed)
def resolve_labels_csv(value: Optional[str]) -> Optional[Path]:
"""
Путь к разметке на уровне снимков; None означает «метки из имён файлов».
Отсутствие файла не ошибка — режим по именам файлов остаётся доступным,
например в контейнере, куда каталог `labels/` не копируется. Но это
заслуживает предупреждения: иначе метрики молча изменятся вместе с
источником меток.
"""
if not value:
return None
path = Path(value)
if not path.is_file():
logger.warning(
"Labels file %s not found; falling back to filename markers. Build it with `./run.sh label`",
path,
)
return None
return path
def make_loader(
dataset: DXADataset,
batch_size: int,
@ -176,6 +200,11 @@ def train(args: argparse.Namespace) -> Dict:
output_dir.mkdir(parents=True, exist_ok=True)
preprocess = PreprocessConfig(norm=args.norm, imagenet_norm=not args.no_imagenet_norm)
labels_csv = resolve_labels_csv(args.labels_csv)
val_studies = load_split(args.split_file) if args.split_file else None
logger.info("Labels source: %s", labels_csv or "filename markers")
if val_studies is not None:
logger.info("Fixed split from %s: %d studies", args.split_file, len(val_studies))
logger.info("Loading dataset from %s", args.data_root)
train_ds, val_ds, preprocess = make_datasets(
data_root=args.data_root,
@ -184,6 +213,8 @@ def train(args: argparse.Namespace) -> Dict:
val_fraction=args.val_fraction,
seed=args.seed,
preprocess=preprocess,
labels_csv=labels_csv,
val_studies=val_studies,
)
if len(train_ds) == 0 or len(val_ds) == 0:
@ -288,6 +319,10 @@ def train(args: argparse.Namespace) -> Dict:
selection_score=score,
epoch=epoch,
data_root=str(args.data_root),
# Источник меток и разбиение пишем в чекпоинт: иначе по файлу
# нельзя понять, на какой разметке он обучен.
labels_csv=str(labels_csv) if labels_csv else None,
split_file=args.split_file,
)
logger.info(" -> saved best checkpoint (smoothed auc %.4f, f1 %.4f, thr %.3f)",
score, best_f1, val_metrics["threshold_prob"])
@ -320,6 +355,8 @@ def train(args: argparse.Namespace) -> Dict:
"backbone": args.backbone,
"device": device,
"preprocess": preprocess.to_dict(),
"labels_csv": str(labels_csv) if labels_csv else None,
"split_file": args.split_file,
"seed": args.seed,
"balance": args.balance,
"pos_weight": pos_weight,
@ -373,6 +410,7 @@ def _write_markdown_report(path: Path, report: Dict) -> None:
"",
f"- Backbone: `{report['backbone']}`",
f"- Устройство: `{report['device']}`",
f"- Источник меток: `{report.get('labels_csv') or 'имена файлов (_good/_bad)'}`",
f"- Seed: {report['seed']}, балансировка классов: `{report['balance']}`",
f"- Размер выборок: train {report['train_size']} снимков / {report['train_studies']} исследований, "
f"val {report['val_size']} снимков / {report['val_studies']} исследований (разбиение по исследованиям)",
@ -446,6 +484,12 @@ def build_parser() -> argparse.ArgumentParser:
help="Каталог датасета (dataset_hack, НД_для_обучения или каталог с DICOM)")
data.add_argument("--annotation-path", default="dataset_hack/НД_для_обучения/разметка.xlsx",
help="Excel-разметка; применяется только для отчёта о расхождениях")
data.add_argument("--labels-csv", default="labels/labels_images.csv",
help="Разметка на уровне снимков из `./run.sh label`. Пустая строка — "
"брать метки из имён файлов (устаревший режим)")
data.add_argument("--split-file", default=None,
help="Файл зафиксированного разбиения (`--export-split`). Нужен, чтобы "
"сравнивать варианты разметки на одном held-out наборе")
data.add_argument("--input-size", type=int, default=224)
data.add_argument("--val-fraction", type=float, default=0.2,
help="Доля изображений в валидации (разбиение по исследованиям)")
@ -493,6 +537,9 @@ def build_parser() -> argparse.ArgumentParser:
parser.add_argument("--dry-run", action="store_true",
help="Только проверить разбор данных и разбиение, без обучения")
parser.add_argument("--export-split", default=None,
help="Записать разбиение по текущим данным и меткам в файл и выйти. "
"Файл затем передаётся в --split-file для сравнения вариантов")
return parser
@ -500,8 +547,26 @@ def main(argv: Optional[List[str]] = None) -> int:
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
args = build_parser().parse_args(argv)
if args.export_split:
set_seed(args.seed)
labels_csv = resolve_labels_csv(args.labels_csv)
records = build_records(args.data_root, args.annotation_path, labels_csv=labels_csv)
path = export_split(
records, args.export_split, val_fraction=args.val_fraction, seed=args.seed
)
_, val_records = split_by_studies(records, load_split(path))
print(f"Labels source: {labels_csv or 'filename markers (_good/_bad)'}")
print(f"Split written: {path} ({len({r.study for r in val_records})} val studies)")
print("\nVal split:\n" + format_summary(val_records))
return 0
if args.dry_run:
set_seed(args.seed)
labels_csv = resolve_labels_csv(args.labels_csv)
val_studies = load_split(args.split_file) if args.split_file else None
print(f"Labels source: {labels_csv or 'filename markers (_good/_bad)'}")
if val_studies is not None:
print(f"Fixed split: {args.split_file} ({len(val_studies)} val studies)")
train_ds, val_ds, cfg = make_datasets(
data_root=args.data_root,
annotation_path=args.annotation_path,
@ -509,6 +574,8 @@ def main(argv: Optional[List[str]] = None) -> int:
val_fraction=args.val_fraction,
seed=args.seed,
preprocess=PreprocessConfig(norm=args.norm),
labels_csv=labels_csv,
val_studies=val_studies,
)
print(f"Preprocess: {cfg.to_dict()}")
print("\nTrain split:\n" + format_summary(train_ds.records))

178
src/dxa/violations.py Normal file
View File

@ -0,0 +1,178 @@
"""
Единый словарь типов нарушений качества DXA.
Источники кодов — два документа задания:
* экспертная таблица `разметка.xlsx`: укладка, ось, артефакты (позвоночник);
позиционирование/ротация и область интереса (бёдра) — `TABLE_TYPES`;
* condition_doctor.txt: движение/размытие, неполнота анатомии, ошибка разметки
позвонков — `MOTION`, `INCOMPLETE_ANATOMY`, `LABELING_ERROR`.
Зачем отдельный модуль. Раньше словарь был свой в трёх местах: `inference.py`
отдавал `artifact_motion` и `quality_violation_detected`, `main.py` переводил
`roi_error` и `incomplete_view` в коды DICOM SR, а веб-интерфейс держал третью
копию подписей. Ни один из них не знал кодов экспертной таблицы, поэтому тип
нарушения в интерфейсе и в выгрузке не совпадал с тем, что кодирует таблица.
Теперь коды, русские подписи и пояснения заданы здесь, а `canon_type` приводит к
ним устаревшие значения внешних источников.
"""
from __future__ import annotations
from typing import Dict, List, Optional, Tuple
# --- Коды -----------------------------------------------------------------
POSITIONING = "positioning" # некорректная укладка (позвоночник)
AXIS_DEVIATION = "axis_deviation" # отклонение оси, сколиоз
ARTIFACT = "artifact" # артефакты, импланты, наложения
ROTATION = "rotation" # ротация/позиционирование бедра
ROI_INCORRECT = "roi_incorrect" # некорректная область интереса
MOTION = "motion" # движение, размытие
INCOMPLETE_ANATOMY = "incomplete_anatomy" # нужная анатомия видна не полностью
LABELING_ERROR = "labeling_error" # ошибка разметки позвонков
UNSPECIFIED = "unspecified" # нарушение без указания критерия
#: Подмножество, которое кодирует экспертная таблица `разметка.xlsx`.
TABLE_TYPES: Tuple[str, ...] = (
POSITIONING,
AXIS_DEVIATION,
ARTIFACT,
ROTATION,
ROI_INCORRECT,
)
#: Все коды, которые может вернуть решение (таблица + критерии condition_doctor).
VIOLATION_TYPES: Tuple[str, ...] = TABLE_TYPES + (
MOTION,
INCOMPLETE_ANATOMY,
LABELING_ERROR,
UNSPECIFIED,
)
#: Подпись для интерфейса и отчётов.
VIOLATION_LABELS: Dict[str, str] = {
POSITIONING: "Некорректная укладка",
AXIS_DEVIATION: "Отклонение оси",
ARTIFACT: "Артефакты и импланты",
ROTATION: "Ротация / позиционирование",
ROI_INCORRECT: "Некорректная область интереса",
MOTION: "Движение, размытие",
INCOMPLETE_ANATOMY: "Анатомия видна не полностью",
LABELING_ERROR: "Ошибка разметки",
UNSPECIFIED: "Нарушение без уточнения",
}
#: Пояснение, чем именно нарушение плохо для дальнейшего анализа.
VIOLATION_NOTES: Dict[str, str] = {
POSITIONING: "Укладка не по стандарту: на нижнем уровне не видны верхние края "
"подвздошных костей или верхний уровень не соответствует половине тела Th12.",
AXIS_DEVIATION: "Ось позвоночника отклонена (сколиоз, перекос): измерение плотности "
"смещается относительно тел позвонков.",
ARTIFACT: "Посторонние объекты или дефект изображения в зоне интереса: импланты, "
"фиксаторы, металл, контраст, наложения, локальные затемнения.",
ROTATION: "Ротация конечности не соответствует норме (≈15–20° внутрь): малый вертел "
"виден слишком хорошо, шейка бедра выглядит укороченной.",
ROI_INCORRECT: "Границы области интереса не совпадают с анатомией: захвачена соседняя "
"кость или мягкие ткани, часть кости пропущена.",
MOTION: "Размытие или раздвоение контуров: устойчивую границу кости провести нельзя.",
INCOMPLETE_ANATOMY: "Нужная область видна не полностью: шейка бедра, проксимальный отдел "
"или позвонки обрезаны либо закрыты.",
LABELING_ERROR: "Метки уровней не соответствуют анатомии: пропущенный позвонок, двойная "
"метка, смещение на соседний уровень.",
UNSPECIFIED: "Нарушение зафиксировано, но источник не указывает конкретный критерий.",
}
#: Область, к которой относится код (для фильтрации в интерфейсе).
VIOLATION_SCOPE: Dict[str, str] = {
POSITIONING: "spine",
AXIS_DEVIATION: "spine",
ARTIFACT: "any",
ROTATION: "hip",
ROI_INCORRECT: "any",
MOTION: "any",
INCOMPLETE_ANATOMY: "any",
LABELING_ERROR: "spine",
UNSPECIFIED: "any",
}
#: Условные локальные коды для текстового отчёта DICOM SR. Полноценного
#: справочника SNOMED/DICOM для контролёра качества DXA в наборе нет, поэтому
#: коды условные; рядом всегда идёт текстовая формулировка.
VIOLATION_SR_CODES: Dict[str, Tuple[str, str]] = {
POSITIONING: ("123456", "Positioning deviation"),
MOTION: ("234567", "Motion artifact"),
ARTIFACT: ("234568", "Foreign object artifact"),
LABELING_ERROR: ("345678", "Labeling error"),
INCOMPLETE_ANATOMY: ("456789", "Incomplete anatomy"),
ROI_INCORRECT: ("567890", "Region of interest mismatch"),
ROTATION: ("678901", "Rotational misalignment"),
AXIS_DEVIATION: ("678902", "Axis deviation"),
UNSPECIFIED: ("999001", "Image quality violation"),
}
#: Код отчёта для пригодного снимка (нарушения нет).
ACCEPTABLE_SR_CODE: Tuple[str, str] = ("113001", "DXA image quality acceptable")
#: Значения внешних источников, приведённые к канону.
LEGACY_ALIASES: Dict[str, str] = {
"artifact_motion": MOTION,
"artifact_other": ARTIFACT,
"quality_violation_detected": UNSPECIFIED,
"roi_error": ROI_INCORRECT,
"position_error": POSITIONING,
"incomplete_view": INCOMPLETE_ANATOMY,
"labeling_error": LABELING_ERROR,
"correct": "",
"none": "",
"": "",
}
def canon_type(value: Optional[str]) -> str:
"""Привести значение типа нарушения к каноническому коду (или пустой строке)."""
if value is None:
return ""
key = str(value).strip().lower()
if key in LEGACY_ALIASES:
return LEGACY_ALIASES[key]
return key if key in VIOLATION_TYPES else UNSPECIFIED if key else ""
def label(value: Optional[str], empty: str = "Корректно") -> str:
"""Человекочитаемая подпись кода; для пустого значения — `empty`."""
code = canon_type(value)
if not code:
return empty
return VIOLATION_LABELS.get(code, code)
def reason(value: Optional[str]) -> str:
"""Пояснение кода; для неизвестного — пояснение категории `unspecified`."""
code = canon_type(value)
if not code:
return ""
return VIOLATION_NOTES.get(code, VIOLATION_NOTES[UNSPECIFIED])
def sr_code(value: Optional[str]) -> Tuple[str, str, str]:
"""Условный код, формулировка и флаг завершения для отчёта DICOM SR."""
code = canon_type(value)
if not code:
return (*ACCEPTABLE_SR_CODE, "FINAL")
numeric, english = VIOLATION_SR_CODES.get(code, VIOLATION_SR_CODES[UNSPECIFIED])
return numeric, english, "WARNING"
def catalogue() -> List[Dict[str, str]]:
"""Список типов нарушений для API и интерфейса (порядок — как в `VIOLATION_TYPES`)."""
return [
{
"code": code,
"label": VIOLATION_LABELS.get(code, code),
"note": VIOLATION_NOTES.get(code, ""),
"scope": VIOLATION_SCOPE.get(code, "any"),
"source": "expert_table" if code in TABLE_TYPES else (
"condition_doctor" if code != UNSPECIFIED else "system"
),
}
for code in VIOLATION_TYPES
]

View File

@ -22,6 +22,7 @@ FastAPI сервер для оценки качества DXA исследова
"""
import os
import time
from datetime import datetime
from fastapi import FastAPI, File, UploadFile, Query
from fastapi.staticfiles import StaticFiles
from pathlib import Path
@ -42,7 +43,9 @@ from src.dxa.inference import (
mask_png_base64,
predict_from_bytes,
)
from src.dxa.model_card import model_card
from src.dxa.preprocess import load_array_from_bytes
from src.dxa.violations import canon_type, label as violation_label, sr_code
from src.utils.utils import get_device
from src.quality.quality_scorer import convert_to_serializable
@ -66,6 +69,7 @@ MODEL_PATH = os.environ.get("DXA_MODEL_PATH", "models/dxa_model.pth")
dxa_model = None
dxa_preprocess = None
dxa_threshold = 0.0
dxa_metadata: Dict = {}
device = None
@ -75,12 +79,14 @@ def load_model():
Модель загружается глобально при первом запросе и сохраняется в памяти.
Путь к чекпоинту берётся из переменной окружения DXA_MODEL_PATH, чтобы
контейнер не зависел от текущего рабочего каталога.
контейнер не зависел от текущего рабочего каталога. Метаданные чекпоинта
сохраняются: они содержат источник разметки, разбиение и валидационные
метрики, которые отдаёт `/api/v1/health` и `/api/v1/model`.
Returns:
DXAQualityModel: Обученная модель или None при ошибке
"""
global dxa_model, dxa_preprocess, dxa_threshold, device
global dxa_model, dxa_preprocess, dxa_threshold, dxa_metadata, device
if dxa_model is None:
device = get_device()
@ -91,6 +97,7 @@ def load_model():
dxa_model = checkpoint.model
dxa_preprocess = checkpoint.preprocess
dxa_threshold = checkpoint.threshold
dxa_metadata = dict(checkpoint.metadata or {})
print(
f"DXA model loaded successfully (threshold logit={dxa_threshold:.4f}, "
f"input={dxa_preprocess.input_size}, preprocess={dxa_preprocess.to_dict()})"
@ -102,6 +109,41 @@ def load_model():
return dxa_model
def loaded_model_info() -> Dict:
"""
Сведения о фактически загруженном чекпоинте.
Отдаётся отдельно от карточки модели: карточка описывает решение в целом
(в том числе результаты экспериментов), а здесь — что именно сейчас в
памяти, включая путь и объём файла. Расхождение этих данных означает, что
работающий чекпоинт отличается от описанного в карточке.
"""
path = Path(MODEL_PATH)
info: Dict = {
"path": str(path),
"exists": path.is_file(),
"loaded": dxa_model is not None,
"backbone": dxa_metadata.get("backbone"),
"head": dxa_metadata.get("head"),
"epoch": dxa_metadata.get("epoch"),
"threshold_logit": round(float(dxa_threshold), 6),
"threshold_probability": round(
float(torch.sigmoid(torch.tensor(float(dxa_threshold)))), 4
),
"labels_csv": dxa_metadata.get("labels_csv"),
"split_file": dxa_metadata.get("split_file"),
"val_metrics": {
k: v for k, v in (dxa_metadata.get("val_metrics") or {}).items()
if k in ("roc_auc", "pr_auc", "f1", "recall", "precision", "n", "n_pos")
},
}
if path.is_file():
info["checkpoint_mtime"] = datetime.fromtimestamp(path.stat().st_mtime).isoformat(
timespec="seconds"
)
return info
def preprocess_dicom(dcm_bytes: bytes, input_size: int = 224):
"""
Предобработка DICOM изображения для модели.
@ -160,6 +202,11 @@ def prediction_to_result(prediction, ds, filename: Optional[str] = None) -> dict
"""
prob = float(prediction.prob)
is_violation = prediction.quality_class == 1
# Тип нарушения приходит от эвристики по метрикам, а не от модели: модель
# решает только бинарную задачу. Код приводится к единому словарю
# (`src.dxa.violations`), а в ответе это помечено явно, чтобы интерфейс не
# показывал предположение как заключение.
violation_code = canon_type(prediction.violation_type)
result = {
"study_uid": str(getattr(ds, "StudyInstanceUID", "") or ""),
@ -167,7 +214,14 @@ def prediction_to_result(prediction, ds, filename: Optional[str] = None) -> dict
"anatomical_region": prediction.anatomical_region,
"quality_class": prediction.quality_class,
"quality_label": "Есть нарушение качества" if is_violation else "Качественное изображение",
"violation_type": prediction.violation_type,
"violation_type": violation_code,
"violation_type_label": violation_label(violation_code, empty=""),
"violation_type_is_heuristic": bool(violation_code),
"violation_type_note": (
"Тип нарушения определён эвристикой по метрикам снимка, а не моделью: модель "
"решает только бинарную задачу «пригоден / есть нарушение». На этом наборе "
"эвристика чаще всего даёт общую категорию «нарушение без уточнения»."
) if violation_code else "",
"reason": prediction.violation_reason,
"confidence": round(prob, 4),
"confidence_per_class": {
@ -218,15 +272,41 @@ async def root():
@app.get("/api/v1/health")
async def health_check():
"""Health check"""
"""
Статус сервиса и сведения о загруженной модели.
Кроме флага загрузки отдаётся происхождение чекпоинта: на какой разметке он
обучен, какое разбиение использовано и с каким порогом принимается решение.
Без этого по интерфейсу нельзя было понять, какая версия модели работает.
"""
model = load_model()
return {
"status": "ok",
"model_loaded": model is not None,
"device": str(device) if device else "unknown"
"device": str(device) if device else "unknown",
"model": loaded_model_info(),
}
@app.get("/api/v1/model")
async def get_model_card():
"""
Карточка решения: разметка, данные, метрики сравнения, словарь нарушений.
Источник данных — `src/dxa/model_card.py`. Метрики сравнения приведены
средним по пяти seed'ам с 95 % доверительным интервалом, а честность оценки
рабочего чекпоинта оговорена явно.
`load_model()` вызывается здесь намеренно: блок `loaded` берётся из метаданных
чекпоинта, и без загрузки первый запрос к только что поднятому сервису вернул
бы пустые метрики.
"""
load_model()
card = model_card()
card["loaded"] = loaded_model_info()
return convert_to_serializable(card)
@app.post("/api/v1/analyze")
async def analyze_dicom(file: UploadFile = File(...)):
"""Analyze single DICOM file"""
@ -470,18 +550,11 @@ async def analyze_dicom_sr(file: UploadFile = File(...)):
return JSONResponse(status_code=500, content={"error": "Model not loaded"})
quality_report = prediction_to_result(prediction, ds)
snomed_map = {
"artifact_motion": ("Motion artifact", "WARNING"),
"artifact_other": ("Foreign object artifact", "WARNING"),
"rotation": ("Rotational misalignment", "WARNING"),
"roi_error": ("Region of interest mismatch", "WARNING"),
"incomplete_view": ("Incomplete anatomy", "WARNING"),
"position_error": ("Positioning deviation", "WARNING"),
}
label, completion = snomed_map.get(
prediction.violation_type, ("DXA image quality acceptable", "FINAL")
)
quality_report["violation_label"] = label
# Код, формулировка и флаг завершения берутся из единого словаря типов
# нарушений — раньше здесь была третья копия соответствий.
code, english_label, completion = sr_code(quality_report["violation_type"])
quality_report["violation_code"] = code
quality_report["violation_label"] = english_label
quality_report["completion"] = completion
sr_content = generate_dicom_sr_text(
@ -528,26 +601,9 @@ def generate_dicom_sr_text(study_uid: str, image_uid: str, quality_report: Dict)
Returns:
str: Текстовое представление SR отчета
"""
# Соответствие типа нарушения коду отчёта. Коды условные (локальные), так
# как полноценного справочника SNOMED/DICOM для контролёра качества DXA в
# наборе нет; текстовая формулировка приводится рядом.
violation_code_map = {
"": ("113001", "DXA image quality acceptable", "FINAL"),
"position_error": ("123456", "Positioning deviation", "WARNING"),
"artifact_motion": ("234567", "Motion artifact", "WARNING"),
"artifact_other": ("234568", "Other artifact", "WARNING"),
"labeling_error": ("345678", "Labeling error", "WARNING"),
"incomplete_view": ("456789", "Incomplete anatomy", "WARNING"),
"roi_error": ("567890", "Region of interest mismatch", "WARNING"),
"rotation": ("678901", "Rotational misalignment", "WARNING"),
"quality_violation_detected": ("999001", "Image quality violation", "WARNING"),
}
violation_type = (quality_report.get("violation_type") or "").strip()
code, label, completion = violation_code_map.get(
violation_type, ("999999", "Unknown finding", "UNKNOWN")
)
# Коды и формулировки — из единого словаря типов нарушений
# (`src.dxa.violations`); здесь только раскладка отчёта.
code, label, completion = sr_code(quality_report.get("violation_type"))
sr_lines = [
"DICOM Structured Report - DXA Quality Assessment",

View File

@ -43,9 +43,9 @@ const { chromium } = require(resolvePlaywright());
const BASE = process.env.BASE_URL || 'http://127.0.0.1:8123';
const REPO = path.resolve(__dirname, '..', '..');
const FILES = [
path.join(REPO, 'dataset_hack/Для теста/spine.dcm'),
path.join(REPO, 'dataset_hack/Для теста/l_hip.dcm'),
path.join(REPO, 'dataset_hack/Для теста/r_hip.dcm'),
path.join(REPO, 'dataset_hack/Для теста/spine_01.dcm'),
path.join(REPO, 'dataset_hack/Для теста/l_hip_01.dcm'),
path.join(REPO, 'dataset_hack/Для теста/r_hip_01.dcm'),
];
async function panelState(page) {
@ -106,9 +106,67 @@ async function panelState(page) {
total: document.getElementById('totalCount').innerText,
ok: document.getElementById('okCount').innerText,
violation: document.getElementById('violationCount').innerText,
avg: document.getElementById('accuracy').innerText,
avg: document.getElementById('meanConfidence').innerText,
})));
// Подсказка о кликабельности строк: пользователи не догадывались, что по
// строке можно нажать, поэтому она должна быть видна и содержать инструкцию.
const hint = await page.evaluate(() => {
const el = document.getElementById('tableHint');
return {
visible: el && !el.classList.contains('hidden') && el.offsetHeight > 0,
text: el ? el.innerText.replace(/\s+/g, ' ').trim() : '',
openButtons: document.querySelectorAll('#resultsTable button').length,
};
});
console.log('hint:', hint);
if (!hint.visible) throw new Error('подсказка о кликабельности строк не видна');
if (!/нажмите на/i.test(hint.text)) throw new Error('в подсказке нет инструкции нажать на строку');
if (hint.openButtons !== 3) throw new Error(`кнопок «Открыть» в строках: ${hint.openButtons}, ожидалось 3`);
// Кнопка «Открыть» в строке должна открывать панель деталей так же, как клик.
// Проверяем до сценария с кликом по строке, чтобы не мешать его подсчётам.
await page.locator('#resultsTable tr').first().locator('button').click();
await page.waitForSelector('#detailSection:not(.hidden)', { timeout: 15000 });
console.log('кнопка «Открыть» открыла панель деталей: да');
await page.locator('#closeDetail').click();
// Пустой фильтр: подсказка о кликабельности не должна висеть над «Нет результатов».
await page.fill('#searchInput', 'заведомо-нет-такого-файла');
await page.waitForSelector('#emptyResults:not(.hidden)', { timeout: 10000 });
const emptyState = await page.evaluate(() => ({
hintHidden: document.getElementById('tableHint').classList.contains('hidden'),
rows: document.querySelectorAll('#resultsTable tr').length,
}));
console.log('пустой фильтр:', emptyState);
if (!emptyState.hintHidden) throw new Error('подсказка осталась видимой при пустом результате');
await page.fill('#searchInput', '');
await page.waitForFunction(
() => document.querySelectorAll('#resultsTable tr').length === 3,
{ timeout: 10000 }
);
// Панель «О модели»: происхождение модели, метрики с интервалами и словарь
// типов нарушений приходят с /api/v1/model, поэтому проверяем, что раздел
// действительно наполняется, а не остаётся пустым каркасом.
await page.locator('#modelInfoToggle').click();
await page.waitForSelector('#modelMetrics table', { timeout: 15000 });
const modelPanel = await page.evaluate(() => ({
summary: document.getElementById('modelSummary').innerText.replace(/\s+/g, ' ').slice(0, 200),
metricRows: document.querySelectorAll('#modelMetrics tbody tr').length,
violationRows: document.querySelectorAll('#modelViolations tbody tr').length,
limitationItems: document.querySelectorAll('#modelLimitations li').length,
datasetCards: document.querySelectorAll('#modelDataset > div').length,
}));
console.log('model panel:', modelPanel);
if (modelPanel.metricRows < 3) {
throw new Error(`панель «О модели»: ожидалось >=3 строк метрик, получено ${modelPanel.metricRows}`);
}
if (modelPanel.violationRows < 5) {
throw new Error(`панель «О модели»: ожидалось >=5 типов нарушений, получено ${modelPanel.violationRows}`);
}
await page.locator('#modelInfoToggle').click();
const shots = [];
const states = [];
for (const idx of [0, 1, 2]) {

View File

@ -32,7 +32,7 @@ const { chromium } = require(resolvePlaywright());
const BASE = process.env.BASE_URL || 'http://127.0.0.1:8123';
const REPO = path.resolve(__dirname, '..', '..');
const FILE = path.join(REPO, 'dataset_hack/Для теста/spine.dcm');
const FILE = path.join(REPO, 'dataset_hack/Для теста/spine_01.dcm');
(async () => {
const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dxa-offline-'));

View File

@ -28,6 +28,7 @@ needs_assets = pytest.mark.skipif(
# Поля, которые панель деталей читает напрямую из ответа.
DETAIL_TOP_LEVEL = [
"anatomical_region", "quality_class", "quality_label", "violation_type",
"violation_type_label", "violation_type_is_heuristic", "violation_type_note",
"reason", "confidence", "confidence_per_class", "threshold_probability",
"region_confidence", "overall_quality", "severity", "metrics",
"view_quality", "reasons", "metrics_note",
@ -160,6 +161,154 @@ class TestDetailedContract:
for name, data in payloads:
assert "не калиброваны" in data["metrics_note"], name
def test_violation_type_comes_from_canonical_dictionary(self, payloads):
"""
Тип нарушения — код из единого словаря, а подпись — из него же.
Раньше интерфейс держал собственную таблицу подписей, и коды экспертной
таблицы (`artifact`, `roi_incorrect`, `positioning`) показывались как
есть. Тест фиксирует, что сервер отдаёт канонический код и подпись.
"""
from src.dxa.violations import VIOLATION_LABELS, VIOLATION_TYPES
for name, data in payloads:
code = data["violation_type"]
assert code == "" or code in VIOLATION_TYPES, f"{name}: unknown code {code}"
if code:
assert data["violation_type_label"] == VIOLATION_LABELS[code], name
assert data["violation_type_is_heuristic"] is True, name
assert "эвристик" in data["violation_type_note"], name
else:
assert data["violation_type_label"] == "", name
assert data["violation_type_is_heuristic"] is False, name
assert data["violation_type_note"] == "", name
def test_violation_type_matches_quality_class(self, payloads):
for name, data in payloads:
if data["quality_class"] == 0:
assert data["violation_type"] == "", name
else:
assert data["violation_type"] != "", name
@needs_assets
class TestHealthAndModelCard:
"""`/health` и `/api/v1/model` — источник сведений о модели для интерфейса."""
def test_health_reports_model_provenance(self, client):
data = client.get("/api/v1/health").json()
assert data["status"] == "ok" and data["model_loaded"] is True
model = data["model"]
assert model["loaded"] is True and model["exists"] is True
for key in ("path", "backbone", "head", "epoch", "threshold_logit",
"threshold_probability", "labels_csv", "val_metrics"):
assert key in model, f"health.model missing {key}"
# Разметка из экспертной таблицы — то, на чём обучен рабочий чекпоинт.
assert model["labels_csv"] == "labels/labels_images.csv"
assert 0.0 < model["threshold_probability"] < 1.0
assert 0.0 <= model["val_metrics"]["roc_auc"] <= 1.0
def test_model_card_covers_canonical_dictionary(self, client):
from src.dxa.violations import VIOLATION_TYPES
card = client.get("/api/v1/model").json()
codes = [v["code"] for v in card["violations"]]
assert codes == list(VIOLATION_TYPES)
for entry in card["violations"]:
assert entry["label"] and entry["note"]
assert entry["scope"] in ("spine", "hip", "any")
assert entry["source"] in ("expert_table", "condition_doctor", "system")
def test_model_card_metrics_are_intervals(self, client):
card = client.get("/api/v1/model").json()
cmp = card["comparison"]
for metric, variants in cmp["metrics"].items():
for name, triple in variants.items():
assert len(triple) == 3, f"{metric}/{name} is not [value, lo, hi]"
assert triple[1] <= triple[0] <= triple[2], f"{metric}/{name} CI inverted"
for metric, triple in cmp["paired_delta_table_minus_union"].items():
assert triple[1] <= triple[0] <= triple[2], f"delta {metric} CI inverted"
def test_model_card_reports_label_rule(self, client):
card = client.get("/api/v1/model").json()
assert card["model"]["label_rule"] == "table"
assert "экспертной таблицы" in card["labels"]["rule"]
# Правило выбиралось измерением: в карточке должно быть и решение, и основание.
assert card["comparison"]["decision"]
assert card["comparison"]["paired_wins"]
def test_model_card_explains_deployed_metrics(self, client):
card = client.get("/api/v1/model").json()
caveat = card["model"]["caveat"]
# Чекпоинт — обычный прогон, а не лучший из выборки; честная оценка рядом.
assert "seed по умолчанию" in caveat
assert card["model"]["val_roc_auc"] == pytest.approx(
card["comparison"]["metrics"]["roc_auc"]["table"][0], abs=0.02
)
def test_model_card_loads_model_on_cold_start(self, client):
"""
Первый же запрос к сервису должен отдавать полную карточку.
Блок `loaded` берётся из метаданных чекпоинта, поэтому эндпоинт обязан
сам инициировать загрузку модели. Здесь состояние сбрасывается, чтобы
воспроизвести холодный старт независимо от порядка тестов.
"""
import src.main as main
saved = (main.dxa_model, main.dxa_preprocess, main.dxa_threshold, main.dxa_metadata)
try:
main.dxa_model = None
main.dxa_metadata = {}
card = client.get("/api/v1/model").json()
assert card["loaded"]["loaded"] is True
assert card["loaded"]["val_metrics"], "метрики чекпоинта пусты после холодного старта"
assert card["loaded"]["val_metrics"]["roc_auc"] > 0.5
finally:
main.dxa_model, main.dxa_preprocess, main.dxa_threshold, main.dxa_metadata = saved
def test_frontend_reads_only_existing_card_fields(self, client):
"""
Фронтенд не должен обращаться к полям карточки, которых в ней нет.
Такой рассинхрон уже случался: ключи в карточке переименовали, а в
`dxa-app.js` остались старые — на панели «О модели» появлялись плитки с
прочерком вместо чисел.
"""
import re
from pathlib import Path
card = client.get("/api/v1/model").json()
source = Path("src/api/static/js/dxa-app.js").read_text(encoding="utf-8")
used = set(re.findall(r"\bds\.([a-z_]+)", source))
used |= set(re.findall(r"\bdeployed\.([a-z_]+)", source))
missing_dataset = sorted(k for k in used if k not in card["dataset"] and k not in card["model"])
assert not missing_dataset, f"фронтенд читает несуществующие поля: {missing_dataset}"
# Плитки блока «Данные» не должны получать прочерк из-за смены ключей.
tiles = re.findall(r"card\('([^']+)',\s*ds\.([a-z_]+)", source)
assert tiles, "не найдено ни одной плитки блока «Данные»"
for _title, key in tiles:
assert key in card["dataset"], f"плитка «{_title}» читает отсутствующий ключ ds.{key}"
def test_model_card_matches_loaded_checkpoint(self, client):
"""Карточка и фактически загруженный чекпоинт не должны расходиться."""
card = client.get("/api/v1/model").json()
loaded = card["loaded"]
assert loaded["backbone"] == card["model"]["backbone"]
assert loaded["head"] == card["model"]["head"]
assert loaded["labels_csv"] == card["model"]["labels_csv"]
assert loaded["threshold_logit"] == pytest.approx(
card["model"]["threshold_logit"], abs=1e-4
)
def test_model_card_lists_limitations_and_sources(self, client):
card = client.get("/api/v1/model").json()
assert len(card["limitations"]) >= 5
assert all(isinstance(text, str) and text for text in card["limitations"])
assert card["sources"]
assert card["snapshot_date"]
@needs_assets
class TestBasicContract:

453
tests/test_excel_labels.py Normal file
View File

@ -0,0 +1,453 @@
"""
Тесты разметки снимков по экспертной таблице `разметка.xlsx`.
Проверяют правила, от которых зависит итоговая метка: чтение критериев,
трактовку «1 = нарушение», голосование по анатомической области, перенос
оценки на единственный снимок бедра и объединение источников
(экспертная таблица ИЛИ имя файла).
"""
import sys
from pathlib import Path
import pandas as pd
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from src.dxa.excel_labels import ( # noqa: E402
ARTIFACT,
AXIS_DEVIATION,
POSITIONING,
ROTATION,
UNSPECIFIED,
RegionCriteria,
StudyCriteria,
_pick_criteria,
apply_excel_labels,
format_summary,
label_dataset,
load_study_criteria,
resolve_region,
)
from src.dxa.labels import ImageRecord # noqa: E402
from src.dxa.dataset import make_datasets # noqa: E402
from src.dxa.train import resolve_labels_csv # noqa: E402
DATASET_ROOT = Path("dataset_hack")
EXCEL_PATH = DATASET_ROOT / "НД_для_обучения" / "разметка.xlsx"
LABELS_CSV = Path("labels/labels_images.csv")
HAVE_DATASET = (DATASET_ROOT / "НД_для_обучения" / "Исследования").is_dir()
needs_dataset = pytest.mark.skipif(not HAVE_DATASET, reason="dataset_hack is not available")
_WIDTH = 19
def _write_excel(path: Path, studies: list) -> Path:
"""Собрать файл разметки того же вида, что и настоящий: две строки заголовков."""
table = [[None] * _WIDTH for _ in range(2 + len(studies))]
for offset, spec in enumerate(studies):
row = table[2 + offset]
row[1] = spec.get("study")
for i, value in enumerate(spec.get("spine", [])):
row[2 + i] = value
for i, value in enumerate(spec.get("hip_right", [])):
row[5 + i] = value
for i, value in enumerate(spec.get("hip_left", [])):
row[7 + i] = value
for column, key in ((9, "spine_total"), (10, "hip_right_total"), (11, "hip_left_total")):
if key in spec:
row[column] = spec[key]
row[12] = spec.get("comment")
pd.DataFrame(table).to_excel(path, header=False, index=False)
return path
def _criteria(**regions) -> StudyCriteria:
"""StudyCriteria из коротких описаний: criteria=(0, 1), total=1."""
cells = {}
for name, spec in regions.items():
criteria, total = spec
keys = ("positioning", "axis_deviation", "artifact") if name == "spine" else ("rotation", "roi_incorrect")
cells[name] = RegionCriteria(violations=dict(zip(keys, criteria)), total=total)
return StudyCriteria(study="s", regions=cells)
class TestLoadStudyCriteria:
"""Файл разметки содержит заголовки и разреженные ячейки; разбор не должен падать."""
def test_reads_criteria_totals_and_comment(self, tmp_path):
path = _write_excel(tmp_path / "a.xlsx", [
{"study": "S1", "spine": [0, 1, 0], "spine_total": 1,
"hip_right": [1, 0], "hip_right_total": 1, "comment": "сколиоз"},
{"study": "S2", "spine": [0, 0, 0], "spine_total": 0},
])
criteria = load_study_criteria(path)
assert set(criteria) == {"S1", "S2"}
spine = criteria["S1"].for_region("spine")
assert spine.total == 1 and spine.bad
assert spine.violated() == [AXIS_DEVIATION]
assert criteria["S1"].comment == "сколиоз"
assert criteria["S1"].for_region("hip_right").violated() == [ROTATION]
assert not criteria["S2"].for_region("spine").bad
def test_missing_region_is_unknown(self, tmp_path):
path = _write_excel(tmp_path / "a.xlsx", [{"study": "S1", "spine": [0, 0, 0], "spine_total": 0}])
criteria = load_study_criteria(path)
cell = criteria["S1"].for_region("hip_left")
assert cell is not None and not cell.known
def test_row_without_study_is_skipped(self, tmp_path):
path = _write_excel(tmp_path / "a.xlsx", [
{"study": None, "spine": [1, 0, 0], "spine_total": 1},
{"study": "S1", "spine": [0, 0, 0], "spine_total": 0},
{"study": "S2", "spine": [1, 0, 0], "spine_total": 1},
])
assert set(load_study_criteria(path)) == {"S1", "S2"}
class TestRegionCriteria:
"""Трактовка значений: 1 в критерии и в итоге означает нарушение."""
def test_total_without_criteria_is_unspecified(self):
cell = RegionCriteria(violations={"positioning": 0, "axis_deviation": 0, "artifact": 0}, total=1)
assert cell.bad and cell.violated() == [UNSPECIFIED]
def test_criterion_without_total_still_flags(self):
# В датасете есть случай: критерий отмечен, итог оставлен нулевым.
cell = RegionCriteria(violations={"positioning": 0, "axis_deviation": 1, "artifact": 0}, total=0)
assert cell.bad and cell.violated() == [AXIS_DEVIATION]
def test_all_zero_is_good(self):
cell = RegionCriteria(violations={"rotation": 0, "roi_incorrect": 0}, total=0)
assert not cell.bad and cell.violated() == []
assert cell.known
def test_all_missing_is_unknown(self):
cell = RegionCriteria(violations={"rotation": None, "roi_incorrect": None}, total=None)
assert not cell.known and not cell.bad
def test_multiple_criteria_are_all_reported(self):
cell = RegionCriteria(violations={"positioning": 1, "axis_deviation": 1, "artifact": 1}, total=1)
assert cell.violated() == [POSITIONING, AXIS_DEVIATION, ARTIFACT]
class TestResolveRegion:
"""Побайтные дубли иногда названы по-разному; область решается голосованием."""
def test_single_name(self):
assert resolve_region([Path("spine_01.dcm")]) == ("spine", False)
def test_majority_wins(self):
sources = [Path(f"r_hip_{i}.dcm") for i in range(4)] + [Path("r_spine_03.dcm")]
assert resolve_region(sources) == ("hip_right", False)
def test_tie_is_ambiguous(self):
region, ambiguous = resolve_region([Path("spine_03_bad.dcm"), Path("l_hip_01.dcm")])
assert region is None and ambiguous
def test_unknown_names(self):
assert resolve_region([Path("bad.dcm")]) == (None, True)
class TestPickCriteria:
"""Оценка берётся со своей стороны; зеркальный перенос — только для единственного бедра."""
def test_same_side_is_used(self):
study = _criteria(hip_right=((1, 0), 1), hip_left=((0, 0), 0))
cell, mirrored = _pick_criteria(study, "hip_right", hip_image_count=2)
assert cell.violated() == [ROTATION] and not mirrored
def test_single_hip_borrows_opposite_side(self):
study = _criteria(hip_right=((None, None), None), hip_left=((1, 0), 1))
cell, mirrored = _pick_criteria(study, "hip_right", hip_image_count=1)
assert cell.violated() == [ROTATION] and mirrored
def test_single_hip_without_opposite_stays_unknown(self):
study = _criteria(hip_right=((None, None), None), hip_left=((None, None), None))
cell, mirrored = _pick_criteria(study, "hip_right", hip_image_count=1)
assert not cell.known and not mirrored
def test_two_hips_never_borrow(self):
study = _criteria(hip_right=((None, None), None), hip_left=((1, 0), 1))
cell, mirrored = _pick_criteria(study, "hip_right", hip_image_count=2)
assert cell is not None and not cell.known and not mirrored
def test_spine_never_borrows(self):
study = _criteria(spine=((None, None, None), None), hip_left=((1, 0), 1))
cell, mirrored = _pick_criteria(study, "spine", hip_image_count=0)
assert not cell.known and not mirrored
class TestApplyExcelLabels:
"""Подстановка построенной разметки в записи датасета."""
def _csv(self, tmp_path, rows):
path = tmp_path / "labels.csv"
path.write_text(
"path_to_image,quality_class,anatomical_region\n"
+ "".join(f"{p},{q},{r}\n" for p, q, r in rows),
encoding="utf-8",
)
return path
def _record(self, path, region=None, label=0, marker=None):
return ImageRecord(path=Path(path), study="S1", region=region, label=label, marker=marker)
def test_replaces_labels_and_regions(self, tmp_path):
csv = self._csv(tmp_path, [("/s/a.dcm", 1, "hip_right")])
records = [self._record("/s/a.dcm", region=None, label=0)]
out = apply_excel_labels(records, csv)
assert out[0].label == 1
assert out[0].region == "hip_right"
def test_keeps_filename_label_when_image_absent(self, tmp_path):
csv = self._csv(tmp_path, [("/s/a.dcm", 1, "spine")])
records = [
self._record("/s/a.dcm", region="spine"),
self._record("/s/b.dcm", region="spine", label=1, marker="bad"),
]
out = apply_excel_labels(records, csv)
assert [r.label for r in out] == [1, 1]
# запись, которой нет в CSV, сохраняет метку из имени файла
assert out[1].path == Path("/s/b.dcm")
def test_other_dataset_raises(self, tmp_path):
csv = self._csv(tmp_path, [("/other/a.dcm", 1, "spine")])
with pytest.raises(ValueError, match="None of the"):
apply_excel_labels([self._record("/s/a.dcm")], csv)
def test_missing_columns_raise(self, tmp_path):
path = tmp_path / "bad.csv"
path.write_text("file,label\n/s/a.dcm,1\n", encoding="utf-8")
with pytest.raises(ValueError, match="lacks columns"):
apply_excel_labels([self._record("/s/a.dcm")], path)
def test_empty_region_becomes_none(self, tmp_path):
csv = self._csv(tmp_path, [("/s/a.dcm", 1, "")])
out = apply_excel_labels([self._record("/s/a.dcm", region="spine")], csv)
assert out[0].region is None
class TestResolveLabelsCsv:
"""Отсутствующий файл разметки не должен молча менять источник меток."""
def test_empty_value_disables(self):
assert resolve_labels_csv("") is None
assert resolve_labels_csv(None) is None
def test_missing_file_falls_back_with_warning(self, tmp_path, caplog):
with caplog.at_level("WARNING"):
assert resolve_labels_csv(str(tmp_path / "nope.csv")) is None
assert "not found" in caplog.text
def test_existing_file_is_returned(self, tmp_path):
path = tmp_path / "labels.csv"
path.write_text("path_to_image,quality_class\n/s/a.dcm,1\n", encoding="utf-8")
assert resolve_labels_csv(str(path)) == path
@needs_dataset
class TestLabelRules:
"""Правило метки: объединение источников, только таблица или чистый эталон."""
@pytest.fixture(scope="class")
def union(self):
return label_dataset(DATASET_ROOT, EXCEL_PATH, label_rule="union")
@pytest.fixture(scope="class")
def table(self):
return label_dataset(DATASET_ROOT, EXCEL_PATH, label_rule="table")
@pytest.fixture(scope="class")
def expert(self):
return label_dataset(DATASET_ROOT, EXCEL_PATH, label_rule="expert")
def test_union_uses_filename_evidence(self, union, table):
assert sum(1 for l in union if l.quality == 1) == 92
assert sum(1 for l in table if l.quality == 1) == 77
def test_table_rule_ignores_filename_marker(self, table):
# Снимок, помеченный `_bad`, но признанный экспертом качественным,
# в режиме «только таблица» остаётся качественным.
mismatched = [l for l in table if l.quality_excel == 0 and l.quality_filename == 1]
assert mismatched, "в наборе должны быть расхождения источников"
assert all(l.quality == 0 for l in mismatched)
def test_table_rule_keeps_filename_only_as_fallback(self, table):
fallback = [l for l in table if l.used_filename_fallback]
assert len(fallback) == 3
assert all(l.quality_excel is None for l in fallback)
def test_expert_rule_drops_unscored_images(self, expert):
assert len(expert) == 249
assert all(l.quality_excel is not None for l in expert)
assert not any(l.used_filename_fallback for l in expert)
def test_rules_agree_where_labels_agree(self, union, table):
"""Там, где правила дают одну метку, совпадают и типы нарушений."""
by_uid = {l.dicom_image_uid: l for l in union}
agreed = 0
for label in table:
reference = by_uid[label.dicom_image_uid]
if label.quality != reference.quality:
continue
assert label.violations == reference.violations
agreed += 1
# Расходятся ровно 15 снимков (см. следующий тест), остальные совпадают.
assert agreed == 252 - 15
def test_filename_only_violations_become_unspecified(self, union, table):
"""
Снимок, который эксперт считает качественным, а имя файла — нарушением:
в режиме объединения он получает `unspecified`, в режиме таблицы — ничего.
"""
by_uid = {l.dicom_image_uid: l for l in union}
disputed = [l for l in table if l.quality_excel == 0 and l.quality_filename == 1]
assert len(disputed) == 15
for label in disputed:
assert label.quality == 0 and label.violations == ()
assert by_uid[label.dicom_image_uid].quality == 1
assert by_uid[label.dicom_image_uid].violations == (UNSPECIFIED,)
def test_unknown_rule_is_rejected(self):
with pytest.raises(ValueError, match="label_rule"):
label_dataset(DATASET_ROOT, EXCEL_PATH, label_rule="nonsense")
def test_rule_is_recorded_in_output(self, union, table, expert):
assert {l.label_rule for l in union} == {"union"}
assert {l.label_rule for l in table} == {"table"}
assert {l.label_rule for l in expert} == {"expert"}
class TestParseVariants:
"""Разбор описания вариантов для сравнения правил разметки."""
def test_default(self):
from src.dxa.compare_labels import DEFAULT_VARIANTS, parse_variants
assert parse_variants(None) == DEFAULT_VARIANTS
def test_custom(self):
from src.dxa.compare_labels import parse_variants
assert parse_variants("a=/tmp/a.csv, b=") == {"a": "/tmp/a.csv", "b": ""}
def test_single_variant_is_rejected(self):
from src.dxa.compare_labels import parse_variants
with pytest.raises(ValueError, match="минимум два"):
parse_variants("only=/tmp/a.csv")
@needs_dataset
class TestLabelDataset:
"""Сквозная проверка на реальном датасете: инварианты разметки."""
@pytest.fixture(scope="class")
def labels(self):
return label_dataset(DATASET_ROOT, EXCEL_PATH)
def test_covers_every_unique_image(self, labels):
assert len(labels) == 252
def test_region_appears_once_per_study(self, labels):
seen = {}
for label in labels:
if label.region is None:
continue
seen.setdefault((label.record.study, label.region), 0)
seen[(label.record.study, label.region)] += 1
assert all(count == 1 for count in seen.values())
assert len(seen) == 251
def test_union_rule_holds(self):
"""Арифметика объединения источников (правило `union`, не по умолчанию)."""
labels = label_dataset(DATASET_ROOT, EXCEL_PATH, label_rule="union")
excel = sum(1 for l in labels if l.quality_excel == 1)
filename = sum(1 for l in labels if l.quality_filename == 1)
both = sum(1 for l in labels if l.quality_excel == 1 and l.quality_filename == 1)
assert sum(1 for l in labels if l.quality == 1) == excel + filename - both
assert (excel, filename, both) == (74, 37, 19)
assert sum(1 for l in labels if l.quality == 1) == 92
def test_default_rule_is_table(self, labels):
"""По умолчанию в метке участвует только экспертная таблица."""
assert {l.label_rule for l in labels} == {"table"}
assert sum(1 for l in labels if l.quality == 1) == 77
def test_violations_match_excel_criteria(self, labels):
criteria = load_study_criteria(EXCEL_PATH)
checked = 0
for label in labels:
if label.region is None or label.quality_excel != 1:
continue
cell = criteria[label.record.study].for_region(label.region)
if cell is None or not cell.known:
continue
assert label.violations == tuple(cell.violated())
checked += 1
assert checked > 60
def test_good_images_have_no_violations(self, labels):
assert all(not l.violations for l in labels if l.quality == 0)
def test_flagged_laterality_mirroring(self, labels):
flagged = [l for l in labels if l.laterality_mirrored]
assert len(flagged) == 6
assert all(l.region in ("hip_left", "hip_right") for l in flagged)
assert all(l.quality_excel is not None for l in flagged)
def test_single_ambiguous_region_is_labelled_from_filename(self, labels):
ambiguous = [l for l in labels if l.region_ambiguous]
assert len(ambiguous) == 1
label = ambiguous[0]
assert label.region is None
assert label.quality == 1 and label.violations == (UNSPECIFIED,)
def test_uids_are_populated(self, labels):
assert all(l.dicom_image_uid and l.dicom_study_uid for l in labels)
assert len({l.dicom_image_uid for l in labels}) == 252
assert len({l.dicom_study_uid for l in labels}) == 100
def test_summary_mentions_regions(self, labels):
text = format_summary(labels)
for region in ("spine", "hip_right", "hip_left"):
assert region in text
assert "ALL" in text
@needs_dataset
class TestTrainingWiring:
"""Обучение должно брать метки из построенного CSV, сохраняя разбиение по исследованиям."""
def test_datasets_use_csv_labels(self):
train_ds, val_ds, _ = make_datasets(
DATASET_ROOT, EXCEL_PATH, labels_csv=LABELS_CSV, seed=42
)
records = list(train_ds.records) + list(val_ds.records)
assert len(records) == 252
# Официальная разметка — правило `table`: 74 нарушения эксперта плюс 3 снимка,
# для которых таблица область не оценивала.
assert sum(r.label for r in records) == 77
def test_split_still_has_no_study_leak(self):
train_ds, val_ds, _ = make_datasets(
DATASET_ROOT, EXCEL_PATH, labels_csv=LABELS_CSV, seed=42
)
overlap = {r.study for r in train_ds.records} & {r.study for r in val_ds.records}
assert overlap == set()
def test_fallback_to_filename_labels(self):
train_ds, val_ds, _ = make_datasets(DATASET_ROOT, EXCEL_PATH, labels_csv=None, seed=42)
records = list(train_ds.records) + list(val_ds.records)
assert sum(r.label for r in records) == 37
def test_region_head_sees_resolved_region(self):
# Область одного конфликтного дубля решается голосованием имён и приходит из CSV.
train_ds, val_ds, _ = make_datasets(
DATASET_ROOT, EXCEL_PATH, labels_csv=LABELS_CSV, seed=42
)
regions = {r.region for r in list(train_ds.records) + list(val_ds.records)}
assert regions == {"spine", "hip_right", "hip_left", None}

View File

@ -4,6 +4,7 @@
Проверяют правила, от которых зависит обучение: метка из имени файла,
склейка побайтных дублей и разбиение по исследованиям без утечки.
"""
import json
import sys
from pathlib import Path
@ -15,11 +16,14 @@ from src.dxa.labels import ( # noqa: E402
QUALITY_BAD,
QUALITY_GOOD,
ImageRecord,
export_split,
format_summary,
label_summary,
load_split,
marker_from_filename,
region_from_filename,
scan_dataset,
split_by_studies,
stratified_group_split,
)
@ -137,6 +141,52 @@ class TestLabelSummary:
assert "Unique images: 2" in text
class TestFixedSplit:
"""Фиксированное разбиение: один held-out набор для сравнения вариантов меток."""
def _records(self, studies):
return [
ImageRecord(path=Path(f"{study}/img.dcm"), study=study, region="spine", label=label)
for study, label in studies
]
def test_export_and_load_roundtrip(self, tmp_path):
records = self._records([(f"s{i}", i % 2) for i in range(10)])
path = export_split(records, tmp_path / "split.json", val_fraction=0.3, seed=1)
payload = json.loads(path.read_text())
assert payload["val_fraction"] == 0.3 and payload["seed"] == 1
assert payload["val_images"] == len(payload["val_studies"])
val_studies = load_split(path)
assert val_studies == set(payload["val_studies"])
assert val_studies <= {r.study for r in records}
def test_split_by_studies_matches_contract(self):
records = self._records([(f"s{i}", i % 2) for i in range(10)])
train, val = split_by_studies(records, ["s0", "s1", "s2"])
assert {r.study for r in val} == {"s0", "s1", "s2"}
assert {r.study for r in train} == {f"s{i}" for i in range(3, 10)}
assert len(train) + len(val) == len(records)
def test_split_by_studies_rejects_empty_side(self):
records = self._records([("s0", 0), ("s1", 1)])
with pytest.raises(ValueError, match="degenerated"):
split_by_studies(records, ["s0", "s1"])
def test_load_split_rejects_empty_file(self, tmp_path):
path = tmp_path / "bad.json"
path.write_text(json.dumps({"val_studies": []}))
with pytest.raises(ValueError, match="val_studies"):
load_split(path)
def test_exported_split_is_stable_for_same_inputs(self, tmp_path):
records = self._records([(f"s{i}", i % 2) for i in range(10)])
first = load_split(export_split(records, tmp_path / "a.json", seed=7))
second = load_split(export_split(records, tmp_path / "b.json", seed=7))
assert first == second
@needs_dataset
class TestRealDataset:
"""Проверки на реальном датасете: инварианты, влияющие на обучение."""

162
tests/test_rename_files.py Normal file
View File

@ -0,0 +1,162 @@
"""
Тесты приведения имён DICOM к единому виду.
Проверяют разбор имён, поиск свободного номера при конфликте, отказ от
переименования файлов без распознанной области и полный цикл
«применить → откатить» на временных файлах.
"""
import csv
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from src.dxa.rename_files import ( # noqa: E402
apply_renames,
canonical_stem,
parse_stem,
plan_renames,
rollback,
write_mapping,
)
class TestParseStem:
@pytest.mark.parametrize("stem, expected", [
("spine_1", ("spine", 1, None)),
("spine_03_bad", ("spine", 3, "bad")),
("l_hip_2", ("hip_left", 2, None)),
("r_hip_01_good", ("hip_right", 1, "good")),
("Spine", ("spine", 1, None)),
("r_hop_02", ("hip_right", 2, None)),
("r_hip03", ("hip_right", 3, None)),
("r_spine_03", ("spine", 3, None)),
("spine-1", ("spine", 1, None)),
])
def test_recognised_names(self, stem, expected):
assert parse_stem(stem) == expected
@pytest.mark.parametrize("stem", ["bad", "good", "series_001", "A2507431060 DXA"])
def test_names_without_region_are_rejected(self, stem):
assert parse_stem(stem) is None
class TestCanonicalStem:
@pytest.mark.parametrize("stem, expected", [
("spine_1", "spine_01"),
("spine_01", "spine_01"),
("Spine_01", "spine_01"),
("spine-1", "spine_01"),
("spine", "spine_01"),
("r_hop_02", "r_hip_02"),
("r_hip03", "r_hip_03"),
("r_spine_03", "spine_03"),
("l_hip_5_good", "l_hip_05_good"),
("r_hip_1_bad", "r_hip_01_bad"),
])
def test_normalisation(self, stem, expected):
assert canonical_stem(stem) == expected
def test_region_is_never_invented(self):
assert canonical_stem("bad") is None
def test_label_is_preserved_not_added(self):
assert canonical_stem("spine_2") == "spine_02"
assert canonical_stem("spine_2_good") == "spine_02_good"
class TestPlanRenames:
def _files(self, tmp_path, names):
paths = []
for name in names:
path = tmp_path / name
path.write_bytes(b"0")
paths.append(path)
return paths
def test_canonical_names_need_no_change(self, tmp_path):
plan = plan_renames(self._files(tmp_path, ["spine_01.dcm", "l_hip_02_good.dcm"]))
assert plan == []
def test_number_width_is_fixed(self, tmp_path):
plan = plan_renames(self._files(tmp_path, ["spine_1.dcm"]))
assert [(p.source.name, p.target.name) for p in plan] == [("spine_1.dcm", "spine_01.dcm")]
def test_collision_bumps_to_free_number(self, tmp_path):
# Оба имени канонизируются в spine_01; второе должно получить свободный номер.
plan = plan_renames(self._files(tmp_path, ["spine_1.dcm", "spine_01.dcm"]))
targets = sorted(p.target.name for p in plan)
assert targets == ["spine_02.dcm"]
assert {p.source.name for p in plan} == {"spine_1.dcm"}
def test_case_only_rename_is_planned(self, tmp_path):
plan = plan_renames(self._files(tmp_path, ["Spine_01.dcm"]))
assert [(p.source.name, p.target.name) for p in plan] == [("Spine_01.dcm", "spine_01.dcm")]
def test_unrecognised_file_is_skipped(self, tmp_path):
plan = plan_renames(self._files(tmp_path, ["bad.dcm", "spine_1.dcm"]))
assert [p.source.name for p in plan] == ["spine_1.dcm"]
def test_targets_are_unique(self, tmp_path):
names = ["spine_1.dcm", "spine-1.dcm", "l_hip_1.dcm", "r_hop_02.dcm", "r_hip03.dcm"]
plan = plan_renames(self._files(tmp_path, names))
targets = [p.target.name for p in plan]
assert len(targets) == len(set(targets))
def test_plan_does_not_overwrite_a_file_that_stays(self, tmp_path):
# spine_02 канонично и остаётся на месте; spine_2 должен уйти на другой номер.
plan = plan_renames(self._files(tmp_path, ["spine_02.dcm", "spine_2.dcm"]))
assert {p.source.name for p in plan} <= {"spine_2.dcm"}
if plan:
assert plan[0].target.name != "spine_02.dcm"
class TestApplyAndRollback:
def _setup(self, tmp_path, names):
paths = []
for name in names:
path = tmp_path / name
path.write_bytes(name.encode())
paths.append(path)
return paths
def test_apply_then_rollback_restores_names(self, tmp_path):
names = ["spine_1.dcm", "l_hip-2.dcm", "Spine_03.dcm", "bad.dcm"]
paths = self._setup(tmp_path, names)
plan = plan_renames(paths)
mapping = write_mapping(plan, tmp_path / "map.csv")
assert apply_renames(plan, dry_run=False) == len(plan)
after = sorted(p.name for p in tmp_path.glob("*.dcm"))
assert after == ["bad.dcm", "l_hip_02.dcm", "spine_01.dcm", "spine_03.dcm"]
assert all(p.read_bytes() for p in tmp_path.glob("*.dcm"))
assert rollback(mapping, dry_run=False) == len(plan)
assert sorted(p.name for p in tmp_path.glob("*.dcm")) == sorted(names)
def test_dry_run_changes_nothing(self, tmp_path):
paths = self._setup(tmp_path, ["spine_1.dcm"])
plan = plan_renames(paths)
assert apply_renames(plan, dry_run=True) == 0
assert (tmp_path / "spine_1.dcm").exists()
def test_rollback_dry_run_reports_without_changing(self, tmp_path):
paths = self._setup(tmp_path, ["spine_1.dcm"])
plan = plan_renames(paths)
mapping = write_mapping(plan, tmp_path / "map.csv")
apply_renames(plan, dry_run=False)
# В режиме плана возвращается число файлов, к которым вернулось бы имя.
assert rollback(mapping, dry_run=True) == 1
assert (tmp_path / "spine_01.dcm").exists()
assert not (tmp_path / "spine_1.dcm").exists()
def test_mapping_file_is_readable_and_complete(self, tmp_path):
paths = self._setup(tmp_path, ["spine_1.dcm", "r_hop_02.dcm"])
plan = plan_renames(paths)
mapping = write_mapping(plan, tmp_path / "map.csv")
rows = list(csv.DictReader(mapping.open(encoding="utf-8")))
assert len(rows) == len(plan)
assert {"old_path", "new_path", "old_name", "new_name", "reason"} <= set(rows[0])
assert {r["reason"] for r in rows} == {"ширина номера", "опечатка hop"}

143
tests/test_violations.py Normal file
View File

@ -0,0 +1,143 @@
"""
Тесты единого словаря типов нарушений.
Словарь обслуживает три места сразу: инференс (коды в отчёте), SR-отчёт
(числовые коды) и веб-интерфейс (подписи). Тесты фиксируют, что все коды
описаны, а устаревшие значения внешних источников приводятся к канону.
"""
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from src.dxa import excel_labels # noqa: E402
from src.dxa.violations import ( # noqa: E402
ARTIFACT,
AXIS_DEVIATION,
INCOMPLETE_ANATOMY,
LABELING_ERROR,
LEGACY_ALIASES,
MOTION,
POSITIONING,
ROI_INCORRECT,
ROTATION,
TABLE_TYPES,
UNSPECIFIED,
VIOLATION_LABELS,
VIOLATION_NOTES,
VIOLATION_SCOPE,
VIOLATION_SR_CODES,
VIOLATION_TYPES,
canon_type,
catalogue,
label,
reason,
sr_code,
)
class TestCanonType:
"""Устаревшие значения из внешних источников приводятся к канону."""
@pytest.mark.parametrize("legacy, expected", [
("artifact_motion", MOTION),
("artifact_other", ARTIFACT),
("quality_violation_detected", UNSPECIFIED),
("roi_error", ROI_INCORRECT),
("position_error", POSITIONING),
("incomplete_view", INCOMPLETE_ANATOMY),
])
def test_legacy_aliases(self, legacy, expected):
assert canon_type(legacy) == expected
@pytest.mark.parametrize("value", ["", None, "correct", "none"])
def test_empty_values(self, value):
assert canon_type(value) == ""
def test_canonical_codes_pass_through(self):
for code in VIOLATION_TYPES:
assert canon_type(code) == code
def test_case_and_spaces_are_normalised(self):
assert canon_type(" ROTATION ") == ROTATION
def test_unknown_value_falls_back_to_unspecified(self):
assert canon_type("что_то_новое") == UNSPECIFIED
def test_every_alias_target_is_a_known_code(self):
for target in LEGACY_ALIASES.values():
assert target == "" or target in VIOLATION_TYPES
class TestLabels:
def test_label_for_known_code(self):
assert label(ROTATION) == VIOLATION_LABELS[ROTATION]
def test_label_for_empty_code_uses_default(self):
assert label("") == "Корректно"
assert label("", empty="—") == "—"
def test_label_accepts_legacy_value(self):
assert label("artifact_motion") == VIOLATION_LABELS[MOTION]
def test_reason_for_unknown_code_is_unspecified_note(self):
assert reason("нечто") == VIOLATION_NOTES[UNSPECIFIED]
def test_reason_for_empty_code_is_empty(self):
assert reason("") == ""
class TestSrCode:
def test_acceptable_code_for_good_image(self):
numeric, english, completion = sr_code("")
assert numeric == "113001" and completion == "FINAL"
assert "acceptable" in english
def test_warning_code_for_violation(self):
numeric, english, completion = sr_code(ROTATION)
assert numeric == VIOLATION_SR_CODES[ROTATION][0] and completion == "WARNING"
def test_unknown_code_falls_back_to_unspecified(self):
assert sr_code("нечто") == (*VIOLATION_SR_CODES[UNSPECIFIED], "WARNING")
class TestCatalogue:
def test_every_code_is_described(self):
entries = catalogue()
assert [e["code"] for e in entries] == list(VIOLATION_TYPES)
for entry in entries:
assert entry["label"] and entry["note"]
assert entry["scope"] in ("spine", "hip", "any")
assert entry["source"] in ("expert_table", "condition_doctor", "system")
def test_table_codes_are_marked_as_expert_table(self):
by_code = {e["code"]: e for e in catalogue()}
for code in TABLE_TYPES:
assert by_code[code]["source"] == "expert_table"
assert by_code[UNSPECIFIED]["source"] == "system"
def test_every_code_has_sr_code(self):
for code in VIOLATION_TYPES:
assert code in VIOLATION_SR_CODES
def test_every_code_has_label_and_note(self):
for code in VIOLATION_TYPES:
assert code in VIOLATION_LABELS
assert code in VIOLATION_NOTES
assert code in VIOLATION_SCOPE
class TestAgreementWithAnnotationTable:
"""Словарь таблицы и общий словарь не должны разойтись."""
def test_table_types_match_table_criteria(self):
expected = set(excel_labels.SPINE_CRITERIA) | set(excel_labels.HIP_CRITERIA)
assert set(TABLE_TYPES) == expected
def test_excel_labels_reexports_same_codes(self):
assert excel_labels.UNSPECIFIED == UNSPECIFIED
assert excel_labels.ARTIFACT == ARTIFACT
assert excel_labels.AXIS_DEVIATION == AXIS_DEVIATION
assert set(excel_labels.VIOLATION_TYPES) >= set(TABLE_TYPES) | {UNSPECIFIED}

431
tools/build_deck.py Normal file
View File

@ -0,0 +1,431 @@
#!/usr/bin/env python3
"""
Сборка презентации решения из шаблона организаторов `docs/lct_temppalte.pptx`.
Шаблон содержит готовые макеты: слайды 7–11 обязательны (титул, описание команды,
карточки участников, история команды, «коротко о решении»), слайды 12–29 —
оформление для содержательной части. Скрипт заполняет обязательные слайды и
подставляет наш контент в подходящие макеты, лишние слайды (вводные инструкции
организаторов и библиотеки иконок) удаляются.
Запуск:
python tools/build_deck.py # docs/deck.pptx
python tools/build_deck.py --out other.pptx
Про команду: данных о команде у скрипта нет, поэтому поля ФИО, контактов,
города и названия команды заполняются заглушками вида «{{...}}» — их нужно
заменить в PowerPoint. Всё, что касается задачи и решения, заполнено по фактам
из репозитория (`README.md`, `docs/labeling.md`, `models/compare_rules/`).
"""
from __future__ import annotations
import argparse
import copy
from pathlib import Path
from typing import Dict, Iterable, List, Sequence
from pptx import Presentation
from pptx.util import Emu, Inches
REPO = Path(__file__).resolve().parents[1]
TEMPLATE = REPO / "docs" / "lct_temppalte.pptx"
IMAGES = REPO / "docs" / "img"
#: Слайды шаблона, которые войдут в презентацию, в нужном порядке.
#: Ключ — номер слайда в шаблоне (нумерация с 1), значение — что он несёт.
DECK_ORDER: Sequence[int] = (7, 8, 9, 10, 11, 13, 21, 16, 17, 20, 22, 24, 15, 19, 18, 25)
#: Команда известна из `README.md` (раздел «Команда»). Контакты, город и место
#: работы/учёбы в репозитории не указаны — они остаются заглушками.
TEAM = (
{"name": "Грачев Татьяна", "role": "Капитан"},
{"name": "Грачев Денис", "role": "Разработка"},
)
CONTACT_PLACEHOLDER = ("{{Ник в мессенджере}}", "{{Телефон}}", "{{Место работы/учёбы}}")
#: Подписи, оставленные шаблоном как инструкция. Если после заполнения такая
#: строка осталась — значит, слайд заполнен не полностью.
INSTRUCTION_MARKERS = (
"Опишите", "Расскажите", "Капитан: ФИО", "Имя Фамилия", "__ человек",
"Опишите в чем",
)
def set_lines(text_frame, lines: Sequence[str]) -> None:
"""
Заменить содержимое текстового блока, сохранив оформление абзаца.
Оформление берётся у первого прогона первого абзаца: шаблонные шрифты,
кегли и цвета остаются как в макете.
"""
paragraphs = text_frame.paragraphs
while len(text_frame.paragraphs) > len(lines):
element = text_frame.paragraphs[-1]._p
element.getparent().remove(element)
while len(text_frame.paragraphs) < len(lines):
text_frame._txBody.append(copy.deepcopy(text_frame.paragraphs[-1]._p))
for paragraph, text in zip(text_frame.paragraphs, lines):
runs = paragraph.runs
if not runs:
paragraph.add_run().text = text
continue
runs[0].text = text
for run in runs[1:]:
run._r.getparent().remove(run._r)
def fill_by_placeholder(slide, content: Dict[int, Sequence[str]]) -> None:
"""Заполнить слайд по индексам плейсхолдеров (idx из шаблона)."""
for shape in slide.shapes:
if not shape.has_text_frame:
continue
try:
idx = shape.placeholder_format.idx
except (ValueError, AttributeError):
continue
if idx in content:
set_lines(shape.text_frame, content[idx])
def drop_shape(shape) -> None:
shape._element.getparent().remove(shape._element)
def apply_layout(prs, keep: Sequence[int], order: Sequence[int]) -> None:
"""
Оставить только нужные слайды и расставить их в порядке `order`.
Номера — исходные, из шаблона (с 1). Карта «номер → элемент» снимается до
удаления: иначе после удаления индексы сдвигаются и порядок перепутается.
"""
sld_id_lst = prs.slides._sldIdLst
elements = list(sld_id_lst)
by_original = {index + 1: element for index, element in enumerate(elements)}
assert set(order) <= set(keep) and len(set(order)) == len(order), "порядок и состав расходятся"
for number in sorted((n for n in by_original if n not in keep), reverse=True):
element = by_original[number]
prs.part.drop_rel(element.rId)
sld_id_lst.remove(element)
for element in list(sld_id_lst):
sld_id_lst.remove(element)
for number in order:
sld_id_lst.append(by_original[number])
def slide_by_number(prs, number: int):
return prs.slides[number - 1]
def fill_team_cards(slide, team: Sequence[dict]) -> None:
"""
Заполнить карточки участников и убрать лишние.
Макет рассчитан на пять человек: карточки — это рамки одинаковой ширины,
стоящие слева направо с шагом 2.52 дюйма, а подписи внутри карточки смещены
относительно её рамки. Поэтому карточка определяется по рамке, а её элементы —
по попаданию в горизонтальные границы рамки. Лишние карточки удаляются целиком:
пустая карточка с шаблонной подписью выглядит как незаполненный слайд.
"""
frames = [
shape for shape in slide.shapes
if shape.width is not None
and abs(Emu(shape.width).inches - 2.40) < 0.05
and abs(Emu(shape.height).inches - 5.29) < 0.05
]
frames.sort(key=lambda s: Emu(s.left).inches)
if len(frames) < len(team):
raise ValueError(f"в макете {len(frames)} карточек, а участников {len(team)}")
keep = frames[: len(team)]
drop = frames[len(team):]
def bounds(frame):
left = Emu(frame.left).inches
return left - 0.05, left + 2.45
for frame in drop:
low, high = bounds(frame)
for shape in list(slide.shapes):
if shape.left is None or shape.top is None:
continue
center = Emu(shape.left).inches + Emu(shape.width).inches / 2
if low < center < high:
drop_shape(shape)
for frame, member in zip(keep, team):
low, high = bounds(frame)
for shape in slide.shapes:
if shape.left is None or not shape.has_text_frame:
continue
center = Emu(shape.left).inches + Emu(shape.width).inches / 2
if not (low < center < high):
continue
text = shape.text_frame.text.strip()
if text == "Имя Фамилия":
set_lines(shape.text_frame, [member["name"]])
elif text.startswith("Роль в команде"):
set_lines(shape.text_frame, [member["role"], *CONTACT_PLACEHOLDER])
def add_screenshots(slide) -> None:
"""Заменить три фоторамки макета на снимки интерфейса без искажения пропорций."""
for shape in list(slide.shapes):
try:
if shape.is_placeholder and shape.placeholder_format.type == 18:
drop_shape(shape)
except (ValueError, AttributeError):
continue
placements = [
("ui-results.png", 0.40, 3.60, 7.40), # таблица результатов — широкая
("ui-detail-violation.png", 8.10, 1.10, 4.40), # панель деталей, нарушение
("ui-model-panel.png", 8.10, 4.40, 4.40), # панель «О модели»
]
for name, left, top, width in placements:
slide.shapes.add_picture(
str(IMAGES / name), Inches(left), Inches(top), width=Inches(width)
)
def build(out_path: Path) -> Path:
prs = Presentation(str(TEMPLATE))
# --- Обязательные слайды 7–11 -------------------------------------------------
fill_by_placeholder(slide_by_number(prs, 7), {
0: ["Контроль качества денситометрических исследований"],
12: ["Команда {{НАЗВАНИЕ КОМАНДЫ}} · задача от {{ПОСТАНОВЩИК ЗАДАЧИ}}"],
})
slide8 = slide_by_number(prs, 8)
fill_by_placeholder(slide8, {0: ["Команда {{НАЗВАНИЕ КОМАНДЫ}}"]})
for shape in slide8.shapes:
if not shape.has_text_frame:
continue
text = shape.text_frame.text
if text.startswith("Капитан:"):
set_lines(shape.text_frame, [
f"Капитан: {TEAM[0]['name']}",
f"Кол-во участников: {len(TEAM)} человека",
"Краткое описание:",
"{{город, место работы или учёбы, как собралась команда}}",
])
elif text.startswith("В чем суть вашего решения"):
set_lines(shape.text_frame, [
"Сервис оценивает качество снимка DXA: пригоден ли он для анализа и что именно не так. "
"Работает офлайн, до трёх минут на исследование.",
])
elif text.startswith("Что делает ваше решение"):
set_lines(shape.text_frame, [
"Правило разметки выбрано измерением, а не по вкусу; модель проверена на то, "
"что использует содержимое снимка.",
])
slide9 = slide_by_number(prs, 9)
fill_team_cards(slide9, TEAM)
slide10 = slide_by_number(prs, 10)
fill_by_placeholder(slide10, {
27: ["{{как собрались, участвовали ли вместе в прошлых проектах, интересные факты}}"],
0: ["Команда {{НАЗВАНИЕ}}"],
})
for shape in slide10.shapes:
if not shape.has_text_frame:
continue
text = shape.text_frame.text
if text.startswith("Расскажите о самых интересных"):
set_lines(shape.text_frame, [
"Данных с надёжной разметкой оказалось меньше трети: экспертная оценка сделана "
"на уровне исследования, а разметки областей интереса в DICOM нет. Локальная "
"vision-модель оказалась непригодна — это выяснилось на калибровке.",
])
elif text.startswith("Что вас вдохновило"):
set_lines(shape.text_frame, [
"{{что заинтересовало в задаче}}",
])
slide11 = slide_by_number(prs, 11)
fill_by_placeholder(slide11, {
38: [
"ResNet18 с замороженным backbone и линейной головой; один путь предобработки для обучения и API;",
"порог решения подобран по F1 и хранится в чекпоинте вместе с весами.",
"На фиксированном held-out наборе, пять seed'ов: ROC-AUC 0.6764 [0.6309; 0.7218], F1 0.5676 [0.5270; 0.6082].",
],
42: [
"Помощник для отделения денситометрии: приоритизирует ручной просмотр и снижает долю повторных исследований.",
"Разворачивается локально в контейнере, медицинские изображения не покидают контур.",
"Экономия — за счёт того, что специалист смотрит в первую очередь снимки с замечаниями.",
],
})
# --- Содержательная часть из макетов 12–29 ------------------------------------
fill_by_placeholder(slide_by_number(prs, 13), {
0: ["Задача и результат"],
1: [
"Области: поясничный отдел позвоночника и проксимальный отдел бедренной кости",
"Вход: DICOM без разметки, до трёх изображений в исследовании",
"Выход: XLSX или CSV, одна строка на снимок — регион, класс качества, тип нарушения",
"252 уникальных снимка в 100 исследованиях; 77 снимков с нарушением",
"Офлайн, в контейнере, не дольше трёх минут на исследование",
],
})
fill_by_placeholder(slide_by_number(prs, 21), {
0: ["Данные и разметка"],
21: ["252"], 18: ["уникальных снимка в 100 исследованиях"],
22: ["77"], 23: ["нарушений в обучающей разметке (30.6 %)"],
24: ["3"], 25: ["снимка без экспертной оценки — помечены явно, а не спрятаны"],
})
fill_by_placeholder(slide_by_number(prs, 16), {
0: ["Как построена разметка"],
49: ["01"], 37: ["Экспертная таблица"],
38: ["Оценка сделана на уровне исследования, по критериям: укладка, ось, артефакты, "
"позиционирование, область интереса"],
50: ["02"], 39: ["Перенос на снимок"],
40: ["После склейки дублей каждая область встречается в исследовании один раз — "
"вердикт переносится однозначно"],
51: ["03"], 41: ["Выбор правила"],
42: ["Два правила сравнили на одном разбиении, пять seed'ов, один эталон: "
"0.6764 против 0.6199"],
52: ["04"], 43: ["Тип нарушения"],
44: ["Берётся из структурированных критериев таблицы; комментарии эксперта сохраняем дословно"],
})
fill_by_placeholder(slide_by_number(prs, 17), {
0: ["Три решения, принятых по эксперименту"],
49: ["01"], 37: ["Аугментация отключена"],
38: ["Яркость и положение снимка сами являются признаками качества. "
"Включение роняло AUC с 0.87 до 0.56"],
50: ["02"], 39: ["Порог 0.5 отвергнут"],
40: ["Вероятности насыщаются. Порог подобран по логиту и F1 и хранится в чекпоинте"],
51: ["03"], 41: ["Vision-модель 9B отвергнута"],
42: ["На калибровке вынесла «непригоден» всем 14 снимкам, включая заведомо качественные"],
})
fill_by_placeholder(slide_by_number(prs, 20), {
0: ["Модель использует снимок, а не анатомию"],
14: ["Область почти однозначно определяется шириной кадра, поэтому проверяем, не выучила ли "
"модель просто область. Сравниваем с правилом «позвоночник значит нарушение» на тех же снимках."],
15: ["Модель: внутри областей AUC 0.85–0.93"],
16: ["Правило области: ровно 0.500 внутри области"],
17: ["Общий AUC модели 0.854 против 0.529 у правила"],
18: ["Проверка идёт по всему набору, включая обучающие снимки, — значения смещены вверх"],
19: ["Вывод: содержимое снимка даёт вклад, подмены качества анатомией нет"],
})
fill_by_placeholder(slide_by_number(prs, 22), {
0: ["Результат на held-out наборе"],
21: ["0.6764"], 18: ["ROC-AUC, 95 % интервал 0.6309–0.7218"],
22: ["0.4759"], 23: ["PR-AUC, интервал 0.4141–0.5377"],
24: ["0.5676"], 25: ["F1, интервал 0.5270–0.6082"],
26: ["5 × 2"], 27: ["прогона: пять seed'ов на два правила; снимки валидации в обучении не участвовали"],
})
fill_by_placeholder(slide_by_number(prs, 24), {
0: ["Почему модель простая"],
26: ["Проблема",
"Полный fine-tune на двухстах снимках переобучается: train-метрика уходит в единицу, "
"качество на валидации — к случайному"],
31: ["Решение",
"Замороженный ResNet18 как экстрактор признаков и линейная голова: обучаются тысячи "
"параметров вместо миллионов"],
32: ["Результат",
"Устойчивые метрики на пяти seed'ах и отсутствие подгонки под обучающую выборку"],
})
fill_by_placeholder(slide_by_number(prs, 15), {
0: ["Ограничения"],
15: ["Разметка выведена из оценки исследования: поштучной экспертной оценки снимков нет"],
16: ["252 снимка и 77 нарушений — доверительные интервалы широкие"],
17: ["Эталон — та же экспертная таблица; независимой истины нет"],
18: ["Тип нарушения определяет эвристика, а не обученная модель"],
19: ["Сторона бедра в 7 исследованиях не проверяема: теги латеральности в DICOM пусты"],
})
slide19 = slide_by_number(prs, 19)
fill_by_placeholder(slide19, {
0: ["Как это выглядит"],
14: ["Интерфейс: загрузка DICOM, таблица результатов, панель деталей и панель «О модели» "
"с метриками, словарём нарушений и списком ограничений."],
})
add_screenshots(slide19)
fill_by_placeholder(slide_by_number(prs, 18), {
0: ["Эксплуатация и упаковка"],
49: ["01"], 37: ["Скорость"],
38: ["15 мс на снимок на ускорителе, 20 мс на CPU; запас к бюджету три минуты более 3000×"],
50: ["02"], 39: ["Требования"],
40: ["Минимально достаточно CPU; чекпоинт 43 МБ; образ содержит код и интерфейс"],
51: ["03"], 41: ["API"],
42: ["Анализ файла, детальный отчёт, DICOM SR, пакетная обработка и выгрузка XLSX"],
52: ["04"], 43: ["Надёжность"],
44: ["Ошибка не выбрасывается исключением: строка получает processing_status = Failure"],
53: ["05"], 45: ["Офлайн"],
46: ["Ни внешних сервисов, ни обращений к CDN: ассеты интерфейса лежат локально"],
54: ["06"], 47: ["Тесты"],
48: ["209 автотестов, включая браузерные сценарии и проверку работы без сети"],
})
fill_by_placeholder(slide_by_number(prs, 25), {
0: ["План развития"],
26: ["Поштучная разметка"], 27: ["Разметить снимки специалистом — это снимает главное ограничение"],
28: ["Мультилейбл нарушений"], 29: ["Обучаемый тип нарушения вместо эвристики"],
30: ["Больше данных"], 31: ["500+ исследований, чтобы сузить доверительные интервалы"],
32: ["Калибровка области"], 33: ["Порог определения области под конкретное оборудование"],
34: ["Пилот в клинике"], 35: ["Приоритизация ручного просмотра с обратной связью врача"],
})
# --- Оставляем только нужные слайды и расставляем порядок ---------------------
apply_layout(prs, DECK_ORDER, DECK_ORDER)
out_path.parent.mkdir(parents=True, exist_ok=True)
prs.save(str(out_path))
return out_path
def check(path: Path) -> List[str]:
"""
Проверить результат: не осталось ли инструкций шаблона.
Заглушки для данных команды помечены «{{...}}» — они ожидаемы и замечанием
не считаются, иначе проверка ругалась бы на наши же плейсхолдеры.
"""
prs = Presentation(str(path))
problems: List[str] = []
for index, slide in enumerate(prs.slides, start=1):
for shape in slide.shapes:
if not shape.has_text_frame:
continue
text = shape.text_frame.text
if "{{" in text:
continue
for marker in INSTRUCTION_MARKERS:
if marker in text:
problems.append(f"слайд {index}: осталась инструкция шаблона «{marker}»")
return problems
def main(argv: Iterable[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Сборка презентации решения из шаблона")
parser.add_argument("--out", default=str(REPO / "docs" / "deck.pptx"))
args = parser.parse_args(list(argv) if argv is not None else None)
out = Path(args.out)
build(out)
print(f"собрано: {out} ({out.stat().st_size / 1024 / 1024:.1f} МБ)")
prs = Presentation(str(out))
print(f"слайдов: {len(prs.slides)}")
problems = check(out)
if problems:
print("замечания:")
for problem in problems:
print(" -", problem)
return 0
if __name__ == "__main__":
raise SystemExit(main())