develop - hack_2026
165
QWEN.md
|
|
@ -35,7 +35,13 @@
|
||||||
|
|
||||||
```
|
```
|
||||||
src/dxa/
|
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)
|
├── preprocess.py # DICOM -> CHW-тензор (единый путь для обучения и API)
|
||||||
├── dataset.py # DXADataset, DataLoader
|
├── dataset.py # DXADataset, DataLoader
|
||||||
├── model.py # сеть, метрики, подбор порога, сохранение/загрузка
|
├── model.py # сеть, метрики, подбор порога, сохранение/загрузка
|
||||||
|
|
@ -43,37 +49,47 @@ src/dxa/
|
||||||
└── inference.py # пакетный инференс, определение области, визуализация
|
└── 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 уникальных снимка; без склейки снимок попадал в оба класса |
|
| Склейка побайтных дублей | 544 файла, но 252 уникальных снимка; без склейки снимок попадал в оба класса |
|
||||||
| Разбиение по исследованиям, не по снимкам | Исключение утечки: снимки одного исследования в одной части |
|
| Разбиение по исследованиям, не по снимкам | Исключение утечки: снимки одного исследования в одной части |
|
||||||
| Линейный зонд (замороженный backbone) | Полный fine-tune при ~250 снимках переобучается (val AUC → 0.5) |
|
| Линейный зонд (замороженный backbone) | Полный fine-tune при ~250 снимках переобучается (val AUC → 0.5) |
|
||||||
| Порог по логиту, подбор по F1 | При 15 % нарушений порог 0.5 даёт нулевой recall; вероятности насыщаются |
|
| Порог по логиту, подбор по F1 | При доле нарушений около 30 % порог 0.5 даёт почти нулевой recall; вероятности насыщаются |
|
||||||
| Аугментация выключена по умолчанию | Яркость и положение сами являются признаками качества: AUC 0.87 → 0.56 |
|
| Аугментация выключена по умолчанию | Яркость и положение сами являются признаками качества: AUC 0.87 → 0.56 |
|
||||||
| Область по ширине кадра | Позвоночник 300 px, бедро 280 px; 99/99 для позвоночника |
|
| Область по ширине кадра | Позвоночник 300 px, бедро 280 px; 99/99 для позвоночника |
|
||||||
| Панель деталей показывает измерения, а не вердикты | Эвристики `detailed_assessment` не калиброваны: `motion_detected`/`any_detected` истинны почти всегда, ROI-отступы срабатывают для 227/252 снимков |
|
| Панель деталей показывает измерения, а не вердикты | Эвристики `detailed_assessment` не калиброваны: `motion_detected`/`any_detected` истинны почти всегда, ROI-отступы срабатывают для 227/252 снимков |
|
||||||
|
|
||||||
### Проверка вклада модели
|
### Проверка вклада модели
|
||||||
|
|
||||||
Не является ли модель просто детектором анатомии (в позвоночнике ~29 % нарушений
|
Не является ли модель просто детектором анатомии (нарушений около трети и в
|
||||||
против ~4–5 % у бёдер, а область почти однозначно определяется по ширине кадра):
|
позвоночнике, и у бёдер, а область почти однозначно определяется по ширине кадра):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python -m src.dxa.discriminator --model-path models/dxa_model.pth
|
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 |
|
| Предиктор | Общий AUC | spine | hip_right | hip_left |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| Модель | 0.822 | 0.838 | 0.895 | 0.948 |
|
| Модель | 0.854 | 0.928 | 0.861 | 0.853 |
|
||||||
| Правило «позвоночник = нарушение» | 0.724 | 0.500 | 0.500 | 0.500 |
|
| Правило «позвоночник = нарушение» | 0.529 | 0.500 | 0.500 | 0.500 |
|
||||||
|
|
||||||
Модель использует содержимое снимка: внутри областей она даёт 0.84–0.95.
|
Модель использует содержимое снимка: внутри областей она даёт 0.85–0.93.
|
||||||
Правило по области внутри области всегда 0.50 (подсказки нет).
|
Правило по области внутри области всегда 0.50 (подсказки нет). Честная оценка на
|
||||||
|
held-out — в `docs/labeling.md` §8: ROC-AUC 0.6764 [0.6309, 0.7218] по пяти seed'ам.
|
||||||
|
|
||||||
## Данные (`dataset_hack/`)
|
## Данные (`dataset_hack/`)
|
||||||
|
|
||||||
|
|
@ -89,37 +105,75 @@ dataset_hack/
|
||||||
|
|
||||||
Факты, важные для обучения:
|
Факты, важные для обучения:
|
||||||
|
|
||||||
- 544 файла на диске, но **252 уникальных снимка** (по пиксельному содержимому).
|
- 544 файла на диске, но **252 уникальных снимка** (по пиксельному содержимому)
|
||||||
- 86 файлов имеют явную метку; после склейки дублей — **37 нарушений из 252 (14.7 %)**.
|
на 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),
|
- Имена файлов приведены к виду `<область>_<NN>[_good|_bad].dcm` (`spine`,
|
||||||
поэтому корректность нанесённых областей нельзя проверить прямым сравнением.
|
`l_hip`, `r_hip`); инструмент — `src/dxa/rename_files.py`, карта отката —
|
||||||
- Метка в Excel относится к исследованию и раздаётся его снимкам; имена файлов
|
`labels/rename_map.csv`. Суффиксы `_good`/`_bad` проставлялись вручную, в
|
||||||
имеют приоритет. Excel используется только для предупреждения о расхождениях.
|
метках **не участвуют** — только как диагностический столбец
|
||||||
|
`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
|
```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 --dry-run # проверить данные без обучения
|
||||||
|
python -m src.dxa.train --labels-csv "" --dry-run # режим меток из имён файлов
|
||||||
python -m src.dxa.train --head mlp --freeze-epochs 0 --epochs 30
|
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` (веса, порог, параметры
|
Артефакты в `--output-dir`: `dxa_model.pth` (веса, порог, параметры
|
||||||
предобработки), `train_report.md`, `train_report.json`.
|
предобработки), `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 | `/` | Веб-интерфейс |
|
||||||
| GET | `/api/v1/health` | Статус, признак загрузки модели |
|
| GET | `/api/v1/health` | Статус, признак загрузки модели и её происхождение (разметка, разбиение, порог, эпоха) |
|
||||||
|
| GET | `/api/v1/model` | Карточка решения: разметка, данные, метрики с интервалами, словарь нарушений, ограничения |
|
||||||
| POST | `/api/v1/analyze` | Базовый анализ файла |
|
| POST | `/api/v1/analyze` | Базовый анализ файла |
|
||||||
| POST | `/api/v1/analyze/detailed` | Расширенный отчёт, опционально маска |
|
| POST | `/api/v1/analyze/detailed` | Расширенный отчёт, опционально маска |
|
||||||
| POST | `/api/v1/analyze/sr` | Текстовый отчёт DICOM SR |
|
| POST | `/api/v1/analyze/sr` | Текстовый отчёт DICOM SR |
|
||||||
|
|
@ -146,11 +201,19 @@ API и CLI используют один код предсказания (`predi
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./run.sh test
|
./run.sh test
|
||||||
python -m pytest tests/ -q # 79 тестов
|
python -m pytest tests/ -q # 208 тестов
|
||||||
```
|
```
|
||||||
|
|
||||||
- `tests/test_labels.py` — разбор имён, склейка дублей, отсутствие утечки при
|
- `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` — предобработка, метрики, подбор порога,
|
- `tests/test_preprocess_and_model.py` — предобработка, метрики, подбор порога,
|
||||||
контракт модели, BatchNorm при заморозке, roundtrip чекпоинта.
|
контракт модели, BatchNorm при заморозке, roundtrip чекпоинта.
|
||||||
- `tests/test_api_contract.py` — поля ответов, которые читает `dxa-app.js`
|
- `tests/test_api_contract.py` — поля ответов, которые читает `dxa-app.js`
|
||||||
|
|
@ -165,10 +228,24 @@ python -m pytest tests/ -q # 79 тестов
|
||||||
панели деталей. Используется временный профиль Chrome, профиль пользователя не
|
панели деталей. Используется временный профиль Chrome, профиль пользователя не
|
||||||
затрагивается. Требуется запущенный сервер на `127.0.0.1:8123`.
|
затрагивается. Требуется запущенный сервер на `127.0.0.1:8123`.
|
||||||
|
|
||||||
- `ui_check.js` — значения панели меняются при переключении строк.
|
- `ui_check.js` — подсказка о кликабельности строк видна и скрывается, когда
|
||||||
|
фильтр не оставил строк; кнопка «Открыть» в строке открывает панель деталей;
|
||||||
|
значения панели меняются при переключении строк; панель «О модели» наполняется
|
||||||
|
метриками и словарём (иначе раздел остался бы пустым каркасом).
|
||||||
- `ui_violation.js` — ветка «нарушение» (бейдж, POOR, HIGH, заключение).
|
- `ui_violation.js` — ветка «нарушение» (бейдж, POOR, HIGH, заключение).
|
||||||
- `ui_offline.js` — страница не обращается к внешним хостам.
|
- `ui_offline.js` — страница не обращается к внешним хостам.
|
||||||
|
|
||||||
|
### Честность интерфейса
|
||||||
|
|
||||||
|
Веб-интерфейс не должен утверждать больше, чем известно решению, поэтому:
|
||||||
|
|
||||||
|
- тип нарушения показан с пометкой «эвристика» и пояснением, что модель решает
|
||||||
|
только бинарную задачу;
|
||||||
|
- ROC-AUC рабочего чекпоинта в баннере состояния помечена как завышенная (он
|
||||||
|
выбран лучшим из пяти seed'ов), а честная оценка лежит в панели «О модели»;
|
||||||
|
- плитка средней уверенности не называется точностью;
|
||||||
|
- подписи типов и метрики приходят с сервера (`/api/v1/model`), копий в JS нет.
|
||||||
|
|
||||||
### Офлайн-работа фронтенда
|
### Офлайн-работа фронтенда
|
||||||
|
|
||||||
Tailwind и FontAwesome лежат локально (`src/api/static/vendor`,
|
Tailwind и FontAwesome лежат локально (`src/api/static/vendor`,
|
||||||
|
|
@ -180,11 +257,21 @@ Tailwind и FontAwesome лежат локально (`src/api/static/vendor`,
|
||||||
|
|
||||||
## Известные ограничения
|
## Известные ограничения
|
||||||
|
|
||||||
1. Разметка на уровне исследования → шум в метках снимков.
|
1. Разметка снимков выведена из таблицы, описывающей исследование: поштучной
|
||||||
2. Мало данных: 252 снимка, 37 нарушений; доверительные интервалы широкие.
|
экспертной оценки снимков в наборе нет. Оценка качества модели упирается в
|
||||||
3. Тип нарушения определяется эвристиками, а не обученной моделью.
|
качество этой разметки, а не только в объём данных.
|
||||||
4. Порог `SPINE_MIN_WIDTH` привязан к текущему оборудованию.
|
2. Мало данных: 252 снимка, 77 нарушений; доверительные интервалы широкие
|
||||||
5. Grad-CAM (`src/models/visualization/gradcam.py`) есть, но не подключён.
|
(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)
|
## Устаревший код (не подключён к API)
|
||||||
|
|
||||||
|
|
@ -207,6 +294,10 @@ docker build -t dxa-quality .
|
||||||
docker run -v /path/to/data:/data -p 8000:8000 dxa-quality
|
docker run -v /path/to/data:/data -p 8000:8000 dxa-quality
|
||||||
```
|
```
|
||||||
|
|
||||||
Dockerfile ставит зафиксированные версии, копирует только `src/`, `models/` и
|
Dockerfile ставит зафиксированные версии, копирует только `src/` и `run.sh`,
|
||||||
`run.sh`, проверяет чекпоинт на этапе сборки и имеет HEALTHCHECK. Данные и тесты
|
проверяет импорт приложения и наличие офлайн-ассетов фронтенда на этапе сборки и
|
||||||
в образ не попадают (`.dockerignore`).
|
имеет HEALTHCHECK. Чекпоинт в образ не копируется — он монтируется в `/app/models`
|
||||||
|
при запуске (`DXA_MODEL_PATH`). Данные, тесты и `labels/` в образ не попадают
|
||||||
|
(`.dockerignore`), поэтому `./run.sh train` внутри контейнера возьмёт метки из
|
||||||
|
имён файлов (с предупреждением); для обучения в контейнере смонтируйте `labels/`
|
||||||
|
или передайте свой `--labels-csv`.
|
||||||
|
|
|
||||||
224
README.md
|
|
@ -4,6 +4,10 @@
|
||||||
принимает DICOM, определяет анатомическую область, оценивает, пригодно ли изображение
|
принимает DICOM, определяет анатомическую область, оценивает, пригодно ли изображение
|
||||||
для клинической интерпретации, и формирует структурированный отчёт.
|
для клинической интерпретации, и формирует структурированный отчёт.
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
<img src="assets/img/ui-results.png" alt="Результаты обработки в веб-интерфейсе: полоса состояния, статистика и таблица снимков" width="90%">
|
||||||
|
</div>
|
||||||
|
|
||||||
## Что делает решение
|
## Что делает решение
|
||||||
|
|
||||||
| Шаг | Реализация |
|
| Шаг | Реализация |
|
||||||
|
|
@ -13,7 +17,7 @@
|
||||||
| Тип нарушения | Общая категория для снимков с нарушением; детальный тип требует разметки типов на уровне снимка |
|
| Тип нарушения | Общая категория для снимков с нарушением; детальный тип требует разметки типов на уровне снимка |
|
||||||
| Отчёт | XLSX/CSV со столбцами из требований; опционально zip с визуализацией зоны интереса |
|
| Отчёт | XLSX/CSV со столбцами из требований; опционально zip с визуализацией зоны интереса |
|
||||||
| API | FastAPI: анализ, детальный анализ, пакетная обработка, экспорт, DICOM SR (текст) |
|
| API | FastAPI: анализ, детальный анализ, пакетная обработка, экспорт, DICOM SR (текст) |
|
||||||
| Веб-интерфейс | Загрузка DICOM, таблица результатов, панель деталей с визуализацией |
|
| Веб-интерфейс | Загрузка DICOM, таблица результатов, панель деталей с визуализацией, панель «О модели» |
|
||||||
|
|
||||||
## Установка и запуск
|
## Установка и запуск
|
||||||
|
|
||||||
|
|
@ -21,9 +25,12 @@
|
||||||
python3 -m venv venv && source venv/bin/activate
|
python3 -m venv venv && source venv/bin/activate
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
./run.sh label # разметить датасет по Excel
|
||||||
|
./run.sh rename # план приведения имён файлов
|
||||||
./run.sh train # обучить модель качества
|
./run.sh train # обучить модель качества
|
||||||
./run.sh infer "dataset_hack/Для теста" results.xlsx # пакетная обработка
|
./run.sh infer "dataset_hack/Для теста" results.xlsx # пакетная обработка
|
||||||
./run.sh serve # API и веб-интерфейс на :8000
|
./run.sh serve # API и веб-интерфейс на :8000
|
||||||
|
./run.sh split && ./run.sh compare # сравнить варианты разметки
|
||||||
./run.sh test # тесты
|
./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 (заморожен) ──▶ линейная голова ──▶ логит
|
DICOM ──▶ предобработка ──▶ ResNet18 (заморожен) ──▶ линейная голова ──▶ логит
|
||||||
│ │
|
│ │
|
||||||
|
|
@ -77,9 +90,15 @@ DICOM ──▶ предобработка ──▶ ResNet18 (замороже
|
||||||
≈ 0.7–0.85. Режим `--head mlp --freeze-epochs 0` оставлен для экспериментов
|
≈ 0.7–0.85. Режим `--head mlp --freeze-epochs 0` оставлен для экспериментов
|
||||||
на большем объёме данных.
|
на большем объёме данных.
|
||||||
|
|
||||||
2. **Метки из имён файлов.** Суффикс `_good`/`_bad` — экспертная оценка снимка;
|
2. **Метки на уровне снимка.** Источник — `labels/labels_images.csv`, построенный
|
||||||
отсутствие суффикса означает «изображение хорошее». Приоритет:
|
из экспертной таблицы командой `./run.sh label` (разбор — в
|
||||||
`_bad` > `_good` > нет метки.
|
`assets/labeling.md`): в таблице отмечены критерии качества по каждому
|
||||||
|
исследованию, а каждая область встречается в нём ровно один раз, поэтому
|
||||||
|
вердикт переносится на снимок однозначно. Такой разметки — 77 нарушений из
|
||||||
|
252 (30.6 %). Правило выбрано измерением: учёт ручных пометок из имён файлов
|
||||||
|
дал худший результат на held-out наборе, поэтому в метках они не участвуют.
|
||||||
|
Резервный режим `--labels-csv ""` берёт метку из суффикса `_good`/`_bad` и
|
||||||
|
оставлен для совместимости.
|
||||||
|
|
||||||
3. **Склейка побайтных дублей.** В датасете 544 файла, но 252 уникальных снимка:
|
3. **Склейка побайтных дублей.** В датасете 544 файла, но 252 уникальных снимка:
|
||||||
один и тот же кадр сохранён многократно под разными именами (часть — с меткой,
|
один и тот же кадр сохранён многократно под разными именами (часть — с меткой,
|
||||||
|
|
@ -97,6 +116,25 @@ DICOM ──▶ предобработка ──▶ ResNet18 (замороже
|
||||||
Порог подбирается по F1 на валидации и сохраняется в чекпоинт; решение
|
Порог подбирается по 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 | `/` | Веб-интерфейс |
|
||||||
| GET | `/api/v1/health` | Статус и признак загрузки модели |
|
| GET | `/api/v1/health` | Статус, признак загрузки модели и её происхождение |
|
||||||
|
| GET | `/api/v1/model` | Карточка решения: разметка, данные, метрики с интервалами, словарь нарушений |
|
||||||
| POST | `/api/v1/analyze` | Базовый анализ одного файла |
|
| POST | `/api/v1/analyze` | Базовый анализ одного файла |
|
||||||
| POST | `/api/v1/analyze/detailed` | Расширенный отчёт, опционально маска |
|
| POST | `/api/v1/analyze/detailed` | Расширенный отчёт, опционально маска |
|
||||||
| POST | `/api/v1/analyze/sr` | Текстовое представление отчёта DICOM SR |
|
| 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...",
|
"image_uid": "1.2.643...",
|
||||||
"anatomical_region": "spine",
|
"anatomical_region": "spine",
|
||||||
"quality_class": 1,
|
"quality_class": 1,
|
||||||
"quality_label": "Violation detected",
|
"quality_label": "Есть нарушение качества",
|
||||||
"violation_type": "quality_violation_detected",
|
"violation_type": "artifact",
|
||||||
"reason": "Выявлено нарушение качества изображения",
|
"violation_type_label": "Артефакты и импланты",
|
||||||
|
"violation_type_is_heuristic": true,
|
||||||
|
"violation_type_note": "Тип нарушения определён эвристикой по метрикам снимка, а не моделью...",
|
||||||
|
"reason": "Посторонние включения или артефакты в зоне интереса",
|
||||||
"confidence": 0.72,
|
"confidence": 0.72,
|
||||||
"threshold_probability": 0.6154,
|
"threshold_probability": 0.5103,
|
||||||
"processing_status": "Success"
|
"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 |
|
| ROC-AUC | **0.6764** [0.6309, 0.7218] | 5 seed'ов на фиксированном разбиении |
|
||||||
| PR-AUC, 5 разбиений | 0.48 ± 0.17 | там же; базовый уровень при 15 % нарушений — 0.15 |
|
| PR-AUC | 0.4759 [0.4141, 0.5377] | там же; базовый уровень при 30 % нарушений — 0.30 |
|
||||||
| F1, 5 разбиений | 0.54 ± 0.11 | там же (порог подобран на той же валидации — смещено вверх) |
|
| F1 | 0.5676 [0.5270, 0.6082] | там же; порог подобран на той же валидации — смещено вверх |
|
||||||
| ROC-AUC, 5-фолдовая CV | **0.81 ± 0.08** | линейный зонд на тех же признаках, разбиение по исследованиям |
|
| ROC-AUC по областям | позвоночник 0.943, бедро R 0.576, бедро L 0.550 | рабочий чекпоинт, собственная валидация |
|
||||||
| Контрольная задача «позвоночник / бедро» | AUC 1.00 | проверка работоспособности пайплайна |
|
| Контрольная задача «позвоночник / бедро» | AUC 1.00 | проверка работоспособности пайплайна |
|
||||||
| Перестановка меток (нулевая гипотеза) | AUC 0.64 | вклад случайных корреляций |
|
| Модель использует снимок, а не область | AUC 0.854 против 0.529 у правила области | `discriminator` на всём наборе, включая обучающие снимки |
|
||||||
|
|
||||||
Метрики по областям — в `models/train_report.md`, он создаётся при обучении.
|
Метрики по областям — в `models/train_report.md`, он создаётся при обучении.
|
||||||
Разбивка важна, потому что нарушения распределены крайне неравномерно: в
|
Разбивка важна, потому что нарушения распределены неравномерно: в позвоночнике
|
||||||
позвоночнике ~29 % снимков с нарушением против ~4–5 % у бёдер, а область почти
|
33 из 99, у бёдер 21–22 из 73–79, а область почти однозначно определяется по
|
||||||
однозначно определяется по ширине кадра. Поэтому общий AUC частично отражает
|
ширине кадра. Поэтому общий 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 с
|
Время обработки одного снимка — порядка 0.02–0.05 с на CPU (ResNet18 с
|
||||||
замороженным backbone), то есть требование «не более 3 минут на исследование»
|
замороженным backbone), то есть требование «не более 3 минут на исследование»
|
||||||
|
|
@ -179,11 +317,12 @@ curl -X POST http://localhost:8000/api/v1/analyze -F "file=@study/spine.dcm"
|
||||||
|
|
||||||
## Ограничения (важно для интерпретации)
|
## Ограничения (важно для интерпретации)
|
||||||
|
|
||||||
1. **Разметка исходных данных — на уровне исследования, а не снимка.**
|
1. **Разметка выведена из оценки исследования.** Экспертная таблица описывает
|
||||||
В наборе один снимок помечен `_bad`, остальные снимки того же исследования
|
исследование, а не снимок; перенос однозначен (область встречается один раз),
|
||||||
не размечены. Метка снимка считается унаследованной от исследования, поэтому
|
но поштучной экспертной оценки снимков в наборе нет. Происхождение каждой
|
||||||
часть меток заведомо шумная.
|
строки зафиксировано в `labels/labels_images.csv`. Разбор — в
|
||||||
2. **Мало данных.** 252 уникальных снимка, 37 нарушений. Доверительные интервалы
|
`assets/labeling.md`.
|
||||||
|
2. **Мало данных.** 252 уникальных снимка, 77 нарушений. Доверительные интервалы
|
||||||
широкие; оценка на закрытом наборе может отличаться.
|
широкие; оценка на закрытом наборе может отличаться.
|
||||||
3. **Тип нарушения определяется эвристиками, а не обученной моделью.** Для
|
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 |
|
| Модель использует снимок, а не только область | `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` | 15 тестов: поля панели деталей, различимость метрик, PNG-визуализации, отсутствие некалиброванных вердиктов |
|
| Контракт API для веб-интерфейса | `python -m pytest tests/test_api_contract.py` | поля панели деталей, различимость метрик, PNG-визуализации, отсутствие некалиброванных вердиктов, канонические коды типов нарушений, карточка модели |
|
||||||
| Разметка и разбиение данных | `python -m pytest tests/test_labels.py` | метки из имён, склейка дублей, отсутствие утечки между train/val |
|
| Единый словарь нарушений | `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 |
|
| Метрики и порог | `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` | ноль внешних запросов, стили и иконки на месте |
|
| Работа без сети | `node tests/browser/ui_offline.js` | ноль внешних запросов, стили и иконки на месте |
|
||||||
|
|
||||||
Браузерные проверки требуют запущенного сервера:
|
Браузерные проверки требуют запущенного сервера:
|
||||||
|
|
@ -244,6 +387,13 @@ bone_2026/
|
||||||
│ ├── main.py # FastAPI: маршруты и загрузка модели
|
│ ├── main.py # FastAPI: маршруты и загрузка модели
|
||||||
│ ├── dxa/ # действующий модуль оценки качества
|
│ ├── dxa/ # действующий модуль оценки качества
|
||||||
│ │ ├── labels.py # разбор имён, метки, склейка дублей, сплит
|
│ │ ├── 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)
|
│ │ ├── preprocess.py # DICOM -> тензор (общий для обучения и API)
|
||||||
│ │ ├── dataset.py # Dataset и DataLoader
|
│ │ ├── dataset.py # Dataset и DataLoader
|
||||||
│ │ ├── model.py # сеть, метрики, подбор порога
|
│ │ ├── model.py # сеть, метрики, подбор порога
|
||||||
|
|
@ -252,8 +402,18 @@ bone_2026/
|
||||||
│ ├── quality/ # эвристики (частично используются API)
|
│ ├── quality/ # эвристики (частично используются API)
|
||||||
│ ├── api/static/ # веб-интерфейс
|
│ ├── api/static/ # веб-интерфейс
|
||||||
│ └── model/, core/, pipeline/ # устаревшие модули, не подключены к API
|
│ └── model/, core/, pipeline/ # устаревшие модули, не подключены к API
|
||||||
├── models/dxa_model.pth # чекпоинт (+ train_report.md)
|
├── labels/labels_images.csv # разметка снимков: официальная (+ .xlsx)
|
||||||
├── tests/ # pytest: метки, сплит, метрики, модель
|
├── 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 не хранятся)
|
├── dataset_hack/ # данные (в git не хранятся)
|
||||||
├── Dockerfile
|
├── Dockerfile
|
||||||
├── requirements.txt
|
├── requirements.txt
|
||||||
|
|
@ -270,6 +430,8 @@ python -m src.dxa.train --epochs 100 --output-dir models
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `--data-root` | `dataset_hack` | Каталог датасета |
|
| `--data-root` | `dataset_hack` | Каталог датасета |
|
||||||
| `--annotation-path` | `dataset_hack/НД_для_обучения/разметка.xlsx` | Excel с разметкой (только отчёт о расхождениях) |
|
| `--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` |
|
| `--backbone` | `resnet18` | `resnet18` / `resnet34` |
|
||||||
| `--head` | `linear` | `linear` (линейный зонд) / `mlp` |
|
| `--head` | `linear` | `linear` (линейный зонд) / `mlp` |
|
||||||
| `--freeze-epochs` | `-1` | `-1` — backbone заморожен всегда; `0` — обучать всю сеть |
|
| `--freeze-epochs` | `-1` | `-1` — backbone заморожен всегда; `0` — обучать всю сеть |
|
||||||
|
|
|
||||||
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
|
@ -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+), чтобы сузить интервалы.
|
||||||
|
- Калибровка порога определения области под конкретное оборудование.
|
||||||
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 144 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
|
@ -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+), чтобы сузить интервалы.
|
||||||
|
- Калибровка порога определения области под конкретное оборудование.
|
||||||
|
|
@ -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">>3000×</td><td class="num">>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>
|
||||||
|
|
@ -50,3 +50,8 @@ pytest==8.4.2
|
||||||
# simpleitk==2.5.6
|
# simpleitk==2.5.6
|
||||||
# pydicom-seg==0.4.1
|
# pydicom-seg==0.4.1
|
||||||
# timm==1.0.29
|
# timm==1.0.29
|
||||||
|
|
||||||
|
# --- Сборка презентации (приложению не нужна) ---
|
||||||
|
# tools/build_deck.py собирает docs/deck.pptx из шаблона организаторов.
|
||||||
|
# В образ инференса эту зависимость ставить не нужно.
|
||||||
|
# python-pptx==1.0.2
|
||||||
|
|
|
||||||
77
run.sh
|
|
@ -6,6 +6,10 @@
|
||||||
# ./run.sh train [доп. аргументы для src.dxa.train]
|
# ./run.sh train [доп. аргументы для src.dxa.train]
|
||||||
# ./run.sh infer <вход> <выход.xlsx|.csv> [доп. аргументы]
|
# ./run.sh infer <вход> <выход.xlsx|.csv> [доп. аргументы]
|
||||||
# ./run.sh serve [порт]
|
# ./run.sh serve [порт]
|
||||||
|
# ./run.sh label
|
||||||
|
# ./run.sh split
|
||||||
|
# ./run.sh rename [--apply]
|
||||||
|
# ./run.sh compare
|
||||||
# ./run.sh test
|
# ./run.sh test
|
||||||
#
|
#
|
||||||
# Скрипт намеренно не содержит своей реализации обучения и инференса: вся
|
# Скрипт намеренно не содержит своей реализации обучения и инференса: вся
|
||||||
|
|
@ -19,6 +23,11 @@ PYTHON=${PYTHON:-python3}
|
||||||
DATA_ROOT=${DATA_ROOT:-dataset_hack}
|
DATA_ROOT=${DATA_ROOT:-dataset_hack}
|
||||||
ANNOTATION_PATH=${ANNOTATION_PATH:-"dataset_hack/НД_для_обучения/разметка.xlsx"}
|
ANNOTATION_PATH=${ANNOTATION_PATH:-"dataset_hack/НД_для_обучения/разметка.xlsx"}
|
||||||
MODEL_PATH=${MODEL_PATH:-models/dxa_model.pth}
|
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}
|
PORT=${PORT:-8000}
|
||||||
|
|
||||||
command=${1:-help}
|
command=${1:-help}
|
||||||
|
|
@ -27,9 +36,11 @@ shift || true
|
||||||
case "$command" in
|
case "$command" in
|
||||||
train)
|
train)
|
||||||
echo -e "${YELLOW}Обучение классификатора качества DXA${NC}"
|
echo -e "${YELLOW}Обучение классификатора качества DXA${NC}"
|
||||||
|
echo " метки: $LABELS_CSV"
|
||||||
"$PYTHON" -m src.dxa.train \
|
"$PYTHON" -m src.dxa.train \
|
||||||
--data-root "$DATA_ROOT" \
|
--data-root "$DATA_ROOT" \
|
||||||
--annotation-path "$ANNOTATION_PATH" \
|
--annotation-path "$ANNOTATION_PATH" \
|
||||||
|
--labels-csv "$LABELS_CSV" \
|
||||||
--output-dir "$(dirname "$MODEL_PATH")" \
|
--output-dir "$(dirname "$MODEL_PATH")" \
|
||||||
"$@"
|
"$@"
|
||||||
echo -e "${GREEN}Готово. Чекпоинт: ${MODEL_PATH}${NC}"
|
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"
|
"$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)
|
test)
|
||||||
echo -e "${YELLOW}Запуск тестов${NC}"
|
echo -e "${YELLOW}Запуск тестов${NC}"
|
||||||
"$PYTHON" -m pytest tests/ -q
|
"$PYTHON" -m pytest tests/ -q
|
||||||
|
|
@ -72,19 +131,35 @@ DXA Quality Assessment — управление запуском
|
||||||
Пример: ./run.sh infer "dataset_hack/Для теста" results.xlsx
|
Пример: ./run.sh infer "dataset_hack/Для теста" results.xlsx
|
||||||
Дополнительно: --zip-out masks.zip
|
Дополнительно: --zip-out masks.zip
|
||||||
serve [порт] Запустить HTTP API и веб-интерфейс.
|
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 Запустить тесты.
|
test Запустить тесты.
|
||||||
|
|
||||||
Переменные окружения:
|
Переменные окружения:
|
||||||
DATA_ROOT Каталог датасета (по умолчанию dataset_hack)
|
DATA_ROOT Каталог датасета (по умолчанию dataset_hack)
|
||||||
ANNOTATION_PATH Excel с разметкой (только для отчёта о расхождениях)
|
ANNOTATION_PATH Excel с экспертной разметкой исследований
|
||||||
MODEL_PATH Путь к чекпоинту (по умолчанию models/dxa_model.pth)
|
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)
|
PORT Порт API (по умолчанию 8000)
|
||||||
PYTHON Интерпретатор (по умолчанию python3)
|
PYTHON Интерпретатор (по умолчанию python3)
|
||||||
|
|
||||||
Типовой порядок работы:
|
Типовой порядок работы:
|
||||||
|
./run.sh label # разметить датасет по Excel
|
||||||
./run.sh train # обучить модель
|
./run.sh train # обучить модель
|
||||||
./run.sh infer dataset_hack results.xlsx
|
./run.sh infer dataset_hack results.xlsx
|
||||||
./run.sh serve # веб-интерфейс на http://localhost:8000
|
./run.sh serve # веб-интерфейс на http://localhost:8000
|
||||||
|
./run.sh split && ./run.sh compare # сравнить варианты разметки
|
||||||
USAGE
|
USAGE
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
|
||||||
|
|
@ -88,6 +88,10 @@
|
||||||
<i class="fas fa-moon dark:hidden"></i>
|
<i class="fas fa-moon dark:hidden"></i>
|
||||||
<i class="fas fa-sun hidden dark:inline"></i>
|
<i class="fas fa-sun hidden dark:inline"></i>
|
||||||
</button>
|
</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">
|
<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>
|
<i class="fas fa-book"></i>
|
||||||
API Docs
|
API Docs
|
||||||
|
|
@ -181,8 +185,8 @@
|
||||||
<i class="fas fa-percentage text-purple-600 dark:text-purple-400"></i>
|
<i class="fas fa-percentage text-purple-600 dark:text-purple-400"></i>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-2xl font-bold text-gray-900 dark:text-white" id="accuracy">-</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>
|
<p class="text-sm text-gray-500 dark:text-gray-400">Ср. уверенность модели</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -200,6 +204,15 @@
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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 -->
|
<!-- Table -->
|
||||||
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm overflow-hidden">
|
<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">
|
<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">
|
<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>
|
Уверенность <i class="fas fa-sort ml-1"></i>
|
||||||
</th>
|
</th>
|
||||||
|
<th class="px-2 py-3 w-8"><span class="sr-only">Открыть детали</span></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="resultsTable" class="divide-y divide-gray-200 dark:divide-gray-700">
|
<tbody id="resultsTable" class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
|
@ -378,6 +392,56 @@
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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 -->
|
<!-- Error Section -->
|
||||||
<section id="errorSection" class="hidden">
|
<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">
|
<div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl p-6">
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,11 @@ const API_BASE = '';
|
||||||
let results = [];
|
let results = [];
|
||||||
let sortColumn = null;
|
let sortColumn = null;
|
||||||
let sortDirection = 'asc';
|
let sortDirection = 'asc';
|
||||||
|
// Сведения о решении, полученные с /api/v1/model: карточка модели и единый
|
||||||
|
// словарь типов нарушений. Интерфейс не держит своей копии словаря, иначе она
|
||||||
|
// расходится с серверной — так и было раньше.
|
||||||
|
let modelCard = null;
|
||||||
|
let violationDictionary = {};
|
||||||
|
|
||||||
// DOM Elements
|
// DOM Elements
|
||||||
const dropZone = document.getElementById('dropZone');
|
const dropZone = document.getElementById('dropZone');
|
||||||
|
|
@ -21,6 +26,8 @@ const statusBanner = document.getElementById('statusBanner');
|
||||||
const statusIcon = document.getElementById('statusIcon');
|
const statusIcon = document.getElementById('statusIcon');
|
||||||
const statusText = document.getElementById('statusText');
|
const statusText = document.getElementById('statusText');
|
||||||
const detailSection = document.getElementById('detailSection');
|
const detailSection = document.getElementById('detailSection');
|
||||||
|
const modelSection = document.getElementById('modelSection');
|
||||||
|
const tableHint = document.getElementById('tableHint');
|
||||||
|
|
||||||
// Theme toggle
|
// Theme toggle
|
||||||
const themeToggle = document.getElementById('themeToggle');
|
const themeToggle = document.getElementById('themeToggle');
|
||||||
|
|
@ -41,10 +48,26 @@ async function checkHealth() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_BASE}/api/v1/health`);
|
const response = await fetch(`${API_BASE}/api/v1/health`);
|
||||||
const data = await response.json();
|
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';
|
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 {
|
} else {
|
||||||
statusIcon.className = 'w-3 h-3 rounded-full bg-yellow-500';
|
statusIcon.className = 'w-3 h-3 rounded-full bg-yellow-500';
|
||||||
statusText.textContent = 'Проблемы с сервисом';
|
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
|
// Drag and drop
|
||||||
dropZone.addEventListener('click', () => fileInput.click());
|
dropZone.addEventListener('click', () => fileInput.click());
|
||||||
|
|
||||||
|
|
@ -196,7 +351,9 @@ function updateStats() {
|
||||||
document.getElementById('totalCount').textContent = total;
|
document.getElementById('totalCount').textContent = total;
|
||||||
document.getElementById('okCount').textContent = ok;
|
document.getElementById('okCount').textContent = ok;
|
||||||
document.getElementById('violationCount').textContent = violation;
|
document.getElementById('violationCount').textContent = violation;
|
||||||
document.getElementById('accuracy').textContent = `${avgConfidence}%`;
|
// Это средняя уверенность модели, а не точность: точность требует
|
||||||
|
// эталонных меток, которых для произвольного загруженного файла нет.
|
||||||
|
document.getElementById('meanConfidence').textContent = `${avgConfidence}%`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter and sort results
|
// Filter and sort results
|
||||||
|
|
@ -251,7 +408,9 @@ function getFilteredResults() {
|
||||||
// Render table
|
// Render table
|
||||||
function renderTable() {
|
function renderTable() {
|
||||||
const filtered = getFilteredResults();
|
const filtered = getFilteredResults();
|
||||||
|
// Подсказка «нажмите на строку» бессмысленна, когда строк нет.
|
||||||
|
if (tableHint) tableHint.classList.toggle('hidden', filtered.length === 0);
|
||||||
|
|
||||||
if (filtered.length === 0) {
|
if (filtered.length === 0) {
|
||||||
resultsTable.innerHTML = '';
|
resultsTable.innerHTML = '';
|
||||||
emptyResults.classList.remove('hidden');
|
emptyResults.classList.remove('hidden');
|
||||||
|
|
@ -304,6 +463,13 @@ function renderTable() {
|
||||||
<span class="text-xs">${confidence}</span>
|
<span class="text-xs">${confidence}</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</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>
|
</tr>
|
||||||
`;
|
`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
@ -375,6 +541,22 @@ document.getElementById('closeDetail')?.addEventListener('click', () => {
|
||||||
detailSection.classList.add('hidden');
|
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
|
// Region label helper
|
||||||
function getRegionLabel(region) {
|
function getRegionLabel(region) {
|
||||||
const labels = {
|
const labels = {
|
||||||
|
|
@ -387,21 +569,17 @@ function getRegionLabel(region) {
|
||||||
return labels[region] || region || '—';
|
return labels[region] || region || '—';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Violation type label
|
// Violation type label.
|
||||||
function getViolationLabel(type) {
|
// Подпись приходит с сервера (`violation_type_label`) из единого словаря
|
||||||
const labels = {
|
// `src/dxa/violations.py`; своей копии словаря интерфейс не держит. Раньше она
|
||||||
'': 'Корректно',
|
// здесь была и разошлась с серверной: коды экспертной таблицы (`artifact`,
|
||||||
'correct': 'Корректно',
|
// `roi_incorrect`, `positioning`) в интерфейсе показывались как есть.
|
||||||
'position_error': 'Ошибка позиционирования',
|
function getViolationLabel(result) {
|
||||||
'artifact_motion': 'Артефакт движения',
|
if (!result) return '—';
|
||||||
'artifact_other': 'Другие артефакты',
|
if (result.violation_type_label) return result.violation_type_label;
|
||||||
'labeling_error': 'Ошибка маркировки',
|
const entry = violationDictionary[result.violation_type];
|
||||||
'incomplete_view': 'Неполный вид',
|
if (entry) return entry.label;
|
||||||
'roi_error': 'Ошибка ROI',
|
return result.violation_type || 'Корректно';
|
||||||
'rotation': 'Неправильный поворот',
|
|
||||||
'quality_violation_detected': 'Нарушение качества'
|
|
||||||
};
|
|
||||||
return labels[type] || type || '—';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Small helpers used by the detail panel
|
// 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>
|
<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 = `
|
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">${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>
|
<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)
|
// Confidence per class (computed on the server)
|
||||||
|
|
@ -653,3 +840,4 @@ function showError(message) {
|
||||||
|
|
||||||
// Initialize
|
// Initialize
|
||||||
checkHealth();
|
checkHealth();
|
||||||
|
loadModelCard();
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
|
|
@ -1,12 +1,15 @@
|
||||||
"""
|
"""
|
||||||
Датасет DXA для обучения классификатора качества.
|
Датасет DXA для обучения классификатора качества.
|
||||||
|
|
||||||
Метки формируются из имён DICOM-файлов (см. `src.dxa.labels`), где суффикс
|
Источник меток по умолчанию — имена DICOM-файлов (суффикс `_good`/`_bad`; см.
|
||||||
`_good`/`_bad` кодирует экспертную оценку; отсутствие суффикса — «хорошее»
|
`src.dxa.labels`). Альтернативный, более полный источник — разметка, построенная
|
||||||
изображение. Одинаковые по содержимому файлы склеиваются в один пример.
|
по экспертной таблице (`labels/labels_images.csv`, см. `src.dxa.excel_labels`):
|
||||||
|
она переносит оценку исследования на снимок и фиксирует типы нарушений.
|
||||||
|
Передайте `labels_csv`, чтобы использовать её.
|
||||||
|
|
||||||
Разбиение на train/val выполняется по исследованиям, чтобы снимки одного
|
Одинаковые по содержимому файлы склеиваются в один пример, а разбиение на
|
||||||
исследования не попадали одновременно в обучение и валидацию.
|
train/val выполняется по исследованиям, чтобы снимки одного исследования не
|
||||||
|
попадали одновременно в обучение и валидацию.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
@ -18,11 +21,13 @@ import numpy as np
|
||||||
import torch
|
import torch
|
||||||
from torch.utils.data import DataLoader, Dataset
|
from torch.utils.data import DataLoader, Dataset
|
||||||
|
|
||||||
|
from src.dxa.excel_labels import apply_excel_labels
|
||||||
from src.dxa.labels import (
|
from src.dxa.labels import (
|
||||||
REGIONS,
|
REGIONS,
|
||||||
ImageRecord,
|
ImageRecord,
|
||||||
format_summary,
|
format_summary,
|
||||||
scan_dataset,
|
scan_dataset,
|
||||||
|
split_by_studies,
|
||||||
stratified_group_split,
|
stratified_group_split,
|
||||||
)
|
)
|
||||||
from src.dxa.preprocess import PreprocessConfig, preprocess_dicom, with_input_size
|
from src.dxa.preprocess import PreprocessConfig, preprocess_dicom, with_input_size
|
||||||
|
|
@ -119,9 +124,17 @@ def build_records(
|
||||||
data_root: str | Path,
|
data_root: str | Path,
|
||||||
annotation_path: Optional[str | Path] = None,
|
annotation_path: Optional[str | Path] = None,
|
||||||
dedup: bool = True,
|
dedup: bool = True,
|
||||||
|
labels_csv: Optional[str | Path] = None,
|
||||||
) -> List[ImageRecord]:
|
) -> List[ImageRecord]:
|
||||||
"""Найти и разметить все уникальные снимки датасета."""
|
"""
|
||||||
|
Найти и разметить все уникальные снимки датасета.
|
||||||
|
|
||||||
|
Если задан `labels_csv`, метки и области берутся из него, а не из имён
|
||||||
|
файлов (см. `src.dxa.excel_labels.apply_excel_labels`).
|
||||||
|
"""
|
||||||
records = scan_dataset(data_root, with_pixel_dedup=dedup, annotation_path=annotation_path)
|
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))
|
logger.info("Dataset scan complete:\n%s", format_summary(records))
|
||||||
return records
|
return records
|
||||||
|
|
||||||
|
|
@ -134,11 +147,25 @@ def make_datasets(
|
||||||
seed: int = 42,
|
seed: int = 42,
|
||||||
dedup: bool = True,
|
dedup: bool = True,
|
||||||
preprocess: Optional[PreprocessConfig] = None,
|
preprocess: Optional[PreprocessConfig] = None,
|
||||||
|
labels_csv: Optional[str | Path] = None,
|
||||||
|
val_studies: Optional[Sequence[str]] = None,
|
||||||
) -> Tuple[DXADataset, DXADataset, PreprocessConfig]:
|
) -> 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)
|
cfg = with_input_size(preprocess or PreprocessConfig(), input_size)
|
||||||
records = build_records(data_root, annotation_path, dedup=dedup)
|
records = build_records(data_root, annotation_path, dedup=dedup, labels_csv=labels_csv)
|
||||||
train_records, val_records = stratified_group_split(records, val_fraction=val_fraction, seed=seed)
|
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(
|
logger.info(
|
||||||
"Split: train=%d images / %d studies, val=%d images / %d studies",
|
"Split: train=%d images / %d studies, val=%d images / %d studies",
|
||||||
|
|
@ -162,6 +189,8 @@ def create_dataloaders(
|
||||||
val_fraction: float = 0.2,
|
val_fraction: float = 0.2,
|
||||||
seed: int = 42,
|
seed: int = 42,
|
||||||
preprocess: Optional[PreprocessConfig] = None,
|
preprocess: Optional[PreprocessConfig] = None,
|
||||||
|
labels_csv: Optional[str | Path] = None,
|
||||||
|
val_studies: Optional[Sequence[str]] = None,
|
||||||
) -> Tuple[DataLoader, DataLoader, PreprocessConfig]:
|
) -> Tuple[DataLoader, DataLoader, PreprocessConfig]:
|
||||||
"""
|
"""
|
||||||
Создать train/val DataLoader.
|
Создать train/val DataLoader.
|
||||||
|
|
@ -177,6 +206,8 @@ def create_dataloaders(
|
||||||
val_fraction=val_fraction,
|
val_fraction=val_fraction,
|
||||||
seed=seed,
|
seed=seed,
|
||||||
preprocess=preprocess,
|
preprocess=preprocess,
|
||||||
|
labels_csv=labels_csv,
|
||||||
|
val_studies=val_studies,
|
||||||
)
|
)
|
||||||
|
|
||||||
pin = torch.cuda.is_available()
|
pin = torch.cuda.is_available()
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ from src.dxa.dataset import build_records
|
||||||
from src.dxa.labels import REGIONS, QUALITY_BAD
|
from src.dxa.labels import REGIONS, QUALITY_BAD
|
||||||
from src.dxa.model import create_model
|
from src.dxa.model import create_model
|
||||||
from src.dxa.preprocess import PreprocessConfig, preprocess_dicom
|
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")
|
logger = logging.getLogger("dxa.discriminator")
|
||||||
|
|
||||||
|
|
@ -98,17 +98,30 @@ def evaluate(records, scores: np.ndarray) -> Dict:
|
||||||
return out
|
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()
|
device = device or get_device()
|
||||||
records = build_records(
|
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))
|
logger.info("Device: %s, images: %d", device, len(records))
|
||||||
|
|
||||||
report = {
|
report = {
|
||||||
"model_path": str(model_path),
|
"model_path": str(model_path),
|
||||||
"device": device,
|
"device": device,
|
||||||
|
"labels_csv": labels_csv,
|
||||||
"n_images": len(records),
|
"n_images": len(records),
|
||||||
"n_violations": int(sum(r.label == QUALITY_BAD for r in records)),
|
"n_violations": int(sum(r.label == QUALITY_BAD for r in records)),
|
||||||
"model": evaluate(records, model_logits(records, model_path, device)),
|
"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 = argparse.ArgumentParser(description="Сравнение модели с эвристикой области")
|
||||||
parser.add_argument("--model-path", default="models/dxa_model.pth")
|
parser.add_argument("--model-path", default="models/dxa_model.pth")
|
||||||
parser.add_argument("--device", default=None)
|
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")
|
parser.add_argument("--json-out", default=None, help="Куда сохранить отчёт в JSON")
|
||||||
args = parser.parse_args(argv)
|
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)
|
logger.error("Checkpoint not found: %s", args.model_path)
|
||||||
return 1
|
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))
|
print(_format_report(report))
|
||||||
if args.json_out:
|
if args.json_out:
|
||||||
Path(args.json_out).write_text(json.dumps(report, ensure_ascii=False, indent=2))
|
Path(args.json_out).write_text(json.dumps(report, ensure_ascii=False, indent=2))
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -52,23 +52,19 @@ from src.dxa.preprocess import (
|
||||||
load_dicom_array,
|
load_dicom_array,
|
||||||
preprocess_from_array,
|
preprocess_from_array,
|
||||||
)
|
)
|
||||||
|
from src.dxa.violations import (
|
||||||
|
ARTIFACT,
|
||||||
|
MOTION,
|
||||||
|
ROTATION,
|
||||||
|
UNSPECIFIED,
|
||||||
|
reason as violation_reason,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger("dxa.inference")
|
logger = logging.getLogger("dxa.inference")
|
||||||
|
|
||||||
# Предсказание вспомогательной головы -> анатомическая область
|
# Предсказание вспомогательной головы -> анатомическая область
|
||||||
REGION_BY_ID = {1: "spine", 2: "hip_right", 3: "hip_left"}
|
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 = [
|
OUTPUT_COLUMNS = [
|
||||||
"path_to_study", "study_uid", "image_uid", "anatomical_region",
|
"path_to_study", "study_uid", "image_uid", "anatomical_region",
|
||||||
"quality_class", "violation_type", "processing_status", "time_of_processing",
|
"quality_class", "violation_type", "processing_status", "time_of_processing",
|
||||||
|
|
@ -411,24 +407,27 @@ def classify_violation_type(
|
||||||
samples: Optional[Dict[str, float]],
|
samples: Optional[Dict[str, float]],
|
||||||
) -> Tuple[str, str]:
|
) -> Tuple[str, str]:
|
||||||
"""
|
"""
|
||||||
Определить тип нарушения по эвристическим метрикам изображения.
|
Предположить тип нарушения по эвристическим признакам изображения.
|
||||||
|
|
||||||
Возвращает (тип, пояснение). Тип выбирается по наиболее выраженному
|
Возвращает (код, пояснение) с кодами из `src.dxa.violations`. Это НЕ вывод
|
||||||
признаку; при отсутствии сигналов возвращается общая категория.
|
модели: модель решает только бинарную задачу «пригоден / есть нарушение», а
|
||||||
|
тип выбирается по наиболее выраженному признаку. При отсутствии признаков
|
||||||
|
возвращается общая категория `unspecified`, поэтому в интерфейсе тип
|
||||||
|
помечен как эвристика, а не как заключение.
|
||||||
"""
|
"""
|
||||||
motion = samples.get("laplacian_variance") if samples else None
|
motion = samples.get("laplacian_variance") if samples else None
|
||||||
bright_frac = samples.get("bright_fraction") 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):
|
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):
|
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:
|
if region in ("hip_left", "hip_right") and samples:
|
||||||
aspect = samples.get("bbox_aspect", 1.0)
|
aspect = samples.get("bbox_aspect", 1.0)
|
||||||
if aspect < 0.4 or aspect > 3.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]:
|
def image_samples(img: np.ndarray) -> Dict[str, float]:
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
@ -327,3 +328,66 @@ def iter_dicom_files(data_root: str | Path) -> Iterable[Path]:
|
||||||
yield root
|
yield root
|
||||||
return
|
return
|
||||||
yield from sorted(root.rglob("*.dcm"))
|
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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
|
@ -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())
|
||||||
|
|
@ -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()
|
||||||
|
|
@ -4,16 +4,19 @@
|
||||||
================================================
|
================================================
|
||||||
|
|
||||||
Модель решает бинарную задачу: годен снимок (0) или есть нарушение (1).
|
Модель решает бинарную задачу: годен снимок (0) или есть нарушение (1).
|
||||||
Метки берутся из имён DICOM-файлов, где `_good`/`_bad` — экспертная оценка,
|
Метки берутся из `labels/labels_images.csv` — разметки, построенной из
|
||||||
а отсутствие суффикса означает «изображение хорошее» (см. `src/dxa/labels.py`).
|
экспертной таблицы (правило `table`, 77 нарушений). Режим с метками из имён
|
||||||
|
файлов (`_good`/`_bad`) сохранён через `--labels-csv ""`.
|
||||||
|
|
||||||
Особенности, отличающие этот скрипт от прежней версии:
|
Особенности, отличающие этот скрипт от прежней версии:
|
||||||
|
|
||||||
1. **Метки из имён файлов.** Раньше метка бралась из Excel по исследованию и
|
1. **Метки на уровне снимка.** Источник — `labels/labels_images.csv`,
|
||||||
раздавалась всем снимкам этого исследования; файлы с явной меткой (`_bad`)
|
построенный из экспертной таблицы (`./run.sh label`, см.
|
||||||
при этом игнорировались либо, наоборот, не сопоставлялись со своими
|
`src/dxa/excel_labels.py` и `docs/labeling.md`): оценка исследования
|
||||||
дублями. Теперь источник метки — имя файла, а Excel используется только для
|
переносится на снимок области. Ручные пометки из имён файлов в метках не
|
||||||
предупреждения о расхождениях.
|
участвуют — они оказались ненадёжными (расходились с экспертом в 15 случаях
|
||||||
|
из 252) и дали худшее качество на held-out наборе; см. §4 в `docs/labeling.md`.
|
||||||
|
Резервный режим `--labels-csv ""` берёт метку из суффикса `_good`/`_bad`.
|
||||||
2. **Склейка дублей.** В датасете 544 файла, но всего 252 уникальных снимка:
|
2. **Склейка дублей.** В датасете 544 файла, но всего 252 уникальных снимка:
|
||||||
один и тот же кадр сохранён многократно под разными именами. Без склейки
|
один и тот же кадр сохранён многократно под разными именами. Без склейки
|
||||||
модель заучивала бы конкретные снимки как разные примеры.
|
модель заучивала бы конкретные снимки как разные примеры.
|
||||||
|
|
@ -54,8 +57,8 @@ from torch.utils.data import WeightedRandomSampler
|
||||||
|
|
||||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
|
||||||
|
|
||||||
from src.dxa.dataset import DXADataset, make_datasets
|
from src.dxa.dataset import DXADataset, build_records, make_datasets
|
||||||
from src.dxa.labels import QUALITY_BAD, format_summary
|
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.model import create_model, per_region_metrics, select_threshold
|
||||||
from src.dxa.preprocess import PreprocessConfig
|
from src.dxa.preprocess import PreprocessConfig
|
||||||
|
|
||||||
|
|
@ -82,6 +85,27 @@ def set_seed(seed: int) -> None:
|
||||||
torch.cuda.manual_seed_all(seed)
|
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(
|
def make_loader(
|
||||||
dataset: DXADataset,
|
dataset: DXADataset,
|
||||||
batch_size: int,
|
batch_size: int,
|
||||||
|
|
@ -176,6 +200,11 @@ def train(args: argparse.Namespace) -> Dict:
|
||||||
output_dir.mkdir(parents=True, exist_ok=True)
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
preprocess = PreprocessConfig(norm=args.norm, imagenet_norm=not args.no_imagenet_norm)
|
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)
|
logger.info("Loading dataset from %s", args.data_root)
|
||||||
train_ds, val_ds, preprocess = make_datasets(
|
train_ds, val_ds, preprocess = make_datasets(
|
||||||
data_root=args.data_root,
|
data_root=args.data_root,
|
||||||
|
|
@ -184,6 +213,8 @@ def train(args: argparse.Namespace) -> Dict:
|
||||||
val_fraction=args.val_fraction,
|
val_fraction=args.val_fraction,
|
||||||
seed=args.seed,
|
seed=args.seed,
|
||||||
preprocess=preprocess,
|
preprocess=preprocess,
|
||||||
|
labels_csv=labels_csv,
|
||||||
|
val_studies=val_studies,
|
||||||
)
|
)
|
||||||
|
|
||||||
if len(train_ds) == 0 or len(val_ds) == 0:
|
if len(train_ds) == 0 or len(val_ds) == 0:
|
||||||
|
|
@ -288,6 +319,10 @@ def train(args: argparse.Namespace) -> Dict:
|
||||||
selection_score=score,
|
selection_score=score,
|
||||||
epoch=epoch,
|
epoch=epoch,
|
||||||
data_root=str(args.data_root),
|
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)",
|
logger.info(" -> saved best checkpoint (smoothed auc %.4f, f1 %.4f, thr %.3f)",
|
||||||
score, best_f1, val_metrics["threshold_prob"])
|
score, best_f1, val_metrics["threshold_prob"])
|
||||||
|
|
@ -320,6 +355,8 @@ def train(args: argparse.Namespace) -> Dict:
|
||||||
"backbone": args.backbone,
|
"backbone": args.backbone,
|
||||||
"device": device,
|
"device": device,
|
||||||
"preprocess": preprocess.to_dict(),
|
"preprocess": preprocess.to_dict(),
|
||||||
|
"labels_csv": str(labels_csv) if labels_csv else None,
|
||||||
|
"split_file": args.split_file,
|
||||||
"seed": args.seed,
|
"seed": args.seed,
|
||||||
"balance": args.balance,
|
"balance": args.balance,
|
||||||
"pos_weight": pos_weight,
|
"pos_weight": pos_weight,
|
||||||
|
|
@ -373,6 +410,7 @@ def _write_markdown_report(path: Path, report: Dict) -> None:
|
||||||
"",
|
"",
|
||||||
f"- Backbone: `{report['backbone']}`",
|
f"- Backbone: `{report['backbone']}`",
|
||||||
f"- Устройство: `{report['device']}`",
|
f"- Устройство: `{report['device']}`",
|
||||||
|
f"- Источник меток: `{report.get('labels_csv') or 'имена файлов (_good/_bad)'}`",
|
||||||
f"- Seed: {report['seed']}, балансировка классов: `{report['balance']}`",
|
f"- Seed: {report['seed']}, балансировка классов: `{report['balance']}`",
|
||||||
f"- Размер выборок: train {report['train_size']} снимков / {report['train_studies']} исследований, "
|
f"- Размер выборок: train {report['train_size']} снимков / {report['train_studies']} исследований, "
|
||||||
f"val {report['val_size']} снимков / {report['val_studies']} исследований (разбиение по исследованиям)",
|
f"val {report['val_size']} снимков / {report['val_studies']} исследований (разбиение по исследованиям)",
|
||||||
|
|
@ -446,6 +484,12 @@ def build_parser() -> argparse.ArgumentParser:
|
||||||
help="Каталог датасета (dataset_hack, НД_для_обучения или каталог с DICOM)")
|
help="Каталог датасета (dataset_hack, НД_для_обучения или каталог с DICOM)")
|
||||||
data.add_argument("--annotation-path", default="dataset_hack/НД_для_обучения/разметка.xlsx",
|
data.add_argument("--annotation-path", default="dataset_hack/НД_для_обучения/разметка.xlsx",
|
||||||
help="Excel-разметка; применяется только для отчёта о расхождениях")
|
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("--input-size", type=int, default=224)
|
||||||
data.add_argument("--val-fraction", type=float, default=0.2,
|
data.add_argument("--val-fraction", type=float, default=0.2,
|
||||||
help="Доля изображений в валидации (разбиение по исследованиям)")
|
help="Доля изображений в валидации (разбиение по исследованиям)")
|
||||||
|
|
@ -493,6 +537,9 @@ def build_parser() -> argparse.ArgumentParser:
|
||||||
|
|
||||||
parser.add_argument("--dry-run", action="store_true",
|
parser.add_argument("--dry-run", action="store_true",
|
||||||
help="Только проверить разбор данных и разбиение, без обучения")
|
help="Только проверить разбор данных и разбиение, без обучения")
|
||||||
|
parser.add_argument("--export-split", default=None,
|
||||||
|
help="Записать разбиение по текущим данным и меткам в файл и выйти. "
|
||||||
|
"Файл затем передаётся в --split-file для сравнения вариантов")
|
||||||
return parser
|
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")
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
|
||||||
args = build_parser().parse_args(argv)
|
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:
|
if args.dry_run:
|
||||||
set_seed(args.seed)
|
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(
|
train_ds, val_ds, cfg = make_datasets(
|
||||||
data_root=args.data_root,
|
data_root=args.data_root,
|
||||||
annotation_path=args.annotation_path,
|
annotation_path=args.annotation_path,
|
||||||
|
|
@ -509,6 +574,8 @@ def main(argv: Optional[List[str]] = None) -> int:
|
||||||
val_fraction=args.val_fraction,
|
val_fraction=args.val_fraction,
|
||||||
seed=args.seed,
|
seed=args.seed,
|
||||||
preprocess=PreprocessConfig(norm=args.norm),
|
preprocess=PreprocessConfig(norm=args.norm),
|
||||||
|
labels_csv=labels_csv,
|
||||||
|
val_studies=val_studies,
|
||||||
)
|
)
|
||||||
print(f"Preprocess: {cfg.to_dict()}")
|
print(f"Preprocess: {cfg.to_dict()}")
|
||||||
print("\nTrain split:\n" + format_summary(train_ds.records))
|
print("\nTrain split:\n" + format_summary(train_ds.records))
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
]
|
||||||
132
src/main.py
|
|
@ -22,6 +22,7 @@ FastAPI сервер для оценки качества DXA исследова
|
||||||
"""
|
"""
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
|
from datetime import datetime
|
||||||
from fastapi import FastAPI, File, UploadFile, Query
|
from fastapi import FastAPI, File, UploadFile, Query
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
@ -42,7 +43,9 @@ from src.dxa.inference import (
|
||||||
mask_png_base64,
|
mask_png_base64,
|
||||||
predict_from_bytes,
|
predict_from_bytes,
|
||||||
)
|
)
|
||||||
|
from src.dxa.model_card import model_card
|
||||||
from src.dxa.preprocess import load_array_from_bytes
|
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.utils.utils import get_device
|
||||||
from src.quality.quality_scorer import convert_to_serializable
|
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_model = None
|
||||||
dxa_preprocess = None
|
dxa_preprocess = None
|
||||||
dxa_threshold = 0.0
|
dxa_threshold = 0.0
|
||||||
|
dxa_metadata: Dict = {}
|
||||||
device = None
|
device = None
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -75,12 +79,14 @@ def load_model():
|
||||||
|
|
||||||
Модель загружается глобально при первом запросе и сохраняется в памяти.
|
Модель загружается глобально при первом запросе и сохраняется в памяти.
|
||||||
Путь к чекпоинту берётся из переменной окружения DXA_MODEL_PATH, чтобы
|
Путь к чекпоинту берётся из переменной окружения DXA_MODEL_PATH, чтобы
|
||||||
контейнер не зависел от текущего рабочего каталога.
|
контейнер не зависел от текущего рабочего каталога. Метаданные чекпоинта
|
||||||
|
сохраняются: они содержат источник разметки, разбиение и валидационные
|
||||||
|
метрики, которые отдаёт `/api/v1/health` и `/api/v1/model`.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
DXAQualityModel: Обученная модель или None при ошибке
|
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:
|
if dxa_model is None:
|
||||||
device = get_device()
|
device = get_device()
|
||||||
|
|
@ -91,6 +97,7 @@ def load_model():
|
||||||
dxa_model = checkpoint.model
|
dxa_model = checkpoint.model
|
||||||
dxa_preprocess = checkpoint.preprocess
|
dxa_preprocess = checkpoint.preprocess
|
||||||
dxa_threshold = checkpoint.threshold
|
dxa_threshold = checkpoint.threshold
|
||||||
|
dxa_metadata = dict(checkpoint.metadata or {})
|
||||||
print(
|
print(
|
||||||
f"DXA model loaded successfully (threshold logit={dxa_threshold:.4f}, "
|
f"DXA model loaded successfully (threshold logit={dxa_threshold:.4f}, "
|
||||||
f"input={dxa_preprocess.input_size}, preprocess={dxa_preprocess.to_dict()})"
|
f"input={dxa_preprocess.input_size}, preprocess={dxa_preprocess.to_dict()})"
|
||||||
|
|
@ -102,6 +109,41 @@ def load_model():
|
||||||
return dxa_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):
|
def preprocess_dicom(dcm_bytes: bytes, input_size: int = 224):
|
||||||
"""
|
"""
|
||||||
Предобработка DICOM изображения для модели.
|
Предобработка DICOM изображения для модели.
|
||||||
|
|
@ -160,6 +202,11 @@ def prediction_to_result(prediction, ds, filename: Optional[str] = None) -> dict
|
||||||
"""
|
"""
|
||||||
prob = float(prediction.prob)
|
prob = float(prediction.prob)
|
||||||
is_violation = prediction.quality_class == 1
|
is_violation = prediction.quality_class == 1
|
||||||
|
# Тип нарушения приходит от эвристики по метрикам, а не от модели: модель
|
||||||
|
# решает только бинарную задачу. Код приводится к единому словарю
|
||||||
|
# (`src.dxa.violations`), а в ответе это помечено явно, чтобы интерфейс не
|
||||||
|
# показывал предположение как заключение.
|
||||||
|
violation_code = canon_type(prediction.violation_type)
|
||||||
|
|
||||||
result = {
|
result = {
|
||||||
"study_uid": str(getattr(ds, "StudyInstanceUID", "") or ""),
|
"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,
|
"anatomical_region": prediction.anatomical_region,
|
||||||
"quality_class": prediction.quality_class,
|
"quality_class": prediction.quality_class,
|
||||||
"quality_label": "Есть нарушение качества" if is_violation else "Качественное изображение",
|
"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,
|
"reason": prediction.violation_reason,
|
||||||
"confidence": round(prob, 4),
|
"confidence": round(prob, 4),
|
||||||
"confidence_per_class": {
|
"confidence_per_class": {
|
||||||
|
|
@ -218,15 +272,41 @@ async def root():
|
||||||
|
|
||||||
@app.get("/api/v1/health")
|
@app.get("/api/v1/health")
|
||||||
async def health_check():
|
async def health_check():
|
||||||
"""Health check"""
|
"""
|
||||||
|
Статус сервиса и сведения о загруженной модели.
|
||||||
|
|
||||||
|
Кроме флага загрузки отдаётся происхождение чекпоинта: на какой разметке он
|
||||||
|
обучен, какое разбиение использовано и с каким порогом принимается решение.
|
||||||
|
Без этого по интерфейсу нельзя было понять, какая версия модели работает.
|
||||||
|
"""
|
||||||
model = load_model()
|
model = load_model()
|
||||||
return {
|
return {
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"model_loaded": model is not None,
|
"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")
|
@app.post("/api/v1/analyze")
|
||||||
async def analyze_dicom(file: UploadFile = File(...)):
|
async def analyze_dicom(file: UploadFile = File(...)):
|
||||||
"""Analyze single DICOM 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"})
|
return JSONResponse(status_code=500, content={"error": "Model not loaded"})
|
||||||
|
|
||||||
quality_report = prediction_to_result(prediction, ds)
|
quality_report = prediction_to_result(prediction, ds)
|
||||||
snomed_map = {
|
# Код, формулировка и флаг завершения берутся из единого словаря типов
|
||||||
"artifact_motion": ("Motion artifact", "WARNING"),
|
# нарушений — раньше здесь была третья копия соответствий.
|
||||||
"artifact_other": ("Foreign object artifact", "WARNING"),
|
code, english_label, completion = sr_code(quality_report["violation_type"])
|
||||||
"rotation": ("Rotational misalignment", "WARNING"),
|
quality_report["violation_code"] = code
|
||||||
"roi_error": ("Region of interest mismatch", "WARNING"),
|
quality_report["violation_label"] = english_label
|
||||||
"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
|
|
||||||
quality_report["completion"] = completion
|
quality_report["completion"] = completion
|
||||||
|
|
||||||
sr_content = generate_dicom_sr_text(
|
sr_content = generate_dicom_sr_text(
|
||||||
|
|
@ -524,31 +597,14 @@ def generate_dicom_sr_text(study_uid: str, image_uid: str, quality_report: Dict)
|
||||||
study_uid: StudyInstanceUID из DICOM
|
study_uid: StudyInstanceUID из DICOM
|
||||||
image_uid: SOPInstanceUID из DICOM
|
image_uid: SOPInstanceUID из DICOM
|
||||||
quality_report: Результат generate_quality_report()
|
quality_report: Результат generate_quality_report()
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
str: Текстовое представление SR отчета
|
str: Текстовое представление SR отчета
|
||||||
"""
|
"""
|
||||||
|
# Коды и формулировки — из единого словаря типов нарушений
|
||||||
# Соответствие типа нарушения коду отчёта. Коды условные (локальные), так
|
# (`src.dxa.violations`); здесь только раскладка отчёта.
|
||||||
# как полноценного справочника SNOMED/DICOM для контролёра качества DXA в
|
code, label, completion = sr_code(quality_report.get("violation_type"))
|
||||||
# наборе нет; текстовая формулировка приводится рядом.
|
|
||||||
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")
|
|
||||||
)
|
|
||||||
|
|
||||||
sr_lines = [
|
sr_lines = [
|
||||||
"DICOM Structured Report - DXA Quality Assessment",
|
"DICOM Structured Report - DXA Quality Assessment",
|
||||||
"=" * 50,
|
"=" * 50,
|
||||||
|
|
|
||||||
|
|
@ -43,9 +43,9 @@ const { chromium } = require(resolvePlaywright());
|
||||||
const BASE = process.env.BASE_URL || 'http://127.0.0.1:8123';
|
const BASE = process.env.BASE_URL || 'http://127.0.0.1:8123';
|
||||||
const REPO = path.resolve(__dirname, '..', '..');
|
const REPO = path.resolve(__dirname, '..', '..');
|
||||||
const FILES = [
|
const FILES = [
|
||||||
path.join(REPO, 'dataset_hack/Для теста/spine.dcm'),
|
path.join(REPO, 'dataset_hack/Для теста/spine_01.dcm'),
|
||||||
path.join(REPO, 'dataset_hack/Для теста/l_hip.dcm'),
|
path.join(REPO, 'dataset_hack/Для теста/l_hip_01.dcm'),
|
||||||
path.join(REPO, 'dataset_hack/Для теста/r_hip.dcm'),
|
path.join(REPO, 'dataset_hack/Для теста/r_hip_01.dcm'),
|
||||||
];
|
];
|
||||||
|
|
||||||
async function panelState(page) {
|
async function panelState(page) {
|
||||||
|
|
@ -106,9 +106,67 @@ async function panelState(page) {
|
||||||
total: document.getElementById('totalCount').innerText,
|
total: document.getElementById('totalCount').innerText,
|
||||||
ok: document.getElementById('okCount').innerText,
|
ok: document.getElementById('okCount').innerText,
|
||||||
violation: document.getElementById('violationCount').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 shots = [];
|
||||||
const states = [];
|
const states = [];
|
||||||
for (const idx of [0, 1, 2]) {
|
for (const idx of [0, 1, 2]) {
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ const { chromium } = require(resolvePlaywright());
|
||||||
|
|
||||||
const BASE = process.env.BASE_URL || 'http://127.0.0.1:8123';
|
const BASE = process.env.BASE_URL || 'http://127.0.0.1:8123';
|
||||||
const REPO = path.resolve(__dirname, '..', '..');
|
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 () => {
|
(async () => {
|
||||||
const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dxa-offline-'));
|
const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dxa-offline-'));
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ needs_assets = pytest.mark.skipif(
|
||||||
# Поля, которые панель деталей читает напрямую из ответа.
|
# Поля, которые панель деталей читает напрямую из ответа.
|
||||||
DETAIL_TOP_LEVEL = [
|
DETAIL_TOP_LEVEL = [
|
||||||
"anatomical_region", "quality_class", "quality_label", "violation_type",
|
"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",
|
"reason", "confidence", "confidence_per_class", "threshold_probability",
|
||||||
"region_confidence", "overall_quality", "severity", "metrics",
|
"region_confidence", "overall_quality", "severity", "metrics",
|
||||||
"view_quality", "reasons", "metrics_note",
|
"view_quality", "reasons", "metrics_note",
|
||||||
|
|
@ -160,6 +161,154 @@ class TestDetailedContract:
|
||||||
for name, data in payloads:
|
for name, data in payloads:
|
||||||
assert "не калиброваны" in data["metrics_note"], name
|
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
|
@needs_assets
|
||||||
class TestBasicContract:
|
class TestBasicContract:
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
Проверяют правила, от которых зависит обучение: метка из имени файла,
|
Проверяют правила, от которых зависит обучение: метка из имени файла,
|
||||||
склейка побайтных дублей и разбиение по исследованиям без утечки.
|
склейка побайтных дублей и разбиение по исследованиям без утечки.
|
||||||
"""
|
"""
|
||||||
|
import json
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
@ -15,11 +16,14 @@ from src.dxa.labels import ( # noqa: E402
|
||||||
QUALITY_BAD,
|
QUALITY_BAD,
|
||||||
QUALITY_GOOD,
|
QUALITY_GOOD,
|
||||||
ImageRecord,
|
ImageRecord,
|
||||||
|
export_split,
|
||||||
format_summary,
|
format_summary,
|
||||||
label_summary,
|
label_summary,
|
||||||
|
load_split,
|
||||||
marker_from_filename,
|
marker_from_filename,
|
||||||
region_from_filename,
|
region_from_filename,
|
||||||
scan_dataset,
|
scan_dataset,
|
||||||
|
split_by_studies,
|
||||||
stratified_group_split,
|
stratified_group_split,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -137,6 +141,52 @@ class TestLabelSummary:
|
||||||
assert "Unique images: 2" in text
|
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
|
@needs_dataset
|
||||||
class TestRealDataset:
|
class TestRealDataset:
|
||||||
"""Проверки на реальном датасете: инварианты, влияющие на обучение."""
|
"""Проверки на реальном датасете: инварианты, влияющие на обучение."""
|
||||||
|
|
|
||||||
|
|
@ -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"}
|
||||||
|
|
@ -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}
|
||||||
|
|
@ -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())
|
||||||