From 3de6ed5af32441a8592f71001c3c46dddb06e174 Mon Sep 17 00:00:00 2001
From: denis
Date: Sun, 27 Sep 2026 22:04:50 +0300
Subject: [PATCH] develop - hack_2026
---
.gitignore | 5 +
Dockerfile | 6 +-
Dockerfile_cuda => Dockerfile_cuda.txt | 0
QWEN.md | 136 +-
README.md | 123 +-
assets/labeling.md | 175 +-
docker-compose.yml | 2 +-
docs/technical-description.html | 202 +-
docs/technical-description.pdf | Bin 1107211 -> 1141257 bytes
labels/labels_images.csv | 41 +-
labels/labels_images.xlsx | Bin 32282 -> 30154 bytes
labels/labels_images_expert.csv | 39 +-
labels/labels_images_expert.xlsx | Bin 31943 -> 29899 bytes
labels/labels_images_table.csv | 41 +-
labels/labels_images_table.xlsx | Bin 32282 -> 30155 bytes
labels/labels_images_union.csv | 505 +++--
labels/labels_images_union.xlsx | Bin 30341 -> 30234 bytes
labels/split_expert_seed42.json | 2 +-
models/dxa_model.pth | Bin 44835659 -> 44837323 bytes
models/train_report.json | 2642 ++++++++++++++----------
models/train_report.md | 28 +-
run.sh | 27 +-
src/api/static/js/labeling.js | 494 +++++
src/api/static/label.html | 142 ++
src/dxa/excel_labels.py | 10 +-
src/dxa/manual_labels.py | 402 ++++
src/dxa/model_card.py | 55 +-
src/dxa/review_pack.py | 841 ++++++++
src/main.py | 266 ++-
tests/browser/review_pack.js | 153 ++
tests/browser/ui_labeling.js | 164 ++
tests/test_excel_labels.py | 55 +-
tests/test_labeling_api.py | 232 +++
tests/test_manual_labels.py | 229 ++
tests/test_review_pack.py | 212 ++
35 files changed, 5572 insertions(+), 1657 deletions(-)
rename Dockerfile_cuda => Dockerfile_cuda.txt (100%)
create mode 100644 src/api/static/js/labeling.js
create mode 100644 src/api/static/label.html
create mode 100644 src/dxa/manual_labels.py
create mode 100644 src/dxa/review_pack.py
create mode 100644 tests/browser/review_pack.js
create mode 100644 tests/browser/ui_labeling.js
create mode 100644 tests/test_labeling_api.py
create mode 100644 tests/test_manual_labels.py
create mode 100644 tests/test_review_pack.py
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 г. |
| Команда | Грачев Денис — разработка; Грачев Татьяна — капитан |
| Версия решения | 1.0.0 (src/__init__.py) |
- | Рабочий чекпоинт | models/dxa_model.pth: ResNet18 + линейная голова, разметка table, seed 42, эпоха 39, порог логита −0.4930 (вероятность 0.379) |
- | Ключевые метрики | ROC-AUC 0.6764 [0.6309, 0.7218], F1 0.5676 [0.5270, 0.6082] — пять seed'ов на фиксированном разбиении по исследованиям |
- | Время обработки | 0.021 с на изображение (медиана, CPU), 548 файлов за 12.1 с; требование «≤ 3 мин на исследование» выполняется с запасом |
+ | Рабочий чекпоинт | models/dxa_model.pth: ResNet18 + линейная голова, разметка table, seed 42, эпоха 57, порог логита −0.1370 (вероятность 0.466) |
+ | Ключевые метрики | 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 |
+ Разд. 11.1; tests/test_manual_labels.py, tests/test_labeling_api.py, tests/browser/ui_labeling.js |
| Обязательная контейнеризация, скрипт сборки и запуска в 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+ исследований: доверительные интервалы сузятся, появится возможность
честной валидации без пересечения с обучением.
- Заменить признак «ширина кадра» на калибровку по метаданным аппарата, чтобы определение области
@@ -971,6 +1038,8 @@ CPU-образ содержит
torch 2.8.0+cpu и ни одног
│ │ ├── inference.py пакетный инференс, определение области, визуализация
│ │ ├── labels.py разбор имён, метки, склейка дублей, разбиение
│ │ ├── excel_labels.py разметка снимков по экспертной таблице
+│ │ ├── manual_labels.py ручная разметка: хранение вердиктов специалиста (/label)
+│ │ ├── review_pack.py автономный HTML-пакет разметки для специалиста (без сервера)
│ │ ├── rename_files.py приведение имён DICOM к единому виду
│ │ ├── violations.py единый словарь типов нарушений
│ │ ├── model_card.py карточка решения для API и интерфейса
@@ -997,12 +1066,19 @@ CPU-образ содержит torch 2.8.0+cpu и ни одног
# инференс и сервис
./run.sh infer "dataset_hack/Для теста" results.xlsx
-./run.sh serve API и веб-интерфейс на порту 8000
+./run.sh serve API и веб-интерфейс на порту 8000; /label — ручная разметка
python -m src.dxa.discriminator проверка вклада содержимого снимка
# проверки
-./run.sh test pytest (209 тестов)
+./run.sh test pytest (272 теста)
node tests/browser/ui_check.js проверки интерфейса (нужен сервер на 127.0.0.1:8123)
+node tests/browser/ui_labeling.js проверка интерфейса ручной разметки
+PACK=review/doctor_review.html node tests/browser/review_pack.js автономный пакет (без сервера)
+
+# разметка специалистом для набора целиком
+./run.sh review --out review/doctor_review.html файл-пакет для специалиста
+./run.sh review --merge review/doctor.csv \
+ --out labels/labels_images_reviewed.csv наложить вердикты на разметку
# контейнер (чекпоинт лежит внутри образа, монтировать пути не нужно)
docker compose up -d CPU-сервис в фоне, http://localhost:8000
diff --git a/docs/technical-description.pdf b/docs/technical-description.pdf
index 730116f4d5c756b4e97d9caf150dd0ba2e127787..4b55cfceb42778a9b03d4a26551b3ac7f0e179fb 100644
GIT binary patch
delta 427058
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