diff --git a/.gitignore b/.gitignore
index cadd626..d4906a5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,3 +6,8 @@
.vscode/
/.pytest_cache/
/venv/
+
+# Автономный HTML-пакет для ручной разметки (снимки внутри, ~7 МБ) —
+# генерируется командой `python -m src.dxa.review_pack --out review/doctor_review.html`,
+# в репозитории не хранится.
+/review/
diff --git a/Dockerfile b/Dockerfile
index 6776d9a..94127fa 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -46,10 +46,12 @@ RUN python -c "import src.main; print('app import ok')"
# --- Проверка статики для офлайн-работы ---
# Веб-интерфейс не должен зависеть от CDN: Tailwind и FontAwesome лежат в
-# src/api/static/vendor и src/api/static/webfonts (см. index.html).
+# src/api/static/vendor и src/api/static/webfonts (см. index.html). Страница
+# ручной разметки /label тоже должна попасть в образ целиком.
RUN python -c "import os; \
files=['src/api/static/vendor/tailwind.js','src/api/static/vendor/fontawesome.css', \
- 'src/api/static/webfonts/fa-solid-900.woff2']; \
+ 'src/api/static/webfonts/fa-solid-900.woff2', \
+ 'src/api/static/label.html','src/api/static/js/labeling.js']; \
missing=[f for f in files if not os.path.exists(f)]; \
assert not missing, f'missing frontend assets: {missing}'; \
print('frontend assets ok')"
diff --git a/Dockerfile_cuda b/Dockerfile_cuda.txt
similarity index 100%
rename from Dockerfile_cuda
rename to Dockerfile_cuda.txt
diff --git a/QWEN.md b/QWEN.md
index e1c2ce3..5a65e4e 100644
--- a/QWEN.md
+++ b/QWEN.md
@@ -37,6 +37,8 @@
src/dxa/
├── labels.py # имена -> метки, склейка дублей, разбиение, фиксация сплита
├── excel_labels.py # разметка снимков по экспертной таблице (labels_images.csv)
+├── manual_labels.py # вердикты специалиста: хранение, наложение, выгрузка (/label)
+├── review_pack.py # автономный HTML-пакет разметки для врача (без сервера и сети)
├── rename_files.py # приведение имён DICOM к виду область_NN[_метка]
├── violations.py # единый словарь типов нарушений (коды, подписи, коды SR)
├── model_card.py # карточка решения: разметка, данные, метрики, ограничения
@@ -63,14 +65,14 @@ src/dxa/
| Решение | Причина |
|---|---|
| Единый словарь типов нарушений (`src/dxa/violations.py`) | Коды, подписи и коды SR были в трёх копиях (инференс, `main.py`, `dxa-app.js`) и не знали кодов экспертной таблицы. Теперь подписи отдаёт сервер, фронт копий не держит |
-| Метки только из экспертной таблицы (правило `table`): `labels/labels_images.csv`, 77 нарушений | Таблица описывает исследование, но каждая область встречается в нём один раз, поэтому вердикт переносится на снимок однозначно. Правило выбрано измерением: учёт ручных пометок из имён файлов дал ROC-AUC 0.6199 против 0.6764, хуже на всех 5 seed'ах. См. `assets/labeling.md` |
-| Склейка побайтных дублей | 544 файла, но 252 уникальных снимка; без склейки снимок попадал в оба класса |
+| Метки только из экспертной таблицы (правило `table`): `labels/labels_images.csv`, 76 нарушений | Таблица описывает исследование, но каждая область встречается в нём один раз, поэтому вердикт переносится на снимок однозначно. Правило выбрано измерением: учёт ручных пометок из имён файлов дал ROC-AUC 0.6003 против 0.6726, хуже на всех 5 seed'ах. См. `assets/labeling.md` |
+| Склейка побайтных дублей | 482 файла, но 251 уникальный снимок; без склейки снимок попадал в оба класса |
| Разбиение по исследованиям, не по снимкам | Исключение утечки: снимки одного исследования в одной части |
| Линейный зонд (замороженный backbone) | Полный fine-tune при ~250 снимках переобучается (val AUC → 0.5) |
| Порог по логиту, подбор по F1 | При доле нарушений около 30 % порог 0.5 даёт почти нулевой recall; вероятности насыщаются |
| Аугментация выключена по умолчанию | Яркость и положение сами являются признаками качества: AUC 0.87 → 0.56 |
| Область по ширине кадра | Позвоночник 300 px, бедро 280 px; 99/99 для позвоночника |
-| Панель деталей показывает измерения, а не вердикты | Эвристики `detailed_assessment` не калиброваны: `motion_detected`/`any_detected` истинны почти всегда, ROI-отступы срабатывают для 227/252 снимков |
+| Панель деталей показывает измерения, а не вердикты | Эвристики `detailed_assessment` не калиброваны: `roi.valid` ложен для всех 251 снимка (краевой отступ срабатывает у 249 из 251), поэтому в панели показываются числовые измерения, а не вердикты «Да/Нет» |
### Проверка вклада модели
@@ -86,12 +88,12 @@ python -m src.dxa.discriminator --model-path models/dxa_model.pth
| Предиктор | Общий AUC | spine | hip_right | hip_left |
|---|---|---|---|---|
-| Модель | 0.854 | 0.928 | 0.861 | 0.853 |
-| Правило «позвоночник = нарушение» | 0.529 | 0.500 | 0.500 | 0.500 |
+| Модель | 0.885 | 0.954 | 0.875 | 0.880 |
+| Правило «позвоночник = нарушение» | 0.534 | 0.500 | 0.500 | 0.500 |
-Модель использует содержимое снимка: внутри областей она даёт 0.85–0.93.
+Модель использует содержимое снимка: внутри областей она даёт 0.88–0.95.
Правило по области внутри области всегда 0.50 (подсказки нет). Честная оценка на
-held-out — в `assets/labeling.md` §8: ROC-AUC 0.6764 [0.6309, 0.7218] по пяти seed'ам.
+held-out — в `assets/labeling.md` §8: ROC-AUC 0.6726 [0.6367, 0.7086] по пяти seed'ам.
## Данные (`dataset_hack/`)
@@ -99,7 +101,7 @@ held-out — в `assets/labeling.md` §8: ROC-AUC 0.6764 [0.6309, 0.7218] по
```
dataset_hack/
-├── Для теста/ # bad.dcm, l_hip.dcm, r_hip.dcm, spine.dcm
+├── Для теста/ # l_hip_01.dcm, r_hip_01.dcm, r_hip_01_bad.dcm, spine_01.dcm
└── НД_для_обучения/
├── разметка.xlsx # экспертная оценка на уровне ИССЛЕДОВАНИЯ
└── Исследования//.../_[_good|_bad].dcm
@@ -107,12 +109,13 @@ dataset_hack/
Факты, важные для обучения:
-- 544 файла на диске, но **252 уникальных снимка** (по пиксельному содержимому)
- на 100 исследований: позвоночник 99, бедро R 79, бедро L 73, 1 с неопределённой
+- **482 файла `.dcm` на диске** (было 520: удалены 38 лишних побайтных копий и 10
+ файлов `.DS_Store`), но **251 уникальный снимок** (по пиксельному содержимому)
+ на 100 исследований: позвоночник 99, бедро R 78, бедро L 73, 1 с неопределённой
областью.
-- Экспертная таблица отмечает нарушения у **74 снимков (29.4 %)**; три снимка
+- Экспертная таблица отмечает нарушения у **73 снимков (29.1 %)**; три снимка
таблица область не оценивала.
-- Рабочая разметка (правило `table`) — **77 нарушений из 252 (30.6 %)**: 74 по
+- Рабочая разметка (правило `table`) — **76 нарушений из 251 (30.3 %)**: 73 по
таблице плюс 3 снимка без экспертной оценки, помеченных `filename_fallback`.
- Дубли не пересекают границы исследований, конфликтов меток при склейке нет.
Два побайтных дубля названы по-разному, поэтому область определяется
@@ -121,7 +124,9 @@ dataset_hack/
`l_hip`, `r_hip`); инструмент — `src/dxa/rename_files.py`, карта отката —
`labels/rename_map.csv`. Суффиксы `_good`/`_bad` проставлялись вручную, в
метках **не участвуют** — только как диагностический столбец
- `quality_from_filename`: они расходились с оценкой эксперта в 15 случаях из 252.
+ `quality_from_filename`: они расходились с оценкой эксперта в 62 случаях из 251
+ (повторное переименование 2026-09-27 дописало пометки уже после сборки
+ разметки и с тех пор расхождений стало вчетверо больше).
- В DICOM **нет** разметки ROI (ни OverlayData, ни GraphicAnnotationSequence) и
пусты теги `Laterality`/`ImageLaterality`, поэтому ни корректность областей, ни
сторону бедра нельзя проверить по метаданным.
@@ -174,10 +179,10 @@ python -m src.dxa.train --head mlp --freeze-epochs 0 --epochs 30
рассинхронизироваться с обучением, а по файлу видно, на какой разметке он обучен.
Рабочий чекпоинт — `models/dxa_model.pth` (правило `table`, seed 42 по умолчанию,
-эпоха 39, порог логита −0.4930 → вероятность 0.379; val ROC-AUC 0.6706 при
-честной оценке 0.6764 [0.6309, 0.7218] по пяти seed'ам). Это единственный
-чекпоинт в репозитории: прежние версии и прогоны сравнения удалены, откатиться
-можно только переобучением.
+эпоха 57, порог логита −0.1370 → вероятность 0.466; val ROC-AUC 0.6689 при
+честной оценке 0.6726 [0.6367, 0.7086] по пяти seed'ам). Переобучен 2026-09-27
+после пересборки разметки. Это единственный чекпоинт в репозитории: прежние
+версии и прогоны сравнения удалены, откатиться можно только переобучением.
---
@@ -186,6 +191,7 @@ python -m src.dxa.train --head mlp --freeze-epochs 0 --epochs 30
| Метод | Путь | Назначение |
|---|---|---|
| GET | `/` | Веб-интерфейс |
+| GET | `/label` | Интерфейс ручной разметки (подтверждение вердиктов специалистом) |
| GET | `/api/v1/health` | Статус, признак загрузки модели и её происхождение (разметка, разбиение, порог, эпоха) |
| GET | `/api/v1/model` | Карточка решения: разметка, данные, метрики с интервалами, словарь нарушений, ограничения |
| POST | `/api/v1/analyze` | Базовый анализ файла |
@@ -193,6 +199,11 @@ python -m src.dxa.train --head mlp --freeze-epochs 0 --epochs 30
| POST | `/api/v1/analyze/sr` | Текстовый отчёт DICOM SR |
| POST | `/api/v1/batch` | Пакетный анализ |
| POST | `/api/v1/export` | Пакетный анализ + XLSX |
+| GET | `/api/v1/labeling/items` | Снимки датасета для разбора: текущая метка, её источник, расхождения |
+| GET | `/api/v1/labeling/image` | PNG снимка для просмотра (путь проверяется на выход за каталог датасета) |
+| POST | `/api/v1/labeling/verdict` | Сохранить вердикт специалиста в `labels/manual_labels.csv` |
+| DELETE | `/api/v1/labeling/verdict` | Снять вердикт и вернуть снимок к построенной разметке |
+| GET | `/api/v1/labeling/export` | Выгрузка разметки: `scope=all` годится как `--labels-csv` |
Путь к модели — переменная окружения `DXA_MODEL_PATH` (по умолчанию
`models/dxa_model.pth`), чтобы контейнер не зависел от рабочего каталога.
@@ -205,7 +216,7 @@ API и CLI используют один код предсказания (`predi
```bash
./run.sh test
-python -m pytest tests/ -q # 209 тестов
+python -m pytest tests/ -q # 272 теста
```
- `tests/test_labels.py` — разбор имён, склейка дублей, отсутствие утечки при
@@ -215,7 +226,10 @@ python -m pytest tests/ -q # 209 тестов
- `tests/test_excel_labels.py` — разметка по экспертной таблице: чтение
критериев, «1 = нарушение», голосование по области, перенос оценки на
единственное бедро, три правила метки (`table` / `union` / `expert`) и их
- согласованность, подключение к обучению.
+ согласованность, подключение к обучению, чтение CSV, сохранённого Excel с BOM.
+- `tests/test_manual_labels.py` — ручная разметка: проверка вердикта (область,
+ метка, тип нарушения и его совместимость с областью), хранение и правка,
+ наложение поверх построенной разметки, подсчёт прогресса и выгрузка.
- `tests/test_violations.py` — единый словарь типов: коды и подписи, коды SR,
приведение устаревших значений, согласованность с критериями таблицы.
- `tests/test_preprocess_and_model.py` — предобработка, метрики, подбор порога,
@@ -224,6 +238,13 @@ python -m pytest tests/ -q # 209 тестов
(панель деталей ранее показывала прочерки из-за расхождения ключей),
различимость метрик между снимками, валидность PNG-визуализаций, отсутствие
некалиброванных вердиктов в ответе.
+- `tests/test_labeling_api.py` — контракт `/api/v1/labeling/*`: отказ отдавать
+ файлы вне датасета (обход каталога, не-DICOM), проверка вердикта, выгрузка,
+ читаемая обучением как `--labels-csv`.
+- `tests/test_review_pack.py` — пакет для врача: отпечаток набора, порядок
+ (расхождения первыми), встроенные данные разбираются как JSON, страница не
+ ссылается на сеть, слияние вердиктов с построенной разметкой и сообщение о
+ путях из чужого пакета.
### Проверка веб-интерфейса в браузере
@@ -237,6 +258,17 @@ python -m pytest tests/ -q # 209 тестов
значения панели меняются при переключении строк; панель «О модели» наполняется
метриками и словарём (иначе раздел остался бы пустым каркасом).
- `ui_violation.js` — ветка «нарушение» (бейдж, POOR, HIGH, заключение).
+- `ui_labeling.js` — интерфейс ручной разметки: список снимков с прогрессом,
+ расхождения первыми, снимок отрисовывается, вердикт сохраняется и переживает
+ перезагрузку страницы, фильтр «только расхождения», отсутствие внешних запросов.
+ Запускать с временным файлом вердиктов:
+ `DXA_MANUAL_LABELS=/tmp/manual_ui.csv python -m uvicorn src.main:app --port 8123`,
+ иначе проверка пишет в рабочий `labels/manual_labels.csv`.
+- `review_pack.js` — автономный пакет разметки: открывает HTML прямо с диска
+ (`file://`, сервер не нужен), проверяет отрисовку встроенного снимка, вердикт,
+ его сохранение после перезагрузки, выгрузку CSV и восстановление прогресса из
+ этого же файла, а также отсутствие любых сетевых запросов. Запуск:
+ `PACK=review/doctor_review.html node tests/browser/review_pack.js`.
- `ui_offline.js` — страница не обращается к внешним хостам.
### Честность интерфейса
@@ -263,9 +295,10 @@ Tailwind и FontAwesome лежат локально (`src/api/static/vendor`,
1. Разметка снимков выведена из таблицы, описывающей исследование: поштучной
экспертной оценки снимков в наборе нет. Оценка качества модели упирается в
- качество этой разметки, а не только в объём данных.
-2. Мало данных: 252 снимка, 77 нарушений; доверительные интервалы широкие
- (ROC-AUC 0.6764 [0.6309, 0.7218] по пяти seed'ам).
+ качество этой разметки, а не только в объём данных. Для поштучной разметки есть
+ интерфейс `/label` (см. ниже) — им ещё не пользовались.
+2. Мало данных: 251 снимок, 76 нарушений; доверительные интервалы широкие
+ (ROC-AUC 0.6726 [0.6367, 0.7086] по пяти seed'ам).
3. Эталон оценки — та же экспертная таблица, независимой истины нет; сравнение
правил разметки частично благоприятствует варианту «только таблица».
4. Тип нарушения определяется эвристиками, а не обученной моделью; 5 снимков
@@ -275,6 +308,52 @@ Tailwind и FontAwesome лежат локально (`src/api/static/vendor`,
6. Сторона бедра в 7 исследованиях с единственным снимком не проверяема: теги
`Laterality` пусты, оценка взята из единственного заполненного столбца.
7. Порог `SPINE_MIN_WIDTH` привязан к текущему оборудованию.
+8. Разметка и датасет уже расходились: повторное переименование 2026-09-27
+ дописало суффиксы `_good`/`_bad` после сборки разметки, из-за чего 20 из 252
+ путей устарели, 6 снимков получали метку «нарушение» вопреки эксперту и
+ пайплайн отдавал 251/82 вместо 252/77. Расхождение устранено пересборкой
+ разметки и переобучением в тот же день; числа в этом файле — уже новые.
+ Разбор — в `assets/labeling.md` §7.
+
+## Ручная разметка (`/label`)
+
+Поштучной экспертной оценки снимков в наборе нет (ограничение №1), а раздел 2.6
+задания просит «автоматическую коррекцию разметки с возможностью подтверждения
+специалистом». Поэтому в сервисе есть отдельный интерфейс `/label`:
+`src/dxa/manual_labels.py` — хранение вердиктов, эндпоинты `/api/v1/labeling/*` —
+список снимков, просмотр и сохранение, страница `src/api/static/label.html` +
+`js/labeling.js`.
+
+Вердикты лежат в `labels/manual_labels.csv` (переменная `DXA_MANUAL_LABELS`) в
+формате построенной разметки, поэтому файл читается тем же `load_labels_csv` и
+принимается обучением как `--labels-csv`. Выгрузка `scope=all` накладывает ручные
+вердикты на построенную разметку и годится как источник меток напрямую.
+
+Оценка модели в интерфейсе намеренно не показывается: подсказка смещала бы
+разметчика, а цель — независимое суждение человека. Список выводит первыми
+снимки, где метка разметки расходится с пометкой в имени файла: там ошибка
+возможна в любом из источников.
+
+### Пакет для врача (разметка без сервиса)
+
+`/label` требует запущенного сервиса и Python, а размечать должен врач — на своей
+машине. Поэтому тот же сценарий собирается в **один HTML-файл**:
+
+```bash
+./run.sh review --out review/doctor_review.html # ~7 МБ, 251 снимок
+./run.sh review --limit 20 --out review/pilot.html # пилот на выборке
+./run.sh review --merge review/doctor.csv --out labels/labels_images_reviewed.csv
+```
+
+Снимки встроены как data-URI, вердикты лежат в `localStorage` браузера, выгрузка —
+CSV в формате `manual_labels.csv` (плюс колонка `pack_id` — отпечаток набора,
+чтобы различить пакеты). Файл открывается двойным щелчком, работает без сети и
+без установки чего-либо; каталог `review/` в git не хранится.
+
+Вердикты врача принимаются как есть: `load_verdicts` читает выгрузку, `--merge`
+накладывает её на построенную разметку (и сообщает о путях, которых нет в
+датасете — признак чужого пакета), а `--labels-csv` с этим файлом годится для
+обучения. Оценка модели в пакет не попадает по той же причине, что и в `/label`.
## Удалённый устаревший код
@@ -292,6 +371,19 @@ position_validator,universal_scorer,medical_quality}.py`) удалены 2026-09
`__version__`, а `src/quality/__init__.py` — только докстрока. При добавлении
новых модулей в `src/quality/` помнить, что пакет больше ничего не импортирует.
+`docs/` после удаления появился снова — владелец вернул его под сдачу: там лежат
+`technical-description.html` (сдаточный технический документ) и его PDF-версия,
+а также презентация команды. Это не материалы README: корневой `README.md`
+ссылается только на `assets/`. PDF печатается из HTML тем же Chrome, а не
+отдельным конвертером:
+
+```bash
+"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --headless \
+ --disable-gpu --no-pdf-header-footer --user-data-dir=/tmp/chrome-pdf-profile \
+ --print-to-pdf="$PWD/docs/technical-description.pdf" \
+ "file://$PWD/docs/technical-description.html"
+```
+
---
## Docker
diff --git a/README.md b/README.md
index 5244ede..365c01a 100644
--- a/README.md
+++ b/README.md
@@ -96,15 +96,16 @@ DICOM ──▶ предобработка ──▶ ResNet18 (замороже
из экспертной таблицы командой `./run.sh label` (разбор — в
`assets/labeling.md`): в таблице отмечены критерии качества по каждому
исследованию, а каждая область встречается в нём ровно один раз, поэтому
- вердикт переносится на снимок однозначно. Такой разметки — 77 нарушений из
- 252 (30.6 %). Правило выбрано измерением: учёт ручных пометок из имён файлов
+ вердикт переносится на снимок однозначно. Такой разметки — 76 нарушений из
+ 251 (30.3 %). Правило выбрано измерением: учёт ручных пометок из имён файлов
дал худший результат на held-out наборе, поэтому в метках они не участвуют.
Резервный режим `--labels-csv ""` берёт метку из суффикса `_good`/`_bad` и
оставлен для совместимости.
-3. **Склейка побайтных дублей.** В датасете 544 файла, но 252 уникальных снимка:
+3. **Склейка побайтных дублей.** В датасете 482 файла, но 251 уникальный снимок:
один и тот же кадр сохранён многократно под разными именами (часть — с меткой,
- часть — без). Без склейки одно изображение попадало бы в оба класса.
+ часть — без). Без склейки одно изображение попадало бы в оба класса. Лишние
+ байт-идентичные копии удалены.
4. **Разбиение по исследованиям.** Снимки одного исследования не попадают
одновременно в train и val — иначе метрики завышаются за счёт утечки.
@@ -161,6 +162,7 @@ DICOM ──▶ предобработка ──▶ ResNet18 (замороже
| Метод | Путь | Назначение |
|---|---|---|
| GET | `/` | Веб-интерфейс |
+| GET | `/label` | Интерфейс ручной разметки: подтверждение и правка вердиктов специалистом |
| GET | `/api/v1/health` | Статус, признак загрузки модели и её происхождение |
| GET | `/api/v1/model` | Карточка решения: разметка, данные, метрики с интервалами, словарь нарушений |
| POST | `/api/v1/analyze` | Базовый анализ одного файла |
@@ -168,6 +170,11 @@ DICOM ──▶ предобработка ──▶ ResNet18 (замороже
| POST | `/api/v1/analyze/sr` | Текстовое представление отчёта DICOM SR |
| POST | `/api/v1/batch` | Пакетный анализ |
| POST | `/api/v1/export` | Пакетный анализ и выгрузка в XLSX |
+| GET | `/api/v1/labeling/items` | Снимки датасета для разбора: текущая метка, её источник, расхождения |
+| GET | `/api/v1/labeling/image` | PNG снимка для просмотра |
+| POST | `/api/v1/labeling/verdict` | Сохранить вердикт специалиста |
+| DELETE | `/api/v1/labeling/verdict` | Снять вердикт, вернуть снимок к построенной разметке |
+| GET | `/api/v1/labeling/export` | Выгрузка разметки: `scope=all` принимается обучением как `--labels-csv` |
```bash
curl -X POST http://localhost:8000/api/v1/analyze -F "file=@study/spine.dcm"
@@ -258,27 +265,85 @@ curl -X POST http://localhost:8000/api/v1/analyze -F "file=@study/spine.dcm"
- числовые метрики в панели деталей по-прежнему идут под дисклеймером о
некалиброванности порогов.
+### Ручная разметка (`/label`)
+
+Поштучной экспертной оценки снимков в наборе нет: таблица оценивает исследование,
+и снимок наследует вердикт исследования. Закрыть это автоматически нечем —
+локальная vision-модель оказалась непригодна (`assets/labeling.md` §11). Поэтому
+в сервисе есть отдельный интерфейс ручной разметки: раздел 2.6 задания просит
+«автоматическую коррекцию разметки с возможностью подтверждения специалистом».
+
+Откройте `http://localhost:8000/label`. Интерфейс показывает снимок целиком и его
+текущую метку с указанием источника (`экспертная таблица`, `имя файла`, `ручной
+вердикт`). Снимки, где метка разметки расходится с пометкой в имени файла, идут
+первыми: там ошибка возможна в любом из источников. Специалист подтверждает
+вердикт или ставит свой — область, «годное / нарушение», тип нарушения,
+комментарий.
+
+Вердикты сохраняются в `labels/manual_labels.csv` в том же формате, что и
+построенная разметка, поэтому файл можно сразу передать обучению:
+
+```bash
+curl -o manual_labels.csv "http://localhost:8000/api/v1/labeling/export?scope=all"
+python -m src.dxa.train --labels-csv manual_labels.csv
+```
+
+`scope=all` накладывает ручные вердикты на построенную разметку (весь набор),
+`scope=reviewed` отдаёт только разобранные снимки — для сверки с построенной
+разметкой. Оценка модели в интерфейсе намеренно не показывается: подсказка
+смещала бы разметчика, а ценность здесь именно в независимом суждении.
+
+### Передать разметку врачу: автономный HTML-пакет
+
+Интерфейс `/label` требует запущенного сервиса, а размечать должен специалист — на
+своей машине, без Python и без сети. Поэтому тот же сценарий упаковывается в один
+файл: снимки встроены внутрь, вердикты хранятся в браузере, выгрузка — CSV.
+
+```bash
+./run.sh review --out review/doctor_review.html # весь набор, ~7 МБ
+./run.sh review --limit 20 --out review/pilot.html # пилот на выборке из 20 снимков
+```
+
+Файл врач открывает двойным щелчком: снимок, построенная метка с указанием
+источника, кнопки «годное / нарушение», тип нарушения, комментарий, горячие
+клавиши. Работает полностью офлайн — страница не делает ни одного сетевого
+запроса, и это проверяется автоматически (`tests/browser/review_pack.js`).
+Прогресс сохраняется в браузере, а кнопка «Выгрузить CSV» отдаёт файл, который
+принимается обучением как есть; загрузить его обратно можно на другой машине —
+кнопкой «Загрузить CSV».
+
+Вердикты врача накладываются на построенную разметку одной командой, с проверкой,
+что все пути есть в датасете:
+
+```bash
+./run.sh review --merge review/doctor.csv --out labels/labels_images_reviewed.csv
+python -m src.dxa.train --labels-csv labels/labels_images_reviewed.csv
+```
+
+Каталог `review/` в git не хранится: пакет генерируется, а внутри — медицинские
+снимки.
+
---
## Метрики
Метрики зависят от выбранного разбиения по исследованиям, поэтому приводятся
с разбросом. Основная оценка — на **фиксированном** разбиении по исследованиям
-(обучение 199 снимков / 81 исследование, валидация 53 снимка / 19 исследований,
+(обучение 198 снимков / 81 исследование, валидация 53 снимка / 19 исследований,
16 нарушений), пять seed'ов обучения, эталон — вердикт эксперта:
| Что измерено | Значение | Как измерено |
|---|---|---|
-| ROC-AUC | **0.6764** [0.6309, 0.7218] | 5 seed'ов на фиксированном разбиении |
-| PR-AUC | 0.4759 [0.4141, 0.5377] | там же; базовый уровень при 30 % нарушений — 0.30 |
-| F1 | 0.5676 [0.5270, 0.6082] | там же; порог подобран на той же валидации — смещено вверх |
-| ROC-AUC по областям | позвоночник 0.943, бедро R 0.576, бедро L 0.550 | рабочий чекпоинт, собственная валидация |
+| ROC-AUC | **0.6726** [0.6367, 0.7086] | 5 seed'ов на фиксированном разбиении |
+| PR-AUC | 0.4753 [0.4188, 0.5318] | там же; базовый уровень при 30 % нарушений — 0.30 |
+| F1 | 0.5559 [0.5212, 0.5906] | там же; порог подобран на той же валидации — смещено вверх |
+| ROC-AUC по областям | позвоночник 0.929, бедро R 0.606, бедро L 0.567 | рабочий чекпоинт, собственная валидация |
| Контрольная задача «позвоночник / бедро» | AUC 1.00 | проверка работоспособности пайплайна |
-| Модель использует снимок, а не область | AUC 0.854 против 0.529 у правила области | `discriminator` на всём наборе, включая обучающие снимки |
+| Модель использует снимок, а не область | AUC 0.885 против 0.534 у правила области | `discriminator` на всём наборе, включая обучающие снимки |
Метрики по областям — в `models/train_report.md`, он создаётся при обучении.
Разбивка важна, потому что нарушения распределены неравномерно: в позвоночнике
-33 из 99, у бёдер 21–22 из 73–79, а область почти однозначно определяется по
+33 из 99, у бёдер 20–22 из 73–78, а область почти однозначно определяется по
ширине кадра. Поэтому общий AUC частично отражает различение области, а не только
распознавание дефекта.
@@ -286,18 +351,18 @@ curl -X POST http://localhost:8000/api/v1/analyze -F "file=@study/spine.dcm"
Метку можно было строить только из экспертной таблицы или дополнительно
учитывать пометки, проставленные вручную в именах файлов (суффикс `_bad`).
-Пометки расходились с оценкой эксперта в 15 случаях из 252, поэтому правило
+Пометки расходились с оценкой эксперта в 62 случаях из 251, поэтому правило
выбиралось измерением: одно разбиение, пять 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.6726** [0.6367, 0.7086] | 0.6003 [0.5592, 0.6415] |
+| PR-AUC | **0.4753** [0.4188, 0.5318] | 0.3864 [0.3487, 0.4242] |
+| F1 | **0.5559** [0.5212, 0.5906] | 0.5354 [0.4978, 0.5729] |
+| Recall / Precision | 0.713 / 0.472 | 0.788 / 0.409 |
-Парная разница (только таблица − с суффиксами): ROC-AUC **+0.0564**
-[+0.0403, +0.0725], PR-AUC **+0.0713** [+0.0398, +0.1028] — знаки `+++++`, то
+Парная разница (только таблица − с суффиксами): ROC-AUC **+0.0723**
+[+0.0541, +0.0905], PR-AUC **+0.0889** [+0.0500, +0.1277] — знаки `+++++`, то
есть преимущество на всех пяти seed'ах.
**Принято правило «только экспертная таблица».** Пометки в именах файлов
@@ -322,12 +387,13 @@ curl -X POST http://localhost:8000/api/v1/analyze -F "file=@study/spine.dcm"
1. **Разметка выведена из оценки исследования.** Экспертная таблица описывает
исследование, а не снимок; перенос однозначен (область встречается один раз),
но поштучной экспертной оценки снимков в наборе нет. Происхождение каждой
- строки зафиксировано в `labels/labels_images.csv`. Разбор — в
+ строки зафиксировано в `labels/labels_images.csv`. Для поштучной разметки есть
+ интерфейс `/label` (см. «Веб-интерфейс»), им ещё не пользовались. Разбор — в
`assets/labeling.md`.
-2. **Мало данных.** 252 уникальных снимка, 77 нарушений. Доверительные интервалы
+2. **Мало данных.** 251 уникальный снимок, 76 нарушений. Доверительные интервалы
широкие; оценка на закрытом наборе может отличаться.
3. **Тип нарушения определяется эвристиками, а не обученной моделью.** Для
- честного мультикласса нужна разметка типов на уровне снимка.
+ честного мультикласса нужна разметка типов на уровне снимка — её даёт `/label`.
4. **Область определяется по размеру кадра.** Признак безошибочно работает на этом
оборудовании (99/99 для позвоночника), но при смене аппарата порог
`SPINE_MIN_WIDTH` потребует калибровки.
@@ -350,15 +416,18 @@ curl -X POST http://localhost:8000/api/v1/analyze -F "file=@study/spine.dcm"
| Проверка | Команда | Результат |
|---|---|---|
-| Модель использует снимок, а не только область | `python -m src.dxa.discriminator` | AUC 0.854 против 0.529 у правила «позвоночник = нарушение»; внутри областей у модели 0.85–0.93, у правила 0.50 |
+| Модель использует снимок, а не только область | `python -m src.dxa.discriminator` | AUC 0.885 против 0.534 у правила «позвоночник = нарушение»; внутри областей у модели 0.88–0.95, у правила 0.50 |
| Контракт API для веб-интерфейса | `python -m pytest tests/test_api_contract.py` | поля панели деталей, различимость метрик, PNG-визуализации, отсутствие некалиброванных вердиктов, канонические коды типов нарушений, карточка модели |
+| Ручная разметка | `python -m pytest tests/test_manual_labels.py tests/test_labeling_api.py` | проверка вердикта (область, метка, совместимость типа нарушения с областью), хранение и наложение на построенную разметку, отказ отдавать файлы вне датасета, выгрузка, читаемая обучением |
+| Пакет разметки для врача | `python -m pytest tests/test_review_pack.py` | отпечаток набора, порядок «расхождения первыми», встроенные данные, отсутствие сетевых ссылок в странице, слияние вердиктов с разметкой |
| Единый словарь нарушений | `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'ов в пользу экспертной таблицы |
+| Выбор правила метки | `./run.sh split && ./run.sh compare` | ROC-AUC 0.6726 против 0.6003 по эталону, парная Δ +0.0723 [+0.0541, +0.0905], 5/5 seed'ов в пользу экспертной таблицы |
| Метрики и порог | `python -m pytest tests/test_preprocess_and_model.py` | подбор порога при дисбалансе, roundtrip чекпоинта, BatchNorm |
| Веб-интерфейс в браузере | `node tests/browser/ui_check.js` | подсказка о кликабельности строк видна и скрывается на пустом фильтре; кнопка «Открыть» открывает панель; значения панели меняются при переключении строк; панель «О модели» наполняется метриками и словарём |
+| Интерфейс ручной разметки в браузере | `node tests/browser/ui_labeling.js` | расхождения идут первыми, снимок отрисовывается, вердикт сохраняется и переживает перезагрузку страницы, фильтр «только расхождения» работает, внешних запросов нет |
| Работа без сети | `node tests/browser/ui_offline.js` | ноль внешних запросов, стили и иконки на месте |
Браузерные проверки требуют запущенного сервера:
@@ -367,6 +436,9 @@ curl -X POST http://localhost:8000/api/v1/analyze -F "file=@study/spine.dcm"
python -m uvicorn src.main:app --port 8123
node tests/browser/ui_check.js # панель деталей обновляется по клику
node tests/browser/ui_offline.js # работа без доступа к внешним сервисам
+# проверка ручной разметки пишет вердикты, поэтому файл стоит отвести в /tmp:
+DXA_MANUAL_LABELS=/tmp/manual_ui.csv python -m uvicorn src.main:app --port 8123
+node tests/browser/ui_labeling.js
```
Офлайн-режим обеспечен локальными копиями Tailwind и FontAwesome
@@ -389,6 +461,8 @@ bone_2026/
│ ├── dxa/ # действующий модуль оценки качества
│ │ ├── labels.py # разбор имён, метки, склейка дублей, сплит
│ │ ├── excel_labels.py # разметка снимков по экспертной таблице
+│ │ ├── manual_labels.py # ручная разметка: хранение вердиктов (/label)
+│ │ ├── review_pack.py # автономный HTML-пакет разметки для врача
│ │ ├── rename_files.py # приведение имён DICOM к единому виду
│ │ ├── violations.py # единый словарь типов нарушений (коды, подписи)
│ │ ├── model_card.py # карточка решения для /api/v1/model и интерфейса
@@ -401,8 +475,9 @@ bone_2026/
│ │ ├── train.py # обучение и отчёт
│ │ └── inference.py # пакетный инференс, определение области
│ ├── quality/ # эвристики качества, используются API
-│ └── api/static/ # веб-интерфейс
+│ └── api/static/ # веб-интерфейс (index.html — анализ, label.html — разметка)
├── labels/labels_images.csv # разметка снимков: официальная (+ .xlsx)
+├── labels/manual_labels.csv # вердикты специалиста из /label (появляется после правок)
├── labels/labels_images_table.csv # вариант «только таблица» (то же, что выше)
├── labels/labels_images_union.csv # вариант «таблица или суффикс имени»
├── labels/labels_images_expert.csv # эталон для оценки: только снимки с оценкой
diff --git a/assets/labeling.md b/assets/labeling.md
index 7bdf25b..b6cfbcc 100644
--- a/assets/labeling.md
+++ b/assets/labeling.md
@@ -43,7 +43,7 @@
| Шаг | Что делается | Почему так |
|---|---|---|
| Ключ склейки | имя каталога исследования | таблица ссылается на каталог (`2.25…`), а в DICOM лежит другой идентификатор (`1.2.643…`); соответствие каталог → тег 100/100 |
-| Склейка дублей | по хешу пиксельных данных | 544 файла — это 252 уникальных снимка |
+| Склейка дублей | по хешу пиксельных данных | 478 файлов — это 251 уникальный снимок |
| Область снимка | голосование по именам файлов группы | один снимок назван и как позвоночник, и как бедро; при равенстве голосов область остаётся неопределённой (такой снимок один) |
| Вердикт | оценка области переносится на её снимок | **каждая область встречается в исследовании ровно один раз**, поэтому не нужно решать, какой из нескольких снимков «плохой» |
| Сторона бедра | при единственном снимке бедра берётся единственный заполненный столбец | в 71 из 72 исследований с двумя бёдрами столбцы совпадают с именами файлов; в 7 исследованиях с одним снимком заполнена противоположная сторона. Факт переноса фиксируется флагом `laterality_mirrored`; теги `Laterality` в DICOM пусты |
@@ -53,8 +53,8 @@
Метка могла строиться двумя способами: только из таблицы или с добавлением
пометок, которые вручную проставлялись в именах файлов (суффикс `_bad`). Пометки
-в именах оказались ненадёжными: они расходились с оценкой эксперта в **15 случаях
-из 252**. Выбор сделан измерением, а не по вкусу.
+в именах оказались ненадёжными: они расходились с оценкой эксперта в **62 случаях
+из 251**. Выбор сделан измерением, а не по вкусу.
**Постановка.** Разбиение по исследованиям зафиксировано один раз, оба варианта
обучены пятью seed'ами на нём, оценены по одному эталону — вердикту эксперта на
@@ -67,25 +67,25 @@
| Метрика (эталон) | только таблица | таблица или суффикс `_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 |
+| ROC-AUC | **0.6726** [0.6367, 0.7086] | 0.6003 [0.5592, 0.6415] |
+| PR-AUC | **0.4753** [0.4188, 0.5318] | 0.3864 [0.3487, 0.4242] |
+| F1 | **0.5559** [0.5212, 0.5906] | 0.5354 [0.4978, 0.5729] |
+| Recall | 0.7125 | 0.7875 |
+| Precision | 0.4716 | 0.4087 |
Парная разница («только таблица» − «с суффиксами»):
| Метрика | Δ, среднее [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--` |
+| ROC-AUC | **+0.0723** [+0.0541, +0.0905] | `+++++` |
+| PR-AUC | **+0.0889** [+0.0500, +0.1277] | `+++++` |
+| F1 | +0.0205 [−0.0182, +0.0592] | `+-+-+` |
+| Precision | +0.0629 [−0.0133, +0.1392] | `+++-+` |
+| Recall | −0.0750 [−0.1931, +0.0431] | `0---0` |
**Решение: принято правило «только экспертная таблица».** Дополнительные пометки
из имён файлов ухудшали согласие модели с экспертом на невиданных
-исследованиях: вариант с ними чаще срабатывал (recall 0.86), но за счёт
+исследованиях: вариант с ними чаще срабатывал (recall 0.79), но за счёт
точности, а по беспороговым метрикам проигрывал на всех пяти seed'ах.
Оговорка: эталон — та же экспертная таблица, поэтому вариант, обучавшийся
@@ -107,8 +107,9 @@
| `labeling_error` | Ошибка разметки | позвоночник | критерии методики |
| `unspecified` | Нарушение без уточнения | любая | служебный |
-Распределение в разметке: `rotation` 36, `artifact` 17, `axis_deviation` 10,
-`roi_incorrect` 7, `positioning` 6, `unspecified` 5.
+Распределение в разметке: `rotation` 35, `artifact` 17, `axis_deviation` 10,
+`roi_incorrect` 7, `positioning` 6, `unspecified` 5. Всего 80 критериев на 76
+нарушений: у части снимков таблица отмечает по два критерия.
Словарь один на всё решение (`src/dxa/violations.py`): коды используют инференс,
отчёт DICOM SR и веб-интерфейс, подписи отдаёт сервер, копий в JavaScript нет.
@@ -120,8 +121,8 @@
| | позвоночник | бедро R | бедро L | неопред. | всего |
|---|---|---|---|---|---|
| качественных | 66 | 58 | 51 | 0 | 175 |
-| с нарушением | 33 | 21 | 22 | 1 | 77 |
-| **итого** | **99** | **79** | **73** | **1** | **252** |
+| с нарушением | 33 | 20 | 22 | 1 | 76 |
+| **итого** | **99** | **78** | **73** | **1** | **251** |
Помимо метки в CSV сохранено происхождение: `quality_from_excel` (вердикт
эксперта), `quality_from_filename` (пометка из имени файла), `sources_conflict`,
@@ -130,7 +131,7 @@
Рядом лежат варианты для воспроизведения сравнения:
`labels_images_table.csv`, `labels_images_union.csv`, `labels_images_expert.csv`
-(эталон: только снимки с экспертной оценкой, 249 строк) и
+(эталон: только снимки с экспертной оценкой, 248 строк) и
`split_expert_seed42.json` (зафиксированное разбиение).
## 7. Гигиена данных: имена файлов
@@ -144,25 +145,56 @@
проверено, что оно на них не влияет: набор из 252 пиксельных групп идентичен до и
после, разметка не изменилась ни в одной строке. Суффиксы `_good`/`_bad`
сохранены как были и в метках не участвуют — только как диагностический столбец.
+Числа в этих двух абзацах — состояние на момент проверки; актуальное состояние
+набора и разметки приведено ниже.
+
+### Расхождение разметки с датасетом и его устранение (2026-09-27)
+
+К описанному проходу добавился более поздний: 2026-09-27 около 02:02 суффиксы
+`_good`/`_bad` дописаны ещё 54 файлам, то есть **после** сборки разметки
+(2026-09-26 22:35). `apply_excel_labels` сопоставляет метки строго по
+`path_to_image`, поэтому во всех четырёх файлах разметки (`labels_images.csv`,
+`_table`, `_union`, `_expert`) 20 строк стали ссылаться на имена, которых больше
+нет. Последствие: эти 20 снимков теряли экспертную метку и падали на пометку из
+имени файла, у 6 из них метка инвертировалась 0 → 1 (вердикт эксперта — «годное»,
+файл назван `..._bad.dcm`), и пайплайн отдавал 251 снимок / 82 нарушения вместо
+записанных выше 252 / 77. Расхождение нашли сравнением меток с именами файлов;
+тесты падали на числах (`assert 251 == 252`), то есть оно было замечено, а не
+осталось незамеченным.
+
+Устранено пересборкой: `./run.sh label` и три варианта правила, `./run.sh split`,
+затем переобучение `./run.sh train`. Актуальное состояние — 251 снимок / 76
+нарушений, все пути разметки существуют, расхождений с датасетом нет. Пометки в
+именах при этом расходятся с вердиктом эксперта в **62 случаях из 251** (было 15
+из 252): после второго переименования пометок стало больше, и они чаще спорят с
+таблицей. На метку это не влияет — правило `table` их не использует (см. §4), —
+но именно эти 62 снимка имеет смысл разобрать первыми в интерфейсе ручной
+разметки (см. §10).
+
+Чистка датасета в тот же день: удалены 38 лишних байт-идентичных копий `.dcm`
+внутри `НД_для_обучения` и 10 файлов `.DS_Store`. На разметку и на набор
+пиксельных групп это не влияет: групп 251, все совпадают с `path_to_image`. На
+диске 482 `.dcm` из 494 файлов. У каждого удалённого файла остался байт-идентичный
+близнец, поэтому потеряно только имя, а не содержимое.
## 8. Оценка качества модели
-Разбиение по исследованиям (по умолчанию seed 42): обучение 199 снимков /
+Разбиение по исследованиям (по умолчанию seed 42): обучение 198 снимков /
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 | рабочий чекпоинт, его собственная валидация |
+| ROC-AUC | **0.6726** [0.6367, 0.7086] | 5 seed'ов на фиксированном разбиении, эталон — вердикт эксперта |
+| PR-AUC | 0.4753 [0.4188, 0.5318] | там же |
+| F1 | 0.5559 [0.5212, 0.5906] | там же; порог подобран по F1 на валидации, поэтому смещён вверх |
+| ROC-AUC по областям | позвоночник 0.929, бедро R 0.606, бедро L 0.567 | рабочий чекпоинт, его собственная валидация |
-Рабочий чекпоинт — обычный прогон с seed по умолчанию (эпоха 39, порог логита
-−0.4930 → вероятность 0.379), его собственная валидационная ROC-AUC 0.6706
+Рабочий чекпоинт — обычный прогон с seed по умолчанию (эпоха 57, порог логита
+−0.1370 → вероятность 0.466), его собственная валидационная ROC-AUC 0.6689
близка к среднему по seed'ам, то есть результат не отобран по удачности.
Проверка, что модель смотрит на снимок, а не угадывает анатомию: внутри областей
-она даёт AUC 0.85–0.93, правило «позвоночник значит нарушение» — ровно 0.50.
+она даёт AUC 0.88–0.95, правило «позвоночник значит нарушение» — ровно 0.50.
Числа считаются на всём наборе, включая обучающие снимки, поэтому смещены вверх и
отвечают на вопрос «есть ли вклад содержимого», а не «каково качество на новых
данных».
@@ -172,7 +204,7 @@
1. **Разметка унаследована от исследования.** Таблица оценивает исследование, а
не снимок; перенос однозначен, потому что область встречается один раз, но
исходная оценка всё равно не поштучная.
-2. **Мало данных:** 252 снимка, 77 нарушений. Интервалы широкие.
+2. **Мало данных:** 251 снимок, 76 нарушений. Интервалы широкие.
3. **Эталон — та же таблица.** Независимой истины нет; вариант, обучавшийся на
таблице, в сравнении в выигрышном положении.
4. **Тип нарушения — эвристика**, а не вывод модели: 5 снимков имеют только
@@ -182,8 +214,83 @@
6. **Сторона бедра в 7 исследованиях не проверяема:** теги латеральности пусты.
7. **Корректность областей интереса наследуется из таблицы:** разметки ROI в
DICOM нет, сравнить её напрямую не с чем.
+8. **Пометки в именах файлов спорят с таблицей в 62 случаях из 251.** На метку
+ это не влияет (правило `table`), но означает, что один из двух источников
+ ошибается почти в четверти набора. Разбирать их поштучно можно в `/label`; для
+ выбора правила измерения в §8 такие снимки не использовались.
+9. **Поштучной разметки всё ещё нет.** Интерфейс `/label` (§10) её даёт, но им
+ ещё не пользовались: пока набор размечен на уровне исследования.
-## 10. Отрицательный результат: локальная vision-модель
+## 10. Ручная разметка: интерфейс `/label`
+
+Поштучной экспертной оценки снимков в наборе нет — это ограничение №1, и
+автоматически его не закрыть: локальная vision-модель оказалась непригодна (§11),
+а экспертная таблица описывает исследование. Поэтому в сервисе есть интерфейс
+ручной разметки — раздел 2.6 задания прямо просит «автоматическую коррекцию
+разметки с возможностью подтверждения специалистом».
+
+```bash
+./run.sh serve # http://localhost:8000/label
+```
+
+Что он делает:
+
+* показывает снимок целиком (PNG через `/api/v1/labeling/image`) без наложений,
+ чтобы разметчик судил по изображению, а не по подсказке алгоритма;
+* отдаёт список снимков с текущей меткой и её источником (`table`, `filename` или
+ `manual`), причём снимки с расхождением метки и пометки в имени файла идут
+ первыми — именно там один из источников ошибается (см. §9, п. 8);
+* сохраняет вердикт: анатомическая область, «годное / нарушение», тип нарушения из
+ `violations.py` и комментарий. Тип нарушения проверяется на совместимость с
+ областью (ротация — критерий бедра), у качественного снимка типа быть не может,
+ а опечатка в коде не превращается молча в `unspecified`;
+* пишет вердикты в `labels/manual_labels.csv`. Формат тот же, что у построенной
+ разметки, поэтому файл читается `load_labels_csv` и принимается обучением как
+ `--labels-csv`;
+* выгружает `scope=all` — весь набор с наложенными ручными вердиктами (готовый
+ источник меток для `./run.sh train`) — или `scope=reviewed` — только разобранные
+ снимки, чтобы сверить их с построенной разметкой.
+
+Оценка модели в интерфейсе намеренно не показывается: разметчик, видя подсказку,
+соглашался бы с ней, и поштучная разметка теряла бы ценность независимого
+суждения. Прогресс по областям и число расхождений показаны, чтобы работу можно
+было вести частями и прерывать.
+
+Горячие клавиши: `g` — годное, `b` — нарушение, `Enter` — сохранить и перейти к
+следующему, `d` — снять вердикт, `j`/`k` — навигация по списку.
+
+Инструмент не заменяет эксперта: он только фиксирует суждение специалиста в
+формате, который пайплайн уже умеет читать.
+
+### Передача врачу: автономный пакет
+
+`/label` требует запущенного сервиса и Python, а размечать должен врач — обычно на
+своей машине, вне сети и без установки чего-либо. Поэтому тот же сценарий
+собирается в один HTML-файл со встроенными снимками (`src/dxa/review_pack.py`):
+
+```bash
+python -m src.dxa.review_pack --out review/doctor_review.html # 251 снимок, ~7 МБ
+python -m src.dxa.review_pack --merge review/doctor.csv \
+ --out labels/labels_images_reviewed.csv # наложить вердикты врача
+```
+
+Страница не делает ни одного сетевого запроса (проверяется тестом), вердикты
+хранит браузер, выгрузка — CSV в формате `manual_labels.csv` с дополнительной
+колонкой `pack_id` (отпечаток набора: по нему видно, что файл вернулся из того же
+пакета). Модель в пакет не попадает: если показать разметчику оценку алгоритма,
+он будет с ней соглашаться, и независимого суждения не получится.
+
+**Что стоит размечать.** Порядок в пакете и в интерфейсе — как в §7: сперва 62
+снимка, где метка разметки расходится с пометкой в имени файла (там ошибается
+один из двух источников). Но одних расхождений мало: они отобраны по признаку,
+который сам может быть смещён. Чтобы получить **независимую** оценку качества,
+нужен ещё случайный подсчёт — если врач разметит, например, случайные 50 снимков
+из 251, по ним можно будет измерить ROC-AUC и F1 модели на человеческом суждении,
+а не на той же экспертной таблице, из которой выведена разметка (ограничение 3 в
+§9). Такой замер в проекте ещё не делался: ни один снимок пока не размечен
+поштучно.
+
+## 11. Отрицательный результат: локальная vision-модель
Планировалась визуальная разметка локальной vision-моделью (9 млрд параметров,
офлайн), чтобы не зависеть от таблицы. На калибровке по 14 снимкам, из которых 8
@@ -196,7 +303,7 @@
контактных листов остались в `src/dxa/render.py` — они полезны для выборочной
ручной проверки.
-## 11. Воспроизведение
+## 12. Воспроизведение
```bash
./run.sh label # разметка по экспертной таблице
@@ -206,9 +313,11 @@ 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. Что дальше
+## 13. Что дальше
-- Поштучная разметка снимков специалистом — снимет ограничение №1.
-- Мультилейбл по типам нарушений вместо эвристики.
+- Поштучная разметка снимков специалистом через `/label` (§10) — снимет
+ ограничение №1 и позволит перемерить качество на независимом суждении.
+- Мультилейбл по типам нарушений вместо эвристики: в интерфейсе тип уже
+ проставляется вручную, но модель его не предсказывает.
- Больше исследований (500+), чтобы сузить интервалы.
- Калибровка порога определения области под конкретное оборудование.
diff --git a/docker-compose.yml b/docker-compose.yml
index 0fec134..5b1cc62 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -36,7 +36,7 @@ services:
image: dxa-quality:cuda
build:
context: .
- dockerfile: Dockerfile_cuda
+ dockerfile: Dockerfile_cuda.txt
ports:
- "8000:8000"
restart: unless-stopped
diff --git a/docs/technical-description.html b/docs/technical-description.html
index 72e9c16..1c9c51e 100644
--- a/docs/technical-description.html
+++ b/docs/technical-description.html
@@ -67,9 +67,9 @@
Дата документа
27 сентября 2026 г.
Команда
Грачев Денис — разработка; Грачев Татьяна — капитан
ROC-AUC 0.6726 [0.6367, 0.7086], F1 0.5559 [0.5212, 0.5906] — пять seed'ов на фиксированном разбиении по исследованиям
+
Время обработки
0.022 с на изображение (медиана, CPU), 482 файла за 13.9 с; требование «≤ 3 мин на исследование» выполняется с запасом
@@ -144,7 +144,7 @@
Разд. 9 (состав столбцов проверен на реальном прогоне)
Время обработки одного исследования ≤ 3 мин
Один снимок — один прямой проход сети 224×224, без итеративных процедур
-
Разд. 13: 0.021 с медиана на CPU, запас более чем трёхкратный
+
Разд. 13: 0.022 с медиана на CPU, запас более чем трёхкратный
Отсутствие необработанных исключений; ошибки фиксируются в отчёте
Ошибка на файле не прерывает пакет: строка получает processing_status = Failure: …
Разд. 16
@@ -155,8 +155,11 @@
Обход каталога, лог прогресса, XLSX/CSV, опциональный zip с визуализацией
Разд. 9, 13
API для пакетной обработки тестового набора
-
FastAPI: восемь маршрутов, включая /api/v1/batch и /api/v1/export
+
FastAPI: четырнадцать маршрутов, включая /api/v1/batch, /api/v1/export и маршруты ручной разметки /api/v1/labeling/*
Разд. 10
+
Дополнительно (п. 2.6): коррекция разметки с возможностью подтверждения специалистом
+
Интерфейс /label: снимок целиком, текущая метка с указанием источника, вердикт специалиста сохраняется в labels/manual_labels.csv и принимается обучением как --labels-csv
Обязательная контейнеризация, скрипт сборки и запуска в Linux
Dockerfile (python:3.11-slim, чекпоинт внутри образа), Dockerfile_cuda для GPU, docker-compose.yml на оба случая, run.sh
Разд. 14; сборка и запуск проверены, см. 14
@@ -224,7 +227,7 @@
Параметры предобработки хранятся в чекпоинте и берутся из него, а не из кода по умолчанию.
Оценка качества. Один прямой проход сети даёт логит. Решение принимается по логиту:
quality_class = 1, если логит выше порога. Порог подобран по F1 на валидации и
- сохранён в чекпоинте (текущее значение −0.4930, что соответствует вероятности 0.379).
+ сохранён в чекпоинте (текущее значение −0.1370, что соответствует вероятности 0.466).
Анатомическая область. Определяется независимо от модели, по геометрии кадра (разд. 7),
с оценкой уверенности.
Измерения и тип нарушения. Для снимков с нарушением вычисляются числовые признаки снимка,
@@ -242,12 +245,12 @@
Показатель
Значение
Пояснение
-
Файлов на диске
544
в обучающем наборе (dataset_hack/НД_для_обучения)
-
Уникальных снимков
252
по пиксельному содержимому; 292 файла — побайтные дубли
+
Файлов на диске
478
DICOM в обучающем наборе (dataset_hack/НД_для_обучения); лишние побайтные копии удалены
+
Уникальных снимков
251
по пиксельному содержимому; 227 файлов — дубли одного и того же кадра
Исследований
100
разбиение выполняется по исследованиям, а не по снимкам
-
Позвоночник / бедро правое / бедро левое / не определено
99 / 79 / 73 / 1
голосование по именам файлов после склейки дублей
-
Нарушений по экспертной таблице
74 (29.4 %)
три снимка таблица не оценивала
-
Нарушений в рабочей разметке
77 (30.6 %)
74 по таблице плюс 3 снимка с пометкой в имени файла
+
Позвоночник / бедро правое / бедро левое / не определено
99 / 78 / 73 / 1
голосование по именам файлов после склейки дублей
+
Нарушений по экспертной таблице
73 (29.1 %)
три снимка таблица не оценивала
+
Нарушений в рабочей разметке
76 (30.3 %)
73 по таблице плюс 3 снимка с пометкой в имени файла
Дубли не пересекают границы исследований, конфликтов меток при склейке не возникает. Два побайтных
@@ -267,7 +270,7 @@
Возможны были два правила: учитывать только экспертную таблицу либо дополнительно учитывать
пометки _good/_bad, проставленные вручную в именах файлов. Пометки
-расходились с экспертом в 15 случаях из 252, поэтому правило выбиралось измерением: одно
+расходились с экспертом в 62 случаях из 251, поэтому правило выбиралось измерением: одно
зафиксированное разбиение, пять seed'ов обучения, один эталон (табл. в разд. 12.3). Выбрано правило
«только экспертная таблица».
@@ -275,7 +278,7 @@
Часть
Снимков
Исследований
Нарушений
Файл
-
Обучение
199
81
61
labels/split_expert_seed42.json
+
Обучение
198
81
60
labels/split_expert_seed42.json
Валидация
53
19
16
@@ -383,9 +386,9 @@ backbone заморожен и обучается только голова на
Аргумент --min-recall позволяет вместо этого взять максимальный порог с recall не ниже
заданного.
-
Рабочий чекпоинт. Лучшая эпоха — 39 из прогона в 64 эпохи (обучение остановлено по терпению),
-порог логита −0.4930 (вероятность 0.379). Метрики этой эпохи — в разделе 12, честная оценка варианта на
-пяти seed'ах — ROC-AUC 0.6764.
+
Рабочий чекпоинт. Лучшая эпоха — 57 из прогона в 82 эпохи (обучение остановлено по терпению),
+порог логита −0.1370 (вероятность 0.466). Метрики этой эпохи — в разделе 12, честная оценка варианта на
+пяти seed'ах — ROC-AUC 0.6726.
6.3. Чекпоинт и отчёты
@@ -437,17 +440,17 @@ backbone заморожен и обучается только голова на
Если ширина кадра неизвестна, используется предсказание головы, а затем форма яркой области
(bbox_aspect и symmetry).
-
Точность. Сопоставление с областью из рабочей разметки (252 снимка, раздел 4):
+
Точность. Сопоставление с областью из рабочей разметки (251 снимок, раздел 4):
Что проверялось
Результат
Позвоночник
99 / 99
-
Сторона бедра (правое / левое)
135 / 152 (88.8 %)
-
Итого по всем областям
234 / 251 (93.2 %)
+
Сторона бедра (правое / левое)
134 / 151 (88.7 %)
+
Итого по всем областям
233 / 250 (93.2 %)
Различение позвоночника и бедра по ширине кадра работает безошибочно, а сторона бедра определяется
-менее надёжно: перевес светимости путает левое и правое в 17 случаях из 152. Сторона не проверялась по
+менее надёжно: перевес светимости путает левое и правое в 17 случаях из 151. Сторона не проверялась по
тегам DICOM — Laterality в наборе пуст, — поэтому это ограничение (раздел 17, п. 7), а не
измеренная ошибка модели.
@@ -490,12 +493,12 @@ acceptable) с флагом FINAL, нарушение — с фла
Экспертная таблица кодирует не все девять кодов, а пять: positioning,
axis_deviation, artifact, rotation, roi_incorrect.
-Распределение в рабочей разметке (в четырёх из 77 нарушений указано по два критерия, поэтому сумма
-больше 77):
+Распределение в рабочей разметке (в четырёх из 76 нарушений указано по два критерия, поэтому сумма
+больше 76):
Код
Снимков
-
rotation
36
+
rotation
35
artifact
17
axis_deviation
10
roi_incorrect
7
@@ -519,8 +522,8 @@ acceptable) с флагом FINAL, нарушение — с фла
На текущем чекпоинте эта ветка практически вырождена. Пороги
motion_threshold и artifact_threshold в вызов не передаются, поэтому
действуют значения по умолчанию (0.0 и 1.0), которых признаки достичь не могут, а условие ротации
-(bbox_aspect вне диапазона 0.4–3.0) на наборе не срабатывает. Прогон по всем 548 файлам
-даёт одинаковый результат: все 341 решение с нарушением помечены unspecified. Для
+(bbox_aspect вне диапазона 0.4–3.0) на наборе не срабатывает. Прогон по всем 482 файлам
+даёт одинаковый результат: все 206 решений с нарушением помечены unspecified. Для
содержательного типа нужна разметка типов на уровне снимка и обученный мультилейбл-классификатор
(раздел 18), поэтому в ответе API тип всегда идёт с флагом
violation_type_is_heuristic = true.
@@ -651,50 +654,92 @@ acceptable) с флагом FINAL, нарушение — с фла
состав данных, словарь нарушений и список ограничений. Данные приходят из /api/v1/model.
+
11.1. Ручная разметка (/label)
+
+
Поштучной экспертной оценки снимков в наборе нет — это ограничение 1 в разд. 17, и автоматически его
+закрыть нечем: локальная vision-модель оказалась непригодна как разметчик (отрицательный результат
+описан в assets/labeling.md §11). Поэтому реализован отдельный интерфейс ручной разметки, который
+отвечает пункту 2.6 задания — «автоматическая коррекция разметки с возможностью подтверждения
+специалистом».
+
+
Страница доступна по адресу /label и работает на тех же офлайн-ассетах, что и основная.
+Она показывает снимок целиком (PNG через /api/v1/labeling/image, без наложений), его
+текущую метку и источник этой метки: table (экспертная таблица),
+filename (пометка в имени файла) или manual (вердикт специалиста). Снимки, где
+метка разметки расходится с пометкой в имени файла, выводятся первыми: именно там один из источников
+ошибается, и таких снимков в наборе 62 из 251.
+
+
Специалист подтверждает вердикт или ставит свой: анатомическая область, «годное / нарушение», тип
+нарушения из словаря (разд. 8) и комментарий. Вердикт проверяется на согласованность: тип нарушения
+обязан относиться к выбранной области (ротация — критерий бедра), у качественного снимка типа быть не
+может, а неизвестный код отклоняется, а не подменяется на «не уточнён». Есть горячие клавиши
+(g — годное, b — нарушение, Enter — сохранить и перейти к
+следующему) и прогресс по областям, чтобы работу можно было вести частями.
+
+
Вердикты сохраняются в labels/manual_labels.csv в том же формате, что и построенная
+разметка, поэтому файл читается тем же загрузчиком и принимается обучением как
+--labels-csv. Выгрузка scope=all отдаёт весь набор с наложенными ручными
+вердиктами (готовый источник меток), scope=reviewed — только разобранные снимки, чтобы
+сверить их с построенной разметкой.
+
+
Разметка без сервиса. Интерфейс /label требует запущенного сервиса, а
+размечать должен специалист — как правило, на своей машине, вне сети и без установки чего-либо.
+Поэтому тот же сценарий упаковывается в один автономный HTML-файл
+(src/dxa/review_pack.py, около 7 МБ на 251 снимок): изображения встроены как
+data-URI, вердикты хранятся в браузере, а кнопка «Выгрузить CSV» отдаёт файл, который принимается
+обучением как есть. Страница не делает ни одного сетевого запроса, и это проверяется автоматически
+(tests/browser/review_pack.js). Вердикты специалиста накладываются на построенную
+разметку командой python -m src.dxa.review_pack --merge; если в файле окажутся пути
+из другого набора, команда сообщает об этом.
+
+
Оценка модели в интерфейсе намеренно не показывается: разметчик, видя подсказку,
+соглашался бы с ней, и поштучная разметка потеряла бы ценность независимого суждения. Инструмент не
+заменяет эксперта — он фиксирует его суждение в формате, который пайплайн уже умеет читать.
+
12. Метрики качества
12.1. Основная оценка
-
Оценка получена на фиксированном разбиении по исследованиям (обучение 199 снимков / 81 исследование,
+
Оценка получена на фиксированном разбиении по исследованиям (обучение 198 снимков / 81 исследование,
валидация 53 снимка / 19 исследований, 16 нарушений), пять seed'ов обучения, эталон — вердикт
эксперта из таблицы. Приоритетные по заданию метрики приведены с 95 % доверительными интервалами.
Метрика
Значение
95 % ДИ
Комментарий
-
ROC-AUC
0.6764
[0.6309, 0.7218]
приоритетная метрика задания
-
PR-AUC
0.4759
[0.4141, 0.5377]
базовый уровень при доле нарушений 30 % — около 0.30
-
F1
0.5676
[0.5270, 0.6082]
порог подбирался по F1 на той же валидации, поэтому значение смещено вверх
-
Recall / Precision
0.700 / 0.486
—
рабочая точка выбранного порога
+
ROC-AUC
0.6726
[0.6367, 0.7086]
приоритетная метрика задания
+
PR-AUC
0.4753
[0.4188, 0.5318]
базовый уровень при доле нарушений 30 % — около 0.30
+
F1
0.5559
[0.5212, 0.5906]
порог подбирался по F1 на той же валидации, поэтому значение смещено вверх
+
Recall / Precision
0.713 / 0.472
—
рабочая точка выбранного порога
12.2. Оценка по областям и контрольные проверки
Что измерено
Значение
Как получено
-
ROC-AUC: позвоночник / бедро правое / бедро левое
0.943 / 0.576 / 0.550
рабочий чекпоинт, собственная валидация
+
ROC-AUC: позвоночник / бедро правое / бедро левое
0.929 / 0.606 / 0.567
рабочий чекпоинт, собственная валидация
Контрольная задача «позвоночник / бедро»
AUC 1.00
проверка работоспособности пайплайна, а не клиническая метрика
-
Модель против правила «позвоночник = нарушение»
0.854 против 0.529
оценка на всём наборе, включая обучающие снимки, поэтому смещена вверх
+
Модель против правила «позвоночник = нарушение»
0.885 против 0.534
оценка на всём наборе, включая обучающие снимки, поэтому смещена вверх
Разбивка по областям нужна потому, что нарушения распределены неравномерно: в позвоночнике
-33 из 99 снимков, у бёдер 21–22 из 73–79, а сама область почти однозначно определяется по ширине
+33 из 99 снимков, у бёдер 20–22 из 73–78, а сама область почти однозначно определяется по ширине
кадра. Поэтому общий AUC частично отражает различение области, а не только распознавание дефекта.
Чтобы отделить одно от другого, выполнена проверка: правило «позвоночник = нарушение» даёт внутри
-областей 0.50 (подсказки нет), модель — 0.85–0.93. Это означает, что модель использует содержимое
+областей 0.50 (подсказки нет), модель — 0.88–0.95. Это означает, что модель использует содержимое
снимка, а не только область.
12.3. Выбор правила разметки
Метрика (эталон)
только таблица
с суффиксами имён
-
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.6726 [0.6367, 0.7086]
0.6003 [0.5592, 0.6415]
+
PR-AUC
0.4753 [0.4188, 0.5318]
0.3864 [0.3487, 0.4242]
+
F1
0.5559 [0.5212, 0.5906]
0.5354 [0.4978, 0.5729]
+
Recall / Precision
0.713 / 0.472
0.788 / 0.409
-
Парная разница (только таблица минус с суффиксами): ROC-AUC +0.0564 [+0.0403, +0.0725],
-PR-AUC +0.0713 [+0.0398, +0.1028] — знаки +++++, то есть преимущество на всех пяти
+
Парная разница (только таблица минус с суффиксами): ROC-AUC +0.0723 [+0.0541, +0.0905],
+PR-AUC +0.0889 [+0.0500, +0.1277] — знаки +++++, то есть преимущество на всех пяти
seed'ах. Принято правило «только экспертная таблица». Оговорка, важная для интерпретации: эталон — та
же таблица, поэтому вариант, обучавшийся на ней, находится в выигрышном положении; значимо здесь то,
что добавление ненадёжных пометок согласие с экспертом снижает, а не повышает.
@@ -704,15 +749,15 @@ seed'ах. Принято правило «только экспертная т
13.1. Измерения
Измерения выполнены на рабочей машине разработчика (macOS, Apple Silicon) на полном наборе данных —
-548 файлов в dataset_hack (544 обучающих плюс 4 тестовых). Инференс принудительно
+482 DICOM-файла в dataset_hack (478 обучающих плюс 4 тестовых). Инференс принудительно
переведён на CPU параметром --device cpu, чтобы числа не зависели от наличия ускорителя.
Показатель
Значение
Условия
-
Время на изображение (медиана)
0.021 с
CPU, включает чтение DICOM, предобработку и проход сети
-
Время на изображение (максимум)
0.131 с
тот же прогон; первый снимок включает прогрев
-
Пакет целиком (548 файлов)
12.1 с
от запуска процесса до записи XLSX, включая загрузку чекпоинта
-
Доля успешно обработанных файлов
548 / 548 (100 %)
ошибок чтения на этом наборе нет
+
Время на изображение (медиана)
0.022 с
CPU, включает чтение DICOM, предобработку и проход сети
+
Время на изображение (максимум)
0.041 с
тот же прогон
+
Пакет целиком (482 файла)
13.9 с
от запуска процесса до записи XLSX, включая загрузку чекпоинта
+
Доля успешно обработанных файлов
482 / 482 (100 %)
ошибок чтения на этом наборе нет
Пиковая память процесса
≈ 640 МБ
maximum resident set size, пакетный CPU-инференс
Ответ API на один файл
16–18 мс
вызов внутри процесса, после прогрева; первый запрос 390 мс. Здесь устройство выбрано автоматически (MPS), на CPU значение того же порядка — см. медиану пакетного прогона выше
Время старта сервиса
≈ 3 с
импорт библиотек и загрузка чекпоинта
@@ -769,10 +814,10 @@ seed'ах. Принято правило «только экспертная т
Что проверено на самом образе. Сборка проходит три внутренние проверки:
app import ok, frontend assets ok и
-checkpoint ok: resnet18 linear threshold -0.4930129051208496. Контейнер запущен без единого
+checkpoint ok: resnet18 linear threshold -0.13703127205371857. Контейнер запущен без единого
монтирования (docker run -d -p 8000:8000 dxa-quality:cpu): чекпоинт присутствует внутри
образа (/app/models/dxa_model.pth), /api/v1/health сообщает
-model_loaded: true, device: cpu, эпоху 39 и файл разметки
+model_loaded: true, device: cpu, эпоху 57 и файл разметки
labels/labels_images.csv, запрос /api/v1/analyze отвечает корректной строкой
результата, а /api/v1/export возвращает XLSX с ожидаемым набором столбцов. Сквозная
проверка выполнена в контейнере на той же машине, где снимались измерения производительности. Оба
@@ -809,8 +854,8 @@ CPU-образ содержит torch 2.8.0+cpu и ни одног
15. Тесты и проверки
-
Тесты — pytest, 209 проверок в шести файлах; запуск — ./run.sh test или
-python -m pytest tests/ -q. Сверка выполнена на дату документа: 209 passed.
+
Тесты — pytest, 272 проверки в девяти файлах; запуск — ./run.sh test или
+python -m pytest tests/ -q. Сверка выполнена на дату документа: 272 passed.
Файл
Тестов
Что проверяет
@@ -818,11 +863,15 @@ CPU-образ содержит torch 2.8.0+cpu и ни одног
разбор имён, определение области и метки по имени, стратифицированное разбиение по
исследованиям, отсутствие утечки, фиксация и переиспользование разбиения в файле, сверка с
реальным датасетом
-
tests/test_excel_labels.py
50
+
tests/test_excel_labels.py
51
разметка по экспертной таблице: чтение критериев, правило «1 = нарушение», голосование по
области, перенос оценки на единственное бедро, три правила метки
- (table / union / expert), их согласованность и подключение
- к обучению
+ (table / union / expert), их согласованность, подключение
+ к обучению и чтение CSV, сохранённого Excel с BOM
+
tests/test_manual_labels.py
26
+
ручная разметка: проверка вердикта (область, метка, совместимость типа нарушения с областью,
+ отказ от опечаток), хранение и правка вердиктов, чтение файла с BOM, наложение поверх построенной
+ разметки, подсчёт прогресса и выгрузка
tests/test_rename_files.py
36
приведение имён DICOM: разбор и канонизация, поиск свободного номера при конфликте, отказ
угадывать область, цикл «применить → откатить»
@@ -836,12 +885,20 @@ CPU-образ содержит torch 2.8.0+cpu и ни одног
поля ответов, которые читает веб-интерфейс: базовый и детальный анализ, здоровье и карточка
модели, выгрузка XLSX, обработка ошибок; различимость метрик между снимками и валидность
PNG-визуализаций
+
tests/test_labeling_api.py
19
+
контракт /api/v1/labeling/*: отказ отдавать файлы вне датасета (обход каталога,
+ не-DICOM), проверка вердикта, выгрузка, читаемая обучением как --labels-csv
+
tests/test_review_pack.py
17
+
автономный пакет разметки: отпечаток набора, порядок «расхождения первыми», встроенные
+ данные разбираются как JSON, в странице нет ссылок на сеть, слияние вердиктов с построенной
+ разметкой и сообщение о путях из чужого пакета
Отдельный контур — браузерные проверки интерфейса (tests/browser/*.js, Node и
playwright-core): они открывают интерфейс во временном профиле Chrome, загружают DICOM,
-кликают по строкам таблицы и снимают содержимое панели деталей. Требуется запущенный сервер на
-127.0.0.1:8123; профиль пользователя не затрагивается.
+кликают по строкам таблицы и снимают содержимое панели деталей. Для проверок интерфейса и ручной
+разметки требуется запущенный сервер на 127.0.0.1:8123; проверке автономного пакета
+сервер не нужен вовсе — она открывает файл прямо с диска. Профиль пользователя не затрагивается.
Сценарий
Что проверяет
@@ -851,8 +908,16 @@ CPU-образ содержит torch 2.8.0+cpu и ни одног
словарём
ui_violation.js
ветку «нарушение»: бейдж, POOR, HIGH, текст заключения
+
ui_labeling.js
+
интерфейс ручной разметки: список снимков с прогрессом, расхождения первыми, отрисовку
+ снимка, сохранение вердикта и его живучесть после перезагрузки страницы, фильтр «только
+ расхождения», отсутствие внешних запросов
ui_offline.js
отсутствие обращений страницы к внешним хостам
+
review_pack.js
+
автономный пакет разметки — без сервера вообще: файл открывается с диска, встроенный снимок
+ рисуется, вердикт сохраняется и переживает перезагрузку, CSV выгружается и загружается обратно,
+ ни одного сетевого запроса
16. Известные ошибки и их обработка
@@ -915,9 +980,10 @@ CPU-образ содержит torch 2.8.0+cpu и ни одног
Разметка выведена из оценки исследования, а не снимка. Экспертная таблица описывает
исследование; перенос вердикта на снимок однозначен (область встречается один раз), но
поштучной экспертной оценки снимков в наборе нет. Оценка качества модели упирается в качество
- этой разметки, а не только в объём данных.
-
Мало данных. 252 уникальных снимка, 77 нарушений. Доверительные интервалы широкие
- (ROC-AUC 0.6764 [0.6309, 0.7218] по пяти seed'ам), поэтому оценка на закрытом наборе может
+ этой разметки, а не только в объём данных. Для поштучной разметки в сервисе есть интерфейс
+ /label (раздел 11.1), им ещё не пользовались.
+
Мало данных. 251 уникальный снимок, 76 нарушений. Доверительные интервалы широкие
+ (ROC-AUC 0.6726 [0.6367, 0.7086] по пяти seed'ам), поэтому оценка на закрытом наборе может
отличаться.
Эталон — та же таблица. Независимой истины нет, поэтому сравнение правил разметки
частично благоприятствует варианту «только таблица» (раздел 12.3).
@@ -938,16 +1004,17 @@ CPU-образ содержит torch 2.8.0+cpu и ни одног
Числовые эвристики панели деталей не калиброваны. Их пороги рассчитаны на другой
масштаб интенсивностей, поэтому в интерфейсе показываются измерения, а не вердикты.
Рабочая точка порога даёт высокий recall при умеренной точности. На обучающем наборе при
- пороге, подобранном по F1, модель относит к нарушениям 341 строку из 548 (≈ 62 %) при
- фактической доле нарушений 30.6 %, что согласуется с precision 0.486. Порог выбран в пользу
+ пороге, подобранном по F1, модель относит к нарушениям 204 строки из 482 (≈ 42 %) при
+ фактической доле нарушений 30.3 %, что согласуется с precision 0.472. Порог выбран в пользу
полноты: пропустить непригодное исследование дороже, чем показать лишнее.
18. План развития
-
Разметить типы нарушений на уровне снимка и обучить мультилейбл-классификатор — это снимает
- главное ограничение (тип нарушения определяется признаками, а не моделью).
+
Разметить типы нарушений на уровне снимка через интерфейс /label (раздел 11.1) и
+ обучить мультилейбл-классификатор — это снимает главное ограничение (тип нарушения определяется
+ признаками, а не моделью). Инструмент готов, разметка ещё не проводилась.
Расширить набор до 500+ исследований: доверительные интервалы сузятся, появится возможность
честной валидации без пересечения с обучением.
+ Выгрузка «весь набор» накладывает ручные вердикты на построенную разметку и
+ принимается обучением как --labels-csv.
+ Оценка модели здесь намеренно не показывается: подсказка смещала бы разметчика.
+
+
+
+
+
+
+
+
+
+
Выберите снимок из списка слева.
+
+
+
+
+
+
+
diff --git a/src/dxa/excel_labels.py b/src/dxa/excel_labels.py
index fb858ef..5fd976e 100644
--- a/src/dxa/excel_labels.py
+++ b/src/dxa/excel_labels.py
@@ -437,8 +437,14 @@ def write_csv(labels: Sequence[ImageLabel], out_path: str | Path) -> 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:
+ """
+ Прочитать построенную разметку; ключ — путь к снимку из `path_to_image`.
+
+ Файл читается как `utf-8-sig`: разметку правят в Excel, а он сохраняет CSV с
+ BOM, после чего имя первого столбца перестаёт быть `path_to_image` и загрузка
+ падает на несовпадении колонок. BOM в начале файла безвреден для чтения.
+ """
+ with Path(path).open(newline="", encoding="utf-8-sig") as fh:
rows = list(csv.DictReader(fh))
if not rows:
raise ValueError(f"Empty labels file: {path}")
diff --git a/src/dxa/manual_labels.py b/src/dxa/manual_labels.py
new file mode 100644
index 0000000..e047078
--- /dev/null
+++ b/src/dxa/manual_labels.py
@@ -0,0 +1,402 @@
+"""
+Ручная разметка снимков специалистом.
+
+Зачем отдельный модуль. Экспертная оценка в наборе есть только на уровне
+исследования (`dataset_hack/НД_для_обучения/разметка.xlsx`), поштучной оценки
+снимков нет — это ограничение №1 в `assets/labeling.md`. Раздел 2.6 задания
+требует «автоматическую коррекцию разметки с возможностью подтверждения
+специалистом», поэтому вердикты, проставленные вручную в интерфейсе `/label`,
+хранятся отдельным файлом и накладываются поверх построенной разметки.
+
+Формат `labels/manual_labels.csv` совместим с построенной разметкой: ключ —
+`path_to_image`, метка — `quality_class`. Поэтому файл читается тем же
+`excel_labels.load_labels_csv` и принимается обучением как `--labels-csv`, а
+проверить отличие от построенной разметки можно обычным сравнением CSV.
+"""
+from __future__ import annotations
+
+import csv
+import logging
+import os
+from dataclasses import dataclass
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Dict, Iterable, List, Optional, Sequence, Set, Tuple
+
+import pydicom
+
+from src.dxa.labels import QUALITY_BAD, QUALITY_GOOD, REGIONS, ImageRecord
+from src.dxa.violations import (
+ LEGACY_ALIASES,
+ VIOLATION_SCOPE,
+ VIOLATION_TYPES,
+ canon_type,
+)
+
+logger = logging.getLogger(__name__)
+
+#: Имя файла с ручными вердиктами внутри каталога разметки.
+MANUAL_FILENAME = "manual_labels.csv"
+
+#: Столбцы файла. Обязательные для обучения — `path_to_image` и
+#: `quality_class`; остальные нужны разбору и повторной правке.
+MANUAL_FIELDS: Tuple[str, ...] = (
+ "path_to_image",
+ "anatomical_region",
+ "quality_class",
+ "violation_type",
+ "comment",
+ "reviewer",
+ "reviewed_at",
+)
+
+#: Значение `label_rule` для строк, проставленных человеком.
+MANUAL_RULE = "manual"
+
+#: Регион, который специалист может выбрать (без «неизвестно»: выбор явный).
+SELECTABLE_REGIONS: Tuple[str, ...] = REGIONS
+
+_KNOWN_VIOLATION_INPUTS = set(VIOLATION_TYPES) | set(LEGACY_ALIASES)
+
+
+class VerdictError(ValueError):
+ """Вердикт не соответствует формату разметки."""
+
+
+@dataclass(frozen=True)
+class ManualVerdict:
+ """Вердикт специалиста по одному снимку."""
+
+ path: str
+ region: str
+ quality: int
+ violation: str = ""
+ comment: str = ""
+ reviewer: str = ""
+ reviewed_at: str = ""
+
+ def as_row(self) -> Dict[str, str]:
+ return {
+ "path_to_image": self.path,
+ "anatomical_region": self.region,
+ "quality_class": str(self.quality),
+ "violation_type": self.violation,
+ "comment": self.comment,
+ "reviewer": self.reviewer,
+ "reviewed_at": self.reviewed_at,
+ }
+
+
+def _normalize_path(value: str | Path) -> str:
+ """Ключ вердикта — путь в том же виде, что у `ImageRecord.path` (POSIX)."""
+ return Path(str(value)).as_posix()
+
+
+def _now() -> str:
+ return datetime.now(timezone.utc).isoformat(timespec="seconds")
+
+
+def _scope_matches(scope: str, region: str) -> bool:
+ """Подходит ли нарушение области снимка.
+
+ В словаре нарушений (`violations.py`) бедро обозначено одним кодом `hip`,
+ потому что критерий «позиционирование/ротация» в экспертной таблице один на
+ оба бедра; в разметке же область всегда конкретная (`hip_right`/`hip_left`).
+ """
+ if scope == "any":
+ return True
+ if scope == "hip":
+ return region in ("hip_right", "hip_left")
+ return scope == region
+
+
+def validate_verdict(
+ region: Optional[str],
+ quality: int,
+ violation: Optional[str],
+) -> Tuple[str, int, str]:
+ """
+ Проверить вердикт и привести тип нарушения к каноническому коду.
+
+ Raises:
+ VerdictError: область вне `SELECTABLE_REGIONS`, метка не 0/1 или тип
+ нарушения не из словаря `violations.py`. Неизвестное значение
+ отклоняется, а не подменяется на `unspecified`: в ручном вводе это
+ почти всегда опечатка.
+ """
+ if region not in SELECTABLE_REGIONS:
+ raise VerdictError(
+ f"Область должна быть одной из {SELECTABLE_REGIONS}, получено {region!r}"
+ )
+ if int(quality) not in (QUALITY_GOOD, QUALITY_BAD):
+ raise VerdictError(f"Метка качества должна быть 0 или 1, получено {quality!r}")
+
+ raw = (violation or "").strip().lower()
+ if raw not in _KNOWN_VIOLATION_INPUTS:
+ raise VerdictError(f"Неизвестный тип нарушения: {violation!r}")
+ code = canon_type(raw)
+
+ if code and quality == QUALITY_GOOD:
+ raise VerdictError("У качественного снимка не может быть типа нарушения")
+
+ scope = VIOLATION_SCOPE.get(code, "any")
+ if code and not _scope_matches(scope, region):
+ raise VerdictError(f"Нарушение {code!r} относится к области {scope!r}, а не к {region!r}")
+
+ return region, int(quality), code
+
+
+def make_verdict(
+ path: str | Path,
+ region: str,
+ quality: int,
+ violation: Optional[str] = None,
+ comment: str = "",
+ reviewer: str = "",
+ reviewed_at: Optional[str] = None,
+) -> ManualVerdict:
+ """Собрать проверенный вердикт; `reviewed_at` по умолчанию — текущий момент."""
+ region, quality, code = validate_verdict(region, quality, violation)
+ return ManualVerdict(
+ path=_normalize_path(path),
+ region=region,
+ quality=quality,
+ violation=code,
+ comment=(comment or "").strip(),
+ reviewer=(reviewer or "").strip(),
+ reviewed_at=reviewed_at or _now(),
+ )
+
+
+def manual_labels_path(labels_dir: str | Path = "labels") -> Path:
+ """Путь к файлу ручных вердиктов."""
+ return Path(labels_dir) / MANUAL_FILENAME
+
+
+def load_verdicts(source: str | Path) -> Dict[str, ManualVerdict]:
+ """
+ Прочитать ручные вердикты; отсутствующий файл — это пустая разметка.
+
+ Битые строки не роняют разбор: они пропускаются с предупреждением, чтобы
+ один испорченный ряд не делал недоступной всю накопленную разметку. Файл
+ читается как `utf-8-sig` — его правят в Excel, а тот сохраняет CSV с BOM.
+ """
+ path = Path(source)
+ if not path.exists():
+ return {}
+
+ verdicts: Dict[str, ManualVerdict] = {}
+ with path.open(newline="", encoding="utf-8-sig") as fh:
+ for lineno, row in enumerate(csv.DictReader(fh), start=2):
+ if not row or not row.get("path_to_image"):
+ continue
+ try:
+ verdict = make_verdict(
+ path=row["path_to_image"],
+ region=row.get("anatomical_region", ""),
+ quality=row.get("quality_class", ""),
+ violation=row.get("violation_type", ""),
+ comment=row.get("comment", ""),
+ reviewer=row.get("reviewer", ""),
+ reviewed_at=row.get("reviewed_at", ""),
+ )
+ except (VerdictError, ValueError) as exc:
+ logger.warning("Строка %d файла %s пропущена: %s", lineno, path, exc)
+ continue
+ verdicts[verdict.path] = verdict
+ return verdicts
+
+
+def save_verdicts(source: str | Path, verdicts: Dict[str, ManualVerdict]) -> Path:
+ """Записать вердикты, отсортировав по пути; запись атомарная."""
+ path = Path(source)
+ path.parent.mkdir(parents=True, exist_ok=True)
+ tmp = path.with_name(path.name + ".tmp")
+
+ with tmp.open("w", newline="", encoding="utf-8") as fh:
+ writer = csv.DictWriter(fh, fieldnames=list(MANUAL_FIELDS))
+ writer.writeheader()
+ for key in sorted(verdicts):
+ writer.writerow(verdicts[key].as_row())
+ os.replace(tmp, path)
+ return path
+
+
+def upsert_verdict(source: str | Path, verdict: ManualVerdict) -> Dict[str, ManualVerdict]:
+ """Добавить или заменить вердикт в файле и вернуть обновлённый словарь."""
+ verdicts = load_verdicts(source)
+ verdicts[verdict.path] = verdict
+ save_verdicts(source, verdicts)
+ return verdicts
+
+
+def remove_verdict(source: str | Path, target: str | Path) -> Dict[str, ManualVerdict]:
+ """Снять вердикт (вернуть снимок к построенной разметке)."""
+ verdicts = load_verdicts(source)
+ if verdicts.pop(_normalize_path(target), None) is None:
+ raise VerdictError(f"Нет ручного вердикта для {target}")
+ save_verdicts(source, verdicts)
+ return verdicts
+
+
+def apply_manual(
+ records: Sequence[ImageRecord],
+ verdicts: Dict[str, ManualVerdict],
+) -> List[ImageRecord]:
+ """
+ Наложить ручные вердикты поверх построенной разметки.
+
+ Записи без вердикта возвращаются как есть, поэтому специалист может
+ разметить часть набора: остальные снимки сохраняют метку из таблицы или из
+ имени файла.
+ """
+ updated: List[ImageRecord] = []
+ for rec in records:
+ verdict = verdicts.get(_normalize_path(rec.path))
+ if verdict is None:
+ updated.append(rec)
+ continue
+ updated.append(
+ ImageRecord(
+ path=rec.path,
+ study=rec.study,
+ region=verdict.region,
+ label=verdict.quality,
+ marker=rec.marker,
+ sources=rec.sources,
+ )
+ )
+ return updated
+
+
+def review_progress(
+ records: Sequence[ImageRecord],
+ verdicts: Dict[str, ManualVerdict],
+) -> Dict:
+ """
+ Сколько снимков разобрано и насколько вердикты расходятся с разметкой.
+
+ `changed` — снимки, где специалист изменил метку качества; `filename_conflict`
+ — снимки, где метка разметки расходится с пометкой в имени файла (их и
+ полезно показать первыми: это кандидаты на ошибку в любом из источников).
+ """
+ by_region: Dict[str, Dict[str, int]] = {
+ region: {"total": 0, "reviewed": 0} for region in SELECTABLE_REGIONS
+ }
+ reviewed = changed = filename_conflict = 0
+
+ for rec in records:
+ key = _normalize_path(rec.path)
+ bucket = by_region.setdefault(
+ rec.region or "unknown", {"total": 0, "reviewed": 0}
+ )
+ bucket["total"] += 1
+
+ marker_label = QUALITY_BAD if rec.marker == "bad" else QUALITY_GOOD
+ if marker_label != rec.label:
+ filename_conflict += 1
+
+ verdict = verdicts.get(key)
+ if verdict is None:
+ continue
+ reviewed += 1
+ bucket["reviewed"] += 1
+ if verdict.quality != rec.label:
+ changed += 1
+
+ return {
+ "total": len(records),
+ "reviewed": reviewed,
+ "remaining": len(records) - reviewed,
+ "changed": changed,
+ "confirmed": reviewed - changed,
+ "filename_conflict": filename_conflict,
+ "by_region": by_region,
+ }
+
+
+def _read_dicom_uids(dcm_path: Path) -> Tuple[str, str]:
+ """StudyInstanceUID и SOPInstanceUID снимка (пустые строки, если тегов нет)."""
+ try:
+ ds = pydicom.dcmread(str(dcm_path), stop_before_pixels=True)
+ except Exception as exc: # битый DICOM не должен ломать выгрузку
+ logger.warning("Не удалось прочитать теги %s: %s", dcm_path, exc)
+ return "", ""
+ return (
+ str(getattr(ds, "StudyInstanceUID", "") or ""),
+ str(getattr(ds, "SOPInstanceUID", "") or ""),
+ )
+
+
+def export_rows(
+ records: Sequence[ImageRecord],
+ verdicts: Dict[str, ManualVerdict],
+ scope: str = "all",
+ expert_paths: Optional[Iterable[str | Path]] = None,
+) -> List[Dict[str, str]]:
+ """
+ Собрать строки для выгрузки в формате построенной разметки.
+
+ Args:
+ scope: `all` — весь набор с наложенными вердиктами (годится как
+ `--labels-csv`); `reviewed` — только разобранные специалистом снимки.
+ expert_paths: пути, метка которых взята из экспертной таблицы; остальные
+ помечаются как `filename` — чтобы по выгрузке было видно происхождение.
+
+ Returns:
+ Строки со столбцами: `path_to_image`, `file`, `study`, `study_uid`,
+ `image_uid`, `anatomical_region`, `quality_class`, `violation_type`,
+ `label_rule`, `reviewer`, `comment`, `reviewed_at`.
+ """
+ if scope not in ("all", "reviewed"):
+ raise VerdictError(f"scope должен быть 'all' или 'reviewed', получено {scope!r}")
+
+ expert = {_normalize_path(p) for p in (expert_paths or ())}
+ rows: List[Dict[str, str]] = []
+
+ for rec in records:
+ key = _normalize_path(rec.path)
+ verdict = verdicts.get(key)
+ if scope == "reviewed" and verdict is None:
+ continue
+
+ study_uid, image_uid = _read_dicom_uids(rec.path)
+ if verdict is not None:
+ rule = MANUAL_RULE
+ else:
+ rule = "table" if key in expert else "filename"
+
+ rows.append(
+ {
+ "path_to_image": key,
+ "file": rec.path.name,
+ "study": rec.study,
+ "study_uid": study_uid,
+ "image_uid": image_uid,
+ "anatomical_region": (verdict.region if verdict else rec.region) or "",
+ "quality_class": str(verdict.quality if verdict else rec.label),
+ "violation_type": verdict.violation if verdict else "",
+ "label_rule": rule,
+ "reviewer": verdict.reviewer if verdict else "",
+ "comment": verdict.comment if verdict else "",
+ "reviewed_at": verdict.reviewed_at if verdict else "",
+ }
+ )
+ return rows
+
+
+#: Столбцы выгрузки (совпадают с `export_rows`).
+EXPORT_FIELDS: Tuple[str, ...] = (
+ "path_to_image",
+ "file",
+ "study",
+ "study_uid",
+ "image_uid",
+ "anatomical_region",
+ "quality_class",
+ "violation_type",
+ "label_rule",
+ "reviewer",
+ "comment",
+ "reviewed_at",
+)
diff --git a/src/dxa/model_card.py b/src/dxa/model_card.py
index 76355f4..445ec03 100644
--- a/src/dxa/model_card.py
+++ b/src/dxa/model_card.py
@@ -22,7 +22,7 @@ from typing import Dict, List
from src.dxa.violations import catalogue as violations_catalogue
#: Дата, на которую верны числа ниже.
-SNAPSHOT_DATE = "2026-09-26"
+SNAPSHOT_DATE = "2026-09-27"
#: Источник чисел — чтобы их можно было перепроверить, а не принимать на веру.
SOURCES: List[str] = [
@@ -32,14 +32,14 @@ SOURCES: List[str] = [
]
DATASET: Dict = {
- "files_on_disk": 544,
- "unique_images": 252,
+ "files_on_disk": 482,
+ "unique_images": 251,
"studies": 100,
- "per_region": {"spine": 99, "hip_right": 79, "hip_left": 73, "unknown": 1},
- "violations_expert": 74,
- "violations_labeled": 77,
+ "per_region": {"spine": 99, "hip_right": 78, "hip_left": 73, "unknown": 1},
+ "violations_expert": 73,
+ "violations_labeled": 76,
"images_without_expert_verdict": 3,
- "violation_share": 0.3056,
+ "violation_share": 0.3028,
}
LABELS: Dict = {
@@ -70,7 +70,8 @@ COMPARISON: Dict = {
"question": (
"Метку можно брать только из экспертной таблицы или дополнительно "
"учитывать служебные пометки, проставленные при подготовке набора. "
- "Пометки расходились с оценкой эксперта в 15 случаях из 252, поэтому "
+ "Пометки расходятся с оценкой эксперта в 62 случаях из 251 (в 20 из них "
+ "эксперт считает снимок годным, а имя файла помечено `_bad`), поэтому "
"правило нужно было выбрать измерением."
),
"variants": {"table": "только экспертная таблица", "union": "таблица и служебные пометки"},
@@ -79,16 +80,16 @@ COMPARISON: Dict = {
"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]},
+ "roc_auc": {"table": [0.6726, 0.6367, 0.7086], "union": [0.6003, 0.5592, 0.6415]},
+ "pr_auc": {"table": [0.4753, 0.4188, 0.5318], "union": [0.3864, 0.3487, 0.4242]},
+ "f1": {"table": [0.5559, 0.5212, 0.5906], "union": [0.5354, 0.4978, 0.5729]},
},
"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],
+ "roc_auc": [0.0723, 0.0541, 0.0905],
+ "pr_auc": [0.0889, 0.0500, 0.1277],
+ "f1": [0.0205, -0.0182, 0.0592],
},
- "paired_wins": "5 из 5 seed'ов в пользу экспертной таблицы",
+ "paired_wins": "5 из 5 seed'ов по ROC-AUC и PR-AUC в пользу экспертной таблицы",
"decision": "Принято правило `table`: служебные пометки в метках не участвуют.",
}
@@ -104,22 +105,22 @@ DEPLOYED: Dict = {
# зафиксированный файл — поэтому поле пустое, и это ожидаемо.
"split_file": None,
"seed": 42,
- "epoch": 39,
- "threshold_logit": -0.4930129051208496,
- "threshold_probability": 0.3792,
- "train_images": 199,
+ "epoch": 57,
+ "threshold_logit": -0.13703127205371857,
+ "threshold_probability": 0.4658,
+ "train_images": 198,
"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,
+ "val_roc_auc": 0.6689,
+ "val_pr_auc": 0.4769,
+ "val_f1": 0.5641,
+ "val_recall": 0.6875,
+ "val_precision": 0.4783,
"caveat": (
"Это обычный прогон с seed по умолчанию, а не лучший из выборки. "
"Незавышенная ожидаемая оценка того же варианта разметки — среднее по пяти "
- "seed'ам: ROC-AUC 0.6764 [0.6309, 0.7218]. Его собственная валидационная "
- "ROC-AUC 0.6706 близка к этому среднему."
+ "seed'ам: ROC-AUC 0.6726 [0.6367, 0.7086]. Его собственная валидационная "
+ "ROC-AUC 0.6689 близка к этому среднему."
),
}
@@ -128,7 +129,7 @@ LIMITATIONS: List[str] = [
"Модель бинарная: она отвечает «пригоден / есть нарушение» и НЕ определяет тип нарушения.",
"Тип нарушения в ответе — эвристика по метрикам снимка; на этом наборе она чаще "
"всего даёт общую категорию `unspecified`.",
- "Мало данных: 252 снимка, 77 нарушений; доверительные интервалы широкие.",
+ "Мало данных: 251 снимок, 76 нарушений; доверительные интервалы широкие.",
"Эталон — экспертная таблица, независимой истины нет; интервалы не покрывают "
"неопределённость самой разметки.",
"Для 3 снимков таблица область не оценивала — метка взята из служебной пометки "
diff --git a/src/dxa/review_pack.py b/src/dxa/review_pack.py
new file mode 100644
index 0000000..ff2f030
--- /dev/null
+++ b/src/dxa/review_pack.py
@@ -0,0 +1,841 @@
+"""
+Самодостаточный HTML-пакет для разметки снимков специалистом вне сервиса.
+
+Зачем. Интерфейс `/label` требует запущенного сервиса (`src/main.py`), а размечать
+должен врач — на своей машине, без Python, без Docker и без сети. Поэтому тот же
+сценарий собирается в один HTML-файл: снимки встроены как data-URI, вердикты
+хранятся в localStorage браузера, выгрузка — CSV в формате `manual_labels.csv`.
+
+Порядок работы:
+
+ python -m src.dxa.review_pack --out review/doctor.html
+ # врач открывает файл двойным щелчком, размечает, жмёт «Выгрузить CSV»
+ python -m src.dxa.review_pack --merge review/doctor.csv --out labels/labels_images_reviewed.csv
+
+Полученный от врача CSV — это готовый `manual_labels.csv`: он читается
+`manual_labels.load_verdicts`, накладывается на построенную разметку и
+принимается обучением как `--labels-csv`. Отдельная команда `--merge` делает
+наложение сразу и заодно сообщает, если в файле есть пути, которых нет в датасете.
+
+Что пакет намеренно НЕ содержит: оценки модели. Разметчик не должен соглашаться
+с подсказкой алгоритма — ему показывается только построенная разметка и её
+источник (экспертная таблица или пометка в имени файла).
+"""
+from __future__ import annotations
+
+import argparse
+import csv
+import hashlib
+import logging
+import json
+from pathlib import Path
+from typing import Dict, Iterable, List, Optional, Sequence, Tuple
+
+from src.dxa.dataset import build_records
+from src.dxa.excel_labels import load_labels_csv
+from src.dxa.inference import image_png_base64
+from src.dxa.labels import QUALITY_BAD, QUALITY_GOOD, REGIONS, ImageRecord
+from src.dxa.manual_labels import (
+ MANUAL_FIELDS,
+ MANUAL_RULE,
+ export_rows,
+ load_verdicts,
+)
+from src.dxa.preprocess import load_dicom_array
+from src.dxa.violations import catalogue, label as violation_label
+
+logger = logging.getLogger(__name__)
+
+#: Столбцы файла, который выгружает разметчик. Совпадают с `manual_labels.csv`,
+#: поэтому выгрузку можно положить в `labels/manual_labels.csv` как есть.
+REVIEW_FIELDS: Tuple[str, ...] = tuple(MANUAL_FIELDS) + ("pack_id",)
+
+#: Маркеры в шаблоне страницы. `str.format` не используется: в JS и CSS слишком
+#: много фигурных скобок, и экранировать их было бы источником ошибок.
+_PLACEHOLDER = {
+ "items": "@@ITEMS@@",
+ "catalogue": "@@CATALOGUE@@",
+ "regions": "@@REGIONS@@",
+ "title": "@@TITLE@@",
+ "pack_id": "@@PACK_ID@@",
+ "total": "@@TOTAL@@",
+}
+
+REGION_LABELS: Dict[str, str] = {
+ "spine": "Позвоночник",
+ "hip_right": "Бедро R",
+ "hip_left": "Бедро L",
+ "": "область не определена",
+}
+
+
+def pack_fingerprint(paths: Iterable[str]) -> str:
+ """
+ Короткий отпечаток набора снимков в пакете.
+
+ Нужен по двум причинам: ключ localStorage не должен смешивать вердикты из
+ разных пакетов, а разметчик и заказчик должны уметь сверить, что речь об
+ одном и том же файле (отпечаток печатается на странице и есть в выгрузке).
+ """
+ digest = hashlib.sha1("\n".join(sorted(paths)).encode("utf-8")).hexdigest()
+ return digest[:10]
+
+
+def collect_items(
+ records: Sequence[ImageRecord],
+ labels_table: Dict[str, Dict[str, str]],
+ expert_paths: Iterable[str] = (),
+ limit: Optional[int] = None,
+ only_conflicts: bool = False,
+) -> List[Dict]:
+ """
+ Собрать элементы пакета: снимок, построенная метка, её источник.
+
+ Порядок — как в интерфейсе `/label`: сперва расхождения метки разметки с
+ пометкой в имени файла (там ошибается один из источников), затем остальные.
+ """
+ expert = set(expert_paths)
+ items: List[Dict] = []
+
+ for rec in records:
+ key = rec.path.as_posix()
+ row = labels_table.get(key, {})
+ marker = (rec.marker or "") or ""
+ marker_label = QUALITY_BAD if marker == "bad" else QUALITY_GOOD
+ conflict = marker_label != rec.label
+
+ if only_conflicts and not conflict:
+ continue
+
+ violation = (row.get("violation_type") or "").strip()
+ items.append(
+ {
+ "path": key,
+ "file": rec.path.name,
+ "study": rec.study,
+ "region": rec.region or "",
+ "label": int(rec.label),
+ "violation": violation,
+ "violation_label": violation_label(violation.split(";")[0], empty="") if violation else "",
+ "conflict": bool(conflict),
+ "marker": marker,
+ "source": ("table" if row else "filename"),
+ }
+ )
+
+ items.sort(key=lambda i: (not i["conflict"], i["path"]))
+ if limit is not None:
+ items = items[:limit]
+ return items
+
+
+def _render_items_html(items: Sequence[Dict]) -> str:
+ """PNG каждого снимка встроить в элемент как data-URI."""
+ rendered: List[Dict] = []
+ total_bytes = 0
+ for index, item in enumerate(items, start=1):
+ array = load_dicom_array(Path(item["path"]))
+ encoded = image_png_base64(array)
+ total_bytes += len(encoded)
+ rendered.append({**item, "image": f"data:image/png;base64,{encoded}"})
+ if index % 25 == 0 or index == len(items):
+ logger.info("Подготовлено снимков: %d/%d", index, len(items))
+ logger.info("Суммарный размер изображений (base64): %.1f МБ", total_bytes / 1024 / 1024)
+ return rendered
+
+
+def render_html(
+ items: Sequence[Dict],
+ pack_title: str,
+ pack_id: str,
+ violations: Sequence[Dict],
+ regions: Sequence[str] = REGIONS,
+) -> str:
+ """
+ Собрать страницу пакета.
+
+ Страница автономна: ни одного внешнего запроса — ни CDN, ни `/static/`.
+ Это требование методики (работа в закрытом контуре) и единственный способ
+ гарантировать, что файл откроется на машине врача без сети.
+ """
+ payload = {
+ "title": pack_title,
+ "packId": pack_id,
+ "items": [
+ {k: v for k, v in item.items() if k != "violation_label"} for item in items
+ ],
+ "violations": [
+ {"code": v["code"], "label": v["label"], "scope": v.get("scope", "any")}
+ for v in violations
+ ],
+ "regions": list(regions),
+ "regionLabels": REGION_LABELS,
+ "reviewFields": list(REVIEW_FIELDS),
+ }
+
+ html = _PAGE.replace(_PLACEHOLDER["title"], pack_title)
+ html = html.replace(_PLACEHOLDER["pack_id"], pack_id)
+ html = html.replace(_PLACEHOLDER["total"], str(len(items)))
+ html = html.replace(
+ _PLACEHOLDER["items"],
+ json.dumps(payload, ensure_ascii=False).replace("", "<\\/"),
+ )
+ # Каталог нарушений и области уже внутри payload; отдельные маркеры оставлены,
+ # чтобы шаблон можно было расширять без пересборки строк.
+ html = html.replace(_PLACEHOLDER["catalogue"], "null")
+ html = html.replace(_PLACEHOLDER["regions"], "null")
+ return html
+
+
+def build_pack(
+ data_root: str | Path = "dataset_hack",
+ annotation_path: str | Path = "dataset_hack/НД_для_обучения/разметка.xlsx",
+ labels_csv: str | Path = "labels/labels_images.csv",
+ limit: Optional[int] = None,
+ only_conflicts: bool = False,
+) -> Tuple[str, Dict]:
+ """Собрать HTML-пакет и вернуть его вместе со сводкой."""
+ records = build_records(
+ str(data_root), str(annotation_path), dedup=True, labels_csv=str(labels_csv)
+ )
+ labels_table = load_labels_csv(labels_csv)
+
+ items = collect_items(
+ records, labels_table, expert_paths=labels_table.keys(),
+ limit=limit, only_conflicts=only_conflicts,
+ )
+ if not items:
+ raise ValueError("В пакет не попал ни один снимок: проверьте фильтры")
+
+ pack_id = pack_fingerprint(item["path"] for item in items)
+ title = f"Разметка снимков DXA — {len(items)} снимков"
+ html = render_html(_render_items_html(items), title, pack_id, catalogue())
+
+ stats = {
+ "items": len(items),
+ "conflicts": sum(1 for item in items if item["conflict"]),
+ "pack_id": pack_id,
+ "bytes": len(html.encode("utf-8")),
+ }
+ return html, stats
+
+
+def write_pack(out_path: str | Path, **kwargs) -> Dict:
+ """Собрать пакет и записать файл; вернуть сводку."""
+ html, stats = build_pack(**kwargs)
+ path = Path(out_path)
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(html, encoding="utf-8")
+ stats["path"] = str(path)
+ return stats
+
+
+def merge_review(
+ review_csv: str | Path,
+ out_csv: str | Path,
+ data_root: str | Path = "dataset_hack",
+ annotation_path: str | Path = "dataset_hack/НД_для_обучения/разметка.xlsx",
+ labels_csv: str | Path = "labels/labels_images.csv",
+) -> Dict:
+ """
+ Наложить вердикты разметчика на построенную разметку и записать результат.
+
+ Возвращает сводку, включая пути из файла разметчика, которых нет в датасете:
+ это признак того, что пакет собирался из другого набора, и молча принимать
+ такие вердикты нельзя.
+ """
+ verdicts = load_verdicts(review_csv)
+ if not verdicts:
+ raise ValueError(f"В файле {review_csv} нет ни одного разобранного вердикта")
+
+ records = build_records(
+ str(data_root), str(annotation_path), dedup=True, labels_csv=str(labels_csv)
+ )
+ known = {rec.path.as_posix() for rec in records}
+ unknown = sorted(set(verdicts) - known)
+
+ labels_table = load_labels_csv(labels_csv)
+ rows = export_rows(
+ records, verdicts, scope="all", expert_paths=labels_table.keys()
+ )
+
+ path = Path(out_csv)
+ path.parent.mkdir(parents=True, exist_ok=True)
+ with path.open("w", newline="", encoding="utf-8-sig") as fh:
+ writer = csv.DictWriter(fh, fieldnames=list(rows[0].keys()))
+ writer.writeheader()
+ writer.writerows(rows)
+
+ overridden = sum(1 for row in rows if row["label_rule"] == MANUAL_RULE)
+ return {
+ "reviewed": len(verdicts),
+ "overridden": overridden,
+ "unmatched": unknown,
+ "rows": len(rows),
+ "path": str(path),
+ }
+
+
+def main(argv: Optional[Sequence[str]] = None) -> int:
+ parser = argparse.ArgumentParser(
+ description="Собрать автономный HTML-пакет для ручной разметки или влить вердикты"
+ )
+ parser.add_argument("--out", help="путь к HTML-пакету или к CSV при --merge")
+ parser.add_argument("--merge", metavar="REVIEW_CSV",
+ help="CSV, полученный от разметчика: наложить на построенную разметку")
+ parser.add_argument("--data-root", default="dataset_hack")
+ parser.add_argument("--annotation-path", default="dataset_hack/НД_для_обучения/разметка.xlsx")
+ parser.add_argument("--labels-csv", default="labels/labels_images.csv")
+ parser.add_argument("--limit", type=int, default=None,
+ help="взять только первые N снимков (например, для пилота)")
+ parser.add_argument("--only-conflicts", action="store_true",
+ help="включить только снимки, где разметка расходится с именем файла")
+ args = parser.parse_args(argv)
+
+ logging.basicConfig(level=logging.INFO, format="%(message)s")
+
+ if args.merge:
+ if not args.out:
+ parser.error("--merge требует --out: куда записать объединённую разметку")
+ summary = merge_review(
+ args.merge, args.out,
+ data_root=args.data_root,
+ annotation_path=args.annotation_path,
+ labels_csv=args.labels_csv,
+ )
+ print(f"Разобрано снимков: {summary['reviewed']}")
+ print(f"Переопределено меток: {summary['overridden']} из {summary['rows']}")
+ if summary["unmatched"]:
+ print(f"ВНИМАНИЕ: путей нет в датасете: {len(summary['unmatched'])}")
+ for path in summary["unmatched"][:5]:
+ print(" ", path)
+ print(f"Разметка записана: {summary['path']}")
+ return 0
+
+ if not args.out:
+ parser.error("укажите --out: куда записать HTML-пакет")
+
+ stats = write_pack(
+ args.out,
+ data_root=args.data_root,
+ annotation_path=args.annotation_path,
+ labels_csv=args.labels_csv,
+ limit=args.limit,
+ only_conflicts=args.only_conflicts,
+ )
+ print(f"Снимков в пакете: {stats['items']} (расхождений: {stats['conflicts']})")
+ print(f"Отпечаток набора: {stats['pack_id']}")
+ print(f"Размер файла: {stats['bytes'] / 1024 / 1024:.1f} МБ")
+ print(f"Готово: {stats['path']}")
+ return 0
+
+
+#: Шаблон страницы. Стили и скрипт встроены: файл должен открываться без сети.
+_PAGE = r"""
+
+
+
+
+@@TITLE@@
+
+
+
+
+
+
Разметка снимков DXA
+
Подтвердите или исправьте вердикт по каждому снимку. Оценка модели здесь не показана намеренно.
+
+
+
+
+
+
+
+
+
+
+
+
Выберите снимок из списка слева.
+
+
+
+
+
+
+
+
+"""
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/src/main.py b/src/main.py
index 39f4c88..75654a0 100644
--- a/src/main.py
+++ b/src/main.py
@@ -13,21 +13,29 @@ FastAPI сервер для оценки качества DXA исследова
Эндпоинты:
- GET / - Главная страница (веб-интерфейс)
+- GET /label - Интерфейс ручной разметки (подтверждение вердиктов специалистом)
- GET /api/v1/health - Проверка статуса
- POST /api/v1/analyze - Базовый анализ
- POST /api/v1/analyze/detailed - Детальный анализ
- POST /api/v1/analyze/sr - DICOM SR отчет
- POST /api/v1/batch - Пакетный анализ
- POST /api/v1/export - Анализ и экспорт в XLSX
+- GET /api/v1/labeling/items - Снимки датасета для разбора
+- GET /api/v1/labeling/image - Рендер снимка для просмотра
+- POST /api/v1/labeling/verdict - Сохранить вердикт специалиста
+- DELETE /api/v1/labeling/verdict - Снять вердикт
+- GET /api/v1/labeling/export - Выгрузка разметки (CSV/XLSX)
"""
+import base64
import os
import time
from datetime import datetime
-from fastapi import FastAPI, File, UploadFile, Query
+from fastapi import Body, FastAPI, File, Query, Response, UploadFile
from fastapi.staticfiles import StaticFiles
from pathlib import Path
from fastapi.responses import StreamingResponse
-from typing import Dict, Optional
+from typing import Dict, List, Optional
+from urllib.parse import quote
import numpy as np
import pydicom
import pandas as pd
@@ -36,6 +44,8 @@ import torch
from starlette.responses import JSONResponse, FileResponse
+from src.dxa.dataset import build_records
+from src.dxa.excel_labels import load_labels_csv
from src.dxa.inference import (
build_reasons,
image_png_base64,
@@ -43,9 +53,21 @@ from src.dxa.inference import (
mask_png_base64,
predict_from_bytes,
)
+from src.dxa.labels import QUALITY_BAD, QUALITY_GOOD, ImageRecord
+from src.dxa.manual_labels import (
+ EXPORT_FIELDS,
+ SELECTABLE_REGIONS,
+ VerdictError,
+ export_rows,
+ load_verdicts,
+ make_verdict,
+ review_progress,
+ upsert_verdict,
+ remove_verdict,
+)
from src.dxa.model_card import model_card
-from src.dxa.preprocess import load_array_from_bytes
-from src.dxa.violations import canon_type, label as violation_label, sr_code
+from src.dxa.preprocess import load_array_from_bytes, load_dicom_array
+from src.dxa.violations import canon_type, catalogue, label as violation_label, sr_code
from src.utils.utils import get_device
from src.quality.quality_scorer import convert_to_serializable
@@ -72,6 +94,65 @@ dxa_threshold = 0.0
dxa_metadata: Dict = {}
device = None
+# Пути ручной разметки. Каталог данных и разметки вынесены в переменные
+# окружения по той же причине, что и чекпоинт: в контейнере рабочий каталог
+# другой, а инструмент разметки должен работать и там.
+DATA_ROOT = os.environ.get("DXA_DATA_ROOT", "dataset_hack")
+ANNOTATION_PATH = os.environ.get(
+ "DXA_ANNOTATION_PATH", "dataset_hack/НД_для_обучения/разметка.xlsx"
+)
+LABELS_CSV = os.environ.get("DXA_LABELS_CSV", "labels/labels_images.csv")
+MANUAL_LABELS_PATH = os.environ.get("DXA_MANUAL_LABELS", "labels/manual_labels.csv")
+
+#: Кэш записей датасета: сканирование хеширует пиксели 482 файлов, а интерфейс
+#: ходит в /items на каждое действие. Датасет между запросами не меняется.
+_records_cache: Dict[str, List[ImageRecord]] = {}
+
+
+def base_records() -> List[ImageRecord]:
+ """Записи датасета с построенной разметкой (без ручных вердиктов)."""
+ if "base" not in _records_cache:
+ try:
+ _records_cache["base"] = build_records(
+ DATA_ROOT, ANNOTATION_PATH, dedup=True, labels_csv=LABELS_CSV
+ )
+ except Exception as exc:
+ print(f"Dataset scan failed: {exc}")
+ _records_cache["base"] = []
+ return _records_cache["base"]
+
+
+def expert_paths() -> set:
+ """Пути, метка которых взята из экспертной таблицы, а не из имени файла."""
+ try:
+ return set(load_labels_csv(LABELS_CSV))
+ except Exception as exc:
+ print(f"Labels CSV unavailable ({LABELS_CSV}): {exc}")
+ return set()
+
+
+def _dataset_path(raw: str) -> Path:
+ """
+ Проверить, что запрошенный снимок лежит внутри каталога датасета.
+
+ Путь приходит от клиента (интерфейс ходит по путям из выгрузки), поэтому без
+ проверки запрос вида `../../etc/passwd` или абсолютный путь отдал бы наружу
+ любой файл на диске.
+ """
+ root = Path(DATA_ROOT).resolve()
+ candidate = Path(raw)
+ if not candidate.is_absolute():
+ candidate = Path.cwd() / candidate
+ candidate = candidate.resolve()
+
+ if candidate != root and root not in candidate.parents:
+ raise VerdictError("Путь вне каталога датасета")
+ if candidate.suffix.lower() != ".dcm":
+ raise VerdictError("Допустимы только файлы .dcm")
+ if not candidate.is_file():
+ raise VerdictError("Файл не найден")
+ return candidate
+
def load_model():
"""
@@ -307,6 +388,183 @@ async def get_model_card():
return convert_to_serializable(card)
+# --- Ручная разметка -------------------------------------------------------
+# Раздел 2.6 задания: «автоматическая коррекция разметки с возможностью
+# подтверждения специалистом». Поштучной экспертной оценки снимков в наборе нет
+# (в `разметка.xlsx` вердикт выставлен исследованию), поэтому вердикты,
+# проставленные в интерфейсе `/label`, хранятся отдельным файлом и
+# накладываются поверх построенной разметки.
+
+
+@app.get("/label")
+async def labeling_page():
+ """Интерфейс ручной разметки: подтверждение и правка вердиктов."""
+ page = Path(__file__).parent / "api/static/label.html"
+ if not page.exists():
+ return JSONResponse({"error": "label.html не найден"}, status_code=404)
+ return FileResponse(str(page))
+
+
+@app.get("/api/v1/labeling/items")
+async def labeling_items(
+ offset: int = Query(0, ge=0),
+ limit: int = Query(24, ge=1, le=200),
+ region: Optional[str] = None,
+ conflicts: bool = False,
+ unreviewed: bool = False,
+ q: Optional[str] = None,
+):
+ """
+ Снимки датасета с текущей меткой и её происхождением.
+
+ Расхождения с пометкой в имени файла идут первыми: там ошибка возможна в
+ любом из источников, поэтому такие снимки разбирать полезнее всего первыми.
+ """
+ records = base_records()
+ verdicts = load_verdicts(MANUAL_LABELS_PATH)
+ expert = expert_paths()
+
+ items: List[Dict] = []
+ for rec in records:
+ key = rec.path.as_posix()
+ verdict = verdicts.get(key)
+ marker_label = QUALITY_BAD if rec.marker == "bad" else QUALITY_GOOD
+
+ item = {
+ "path": key,
+ "file": rec.path.name,
+ "study": rec.study,
+ "region": (verdict.region if verdict else rec.region) or "",
+ "base_region": rec.region or "",
+ "label": verdict.quality if verdict else rec.label,
+ "base_label": rec.label,
+ "violation": verdict.violation if verdict else "",
+ "violation_label": violation_label(verdict.violation, empty="") if verdict else "",
+ "marker": rec.marker or "",
+ "filename_conflict": marker_label != rec.label,
+ "reviewed": verdict is not None,
+ "reviewer": verdict.reviewer if verdict else "",
+ "comment": verdict.comment if verdict else "",
+ "reviewed_at": verdict.reviewed_at if verdict else "",
+ "label_source": "manual" if verdict else ("table" if key in expert else "filename"),
+ "image_url": "/api/v1/labeling/image?path=" + quote(key),
+ }
+
+ if region and item["region"] != region:
+ continue
+ if conflicts and not item["filename_conflict"]:
+ continue
+ if unreviewed and item["reviewed"]:
+ continue
+ if q and q.lower() not in key.lower():
+ continue
+ items.append(item)
+
+ items.sort(key=lambda i: (not i["filename_conflict"], i["reviewed"], i["path"]))
+ return {
+ "items": items[offset : offset + limit],
+ "total": len(items),
+ "offset": offset,
+ "limit": limit,
+ "progress": review_progress(records, verdicts),
+ "regions": list(SELECTABLE_REGIONS),
+ "violations": catalogue(),
+ }
+
+
+@app.get("/api/v1/labeling/image")
+async def labeling_image(path: str = Query(..., description="Путь к снимку из датасета")):
+ """PNG снимка для просмотра специалистом (без разметки и наложений)."""
+ try:
+ target = _dataset_path(path)
+ except VerdictError as exc:
+ return JSONResponse({"error": str(exc)}, status_code=403)
+
+ try:
+ array = load_dicom_array(target)
+ except Exception as exc: # битый DICOM — ошибка формата, а не отказ доступа
+ return JSONResponse({"error": f"Не удалось прочитать DICOM: {exc}"}, status_code=422)
+
+ return Response(content=base64.b64decode(image_png_base64(array)), media_type="image/png")
+
+
+@app.post("/api/v1/labeling/verdict")
+async def save_labeling_verdict(payload: dict = Body(...)):
+ """Сохранить вердикт специалиста по снимку."""
+ try:
+ verdict = make_verdict(
+ path=payload.get("path", ""),
+ region=payload.get("region", ""),
+ quality=payload.get("quality", -1),
+ violation=payload.get("violation", ""),
+ comment=payload.get("comment", ""),
+ reviewer=payload.get("reviewer", ""),
+ )
+ _dataset_path(verdict.path) # нельзя разметить снимок вне датасета
+ except (VerdictError, ValueError, TypeError) as exc:
+ return JSONResponse({"error": str(exc)}, status_code=400)
+
+ upsert_verdict(MANUAL_LABELS_PATH, verdict)
+ return {
+ "saved": verdict.as_row(),
+ "progress": review_progress(base_records(), load_verdicts(MANUAL_LABELS_PATH)),
+ }
+
+
+@app.delete("/api/v1/labeling/verdict")
+async def delete_labeling_verdict(path: str = Query(...)):
+ """Снять вердикт: снимок возвращается к построенной разметке."""
+ try:
+ verdicts = remove_verdict(MANUAL_LABELS_PATH, path)
+ except VerdictError as exc:
+ return JSONResponse({"error": str(exc)}, status_code=404)
+ return {"progress": review_progress(base_records(), verdicts)}
+
+
+@app.get("/api/v1/labeling/export")
+async def labeling_export(scope: str = "all", fmt: str = "csv"):
+ """
+ Выгрузить разметку.
+
+ `scope=all` — весь набор с наложенными ручными вердиктами: такой файл
+ принимается обучением как `--labels-csv`. `scope=reviewed` — только
+ разобранные специалистом снимки (для сверки с построенной разметкой).
+ """
+ try:
+ rows = export_rows(
+ base_records(),
+ load_verdicts(MANUAL_LABELS_PATH),
+ scope=scope,
+ expert_paths=expert_paths(),
+ )
+ except VerdictError as exc:
+ return JSONResponse({"error": str(exc)}, status_code=400)
+
+ frame = pd.DataFrame(rows, columns=list(EXPORT_FIELDS))
+ stamp = datetime.now().strftime("%Y%m%d_%H%M")
+
+ if fmt == "xlsx":
+ buffer = io.BytesIO()
+ frame.to_excel(buffer, index=False)
+ buffer.seek(0)
+ return StreamingResponse(
+ buffer,
+ media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ headers={
+ "Content-Disposition": f'attachment; filename="manual_labels_{stamp}.xlsx"'
+ },
+ )
+
+ text = io.StringIO()
+ frame.to_csv(text, index=False)
+ # BOM нужен, чтобы Excel открыл кириллицу в UTF-8, а не в cp1251.
+ return StreamingResponse(
+ io.BytesIO(text.getvalue().encode("utf-8-sig")),
+ media_type="text/csv; charset=utf-8",
+ headers={"Content-Disposition": f'attachment; filename="manual_labels_{stamp}.csv"'},
+ )
+
+
@app.post("/api/v1/analyze")
async def analyze_dicom(file: UploadFile = File(...)):
"""Analyze single DICOM file"""
diff --git a/tests/browser/review_pack.js b/tests/browser/review_pack.js
new file mode 100644
index 0000000..18904e7
--- /dev/null
+++ b/tests/browser/review_pack.js
@@ -0,0 +1,153 @@
+/**
+ * Проверка автономного HTML-пакета для ручной разметки (файл для врача).
+ *
+ * Пакет открывается напрямую с диска (`file://`), без сервера и без сети —
+ * именно так его откроет врач. Сценарий повторяет его работу: выбрать снимок,
+ * посмотреть, отметить вердикт, убедиться, что он пережил перезагрузку, выгрузить
+ * CSV, а затем загрузить его обратно (восстановление прогресса на другой машине).
+ *
+ * Пакет нужно собрать заранее:
+ * python -m src.dxa.review_pack --out /tmp/review_small.html --limit 8
+ * PACK=/tmp/review_small.html node tests/browser/review_pack.js
+ *
+ * Переменные окружения:
+ * PACK путь к HTML-пакету (обязателен)
+ * PLAYWRIGHT путь к playwright-core (по умолчанию bundled Browser Use)
+ */
+const fs = require('fs');
+const path = require('path');
+const os = require('os');
+
+function resolvePlaywright() {
+ if (process.env.PLAYWRIGHT) return process.env.PLAYWRIGHT;
+ const base = path.join(os.homedir(), '.qwen', 'updates', 'npm');
+ if (!fs.existsSync(base)) return 'playwright-core';
+ const versions = fs.readdirSync(path.join(base, fs.readdirSync(base)[0], 'versions')).sort();
+ for (const v of versions.reverse()) {
+ const candidate = path.join(
+ base, fs.readdirSync(base)[0], 'versions', v,
+ 'node_modules/@qwen-code/qwen-code/bundled/browser-use/runtime/node_modules/playwright-core'
+ );
+ if (fs.existsSync(candidate)) return candidate;
+ }
+ return 'playwright-core';
+}
+
+const { chromium } = require(resolvePlaywright());
+
+const PACK = process.env.PACK;
+if (!PACK || !fs.existsSync(PACK)) {
+ console.error('FAILED: укажите PACK=путь/к/пакету.html (соберите его через src.dxa.review_pack)');
+ process.exit(1);
+}
+
+function assert(condition, message) {
+ if (!condition) throw new Error(message);
+}
+
+(async () => {
+ const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dxa-pack-'));
+ const context = await chromium.launchPersistentContext(userDataDir, {
+ channel: 'chrome',
+ headless: true,
+ acceptDownloads: true,
+ });
+ const page = context.pages()[0] || await context.newPage();
+ await page.setViewportSize({ width: 1500, height: 1000 });
+ // beforeunload предупреждает о невыгруженной разметке — для проверки это ожидаемо.
+ page.on('dialog', (dialog) => dialog.accept());
+
+ const consoleErrors = [];
+ const external = [];
+ page.on('console', (m) => { if (m.type() === 'error') consoleErrors.push(m.text()); });
+ page.on('pageerror', (e) => consoleErrors.push('PAGEERROR ' + e.message));
+ page.on('request', (request) => {
+ const url = request.url();
+ if (!url.startsWith('file:') && !url.startsWith('data:') && !url.startsWith('blob:')) {
+ external.push(url);
+ }
+ });
+
+ await page.goto('file://' + path.resolve(PACK), { waitUntil: 'domcontentloaded' });
+ console.log('title:', await page.title());
+
+ await page.waitForFunction(() => document.querySelectorAll('.item').length > 0, { timeout: 30000 });
+ const total = await page.evaluate(() => document.querySelectorAll('.item').length);
+ console.log('снимков в списке:', total);
+ console.log('прогресс:', (await page.locator('#progressText').innerText()).trim());
+ console.log('отпечаток пакета:', (await page.locator('#packId').innerText()).trim());
+ assert(total > 1, 'в пакете должно быть несколько снимков');
+
+ // Разбор первого снимка: изображение встроено как data-URI и рисуется.
+ await page.locator('.item').first().click();
+ await page.waitForFunction(() => {
+ const img = document.getElementById('image');
+ return img && img.naturalWidth > 0;
+ }, { timeout: 20000 });
+ const image = await page.evaluate(() => {
+ const img = document.getElementById('image');
+ return { w: img.naturalWidth, h: img.naturalHeight, data: img.getAttribute('src').startsWith('data:image/png;base64,') };
+ });
+ console.log('снимок:', image);
+ assert(image.w > 50 && image.h > 50, 'снимок не отрисован');
+ assert(image.data, 'снимок должен быть встроен как data-URI');
+
+ // Построенная разметка и её источник видны сразу (режим подтверждения).
+ const detail = await page.locator('#detail').innerText();
+ assert(/Построенная разметка/.test(detail), 'не показана построенная разметка');
+ assert(/источник:/.test(detail), 'не показан источник метки');
+ console.log('подсказка о расхождении:', /не совпадает с пометкой/.test(detail) ? 'есть' : 'нет в этом снимке');
+
+ // Нарушение: тип подставляется по области; годное — снимает тип.
+ await page.locator('#markBad').click();
+ const chosen = await page.locator('#violation').inputValue();
+ console.log('тип при отметке «нарушение»:', chosen || '(пусто)');
+ assert(chosen, 'тип нарушения не подставился');
+ await page.locator('#markGood').click();
+ assert((await page.locator('#violation').inputValue()) === '', 'тип должен сниматься при «годное»');
+
+ await page.locator('#reviewer').fill('Тестова А. А.');
+ await page.locator('#save').click();
+ await page.waitForFunction(() => /Разобрано 1 /.test(document.getElementById('progressText').innerText), { timeout: 15000 });
+ console.log('после сохранения:', (await page.locator('#progressText').innerText()).trim());
+
+ // Вердикт должен пережить перезагрузку страницы (localStorage).
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await page.waitForFunction(() => /Разобрано 1 /.test(document.getElementById('progressText').innerText), { timeout: 20000 });
+ console.log('после перезагрузки:', (await page.locator('#progressText').innerText()).trim());
+
+ // Выгрузка CSV.
+ const [download] = await Promise.all([
+ page.waitForEvent('download', { timeout: 20000 }),
+ page.locator('#export').click(),
+ ]);
+ const csvPath = path.join(userDataDir, 'export.csv');
+ await download.saveAs(csvPath);
+ const csvText = fs.readFileSync(csvPath, 'utf-8');
+ const lines = csvText.replace(/^\ufeff/, '').trim().split(/\r?\n/);
+ const header = lines[0].split(',');
+ console.log('CSV: строк', lines.length - 1, '| колонки:', header.join(','));
+ assert(/path_to_image/.test(header[0]), 'в CSV нет колонки path_to_image');
+ assert(header.includes('quality_class') && header.includes('pack_id'), 'в CSV нет обязательных колонок');
+ assert(lines.length === 2, `в CSV должна быть одна строка с вердиктом, получено ${lines.length - 1}`);
+ assert(/,0,/.test(lines[1]), 'вердикт «годное» должен записаться как quality_class=0');
+
+ // Восстановление прогресса из файла: чистим хранилище и загружаем CSV обратно.
+ await page.evaluate(() => window.localStorage.clear());
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await page.waitForFunction(() => /Разобрано 0 /.test(document.getElementById('progressText').innerText), { timeout: 20000 });
+ await page.locator('#import').setInputFiles(csvPath);
+ await page.waitForFunction(() => /Разобрано 1 /.test(document.getElementById('progressText').innerText), { timeout: 20000 });
+ console.log('после загрузки CSV:', (await page.locator('#progressText').innerText()).trim());
+
+ console.log('внешние запросы:', external.length ? external : 'нет');
+ assert(external.length === 0, `пакет обратился к сети: ${external.join(', ')}`);
+ console.log('ошибки консоли:', consoleErrors.length ? consoleErrors : 'нет');
+ assert(consoleErrors.length === 0, `ошибки в консоли: ${consoleErrors.join(' | ')}`);
+
+ await context.close();
+ console.log('OK: пакет разметки работает автономно (вердикт, перезагрузка, выгрузка, загрузка)');
+})().catch((error) => {
+ console.error('FAILED:', error.message);
+ process.exit(1);
+});
diff --git a/tests/browser/ui_labeling.js b/tests/browser/ui_labeling.js
new file mode 100644
index 0000000..e4a5e77
--- /dev/null
+++ b/tests/browser/ui_labeling.js
@@ -0,0 +1,164 @@
+/**
+ * Проверка интерфейса ручной разметки в реальном браузере.
+ *
+ * Сценарий повторяет работу специалиста: открыть /label, выбрать снимок из
+ * списка, посмотреть изображение, отметить вердикт и сохранить его. Проверяется,
+ * что вердикт действительно дошёл до сервера (счётчик разобранных растёт и
+ * остаётся после перезагрузки) и что страница не обращается к внешним хостам.
+ *
+ * Требуется запущенный сервер с временным файлом вердиктов, чтобы не портить
+ * рабочий labels/manual_labels.csv:
+ * DXA_MANUAL_LABELS=/tmp/manual_ui.csv python -m uvicorn src.main:app --port 8123
+ * node tests/browser/ui_labeling.js
+ *
+ * Переменные окружения:
+ * BASE_URL адрес сервера (по умолчанию http://127.0.0.1:8123)
+ * PLAYWRIGHT путь к playwright-core (по умолчанию bundled Browser Use)
+ */
+const fs = require('fs');
+const path = require('path');
+const os = require('os');
+
+function resolvePlaywright() {
+ if (process.env.PLAYWRIGHT) return process.env.PLAYWRIGHT;
+ const base = path.join(os.homedir(), '.qwen', 'updates', 'npm');
+ if (!fs.existsSync(base)) return 'playwright-core';
+ const versions = fs.readdirSync(path.join(base, fs.readdirSync(base)[0], 'versions')).sort();
+ for (const v of versions.reverse()) {
+ const candidate = path.join(
+ base, fs.readdirSync(base)[0], 'versions', v,
+ 'node_modules/@qwen-code/qwen-code/bundled/browser-use/runtime/node_modules/playwright-core'
+ );
+ if (fs.existsSync(candidate)) return candidate;
+ }
+ return 'playwright-core';
+}
+
+const { chromium } = require(resolvePlaywright());
+
+const BASE = process.env.BASE_URL || 'http://127.0.0.1:8123';
+
+function assert(condition, message) {
+ if (!condition) throw new Error(message);
+}
+
+(async () => {
+ const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dxa-label-'));
+ const context = await chromium.launchPersistentContext(userDataDir, {
+ channel: 'chrome',
+ headless: true,
+ });
+ const page = context.pages()[0] || await context.newPage();
+ await page.setViewportSize({ width: 1500, height: 1100 });
+
+ const consoleErrors = [];
+ const external = [];
+ page.on('console', (m) => { if (m.type() === 'error') consoleErrors.push(m.text()); });
+ page.on('pageerror', (e) => consoleErrors.push('PAGEERROR ' + e.message));
+ page.on('request', (request) => {
+ const url = request.url();
+ if (!url.startsWith(BASE) && !url.startsWith('data:')) external.push(url);
+ });
+
+ await page.goto(`${BASE}/label`, { waitUntil: 'domcontentloaded' });
+ console.log('title:', await page.title());
+
+ await page.waitForFunction(
+ () => document.querySelectorAll('#list .item-card').length > 0,
+ { timeout: 90000 }
+ );
+
+ const summary = await page.evaluate(() => ({
+ progress: document.getElementById('progressText').innerText.trim(),
+ conflicts: document.getElementById('conflictText').innerText.trim(),
+ byRegion: document.getElementById('byRegion').innerText.trim(),
+ items: document.querySelectorAll('#list .item-card').length,
+ }));
+ console.log('summary:', summary);
+ assert(summary.items > 0, 'список снимков пуст');
+ assert(/\d+ из \d+/.test(summary.progress), `счётчик разобранных не найден: ${summary.progress}`);
+
+ const total = Number(summary.progress.match(/из (\d+)/)[1]);
+ assert(total > 100, `ожидался весь датасет, получено ${total}`);
+
+ // Первым идёт расхождение разметки с пометкой в имени файла.
+ const first = await page.evaluate(() => ({
+ file: document.querySelector('#list .item-card .truncate').innerText.trim(),
+ conflict: /расхождение/.test(document.querySelector('#list .item-card').innerText),
+ }));
+ console.log('первый снимок:', first);
+ assert(first.conflict, 'расхождения должны идти первыми');
+
+ // Разбор снимка: изображение должно реально загрузиться с сервера.
+ await page.locator('#list .item-card').first().click();
+ await page.waitForSelector('#detail #image', { timeout: 20000 });
+ await page.waitForFunction(
+ () => { const img = document.getElementById('image'); return img && img.naturalWidth > 0; },
+ { timeout: 30000 }
+ );
+ const image = await page.evaluate(() => {
+ const img = document.getElementById('image');
+ return { w: img.naturalWidth, h: img.naturalHeight, src: img.getAttribute('src').slice(0, 40) };
+ });
+ console.log('изображение:', image);
+ assert(image.w > 50 && image.h > 50, `снимок не отрисован: ${JSON.stringify(image)}`);
+
+ // Вердикт: «нарушение» требует типа, «годное» его снимает.
+ await page.locator('#markBad').click();
+ const chosen = await page.locator('#violation').inputValue();
+ console.log('выбранный тип нарушения при отметке «нарушение»:', chosen || '(пусто)');
+ assert(chosen, 'при отметке «нарушение» тип не подставился');
+
+ await page.locator('#markGood').click();
+ assert(
+ (await page.locator('#violation').inputValue()) === '',
+ 'при отметке «годное» тип нарушения должен сниматься'
+ );
+
+ await page.locator('#markGood').click();
+ await page.locator('#saveVerdict').click();
+ const reviewedBefore = Number(summary.progress.match(/Разобрано (\d+)/)[1]);
+ const expected = reviewedBefore + 1;
+ await page.waitForFunction(
+ (want) => new RegExp(`Разобрано ${want} `).test(document.getElementById('progressText').innerText),
+ expected,
+ { timeout: 20000 }
+ );
+ console.log('после сохранения:', (await page.locator('#progressText').innerText()).trim());
+
+ // Вердикт должен сохраниться на сервере, а не только в состоянии страницы.
+ await page.reload({ waitUntil: 'domcontentloaded' });
+ await page.waitForFunction(
+ (want) => new RegExp(`Разобрано ${want} `).test(document.getElementById('progressText').innerText),
+ expected,
+ { timeout: 90000 }
+ );
+ console.log('после перезагрузки:', (await page.locator('#progressText').innerText()).trim());
+
+ // Фильтр «только расхождения» должен оставлять только расхождения.
+ await page.locator('#filterConflicts').check();
+ await page.waitForFunction(
+ () => document.querySelectorAll('#list .item-card').length > 0,
+ { timeout: 20000 }
+ );
+ const filtered = await page.evaluate(() => {
+ const cards = Array.from(document.querySelectorAll('#list .item-card'));
+ return {
+ items: cards.length,
+ withoutConflict: cards.filter((c) => !/расхождение/.test(c.innerText)).length,
+ };
+ });
+ console.log('фильтр расхождений:', filtered);
+ assert(filtered.withoutConflict === 0, 'в фильтре «только расхождения» есть лишние снимки');
+
+ console.log('внешние запросы:', external.length ? external : 'нет');
+ assert(external.length === 0, `страница обратилась к внешним хостам: ${external.join(', ')}`);
+ console.log('ошибки консоли:', consoleErrors.length ? consoleErrors : 'нет');
+ assert(consoleErrors.length === 0, `ошибки в консоли: ${consoleErrors.join(' | ')}`);
+
+ await context.close();
+ console.log('OK: интерфейс ручной разметки работает');
+})().catch((error) => {
+ console.error('FAILED:', error.message);
+ process.exit(1);
+});
diff --git a/tests/test_excel_labels.py b/tests/test_excel_labels.py
index 5542d51..9dc422d 100644
--- a/tests/test_excel_labels.py
+++ b/tests/test_excel_labels.py
@@ -26,6 +26,7 @@ from src.dxa.excel_labels import ( # noqa: E402
apply_excel_labels,
format_summary,
label_dataset,
+ load_labels_csv,
load_study_criteria,
resolve_region,
)
@@ -38,6 +39,13 @@ EXCEL_PATH = DATASET_ROOT / "НД_для_обучения" / "разметка.x
LABELS_CSV = Path("labels/labels_images.csv")
HAVE_DATASET = (DATASET_ROOT / "НД_для_обучения" / "Исследования").is_dir()
+# Текущее состояние набора после пересборки разметки 2026-09-27 и удаления
+# побайтных дубликатов. Числа ниже — якоря: если набор или правило снова
+# разойдутся, тесты должны упасть, а не молча согласиться с новыми значениями.
+TOTAL_IMAGES = 251 # уникальных снимков (252-й оказался дублем)
+TOTAL_VIOLATIONS = 76 # нарушений по правилу `table`
+DISPUTED = 20 # эксперт «годное», а имя файла — «нарушение»
+
needs_dataset = pytest.mark.skipif(not HAVE_DATASET, reason="dataset_hack is not available")
_WIDTH = 19
@@ -229,6 +237,19 @@ class TestApplyExcelLabels:
out = apply_excel_labels([self._record("/s/a.dcm", region="spine")], csv)
assert out[0].region is None
+ def test_reads_file_saved_by_excel_with_bom(self, tmp_path):
+ """
+ Excel сохраняет CSV с BOM, после чего первый столбец перестаёт называться
+ `path_to_image` и загрузка падает на несовпадении колонок. Разметку правят
+ в Excel, поэтому такой файл обязан читаться.
+ """
+ path = tmp_path / "labels.csv"
+ path.write_bytes(
+ "\ufeffpath_to_image,quality_class,anatomical_region\n/s/a.dcm,1,spine\n".encode("utf-8")
+ )
+ table = load_labels_csv(path)
+ assert table["/s/a.dcm"]["quality_class"] == "1"
+
class TestResolveLabelsCsv:
"""Отсутствующий файл разметки не должен молча менять источник меток."""
@@ -265,8 +286,8 @@ class TestLabelRules:
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
+ assert sum(1 for l in union if l.quality == 1) == 96
+ assert sum(1 for l in table if l.quality == 1) == TOTAL_VIOLATIONS
def test_table_rule_ignores_filename_marker(self, table):
# Снимок, помеченный `_bad`, но признанный экспертом качественным,
@@ -281,7 +302,7 @@ class TestLabelRules:
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 len(expert) == 248
assert all(l.quality_excel is not None for l in expert)
assert not any(l.used_filename_fallback for l in expert)
@@ -295,8 +316,8 @@ class TestLabelRules:
continue
assert label.violations == reference.violations
agreed += 1
- # Расходятся ровно 15 снимков (см. следующий тест), остальные совпадают.
- assert agreed == 252 - 15
+ # Расходятся ровно спорные снимки (см. следующий тест), остальные совпадают.
+ assert agreed == TOTAL_IMAGES - DISPUTED
def test_filename_only_violations_become_unspecified(self, union, table):
"""
@@ -305,7 +326,7 @@ class TestLabelRules:
"""
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
+ assert len(disputed) == DISPUTED
for label in disputed:
assert label.quality == 0 and label.violations == ()
assert by_uid[label.dicom_image_uid].quality == 1
@@ -350,7 +371,7 @@ class TestLabelDataset:
return label_dataset(DATASET_ROOT, EXCEL_PATH)
def test_covers_every_unique_image(self, labels):
- assert len(labels) == 252
+ assert len(labels) == TOTAL_IMAGES
def test_region_appears_once_per_study(self, labels):
seen = {}
@@ -360,7 +381,7 @@ class TestLabelDataset:
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
+ assert len(seen) == TOTAL_IMAGES - 1 # один снимок без определённой области
def test_union_rule_holds(self):
"""Арифметика объединения источников (правило `union`, не по умолчанию)."""
@@ -369,13 +390,13 @@ class TestLabelDataset:
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
+ assert (excel, filename, both) == (73, 54, 31)
+ assert sum(1 for l in labels if l.quality == 1) == 96
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
+ assert sum(1 for l in labels if l.quality == 1) == TOTAL_VIOLATIONS
def test_violations_match_excel_criteria(self, labels):
criteria = load_study_criteria(EXCEL_PATH)
@@ -408,7 +429,7 @@ class TestLabelDataset:
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_image_uid for l in labels}) == TOTAL_IMAGES
assert len({l.dicom_study_uid for l in labels}) == 100
def test_summary_mentions_regions(self, labels):
@@ -427,10 +448,10 @@ class TestTrainingWiring:
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 len(records) == TOTAL_IMAGES
+ # Официальная разметка — правило `table`: 73 нарушения эксперта плюс 3 снимка,
# для которых таблица область не оценивала.
- assert sum(r.label for r in records) == 77
+ assert sum(r.label for r in records) == TOTAL_VIOLATIONS
def test_split_still_has_no_study_leak(self):
train_ds, val_ds, _ = make_datasets(
@@ -442,7 +463,9 @@ class TestTrainingWiring:
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
+ # Метки из имён файлов: 54 снимка помечены `_bad` (пометки проставлены
+ # вручную и расходятся с вердиктом эксперта чаще, чем совпадают).
+ assert sum(r.label for r in records) == 54
def test_region_head_sees_resolved_region(self):
# Область одного конфликтного дубля решается голосованием имён и приходит из CSV.
diff --git a/tests/test_labeling_api.py b/tests/test_labeling_api.py
new file mode 100644
index 0000000..5c00eae
--- /dev/null
+++ b/tests/test_labeling_api.py
@@ -0,0 +1,232 @@
+"""
+Контракт API ручной разметки (`/api/v1/labeling/*`).
+
+Проверяется не только форма ответов, но и два свойства, которые легко потерять:
+вердикт нельзя сохранить для снимка вне датасета (интерфейс ходит по путям,
+приходящим от клиента), а выгрузка должна читаться тем же `load_labels_csv`, что
+и построенная разметка, иначе её нельзя передать в обучение как `--labels-csv`.
+"""
+import csv
+import os
+import sys
+from pathlib import Path
+
+import pytest
+
+sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
+
+DATASET = Path("dataset_hack")
+CHECKPOINT = Path("models/dxa_model.pth")
+
+pytest.importorskip("fastapi")
+pytest.importorskip("httpx")
+
+needs_dataset = pytest.mark.skipif(
+ not DATASET.is_dir(),
+ reason="датасет dataset_hack недоступен",
+)
+
+VALID_VIOLATION = {
+ "spine": "artifact",
+ "hip_right": "rotation",
+ "hip_left": "rotation",
+}
+
+
+@pytest.fixture(scope="module")
+def client():
+ os.environ.setdefault("DXA_MODEL_PATH", str(CHECKPOINT))
+ from fastapi.testclient import TestClient
+
+ from src.main import app
+
+ return TestClient(app)
+
+
+@pytest.fixture
+def manual_store(tmp_path, monkeypatch):
+ """Вердикты пишутся в tmp, а не в боевой `labels/manual_labels.csv`."""
+ path = tmp_path / "manual_labels.csv"
+ monkeypatch.setattr("src.main.MANUAL_LABELS_PATH", str(path))
+ return path
+
+
+def _first_item(client) -> dict:
+ response = client.get("/api/v1/labeling/items", params={"limit": 1})
+ assert response.status_code == 200
+ items = response.json()["items"]
+ assert items, "датасет должен содержать хотя бы один снимок"
+ return items[0]
+
+
+@needs_dataset
+class TestItems:
+ def test_reports_progress_and_dictionary(self, client, manual_store):
+ payload = client.get("/api/v1/labeling/items", params={"limit": 1}).json()
+ assert payload["total"] == payload["progress"]["total"]
+ assert payload["progress"]["reviewed"] == 0
+ assert set(payload["regions"]) == {"spine", "hip_right", "hip_left"}
+ codes = {v["code"] for v in payload["violations"]}
+ assert {"positioning", "axis_deviation", "artifact", "rotation", "roi_incorrect"} <= codes
+
+ def test_item_carries_provenance(self, client, manual_store):
+ item = _first_item(client)
+ for field in ("path", "file", "study", "region", "label", "base_label",
+ "label_source", "filename_conflict", "reviewed", "image_url"):
+ assert field in item, f"нет поля {field}"
+ assert item["label_source"] in ("manual", "table", "filename")
+ assert item["label"] in (0, 1)
+
+ def test_conflicts_come_first(self, client, manual_store):
+ items = client.get("/api/v1/labeling/items", params={"limit": 200}).json()["items"]
+ flags = [i["filename_conflict"] for i in items]
+ assert any(flags), "в датасете есть расхождения разметки с именем файла"
+ first_false = flags.index(False)
+ assert not any(flags[first_false:]), "расхождения должны идти первыми"
+
+ def test_region_filter(self, client, manual_store):
+ payload = client.get(
+ "/api/v1/labeling/items", params={"limit": 200, "region": "spine"}
+ ).json()
+ assert payload["items"]
+ assert {i["region"] for i in payload["items"]} == {"spine"}
+ assert payload["total"] == payload["progress"]["by_region"]["spine"]["total"]
+
+
+@needs_dataset
+class TestImage:
+ def test_returns_png_for_dataset_image(self, client, manual_store):
+ item = _first_item(client)
+ response = client.get("/api/v1/labeling/image", params={"path": item["path"]})
+ assert response.status_code == 200
+ assert response.headers["content-type"] == "image/png"
+ assert response.content.startswith(b"\x89PNG")
+
+ @pytest.mark.parametrize(
+ "bad_path",
+ ["../../etc/passwd", "/etc/passwd", "README.md", "dataset_hack/нет_такого.dcm"],
+ )
+ def test_refuses_paths_outside_dataset(self, client, manual_store, bad_path):
+ response = client.get("/api/v1/labeling/image", params={"path": bad_path})
+ assert response.status_code == 403, f"{bad_path} не должен отдаваться"
+
+
+@needs_dataset
+class TestVerdict:
+ def test_saves_and_shows_in_items(self, client, manual_store):
+ item = _first_item(client)
+ region = item["region"] or "spine"
+
+ response = client.post(
+ "/api/v1/labeling/verdict",
+ json={
+ "path": item["path"],
+ "region": region,
+ "quality": 1,
+ "violation": VALID_VIOLATION[region],
+ "comment": "размытие контура",
+ "reviewer": "специалист",
+ },
+ )
+ assert response.status_code == 200
+ assert response.json()["progress"]["reviewed"] == 1
+
+ stored = list(csv.DictReader(manual_store.open(newline="", encoding="utf-8")))
+ assert stored[0]["quality_class"] == "1"
+ assert stored[0]["reviewer"] == "специалист"
+
+ after = client.get("/api/v1/labeling/items", params={"limit": 200}).json()
+ reviewed = [i for i in after["items"] if i["path"] == item["path"]]
+ assert reviewed and reviewed[0]["reviewed"] is True
+ assert reviewed[0]["label_source"] == "manual"
+ assert reviewed[0]["label"] == 1
+
+ def test_good_image_rejects_violation_type(self, client, manual_store):
+ item = _first_item(client)
+ response = client.post(
+ "/api/v1/labeling/verdict",
+ json={"path": item["path"], "region": item["region"] or "spine",
+ "quality": 0, "violation": "artifact"},
+ )
+ assert response.status_code == 400
+ assert "не может быть типа нарушения" in response.json()["error"]
+
+ def test_unknown_violation_is_rejected(self, client, manual_store):
+ item = _first_item(client)
+ response = client.post(
+ "/api/v1/labeling/verdict",
+ json={"path": item["path"], "region": item["region"] or "spine",
+ "quality": 1, "violation": "ukladka_plohaya"},
+ )
+ assert response.status_code == 400
+
+ def test_outside_dataset_path_is_rejected(self, client, manual_store):
+ response = client.post(
+ "/api/v1/labeling/verdict",
+ json={"path": "/tmp/чужой.dcm", "region": "spine", "quality": 1, "violation": "artifact"},
+ )
+ assert response.status_code == 400
+
+ def test_delete_returns_image_to_built_labels(self, client, manual_store):
+ item = _first_item(client)
+ region = item["region"] or "spine"
+ client.post(
+ "/api/v1/labeling/verdict",
+ json={"path": item["path"], "region": region, "quality": 1,
+ "violation": VALID_VIOLATION[region]},
+ )
+ response = client.delete("/api/v1/labeling/verdict", params={"path": item["path"]})
+ assert response.status_code == 200
+ assert response.json()["progress"]["reviewed"] == 0
+
+ second = client.delete("/api/v1/labeling/verdict", params={"path": item["path"]})
+ assert second.status_code == 404
+
+
+@needs_dataset
+class TestExport:
+ def test_reviewed_export_is_loadable_by_training(self, client, manual_store):
+ from src.dxa.excel_labels import load_labels_csv
+
+ item = _first_item(client)
+ region = item["region"] or "spine"
+ client.post(
+ "/api/v1/labeling/verdict",
+ json={"path": item["path"], "region": region, "quality": 1,
+ "violation": VALID_VIOLATION[region], "reviewer": "специалист"},
+ )
+
+ response = client.get("/api/v1/labeling/export", params={"scope": "reviewed"})
+ assert response.status_code == 200
+ assert "text/csv" in response.headers["content-type"]
+
+ path = manual_store.parent / "export.csv"
+ path.write_bytes(response.content)
+ table = load_labels_csv(path)
+ assert table[item["path"]]["quality_class"] == "1"
+ assert table[item["path"]]["anatomical_region"] == region
+
+ def test_all_export_covers_whole_dataset(self, client, manual_store):
+ payload = client.get("/api/v1/labeling/items", params={"limit": 1}).json()
+ response = client.get("/api/v1/labeling/export", params={"scope": "all"})
+ rows = list(csv.DictReader(response.content.decode("utf-8-sig").splitlines()))
+ assert len(rows) == payload["progress"]["total"]
+ assert all(row["label_rule"] in ("table", "filename", "manual") for row in rows)
+
+ def test_bad_scope_rejected(self, client, manual_store):
+ response = client.get("/api/v1/labeling/export", params={"scope": "everything"})
+ assert response.status_code == 400
+
+ def test_xlsx_export(self, client, manual_store):
+ response = client.get(
+ "/api/v1/labeling/export", params={"scope": "reviewed", "fmt": "xlsx"}
+ )
+ assert response.status_code == 200
+ assert "spreadsheetml" in response.headers["content-type"]
+ assert response.content.startswith(b"PK")
+
+
+def test_label_page_is_served(client):
+ response = client.get("/label")
+ assert response.status_code == 200
+ assert "text/html" in response.headers["content-type"]
diff --git a/tests/test_manual_labels.py b/tests/test_manual_labels.py
new file mode 100644
index 0000000..52c6e9e
--- /dev/null
+++ b/tests/test_manual_labels.py
@@ -0,0 +1,229 @@
+"""
+Тесты ручной разметки: проверка вердикта, хранение, наложение и выгрузка.
+
+Ключевое свойство, которое здесь фиксируется: файл ручных вердиктов должен
+читаться тем же загрузчиком, что и построенная разметка (`load_labels_csv`),
+иначе его нельзя передать в обучение как `--labels-csv` и вся затея с
+подтверждением специалистом теряет смысл.
+"""
+import csv
+from pathlib import Path
+
+import pytest
+
+from src.dxa.excel_labels import load_labels_csv
+from src.dxa.labels import QUALITY_BAD, QUALITY_GOOD, ImageRecord
+from src.dxa.manual_labels import (
+ MANUAL_FIELDS,
+ ManualVerdict,
+ VerdictError,
+ apply_manual,
+ export_rows,
+ load_verdicts,
+ make_verdict,
+ remove_verdict,
+ review_progress,
+ save_verdicts,
+ upsert_verdict,
+ validate_verdict,
+)
+
+
+def _record(path, region="spine", label=QUALITY_GOOD, marker=None, study="S1"):
+ return ImageRecord(path=Path(path), study=study, region=region, label=label, marker=marker)
+
+
+class TestValidateVerdict:
+ def test_accepts_good_and_bad(self):
+ assert validate_verdict("spine", QUALITY_GOOD, "") == ("spine", 0, "")
+ assert validate_verdict("hip_left", QUALITY_BAD, "rotation") == ("hip_left", 1, "rotation")
+
+ def test_legacy_alias_is_canonicalized(self):
+ """Устаревшие значения внешних источников приводятся к канону, а не отклоняются."""
+ assert validate_verdict("spine", 1, "position_error")[2] == "positioning"
+ assert validate_verdict("spine", 1, "roi_error")[2] == "roi_incorrect"
+
+ def test_rejects_unknown_violation_instead_of_silent_unspecified(self):
+ with pytest.raises(VerdictError, match="Неизвестный тип нарушения"):
+ validate_verdict("spine", 1, "ukladka_plohaya")
+
+ def test_rejects_region_outside_selectable(self):
+ with pytest.raises(VerdictError, match="Область должна быть"):
+ validate_verdict("unknown", 0, "")
+
+ def test_rejects_quality_outside_binary(self):
+ with pytest.raises(VerdictError, match="0 или 1"):
+ validate_verdict("spine", 2, "")
+
+ def test_rejects_violation_for_good_image(self):
+ with pytest.raises(VerdictError, match="не может быть типа нарушения"):
+ validate_verdict("spine", 0, "artifact")
+
+ def test_rejects_violation_from_other_region(self):
+ """Ротация — критерий бедра; на позвоночнике это признак ошибки ввода."""
+ with pytest.raises(VerdictError, match="относится к области"):
+ validate_verdict("spine", 1, "rotation")
+
+ def test_hip_scope_covers_both_hips(self):
+ """В словаре нарушений бедро — один код `hip`, а область всегда конкретная."""
+ assert validate_verdict("hip_right", 1, "rotation")[2] == "rotation"
+ assert validate_verdict("hip_left", 1, "rotation")[2] == "rotation"
+ with pytest.raises(VerdictError, match="относится к области"):
+ validate_verdict("hip_right", 1, "axis_deviation")
+
+
+class TestStore:
+ def test_missing_file_is_empty(self, tmp_path):
+ assert load_verdicts(tmp_path / "manual_labels.csv") == {}
+
+ def test_roundtrip_and_upsert(self, tmp_path):
+ store = tmp_path / "manual_labels.csv"
+ upsert_verdict(store, make_verdict("/s/a.dcm", "spine", 1, "artifact", comment="мусор"))
+ upsert_verdict(store, make_verdict("/s/a.dcm", "spine", 0, "", reviewer="Иванов"))
+
+ verdicts = load_verdicts(store)
+ assert len(verdicts) == 1
+ assert verdicts["/s/a.dcm"].quality == QUALITY_GOOD
+ assert verdicts["/s/a.dcm"].reviewer == "Иванов"
+
+ def test_remove_verdict_returns_image_to_built_labels(self, tmp_path):
+ store = tmp_path / "manual_labels.csv"
+ upsert_verdict(store, make_verdict("/s/a.dcm", "spine", 1, "artifact"))
+ upsert_verdict(store, make_verdict("/s/b.dcm", "spine", 1, "artifact"))
+
+ assert set(remove_verdict(store, "/s/a.dcm")) == {"/s/b.dcm"}
+ with pytest.raises(VerdictError, match="Нет ручного вердикта"):
+ remove_verdict(store, "/s/a.dcm")
+
+ def test_file_has_declared_columns(self, tmp_path):
+ store = tmp_path / "manual_labels.csv"
+ upsert_verdict(store, make_verdict("/s/a.dcm", "spine", 1, "artifact"))
+ with store.open(newline="", encoding="utf-8") as fh:
+ assert tuple(next(csv.reader(fh))) == MANUAL_FIELDS
+
+ def test_reads_file_saved_by_excel_with_bom(self, tmp_path):
+ """Excel сохраняет CSV с BOM; разметку после ручной правки надо читать."""
+ store = tmp_path / "manual_labels.csv"
+ body = (
+ "path_to_image,anatomical_region,quality_class,violation_type,comment,reviewer,reviewed_at\n"
+ "/s/a.dcm,spine,1,artifact,,,\n"
+ )
+ store.write_bytes(b"\xef\xbb\xbf" + body.encode("utf-8"))
+ assert set(load_verdicts(store)) == {"/s/a.dcm"}
+
+ def test_corrupt_row_is_skipped_not_fatal(self, tmp_path):
+ store = tmp_path / "manual_labels.csv"
+ store.write_text(
+ "path_to_image,anatomical_region,quality_class,violation_type,comment,reviewer,reviewed_at\n"
+ "/s/ok.dcm,spine,1,artifact,,,\n"
+ "/s/bad_region.dcm,hips,1,artifact,,,\n"
+ "/s/bad_quality.dcm,spine,7,artifact,,,\n",
+ encoding="utf-8",
+ )
+ assert set(load_verdicts(store)) == {"/s/ok.dcm"}
+
+ def test_readable_by_training_loader(self, tmp_path):
+ """Файл вердиктов должен приниматься обучением как `--labels-csv`."""
+ store = tmp_path / "manual_labels.csv"
+ upsert_verdict(store, make_verdict("/s/a.dcm", "spine", 1, "artifact"))
+ upsert_verdict(store, make_verdict("/s/b.dcm", "hip_left", 0, ""))
+
+ table = load_labels_csv(store)
+ assert table["/s/a.dcm"]["quality_class"] == "1"
+ assert table["/s/b.dcm"]["anatomical_region"] == "hip_left"
+
+
+class TestOverlay:
+ def test_overrides_only_reviewed_images(self):
+ records = [
+ _record("/s/a.dcm", label=QUALITY_GOOD),
+ _record("/s/b.dcm", label=QUALITY_BAD, marker="bad"),
+ ]
+ verdicts = {"/s/a.dcm": make_verdict("/s/a.dcm", "hip_right", 1, "rotation")}
+
+ updated = apply_manual(records, verdicts)
+ assert [(r.label, r.region) for r in updated] == [(1, "hip_right"), (1, "spine")]
+ assert updated[1].region == "spine"
+ assert updated[1].marker == "bad"
+
+ def test_partial_review_keeps_other_images_intact(self):
+ records = [_record(f"/s/{i}.dcm") for i in range(5)]
+ updated = apply_manual(records, {"/s/2.dcm": make_verdict("/s/2.dcm", "spine", 1, "artifact")})
+ assert [r.label for r in updated] == [0, 0, 1, 0, 0]
+
+
+class TestProgress:
+ def test_counts_reviewed_changed_and_conflicts(self):
+ records = [
+ _record("/s/a.dcm", region="spine", label=QUALITY_GOOD),
+ _record("/s/b.dcm", region="spine", label=QUALITY_GOOD, marker="bad"),
+ _record("/s/c.dcm", region="hip_left", label=QUALITY_BAD, marker="bad"),
+ ]
+ verdicts = {"/s/a.dcm": make_verdict("/s/a.dcm", "spine", 0, "")}
+
+ progress = review_progress(records, verdicts)
+ assert progress["total"] == 3
+ assert progress["reviewed"] == 1
+ assert progress["remaining"] == 2
+ assert progress["confirmed"] == 1
+ assert progress["changed"] == 0
+ # /s/b.dcm: разметка «годное», пометка в имени «bad»
+ assert progress["filename_conflict"] == 1
+ assert progress["by_region"]["spine"] == {"total": 2, "reviewed": 1}
+ assert progress["by_region"]["hip_left"] == {"total": 1, "reviewed": 0}
+
+ def test_changed_counts_label_corrections(self):
+ records = [_record("/s/a.dcm", label=QUALITY_GOOD)]
+ verdicts = {"/s/a.dcm": make_verdict("/s/a.dcm", "spine", 1, "artifact")}
+ progress = review_progress(records, verdicts)
+ assert progress["changed"] == 1
+ assert progress["confirmed"] == 0
+
+
+class TestExport:
+ def test_scope_reviewed_contains_only_reviewed(self):
+ records = [_record("/s/a.dcm"), _record("/s/b.dcm", label=QUALITY_BAD)]
+ verdicts = {"/s/a.dcm": make_verdict("/s/a.dcm", "spine", 1, "artifact", reviewer="Иванов")}
+
+ rows = export_rows(records, verdicts, scope="reviewed")
+ assert len(rows) == 1
+ assert rows[0]["label_rule"] == "manual"
+ assert rows[0]["quality_class"] == "1"
+ assert rows[0]["reviewer"] == "Иванов"
+
+ def test_scope_all_merges_and_marks_origin(self):
+ records = [_record("/s/a.dcm", label=QUALITY_BAD), _record("/s/b.dcm")]
+ verdicts = {}
+
+ rows = export_rows(records, verdicts, scope="all", expert_paths=["/s/a.dcm"])
+ rules = {r["path_to_image"]: r["label_rule"] for r in rows}
+ assert rules == {"/s/a.dcm": "table", "/s/b.dcm": "filename"}
+
+ def test_unknown_scope_rejected(self):
+ with pytest.raises(VerdictError, match="scope должен быть"):
+ export_rows([], {}, scope="everything")
+
+ def test_missing_dicom_does_not_break_export(self):
+ """Пути вне датасета (или битый DICOM) дают пустые UID, а не исключение."""
+ rows = export_rows([_record("/no/such/file.dcm")], {}, scope="all")
+ assert rows[0]["study_uid"] == ""
+ assert rows[0]["image_uid"] == ""
+
+ def test_empty_verdict_is_not_confused_with_good(self):
+ """Словарь вердиктов пуст — строка всё равно описывает метку из разметки."""
+ rows = export_rows([_record("/s/a.dcm", label=QUALITY_BAD)], {}, scope="all")
+ assert rows[0]["quality_class"] == "1"
+ assert rows[0]["violation_type"] == ""
+ assert rows[0]["reviewed_at"] == ""
+
+
+def test_verdict_carries_timestamp():
+ verdict: ManualVerdict = make_verdict("/s/a.dcm", "spine", 1, "artifact")
+ assert verdict.reviewed_at.endswith("+00:00")
+
+
+def test_saving_empty_store_creates_header(tmp_path):
+ store = tmp_path / "manual_labels.csv"
+ save_verdicts(store, {})
+ assert store.read_text(encoding="utf-8").startswith("path_to_image,")
+ assert load_verdicts(store) == {}
diff --git a/tests/test_review_pack.py b/tests/test_review_pack.py
new file mode 100644
index 0000000..fdf95ac
--- /dev/null
+++ b/tests/test_review_pack.py
@@ -0,0 +1,212 @@
+"""
+Тесты автономного HTML-пакета для ручной разметки (`src/dxa/review_pack.py`).
+
+Проверяется то, от чего зависит работа врача без сервиса: страница не тянет
+ничего из сети, встроенные данные разбираются, выгрузка читается обучением, а
+вердикты из чужого пакета не подмешиваются молча.
+"""
+import csv
+import json
+import re
+from pathlib import Path
+
+import pytest
+
+from src.dxa.dataset import build_records
+from src.dxa.excel_labels import load_labels_csv
+from src.dxa.labels import QUALITY_BAD, QUALITY_GOOD, ImageRecord
+from src.dxa.review_pack import (
+ REVIEW_FIELDS,
+ collect_items,
+ main,
+ merge_review,
+ pack_fingerprint,
+ render_html,
+)
+
+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")
+
+
+def _record(path, region="spine", label=QUALITY_GOOD, marker=None, study="S1"):
+ return ImageRecord(path=Path(path), study=study, region=region, label=label, marker=marker)
+
+
+def _labels_table(**overrides):
+ row = {"quality_class": "0", "anatomical_region": "spine", "violation_type": ""}
+ row.update(overrides)
+ return {"/s/a.dcm": row}
+
+
+class TestFingerprint:
+ def test_is_order_independent(self):
+ assert pack_fingerprint(["a", "b"]) == pack_fingerprint(["b", "a"])
+
+ def test_differs_for_different_sets(self):
+ assert pack_fingerprint(["a", "b"]) != pack_fingerprint(["a", "c"])
+
+ def test_short_and_stable(self):
+ value = pack_fingerprint(["a"])
+ assert len(value) == 10 and value == pack_fingerprint(["a"])
+
+
+class TestCollectItems:
+ def test_conflicts_come_first(self):
+ records = [
+ _record("/s/a.dcm", label=QUALITY_GOOD),
+ _record("/s/b.dcm", label=QUALITY_BAD, marker="good"),
+ _record("/s/c.dcm", label=QUALITY_GOOD, marker="bad"),
+ ]
+ items = collect_items(records, _labels_table(), expert_paths=["/s/a.dcm"])
+ assert [item["path"] for item in items] == ["/s/b.dcm", "/s/c.dcm", "/s/a.dcm"]
+ assert all(item["conflict"] for item in items[:2])
+ assert not items[2]["conflict"]
+
+ def test_marks_source_from_labels_table(self):
+ records = [_record("/s/a.dcm"), _record("/s/b.dcm")]
+ items = collect_items(records, _labels_table(), expert_paths=["/s/a.dcm"])
+ sources = {item["path"]: item["source"] for item in items}
+ assert sources == {"/s/a.dcm": "table", "/s/b.dcm": "filename"}
+
+ def test_carries_violation_from_built_labels(self):
+ records = [_record("/s/a.dcm", label=QUALITY_BAD)]
+ items = collect_items(records, _labels_table(violation_type="rotation;roi_incorrect"))
+ assert items[0]["violation"] == "rotation;roi_incorrect"
+ # Подпись берётся по первому критерию: в разметке их может быть два.
+ assert items[0]["violation_label"] == "Ротация / позиционирование"
+
+ def test_limit_and_conflicts_only(self):
+ records = [
+ _record("/s/a.dcm", label=QUALITY_GOOD),
+ _record("/s/b.dcm", label=QUALITY_BAD, marker="good"),
+ _record("/s/c.dcm", label=QUALITY_GOOD, marker="bad"),
+ ]
+ assert len(collect_items(records, {}, limit=2)) == 2
+ only = collect_items(records, {}, only_conflicts=True)
+ assert [item["path"] for item in only] == ["/s/b.dcm", "/s/c.dcm"]
+
+
+class TestRenderHtml:
+ def _items(self):
+ return [
+ {"path": "/s/a.dcm", "file": "a.dcm", "study": "S1", "region": "spine",
+ "label": 1, "violation": "artifact", "violation_label": "Артефакты",
+ "conflict": True, "marker": "bad", "source": "table", "image": "data:image/png;base64,AA"},
+ ]
+
+ def test_all_placeholders_are_substituted(self):
+ html = render_html(self._items(), "Пакет", "abcdef1234", [])
+ assert "@@" not in html, "в странице остались неподставленные маркеры"
+
+ def test_does_not_reference_network(self):
+ """Пакет открывается с диска: внешних ресурсов быть не должно."""
+ html = render_html(self._items(), "Пакет", "abcdef1234", [])
+ assert "http://" not in html and "https://" not in html
+ assert not re.search(r'(?:src|href)="/(?!/)', html), "есть ссылки на /static или CDN"
+
+ def test_payload_is_valid_json_with_items(self):
+ html = render_html(self._items(), "Пакет", "abcdef1234", [{"code": "artifact", "label": "A", "scope": "any"}])
+ raw = re.search(r'', html, re.S)
+ assert raw, "не найден блок с данными"
+ payload = json.loads(raw.group(1))
+ assert payload["packId"] == "abcdef1234"
+ assert len(payload["items"]) == 1
+ assert payload["items"][0]["conflict"] is True
+ assert "image" in payload["items"][0]
+ assert payload["reviewFields"] == list(REVIEW_FIELDS)
+
+ def test_embedded_image_uri_is_not_broken(self):
+ """`` внутри JSON ломало бы разбор блока script."""
+ item = {**self._items()[0], "comment": "закрывающий тег в тексте"}
+ html = render_html([item], "Пакет", "abcdef1234", [])
+ payload = re.search(r'', html, re.S)
+ assert json.loads(payload.group(1))
+
+
+@needs_dataset
+class TestMergeReview:
+ def _write_review(self, tmp_path, rows):
+ path = tmp_path / "doctor.csv"
+ with path.open("w", newline="", encoding="utf-8") as fh:
+ writer = csv.DictWriter(fh, fieldnames=list(REVIEW_FIELDS))
+ writer.writeheader()
+ writer.writerows(rows)
+ return path
+
+ def test_overrides_built_labels(self, tmp_path):
+ records = build_records(
+ str(DATASET_ROOT), str(EXCEL_PATH), dedup=True, labels_csv=str(LABELS_CSV)
+ )
+ first = records[0].path.as_posix()
+ review = self._write_review(tmp_path, [{
+ "path_to_image": first, "anatomical_region": "spine", "quality_class": "1",
+ "violation_type": "artifact", "comment": "проверено врачом",
+ "reviewer": "Тестова", "reviewed_at": "2026-09-27T00:00:00+00:00", "pack_id": "x",
+ }])
+
+ out = tmp_path / "merged.csv"
+ summary = merge_review(review, out, labels_csv=LABELS_CSV)
+
+ assert summary["reviewed"] == 1 and summary["overridden"] == 1
+ assert not summary["unmatched"]
+ table = load_labels_csv(out)
+ assert len(table) == summary["rows"]
+ assert table[first]["quality_class"] == "1"
+ assert table[first]["label_rule"] == "manual"
+ assert table[first]["reviewer"] == "Тестова"
+ # Остальные снимки остаются с меткой построенной разметки.
+ assert any(row["label_rule"] == "table" for row in table.values())
+
+ def test_reports_paths_absent_from_dataset(self, tmp_path):
+ review = self._write_review(tmp_path, [{
+ "path_to_image": "/чужой/снимок.dcm", "anatomical_region": "spine",
+ "quality_class": "0", "violation_type": "", "comment": "", "reviewer": "",
+ "reviewed_at": "", "pack_id": "x",
+ }])
+ out = tmp_path / "merged.csv"
+ summary = merge_review(review, out, labels_csv=LABELS_CSV)
+ assert summary["unmatched"] == ["/чужой/снимок.dcm"]
+ assert summary["overridden"] == 0
+
+ def test_empty_review_is_rejected(self, tmp_path):
+ path = tmp_path / "empty.csv"
+ with path.open("w", newline="", encoding="utf-8") as fh:
+ csv.DictWriter(fh, fieldnames=list(REVIEW_FIELDS)).writeheader()
+ with pytest.raises(ValueError, match="нет ни одного разобранного вердикта"):
+ merge_review(path, tmp_path / "out.csv", labels_csv=LABELS_CSV)
+
+
+@needs_dataset
+class TestCli:
+ def test_build_writes_file_with_expected_shape(self, tmp_path, capsys):
+ out = tmp_path / "pack.html"
+ code = main(["--out", str(out), "--limit", "3"])
+ assert code == 0
+ assert out.exists() and out.stat().st_size > 10_000
+
+ html = out.read_text(encoding="utf-8")
+ payload = re.search(r'', html, re.S)
+ data = json.loads(payload.group(1))
+ assert len(data["items"]) == 3
+ assert all(item["image"].startswith("data:image/png;base64,") for item in data["items"])
+
+ printed = capsys.readouterr().out
+ assert "Снимков в пакете: 3" in printed
+ assert "Отпечаток набора:" in printed
+
+ def test_only_conflicts_filter(self, tmp_path):
+ out = tmp_path / "pack.html"
+ main(["--out", str(out), "--only-conflicts", "--limit", "5"])
+ html = out.read_text(encoding="utf-8")
+ data = json.loads(
+ re.search(r'', html, re.S).group(1)
+ )
+ assert data["items"] and all(item["conflict"] for item in data["items"])
+
+ def test_merge_requires_out(self, tmp_path, capsys):
+ with pytest.raises(SystemExit):
+ main(["--merge", str(tmp_path / "x.csv")])