diff --git a/src/dicognito/release_notes.md b/src/dicognito/release_notes.md index 35f4cc5..74b0ccd 100644 --- a/src/dicognito/release_notes.md +++ b/src/dicognito/release_notes.md @@ -2,6 +2,10 @@ - Drop support for Python 3.10 ([#185](https://github.com/blairconrad/dicognito/issues/185)) +### Fixed + +- Anonymized UIs are not conformant DICOM UIDs. They now use the UUID-derived "2.25." form ([#189](https://github.com/blairconrad/dicognito/issues/189)) + ## 0.19.0 ### Changed diff --git a/src/dicognito/uianonymizer.py b/src/dicognito/uianonymizer.py index 9406ea8..791c006 100644 --- a/src/dicognito/uianonymizer.py +++ b/src/dicognito/uianonymizer.py @@ -1,5 +1,6 @@ """Replace UIs with a new value.""" +import uuid from collections.abc import Iterator import pydicom @@ -8,6 +9,10 @@ from dicognito.element_anonymizer import ElementAnonymizer from dicognito.randomizer import Randomizer +# Fixed namespace for dicognito's name-based UUIDs. Never change it, or the same +# seed will stop producing the same UIs. +DICOGNITO_NAMESPACE = uuid.UUID("220d81d1-0385-470f-9194-f7c213aa7490") + class UIAnonymizer(ElementAnonymizer): """ @@ -19,6 +24,7 @@ class UIAnonymizer(ElementAnonymizer): def __init__(self, randomizer: Randomizer) -> None: """Create a new UIAnonymizer.""" self._randomizer = randomizer + self._seed_namespace = uuid.uuid5(DICOGNITO_NAMESPACE, randomizer.seed) def __call__( self, @@ -62,4 +68,5 @@ def describe_actions(self) -> Iterator[str]: yield "Replace all UI elements with anonymized values" def _new_ui(self, ui: str) -> str: - return "2." + str(10**39 + self._randomizer.to_int(ui)) + # A UID made from a UUID, as described in DICOM PS3.5 section B.2. + return f"2.25.{uuid.uuid5(self._seed_namespace, ui).int}" diff --git a/tests/test_anonymize_single_instance.py b/tests/test_anonymize_single_instance.py index cb3cf48..832e911 100644 --- a/tests/test_anonymize_single_instance.py +++ b/tests/test_anonymize_single_instance.py @@ -1,6 +1,7 @@ from __future__ import annotations import datetime as dt +import uuid import pydicom import pytest @@ -78,6 +79,58 @@ def test_repeated_identifying_uis_get_same_values(one_element_path, another_elem assert one_uid == another_uid +@pytest.mark.parametrize( + "element_path", + [ + "file_meta.MediaStorageSOPInstanceUID", + "SOPInstanceUID", + "StudyInstanceUID", + "SeriesInstanceUID", + "FrameOfReferenceUID", + ], +) +def test_identifying_uis_are_conformant_uuid_derived_uids(element_path): + with load_test_instance() as dataset: + anonymizer = Anonymizer() + anonymizer.anonymize(dataset) + + actual = eval("dataset." + element_path) + + assert_is_uuid5_derived_uid(actual) + + +def test_multivalued_identifying_ui_gets_conformant_values(): + with load_test_instance() as dataset: + original = ["1.2.3.1.1", "1.2.3.1.2"] + dataset.SourceImageSequence[0].ReferencedSOPInstanceUID = original + + anonymizer = Anonymizer() + anonymizer.anonymize(dataset) + + actual = dataset.SourceImageSequence[0].ReferencedSOPInstanceUID + + assert len(set(actual)) == len(original) + for value in actual: + assert_is_uuid5_derived_uid(value) + + +def test_identifying_uis_are_the_same_with_the_same_seed(): + with load_test_instance() as dataset1, load_test_instance() as dataset2: + Anonymizer(seed="some seed").anonymize(dataset1) + Anonymizer(seed="some seed").anonymize(dataset2) + + assert dataset1.SOPInstanceUID == dataset2.SOPInstanceUID + assert dataset1.StudyInstanceUID == dataset2.StudyInstanceUID + + +def test_identifying_uis_differ_with_a_different_seed(): + with load_test_instance() as dataset1, load_test_instance() as dataset2: + Anonymizer(seed="some seed").anonymize(dataset1) + Anonymizer(seed="another seed").anonymize(dataset2) + + assert dataset1.SOPInstanceUID != dataset2.SOPInstanceUID + + @pytest.mark.parametrize( "element_path", [ @@ -733,3 +786,13 @@ def assert_element_is(dataset: pydicom.dataset.Dataset, element_name: str, expec assert element_name not in dataset else: assert expected == getattr(dataset, element_name) + + +def assert_is_uuid5_derived_uid(uid: str) -> None: + assert uid.startswith("2.25.") + remainder = uid.removeprefix("2.25.") + assert remainder.isdigit() + assert remainder == "0" or not remainder.startswith("0") + derived_uuid = uuid.UUID(int=int(remainder)) + assert derived_uuid.version == 5 # noqa: PLR2004 + assert derived_uuid.variant == uuid.RFC_4122