52constexpr const char* version_string =
"1.0.0";
53constexpr const char* default_calling_ae =
"GETSCU";
54constexpr const char* default_called_ae =
"ANY-SCP";
56constexpr size_t max_ae_title_length = 16;
57constexpr int progress_bar_width = 40;
60static const std::vector<std::string_view> storage_sop_classes = {
61 "1.2.840.10008.5.1.4.1.1.2",
62 "1.2.840.10008.5.1.4.1.1.4",
63 "1.2.840.10008.5.1.4.1.1.7",
64 "1.2.840.10008.5.1.4.1.1.1",
65 "1.2.840.10008.5.1.4.1.1.1.1",
66 "1.2.840.10008.5.1.4.1.1.12.1",
67 "1.2.840.10008.5.1.4.1.1.6.1",
68 "1.2.840.10008.5.1.4.1.1.88.11",
69 "1.2.840.10008.5.1.4.1.1.88.22",
70 "1.2.840.10008.5.1.4.1.1.2.1",
71 "1.2.840.10008.5.1.4.1.1.4.1",
72 "1.2.840.10008.5.1.4.1.1.128",
79enum class query_model {
84enum class query_level {
106 std::string peer_host;
107 uint16_t peer_port{0};
108 std::string calling_ae_title{default_calling_ae};
109 std::string called_ae_title{default_called_ae};
114 std::chrono::seconds dimse_timeout{0};
117 query_model model{query_model::study_root};
118 query_level level{query_level::study};
121 std::vector<query_key> keys;
122 std::string query_file;
125 std::filesystem::path output_dir{
"."};
126 std::string output_format;
129 bool prefer_lossless{
false};
130 bool prefer_explicit{
false};
131 bool accept_all{
false};
134 bool show_progress{
true};
142 bool show_help{
false};
143 bool show_version{
false};
151 std::atomic<uint16_t> remaining{0};
153 std::atomic<uint16_t>
failed{0};
154 std::atomic<uint16_t>
warning{0};
155 std::atomic<size_t> bytes_received{0};
156 std::chrono::steady_clock::time_point start_time;
164 start_time = std::chrono::steady_clock::now();
167 [[nodiscard]] uint16_t total()
const {
176std::string_view query_model_to_string(query_model model) {
178 case query_model::patient_root:
return "Patient Root";
179 case query_model::study_root:
return "Study Root";
180 default:
return "Unknown";
186 case query_level::patient:
return "PATIENT";
187 case query_level::study:
return "STUDY";
188 case query_level::series:
return "SERIES";
189 case query_level::image:
return "IMAGE";
190 default:
return "UNKNOWN";
194std::string_view get_get_sop_class_uid(query_model model) {
196 case query_model::patient_root:
198 case query_model::study_root:
205std::string format_size(
size_t bytes) {
206 constexpr size_t KB = 1024;
207 constexpr size_t MB = KB * 1024;
208 constexpr size_t GB = MB * 1024;
210 std::ostringstream oss;
211 oss << std::fixed << std::setprecision(2);
214 oss << static_cast<double>(bytes) / GB <<
" GB";
215 }
else if (bytes >= MB) {
216 oss << static_cast<double>(bytes) / MB <<
" MB";
217 }
else if (bytes >= KB) {
218 oss << static_cast<double>(bytes) / KB <<
" KB";
220 oss << bytes <<
" B";
232 ____ _____ _____ ____ ____ _ _
233 / ___| ____|_ _| / ___| / ___| | | |
234 | | _| _| | | \___ \| | | | | |
235 | |_| | |___ | | ___) | |___| |_| |
236 \____|_____| |_| |____/ \____|\___/
238 DICOM C-GET Client v)" << version_string << R"(
242void print_usage(
const char* program_name) {
243 std::cout <<
"Usage: " << program_name << R
"( [options] <peer> <port>
246 peer Remote host address (IP or hostname)
247 port Remote port number (typically 104 or 11112)
250 -h, --help Show this help message and exit
251 -v, --verbose Verbose output mode
252 -d, --debug Debug output mode
253 -q, --quiet Quiet mode (minimal output)
254 --version Show version information
257 -aet, --aetitle <aetitle> Calling AE Title (default: GETSCU)
258 -aec, --call <aetitle> Called AE Title (default: ANY-SCP)
259 -to, --timeout <seconds> Connection timeout (default: 60)
260 -ta, --acse-timeout <seconds> ACSE timeout (default: 60)
261 -td, --dimse-timeout <seconds> DIMSE timeout (default: 0=infinite)
264 -P, --patient-root Patient Root Query Model
265 -S, --study-root Study Root Query Model (default)
268 -L, --level <level> Retrieve level (PATIENT|STUDY|SERIES|IMAGE)
271 -k, --key <tag=value> Query key for retrieval
272 -f, --query-file <file> Read query keys from file
275 -od, --output-dir <dir> Output directory (default: current)
276 --output-format <format> Filename format
279 -xs, --prefer-lossless Prefer lossless transfer syntax
280 -xe, --prefer-explicit Prefer Explicit VR LE
281 +xa, --accept-all Accept all transfer syntaxes
284 -p, --progress Show progress information (default)
285 --no-progress Disable progress display
288 C-GET retrieves objects directly to the calling SCU, eliminating
289 the need for a separate storage SCP. This makes it firewall-friendly
290 but requires SCP support for C-GET (less common than C-MOVE).
293 # Get single instance
294 )" << program_name << R"( -L IMAGE \
295 -k "0008,0018=1.2.840..." \
300 )" << program_name << R"( -L STUDY \
301 -k "0020,000D=1.2.840..." \
306 # Get with lossless preference
307 )" << program_name << R"( --prefer-lossless \
309 -k "0020,000E=1.2.840..." \
313 0 Success - All objects retrieved
314 1 Partial success - Some sub-operations failed
315 2 Error - Retrieve failed or invalid arguments
319void print_version() {
320 std::cout <<
"get_scu version " << version_string <<
"\n";
321 std::cout <<
"PACS System DICOM Utilities\n";
322 std::cout <<
"Copyright (c) 2024\n";
325void display_progress(
const get_progress& progress,
bool verbose) {
326 auto total = progress.total();
327 if (total == 0)
return;
329 uint16_t
done = progress.completed + progress.failed + progress.warning;
330 float pct =
static_cast<float>(
done) / total;
332 auto elapsed = std::chrono::steady_clock::now() - progress.start_time;
333 auto elapsed_sec = std::chrono::duration<double>(elapsed).count();
334 double speed = elapsed_sec > 0
335 ?
static_cast<double>(progress.bytes_received) / elapsed_sec / 1024.0
339 int filled =
static_cast<int>(pct * progress_bar_width);
340 for (
int i = 0; i < progress_bar_width; ++i) {
341 if (i < filled) std::cout <<
"=";
342 else if (i == filled) std::cout <<
">";
343 else std::cout <<
" ";
347 std::cout << std::fixed << std::setprecision(1) << (pct * 100) <<
"% ";
348 std::cout <<
"(" <<
done <<
"/" << total <<
") ";
351 std::cout << std::setprecision(1) << speed <<
" KB/s ";
352 if (progress.failed > 0) {
353 std::cout <<
"[" << progress.failed.load() <<
" failed] ";
357 std::cout << std::flush;
364bool parse_timeout(
const std::string& value, std::chrono::seconds& result,
365 const std::string& option_name) {
367 int seconds = std::stoi(value);
369 std::cerr <<
"Error: " << option_name <<
" must be non-negative\n";
372 result = std::chrono::seconds{seconds};
374 }
catch (
const std::exception&) {
375 std::cerr <<
"Error: Invalid value for " << option_name <<
": '"
381bool validate_ae_title(
const std::string& ae_title,
382 const std::string& option_name) {
383 if (ae_title.empty()) {
384 std::cerr <<
"Error: " << option_name <<
" cannot be empty\n";
387 if (ae_title.length() > max_ae_title_length) {
388 std::cerr <<
"Error: " << option_name <<
" exceeds "
389 << max_ae_title_length <<
" characters\n";
395std::optional<query_level> parse_level(
const std::string& level_str) {
396 std::string upper = level_str;
397 std::transform(upper.begin(), upper.end(), upper.begin(), ::toupper);
399 if (upper ==
"PATIENT")
return query_level::patient;
400 if (upper ==
"STUDY")
return query_level::study;
401 if (upper ==
"SERIES")
return query_level::series;
402 if (upper ==
"IMAGE" || upper ==
"INSTANCE")
return query_level::image;
406bool parse_query_key(
const std::string& key_str, query_key& key) {
407 std::regex key_regex(R
"(\(?([0-9A-Fa-f]{4}),([0-9A-Fa-f]{4})\)?=?(.*))");
410 if (!std::regex_match(key_str, match, key_regex)) {
411 std::cerr <<
"Error: Invalid query key format: '" << key_str <<
"'\n";
415 uint16_t group =
static_cast<uint16_t
>(std::stoul(match[1].str(),
nullptr, 16));
416 uint16_t element =
static_cast<uint16_t
>(std::stoul(match[2].str(),
nullptr, 16));
419 key.value = match[3].str();
424bool load_query_file(
const std::string& filename, std::vector<query_key>& keys) {
425 std::ifstream file(filename);
426 if (!file.is_open()) {
427 std::cerr <<
"Error: Cannot open query file: " << filename <<
"\n";
432 while (std::getline(file, line)) {
433 auto pos =
line.find_first_not_of(
" \t");
434 if (pos == std::string::npos || line[pos] ==
'#') {
439 if (!parse_query_key(line, key)) {
448bool parse_arguments(
int argc,
char* argv[], options& opts) {
449 std::vector<std::string> positional_args;
451 for (
int i = 1; i < argc; ++i) {
452 std::string arg = argv[i];
454 if (arg ==
"-h" || arg ==
"--help") {
455 opts.show_help =
true;
458 if (arg ==
"--version") {
459 opts.show_version =
true;
463 if (arg ==
"-v" || arg ==
"--verbose") {
467 if (arg ==
"-d" || arg ==
"--debug") {
472 if (arg ==
"-q" || arg ==
"--quiet") {
477 if ((arg ==
"-aet" || arg ==
"--aetitle") && i + 1 < argc) {
478 opts.calling_ae_title = argv[++i];
479 if (!validate_ae_title(opts.calling_ae_title,
"Calling AE Title")) {
484 if ((arg ==
"-aec" || arg ==
"--call") && i + 1 < argc) {
485 opts.called_ae_title = argv[++i];
486 if (!validate_ae_title(opts.called_ae_title,
"Called AE Title")) {
492 if ((arg ==
"-to" || arg ==
"--timeout") && i + 1 < argc) {
493 if (!parse_timeout(argv[++i], opts.connection_timeout,
"timeout")) {
498 if ((arg ==
"-ta" || arg ==
"--acse-timeout") && i + 1 < argc) {
499 if (!parse_timeout(argv[++i], opts.acse_timeout,
"ACSE timeout")) {
504 if ((arg ==
"-td" || arg ==
"--dimse-timeout") && i + 1 < argc) {
505 if (!parse_timeout(argv[++i], opts.dimse_timeout,
"DIMSE timeout")) {
511 if (arg ==
"-P" || arg ==
"--patient-root") {
512 opts.model = query_model::patient_root;
515 if (arg ==
"-S" || arg ==
"--study-root") {
516 opts.model = query_model::study_root;
520 if ((arg ==
"-L" || arg ==
"--level") && i + 1 < argc) {
521 auto level = parse_level(argv[++i]);
523 std::cerr <<
"Error: Invalid query level: '" << argv[i] <<
"'\n";
530 if ((arg ==
"-k" || arg ==
"--key") && i + 1 < argc) {
532 if (!parse_query_key(argv[++i], key)) {
535 opts.keys.push_back(key);
538 if ((arg ==
"-f" || arg ==
"--query-file") && i + 1 < argc) {
539 opts.query_file = argv[++i];
543 if ((arg ==
"-od" || arg ==
"--output-dir") && i + 1 < argc) {
544 opts.output_dir = argv[++i];
547 if (arg ==
"--output-format" && i + 1 < argc) {
548 opts.output_format = argv[++i];
552 if (arg ==
"-xs" || arg ==
"--prefer-lossless") {
553 opts.prefer_lossless =
true;
556 if (arg ==
"-xe" || arg ==
"--prefer-explicit") {
557 opts.prefer_explicit =
true;
560 if (arg ==
"+xa" || arg ==
"--accept-all") {
561 opts.accept_all =
true;
565 if (arg ==
"-p" || arg ==
"--progress") {
566 opts.show_progress =
true;
569 if (arg ==
"--no-progress") {
570 opts.show_progress =
false;
574 if (arg.starts_with(
"-")) {
575 std::cerr <<
"Error: Unknown option '" << arg <<
"'\n";
579 positional_args.push_back(arg);
582 if (positional_args.size() != 2) {
583 std::cerr <<
"Error: Expected <peer> <port> arguments\n";
587 opts.peer_host = positional_args[0];
590 int port_int = std::stoi(positional_args[1]);
591 if (port_int < 1 || port_int > 65535) {
592 std::cerr <<
"Error: Port must be between 1 and 65535\n";
595 opts.peer_port =
static_cast<uint16_t
>(port_int);
597 std::cerr <<
"Error: Invalid port number '" << positional_args[1] <<
"'\n";
601 if (!opts.query_file.empty()) {
602 if (!load_query_file(opts.query_file, opts.keys)) {
607 if (opts.keys.empty()) {
608 std::cerr <<
"Error: At least one query key (-k) is required\n";
626 ds.set_string(tags::query_retrieve_level, vr_type::CS, level_str);
628 for (
const auto& key : opts.keys) {
629 ds.set_string(key.tag, vr_type::UN, key.value);
639std::filesystem::path generate_file_path(
640 const std::filesystem::path& output_dir,
645 auto sop_uid = dataset.
get_string(tags::sop_instance_uid,
"UNKNOWN");
648 std::string filename = sop_uid +
".dcm";
649 std::replace(filename.begin(), filename.end(),
'/',
'_');
650 std::replace(filename.begin(), filename.end(),
'\\',
'_');
652 return output_dir / filename;
655bool save_dicom_file(
const std::filesystem::path& path,
657 std::filesystem::create_directories(path.parent_path());
663 auto result = file.save(path);
664 return result.is_ok();
673 std::string_view sop_class_uid) {
677 dimse_message msg{command_field::c_get_rq,
message_id};
678 msg.set_affected_sop_class_uid(sop_class_uid);
679 msg.set_priority(priority_medium);
684int perform_get(
const options& opts) {
692 std::cout <<
"Requesting Association\n";
694 std::cout <<
" Peer: " << opts.peer_host <<
":"
695 << opts.peer_port <<
"\n";
696 std::cout <<
" Calling AE: " << opts.calling_ae_title <<
"\n";
697 std::cout <<
" Called AE: " << opts.called_ae_title <<
"\n";
698 std::cout <<
" Query Model: " << query_model_to_string(opts.model)
702 std::cout <<
" Output: " << opts.output_dir <<
"\n\n";
706 std::filesystem::create_directories(opts.output_dir);
718 std::string(sop_class_uid),
720 "1.2.840.10008.1.2.1",
726 uint8_t context_id = 3;
727 for (
auto storage_sop : storage_sop_classes) {
730 std::string(storage_sop),
732 "1.2.840.10008.1.2.1",
740 auto start_time = std::chrono::steady_clock::now();
741 auto timeout = std::chrono::duration_cast<std::chrono::milliseconds>(
742 opts.connection_timeout);
743 auto connect_result = association::connect(opts.peer_host, opts.peer_port,
746 if (connect_result.is_err()) {
747 std::cerr <<
"Association Failed: " << connect_result.error().message
752 auto& assoc = connect_result.value();
755 std::cout <<
"Association Accepted\n";
758 if (!assoc.has_accepted_context(sop_class_uid)) {
759 std::cerr <<
"Error: C-GET SOP Class not accepted by remote SCP\n";
760 std::cerr <<
"Note: C-GET is less commonly supported than C-MOVE\n";
765 auto context_id_opt = assoc.accepted_context_id(sop_class_uid);
766 if (!context_id_opt) {
767 std::cerr <<
"Error: Could not get presentation context ID\n";
771 uint8_t get_context_id = *context_id_opt;
773 auto query_ds = build_query_dataset(opts);
775 auto get_rq = make_c_get_rq(1, sop_class_uid);
776 get_rq.set_dataset(std::move(query_ds));
779 std::cout <<
"Initiating C-GET...\n";
782 auto send_result = assoc.send_dimse(get_context_id, get_rq);
783 if (send_result.is_err()) {
784 std::cerr <<
"Send Failed: " << send_result.error().message <<
"\n";
790 get_progress progress;
793 std::vector<std::filesystem::path> received_files;
795 bool retrieve_complete =
false;
796 uint16_t final_completed = 0;
797 uint16_t final_failed = 0;
798 uint16_t final_warning = 0;
800 auto dimse_timeout = opts.dimse_timeout.count() > 0
801 ? std::chrono::duration_cast<std::chrono::milliseconds>(opts.dimse_timeout)
802 : std::chrono::milliseconds{60000};
804 while (!retrieve_complete) {
805 auto recv_result = assoc.receive_dimse(dimse_timeout);
806 if (recv_result.is_err()) {
807 std::cerr <<
"\nReceive Failed: " << recv_result.error().message
813 auto& [recv_context_id, msg] = recv_result.value();
814 auto cmd = msg.command();
816 if (cmd == command_field::c_get_rsp) {
817 auto status = msg.status();
819 if (
auto remaining = msg.remaining_subops()) {
820 progress.remaining = *remaining;
822 if (
auto completed = msg.completed_subops()) {
826 if (
auto failed = msg.failed_subops()) {
827 progress.failed = *
failed;
830 if (
auto warning = msg.warning_subops()) {
835 if (opts.show_progress && !opts.quiet) {
836 display_progress(progress, opts.verbose);
839 if (status == status_success ||
840 status == status_cancel ||
841 (status & 0xF000) == 0xA000 ||
842 (status & 0xF000) == 0xC000) {
844 retrieve_complete =
true;
846 if (status != status_success && status != status_cancel &&
848 std::cerr <<
"\nC-GET failed with status: 0x" << std::hex
849 <<
status << std::dec <<
"\n";
853 }
else if (cmd == command_field::c_store_rq) {
855 if (msg.has_dataset()) {
856 auto dataset_result = msg.dataset();
857 if (dataset_result.is_err()) {
859 std::cerr <<
"\nWarning: Failed to get dataset\n";
863 const auto& dataset = dataset_result.value().
get();
865 auto file_path = generate_file_path(opts.output_dir, dataset);
866 bool saved = save_dicom_file(file_path, dataset);
869 received_files.push_back(file_path);
873 progress.bytes_received += 1024;
875 auto sop_class = msg.affected_sop_class_uid();
876 auto sop_instance = msg.affected_sop_instance_uid();
878 auto store_rsp = make_c_store_rsp(
882 saved ? status_success : 0xA700
885 auto send_rsp_result = assoc.send_dimse(recv_context_id, store_rsp);
886 if (send_rsp_result.is_err() && opts.verbose) {
887 std::cerr <<
"\nWarning: Failed to send C-STORE response\n";
890 if (!saved && opts.verbose) {
891 std::cerr <<
"\nWarning: Failed to save " << file_path <<
"\n";
897 if (opts.show_progress && !opts.quiet) {
901 if (!opts.quiet && opts.verbose) {
902 std::cout <<
"Releasing Association\n";
905 auto release_result = assoc.release(timeout);
906 if (release_result.is_err() && opts.verbose) {
907 std::cerr <<
"Warning: Release failed: "
908 << release_result.error().message <<
"\n";
911 auto end_time = std::chrono::steady_clock::now();
912 auto total_duration = std::chrono::duration_cast<std::chrono::milliseconds>(
913 end_time - start_time);
917 std::cout <<
"\n========================================\n";
918 std::cout <<
" Get Summary\n";
919 std::cout <<
"========================================\n";
920 std::cout <<
" Mode: C-GET\n";
923 std::cout <<
" Output: " << opts.output_dir <<
"\n";
924 std::cout <<
" ----------------------------------------\n";
925 std::cout <<
" Received: " << received_files.size() <<
" files\n";
926 std::cout <<
" Data Size: " << format_size(progress.bytes_received)
928 std::cout <<
" Completed: " << final_completed <<
"\n";
929 if (final_warning > 0) {
930 std::cout <<
" Warnings: " << final_warning <<
"\n";
932 if (final_failed > 0) {
933 std::cout <<
" Failed: " << final_failed <<
"\n";
935 std::cout <<
" Total Time: " << total_duration.count() <<
" ms\n";
937 if (total_duration.count() > 0) {
938 double speed =
static_cast<double>(progress.bytes_received) /
939 (total_duration.count() / 1000.0) / (1024.0 * 1024.0);
940 std::cout << std::fixed << std::setprecision(2);
941 std::cout <<
" Average Speed: " << speed <<
" MB/s\n";
944 std::cout <<
"========================================\n";
947 if (final_failed > 0 && final_completed == 0) {
949 }
else if (final_failed > 0) {
961int main(
int argc,
char* argv[]) {
964 if (!parse_arguments(argc, argv, opts)) {
965 if (!opts.show_help && !opts.show_version) {
966 std::cerr <<
"\nUse --help for usage information.\n";
971 if (opts.show_version) {
976 if (opts.show_help) {
978 print_usage(argv[0]);
986 return perform_get(opts);
DICOM Association management per PS3.8.
auto get(dicom_tag tag) noexcept -> dicom_element *
Get a pointer to the element with the given tag.
auto get_string(dicom_tag tag, std::string_view default_value="") const -> std::string
Get the string value of an element.
static auto create(dicom_dataset dataset, const encoding::transfer_syntax &ts) -> dicom_file
Create a new DICOM file from a dataset.
static const transfer_syntax explicit_vr_little_endian
Explicit VR Little Endian (1.2.840.10008.1.2.1)
DICOM Dataset - ordered collection of Data Elements.
DICOM Part 10 file handling for reading/writing DICOM files.
DICOM Tag representation (Group, Element pairs)
Compile-time constants for commonly used DICOM tags.
DIMSE message encoding and decoding.
@ failed
Job failed with error.
@ completed
Job completed successfully.
constexpr int connection_timeout
auto query_level_to_string(query_level level) -> std::string
Convert query level to string.
std::chrono::milliseconds default_timeout()
Default timeout for test operations (5s normal, 30s CI)
@ done
Job completed successfully.
constexpr std::string_view study_root_get_sop_class_uid
Study Root Query/Retrieve Information Model - GET.
constexpr std::string_view patient_root_get_sop_class_uid
Patient Root Query/Retrieve Information Model - GET.
@ study_root
Study Root Query/Retrieve Information Model.
@ patient_root
Patient Root Query/Retrieve Information Model.
constexpr int timeout
Lock timeout exceeded.
DICOM Retrieve SCP service (C-MOVE/C-GET handler)
Configuration for SCU association request.
std::string called_ae_title
Remote AE Title (16 chars max)
std::string calling_ae_title
Our AE Title (16 chars max)
std::string implementation_class_uid
std::string implementation_version_name
std::vector< proposed_presentation_context > proposed_contexts