bone_2026/docs/technical-description.html

1094 lines
106 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<title>Техническое описание — автоматизированная оценка качества DXA</title>
<style>
@page { size: A4; margin: 18mm 16mm 16mm 16mm; }
html { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
body {
font: 10.5pt/1.5 "PT Serif", Georgia, "Times New Roman", serif;
color: #14181d; margin: 0; max-width: none;
}
h1, h2, h3, h4 { font-family: "Helvetica Neue", Arial, sans-serif; color: #0f1c2e; line-height: 1.25; }
h1 { font-size: 19pt; margin: 0 0 6pt; }
h2 {
font-size: 13pt; margin: 20pt 0 8pt; padding-bottom: 3pt;
border-bottom: 1.2pt solid #1f4e79; page-break-after: avoid;
}
h3 { font-size: 11pt; margin: 14pt 0 5pt; page-break-after: avoid; }
h4 { font-size: 10.5pt; margin: 11pt 0 4pt; page-break-after: avoid; }
p { margin: 0 0 7pt; text-align: justify; }
ul, ol { margin: 0 0 7pt; padding-left: 16pt; }
li { margin-bottom: 2.5pt; }
code, .mono { font-family: "SFMono-Regular", Menlo, Consolas, monospace; font-size: 9pt; background: #f4f6f8; padding: 0 2pt; }
pre {
font-family: "SFMono-Regular", Menlo, Consolas, monospace; font-size: 8.8pt;
background: #f6f8fa; border: 0.6pt solid #d6dde4; border-radius: 3pt;
padding: 6pt 8pt; margin: 0 0 8pt; white-space: pre-wrap; page-break-inside: avoid;
}
table { border-collapse: collapse; width: 100%; margin: 0 0 9pt; font-size: 9.5pt; page-break-inside: avoid; }
th, td { border: 0.6pt solid #b9c4ce; padding: 3.5pt 5pt; text-align: left; vertical-align: top; }
th { background: #eaeff4; font-family: "Helvetica Neue", Arial, sans-serif; font-size: 9pt; }
td.num, th.num { text-align: right; white-space: nowrap; }
.cover { border: 1pt solid #1f4e79; border-left: 6pt solid #1f4e79; padding: 12pt 14pt; margin-bottom: 14pt; }
.cover .sub { font-size: 11.5pt; color: #35506e; margin: 2pt 0 10pt; }
.cover table { margin-bottom: 0; font-size: 9.5pt; }
.cover th { width: 34%; background: #f3f6f9; }
.lead { font-size: 10.5pt; background: #f3f6f9; border-left: 3pt solid #6b8fb4; padding: 7pt 9pt; margin-bottom: 12pt; }
.note { font-size: 9.5pt; background: #fdf6e3; border: 0.6pt solid #e0c893; border-radius: 3pt; padding: 6pt 8pt; margin: 0 0 9pt; }
.flag { font-family: "Helvetica Neue", Arial, sans-serif; font-size: 8.5pt; font-weight: 600; color: #8a5a00; background: #fdf0d5; border: 0.6pt solid #e0c893; border-radius: 2pt; padding: 0 3pt; white-space: nowrap; }
.toc { page-break-after: always; }
.toc ol { list-style: none; padding-left: 0; counter-reset: toc; }
.toc li { margin-bottom: 3pt; font-size: 10pt; }
.toc a { color: #1f4e79; text-decoration: none; }
.flow { margin: 0 0 9pt; page-break-inside: avoid; }
.flow table { font-size: 9pt; }
.flow td, .flow th { text-align: center; vertical-align: middle; }
.flow .arrow { border: 0; width: 14pt; font-size: 12pt; color: #1f4e79; }
.page-break { page-break-before: always; }
.imgs { display: block; page-break-inside: avoid; margin-bottom: 10pt; }
.imgs img { width: 100%; border: 0.6pt solid #b9c4ce; border-radius: 3pt; }
.imgs.two { display: flex; gap: 6pt; }
.imgs .cap { font-size: 8.8pt; color: #4a5a6a; margin-top: 3pt; }
figure { margin: 0 0 10pt; page-break-inside: avoid; }
figure img { width: 100%; border: 0.6pt solid #b9c4ce; border-radius: 3pt; }
figcaption { font-size: 8.8pt; color: #4a5a6a; margin-top: 3pt; }
.sign { margin-top: 16pt; font-size: 9.5pt; color: #35506e; }
</style>
</head>
<body>
<div class="cover">
<h1>Автоматизированная оценка качества денситометрических исследований</h1>
<div class="sub">Техническое описание решения</div>
<table>
<tr><th>Задача</th><td>Контроль качества DXA-исследований: бинарная оценка «качественное / есть нарушение», определение анатомической области, тип нарушения, структурированный отчёт</td></tr>
<tr><th>Дата документа</th><td>27 сентября 2026 г.</td></tr>
<tr><th>Команда</th><td>Грачев Денис — разработка; Грачев Татьяна — капитан</td></tr>
<tr><th>Версия решения</th><td>1.0.0</td></tr>
<tr><th>Рабочий чекпоинт</th><td><code>models/dxa_model.pth</code>: ResNet18 + линейная голова, разметка <code>table</code>, seed 42, эпоха 57, порог логита −0.1370 (вероятность 0.466)</td></tr>
<tr><th>Ключевые метрики</th><td>ROC-AUC 0.6726 [0.6367, 0.7086], F1 0.5559 [0.5212, 0.5906] — пять seed'ов на фиксированном разбиении по исследованиям</td></tr>
<tr><th>Время обработки</th><td>0.022 с на изображение (медиана, CPU), 482 файла за 13.9 с; требование «≤ 3 мин на исследование» выполняется с запасом</td></tr>
</table>
</div>
<p class="lead">Документ описывает фактическое состояние репозитория на дату, указанную на титуле:
все параметры, пороги, метрики и имена полей приведены по коду и по выполненным измерениям, а не по
проектным намерениям. Там, где значение измерено на конкретной машине, указано, на какой.
Расхождения и ограничения перечислены явно — разделы 15 и 16.</p>
<div class="toc">
<h2 style="margin-top:0">Содержание</h2>
<ol>
<li><a href="#s1">1. Назначение и область применения</a></li>
<li><a href="#s2">2. Соответствие требованиям задания</a></li>
<li><a href="#s3">3. Архитектура решения</a></li>
<li><a href="#s4">4. Состав данных и разметка</a></li>
<li><a href="#s5">5. Предобработка изображений</a></li>
<li><a href="#s6">6. Модель и процедура обучения</a></li>
<li><a href="#s7">7. Определение анатомической области</a></li>
<li><a href="#s8">8. Таксономия нарушений качества</a></li>
<li><a href="#s9">9. Форматы входных и выходных данных</a></li>
<li><a href="#s10">10. API сервиса</a></li>
<li><a href="#s11">11. Веб-интерфейс</a></li>
<li><a href="#s12">12. Метрики качества</a></li>
<li><a href="#s13">13. Производительность и системные требования</a></li>
<li><a href="#s14">14. Сборка, запуск и развёртывание</a></li>
<li><a href="#s15">15. Тесты и проверки</a></li>
<li><a href="#s16">16. Известные ошибки и их обработка</a></li>
<li><a href="#s17">17. Ограничения и достоверность результатов</a></li>
<li><a href="#s18">18. План развития</a></li>
<li><a href="#a1">Приложение А. Структура репозитория</a></li>
<li><a href="#a2">Приложение Б. Команды</a></li>
</ol>
</div>
<h2 id="s1">1. Назначение и область применения</h2>
<p>Сервис принимает денситометрическое исследование в формате DICOM и выполняет автоматизированный
контроль его качества: определяет анатомическую область, решает бинарную задачу «изображение пригодно
для клинической интерпретации / содержит нарушение качества», относит нарушение к категории и
формирует структурированный отчёт (XLSX/CSV) с одной строкой на изображение.</p>
<p>Задача решается как цифровой помощник, а не как замена врача: результат предназначен для отбора
исследований, требующих внимания специалиста, и для единообразной регистрации нарушений. Области
анализа — поясничный отдел позвоночника и проксимальный отдел бедренной кости (левая и правая
стороны рассматриваются как отдельные снимки, поскольку сторона влияет на трактовку критериев).</p>
<p>Решение работает полностью офлайн: изображения не передаются во внешние сервисы, все зависимости и
веса фиксированы, доступ в сеть при инференсе не требуется. Это прямое требование методики
(п.&nbsp;3.2 задания) и одновременно условие применимости в закрытом контуре медицинской организации.</p>
<h2 id="s2">2. Соответствие требованиям задания</h2>
<p>Таблица связывает пункты задания с реализацией и со способом проверки. Формулировки требований
приведены по тексту методических рекомендаций.</p>
<table>
<tr><th style="width:34%">Требование</th><th style="width:40%">Реализация</th><th>Чем подтверждается</th></tr>
<tr><td>Области: поясничный отдел позвоночника и проксимальный отдел бедренной кости</td>
<td>Область определяется автоматически по кадру; бедро разделяется на левое и правое</td>
<td>Разд. 7; на валидации 99/99 для позвоночника</td></tr>
<tr><td>Бинарная классификация «качественное / есть нарушение»</td>
<td>ResNet18 (ImageNet) + линейная голова, порог по логиту, подобранный по F1</td>
<td>Разд. 6, 12; <code>tests/test_preprocess_and_model.py</code></td></tr>
<tr><td>Определение типа нарушения</td>
<td>Единый словарь кодов <code>src/dxa/violations.py</code>; отнесение к категории — по измеряемым признакам снимка</td>
<td>Разд. 8; <code>tests/test_violations.py</code></td></tr>
<tr><td>Оценка корректности разметки анатомических структур</td>
<td>В наборе нет ROI-разметки в DICOM, поэтому оценивается геометрия видимой зоны измерения и её границы</td>
<td>Разд. 8, 17 (ограничение 4)</td></tr>
<tr><td>Отчёт <code>.xlsx</code>/<code>.csv</code>, одна строка на изображение, столбцы из п.&nbsp;2.5</td>
<td>CLI-инференс и <code>POST /api/v1/export</code>; обязательные восемь столбцов плюс три диагностических</td>
<td>Разд. 9 (состав столбцов проверен на реальном прогоне)</td></tr>
<tr><td>Время обработки одного исследования ≤ 3 мин</td>
<td>Один снимок — один прямой проход сети 224×224, без итеративных процедур</td>
<td>Разд. 13: 0.022 с медиана на CPU, запас более чем трёхкратный</td></tr>
<tr><td>Отсутствие необработанных исключений; ошибки фиксируются в отчёте</td>
<td>Ошибка на файле не прерывает пакет: строка получает <code>processing_status = Failure: …</code></td>
<td>Разд. 16</td></tr>
<tr><td>Воспроизводимость при повторном запуске</td>
<td>Фиксированный seed, зафиксированное разбиение в файле, версии зависимостей, самодостаточный чекпоинт</td>
<td>Разд. 6, 14; <code>tests/test_labels.py</code></td></tr>
<tr><td>Пакетная обработка архива + общая таблица + zip с дополнительными сериями</td>
<td>Обход каталога, лог прогресса, XLSX/CSV, опциональный zip с визуализацией</td>
<td>Разд. 9, 13</td></tr>
<tr><td>API для пакетной обработки тестового набора</td>
<td>FastAPI: четырнадцать маршрутов, включая <code>/api/v1/batch</code>, <code>/api/v1/export</code> и маршруты ручной разметки <code>/api/v1/labeling/*</code></td>
<td>Разд. 10</td></tr>
<tr><td>Дополнительно (п. 2.6): коррекция разметки с возможностью подтверждения специалистом</td>
<td>Интерфейс <code>/label</code>: снимок целиком, текущая метка с указанием источника, вердикт специалиста сохраняется в <code>labels/manual_labels.csv</code> и принимается обучением как <code>--labels-csv</code></td>
<td>Разд. 11.1; <code>tests/test_manual_labels.py</code>, <code>tests/test_labeling_api.py</code>, <code>tests/browser/ui_labeling.js</code></td></tr>
<tr><td>Обязательная контейнеризация, скрипт сборки и запуска в Linux</td>
<td><code>Dockerfile</code> (python:3.11-slim, чекпоинт внутри образа), <code>Dockerfile_cuda</code> для GPU, <code>docker-compose.yml</code> на оба случая, <code>run.sh</code></td>
<td>Разд. 14; сборка и запуск проверены, см. 14</td></tr>
<tr><td>Фиксация зависимостей, включая базовый контейнер</td>
<td><code>requirements.txt</code> с точными версиями, базовый образ по тегу, torch с CPU-индексом (CPU-вариант) и cu126 (GPU-вариант)</td>
<td>Разд. 14</td></tr>
<tr><td>Работа без обращения к внешним сервисам</td>
<td>Офлайн-ассеты фронтенда, отсутствие сетевых вызовов в коде предсказания</td>
<td><code>tests/browser/ui_offline.js</code></td></tr>
<tr><td>Полный комплект документации</td>
<td><code>README.md</code> (обзор и воспроизведение), <code>assets/labeling.md</code> (разметка и её обоснование), <code>docs/technical-description.html</code> и <code>.pdf</code> (данный документ)</td>
<td>—</td></tr>
</table>
<h2 id="s3">3. Архитектура решения</h2>
<h3>3.1. Слои</h3>
<table>
<tr><th style="width:22%">Слой</th><th style="width:26%">Файлы</th><th>Ответственность</th></tr>
<tr><td>Прикладной ядерный слой</td><td><code>src/dxa/</code></td>
<td>Чтение DICOM, предобработка, модель, метрики, определение области, разметка данных, обучение, пакетный инференс</td></tr>
<tr><td>Сервис</td><td><code>src/main.py</code></td>
<td>FastAPI: загрузка чекпоинта при старте, маршруты анализа, пакетной обработки, экспорта, здоровья и карточки решения</td></tr>
<tr><td>Веб-интерфейс</td><td><code>src/api/static/</code></td>
<td>Загрузка файлов, таблица результатов, панель деталей, панель «О модели»; все подписи и метрики получает с сервера</td></tr>
<tr><td>Эвристики качества</td><td><code>src/quality/</code></td>
<td>Числовые измерения снимка (резкость, плотные включения, полнота и поворот области) для панели деталей</td></tr>
<tr><td>Инфраструктура</td><td><code>run.sh</code>, <code>docker-compose.yml</code>, <code>Dockerfile</code>, <code>Dockerfile_cuda</code>, <code>Jenkinsfile</code></td>
<td>Единая точка входа для сборки, обучения, инференса и тестов; сборка образа и выкладка</td></tr>
</table>
<h3>3.2. Поток обработки одного изображения</h3>
<p>Ниже — фактический путь данных от файла до строки отчёта. Ключевое свойство: обучение и сервис
используют <em>один и тот же</em> код предобработки и предсказания, поэтому расхождение между
офлайн-оценкой и работой API невозможно по построению, а не по договорённости.</p>
<div class="flow">
<table>
<tr>
<th>DICOM</th><td class="arrow">→</td>
<th>Чтение пикселей и метаданных</th><td class="arrow">→</td>
<th>Предобработка 224×224</th><td class="arrow">→</td>
<th>ResNet18 + линейная голова</th>
</tr>
<tr>
<td colspan="7" style="border:0; height:4pt"></td>
</tr>
<tr>
<th>Логит → порог</th><td class="arrow">→</td>
<th>Область по кадру</th><td class="arrow">→</td>
<th>Числовые измерения</th><td class="arrow">→</td>
<th>Строка отчёта / ответ API</th>
</tr>
</table>
</div>
<p>Порядок операций и точка принятия решения:</p>
<ol>
<li><b>Чтение.</b> Из файла берутся пиксельные данные и идентификаторы исследования и снимка
(<code>StudyInstanceUID</code>, <code>SOPInstanceUID</code>). Персональные данные не используются:
решение не зависит от их наличия.</li>
<li><b>Предобработка.</b> Приводится к виду, ожидаемому предобученным backbone (описание — разд. 5).
Параметры предобработки хранятся в чекпоинте и берутся из него, а не из кода по умолчанию.</li>
<li><b>Оценка качества.</b> Один прямой проход сети даёт логит. Решение принимается по логиту:
<code>quality_class = 1</code>, если логит выше порога. Порог подобран по F1 на валидации и
сохранён в чекпоинте (текущее значение −0.1370, что соответствует вероятности 0.466).</li>
<li><b>Анатомическая область.</b> Определяется независимо от модели, по геометрии кадра (разд. 7),
с оценкой уверенности.</li>
<li><b>Измерения и тип нарушения.</b> Для снимков с нарушением вычисляются числовые признаки снимка,
по которым нарушение относится к категории словаря (разд. 8).</li>
<li><b>Отчёт.</b> Строка собирается со статусом обработки и временем; ошибка на файле не прерывает пакет.</li>
</ol>
<div class="note">В <code>README.md</code> приведён полный набор проектных диаграмм (компоненты, потоки данных,
последовательность, развёртывание). Они описывают целевую архитектуру, включая блоки, которые в текущей
реализации не используются, поэтому в настоящем документе поток описан текстом по коду.</div>
<h2 id="s4">4. Состав данных и разметка</h2>
<h3>4.1. Набор</h3>
<table>
<tr><th>Показатель</th><th class="num">Значение</th><th>Пояснение</th></tr>
<tr><td>Файлов на диске</td><td class="num">478</td><td>DICOM в обучающем наборе (<code>dataset_hack/НД_для_обучения</code>); лишние побайтные копии удалены</td></tr>
<tr><td>Уникальных снимков</td><td class="num">251</td><td>по пиксельному содержимому; 227 файлов — дубли одного и того же кадра</td></tr>
<tr><td>Исследований</td><td class="num">100</td><td>разбиение выполняется по исследованиям, а не по снимкам</td></tr>
<tr><td>Позвоночник / бедро правое / бедро левое / не определено</td><td class="num">99 / 78 / 73 / 1</td><td>голосование по именам файлов после склейки дублей</td></tr>
<tr><td>Нарушений по экспертной таблице</td><td class="num">73 (29.1 %)</td><td>три снимка таблица не оценивала</td></tr>
<tr><td>Нарушений в рабочей разметке</td><td class="num">76 (30.3 %)</td><td>73 по таблице плюс 3 снимка с пометкой в имени файла</td></tr>
</table>
<p>Дубли не пересекают границы исследований, конфликтов меток при склейке не возникает. Два побайтных
дубля названы по-разному, поэтому область определяется голосованием по именам файлов. Изучение
набора выявило две особенности, которые пришлось учесть в разметке: один и тот же снимок встречается
под несколькими именами (без склейки он попадал бы одновременно в оба класса), и имена файлов местами
расходятся с оценкой эксперта.</p>
<h3>4.2. Единица разметки и правило получения метки</h3>
<p>Экспертная таблица описывает <b>исследование</b>, а не отдельный снимок. Однако каждая
анатомическая область встречается в исследовании ровно один раз (после склейки дублей), поэтому
вердикт исследования по области переносится на снимок однозначно — не требуется решать, какой из
нескольких снимков «плохой». Так получены метки и типы нарушений (<code>labels/labels_images.csv</code>);
каждый источник свидетельства сохранён в отдельном столбце, поэтому правило можно переиграть без
повторного разбора данных.</p>
<p>Возможны были два правила: учитывать только экспертную таблицу либо дополнительно учитывать
пометки <code>_good</code>/<code>_bad</code>, проставленные вручную в именах файлов. Пометки
расходились с экспертом в 62 случаях из 251, поэтому правило выбиралось измерением: одно
зафиксированное разбиение, пять seed'ов обучения, один эталон (табл. в разд. 12.3). Выбрано правило
«только экспертная таблица».</p>
<h3>4.3. Разбиение выборки</h3>
<table>
<tr><th>Часть</th><th class="num">Снимков</th><th class="num">Исследований</th><th class="num">Нарушений</th><th>Файл</th></tr>
<tr><td>Обучение</td><td class="num">198</td><td class="num">81</td><td class="num">60</td><td rowspan="2"><code>labels/split_expert_seed42.json</code></td></tr>
<tr><td>Валидация</td><td class="num">53</td><td class="num">19</td><td class="num">16</td></tr>
</table>
<p>Разбиение стратифицировано по эталону и <b>фиксировано в файле</b>: все сравниваемые варианты
обучаются и оцениваются на одном и том же held-out наборе, что делает сравнения корректными.
Снимки одного исследования не попадают одновременно в обе части — это исключает утечку, при которой
метрики растут за счёт запоминания конкретных исследований. Наличие утечки и воспроизводимость
разбиения проверяются тестами (<code>tests/test_labels.py</code>).</p>
<h2 id="s5">5. Предобработка изображений</h2>
<p>Предобработка описана декларативно в <code>src/dxa/preprocess.py</code> (неизменяемая конфигурация
<code>PreprocessConfig</code>) и используется и при обучении, и при работе API. Параметры хранятся в
чекпоинте и восстанавливаются из него, поэтому вход модели не может разойтись между двумя режимами.</p>
<table>
<tr><th style="width:24%">Параметр</th><th style="width:16%">Значение</th><th>Назначение</th></tr>
<tr><td><code>norm</code></td><td><code>percentile</code></td><td>приведение интенсивностей к [0, 1]; допустимо также <code>minmax</code></td></tr>
<tr><td><code>p_low</code> / <code>p_high</code></td><td>0.5 / 99.5</td><td>отсекаемые перцентили — устойчивость к выбросам и шуму</td></tr>
<tr><td><code>imagenet_norm</code></td><td><code>true</code></td><td>стандартизация mean (0.485, 0.456, 0.406), std (0.229, 0.224, 0.225), как ожидает предобученный backbone</td></tr>
<tr><td><code>input_size</code></td><td>224</td><td>сторона квадратного входа сети</td></tr>
</table>
<p>Порядок операций над одним файлом:</p>
<ol>
<li><b>Чтение.</b> <code>pydicom.dcmread</code> и <code>pixel_array</code> в <code>float32</code>;
многокадровое изображение усредняется по кадрам, иная размерность — ошибка.</li>
<li><b>Коррекция интенсивностей.</b> Учитываются <code>RescaleSlope</code> и
<code>RescaleIntercept</code>; для <code>MONOCHROME1</code> яркость инвертируется.</li>
<li><b>Нормировка.</b> По перцентилям 0.5 / 99.5 с обрезкой в [0, 1] и защитой от вырожденного
диапазона (<code>hi ≤ lo</code>).</li>
<li><b>Приведение к тензору.</b> Умножение на 255 и <code>uint8</code>, дублирование одного канала
в три, resize 224&times;224 билинейно, перевод в CHW, деление на 255, затем нормировка ImageNet.</li>
</ol>
<p>Если в чекпоинте нет блока <code>preprocess</code> (старый файл), применяются значения по умолчанию —
<code>percentile</code> с нормировкой ImageNet; устаревший профиль <code>minmax</code> без ImageNet
доступен флагом <code>--no-imagenet-norm</code>.</p>
<div class="note">Порядок «resize в <code>uint8</code>, затем нормировка» сохранён намеренно: он совпадает
с историческим кодом проекта, поэтому смена реализации не сдвинула распределение входа и не обесценила
ранее подобранный порог.</div>
<h2 id="s6">6. Модель и процедура обучения</h2>
<h3>6.1. Архитектура</h3>
<table>
<tr><th style="width:26%">Элемент</th><th>Реализация</th></tr>
<tr><td>Backbone</td>
<td><code>ResNet18</code> с весами ImageNet (<code>IMAGENET1K_V1</code>), все слои кроме
<code>fc</code>; признак — 512 чисел. Флагом <code>--backbone</code> допустим также
<code>ResNet34</code></td></tr>
<tr><td>Голова качества</td>
<td>по умолчанию <code>linear</code>: <code>Flatten</code> + <code>Linear(512, 2)</code> — линейный
зонд, 1026 обучаемых параметров. Режим <code>mlp</code> (флаг <code>--head</code>):
<code>Linear(512, 256)</code> + <code>ReLU</code> + <code>Dropout(0.3)</code> +
<code>Linear(256, 2)</code></td></tr>
<tr><td>Вспомогательная голова области</td>
<td><code>Flatten</code> + <code>Linear(512, 4)</code>: три анатомические области плюс «неизвестно».
Обучается с весом 0.3 как дополнительная задача и заставляет backbone различать анатомию; логиты
качества она не сдвигает</td></tr>
<tr><td>Стандартизация входа головы</td>
<td>признаки нормируются по среднему и СКО обучающей выборки, зафиксированным в буферах
<code>feat_mean</code>/<code>feat_std</code>. Без неё логиты смещены, вероятности скучены у нуля и
подобранный порог теряет смысл</td></tr>
</table>
<h3>6.2. Гиперпараметры и процедура обучения</h3>
<table>
<tr><th style="width:30%">Параметр</th><th style="width:22%">Значение по умолчанию</th><th>Комментарий</th></tr>
<tr><td>Оптимизатор</td><td>AdamW</td>
<td><code>lr</code> 3e-4, <code>weight_decay</code> 5e-2. Заметный weight decay нужен не только
против переобучения: без него логиты за 100 эпох насыщаются и порог вырождается</td></tr>
<tr><td>Планировщик</td><td>ReduceLROnPlateau</td><td>множитель 0.5, терпение 3, следит за <code>val_loss</code></td></tr>
<tr><td>Функция потерь</td><td>CrossEntropy</td>
<td>балансировка классов выключена (<code>--balance none</code>); доступны <code>loss</code>
(вес <code>n_good / n_bad</code>) и <code>sampler</code></td></tr>
<tr><td>Батч / эпохи</td><td>16 / 100</td><td>ранняя остановка после 25 эпох без улучшения</td></tr>
<tr><td>Отбор чекпоинта</td><td>окно 5</td>
<td>оценка эпохи — ROC-AUC, сглаженный по последним 5 эпохам; до заполнения окна чекпоинт не
сохраняется, при равном AUC решает F1</td></tr>
<tr><td>Вес головы области</td><td>0.3</td><td><code>--region-loss-weight</code>; снимки с неизвестной областью в её loss не входят</td></tr>
<tr><td>Обрезка градиентов</td><td>норма 5.0</td><td>защита от выбросов</td></tr>
<tr><td>Заморозка backbone</td><td>всегда</td>
<td><code>--freeze-epochs -1</code> по умолчанию (линейный зонд); при размораживании lr падает
в 10 раз</td></tr>
<tr><td>Seed / устройство</td><td>42 / авто</td><td>порядок выбора устройства: CUDA &rarr; MPS &rarr; CPU</td></tr>
</table>
<p><b>Почему линейный зонд.</b> При ~250 уникальных снимках полный fine-tune ResNet18 за несколько эпох
запоминает обучающую выборку: train F1 стремится к 1.0, а val AUC падает к 0.5. Поэтому по умолчанию
backbone заморожен и обучается только голова на признаках ImageNet. При заморозке слои BatchNorm
остаются в режиме <code>eval</code>: иначе бегущие статистики продолжали бы обновляться и признаки
«уезжали» бы от тех, на которых рассчитывалась стандартизация.</p>
<p><b>Почему порог по логиту.</b> Порог хранится и применяется в логитах (log-odds): после обучения
линейный зонд разделяет обучающую выборку почти идеально, вероятности насыщаются в 0/1, и порог в
единицах вероятности вырождается. Для человека порог переводится в вероятность через сигмоиду. ROC-AUC
и PR-AUC считаются по вероятностям и от порога не зависят.</p>
<p><b>Как подбирается порог.</b> На каждой эпохе по валидации: кандидаты — все наблюдаемые логиты плюс
края диапазона, выбирается максимум F1; при равном F1 берётся меньший логит, чтобы не терять recall.
Аргумент <code>--min-recall</code> позволяет вместо этого взять максимальный порог с recall не ниже
заданного.</p>
<p><b>Рабочий чекпоинт.</b> Лучшая эпоха — 57 из прогона в 82 эпохи (обучение остановлено по терпению),
порог логита −0.1370 (вероятность 0.466). Метрики этой эпохи — в разделе 12, честная оценка варианта на
пяти seed'ах — ROC-AUC 0.6726.</p>
<h3>6.3. Чекпоинт и отчёты</h3>
<p>Чекпоинт самодостаточен: кроме весов в нём лежат архитектура, параметры предобработки, порог и
происхождение данных, поэтому инференс не может рассинхронизироваться с обучением.</p>
<table>
<tr><th style="width:36%">Поле</th><th>Содержимое</th></tr>
<tr><td><code>model_state_dict</code></td><td>веса сети; <code>format_version</code> — 2</td></tr>
<tr><td><code>backbone</code>, <code>head</code></td><td>архитектура; при явном расхождении с запрошенной загрузка завершается ошибкой</td></tr>
<tr><td><code>preprocess</code></td><td>параметры предобработки (раздел 5)</td></tr>
<tr><td><code>threshold</code>, <code>threshold_logit</code></td><td>рабочий порог по логитам</td></tr>
<tr><td><code>labels_csv</code>, <code>split_file</code></td><td>на какой разметке и на каком разбиении обучена модель</td></tr>
<tr><td><code>epoch</code>, <code>val_metrics</code>, <code>selection_score</code></td><td>эпоха, её метрики и сглаженная оценка, по которой выбран чекпоинт</td></tr>
<tr><td><code>history</code>, <code>optimizer_state_dict</code></td><td>история обучения и состояние оптимизатора</td></tr>
</table>
<p>Рядом с чекпоинтом обучение пишет <code>train_report.md</code> и <code>train_report.json</code>:
гиперпараметры, метрики на валидации, метрики по областям и историю по эпохам. Артефакты кладутся в
<code>--output-dir</code> (по умолчанию <code>models</code>).</p>
<h2 id="s7">7. Определение анатомической области</h2>
<p>Область определяется по содержимому снимка, а не по имени файла: на закрытом наборе соглашение об
именах может отсутствовать. Решение принимается по геометрии кадра (<code>resolve_region</code> в
<code>src/dxa/inference.py</code>) независимо от модели качества; обученная вспомогательная голова
области привлекается только как уточнение.</p>
<p>Признаки считает функция <code>heuristic_signals</code> по маске яркой (костной) ткани с порогом по
95-му перцентилю:</p>
<table>
<tr><th style="width:26%">Признак</th><th>Что измеряет</th></tr>
<tr><td><code>width</code></td><td>ширина кадра в пикселях — основной признак области на этом оборудовании (у позвоночника 300 px, у бедра 280 px)</td></tr>
<tr><td><code>bbox_aspect</code></td><td>отношение высоты яркой области к её ширине (вытянутость)</td></tr>
<tr><td><code>left_right_ratio</code></td><td>перевес светимости левой половины над правой — наклон в сторону бедра</td></tr>
<tr><td><code>symmetry</code></td><td>симметрия изображения относительно вертикальной оси</td></tr>
</table>
<p>Порог ширины задан константой <code>SPINE_MIN_WIDTH = 295</code>. Порядок решений:</p>
<ol>
<li>если ширина кадра ≥ 295 — <code>spine</code>, уверенность 0.8;</li>
<li>иначе (бедро) — сторона по перевесу светимости: <code>left_right_ratio</code> &gt; 1.3 даёт
<code>hip_right</code>, &lt; 0.7 — <code>hip_left</code>, уверенность 0.6;</li>
<li>при промежуточном перевесе — предсказание головы области, если её уверенность ≥ 0.5;</li>
<li>если и оно неуверенно — общая область <code>hip</code> с уверенностью 0.4.</li>
</ol>
<p>Если ширина кадра неизвестна, используется предсказание головы, а затем форма яркой области
(<code>bbox_aspect</code> и <code>symmetry</code>).</p>
<p><b>Точность.</b> Сопоставление с областью из рабочей разметки (251 снимок, раздел 4):</p>
<table>
<tr><th>Что проверялось</th><th class="num">Результат</th></tr>
<tr><td>Позвоночник</td><td class="num">99 / 99</td></tr>
<tr><td>Сторона бедра (правое / левое)</td><td class="num">134 / 151 (88.7 %)</td></tr>
<tr><td>Итого по всем областям</td><td class="num">233 / 250 (93.2 %)</td></tr>
</table>
<p>Различение позвоночника и бедра по ширине кадра работает безошибочно, а сторона бедра определяется
менее надёжно: перевес светимости путает левое и правое в 17 случаях из 151. Сторона не проверялась по
тегам DICOM — <code>Laterality</code> в наборе пуст, — поэтому это ограничение (раздел 17, п. 7), а не
измеренная ошибка модели.</p>
<div class="note">Признак «ширина кадра» привязан к текущему аппарату. Он безошибочен на этом наборе, но
при смене оборудования порог потребует калибровки — раздел 17, ограничение 8.</div>
<h2 id="s8">8. Таксономия нарушений качества</h2>
<p>Коды типов нарушений собраны в одном модуле <code>src/dxa/violations.py</code>. Раньше словарь был
свой в трёх местах (инференс, <code>main.py</code> и веб-интерфейс), и ни одна копия не знала кодов
экспертной таблицы; теперь коды, русские подписи и пояснения заданы один раз, а сервер отдаёт их
клиенту.</p>
<table>
<tr><th style="width:22%">Код</th><th style="width:26%">Подпись</th><th style="width:12%">Область</th><th>Источник</th></tr>
<tr><td><code>positioning</code></td><td>Некорректная укладка</td><td>spine</td><td>экспертная таблица</td></tr>
<tr><td><code>axis_deviation</code></td><td>Отклонение оси</td><td>spine</td><td>экспертная таблица</td></tr>
<tr><td><code>artifact</code></td><td>Артефакты и импланты</td><td>любая</td><td>экспертная таблица</td></tr>
<tr><td><code>rotation</code></td><td>Ротация / позиционирование</td><td>hip</td><td>экспертная таблица</td></tr>
<tr><td><code>roi_incorrect</code></td><td>Некорректная область интереса</td><td>любая</td><td>экспертная таблица</td></tr>
<tr><td><code>motion</code></td><td>Движение, размытие</td><td>любая</td><td><code>condition_doctor.txt</code></td></tr>
<tr><td><code>incomplete_anatomy</code></td><td>Анатомия видна не полностью</td><td>любая</td><td><code>condition_doctor.txt</code></td></tr>
<tr><td><code>labeling_error</code></td><td>Ошибка разметки</td><td>spine</td><td><code>condition_doctor.txt</code></td></tr>
<tr><td><code>unspecified</code></td><td>Нарушение без уточнения</td><td>любая</td><td>системный код</td></tr>
</table>
<p>У каждого кода есть пояснение, чем нарушение мешает измерению (например, для <code>rotation</code> —
что малый вертел виден слишком хорошо и шейка бедра кажется укороченной). Функция
<code>catalogue()</code> отдаёт словарь через <code>/api/v1/model</code>, поэтому подписи в интерфейсе и
в выгрузке не могут разойтись с кодами.</p>
<p>Для текстового отчёта DICOM SR у каждого кода заданы условный числовой код и английская формулировка.
Полноценного справочника SNOMED/DICOM для контроля качества DXA в наборе нет, поэтому коды условные и
всегда идут рядом с текстом: пригодный снимок кодируется <code>113001</code> (DXA image quality
acceptable) с флагом <code>FINAL</code>, нарушение — с флагом <code>WARNING</code>. Устаревшие значения
внешних источников (<code>artifact_motion</code>, <code>roi_error</code>, <code>position_error</code>,
<code>quality_violation_detected</code> и другие) приводит к канону функция <code>canon_type</code>.</p>
<h3>8.1. Тип нарушения в разметке</h3>
<p>Экспертная таблица кодирует не все девять кодов, а пять: <code>positioning</code>,
<code>axis_deviation</code>, <code>artifact</code>, <code>rotation</code>, <code>roi_incorrect</code>.
Распределение в рабочей разметке (в четырёх из 76 нарушений указано по два критерия, поэтому сумма
больше 76):</p>
<table>
<tr><th>Код</th><th class="num">Снимков</th></tr>
<tr><td><code>rotation</code></td><td class="num">35</td></tr>
<tr><td><code>artifact</code></td><td class="num">17</td></tr>
<tr><td><code>axis_deviation</code></td><td class="num">10</td></tr>
<tr><td><code>roi_incorrect</code></td><td class="num">7</td></tr>
<tr><td><code>positioning</code></td><td class="num">6</td></tr>
<tr><td><code>unspecified</code></td><td class="num">5</td></tr>
</table>
<p>Коды <code>motion</code>, <code>incomplete_anatomy</code> и <code>labeling_error</code> в разметке не
встречаются: таблица их не кодирует, хотя словарь их знает ради критериев
<code>condition_doctor.txt</code>. Пять снимков имеют только <code>unspecified</code>, потому что
источник не указывает критерий.</p>
<h3>8.2. Тип нарушения при инференсе</h3>
<p>Модель решает только бинарную задачу, поэтому тип для снимка с нарушением выбирает функция
<code>classify_violation_type</code> по дешёвым признакам: размытие
(<code>laplacian_variance</code>), доля плотных пикселей (<code>bright_fraction</code>) и вытянутость
яркой области (<code>bbox_aspect</code> для бедра). При отсутствии выраженного признака возвращается
<code>unspecified</code>.</p>
<div class="note">На текущем чекпоинте эта ветка практически вырождена. Пороги
<code>motion_threshold</code> и <code>artifact_threshold</code> в вызов не передаются, поэтому
действуют значения по умолчанию (0.0 и 1.0), которых признаки достичь не могут, а условие ротации
(<code>bbox_aspect</code> вне диапазона 0.4–3.0) на наборе не срабатывает. Прогон по всем 482 файлам
даёт одинаковый результат: все 206 решений с нарушением помечены <code>unspecified</code>. Для
содержательного типа нужна разметка типов на уровне снимка и обученный мультилейбл-классификатор
(раздел 18), поэтому в ответе API тип всегда идёт с флагом
<code>violation_type_is_heuristic = true</code>.</div>
<h2 id="s9">9. Форматы входных и выходных данных</h2>
<h3>9.1. Вход</h3>
<p>Один файл DICOM (для API — один HTTP-загрузкой, для CLI — файл или каталог с рекурсивным обходом).
Исследование может содержать несколько снимков — каждый обрабатывается отдельно и даёт отдельную
строку отчёта. Перед обработкой файл читается целиком; при ошибке чтения строка получает статус
сбоя, а пакет продолжается.</p>
<h3>9.2. Выходной файл</h3>
<p>Состав столбцов проверен на реальных прогонах — и через CLI, и через <code>/api/v1/export</code>.
Обязательные восемь столбцов задания совпадают в обоих путях по составу и порядку; дополнительно
пишутся <code>confidence</code> и <code>violation_reason</code>, а CLI-инференс добавляет одиннадцатый
столбец <code>region_confidence</code>. Таблица ниже отражает CLI-вывод; в ответе
<code>/api/v1/export</code> последнего столбца нет.</p>
<table>
<tr><th style="width:8%">№</th><th style="width:30%">Столбец</th><th style="width:16%">Тип</th><th>Содержимое</th></tr>
<tr><td>1</td><td><code>path_to_study</code></td><td>строка</td><td>путь к исследованию (для загрузки через API — <code>upload://имя</code>)</td></tr>
<tr><td>2</td><td><code>study_uid</code></td><td>строка</td><td><code>StudyInstanceUID</code> из DICOM</td></tr>
<tr><td>3</td><td><code>image_uid</code></td><td>строка</td><td><code>SOPInstanceUID</code> из DICOM</td></tr>
<tr><td>4</td><td><code>anatomical_region</code></td><td>строка</td><td><code>spine</code> / <code>hip_left</code> / <code>hip_right</code> / <code>hip</code></td></tr>
<tr><td>5</td><td><code>quality_class</code></td><td>целое</td><td>0 — качественное, 1 — есть нарушение</td></tr>
<tr><td>6</td><td><code>violation_type</code></td><td>строка</td><td>код нарушения или пусто</td></tr>
<tr><td>7</td><td><code>processing_status</code></td><td>строка</td><td><code>Success</code> либо <code>Failure: &lt;причина&gt;</code></td></tr>
<tr><td>8</td><td><code>time_of_processing</code></td><td>вещественное</td><td>время обработки снимка, секунды</td></tr>
<tr><td>9</td><td><code>confidence</code></td><td>вещественное</td><td>уверенность модели в решении</td></tr>
<tr><td>10</td><td><code>violation_reason</code></td><td>строка</td><td>текстовое пояснение к отнесённой категории</td></tr>
<tr><td>11</td><td><code>region_confidence</code></td><td>вещественное</td><td>уверенность определения области</td></tr>
</table>
<div class="note">Время обработки отдаётся в столбце файла результатов; одиночный ответ
<code>POST /api/v1/analyze</code> этого столбца не содержит — при интеграции время измеряется на
стороне вызывающей системы (см. разд. 10 и 13).</div>
<div class="note">Расхождение ровно в один столбец — единственное различие двух путей выгрузки; оно
перечислено в разделе 16. Обязательные столбцы и их порядок при этом одинаковы, поэтому автоматический
разбор результатов не зависит от того, каким путём получен файл.</div>
<h2 id="s10">10. API сервиса</h2>
<p>Сервис на FastAPI. Маршруты и состав полей ответов ниже приведены по фактическим ответам на
запросах к тестовым файлам, а не по схеме из документации кода.</p>
<table>
<tr><th style="width:9%">Метод</th><th style="width:27%">Путь</th><th>Назначение</th></tr>
<tr><td>GET</td><td><code>/</code></td><td>веб-интерфейс</td></tr>
<tr><td>GET</td><td><code>/api/v1/health</code></td><td>статус сервиса; поля ответа: <code>status</code>, <code>device</code>, <code>model_loaded</code>, <code>model</code></td></tr>
<tr><td>GET</td><td><code>/api/v1/model</code></td><td>карточка решения: <code>model</code>, <code>labels</code>, <code>dataset</code>, <code>comparison</code>, <code>violations</code>, <code>limitations</code>, <code>sources</code>, <code>snapshot_date</code>, <code>loaded</code></td></tr>
<tr><td>POST</td><td><code>/api/v1/analyze</code></td><td>анализ одного файла (multipart-загрузка)</td></tr>
<tr><td>POST</td><td><code>/api/v1/analyze/detailed</code></td><td>расширенный отчёт, опционально с маской</td></tr>
<tr><td>POST</td><td><code>/api/v1/analyze/sr</code></td><td>текстовое представление отчёта DICOM SR: <code>format</code>, <code>sr_content</code>, UID</td></tr>
<tr><td>POST</td><td><code>/api/v1/batch</code></td><td>пакетная обработка нескольких файлов</td></tr>
<tr><td>POST</td><td><code>/api/v1/export</code></td><td>пакетная обработка и выгрузка в XLSX</td></tr>
</table>
<h3>10.1. Состав ответа <code>/api/v1/analyze</code></h3>
<p>Поля: <code>filename</code>, <code>study_uid</code>, <code>image_uid</code>,
<code>anatomical_region</code>, <code>region_confidence</code>, <code>quality_class</code>,
<code>quality_label</code>, <code>overall_quality</code>, <code>severity</code>,
<code>confidence</code>, <code>confidence_per_class</code>, <code>threshold_probability</code>,
<code>violation_type</code>, <code>violation_type_label</code>,
<code>violation_type_is_heuristic</code>, <code>violation_type_note</code>, <code>reason</code>,
<code>metrics</code>, <code>samples</code>, <code>processing_status</code>.</p>
<p>Два поля заслуживают пояснения при интеграции:</p>
<ul>
<li><code>violation_type_is_heuristic = true</code> — тип нарушения определён по измеряемым признакам
снимка, а не предсказан моделью; модель решает только бинарную задачу. Флаг отдаётся клиенту
явно, чтобы это различие не терялось в интеграции.</li>
<li><code>quality_label</code>, <code>violation_type_label</code>, <code>violation_type_note</code> —
готовые подписи для интерфейса. Они приходят с сервера: клиент не держит собственных копий
словаря, поэтому подписи не могут разойтись с кодами.</li>
</ul>
<h3>10.2. Расширенный ответ <code>/api/v1/analyze/detailed</code></h3>
<p>Дополнительно к предыдущему набору: <code>image</code> (визуализация снимка), <code>mask</code>
(маска зоны измерения, при запросе), <code>reasons</code>, <code>metrics_note</code>,
<code>view_quality</code>, а также измерения по областям: <code>spine_completeness</code>,
<code>hip_completeness</code>, <code>hip_rotation</code>.</p>
<div class="note">Измерения в расширенном ответе сопровождаются пометкой о том, что их пороги не
калиброваны (разд. 17), и не превращаются в вердикты «да/нет»: интерфейс показывает числа.</div>
<h2 id="s11">11. Веб-интерфейс</h2>
<p>Интерфейс предназначен для ручной проверки работы сервиса и для демонстрации: загрузка файлов,
таблица результатов со статистикой, панель деталей по выбранной строке и панель «О модели» с
источником загруженного чекпоинта.</p>
<figure>
<img src="../assets/img/ui-results.png" alt="Таблица результатов: полоса состояния, статистика, строки снимков">
<figcaption>Рис. 1. Результаты обработки: полоса состояния показывает, какой чекпоинт отвечает
(разметка, эпоха, порог), рядом — статистика и таблица снимков.</figcaption>
</figure>
<div class="imgs two">
<figure>
<img src="../assets/img/ui-detail-violation.png" alt="Панель деталей: снимок с нарушением">
<figcaption>Рис. 2а. Нарушение: уровень, заключение с вероятностью и порогом, тип нарушения с пометкой «эвристика».</figcaption>
</figure>
<figure>
<img src="../assets/img/ui-detail-clean.png" alt="Панель деталей: качественный снимок">
<figcaption>Рис. 2б. Качественный снимок: измерения подписаны как справочные.</figcaption>
</figure>
</div>
<p>Отдельный принцип — <b>интерфейс не утверждает больше, чем известно решению</b>:</p>
<ul>
<li>тип нарушения показан с пометкой «эвристика» и пояснением, что модель решает только бинарную задачу;</li>
<li>метрика рабочего чекпоинта помечена как завышенная (чекпоинт выбирался лучшим из пяти seed'ов),
а честная оценка варианта приведена рядом — в панели «О модели»;</li>
<li>плитка средней уверенности не называется точностью: точность требует эталонных меток, которых
для произвольного файла нет;</li>
<li>числовые измерения панели деталей идут под дисклеймером о некалиброванности порогов.</li>
</ul>
<figure>
<img src="../assets/img/ui-model-panel.png" alt="Панель «О модели»: чекпоинт, метрики с интервалами, данные, словарь нарушений">
<figcaption>Рис. 3. Панель «О модели»: источник чекпоинта, метрики с доверительными интервалами,
состав данных, словарь нарушений и список ограничений. Данные приходят из <code>/api/v1/model</code>.</figcaption>
</figure>
<h3>11.1. Ручная разметка (<code>/label</code>)</h3>
<p>Поштучной экспертной оценки снимков в наборе нет — это ограничение 1 в разд. 17, и автоматически его
закрыть нечем: локальная vision-модель оказалась непригодна как разметчик (отрицательный результат
описан в <code>assets/labeling.md</code> §11). Поэтому реализован отдельный интерфейс ручной разметки, который
отвечает пункту 2.6 задания — «автоматическая коррекция разметки с возможностью подтверждения
специалистом».</p>
<p>Страница доступна по адресу <code>/label</code> и работает на тех же офлайн-ассетах, что и основная.
Она показывает снимок целиком (PNG через <code>/api/v1/labeling/image</code>, без наложений), его
текущую метку и <b>источник</b> этой метки: <code>table</code> (экспертная таблица),
<code>filename</code> (пометка в имени файла) или <code>manual</code> (вердикт специалиста). Снимки, где
метка разметки расходится с пометкой в имени файла, выводятся первыми: именно там один из источников
ошибается, и таких снимков в наборе 62 из 251.</p>
<p>Специалист подтверждает вердикт или ставит свой: анатомическая область, «годное / нарушение», тип
нарушения из словаря (разд. 8) и комментарий. Вердикт проверяется на согласованность: тип нарушения
обязан относиться к выбранной области (ротация — критерий бедра), у качественного снимка типа быть не
может, а неизвестный код отклоняется, а не подменяется на «не уточнён». Есть горячие клавиши
(<code>g</code> — годное, <code>b</code> — нарушение, <code>Enter</code> — сохранить и перейти к
следующему) и прогресс по областям, чтобы работу можно было вести частями.</p>
<p>Вердикты сохраняются в <code>labels/manual_labels.csv</code> в том же формате, что и построенная
разметка, поэтому файл читается тем же загрузчиком и принимается обучением как
<code>--labels-csv</code>. Выгрузка <code>scope=all</code> отдаёт весь набор с наложенными ручными
вердиктами (готовый источник меток), <code>scope=reviewed</code> — только разобранные снимки, чтобы
сверить их с построенной разметкой.</p>
<p><b>Разметка без сервиса.</b> Интерфейс <code>/label</code> требует запущенного сервиса, а
размечать должен специалист — как правило, на своей машине, вне сети и без установки чего-либо.
Поэтому тот же сценарий упаковывается в один автономный HTML-файл
(<code>src/dxa/review_pack.py</code>, около 7 МБ на 251 снимок): изображения встроены как
data-URI, вердикты хранятся в браузере, а кнопка «Выгрузить CSV» отдаёт файл, который принимается
обучением как есть. Страница не делает ни одного сетевого запроса, и это проверяется автоматически
(<code>tests/browser/review_pack.js</code>). Вердикты специалиста накладываются на построенную
разметку командой <code>python -m src.dxa.review_pack --merge</code>; если в файле окажутся пути
из другого набора, команда сообщает об этом.</p>
<div class="note">Оценка модели в интерфейсе намеренно не показывается: разметчик, видя подсказку,
соглашался бы с ней, и поштучная разметка потеряла бы ценность независимого суждения. Инструмент не
заменяет эксперта — он фиксирует его суждение в формате, который пайплайн уже умеет читать.</div>
<h2 id="s12">12. Метрики качества</h2>
<h3>12.1. Основная оценка</h3>
<p>Оценка получена на фиксированном разбиении по исследованиям (обучение 198 снимков / 81 исследование,
валидация 53 снимка / 19 исследований, 16 нарушений), пять seed'ов обучения, эталон — вердикт
эксперта из таблицы. Приоритетные по заданию метрики приведены с 95 % доверительными интервалами.</p>
<table>
<tr><th>Метрика</th><th class="num">Значение</th><th class="num">95 % ДИ</th><th>Комментарий</th></tr>
<tr><td>ROC-AUC</td><td class="num">0.6726</td><td class="num">[0.6367, 0.7086]</td><td>приоритетная метрика задания</td></tr>
<tr><td>PR-AUC</td><td class="num">0.4753</td><td class="num">[0.4188, 0.5318]</td><td>базовый уровень при доле нарушений 30 % — около 0.30</td></tr>
<tr><td>F1</td><td class="num">0.5559</td><td class="num">[0.5212, 0.5906]</td><td>порог подбирался по F1 на той же валидации, поэтому значение смещено вверх</td></tr>
<tr><td>Recall / Precision</td><td class="num">0.713 / 0.472</td><td class="num">—</td><td>рабочая точка выбранного порога</td></tr>
</table>
<h3>12.2. Оценка по областям и контрольные проверки</h3>
<table>
<tr><th>Что измерено</th><th class="num">Значение</th><th>Как получено</th></tr>
<tr><td>ROC-AUC: позвоночник / бедро правое / бедро левое</td><td class="num">0.929 / 0.606 / 0.567</td><td>рабочий чекпоинт, собственная валидация</td></tr>
<tr><td>Контрольная задача «позвоночник / бедро»</td><td class="num">AUC 1.00</td><td>проверка работоспособности пайплайна, а не клиническая метрика</td></tr>
<tr><td>Модель против правила «позвоночник = нарушение»</td><td class="num">0.885 против 0.534</td><td>оценка на всём наборе, включая обучающие снимки, поэтому смещена вверх</td></tr>
</table>
<p>Разбивка по областям нужна потому, что нарушения распределены неравномерно: в позвоночнике
33 из 99 снимков, у бёдер 20–22 из 73–78, а сама область почти однозначно определяется по ширине
кадра. Поэтому общий AUC частично отражает различение области, а не только распознавание дефекта.
Чтобы отделить одно от другого, выполнена проверка: правило «позвоночник = нарушение» даёт внутри
областей 0.50 (подсказки нет), модель — 0.88–0.95. Это означает, что модель использует содержимое
снимка, а не только область.</p>
<h3>12.3. Выбор правила разметки</h3>
<table>
<tr><th>Метрика (эталон)</th><th class="num">только таблица</th><th class="num">с суффиксами имён</th></tr>
<tr><td>ROC-AUC</td><td class="num">0.6726 [0.6367, 0.7086]</td><td class="num">0.6003 [0.5592, 0.6415]</td></tr>
<tr><td>PR-AUC</td><td class="num">0.4753 [0.4188, 0.5318]</td><td class="num">0.3864 [0.3487, 0.4242]</td></tr>
<tr><td>F1</td><td class="num">0.5559 [0.5212, 0.5906]</td><td class="num">0.5354 [0.4978, 0.5729]</td></tr>
<tr><td>Recall / Precision</td><td class="num">0.713 / 0.472</td><td class="num">0.788 / 0.409</td></tr>
</table>
<p>Парная разница (только таблица минус с суффиксами): ROC-AUC <b>+0.0723</b> [+0.0541, +0.0905],
PR-AUC <b>+0.0889</b> [+0.0500, +0.1277] — знаки <code>+++++</code>, то есть преимущество на всех пяти
seed'ах. Принято правило «только экспертная таблица». Оговорка, важная для интерпретации: эталон — та
же таблица, поэтому вариант, обучавшийся на ней, находится в выигрышном положении; значимо здесь то,
что добавление ненадёжных пометок согласие с экспертом <b>снижает</b>, а не повышает.</p>
<h2 id="s13">13. Производительность и системные требования</h2>
<h3>13.1. Измерения</h3>
<p>Измерения выполнены на рабочей машине разработчика (macOS, Apple Silicon) на полном наборе данных —
482 DICOM-файла в <code>dataset_hack</code> (478 обучающих плюс 4 тестовых). Инференс принудительно
переведён на CPU параметром <code>--device cpu</code>, чтобы числа не зависели от наличия ускорителя.</p>
<table>
<tr><th>Показатель</th><th class="num">Значение</th><th>Условия</th></tr>
<tr><td>Время на изображение (медиана)</td><td class="num">0.022 с</td><td>CPU, включает чтение DICOM, предобработку и проход сети</td></tr>
<tr><td>Время на изображение (максимум)</td><td class="num">0.041 с</td><td>тот же прогон</td></tr>
<tr><td>Пакет целиком (482 файла)</td><td class="num">13.9 с</td><td>от запуска процесса до записи XLSX, включая загрузку чекпоинта</td></tr>
<tr><td>Доля успешно обработанных файлов</td><td class="num">482 / 482 (100 %)</td><td>ошибок чтения на этом наборе нет</td></tr>
<tr><td>Пиковая память процесса</td><td class="num">≈ 640 МБ</td><td>maximum resident set size, пакетный CPU-инференс</td></tr>
<tr><td>Ответ API на один файл</td><td class="num">16–18 мс</td><td>вызов внутри процесса, после прогрева; первый запрос 390 мс. Здесь устройство выбрано автоматически (MPS), на CPU значение того же порядка — см. медиану пакетного прогона выше</td></tr>
<tr><td>Время старта сервиса</td><td class="num">≈ 3 с</td><td>импорт библиотек и загрузка чекпоинта</td></tr>
</table>
<p>Отсюда следует прямой ответ на требование «не более 3 минут на исследование»: даже если
исследование содержит максимальные по заданию три снимка, обработка занимает доли секунды, а
трёхминутный бюджет расходуется практически только на передачу файлов и накладные расходы
интеграции. Запас более чем трёхкратный, поэтому узким местом решение не ограничивает.</p>
<h3>13.2. Минимальная и рекомендуемая конфигурация</h3>
<table>
<tr><th style="width:26%">Ресурс</th><th style="width:37%">Минимальная конфигурация</th><th>Рекомендуемая</th></tr>
<tr><td>Процессор</td><td>2 ядра x86-64 или ARM64</td><td>4 ядра и более; инференс векторизован и масштабируется по кадрам</td></tr>
<tr><td>Оперативная память</td><td>1.5 ГБ (измеренный пик ≈ 640 МБ + запас на ОС и веб-слой)</td><td>4 ГБ</td></tr>
<tr><td>Графический ускоритель</td><td><b>Не требуется</b>: torch устанавливается из CPU-индекса, инференс на CPU укладывается в требование по времени</td><td>Любой GPU с поддержкой CUDA 12.6, включая H200 (compute capability 9.0) — вариант сборки <code>Dockerfile_cuda</code></td></tr>
<tr><td>Дисковое пространство</td><td>базовый образ python:3.11-slim (150 МБ) + зависимости, код и чекпоинт (43 МБ) — всё внутри образа</td><td>плюс место под входные архивы DICOM и результаты</td></tr>
<tr><td>Сеть</td><td>Не требуется при работе: образ и веса фиксированы, обращений во внешние сервисы нет</td><td>Нужна только для сборки образа и загрузки базовых зависимостей</td></tr>
</table>
<p><b>Размер образа (измерено).</b> CPU-вариант: 1.46 ГБ под <code>linux/arm64</code> и 1.8 ГБ под
<code>linux/amd64</code>. GPU-вариант: 7.0 ГБ под <code>linux/amd64</code> — почти всё сверх базы
занимают CUDA-библиотеки внутри колёс torch (<code>nvidia-cublas-cu12</code>,
<code>nvidia-cudnn-cu12</code>, <code>nvidia-nccl-cu12</code> и другие). Базовый образ
<code>python:3.11-slim</code> — 150 МБ; torch и torchvision берутся из CPU-индекса в CPU-варианте и из
индекса <code>cu126</code> в GPU-варианте, поэтому CUDA-колёса в CPU-образ не попадают (там
<code>torch 2.8.0+cpu</code> и ноль nvidia-пакетов). Данные, тесты, <code>labels/</code>,
<code>assets/</code> и <code>docs/</code> в образ не входят; чекпоинт (43 МБ), наоборот, лежит внутри —
монтировать каталог с моделями при запуске не нужно.</p>
<h2 id="s14">14. Сборка, запуск и развёртывание</h2>
<h3>14.1. Состав образа</h3>
<table>
<tr><th style="width:30%">Элемент</th><th>Значение</th></tr>
<tr><td>Базовый образ</td><td><code>python:3.11-slim</code> (фиксированный тег); у GPU-варианта он тот же — CUDA и cuDNN приходят внутри колёс torch, отдельный <code>nvidia/cuda</code>-образ не нужен</td></tr>
<tr><td>Зависимости</td><td><code>requirements.txt</code> с точными версиями; CPU-вариант берёт torch и torchvision из CPU-индекса PyTorch, GPU-вариант — из индекса <code>cu126</code></td></tr>
<tr><td>Что копируется в образ</td><td><code>src/</code>, <code>requirements.txt</code>, <code>run.sh</code> и чекпоинт <code>models/dxa_model.pth</code></td></tr>
<tr><td>Что не копируется</td><td>данные, тесты, <code>labels/</code>, <code>assets/</code>, <code>docs/</code> (см. <code>.dockerignore</code>)</td></tr>
<tr><td>Чекпоинт</td><td>копируется на этапе сборки и лежит в образе как <code>/app/models/dxa_model.pth</code>; путь задаётся переменной <code>DXA_MODEL_PATH</code>. Монтировать каталог с моделями не нужно</td></tr>
<tr><td>Проверки на этапе сборки</td><td>импорт приложения (<code>import src.main</code>), наличие офлайн-ассетов фронтенда и читаемость чекпоинта (ключи <code>backbone</code>, <code>head</code>, <code>threshold</code>); сборка падает при их отсутствии</td></tr>
<tr><td>Контроль состояния</td><td><code>HEALTHCHECK</code> обращается к <code>/api/v1/health</code> каждые 30 с</td></tr>
<tr><td>Запуск</td><td><code>docker-compose.yml</code>: сервис <code>dxa-cpu</code> поднимается по умолчанию, GPU-сервис <code>dxa-cuda</code> — под профилем <code>cuda</code></td></tr>
<tr><td>Точка входа</td><td><code>uvicorn src.main:app</code> на порту 8000</td></tr>
</table>
<p>Модель встроена в образ, поэтому штатно чекпоинт всегда на месте. Если файл всё же недоступен
(например, образ повреждён), сервис всё равно поднимается: <code>/api/v1/health</code> сообщает
<code>model_loaded: false</code>, а запросы анализа возвращают ошибку вместо тихой неверной оценки.
Это сознательный выбор: отсутствие модели должно быть заметно сразу, а не проявляться как «странные»
результаты.</p>
<p><b>Что проверено на самом образе.</b> Сборка проходит три внутренние проверки:
<code>app import ok</code>, <code>frontend assets ok</code> и
<code>checkpoint ok: resnet18 linear threshold -0.13703127205371857</code>. Контейнер запущен без единого
монтирования (<code>docker run -d -p 8000:8000 dxa-quality:cpu</code>): чекпоинт присутствует внутри
образа (<code>/app/models/dxa_model.pth</code>), <code>/api/v1/health</code> сообщает
<code>model_loaded: true</code>, <code>device: cpu</code>, эпоху 57 и файл разметки
<code>labels/labels_images.csv</code>, запрос <code>/api/v1/analyze</code> отвечает корректной строкой
результата, а <code>/api/v1/export</code> возвращает XLSX с ожидаемым набором столбцов. Сквозная
проверка выполнена в контейнере на той же машине, где снимались измерения производительности. Оба
варианта дополнительно собраны под целевой <code>linux/amd64</code> (эмуляция на той же машине):
CPU-образ содержит <code>torch 2.8.0+cpu</code> и ни одного nvidia-пакета, GPU-образ —
<code>torch 2.8.0+cu126</code> с cuDNN 9.10.2 и библиотекой <code>libtorch_cuda.so</code>; в обоих
сервис стартует, и <code>/api/v1/health</code>, <code>/api/v1/analyze</code> и
<code>/api/v1/export</code> отвечают корректно (<code>device: cpu</code>, поскольку ускорителя в машине
нет). Работа на самом GPU не проверялась: для неё нужна машина с драйвером NVIDIA, где критерий
приёмки — <code>torch.cuda.is_available() == True</code> и <code>device: cuda</code> в
<code>/api/v1/health</code>.</p>
<h3>14.2. Воспроизводимость</h3>
<ul>
<li>Версии всех зависимостей зафиксированы, базовый образ задан тегом, версии torch и torchvision указаны явно.</li>
<li>Обучение детерминировано по seed; разбиение выборки сохраняется в файл и переиспользуется.</li>
<li>Чекпоинт самодостаточен: внутри хранятся <code>backbone</code>, <code>head</code>, параметры
предобработки, <code>threshold</code>, а также <code>labels_csv</code> и <code>split_file</code>.
Поэтому по файлу видно, на какой разметке и на каком разбиении получена модель, а инференс не
может рассинхронизироваться с обучением.</li>
<li>Отказ от зависимости от рабочего каталога: путь к модели и рабочие каталоги задаются переменными окружения.</li>
</ul>
<h3>14.3. Сборочный конвейер</h3>
<p><code>Jenkinsfile</code> выполняет три шага: сборка образа, публикация в реестр и выкладка в
кластер. Скрипт <code>run.sh</code> — единая точка входа для разработки и эксплуатации: обучение,
разметка, приведение имён файлов, инференс, сервис, сравнение вариантов разметки и тесты
(перечень команд — приложение Б). Для запуска образа есть <code>docker-compose.yml</code> с двумя
сервисами (14.1). Файл выкладки swarm (<code>/data/deploy/rell-bone-2026/docker-swarm.yml</code>) лежит
вне репозитория: если он монтирует каталог с моделями в <code>/app/models</code>, монтирование
перекроет встроенный чекпоинт, поэтому его нужно убрать при обновлении выкладки.</p>
<h2 id="s15">15. Тесты и проверки</h2>
<p>Тесты — <code>pytest</code>, 272 проверки в девяти файлах; запуск — <code>./run.sh test</code> или
<code>python -m pytest tests/ -q</code>. Сверка выполнена на дату документа: 272 passed.</p>
<table>
<tr><th style="width:30%">Файл</th><th class="num" style="width:10%">Тестов</th><th>Что проверяет</th></tr>
<tr><td><code>tests/test_labels.py</code></td><td class="num">38</td>
<td>разбор имён, определение области и метки по имени, стратифицированное разбиение по
исследованиям, отсутствие утечки, фиксация и переиспользование разбиения в файле, сверка с
реальным датасетом</td></tr>
<tr><td><code>tests/test_excel_labels.py</code></td><td class="num">51</td>
<td>разметка по экспертной таблице: чтение критериев, правило «1 = нарушение», голосование по
области, перенос оценки на единственное бедро, три правила метки
(<code>table</code> / <code>union</code> / <code>expert</code>), их согласованность, подключение
к обучению и чтение CSV, сохранённого Excel с BOM</td></tr>
<tr><td><code>tests/test_manual_labels.py</code></td><td class="num">26</td>
<td>ручная разметка: проверка вердикта (область, метка, совместимость типа нарушения с областью,
отказ от опечаток), хранение и правка вердиктов, чтение файла с BOM, наложение поверх построенной
разметки, подсчёт прогресса и выгрузка</td></tr>
<tr><td><code>tests/test_rename_files.py</code></td><td class="num">36</td>
<td>приведение имён DICOM: разбор и канонизация, поиск свободного номера при конфликте, отказ
угадывать область, цикл «применить &rarr; откатить»</td></tr>
<tr><td><code>tests/test_preprocess_and_model.py</code></td><td class="num">32</td>
<td>конфигурация и нормировка, сборка тензора и нормировка ImageNet, метрики, подбор порога,
контракт модели, BatchNorm при заморозке, roundtrip чекпоинта, проверка на реальных снимках</td></tr>
<tr><td><code>tests/test_violations.py</code></td><td class="num">28</td>
<td>единый словарь типов: приведение устаревших значений, подписи, коды SR и каталог для API,
согласованность с критериями экспертной таблицы</td></tr>
<tr><td><code>tests/test_api_contract.py</code></td><td class="num">25</td>
<td>поля ответов, которые читает веб-интерфейс: базовый и детальный анализ, здоровье и карточка
модели, выгрузка XLSX, обработка ошибок; различимость метрик между снимками и валидность
PNG-визуализаций</td></tr>
<tr><td><code>tests/test_labeling_api.py</code></td><td class="num">19</td>
<td>контракт <code>/api/v1/labeling/*</code>: отказ отдавать файлы вне датасета (обход каталога,
не-DICOM), проверка вердикта, выгрузка, читаемая обучением как <code>--labels-csv</code></td></tr>
<tr><td><code>tests/test_review_pack.py</code></td><td class="num">17</td>
<td>автономный пакет разметки: отпечаток набора, порядок «расхождения первыми», встроенные
данные разбираются как JSON, в странице нет ссылок на сеть, слияние вердиктов с построенной
разметкой и сообщение о путях из чужого пакета</td></tr>
</table>
<p>Отдельный контур — браузерные проверки интерфейса (<code>tests/browser/*.js</code>, Node и
<code>playwright-core</code>): они открывают интерфейс во временном профиле Chrome, загружают DICOM,
кликают по строкам таблицы и снимают содержимое панели деталей. Для проверок интерфейса и ручной
разметки требуется запущенный сервер на <code>127.0.0.1:8123</code>; проверке автономного пакета
сервер не нужен вовсе — она открывает файл прямо с диска. Профиль пользователя не затрагивается.</p>
<table>
<tr><th style="width:28%">Сценарий</th><th>Что проверяет</th></tr>
<tr><td><code>ui_check.js</code></td>
<td>подсказку о кликабельности строк и её скрытие при пустом фильтре; открытие панели деталей из
строки; изменение значений при переключении строк; наполнение панели «О модели» метриками и
словарём</td></tr>
<tr><td><code>ui_violation.js</code></td>
<td>ветку «нарушение»: бейдж, <code>POOR</code>, <code>HIGH</code>, текст заключения</td></tr>
<tr><td><code>ui_labeling.js</code></td>
<td>интерфейс ручной разметки: список снимков с прогрессом, расхождения первыми, отрисовку
снимка, сохранение вердикта и его живучесть после перезагрузки страницы, фильтр «только
расхождения», отсутствие внешних запросов</td></tr>
<tr><td><code>ui_offline.js</code></td>
<td>отсутствие обращений страницы к внешним хостам</td></tr>
<tr><td><code>review_pack.js</code></td>
<td>автономный пакет разметки — без сервера вообще: файл открывается с диска, встроенный снимок
рисуется, вердикт сохраняется и переживает перезагрузку, CSV выгружается и загружается обратно,
ни одного сетевого запроса</td></tr>
</table>
<h2 id="s16">16. Известные ошибки и их обработка</h2>
<p>Требование задания — отсутствие необработанных исключений: сбой на одном файле фиксируется в отчёте и
не прерывает пакет. Ниже — фактические режимы отказа и способ обработки.</p>
<table>
<tr><th style="width:30%">Ситуация</th><th style="width:38%">Поведение</th><th>Где обрабатывается</th></tr>
<tr><td>Ошибка чтения или обработки файла (CLI)</td>
<td>строка получает <code>quality_class = -1</code>, область <code>unknown</code>, пустые UID и
<code>processing_status = Failure: &lt;Тип&gt;: &lt;сообщение&gt;</code> (до 120 символов);
остальные файлы обрабатываются дальше</td>
<td><code>process_one</code>, <code>src/dxa/inference.py</code></td></tr>
<tr><td>Неподдерживаемая форма <code>pixel_array</code> (не 2D и не 3D)</td>
<td><code>ValueError</code>, превращается в <code>Failure:</code> согласно строке выше</td>
<td><code>preprocess.py</code></td></tr>
<tr><td>Чекпоинт отсутствует или не читается</td>
<td>сервис поднимается, <code>/api/v1/health</code> отдаёт <code>model_loaded: false</code>, а
запросы анализа — HTTP 500 <code>{"error": "Model not loaded"}</code></td>
<td><code>load_model()</code>, <code>src/main.py</code></td></tr>
<tr><td>Архитектура чекпоинта не совпала с запрошенной</td>
<td><code>ValueError</code> с указанием сохранённых <code>backbone</code> / <code>head</code></td>
<td><code>load_model</code>, <code>src/dxa/inference.py</code></td></tr>
<tr><td>Чекпоинт — «сырой» <code>state_dict</code> без метаданных</td>
<td><code>ValueError</code>: файл не содержит <code>model_state_dict</code></td>
<td><code>DXAQualityModel.load</code></td></tr>
<tr><td>Сбой в одиночном запросе <code>/analyze</code>, <code>/analyze/detailed</code>,
<code>/analyze/sr</code></td>
<td>HTTP 500 с телом <code>{"error": &lt;текст&gt;, "processing_status": "Failure: &lt;первые 50
символов&gt;"}</code>; детальный эндпоинт дополнительно печатает стек в лог сервера</td>
<td>обработчики маршрутов</td></tr>
<tr><td>Сбой одного файла в <code>/api/v1/batch</code></td>
<td>элемент становится <code>{"filename", "error", "processing_status": "Failure"}</code>,
остальные файлы возвращаются нормально</td>
<td><code>batch_analyze</code></td></tr>
<tr><td>Сбой одного файла в <code>/api/v1/export</code></td>
<td>строка со статусом <code>Failure: &lt;до 80 символов&gt;</code> и <code>quality_class = -1</code>
попадает в XLSX, выгрузка не прерывается</td>
<td><code>export_results</code></td></tr>
<tr><td>Файл разметки не найден при обучении</td>
<td>предупреждение в лог и откат на метки из имён файлов; откат залогирован явно, потому что
вместе с источником меток меняются метрики</td>
<td><code>resolve_labels_csv</code>, <code>src/dxa/train.py</code></td></tr>
<tr><td>Пустое разбиение (нет обучающих или валидационных снимков)</td>
<td><code>RuntimeError</code> с числами <code>train</code> / <code>val</code> до начала обучения</td>
<td><code>train()</code></td></tr>
</table>
<div class="note">Сервис без чекпоинта намеренно не пытается «угадывать»: он поднимается, но честно
сообщает <code>model_loaded: false</code> и отклоняет запросы анализа. Молчаливая неверная оценка вместо
явной ошибки была бы опаснее в медицинском контуре.</div>
<h2 id="s17">17. Ограничения и достоверность результатов</h2>
<p>Ограничения сформулированы так, чтобы читатель мог оценить границы применимости решения, а не
только увидеть итоговые метрики.</p>
<ol>
<li><b>Разметка выведена из оценки исследования, а не снимка.</b> Экспертная таблица описывает
исследование; перенос вердикта на снимок однозначен (область встречается один раз), но
поштучной экспертной оценки снимков в наборе нет. Оценка качества модели упирается в качество
этой разметки, а не только в объём данных. Для поштучной разметки в сервисе есть интерфейс
<code>/label</code> (раздел 11.1), им ещё не пользовались.</li>
<li><b>Мало данных.</b> 251 уникальный снимок, 76 нарушений. Доверительные интервалы широкие
(ROC-AUC 0.6726 [0.6367, 0.7086] по пяти seed'ам), поэтому оценка на закрытом наборе может
отличаться.</li>
<li><b>Эталон — та же таблица.</b> Независимой истины нет, поэтому сравнение правил разметки
частично благоприятствует варианту «только таблица» (раздел 12.3).</li>
<li><b>В DICOM нет разметки областей интереса.</b> Ни overlay, ни graphic annotation в файлах нет,
поэтому корректность нанесённых областей измерения нельзя проверить прямым сравнением с
эталоном: оценивается геометрия видимой зоны.</li>
<li><b>Тип нарушения определяется признаками снимка, а не обученной моделью.</b> Модель решает только
бинарную задачу, поэтому в выгрузке тип содержателен только для размеченных снимков, а на
инференсе эвристика на текущих данных всегда даёт «не уточнён» (раздел 8.2). Для честного
мультикласса нужна разметка типов на уровне снимка; пять снимков имеют только код
«не уточнён», потому что источник не указывает критерий.</li>
<li><b>Три снимка размечены по пометке в имени файла</b> — экспертная таблица их область не
оценивала. Такие строки помечены признаком <code>filename_fallback</code>.</li>
<li><b>Сторона бедра в семи исследованиях с единственным снимком не проверяема</b>: теги
<code>Laterality</code> пусты, оценка взята из единственного заполненного столбца таблицы.</li>
<li><b>Порог определения области привязан к текущему оборудованию.</b> Признак «ширина кадра»
безошибочно работает на этом наборе, но при смене аппарата потребует калибровки.</li>
<li><b>Числовые эвристики панели деталей не калиброваны.</b> Их пороги рассчитаны на другой
масштаб интенсивностей, поэтому в интерфейсе показываются измерения, а не вердикты.</li>
<li><b>Рабочая точка порога даёт высокий recall при умеренной точности.</b> На обучающем наборе при
пороге, подобранном по F1, модель относит к нарушениям 204 строки из 482 (≈ 42 %) при
фактической доле нарушений 30.3 %, что согласуется с precision 0.472. Порог выбран в пользу
полноты: пропустить непригодное исследование дороже, чем показать лишнее.</li>
</ol>
<h2 id="s18">18. План развития</h2>
<ul>
<li>Разметить типы нарушений на уровне снимка через интерфейс <code>/label</code> (раздел 11.1) и
обучить мультилейбл-классификатор — это снимает главное ограничение (тип нарушения определяется
признаками, а не моделью). Инструмент готов, разметка ещё не проводилась.</li>
<li>Расширить набор до 500+ исследований: доверительные интервалы сузятся, появится возможность
честной валидации без пересечения с обучением.</li>
<li>Заменить признак «ширина кадра» на калибровку по метаданным аппарата, чтобы определение области
не зависело от конкретного оборудования.</li>
<li>Добавить локализацию нарушения (тепловая карта или контур) — в задании это отнесено к
дополнительному функционалу, и для него нужна соответствующая разметка.</li>
<li>Собрать пилотную интеграцию с АРМ: отбор исследований, требующих внимания, и журналирование
подтверждений специалиста.</li>
</ul>
<h2 id="a1">Приложение А. Структура репозитория</h2>
<pre>bone_2026/
├── src/
│ ├── main.py FastAPI: маршруты и загрузка модели
│ ├── dxa/ действующий модуль оценки качества
│ │ ├── preprocess.py DICOM -> тензор (общий путь для обучения и API)
│ │ ├── model.py сеть, метрики, подбор порога, чекпоинт
│ │ ├── train.py обучение и отчёт (md/json)
│ │ ├── dataset.py DXADataset, DataLoader
│ │ ├── inference.py пакетный инференс, определение области, визуализация
│ │ ├── labels.py разбор имён, метки, склейка дублей, разбиение
│ │ ├── excel_labels.py разметка снимков по экспертной таблице
│ │ ├── manual_labels.py ручная разметка: хранение вердиктов специалиста (/label)
│ │ ├── review_pack.py автономный HTML-пакет разметки для специалиста (без сервера)
│ │ ├── rename_files.py приведение имён DICOM к единому виду
│ │ ├── violations.py единый словарь типов нарушений
│ │ ├── model_card.py карточка решения для API и интерфейса
│ │ ├── compare_labels.py сравнение источников разметки
│ │ ├── discriminator.py проверка вклада содержимого снимка
│ │ └── render.py рендер снимков и контактных листов
│ ├── quality/ эвристики: измерения снимка
│ ├── api/static/ веб-интерфейс (офлайн-ассеты)
│ └── utils/ выбор устройства
├── labels/ размеченные данные, разбиение, карта переименований
├── assets/ схемы, скриншоты, обоснование разметки
├── models/dxa_model.pth рабочий чекпоинт и отчёт обучения
├── tests/ pytest + браузерные проверки интерфейса
├── docs/ настоящий документ (HTML и PDF)
├── run.sh, docker-compose.yml, Dockerfile, Dockerfile_cuda, Jenkinsfile, requirements.txt</pre>
<h2 id="a2">Приложение Б. Команды</h2>
<pre># обучение и разметка
./run.sh label разметить датасет по экспертной таблице
./run.sh rename план приведения имён файлов (--apply для применения)
./run.sh train обучить модель (режим меток по умолчанию)
./run.sh split &amp;&amp; ./run.sh compare сравнить варианты разметки на одном разбиении
# инференс и сервис
./run.sh infer "dataset_hack/Для теста" results.xlsx
./run.sh serve API и веб-интерфейс на порту 8000; /label — ручная разметка
python -m src.dxa.discriminator проверка вклада содержимого снимка
# проверки
./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
docker compose --profile cuda up -d dxa-cuda GPU-сервис (нужен nvidia-container-toolkit)
docker build -t dxa-quality . сборка CPU-образа напрямую
docker run -p 8000:8000 dxa-quality запуск без compose</pre>
<p class="sign">Документ подготовлен по фактическому состоянию репозитория; машинные отчёты обучения —
<code>models/train_report.json</code>, обоснование разметки — <code>assets/labeling.md</code>.</p>
</body>
</html>