Files
LORKT_AI_Service/service/predictors/wrist.py
T
2025-12-03 16:50:06 +05:00

485 lines
18 KiB
Python

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)