Базовый коммит
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