Source code for pymedphys._experimental.pseudonymisation

# Copyright (C) 2026 Matthew Jennings
# Copyright (C) 2020 Stuart Swerdloff, Simon Biggs
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at

#     http://www.apache.org/licenses/LICENSE-2.0

# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import functools
import json
import logging
import pathlib
import sys
from os.path import abspath, dirname, isdir, isfile
from os.path import join as pjoin

from pymedphys._dicom.anonymise import (
    anonymise_dataset,
    anonymise_directory,
    anonymise_file,
    get_baseline_keyword_vr_dict,
    get_default_identifying_keywords,
)
from pymedphys._dicom.anonymise import strategy as anon_strategy
from pymedphys._dicom.anonymise.api import print_cli_summary
from pymedphys._dicom.anonymise.diagnostics import report_errors_by_type
from pymedphys._imports import pydicom

from . import strategy

HERE = dirname(abspath(__file__))

IDENTIFYING_UIDS_FILEPATH = pjoin(HERE, "identifying_uids.json")

LIMITATION_NOTICE = (
    "experimental pseudonymisation hashes UIDs and some numeric values "
    "without a secret key, so anyone who holds the original UIDs can re-link "
    "records, and anyone can recover small-range values such as weight by "
    "hashing every plausible value. It shifts every patient's dates by the "
    "same offset, and its output keeps the original file preamble and the "
    "original SOP Instance UID in the File Meta Information. Review its output "
    "before sharing it. See https://docs.pymedphys.com/en/latest/users/"
    "background/dicom-deidentification.html"
)


