Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 52 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,12 +41,20 @@ The _CSA Image Header Info_ and _CSA Series Header Info_ elements contain encode

- **Fast and Lightweight**: Minimal dependencies (numpy, pydicom)
- **Comprehensive Parsing**: Supports both CSA header types (Type 1 and Type 2)
- **XA Enhanced DICOM Support**: Full support for syngo MR XA30, XA60+ with XProtocol format
- **ASCCONV Support**: Automatic parsing of embedded ASCCONV protocol parameters
- **Type-Safe**: Complete type hints for all public APIs
- **Well-Tested**: 96% test coverage with 161 tests
- **Well-Tested**: 94% test coverage with 223 tests
- **Python 3.9+**: Modern Python with support through Python 3.13
- **NiBabel Compatible**: Integrates seamlessly with neuroimaging workflows

### Supported DICOM Formats

- **Standard Siemens DICOMs**: Binary CSA headers in tags `(0x0029, 0x1010)` and `(0x0029, 0x1020)`
- **XA Enhanced DICOMs**: XProtocol format in `SharedFunctionalGroupsSequence` (syngo MR XA30, XA60, and newer)

The library automatically detects the format and returns the appropriate parser.

**Table of Contents**

- [Features](#features)
Expand Down Expand Up @@ -177,6 +185,49 @@ Example parsed CSA header structure:
}
```

### Working with XA Enhanced DICOMs

XA Enhanced DICOMs (syngo MR XA30, XA60, and newer) store protocol data in XProtocol format within `SharedFunctionalGroupsSequence`. The library automatically detects and handles these formats:

```python
>>> import pydicom
>>> from csa_header import CsaHeader
>>> from csa_header.ascii import CsaAsciiHeader
>>>
>>> # Load XA Enhanced DICOM
>>> dcm = pydicom.dcmread("xa_enhanced.dcm")
>>>
>>> # Use the same API - automatic format detection
>>> header = CsaHeader.from_dicom(dcm, 'image')
>>>
>>> # For XA Enhanced, returns CsaAsciiHeader instead of CsaHeader
>>> if isinstance(header, CsaAsciiHeader):
... # Access parsed protocol data
... protocol = header.parsed
... slice_array = protocol['sSliceArray']
... print(f"Number of slices: {slice_array['lSize']}")
Number of slices: 176
```

**Key differences for XA Enhanced DICOMs:**

- Returns `CsaAsciiHeader` instead of `CsaHeader`
- Use `.parsed` property instead of `.read()` method
- The `csa_type` parameter ('image' or 'series') is ignored for XA Enhanced files, as protocol data is stored in a single location

```python
>>> # Seamless API regardless of DICOM format
>>> def get_protocol_data(dcm):
... header = CsaHeader.from_dicom(dcm, 'image')
... if header is None:
... return None
... # Handle both formats
... if isinstance(header, CsaAsciiHeader):
... return header.parsed
... else:
... return header.read()
```

## Advanced Usage

### Extracting ASCCONV Protocol
Expand Down
106 changes: 106 additions & 0 deletions XA_ENHANCED_ANALYSIS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
# XA Enhanced DICOM Analysis - Issue #31

## Summary

XA Enhanced DICOM files (syngo MR XA30, XA60, etc.) store Siemens protocol data in a different format and location compared to standard Siemens DICOMs.

## Key Findings

### 1. Data Location Differences

**Standard Siemens DICOMs:**
- CSA Image Header: `(0x0029, 0x1010)`
- CSA Series Header: `(0x0029, 0x1020)`

**XA Enhanced DICOMs:**
- No CSA tags at `(0x0029, 0x1010)` or `(0x0029, 0x1020)`
- Protocol data stored in: `SharedFunctionalGroupsSequence[0][(0x0021, 0x10FE)][0][(0x0021, 0x1019)]`

### 2. Data Format Differences

**Standard Siemens DICOMs:**
- Binary CSA format (Type 1 or Type 2 with "SV10" signature)
- Contains structured binary data with check bits (77 or 205)
- Parsed by `CsaHeader` class

**XA Enhanced DICOMs:**
- **XProtocol format** (ASCII/XML-like text)
- Starts with `<XProtocol>` tag
- Contains ASCCONV-style parameters
- **Already parseable by `CsaAsciiHeader` class!**

### 3. Sample XProtocol Data Structure

```
<XProtocol>
{
<Name> "PhoenixMetaProtocol"
<ID> 1000002
<Userversion> 2.0

<ParamMap."">
{
<ParamLong."Count"> { ... }
<ParamString."Protocol0"> { ... }
...
}
}
```

## Test Data

Sample XA files downloaded and stored in:
- `tests/files/xa_enhanced/xa30_sample.dcm` (XA30 - Siemens MAGNETOM Prisma Fit)
- `tests/files/xa_enhanced/xa60_sample.dcm` (XA60 - Siemens MAGNETOM Terra.X)

Full test repositories cloned to:
- `tests/files/xa_enhanced/xa30_repo/` (https://github.com/neurolabusc/dcm_qa_xa30)
- `tests/files/xa_enhanced/xa60_repo/` (https://github.com/neurolabusc/dcm_qa_xa60)

## Solution Approach

The library already has the components needed to support XA Enhanced DICOMs:

1. **`CsaAsciiHeader`** class can successfully parse XProtocol data
2. Just need to update `CsaHeader.from_dicom()` to:
- Detect XA Enhanced DICOMs (check for SharedFunctionalGroupsSequence)
- Extract XProtocol data from the correct tag sequence
- Return `CsaAsciiHeader` instance instead of `CsaHeader` instance

## Implementation Plan

1. Modify `CsaHeader.from_dicom()` method:
- Add XA Enhanced DICOM detection
- Extract XProtocol data from SharedFunctionalGroupsSequence
- Return appropriate parser (CsaHeader for binary, CsaAsciiHeader for XProtocol)

2. Add comprehensive tests for XA Enhanced support

3. Update documentation to list XA Enhanced as supported format

## Example Usage (Post-Implementation)

```python
import pydicom
from csa_header import CsaHeader

