MesserMMP's picture
Update app.py
493dec4 verified
Raw
History Blame Contribute Delete
49.7 kB
import os
import base64
import json
import tempfile
from datetime import datetime
from dataclasses import asdict
from typing import List, Dict, Any
from pathlib import Path
import gradio as gr
import pydicom
from src.syntax_pred.config import CFG
from src.syntax_pred.infer import Study, run_inference
# ==================== КОНФИГУРАЦИЯ ДИЗАЙНА ====================
APPLE_STYLE_CSS = """
/* Сбалансированная современная цветовая гамма */
:root {
--bg-primary: #4f46e5;
--bg-secondary: #7c3aed;
--bg-gradient: linear-gradient(135deg, #4f46e5 0%, #7c3aed 50%, #a855f7 100%);
--bg-surface: linear-gradient(135deg, rgba(255,255,255,0.97) 0%, rgba(255,255,255,0.94) 100%);
--accent-cyan: #06b6d4;
--accent-teal: #3b82f6;
--accent-purple: #8b5cf6;
--accent-pink: #d946ef;
--accent-orange: #f97316;
--accent-green: #10b981;
--accent-red: #ef4444;
--accent-yellow: #eab308;
--accent-cyan-light: rgba(6, 182, 212, 0.08);
--accent-teal-light: rgba(59, 130, 246, 0.08);
--accent-purple-light: rgba(139, 92, 246, 0.08);
--accent-pink-light: rgba(217, 70, 239, 0.08);
--accent-green-light: rgba(16, 185, 129, 0.08);
--accent-red-light: rgba(239, 68, 68, 0.08);
--accent-orange-light: rgba(249, 115, 22, 0.08);
--text-primary: #1e293b;
--text-secondary: #64748b;
--text-light: #94a3b8;
--text-white: #ffffff;
--border-color: rgba(255, 255, 255, 0.2);
--border-dark: rgba(0, 0, 0, 0.06);
--shadow-sm: 0 4px 6px -1px rgba(0, 0, 0, 0.05);
--shadow-md: 0 10px 15px -3px rgba(0, 0, 0, 0.08);
--shadow-lg: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
--shadow-glow: 0 0 0 3px rgba(6, 182, 212, 0.2);
--shadow-glow-purple: 0 0 0 3px rgba(139, 92, 246, 0.2);
}
* {
font-family: -apple-system, "SF Pro Text", "SF Pro Display", "Inter", "Helvetica Neue", system-ui, sans-serif;
}
body, .gradio-container {
background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 50%, #a855f7 100%) !important;
position: relative;
}
body::before {
content: '';
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: radial-gradient(circle at 20% 50%, rgba(255,255,255,0.08) 0%, transparent 70%);
pointer-events: none;
z-index: 0;
}
.gradio-container {
position: relative;
z-index: 1;
}
.gr-box, .gr-form, .panel, .tabs, .tab-nav, .accordion {
background: transparent !important;
border: none !important;
}
.apple-container {
max-width: 1400px;
margin: 0 auto;
padding: 2rem 1.5rem;
}
.apple-header {
text-align: center;
margin-bottom: 2rem;
position: relative;
padding: 1rem 0;
}
.apple-title {
font-size: 3.2rem;
font-weight: 800;
background: linear-gradient(135deg, #ffffff 0%, #e2e8f0 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
letter-spacing: -0.02em;
margin: 0;
text-shadow: 0 2px 10px rgba(0,0,0,0.05);
}
.apple-subtitle {
font-size: 1.1rem;
color: rgba(255, 255, 255, 0.85);
margin-top: 0.5rem;
font-weight: 400;
}
.apple-card {
background: rgba(255, 255, 255, 0.96);
backdrop-filter: blur(20px);
border-radius: 24px;
border: 1px solid rgba(255, 255, 255, 0.3);
box-shadow: var(--shadow-md);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
}
.apple-card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-lg);
background: rgba(255, 255, 255, 0.98);
}
.apple-card-header {
padding: 1.25rem 1.5rem;
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
background: linear-gradient(135deg, rgba(255,255,255,0.4) 0%, rgba(255,255,255,0.2) 100%);
}
.apple-card-title {
font-size: 1.25rem;
font-weight: 700;
background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin: 0;
display: flex;
align-items: center;
gap: 0.5rem;
}
.apple-card-content {
padding: 1.5rem;
}
.add-study-card {
background: linear-gradient(135deg, rgba(255,255,255,0.98) 0%, rgba(255,255,255,0.96) 100%);
border: 1px solid rgba(255,255,255,0.4);
position: relative;
overflow: hidden;
}
.apple-button {
background: linear-gradient(135deg, #06b6d4 0%, #3b82f6 100%);
color: white;
border: none;
border-radius: 980px;
padding: 0.75rem 1.5rem;
font-size: 0.9375rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: var(--shadow-sm);
display: inline-flex;
align-items: center;
gap: 0.5rem;
position: relative;
overflow: hidden;
}
.apple-button::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.15), transparent);
transition: left 0.5s;
}
.apple-button:hover::before {
left: 100%;
}
.apple-button:hover {
transform: translateY(-1px);
box-shadow: var(--shadow-md);
background: linear-gradient(135deg, #0891b2 0%, #2563eb 100%);
}
.apple-button-add {
background: linear-gradient(135deg, #8b5cf6 0%, #a855f7 100%);
color: white;
border: none;
border-radius: 980px;
padding: 0.75rem 1.5rem;
font-size: 0.9375rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: var(--shadow-sm);
display: inline-flex;
align-items: center;
gap: 0.5rem;
position: relative;
overflow: hidden;
}
.apple-button-add::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.15), transparent);
transition: left 0.5s;
}
.apple-button-add:hover::before {
left: 100%;
}
.apple-button-add:hover {
transform: translateY(-1px);
box-shadow: var(--shadow-md);
background: linear-gradient(135deg, #7c3aed 0%, #9333ea 100%);
}
.apple-button-clear {
background: linear-gradient(135deg, #f97316 0%, #fbbf24 100%);
color: white;
border: none;
border-radius: 980px;
padding: 0.75rem 1.5rem;
font-size: 0.9375rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: var(--shadow-sm);
display: inline-flex;
align-items: center;
gap: 0.5rem;
position: relative;
overflow: hidden;
}
.apple-button-clear::before {
content: '';
position: absolute;
top: 0;
left: -100%;
width: 100%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.15), transparent);
transition: left 0.5s;
}
.apple-button-clear:hover::before {
left: 100%;
}
.apple-button-clear:hover {
transform: translateY(-1px);
box-shadow: var(--shadow-md);
background: linear-gradient(135deg, #ea580c 0%, #eab308 100%);
}
.apple-input {
border: 1.5px solid rgba(0, 0, 0, 0.05) !important;
border-radius: 14px !important;
padding: 0.75rem 1rem !important;
font-size: 0.9375rem !important;
background: rgba(255, 255, 255, 0.95) !important;
transition: all 0.3s ease !important;
}
.apple-input:focus {
outline: none !important;
border-color: #06b6d4 !important;
box-shadow: var(--shadow-glow) !important;
background: white !important;
}
.apple-table {
border-radius: 16px;
overflow: hidden;
border: none;
}
.apple-table table {
background: rgba(255, 255, 255, 0.95) !important;
border-collapse: collapse !important;
width: 100%;
}
.apple-table th {
background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%) !important;
color: white !important;
font-weight: 600 !important;
font-size: 0.875rem !important;
padding: 12px 16px !important;
border-bottom: none !important;
border-top: none !important;
border-left: none !important;
border-right: none !important;
}
.apple-table td {
background: rgba(255, 255, 255, 0.98) !important;
color: var(--text-primary) !important;
padding: 12px 16px !important;
border-bottom: 1px solid rgba(0, 0, 0, 0.05) !important;
border-top: none !important;
border-left: none !important;
border-right: none !important;
}
.apple-table tr:last-child td {
border-bottom: none !important;
}
.apple-table tr:hover td {
background: rgba(139, 92, 246, 0.04) !important;
}
.status-badge {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.25rem 0.75rem;
border-radius: 20px;
font-size: 0.8125rem;
font-weight: 600;
white-space: nowrap;
}
.status-running {
background: linear-gradient(135deg, #06b6d4 0%, #3b82f6 100%);
color: white;
box-shadow: var(--shadow-sm);
}
.status-done {
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
color: white;
box-shadow: var(--shadow-sm);
}
.status-error {
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
color: white;
box-shadow: var(--shadow-sm);
}
.status-queued {
background: linear-gradient(135deg, #64748b 0%, #475569 100%);
color: white;
box-shadow: var(--shadow-sm);
}
@keyframes pulse {
0%, 100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.85;
transform: scale(1.02);
}
}
.pulse {
animation: pulse 1.5s ease-in-out infinite;
}
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.15);
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: linear-gradient(135deg, #06b6d4 0%, #3b82f6 100%);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: linear-gradient(135deg, #0891b2 0%, #2563eb 100%);
}
.upload-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.instruction-box {
background: linear-gradient(135deg, rgba(139, 92, 246, 0.06) 0%, rgba(6, 182, 212, 0.06) 100%);
border-radius: 16px;
padding: 1rem;
margin-top: 1rem;
border: 1px solid rgba(139, 92, 246, 0.15);
backdrop-filter: blur(10px);
}
.instruction-title {
font-size: 0.875rem;
font-weight: 700;
background: linear-gradient(135deg, #8b5cf6 0%, #a855f7 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
margin-bottom: 0.5rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.instruction-list {
list-style: none;
padding: 0;
margin: 0;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 0.5rem;
}
.instruction-list li {
font-size: 0.8125rem;
color: var(--text-secondary);
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: 500;
}
@media (max-width: 768px) {
.upload-grid {
grid-template-columns: 1fr;
gap: 1rem;
}
.instruction-list {
grid-template-columns: 1fr;
}
.apple-title {
font-size: 2rem;
}
}
"""
# Логотип (base64)
DEFAULT_LOGO = "assets/logo.png"
LOGO_PATH = os.environ.get("LOGO_PATH", DEFAULT_LOGO)
def _logo_html() -> str:
"""Генерация HTML для логотипа"""
path = LOGO_PATH
if not path or not os.path.exists(path):
return ""
try:
with open(path, "rb") as f:
data = base64.b64encode(f.read()).decode("ascii")
ext = os.path.splitext(path)[1].lower()
mime = "image/png" if ext in {".png", ""} else "image/jpeg"
return f'<img src="data:{mime};base64,{data}" alt="Логотип" style="height: 36px; width: auto; border-radius: 10px; filter: drop-shadow(0 2px 6px rgba(0,0,0,0.08));" />'
except Exception:
return ""
def _is_dicom_path(path: str) -> bool:
"""Проверка на DICOM файл"""
if not os.path.exists(path):
return False
ext = os.path.splitext(path)[1].lower()
if ext not in {".dcm", ""}:
return False
try:
pydicom.dcmread(path, stop_before_pixels=True)
return True
except Exception:
return False
def _files_to_paths(files) -> List[str]:
"""Конвертация файлов в пути"""
if not files:
return []
raw_paths = [f.name for f in files if hasattr(f, "name") and os.path.exists(f.name)]
return [p for p in raw_paths if _is_dicom_path(p)]
def _collect_input_paths(files_all, folder_files) -> List[str]:
"""Сбор всех путей из загрузок"""
combined = _files_to_paths(files_all) + _files_to_paths(folder_files)
return list(dict.fromkeys(combined))
def _build_report_file(result: Dict[str, Any]) -> str:
"""Создание отчета в JSON"""
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
with tempfile.NamedTemporaryFile(
mode="w",
prefix=f"autoangioscore_report_{ts}_",
suffix=".json",
encoding="utf-8",
delete=False,
) as f:
json.dump(result, f, ensure_ascii=False, indent=2)
return f.name
def _status_badge_html(state: str, with_animation: bool = False) -> str:
"""Генерация HTML для статус бейджа"""
state = state.lower()
status_config = {
"running": {"class": "status-running", "icon": "⏱️", "text": "Выполняется"},
"done": {"class": "status-done", "icon": "✅", "text": "Завершено"},
"error": {"class": "status-error", "icon": "❌", "text": "Ошибка"},
"queued": {"class": "status-queued", "icon": "⏳", "text": "В очереди"}
}
config = status_config.get(state, status_config["queued"])
animation_class = " pulse" if with_animation and state == "running" else ""
return f'<span class="status-badge {config["class"]}{animation_class}">{config["icon"]} {config["text"]}</span>'
def _update_status_table(studies, status: str) -> List[List]:
"""Обновление таблицы со статусами"""
badge = _status_badge_html(status, with_animation=(status == "running"))
return [
[s.get("name", "-"), s.get("description", "-") or "-", len(s.get("files", [])), badge]
for s in (studies or [])
]
def _format_results_html(result: Dict[str, Any]) -> str:
"""Форматирование результатов в красивом HTML с детальной информацией (одна кнопка на исследование)"""
if not result:
return '''
<div class="apple-card" style="padding: 2rem; text-align: center;">
<div style="color: var(--text-secondary);">
📊 Результаты появятся после запуска анализа
</div>
</div>
'''
if "error" in result:
return f'''
<div class="apple-card" style="padding: 1.5rem; background: linear-gradient(135deg, rgba(239,68,68,0.10) 0%, rgba(220,38,38,0.10) 100%);">
<div style="color: var(--accent-red); display: flex; align-items: center; gap: 0.75rem;">
<span style="font-size: 1.5rem;">⚠️</span>
<div>
<strong>Ошибка</strong>
<div style="margin-top: 0.25rem;">{result.get("error", "Неизвестная ошибка")}</div>
</div>
</div>
</div>
'''
studies = result.get("studies", []) or []
if not studies:
return '''
<div class="apple-card" style="padding: 2rem; text-align: center;">
<div style="color: var(--text-secondary);">
📋 Нет исследований для отображения
</div>
</div>
'''
# Добавляем стили для аккордеона и кнопки (без JavaScript)
accordion_styles = """
<style>
.detail-accordion {
margin-top: 1rem;
border-radius: 12px;
overflow: hidden;
}
.detail-accordion summary {
cursor: pointer;
list-style: none;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
}
.detail-accordion summary::-webkit-details-marker {
display: none;
}
.detail-button {
background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%);
color: white;
border: none;
border-radius: 980px;
padding: 0.6rem 1.5rem;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
display: inline-flex;
align-items: center;
gap: 0.5rem;
margin: 0 auto;
}
.detail-button:hover {
transform: translateY(-1px);
box-shadow: var(--shadow-md);
background: linear-gradient(135deg, #5b52e8 0%, #8b4ced 100%);
}
.detail-accordion[open] .detail-button {
background: linear-gradient(135deg, #f97316 0%, #fbbf24 100%);
}
.detail-accordion[open] .detail-button:hover {
background: linear-gradient(135deg, #ea580c 0%, #eab308 100%);
}
/* Меняем текст кнопки через CSS псевдоэлементы */
.detail-button .toggle-text-open {
display: inline;
}
.detail-button .toggle-text-close {
display: none;
}
.detail-accordion[open] .detail-button .toggle-text-open {
display: none;
}
.detail-accordion[open] .detail-button .toggle-text-close {
display: inline;
}
.detail-button .toggle-icon-open {
display: inline;
}
.detail-button .toggle-icon-close {
display: none;
}
.detail-accordion[open] .detail-button .toggle-icon-open {
display: none;
}
.detail-accordion[open] .detail-button .toggle-icon-close {
display: inline;
}
.files-list {
margin-top: 8px;
max-height: 300px;
overflow-y: auto;
}
.file-item {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 11px;
padding: 6px 0;
border-bottom: 1px solid rgba(0,0,0,0.05);
}
.file-name {
color: var(--text-secondary);
font-size: 11px;
}
.file-prob {
font-weight: 500;
font-size: 11px;
}
.other-file-item {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 11px;
padding: 6px 0;
border-bottom: 1px solid rgba(0,0,0,0.05);
}
.other-file-info {
display: flex;
flex-direction: column;
align-items: flex-start;
}
.other-file-name {
color: var(--text-secondary);
font-size: 11px;
}
.other-file-status {
font-size: 9px;
}
.other-file-prob {
text-align: right;
}
.other-file-prob-value {
font-weight: 600;
font-size: 11px;
}
.other-file-prob-label {
font-size: 9px;
color: var(--text-light);
}
</style>
"""
cards_html = [accordion_styles]
for idx, s in enumerate(studies):
study_name = s.get("study", "-")
description = s.get("description", "")
unique_id = f"details_{idx}_{study_name.replace(' ', '_').replace('/', '_')}"
left_data = s.get("left", {})
right_data = s.get("right", {})
other_data = s.get("other", [])
total_obj = s.get("total", {})
left_mean = left_data.get("mean", 0.0)
right_mean = right_data.get("mean", 0.0)
total_mean = total_obj.get("mean", 0.0)
left_files_count = left_data.get("n_files", 0)
right_files_count = right_data.get("n_files", 0)
other_files_count = len(other_data)
left_per_model = left_data.get("per_model", [])
right_per_model = right_data.get("per_model", [])
risk_key = next((k for k in total_obj.keys() if "High-risk" in str(k)), None)
is_high_risk = bool(total_obj.get(risk_key, False)) if risk_key else total_mean > 0.5
if is_high_risk:
badge_bg = "#fee2e2"
badge_color = "#991b1b"
badge_text = "⚠️ Высокий риск"
else:
badge_bg = "#dcfce7"
badge_color = "#14532d"
badge_text = "✅ Низкий риск"
badge_style = f"""
background: {badge_bg};
color: {badge_color};
padding: 8px 16px;
border-radius: 20px;
font-size: 14px;
font-weight: 700;
display: inline-flex;
align-items: center;
gap: 6px;
border: 1px solid rgba(0,0,0,0.05);
"""
# Генерация HTML для моделей левой артерии
left_models_html = ""
if left_per_model:
left_models_html = '<div style="margin-top: 8px;">'
for i, model_info in enumerate(left_per_model, 1):
model_score = model_info.get("score", 0.0)
left_models_html += f'''
<div class="file-item">
<span class="file-name">🎯 Модель {i}</span>
<span class="file-prob" style="color: #06b6d4;">{model_score:.3f}</span>
</div>
'''
left_models_html += '</div>'
else:
left_models_html = '<div style="margin-top: 8px; font-size: 11px; color: var(--text-light); text-align: center;">Нет данных моделей</div>'
# Генерация HTML для моделей правой артерии
right_models_html = ""
if right_per_model:
right_models_html = '<div style="margin-top: 8px;">'
for i, model_info in enumerate(right_per_model, 1):
model_score = model_info.get("score", 0.0)
right_models_html += f'''
<div class="file-item">
<span class="file-name">🎯 Модель {i}</span>
<span class="file-prob" style="color: #8b5cf6;">{model_score:.3f}</span>
</div>
'''
right_models_html += '</div>'
else:
right_models_html = '<div style="margin-top: 8px; font-size: 11px; color: var(--text-light); text-align: center;">Нет данных моделей</div>'
# Файлы левой артерии (ВСЕ файлы)
left_files_with_probs = left_data.get("files", [])
left_files_html = ""
if left_files_with_probs:
left_files_html = '<div class="files-list">'
for file_info in left_files_with_probs:
file_path = file_info.get("path", "unknown")
file_name = os.path.basename(file_path) if file_path else "unknown"
artery_prob = file_info.get("artery_prob", 0.0)
left_files_html += f'''
<div class="file-item">
<span class="file-name">📄 {file_name[:50]}</span>
<span class="file-prob" style="color: #06b6d4;">{artery_prob:.3f}</span>
</div>
'''
left_files_html += '</div>'
else:
left_files_html = '<div style="margin-top: 8px; font-size: 11px; color: var(--text-light); text-align: center;">Нет файлов</div>'
# Файлы правой артерии (ВСЕ файлы)
right_files_with_probs = right_data.get("files", [])
right_files_html = ""
if right_files_with_probs:
right_files_html = '<div class="files-list">'
for file_info in right_files_with_probs:
file_path = file_info.get("path", "unknown")
file_name = os.path.basename(file_path) if file_path else "unknown"
artery_prob = file_info.get("artery_prob", 0.0)
right_files_html += f'''
<div class="file-item">
<span class="file-name">📄 {file_name[:50]}</span>
<span class="file-prob" style="color: #8b5cf6;">{artery_prob:.3f}</span>
</div>
'''
right_files_html += '</div>'
else:
right_files_html = '<div style="margin-top: 8px; font-size: 11px; color: var(--text-light); text-align: center;">Нет файлов</div>'
# Нераспознанные файлы (ВСЕ файлы)
other_files_html = ""
if other_data:
other_files_html = '<div class="files-list">'
for file_info in other_data:
file_path = file_info.get("path", "unknown")
file_name = os.path.basename(file_path) if file_path else "unknown"
artery_prob = file_info.get("artery_prob", 0.0)
left_threshold = 0.10
right_threshold = 0.90
if artery_prob <= left_threshold:
prob_status = "⚠️ Ниже порога левой артерии"
prob_color = "#f97316"
elif artery_prob >= right_threshold:
prob_status = "⚠️ Выше порога правой артерии"
prob_color = "#f97316"
else:
prob_status = "❓ В серой зоне (неопределён)"
prob_color = "#ef4444"
other_files_html += f'''
<div class="other-file-item">
<div class="other-file-info">
<span class="other-file-name">📄 {file_name[:50]}</span>
<span class="other-file-status" style="color: {prob_color};">{prob_status}</span>
</div>
<div class="other-file-prob">
<div class="other-file-prob-value" style="color: #f97316;">{artery_prob:.3f}</div>
<div class="other-file-prob-label">вероятность</div>
</div>
</div>
'''
other_files_html += '</div>'
else:
other_files_html = '<div style="margin-top: 8px; font-size: 11px; color: var(--text-light); text-align: center;">Нераспознанных файлов нет</div>'
card_html = f'''
<div class="apple-card" style="margin-bottom: 1rem;">
<div class="apple-card-header">
<div style="display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 10px;">
<div style="display: flex; align-items: center; gap: 8px;">
<span style="font-size: 1.25rem; font-weight: 700; color: var(--text-primary);">🏥 {study_name}</span>
</div>
<div style="{badge_style}">
{badge_text}
</div>
</div>
{f'<div style="font-size: 0.875rem; color: var(--text-secondary); margin-top: 0.5rem;">{description}</div>' if description else ''}
</div>
<div class="apple-card-content">
<!-- Три основных показателя (всегда видны) -->
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; margin-bottom: 0rem;">
<div style="text-align: center;
background: linear-gradient(135deg, rgba(6,182,212,0.10) 0%, rgba(59,130,246,0.10) 100%);
border-radius: 16px;
padding: 0.85rem;
border: 1px solid rgba(59,130,246,0.15);">
<div style="font-size: 0.875rem; color: var(--text-secondary); margin-bottom: 0.5rem;">
🧬 Левая артерия
</div>
<div style="font-size: 1.5rem; font-weight: 800;
background: linear-gradient(135deg, #06b6d4 0%, #3b82f6 100%);
-webkit-background-clip: text; -webkit-text-fill-color: transparent;">
{left_mean:.3f}
</div>
<div style="font-size: 0.7rem; color: var(--text-secondary); margin-top: 0.25rem;">
SYNTAX Score
</div>
<div style="font-size: 0.7rem; color: var(--text-muted); margin-top: 0.25rem;">
📁 {left_files_count} файлов
</div>
</div>
<div style="text-align: center;
background: linear-gradient(135deg, rgba(139,92,246,0.10) 0%, rgba(168,85,247,0.10) 100%);
border-radius: 16px;
padding: 0.85rem;
border: 1px solid rgba(168,85,247,0.15);">
<div style="font-size: 0.875rem; color: var(--text-secondary); margin-bottom: 0.5rem;">
🧬 Правая артерия
</div>
<div style="font-size: 1.5rem; font-weight: 800;
background: linear-gradient(135deg, #8b5cf6 0%, #a855f7 100%);
-webkit-background-clip: text; -webkit-text-fill-color: transparent;">
{right_mean:.3f}
</div>
<div style="font-size: 0.7rem; color: var(--text-secondary); margin-top: 0.25rem;">
SYNTAX Score
</div>
<div style="font-size: 0.7rem; color: var(--text-muted); margin-top: 0.25rem;">
📁 {right_files_count} файлов
</div>
</div>
<div style="text-align: center;
background: linear-gradient(135deg, rgba(249,115,22,0.10) 0%, rgba(251,191,36,0.10) 100%);
border-radius: 16px;
padding: 0.85rem;
border: 1px solid rgba(249,115,22,0.20);">
<div style="font-size: 0.875rem; color: var(--text-secondary); margin-bottom: 0.5rem;">
📊 Общий SYNTAX Score
</div>
<div style="font-size: 1.75rem; font-weight: 900;
background: linear-gradient(135deg, #f97316 0%, #fbbf24 100%);
-webkit-background-clip: text; -webkit-text-fill-color: transparent;">
{total_mean:.3f}
</div>
<div style="font-size: 0.7rem; color: var(--text-secondary); margin-top: 0.25rem;">
{'⚠️ Рекомендовано вмешательство' if is_high_risk else '✅ Плановое наблюдение'}
</div>
</div>
</div>
<!-- Кнопка для раскрытия детальной информации (без JavaScript, через CSS) -->
<details id="{unique_id}" class="detail-accordion">
<summary>
<div class="detail-button">
<span class="toggle-icon-open">▼</span>
<span class="toggle-icon-close">▲</span>
<span class="toggle-text-open">Показать детальную информацию</span>
<span class="toggle-text-close">Скрыть детальную информацию</span>
</div>
</summary>
<!-- Детальная информация (появляется после клика) -->
<div style="margin-top: 1.5rem;">
<!-- Оценка по моделям -->
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin-bottom: 1rem;">
<div style="background: rgba(6,182,212,0.04); border-radius: 12px; padding: 0.75rem; border: 1px solid rgba(6,182,212,0.1);">
<div style="font-size: 0.8125rem; font-weight: 700; color: #06b6d4; margin-bottom: 0.5rem; display: flex; align-items: center; gap: 0.5rem;">
<span>🎯</span> SYNTAX Score по моделям (левая артерия)
</div>
{left_models_html}
</div>
<div style="background: rgba(139,92,246,0.04); border-radius: 12px; padding: 0.75rem; border: 1px solid rgba(139,92,246,0.1);">
<div style="font-size: 0.8125rem; font-weight: 700; color: #8b5cf6; margin-bottom: 0.5rem; display: flex; align-items: center; gap: 0.5rem;">
<span>🎯</span> SYNTAX Score по моделям (правая артерия)
</div>
{right_models_html}
</div>
</div>
<!-- Файлы левой артерии (ВСЕ) -->
<div style="background: rgba(6,182,212,0.03); border-radius: 12px; padding: 0.75rem; margin-bottom: 0.75rem; border: 1px solid rgba(6,182,212,0.1);">
<div style="font-size: 0.75rem; font-weight: 700; color: #06b6d4; margin-bottom: 0.5rem; display: flex; align-items: center; gap: 0.5rem;">
<span>📂</span> Файлы левой артерии (вероятность ≤ 0.10)
<span style="background: #06b6d4; color: white; padding: 0 6px; border-radius: 12px; font-size: 9px;">{left_files_count}</span>
</div>
{left_files_html}
</div>
<!-- Файлы правой артерии (ВСЕ) -->
<div style="background: rgba(139,92,246,0.03); border-radius: 12px; padding: 0.75rem; margin-bottom: 0.75rem; border: 1px solid rgba(139,92,246,0.1);">
<div style="font-size: 0.75rem; font-weight: 700; color: #8b5cf6; margin-bottom: 0.5rem; display: flex; align-items: center; gap: 0.5rem;">
<span>📂</span> Файлы правой артерии (вероятность ≥ 0.90)
<span style="background: #8b5cf6; color: white; padding: 0 6px; border-radius: 12px; font-size: 9px;">{right_files_count}</span>
</div>
{right_files_html}
</div>
<!-- Нераспознанные файлы (ВСЕ) -->
<div style="background: rgba(249,115,22,0.03); border-radius: 12px; padding: 0.75rem; border: 1px solid rgba(249,115,22,0.1);">
<div style="font-size: 0.75rem; font-weight: 700; color: #f97316; margin-bottom: 0.5rem; display: flex; align-items: center; gap: 0.5rem;">
<span>❓</span> Нераспознанные файлы (0.10 < вероятность < 0.90)
<span style="background: #f97316; color: white; padding: 0 6px; border-radius: 12px; font-size: 9px;">{other_files_count}</span>
</div>
{other_files_html}
<div style="font-size: 0.65rem; color: var(--text-light); margin-top: 0.5rem; padding-top: 0.25rem; border-top: 1px solid rgba(0,0,0,0.05);">
💡 Вероятность классификации артерии: ≤0.10 → левая, ≥0.90 → правая
</div>
</div>
</div>
</details>
<!-- Дополнительная статистика (всегда видна) -->
<div style="margin-top: 1rem; padding-top: 0.75rem; border-top: 1px solid rgba(0,0,0,0.05); display: flex; justify-content: space-between; font-size: 0.65rem; color: var(--text-light);">
<span>🎯 Порог высокого риска SYNTAX: ≥22.0</span>
<span>🧠 Ансамбль моделей: {len(left_per_model)} левых / {len(right_per_model)} правых</span>
</div>
</div>
</div>
'''
cards_html.append(card_html)
return "".join(cards_html)
def create_ui():
"""Создание интерфейса в стиле Apple"""
with gr.Blocks(title="AutoAngioScore", css=APPLE_STYLE_CSS) as demo:
# Главный контейнер
with gr.Column(elem_classes="apple-container"):
# Хедер
with gr.Row(elem_classes="apple-header"):
with gr.Column(scale=1, min_width=0):
logo_html = _logo_html()
if logo_html:
gr.HTML(f'<div style="margin-bottom: 1rem;">{logo_html}</div>')
gr.HTML('''
<h1 class="apple-title">AutoAngioScore</h1>
<p class="apple-subtitle">Автоматическая оценка степени коронарного поражения по видеозаписям ангиографии</p>
''')
# ==================== БЛОК ДОБАВЛЕНИЯ ИССЛЕДОВАНИЯ ====================
with gr.Group(elem_classes="apple-card add-study-card"):
with gr.Column(elem_classes="apple-card-header"):
gr.HTML('<h3 class="apple-card-title">✨ Добавление нового исследования</h3>')
with gr.Column(elem_classes="apple-card-content"):
# Информация об исследовании
with gr.Row():
study_name = gr.Textbox(
label="Идентификатор исследования",
placeholder="Например: INV-2024-001",
elem_classes="apple-input",
scale=1
)
study_desc = gr.Textbox(
label="Описание (необязательно)",
placeholder="Клинические данные, особенности...",
elem_classes="apple-input",
scale=1
)
# Загрузка DICOM - две колонки
gr.HTML('<div class="upload-grid">')
with gr.Column(elem_classes="apple-card", variant="panel"):
with gr.Column(elem_classes="apple-card-header"):
gr.HTML('<h3 class="apple-card-title" style="font-size: 1rem;">📄 Отдельные файлы</h3>')
with gr.Column(elem_classes="apple-card-content"):
files_all = gr.File(
label="Выберите DICOM файлы",
file_count="multiple",
elem_classes="apple-input"
)
gr.HTML('''
<div style="font-size: 0.75rem; color: var(--text-secondary); margin-top: 0.5rem;">
✓ Поддерживаются файлы .dcm
</div>
''')
with gr.Column(elem_classes="apple-card", variant="panel"):
with gr.Column(elem_classes="apple-card-header"):
gr.HTML('<h3 class="apple-card-title" style="font-size: 1rem;">📁 Папка с исследованием</h3>')
with gr.Column(elem_classes="apple-card-content"):
files_folder = gr.File(
label="Выберите папку",
file_count="directory",
elem_classes="apple-input"
)
gr.HTML('''
<div style="font-size: 0.75rem; color: var(--text-secondary); margin-top: 0.5rem;">
✓ Выберите папку, содержащую DICOM файлы
</div>
''')
gr.HTML('</div>')
# Кнопки управления добавлением
with gr.Row():
btn_add = gr.Button(
"✨ Добавить в очередь",
elem_classes="apple-button-add",
scale=1
)
btn_clear = gr.Button(
"🗑️ Очистить форму",
elem_classes="apple-button-clear",
scale=1
)
# Краткая инструкция
gr.HTML('''
<div class="instruction-box">
<div class="instruction-title">
<span>🎯</span> Быстрый старт
</div>
<ul class="instruction-list">
<li>📝 1. Укажите ID исследования</li>
<li>📂 2. Загрузите DICOM файлы</li>
<li>➕ 3. Добавьте в очередь</li>
<li>🚀 4. Запустите анализ</li>
</ul>
</div>
''')
# ==================== ОЧЕРЕДЬ ИССЛЕДОВАНИЙ ====================
with gr.Group(elem_classes="apple-card"):
with gr.Column(elem_classes="apple-card-header"):
gr.HTML('<h3 class="apple-card-title">📋 Очередь исследований</h3>')
with gr.Column(elem_classes="apple-card-content"):
queue_table = gr.Dataframe(
headers=["Исследование", "Описание", "Файлов", "Статус"],
datatype=["str", "str", "number", "html"],
interactive=False,
elem_classes="apple-table",
row_count=(0, "dynamic"),
wrap=True
)
gr.HTML('''
<div style="font-size: 0.8125rem; color: var(--text-secondary); margin-top: 0.75rem; text-align: center;">
💡 Добавьте исследования в очередь для пакетного анализа
</div>
''')
# ==================== ЗАПУСК АНАЛИЗА ====================
with gr.Row():
run_btn = gr.Button(
"🚀 Запустить анализ очереди",
elem_classes="apple-button"
)
# ==================== РЕЗУЛЬТАТЫ ====================
with gr.Group(elem_classes="apple-card"):
with gr.Column(elem_classes="apple-card-header"):
gr.HTML('<h3 class="apple-card-title">📊 Результаты анализа</h3>')
with gr.Column(elem_classes="apple-card-content"):
out_summary = gr.HTML(label="")
out_json = gr.JSON(label="Подробные данные", visible=False) # Скрытый JSON для логики
with gr.Row():
out_report = gr.File(
label="📥 Скачать отчет JSON",
interactive=False,
elem_classes="apple-input"
)
# Footer
gr.HTML('''
<div style="margin-top: 3rem; padding-top: 1.5rem; border-top: 1px solid rgba(255,255,255,0.15); text-align: center; font-size: 0.75rem; color: rgba(255,255,255,0.7);">
<p>⚠️ Для исследовательских целей. Не является медицинским устройством.</p>
<p>© 2024 AutoAngioScore | Инновационная оценка коронарного поражения</p>
</div>
''')
# ==================== ЛОГИКА ====================
studies_state = gr.State([])
def _add_study_fn(studies: List[Dict[str, Any]], name: str, desc: str, files, folder):
name = (name or "").strip() or f"Исследование_{len(studies)+1}"
desc = (desc or "").strip()
paths = _collect_input_paths(files, folder)
if not paths:
table = _update_status_table(studies, "Queued")
return studies, table, name, desc, files, folder
new_study = {
"name": name,
"description": desc,
"files": paths
}
studies = studies + [new_study]
table = _update_status_table(studies, "Queued")
return studies, table, "", "", None, None
btn_add.click(
_add_study_fn,
inputs=[studies_state, study_name, study_desc, files_all, files_folder],
outputs=[studies_state, queue_table, study_name, study_desc, files_all, files_folder]
)
def _clear_form():
return "", "", None, None
btn_clear.click(_clear_form, inputs=None, outputs=[study_name, study_desc, files_all, files_folder])
def _clear_all():
return [], []
# Добавляем кнопку очистки очереди (скрытая функциональность)
with gr.Row(visible=False):
clear_queue = gr.Button("Очистить очередь")
clear_queue.click(_clear_all, inputs=None, outputs=[studies_state, queue_table])
def _before_run(studies):
return _update_status_table(studies, "Running")
def _run_infer(studies):
study_objs = []
for s in (studies or []):
study_objs.append(Study(name=s["name"], description=s.get("description", ""), files=s["files"]))
result = run_inference(study_objs)
report_path = _build_report_file(result)
return result, report_path
def _after_run(studies, result, report_path):
table = _update_status_table(studies, "Done")
return _format_results_html(result), result, report_path, table
run_btn.click(
_before_run,
inputs=[studies_state],
outputs=[queue_table]
).then(
_run_infer,
inputs=[studies_state],
outputs=[out_json, out_report]
).then(
_after_run,
inputs=[studies_state, out_json, out_report],
outputs=[out_summary, out_json, out_report, queue_table]
)
return demo
if __name__ == "__main__":
favicon = LOGO_PATH if (LOGO_PATH and os.path.exists(LOGO_PATH)) else None
demo = create_ui()
demo.launch(
favicon_path=favicon,
server_name="0.0.0.0",
server_port=7860,
share=False,
)