[docs] class PseudonymisationLimitationWarning(UserWarning): """Experimental pseudonymisation has known security limitations. This is not a deprecation warning. No replacement is available yet, so experimental pseudonymisation is not scheduled for removal. """
@functools.lru_cache() def _get_default_identifying_uids(): with open(IDENTIFYING_UIDS_FILEPATH) as uid_file: IDENTIFYING_UIDS = json.load(uid_file) return tuple(IDENTIFYING_UIDS) def get_default_identifying_uids(): return list(_get_default_identifying_uids()) @functools.lru_cache() def _get_default_pseudonymisation_keywords(): anon_keyword_list = get_default_identifying_keywords() # The preferred approach is to pseudonymise the contents # of sequences, rather than operate on the sequence itself # # Eliminating the keywords that are sequences fixes issue #1034 # for default usage identifying_keywords_less_sequences = [ x for x in anon_keyword_list if not x.endswith("Sequence") ] anon_keyword_set = set(identifying_keywords_less_sequences) pseudo_uid_set = set(get_default_identifying_uids()) return tuple(anon_keyword_set.union(pseudo_uid_set)) def get_default_pseudonymisation_keywords(): return list(_get_default_pseudonymisation_keywords()) @report_errors_by_type def anonymise_with_pseudo_cli(args): # Python warnings are easy to miss on the command line, so say it directly. print(f"Warning: {LIMITATION_NOTICE}", file=sys.stderr) if args.delete_unknown_tags: handle_unknown_tags = True elif args.ignore_unknown_tags: handle_unknown_tags = False else: handle_unknown_tags = None if not args.keywords_to_leave_unchanged: keywords_to_leave_unchanged = () else: keywords_to_leave_unchanged = args.keywords_to_leave_unchanged replacement_strategy = anon_strategy.ANONYMISATION_HARDCODE_DISPATCH identifying_keywords_for_pseudo = get_default_identifying_keywords() logging.info("Pseudonymisation called!") identifying_keywords_for_pseudo = get_default_pseudonymisation_keywords() logging.info("Using pseudonymisation keywords") replacement_strategy = strategy.pseudonymisation_dispatch logging.info("Using pseudonymisation strategy") if isfile(args.input_path): anonymise_file( dicom_filepath=args.input_path, output_filepath=args.output_path, delete_original_file=args.delete_original_files, anonymise_filename=not args.preserve_filenames, replace_values=not args.clear_values, keywords_to_leave_unchanged=keywords_to_leave_unchanged, delete_private_tags=not args.keep_private_tags, delete_unknown_tags=handle_unknown_tags, replacement_strategy=replacement_strategy, identifying_keywords=identifying_keywords_for_pseudo, ) file_count = 1 elif isdir(args.input_path): anon_filepaths = anonymise_directory( dicom_dirpath=args.input_path, output_dirpath=args.output_path, delete_original_files=args.delete_original_files, anonymise_filenames=not args.preserve_filenames, replace_values=not args.clear_values, keywords_to_leave_unchanged=keywords_to_leave_unchanged, delete_private_tags=not args.keep_private_tags, delete_unknown_tags=handle_unknown_tags, replacement_strategy=replacement_strategy, identifying_keywords=identifying_keywords_for_pseudo, ) file_count = len(anon_filepaths) else: raise FileNotFoundError( "No file or directory was found at the supplied input path." ) print_cli_summary(file_count) def is_valid_strategy_for_keywords( identifying_keywords=None, replacement_strategy=None ): if identifying_keywords is None: identifying_keywords = get_default_pseudonymisation_keywords() if replacement_strategy is None: replacement_strategy = strategy.pseudonymisation_dispatch baseline_keyword_vr_dict = get_baseline_keyword_vr_dict() for keyword in identifying_keywords: vr = baseline_keyword_vr_dict[keyword] # pydicom.datadict.dictionary_VR(pydicom.datadict.tag_for_keyword(keyword)) if vr not in replacement_strategy: return False return True def pseudonymise(dicom_input, output_path=None): """Convenient API to pseudonymisation. Elements whose tags are not in the pydicom dictionary will be deleted PatientSex will not be modified/pseudonymised For finer control, pass this module's strategy and keywords to ``pymedphys.dicom.anonymise`` instead Parameters ---------- dicom_input : ``pydicom.dataset.Dataset | str | pathlib.Path`` Either a dataset, a path to a file or a path to a directory output_path : ``str | pathlib.Path``, optional For a file input, provide a file path with a directory component. Its parent directory is used, but the filename is generated from the pseudonymised dataset. For a directory input, this is the destination directory. If None, output is written alongside the input. Ignored for a Dataset input, which returns a new dataset without saving it. Defaults to None. Returns ------- ``pydicom.dataset.Dataset`` | ``str`` | ``list`` of ``str`` if the dicom_input was a dataset, return the pseudonymised dataset if the dicom input was a file, return the path to the pseudonymised file. if the dicom input was a directory, return the list of successfully anonymised files, and return that instead of None """ replacement_strategy = strategy.pseudonymisation_dispatch identifying_keywords_for_pseudo = get_default_pseudonymisation_keywords() if not is_valid_strategy_for_keywords(): logging.error("Pseudonymisation strategy is not valid for keywords") logging.error("Please submit issue to PyMedPhys") # but continue on, the data might not contain the offending keywords # and if it does... there will be some kind of error raised keywords_to_leave_unchanged = ["PatientSex"] if isinstance(dicom_input, pydicom.dataset.Dataset): pseudo_ds = anonymise_dataset( dicom_input, keywords_to_leave_unchanged=keywords_to_leave_unchanged, delete_unknown_tags=True, replacement_strategy=replacement_strategy, identifying_keywords=identifying_keywords_for_pseudo, ) return pseudo_ds if pathlib.Path().joinpath(dicom_input).is_dir(): pseudonymised_file_list = anonymise_directory( dicom_input, output_dirpath=output_path, keywords_to_leave_unchanged=keywords_to_leave_unchanged, delete_unknown_tags=True, replacement_strategy=replacement_strategy, identifying_keywords=identifying_keywords_for_pseudo, ) return pseudonymised_file_list if not pathlib.Path().joinpath(dicom_input).is_file(): raise FileNotFoundError(f"Unable to find {dicom_input}") pseudonymised_filepath = anonymise_file( dicom_input, output_filepath=output_path, keywords_to_leave_unchanged=keywords_to_leave_unchanged, delete_unknown_tags=True, replacement_strategy=replacement_strategy, identifying_keywords=identifying_keywords_for_pseudo, ) return pseudonymised_filepath