47std::atomic<kcenon::pacs::network::dicom_server*> g_server{
nullptr};
50std::atomic<bool> g_running{
true};
56void signal_handler(
int signal) {
57 std::cout <<
"\nReceived signal " << signal <<
", shutting down...\n";
60 auto* server = g_server.load();
69void install_signal_handlers() {
70 std::signal(SIGINT, signal_handler);
71 std::signal(SIGTERM, signal_handler);
73 std::signal(SIGHUP, signal_handler);
81void print_usage(
const char* program_name) {
83Query/Retrieve SCP - DICOM Query/Retrieve Server
85Usage: )" << program_name << R"( <port> <ae_title> [options]
88 port Port number to listen on (typically 104 or 11112)
89 ae_title Application Entity Title for this server (max 16 chars)
92 --storage-dir <path> Directory containing DICOM files to serve
95 --index-db <path> SQLite database for indexing (default: in-memory)
96 --peer <spec> Known peer for C-MOVE (format: AE:host:port)
97 Can be specified multiple times
98 --max-assoc <n> Maximum concurrent associations (default: 10)
99 --timeout <sec> Idle timeout in seconds (default: 300)
100 --scan-only Scan storage and exit (for indexing)
101 --help Show this help message
104 )" << program_name << R"( 11112 MY_PACS --storage-dir ./dicom
105 )" << program_name << R"( 11112 MY_PACS --storage-dir ./dicom --index-db ./pacs.db
106 )" << program_name << R"( 11112 MY_PACS --storage-dir ./dicom --peer VIEWER:192.168.1.10:11113
107 )" << program_name << R"( 11112 MY_PACS --storage-dir ./dicom --peer WS1:10.0.0.1:104 --peer WS2:10.0.0.2:104
110 - Press Ctrl+C to stop the server gracefully
111 - Files are indexed on startup from the storage directory
112 - C-FIND supports Patient Root and Study Root queries
113 - C-MOVE requires known peers to be configured with --peer
114 - C-GET sends files directly to the requesting SCU
118 1 Error - Failed to start server or invalid arguments
126 std::string ae_title;
136 std::string ae_title;
137 std::filesystem::path storage_dir;
138 std::filesystem::path index_db;
139 std::vector<peer_config> peers;
140 size_t max_associations = 10;
141 uint32_t idle_timeout = 300;
142 bool scan_only =
false;
151bool parse_peer(
const std::string& spec, peer_config& peer) {
152 size_t first_colon = spec.find(
':');
153 if (first_colon == std::string::npos) {
157 size_t last_colon = spec.rfind(
':');
158 if (last_colon == first_colon || last_colon == spec.length() - 1) {
162 peer.ae_title = spec.substr(0, first_colon);
163 peer.host = spec.substr(first_colon + 1, last_colon - first_colon - 1);
166 int port_int = std::stoi(spec.substr(last_colon + 1));
167 if (port_int < 1 || port_int > 65535) {
170 peer.port =
static_cast<uint16_t
>(port_int);
171 }
catch (
const std::exception&) {
175 if (peer.ae_title.empty() || peer.ae_title.length() > 16 || peer.host.empty()) {
189bool parse_arguments(
int argc,
char* argv[], qr_scp_args& args) {
195 for (
int i = 1; i < argc; ++i) {
196 if (std::string(argv[i]) ==
"--help" || std::string(argv[i]) ==
"-h") {
203 int port_int = std::stoi(argv[1]);
204 if (port_int < 1 || port_int > 65535) {
205 std::cerr <<
"Error: Port must be between 1 and 65535\n";
208 args.port =
static_cast<uint16_t
>(port_int);
209 }
catch (
const std::exception&) {
210 std::cerr <<
"Error: Invalid port number '" << argv[1] <<
"'\n";
215 args.ae_title = argv[2];
216 if (args.ae_title.length() > 16) {
217 std::cerr <<
"Error: AE title exceeds 16 characters\n";
222 for (
int i = 3; i < argc; ++i) {
223 std::string arg = argv[i];
225 if (arg ==
"--storage-dir" && i + 1 < argc) {
226 args.storage_dir = argv[++i];
227 }
else if (arg ==
"--index-db" && i + 1 < argc) {
228 args.index_db = argv[++i];
229 }
else if (arg ==
"--peer" && i + 1 < argc) {
231 if (!parse_peer(argv[++i], peer)) {
232 std::cerr <<
"Error: Invalid peer format. Use AE:host:port\n";
235 args.peers.push_back(peer);
236 }
else if (arg ==
"--max-assoc" && i + 1 < argc) {
238 int val = std::stoi(argv[++i]);
240 std::cerr <<
"Error: max-assoc must be positive\n";
243 args.max_associations =
static_cast<size_t>(val);
244 }
catch (
const std::exception&) {
245 std::cerr <<
"Error: Invalid max-assoc value\n";
248 }
else if (arg ==
"--timeout" && i + 1 < argc) {
250 int val = std::stoi(argv[++i]);
252 std::cerr <<
"Error: timeout cannot be negative\n";
255 args.idle_timeout =
static_cast<uint32_t
>(val);
256 }
catch (
const std::exception&) {
257 std::cerr <<
"Error: Invalid timeout value\n";
260 }
else if (arg ==
"--scan-only") {
261 args.scan_only =
true;
263 std::cerr <<
"Error: Unknown option '" << arg <<
"'\n";
269 if (args.storage_dir.empty()) {
270 std::cerr <<
"Error: --storage-dir is required\n";
283std::string current_timestamp() {
284 auto now = std::chrono::system_clock::now();
285 auto time_t_now = std::chrono::system_clock::to_time_t(now);
286 auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(
287 now.time_since_epoch()) % 1000;
291 localtime_s(&tm_buf, &time_t_now);
293 localtime_r(&time_t_now, &tm_buf);
296 std::ostringstream oss;
297 oss << std::put_time(&tm_buf,
"%Y-%m-%d %H:%M:%S");
298 oss <<
'.' << std::setfill(
'0') << std::setw(3) << ms.count();
305std::string format_bytes(
size_t bytes) {
306 const char*
units[] = {
"B",
"KB",
"MB",
"GB",
"TB"};
308 double size =
static_cast<double>(bytes);
310 while (size >= 1024.0 && unit_index < 4) {
315 std::ostringstream oss;
316 if (unit_index == 0) {
317 oss << bytes <<
" " <<
units[unit_index];
319 oss << std::fixed << std::setprecision(2) << size <<
" " <<
units[unit_index];
331 const std::filesystem::path& storage_dir,
335 namespace fs = std::filesystem;
340 std::cout <<
"Scanning " << storage_dir <<
"...\n";
342 for (
const auto& entry : fs::recursive_directory_iterator(storage_dir)) {
343 if (!entry.is_regular_file()) {
348 auto ext = entry.path().extension().string();
349 bool is_dcm = ext ==
".dcm" || ext ==
".DCM" || ext.empty();
355 auto file = dicom_file::open(entry.path());
361 const auto& dataset = file.value().dataset();
364 auto patient_id = dataset.get_string(tags::patient_id,
"");
365 auto patient_name = dataset.get_string(tags::patient_name,
"");
366 auto birth_date = dataset.get_string(tags::patient_birth_date,
"");
367 auto sex = dataset.get_string(tags::patient_sex,
"");
370 auto patient_pk = db.
upsert_patient(patient_id, patient_name, birth_date, sex);
371 if (patient_pk.is_err()) {
377 auto study_uid = dataset.get_string(tags::study_instance_uid,
"");
378 auto study_id = dataset.get_string(tags::study_id,
"");
379 auto study_date = dataset.get_string(tags::study_date,
"");
380 auto study_time = dataset.get_string(tags::study_time,
"");
381 auto accession = dataset.get_string(tags::accession_number,
"");
382 auto ref_phys = dataset.get_string(tags::referring_physician_name,
"");
383 auto study_desc = dataset.get_string(tags::study_description,
"");
387 patient_pk.value(), study_uid, study_id, study_date, study_time,
388 accession, ref_phys, study_desc);
389 if (study_pk.is_err()) {
395 auto series_uid = dataset.get_string(tags::series_instance_uid,
"");
396 auto modality = dataset.get_string(tags::modality,
"");
397 auto series_num_str = dataset.get_string(tags::series_number,
"");
398 auto series_desc = dataset.get_string(tags::series_description,
"");
400 auto station = dataset.get_string(tags::station_name,
"");
402 std::optional<int> series_num;
403 if (!series_num_str.empty()) {
405 series_num = std::stoi(series_num_str);
411 study_pk.value(), series_uid, modality, series_num,
412 series_desc, body_part, station);
413 if (series_pk.is_err()) {
419 auto sop_uid = dataset.get_string(tags::sop_instance_uid,
"");
420 auto sop_class = dataset.get_string(tags::sop_class_uid,
"");
421 auto inst_num_str = dataset.get_string(tags::instance_number,
"");
424 std::optional<int> inst_num;
425 if (!inst_num_str.empty()) {
427 inst_num = std::stoi(inst_num_str);
431 auto file_size =
static_cast<int64_t
>(fs::file_size(entry.path()));
435 series_pk.value(), sop_uid, sop_class,
436 entry.path().string(), file_size, transfer_syntax_uid, inst_num);
438 if (instance_pk.is_ok()) {
440 if (count % 100 == 0) {
441 std::cout <<
" Indexed " << count <<
" files...\n";
446 }
catch (
const std::exception& e) {
451 std::cout <<
"Scan complete: " << count <<
" files indexed";
453 std::cout <<
" (" << errors <<
" errors)";
463std::vector<kcenon::pacs::core::dicom_dataset> handle_query(
466 [[maybe_unused]]
const std::string& calling_ae,
474 std::vector<dicom_dataset> results;
478 case query_level::patient: {
484 if (patients.is_ok()) {
485 for (
const auto& p : patients.value()) {
487 ds.set_string(tags::query_retrieve_level, vr_type::CS,
"PATIENT");
488 ds.set_string(tags::patient_id, vr_type::LO, p.patient_id);
489 ds.set_string(tags::patient_name, vr_type::PN, p.patient_name);
490 ds.set_string(tags::patient_birth_date, vr_type::DA, p.birth_date);
491 ds.set_string(tags::patient_sex, vr_type::CS, p.sex);
492 results.push_back(std::move(ds));
498 case query_level::study: {
508 if (studies.is_ok()) {
509 for (
const auto& s : studies.value()) {
514 ds.set_string(tags::query_retrieve_level, vr_type::CS,
"STUDY");
516 ds.set_string(tags::patient_id, vr_type::LO,
patient->patient_id);
517 ds.set_string(tags::patient_name, vr_type::PN,
patient->patient_name);
518 ds.set_string(tags::patient_birth_date, vr_type::DA,
patient->birth_date);
519 ds.set_string(tags::patient_sex, vr_type::CS,
patient->sex);
521 ds.set_string(tags::study_instance_uid, vr_type::UI, s.study_uid);
522 ds.set_string(tags::study_id, vr_type::SH, s.study_id);
523 ds.set_string(tags::study_date, vr_type::DA, s.study_date);
524 ds.set_string(tags::study_time, vr_type::TM, s.study_time);
525 ds.set_string(tags::accession_number, vr_type::SH, s.accession_number);
526 ds.set_string(tags::referring_physician_name, vr_type::PN, s.referring_physician);
527 ds.set_string(tags::study_description, vr_type::LO, s.study_description);
528 ds.set_string(tags::modalities_in_study, vr_type::CS, s.modalities_in_study);
529 results.push_back(std::move(ds));
535 case query_level::series: {
543 if (series_list.is_ok()) {
544 for (
const auto& ser : series_list.value()) {
549 ds.set_string(tags::query_retrieve_level, vr_type::CS,
"SERIES");
551 ds.set_string(tags::study_instance_uid, vr_type::UI,
study->study_uid);
553 ds.set_string(tags::series_instance_uid, vr_type::UI, ser.series_uid);
554 ds.set_string(tags::modality, vr_type::CS, ser.modality);
555 if (ser.series_number.has_value()) {
556 ds.set_string(tags::series_number, vr_type::IS,
557 std::to_string(ser.series_number.value()));
559 ds.set_string(tags::series_description, vr_type::LO, ser.series_description);
561 results.push_back(std::move(ds));
567 case query_level::image: {
574 if (instances.is_ok()) {
575 for (
const auto& inst : instances.value()) {
580 ds.set_string(tags::query_retrieve_level, vr_type::CS,
"IMAGE");
582 ds.set_string(tags::series_instance_uid, vr_type::UI,
series->series_uid);
587 ds.set_string(tags::study_instance_uid, vr_type::UI,
study->study_uid);
590 ds.set_string(tags::sop_instance_uid, vr_type::UI, inst.sop_uid);
591 ds.set_string(tags::sop_class_uid, vr_type::UI, inst.sop_class_uid);
592 if (inst.instance_number.has_value()) {
593 ds.set_string(tags::instance_number, vr_type::IS,
594 std::to_string(inst.instance_number.value()));
596 results.push_back(std::move(ds));
602 }
catch (
const std::exception& e) {
603 std::cerr <<
"[" << current_timestamp() <<
"] Query error: " << e.what() <<
"\n";
612std::vector<kcenon::pacs::core::dicom_file> handle_retrieve(
619 std::vector<dicom_file> files;
623 auto sop_uid = query_keys.
get_string(tags::sop_instance_uid,
"");
624 auto series_uid = query_keys.
get_string(tags::series_instance_uid,
"");
625 auto study_uid = query_keys.
get_string(tags::study_instance_uid,
"");
628 std::vector<instance_record> instances;
630 if (!sop_uid.empty()) {
634 instances.push_back(*inst);
636 }
else if (!series_uid.empty()) {
639 if (result.is_ok()) {
640 instances = std::move(result.value());
642 }
else if (!study_uid.empty()) {
645 if (series_result.is_ok()) {
646 for (
const auto& ser : series_result.value()) {
648 if (inst_result.is_ok()) {
649 for (
auto& inst : inst_result.value()) {
650 instances.push_back(std::move(inst));
658 if (studies_result.is_ok()) {
659 for (
const auto& study : studies_result.value()) {
661 if (series_result.is_ok()) {
662 for (
const auto& ser : series_result.value()) {
664 if (inst_result.is_ok()) {
665 for (
auto& inst : inst_result.value()) {
666 instances.push_back(std::move(inst));
676 for (
const auto& inst : instances) {
677 auto file = dicom_file::open(inst.file_path);
679 files.push_back(std::move(file.value()));
682 }
catch (
const std::exception& e) {
683 std::cerr <<
"[" << current_timestamp() <<
"] Retrieve error: " << e.what() <<
"\n";
694bool run_server(
const qr_scp_args& args) {
700 std::cout <<
"\nStarting Query/Retrieve SCP...\n";
701 std::cout <<
" AE Title: " << args.ae_title <<
"\n";
702 std::cout <<
" Port: " << args.port <<
"\n";
703 std::cout <<
" Storage Directory: " << args.storage_dir <<
"\n";
704 if (!args.index_db.empty()) {
705 std::cout <<
" Index Database: " << args.index_db <<
"\n";
707 std::cout <<
" Index Database: (in-memory)\n";
709 std::cout <<
" Max Associations: " << args.max_associations <<
"\n";
710 std::cout <<
" Idle Timeout: " << args.idle_timeout <<
" seconds\n";
711 if (!args.peers.empty()) {
712 std::cout <<
" Known Peers:\n";
713 for (
const auto& peer : args.peers) {
714 std::cout <<
" - " << peer.ae_title <<
" -> "
715 << peer.host <<
":" << peer.port <<
"\n";
722 if (!std::filesystem::exists(args.storage_dir, ec)) {
723 std::cerr <<
"Error: Storage directory does not exist: " << args.storage_dir <<
"\n";
728 std::string db_path = args.index_db.empty() ?
":memory:" : args.index_db.string();
729 auto db_result = index_database::open(db_path);
730 if (db_result.is_err()) {
731 std::cerr <<
"Failed to open database: " << db_result.error().message <<
"\n";
734 auto db = std::move(db_result.value());
737 auto indexed = scan_storage(args.storage_dir, *db);
739 if (args.scan_only) {
740 std::cout <<
"\nScan complete. Exiting.\n";
745 std::cout <<
"\nWarning: No DICOM files found in storage directory.\n";
746 std::cout <<
" Server will start but queries will return no results.\n\n";
750 std::map<std::string, std::pair<std::string, uint16_t>> peer_map;
751 for (
const auto& peer : args.peers) {
752 peer_map[peer.ae_title] = {peer.host, peer.port};
756 server_config config;
757 config.ae_title = args.ae_title;
758 config.port = args.port;
759 config.max_associations = args.max_associations;
760 config.idle_timeout = std::chrono::seconds{args.idle_timeout};
761 config.implementation_class_uid =
"1.2.826.0.1.3680043.2.1545.1";
762 config.implementation_version_name =
"QR_SCP_001";
765 dicom_server server{config};
769 server.register_service(std::make_shared<verification_scp>());
772 auto query_service = std::make_shared<query_scp>();
773 query_service->set_handler(
774 [&db](query_level level,
const dicom_dataset& keys,
const std::string& ae) {
775 return handle_query(level, keys, ae, *db);
778 server.register_service(query_service);
781 auto retrieve_service = std::make_shared<retrieve_scp>();
782 retrieve_service->set_retrieve_handler(
783 [&db](
const dicom_dataset& keys) {
784 return handle_retrieve(keys, *db);
788 retrieve_service->set_destination_resolver(
789 [&peer_map](
const std::string& ae_title)
790 -> std::optional<std::pair<std::string, uint16_t>> {
791 auto it = peer_map.find(ae_title);
792 if (it != peer_map.end()) {
798 server.register_service(retrieve_service);
801 server.on_association_established([](
const association& assoc) {
802 std::cout <<
"[" << current_timestamp() <<
"] "
803 <<
"Association established from: " << assoc.calling_ae()
804 <<
" -> " << assoc.called_ae() <<
"\n";
807 server.on_association_released([](
const association& assoc) {
808 std::cout <<
"[" << current_timestamp() <<
"] "
809 <<
"Association released: " << assoc.calling_ae() <<
"\n";
812 server.on_error([](
const std::string& error) {
813 std::cerr <<
"[" << current_timestamp() <<
"] "
814 <<
"Error: " <<
error <<
"\n";
818 auto result = server.start();
819 if (result.is_err()) {
820 std::cerr <<
"Failed to start server: " << result.error().message <<
"\n";
825 std::cout <<
"=================================================\n";
826 std::cout <<
" Query/Retrieve SCP is running on port " << args.port <<
"\n";
827 std::cout <<
" Storage: " << args.storage_dir <<
"\n";
828 std::cout <<
" Indexed: " << indexed <<
" DICOM files\n";
829 std::cout <<
" Press Ctrl+C to stop\n";
830 std::cout <<
"=================================================\n\n";
833 server.wait_for_shutdown();
836 auto server_stats = server.get_statistics();
839 std::cout <<
"=================================================\n";
840 std::cout <<
" Server Statistics\n";
841 std::cout <<
"=================================================\n";
842 std::cout <<
" Total Associations: " << server_stats.total_associations <<
"\n";
843 std::cout <<
" Rejected Associations: " << server_stats.rejected_associations <<
"\n";
844 std::cout <<
" Messages Processed: " << server_stats.messages_processed <<
"\n";
845 std::cout <<
" Queries Processed: " << query_service->queries_processed() <<
"\n";
846 std::cout <<
" C-MOVE Operations: " << retrieve_service->move_operations() <<
"\n";
847 std::cout <<
" C-GET Operations: " << retrieve_service->get_operations() <<
"\n";
848 std::cout <<
" Images Transferred: " << retrieve_service->images_transferred() <<
"\n";
849 std::cout <<
" Bytes Received: " << format_bytes(server_stats.bytes_received) <<
"\n";
850 std::cout <<
" Bytes Sent: " << format_bytes(server_stats.bytes_sent) <<
"\n";
851 std::cout <<
" Uptime: " << server_stats.uptime().count() <<
" seconds\n";
852 std::cout <<
"=================================================\n";
860int main(
int argc,
char* argv[]) {
862 ___ ____ ____ ____ ____
863 / _ \| _ \ / ___| / ___| _ \
864 | | | | |_) | \___ \| | | |_) |
865 | |_| | _ < ___) | |___| __/
866 \__\_\_| \_\ |____/ \____|_|
868 DICOM Query/Retrieve Server
873 if (!parse_arguments(argc, argv, args)) {
874 print_usage(argv[0]);
879 install_signal_handlers();
881 bool success = run_server(args);
883 std::cout <<
"\nQuery/Retrieve SCP terminated\n";
884 return success ? 0 : 1;
auto get_string(dicom_tag tag, std::string_view default_value="") const -> std::string
Get the string value of an element.
auto find_patient_by_pk(int64_t pk) const -> std::optional< patient_record >
Find a patient by primary key.
auto find_study_by_pk(int64_t pk) const -> std::optional< study_record >
Find a study by primary key.
auto list_instances(std::string_view series_uid) const -> Result< std::vector< instance_record > >
List all instances for a series.
auto list_series(std::string_view study_uid) const -> Result< std::vector< series_record > >
List all series for a study.
auto find_series_by_pk(int64_t pk) const -> std::optional< series_record >
Find a series by primary key.
auto upsert_instance(int64_t series_pk, std::string_view sop_uid, std::string_view sop_class_uid, std::string_view file_path, int64_t file_size, std::string_view transfer_syntax="", std::optional< int > instance_number=std::nullopt) -> Result< int64_t >
Insert or update an instance record.
auto search_studies(const study_query &query) const -> Result< std::vector< study_record > >
Search studies with query criteria.
auto upsert_patient(std::string_view patient_id, std::string_view patient_name="", std::string_view birth_date="", std::string_view sex="") -> Result< int64_t >
Insert or update a patient record.
auto upsert_series(int64_t study_pk, std::string_view series_uid, std::string_view modality="", std::optional< int > series_number=std::nullopt, std::string_view series_description="", std::string_view body_part_examined="", std::string_view station_name="") -> Result< int64_t >
Insert or update a series record.
auto list_studies(std::string_view patient_id) const -> Result< std::vector< study_record > >
List all studies for a patient.
auto search_instances(const instance_query &query) const -> Result< std::vector< instance_record > >
Search instances with query criteria.
auto search_series(const series_query &query) const -> Result< std::vector< series_record > >
Search series with query criteria.
auto find_instance(std::string_view sop_uid) const -> std::optional< instance_record >
Find an instance by SOP Instance UID.
auto search_patients(const patient_query &query) const -> Result< std::vector< patient_record > >
Search patients with query criteria.
auto upsert_study(int64_t patient_pk, std::string_view study_uid, std::string_view study_id="", std::string_view study_date="", std::string_view study_time="", std::string_view accession_number="", std::string_view referring_physician="", std::string_view study_description="") -> Result< int64_t >
Insert or update a study record.
DICOM Dataset - ordered collection of Data Elements.
DICOM Part 10 file handling for reading/writing DICOM files.
Multi-threaded DICOM server for handling multiple associations.
Compile-time constants for commonly used DICOM tags.
Filesystem-based DICOM storage with hierarchical organization.
PACS index database for metadata storage and retrieval.
@ error
Node returned an error.
@ body_part
(0018,0015) Body Part Examined
query_level
DICOM Query/Retrieve level enumeration.
DICOM Query SCP service (C-FIND handler)
DICOM Retrieve SCP service (C-MOVE/C-GET handler)
DICOM Server configuration structures.
std::optional< std::string > series_uid
Series Instance UID for filtering by series (exact match)
std::optional< std::string > sop_uid
SOP Instance UID (exact match)
std::optional< std::string > sop_class_uid
SOP Class UID filter (exact match)
std::optional< std::string > patient_name
Patient name pattern (supports * wildcard)
std::optional< std::string > patient_id
Patient ID pattern (supports * wildcard)
std::optional< std::string > series_description
Series description pattern (supports * wildcard)
std::optional< std::string > study_uid
Study Instance UID for filtering by study (exact match)
std::optional< std::string > series_uid
Series Instance UID (exact match)
std::optional< std::string > modality
Modality filter (exact match, e.g., "CT", "MR")
std::optional< std::string > study_uid
Study Instance UID (exact match)
std::optional< std::string > accession_number
Accession number pattern (supports * wildcard)
std::optional< std::string > study_date
Study date (exact match, format: YYYYMMDD)
std::optional< std::string > patient_name
Patient name pattern (supports * wildcard)
std::optional< std::string > study_description
Study description pattern (supports * wildcard)
std::optional< std::string > patient_id
Patient ID for filtering by patient (exact match or wildcard)
DICOM Verification SCP service (C-ECHO handler)