PACS System 0.1.0
PACS DICOM system library
Loading...
Searching...
No Matches
main.cpp
Go to the documentation of this file.
1
31
32#include <algorithm>
33#include <chrono>
34#include <cstdlib>
35#include <fstream>
36#include <iomanip>
37#include <iostream>
38#include <regex>
39#include <sstream>
40#include <string>
41#include <vector>
42
43namespace {
44
45// =============================================================================
46// Constants
47// =============================================================================
48
49constexpr const char* version_string = "1.0.0";
50constexpr const char* default_calling_ae = "FINDSCU";
51constexpr const char* default_called_ae = "ANY-SCP";
52constexpr auto default_timeout = std::chrono::seconds{30};
53constexpr size_t max_ae_title_length = 16;
54
55// =============================================================================
56// Query Model and Level
57// =============================================================================
58
59enum class query_model {
62};
63
64enum class query_level {
65 patient,
66 study,
67 series,
68 image
69};
70
71enum class output_format {
72 text,
73 json,
74 xml,
75 csv
76};
77
78// =============================================================================
79// Query Key
80// =============================================================================
81
82struct query_key {
84 std::string value;
85};
86
87// =============================================================================
88// Command Line Options
89// =============================================================================
90
91struct options {
92 // Network options
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};
97
98 // Timeout options
99 std::chrono::seconds connection_timeout{default_timeout};
100 std::chrono::seconds acse_timeout{default_timeout};
101 std::chrono::seconds dimse_timeout{0}; // 0 = infinite
102
103 // Query model and level
104 query_model model{query_model::patient_root};
105 query_level level{query_level::study};
106
107 // Query keys
108 std::vector<query_key> keys;
109 std::string query_file;
110
111 // Output options
112 output_format format{output_format::text};
113 std::string output_file;
114 bool extract_to_files{false};
115 size_t max_results{0}; // 0 = unlimited
116
117 // Verbosity
118 bool verbose{false};
119 bool debug{false};
120 bool quiet{false};
121
122 // Help/version flags
123 bool show_help{false};
124 bool show_version{false};
125};
126
127// =============================================================================
128// Utility Functions
129// =============================================================================
130
131std::string_view query_model_to_string(query_model model) {
132 switch (model) {
133 case query_model::patient_root: return "Patient Root";
134 case query_model::study_root: return "Study Root";
135 default: return "Unknown";
136 }
137}
138
139std::string_view query_level_to_string(query_level level) {
140 switch (level) {
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";
146 }
147}
148
149std::string_view get_find_sop_class_uid(query_model model) {
150 switch (model) {
151 case query_model::patient_root:
153 case query_model::study_root:
155 default:
157 }
158}
159
160// =============================================================================
161// Output Functions
162// =============================================================================
163
164void print_banner() {
165 std::cout << R"(
166 _____ ___ _ _ ____ ____ ____ _ _
167 | ___|_ _| \ | | _ \ / ___| / ___| | | |
168 | |_ | || \| | | | | \___ \| | | | | |
169 | _| | || |\ | |_| | ___) | |___| |_| |
170 |_| |___|_| \_|____/ |____/ \____|\___/
171
172 DICOM C-FIND Client v)" << version_string << R"(
173)" << "\n";
174}
175
176void print_usage(const char* program_name) {
177 std::cout << "Usage: " << program_name << R"( [options] <peer> <port>
178
179Arguments:
180 peer Remote host address (IP or hostname)
181 port Remote port number (typically 104 or 11112)
182
183Options:
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
189
190Network Options:
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)
196
197Query Model:
198 -P, --patient-root Patient Root Query Model (default)
199 -S, --study-root Study Root Query Model
200
201Query Level:
202 -L, --level <level> Query level (PATIENT|STUDY|SERIES|IMAGE)
203
204Query Keys:
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
208
209Output Options:
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)
214
215Common Query Keys:
216 Patient Level:
217 (0010,0010) PatientName (0010,0020) PatientID
218 (0010,0030) PatientBirthDate (0010,0040) PatientSex
219
220 Study Level:
221 (0020,000D) StudyInstanceUID (0008,0020) StudyDate
222 (0008,0030) StudyTime (0008,0050) AccessionNumber
223 (0008,1030) StudyDescription (0008,0060) Modality
224
225 Series Level:
226 (0020,000E) SeriesInstanceUID (0008,0060) Modality
227 (0020,0011) SeriesNumber (0008,103E) SeriesDescription
228
229 Image Level:
230 (0008,0018) SOPInstanceUID (0020,0013) InstanceNumber
231
232Examples:
233 # Find all studies for a patient
234 )" << program_name << R"( -P -L STUDY -k "0010,0010=Smith*" localhost 11112
235
236 # Find CT studies in date range
237 )" << program_name << R"( -S -L STUDY \
238 -k "0008,0060=CT" \
239 -k "0008,0020=20240101-20241231" \
240 pacs.example.com 104
241
242 # Find series for a study
243 )" << program_name << R"( -S -L SERIES \
244 -k "0020,000D=1.2.840..." \
245 -o json \
246 localhost 11112
247
248 # Query with file
249 )" << program_name << R"( -f query_keys.txt localhost 11112
250
251Query File Format (one key per line):
252 (0010,0010)=Smith*
253 (0010,0020)=
254 (0008,0020)=20240101-20241231
255
256Exit Codes:
257 0 Success - Query completed
258 1 Error - Query failed or no results
259 2 Error - Invalid arguments or connection failure
260)";
261}
262
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";
267}
268
269// =============================================================================
270// Argument Parsing
271// =============================================================================
272
273bool parse_timeout(const std::string& value, std::chrono::seconds& result,
274 const std::string& option_name) {
275 try {
276 int seconds = std::stoi(value);
277 if (seconds < 0) {
278 std::cerr << "Error: " << option_name << " must be non-negative\n";
279 return false;
280 }
281 result = std::chrono::seconds{seconds};
282 return true;
283 } catch (const std::exception&) {
284 std::cerr << "Error: Invalid value for " << option_name << ": '"
285 << value << "'\n";
286 return false;
287 }
288}
289
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";
294 return false;
295 }
296 if (ae_title.length() > max_ae_title_length) {
297 std::cerr << "Error: " << option_name << " exceeds "
298 << max_ae_title_length << " characters\n";
299 return false;
300 }
301 return true;
302}
303
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);
307
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;
312 return std::nullopt;
313}
314
315bool parse_query_key(const std::string& key_str, query_key& key) {
316 // Expected format: "gggg,eeee=value" or "(gggg,eeee)=value"
317 std::regex key_regex(R"(\‍(?([0-9A-Fa-f]{4}),([0-9A-Fa-f]{4})\)?=?(.*))");
318 std::smatch match;
319
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";
323 return false;
324 }
325
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));
328
329 key.tag = kcenon::pacs::core::dicom_tag{group, element};
330 key.value = match[3].str();
331
332 return true;
333}
334
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";
339 return false;
340 }
341
342 std::string line;
343 int line_num = 0;
344 while (std::getline(file, line)) {
345 ++line_num;
346
347 // Skip empty lines and comments
348 auto pos = line.find_first_not_of(" \t");
349 if (pos == std::string::npos || line[pos] == '#') {
350 continue;
351 }
352
353 query_key key;
354 if (!parse_query_key(line, key)) {
355 std::cerr << " (at line " << line_num << ")\n";
356 return false;
357 }
358 keys.push_back(key);
359 }
360
361 return true;
362}
363
364bool parse_arguments(int argc, char* argv[], options& opts) {
365 std::vector<std::string> positional_args;
366
367 for (int i = 1; i < argc; ++i) {
368 std::string arg = argv[i];
369
370 // Help options
371 if (arg == "-h" || arg == "--help") {
372 opts.show_help = true;
373 return true;
374 }
375 if (arg == "--version") {
376 opts.show_version = true;
377 return true;
378 }
379
380 // Verbosity options
381 if (arg == "-v" || arg == "--verbose") {
382 opts.verbose = true;
383 continue;
384 }
385 if (arg == "-d" || arg == "--debug") {
386 opts.debug = true;
387 opts.verbose = true;
388 continue;
389 }
390 if (arg == "-q" || arg == "--quiet") {
391 opts.quiet = true;
392 continue;
393 }
394
395 // Network options
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")) {
399 return false;
400 }
401 continue;
402 }
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")) {
406 return false;
407 }
408 continue;
409 }
410
411 // Timeout options
412 if ((arg == "-to" || arg == "--timeout") && i + 1 < argc) {
413 if (!parse_timeout(argv[++i], opts.connection_timeout, "timeout")) {
414 return false;
415 }
416 continue;
417 }
418 if ((arg == "-ta" || arg == "--acse-timeout") && i + 1 < argc) {
419 if (!parse_timeout(argv[++i], opts.acse_timeout, "ACSE timeout")) {
420 return false;
421 }
422 continue;
423 }
424 if ((arg == "-td" || arg == "--dimse-timeout") && i + 1 < argc) {
425 if (!parse_timeout(argv[++i], opts.dimse_timeout, "DIMSE timeout")) {
426 return false;
427 }
428 continue;
429 }
430
431 // Query model
432 if (arg == "-P" || arg == "--patient-root") {
433 opts.model = query_model::patient_root;
434 continue;
435 }
436 if (arg == "-S" || arg == "--study-root") {
437 opts.model = query_model::study_root;
438 continue;
439 }
440
441 // Query level
442 if ((arg == "-L" || arg == "--level") && i + 1 < argc) {
443 auto level = parse_level(argv[++i]);
444 if (!level) {
445 std::cerr << "Error: Invalid query level: '" << argv[i] << "'\n";
446 return false;
447 }
448 opts.level = *level;
449 continue;
450 }
451
452 // Query keys
453 if ((arg == "-k" || arg == "--key") && i + 1 < argc) {
454 query_key key;
455 if (!parse_query_key(argv[++i], key)) {
456 return false;
457 }
458 opts.keys.push_back(key);
459 continue;
460 }
461 if ((arg == "-f" || arg == "--query-file") && i + 1 < argc) {
462 opts.query_file = argv[++i];
463 continue;
464 }
465
466 // Output options
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;
473 else {
474 std::cerr << "Error: Invalid output format: '" << fmt << "'\n";
475 return false;
476 }
477 continue;
478 }
479 if (arg == "--output-file" && i + 1 < argc) {
480 opts.output_file = argv[++i];
481 continue;
482 }
483 if (arg == "-X" || arg == "--extract") {
484 opts.extract_to_files = true;
485 continue;
486 }
487 if (arg == "--max-results" && i + 1 < argc) {
488 try {
489 opts.max_results = static_cast<size_t>(std::stoul(argv[++i]));
490 } catch (...) {
491 std::cerr << "Error: Invalid max-results value\n";
492 return false;
493 }
494 continue;
495 }
496
497 // Unknown option
498 if (arg.starts_with("-")) {
499 std::cerr << "Error: Unknown option '" << arg << "'\n";
500 return false;
501 }
502
503 positional_args.push_back(arg);
504 }
505
506 // Validate positional arguments
507 if (positional_args.size() != 2) {
508 std::cerr << "Error: Expected <peer> <port> arguments\n";
509 return false;
510 }
511
512 opts.peer_host = positional_args[0];
513
514 try {
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";
518 return false;
519 }
520 opts.peer_port = static_cast<uint16_t>(port_int);
521 } catch (...) {
522 std::cerr << "Error: Invalid port number '" << positional_args[1] << "'\n";
523 return false;
524 }
525
526 // Load query file if specified
527 if (!opts.query_file.empty()) {
528 if (!load_query_file(opts.query_file, opts.keys)) {
529 return false;
530 }
531 }
532
533 return true;
534}
535
536// =============================================================================
537// Query Dataset Building
538// =============================================================================
539
540kcenon::pacs::core::dicom_dataset build_query_dataset(const options& opts) {
541 using namespace kcenon::pacs::core;
542 using namespace kcenon::pacs::encoding;
543
544 dicom_dataset ds;
545
546 // Set Query/Retrieve Level
547 std::string level_str{query_level_to_string(opts.level)};
548 ds.set_string(tags::query_retrieve_level, vr_type::CS, level_str);
549
550 // Add default return keys based on level
551 switch (opts.level) {
552 case query_level::patient:
553 // Patient level return keys
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, "");
558 break;
559
560 case query_level::study:
561 // Patient info
562 ds.set_string(tags::patient_name, vr_type::PN, "");
563 ds.set_string(tags::patient_id, vr_type::LO, "");
564 // Study info
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, "");
574 break;
575
576 case query_level::series:
577 // Series info
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, "");
583 break;
584
585 case query_level::image:
586 // Image info
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, "");
590 break;
591 }
592
593 // Apply user-specified query keys
594 for (const auto& key : opts.keys) {
595 ds.set_string(key.tag, vr_type::UN, key.value);
596 }
597
598 return ds;
599}
600
601// =============================================================================
602// Result Formatting
603// =============================================================================
604
605void format_text_result(std::ostream& os, const kcenon::pacs::core::dicom_dataset& ds,
606 size_t index) {
607 os << "Result " << (index + 1) << ":\n";
608
609 for (const auto& [tag, element] : ds) {
610 auto value = ds.get_string(tag, "");
611
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";
616 }
617 os << "\n";
618}
619
620void format_json_results(std::ostream& os,
621 const std::vector<kcenon::pacs::core::dicom_dataset>& results) {
622 os << "[\n";
623 for (size_t i = 0; i < results.size(); ++i) {
624 os << " {\n";
625 const auto& ds = results[i];
626 bool first = true;
627 for (const auto& [tag, element] : ds) {
628 if (!first) os << ",\n";
629 first = false;
630
631 auto value = ds.get_string(tag, "");
632
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();
637
638 os << " \"" << tag_str.str() << "\": \"" << value << "\"";
639 }
640 os << "\n }";
641 if (i < results.size() - 1) os << ",";
642 os << "\n";
643 }
644 os << "]\n";
645}
646
647void format_csv_results(std::ostream& os,
648 const std::vector<kcenon::pacs::core::dicom_dataset>& results,
649 query_level level) {
650 // Header based on level
651 switch (level) {
652 case query_level::patient:
653 os << "PatientName,PatientID,PatientBirthDate,PatientSex\n";
654 break;
655 case query_level::study:
656 os << "PatientName,PatientID,StudyInstanceUID,StudyDate,StudyTime,"
657 << "AccessionNumber,StudyDescription,Modalities\n";
658 break;
659 case query_level::series:
660 os << "SeriesInstanceUID,Modality,SeriesNumber,SeriesDescription,"
661 << "NumberOfInstances\n";
662 break;
663 case query_level::image:
664 os << "SOPInstanceUID,SOPClassUID,InstanceNumber\n";
665 break;
666 }
667
668 for (const auto& ds : results) {
669 using namespace kcenon::pacs::core;
670 switch (level) {
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";
676 break;
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";
686 break;
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";
693 break;
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";
698 break;
699 }
700 }
701}
702
703// =============================================================================
704// Query Implementation
705// =============================================================================
706
707int perform_query(const options& opts) {
708 using namespace kcenon::pacs::network;
709 using namespace kcenon::pacs::network::dimse;
710 using namespace kcenon::pacs::services;
711
712 auto sop_class_uid = get_find_sop_class_uid(opts.model);
713
714 // Print connection info
715 if (!opts.quiet) {
716 std::cout << "Requesting Association\n";
717 if (opts.verbose) {
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)
723 << "\n";
724 std::cout << " Query Level: " << query_level_to_string(opts.level)
725 << "\n";
726 std::cout << "\n";
727 }
728 }
729
730 // Configure association
731 association_config config;
732 config.calling_ae_title = opts.calling_ae_title;
733 config.called_ae_title = opts.called_ae_title;
734 config.implementation_class_uid = "1.2.826.0.1.3680043.2.1545.1";
735 config.implementation_version_name = "FIND_SCU_100";
736
737 // Propose Query SOP Class
738 config.proposed_contexts.push_back({
739 1, // Context ID
740 std::string(sop_class_uid),
741 {
742 "1.2.840.10008.1.2.1", // Explicit VR Little Endian
743 "1.2.840.10008.1.2" // Implicit VR Little Endian
744 }
745 });
746
747 // Establish association
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,
752 config, timeout);
753
754 if (connect_result.is_err()) {
755 std::cerr << "Association Failed: " << connect_result.error().message
756 << "\n";
757 return 2;
758 }
759
760 auto& assoc = connect_result.value();
761 auto connect_time = std::chrono::steady_clock::now();
762
763 if (!opts.quiet) {
764 std::cout << "Association Accepted\n";
765 if (opts.verbose) {
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()
770 << " ms)\n";
771 }
772 }
773
774 // Check if context was accepted
775 if (!assoc.has_accepted_context(sop_class_uid)) {
776 std::cerr << "Error: Query SOP Class not accepted by remote SCP\n";
777 assoc.abort();
778 return 2;
779 }
780
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";
784 assoc.abort();
785 return 2;
786 }
787 uint8_t context_id = *context_id_opt;
788
789 // Build query dataset
790 auto query_ds = build_query_dataset(opts);
791
792 // Create C-FIND request
793 auto find_rq = make_c_find_rq(1, sop_class_uid);
794 find_rq.set_dataset(std::move(query_ds));
795
796 if (!opts.quiet && opts.verbose) {
797 std::cout << "Sending C-FIND Request\n";
798 }
799
800 // Send C-FIND request
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";
804 assoc.abort();
805 return 2;
806 }
807
808 // Receive responses
809 std::vector<kcenon::pacs::core::dicom_dataset> results;
810 bool query_complete = false;
811 size_t pending_count = 0;
812
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};
816
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
821 << "\n";
822 assoc.abort();
823 return 2;
824 }
825
826 auto& [recv_context_id, find_rsp] = recv_result.value();
827
828 if (find_rsp.command() != command_field::c_find_rsp) {
829 std::cerr << "Error: Unexpected response (expected C-FIND-RSP)\n";
830 assoc.abort();
831 return 2;
832 }
833
834 auto status = find_rsp.status();
835
836 if (status == status_pending || status == status_pending_warning) {
837 ++pending_count;
838
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());
844 }
845 }
846 }
847
848 if (!opts.quiet && opts.verbose && pending_count % 10 == 0) {
849 std::cout << "\rReceived " << pending_count << " results..."
850 << std::flush;
851 }
852 } else if (status == status_success) {
853 query_complete = true;
854 } else if (status == status_cancel) {
855 query_complete = true;
856 if (!opts.quiet) {
857 std::cout << "Query was cancelled.\n";
858 }
859 } else {
860 query_complete = true;
861 std::cerr << "Query failed with status: 0x" << std::hex << status
862 << std::dec << "\n";
863 }
864 }
865
866 if (!opts.quiet && opts.verbose) {
867 std::cout << "\n";
868 }
869
870 // Release association
871 if (!opts.quiet && opts.verbose) {
872 std::cout << "Releasing Association\n";
873 }
874
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";
879 }
880
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);
884
885 // Output results
886 std::ostream* out = &std::cout;
887 std::ofstream file_out;
888
889 if (!opts.output_file.empty()) {
890 file_out.open(opts.output_file);
891 if (file_out.is_open()) {
892 out = &file_out;
893 } else {
894 std::cerr << "Warning: Could not open output file: "
895 << opts.output_file << "\n";
896 }
897 }
898
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);
903 }
904 break;
905 case output_format::json:
906 format_json_results(*out, results);
907 break;
908 case output_format::csv:
909 format_csv_results(*out, results, opts.level);
910 break;
911 case output_format::xml:
912 // TODO: Implement XML output
913 for (size_t i = 0; i < results.size(); ++i) {
914 format_text_result(*out, results[i], i);
915 }
916 break;
917 }
918
919 // Print summary
920 if (!opts.quiet) {
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 << ")";
924 }
925 std::cout << "\n";
926
927 if (opts.verbose) {
928 std::cout << "Query Time: " << total_duration.count() << " ms\n";
929 }
930
931 std::cout << "Query Complete\n";
932 }
933
934 return results.empty() ? 1 : 0;
935}
936
937} // namespace
938
939// =============================================================================
940// Main Entry Point
941// =============================================================================
942
943int main(int argc, char* argv[]) {
944 options opts;
945
946 if (!parse_arguments(argc, argv, opts)) {
947 if (!opts.show_help && !opts.show_version) {
948 std::cerr << "\nUse --help for usage information.\n";
949 return 2;
950 }
951 }
952
953 if (opts.show_version) {
954 print_version();
955 return 0;
956 }
957
958 if (opts.show_help) {
959 print_banner();
960 print_usage(argv[0]);
961 return 0;
962 }
963
964 // Print banner unless quiet mode or structured output
965 bool suppress_banner = opts.quiet ||
966 opts.format == output_format::json ||
967 opts.format == output_format::csv ||
968 opts.format == output_format::xml;
969
970 if (!suppress_banner) {
971 print_banner();
972 }
973
974 return perform_query(opts);
975}
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.
int main()
Definition main.cpp:84
constexpr dicom_tag status
Status.
constexpr dicom_tag sop_class_uid
SOP Class UID.
constexpr int connection_timeout
Definition result.h:95
auto query_level_to_string(query_level level) -> std::string
Convert query level to string.
Definition events.h:176
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.
Definition query_scp.h:42
constexpr std::string_view get_find_sop_class_uid(query_model model) noexcept
Get the FIND SOP Class UID for a query model.
Definition query_scu.h:70
constexpr std::string_view patient_root_find_sop_class_uid
Patient Root Query/Retrieve Information Model - FIND.
Definition query_scp.h:38
@ 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::vector< proposed_presentation_context > proposed_contexts