commit
939932eeb0
38 changed files with 2942 additions and 0 deletions
@ -0,0 +1,8 @@ |
|||||||
|
__pycache__ |
||||||
|
.vscode |
||||||
|
data/* |
||||||
|
!data/demo/ |
||||||
|
!data/demo/** |
||||||
|
client/static/* |
||||||
|
!client/static/logo.png |
||||||
|
!client/static/favicon.ico |
||||||
|
After Width: | Height: | Size: 5.6 KiB |
|
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__() |
||||||
Loading…
Reference in new issue