develop - hack_2026

This commit is contained in:
denis 2026-09-23 23:37:37 +03:00
parent 5a8dbb43a5
commit 8d93298877
7 changed files with 594 additions and 311 deletions

View File

@ -44,6 +44,17 @@ RUN python -c "import os, torch; p='models/dxa_model.pth'; \
assert 'model_state_dict' in ck, 'checkpoint has no model_state_dict'; \ assert 'model_state_dict' in ck, 'checkpoint has no model_state_dict'; \
print('checkpoint ok:', ck.get('backbone'), ck.get('head'))" print('checkpoint ok:', ck.get('backbone'), ck.get('head'))"
# --- Сборка статики для офлайн-работы ---
# Веб-интерфейс не должен зависеть от CDN: Tailwind и FontAwesome лежат в
# src/api/static/vendor и src/api/static/webfonts (см. index.html). Сборка
# проверяет их наличие, чтобы образ не остался без оформления.
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']; \
missing=[f for f in files if not os.path.exists(f)]; \
assert not missing, f'missing frontend assets: {missing}'; \
print('frontend assets ok')"
ENV PYTHONUNBUFFERED=1 \ ENV PYTHONUNBUFFERED=1 \
PYTHONPATH=/app \ PYTHONPATH=/app \
DXA_MODEL_PATH=/app/models/dxa_model.pth DXA_MODEL_PATH=/app/models/dxa_model.pth

58
QWEN.md
View File

