Browse Source

Базовый коммит

master
Aleksandr Mochalov 1 month ago
commit
939932eeb0
  1. 1
      .gitattributes
  2. 8
      .gitignore
  3. BIN
      client/static/favicon.ico
  4. BIN
      client/static/logo.png
  5. 233
      client/templates/login.html
  6. 294
      client/templates/result.html
  7. 615
      client/templates/upload.html
  8. BIN
      data/demo/shoulder/shoulder_fracture.dcm
  9. BIN
      data/demo/shoulder/shoulder_normal.dcm
  10. BIN
      data/demo/sinus/sinus_normal.dcm
  11. BIN
      data/demo/sinus/sinusitis.dcm
  12. BIN
      data/demo/wrist/wrist_fracture.dcm
  13. BIN
      data/demo/wrist/wrist_normal.dcm
  14. 130
      main.py
  15. 18
      requirements.txt
  16. 0
      service/__init__.py
  17. 70
      service/auth.py
  18. 17
      service/database.py
  19. 7
      service/db_requests.py
  20. 11
      service/models.py
  21. 0
      service/models/shoulder/frac_model.pth
  22. 0
      service/models/shoulder/lr_model.pth
  23. 0
      service/models/shoulder/move_model.pth
  24. 0
      service/models/shoulder/parts_model.pth
  25. 0
      service/models/sinus/segmodel.pth
  26. 0
      service/models/wrist/bone_model.pth
  27. 0
      service/models/wrist/frac_model.pth
  28. 0
      service/models/wrist/lr_model.pth
  29. 0
      service/models/wrist/move_model.pth
  30. 0
      service/predictors/__init__.py
  31. 494
      service/predictors/shoulder.py
  32. 222
      service/predictors/sinus.py
  33. 484
      service/predictors/wrist.py
  34. 86
      service/preprocessor.py
  35. 166
      service/reports.py
  36. 12
      service/schemas.py
  37. 33
      service/sr_tags.py
  38. 41
      service/structs.py

1
.gitattributes vendored

@ -0,0 +1 @@
*.pth filter=lfs diff=lfs merge=lfs -text

8
.gitignore vendored

@ -0,0 +1,8 @@
__pycache__
.vscode
data/*
!data/demo/
!data/demo/**
client/static/*
!client/static/logo.png
!client/static/favicon.ico

BIN
client/static/favicon.ico

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

BIN
client/static/logo.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

233
client/templates/login.html

@ -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>

294
client/templates/result.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>

615
client/templates/upload.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>

BIN
data/demo/shoulder/shoulder_fracture.dcm

Binary file not shown.

BIN
data/demo/shoulder/shoulder_normal.dcm

Binary file not shown.

BIN
data/demo/sinus/sinus_normal.dcm

Binary file not shown.

BIN
data/demo/sinus/sinusitis.dcm

Binary file not shown.

BIN
data/demo/wrist/wrist_fracture.dcm

Binary file not shown.

BIN
data/demo/wrist/wrist_normal.dcm

Binary file not shown.

130
main.py

@ -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}")

18
requirements.txt

@ -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
service/__init__.py

70
service/auth.py

@ -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

17
service/database.py

@ -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()

7
service/db_requests.py

@ -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()

11
service/models.py

@ -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
service/models/shoulder/frac_model.pth

0
service/models/shoulder/lr_model.pth

0
service/models/shoulder/move_model.pth

0
service/models/shoulder/parts_model.pth

0
service/models/sinus/segmodel.pth

0
service/models/wrist/bone_model.pth

0
service/models/wrist/frac_model.pth

0
service/models/wrist/lr_model.pth

0
service/models/wrist/move_model.pth

0
service/predictors/__init__.py

494
service/predictors/shoulder.py

@ -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)

222
service/predictors/sinus.py

@ -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)

484
service/predictors/wrist.py

@ -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)

86
service/preprocessor.py

@ -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

166
service/reports.py

@ -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

12
service/schemas.py

@ -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

33
service/sr_tags.py

@ -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
}

41
service/structs.py

@ -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…
Cancel
Save