bone_2026/src/api/static/index.html

875 lines
28 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

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

<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Bone Quality Assessment - Анализ качества костной ткани</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
padding: 20px;
}
.container {
background: white;
border-radius: 24px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
padding: 40px;
max-width: 1000px;
width: 100%;
}
h1 {
color: #2d3748;
font-size: 28px;
margin-bottom: 8px;
display: flex;
align-items: center;
gap: 12px;
}
.subtitle {
color: #718096;
font-size: 16px;
margin-bottom: 30px;
}
.upload-area {
border: 3px dashed #e2e8f0;
border-radius: 16px;
padding: 50px 20px;
text-align: center;
transition: all 0.3s ease;
cursor: pointer;
background: #f7fafc;
position: relative;
}
.upload-area:hover {
border-color: #667eea;
background: #edf2f7;
}
.upload-area.drag-over {
border-color: #667eea;
background: #eef2ff;
transform: scale(1.02);
}
.upload-area .icon {
font-size: 48px;
margin-bottom: 16px;
display: block;
}
.upload-area h3 {
color: #2d3748;
font-size: 20px;
margin-bottom: 8px;
}
.upload-area p {
color: #718096;
font-size: 14px;
}
.upload-area input[type="file"] {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
opacity: 0;
cursor: pointer;
}
#previewContainer {
display: none;
margin-top: 24px;
padding: 20px;
background: #f7fafc;
border-radius: 12px;
text-align: center;
}
#previewContainer img {
max-width: 100%;
max-height: 400px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
#fileName {
margin-top: 12px;
color: #2d3748;
font-weight: 500;
}
.btn-analyze {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
padding: 14px 40px;
border-radius: 12px;
font-size: 18px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
margin-top: 20px;
width: 100%;
}
.btn-analyze:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(102, 126, 234, 0.4);
}
.btn-analyze:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-analyze.loading {
position: relative;
color: transparent;
}
.btn-analyze.loading::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 24px;
height: 24px;
margin: -12px 0 0 -12px;
border: 3px solid rgba(255, 255, 255, 0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* Results */
#results {
display: none;
margin-top: 30px;
animation: fadeIn 0.5s ease;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.result-card {
background: #f7fafc;
border-radius: 16px;
padding: 24px;
margin-bottom: 16px;
}
.result-card .label {
color: #718096;
font-size: 14px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 6px;
}
.result-card .value {
font-size: 20px;
font-weight: 700;
color: #2d3748;
}
.quality-badge {
display: inline-block;
padding: 6px 20px;
border-radius: 20px;
font-weight: 700;
font-size: 18px;
}
.quality-GOOD {
background: #c6f6d5;
color: #22543d;
}
.quality-WARNING {
background: #fefcbf;
color: #744210;
}
.quality-POOR {
background: #fed7d7;
color: #9b2c2c;
}
.severity-LOW {
background: #c6f6d5;
color: #22543d;
}
.severity-MEDIUM {
background: #fefcbf;
color: #744210;
}
.severity-HIGH {
background: #fed7d7;
color: #9b2c2c;
}
.issue-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 0;
border-bottom: 1px solid #e2e8f0;
}
.issue-item:last-child {
border-bottom: none;
}
.issue-badge {
padding: 2px 12px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
}
.issue-ARTIFACT {
background: #fefcbf;
color: #744210;
}
.issue-POSITION_ERROR {
background: #fed7d7;
color: #9b2c2c;
}
.issue-LOW_CONTRAST {
background: #bee3f8;
color: #2a69ac;
}
.grid-2 {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-top: 16px;
}
.validation-item {
background: white;
padding: 16px;
border-radius: 12px;
border-left: 4px solid #667eea;
}
.validation-item .label {
font-size: 13px;
color: #718096;
font-weight: 600;
}
.validation-item .value {
font-size: 14px;
color: #2d3748;
margin-top: 4px;
word-break: break-all;
}
.validation-item .detail {
font-size: 12px;
color: #718096;
margin-top: 4px;
}
.confidence-bar {
width: 100%;
height: 8px;
background: #e2e8f0;
border-radius: 4px;
overflow: hidden;
margin-top: 8px;
}
.confidence-bar .fill {
height: 100%;
background: linear-gradient(90deg, #667eea, #764ba2);
border-radius: 4px;
transition: width 0.8s ease;
}
.error-message {
background: #fed7d7;
color: #9b2c2c;
padding: 16px;
border-radius: 12px;
margin-top: 16px;
display: none;
}
.file-info {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
margin-top: 12px;
font-size: 14px;
color: #4a5568;
}
/* Mask visualization */
#maskContainer {
display: none;
margin-top: 24px;
padding: 20px;
background: #f7fafc;
border-radius: 12px;
text-align: center;
}
#maskContainer h3 {
color: #2d3748;
margin-bottom: 12px;
}
.mask-wrapper {
position: relative;
display: inline-block;
width: 100%;
max-width: 100%;
overflow: hidden;
border-radius: 8px;
background: #1a202c;
}
#maskImage {
display: block;
width: 100%;
height: auto;
border-radius: 8px;
}
#maskCanvas {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
border-radius: 8px;
}
.mask-controls {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
margin: 16px 0;
flex-wrap: wrap;
}
.mask-controls label {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
color: #4a5568;
cursor: pointer;
}
.mask-controls input[type="range"] {
width: 150px;
cursor: pointer;
}
.btn-toggle {
background: #edf2f7;
border: 1px solid #e2e8f0;
padding: 8px 16px;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
color: #4a5568;
transition: all 0.3s ease;
}
.btn-toggle:hover {
background: #e2e8f0;
border-color: #667eea;
}
.btn-toggle.active {
background: #667eea;
color: white;
border-color: #667eea;
}
.mask-stats {
display: flex;
gap: 24px;
justify-content: center;
flex-wrap: wrap;
font-size: 13px;
color: #718096;
margin-top: 12px;
}
.mask-stats span {
background: white;
padding: 4px 12px;
border-radius: 4px;
}
@media (max-width: 640px) {
.container {
padding: 20px;
}
.grid-2 {
grid-template-columns: 1fr;
}
h1 {
font-size: 22px;
}
.mask-controls {
flex-direction: column;
}
.mask-controls input[type="range"] {
width: 100%;
}
}
</style>
</head>
<body>
<div class="container">
<h1>🦴 Bone Quality Assessment</h1>
<p class="subtitle">Загрузите медицинское изображение для анализа качества костной ткани</p>
<!-- Upload Area -->
<div class="upload-area" id="uploadArea">
<span class="icon">📤</span>
<h3>Перетащите изображение сюда</h3>
<p>или кликните для выбора файла</p>
<p style="font-size: 12px; color: #a0aec0; margin-top: 8px;">Поддерживаются: PNG, JPG, JPEG</p>
<input type="file" id="fileInput" accept=".png,.jpg,.jpeg">
</div>
<!-- Preview -->
<div id="previewContainer">
<img id="previewImage" src="#" alt="Preview">
<div id="fileName"></div>
<div class="file-info">
<span id="fileSize"></span>
<span>•</span>
<span id="fileType"></span>
</div>
</div>
<button class="btn-analyze" id="analyzeBtn" disabled>🔍 Анализировать</button>
<!-- Error -->
<div class="error-message" id="errorMessage"></div>
<!-- Mask Visualization -->
<div id="maskContainer">
<h3>🧩 Визуализация сегментации</h3>
<div class="mask-controls">
<label>
<span>Прозрачность:</span>
<input type="range" id="maskOpacity" min="0" max="100" value="50">
<span id="opacityLabel">50%</span>
</label>
<button class="btn-toggle active" id="toggleMaskBtn">Скрыть маску</button>
<button class="btn-toggle" id="toggleOriginalBtn">Показать оригинал</button>
</div>
<div class="mask-wrapper">
<img id="maskImage" src="#" alt="Mask Visualization">
<canvas id="maskCanvas"></canvas>
</div>
<div class="mask-stats" id="maskStats">
<span>🧊 Объектов: <strong id="numObjects">-</strong></span>
<span>📏 Размер: <strong id="objectSize">-</strong></span>
<span>🎯 Уверенность: <strong id="maskConfidence">-</strong></span>
</div>
</div>
<!-- Results -->
<div id="results">
<div class="result-card">
<div class="label">Общее качество</div>
<div id="overallQuality"></div>
</div>
<div class="result-card">
<div class="label">Серьезность проблемы</div>
<div id="severityDisplay"></div>
</div>
<div class="result-card">
<div class="label">Выявленные проблемы</div>
<div id="issuesList"></div>
</div>
<div class="grid-2">
<div class="validation-item">
<div class="label">📍 Позиционирование</div>
<div class="value" id="positionValidation"></div>
<div class="detail" id="positionDetails"></div>
</div>
<div class="validation-item">
<div class="label">🔬 Артефакты</div>
<div class="value" id="artifactValidation"></div>
<div class="detail" id="artifactDetails"></div>
</div>
</div>
<div class="result-card">
<div class="label">Уверенность модели</div>
<div id="confidenceDisplay"></div>
<div class="confidence-bar">
<div class="fill" id="confidenceFill" style="width: 0%"></div>
</div>
</div>
<div class="result-card" id="metricsCard" style="display: none;">
<div class="label">Детальные метрики</div>
<div id="metricsDisplay" style="font-size: 14px; color: #4a5568; max-height: 300px; overflow-y: auto;"></div>
</div>
</div>
</div>
<script>
// Конфигурация
const API_URL = 'http://localhost:8000/api/v1/analyze';
// DOM элементы
const uploadArea = document.getElementById('uploadArea');
const fileInput = document.getElementById('fileInput');
const previewContainer = document.getElementById('previewContainer');
const previewImage = document.getElementById('previewImage');
const fileName = document.getElementById('fileName');
const fileSize = document.getElementById('fileSize');
const fileType = document.getElementById('fileType');
const analyzeBtn = document.getElementById('analyzeBtn');
const results = document.getElementById('results');
const errorMessage = document.getElementById('errorMessage');
const maskContainer = document.getElementById('maskContainer');
const maskImage = document.getElementById('maskImage');
const maskCanvas = document.getElementById('maskCanvas');
const maskOpacity = document.getElementById('maskOpacity');
const opacityLabel = document.getElementById('opacityLabel');
const toggleMaskBtn = document.getElementById('toggleMaskBtn');
const toggleOriginalBtn = document.getElementById('toggleOriginalBtn');
const numObjects = document.getElementById('numObjects');
const objectSize = document.getElementById('objectSize');
const maskConfidence = document.getElementById('maskConfidence');
// Состояние
let selectedFile = null;
let currentImageData = null;
let currentMaskData = null;
let showMask = true;
let currentMaskBase64 = null;
// Drag and drop
uploadArea.addEventListener('dragover', (e) => {
e.preventDefault();
uploadArea.classList.add('drag-over');
});
uploadArea.addEventListener('dragleave', () => {
uploadArea.classList.remove('drag-over');
});
uploadArea.addEventListener('drop', (e) => {
e.preventDefault();
uploadArea.classList.remove('drag-over');
if (e.dataTransfer.files.length) {
handleFile(e.dataTransfer.files[0]);
}
});
fileInput.addEventListener('change', (e) => {
if (e.target.files.length) {
handleFile(e.target.files[0]);
}
});
function handleFile(file) {
selectedFile = file;
const reader = new FileReader();
reader.onload = (e) => {
currentImageData = e.target.result;
previewImage.src = e.target.result;
previewContainer.style.display = 'block';
fileName.textContent = file.name;
fileSize.textContent = formatFileSize(file.size);
fileType.textContent = file.type || 'Неизвестно';
analyzeBtn.disabled = false;
results.style.display = 'none';
maskContainer.style.display = 'none';
errorMessage.style.display = 'none';
currentMaskData = null;
currentMaskBase64 = null;
};
reader.readAsDataURL(file);
}
function formatFileSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(2) + ' MB';
}
// Mask controls
maskOpacity.addEventListener('input', () => {
const val = maskOpacity.value;
opacityLabel.textContent = val + '%';
if (currentImageData && currentMaskBase64) {
drawMask(currentImageData, currentMaskBase64, val / 100);
}
});
toggleMaskBtn.addEventListener('click', () => {
showMask = !showMask;
toggleMaskBtn.textContent = showMask ? 'Скрыть маску' : 'Показать маску';
toggleMaskBtn.classList.toggle('active');
if (currentImageData && currentMaskBase64) {
drawMask(currentImageData, currentMaskBase64, maskOpacity.value / 100);
}
});
toggleOriginalBtn.addEventListener('click', () => {
if (currentImageData) {
const ctx = maskCanvas.getContext('2d');
const img = new Image();
img.onload = () => {
maskCanvas.width = img.width;
maskCanvas.height = img.height;
ctx.drawImage(img, 0, 0);
};
img.src = currentImageData;
}
toggleMaskBtn.textContent = 'Показать маску';
toggleMaskBtn.classList.remove('active');
showMask = false;
});
// Draw mask function
function drawMask(imageSrc, maskBase64, opacity) {
const ctx = maskCanvas.getContext('2d');
const img = new Image();
img.onload = () => {
maskCanvas.width = img.width;
maskCanvas.height = img.height;
ctx.clearRect(0, 0, maskCanvas.width, maskCanvas.height);
ctx.drawImage(img, 0, 0);
if (showMask && maskBase64) {
const maskImg = new Image();
maskImg.onload = () => {
ctx.globalAlpha = opacity * 0.6;
ctx.drawImage(maskImg, 0, 0, maskCanvas.width, maskCanvas.height);
ctx.globalAlpha = 1;
// Информация о маске
ctx.fillStyle = 'rgba(255, 255, 255, 0.85)';
ctx.fillRect(10, 10, 200, 60);
ctx.fillStyle = '#2d3748';
ctx.font = '12px Arial';
ctx.fillText(`Объектов: ${currentMaskData?.num_objects || 0}`, 20, 30);
ctx.fillText(`Размер: ${((currentMaskData?.object_size || 0) * 100).toFixed(1)}%`, 20, 50);
ctx.globalAlpha = 1;
};
maskImg.src = `data:image/png;base64,${maskBase64}`;
}
};
img.src = imageSrc;
}
// Analyze
analyzeBtn.addEventListener('click', async () => {
if (!selectedFile) return;
analyzeBtn.disabled = true;
analyzeBtn.classList.add('loading');
results.style.display = 'none';
maskContainer.style.display = 'none';
errorMessage.style.display = 'none';
const formData = new FormData();
formData.append('file', selectedFile);
try {
const response = await fetch(API_URL, {
method: 'POST',
body: formData
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Ошибка сервера (${response.status}): ${errorText}`);
}
const data = await response.json();
// Сохраняем маску
if (data.mask) {
currentMaskBase64 = data.mask;
currentMaskData = {
num_objects: data.metrics?.artifact?.num_objects || 0,
object_size: data.metrics?.artifact?.object_size || 0,
edge_energy: data.metrics?.artifact?.edge_energy || 0
};
maskContainer.style.display = 'block';
maskImage.src = currentImageData;
// Обновляем статистику
numObjects.textContent = currentMaskData.num_objects;
objectSize.textContent = (currentMaskData.object_size * 100).toFixed(1) + '%';
maskConfidence.textContent = (data.confidence * 100).toFixed(1) + '%';
// Рисуем маску
drawMask(currentImageData, data.mask, maskOpacity.value / 100);
}
displayResults(data);
} catch (error) {
errorMessage.textContent = `❌ Ошибка: ${error.message}`;
errorMessage.style.display = 'block';
} finally {
analyzeBtn.disabled = false;
analyzeBtn.classList.remove('loading');
}
});
function displayResults(data) {
// Overall quality
const quality = data.overall_quality || 'GOOD';
const qualityEl = document.getElementById('overallQuality');
const badgeClass = `quality-${quality}`;
const labelMap = {
'GOOD': '✅ Хорошее',
'WARNING': '⚠️ Требует внимания',
'POOR': '❌ Плохое'
};
qualityEl.innerHTML = `<span class="quality-badge ${badgeClass}">${labelMap[quality] || quality}</span>`;
// Severity
const severity = data.severity || 'LOW';
const severityEl = document.getElementById('severityDisplay');
const severityClass = `severity-${severity}`;
const severityMap = {
'LOW': '🟢 Низкая',
'MEDIUM': '🟡 Средняя',
'HIGH': '🔴 Высокая'
};
severityEl.innerHTML = `<span class="quality-badge ${severityClass}">${severityMap[severity] || severity}</span>`;
// Issues
const issuesEl = document.getElementById('issuesList');
if (data.issues && data.issues.length > 0) {
issuesEl.innerHTML = data.issues.map(issue => {
const type = issue.type || 'info';
const badgeClass = `issue-${type}`;
const details = issue.details || '';
const severity = issue.severity || '';
return `<div class="issue-item">
<span class="issue-badge ${badgeClass}">${type.replace('_', ' ')}</span>
<span>${details}</span>
${severity ? `<span style="font-size: 12px; color: #718096;">(${severity})</span>` : ''}
</div>`;
}).join('');
} else {
issuesEl.innerHTML = '<div style="color: #48bb78;">✅ Проблем не обнаружено</div>';
}
// Position validation
const pos = data.position_validation || {};
const posEl = document.getElementById('positionValidation');
const posDetails = document.getElementById('positionDetails');
if (pos.valid !== undefined) {
posEl.innerHTML = pos.valid ? '✅ Корректное' : '❌ Некорректное';
posDetails.textContent = pos.position ? `Позиция: ${pos.position}, отклонение: ${(pos.deviation || 0).toFixed(3)}` : '';
} else {
posEl.textContent = 'Не определено';
posDetails.textContent = '';
}
// Artifact validation
const art = data.artifact_validation || {};
const artEl = document.getElementById('artifactValidation');
const artDetails = document.getElementById('artifactDetails');
if (art.artifact !== undefined) {
artEl.innerHTML = art.artifact ? '⚠️ Обнаружены' : '✅ Не обнаружены';
artDetails.textContent = art.type ? `Тип: ${art.type}` : '';
} else {
artEl.textContent = 'Не определено';
artDetails.textContent = '';
}
// Confidence
const confidence = data.confidence || 0;
document.getElementById('confidenceDisplay').textContent =
`${(confidence * 100).toFixed(1)}%`;
document.getElementById('confidenceFill').style.width = `${(confidence * 100).toFixed(1)}%`;
// Metrics
const metrics = data.metrics || {};
const metricsCard = document.getElementById('metricsCard');
const metricsDisplay = document.getElementById('metricsDisplay');
if (Object.keys(metrics).length > 0) {
metricsCard.style.display = 'block';
metricsDisplay.innerHTML = `<pre style="white-space: pre-wrap; word-break: break-all; font-size: 12px;">${JSON.stringify(metrics, null, 2)}</pre>`;
} else {
metricsCard.style.display = 'none';
}
results.style.display = 'block';
results.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
// Health check
async function checkHealth() {
try {
const response = await fetch('http://localhost:8000/api/v1/health');
if (response.ok) {
console.log('✅ API сервер работает');
} else {
console.warn('⚠️ API сервер недоступен');
}
} catch {
console.warn('⚠️ Не удалось подключиться к API серверу');
}
}
checkHealth();
console.log('🦴 Bone Quality Assessment UI загружен');
console.log(`API URL: ${API_URL}`);
</script>
</body>
</html>