diff --git a/README.md b/README.md index 77c8089..1d1dfe8 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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 diff --git a/XA_ENHANCED_ANALYSIS.md b/XA_ENHANCED_ANALYSIS.md new file mode 100644 index 0000000..45813d4 --- /dev/null +++ b/XA_ENHANCED_ANALYSIS.md @@ -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 `` tag +- Contains ASCCONV-style parameters +- **Already parseable by `CsaAsciiHeader` class!** + +### 3. Sample XProtocol Data Structure + +``` + +{ + "PhoenixMetaProtocol" + 1000002 + 2.0 + + + { + { ... } + { ... } + ... + } +} +``` + +## 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 `` were being interpreted as binary integers. diff --git a/csa_header/header.py b/csa_header/header.py index 18b9e8b..69022cf 100644 --- a/csa_header/header.py +++ b/csa_header/header.py @@ -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. @@ -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, @@ -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 @@ -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 ------ @@ -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() @@ -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 diff --git a/tests/files/xa_enhanced/xa30_sample.dcm b/tests/files/xa_enhanced/xa30_sample.dcm new file mode 100644 index 0000000..e6b50d6 Binary files /dev/null and b/tests/files/xa_enhanced/xa30_sample.dcm differ diff --git a/tests/files/xa_enhanced/xa60_sample.dcm b/tests/files/xa_enhanced/xa60_sample.dcm new file mode 100755 index 0000000..70c823d Binary files /dev/null and b/tests/files/xa_enhanced/xa60_sample.dcm differ diff --git a/tests/fixtures.py b/tests/fixtures.py index 0644e1e..eaaab70 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -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" diff --git a/tests/test_xa_enhanced.py b/tests/test_xa_enhanced.py new file mode 100644 index 0000000..41af105 --- /dev/null +++ b/tests/test_xa_enhanced.py @@ -0,0 +1,257 @@ +"""Tests for XA Enhanced DICOM support (Issue #31).""" + +from __future__ import annotations + +from unittest import TestCase + +import pydicom + +from csa_header.ascii import CsaAsciiHeader +from csa_header.header import CsaHeader +from tests.fixtures import XA30_SAMPLE_DICOM, XA60_SAMPLE_DICOM + + +class XAEnhancedDetectionTestCase(TestCase): + """Tests for XA Enhanced DICOM format detection.""" + + def setUp(self): + """Load XA Enhanced DICOM test files.""" + self.xa30_dcm = pydicom.dcmread(XA30_SAMPLE_DICOM) + self.xa60_dcm = pydicom.dcmread(XA60_SAMPLE_DICOM) + + def test_xa30_has_shared_functional_groups_sequence(self): + """Test that XA30 DICOM has SharedFunctionalGroupsSequence.""" + self.assertIn("SharedFunctionalGroupsSequence", self.xa30_dcm) + + def test_xa60_has_shared_functional_groups_sequence(self): + """Test that XA60 DICOM has SharedFunctionalGroupsSequence.""" + self.assertIn("SharedFunctionalGroupsSequence", self.xa60_dcm) + + def test_xa30_does_not_have_standard_csa_tags(self): + """Test that XA30 DICOM does not have standard CSA tags.""" + self.assertNotIn((0x0029, 0x1010), self.xa30_dcm) + self.assertNotIn((0x0029, 0x1020), self.xa30_dcm) + + def test_xa60_does_not_have_standard_csa_tags(self): + """Test that XA60 DICOM does not have standard CSA tags.""" + self.assertNotIn((0x0029, 0x1010), self.xa60_dcm) + self.assertNotIn((0x0029, 0x1020), self.xa60_dcm) + + def test_xa30_has_xprotocol_data(self): + """Test that XA30 DICOM has XProtocol data in the expected location.""" + sds = self.xa30_dcm.SharedFunctionalGroupsSequence[0] + self.assertIn((0x0021, 0x10FE), sds) + + mrprot_seq = sds[(0x0021, 0x10FE)] + self.assertIsNotNone(mrprot_seq.value) + self.assertGreater(len(mrprot_seq.value), 0) + + mrprot_item = mrprot_seq.value[0] + self.assertIn((0x0021, 0x1019), mrprot_item) + + protocol_data = mrprot_item[(0x0021, 0x1019)].value + self.assertIsNotNone(protocol_data) + self.assertIsInstance(protocol_data, bytes) + + def test_xa60_has_xprotocol_data(self): + """Test that XA60 DICOM has XProtocol data in the expected location.""" + sds = self.xa60_dcm.SharedFunctionalGroupsSequence[0] + self.assertIn((0x0021, 0x10FE), sds) + + mrprot_seq = sds[(0x0021, 0x10FE)] + mrprot_item = mrprot_seq.value[0] + self.assertIn((0x0021, 0x1019), mrprot_item) + + protocol_data = mrprot_item[(0x0021, 0x1019)].value + self.assertIsNotNone(protocol_data) + + +class XAEnhancedExtractionTestCase(TestCase): + """Tests for XA Enhanced XProtocol data extraction.""" + + def setUp(self): + """Load XA Enhanced DICOM test files.""" + self.xa30_dcm = pydicom.dcmread(XA30_SAMPLE_DICOM) + self.xa60_dcm = pydicom.dcmread(XA60_SAMPLE_DICOM) + + def test_extract_xa_enhanced_protocol_xa30(self): + """Test extracting XProtocol from XA30 DICOM.""" + protocol = CsaHeader._extract_xa_enhanced_protocol(self.xa30_dcm) + self.assertIsNotNone(protocol) + self.assertIsInstance(protocol, bytes) + self.assertGreater(len(protocol), 0) + + # Check that it starts with XProtocol marker + self.assertTrue(protocol.startswith(b"")) + + def test_extract_xa_enhanced_protocol_xa60(self): + """Test extracting XProtocol from XA60 DICOM.""" + protocol = CsaHeader._extract_xa_enhanced_protocol(self.xa60_dcm) + self.assertIsNotNone(protocol) + self.assertIsInstance(protocol, bytes) + self.assertGreater(len(protocol), 0) + self.assertTrue(protocol.startswith(b"")) + + def test_extract_xa_enhanced_protocol_returns_none_for_standard_dicom(self): + """Test that extraction returns None for non-XA Enhanced DICOMs.""" + # Create a minimal DICOM dataset without SharedFunctionalGroupsSequence + standard_dcm = pydicom.Dataset() + result = CsaHeader._extract_xa_enhanced_protocol(standard_dcm) + self.assertIsNone(result) + + +class XAEnhancedFromDicomTestCase(TestCase): + """Tests for CsaHeader.from_dicom() with XA Enhanced DICOMs.""" + + def setUp(self): + """Load XA Enhanced DICOM test files.""" + self.xa30_dcm = pydicom.dcmread(XA30_SAMPLE_DICOM) + self.xa60_dcm = pydicom.dcmread(XA60_SAMPLE_DICOM) + + def test_from_dicom_xa30_returns_csa_ascii_header(self): + """Test that from_dicom returns CsaAsciiHeader for XA30 files.""" + result = CsaHeader.from_dicom(self.xa30_dcm, "image") + self.assertIsNotNone(result) + self.assertIsInstance(result, CsaAsciiHeader) + + def test_from_dicom_xa60_returns_csa_ascii_header(self): + """Test that from_dicom returns CsaAsciiHeader for XA60 files.""" + result = CsaHeader.from_dicom(self.xa60_dcm, "image") + self.assertIsNotNone(result) + self.assertIsInstance(result, CsaAsciiHeader) + + def test_from_dicom_xa30_with_series_type(self): + """Test from_dicom with 'series' type for XA30 (should still work).""" + # XA Enhanced stores protocol in one place, type is ignored + result = CsaHeader.from_dicom(self.xa30_dcm, "series") + self.assertIsNotNone(result) + self.assertIsInstance(result, CsaAsciiHeader) + + def test_from_dicom_xa60_with_series_type(self): + """Test from_dicom with 'series' type for XA60 (should still work).""" + result = CsaHeader.from_dicom(self.xa60_dcm, "series") + self.assertIsNotNone(result) + self.assertIsInstance(result, CsaAsciiHeader) + + +class XAEnhancedParsingTestCase(TestCase): + """Tests for parsing XA Enhanced XProtocol data.""" + + def setUp(self): + """Load and extract XA Enhanced headers.""" + self.xa30_dcm = pydicom.dcmread(XA30_SAMPLE_DICOM) + self.xa60_dcm = pydicom.dcmread(XA60_SAMPLE_DICOM) + + self.xa30_header = CsaHeader.from_dicom(self.xa30_dcm, "image") + self.xa60_header = CsaHeader.from_dicom(self.xa60_dcm, "image") + + def test_xa30_parsing_succeeds(self): + """Test that XA30 XProtocol data can be parsed.""" + self.assertIsNotNone(self.xa30_header) + parsed = self.xa30_header.parsed + self.assertIsInstance(parsed, dict) + self.assertGreater(len(parsed), 0) + + def test_xa60_parsing_succeeds(self): + """Test that XA60 XProtocol data can be parsed.""" + self.assertIsNotNone(self.xa60_header) + parsed = self.xa60_header.parsed + self.assertIsInstance(parsed, dict) + self.assertGreater(len(parsed), 0) + + def test_xa30_parsed_contains_expected_keys(self): + """Test that XA30 parsed data contains expected protocol keys.""" + parsed = self.xa30_header.parsed + + # Check for common protocol parameters + self.assertIn("sSliceArray", parsed) + self.assertIsInstance(parsed["sSliceArray"], dict) + self.assertIn("lSize", parsed["sSliceArray"]) + + def test_xa60_parsed_contains_expected_keys(self): + """Test that XA60 parsed data contains expected protocol keys.""" + parsed = self.xa60_header.parsed + self.assertIn("sSliceArray", parsed) + self.assertIsInstance(parsed["sSliceArray"], dict) + + def test_xa30_slice_array_data(self): + """Test accessing specific XA30 protocol data.""" + parsed = self.xa30_header.parsed + slice_array = parsed["sSliceArray"] + + # Check that we can access nested data + self.assertIn("lSize", slice_array) + self.assertIsInstance(slice_array["lSize"], (int, float)) + self.assertGreaterEqual(slice_array["lSize"], 1) + + def test_xa60_slice_array_data(self): + """Test accessing specific XA60 protocol data.""" + parsed = self.xa60_header.parsed + slice_array = parsed["sSliceArray"] + + self.assertIn("lSize", slice_array) + self.assertIsInstance(slice_array["lSize"], (int, float)) + + +class XAEnhancedIssue31RegressionTestCase(TestCase): + """Regression tests for Issue #31.""" + + def setUp(self): + """Set up test data matching Issue #31 example.""" + self.dcm = pydicom.dcmread(XA30_SAMPLE_DICOM) + + def test_issue_31_example_code_does_not_raise(self): + """ + Test that the example code from Issue #31 now works. + + Original error: + CsaReadError: CSA element #0 has an invalid check bit value: 1632648224! + """ + # This is the approach from Issue #31 + sds = self.dcm.SharedFunctionalGroupsSequence[0] + mrprot = sds[(0x0021, 0x10FE)][0][(0x0021, 0x1019)] + + # Using CsaHeader directly on the value should fail (it's XProtocol, not CSA) + # But using from_dicom should work + header = CsaHeader.from_dicom(self.dcm, "image") + self.assertIsNotNone(header) + + # Should return CsaAsciiHeader which can parse XProtocol + self.assertIsInstance(header, CsaAsciiHeader) + + # Should be able to parse without error + parsed = header.parsed + self.assertIsInstance(parsed, dict) + self.assertGreater(len(parsed), 0) + + def test_issue_31_direct_xprotocol_parsing(self): + """Test that we can parse XProtocol data directly with CsaAsciiHeader.""" + sds = self.dcm.SharedFunctionalGroupsSequence[0] + xprotocol_data = sds[(0x0021, 0x10FE)][0][(0x0021, 0x1019)].value + + # This should work now + header = CsaAsciiHeader(xprotocol_data) + parsed = header.parsed + + self.assertIsInstance(parsed, dict) + self.assertGreater(len(parsed), 0) + self.assertIn("sSliceArray", parsed) + + +class XAEnhancedBackwardCompatibilityTestCase(TestCase): + """Tests to ensure XA Enhanced support doesn't break existing functionality.""" + + def test_from_dicom_still_validates_csa_type(self): + """Test that invalid csa_type still raises ValueError.""" + dcm = pydicom.dcmread(XA30_SAMPLE_DICOM) + + with self.assertRaises(ValueError) as cm: + CsaHeader.from_dicom(dcm, "invalid_type") + + self.assertIn("Invalid csa_type", str(cm.exception)) + + def test_from_dicom_returns_none_for_empty_dicom(self): + """Test that from_dicom returns None for DICOM without CSA or XProtocol.""" + empty_dcm = pydicom.Dataset() + result = CsaHeader.from_dicom(empty_dcm, "image") + self.assertIsNone(result)