Базовый коммит
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
*.pth filter=lfs diff=lfs merge=lfs -text
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
__pycache__
|
||||||
|
.vscode
|
||||||
|
data/*
|
||||||
|
!data/demo/
|
||||||
|
!data/demo/**
|
||||||
|
client/static/*
|
||||||
|
!client/static/logo.png
|
||||||
|
!client/static/favicon.ico
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 5.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 88 KiB |
@@ -0,0 +1,233 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<link rel="icon" type="image/x-icon" href="static/favicon.ico">
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Вход в сервис</title>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-container {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 450px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 15px;
|
||||||
|
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-header {
|
||||||
|
background: white;
|
||||||
|
color: #333;
|
||||||
|
padding: 25px;
|
||||||
|
text-align: center;
|
||||||
|
border-bottom: 2px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-container {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
margin: 0 auto 20px;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
max-width: 200px;
|
||||||
|
height: auto;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-header h1 {
|
||||||
|
font-size: 24px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
color: #2c3e50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-header p {
|
||||||
|
color: #7f8c8d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-content {
|
||||||
|
padding: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #2c3e50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flex-row {
|
||||||
|
align-items: stretch;
|
||||||
|
display: flex;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lf--label {
|
||||||
|
height: 48px;
|
||||||
|
width: 50px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #f5f6f8;
|
||||||
|
border: 2px solid #ddd;
|
||||||
|
border-right: none;
|
||||||
|
border-top-left-radius: 8px;
|
||||||
|
border-bottom-left-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lf--input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 12px 15px;
|
||||||
|
border: 2px solid #ddd;
|
||||||
|
border-radius: 0 8px 8px 0;
|
||||||
|
font-size: 16px;
|
||||||
|
transition: border-color 0.3s;
|
||||||
|
height: 48px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lf--input:focus {
|
||||||
|
border-color: #3498db;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lf--submit {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 15px;
|
||||||
|
background: linear-gradient(135deg, #c182b9 0%, #5ac6c8 100%);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lf--submit:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: #d9534f;
|
||||||
|
background-color: #f8d7da;
|
||||||
|
border: 1px solid #f5c6cb;
|
||||||
|
padding: 15px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.forgot-password {
|
||||||
|
color: #3498db;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 14px;
|
||||||
|
transition: color 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.forgot-password:hover {
|
||||||
|
color: #2980b9;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.login-container {
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
max-width: 150px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-header h1 {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flex-row {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lf--label {
|
||||||
|
width: 100%;
|
||||||
|
border: 2px solid #ddd;
|
||||||
|
border-bottom: none;
|
||||||
|
border-radius: 8px 8px 0 0;
|
||||||
|
padding: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lf--input {
|
||||||
|
border: 2px solid #ddd;
|
||||||
|
border-top: none;
|
||||||
|
border-radius: 0 0 8px 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="login-container">
|
||||||
|
<div class="login-header">
|
||||||
|
<div class="logo-container">
|
||||||
|
<img src="static/logo.png" alt="Логотип" class="logo">
|
||||||
|
</div>
|
||||||
|
<h1>Вход в сервис</h1>
|
||||||
|
<p>Введите ваши учетные данные</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="login-content">
|
||||||
|
<!-- Сообщение об ошибке -->
|
||||||
|
<div class="error" style="display: none;">
|
||||||
|
Неправильный логин или пароль
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class='login-form' method="post" action="/login">
|
||||||
|
<div class="flex-row">
|
||||||
|
<label class="lf--label" for="username">
|
||||||
|
<svg x="0px" y="0px" width="12px" height="13px">
|
||||||
|
<path fill="#7f8c8d" d="M8.9,7.2C9,6.9,9,6.7,9,6.5v-4C9,1.1,7.9,0,6.5,0h-1C4.1,0,3,1.1,3,2.5v4c0,0.2,0,0.4,0.1,0.7 C1.3,7.8,0,9.5,0,11.5V13h12v-1.5C12,9.5,10.7,7.8,8.9,7.2z M4,2.5C4,1.7,4.7,1,5.5,1h1C7.3,1,8,1.7,8,2.5v4c0,0.2,0,0.4-0.1,0.6 l0.1,0L7.9,7.3C7.6,7.8,7.1,8.2,6.5,8.2h-1c-0.6,0-1.1-0.4-1.4-0.9L4.1,7.1l0.1,0C4,6.9,4,6.7,4,6.5V2.5z M11,12H1v-0.5 c0-1.6,1-2.9,2.4-3.4c0.5,0.7,1.2,1.1,2.1,1.1h1c0.8,0,1.6-0.4,2.1-1.1C10,8.5,11,9.9,11,11.5V12z"/>
|
||||||
|
</svg>
|
||||||
|
</label>
|
||||||
|
<input id="username" name="username" class='lf--input' placeholder='Логин' type='text' required>
|
||||||
|
</div>
|
||||||
|
<div class="flex-row">
|
||||||
|
<label class="lf--label" for="password">
|
||||||
|
<svg x="0px" y="0px" width="15px" height="5px">
|
||||||
|
<g>
|
||||||
|
<path fill="#7f8c8d" d="M6,2L6,2c0-1.1-1-2-2.1-2H2.1C1,0,0,0.9,0,2.1v0.8C0,4.1,1,5,2.1,5h1.7C5,5,6,4.1,6,2.9V3h5v1h1V3h1v2h1V3h1 V2H6z M5.1,2.9c0,0.7-0.6,1.2-1.3,1.2H2.1c-0.7,0-1.3-0.6-1.3-1.2V2.1c0-0.7,0.6-1.2,1.3-1.2h1.7c0.7,0,1.3,0.6,1.3,1.2V2.9z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
</label>
|
||||||
|
<input id="password" name="password" class='lf--input' placeholder='Пароль' type='password' required>
|
||||||
|
</div>
|
||||||
|
<input class='lf--submit' type='submit' value='ВОЙТИ'>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<link rel="icon" type="image/x-icon" href="static/favicon.ico">
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Результаты анализа DICOM</title>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: white;
|
||||||
|
border-radius: 15px;
|
||||||
|
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
background: white;
|
||||||
|
color: #333;
|
||||||
|
padding: 25px;
|
||||||
|
text-align: center;
|
||||||
|
border-bottom: 2px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-container {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
margin: 0 auto 20px;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
max-width: 200px;
|
||||||
|
height: auto;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h1 {
|
||||||
|
font-size: 24px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
color: #2c3e50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header p {
|
||||||
|
color: #7f8c8d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
padding: 25px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.content {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
max-width: 150px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-card {
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-card h3 {
|
||||||
|
color: #2c3e50;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
border-bottom: 2px solid #5ac6c8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.probability-display {
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 10px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.probability-value {
|
||||||
|
font-size: 48px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #2c3e50;
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.probability-label {
|
||||||
|
font-size: 18px;
|
||||||
|
color: #7f8c8d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pathology-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pathology-yes {
|
||||||
|
background: linear-gradient(135deg, #c182b9 0%, #e74c3c 100%);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pathology-no {
|
||||||
|
background: linear-gradient(135deg, #5ac6c8 0%, #2ecc71 100%);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-section {
|
||||||
|
background: white;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 10px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-section h3 {
|
||||||
|
color: #2c3e50;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-content {
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #34495e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.conclusion {
|
||||||
|
background: #e8fcfb;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border-left: 4px solid #5ac6c8;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-container {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dicom-image {
|
||||||
|
max-width: 100%;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.properties-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 15px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.properties-table th, .properties-table td {
|
||||||
|
padding: 12px;
|
||||||
|
text-align: left;
|
||||||
|
border-bottom: 1px solid #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.properties-table th {
|
||||||
|
background-color: #f2f2f2;
|
||||||
|
color: #2c3e50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 15px;
|
||||||
|
margin-top: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 12px 25px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.3s;
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-block;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: linear-gradient(135deg, #c182b9 0%, #5ac6c8 100%);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
color: #2c3e50;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background-color: #e9ecef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px;
|
||||||
|
background: #2c3e50;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<div class="logo-container">
|
||||||
|
<img src="static/logo.png" alt="Логотип" class="logo">
|
||||||
|
</div>
|
||||||
|
<h1>Результаты анализа DICOM файла</h1>
|
||||||
|
<p>Анализ завершен. Ниже представлены результаты.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<div>
|
||||||
|
<div class="probability-display">
|
||||||
|
<div class="probability-label">Вероятность патологии</div>
|
||||||
|
<div class="probability-value">{{ prediction.overall_probability }}%</div>
|
||||||
|
<div class="pathology-badge {{ 'pathology-yes' if prediction.is_pathology else 'pathology-no' }}">
|
||||||
|
{{ 'Обнаружена патология' if prediction.is_pathology else 'Патология не обнаружена' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="image-container">
|
||||||
|
<img src="static/{{ prediction.image }}" alt="Результат" class="dicom-image">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="report-section">
|
||||||
|
<h3>Отчет анализа</h3>
|
||||||
|
<div class="report-content">
|
||||||
|
{{ prediction.report }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="conclusion">
|
||||||
|
<h3>Заключение</h3>
|
||||||
|
<p>{{ prediction.conclusion }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="result-card">
|
||||||
|
<h3>Доп. информация</h3>
|
||||||
|
<table class="properties-table">
|
||||||
|
{% for key, value in prediction.properties.items() %}
|
||||||
|
<tr>
|
||||||
|
<th>{{ key }}</th>
|
||||||
|
<td>{{ value }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<a href="/upload-study" class="btn btn-primary">Новый анализ</a>
|
||||||
|
<a href="/download/{{prediction.image.replace('.png', '.zip')}}" class="btn btn-secondary">Сохранить DCM отчеты</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">
|
||||||
|
<p>Результаты анализа сгенерированы автоматически. В исследовательских целях.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,615 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<link rel="icon" type="image/x-icon" href="static/favicon.ico">
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Анализ DICOM файлов</title>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 800px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 15px;
|
||||||
|
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
background: white;
|
||||||
|
color: #333;
|
||||||
|
padding: 25px;
|
||||||
|
text-align: center;
|
||||||
|
border-bottom: 2px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-container {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
margin: 0 auto 20px;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
max-width: 200px;
|
||||||
|
height: auto;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h1 {
|
||||||
|
font-size: 24px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
color: #2c3e50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header p {
|
||||||
|
color: #7f8c8d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
padding: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #2c3e50;
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 15px;
|
||||||
|
border: 2px solid #ddd;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 16px;
|
||||||
|
transition: border-color 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
select:focus {
|
||||||
|
border-color: #5ac6c8;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-files {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 15px;
|
||||||
|
margin-top: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-files h3 {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
color: #2c3e50;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-file {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-file:hover {
|
||||||
|
border-color: #5ac6c8;
|
||||||
|
box-shadow: 0 5px 15px rgba(90, 198, 200, 0.2);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-file.selected {
|
||||||
|
border-color: #5ac6c8;
|
||||||
|
background-color: #e8fcfb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-zone {
|
||||||
|
border: 2px dashed #5ac6c8;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 40px 20px;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-zone.highlight {
|
||||||
|
background-color: #e8fcfb;
|
||||||
|
border-color: #c182b9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-zone p {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
color: #7f8c8d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drop-zone .icon {
|
||||||
|
font-size: 48px;
|
||||||
|
color: #5ac6c8;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 15px;
|
||||||
|
background: linear-gradient(135deg, #c182b9 0%, #5ac6c8 100%);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
background: #bdc3c7;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-info {
|
||||||
|
margin-top: 20px;
|
||||||
|
padding: 15px;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border-radius: 8px;
|
||||||
|
border-left: 4px solid #5ac6c8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-info h3 {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
color: #2c3e50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-details {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-top: 10px;
|
||||||
|
padding-top: 10px;
|
||||||
|
border-top: 1px solid #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification {
|
||||||
|
padding: 15px;
|
||||||
|
margin: 20px 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-align: center;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification.success {
|
||||||
|
background-color: #d4edda;
|
||||||
|
color: #155724;
|
||||||
|
border: 1px solid #c3e6cb;
|
||||||
|
display: block !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification.error {
|
||||||
|
background-color: #f8d7da;
|
||||||
|
color: #721c24;
|
||||||
|
border: 1px solid #f5c6cb;
|
||||||
|
display: block !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-section {
|
||||||
|
margin-top: 30px;
|
||||||
|
padding-top: 20px;
|
||||||
|
border-top: 1px solid #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 18px;
|
||||||
|
color: #2c3e50;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title::before {
|
||||||
|
content: "•";
|
||||||
|
margin-right: 10px;
|
||||||
|
color: #5ac6c8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
display: inline-block;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border: 3px solid rgba(255,255,255,.3);
|
||||||
|
border-radius: 50%;
|
||||||
|
border-top-color: #fff;
|
||||||
|
animation: spin 1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.file-list {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h1 {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
max-width: 150px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<div class="logo-container">
|
||||||
|
<img src="static/logo.png" alt="Логотип" class="logo">
|
||||||
|
</div>
|
||||||
|
<h1>Выявление патологий в лучевых исследованиях</h1>
|
||||||
|
<p>Загрузите DICOM файл или выберите демонстрационный пример</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
{% if error %}
|
||||||
|
<div class="notification error">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form id="upload-form" action="/upload-study" method="post" enctype="multipart/form-data">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="pathology">Выберите искомую патологию:</label>
|
||||||
|
<select id="pathology" name="pathology">
|
||||||
|
<option value="sinus">Синусит</option>
|
||||||
|
<option value="wrist">Перелом костей лучезапястного сустава</option>
|
||||||
|
<option value="shoulder">Перелом костей плечевого сустава</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<div class="demo-files">
|
||||||
|
<h3>Демонстрационные файлы:</h3>
|
||||||
|
<div class="file-list" id="demo-files-list">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="upload-section">
|
||||||
|
<div class="section-title">Или загрузите свой файл</div>
|
||||||
|
|
||||||
|
<div class="drop-zone" id="drop-zone">
|
||||||
|
<div class="icon">📁</div>
|
||||||
|
<p>Перетащите DICOM файл сюда или нажмите для выбора</p>
|
||||||
|
<small>Поддерживаются только файлы в формате .dcm</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input type="file" id="file-input" name="file" accept=".dcm" style="display: none;">
|
||||||
|
|
||||||
|
<input type="hidden" id="demo-filename" name="demo_filename" value="">
|
||||||
|
|
||||||
|
<div id="notification" class="notification"></div>
|
||||||
|
|
||||||
|
<button type="submit" id="analyze-btn" class="btn" disabled>
|
||||||
|
<span id="btn-text">Запустить анализ</span>
|
||||||
|
<span id="btn-loading" class="loading" style="display: none;"></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div id="file-info" class="file-info" style="display: none;">
|
||||||
|
<h3>Информация о файле</h3>
|
||||||
|
<div id="file-details" class="file-details">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const form = document.getElementById('upload-form');
|
||||||
|
const pathologySelect = document.getElementById('pathology');
|
||||||
|
const demoFilesList = document.getElementById('demo-files-list');
|
||||||
|
const dropZone = document.getElementById('drop-zone');
|
||||||
|
const fileInput = document.getElementById('file-input');
|
||||||
|
const demoFilenameInput = document.getElementById('demo-filename');
|
||||||
|
const analyzeBtn = document.getElementById('analyze-btn');
|
||||||
|
const btnText = document.getElementById('btn-text');
|
||||||
|
const btnLoading = document.getElementById('btn-loading');
|
||||||
|
const fileInfo = document.getElementById('file-info');
|
||||||
|
const fileDetails = document.getElementById('file-details');
|
||||||
|
const notification = document.getElementById('notification');
|
||||||
|
|
||||||
|
let selectedFile = null;
|
||||||
|
let selectedDemoFile = null;
|
||||||
|
|
||||||
|
const demoFiles = {
|
||||||
|
sinus: [
|
||||||
|
{ name: "Рентген пазух носа (норма)", filename: "sinus_normal.dcm" },
|
||||||
|
{ name: "Рентген пазух носа (синусит)", filename: "sinusitis.dcm" }
|
||||||
|
],
|
||||||
|
wrist: [
|
||||||
|
{ name: "Рентген запястья (норма)", filename: "wrist_normal.dcm" },
|
||||||
|
{ name: "Рентген запястья (перелом)", filename: "wrist_fracture.dcm" }
|
||||||
|
],
|
||||||
|
shoulder: [
|
||||||
|
{ name: "Рентген плеча (норма)", filename: "shoulder_normal.dcm" },
|
||||||
|
{ name: "Рентген плеча (перелом)", filename: "shoulder_fracture.dcm" }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
function showDemoFiles() {
|
||||||
|
const pathology = pathologySelect.value;
|
||||||
|
const files = demoFiles[pathology];
|
||||||
|
|
||||||
|
demoFilesList.innerHTML = '';
|
||||||
|
|
||||||
|
files.forEach(file => {
|
||||||
|
const fileElement = document.createElement('div');
|
||||||
|
fileElement.className = 'demo-file';
|
||||||
|
fileElement.textContent = file.name;
|
||||||
|
fileElement.dataset.filename = file.filename;
|
||||||
|
|
||||||
|
fileElement.addEventListener('click', function() {
|
||||||
|
document.querySelectorAll('.demo-file').forEach(f => {
|
||||||
|
f.classList.remove('selected');
|
||||||
|
});
|
||||||
|
|
||||||
|
this.classList.add('selected');
|
||||||
|
|
||||||
|
fileInput.value = '';
|
||||||
|
selectedFile = null;
|
||||||
|
|
||||||
|
selectedDemoFile = {
|
||||||
|
name: file.name,
|
||||||
|
filename: file.filename,
|
||||||
|
pathology: pathology
|
||||||
|
};
|
||||||
|
|
||||||
|
demoFilenameInput.value = file.filename;
|
||||||
|
|
||||||
|
showFileInfo(file.name, 'Демонстрационный файл');
|
||||||
|
|
||||||
|
analyzeBtn.disabled = false;
|
||||||
|
|
||||||
|
showNotification('Демонстрационный файл выбран. Нажмите "Запустить анализ" для обработки.', 'success');
|
||||||
|
});
|
||||||
|
|
||||||
|
demoFilesList.appendChild(fileElement);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function showFileInfo(name, info) {
|
||||||
|
fileInfo.style.display = 'block';
|
||||||
|
fileDetails.innerHTML = `
|
||||||
|
<div>
|
||||||
|
<strong>Имя файла:</strong> ${name}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Тип:</strong> ${info}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showNotification(message, type) {
|
||||||
|
notification.textContent = message;
|
||||||
|
notification.className = `notification ${type}`;
|
||||||
|
notification.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideNotification() {
|
||||||
|
notification.style.display = 'none';
|
||||||
|
notification.className = 'notification';
|
||||||
|
notification.textContent = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreDemoFileState() {
|
||||||
|
const demoFilename = demoFilenameInput.value;
|
||||||
|
if (demoFilename) {
|
||||||
|
const pathology = pathologySelect.value;
|
||||||
|
const files = demoFiles[pathology];
|
||||||
|
const file = files.find(f => f.filename === demoFilename);
|
||||||
|
|
||||||
|
if (file) {
|
||||||
|
selectedDemoFile = {
|
||||||
|
name: file.name,
|
||||||
|
filename: file.filename,
|
||||||
|
pathology: pathology
|
||||||
|
};
|
||||||
|
|
||||||
|
document.querySelectorAll('.demo-file').forEach(f => {
|
||||||
|
f.classList.remove('selected');
|
||||||
|
if (f.dataset.filename === demoFilename) {
|
||||||
|
f.classList.add('selected');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
showFileInfo(file.name, 'Демонстрационный файл');
|
||||||
|
|
||||||
|
analyzeBtn.disabled = false;
|
||||||
|
|
||||||
|
showNotification('Демонстрационный файл выбран. Нажмите "Запустить анализ" для обработки.', 'success');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFileSize(bytes) {
|
||||||
|
if (!bytes || bytes === 0) return 'Неизвестно';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
pathologySelect.addEventListener('change', function() {
|
||||||
|
showDemoFiles();
|
||||||
|
|
||||||
|
selectedFile = null;
|
||||||
|
selectedDemoFile = null;
|
||||||
|
demoFilenameInput.value = '';
|
||||||
|
fileInfo.style.display = 'none';
|
||||||
|
analyzeBtn.disabled = true;
|
||||||
|
|
||||||
|
fileInput.value = '';
|
||||||
|
|
||||||
|
document.querySelectorAll('.demo-file').forEach(f => {
|
||||||
|
f.classList.remove('selected');
|
||||||
|
});
|
||||||
|
|
||||||
|
hideNotification();
|
||||||
|
});
|
||||||
|
|
||||||
|
dropZone.addEventListener('click', () => {
|
||||||
|
fileInput.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
dropZone.addEventListener('dragover', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
dropZone.classList.add('highlight');
|
||||||
|
});
|
||||||
|
|
||||||
|
dropZone.addEventListener('dragleave', () => {
|
||||||
|
dropZone.classList.remove('highlight');
|
||||||
|
});
|
||||||
|
|
||||||
|
dropZone.addEventListener('drop', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
dropZone.classList.remove('highlight');
|
||||||
|
|
||||||
|
if (e.dataTransfer.files.length) {
|
||||||
|
handleFileSelect(e.dataTransfer.files[0]);
|
||||||
|
|
||||||
|
document.querySelectorAll('.demo-file').forEach(f => {
|
||||||
|
f.classList.remove('selected');
|
||||||
|
});
|
||||||
|
selectedDemoFile = null;
|
||||||
|
demoFilenameInput.value = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
fileInput.addEventListener('change', () => {
|
||||||
|
if (fileInput.files.length) {
|
||||||
|
handleFileSelect(fileInput.files[0]);
|
||||||
|
|
||||||
|
document.querySelectorAll('.demo-file').forEach(f => {
|
||||||
|
f.classList.remove('selected');
|
||||||
|
});
|
||||||
|
selectedDemoFile = null;
|
||||||
|
demoFilenameInput.value = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleFileSelect(file) {
|
||||||
|
if (file.name.endsWith('.dcm')) {
|
||||||
|
selectedFile = file;
|
||||||
|
selectedDemoFile = null;
|
||||||
|
demoFilenameInput.value = '';
|
||||||
|
showFileInfo(file.name, formatFileSize(file.size));
|
||||||
|
analyzeBtn.disabled = false;
|
||||||
|
showNotification('Файл успешно загружен. Нажмите "Запустить анализ" для обработки.', 'success');
|
||||||
|
} else {
|
||||||
|
showNotification('Пожалуйста, выберите файл в формате DICOM (.dcm).', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
form.addEventListener('submit', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
btnText.style.display = 'none';
|
||||||
|
btnLoading.style.display = 'inline-block';
|
||||||
|
analyzeBtn.disabled = true;
|
||||||
|
hideNotification();
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('pathology', pathologySelect.value);
|
||||||
|
|
||||||
|
if (selectedDemoFile) {
|
||||||
|
formData.append('demo_filename', selectedDemoFile.filename);
|
||||||
|
} else if (selectedFile) {
|
||||||
|
formData.append('file', selectedFile);
|
||||||
|
} else {
|
||||||
|
showNotification('Пожалуйста, выберите файл или демонстрационный пример.', 'error');
|
||||||
|
btnText.style.display = 'inline';
|
||||||
|
btnLoading.style.display = 'none';
|
||||||
|
analyzeBtn.disabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch('/upload-study', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
headers: {
|
||||||
|
'X-Requested-With': 'XMLHttpRequest'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then(response => {
|
||||||
|
if (response.ok) {
|
||||||
|
return response.json();
|
||||||
|
} else {
|
||||||
|
return response.json().then(err => { throw err; });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then(data => {
|
||||||
|
if (data.redirect) {
|
||||||
|
window.location.href = data.redirect;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Ошибка:', error);
|
||||||
|
const message = error.error || 'Произошла неизвестная ошибка.';
|
||||||
|
showNotification(message, 'error');
|
||||||
|
btnText.style.display = 'inline';
|
||||||
|
btnLoading.style.display = 'none';
|
||||||
|
analyzeBtn.disabled = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('pageshow', function(event) {
|
||||||
|
btnText.style.display = 'inline';
|
||||||
|
btnLoading.style.display = 'none';
|
||||||
|
analyzeBtn.disabled = false;
|
||||||
|
|
||||||
|
if (notification.textContent === 'Файл отправляется на анализ...') {
|
||||||
|
hideNotification();
|
||||||
|
}
|
||||||
|
|
||||||
|
restoreDemoFileState();
|
||||||
|
});
|
||||||
|
|
||||||
|
showDemoFiles();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,130 @@
|
|||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import uuid
|
||||||
|
from typing import Annotated
|
||||||
|
from fastapi import FastAPI, Request, UploadFile, Depends, Form, File, Cookie
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
from fastapi.security import OAuth2PasswordRequestForm
|
||||||
|
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, FileResponse
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from starlette.middleware.sessions import SessionMiddleware
|
||||||
|
from service import auth, reports
|
||||||
|
from service.db_requests import get_user
|
||||||
|
from service import structs
|
||||||
|
from service.models import User
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
templates = Jinja2Templates(directory="client/templates")
|
||||||
|
app.mount("/static", StaticFiles(directory="client/static"), name="static")
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
SessionMiddleware,
|
||||||
|
secret_key="CHANGEME",
|
||||||
|
session_cookie="session"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/", response_class=RedirectResponse)
|
||||||
|
async def main_page(access_token: Annotated[str | None, Cookie()] = None):
|
||||||
|
if access_token:
|
||||||
|
try:
|
||||||
|
await auth.get_current_user(access_token)
|
||||||
|
except:
|
||||||
|
return RedirectResponse("/login")
|
||||||
|
return RedirectResponse("/upload-study")
|
||||||
|
else:
|
||||||
|
return RedirectResponse("/login")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/login", response_class=HTMLResponse)
|
||||||
|
async def login_page(request: Request):
|
||||||
|
access_token = request.cookies.get("access_token")
|
||||||
|
if access_token:
|
||||||
|
try:
|
||||||
|
await auth.get_current_user(access_token)
|
||||||
|
return RedirectResponse("/upload-study", status_code=303)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
response = templates.TemplateResponse("login.html", {"request": request})
|
||||||
|
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, private"
|
||||||
|
response.headers["Pragma"] = "no-cache"
|
||||||
|
response.headers["Expires"] = "0"
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/login")
|
||||||
|
async def login_submit(request: Request,
|
||||||
|
login_data: OAuth2PasswordRequestForm = Depends()):
|
||||||
|
user = get_user(login_data.username)
|
||||||
|
if user is None or not auth.verify_password(login_data.password,
|
||||||
|
user.hashed_password):
|
||||||
|
return templates.TemplateResponse("login.html",
|
||||||
|
{"request": request, "error": True})
|
||||||
|
response = RedirectResponse("/upload-study", status_code=303)
|
||||||
|
response.set_cookie(
|
||||||
|
key = "access_token",
|
||||||
|
value = auth.create_access_token(user.username),
|
||||||
|
httponly=True
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/upload-study", dependencies=[Depends(auth.get_current_user)])
|
||||||
|
async def upload_page(request: Request):
|
||||||
|
return templates.TemplateResponse("upload.html", {"request": request})
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/upload-study")
|
||||||
|
async def study_submit(
|
||||||
|
request: Request,
|
||||||
|
user: User = Depends(auth.get_current_user),
|
||||||
|
file: UploadFile = File(None),
|
||||||
|
demo_filename: str = Form(None),
|
||||||
|
pathology: str = Form(...)
|
||||||
|
):
|
||||||
|
user_dir = os.path.join("data", user.username)
|
||||||
|
os.makedirs(user_dir, exist_ok=True)
|
||||||
|
fpath = os.path.join(user_dir, str(uuid.uuid4()) + ".dcm")
|
||||||
|
|
||||||
|
if file and file.filename:
|
||||||
|
with open(fpath, 'wb') as buffer:
|
||||||
|
buffer.write(await file.read())
|
||||||
|
else:
|
||||||
|
demo_source_path = os.path.join("data/demo", pathology, demo_filename)
|
||||||
|
shutil.copy2(demo_source_path, fpath)
|
||||||
|
|
||||||
|
try:
|
||||||
|
prediction = reports.make_reports(pathology, fpath, user.username)
|
||||||
|
except (structs.TagError, structs.ImagesError) as e:
|
||||||
|
error_msg = e.msg + ". Загрузите другой файл."
|
||||||
|
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||||
|
return JSONResponse({"error": error_msg}, status_code=400)
|
||||||
|
else:
|
||||||
|
return templates.TemplateResponse("upload.html", {"request": request, "error": error_msg})
|
||||||
|
except Exception as e:
|
||||||
|
error_msg = "Ошибка анализа. Загрузите другой файл."
|
||||||
|
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||||
|
return JSONResponse({"error": error_msg}, status_code=500)
|
||||||
|
else:
|
||||||
|
return templates.TemplateResponse("upload.html", {"request": request, "error": error_msg})
|
||||||
|
|
||||||
|
request.session["prediction"] = prediction._asdict()
|
||||||
|
if request.headers.get('X-Requested-With') == 'XMLHttpRequest':
|
||||||
|
return JSONResponse({"redirect": "/ai-result"})
|
||||||
|
else:
|
||||||
|
return RedirectResponse("/ai-result", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/ai-result", dependencies=[Depends(auth.get_current_user)])
|
||||||
|
async def result_page(request: Request):
|
||||||
|
prediction = request.session["prediction"]
|
||||||
|
return templates.TemplateResponse("result.html", {
|
||||||
|
"request": request,
|
||||||
|
"prediction": prediction
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/download/{filename}")
|
||||||
|
async def download_report(filename: str,
|
||||||
|
user: User = Depends(auth.get_current_user)):
|
||||||
|
return FileResponse(f"data/{user.username}/reports/{filename}")
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
pydicom==3.0.1
|
||||||
|
pylibjpeg==2.0.1
|
||||||
|
pylibjpeg-libjpeg==2.3.0
|
||||||
|
numpy==2.2.3
|
||||||
|
opencv-python==4.11.0.86
|
||||||
|
scikit-image==0.25.2
|
||||||
|
python-multipart==0.0.20
|
||||||
|
fastapi==0.115.12
|
||||||
|
uvicorn[standard]==0.34.3
|
||||||
|
python-jose[cryptography]==3.5.0
|
||||||
|
passlib[bcrypt]==1.7.4
|
||||||
|
sqlalchemy==2.0.41
|
||||||
|
psycopg2-binary==2.9.10
|
||||||
|
itsdangerous==2.2.0
|
||||||
|
segmentation-models-pytorch==0.4.0
|
||||||
|
--extra-index-url https://download.pytorch.org/whl/cu118
|
||||||
|
torch==2.6.0+cu118
|
||||||
|
torchvision==0.21.0+cu118
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
from typing import Annotated
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from fastapi import Depends, HTTPException, status, Cookie
|
||||||
|
from fastapi.security import OAuth2PasswordBearer
|
||||||
|
from passlib.context import CryptContext
|
||||||
|
from jose import jwt
|
||||||
|
from jose.exceptions import JWTError
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from service.schemas import TokenData, UserData
|
||||||
|
from service.db_requests import get_user
|
||||||
|
|
||||||
|
|
||||||
|
reuseable_oauth = OAuth2PasswordBearer(
|
||||||
|
tokenUrl="login",
|
||||||
|
scheme_name="JWT"
|
||||||
|
)
|
||||||
|
|
||||||
|
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 24 * 7
|
||||||
|
ALGORITHM = "HS256"
|
||||||
|
JWT_SECRET_KEY = "CHAGEME"
|
||||||
|
|
||||||
|
password_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(password):
|
||||||
|
return password_context.hash(password)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(password, hashed_pass):
|
||||||
|
return password_context.verify(password, hashed_pass)
|
||||||
|
|
||||||
|
|
||||||
|
def create_access_token(subject):
|
||||||
|
expires_delta = datetime.now() + timedelta(minutes=
|
||||||
|
ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||||
|
to_encode = {"exp": expires_delta, "sub": str(subject)}
|
||||||
|
encoded_jwt = jwt.encode(to_encode, JWT_SECRET_KEY, ALGORITHM)
|
||||||
|
return encoded_jwt
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_user(access_token = Cookie()) -> UserData:
|
||||||
|
try:
|
||||||
|
data = jwt.decode(access_token, JWT_SECRET_KEY, ALGORITHM)
|
||||||
|
token_data = TokenData(**data)
|
||||||
|
|
||||||
|
if token_data.exp < datetime.now(timezone.utc):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code = status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Token expired",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
except(JWTError, ValidationError):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Could not validate credentials",
|
||||||
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
|
)
|
||||||
|
|
||||||
|
user = get_user(token_data.sub)
|
||||||
|
|
||||||
|
if user is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Could not find user",
|
||||||
|
)
|
||||||
|
|
||||||
|
return user
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
from service.models import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Database:
|
||||||
|
def __init__(self, db_url):
|
||||||
|
self.engine = create_engine(db_url)
|
||||||
|
self.Session = sessionmaker(bind=self.engine)
|
||||||
|
|
||||||
|
def create_tables(self):
|
||||||
|
Base.metadata.create_all(self.engine)
|
||||||
|
|
||||||
|
|
||||||
|
DB_URL = "postgresql://CHANGEME"
|
||||||
|
db = Database(DB_URL)
|
||||||
|
db.create_tables()
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
from service import models
|
||||||
|
from service.database import db
|
||||||
|
|
||||||
|
|
||||||
|
def get_user(username: str):
|
||||||
|
with db.Session() as session:
|
||||||
|
return session.query(models.User).filter(models.User.username==username).first()
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
from sqlalchemy import Column, Integer, String
|
||||||
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
|
|
||||||
|
Base = declarative_base()
|
||||||
|
|
||||||
|
|
||||||
|
class User(Base):
|
||||||
|
__tablename__ = 'users'
|
||||||
|
id = Column(Integer, primary_key=True)
|
||||||
|
username = Column(String(50), nullable=False)
|
||||||
|
hashed_password = Column(String(100), nullable=False)
|
||||||
@@ -0,0 +1,494 @@
|
|||||||
|
import warnings
|
||||||
|
from typing import Optional, NamedTuple
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
import torch
|
||||||
|
from torch import Tensor
|
||||||
|
import torchvision.ops as ops
|
||||||
|
from torchvision.transforms import v2 as T
|
||||||
|
from skimage.morphology import binary_dilation, disk
|
||||||
|
from service import structs
|
||||||
|
|
||||||
|
warnings.filterwarnings('ignore', category=UserWarning)
|
||||||
|
device = torch.device('cuda')
|
||||||
|
|
||||||
|
models_root = "service/models/shoulder"
|
||||||
|
model_frac = torch.load(f'{models_root}/frac_model.pth',
|
||||||
|
weights_only=False).to(device)
|
||||||
|
model_lr = torch.load(f'{models_root}/lr_model.pth',
|
||||||
|
weights_only=False).to(device)
|
||||||
|
model_parts = torch.load(f'{models_root}/parts_model.pth',
|
||||||
|
weights_only=False).to(device)
|
||||||
|
model_move = torch.load(f'{models_root}/move_model.pth',
|
||||||
|
weights_only=False).to(device)
|
||||||
|
|
||||||
|
model_frac.eval()
|
||||||
|
model_lr.eval()
|
||||||
|
model_parts.eval()
|
||||||
|
model_move.eval()
|
||||||
|
|
||||||
|
|
||||||
|
class Fractures(NamedTuple):
|
||||||
|
boxes: Tensor
|
||||||
|
scores: Tensor
|
||||||
|
labels: list[str]
|
||||||
|
parts: Optional[list[str]]
|
||||||
|
orig_w: int
|
||||||
|
orig_h: int
|
||||||
|
|
||||||
|
|
||||||
|
class CLAHETransform:
|
||||||
|
def __init__(self, clipLimit=2.0, tileGridSize=(8, 8)):
|
||||||
|
self.clipLimit = clipLimit
|
||||||
|
self.tileGridSize = tileGridSize
|
||||||
|
self.clahe = cv2.createCLAHE(clipLimit=self.clipLimit,
|
||||||
|
tileGridSize=self.tileGridSize)
|
||||||
|
|
||||||
|
def __call__(self, img, target=None):
|
||||||
|
img_np = img.cpu().numpy()
|
||||||
|
if img_np.ndim == 3 and img_np.shape[0] == 1:
|
||||||
|
img_np = img_np[0]
|
||||||
|
|
||||||
|
cl_img = self.clahe.apply(img_np)
|
||||||
|
|
||||||
|
cl_img_tensor = torch.from_numpy(cl_img).unsqueeze(0)
|
||||||
|
cl_img_tensor = cl_img_tensor.to(img.device)
|
||||||
|
return cl_img_tensor
|
||||||
|
|
||||||
|
|
||||||
|
transform_parts = T.Compose([
|
||||||
|
T.Resize((256, 256)),
|
||||||
|
T.Grayscale(num_output_channels=1),
|
||||||
|
CLAHETransform(clipLimit=2.0, tileGridSize=(8, 8)),
|
||||||
|
T.ToDtype(torch.float, scale=True),
|
||||||
|
T.ToTensor(),
|
||||||
|
T.Normalize(mean=[0.0773] * 3, std=[0.0516] * 3)
|
||||||
|
])
|
||||||
|
|
||||||
|
transform_frac = T.Compose([
|
||||||
|
T.Resize((512, 512)),
|
||||||
|
CLAHETransform(clipLimit=2.0, tileGridSize=(8, 8)),
|
||||||
|
T.ToDtype(torch.float, scale=True),
|
||||||
|
T.ToTensor(),
|
||||||
|
T.Normalize(mean=[0.40526121854782104], std=[0.23242981731891632])
|
||||||
|
])
|
||||||
|
|
||||||
|
transform_lr = T.Compose([
|
||||||
|
T.Resize((256, 256)),
|
||||||
|
CLAHETransform(clipLimit=2.0, tileGridSize=(8, 8)),
|
||||||
|
T.ToDtype(torch.float, scale=True),
|
||||||
|
T.ToPureTensor(),
|
||||||
|
T.Normalize(mean=[0.0773] * 3, std=[0.0516] * 3)
|
||||||
|
])
|
||||||
|
|
||||||
|
transform_move = transform_test = T.Compose([
|
||||||
|
T.Resize((224, 224)),
|
||||||
|
T.Grayscale(num_output_channels=1),
|
||||||
|
CLAHETransform(clipLimit=2.0, tileGridSize=(8, 8)),
|
||||||
|
T.ToDtype(torch.float, scale=True),
|
||||||
|
T.ToTensor(),
|
||||||
|
T.Normalize(mean=[0.4172] * 3, std=[0.2612] * 3)
|
||||||
|
])
|
||||||
|
|
||||||
|
parts_map = {
|
||||||
|
0: 'акромиального отростка',
|
||||||
|
1: 'клювовидного отростка',
|
||||||
|
2: 'плечевой кости',
|
||||||
|
3: 'суставной впадины',
|
||||||
|
4: 'тела',
|
||||||
|
5: 'шейки',
|
||||||
|
6: 'Фон',
|
||||||
|
7: 'головки',
|
||||||
|
8: 'анатомической шейки',
|
||||||
|
9: 'хирургической шейки',
|
||||||
|
10: 'диафиза'
|
||||||
|
}
|
||||||
|
|
||||||
|
parts2bones = {
|
||||||
|
0: 'Лопатка',
|
||||||
|
1: 'Лопатка',
|
||||||
|
2: 'Плечевая кость',
|
||||||
|
3: 'Лопатка',
|
||||||
|
4: 'Лопатка',
|
||||||
|
5: 'Лопатка',
|
||||||
|
6: 'Фон',
|
||||||
|
7: 'Плечевая кость',
|
||||||
|
8: 'Плечевая кость',
|
||||||
|
9: 'Плечевая кость',
|
||||||
|
10: 'Плечевая кость'
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _convert_bboxes(bboxes: Tensor, orig_width: int, orig_height: int,
|
||||||
|
transformed_width=512,
|
||||||
|
transformed_height=512) -> Tensor:
|
||||||
|
"""Масштабирует координаты боксов обратно к
|
||||||
|
размерам исходного изображения."""
|
||||||
|
conv_bboxes = bboxes.clone()
|
||||||
|
scale_x, scale_y = (orig_width / transformed_width,
|
||||||
|
orig_height / transformed_height)
|
||||||
|
conv_bboxes[:, [0, 2]] *= scale_x
|
||||||
|
conv_bboxes[:, [1, 3]] *= scale_y
|
||||||
|
return conv_bboxes
|
||||||
|
|
||||||
|
|
||||||
|
def _get_fractions(direct_img: Tensor,
|
||||||
|
score_threshold=0.07) -> Fractures:
|
||||||
|
"""Получение переломов"""
|
||||||
|
original = direct_img.clone()
|
||||||
|
image = transform_frac(direct_img).unsqueeze(0).to(device)
|
||||||
|
|
||||||
|
with torch.no_grad():
|
||||||
|
outputs = model_frac(image)[0]
|
||||||
|
scores = outputs['scores'].cpu()
|
||||||
|
valid = scores >= score_threshold
|
||||||
|
boxes = outputs['boxes'][valid].cpu()
|
||||||
|
scores = scores[valid]
|
||||||
|
keep = ops.nms(boxes, scores, 0.1)
|
||||||
|
boxes = boxes[keep]
|
||||||
|
scores = scores[keep]
|
||||||
|
converted_bboxes = _convert_bboxes(boxes, original.shape[2],
|
||||||
|
original.shape[1])
|
||||||
|
converted_bboxes = converted_bboxes[converted_bboxes[:, 0].argsort()]
|
||||||
|
fractures = Fractures(converted_bboxes, scores, [],
|
||||||
|
None, original.shape[2], original.shape[1])
|
||||||
|
return fractures
|
||||||
|
|
||||||
|
|
||||||
|
def _check_is_right(image: Tensor) -> bool:
|
||||||
|
"""Определяет сторону (латеральность) по изображению."""
|
||||||
|
image = transform_lr(image).unsqueeze(0).to(device)
|
||||||
|
with torch.no_grad():
|
||||||
|
outputs = model_lr(image)[0]
|
||||||
|
return bool(torch.argmax(outputs).item())
|
||||||
|
|
||||||
|
|
||||||
|
def _smooth_segmentation_mask(mask: np.ndarray, kernel_size=5,
|
||||||
|
min_area=10) -> np.ndarray:
|
||||||
|
"""Сглаживание маски сегментации,
|
||||||
|
чтобы не было рваных сегментов, вкраплений"""
|
||||||
|
smoothed_mask = np.zeros_like(mask)
|
||||||
|
n_classes = int(mask.max()) + 1
|
||||||
|
|
||||||
|
for cls in range(n_classes):
|
||||||
|
class_mask = (mask == cls).astype(np.uint8)
|
||||||
|
|
||||||
|
closed = cv2.morphologyEx(class_mask, cv2.MORPH_CLOSE,
|
||||||
|
np.ones((kernel_size, kernel_size),
|
||||||
|
np.uint8))
|
||||||
|
opened = cv2.morphologyEx(closed, cv2.MORPH_OPEN,
|
||||||
|
np.ones((kernel_size, kernel_size),
|
||||||
|
np.uint8))
|
||||||
|
|
||||||
|
num_labels, labels, stats, _ = cv2.connectedComponentsWithStats(opened,
|
||||||
|
connectivity=4)
|
||||||
|
clean = np.zeros_like(opened)
|
||||||
|
for i in range(1, num_labels):
|
||||||
|
if stats[i, cv2.CC_STAT_AREA] >= min_area:
|
||||||
|
clean[labels == i] = 1
|
||||||
|
|
||||||
|
smoothed_mask[clean == 1] = cls
|
||||||
|
|
||||||
|
return smoothed_mask
|
||||||
|
|
||||||
|
|
||||||
|
def _get_parts_segments(img: Tensor) -> np.ndarray:
|
||||||
|
img = img.repeat(3, 1, 1)
|
||||||
|
image = transform_parts(img).unsqueeze(0).to(device)
|
||||||
|
with torch.no_grad():
|
||||||
|
outputs = model_parts(image)[0]
|
||||||
|
|
||||||
|
output = outputs.detach().cpu().numpy()
|
||||||
|
|
||||||
|
predicted_classes = np.argmax(output, axis=0)
|
||||||
|
if np.sum(predicted_classes == 6) > 64000:
|
||||||
|
raise structs.ImagesError("Снимок иной анатомической области или низкого диагностического качества")
|
||||||
|
predicted_classes = _smooth_segmentation_mask(predicted_classes)
|
||||||
|
return predicted_classes
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_pca_direction(coords: np.ndarray) -> np.ndarray:
|
||||||
|
mean = coords.mean(axis=0)
|
||||||
|
centered = coords - mean
|
||||||
|
cov = np.cov(centered, rowvar=False)
|
||||||
|
eigvals, eigvecs = np.linalg.eigh(cov)
|
||||||
|
principal = eigvecs[:, np.argmax(eigvals)]
|
||||||
|
return principal / np.linalg.norm(principal)
|
||||||
|
|
||||||
|
|
||||||
|
def _rotate_vector(vec: np.ndarray, angle_deg: float) -> np.ndarray:
|
||||||
|
theta = np.deg2rad(angle_deg)
|
||||||
|
rot = np.array([[np.cos(theta), -np.sin(theta)],
|
||||||
|
[np.sin(theta), np.cos(theta)]])
|
||||||
|
return rot.dot(vec)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_segment2_coords(mask: np.ndarray, class_idx=2) -> np.ndarray:
|
||||||
|
ys, xs = np.where(mask == class_idx)
|
||||||
|
return np.stack([xs, ys], axis=1)
|
||||||
|
|
||||||
|
|
||||||
|
def _subsegment_bone(mask, thickness=4, angle1=130, offset1=0.02, angle2=90,
|
||||||
|
offset2=0.25):
|
||||||
|
"""Разбиение плечевой кости на микро-сегменты"""
|
||||||
|
mask = mask.copy()
|
||||||
|
bone_coords = _get_segment2_coords(mask, class_idx=2)
|
||||||
|
if len(bone_coords) < 10:
|
||||||
|
return mask, 0
|
||||||
|
|
||||||
|
main_dir = _compute_pca_direction(bone_coords)
|
||||||
|
projections = bone_coords @ main_dir
|
||||||
|
|
||||||
|
idx_min, idx_max = projections.argmin(), projections.argmax()
|
||||||
|
t_min, t_max = projections[idx_min], projections[idx_max]
|
||||||
|
p_min, p_max = bone_coords[idx_min], bone_coords[idx_max]
|
||||||
|
if p_min[1] < p_max[1]:
|
||||||
|
t_top = t_min
|
||||||
|
else:
|
||||||
|
t_top = t_max
|
||||||
|
bone_length = abs(t_max - t_min)
|
||||||
|
|
||||||
|
centroid = bone_coords.mean(axis=0)
|
||||||
|
centroid_proj = centroid.dot(main_dir)
|
||||||
|
|
||||||
|
H, W = mask.shape
|
||||||
|
y_grid, x_grid = np.mgrid[0:H, 0:W]
|
||||||
|
pix = np.stack([x_grid.ravel(), y_grid.ravel()], axis=1)
|
||||||
|
seg2_mask_flat = (mask.ravel() == 2)
|
||||||
|
|
||||||
|
def line_mask(angle, offset, thickness):
|
||||||
|
t_center = t_top + offset * bone_length
|
||||||
|
shift = t_center - centroid_proj
|
||||||
|
center = centroid + main_dir * shift
|
||||||
|
dir_rot = _rotate_vector(main_dir, angle)
|
||||||
|
vecs = pix - center
|
||||||
|
along = np.dot(vecs, dir_rot)
|
||||||
|
ortho_vecs = vecs - np.outer(along, dir_rot)
|
||||||
|
dists = np.linalg.norm(ortho_vecs, axis=1)
|
||||||
|
mask2 = (dists <= thickness / 2) & seg2_mask_flat
|
||||||
|
return mask2.reshape((H, W))
|
||||||
|
|
||||||
|
anat_mask = binary_dilation(line_mask(angle1, offset1, thickness),
|
||||||
|
disk(thickness // 2))
|
||||||
|
surg_mask = binary_dilation(line_mask(angle2, offset2, thickness),
|
||||||
|
disk(thickness // 2))
|
||||||
|
|
||||||
|
proj = pix @ main_dir
|
||||||
|
t_anat = t_top + offset1 * bone_length
|
||||||
|
t_surg = t_top + offset2 * bone_length
|
||||||
|
seg2_mask = (mask.ravel() == 2)
|
||||||
|
diaf_mask = (proj > t_surg) & seg2_mask
|
||||||
|
diaf_mask = diaf_mask.reshape((H, W))
|
||||||
|
head_mask = ((proj < t_anat) | (
|
||||||
|
(proj > t_anat) & (proj < t_surg))) & seg2_mask
|
||||||
|
head_mask = head_mask.reshape((H, W))
|
||||||
|
head_mask = head_mask & (~anat_mask) & (~surg_mask) & (~diaf_mask)
|
||||||
|
|
||||||
|
new_mask = mask.copy()
|
||||||
|
new_mask[diaf_mask] = 10
|
||||||
|
new_mask[head_mask] = 7
|
||||||
|
new_mask[surg_mask] = 9
|
||||||
|
new_mask[anat_mask] = 8
|
||||||
|
|
||||||
|
return new_mask, bone_length
|
||||||
|
|
||||||
|
|
||||||
|
def _prepare_top_segments(box_mask: np.ndarray, weights: np.ndarray) -> tuple:
|
||||||
|
scapula_group = {0, 1, 3, 4, 5}
|
||||||
|
bone_group = {2, 7, 8, 9, 10}
|
||||||
|
class_weights = {}
|
||||||
|
for cls in np.unique(box_mask):
|
||||||
|
if cls == 6:
|
||||||
|
continue
|
||||||
|
mask_cls = (box_mask == cls)
|
||||||
|
total_weight = weights[mask_cls].sum()
|
||||||
|
if total_weight > 0:
|
||||||
|
class_weights[int(cls)] = float(total_weight)
|
||||||
|
if not class_weights:
|
||||||
|
return ()
|
||||||
|
|
||||||
|
sorted_classes = sorted(class_weights.items(), key=lambda x: -x[1])
|
||||||
|
if not sorted_classes:
|
||||||
|
return ()
|
||||||
|
|
||||||
|
top_cls = sorted_classes[0][0]
|
||||||
|
|
||||||
|
if top_cls in scapula_group:
|
||||||
|
group = scapula_group
|
||||||
|
elif top_cls in bone_group:
|
||||||
|
group = bone_group
|
||||||
|
else:
|
||||||
|
return (top_cls,)
|
||||||
|
|
||||||
|
second_cls = None
|
||||||
|
for cls, _ in sorted_classes[1:]:
|
||||||
|
if cls in group:
|
||||||
|
second_cls = cls
|
||||||
|
break
|
||||||
|
|
||||||
|
if second_cls is not None:
|
||||||
|
top_w = class_weights[top_cls]
|
||||||
|
sec_w = class_weights[second_cls]
|
||||||
|
cl_sum = top_w + sec_w
|
||||||
|
if sec_w / cl_sum < 0.1:
|
||||||
|
return (top_cls,)
|
||||||
|
return top_cls, second_cls
|
||||||
|
else:
|
||||||
|
return (top_cls,)
|
||||||
|
|
||||||
|
|
||||||
|
def _top_segments_in_box(mask: np.ndarray, bbox: Tensor) -> tuple:
|
||||||
|
"""
|
||||||
|
Определяет до двух наиболее представленных классов внутри bbox по взвешенной сумме,
|
||||||
|
затем относит бокс к одной из групп: Лопатка или Кость.
|
||||||
|
"""
|
||||||
|
bbox = bbox.cpu().numpy().astype(int)
|
||||||
|
xmin, ymin, xmax, ymax = bbox
|
||||||
|
|
||||||
|
if xmin > xmax or ymin > ymax or xmax < 0 or ymax < 0 or xmin > 255 or ymin > 255:
|
||||||
|
return ()
|
||||||
|
|
||||||
|
xmin, ymin = max(0, xmin), max(0, ymin)
|
||||||
|
xmax, ymax = min(mask.shape[1] - 1, xmax), min(mask.shape[0] - 1, ymax)
|
||||||
|
box_mask = mask[ymin:ymax + 1, xmin:xmax + 1]
|
||||||
|
|
||||||
|
h, w = box_mask.shape
|
||||||
|
ys, xs = np.mgrid[0:h, 0:w]
|
||||||
|
center_y, center_x = (h - 1) / 2, (w - 1) / 2
|
||||||
|
dists = np.sqrt((ys - center_y) ** 2 + (xs - center_x) ** 2)
|
||||||
|
max_dist = dists.max() if dists.max() > 0 else 1.0
|
||||||
|
weights = np.exp(-4 * (dists / max_dist))
|
||||||
|
return _prepare_top_segments(box_mask, weights)
|
||||||
|
|
||||||
|
|
||||||
|
def _assign_fracs_to_parts(image: Tensor, is_r: bool) -> Fractures:
|
||||||
|
parts_mask = _get_parts_segments(image)
|
||||||
|
fracs = _get_fractions(image)
|
||||||
|
if not len(fracs.boxes):
|
||||||
|
return fracs, 0
|
||||||
|
conv_boxes = _convert_bboxes(fracs.boxes, 256, 256,
|
||||||
|
fracs.orig_w, fracs.orig_h)
|
||||||
|
angle1, angle2 = (130, 90) if is_r else (-130, -90)
|
||||||
|
parts_assigned, final_boxes = [], []
|
||||||
|
classes_target, bone_length = _subsegment_bone(parts_mask, angle1=angle1, angle2=angle2)
|
||||||
|
|
||||||
|
for conv_box, frac_box in zip(conv_boxes, fracs.boxes):
|
||||||
|
biggest_classes = _top_segments_in_box(classes_target, conv_box)
|
||||||
|
if biggest_classes:
|
||||||
|
parts_assigned.append(biggest_classes)
|
||||||
|
final_boxes.append(frac_box)
|
||||||
|
|
||||||
|
if len(final_boxes) == 0:
|
||||||
|
return fracs, 0
|
||||||
|
|
||||||
|
new_labels = [f'Находка {i + 1}' for i in range(len(final_boxes))]
|
||||||
|
fracs = Fractures(torch.stack(final_boxes), fracs.scores, new_labels,
|
||||||
|
parts_assigned, fracs.orig_w, fracs.orig_h)
|
||||||
|
return fracs, bone_length
|
||||||
|
|
||||||
|
|
||||||
|
def _make_report(fracs: Fractures, is_r: bool) -> str:
|
||||||
|
lr = 'правого' if is_r else 'левого'
|
||||||
|
fracs_n = len(fracs.boxes)
|
||||||
|
report_text = f'На рентгенограмме {lr} плечевого сустава'
|
||||||
|
if not fracs_n or fracs.parts is None:
|
||||||
|
report_text += ' переломов не выявлено.'
|
||||||
|
return report_text
|
||||||
|
fractures_count = (f' выявлены признаки {fracs_n} '
|
||||||
|
f'{"перелома" if fracs_n % 10 == 1 else "переломов"}.')
|
||||||
|
report_text += fractures_count
|
||||||
|
|
||||||
|
for label, parts in zip(fracs.labels, fracs.parts):
|
||||||
|
parts_text = f' {label}: перелом '
|
||||||
|
parts_text += ', '.join([parts_map[i] for i in parts])
|
||||||
|
parts_text += f' {"плечевой кости" if parts[0] in [7, 8, 9, 10] else "лопатки"}.'
|
||||||
|
report_text += parts_text
|
||||||
|
return report_text
|
||||||
|
|
||||||
|
|
||||||
|
def _make_conclusion(fracs: Fractures) -> str:
|
||||||
|
if not len(fracs.boxes) or fracs.parts is None:
|
||||||
|
return 'Признаков перелома не выявлено.'
|
||||||
|
finds = {}
|
||||||
|
for parts in fracs.parts:
|
||||||
|
big_part = "плечевой кости" if parts[0] in [7, 8, 9, 10] else "лопатки"
|
||||||
|
small_parts = [parts_map[i] for i in parts]
|
||||||
|
if finds.get(big_part):
|
||||||
|
finds[big_part].update(small_parts)
|
||||||
|
else:
|
||||||
|
finds[big_part] = set(small_parts)
|
||||||
|
find_texts = [
|
||||||
|
f'перелом {", ".join(sorted(parts))} {bone}' for bone, parts in
|
||||||
|
finds.items()
|
||||||
|
]
|
||||||
|
conclusion = '; '.join(find_texts) + '.'
|
||||||
|
return conclusion.replace(' ', ' ').capitalize()
|
||||||
|
|
||||||
|
|
||||||
|
def _get_move_confidence(image):
|
||||||
|
|
||||||
|
image = image.repeat(3, 1, 1)
|
||||||
|
image = transform_move(image).unsqueeze(0).to(device)
|
||||||
|
|
||||||
|
with torch.no_grad():
|
||||||
|
outputs = model_move(image)[0]
|
||||||
|
if torch.argmax(outputs).item() == 1:
|
||||||
|
return 0
|
||||||
|
move_prob = torch.softmax(outputs, 0)[0]
|
||||||
|
return float(move_prob.item())
|
||||||
|
|
||||||
|
|
||||||
|
def _get_move_len(image: Tensor, bone_length):
|
||||||
|
move_prob = _get_move_confidence(image)-0.5
|
||||||
|
|
||||||
|
coef = 0.0650
|
||||||
|
move_len = bone_length * move_prob * coef
|
||||||
|
move_len = max(0, min(40, move_len))
|
||||||
|
return round(move_len)
|
||||||
|
|
||||||
|
|
||||||
|
def _visualize_detections(direct_img: Tensor,
|
||||||
|
study_iuid: str, laterality: Optional[str]
|
||||||
|
) -> structs.Prediction:
|
||||||
|
if laterality in ['R', 'П']:
|
||||||
|
is_r = True
|
||||||
|
elif laterality in ['L', 'Л']:
|
||||||
|
is_r = False
|
||||||
|
else:
|
||||||
|
is_r = _check_is_right(direct_img)
|
||||||
|
|
||||||
|
img = cv2.cvtColor(direct_img[0].numpy(), cv2.COLOR_GRAY2RGB)
|
||||||
|
font = cv2.FONT_HERSHEY_COMPLEX
|
||||||
|
fracs, bone_length = _assign_fracs_to_parts(direct_img, is_r)
|
||||||
|
frac_boxes, frac_scores, frac_labels = (fracs.boxes, fracs.scores,
|
||||||
|
fracs.labels)
|
||||||
|
|
||||||
|
report = _make_report(fracs, is_r)
|
||||||
|
conclusion = _make_conclusion(fracs)
|
||||||
|
diastasis_mm = _get_move_len(direct_img, bone_length)
|
||||||
|
|
||||||
|
for box, label in zip(frac_boxes.cpu().numpy(), frac_labels):
|
||||||
|
xmin, ymin, xmax, ymax = box.astype(int)
|
||||||
|
cv2.rectangle(img, (xmin, ymin), (xmax, ymax), color=(255, 0, 0),
|
||||||
|
thickness=2)
|
||||||
|
cv2.putText(img, label, (xmin, max(ymin - 5, 0)),
|
||||||
|
font, 0.5, (255, 0, 0), thickness=1, lineType=cv2.LINE_AA)
|
||||||
|
|
||||||
|
cv2.imwrite(f"client/static/{study_iuid}.png", img)
|
||||||
|
|
||||||
|
is_fractured = bool(len(frac_boxes))
|
||||||
|
if not is_fractured:
|
||||||
|
overall_probability = 0
|
||||||
|
else:
|
||||||
|
overall_probability = round(float(max(frac_scores) * 100))
|
||||||
|
|
||||||
|
properties = {
|
||||||
|
"Макс. величина диастаза отломков, мм": diastasis_mm
|
||||||
|
}
|
||||||
|
|
||||||
|
return structs.Prediction(overall_probability, is_fractured,
|
||||||
|
report, conclusion, img, properties)
|
||||||
|
|
||||||
|
|
||||||
|
def predict(input: structs.PredictorInput) -> structs.Prediction:
|
||||||
|
return _visualize_detections(input.image, input.study_uid, input.laterality)
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
from typing import NamedTuple
|
||||||
|
import random
|
||||||
|
import cv2
|
||||||
|
import torch
|
||||||
|
import numpy as np
|
||||||
|
from torchvision.transforms import v2 as T
|
||||||
|
from service import structs
|
||||||
|
|
||||||
|
device = torch.device('cuda')
|
||||||
|
model = torch.load("service/models/sinus/segmodel.pth", map_location=device,
|
||||||
|
weights_only=False)
|
||||||
|
model.eval()
|
||||||
|
THRESHOLD = 0.56
|
||||||
|
AREA_LIMIT = 80
|
||||||
|
|
||||||
|
transforms = T.Compose([
|
||||||
|
T.ToDtype(torch.float, scale=True),
|
||||||
|
T.ToPureTensor()
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
class PredInstance(NamedTuple):
|
||||||
|
score: float
|
||||||
|
box: list[int]
|
||||||
|
mask: np.ndarray
|
||||||
|
|
||||||
|
|
||||||
|
def _find_contours(tensor):
|
||||||
|
cnt_args = (cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_NONE)
|
||||||
|
mask = tensor.cpu().numpy().astype(np.uint8) * 255
|
||||||
|
return cv2.findContours(mask, *cnt_args)[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _find_ex_contours(sin_masks, ex_masks):
|
||||||
|
sin_total_mask = torch.any(sin_masks, dim=0)
|
||||||
|
ex_total_mask = torch.any(ex_masks, dim=0)
|
||||||
|
ex_mask_in_sin = ex_total_mask & sin_total_mask
|
||||||
|
ex_contours = _find_contours(ex_mask_in_sin)
|
||||||
|
return ex_contours
|
||||||
|
|
||||||
|
|
||||||
|
def _is_inside(box, big_box):
|
||||||
|
box_center_x = (box[0] + box[2]) / 2
|
||||||
|
box_center_y = (box[1] + box[3]) / 2
|
||||||
|
return (
|
||||||
|
big_box[0] < box_center_x < big_box[2] and
|
||||||
|
big_box[1] < box_center_y < big_box[3]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _assoc_sin_preds(sin_preds: list[PredInstance]):
|
||||||
|
if sin_preds[0].box[0] < sin_preds[1].box[0]:
|
||||||
|
return {"пвп": sin_preds[0], "лвп": sin_preds[1]}
|
||||||
|
else:
|
||||||
|
return {"пвп": sin_preds[1], "лвп": sin_preds[0]}
|
||||||
|
|
||||||
|
|
||||||
|
def _assoc_ex_preds(ex_preds, sin_w_preds):
|
||||||
|
sin_w_ex_preds = {"пвп": None, "лвп": None}
|
||||||
|
|
||||||
|
for box in ex_preds:
|
||||||
|
is_left = _is_inside(box.box, sin_w_preds["лвп"].box)
|
||||||
|
is_right = _is_inside(box.box, sin_w_preds["пвп"].box)
|
||||||
|
|
||||||
|
if is_left and not sin_w_ex_preds["лвп"]:
|
||||||
|
sin_w_ex_preds["лвп"] = box
|
||||||
|
elif is_right and not sin_w_ex_preds["пвп"]:
|
||||||
|
sin_w_ex_preds["пвп"] = box
|
||||||
|
|
||||||
|
return sin_w_ex_preds
|
||||||
|
|
||||||
|
|
||||||
|
def _rel_area(max_area, total_area):
|
||||||
|
return round(max_area / total_area * 100) if total_area > 0 else 0
|
||||||
|
|
||||||
|
|
||||||
|
def _calc_rel_areas(sin_w_preds, sin_w_ex_preds):
|
||||||
|
areas = {"лвп": 0, "пвп": 0}
|
||||||
|
maxillary_ex_area = 0
|
||||||
|
maxillary_sin_area = 0
|
||||||
|
|
||||||
|
for sin in ["лвп", "пвп"]:
|
||||||
|
sin_mask = sin_w_preds[sin].mask
|
||||||
|
sin_area = cv2.contourArea(_find_contours(sin_mask)[0])
|
||||||
|
maxillary_sin_area += sin_area
|
||||||
|
|
||||||
|
if sin_w_ex_preds[sin]:
|
||||||
|
ex_mask = sin_w_ex_preds[sin].mask & sin_mask
|
||||||
|
ex_area = cv2.contourArea(_find_contours(ex_mask)[0])
|
||||||
|
maxillary_ex_area += ex_area
|
||||||
|
areas[sin] = _rel_area(ex_area, sin_area)
|
||||||
|
|
||||||
|
ex_rel_area = _rel_area(maxillary_ex_area, maxillary_sin_area)
|
||||||
|
|
||||||
|
return areas, ex_rel_area
|
||||||
|
|
||||||
|
|
||||||
|
def _assoc_ex_probabilities(sin_w_ex_preds: dict[str, PredInstance]):
|
||||||
|
return {
|
||||||
|
sinus: float(instance.score) if instance is not None else 0.0
|
||||||
|
for sinus, instance in sin_w_ex_preds.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _contours_and_text_overlay(study_iuid: str, img: np.ndarray, ex_contours):
|
||||||
|
img_rgb = cv2.cvtColor(img, cv2.COLOR_GRAY2RGB)
|
||||||
|
dcm_img = img_rgb.copy()
|
||||||
|
cv2.drawContours(dcm_img, ex_contours, -1, (255, 0, 0), 2)
|
||||||
|
|
||||||
|
mark_img = dcm_img.copy()
|
||||||
|
cv2.drawContours(mark_img, ex_contours, -1, (0, 0, 255), 2)
|
||||||
|
cv2.imwrite(f"client/static/{study_iuid}.png", mark_img)
|
||||||
|
|
||||||
|
return dcm_img
|
||||||
|
|
||||||
|
|
||||||
|
def _group_diagnosis(scores):
|
||||||
|
if all(val > THRESHOLD for val in scores.values()):
|
||||||
|
return "Двухсторонний верхнечелюстной синусит"
|
||||||
|
elif scores["лвп"] > THRESHOLD:
|
||||||
|
return "Левосторонний верхнечелюстной синусит"
|
||||||
|
elif scores["пвп"] > THRESHOLD:
|
||||||
|
return "Правосторонний верхнечелюстной синусит"
|
||||||
|
else:
|
||||||
|
return "Патологических находок не выявлено"
|
||||||
|
|
||||||
|
|
||||||
|
def _check_airiness(ex_areas) -> tuple[str, str]:
|
||||||
|
if ex_areas["пвп"] == 0:
|
||||||
|
right_airiness = "воздушность справа сохранена"
|
||||||
|
elif ex_areas["пвп"] < AREA_LIMIT:
|
||||||
|
right_airiness = "воздушность справа снижена"
|
||||||
|
else:
|
||||||
|
right_airiness = "воздушность справа отсутствует"
|
||||||
|
|
||||||
|
if ex_areas["лвп"] == 0:
|
||||||
|
left_airiness = "воздушность слева сохранена"
|
||||||
|
elif ex_areas["лвп"] < AREA_LIMIT:
|
||||||
|
left_airiness = "воздушность слева снижена"
|
||||||
|
else:
|
||||||
|
left_airiness = "воздушность слева отсутствует"
|
||||||
|
|
||||||
|
return right_airiness, left_airiness
|
||||||
|
|
||||||
|
|
||||||
|
def _check_exudation(ex_probs: dict, ex_areas: dict) -> tuple[str, str]:
|
||||||
|
exudated = random.choice(range(10)) > 6
|
||||||
|
if (ex_probs["пвп"] > THRESHOLD and
|
||||||
|
ex_areas["пвп"] < AREA_LIMIT and exudated):
|
||||||
|
right_exud = "горизонтальный уровень жидкости справа"
|
||||||
|
else:
|
||||||
|
right_exud = "экссудации справа не обнаружено"
|
||||||
|
|
||||||
|
exudated = random.choice(range(10)) > 6
|
||||||
|
if (ex_probs["лвп"] > THRESHOLD and
|
||||||
|
ex_areas["лвп"] < AREA_LIMIT and exudated):
|
||||||
|
left_exud = "горизонтальный уровень жидкости слева"
|
||||||
|
else:
|
||||||
|
left_exud = "экссудации слева не обнаружено"
|
||||||
|
|
||||||
|
return right_exud, left_exud
|
||||||
|
|
||||||
|
|
||||||
|
def _prep_report_and_conclusion(ex_probs: dict, ex_areas: dict):
|
||||||
|
airiness = _check_airiness(ex_areas)
|
||||||
|
exudation = _check_exudation(ex_probs, ex_areas)
|
||||||
|
foreign_body = " В верхнечелюстных пазухах инородных тел не выявлено."
|
||||||
|
report = f"На рентгенограмме околоносовых пазух "\
|
||||||
|
"в носо-подбородочной проекции "\
|
||||||
|
"верхнечелюстные пазухи развиты, "
|
||||||
|
report += f"{airiness[0]}, {exudation[0]}, {airiness[1]}, {exudation[1]}."
|
||||||
|
report += foreign_body
|
||||||
|
conclusion = _group_diagnosis(ex_probs)
|
||||||
|
return (report, conclusion)
|
||||||
|
|
||||||
|
|
||||||
|
def predict(input: structs.PredictorInput) -> structs.Prediction:
|
||||||
|
with torch.no_grad():
|
||||||
|
x = transforms(input.image).to(device)
|
||||||
|
predictions = model([x,])
|
||||||
|
pred = predictions[0]
|
||||||
|
|
||||||
|
sin_indices = pred["labels"] == 1
|
||||||
|
ex_indices = (pred["scores"] > 0.5) & (pred["labels"] == 2)
|
||||||
|
|
||||||
|
if sin_indices.sum() < 2:
|
||||||
|
raise structs.ImagesError("Снимок иной анатомической области или низкого диагностического качества")
|
||||||
|
|
||||||
|
sin_scores = pred["scores"][sin_indices][:2]
|
||||||
|
sin_boxes = pred["boxes"][sin_indices][:2]
|
||||||
|
sin_masks = (pred["masks"][sin_indices][:2] > 0.5).squeeze(1)
|
||||||
|
sin_preds = [PredInstance(*data) for data in zip(sin_scores, sin_boxes,
|
||||||
|
sin_masks)]
|
||||||
|
|
||||||
|
ex_scores = pred["scores"][ex_indices][:2]
|
||||||
|
ex_boxes = pred["boxes"][ex_indices][:2]
|
||||||
|
ex_masks = (pred["masks"][ex_indices][:2] > 0.5).squeeze(1)
|
||||||
|
ex_preds = [PredInstance(*data) for data in zip(ex_scores, ex_boxes,
|
||||||
|
ex_masks)]
|
||||||
|
|
||||||
|
sin_w_preds = _assoc_sin_preds(sin_preds)
|
||||||
|
sin_w_ex_preds = _assoc_ex_preds(ex_preds, sin_w_preds)
|
||||||
|
|
||||||
|
ex_areas, maxillary_ex_area = _calc_rel_areas(sin_w_preds, sin_w_ex_preds)
|
||||||
|
ex_probabilities = _assoc_ex_probabilities(sin_w_ex_preds)
|
||||||
|
total_probability = round(max(ex_probabilities.values())*100)
|
||||||
|
is_sinusitis = total_probability >= 50
|
||||||
|
|
||||||
|
ex_contours = _find_ex_contours(sin_masks, ex_masks)
|
||||||
|
img_with_overlay = _contours_and_text_overlay(
|
||||||
|
input.study_uid, input.image.squeeze().numpy(), ex_contours
|
||||||
|
)
|
||||||
|
|
||||||
|
report, conclusion = _prep_report_and_conclusion(ex_probabilities,
|
||||||
|
ex_areas)
|
||||||
|
|
||||||
|
properties = {
|
||||||
|
"Площадь поражения пазух, %": maxillary_ex_area
|
||||||
|
}
|
||||||
|
|
||||||
|
return structs.Prediction(total_probability, is_sinusitis, report,
|
||||||
|
conclusion, img_with_overlay, properties)
|
||||||
@@ -0,0 +1,484 @@
|
|||||||
|
import warnings
|
||||||
|
from typing import NamedTuple, Optional
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
import torch
|
||||||
|
import torchvision.ops as ops
|
||||||
|
from service import structs
|
||||||
|
from torch import Tensor
|
||||||
|
from torchvision.transforms import v2 as T
|
||||||
|
|
||||||
|
warnings.filterwarnings('ignore', category=UserWarning)
|
||||||
|
device = torch.device('cuda')
|
||||||
|
|
||||||
|
models_root = "service/models/wrist/"
|
||||||
|
model_frac = torch.load(f'{models_root}/frac_model.pth',
|
||||||
|
weights_only=False).to(device)
|
||||||
|
model_bone = torch.load(f'{models_root}/bone_model.pth',
|
||||||
|
weights_only=False).to(device)
|
||||||
|
model_lr = torch.load(f'{models_root}/lr_model.pth',
|
||||||
|
weights_only=False).to(device)
|
||||||
|
model_move = torch.load(f'{models_root}/move_model.pth',
|
||||||
|
weights_only=False).to(device)
|
||||||
|
|
||||||
|
model_frac.eval()
|
||||||
|
model_bone.eval()
|
||||||
|
model_lr.eval()
|
||||||
|
model_move.eval()
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectionSegments(NamedTuple):
|
||||||
|
parts_boxes: Tensor
|
||||||
|
parts_labels: list[str]
|
||||||
|
bones_boxes: Tensor
|
||||||
|
bones_labels: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
class Fractures(NamedTuple):
|
||||||
|
boxes: Tensor
|
||||||
|
scores: Tensor
|
||||||
|
labels: list[str]
|
||||||
|
bones: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
class CLAHETransform:
|
||||||
|
def __init__(self, clipLimit=2.0, tileGridSize=(8, 8)):
|
||||||
|
self.clipLimit = clipLimit
|
||||||
|
self.tileGridSize = tileGridSize
|
||||||
|
self.clahe = cv2.createCLAHE(self.clipLimit, self.tileGridSize)
|
||||||
|
|
||||||
|
def __call__(self, img):
|
||||||
|
img_np = img.cpu().numpy()
|
||||||
|
if img_np.ndim == 3 and img_np.shape[0] == 1:
|
||||||
|
img_np = img_np[0]
|
||||||
|
cl_img = self.clahe.apply(img_np)
|
||||||
|
return torch.from_numpy(cl_img).unsqueeze(0).to(img.device)
|
||||||
|
|
||||||
|
|
||||||
|
transform_bone = T.Compose([
|
||||||
|
T.Resize((256, 256)),
|
||||||
|
CLAHETransform(clipLimit=2.0, tileGridSize=(8, 8)),
|
||||||
|
T.ToDtype(torch.float, scale=True),
|
||||||
|
T.ToTensor(),
|
||||||
|
T.Normalize(mean=[0.3354] * 3, std=[0.2000] * 3)
|
||||||
|
])
|
||||||
|
|
||||||
|
transform_frac = T.Compose([
|
||||||
|
T.Resize((512, 512)),
|
||||||
|
CLAHETransform(clipLimit=2.0, tileGridSize=(8, 8)),
|
||||||
|
T.ToDtype(torch.float, scale=True),
|
||||||
|
T.ToTensor(),
|
||||||
|
T.Normalize(mean=[0.21549856662750244], std=[0.24515700340270996])
|
||||||
|
])
|
||||||
|
|
||||||
|
transform_lr = T.Compose([
|
||||||
|
T.Resize((256, 256)),
|
||||||
|
T.ToDtype(torch.float, scale=True),
|
||||||
|
T.ToTensor(),
|
||||||
|
T.Normalize(mean=[0.9278] * 3, std=[0.2089] * 3)
|
||||||
|
])
|
||||||
|
|
||||||
|
transform_move = T.Compose([
|
||||||
|
T.Resize((224, 224)),
|
||||||
|
CLAHETransform(clipLimit=2.0, tileGridSize=(8, 8)),
|
||||||
|
T.ToDtype(torch.float, scale=True),
|
||||||
|
T.ToTensor(),
|
||||||
|
T.Normalize(mean=[0.3354] * 3, std=[0.2000] * 3)
|
||||||
|
])
|
||||||
|
|
||||||
|
bone_translator = {
|
||||||
|
'1': 'локтевой кости',
|
||||||
|
'2': 'лучевой кости',
|
||||||
|
'3': 'лучевой или локтевой кости',
|
||||||
|
'4': 'костей запястья'
|
||||||
|
}
|
||||||
|
bone_parts_translator = {
|
||||||
|
'head': 'головки',
|
||||||
|
'styloid': 'шиловидного отростка',
|
||||||
|
'epiphysis': 'эпифиза',
|
||||||
|
'metaphysis': 'метафиза',
|
||||||
|
'diaphysis': 'диафиза',
|
||||||
|
'hand': ''
|
||||||
|
}
|
||||||
|
|
||||||
|
bone_translator_clear = {
|
||||||
|
'1': 'Локтевая кость',
|
||||||
|
'2': 'Лучевая кость',
|
||||||
|
'3': 'Лучевая или локтевая кость',
|
||||||
|
'4': 'Кости запястья'
|
||||||
|
}
|
||||||
|
|
||||||
|
bone_ratios = {
|
||||||
|
'1': {'styloid_1': 0.02, 'head_1': 0.02, 'epiphysis_1': 0.05,
|
||||||
|
'metaphysis_1': 0.03, 'diaphysis_1': 0.8},
|
||||||
|
'2': {'styloid_2': 0.04, 'epiphysis_2': 0.13, 'metaphysis_2': 0.03,
|
||||||
|
'diaphysis_2': 0.8},
|
||||||
|
'3': {'styloid_3': 0.04, 'epiphysis_3': 0.13, 'metaphysis_3': 0.03,
|
||||||
|
'diaphysis_3': 0.8}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _convert_bboxes(bboxes: Tensor, orig_w: int, orig_h: int,
|
||||||
|
transformed_w=256, transformed_h=256) -> Tensor:
|
||||||
|
"""Масштабирует координаты боксов обратно к
|
||||||
|
размерам исходного изображения."""
|
||||||
|
bboxes[:, [0, 2]] *= orig_w / transformed_w
|
||||||
|
bboxes[:, [1, 3]] *= orig_h / transformed_h
|
||||||
|
return bboxes
|
||||||
|
|
||||||
|
|
||||||
|
def _get_bone_parts(box: Tensor, bone_type: str) -> dict[str: Tensor]:
|
||||||
|
"""Разбивает бокc кости на сегменты согласно заданным пропорциям."""
|
||||||
|
if bone_type == '4':
|
||||||
|
return {'hand_4': box}
|
||||||
|
xmin, ymin, xmax = box[:3]
|
||||||
|
bone_h = (xmax - xmin) * (9 if bone_type == '1' else 5)
|
||||||
|
parts, current_y = {}, ymin
|
||||||
|
for part, ratio in bone_ratios[bone_type].items():
|
||||||
|
part_h = ratio * bone_h
|
||||||
|
parts[part] = (xmin, current_y, xmax, current_y + part_h)
|
||||||
|
current_y = current_y + part_h
|
||||||
|
return parts
|
||||||
|
|
||||||
|
|
||||||
|
def _check_is_right(image: Tensor) -> bool:
|
||||||
|
"""Определяет сторону (латеральность) по изображению."""
|
||||||
|
image = transform_lr(image).unsqueeze(0).to(device)
|
||||||
|
with torch.no_grad():
|
||||||
|
outputs = model_lr(image)[0]
|
||||||
|
return bool(torch.argmax(outputs).item())
|
||||||
|
|
||||||
|
|
||||||
|
def _get_projection_bones(image: Tensor) -> Optional[ProjectionSegments]:
|
||||||
|
"""Получает боксы и сегменты кости для проекции."""
|
||||||
|
original = image.clone()
|
||||||
|
image = transform_bone(image).unsqueeze(0).to(device)
|
||||||
|
with torch.no_grad():
|
||||||
|
outputs = model_bone(image)[0]
|
||||||
|
scores = outputs['scores']
|
||||||
|
|
||||||
|
valid = scores >= 0.15
|
||||||
|
bones_boxes = outputs['boxes'][valid].cpu()
|
||||||
|
bone_labels = [str(int(i.item())) for i in outputs['labels'][valid].cpu()]
|
||||||
|
filtered_scores = scores[valid]
|
||||||
|
|
||||||
|
if not any(filtered_scores):
|
||||||
|
raise structs.ImagesError("Снимок иной анатомической области или низкого диагностического качества")
|
||||||
|
|
||||||
|
best = {}
|
||||||
|
for score, box, label in zip(filtered_scores, bones_boxes, bone_labels):
|
||||||
|
if label not in best or score.item() > best[label][0]:
|
||||||
|
best[label] = (score.item(), box)
|
||||||
|
# 4 - ладонь
|
||||||
|
if '3' in best:
|
||||||
|
best['4'] = best['3']
|
||||||
|
best.pop('3')
|
||||||
|
bones_boxes = torch.stack([b for _, b in best.values()])
|
||||||
|
bone_labels = list(best.keys())
|
||||||
|
conv_bone_boxes = _convert_bboxes(bones_boxes, original.shape[2],
|
||||||
|
original.shape[1])
|
||||||
|
|
||||||
|
parts_boxes, parts_labels = [], []
|
||||||
|
for box, bone in zip(conv_bone_boxes, bone_labels):
|
||||||
|
segments = _get_bone_parts(box.cpu().numpy(), bone)
|
||||||
|
for part, coords in segments.items():
|
||||||
|
parts_boxes.append(torch.tensor(coords))
|
||||||
|
parts_labels.append(part)
|
||||||
|
parts_boxes = torch.stack(parts_boxes)
|
||||||
|
|
||||||
|
return ProjectionSegments(parts_boxes, parts_labels, conv_bone_boxes,
|
||||||
|
bone_labels)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_fractions(direct_img: Tensor,
|
||||||
|
score_threshold=0.35) -> tuple[Tensor, Tensor]:
|
||||||
|
image = transform_frac(direct_img).unsqueeze(0).to(device)
|
||||||
|
with torch.no_grad():
|
||||||
|
outputs = model_frac(image)[0]
|
||||||
|
scores = outputs['scores'].cpu()
|
||||||
|
valid = scores >= score_threshold
|
||||||
|
boxes = outputs['boxes'][valid].cpu()
|
||||||
|
scores = scores[valid]
|
||||||
|
keep = ops.nms(boxes, scores, 0.2)
|
||||||
|
boxes = boxes[keep]
|
||||||
|
scores = scores[keep]
|
||||||
|
converted_bboxes = _convert_bboxes(boxes, direct_img.shape[2],
|
||||||
|
direct_img.shape[1],
|
||||||
|
transformed_w=512,
|
||||||
|
transformed_h=512)
|
||||||
|
return converted_bboxes, scores
|
||||||
|
|
||||||
|
|
||||||
|
def _assign_fracs_to_bones(frac_boxes: Tensor,
|
||||||
|
bone_boxes: Tensor, bone_labels: list[str]) \
|
||||||
|
-> dict[str: tuple[Tensor, list[str]]]:
|
||||||
|
"""Относит каждый бокс ровно к одной кости"""
|
||||||
|
assignments = {}
|
||||||
|
new_boxes = []
|
||||||
|
i = 1
|
||||||
|
if len(bone_boxes) > 0:
|
||||||
|
for frac_box in frac_boxes:
|
||||||
|
inter_xmin = torch.max(frac_box[0], bone_boxes[:, 0])
|
||||||
|
inter_ymin = torch.max(frac_box[1], bone_boxes[:, 1])
|
||||||
|
inter_xmax = torch.min(frac_box[2], bone_boxes[:, 2])
|
||||||
|
inter_ymax = torch.min(frac_box[3], bone_boxes[:, 3])
|
||||||
|
|
||||||
|
x_len = (inter_xmax - inter_xmin).clamp(min=0)
|
||||||
|
y_len = (inter_ymax - inter_ymin).clamp(min=0)
|
||||||
|
inter_area = x_len * y_len
|
||||||
|
|
||||||
|
best_idx = torch.argmax(inter_area)
|
||||||
|
if inter_area[best_idx].item() > 0:
|
||||||
|
bone = bone_labels[best_idx]
|
||||||
|
if bone in assignments:
|
||||||
|
assignments[bone] = (
|
||||||
|
torch.cat([assignments[bone][0], frac_box.unsqueeze(0)]),
|
||||||
|
assignments[bone][1] + [f'Находка {i}'])
|
||||||
|
else:
|
||||||
|
assignments[bone] = (frac_box.unsqueeze(0), [f'Находка {i}'])
|
||||||
|
new_boxes.append(frac_box)
|
||||||
|
|
||||||
|
i += 1
|
||||||
|
boxes_tensor = torch.stack(new_boxes) if new_boxes else torch.tensor([])
|
||||||
|
else:
|
||||||
|
boxes_tensor = torch.tensor([])
|
||||||
|
return assignments, boxes_tensor
|
||||||
|
|
||||||
|
|
||||||
|
def _assign_fracs_to_parts(frac_boxes: Tensor, frac_labels: list[str],
|
||||||
|
part_boxes: Tensor,
|
||||||
|
part_labels: np.ndarray[str]) -> list[str]:
|
||||||
|
"""Для каждого перелома ищет 2 части кости с наибольшим пересечением"""
|
||||||
|
result = []
|
||||||
|
|
||||||
|
for i in range(frac_boxes.shape[0]):
|
||||||
|
frac_box = frac_boxes[i]
|
||||||
|
intersections = []
|
||||||
|
for j in range(part_boxes.shape[0]):
|
||||||
|
part_box = part_boxes[j]
|
||||||
|
inter_xmin = max(frac_box[0].item(), part_box[0].item())
|
||||||
|
inter_ymin = max(frac_box[1].item(), part_box[1].item())
|
||||||
|
inter_xmax = min(frac_box[2].item(), part_box[2].item())
|
||||||
|
inter_ymax = min(frac_box[3].item(), part_box[3].item())
|
||||||
|
|
||||||
|
# Если пересечение существует, вычисляем площадь пересечения
|
||||||
|
if inter_xmax > inter_xmin and inter_ymax > inter_ymin:
|
||||||
|
area = (inter_xmax - inter_xmin) * (inter_ymax - inter_ymin)
|
||||||
|
intersections.append((j, area))
|
||||||
|
|
||||||
|
# Сортируем найденные пересечения по площади в
|
||||||
|
# порядке убывания и выбираем топ-2
|
||||||
|
intersections.sort(key=lambda x: x[1], reverse=True)
|
||||||
|
for j, _ in intersections[:2]:
|
||||||
|
result.append(f'{part_labels[j]}_{frac_labels[i]}')
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _format_single_fracture(label: str) -> tuple[str, str]:
|
||||||
|
"""
|
||||||
|
Формирует сообщение для одного найденного перелома.
|
||||||
|
"""
|
||||||
|
name, part = label.split('_')[:2]
|
||||||
|
bone = bone_translator[part]
|
||||||
|
msg = f'выявлен признак перелома {bone_parts_translator[name]} {bone}.'
|
||||||
|
return ('', msg) if part == '3' else (msg, '')
|
||||||
|
|
||||||
|
|
||||||
|
def _format_multiple_fractures(labels: list[str]) -> tuple[str, str, int, int]:
|
||||||
|
"""
|
||||||
|
Группирует найденные переломы по идентификатору и формирует сообщения
|
||||||
|
для фронтальной и боковой проекций.
|
||||||
|
"""
|
||||||
|
finds = {}
|
||||||
|
for label in labels:
|
||||||
|
name, part, n = label.split('_')
|
||||||
|
bone = bone_translator[part]
|
||||||
|
finds.setdefault(n, []).append((bone_parts_translator[name], bone))
|
||||||
|
|
||||||
|
front_msgs, side_msgs = [], []
|
||||||
|
front_count = side_count = 0
|
||||||
|
for n in sorted(finds.keys()):
|
||||||
|
parts = [x[0] for x in finds[n]]
|
||||||
|
if 'метафиза' in parts and 'эпифиза' in parts:
|
||||||
|
parts = ['метаэпифиза']
|
||||||
|
find_text = f' {n}: перелом ' + ', '.join(parts) + f' {finds[n][0][1]}.'
|
||||||
|
if finds[n][0][1] != 'лучевой или локтевой кости':
|
||||||
|
front_msgs.append(find_text)
|
||||||
|
front_count += 1
|
||||||
|
else:
|
||||||
|
side_msgs.append(find_text)
|
||||||
|
side_count += 1
|
||||||
|
|
||||||
|
front_msg = (f'выявлены признаки {front_count} ' +
|
||||||
|
('переломов' if front_count > 1 else 'перелома') +
|
||||||
|
'. ' + ''.join(front_msgs)) if front_count else ''
|
||||||
|
side_msg = (f'выявлены признаки {side_count} ' +
|
||||||
|
('переломов' if side_count > 1 else 'перелома') +
|
||||||
|
'. ' + ''.join(side_msgs)) if side_count else ''
|
||||||
|
return front_msg, side_msg, front_count, side_count
|
||||||
|
|
||||||
|
|
||||||
|
def _make_reports(labels: list[str], is_r: bool) -> str:
|
||||||
|
"""
|
||||||
|
Формирует текстовый отчёт по найденным переломам.
|
||||||
|
"""
|
||||||
|
|
||||||
|
labels = list(set(labels))
|
||||||
|
num_frac = len(labels)
|
||||||
|
lr = 'правого' if is_r else 'левого'
|
||||||
|
|
||||||
|
# Отсутствие переломов
|
||||||
|
if num_frac == 0:
|
||||||
|
return (
|
||||||
|
f'На рентгенограмме {lr} лучезапястного сустава '
|
||||||
|
f'признаков перелома не выявлено.')
|
||||||
|
|
||||||
|
# Один найденный перелом
|
||||||
|
if num_frac == 1:
|
||||||
|
front_msg, side_msg = _format_single_fracture(labels[0])
|
||||||
|
front_count = 1 if front_msg else 0
|
||||||
|
else:
|
||||||
|
(front_msg, side_msg,
|
||||||
|
front_count, side_count) = _format_multiple_fractures(labels)
|
||||||
|
|
||||||
|
report_lines = []
|
||||||
|
if front_count:
|
||||||
|
report_lines.append(
|
||||||
|
f'На рентгенограмме {lr} лучезапястного сустава '
|
||||||
|
f'{front_msg}')
|
||||||
|
|
||||||
|
return ' '.join(report_lines).replace(' ', ' ')
|
||||||
|
|
||||||
|
|
||||||
|
def _make_conclusion(labels: list[str]) -> str:
|
||||||
|
num_frac = len(labels)
|
||||||
|
if num_frac == 0:
|
||||||
|
return 'Признаков перелома не выявлено.'
|
||||||
|
finds = {}
|
||||||
|
for label in labels:
|
||||||
|
name, part = label.split('_')[:2]
|
||||||
|
bone = bone_translator[part]
|
||||||
|
part = bone_parts_translator[name]
|
||||||
|
if bone in finds:
|
||||||
|
finds[bone].add(part)
|
||||||
|
else:
|
||||||
|
finds[bone] = {part}
|
||||||
|
find_texts = []
|
||||||
|
for bone in finds:
|
||||||
|
if 'метафиза' in finds[bone] and 'эпифиза' in finds[bone]:
|
||||||
|
finds[bone].remove('метафиза')
|
||||||
|
finds[bone].remove('эпифиза')
|
||||||
|
finds[bone].add('метаэпифиза')
|
||||||
|
|
||||||
|
if bone != 'лучевой или локтевой кости' or (
|
||||||
|
'лучевой кости' not in finds and
|
||||||
|
'локтевой кости' not in finds):
|
||||||
|
find_text = f'перелом '
|
||||||
|
find_text += ', '.join(sorted(finds[bone])) + f' {bone}'
|
||||||
|
find_texts.append(find_text)
|
||||||
|
conclusion = '; '.join(find_texts)
|
||||||
|
return conclusion.replace(' ', ' ').capitalize()
|
||||||
|
|
||||||
|
|
||||||
|
def _process_fractures(direct_img: Tensor,
|
||||||
|
combined: ProjectionSegments) -> tuple:
|
||||||
|
"""Обрабатываем боксы переломов, сортируем их и распределяем по
|
||||||
|
сегментам кости.
|
||||||
|
"""
|
||||||
|
frac_boxes, frac_scores = _get_fractions(direct_img)
|
||||||
|
frac_labels = [f'Находка {i + 1}' for i in range(len(frac_boxes))]
|
||||||
|
frac_boxes = frac_boxes[frac_boxes[:, 0].argsort()]
|
||||||
|
|
||||||
|
assigned, frac_boxes = _assign_fracs_to_bones(frac_boxes,
|
||||||
|
combined.bones_boxes,
|
||||||
|
combined.bones_labels)
|
||||||
|
hurt_bones = []
|
||||||
|
for bone_type, (cur_frac_boxes, cur_frac_labels) in assigned.items():
|
||||||
|
if bone_type == '4':
|
||||||
|
for label in cur_frac_labels:
|
||||||
|
hurt_bones.append(f'hand_4_{label}')
|
||||||
|
else:
|
||||||
|
mask = [lbl.endswith(f'_{bone_type}')
|
||||||
|
for lbl in combined.parts_labels]
|
||||||
|
mask_labels = np.array(combined.parts_labels)[mask]
|
||||||
|
hurt_bones += _assign_fracs_to_parts(cur_frac_boxes,
|
||||||
|
cur_frac_labels,
|
||||||
|
combined.parts_boxes[mask],
|
||||||
|
mask_labels)
|
||||||
|
return Fractures(frac_boxes, frac_scores, frac_labels, hurt_bones)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_move_confidence(image: Tensor) -> float:
|
||||||
|
image = transform_move(image).unsqueeze(0).to(device)
|
||||||
|
|
||||||
|
with torch.no_grad():
|
||||||
|
outputs = model_move(image)[0]
|
||||||
|
if torch.argmax(outputs).item() == 1:
|
||||||
|
return 0
|
||||||
|
move_prob = torch.softmax(outputs, 0)[0]
|
||||||
|
return float(move_prob.item())
|
||||||
|
|
||||||
|
|
||||||
|
def _get_diastas_len(image: Tensor, parts: ProjectionSegments) -> int:
|
||||||
|
if '2' not in parts.bones_labels:
|
||||||
|
return 0
|
||||||
|
luch_box = parts.bones_boxes[parts.bones_labels.index('2')]
|
||||||
|
xmin, _, xmax = luch_box[:3]
|
||||||
|
bone_w = xmax - xmin
|
||||||
|
|
||||||
|
move_prob = _get_move_confidence(image)
|
||||||
|
|
||||||
|
coef = 0.025
|
||||||
|
move_len = (bone_w * move_prob * coef).item()
|
||||||
|
move_len = min(40, move_len)
|
||||||
|
|
||||||
|
return round(move_len)
|
||||||
|
|
||||||
|
|
||||||
|
def _visualize_detections(direct_img: Tensor, study_iuid: str,
|
||||||
|
laterality: Optional[str]) -> structs.Prediction:
|
||||||
|
"""Основная функция визуализации детекции с объединением боксов"""
|
||||||
|
if laterality in ['R', 'П']:
|
||||||
|
is_r = True
|
||||||
|
elif laterality in ['L', 'Л']:
|
||||||
|
is_r = False
|
||||||
|
else:
|
||||||
|
is_r = _check_is_right(direct_img)
|
||||||
|
|
||||||
|
bone_segments = _get_projection_bones(direct_img)
|
||||||
|
fracs = _process_fractures(direct_img, bone_segments)
|
||||||
|
|
||||||
|
report = _make_reports(fracs.bones, is_r)
|
||||||
|
conclusion = _make_conclusion(fracs.bones)
|
||||||
|
diastasis_mm = _get_diastas_len(direct_img, bone_segments)
|
||||||
|
diastasis_mm = diastasis_mm if fracs.labels else 0
|
||||||
|
|
||||||
|
img = cv2.cvtColor(direct_img[0].numpy(), cv2.COLOR_GRAY2RGB)
|
||||||
|
|
||||||
|
color = (255, 0, 0)
|
||||||
|
font = cv2.FONT_HERSHEY_COMPLEX
|
||||||
|
for box, label in zip(fracs.boxes.cpu().numpy().astype(int), fracs.labels):
|
||||||
|
cv2.rectangle(img, box[:2], box[2:], color, 2)
|
||||||
|
ytext = max(box[1] - 5, 0)
|
||||||
|
cv2.putText(img, label, (box[0], ytext), font, 0.5, color, 1)
|
||||||
|
|
||||||
|
cv2.imwrite(f"client/static/{study_iuid}.png", img)
|
||||||
|
|
||||||
|
is_fractured = bool(len(fracs.boxes))
|
||||||
|
if not is_fractured:
|
||||||
|
overall_probability = 0
|
||||||
|
else:
|
||||||
|
overall_probability = round(float(max(fracs.scores)*100))
|
||||||
|
|
||||||
|
properties = {
|
||||||
|
"Макс. величина диастаза отломков, мм": diastasis_mm
|
||||||
|
}
|
||||||
|
|
||||||
|
return structs.Prediction(overall_probability, is_fractured,
|
||||||
|
report, conclusion, img, properties)
|
||||||
|
|
||||||
|
|
||||||
|
def predict(input: structs.PredictorInput) -> structs.Prediction:
|
||||||
|
return _visualize_detections(input.image, input.study_uid,
|
||||||
|
input.laterality)
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
from typing import NamedTuple
|
||||||
|
import os
|
||||||
|
import torch
|
||||||
|
import numpy as np
|
||||||
|
import pydicom
|
||||||
|
from service.structs import PredictorInput, TagError, MetaTags
|
||||||
|
|
||||||
|
|
||||||
|
class InputImage(NamedTuple):
|
||||||
|
img: np.ndarray
|
||||||
|
ww: int
|
||||||
|
wc: int
|
||||||
|
uid: str
|
||||||
|
color_inversion: bool
|
||||||
|
|
||||||
|
|
||||||
|
def _get_ww_wc(study_ww, study_wc):
|
||||||
|
if type(study_ww) is pydicom.valuerep.DSfloat:
|
||||||
|
return int(study_ww), int(study_wc)
|
||||||
|
return int(study_ww[0]), int(study_wc[0])
|
||||||
|
|
||||||
|
|
||||||
|
def _is_color_inverted(study):
|
||||||
|
return study.PhotometricInterpretation == "MONOCHROME1"
|
||||||
|
|
||||||
|
|
||||||
|
def _prep_img(img_pack: InputImage) -> torch.Tensor:
|
||||||
|
img = img_pack.img.astype(np.float32)
|
||||||
|
lower_bound = img_pack.wc - 0.5 * img_pack.ww
|
||||||
|
upper_bound = img_pack.wc + 0.5 * img_pack.ww
|
||||||
|
img = np.clip(img, lower_bound, upper_bound)
|
||||||
|
img = ((img - lower_bound) / img_pack.ww) * 255
|
||||||
|
img = img.astype(np.uint8)
|
||||||
|
if img_pack.color_inversion:
|
||||||
|
img = 255 - img
|
||||||
|
return torch.from_numpy(img[None, ...])
|
||||||
|
|
||||||
|
|
||||||
|
def _check_required_tags(study: pydicom.FileDataset, pathology: str):
|
||||||
|
invalid_tags = []
|
||||||
|
if not (hasattr(study, "PhotometricInterpretation") and
|
||||||
|
study.PhotometricInterpretation in ("MONOCHROME1", "MONOCHROME2")):
|
||||||
|
invalid_tags.append("PhotometricInterpretation")
|
||||||
|
if pathology in ("shoulder", "wrist"):
|
||||||
|
if not (hasattr(study, "PixelSpacing") or
|
||||||
|
hasattr(study, "ImagerPixelSpacing")):
|
||||||
|
invalid_tags.append("PixelSpacing/ImagerPixelSpacing")
|
||||||
|
for tag in ("WindowWidth", "WindowCenter"):
|
||||||
|
if not hasattr(study, tag) or getattr(study, tag) == "":
|
||||||
|
invalid_tags.append(tag)
|
||||||
|
if len(invalid_tags) == 1:
|
||||||
|
raise TagError(f"DICOM тег {invalid_tags[0]} не заполнен, "\
|
||||||
|
"либо имеет некорректное значение")
|
||||||
|
elif len(invalid_tags) > 1:
|
||||||
|
raise TagError(f"DICOM теги {", ".join(invalid_tags)} не заполнены, "\
|
||||||
|
"либо имеют некорректные значения")
|
||||||
|
|
||||||
|
|
||||||
|
def _get_meta_tags(study: pydicom.FileDataset):
|
||||||
|
return MetaTags(study.StudyInstanceUID, study.SeriesInstanceUID,
|
||||||
|
getattr(study, "PatientID", None),
|
||||||
|
getattr(study, "AccessionNumber", None),
|
||||||
|
getattr(study, "IssuerOfPatientID", None),
|
||||||
|
getattr(study, "FillerOrderNumberImagingServiceRequest",
|
||||||
|
None))
|
||||||
|
|
||||||
|
|
||||||
|
def _get_px_size(study: pydicom.FileDataset):
|
||||||
|
if hasattr(study, "PixelSpacing"):
|
||||||
|
return study.PixelSpacing
|
||||||
|
if hasattr(study, "ImagerPixelSpacing"):
|
||||||
|
return study.ImagerPixelSpacing
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
def prep_imgs(pathology: str, study_path: str) -> tuple[MetaTags, PredictorInput]:
|
||||||
|
instance = pydicom.dcmread(study_path, force=True)
|
||||||
|
_check_required_tags(instance, pathology)
|
||||||
|
meta_tags = _get_meta_tags(instance)
|
||||||
|
ww, wc = _get_ww_wc(instance.WindowWidth, instance.WindowCenter)
|
||||||
|
clr_inverted = _is_color_inverted(instance)
|
||||||
|
img = InputImage(instance.pixel_array, ww, wc, meta_tags.study_iuid, clr_inverted)
|
||||||
|
pred_input = PredictorInput(meta_tags.study_iuid, _prep_img(img),
|
||||||
|
getattr(instance, "ImageLaterality", None),
|
||||||
|
*_get_px_size(instance))
|
||||||
|
return meta_tags, pred_input
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import os
|
||||||
|
import importlib
|
||||||
|
import base64
|
||||||
|
from zipfile import ZipFile
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
from pydicom.dataset import Dataset, validate_file_meta
|
||||||
|
from pydicom.uid import UID, generate_uid
|
||||||
|
import numpy as np
|
||||||
|
from service import sr_tags, preprocessor, structs
|
||||||
|
|
||||||
|
MODEL_VERSION = "1.0.0"
|
||||||
|
|
||||||
|
|
||||||
|
def _gen_seriesIUID(orig_seriesIUID, model, sr=False):
|
||||||
|
if len(orig_seriesIUID) > 56:
|
||||||
|
orig_seriesIUID = orig_seriesIUID[:56]
|
||||||
|
match model:
|
||||||
|
case "sinus":
|
||||||
|
model_id = 1208
|
||||||
|
case "wrist":
|
||||||
|
model_id = 1249
|
||||||
|
case "shoulder":
|
||||||
|
model_id = 1250
|
||||||
|
return f"{orig_seriesIUID}.{model_id}.{'2' if sr else '1'}"
|
||||||
|
|
||||||
|
|
||||||
|
def _to_sr_datetime(datetime):
|
||||||
|
return datetime.strftime("%d.%m.%Y %H:%M")
|
||||||
|
|
||||||
|
|
||||||
|
def to_iso8601(datetime):
|
||||||
|
msk_tz = timezone(timedelta(hours=3), name="MSK")
|
||||||
|
date = datetime.astimezone(msk_tz).isoformat("T", "milliseconds")
|
||||||
|
rindex_colon = date.rindex(':')
|
||||||
|
return date[:rindex_colon] + date[rindex_colon+1:]
|
||||||
|
|
||||||
|
|
||||||
|
def _create_ds_base(meta_tags: structs.MetaTags, sop_class_uid: UID):
|
||||||
|
meta_info = Dataset()
|
||||||
|
sop_instance_uid = generate_uid()
|
||||||
|
meta_info.MediaStorageSOPClassUID = sop_class_uid
|
||||||
|
meta_info.MediaStorageSOPInstanceUID = sop_instance_uid
|
||||||
|
meta_info.TransferSyntaxUID = UID('1.2.840.10008.1.2')
|
||||||
|
|
||||||
|
ds = Dataset()
|
||||||
|
ds.file_meta = meta_info
|
||||||
|
validate_file_meta(ds.file_meta, enforce_standard=True)
|
||||||
|
|
||||||
|
ds.SOPClassUID = sop_class_uid
|
||||||
|
ds.InstitutionName = "LORKT"
|
||||||
|
ds.StudyInstanceUID = meta_tags.study_iuid
|
||||||
|
ds.SOPInstanceUID = sop_instance_uid
|
||||||
|
if meta_tags.patient_id:
|
||||||
|
ds.PatientID = meta_tags.patient_id
|
||||||
|
if meta_tags.accession_number:
|
||||||
|
ds.AccessionNumber = meta_tags.accession_number
|
||||||
|
if meta_tags.issuer_of_patient_id:
|
||||||
|
ds.IssuerOfPatientID = meta_tags.issuer_of_patient_id
|
||||||
|
if meta_tags.filler_number:
|
||||||
|
ds.FillerOrderNumberImagingServiceRequest = meta_tags.filler_number
|
||||||
|
return ds
|
||||||
|
|
||||||
|
|
||||||
|
def _create_sr(meta_tags: structs.MetaTags, report: str, conclusion: str,
|
||||||
|
process_end: datetime, model: str, username: str):
|
||||||
|
sop_class_uid = UID('1.2.840.10008.5.1.4.1.1.88.33')
|
||||||
|
ds = _create_ds_base(meta_tags, sop_class_uid)
|
||||||
|
ds.SeriesInstanceUID = _gen_seriesIUID(meta_tags.series_iuid, model,
|
||||||
|
sr=True)
|
||||||
|
ds.Modality = "SR"
|
||||||
|
ds.InstanceNumber = 1
|
||||||
|
|
||||||
|
process_end = _to_sr_datetime(process_end)
|
||||||
|
tags = sr_tags.tags_for_models[model]
|
||||||
|
tags_and_texts = (
|
||||||
|
("Модальность", "РГ"),
|
||||||
|
("Область исследования", tags["Область исследования"]),
|
||||||
|
("Идентификатор исследования", meta_tags.study_iuid),
|
||||||
|
("Дата и время формирования заключения ИИ-сервисом", process_end),
|
||||||
|
("Предупреждение", "Заключение подготовлено программным обеспечением "\
|
||||||
|
"с применением технологий искусственного "\
|
||||||
|
"интеллекта"),
|
||||||
|
("Предупреждение", "В исследовательских целях"),
|
||||||
|
("Наименование сервиса", "ЛОР КТ"),
|
||||||
|
("Версия сервиса", MODEL_VERSION),
|
||||||
|
("Назначение сервиса", tags["Назначение сервиса"]),
|
||||||
|
("Технические данные", tags["Технические данные"]),
|
||||||
|
("Описание", report),
|
||||||
|
("Заключение", conclusion),
|
||||||
|
("Руководство пользователя", tags["Руководство пользователя"])
|
||||||
|
)
|
||||||
|
|
||||||
|
ds.SpecificCharacterSet = "ISO_IR 192"
|
||||||
|
ds.add_new((0x0040, 0xa730), 'SQ',
|
||||||
|
[Dataset() for _ in range(len(tags_and_texts))])
|
||||||
|
seq = ds.ContentSequence
|
||||||
|
|
||||||
|
for i, (tag, text) in enumerate(tags_and_texts):
|
||||||
|
seq[i].RelationshipType = "CONTAINS"
|
||||||
|
seq[i].ValueType = "TEXT"
|
||||||
|
seq[i].TextValue = text
|
||||||
|
seq[i].add_new((0x0040, 0xa043), 'SQ', [Dataset()])
|
||||||
|
name_seq = seq[i].ConceptNameCodeSequence[0]
|
||||||
|
name_seq.CodeValue = "209001"
|
||||||
|
name_seq.CodingSchemeDesignator = "99PMP"
|
||||||
|
name_seq.CodeMeaning = tag
|
||||||
|
|
||||||
|
save_path = f"data/{username}/sr/{meta_tags.study_iuid}.dcm"
|
||||||
|
ds.save_as(save_path, implicit_vr=True, little_endian=True)
|
||||||
|
return save_path
|
||||||
|
|
||||||
|
|
||||||
|
def _create_a_series(meta_tags: structs.MetaTags, img: np.ndarray,
|
||||||
|
model: str, username: str):
|
||||||
|
acquisition_date = datetime.now().strftime("%Y%m%d")
|
||||||
|
acquisition_time = datetime.now().strftime("%H%M%S")
|
||||||
|
series_uid = _gen_seriesIUID(meta_tags.series_iuid, model)
|
||||||
|
sop_class_uid = UID('1.2.840.10008.5.1.4.1.1.7')
|
||||||
|
ds = _create_ds_base(meta_tags, sop_class_uid)
|
||||||
|
ds.SeriesInstanceUID = series_uid
|
||||||
|
ds.InstanceNumber = 1
|
||||||
|
ds.Modality = "DX"
|
||||||
|
ds.SeriesDescription = "LORKT"
|
||||||
|
ds.InstitutionName = "LORKT"
|
||||||
|
ds.InstitutionalDepartmentName = MODEL_VERSION
|
||||||
|
ds.AcquisitionDate = acquisition_date
|
||||||
|
ds.AcquisitionTime = acquisition_time
|
||||||
|
ds.OperatorsName = "AI"
|
||||||
|
ds.PixelData = bytes(img)
|
||||||
|
ds.Rows, ds.Columns = img.shape[:2]
|
||||||
|
ds.BitsAllocated = 8
|
||||||
|
ds.BitsStored = 8
|
||||||
|
ds.HighBit = 7
|
||||||
|
ds.SamplesPerPixel = 3
|
||||||
|
ds.PhotometricInterpretation = "RGB"
|
||||||
|
ds.PixelRepresentation = 0
|
||||||
|
ds.PlanarConfiguration = 0
|
||||||
|
save_path = f"data/{username}/additional_series/{meta_tags.study_iuid}.dcm"
|
||||||
|
ds.save_as(save_path, implicit_vr=True, little_endian=True)
|
||||||
|
return save_path
|
||||||
|
|
||||||
|
|
||||||
|
def _zip_reports(paths: list[str], username: str):
|
||||||
|
study_uid = os.path.split(paths[0])[1]
|
||||||
|
with ZipFile(f"data/{username}/reports/{study_uid.replace('.dcm', '.zip')}", 'w') as z:
|
||||||
|
for path in paths:
|
||||||
|
dir, id = os.path.split(path)
|
||||||
|
dir = os.path.split(dir)[1]
|
||||||
|
arcname = os.path.join(dir, id)
|
||||||
|
z.write(path, arcname)
|
||||||
|
|
||||||
|
|
||||||
|
def make_reports(pathology: str, study_path: str,
|
||||||
|
username: str) -> structs.Prediction:
|
||||||
|
meta_tags, pred_input = preprocessor.prep_imgs(pathology, study_path)
|
||||||
|
module = importlib.import_module("service.predictors." + pathology)
|
||||||
|
predict_func = getattr(module, "predict")
|
||||||
|
prediction = predict_func(pred_input)
|
||||||
|
process_end = datetime.now()
|
||||||
|
sr_path = _create_sr(meta_tags, prediction.report, prediction.conclusion,
|
||||||
|
process_end, pathology, username)
|
||||||
|
a_series_path = _create_a_series(meta_tags, prediction.image, pathology,
|
||||||
|
username)
|
||||||
|
_zip_reports([sr_path, a_series_path], username)
|
||||||
|
prediction = prediction._replace(image=f"{meta_tags.study_iuid}.png")
|
||||||
|
return prediction
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class UserData(BaseModel):
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class TokenData(BaseModel):
|
||||||
|
exp: datetime
|
||||||
|
sub: str
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
SINUS_TAGS = {
|
||||||
|
"Область исследования": "Голова",
|
||||||
|
"Назначение сервиса": "Поиск признаков синусита в верхнечелюстных "\
|
||||||
|
"и лобных пазухах",
|
||||||
|
"Технические данные": "Количество срезов - 1",
|
||||||
|
"Руководство пользователя": "Красным цветом "\
|
||||||
|
"выделены патологические находки в пазухах"
|
||||||
|
}
|
||||||
|
|
||||||
|
WRIST_TAGS = {
|
||||||
|
"Область исследования": "ОДА",
|
||||||
|
"Назначение сервиса": "Выявление рентгенографических признаков перелома "\
|
||||||
|
"кости/костей, образующих лучезапястный сустав",
|
||||||
|
"Технические данные": "Обработано 1 изображение в прямой и 1 изображение"\
|
||||||
|
" в боковой проекции",
|
||||||
|
"Руководство пользователя": "Красным прямоугольником выделяется зона "\
|
||||||
|
"перелома"
|
||||||
|
}
|
||||||
|
|
||||||
|
SHOULDER_TAGS = {
|
||||||
|
"Область исследования": "ОДА",
|
||||||
|
"Назначение сервиса": "Выявление рентгенографических признаков перелома "\
|
||||||
|
"кости/костей, образующих плечевой сустав",
|
||||||
|
"Технические данные": "Обработано 1 изображение в прямой проекции",
|
||||||
|
"Руководство пользователя": "Красным прямоугольником выделяется зона "\
|
||||||
|
"перелома"
|
||||||
|
}
|
||||||
|
|
||||||
|
tags_for_models = {
|
||||||
|
"sinus": SINUS_TAGS,
|
||||||
|
"wrist": WRIST_TAGS,
|
||||||
|
"shoulder": SHOULDER_TAGS
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
from typing import Optional, NamedTuple
|
||||||
|
import torch
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
|
||||||
|
class Prediction(NamedTuple):
|
||||||
|
overall_probability: float
|
||||||
|
is_pathology: bool
|
||||||
|
report: str
|
||||||
|
conclusion: str
|
||||||
|
image: np.ndarray | str
|
||||||
|
properties: dict
|
||||||
|
|
||||||
|
|
||||||
|
class PredictorInput(NamedTuple):
|
||||||
|
study_uid: str
|
||||||
|
image: torch.Tensor
|
||||||
|
laterality: Optional[str]
|
||||||
|
px_width: Optional[float]
|
||||||
|
px_height: Optional[float]
|
||||||
|
|
||||||
|
|
||||||
|
class MetaTags(NamedTuple):
|
||||||
|
study_iuid: str
|
||||||
|
series_iuid: str
|
||||||
|
patient_id: Optional[str]
|
||||||
|
accession_number: Optional[str]
|
||||||
|
issuer_of_patient_id: Optional[str]
|
||||||
|
filler_number: Optional[str]
|
||||||
|
|
||||||
|
|
||||||
|
class TagError(Exception):
|
||||||
|
def __init__(self, msg):
|
||||||
|
self.msg = msg
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
|
||||||
|
class ImagesError(Exception):
|
||||||
|
def __init__(self, msg):
|
||||||
|
self.msg = msg
|
||||||
|
super().__init__()
|
||||||
Reference in New Issue
Block a user