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'; \
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 \
PYTHONPATH=/app \
DXA_MODEL_PATH=/app/models/dxa_model.pth

58
QWEN.md
View File

@ -54,21 +54,26 @@ src/dxa/
| Порог по логиту, подбор по F1 | При 15 % нарушений порог 0.5 даёт нулевой recall; вероятности насыщаются |
| Аугментация выключена по умолчанию | Яркость и положение сами являются признаками качества: AUC 0.87 → 0.56 |
| Область по ширине кадра | Позвоночник 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)
- PR-AUC по 5 разбиениям: 0.48 ± 0.17 (базовый уровень при 15 % нарушений — 0.15)
- F1 по 5 разбиениям: 0.54 ± 0.11 (порог подобран на той же валидации → смещено вверх)
- ROC-AUC, 5-фолдовая CV линейного зонда: **0.81 ± 0.08**
- Контрольная задача «позвоночник / бедро»: AUC 1.00 (проверка пайплайна)
- Нулевая гипотеза (перестановка меток): AUC 0.64
Не является ли модель просто детектором анатомии (в позвоночнике ~29 % нарушений
против ~4–5 % у бёдер, а область почти однозначно определяется по ширине кадра):
Разбивка по областям обязательна: в позвоночнике ~29 % нарушений против ~4–5 %
у бёдер, а область почти однозначно определяется по ширине кадра, поэтому общий
AUC частично отражает различение области.
```bash
python -m src.dxa.discriminator --model-path models/dxa_model.pth
```
---
Результат на чекпоинте `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/`)
@ -141,12 +146,35 @@ API и CLI используют один код предсказания (`predi
```bash
./run.sh test
python -m pytest tests/ -q # 65 тестов
python -m pytest tests/ -q # 79 тестов
```
`tests/test_labels.py` — разбор имён, склейка дублей, отсутствие утечки при
разбиении. `tests/test_preprocess_and_model.py` — предобработка, метрики,
подбор порога, контракт модели, BatchNorm при заморозке, roundtrip чекпоинта.
- `tests/test_labels.py` — разбор имён, склейка дублей, отсутствие утечки при
разбиении.
- `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 на валидации |
| Тип нарушения | Эвристики по изображению: размытие/движение, посторонние включения, геометрия ROI |
| Тип нарушения | Общая категория для снимков с нарушением; детальный тип требует разметки типов на уровне снимка |
| Отчёт | XLSX/CSV со столбцами из требований; опционально zip с визуализацией зоны интереса |
| 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 в файлах нет,
поэтому корректность нанесённых областей измерения нельзя проверить прямым
сравнением — оценивается только геометрия видимой зоны.
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 name="viewport" content="width=device-width, initial-scale=1.0">
<title>DXA Quality Assessment</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<!-- Пустая favicon: без неё браузер запрашивает /favicon.ico и получает 404. -->
<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>
tailwind.config = {
darkMode: 'class',
@ -177,7 +182,7 @@
</div>
<div>
<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>
@ -187,7 +192,7 @@
<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">
<i class="fas fa-file-excel"></i>
Скачать XLSX
Скачать CSV
</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">
<i class="fas fa-trash"></i>
@ -303,42 +308,22 @@
</div>
</div>
<!-- Motion Metrics -->
<!-- Image measurements -->
<div class="md:col-span-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>
<div id="detailMotion" class="grid grid-cols-2 md:grid-cols-4 gap-3">
<!-- Populated by JS -->
</div>
</div>
<!-- Artifacts -->
<!-- ROI -->
<div class="md:col-span-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>
<div id="detailArtifacts" class="grid grid-cols-2 md:grid-cols-5 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">
<div id="detailArtifacts" class="grid grid-cols-2 md:grid-cols-3 gap-3">
<!-- Populated by JS -->
</div>
</div>
@ -364,6 +349,11 @@
</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 -->
<div class="mt-6">
<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();
results.push({
filename: file.name,
// File сохраняем, чтобы подгрузить визуализацию по клику без
// повторного выбора файлов пользователем.
file: file,
...data
});
} catch (e) {
results.push({
filename: file.name,
file: file,
quality_class: -1,
quality_label: 'Error',
processing_status: `Failure: ${e.message}`,
@ -316,25 +320,40 @@ document.getElementById('exportXlsx')?.addEventListener('click', async () => {
btn.disabled = true;
try {
// For proper XLSX export, we'd need to re-upload files
// For now, export as CSV which Excel can open
const headers = ['filename', 'study_uid', 'image_uid', 'anatomical_region', 'quality_class', 'quality_label', 'confidence', 'processing_status'];
const csvContent = [
headers.join(','),
...results.map(r => headers.map(h => {
let val = r[h] || '';
if (typeof val === 'string' && val.includes(',')) {
val = `"${val}"`;
}
return val;
}).join(','))
// Основные столбцы соответствуют формату задания; пояснения — справа.
const headers = ['path_to_study', 'study_uid', 'image_uid', 'anatomical_region',
'quality_class', 'violation_type', 'processing_status',
'time_of_processing', 'confidence', 'violation_reason'];
const rows = results.map(r => ({
path_to_study: `upload://${r.filename || 'unknown'}`,
study_uid: r.study_uid || '',
image_uid: r.image_uid || '',
anatomical_region: r.anatomical_region || 'unknown',
quality_class: r.quality_class,
violation_type: r.violation_type || '',
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');
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = `dxa_results_${new Date().toISOString().slice(0, 10)}.csv`;
link.click();
URL.revokeObjectURL(link.href);
} catch (e) {
showError('Ошибка экспорта: ' + e.message);
} finally {
@ -356,42 +375,56 @@ document.getElementById('closeDetail')?.addEventListener('click', () => {
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
async function viewDetail(result) {
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
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' :
'bg-gray-100 text-gray-800';
const qualityLabel = result.quality_class === 0 ? 'OK' : result.quality_class === 1 ? 'Нарушение' : 'Ошибка';
'bg-gray-100 text-gray-800 dark:text-gray-300';
const qualityLabel = result.quality_class === 0 ? 'OK' :
result.quality_class === 1 ? 'Нарушение' : 'Ошибка';
document.getElementById('detailBadge').innerHTML = `
<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>
`;
// UIDs
// UIDs — длинные UID переносим, иначе они вылезают за панель
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"><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">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 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
const viewLabels = { 'full': 'Полный', 'partial': 'Частичный', 'unknown': 'Неизвестно' };
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">${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
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">${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
const confCorrect = result.confidence_per_class?.correct || 0;
const confViolation = result.confidence_per_class?.violation || 0;
// Confidence per class (computed on the server)
const confCorrect = result.confidence_per_class?.correct ?? (1 - (result.confidence || 0));
const confViolation = result.confidence_per_class?.violation ?? (result.confidence || 0);
document.getElementById('detailConfidence').innerHTML = `
<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="bg-green-500 h-2 rounded-full" style="width: ${confCorrect * 100}%"></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="bg-red-500 h-2 rounded-full" style="width: ${confViolation * 100}%"></div>
</div>
</div>
`;
// Overall quality
// Overall quality + severity
const overallClass = result.overall_quality === 'GOOD' ? 'text-green-600' :
result.overall_quality === 'FAIR' ? 'text-yellow-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 === '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 = `
<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 motion = metrics.motion || {};
const blurLaplacian = motion.blur_laplacian?.toFixed(4) || '—';
const blurFFT = motion.blur_fft?.toFixed(4) || '—';
document.getElementById('detailMotion').innerHTML = `
<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.motion_detected ? 'bg-red-500' : 'bg-green-500'}"></div>
<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>
${card('Резкость (Laplacian)', `<span class="font-mono">${num(metrics.sharpness_laplacian, 6)}</span>`)}
${card('Резкость (FFT)', `<span class="font-mono">${num(metrics.sharpness_fft, 6)}</span>`)}
${card('Кадр', metrics.calibration ? `${metrics.calibration.frame_width}×${metrics.calibration.frame_height}` : '—')}
${card('Порог маски', metrics.calibration ? `p${metrics.calibration.mask_percentile}` : '—')}
`;
// 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 = `
<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.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>
${card('Размер зоны', roi.size ? `${roi.size.width}×${roi.size.height}` : '—')}
${card('BBox', `<span class="font-mono text-xs">${bbox}</span>`)}
${card('Замечаний', roi.issues?.length || 0)}
`;
// 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 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');
document.getElementById('detailSpine').innerHTML = `
<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-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>
${card('Полнота (справ.)', fmtYesNo(spine.valid))}
${card('Замечаний (справ.)', spine.issue_count ?? 0)}
`;
} else {
spineSection.classList.add('hidden');
}
// Hip completeness
// Полнота и поворот бедра — справочная оценка
const hip = result.hip_completeness || {};
const hipRotation = result.hip_rotation || {};
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');
const angle = typeof hipRotation.rotation_angle === 'number'
? hipRotation.rotation_angle.toFixed(1) + '°' : '—';
document.getElementById('detailHip').innerHTML = `
<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.is_complete ? 'bg-green-500' : 'bg-red-500'}"></div>
<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>
${card('Полнота (справ.)', fmtYesNo(hip.valid))}
${card('Соотношение сторон', `<span class="font-mono">${num(hip.aspect_ratio, 2)}</span>`)}
${card('Угол поворота', `<span class="font-mono">${angle}</span>`)}
${card('Замечаний (справ.)', (hip.issue_count || 0) + (hipRotation.issue_count || 0))}
`;
} else {
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
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';
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 = `
<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>
${result.reason || (result.quality_class === 0 ? 'Качество соответствует норме' : 'Нарушение качества')}
</span>
${reasons}
</div>
`;
// Scroll to detail
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
function viewDetailByIndex(row) {
const idx = parseInt(row.getAttribute('data-index'));
const filtered = getFilteredResults();
if (filtered[idx]) {
viewDetail(filtered[idx]);
const detail = 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
import argparse
import base64
import io
import logging
import sys
import warnings
@ -84,6 +86,135 @@ class HeuristicSignals:
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
class ImageResult:
"""Результат обработки одного изображения."""
@ -116,6 +247,7 @@ class Prediction:
violation_type: str
violation_reason: str
samples: Dict[str, float]
metrics: Dict = field(default_factory=dict)
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,
cfg: PreprocessConfig, device: str, threshold: float) -> Prediction:
cfg: PreprocessConfig, device: str, threshold: float,
with_metrics: bool = False) -> Prediction:
"""
Предсказание качества по уже прочитанному массиву пикселей.
Общая точка входа для файлового инференса и HTTP API: гарантирует, что
предобработка и решающее правило совпадают во всех режимах.
Args:
with_metrics: если True, дополнительно считаются эвристические метрики
по критериям качества (структура как у ``generate_quality_report``)
и тип нарушения для снимков с нарушением.
"""
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
samples = image_samples(arr)
metrics: Dict = {}
if with_metrics:
metrics = quality_metrics(arr, region)
# Тип нарушения: модель различает только «годно / нарушение», поэтому для
# снимков с нарушением указывается общая категория. Конкретный тип требует
# разметки типов на уровне снимка, которой в наборе нет.
if quality_class == 1:
violation_type, reason = classify_violation_type(region, {}, samples)
else:
@ -347,26 +532,27 @@ def predict_from_array(arr: np.ndarray, model: DXAQualityModel,
violation_type=violation_type,
violation_reason=reason,
samples=samples,
metrics=metrics,
)
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 без записи на диск.
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)
if arr.ndim == 3:
arr = arr.mean(axis=0) if arr.shape[0] > 1 else arr[0]
if str(getattr(ds, "PhotometricInterpretation", "")).upper() == "MONOCHROME1":
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:

View File

@ -30,18 +30,19 @@ from typing import Dict, Optional
import numpy as np
import pydicom
import pandas as pd
from PIL import Image
import io
import torch
import base64
from starlette.responses import JSONResponse, FileResponse
from src.dxa.inference import (
REASON_BY_TYPE,
build_reasons,
image_png_base64,
load_model as load_dxa_checkpoint,
mask_png_base64,
predict_from_bytes,
)
from src.dxa.preprocess import load_array_from_bytes
from src.utils.utils import get_device
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
def predict_upload(dcm_bytes: bytes):
def predict_upload(dcm_bytes: bytes, with_metrics: bool = False):
"""
Единая точка предсказания для HTTP-эндпоинтов.
Возвращает (prediction, dataset_metadata) или (None, None), если модель не
загружена. Использование общего пути гарантирует, что предобработка и
решающий порог совпадают с CLI-инференсом.
Args:
with_metrics: считать ли эвристические метрики по критериям качества.
Нужны только детальному эндпоинту; для остальных это лишние
вычисления.
"""
model = load_model()
if model is 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:
"""Привести предсказание к формату ответа API."""
"""
Привести предсказание к формату ответа API.
Структура намеренно повторяет форму `generate_quality_report`, включая
``confidence`` как вероятность класса (0..1), ``confidence_per_class``,
``metrics`` и ``severity``. Именно эти поля читает веб-интерфейс, и раньше
он молча рисовал прочерки, потому что ключи не совпадали.
"""
prob = float(prediction.prob)
is_violation = prediction.quality_class == 1
result = {
"study_uid": str(getattr(ds, "StudyInstanceUID", "") or ""),
"image_uid": str(getattr(ds, "SOPInstanceUID", "") or ""),
"anatomical_region": prediction.anatomical_region,
"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,
"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(
float(torch.sigmoid(torch.tensor(prediction.threshold))), 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",
}
if filename:
@ -238,20 +263,19 @@ async def analyze_dicom_detailed(
):
"""
Детальный анализ DICOM файла с комплексной оценкой качества.
Это основной эндпоинт для получения полной информации о качестве исследования.
В отличие от базового /analyze, возвращает:
- violation_type: конкретный тип нарушения
- reason: человекочитаемое объяснение
- confidence_per_class: вероятности для каждого класса
- view_quality: полный/частичный вид
- metrics: детальные метрики по каждому критерию
Это основной эндпоинт для веб-интерфейса. Возвращает:
- violation_type и reason: тип и объяснение нарушения
- confidence_per_class: вероятности классов
- metrics: метрики по критериям (движение, артефакты, ROI, позиция,
полнота области) в структуре `generate_quality_report`
- view_quality, overall_quality, severity
- image и mask (base64 PNG) при include_visualization=true
Returns:
JSON с детальным отчетом о качестве исследования
"""
try:
# Load model
model = load_model()
if model is None:
return JSONResponse(
@ -260,23 +284,34 @@ async def analyze_dicom_detailed(
)
dcm_bytes = await file.read()
prediction, ds = predict_upload(dcm_bytes)
prediction, ds = predict_upload(dcm_bytes, with_metrics=True)
if prediction is None:
return JSONResponse(status_code=500, content={"error": "Model not loaded"})
result = prediction_to_result(prediction, ds, file.filename)
result["quality_label"] = (
"Качественное изображение" 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)
metrics = prediction.metrics
# Дополнительный функционал: схематичная маска костной ткани
# (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:
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:
result["image"] = None
result["mask"] = None
return convert_to_serializable(result)
@ -293,43 +328,29 @@ async def analyze_dicom_detailed(
)
def _view_quality(ds) -> str:
"""Полнота видимости области: грубая оценка по числу строк изображения."""
try:
rows = int(ds.Rows)
except Exception:
def _view_quality(prediction, metrics: Dict) -> str:
"""
Справочная оценка полноты области по маске яркости.
Полнота проверяется на маске, построенной по 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"
# Снимки бедра в датасете компактнее (≈235–290 строк), позвоночника — выше.
return "full" if rows >= 260 else "partial"
def _mask_base64(dcm_bytes: bytes) -> str:
"""PNG-маска костной ткани (90-й перцентиль яркости) в base64."""
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
if not completeness:
return "unknown"
return "full" if completeness.get("valid") else "partial"
@app.post("/api/v1/batch")