Skip to content

Commit fcbc7ea

Browse files
ZviBaratzclaude
andcommitted
feat: add XA Enhanced DICOM support (fixes #31)
Add full support for Siemens syngo MR XA Enhanced DICOM files (XA30, XA60+) which store protocol data in XProtocol format rather than binary CSA format. ## Changes ### Core Implementation - Add XA_ENHANCED_TAGS class variable with XA-specific tag definitions - Add _extract_xa_enhanced_protocol() method to extract XProtocol data from SharedFunctionalGroupsSequence - Update from_dicom() to automatically detect and handle both formats: - Returns CsaHeader for standard binary CSA format - Returns CsaAsciiHeader for XA Enhanced XProtocol format - Maintains full backward compatibility ### Tests - Add comprehensive test suite (23 new tests) in test_xa_enhanced.py - Test XA Enhanced detection, extraction, and parsing - Add regression tests for Issue #31 - Verify backward compatibility - All 223 tests pass (200 existing + 23 new) - Coverage: 94% ### Test Data - Add XA30 sample DICOM (syngo MR XA30, MAGNETOM Prisma Fit) - Add XA60 sample DICOM (syngo MR XA60, MAGNETOM Terra.X) - Update test fixtures with XA Enhanced file paths ### Documentation - Update README with XA Enhanced support in features - Add "Supported DICOM Formats" section - Add "Working with XA Enhanced DICOMs" usage guide - Add XA_ENHANCED_ANALYSIS.md with technical details ## Technical Details XA Enhanced DICOMs store protocol data differently: - Location: SharedFunctionalGroupsSequence[0][(0x0021,0x10FE)][0][(0x0021,0x1019)] - Format: XProtocol (ASCII/XML-like) instead of binary CSA - Already parseable by existing CsaAsciiHeader class The from_dicom() method now: 1. First tries standard CSA tags (0x0029,0x1010/0x1020) 2. If not found, tries XA Enhanced location 3. Returns appropriate parser (CsaHeader or CsaAsciiHeader) Closes #31 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent c07e9b9 commit fcbc7ea

7 files changed

Lines changed: 524 additions & 27 deletions

File tree

README.md

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,12 +41,20 @@ The _CSA Image Header Info_ and _CSA Series Header Info_ elements contain encode
4141

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

51+
### Supported DICOM Formats
52+
53+
- **Standard Siemens DICOMs**: Binary CSA headers in tags `(0x0029, 0x1010)` and `(0x0029, 0x1020)`
54+
- **XA Enhanced DICOMs**: XProtocol format in `SharedFunctionalGroupsSequence` (syngo MR XA30, XA60, and newer)
55+
56+
The library automatically detects the format and returns the appropriate parser.
57+
5058
**Table of Contents**
5159

5260
- [Features](#features)
@@ -177,6 +185,49 @@ Example parsed CSA header structure:
177185
}
178186
```
179187

188+
### Working with XA Enhanced DICOMs
189+
190+
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:
191+
192+
```python
193+
>>> import pydicom
194+
>>> from csa_header import CsaHeader
195+
>>> from csa_header.ascii import CsaAsciiHeader
196+
>>>
197+
>>> # Load XA Enhanced DICOM
198+
>>> dcm = pydicom.dcmread("xa_enhanced.dcm")
199+
>>>
200+
>>> # Use the same API - automatic format detection
201+
>>> header = CsaHeader.from_dicom(dcm, 'image')
202+
>>>
203+
>>> # For XA Enhanced, returns CsaAsciiHeader instead of CsaHeader
204+
>>> if isinstance(header, CsaAsciiHeader):
205+
... # Access parsed protocol data
206+
... protocol = header.parsed
207+
... slice_array = protocol['sSliceArray']
208+
... print(f"Number of slices: {slice_array['lSize']}")
209+
Number of slices: 176
210+
```
211+
212+
**Key differences for XA Enhanced DICOMs:**
213+
214+
- Returns `CsaAsciiHeader` instead of `CsaHeader`
215+
- Use `.parsed` property instead of `.read()` method
216+
- The `csa_type` parameter ('image' or 'series') is ignored for XA Enhanced files, as protocol data is stored in a single location
217+
218+
```python
219+
>>> # Seamless API regardless of DICOM format
220+
>>> def get_protocol_data(dcm):
221+
... header = CsaHeader.from_dicom(dcm, 'image')
222+
... if header is None:
223+
... return None
224+
... # Handle both formats
225+
... if isinstance(header, CsaAsciiHeader):
226+
... return header.parsed
227+
... else:
228+
... return header.read()
229+
```
230+
180231
## Advanced Usage
181232

182233
### Extracting ASCCONV Protocol

XA_ENHANCED_ANALYSIS.md

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
# XA Enhanced DICOM Analysis - Issue #31
2+
3+
## Summary
4+
5+
XA Enhanced DICOM files (syngo MR XA30, XA60, etc.) store Siemens protocol data in a different format and location compared to standard Siemens DICOMs.
6+
7+
## Key Findings
8+
9+
### 1. Data Location Differences
10+
11+
**Standard Siemens DICOMs:**
12+
- CSA Image Header: `(0x0029, 0x1010)`
13+
- CSA Series Header: `(0x0029, 0x1020)`
14+
15+
**XA Enhanced DICOMs:**
16+
- No CSA tags at `(0x0029, 0x1010)` or `(0x0029, 0x1020)`
17+
- Protocol data stored in: `SharedFunctionalGroupsSequence[0][(0x0021, 0x10FE)][0][(0x0021, 0x1019)]`
18+
19+
### 2. Data Format Differences
20+
21+
**Standard Siemens DICOMs:**
22+
- Binary CSA format (Type 1 or Type 2 with "SV10" signature)
23+
- Contains structured binary data with check bits (77 or 205)
24+
- Parsed by `CsaHeader` class
25+
26+
**XA Enhanced DICOMs:**
27+
- **XProtocol format** (ASCII/XML-like text)
28+
- Starts with `<XProtocol>` tag
29+
- Contains ASCCONV-style parameters
30+
- **Already parseable by `CsaAsciiHeader` class!**
31+
32+
### 3. Sample XProtocol Data Structure
33+
34+
```
35+
<XProtocol>
36+
{
37+
<Name> "PhoenixMetaProtocol"
38+
<ID> 1000002
39+
<Userversion> 2.0
40+
41+
<ParamMap."">
42+
{
43+
<ParamLong."Count"> { ... }
44+
<ParamString."Protocol0"> { ... }
45+
...
46+
}
47+
}
48+
```
49+
50+
## Test Data
51+
52+
Sample XA files downloaded and stored in:
53+
- `tests/files/xa_enhanced/xa30_sample.dcm` (XA30 - Siemens MAGNETOM Prisma Fit)
54+
- `tests/files/xa_enhanced/xa60_sample.dcm` (XA60 - Siemens MAGNETOM Terra.X)
55+
56+
Full test repositories cloned to:
57+
- `tests/files/xa_enhanced/xa30_repo/` (https://github.com/neurolabusc/dcm_qa_xa30)
58+
- `tests/files/xa_enhanced/xa60_repo/` (https://github.com/neurolabusc/dcm_qa_xa60)
59+
60+
## Solution Approach
61+
62+
The library already has the components needed to support XA Enhanced DICOMs:
63+
64+
1. **`CsaAsciiHeader`** class can successfully parse XProtocol data
65+
2. Just need to update `CsaHeader.from_dicom()` to:
66+
- Detect XA Enhanced DICOMs (check for SharedFunctionalGroupsSequence)
67+
- Extract XProtocol data from the correct tag sequence
68+
- Return `CsaAsciiHeader` instance instead of `CsaHeader` instance
69+
70+
## Implementation Plan
71+
72+
1. Modify `CsaHeader.from_dicom()` method:
73+
- Add XA Enhanced DICOM detection
74+
- Extract XProtocol data from SharedFunctionalGroupsSequence
75+
- Return appropriate parser (CsaHeader for binary, CsaAsciiHeader for XProtocol)
76+
77+
2. Add comprehensive tests for XA Enhanced support
78+
79+
3. Update documentation to list XA Enhanced as supported format
80+
81+
## Example Usage (Post-Implementation)
82+
83+
```python
84+
import pydicom
85+
from csa_header import CsaHeader
86+
87+
# Load XA Enhanced DICOM
88+
dcm = pydicom.dcmread('xa_enhanced.dcm')
89+
90+
# This will now work and return a CsaAsciiHeader instance
91+
header = CsaHeader.from_dicom(dcm, 'image') # or 'series'
92+
parsed = header.parsed # Access parsed protocol data
93+
94+
# Example: Get slice information
95+
n_slices = header.parsed['sSliceArray']['lSize']
96+
```
97+
98+
## Original Error
99+
100+
When trying to parse XProtocol data as binary CSA:
101+
```
102+
CsaReadError: CSA element #0 has an invalid check bit value: 1632648224!
103+
Valid values are {205, 77}
104+
```
105+
106+
This occurred because the ASCII characters `<XProtocol>` were being interpreted as binary integers.

csa_header/header.py

Lines changed: 104 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,13 @@ class CsaHeader:
6363
"series": (0x0029, 0x1020), # CSA Series Header Info
6464
}
6565

66+
#: XA Enhanced DICOM tags for XProtocol data.
67+
#: These are used in syngo MR XA30, XA60, etc.
68+
XA_ENHANCED_TAGS: ClassVar[dict[str, tuple[int, int]]] = {
69+
"sequence_tag": (0x0021, 0x10FE), # MR Protocol Sequence
70+
"protocol_tag": (0x0021, 0x1019), # XProtocol data
71+
}
72+
6673
def __init__(self, raw: bytes):
6774
"""
6875
Initialize a new `CsaHeader` instance.
@@ -362,6 +369,59 @@ def is_type_2(self) -> bool:
362369
"""
363370
return self._csa_type == self.CSA_TYPE_2
364371

372+
@staticmethod
373+
def _extract_xa_enhanced_protocol(
374+
dcm_data: pydicom.Dataset,
375+
) -> bytes | None:
376+
"""
377+
Extract XProtocol data from XA Enhanced DICOM files.
378+
379+
XA Enhanced DICOMs (syngo MR XA30, XA60, etc.) store protocol data in
380+
XProtocol format (ASCII/XML-like) within the SharedFunctionalGroupsSequence,
381+
rather than using the standard CSA binary tags.
382+
383+
Parameters
384+
----------
385+
dcm_data : pydicom.Dataset
386+
DICOM dataset that may be in XA Enhanced format
387+
388+
Returns
389+
-------
390+
bytes or None
391+
XProtocol data as bytes, or None if not present/not XA Enhanced format
392+
"""
393+
# Check for SharedFunctionalGroupsSequence (present in Enhanced DICOMs)
394+
if "SharedFunctionalGroupsSequence" not in dcm_data:
395+
return None
396+
397+
try:
398+
# Navigate the sequence structure:
399+
# SharedFunctionalGroupsSequence[0][(0x0021, 0x10FE)][0][(0x0021, 0x1019)]
400+
sds = dcm_data.SharedFunctionalGroupsSequence[0]
401+
sequence_tag = CsaHeader.XA_ENHANCED_TAGS["sequence_tag"]
402+
protocol_tag = CsaHeader.XA_ENHANCED_TAGS["protocol_tag"]
403+
404+
if sequence_tag not in sds:
405+
return None
406+
407+
mrprot_seq = sds[sequence_tag]
408+
if not hasattr(mrprot_seq, "value") or not mrprot_seq.value:
409+
return None
410+
411+
mrprot_item = mrprot_seq.value[0]
412+
if protocol_tag not in mrprot_item:
413+
return None
414+
415+
protocol_data = mrprot_item[protocol_tag].value
416+
if protocol_data is None:
417+
return None
418+
419+
return bytes(protocol_data)
420+
421+
except (AttributeError, IndexError, KeyError):
422+
# If any part of the navigation fails, this isn't XA Enhanced format
423+
return None
424+
365425
@staticmethod
366426
def _extract_csa_bytes(
367427
dcm_data: pydicom.Dataset,
@@ -404,14 +464,18 @@ def from_dicom(
404464
cls,
405465
dcm_data: pydicom.Dataset,
406466
csa_type: Literal["image", "series"] = "image",
407-
) -> CsaHeader | None:
467+
) -> CsaHeader | CsaAsciiHeader | None:
408468
"""
409469
Extract and parse CSA header directly from a DICOM dataset.
410470
411471
This method implements the DICOM private tag search protocol to locate
412-
and extract CSA headers from Siemens DICOM files. The implementation is
413-
inspired by nibabel's ``get_csa_header()`` function, adapted to csa_header's
414-
API design.
472+
and extract CSA headers from Siemens DICOM files. It supports both:
473+
474+
- Standard Siemens DICOMs with binary CSA headers (tags 0x0029,0x1010/0x1020)
475+
- XA Enhanced DICOMs with XProtocol data (syngo MR XA30, XA60, etc.)
476+
477+
The implementation is inspired by nibabel's ``get_csa_header()`` function,
478+
adapted to csa_header's API design.
415479
416480
For more information on nibabel's CSA header support, see:
417481
https://github.com/nipy/nibabel
@@ -426,12 +490,18 @@ def from_dicom(
426490
- ``'image'`` : CSA Image Header Info (0x0029, 0x1010)
427491
- ``'series'`` : CSA Series Header Info (0x0029, 0x1020)
428492
493+
Note: For XA Enhanced DICOMs, this parameter is ignored as protocol
494+
data is stored in a single location (SharedFunctionalGroupsSequence).
495+
429496
Returns
430497
-------
431-
CsaHeader or None
432-
CsaHeader instance containing the raw CSA data, or None if the
433-
specified CSA header is not present in the dataset. Call ``.read()``
434-
on the returned instance to get the parsed dictionary.
498+
CsaHeader, CsaAsciiHeader, or None
499+
- ``CsaHeader`` : For standard binary CSA headers
500+
- ``CsaAsciiHeader`` : For XA Enhanced XProtocol data
501+
- ``None`` : If no CSA/protocol data is found
502+
503+
Call ``.read()`` or access ``.parsed`` on the returned instance to
504+
get the parsed dictionary.
435505
436506
Raises
437507
------
@@ -443,33 +513,35 @@ def from_dicom(
443513
>>> import pydicom
444514
>>> from csa_header import CsaHeader
445515
>>>
446-
>>> # Load DICOM file
516+
>>> # Load DICOM file (works for both standard and XA Enhanced)
447517
>>> dcm = pydicom.dcmread('siemens_scan.dcm')
448518
>>>
449-
>>> # Extract image CSA header
519+
>>> # Extract CSA header (automatically detects format)
450520
>>> csa_header = CsaHeader.from_dicom(dcm, 'image')
451521
>>> if csa_header:
452-
... csa_dict = csa_header.read()
453-
... print(f"Found {len(csa_dict)} CSA tags")
454-
>>>
455-
>>> # Extract series CSA header
456-
>>> csa_header = CsaHeader.from_dicom(dcm, 'series')
457-
>>> if csa_header:
458-
... csa_dict = csa_header.read()
459-
... protocol = csa_dict.get('MrPhoenixProtocol')
522+
... # For binary CSA headers, use .read()
523+
... # For XA Enhanced (CsaAsciiHeader), use .parsed
524+
... if isinstance(csa_header, CsaAsciiHeader):
525+
... csa_dict = csa_header.parsed
526+
... else:
527+
... csa_dict = csa_header.read()
528+
... print(f"Found CSA data")
460529
461530
Notes
462531
-----
463-
This is a convenience method that combines DICOM tag extraction with
464-
CSA header parsing. It is equivalent to:
532+
For standard Siemens DICOMs, this is equivalent to:
465533
466534
>>> raw_bytes = dcm[(0x0029, 0x1010)].value # for 'image'
467535
>>> csa_header = CsaHeader(raw_bytes)
468536
>>> csa_dict = csa_header.read()
469537
538+
For XA Enhanced DICOMs, it extracts XProtocol data from:
539+
SharedFunctionalGroupsSequence[0][(0x0021,0x10FE)][0][(0x0021,0x1019)]
540+
470541
See Also
471542
--------
472-
CsaHeader.read : Parse CSA header from raw bytes
543+
CsaHeader.read : Parse binary CSA header from raw bytes
544+
CsaAsciiHeader.parsed : Access parsed XProtocol data
473545
"""
474546
# Validate csa_type parameter
475547
csa_type_lower = csa_type.lower()
@@ -478,10 +550,16 @@ def from_dicom(
478550
msg = f"Invalid csa_type: {csa_type!r}. Must be one of: {valid_types}"
479551
raise ValueError(msg)
480552

481-
# Extract raw CSA bytes
553+
# First, try standard CSA binary format
482554
raw_bytes = cls._extract_csa_bytes(dcm_data, csa_type_lower)
483-
if raw_bytes is None:
484-
return None
555+
if raw_bytes is not None:
556+
return cls(raw_bytes)
557+
558+
# If not found, try XA Enhanced XProtocol format
559+
xa_protocol = cls._extract_xa_enhanced_protocol(dcm_data)
560+
if xa_protocol is not None:
561+
# XA Enhanced uses XProtocol format (ASCII), return CsaAsciiHeader
562+
return CsaAsciiHeader(xa_protocol)
485563

486-
# Create and return CsaHeader instance
487-
return cls(raw_bytes)
564+
# No CSA data found in either format
565+
return None
3.28 MB
Binary file not shown.
6.64 MB
Binary file not shown.

tests/fixtures.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,8 @@
66
DWI_CSA_IMAGE_HEADER_INFO: Path = TEST_FILES_DIR / "dwi_image_header_info"
77
RSFMRI_CSA_SERIES_HEADER_INFO: Path = TEST_FILES_DIR / "rsfmri_series_header_info"
88
E11_CSA_SERIES_HEADER_INFO: Path = TEST_FILES_DIR / "e11_series_header_info"
9+
10+
# XA Enhanced DICOM test files (syngo MR XA30, XA60)
11+
XA_ENHANCED_DIR: Path = TEST_FILES_DIR / "xa_enhanced"
12+
XA30_SAMPLE_DICOM: Path = XA_ENHANCED_DIR / "xa30_sample.dcm"
13+
XA60_SAMPLE_DICOM: Path = XA_ENHANCED_DIR / "xa60_sample.dcm"

0 commit comments

Comments
 (0)