49enum class operation_type {
70 std::vector<std::filesystem::path> input_paths;
71 std::filesystem::path output_path;
72 std::vector<modification> modifications;
73 std::filesystem::path script_file;
74 bool erase_private{
false};
75 bool gen_study_uid{
false};
76 bool gen_series_uid{
false};
77 bool gen_instance_uid{
false};
78 bool create_backup{
true};
80 bool recursive{
false};
90 std::string generate() {
91 static std::atomic<uint64_t>
counter{0};
92 auto now = std::chrono::system_clock::now();
93 auto timestamp = std::chrono::duration_cast<std::chrono::milliseconds>(
94 now.time_since_epoch())
96 return std::string(uid_root_) +
"." + std::to_string(timestamp) +
"." +
97 std::to_string(++counter);
101 static constexpr const char* uid_root_ =
"1.2.826.0.1.3680043.8.1055.2";
108void print_usage(
const char* program_name) {
109 std::cout <<
"\nDICOM Modify - Tag Modification Utility\n\n";
110 std::cout <<
"Usage: " << program_name <<
" [options] <dicom-file>...\n\n";
111 std::cout <<
"Arguments:\n";
112 std::cout <<
" dicom-file One or more DICOM files to modify\n\n";
113 std::cout <<
"Tag Modification Options:\n";
114 std::cout <<
" -i, --insert <tag=value> Add or modify tag (creates if not exists)\n";
115 std::cout <<
" Example: -i \"(0010,0010)=Anonymous\"\n";
116 std::cout <<
" Example: -i PatientName=Anonymous\n";
117 std::cout <<
" -m, --modify <tag=value> Modify existing tag (error if not exists)\n";
118 std::cout <<
" Example: -m \"(0010,0020)=NEW_ID\"\n";
119 std::cout <<
" -e, --erase <tag> Delete tag\n";
120 std::cout <<
" Example: -e \"(0010,1000)\"\n";
121 std::cout <<
" Example: -e OtherPatientIDs\n";
122 std::cout <<
" -ea, --erase-all <tag> Delete all matching tags (including in sequences)\n";
123 std::cout <<
" -ep, --erase-private Delete all private tags\n\n";
124 std::cout <<
"UID Generation Options:\n";
125 std::cout <<
" -gst, --gen-stud-uid Generate new StudyInstanceUID\n";
126 std::cout <<
" -gse, --gen-ser-uid Generate new SeriesInstanceUID\n";
127 std::cout <<
" -gin, --gen-inst-uid Generate new SOPInstanceUID\n\n";
128 std::cout <<
"Output Options:\n";
129 std::cout <<
" -o, --output <path> Output file or directory\n";
130 std::cout <<
" -nb, --no-backup Do not create backup file (.bak)\n\n";
131 std::cout <<
"Script Option:\n";
132 std::cout <<
" --script <file> Read modification commands from script file\n\n";
133 std::cout <<
"Processing Options:\n";
134 std::cout <<
" -r, --recursive Process directories recursively\n";
135 std::cout <<
" --dry-run Show what would be done without modifying\n";
136 std::cout <<
" -v, --verbose Show detailed output\n";
137 std::cout <<
" -h, --help Show this help message\n\n";
138 std::cout <<
"Tag Format:\n";
139 std::cout <<
" Tags can be specified in two formats:\n";
140 std::cout <<
" - Numeric: (GGGG,EEEE) e.g., (0010,0010)\n";
141 std::cout <<
" - Keyword: e.g., PatientName, PatientID\n\n";
142 std::cout <<
"Script File Format:\n";
143 std::cout <<
" Lines starting with # are comments\n";
144 std::cout <<
" i (0010,0010)=Anonymous Insert/modify tag\n";
145 std::cout <<
" m (0008,0050)=ACC001 Modify existing tag\n";
146 std::cout <<
" e (0010,1000) Erase tag\n";
147 std::cout <<
" ea (0010,1001) Erase all matching tags\n\n";
148 std::cout <<
"Examples:\n";
149 std::cout <<
" " << program_name <<
" -i \"(0010,0010)=Anonymous\" patient.dcm\n";
150 std::cout <<
" " << program_name <<
" -m PatientName=\"Hong^Gildong\" -o modified.dcm patient.dcm\n";
151 std::cout <<
" " << program_name <<
" -gst -gse -gin -o anonymized.dcm patient.dcm\n";
152 std::cout <<
" " << program_name <<
" --script modify.txt *.dcm\n";
153 std::cout <<
" " << program_name <<
" -i PatientID=NEW_ID patient.dcm (in-place with backup)\n";
154 std::cout <<
" " << program_name <<
" -i PatientID=NEW_ID -nb patient.dcm (no backup)\n\n";
155 std::cout <<
"Exit Codes:\n";
156 std::cout <<
" 0 Success\n";
157 std::cout <<
" 1 Invalid arguments\n";
158 std::cout <<
" 2 File/processing error\n";
166std::optional<kcenon::pacs::core::dicom_tag> parse_tag_string(
167 const std::string& tag_str) {
168 std::string s = tag_str;
171 if (!s.empty() && s.front() ==
'(') {
174 if (!s.empty() && s.back() ==
')') {
179 s.erase(std::remove(s.begin(), s.end(),
' '), s.end());
182 size_t comma_pos = s.find(
',');
183 if (comma_pos != std::string::npos) {
186 static_cast<uint16_t
>(std::stoul(s.substr(0, comma_pos),
nullptr, 16));
188 static_cast<uint16_t
>(std::stoul(s.substr(comma_pos + 1),
nullptr, 16));
196 if (s.length() == 8) {
199 static_cast<uint16_t
>(std::stoul(s.substr(0, 4),
nullptr, 16));
201 static_cast<uint16_t
>(std::stoul(s.substr(4, 4),
nullptr, 16));
216std::optional<kcenon::pacs::core::dicom_tag> resolve_tag(
const std::string& str) {
218 if (str.find(
'(') != std::string::npos || str.find(
',') != std::string::npos ||
219 (str.length() == 8 && std::all_of(str.begin(), str.end(), ::isxdigit))) {
220 return parse_tag_string(str);
225 auto info = dict.find_by_keyword(str);
240bool parse_modification_string(
const std::string& str, operation_type op,
244 if (op == operation_type::erase || op == operation_type::erase_all) {
246 auto tag_opt = resolve_tag(str);
256 auto eq_pos = str.find(
'=');
257 if (eq_pos == std::string::npos || eq_pos == 0) {
261 std::string tag_str = str.substr(0, eq_pos);
262 std::string value = str.substr(eq_pos + 1);
265 if (value.size() >= 2) {
266 if ((value.front() ==
'"' && value.back() ==
'"') ||
267 (value.front() ==
'\'' && value.back() ==
'\'')) {
268 value = value.substr(1, value.size() - 2);
272 auto tag_opt = resolve_tag(tag_str);
279 mod.keyword = tag_str;
289bool parse_script_file(
const std::filesystem::path& script_path,
290 std::vector<modification>& modifications) {
291 std::ifstream file(script_path);
292 if (!file.is_open()) {
293 std::cerr <<
"Error: Cannot open script file: " << script_path.string()
300 while (std::getline(file, line)) {
304 size_t start =
line.find_first_not_of(
" \t");
305 if (start == std::string::npos) {
311 if (line[0] ==
'#') {
316 auto comment_pos =
line.find(
'#');
317 if (comment_pos != std::string::npos) {
320 size_t end =
line.find_last_not_of(
" \t");
321 if (end != std::string::npos) {
334 if (
line.length() >= 2 && line[0] ==
'i' && line[1] ==
' ') {
335 op = operation_type::insert;
336 arg =
line.substr(2);
337 }
else if (
line.length() >= 2 && line[0] ==
'm' && line[1] ==
' ') {
338 op = operation_type::modify;
339 arg =
line.substr(2);
340 }
else if (
line.length() >= 2 && line[0] ==
'e' && line[1] ==
' ') {
341 op = operation_type::erase;
342 arg =
line.substr(2);
343 }
else if (
line.length() >= 3 &&
line.substr(0, 2) ==
"ea" &&
345 op = operation_type::erase_all;
346 arg =
line.substr(3);
348 std::cerr <<
"Warning: Invalid command in script file at line "
349 << line_num <<
": " <<
line <<
"\n";
354 start = arg.find_first_not_of(
" \t");
355 if (start != std::string::npos) {
356 arg = arg.substr(start);
360 if (!parse_modification_string(arg, op, mod)) {
361 std::cerr <<
"Warning: Invalid modification in script file at line "
362 << line_num <<
": " << arg <<
"\n";
366 modifications.push_back(std::move(mod));
379bool parse_arguments(
int argc,
char* argv[], options& opts) {
384 for (
int i = 1; i < argc; ++i) {
385 std::string arg = argv[i];
387 if (arg ==
"--help" || arg ==
"-h") {
389 }
else if ((arg ==
"-o" || arg ==
"--output") && i + 1 < argc) {
390 opts.output_path = argv[++i];
391 }
else if ((arg ==
"-i" || arg ==
"--insert") && i + 1 < argc) {
393 if (!parse_modification_string(argv[++i], operation_type::insert,
395 std::cerr <<
"Error: Invalid --insert format. Use tag=value "
396 "(e.g., \"(0010,0010)=Anonymous\")\n";
399 opts.modifications.push_back(std::move(mod));
400 }
else if ((arg ==
"-m" || arg ==
"--modify") && i + 1 < argc) {
402 if (!parse_modification_string(argv[++i], operation_type::modify,
404 std::cerr <<
"Error: Invalid --modify format. Use tag=value "
405 "(e.g., \"(0010,0020)=NEW_ID\")\n";
408 opts.modifications.push_back(std::move(mod));
409 }
else if ((arg ==
"-e" || arg ==
"--erase") && i + 1 < argc) {
411 if (!parse_modification_string(argv[++i], operation_type::erase,
413 std::cerr <<
"Error: Invalid --erase format. Use tag "
414 "(e.g., \"(0010,1000)\")\n";
417 opts.modifications.push_back(std::move(mod));
418 }
else if ((arg ==
"-ea" || arg ==
"--erase-all") && i + 1 < argc) {
420 if (!parse_modification_string(argv[++i], operation_type::erase_all,
422 std::cerr <<
"Error: Invalid --erase-all format. Use tag "
423 "(e.g., \"(0010,1001)\")\n";
426 opts.modifications.push_back(std::move(mod));
427 }
else if (arg ==
"-ep" || arg ==
"--erase-private") {
428 opts.erase_private =
true;
429 }
else if (arg ==
"-gst" || arg ==
"--gen-stud-uid") {
430 opts.gen_study_uid =
true;
431 }
else if (arg ==
"-gse" || arg ==
"--gen-ser-uid") {
432 opts.gen_series_uid =
true;
433 }
else if (arg ==
"-gin" || arg ==
"--gen-inst-uid") {
434 opts.gen_instance_uid =
true;
435 }
else if (arg ==
"-nb" || arg ==
"--no-backup") {
436 opts.create_backup =
false;
437 }
else if (arg ==
"--script" && i + 1 < argc) {
438 opts.script_file = argv[++i];
439 }
else if (arg ==
"-r" || arg ==
"--recursive") {
440 opts.recursive =
true;
441 }
else if (arg ==
"--dry-run") {
443 }
else if (arg ==
"-v" || arg ==
"--verbose") {
445 }
else if (arg[0] ==
'-') {
446 std::cerr <<
"Error: Unknown option '" << arg <<
"'\n";
449 opts.input_paths.emplace_back(arg);
454 if (!opts.script_file.empty()) {
455 if (!parse_script_file(opts.script_file, opts.modifications)) {
461 if (opts.input_paths.empty()) {
462 std::cerr <<
"Error: No input files specified\n";
467 if (opts.modifications.empty() && !opts.erase_private &&
468 !opts.gen_study_uid && !opts.gen_series_uid && !opts.gen_instance_uid) {
469 std::cerr <<
"Error: No modification operation specified\n";
474 if (opts.output_path.empty()) {
475 opts.in_place =
true;
486 std::vector<kcenon::pacs::core::dicom_tag> private_tags;
488 for (
const auto& [tag, element] : dataset) {
489 if (tag.is_private()) {
490 private_tags.push_back(tag);
494 for (
const auto& tag : private_tags) {
499 for (
auto& [tag, element] : dataset) {
500 if (element.is_sequence()) {
501 for (
auto& item : element.sequence_items()) {
502 remove_private_tags_recursive(item);
524 for (
auto& [seq_tag, element] : dataset) {
525 if (element.is_sequence()) {
526 for (
auto& item : element.sequence_items()) {
527 count += remove_tag_recursive(item, tag);
543 uid_generator& uid_gen) {
547 auto& dict = dicom_dictionary::instance();
550 for (
const auto& mod : opts.modifications) {
552 case operation_type::insert: {
553 auto info = dict.find(mod.tag);
554 vr_type
vr =
info ?
static_cast<vr_type
>(
info->vr) : vr_type::
LO;
557 std::cout <<
" Insert " << mod.tag.
to_string() <<
" ("
558 << mod.keyword <<
") = \"" << mod.value <<
"\"\n";
565 case operation_type::modify: {
567 std::cerr <<
" Error: Tag " << mod.tag.
to_string() <<
" ("
568 << mod.keyword <<
") does not exist (use -i to insert)\n";
572 auto info = dict.find(mod.tag);
573 vr_type
vr =
info ?
static_cast<vr_type
>(
info->vr) : vr_type::
LO;
576 std::cout <<
" Modify " << mod.tag.
to_string() <<
" ("
577 << mod.keyword <<
") = \"" << mod.value <<
"\"\n";
584 case operation_type::erase: {
586 std::cout <<
" Erase " << mod.tag.
to_string() <<
" ("
587 << mod.keyword <<
")\n";
594 case operation_type::erase_all: {
595 size_t count = remove_tag_recursive(dataset, mod.tag);
597 std::cout <<
" Erase all " << mod.tag.
to_string() <<
" ("
598 << mod.keyword <<
") - removed " << count
607 if (opts.erase_private) {
609 std::cout <<
" Erasing all private tags...\n";
611 remove_private_tags_recursive(dataset);
615 if (opts.gen_study_uid) {
616 std::string new_uid = uid_gen.generate();
618 std::cout <<
" Generate new StudyInstanceUID: " << new_uid <<
"\n";
620 dataset.
set_string(tags::study_instance_uid, vr_type::UI, new_uid);
623 if (opts.gen_series_uid) {
624 std::string new_uid = uid_gen.generate();
626 std::cout <<
" Generate new SeriesInstanceUID: " << new_uid <<
"\n";
628 dataset.
set_string(tags::series_instance_uid, vr_type::UI, new_uid);
631 if (opts.gen_instance_uid) {
632 std::string new_uid = uid_gen.generate();
634 std::cout <<
" Generate new SOPInstanceUID: " << new_uid <<
"\n";
636 dataset.
set_string(tags::sop_instance_uid, vr_type::UI, new_uid);
645struct process_stats {
646 size_t total_files{0};
647 size_t successful{0};
656bool create_backup(
const std::filesystem::path& file_path) {
657 auto backup_path = file_path;
658 backup_path +=
".bak";
661 std::filesystem::copy_file(file_path, backup_path,
662 std::filesystem::copy_options::overwrite_existing,
665 std::cerr <<
"Warning: Failed to create backup file: " << backup_path.string()
666 <<
" (" << ec.message() <<
")\n";
681bool process_file(
const std::filesystem::path& input_path,
682 const std::filesystem::path& output_path,
const options& opts,
683 uid_generator& uid_gen) {
687 std::cout <<
"Processing: " << input_path.string() <<
"\n";
692 std::cout <<
"Would modify: " << input_path.string() <<
"\n";
693 for (
const auto& mod : opts.modifications) {
695 case operation_type::insert:
696 std::cout <<
" Insert " << mod.tag.
to_string() <<
" = \""
697 << mod.value <<
"\"\n";
699 case operation_type::modify:
700 std::cout <<
" Modify " << mod.tag.
to_string() <<
" = \""
701 << mod.value <<
"\"\n";
703 case operation_type::erase:
704 std::cout <<
" Erase " << mod.tag.
to_string() <<
"\n";
706 case operation_type::erase_all:
707 std::cout <<
" Erase all " << mod.tag.
to_string() <<
"\n";
711 if (opts.erase_private) {
712 std::cout <<
" Erase all private tags\n";
714 if (opts.gen_study_uid) {
715 std::cout <<
" Generate new StudyInstanceUID\n";
717 if (opts.gen_series_uid) {
718 std::cout <<
" Generate new SeriesInstanceUID\n";
720 if (opts.gen_instance_uid) {
721 std::cout <<
" Generate new SOPInstanceUID\n";
723 std::cout <<
" Output: " << output_path.string() <<
"\n";
728 if (opts.in_place && opts.create_backup) {
729 if (!create_backup(input_path)) {
735 auto result = dicom_file::open(input_path);
736 if (result.is_err()) {
737 std::cerr <<
"Error: Failed to open '" << input_path.string()
738 <<
"': " << result.error().message <<
"\n";
742 auto file = std::move(result.value());
743 auto& dataset = file.dataset();
746 if (!apply_modifications(dataset, opts, uid_gen)) {
751 auto output_file = dicom_file::create(std::move(dataset), file.transfer_syntax());
754 auto output_dir = output_path.parent_path();
755 if (!output_dir.empty() && !std::filesystem::exists(output_dir)) {
756 std::filesystem::create_directories(output_dir);
760 auto save_result = output_file.save(output_path);
761 if (save_result.is_err()) {
762 std::cerr <<
"Error: Failed to save '" << output_path.string()
763 <<
"': " << save_result.error().message <<
"\n";
768 std::cout <<
" Saved: " << output_path.string() <<
"\n";
779void process_inputs(
const options& opts, process_stats& stats) {
780 uid_generator uid_gen;
782 for (
const auto& input_path : opts.input_paths) {
783 if (!std::filesystem::exists(input_path)) {
784 std::cerr <<
"Error: Path does not exist: " << input_path.string()
790 if (std::filesystem::is_directory(input_path)) {
792 auto process_entry = [&](
const std::filesystem::path& file_path) {
793 auto ext = file_path.extension().string();
794 std::transform(ext.begin(), ext.end(), ext.begin(), ::tolower);
795 if (ext !=
".dcm" && ext !=
".dicom" && !ext.empty()) {
801 std::filesystem::path output_path;
803 output_path = file_path;
806 std::filesystem::relative(file_path, input_path);
807 output_path = opts.output_path /
relative;
810 if (process_file(file_path, output_path, opts, uid_gen)) {
817 if (opts.recursive) {
818 for (
const auto& entry :
819 std::filesystem::recursive_directory_iterator(input_path)) {
820 if (entry.is_regular_file()) {
821 process_entry(entry.path());
825 for (
const auto& entry :
826 std::filesystem::directory_iterator(input_path)) {
827 if (entry.is_regular_file()) {
828 process_entry(entry.path());
836 std::filesystem::path output_path;
838 output_path = input_path;
840 output_path = opts.output_path;
843 if (process_file(input_path, output_path, opts, uid_gen)) {
856void print_summary(
const process_stats& stats) {
857 if (stats.total_files > 1) {
859 std::cout <<
"========================================\n";
860 std::cout <<
" Processing Summary\n";
861 std::cout <<
"========================================\n";
862 std::cout <<
" Total files: " << stats.total_files <<
"\n";
863 std::cout <<
" Successful: " << stats.successful <<
"\n";
864 std::cout <<
" Failed: " << stats.failed <<
"\n";
865 std::cout <<
"========================================\n";
871int main(
int argc,
char* argv[]) {
873 ____ ____ __ __ __ __ ___ ____ ___ _______ __
874 | _ \ / ___| \/ | | \/ |/ _ \| _ \_ _| ___\ \ / /
875 | | | | | | |\/| | | |\/| | | | | | | | || |_ \ V /
876 | |_| | |___| | | | | | | | |_| | |_| | || _| | |
877 |____/ \____|_| |_| |_| |_|\___/|____/___|_| |_|
879 DICOM Tag Modification Utility
884 if (!parse_arguments(argc, argv, opts)) {
885 print_usage(argv[0]);
890 process_inputs(opts, stats);
892 print_summary(stats);
894 if (stats.failed > 0) {
898 if (stats.total_files == 1 && stats.successful == 1) {
899 std::cout <<
"Successfully modified file.\n";
auto remove(dicom_tag tag) -> bool
Remove an element from the dataset.
void set_string(dicom_tag tag, encoding::vr_type vr, std::string_view value)
Set a string value for the given tag.
auto contains(dicom_tag tag) const noexcept -> bool
Check if the dataset contains an element with the given tag.
static auto instance() -> dicom_dictionary &
Get the singleton instance.
auto to_string() const -> std::string
Convert to string representation.
DICOM Data Dictionary for tag metadata lookup.
DICOM Part 10 file handling for reading/writing DICOM files.
Compile-time constants for commonly used DICOM tags.
DICOM Anonymization - Patient data removal/replacement.
@ modify
Clinician modifies AI result (e.g., edits segmentation)
@ failed
Job failed with error.
@ LO
Long String (64 chars max)
@ counter
Monotonic increasing value.
@ relative
RELATIVE - Relative dose.
@ op
Ophthalmic Photography / Tomography.