49constexpr const char* version_string =
"1.0.0";
50constexpr const char* default_calling_ae =
"FINDSCU";
51constexpr const char* default_called_ae =
"ANY-SCP";
53constexpr size_t max_ae_title_length = 16;
59enum class query_model {
64enum class query_level {
71enum class output_format {
93 std::string peer_host;
94 uint16_t peer_port{0};
95 std::string calling_ae_title{default_calling_ae};
96 std::string called_ae_title{default_called_ae};
101 std::chrono::seconds dimse_timeout{0};
104 query_model model{query_model::patient_root};
105 query_level level{query_level::study};
108 std::vector<query_key> keys;
109 std::string query_file;
112 output_format format{output_format::text};
113 std::string output_file;
114 bool extract_to_files{
false};
115 size_t max_results{0};
123 bool show_help{
false};
124 bool show_version{
false};
131std::string_view query_model_to_string(query_model model) {
133 case query_model::patient_root:
return "Patient Root";
134 case query_model::study_root:
return "Study Root";
135 default:
return "Unknown";
141 case query_level::patient:
return "PATIENT";
142 case query_level::study:
return "STUDY";
143 case query_level::series:
return "SERIES";
144 case query_level::image:
return "IMAGE";
145 default:
return "UNKNOWN";
151 case query_model::patient_root:
153 case query_model::study_root:
166 _____ ___ _ _ ____ ____ ____ _ _
167 | ___|_ _| \ | | _ \ / ___| / ___| | | |
168 | |_ | || \| | | | | \___ \| | | | | |
169 | _| | || |\ | |_| | ___) | |___| |_| |
170 |_| |___|_| \_|____/ |____/ \____|\___/
172 DICOM C-FIND Client v)" << version_string << R"(
176void print_usage(
const char* program_name) {
177 std::cout <<
"Usage: " << program_name << R
"( [options] <peer> <port>
180 peer Remote host address (IP or hostname)
181 port Remote port number (typically 104 or 11112)
184 -h, --help Show this help message and exit
185 -v, --verbose Verbose output mode
186 -d, --debug Debug output mode (more details than verbose)
187 -q, --quiet Quiet mode (minimal output)
188 --version Show version information
191 -aet, --aetitle <aetitle> Calling AE Title (default: FINDSCU)
192 -aec, --call <aetitle> Called AE Title (default: ANY-SCP)
193 -to, --timeout <seconds> Connection timeout (default: 30)
194 -ta, --acse-timeout <seconds> ACSE timeout (default: 30)
195 -td, --dimse-timeout <seconds> DIMSE timeout (default: 0=infinite)
198 -P, --patient-root Patient Root Query Model (default)
199 -S, --study-root Study Root Query Model
202 -L, --level <level> Query level (PATIENT|STUDY|SERIES|IMAGE)
205 -k, --key <tag=value> Query key (e.g., 0010,0010=Smith*)
206 Multiple -k options allowed
207 -f, --query-file <file> Read query keys from file
210 -o, --output <format> Output format (text|json|xml|csv)
211 --output-file <file> Write results to file
212 -X, --extract Extract results to DICOM files
213 --max-results <n> Maximum number of results (0=unlimited)
217 (0010,0010) PatientName (0010,0020) PatientID
218 (0010,0030) PatientBirthDate (0010,0040) PatientSex
221 (0020,000D) StudyInstanceUID (0008,0020) StudyDate
222 (0008,0030) StudyTime (0008,0050) AccessionNumber
223 (0008,1030) StudyDescription (0008,0060) Modality
226 (0020,000E) SeriesInstanceUID (0008,0060) Modality
227 (0020,0011) SeriesNumber (0008,103E) SeriesDescription
230 (0008,0018) SOPInstanceUID (0020,0013) InstanceNumber
233 # Find all studies for a patient
234 )" << program_name << R"( -P -L STUDY -k "0010,0010=Smith*" localhost 11112
236 # Find CT studies in date range
237 )" << program_name << R"( -S -L STUDY \
239 -k "0008,0020=20240101-20241231" \
242 # Find series for a study
243 )" << program_name << R"( -S -L SERIES \
244 -k "0020,000D=1.2.840..." \
249 )" << program_name << R"( -f query_keys.txt localhost 11112
251Query File Format (one key per line):
254 (0008,0020)=20240101-20241231
257 0 Success - Query completed
258 1 Error - Query failed or no results
259 2 Error - Invalid arguments or connection failure
263void print_version() {
264 std::cout <<
"find_scu version " << version_string <<
"\n";
265 std::cout <<
"PACS System DICOM Utilities\n";
266 std::cout <<
"Copyright (c) 2024\n";
273bool parse_timeout(
const std::string& value, std::chrono::seconds& result,
274 const std::string& option_name) {
276 int seconds = std::stoi(value);
278 std::cerr <<
"Error: " << option_name <<
" must be non-negative\n";
281 result = std::chrono::seconds{seconds};
283 }
catch (
const std::exception&) {
284 std::cerr <<
"Error: Invalid value for " << option_name <<
": '"
290bool validate_ae_title(
const std::string& ae_title,
291 const std::string& option_name) {
292 if (ae_title.empty()) {
293 std::cerr <<
"Error: " << option_name <<
" cannot be empty\n";
296 if (ae_title.length() > max_ae_title_length) {
297 std::cerr <<
"Error: " << option_name <<
" exceeds "
298 << max_ae_title_length <<
" characters\n";
304std::optional<query_level> parse_level(
const std::string& level_str) {
305 std::string upper = level_str;
306 std::transform(upper.begin(), upper.end(), upper.begin(), ::toupper);
308 if (upper ==
"PATIENT")
return query_level::patient;
309 if (upper ==
"STUDY")
return query_level::study;
310 if (upper ==
"SERIES")
return query_level::series;
311 if (upper ==
"IMAGE" || upper ==
"INSTANCE")
return query_level::image;
315bool parse_query_key(
const std::string& key_str, query_key& key) {
317 std::regex key_regex(R
"(\(?([0-9A-Fa-f]{4}),([0-9A-Fa-f]{4})\)?=?(.*))");
320 if (!std::regex_match(key_str, match, key_regex)) {
321 std::cerr <<
"Error: Invalid query key format: '" << key_str <<
"'\n";
322 std::cerr <<
"Expected format: gggg,eeee=value or (gggg,eeee)=value\n";
326 uint16_t group =
static_cast<uint16_t
>(std::stoul(match[1].str(),
nullptr, 16));
327 uint16_t element =
static_cast<uint16_t
>(std::stoul(match[2].str(),
nullptr, 16));
330 key.value = match[3].str();
335bool load_query_file(
const std::string& filename, std::vector<query_key>& keys) {
336 std::ifstream file(filename);
337 if (!file.is_open()) {
338 std::cerr <<
"Error: Cannot open query file: " << filename <<
"\n";
344 while (std::getline(file, line)) {
348 auto pos =
line.find_first_not_of(
" \t");
349 if (pos == std::string::npos || line[pos] ==
'#') {
354 if (!parse_query_key(line, key)) {
355 std::cerr <<
" (at line " << line_num <<
")\n";
364bool parse_arguments(
int argc,
char* argv[], options& opts) {
365 std::vector<std::string> positional_args;
367 for (
int i = 1; i < argc; ++i) {
368 std::string arg = argv[i];
371 if (arg ==
"-h" || arg ==
"--help") {
372 opts.show_help =
true;
375 if (arg ==
"--version") {
376 opts.show_version =
true;
381 if (arg ==
"-v" || arg ==
"--verbose") {
385 if (arg ==
"-d" || arg ==
"--debug") {
390 if (arg ==
"-q" || arg ==
"--quiet") {
396 if ((arg ==
"-aet" || arg ==
"--aetitle") && i + 1 < argc) {
397 opts.calling_ae_title = argv[++i];
398 if (!validate_ae_title(opts.calling_ae_title,
"Calling AE Title")) {
403 if ((arg ==
"-aec" || arg ==
"--call") && i + 1 < argc) {
404 opts.called_ae_title = argv[++i];
405 if (!validate_ae_title(opts.called_ae_title,
"Called AE Title")) {
412 if ((arg ==
"-to" || arg ==
"--timeout") && i + 1 < argc) {
413 if (!parse_timeout(argv[++i], opts.connection_timeout,
"timeout")) {
418 if ((arg ==
"-ta" || arg ==
"--acse-timeout") && i + 1 < argc) {
419 if (!parse_timeout(argv[++i], opts.acse_timeout,
"ACSE timeout")) {
424 if ((arg ==
"-td" || arg ==
"--dimse-timeout") && i + 1 < argc) {
425 if (!parse_timeout(argv[++i], opts.dimse_timeout,
"DIMSE timeout")) {
432 if (arg ==
"-P" || arg ==
"--patient-root") {
433 opts.model = query_model::patient_root;
436 if (arg ==
"-S" || arg ==
"--study-root") {
437 opts.model = query_model::study_root;
442 if ((arg ==
"-L" || arg ==
"--level") && i + 1 < argc) {
443 auto level = parse_level(argv[++i]);
445 std::cerr <<
"Error: Invalid query level: '" << argv[i] <<
"'\n";
453 if ((arg ==
"-k" || arg ==
"--key") && i + 1 < argc) {
455 if (!parse_query_key(argv[++i], key)) {
458 opts.keys.push_back(key);
461 if ((arg ==
"-f" || arg ==
"--query-file") && i + 1 < argc) {
462 opts.query_file = argv[++i];
467 if ((arg ==
"-o" || arg ==
"--output") && i + 1 < argc) {
468 std::string fmt = argv[++i];
469 if (fmt ==
"text") opts.format = output_format::text;
470 else if (fmt ==
"json") opts.format = output_format::json;
471 else if (fmt ==
"xml") opts.format = output_format::xml;
472 else if (fmt ==
"csv") opts.format = output_format::csv;
474 std::cerr <<
"Error: Invalid output format: '" << fmt <<
"'\n";
479 if (arg ==
"--output-file" && i + 1 < argc) {
480 opts.output_file = argv[++i];
483 if (arg ==
"-X" || arg ==
"--extract") {
484 opts.extract_to_files =
true;
487 if (arg ==
"--max-results" && i + 1 < argc) {
489 opts.max_results =
static_cast<size_t>(std::stoul(argv[++i]));
491 std::cerr <<
"Error: Invalid max-results value\n";
498 if (arg.starts_with(
"-")) {
499 std::cerr <<
"Error: Unknown option '" << arg <<
"'\n";
503 positional_args.push_back(arg);
507 if (positional_args.size() != 2) {
508 std::cerr <<
"Error: Expected <peer> <port> arguments\n";
512 opts.peer_host = positional_args[0];
515 int port_int = std::stoi(positional_args[1]);
516 if (port_int < 1 || port_int > 65535) {
517 std::cerr <<
"Error: Port must be between 1 and 65535\n";
520 opts.peer_port =
static_cast<uint16_t
>(port_int);
522 std::cerr <<
"Error: Invalid port number '" << positional_args[1] <<
"'\n";
527 if (!opts.query_file.empty()) {
528 if (!load_query_file(opts.query_file, opts.keys)) {
548 ds.set_string(tags::query_retrieve_level, vr_type::CS, level_str);
551 switch (opts.level) {
552 case query_level::patient:
554 ds.set_string(tags::patient_name, vr_type::PN,
"");
555 ds.set_string(tags::patient_id, vr_type::LO,
"");
556 ds.set_string(tags::patient_birth_date, vr_type::DA,
"");
557 ds.set_string(tags::patient_sex, vr_type::CS,
"");
560 case query_level::study:
562 ds.set_string(tags::patient_name, vr_type::PN,
"");
563 ds.set_string(tags::patient_id, vr_type::LO,
"");
565 ds.set_string(tags::study_instance_uid, vr_type::UI,
"");
566 ds.set_string(tags::study_date, vr_type::DA,
"");
567 ds.set_string(tags::study_time, vr_type::TM,
"");
568 ds.set_string(tags::accession_number, vr_type::SH,
"");
569 ds.set_string(tags::study_id, vr_type::SH,
"");
570 ds.set_string(tags::study_description, vr_type::LO,
"");
571 ds.set_string(tags::modalities_in_study, vr_type::CS,
"");
572 ds.set_string(tags::number_of_study_related_series, vr_type::IS,
"");
573 ds.set_string(tags::number_of_study_related_instances, vr_type::IS,
"");
576 case query_level::series:
578 ds.set_string(tags::series_instance_uid, vr_type::UI,
"");
579 ds.set_string(tags::modality, vr_type::CS,
"");
580 ds.set_string(tags::series_number, vr_type::IS,
"");
581 ds.set_string(tags::series_description, vr_type::LO,
"");
582 ds.set_string(tags::number_of_series_related_instances, vr_type::IS,
"");
585 case query_level::image:
587 ds.set_string(tags::sop_instance_uid, vr_type::UI,
"");
588 ds.set_string(tags::sop_class_uid, vr_type::UI,
"");
589 ds.set_string(tags::instance_number, vr_type::IS,
"");
594 for (
const auto& key : opts.keys) {
595 ds.set_string(key.tag, vr_type::UN, key.value);
607 os <<
"Result " << (index + 1) <<
":\n";
609 for (
const auto& [tag, element] : ds) {
610 auto value = ds.get_string(tag,
"");
612 os <<
" (" << std::hex << std::setw(4) << std::setfill(
'0')
613 << tag.group() <<
","
614 << std::setw(4) << std::setfill(
'0') << tag.element() << std::dec
615 <<
") = \"" << value <<
"\"\n";
620void format_json_results(std::ostream& os,
621 const std::vector<kcenon::pacs::core::dicom_dataset>& results) {
623 for (
size_t i = 0; i < results.size(); ++i) {
625 const auto& ds = results[i];
627 for (
const auto& [tag, element] : ds) {
628 if (!first) os <<
",\n";
631 auto value = ds.get_string(tag,
"");
633 std::ostringstream tag_str;
634 tag_str << std::hex << std::uppercase << std::setw(4)
635 << std::setfill(
'0') << tag.group()
636 << std::setw(4) << std::setfill(
'0') << tag.element();
638 os <<
" \"" << tag_str.str() <<
"\": \"" << value <<
"\"";
641 if (i < results.size() - 1) os <<
",";
647void format_csv_results(std::ostream& os,
648 const std::vector<kcenon::pacs::core::dicom_dataset>& results,
652 case query_level::patient:
653 os <<
"PatientName,PatientID,PatientBirthDate,PatientSex\n";
655 case query_level::study:
656 os <<
"PatientName,PatientID,StudyInstanceUID,StudyDate,StudyTime,"
657 <<
"AccessionNumber,StudyDescription,Modalities\n";
659 case query_level::series:
660 os <<
"SeriesInstanceUID,Modality,SeriesNumber,SeriesDescription,"
661 <<
"NumberOfInstances\n";
663 case query_level::image:
664 os <<
"SOPInstanceUID,SOPClassUID,InstanceNumber\n";
668 for (
const auto& ds : results) {
671 case query_level::patient:
672 os <<
"\"" << ds.get_string(tags::patient_name,
"") <<
"\","
673 <<
"\"" << ds.get_string(tags::patient_id,
"") <<
"\","
674 <<
"\"" << ds.get_string(tags::patient_birth_date,
"") <<
"\","
675 <<
"\"" << ds.get_string(tags::patient_sex,
"") <<
"\"\n";
677 case query_level::study:
678 os <<
"\"" << ds.get_string(tags::patient_name,
"") <<
"\","
679 <<
"\"" << ds.get_string(tags::patient_id,
"") <<
"\","
680 <<
"\"" << ds.get_string(tags::study_instance_uid,
"") <<
"\","
681 <<
"\"" << ds.get_string(tags::study_date,
"") <<
"\","
682 <<
"\"" << ds.get_string(tags::study_time,
"") <<
"\","
683 <<
"\"" << ds.get_string(tags::accession_number,
"") <<
"\","
684 <<
"\"" << ds.get_string(tags::study_description,
"") <<
"\","
685 <<
"\"" << ds.get_string(tags::modalities_in_study,
"") <<
"\"\n";
687 case query_level::series:
688 os <<
"\"" << ds.get_string(tags::series_instance_uid,
"") <<
"\","
689 <<
"\"" << ds.get_string(tags::modality,
"") <<
"\","
690 <<
"\"" << ds.get_string(tags::series_number,
"") <<
"\","
691 <<
"\"" << ds.get_string(tags::series_description,
"") <<
"\","
692 <<
"\"" << ds.get_string(tags::number_of_series_related_instances,
"") <<
"\"\n";
694 case query_level::image:
695 os <<
"\"" << ds.get_string(tags::sop_instance_uid,
"") <<
"\","
696 <<
"\"" << ds.get_string(tags::sop_class_uid,
"") <<
"\","
697 <<
"\"" << ds.get_string(tags::instance_number,
"") <<
"\"\n";
707int perform_query(
const options& opts) {
716 std::cout <<
"Requesting Association\n";
718 std::cout <<
" Peer: " << opts.peer_host <<
":"
719 << opts.peer_port <<
"\n";
720 std::cout <<
" Calling AE: " << opts.calling_ae_title <<
"\n";
721 std::cout <<
" Called AE: " << opts.called_ae_title <<
"\n";
722 std::cout <<
" Query Model: " << query_model_to_string(opts.model)
740 std::string(sop_class_uid),
742 "1.2.840.10008.1.2.1",
748 auto start_time = std::chrono::steady_clock::now();
749 auto timeout = std::chrono::duration_cast<std::chrono::milliseconds>(
750 opts.connection_timeout);
751 auto connect_result = association::connect(opts.peer_host, opts.peer_port,
754 if (connect_result.is_err()) {
755 std::cerr <<
"Association Failed: " << connect_result.error().message
760 auto& assoc = connect_result.value();
761 auto connect_time = std::chrono::steady_clock::now();
764 std::cout <<
"Association Accepted\n";
766 auto connect_duration =
767 std::chrono::duration_cast<std::chrono::milliseconds>(
768 connect_time - start_time);
769 std::cout <<
" (established in " << connect_duration.count()
775 if (!assoc.has_accepted_context(sop_class_uid)) {
776 std::cerr <<
"Error: Query SOP Class not accepted by remote SCP\n";
781 auto context_id_opt = assoc.accepted_context_id(sop_class_uid);
782 if (!context_id_opt) {
783 std::cerr <<
"Error: Could not get presentation context ID\n";
787 uint8_t context_id = *context_id_opt;
790 auto query_ds = build_query_dataset(opts);
793 auto find_rq = make_c_find_rq(1, sop_class_uid);
794 find_rq.set_dataset(std::move(query_ds));
796 if (!opts.quiet && opts.verbose) {
797 std::cout <<
"Sending C-FIND Request\n";
801 auto send_result = assoc.send_dimse(context_id, find_rq);
802 if (send_result.is_err()) {
803 std::cerr <<
"Send Failed: " << send_result.error().message <<
"\n";
809 std::vector<kcenon::pacs::core::dicom_dataset> results;
810 bool query_complete =
false;
811 size_t pending_count = 0;
813 auto dimse_timeout = opts.dimse_timeout.count() > 0
814 ? std::chrono::duration_cast<std::chrono::milliseconds>(opts.dimse_timeout)
815 : std::chrono::milliseconds{30000};
817 while (!query_complete) {
818 auto recv_result = assoc.receive_dimse(dimse_timeout);
819 if (recv_result.is_err()) {
820 std::cerr <<
"Receive Failed: " << recv_result.error().message
826 auto& [recv_context_id, find_rsp] = recv_result.value();
828 if (find_rsp.command() != command_field::c_find_rsp) {
829 std::cerr <<
"Error: Unexpected response (expected C-FIND-RSP)\n";
834 auto status = find_rsp.status();
836 if (status == status_pending || status == status_pending_warning) {
839 if (find_rsp.has_dataset()) {
840 if (opts.max_results == 0 || results.size() < opts.max_results) {
841 auto dataset_result = find_rsp.dataset();
842 if (dataset_result.is_ok()) {
843 results.push_back(dataset_result.value().get());
848 if (!opts.quiet && opts.verbose && pending_count % 10 == 0) {
849 std::cout <<
"\rReceived " << pending_count <<
" results..."
852 }
else if (status == status_success) {
853 query_complete =
true;
854 }
else if (status == status_cancel) {
855 query_complete =
true;
857 std::cout <<
"Query was cancelled.\n";
860 query_complete =
true;
861 std::cerr <<
"Query failed with status: 0x" << std::hex <<
status
866 if (!opts.quiet && opts.verbose) {
871 if (!opts.quiet && opts.verbose) {
872 std::cout <<
"Releasing Association\n";
875 auto release_result = assoc.release(timeout);
876 if (release_result.is_err() && opts.verbose) {
877 std::cerr <<
"Warning: Release failed: "
878 << release_result.error().message <<
"\n";
881 auto end_time = std::chrono::steady_clock::now();
882 auto total_duration = std::chrono::duration_cast<std::chrono::milliseconds>(
883 end_time - start_time);
886 std::ostream* out = &std::cout;
887 std::ofstream file_out;
889 if (!opts.output_file.empty()) {
890 file_out.open(opts.output_file);
891 if (file_out.is_open()) {
894 std::cerr <<
"Warning: Could not open output file: "
895 << opts.output_file <<
"\n";
899 switch (opts.format) {
900 case output_format::text:
901 for (
size_t i = 0; i < results.size(); ++i) {
902 format_text_result(*out, results[i], i);
905 case output_format::json:
906 format_json_results(*out, results);
908 case output_format::csv:
909 format_csv_results(*out, results, opts.level);
911 case output_format::xml:
913 for (
size_t i = 0; i < results.size(); ++i) {
914 format_text_result(*out, results[i], i);
921 std::cout <<
"\nTotal Results: " << results.size();
922 if (opts.max_results > 0 && pending_count > opts.max_results) {
923 std::cout <<
" (limited from " << pending_count <<
")";
928 std::cout <<
"Query Time: " << total_duration.count() <<
" ms\n";
931 std::cout <<
"Query Complete\n";
934 return results.empty() ? 1 : 0;
943int main(
int argc,
char* argv[]) {
946 if (!parse_arguments(argc, argv, opts)) {
947 if (!opts.show_help && !opts.show_version) {
948 std::cerr <<
"\nUse --help for usage information.\n";
953 if (opts.show_version) {
958 if (opts.show_help) {
960 print_usage(argv[0]);
965 bool suppress_banner = opts.quiet ||
966 opts.format == output_format::json ||
967 opts.format == output_format::csv ||
968 opts.format == output_format::xml;
970 if (!suppress_banner) {
974 return perform_query(opts);
DICOM Association management per PS3.8.
DICOM Dataset - ordered collection of Data Elements.
DICOM Tag representation (Group, Element pairs)
Compile-time constants for commonly used DICOM tags.
DIMSE message encoding and decoding.
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)
constexpr std::string_view study_root_find_sop_class_uid
Study Root Query/Retrieve Information Model - FIND.
constexpr std::string_view get_find_sop_class_uid(query_model model) noexcept
Get the FIND SOP Class UID for a query model.
constexpr std::string_view patient_root_find_sop_class_uid
Patient Root Query/Retrieve Information Model - FIND.
@ study_root
Study Root Query/Retrieve Information Model.
@ patient_root
Patient Root Query/Retrieve Information Model.
constexpr int timeout
Lock timeout exceeded.
DICOM Query SCP service (C-FIND 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