llm-tools/mcps/dicom_mcp/dicom_mcp/helpers/sequence.py
Gregory Gauthier 83ec950df7 first commit
2026-04-08 12:11:04 +01:00

115 lines
4.2 KiB
Python

"""MRI sequence identification helpers."""
from typing import List
import pydicom
from dicom_mcp.constants import SequenceType
from dicom_mcp.helpers.tags import _safe_get_tag
def _identify_sequence_type(ds: pydicom.Dataset) -> SequenceType:
"""Identify the MRI sequence type from DICOM metadata.
Uses a two-tier approach:
1. Series description keyword matching (fast, covers named protocols)
2. DICOM acquisition tag inspection (structural fallback)
IMPORTANT: Keyword matching order matters. More specific terms (e.g., 't1_mapping',
'molli') must be checked before generic terms (e.g., 't1') to avoid false positives.
"""
series_desc = _safe_get_tag(ds, (0x0008, 0x103E), "").lower()
# --- Tier 1: keyword matches on series description ---
# NOTE: Order is intentional - specific patterns checked before generic ones
# Dixon variants (check before MOST/IDEAL since some descriptions contain both)
if any(kw in series_desc for kw in ["dixon", "ideal", "flex"]):
return SequenceType.DIXON
# T1 mapping protocols (MOLLI, NOLLI, ShMOLLI, SASHA, etc.)
# Must check before generic 't1' to avoid misclassification
if any(kw in series_desc for kw in ["molli", "nolli", "shmolli", "sasha"]):
return SequenceType.T1_MAPPING
# Multi-echo GRE (MOST protocol for T2*/PDFF)
if "most" in series_desc or "multi" in series_desc and "echo" in series_desc:
return SequenceType.MULTI_ECHO_GRE
# Localizer / survey / scout
if any(kw in series_desc for kw in ["localizer", "survey", "scout"]):
return SequenceType.LOCALIZER
# Standard named sequences (specific before generic)
if "flair" in series_desc:
return SequenceType.FLAIR
if "dwi" in series_desc or "diffusion" in series_desc:
return SequenceType.DWI
# Check 't2' before 't1' since 't2' is more specific
if "t2" in series_desc:
return SequenceType.T2
if "t1" in series_desc:
return SequenceType.T1
# --- Tier 2: inspect DICOM acquisition tags ---
image_type = _safe_get_tag(ds, (0x0008, 0x0008), "").lower()
if "dixon" in image_type or "ideal" in image_type:
return SequenceType.DIXON
scanning_seq = _safe_get_tag(ds, (0x0018, 0x0020), "")
seq_variant = _safe_get_tag(ds, (0x0018, 0x0021), "")
has_ir = "IR" in scanning_seq
has_se = "SE" in scanning_seq
has_gr = "GR" in scanning_seq
has_mp = "MP" in seq_variant # Magnetization Prepared
# GR + IR (+ MP) = gradient echo inversion recovery (MOLLI-like T1 mapping)
if has_gr and has_ir and has_mp:
return SequenceType.T1_MAPPING
# SE + IR = spin echo inversion recovery
if has_se and has_ir:
return SequenceType.SPIN_ECHO_IR
# Multi-echo detection via NumberOfEchoes tag
num_echoes = _safe_get_tag(ds, (0x0018, 0x0086), "")
try:
if int(num_echoes) > 1 and has_gr:
return SequenceType.MULTI_ECHO_GRE
except (ValueError, TypeError):
pass
return SequenceType.UNKNOWN
def _is_dixon_sequence(ds: pydicom.Dataset) -> bool:
"""Check if a DICOM file is from a Dixon sequence."""
return _identify_sequence_type(ds) == SequenceType.DIXON
def _get_dixon_image_types(ds: pydicom.Dataset) -> List[str]:
"""Extract Dixon image types (water, fat, in-phase, out-phase) from metadata."""
image_types = []
image_type_tag = ds.get((0x0008, 0x0008))
if image_type_tag:
image_type_str = str(image_type_tag.value).lower()
if "water" in image_type_str or "w" in image_type_str.split("\\"):
image_types.append("water")
if "fat" in image_type_str or "f" in image_type_str.split("\\"):
image_types.append("fat")
if "in" in image_type_str or "ip" in image_type_str:
image_types.append("in-phase")
if "out" in image_type_str or "op" in image_type_str:
image_types.append("out-phase")
series_desc = _safe_get_tag(ds, (0x0008, 0x103E), "").lower()
if "water" in series_desc and "water" not in image_types:
image_types.append("water")
if "fat" in series_desc and "fat" not in image_types:
image_types.append("fat")
return image_types if image_types else ["unknown"]