# Load XA Enhanced DICOM
dcm = pydicom.dcmread('xa_enhanced.dcm')

# This will now work and return a CsaAsciiHeader instance
header = CsaHeader.from_dicom(dcm, 'image') # or 'series'
parsed = header.parsed # Access parsed protocol data

# Example: Get slice information
n_slices = header.parsed['sSliceArray']['lSize']
```

## Original Error

When trying to parse XProtocol data as binary CSA:
```
CsaReadError: CSA element #0 has an invalid check bit value: 1632648224!
Valid values are {205, 77}
```

This occurred because the ASCII characters `<XProtocol>` were being interpreted as binary integers.
130 changes: 104 additions & 26 deletions csa_header/header.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,13 @@ class CsaHeader:
"series": (0x0029, 0x1020), # CSA Series Header Info
}

#: XA Enhanced DICOM tags for XProtocol data.
#: These are used in syngo MR XA30, XA60, etc.
XA_ENHANCED_TAGS: ClassVar[dict[str, tuple[int, int]]] = {
"sequence_tag": (0x0021, 0x10FE), # MR Protocol Sequence
"protocol_tag": (0x0021, 0x1019), # XProtocol data
}

def __init__(self, raw: bytes):
"""
Initialize a new `CsaHeader` instance.
Expand Down Expand Up @@ -362,6 +369,59 @@ def is_type_2(self) -> bool:
"""
return self._csa_type == self.CSA_TYPE_2

@staticmethod
def _extract_xa_enhanced_protocol(
dcm_data: pydicom.Dataset,
) -> bytes | None:
"""
Extract XProtocol data from XA Enhanced DICOM files.

XA Enhanced DICOMs (syngo MR XA30, XA60, etc.) store protocol data in
XProtocol format (ASCII/XML-like) within the SharedFunctionalGroupsSequence,
rather than using the standard CSA binary tags.

Parameters
----------
dcm_data : pydicom.Dataset
DICOM dataset that may be in XA Enhanced format

Returns
-------
bytes or None
XProtocol data as bytes, or None if not present/not XA Enhanced format
"""
# Check for SharedFunctionalGroupsSequence (present in Enhanced DICOMs)
if "SharedFunctionalGroupsSequence" not in dcm_data:
return None

try:
# Navigate the sequence structure:
# SharedFunctionalGroupsSequence[0][(0x0021, 0x10FE)][0][(0x0021, 0x1019)]
sds = dcm_data.SharedFunctionalGroupsSequence[0]
sequence_tag = CsaHeader.XA_ENHANCED_TAGS["sequence_tag"]
protocol_tag = CsaHeader.XA_ENHANCED_TAGS["protocol_tag"]

if sequence_tag not in sds:
return None

mrprot_seq = sds[sequence_tag]
if not hasattr(mrprot_seq, "value") or not mrprot_seq.value:
return None

mrprot_item = mrprot_seq.value[0]
if protocol_tag not in mrprot_item:
return None

protocol_data = mrprot_item[protocol_tag].value
if protocol_data is None:
return None

return bytes(protocol_data)

except (AttributeError, IndexError, KeyError):
# If any part of the navigation fails, this isn't XA Enhanced format
return None

@staticmethod
def _extract_csa_bytes(
dcm_data: pydicom.Dataset,
Expand Down Expand Up @@ -404,14 +464,18 @@ def from_dicom(
cls,
dcm_data: pydicom.Dataset,
csa_type: Literal["image", "series"] = "image",
) -> CsaHeader | None:
) -> CsaHeader | CsaAsciiHeader | None:
"""
Extract and parse CSA header directly from a DICOM dataset.

This method implements the DICOM private tag search protocol to locate
and extract CSA headers from Siemens DICOM files. The implementation is
inspired by nibabel's ``get_csa_header()`` function, adapted to csa_header's
API design.
and extract CSA headers from Siemens DICOM files. It supports both:

- Standard Siemens DICOMs with binary CSA headers (tags 0x0029,0x1010/0x1020)
- XA Enhanced DICOMs with XProtocol data (syngo MR XA30, XA60, etc.)

The implementation is inspired by nibabel's ``get_csa_header()`` function,
adapted to csa_header's API design.

For more information on nibabel's CSA header support, see:
https://github.com/nipy/nibabel
Expand All @@ -426,12 +490,18 @@ def from_dicom(
- ``'image'`` : CSA Image Header Info (0x0029, 0x1010)
- ``'series'`` : CSA Series Header Info (0x0029, 0x1020)

Note: For XA Enhanced DICOMs, this parameter is ignored as protocol
data is stored in a single location (SharedFunctionalGroupsSequence).

Returns
-------
CsaHeader or None
CsaHeader instance containing the raw CSA data, or None if the
specified CSA header is not present in the dataset. Call ``.read()``
on the returned instance to get the parsed dictionary.
CsaHeader, CsaAsciiHeader, or None
- ``CsaHeader`` : For standard binary CSA headers
- ``CsaAsciiHeader`` : For XA Enhanced XProtocol data
- ``None`` : If no CSA/protocol data is found

Call ``.read()`` or access ``.parsed`` on the returned instance to
get the parsed dictionary.

Raises
------
Expand All @@ -443,33 +513,35 @@ def from_dicom(
>>> import pydicom
>>> from csa_header import CsaHeader
>>>
>>> # Load DICOM file
>>> # Load DICOM file (works for both standard and XA Enhanced)
>>> dcm = pydicom.dcmread('siemens_scan.dcm')
>>>
>>> # Extract image CSA header
>>> # Extract CSA header (automatically detects format)
>>> csa_header = CsaHeader.from_dicom(dcm, 'image')
>>> if csa_header:
... csa_dict = csa_header.read()
... print(f"Found {len(csa_dict)} CSA tags")
>>>
>>> # Extract series CSA header
>>> csa_header = CsaHeader.from_dicom(dcm, 'series')
>>> if csa_header:
... csa_dict = csa_header.read()
... protocol = csa_dict.get('MrPhoenixProtocol')
... # For binary CSA headers, use .read()
... # For XA Enhanced (CsaAsciiHeader), use .parsed
... if isinstance(csa_header, CsaAsciiHeader):
... csa_dict = csa_header.parsed
... else:
... csa_dict = csa_header.read()
... print(f"Found CSA data")

Notes
-----
This is a convenience method that combines DICOM tag extraction with
CSA header parsing. It is equivalent to:
For standard Siemens DICOMs, this is equivalent to:

>>> raw_bytes = dcm[(0x0029, 0x1010)].value # for 'image'
>>> csa_header = CsaHeader(raw_bytes)
>>> csa_dict = csa_header.read()

For XA Enhanced DICOMs, it extracts XProtocol data from:
SharedFunctionalGroupsSequence[0][(0x0021,0x10FE)][0][(0x0021,0x1019)]

See Also
--------
CsaHeader.read : Parse CSA header from raw bytes
CsaHeader.read : Parse binary CSA header from raw bytes
CsaAsciiHeader.parsed : Access parsed XProtocol data
"""
# Validate csa_type parameter
csa_type_lower = csa_type.lower()
Expand All @@ -478,10 +550,16 @@ def from_dicom(
msg = f"Invalid csa_type: {csa_type!r}. Must be one of: {valid_types}"
raise ValueError(msg)

# Extract raw CSA bytes
# First, try standard CSA binary format
raw_bytes = cls._extract_csa_bytes(dcm_data, csa_type_lower)
if raw_bytes is None:
return None
if raw_bytes is not None:
return cls(raw_bytes)

# If not found, try XA Enhanced XProtocol format
xa_protocol = cls._extract_xa_enhanced_protocol(dcm_data)
if xa_protocol is not None:
# XA Enhanced uses XProtocol format (ASCII), return CsaAsciiHeader
return CsaAsciiHeader(xa_protocol)

# Create and return CsaHeader instance
return cls(raw_bytes)
# No CSA data found in either format
return None
Binary file added tests/files/xa_enhanced/xa30_sample.dcm
Binary file not shown.
Binary file added tests/files/xa_enhanced/xa60_sample.dcm
Binary file not shown.
5 changes: 5 additions & 0 deletions tests/fixtures.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,8 @@
DWI_CSA_IMAGE_HEADER_INFO: Path = TEST_FILES_DIR / "dwi_image_header_info"
RSFMRI_CSA_SERIES_HEADER_INFO: Path = TEST_FILES_DIR / "rsfmri_series_header_info"
E11_CSA_SERIES_HEADER_INFO: Path = TEST_FILES_DIR / "e11_series_header_info"

# XA Enhanced DICOM test files (syngo MR XA30, XA60)
XA_ENHANCED_DIR: Path = TEST_FILES_DIR / "xa_enhanced"
XA30_SAMPLE_DICOM: Path = XA_ENHANCED_DIR / "xa30_sample.dcm"
XA60_SAMPLE_DICOM: Path = XA_ENHANCED_DIR / "xa60_sample.dcm"
Loading