You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
311 lines
11 KiB
311 lines
11 KiB
import numpy as np |
|
|
|
from scoliosis_geometry import angle_diff_deg |
|
|
|
def fill_zero_gaps(sign: np.ndarray, max_gap: int) -> np.ndarray: |
|
if max_gap <= 0: |
|
return sign |
|
sign = sign.copy() |
|
n = len(sign) |
|
i = 0 |
|
while i < n: |
|
if sign[i] != 0: |
|
i += 1 |
|
continue |
|
j = i |
|
while j < n and sign[j] == 0: |
|
j += 1 |
|
left = sign[i - 1] if i > 0 else 0 |
|
right = sign[j] if j < n else 0 |
|
if (j - i) <= max_gap and left != 0 and left == right: |
|
sign[i:j] = left |
|
i = j |
|
return sign |
|
|
|
def runs_of_same_sign(sign: np.ndarray) -> list[tuple[int, int, int]]: |
|
"Находит непрерывные куски одинакового знака (+1 подряд, -1 подряд)" |
|
n = len(sign) |
|
out = [] |
|
i = 0 |
|
while i < n: |
|
if sign[i] == 0: |
|
i += 1 |
|
continue |
|
sgn = int(sign[i]) |
|
s = i |
|
while i < n and sign[i] == sgn: |
|
i += 1 |
|
e = i - 1 |
|
out.append((sgn, s, e)) |
|
return out |
|
|
|
def extend_through_zeros(sign: np.ndarray, s: int, e: int, max_extend: int) -> tuple[int, int]: |
|
"""Если дуга заканчивается рядом с нулями, |
|
функция слегка "расширяет" дугу в нули, |
|
чтобы включить край (где dx почти 0)""" |
|
n = len(sign) |
|
k = 0 |
|
while k < max_extend and s > 0 and sign[s - 1] == 0: |
|
s -= 1 |
|
k += 1 |
|
k = 0 |
|
while k < max_extend and e < n - 1 and sign[e + 1] == 0: |
|
e += 1 |
|
k += 1 |
|
return s, e |
|
|
|
def dx_to_sign(dx: np.ndarray, eps: float) -> np.ndarray: |
|
return np.where(dx > eps, 1, np.where(dx < -eps, -1, 0)).astype(np.int8) |
|
|
|
def compute_eps(dx: np.ndarray, eps_px: float, eps_rel: float = 0.01) -> float: |
|
maxabs = float(np.max(np.abs(dx)) + 1e-6) |
|
return max(float(eps_px), float(eps_rel) * maxabs) |
|
|
|
def detect_arcs_from_dx(dx, eps_px=2.0, min_len=3, zero_gap=1, edge_zero_extend=2): |
|
"Основная детекция дуг" |
|
dx = np.asarray(dx, dtype=np.float32) |
|
if len(dx) < 2: |
|
return [] |
|
|
|
eps = compute_eps(dx, eps_px, eps_rel=0.01) |
|
sign = dx_to_sign(dx, eps) |
|
sign = fill_zero_gaps(sign, zero_gap) |
|
|
|
arcs = [] |
|
for sgn, s, e in runs_of_same_sign(sign): |
|
if (e - s + 1) >= int(min_len): |
|
s, e = extend_through_zeros(sign, s, e, edge_zero_extend) |
|
arcs.append((s, e)) |
|
return arcs |
|
|
|
def arc_sign(signal: np.ndarray, s: int, e: int) -> int: |
|
"""Определяет знак дуги по сигналу на участке (берёт медиану). |
|
Нужно чтобы знак не ломался из-за шума""" |
|
return 1 if float(np.median(signal[s:e + 1])) > 0 else -1 |
|
|
|
def merge_arcs(arcs, sign_signal, max_gap=0): |
|
"""Сливает дуги, если: |
|
- знак одинаковый |
|
- и они либо пересекаются, либо рядом (max_gap позволяет "почти рядом") |
|
Это финальная "склейка дуг", чтобы не было дробления""" |
|
if not arcs: |
|
return [] |
|
|
|
arcs = sorted(arcs, key=lambda t: (t[0], t[1])) |
|
out = [[arcs[0][0], arcs[0][1], arc_sign(sign_signal, arcs[0][0], arcs[0][1])]] |
|
|
|
for s, e in arcs[1:]: |
|
sgn = arc_sign(sign_signal, s, e) |
|
ps, pe, psgn = out[-1] |
|
|
|
if sgn == psgn and s <= pe + max_gap: |
|
out[-1][1] = max(pe, e) |
|
else: |
|
out.append([s, e, sgn]) |
|
|
|
return [(s, e) for s, e, _ in out] |
|
|
|
def cobb_for_arc(vertebrae, s, e, dx_cobb, dx_ref_sign): |
|
idxs = np.arange(s, e + 1) |
|
apex_i = int(idxs[np.argmax(np.abs(dx_cobb[s:e + 1]))]) |
|
|
|
upper_candidates = range(s, apex_i + 1) |
|
lower_candidates = range(apex_i, e + 1) |
|
|
|
best_cobb = -1.0 |
|
best_pair = (s, e) |
|
|
|
for ui in upper_candidates: |
|
ta = vertebrae[ui]["ang"] |
|
for li in lower_candidates: |
|
c = float(angle_diff_deg(ta, vertebrae[li]["ang"])) |
|
if c > best_cobb: |
|
best_cobb = c |
|
best_pair = (ui, li) |
|
|
|
upper_idx, lower_idx = best_pair |
|
|
|
side_sign = arc_sign(dx_ref_sign, s, e) |
|
direction = "левосторонний" if side_sign > 0 else "правосторонний" |
|
|
|
return { |
|
"start_idx": int(s), "end_idx": int(e), |
|
"apex_idx": int(apex_i), "apex_label": vertebrae[apex_i]["l"], |
|
"direction": direction, |
|
"side_sign": int(side_sign), |
|
"cobb_deg": float(best_cobb), |
|
"upper_end": vertebrae[upper_idx]["l"], |
|
"lower_end": vertebrae[lower_idx]["l"], |
|
"upper_idx": int(upper_idx), |
|
"lower_idx": int(lower_idx), |
|
} |
|
|
|
def arc_len(a): |
|
"Длина дуги в позвонках: end-start+1" |
|
return a["end_idx"] - a["start_idx"] + 1 |
|
|
|
def is_force(a, force_cobb, force_len): |
|
""""Форс-правило": если дуга очень большая по Cobb и не слишком короткая |
|
- считаем её структурной, даже если остальные критерии спорные.""" |
|
return (a["cobb_deg"] >= force_cobb) and (arc_len(a) >= force_len) |
|
|
|
def dedupe_by_range(arcs): |
|
"Функция удаляет дубликаты по (start_idx, end_idx)" |
|
seen = set() |
|
out = [] |
|
for a in arcs: |
|
key = (a["start_idx"], a["end_idx"]) |
|
if key not in seen: |
|
out.append(a) |
|
seen.add(key) |
|
return out |
|
|
|
def split_arcs( |
|
arc_infos, |
|
# 1) Главная дуга всегда structural, если вообще есть "сколиоз" |
|
min_cobb_main=1.0, |
|
|
|
# 2) Вторая (для S-типа) должна быть не "шумом" |
|
min_cobb_second_abs=5.0, |
|
min_cobb_second_rel=0.35, |
|
|
|
min_len_struct=4, |
|
min_len_minor=3, |
|
min_apex_margin_struct=2, |
|
min_amp_rel_struct=0.25, |
|
|
|
min_cobb_minor=3.0, |
|
|
|
force_struct_cobb=40.0, |
|
force_struct_min_len=3 |
|
): |
|
""" |
|
Разделяет все дуги на: |
|
- structural (важные, формируют тип C/S) |
|
- minor (дополнительные) |
|
""" |
|
if not arc_infos: |
|
return [], [] |
|
|
|
main = max(arc_infos, key=lambda a: a["cobb_deg"]) |
|
|
|
def passes_geom(a): |
|
amp_rel = a.get("amp_rel") |
|
if amp_rel is None: |
|
return False |
|
|
|
apex_margin = int(a.get("apex_margin", 999)) |
|
|
|
return ( |
|
(arc_len(a) >= min_len_struct) and |
|
(apex_margin >= min_apex_margin_struct) and |
|
(amp_rel >= min_amp_rel_struct) |
|
) |
|
|
|
structural, minor = [], [] |
|
|
|
main_is_force = (main["cobb_deg"] >= force_struct_cobb) and (arc_len(main) >= force_struct_min_len) |
|
|
|
if main_is_force or (main["cobb_deg"] >= min_cobb_main): |
|
structural.append(main) |
|
|
|
second_thr = max(min_cobb_second_abs, min_cobb_second_rel * float(main["cobb_deg"])) |
|
main_sign = int(main.get("side_sign", 0)) |
|
|
|
for a in arc_infos: |
|
if a is main: |
|
continue |
|
|
|
length = arc_len(a) |
|
a_sign = int(a.get("side_sign", 0)) |
|
|
|
if is_force(a, force_struct_cobb, force_struct_min_len): |
|
structural.append(a) |
|
continue |
|
|
|
is_opposite = (main_sign != 0) and (a_sign != 0) and (a_sign != main_sign) |
|
|
|
if is_opposite and (a["cobb_deg"] >= second_thr) and passes_geom(a): |
|
structural.append(a) |
|
else: |
|
if length >= min_len_minor and a["cobb_deg"] >= min_cobb_minor: |
|
minor.append(a) |
|
|
|
return dedupe_by_range(structural), dedupe_by_range(minor) |
|
|
|
def chaklin_degree(cobb_deg): |
|
"Переводит Cobb в степень по Чаклину (0/I/II/III/IV)" |
|
a = float(cobb_deg) |
|
if a < 1.0: |
|
return "0 ст. (практически нет)" |
|
if a <= 10.: |
|
return "I ст." |
|
if a <= 25.: |
|
return "II ст." |
|
if a <= 50.: |
|
return "III ст." |
|
return "IV ст." |
|
|
|
def scoliosis_type(structural_arcs): |
|
if len(structural_arcs) <= 1: |
|
return "C-сколиоз" |
|
|
|
signs = {int(a["side_sign"]) for a in structural_arcs if a.get("side_sign") is not None} |
|
|
|
if len(structural_arcs) >= 3 and len(signs) >= 2: |
|
return "Z-сколиоз" |
|
if len(signs) >= 2: |
|
return "S-сколиоз" |
|
return "C-сколиоз" |
|
|
|
def build_conclusion(arc_infos, structural=None, minor=None): |
|
""" |
|
Собирает текст заключения: |
|
- выбирает главную дугу |
|
- пишет тип, направление, Cobb, степень |
|
- выводит список структурных дуг |
|
- выводит список дополнительных дуг (minor) |
|
""" |
|
if not arc_infos: |
|
return "Угловая деформация оси позвоночника не выявлена. Рентгенологических признаков сколиоза нет." |
|
if structural is None and minor is None: |
|
structural, minor = split_arcs(arc_infos) |
|
else: |
|
structural = structural or [] |
|
minor = minor or [] |
|
|
|
def fmt_arc(i, a, with_len=False): |
|
arc_degree = chaklin_degree(a["cobb_deg"]) |
|
s = (f" Дуга {i}: {a['direction']}, уровни {a['upper_end']}-{a['lower_end']}, " |
|
f"вершина ~ {a['apex_label']}, Cobb={a['cobb_deg']:.1f}°, степень={arc_degree}") |
|
if with_len: |
|
length = arc_len(a) |
|
s += f", длина={length} позв." |
|
return s + "." |
|
|
|
main = max(arc_infos, key=lambda x: x["cobb_deg"]) |
|
|
|
scol_type = scoliosis_type(structural) if structural else "не определён (нет структурных дуг)" |
|
degree = chaklin_degree(main["cobb_deg"]) |
|
|
|
lines = [ |
|
f"1. Тип сколиоза - {scol_type}.", |
|
f"2. Направление основной дуги - {main['direction']} (по изображению).", |
|
f"3. Угол деформации (Cobb) основной дуги - {main['cobb_deg']:.1f}°.", |
|
f"4. Степень сколиоза по углу деформации - {degree}.", |
|
"", |
|
"Структурные дуги (учитываются для типа):" |
|
] |
|
|
|
if structural: |
|
for i, a in enumerate(sorted(structural, key=lambda a: a["cobb_deg"], reverse=True), start=1): |
|
lines.append(fmt_arc(i, a)) |
|
else: |
|
lines.append(" Нет (не прошли критерии структурности).") |
|
|
|
if minor: |
|
lines += ["", "Дополнительные дуги (не учитываются для типа):"] |
|
for i, a in enumerate(sorted(minor, key=lambda a: a["cobb_deg"], reverse=True), start=1): |
|
lines.append(fmt_arc(i, a, with_len=True)) |
|
|
|
return "\n".join(lines)
|
|
|