develop - hack_2026
This commit is contained in:
parent
5a8dbb43a5
commit
8d93298877
11
Dockerfile
11
Dockerfile
|
|
@ -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
58
QWEN.md
|
|
@ -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).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
37
README.md
37
README.md
|
|
@ -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.
|
||||||
|
|
||||||
## План доработки
|
## План доработки
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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,12 +375,8 @@ document.getElementById('closeDetail')?.addEventListener('click', () => {
|
||||||
detailSection.classList.add('hidden');
|
detailSection.classList.add('hidden');
|
||||||
});
|
});
|
||||||
|
|
||||||
// View detailed analysis for a file
|
// Region label helper
|
||||||
async function viewDetail(result) {
|
function getRegionLabel(region) {
|
||||||
detailSection.classList.remove('hidden');
|
|
||||||
|
|
||||||
// Region label helper
|
|
||||||
const getRegionLabel = (region) => {
|
|
||||||
const labels = {
|
const labels = {
|
||||||
'spine': 'Позвоночник',
|
'spine': 'Позвоночник',
|
||||||
'hip_left': 'Бедро левое',
|
'hip_left': 'Бедро левое',
|
||||||
|
|
@ -370,11 +385,12 @@ async function viewDetail(result) {
|
||||||
'unknown': 'Неизвестно'
|
'unknown': 'Неизвестно'
|
||||||
};
|
};
|
||||||
return labels[region] || region || '—';
|
return labels[region] || region || '—';
|
||||||
};
|
}
|
||||||
|
|
||||||
// Violation type label
|
// Violation type label
|
||||||
const getViolationLabel = (type) => {
|
function getViolationLabel(type) {
|
||||||
const labels = {
|
const labels = {
|
||||||
|
'': 'Корректно',
|
||||||
'correct': 'Корректно',
|
'correct': 'Корректно',
|
||||||
'position_error': 'Ошибка позиционирования',
|
'position_error': 'Ошибка позиционирования',
|
||||||
'artifact_motion': 'Артефакт движения',
|
'artifact_motion': 'Артефакт движения',
|
||||||
|
|
@ -382,16 +398,33 @@ async function viewDetail(result) {
|
||||||
'labeling_error': 'Ошибка маркировки',
|
'labeling_error': 'Ошибка маркировки',
|
||||||
'incomplete_view': 'Неполный вид',
|
'incomplete_view': 'Неполный вид',
|
||||||
'roi_error': 'Ошибка ROI',
|
'roi_error': 'Ошибка ROI',
|
||||||
'rotation': 'Неправильный поворот'
|
'rotation': 'Неправильный поворот',
|
||||||
|
'quality_violation_detected': 'Нарушение качества'
|
||||||
};
|
};
|
||||||
return labels[type] || type || '—';
|
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');
|
||||||
|
|
||||||
// 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
145
src/main.py
145
src/main.py
|
|
@ -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")
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue