Initial scoliosis pipeline (without models)\
This commit is contained in:
@@ -0,0 +1,311 @@
|
||||
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)
|
||||
Reference in New Issue
Block a user