@ -54,21 +54,26 @@ src/dxa/
| Порог по логиту, подбор по F1 | При 15 % нарушений порог 0.5 даёт нулевой recall; вероятности насыщаются | | Порог по логиту, подбор по F1 | При 15 % нарушений порог 0.5 даёт нулевой recall; вероятности насыщаются |
| Аугментация выключена по умолчанию | Яркость и положение сами являются признаками качества: AUC 0.87 → 0.56 | | Аугментация выключена по умолчанию | Яркость и положение сами являются признаками качества: AUC 0.87 → 0.56 |
| Область по ширине кадра | Позвоночник 300 px, бедро 280 px; 99/99 для позвоночника | | Область по ширине кадра | Позвоночник 300 px, бедро 280 px; 99/99 для позвоночника |
| Панель деталей показывает измерения, а не вердикты | Эвристики `detailed_assessment` не калиброваны: `motion_detected`/`any_detected` истинны почти всегда, ROI-отступы срабатывают для 227/252 снимков |
### Метрики (валидация, разбиение по исследованиям) ### Проверка вклада модели
- ROC-AUC по 5 разбиениям: **0.76 ± 0.08** (0.64 – 0.84) Не является ли модель просто детектором анатомии (в позвоночнике ~29 % нарушений
- PR-AUC по 5 разбиениям: 0.48 ± 0.17 (базовый уровень при 15 % нарушений — 0.15) против ~4–5 % у бёдер, а область почти однозначно определяется по ширине кадра):
- F1 по 5 разбиениям: 0.54 ± 0.11 (порог подобран на той же валидации → смещено вверх)
- ROC-AUC, 5-фолдовая CV линейного зонда: **0.81 ± 0.08**
- Контрольная задача «позвоночник / бедро»: AUC 1.00 (проверка пайплайна)
- Нулевая гипотеза (перестановка меток): AUC 0.64
Разбивка по областям обязательна: в позвоночнике ~29 % нарушений против ~4–5 % ```bash
у бёдер, а область почти однозначно определяется по ширине кадра, поэтому общий python -m src.dxa.discriminator --model-path models/dxa_model.pth
AUC частично отражает различение области. ```
--- Результат на чекпоинте `models/dxa_model.pth`:
| Предиктор | Общий AUC | spine | hip_right | hip_left |
|---|---|---|---|---|
| Модель | 0.822 | 0.838 | 0.895 | 0.948 |
| Правило «позвоночник = нарушение» | 0.724 | 0.500 | 0.500 | 0.500 |
Модель использует содержимое снимка: внутри областей она даёт 0.84–0.95.
Правило по области внутри области всегда 0.50 (подсказки нет).
## Данные (`dataset_hack/`) ## Данные (`dataset_hack/`)
@ -141,12 +146,35 @@ API и CLI используют один код предсказания (`predi
```bash ```bash
./run.sh test ./run.sh test
python -m pytest tests/ -q # 65 тестов python -m pytest tests/ -q # 79 тестов
``` ```
`tests/test_labels.py` — разбор имён, склейка дублей, отсутствие утечки при - `tests/test_labels.py` — разбор имён, склейка дублей, отсутствие утечки при
разбиении. `tests/test_preprocess_and_model.py` — предобработка, метрики, разбиении.
подбор порога, контракт модели, BatchNorm при заморозке, roundtrip чекпоинта. - `tests/test_preprocess_and_model.py` — предобработка, метрики, подбор порога,
контракт модели, BatchNorm при заморозке, roundtrip чекпоинта.
- `tests/test_api_contract.py` — поля ответов, которые читает `dxa-app.js`
(панель деталей ранее показывала прочерки из-за расхождения ключей),
различимость метрик между снимками, валидность PNG-визуализаций, отсутствие
некалиброванных вердиктов в ответе.
### Проверка веб-интерфейса в браузере
`tests/browser/*.js` (Node + playwright-core из bundled Browser Use) открывают
интерфейс, загружают DICOM, кликают по строкам таблицы и снимают содержимое
панели деталей. Используется временный профиль Chrome, профиль пользователя не
затрагивается. Требуется запущенный сервер на `127.0.0.1:8123`.
- `ui_check.js` — значения панели меняются при переключении строк.
- `ui_violation.js` — ветка «нарушение» (бейдж, POOR, HIGH, заключение).
- `ui_offline.js` — страница не обращается к внешним хостам.
### Офлайн-работа фронтенда
Tailwind и FontAwesome лежат локально (`src/api/static/vendor`,
`src/api/static/webfonts`), страница не обращается к CDN. Требование методики —
работа без внешних сервисов; CDN-версии ломали оформление в закрытом контуре.
Наличие ассетов проверяется на этапе сборки образа (см. Dockerfile).
--- ---

View File

@ -10,10 +10,10 @@
|---|---| |---|---|
| Определение анатомической области | Ширина кадра (позвоночник / бедро) + голова области + геометрия яркой зоны | | Определение анатомической области | Ширина кадра (позвоночник / бедро) + голова области + геометрия яркой зоны |
| Бинарная оценка качества | ResNet18 (ImageNet) → линейная голова; порог подобран по F1 на валидации | | Бинарная оценка качества | ResNet18 (ImageNet) → линейная голова; порог подобран по F1 на валидации |
| Тип нарушения | Эвристики по изображению: размытие/движение, посторонние включения, геометрия ROI | | Тип нарушения | Общая категория для снимков с нарушением; детальный тип требует разметки типов на уровне снимка |
| Отчёт | XLSX/CSV со столбцами из требований; опционально zip с визуализацией зоны интереса | | Отчёт | XLSX/CSV со столбцами из требований; опционально zip с визуализацией зоны интереса |
| API | FastAPI: анализ, детальный анализ, пакетная обработка, экспорт, DICOM SR (текст) | | API | FastAPI: анализ, детальный анализ, пакетная обработка, экспорт, DICOM SR (текст) |
| Веб-интерфейс | Загрузка DICOM, таблица результатов | | Веб-интерфейс | Загрузка DICOM, таблица результатов, панель деталей с визуализацией |
## Установка и запуск ## Установка и запуск
@ -193,6 +193,39 @@ curl -X POST http://localhost:8000/api/v1/analyze -F "file=@study/spine.dcm"
5. **В DICOM нет разметки ROI.** Ни overlay, ни graphic annotation в файлах нет, 5. **В DICOM нет разметки ROI.** Ни overlay, ни graphic annotation в файлах нет,
поэтому корректность нанесённых областей измерения нельзя проверить прямым поэтому корректность нанесённых областей измерения нельзя проверить прямым
сравнением — оценивается только геометрия видимой зоны. сравнением — оценивается только геометрия видимой зоны.
6. **Эвристики из `src/quality/detailed_assessment.py` не калиброваны.**
Пороги для «движения», «артефактов» и отступов ROI рассчитаны на другой
масштаб интенсивностей: на этом наборе они срабатывают почти для любого
снимка, а признак резкости ведёт себя противоположно в позвоночнике и
бёдрах. Поэтому в панели деталей показываются **числовые измерения** с
пометкой «справ.», а не вердикты «Да/Нет». Классификацию выполняет только
модель. Проверить её вклад можно командой:
```bash
python -m src.dxa.discriminator --model-path models/dxa_model.pth
```
## Что проверено и как
| Проверка | Команда | Результат |
|---|---|---|
| Модель использует снимок, а не только область | `python -m src.dxa.discriminator` | AUC 0.82 против 0.72 у правила «позвоночник = нарушение»; внутри областей у модели 0.84–0.95, у правила 0.50 |
| Контракт API для веб-интерфейса | `python -m pytest tests/test_api_contract.py` | 15 тестов: поля панели деталей, различимость метрик, PNG-визуализации, отсутствие некалиброванных вердиктов |
| Разметка и разбиение данных | `python -m pytest tests/test_labels.py` | метки из имён, склейка дублей, отсутствие утечки между train/val |
| Метрики и порог | `python -m pytest tests/test_preprocess_and_model.py` | подбор порога при дисбалансе, roundtrip чекпоинта, BatchNorm |
| Веб-интерфейс в браузере | `node tests/browser/ui_check.js` | значения панели меняются при переключении строк |
| Работа без сети | `node tests/browser/ui_offline.js` | ноль внешних запросов, стили и иконки на месте |
Браузерные проверки требуют запущенного сервера:
```bash
python -m uvicorn src.main:app --port 8123
node tests/browser/ui_check.js # панель деталей обновляется по клику
node tests/browser/ui_offline.js # работа без доступа к внешним сервисам
```
Офлайн-режим обеспечен локальными копиями Tailwind и FontAwesome
(`src/api/static/vendor`, `src/api/static/webfonts`); страница не обращается к CDN.
## План доработки ## План доработки

View File

@ -4,8 +4,13 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DXA Quality Assessment</title> <title>DXA Quality Assessment</title>
<script src="https://cdn.tailwindcss.com"></script> <!-- Пустая favicon: без неё браузер запрашивает /favicon.ico и получает 404. -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> <link rel="icon" href="data:,">
<!-- Tailwind и FontAwesome лежат локально (static/vendor, static/webfonts):
решение должно работать без доступа к внешним сервисам, а CDN-версии
ломали оформление в офлайн-контейнере. Версии зафиксированы. -->
<script src="/static/vendor/tailwind.js"></script>
<link rel="stylesheet" href="/static/vendor/fontawesome.css">
<script> <script>
tailwind.config = { tailwind.config = {
darkMode: 'class', darkMode: 'class',
@ -177,7 +182,7 @@
</div> </div>
<div> <div>
<p class="text-2xl font-bold text-gray-900 dark:text-white" id="accuracy">-</p> <p class="text-2xl font-bold text-gray-900 dark:text-white" id="accuracy">-</p>
<p class="text-sm text-gray-500 dark:text-gray-400">Точность</p> <p class="text-sm text-gray-500 dark:text-gray-400">Ср. уверенность</p>
</div> </div>
</div> </div>
</div> </div>
@ -187,7 +192,7 @@
<div class="flex flex-wrap gap-3 mb-4"> <div class="flex flex-wrap gap-3 mb-4">
<button id="exportXlsx" class="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition"> <button id="exportXlsx" class="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition">
<i class="fas fa-file-excel"></i> <i class="fas fa-file-excel"></i>
Скачать XLSX Скачать CSV
</button> </button>
<button id="clearResults" class="flex items-center gap-2 px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition"> <button id="clearResults" class="flex items-center gap-2 px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition">
<i class="fas fa-trash"></i> <i class="fas fa-trash"></i>
@ -303,42 +308,22 @@
</div> </div>
</div> </div>
<!-- Motion Metrics --> <!-- Image measurements -->
<div class="md:col-span-2"> <div class="md:col-span-2">
<h4 class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2"> <h4 class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">
<i class="fas fa-running mr-1"></i> Движение (Motion) <i class="fas fa-ruler-combined mr-1"></i> Измерения снимка
</h4> </h4>
<div id="detailMotion" class="grid grid-cols-2 md:grid-cols-4 gap-3"> <div id="detailMotion" class="grid grid-cols-2 md:grid-cols-4 gap-3">
<!-- Populated by JS --> <!-- Populated by JS -->
</div> </div>
</div> </div>
<!-- Artifacts --> <!-- ROI -->
<div class="md:col-span-2"> <div class="md:col-span-2">
<h4 class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2"> <h4 class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">
<i class="fas fa-exclamation-triangle mr-1"></i> Артефакты <i class="fas fa-crop-alt mr-1"></i> Зона интереса
</h4> </h4>
<div id="detailArtifacts" class="grid grid-cols-2 md:grid-cols-5 gap-3"> <div id="detailArtifacts" class="grid grid-cols-2 md:grid-cols-3 gap-3">
<!-- Populated by JS -->
</div>
</div>
<!-- Position -->
<div>
<h4 class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">
<i class="fas fa-crosshairs mr-1"></i> Позиция
</h4>
<div id="detailPosition" class="space-y-2 text-sm">
<!-- Populated by JS -->
</div>
</div>
<!-- ROI -->
<div>
<h4 class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">
<i class="fas fa-crop-alt mr-1"></i> ROI
</h4>
<div id="detailROI" class="space-y-2 text-sm">
<!-- Populated by JS --> <!-- Populated by JS -->
</div> </div>
</div> </div>
@ -364,6 +349,11 @@
</div> </div>
</div> </div>
<!-- Metrics disclaimer -->
<div id="detailMetricsNote" class="hidden mt-4 text-xs text-amber-700 dark:text-amber-400 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg px-3 py-2">
<!-- Populated by JS -->
</div>
<!-- Reason --> <!-- Reason -->
<div class="mt-6"> <div class="mt-6">
<h4 class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">Заключение</h4> <h4 class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">Заключение</h4>

View File

@ -157,11 +157,15 @@ async function handleFiles(files) {
const data = await response.json(); const data = await response.json();
results.push({ results.push({
filename: file.name, filename: file.name,
// File сохраняем, чтобы подгрузить визуализацию по клику без
// повторного выбора файлов пользователем.
file: file,
...data ...data
}); });
} catch (e) { } catch (e) {
results.push({ results.push({
filename: file.name, filename: file.name,
file: file,
quality_class: -1, quality_class: -1,
quality_label: 'Error', quality_label: 'Error',
processing_status: `Failure: ${e.message}`, processing_status: `Failure: ${e.message}`,
@ -316,18 +320,32 @@ document.getElementById('exportXlsx')?.addEventListener('click', async () => {
btn.disabled = true; btn.disabled = true;
try { try {
// For proper XLSX export, we'd need to re-upload files // Основные столбцы соответствуют формату задания; пояснения — справа.
// For now, export as CSV which Excel can open const headers = ['path_to_study', 'study_uid', 'image_uid', 'anatomical_region',
const headers = ['filename', 'study_uid', 'image_uid', 'anatomical_region', 'quality_class', 'quality_label', 'confidence', 'processing_status']; 'quality_class', 'violation_type', 'processing_status',
const csvContent = [ 'time_of_processing', 'confidence', 'violation_reason'];
headers.join(','), const rows = results.map(r => ({
...results.map(r => headers.map(h => { path_to_study: `upload://${r.filename || 'unknown'}`,
let val = r[h] || ''; study_uid: r.study_uid || '',
if (typeof val === 'string' && val.includes(',')) { image_uid: r.image_uid || '',
val = `"${val}"`; anatomical_region: r.anatomical_region || 'unknown',
} quality_class: r.quality_class,
return val; violation_type: r.violation_type || '',
}).join(',')) processing_status: r.processing_status || '',
time_of_processing: r.time_of_processing ?? '',
confidence: r.confidence ?? '',
violation_reason: r.reason || '',
}));
const escapeCell = (val) => {
const s = String(val ?? '');
return /[",\n;]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
};
// BOM + точка с запятой: Excel в русской локали открывает такой файл
// без мастер-импорта и не ломает кириллицу.
const csvContent = '\uFEFF' + [
headers.join(';'),
...rows.map(r => headers.map(h => escapeCell(r[h])).join(';'))
].join('\n'); ].join('\n');
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
@ -335,6 +353,7 @@ document.getElementById('exportXlsx')?.addEventListener('click', async () => {
link.href = URL.createObjectURL(blob); link.href = URL.createObjectURL(blob);
link.download = `dxa_results_${new Date().toISOString().slice(0, 10)}.csv`; link.download = `dxa_results_${new Date().toISOString().slice(0, 10)}.csv`;
link.click(); link.click();
URL.revokeObjectURL(link.href);
} catch (e) { } catch (e) {
showError('Ошибка экспорта: ' + e.message); showError('Ошибка экспорта: ' + e.message);
} finally { } finally {
@ -356,42 +375,56 @@ document.getElementById('closeDetail')?.addEventListener('click', () => {
detailSection.classList.add('hidden'); detailSection.classList.add('hidden');
}); });
// Region label helper
function getRegionLabel(region) {
const labels = {
'spine': 'Позвоночник',
'hip_left': 'Бедро левое',
'hip_right': 'Бедро правое',
'hip': 'Бедро (общее)',
'unknown': 'Неизвестно'
};
return labels[region] || region || '—';
}
// Violation type label
function getViolationLabel(type) {
const labels = {
'': 'Корректно',
'correct': 'Корректно',
'position_error': 'Ошибка позиционирования',
'artifact_motion': 'Артефакт движения',
'artifact_other': 'Другие артефакты',
'labeling_error': 'Ошибка маркировки',
'incomplete_view': 'Неполный вид',
'roi_error': 'Ошибка ROI',
'rotation': 'Неправильный поворот',
'quality_violation_detected': 'Нарушение качества'
};
return labels[type] || type || '—';
}
// Small helpers used by the detail panel
const fmtYesNo = (v) => (v ? 'Да' : 'Нет');
const dot = (bad) => `<div class="w-2 h-2 rounded-full ${bad ? 'bg-red-500' : 'bg-green-500'}"></div>`;
const num = (v, digits = 4) => (typeof v === 'number' && isFinite(v) ? v.toFixed(digits) : '—');
const pct = (v) => (typeof v === 'number' && isFinite(v) ? (v * 100).toFixed(1) + '%' : '—');
const card = (title, value) => `
<div class="p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
<div class="text-xs text-gray-500 dark:text-gray-400 mb-1">${title}</div>
<span class="font-medium text-sm">${value}</span>
</div>`;
// View detailed analysis for a file // View detailed analysis for a file
async function viewDetail(result) { async function viewDetail(result) {
detailSection.classList.remove('hidden'); detailSection.classList.remove('hidden');
// Region label helper
const getRegionLabel = (region) => {
const labels = {
'spine': 'Позвоночник',
'hip_left': 'Бедро левое',
'hip_right': 'Бедро правое',
'hip': 'Бедро (общее)',
'unknown': 'Неизвестно'
};
return labels[region] || region || '—';
};
// Violation type label
const getViolationLabel = (type) => {
const labels = {
'correct': 'Корректно',
'position_error': 'Ошибка позиционирования',
'artifact_motion': 'Артефакт движения',
'artifact_other': 'Другие артефакты',
'labeling_error': 'Ошибка маркировки',
'incomplete_view': 'Неполный вид',
'roi_error': 'Ошибка ROI',
'rotation': 'Неправильный поворот'
};
return labels[type] || type || '—';
};
// Quality badge // Quality badge
const qualityClass = result.quality_class === 0 ? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400' : const qualityClass = result.quality_class === 0 ? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400' :
result.quality_class === 1 ? 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400' : result.quality_class === 1 ? 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400' :
'bg-gray-100 text-gray-800'; 'bg-gray-100 text-gray-800 dark:text-gray-300';
const qualityLabel = result.quality_class === 0 ? 'OK' : result.quality_class === 1 ? 'Нарушение' : 'Ошибка'; const qualityLabel = result.quality_class === 0 ? 'OK' :
result.quality_class === 1 ? 'Нарушение' : 'Ошибка';
document.getElementById('detailBadge').innerHTML = ` document.getElementById('detailBadge').innerHTML = `
<span class="px-3 py-1.5 rounded-lg text-sm font-medium ${qualityClass}"> <span class="px-3 py-1.5 rounded-lg text-sm font-medium ${qualityClass}">
@ -400,234 +433,215 @@ async function viewDetail(result) {
<span class="text-sm text-gray-600 dark:text-gray-400">${result.quality_label || ''}</span> <span class="text-sm text-gray-600 dark:text-gray-400">${result.quality_label || ''}</span>
`; `;
// UIDs // UIDs — длинные UID переносим, иначе они вылезают за панель
document.getElementById('detailUIDs').innerHTML = ` document.getElementById('detailUIDs').innerHTML = `
<div class="flex justify-between"><span class="text-gray-500">Study UID:</span><span class="text-gray-900 dark:text-gray-200">${result.study_uid || '—'}</span></div> <div class="flex justify-between gap-4"><span class="text-gray-500 shrink-0">Study UID:</span><span class="text-gray-900 dark:text-gray-200 break-all text-right">${result.study_uid || '—'}</span></div>
<div class="flex justify-between"><span class="text-gray-500">Image UID:</span><span class="text-gray-900 dark:text-gray-200">${result.image_uid || '—'}</span></div> <div class="flex justify-between gap-4"><span class="text-gray-500 shrink-0">Image UID:</span><span class="text-gray-900 dark:text-gray-200 break-all text-right">${result.image_uid || '—'}</span></div>
`; `;
// Basic info // Basic info
const viewLabels = { 'full': 'Полный', 'partial': 'Частичный', 'unknown': 'Неизвестно' };
document.getElementById('detailBasic').innerHTML = ` document.getElementById('detailBasic').innerHTML = `
<div class="flex justify-between"><span class="text-gray-500">Файл:</span><span class="font-medium">${result.filename || '—'}</span></div> <div class="flex justify-between"><span class="text-gray-500">Файл:</span><span class="font-medium break-all text-right">${result.filename || '—'}</span></div>
<div class="flex justify-between"><span class="text-gray-500">Регион:</span><span class="font-medium">${getRegionLabel(result.anatomical_region)}</span></div> <div class="flex justify-between"><span class="text-gray-500">Регион:</span><span class="font-medium">${getRegionLabel(result.anatomical_region)}</span></div>
<div class="flex justify-between"><span class="text-gray-500">Вид снимка:</span><span class="font-medium">${result.view_quality || 'unknown'}</span></div> <div class="flex justify-between"><span class="text-gray-500">Уверенность региона:</span><span class="font-medium">${pct(result.region_confidence)}</span></div>
<div class="flex justify-between"><span class="text-gray-500">Полнота (справ.) :</span><span class="font-medium">${viewLabels[result.view_quality] || result.view_quality || '—'}</span></div>
`; `;
// Quality details // Quality details
document.getElementById('detailQuality').innerHTML = ` document.getElementById('detailQuality').innerHTML = `
<div class="flex justify-between"><span class="text-gray-500">Тип нарушения:</span><span class="font-medium">${getViolationLabel(result.violation_type)}</span></div> <div class="flex justify-between"><span class="text-gray-500">Тип нарушения:</span><span class="font-medium">${getViolationLabel(result.violation_type)}</span></div>
<div class="flex justify-between"><span class="text-gray-500">Класс:</span><span class="font-medium">${result.quality_class}</span></div> <div class="flex justify-between"><span class="text-gray-500">Класс:</span><span class="font-medium">${result.quality_class}</span></div>
<div class="flex justify-between"><span class="text-gray-500">Порог решения:</span><span class="font-medium">${pct(result.threshold_probability)}</span></div>
`; `;
// Confidence // Confidence per class (computed on the server)
const confCorrect = result.confidence_per_class?.correct || 0; const confCorrect = result.confidence_per_class?.correct ?? (1 - (result.confidence || 0));
const confViolation = result.confidence_per_class?.violation || 0; const confViolation = result.confidence_per_class?.violation ?? (result.confidence || 0);
document.getElementById('detailConfidence').innerHTML = ` document.getElementById('detailConfidence').innerHTML = `
<div class="space-y-2"> <div class="space-y-2">
<div class="flex justify-between text-sm"><span class="text-gray-500">Верный:</span><span class="font-medium text-green-600">${(confCorrect * 100).toFixed(1)}%</span></div> <div class="flex justify-between text-sm"><span class="text-gray-500">Верный:</span><span class="font-medium text-green-600">${pct(confCorrect)}</span></div>
<div class="w-full bg-gray-200 dark:bg-gray-600 rounded-full h-2"> <div class="w-full bg-gray-200 dark:bg-gray-600 rounded-full h-2">
<div class="bg-green-500 h-2 rounded-full" style="width: ${confCorrect * 100}%"></div> <div class="bg-green-500 h-2 rounded-full" style="width: ${confCorrect * 100}%"></div>
</div> </div>
<div class="flex justify-between text-sm"><span class="text-gray-500">Нарушение:</span><span class="font-medium text-red-600">${(confViolation * 100).toFixed(1)}%</span></div> <div class="flex justify-between text-sm"><span class="text-gray-500">Нарушение:</span><span class="font-medium text-red-600">${pct(confViolation)}</span></div>
<div class="w-full bg-gray-200 dark:bg-gray-600 rounded-full h-2"> <div class="w-full bg-gray-200 dark:bg-gray-600 rounded-full h-2">
<div class="bg-red-500 h-2 rounded-full" style="width: ${confViolation * 100}%"></div> <div class="bg-red-500 h-2 rounded-full" style="width: ${confViolation * 100}%"></div>
</div> </div>
</div> </div>
`; `;
// Overall quality // Overall quality + severity
const overallClass = result.overall_quality === 'GOOD' ? 'text-green-600' : const overallClass = result.overall_quality === 'GOOD' ? 'text-green-600' :
result.overall_quality === 'FAIR' ? 'text-yellow-600' : result.overall_quality === 'FAIR' ? 'text-yellow-600' :
result.overall_quality === 'POOR' ? 'text-red-600' : 'text-gray-600'; result.overall_quality === 'POOR' ? 'text-red-600' : 'text-gray-600';
const severityClass = result.severity === 'LOW' ? 'bg-green-100 text-green-800 dark:bg-green-900/30' : const severityClass = result.severity === 'NONE' ? 'bg-green-100 text-green-800 dark:bg-green-900/30' :
result.severity === 'LOW' ? 'bg-green-100 text-green-800 dark:bg-green-900/30' :
result.severity === 'MEDIUM' ? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30' : result.severity === 'MEDIUM' ? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30' :
result.severity === 'HIGH' ? 'bg-red-100 text-red-800 dark:bg-red-900/30' : 'bg-gray-100'; result.severity === 'HIGH' ? 'bg-red-100 text-red-800 dark:bg-red-900/30' : 'bg-gray-100 text-gray-800 dark:bg-gray-700';
document.getElementById('detailOverall').innerHTML = ` document.getElementById('detailOverall').innerHTML = `
<div class="flex justify-between"><span class="text-gray-500">Качество:</span><span class="font-medium ${overallClass}">${result.overall_quality || '—'}</span></div> <div class="flex justify-between"><span class="text-gray-500">Качество:</span><span class="font-medium ${overallClass}">${result.overall_quality || '—'}</span></div>
<div class="flex justify-between items-center"><span class="text-gray-500">Серьёзность:</span><span class="px-2 py-0.5 rounded text-xs ${severityClass}">${result.severity || '—'}</span></div> <div class="flex justify-between items-center"><span class="text-gray-500">Серьёзность:</span><span class="px-2 py-0.5 rounded text-xs font-medium ${severityClass}">${result.severity || '—'}</span></div>
`; `;
// Motion metrics // Числовые характеристики снимка.
// Панель намеренно показывает измерения, а не вердикты «Да/Нет»: пороги
// встроенных эвристик на этих данных не калиброваны (например, проверка
// отступов ROI срабатывает почти всегда, а признак резкости ведёт себя
// противоположно в позвоночнике и в бёдрах). См. metrics_note ниже.
const metrics = result.metrics || {}; const metrics = result.metrics || {};
const motion = metrics.motion || {};
const blurLaplacian = motion.blur_laplacian?.toFixed(4) || '—';
const blurFFT = motion.blur_fft?.toFixed(4) || '—';
document.getElementById('detailMotion').innerHTML = ` document.getElementById('detailMotion').innerHTML = `
<div class="p-3 bg-gray-50 dark:bg-gray-700 rounded-lg"> ${card('Резкость (Laplacian)', `<span class="font-mono">${num(metrics.sharpness_laplacian, 6)}</span>`)}
<div class="text-xs text-gray-500 mb-1">Движение</div> ${card('Резкость (FFT)', `<span class="font-mono">${num(metrics.sharpness_fft, 6)}</span>`)}
<div class="flex items-center gap-2"> ${card('Кадр', metrics.calibration ? `${metrics.calibration.frame_width}×${metrics.calibration.frame_height}` : '—')}
<div class="w-2 h-2 rounded-full ${motion.motion_detected ? 'bg-red-500' : 'bg-green-500'}"></div> ${card('Порог маски', metrics.calibration ? `p${metrics.calibration.mask_percentile}` : '—')}
<span class="font-medium">${motion.motion_detected ? 'Да' : 'Нет'}</span>
</div>
</div>
<div class="p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
<div class="text-xs text-gray-500 mb-1">Blur Laplacian</div>
<span class="font-mono text-sm">${blurLaplacian}</span>
${motion.is_blurred_laplacian ? '<span class="text-xs text-red-500">размыто</span>' : ''}
</div>
<div class="p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
<div class="text-xs text-gray-500 mb-1">Blur FFT</div>
<span class="font-mono text-sm">${blurFFT}</span>
${motion.is_blurred_fft ? '<span class="text-xs text-red-500">размыто</span>' : ''}
</div>
<div class="p-3 bg-gray-50 dark:bg-gray-700 rounded-lg">
<div class="text-xs text-gray-500 mb-1">Дублирование краёв</div>
<div class="flex items-center gap-2">
<div class="w-2 h-2 rounded-full ${motion.edge_duplication ? 'bg-red-500' : 'bg-green-500'}"></div>
<span class="font-medium">${motion.edge_duplication ? 'Да' : 'Нет'}</span>
</div>
</div>
`; `;
// Artifacts // Границы зоны интереса
const artifacts = metrics.artifacts || {}; const roi = metrics.roi || {};
const bbox = roi.bounding_box
? `x:${roi.bounding_box.x_min}-${roi.bounding_box.x_max}, y:${roi.bounding_box.y_min}-${roi.bounding_box.y_max}`
: '—';
document.getElementById('detailArtifacts').innerHTML = ` document.getElementById('detailArtifacts').innerHTML = `
<div class="p-2 bg-gray-50 dark:bg-gray-700 rounded-lg text-center"> ${card('Размер зоны', roi.size ? `${roi.size.width}×${roi.size.height}` : '—')}
<div class="text-xs text-gray-500">Металл</div> ${card('BBox', `<span class="font-mono text-xs">${bbox}</span>`)}
<div class="flex items-center justify-center gap-1 mt-1"> ${card('Замечаний', roi.issues?.length || 0)}
<div class="w-2 h-2 rounded-full ${artifacts.metal_detected ? 'bg-red-500' : 'bg-green-500'}"></div>
<span class="text-sm font-medium">${artifacts.metal_detected ? 'Да' : 'Нет'}</span>
</div>
</div>
<div class="p-2 bg-gray-50 dark:bg-gray-700 rounded-lg text-center">
<div class="text-xs text-gray-500">Имплантат</div>
<div class="flex items-center justify-center gap-1 mt-1">
<div class="w-2 h-2 rounded-full ${artifacts.implant_detected ? 'bg-red-500' : 'bg-green-500'}"></div>
<span class="text-sm font-medium">${artifacts.implant_detected ? 'Да' : 'Нет'}</span>
</div>
</div>
<div class="p-2 bg-gray-50 dark:bg-gray-700 rounded-lg text-center">
<div class="text-xs text-gray-500">Цемент</div>
<div class="flex items-center justify-center gap-1 mt-1">
<div class="w-2 h-2 rounded-full ${artifacts.cement_detected ? 'bg-red-500' : 'bg-green-500'}"></div>
<span class="text-sm font-medium">${artifacts.cement_detected ? 'Да' : 'Нет'}</span>
</div>
</div>
<div class="p-2 bg-gray-50 dark:bg-gray-700 rounded-lg text-center">
<div class="text-xs text-gray-500">Кальцификаты</div>
<div class="flex items-center justify-center gap-1 mt-1">
<div class="w-2 h-2 rounded-full ${artifacts.calcification_detected ? 'bg-yellow-500' : 'bg-green-500'}"></div>
<span class="text-sm font-medium">${artifacts.calcification_detected ? 'Да' : 'Нет'}</span>
</div>
</div>
<div class="p-2 bg-gray-50 dark:bg-gray-700 rounded-lg text-center">
<div class="text-xs text-gray-500">Локальные дефекты</div>
<div class="flex items-center justify-center gap-1 mt-1">
<div class="w-2 h-2 rounded-full ${artifacts.local_defects?.length > 0 ? 'bg-red-500' : 'bg-green-500'}"></div>
<span class="text-sm font-medium">${artifacts.local_defects?.length || 0}</span>
</div>
</div>
`; `;
// Position // Полнота области (позвоночник) — справочная оценка
const position = metrics.position || {};
const posValid = position.valid !== false;
document.getElementById('detailPosition').innerHTML = `
<div class="flex justify-between"><span class="text-gray-500">Центр:</span><span class="font-mono">${position.center_pixels ? `[${position.center_pixels[0]}, ${position.center_pixels[1]}]` : '—'}</span></div>
<div class="flex justify-between"><span class="text-gray-500">Отклонение:</span><span class="font-mono">${position.deviation?.toFixed(4) || '—'}</span></div>
<div class="flex justify-between"><span class="text-gray-500">Размер объекта:</span><span class="font-mono">${position.object_size?.toFixed(4) || '—'}</span></div>
<div class="flex justify-between items-center"><span class="text-gray-500">Валидность:</span><span class="px-2 py-0.5 rounded text-xs ${posValid ? 'bg-green-100 text-green-800 dark:bg-green-900/30' : 'bg-red-100 text-red-800 dark:bg-red-900/30'}">${posValid ? 'OK' : 'Ошибка'}</span></div>
`;
// ROI
const roi = metrics.roi_check || {};
const roiValid = roi.valid !== false;
document.getElementById('detailROI').innerHTML = `
<div class="flex justify-between"><span class="text-gray-500">Валидность:</span><span class="px-2 py-0.5 rounded text-xs ${roiValid ? 'bg-green-100 text-green-800 dark:bg-green-900/30' : 'bg-red-100 text-red-800 dark:bg-red-900/30'}">${roiValid ? 'OK' : 'Проблема'}</span></div>
<div class="flex justify-between"><span class="text-gray-500">Размер:</span><span class="font-mono">${roi.size ? `${roi.size.width}x${roi.size.height}` : '—'}</span></div>
<div class="flex justify-between"><span class="text-gray-500">BBox:</span><span class="font-mono text-xs">${roi.bounding_box ? `x:${roi.bounding_box.x_min}-${roi.bounding_box.x_max}, y:${roi.bounding_box.y_min}-${roi.bounding_box.y_max}` : '—'}</span></div>
${roi.issues?.length ? `<div class="mt-2"><span class="text-gray-500 text-xs">Проблемы:</span><ul class="text-xs text-red-600 list-disc list-inside">${roi.issues.map(i => `<li>${i}</li>`).join('')}</ul></div>` : ''}
`;
// Spine completeness
const spine = result.spine_completeness || {}; const spine = result.spine_completeness || {};
const spineSection = document.getElementById('spineSection'); const spineSection = document.getElementById('spineSection');
if (spine && Object.keys(spine).length > 0) { if (result.anatomical_region === 'spine' && Object.keys(spine).length > 0) {
spineSection.classList.remove('hidden'); spineSection.classList.remove('hidden');
document.getElementById('detailSpine').innerHTML = ` document.getElementById('detailSpine').innerHTML = `
<div class="p-2 bg-gray-50 dark:bg-gray-700 rounded-lg text-center"> ${card('Полнота (справ.)', fmtYesNo(spine.valid))}
<div class="text-xs text-gray-500">Позвонков</div> ${card('Замечаний (справ.)', spine.issue_count ?? 0)}
<span class="font-medium">${spine.vertebrae_count || '—'}</span>
</div>
<div class="p-2 bg-gray-50 dark:bg-gray-700 rounded-lg text-center">
<div class="text-xs text-gray-500">Полный вид</div>
<div class="flex items-center justify-center gap-1 mt-1">
<div class="w-2 h-2 rounded-full ${spine.is_complete ? 'bg-green-500' : 'bg-red-500'}"></div>
<span class="text-sm">${spine.is_complete ? 'Да' : 'Нет'}</span>
</div>
</div>
<div class="p-2 bg-gray-50 dark:bg-gray-700 rounded-lg text-center">
<div class="text-xs text-gray-500">Валидность</div>
<div class="flex items-center justify-center gap-1 mt-1">
<div class="w-2 h-2 rounded-full ${spine.valid ? 'bg-green-500' : 'bg-red-500'}"></div>
<span class="text-sm">${spine.valid ? 'OK' : 'Ошибка'}</span>
</div>
</div>
<div class="p-2 bg-gray-50 dark:bg-gray-700 rounded-lg text-center">
<div class="text-xs text-gray-500">Проблемы</div>
<span class="text-sm text-red-600">${spine.issues?.length || 0}</span>
</div>
`; `;
} else { } else {
spineSection.classList.add('hidden'); spineSection.classList.add('hidden');
} }
// Hip completeness // Полнота и поворот бедра — справочная оценка
const hip = result.hip_completeness || {}; const hip = result.hip_completeness || {};
const hipRotation = result.hip_rotation || {}; const hipRotation = result.hip_rotation || {};
const hipSection = document.getElementById('hipSection'); const hipSection = document.getElementById('hipSection');
if ((hip && Object.keys(hip).length > 0) || (hipRotation && Object.keys(hipRotation).length > 0)) { if (result.anatomical_region?.startsWith('hip') && (Object.keys(hip).length > 0 || Object.keys(hipRotation).length > 0)) {
hipSection.classList.remove('hidden'); hipSection.classList.remove('hidden');
const angle = typeof hipRotation.rotation_angle === 'number'
? hipRotation.rotation_angle.toFixed(1) + '°' : '—';
document.getElementById('detailHip').innerHTML = ` document.getElementById('detailHip').innerHTML = `
<div class="p-2 bg-gray-50 dark:bg-gray-700 rounded-lg text-center"> ${card('Полнота (справ.)', fmtYesNo(hip.valid))}
<div class="text-xs text-gray-500">Полный вид</div> ${card('Соотношение сторон', `<span class="font-mono">${num(hip.aspect_ratio, 2)}</span>`)}
<div class="flex items-center justify-center gap-1 mt-1"> ${card('Угол поворота', `<span class="font-mono">${angle}</span>`)}
<div class="w-2 h-2 rounded-full ${hip.is_complete ? 'bg-green-500' : 'bg-red-500'}"></div> ${card('Замечаний (справ.)', (hip.issue_count || 0) + (hipRotation.issue_count || 0))}
<span class="text-sm">${hip.is_complete ? 'Да' : 'Нет'}</span>
</div>
</div>
<div class="p-2 bg-gray-50 dark:bg-gray-700 rounded-lg text-center">
<div class="text-xs text-gray-500">Соотношение сторон</div>
<span class="font-mono text-sm">${hip.aspect_ratio?.toFixed(2) || '—'}</span>
</div>
<div class="p-2 bg-gray-50 dark:bg-gray-700 rounded-lg text-center">
<div class="text-xs text-gray-500">Угол поворота</div>
<span class="font-mono text-sm">${hipRotation.angle ? hipRotation.angle.toFixed(1) + '°' : '—'}</span>
</div>
<div class="p-2 bg-gray-50 dark:bg-gray-700 rounded-lg text-center">
<div class="text-xs text-gray-500">Валидность</div>
<div class="flex items-center justify-center gap-1 mt-1">
<div class="w-2 h-2 rounded-full ${(hip.valid && hipRotation.valid) ? 'bg-green-500' : 'bg-red-500'}"></div>
<span class="text-sm">${(hip.valid && hipRotation.valid) ? 'OK' : 'Ошибка'}</span>
</div>
</div>
`; `;
} else { } else {
hipSection.classList.add('hidden'); hipSection.classList.add('hidden');
} }
// Дисклеймер о метриках
const noteEl = document.getElementById('detailMetricsNote');
if (noteEl) {
noteEl.textContent = result.metrics_note || '';
noteEl.classList.toggle('hidden', !result.metrics_note);
}
// Visualization: исходный снимок и маска костной ткани.
// Загружаются отдельным запросом с include_visualization=true, чтобы не
// передавать base64 в каждом ответе списка.
const vizSection = document.getElementById('detailVizSection');
const imageBox = document.getElementById('detailImage');
const maskBox = document.getElementById('detailMask');
if (result.image) {
vizSection.classList.remove('hidden');
imageBox.innerHTML = `<p class="text-xs text-gray-500 mb-1">Оригинал</p>
<img src="data:image/png;base64,${result.image}" alt="Оригинал" class="w-full rounded-lg border border-gray-200 dark:border-gray-700">`;
maskBox.innerHTML = `<p class="text-xs text-gray-500 mb-1">Маска ROI</p>
<img src="data:image/png;base64,${result.mask}" alt="Маска ROI" class="w-full rounded-lg border border-gray-200 dark:border-gray-700">`;
} else {
vizSection.classList.add('hidden');
imageBox.innerHTML = '<p class="text-xs text-gray-500 mb-1">Оригинал</p>';
maskBox.innerHTML = '<p class="text-xs text-gray-500 mb-1">Маска ROI</p>';
}
// Reason // Reason
const reasonClass = result.quality_class === 0 ? 'bg-green-50 dark:bg-green-900/20 text-green-800 dark:text-green-200 border border-green-200 dark:border-green-800' : const reasonClass = result.quality_class === 0 ? 'bg-green-50 dark:bg-green-900/20 text-green-800 dark:text-green-200 border border-green-200 dark:border-green-800' :
'bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-200 border border-red-200 dark:border-red-800'; 'bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-200 border border-red-200 dark:border-red-800';
const reasons = Array.isArray(result.reasons) && result.reasons.length
? `<ul class="list-disc list-inside space-y-1">${result.reasons.map(r => `<li>${r}</li>`).join('')}</ul>`
: (result.reason || (result.quality_class === 0 ? 'Качество соответствует норме' : 'Нарушение качества'));
document.getElementById('detailReason').innerHTML = ` document.getElementById('detailReason').innerHTML = `
<span class="${reasonClass} px-4 py-3 rounded-lg block"> <div class="${reasonClass} px-4 py-3 rounded-lg block">
<i class="fas fa-${result.quality_class === 0 ? 'check-circle' : 'exclamation-circle'} mr-2"></i> <i class="fas fa-${result.quality_class === 0 ? 'check-circle' : 'exclamation-circle'} mr-2"></i>
${result.reason || (result.quality_class === 0 ? 'Качество соответствует норме' : 'Нарушение качества')} ${reasons}
</span> </div>
`; `;
// Scroll to detail // Scroll to detail
detailSection.scrollIntoView({ behavior: 'smooth' }); detailSection.scrollIntoView({ behavior: 'smooth' });
} }
// Load visualization (original + mask) for the currently open detail panel.
// Kept separate from the main analysis loop: base64 images are large and are
// only needed for the single row the user actually clicked.
let vizRequestId = 0;
async function loadVisualization(rowIndex, file) {
const requestId = ++vizRequestId;
try {
const formData = new FormData();
formData.append('file', file);
const response = await fetch(`${API_BASE}/api/v1/analyze/detailed?include_visualization=true`, {
method: 'POST',
body: formData
});
if (!response.ok) return;
const data = await response.json();
// Ответ мог прийти после клика по другой строке — тогда не применяем.
if (requestId !== vizRequestId) return;
const detail = getFilteredResults()[rowIndex];
if (!detail) return;
detail.image = data.image;
detail.mask = data.mask;
renderVisualization(detail);
} catch (e) {
// Визуализация необязательна: ошибку не показываем, панель остаётся рабочей.
}
}
function renderVisualization(detail) {
const vizSection = document.getElementById('detailVizSection');
const imageBox = document.getElementById('detailImage');
const maskBox = document.getElementById('detailMask');
if (!detail.image) return;
vizSection.classList.remove('hidden');
imageBox.innerHTML = `<p class="text-xs text-gray-500 mb-1">Оригинал</p>
<img src="data:image/png;base64,${detail.image}" alt="Оригинал" class="w-full rounded-lg border border-gray-200 dark:border-gray-700">`;
maskBox.innerHTML = `<p class="text-xs text-gray-500 mb-1">Маска ROI</p>
<img src="data:image/png;base64,${detail.mask}" alt="Маска ROI" class="w-full rounded-lg border border-gray-200 dark:border-gray-700">`;
}
// View detail by table row index // View detail by table row index
function viewDetailByIndex(row) { function viewDetailByIndex(row) {
const idx = parseInt(row.getAttribute('data-index')); const idx = parseInt(row.getAttribute('data-index'));
const filtered = getFilteredResults(); const filtered = getFilteredResults();
if (filtered[idx]) { const detail = filtered[idx];
viewDetail(filtered[idx]); if (!detail) return;
// Подсвечиваем выбранную строку, иначе непонятно, данные какой строки показаны.
document.querySelectorAll('#resultsTable tr').forEach(tr => {
tr.classList.remove('bg-primary-50', 'dark:bg-primary-900/20');
});
row.classList.add('bg-primary-50', 'dark:bg-primary-900/20');
viewDetail(detail);
const file = detail.file;
if (file && !detail.image) {
loadVisualization(idx, file);
} else if (file) {
renderVisualization(detail);
} }
} }

View File

@ -23,6 +23,8 @@ violation_type, processing_status, time_of_processing`.
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import base64
import io
import logging import logging
import sys import sys
import warnings import warnings
@ -84,6 +86,135 @@ class HeuristicSignals:
height: int = 0 height: int = 0
def quality_metrics(arr: np.ndarray, region: str) -> Dict:
"""
Числовые характеристики снимка, показываемые в панели деталей.
ВАЖНО о статусе этих величин. Эвристики из `src/quality/detailed_assessment`
(пороги для «движения», «артефактов», «ROI») на этом датасете насыщены:
например `motion_detected` и `any_detected` оказываются True практически
для любого снимка, а проверка отступов ROI срабатывает для 227 из 252
изображений, потому что маска строится по 90-му перцентилю яркости и
касается границ кадра. Направление признака «резкости» к тому же
противоположно в позвоночнике и в бёдрах, поэтому единый порог для них
неприменим.
Выдавать такие значения как заключение («артефакт: Да») нельзя — это
дезинформирует врача. Поэтому здесь сохраняются числовые измерения, по
которым решение можно проверить, а флаги-вердикты не формируются.
Классификацию выполняет модель (`quality_class`), она проверена отдельно:
внутри областей её ROC-AUC 0.84–0.95, тогда как правило «позвоночник =
нарушение» даёт 0.50 (см. `python -m src.dxa.discriminator`).
"""
norm = (arr - arr.min()) / (arr.max() - arr.min() + 1e-6)
seg = (norm > np.percentile(norm, 90)).astype(np.uint8)
from src.quality.detailed_assessment import (
calculate_blur_fft,
calculate_blur_laplacian,
check_roi_boundaries,
)
laplacian = float(calculate_blur_laplacian(norm))
fft = float(calculate_blur_fft(norm))
roi = check_roi_boundaries(seg, norm.shape)
metrics: Dict = {
"sharpness_laplacian": laplacian,
"sharpness_fft": fft,
"roi": {
"size": roi.get("size"),
"bounding_box": roi.get("bounding_box"),
"valid": roi.get("valid"),
"reason": roi.get("reason"),
"issues": roi.get("issues") or [],
},
# Границы информативного поля: сравнивать значения между снимками
# корректно только при одинаковом размере кадра и одинаковой укладке.
"calibration": {
"frame_width": int(norm.shape[1]),
"frame_height": int(norm.shape[0]),
"mask_percentile": 90,
},
}
if region == "spine":
from src.quality.detailed_assessment import check_spine_completeness
completeness = check_spine_completeness(seg)
# Число «позвонков» и текстовые комментарии эвристики намеренно не
# передаются: на маске по яркости она выделяет десятки объектов
# (например «Позвонков 46») и выдаёт это как заключение. Показывать
# такое врачу нельзя. Остаётся только справочный флаг полноты.
metrics["spine_completeness"] = {
"valid": completeness.get("valid"),
"issue_count": len(completeness.get("issues") or []),
}
elif region in ("hip", "hip_left", "hip_right"):
from src.quality.detailed_assessment import check_hip_completeness, check_hip_rotation
completeness = check_hip_completeness(seg)
rotation = check_hip_rotation(seg, np.stack([norm] * 3, axis=2))
metrics["hip_completeness"] = {
"valid": completeness.get("valid"),
"aspect_ratio": completeness.get("aspect_ratio"),
"issue_count": len(completeness.get("issues") or []),
}
metrics["hip_rotation"] = {
"valid": rotation.get("valid"),
"rotation_angle": rotation.get("rotation_angle"),
"issue_count": len(rotation.get("issues") or []),
}
return metrics
def build_reasons(prediction: "Prediction", metrics: Dict) -> List[str]:
"""
Пояснения к решению для панели деталей.
Опираются только на проверенные величины: анатомическую область и
вероятность класса от модели. Числовые метрики эвристик в формулировки не
выносятся — их достоверность на этом датасете не подтверждена (см.
`quality_metrics`).
"""
region_label = {
"spine": "поясничный отдел позвоночника",
"hip_left": "проксимальный отдел левой бедренной кости",
"hip_right": "проксимальный отдел правой бедренной кости",
"hip": "проксимальный отдел бедренной кости",
}.get(prediction.anatomical_region, "область не определена")
reasons = [
f"Определена область: {region_label} "
f"(уверенность {prediction.region_confidence:.2f})."
]
if prediction.quality_class == 1:
reasons.append(
f"Модель оценила вероятность нарушения как {prediction.prob:.2f} "
f"(порог {prob_from_logit(prediction.threshold):.2f}); "
"изображение требует ручной проверки."
)
else:
reasons.append(
f"Вероятность нарушения {prediction.prob:.2f} ниже рабочего порога; "
"изображение отнесено к качественным."
)
if prediction.region_confidence < 0.5:
reasons.append(
"Анатомическая область определена неуверенно — проверьте укладку "
"и корректность выбранной области."
)
reasons.append(
"Числовые метрики (резкость, границы области) приведены для справки: "
"их пороги на этом наборе данных не калиброваны."
)
return reasons
@dataclass @dataclass
class ImageResult: class ImageResult:
"""Результат обработки одного изображения.""" """Результат обработки одного изображения."""
@ -116,6 +247,7 @@ class Prediction:
violation_type: str violation_type: str
violation_reason: str violation_reason: str
samples: Dict[str, float] samples: Dict[str, float]
metrics: Dict = field(default_factory=dict)
def get_device(prefer: Optional[str] = None) -> str: def get_device(prefer: Optional[str] = None) -> str:
@ -310,13 +442,59 @@ def image_samples(img: np.ndarray) -> Dict[str, float]:
} }
def prob_from_logit(logit: float) -> float:
"""Перевести порог из логитов в вероятность (для отображения человеку)."""
return float(1.0 / (1.0 + np.exp(-logit)))
def mask_png_base64(arr: np.ndarray, outline: bool = True) -> str:
"""
PNG в base64: маска костной ткани (90-й перцентиль яркости).
При ``outline=True`` вместо сплошной маски рисуется её контур на исходном
изображении — так на снимке видно, какая зона попала в маску, и это
читаемее для врача, чем чёрно-белая заливка.
"""
norm = (arr - arr.min()) / (arr.max() - arr.min() + 1e-6)
mask = norm > np.percentile(norm, 90)
if not outline:
image = Image.fromarray((mask * 255).astype(np.uint8))
else:
rgb = np.stack([norm] * 3, axis=-1)
edges = np.zeros_like(mask)
edges[1:-1, 1:-1] = (
(mask[2:, 1:-1] ^ mask[:-2, 1:-1]) | (mask[1:-1, 2:] ^ mask[1:-1, :-2])
)
rgb[edges] = [1.0, 0.2, 0.2]
image = Image.fromarray((np.clip(rgb, 0, 1) * 255).astype(np.uint8))
buffer = io.BytesIO()
image.save(buffer, format="PNG")
return base64.b64encode(buffer.getvalue()).decode("utf-8")
def image_png_base64(arr: np.ndarray) -> str:
"""PNG в base64: исходное изображение, приведённое к диапазону [0, 255]."""
norm = (arr - arr.min()) / (arr.max() - arr.min() + 1e-6)
buffer = io.BytesIO()
Image.fromarray((norm * 255).astype(np.uint8)).save(buffer, format="PNG")
return base64.b64encode(buffer.getvalue()).decode("utf-8")
def predict_from_array(arr: np.ndarray, model: DXAQualityModel, def predict_from_array(arr: np.ndarray, model: DXAQualityModel,
cfg: PreprocessConfig, device: str, threshold: float) -> Prediction: cfg: PreprocessConfig, device: str, threshold: float,
with_metrics: bool = False) -> Prediction:
""" """
Предсказание качества по уже прочитанному массиву пикселей. Предсказание качества по уже прочитанному массиву пикселей.
Общая точка входа для файлового инференса и HTTP API: гарантирует, что Общая точка входа для файлового инференса и HTTP API: гарантирует, что
предобработка и решающее правило совпадают во всех режимах. предобработка и решающее правило совпадают во всех режимах.
Args:
with_metrics: если True, дополнительно считаются эвристические метрики
по критериям качества (структура как у ``generate_quality_report``)
и тип нарушения для снимков с нарушением.
""" """
tensor = torch.from_numpy(preprocess_from_array(arr, cfg)).float().unsqueeze(0).to(device) tensor = torch.from_numpy(preprocess_from_array(arr, cfg)).float().unsqueeze(0).to(device)
@ -332,6 +510,13 @@ def predict_from_array(arr: np.ndarray, model: DXAQualityModel,
quality_class = 1 if logit >= threshold else 0 quality_class = 1 if logit >= threshold else 0
samples = image_samples(arr) samples = image_samples(arr)
metrics: Dict = {}
if with_metrics:
metrics = quality_metrics(arr, region)
# Тип нарушения: модель различает только «годно / нарушение», поэтому для
# снимков с нарушением указывается общая категория. Конкретный тип требует
# разметки типов на уровне снимка, которой в наборе нет.
if quality_class == 1: if quality_class == 1:
violation_type, reason = classify_violation_type(region, {}, samples) violation_type, reason = classify_violation_type(region, {}, samples)
else: else:
@ -347,26 +532,27 @@ def predict_from_array(arr: np.ndarray, model: DXAQualityModel,
violation_type=violation_type, violation_type=violation_type,
violation_reason=reason, violation_reason=reason,
samples=samples, samples=samples,
metrics=metrics,
) )
def predict_from_bytes(dcm_bytes: bytes, model: DXAQualityModel, def predict_from_bytes(dcm_bytes: bytes, model: DXAQualityModel,
cfg: PreprocessConfig, device: str, threshold: float) -> Tuple[Prediction, pydicom.Dataset]: cfg: PreprocessConfig, device: str, threshold: float,
with_metrics: bool = False) -> Tuple[Prediction, pydicom.Dataset]:
""" """
Предсказание по байтам DICOM без записи на диск. Предсказание по байтам DICOM без записи на диск.
API принимает файлы потоком, поэтому чтение идёт через BytesIO — временные API принимает файлы потоком, поэтому чтение идёт через BytesIO — временные
файлы не создаются. файлы не создаются.
""" """
import io as _io ds = pydicom.dcmread(io.BytesIO(dcm_bytes))
ds = pydicom.dcmread(_io.BytesIO(dcm_bytes))
arr = ds.pixel_array.astype(np.float32) arr = ds.pixel_array.astype(np.float32)
if arr.ndim == 3: if arr.ndim == 3:
arr = arr.mean(axis=0) if arr.shape[0] > 1 else arr[0] arr = arr.mean(axis=0) if arr.shape[0] > 1 else arr[0]
if str(getattr(ds, "PhotometricInterpretation", "")).upper() == "MONOCHROME1": if str(getattr(ds, "PhotometricInterpretation", "")).upper() == "MONOCHROME1":
arr = arr.max() - arr arr = arr.max() - arr
return predict_from_array(arr, model, cfg, device, threshold), ds prediction = predict_from_array(arr, model, cfg, device, threshold, with_metrics=with_metrics)
return prediction, ds
def study_uid_for(ds: pydicom.Dataset, dcm_path: Path) -> str: def study_uid_for(ds: pydicom.Dataset, dcm_path: Path) -> str:

View File

@ -30,18 +30,19 @@ from typing import Dict, Optional
import numpy as np import numpy as np
import pydicom import pydicom
import pandas as pd import pandas as pd
from PIL import Image
import io import io
import torch import torch
import base64
from starlette.responses import JSONResponse, FileResponse from starlette.responses import JSONResponse, FileResponse
from src.dxa.inference import ( from src.dxa.inference import (
REASON_BY_TYPE, build_reasons,
image_png_base64,
load_model as load_dxa_checkpoint, load_model as load_dxa_checkpoint,
mask_png_base64,
predict_from_bytes, predict_from_bytes,
) )
from src.dxa.preprocess import load_array_from_bytes
from src.utils.utils import get_device from src.utils.utils import get_device
from src.quality.quality_scorer import convert_to_serializable from src.quality.quality_scorer import convert_to_serializable
@ -127,36 +128,60 @@ def preprocess_dicom(dcm_bytes: bytes, input_size: int = 224):
return tensor.float().unsqueeze(0), ds return tensor.float().unsqueeze(0), ds
def predict_upload(dcm_bytes: bytes): def predict_upload(dcm_bytes: bytes, with_metrics: bool = False):
""" """
Единая точка предсказания для HTTP-эндпоинтов. Единая точка предсказания для HTTP-эндпоинтов.
Возвращает (prediction, dataset_metadata) или (None, None), если модель не Возвращает (prediction, dataset_metadata) или (None, None), если модель не
загружена. Использование общего пути гарантирует, что предобработка и загружена. Использование общего пути гарантирует, что предобработка и
решающий порог совпадают с CLI-инференсом. решающий порог совпадают с CLI-инференсом.
Args:
with_metrics: считать ли эвристические метрики по критериям качества.
Нужны только детальному эндпоинту; для остальных это лишние
вычисления.
""" """
model = load_model() model = load_model()
if model is None: if model is None:
return None, None return None, None
return predict_from_bytes(dcm_bytes, model, dxa_preprocess, device, dxa_threshold) return predict_from_bytes(
dcm_bytes, model, dxa_preprocess, device, dxa_threshold, with_metrics=with_metrics
)
def prediction_to_result(prediction, ds, filename: Optional[str] = None) -> dict: def prediction_to_result(prediction, ds, filename: Optional[str] = None) -> dict:
"""Привести предсказание к формату ответа API.""" """
Привести предсказание к формату ответа API.
Структура намеренно повторяет форму `generate_quality_report`, включая
``confidence`` как вероятность класса (0..1), ``confidence_per_class``,
``metrics`` и ``severity``. Именно эти поля читает веб-интерфейс, и раньше
он молча рисовал прочерки, потому что ключи не совпадали.
"""
prob = float(prediction.prob)
is_violation = prediction.quality_class == 1
result = { result = {
"study_uid": str(getattr(ds, "StudyInstanceUID", "") or ""), "study_uid": str(getattr(ds, "StudyInstanceUID", "") or ""),
"image_uid": str(getattr(ds, "SOPInstanceUID", "") or ""), "image_uid": str(getattr(ds, "SOPInstanceUID", "") or ""),
"anatomical_region": prediction.anatomical_region, "anatomical_region": prediction.anatomical_region,
"quality_class": prediction.quality_class, "quality_class": prediction.quality_class,
"quality_label": "OK" if prediction.quality_class == 0 else "Violation detected", "quality_label": "Есть нарушение качества" if is_violation else "Качественное изображение",
"violation_type": prediction.violation_type, "violation_type": prediction.violation_type,
"reason": prediction.violation_reason, "reason": prediction.violation_reason,
"confidence": round(prediction.prob, 4), "confidence": round(prob, 4),
"confidence_per_class": {
"correct": round(1.0 - prob, 4),
"violation": round(prob, 4),
},
"threshold_probability": round( "threshold_probability": round(
float(torch.sigmoid(torch.tensor(prediction.threshold))), 4 float(torch.sigmoid(torch.tensor(prediction.threshold))), 4
), ),
"region_confidence": round(prediction.region_confidence, 4), "region_confidence": round(prediction.region_confidence, 4),
"metrics": prediction.samples, "overall_quality": "POOR" if is_violation else "GOOD",
"severity": "HIGH" if is_violation else "NONE",
"metrics": prediction.metrics,
"samples": prediction.samples,
"processing_status": "Success", "processing_status": "Success",
} }
if filename: if filename:
@ -239,19 +264,18 @@ async def analyze_dicom_detailed(
""" """
Детальный анализ DICOM файла с комплексной оценкой качества. Детальный анализ DICOM файла с комплексной оценкой качества.
Это основной эндпоинт для получения полной информации о качестве исследования. Это основной эндпоинт для веб-интерфейса. Возвращает:
В отличие от базового /analyze, возвращает: - violation_type и reason: тип и объяснение нарушения
- violation_type: конкретный тип нарушения - confidence_per_class: вероятности классов
- reason: человекочитаемое объяснение - metrics: метрики по критериям (движение, артефакты, ROI, позиция,
- confidence_per_class: вероятности для каждого класса полнота области) в структуре `generate_quality_report`
- view_quality: полный/частичный вид - view_quality, overall_quality, severity
- metrics: детальные метрики по каждому критерию - image и mask (base64 PNG) при include_visualization=true
Returns: Returns:
JSON с детальным отчетом о качестве исследования JSON с детальным отчетом о качестве исследования
""" """
try: try:
# Load model
model = load_model() model = load_model()
if model is None: if model is None:
return JSONResponse( return JSONResponse(
@ -260,23 +284,34 @@ async def analyze_dicom_detailed(
) )
dcm_bytes = await file.read() dcm_bytes = await file.read()
prediction, ds = predict_upload(dcm_bytes) prediction, ds = predict_upload(dcm_bytes, with_metrics=True)
if prediction is None: if prediction is None:
return JSONResponse(status_code=500, content={"error": "Model not loaded"}) return JSONResponse(status_code=500, content={"error": "Model not loaded"})
result = prediction_to_result(prediction, ds, file.filename) result = prediction_to_result(prediction, ds, file.filename)
result["quality_label"] = ( metrics = prediction.metrics
"Качественное изображение" if prediction.quality_class == 0 else "Есть нарушение качества"
)
result["view_quality"] = _view_quality(ds)
result["overall_quality"] = "GOOD" if prediction.quality_class == 0 else "POOR"
result["reasons"] = _build_reasons(prediction)
# Дополнительный функционал: схематичная маска костной ткани # Детализированные структуры выносим на верхний уровень: интерфейс
# (90-й перцентиль яркости) как основа для визуализации нарушения. # читает их отдельно от metrics (панели «Позвоночник» и «Бедро»).
for key in ("spine_completeness", "hip_completeness", "hip_rotation"):
result[key] = metrics.get(key, {})
result["view_quality"] = _view_quality(prediction, metrics)
result["reasons"] = build_reasons(prediction, metrics)
# Дисклеймер о статусе эвристических метрик: они приведены как числовые
# измерения, а не как заключение (пороги не калиброваны на этих данных).
result["metrics_note"] = (
"Числовые метрики приведены для справки; пороги не калиброваны на этом "
"наборе данных и не являются заключением. Классификацию выполняет модель."
)
# Визуализация: исходный снимок и маска костной ткани.
if include_visualization: if include_visualization:
result["mask"] = _mask_base64(dcm_bytes) arr = load_array_from_bytes(dcm_bytes)
result["image"] = image_png_base64(arr)
result["mask"] = mask_png_base64(arr)
else: else:
result["image"] = None
result["mask"] = None result["mask"] = None
return convert_to_serializable(result) return convert_to_serializable(result)
@ -293,43 +328,29 @@ async def analyze_dicom_detailed(
) )
def _view_quality(ds) -> str: def _view_quality(prediction, metrics: Dict) -> str:
"""Полнота видимости области: грубая оценка по числу строк изображения.""" """
try: Справочная оценка полноты области по маске яркости.
rows = int(ds.Rows)
except Exception: Полнота проверяется на маске, построенной по 90-му перцентилю яркости.
Такая маска захватывает контур тела и касается границ кадра, поэтому
проверка «позвонки/бедро не обрезаны» срабатывает почти всегда, и
значение ``partial`` не означает реальную обрезку. Для достоверной оценки
нужна настоящая сегментация костной ткани, которой в решении нет.
Значение оставлено как ориентир, но интерфейс помечает его как
справочное, чтобы оно не читалось как заключение.
"""
if prediction.anatomical_region == "spine":
completeness = metrics.get("spine_completeness") or {}
elif prediction.anatomical_region in ("hip", "hip_left", "hip_right"):
completeness = metrics.get("hip_completeness") or {}
else:
return "unknown" return "unknown"
# Снимки бедра в датасете компактнее (≈235–290 строк), позвоночника — выше.
return "full" if rows >= 260 else "partial"
if not completeness:
def _mask_base64(dcm_bytes: bytes) -> str: return "unknown"
"""PNG-маска костной ткани (90-й перцентиль яркости) в base64.""" return "full" if completeness.get("valid") else "partial"
ds = pydicom.dcmread(io.BytesIO(dcm_bytes))
img = ds.pixel_array.astype(np.float32)
if img.ndim == 3:
img = img.mean(axis=0) if img.shape[0] > 1 else img[0]
norm = (img - img.min()) / (img.max() - img.min() + 1e-8)
mask = (norm > np.percentile(norm, 90)).astype(np.uint8) * 255
buffer = io.BytesIO()
Image.fromarray(mask).save(buffer, format="PNG")
return base64.b64encode(buffer.getvalue()).decode("utf-8")
def _build_reasons(prediction) -> list:
"""Пояснения к решению в форме, принятой в методике оценки качества."""
if prediction.quality_class == 0:
return [
f"Область ({prediction.anatomical_region}) видна достаточно полно.",
"Значимых артефактов и выраженного размытия не выявлено.",
]
reasons = [
f"Выявлено нарушение: {REASON_BY_TYPE.get(prediction.violation_type, 'нарушение качества')}.",
f"Область исследования: {prediction.anatomical_region}.",
"Требуется ручная проверка перед дальнейшим анализом.",
]
return reasons
@app.post("/api/v1/batch") @app.post("/api/v1/batch")