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