核心架构与语言DCMTK (storescp.exe)语言C。架构基于传统的阻塞式 Socket 或多线程模型。每个连接通常对应一个线程或进程分支。特点轻量级、启动速度快、内存占用极低。它是 DICOM 标准的参考实现之一稳定性极高。dcm4che (storescp)语言Java。架构基于 Netty 异步非阻塞 I/O框架。特点利用 Java NIO机制单线程或少量线程即可处理大量并发连接。内存开销相对较大JVM overhead但吞吐量上限更高。经过一段时间比较测试特性DCMTKstorescp.exedcm4chestorescp底层语言CJava (Netty)并发模型多线程/阻塞 I/O异步非阻塞 I/O高并发能力中等受线程限制极高事件驱动内存占用极低较高JVM启动速度毫秒级秒级JVM 预热扩展开发困难需改 C 源码容易Java API/回调典型场景嵌入式设备、小型 PACS、测试工具、边缘节点大型医院 PACS、云影像平台、高吞吐归档中心选型建议如果你需要在资源受限的环境如嵌入式医疗设备、树莓派或小型诊所部署一个简单的接收服务DCMTKstorescp.exe 是更好的选择因为它轻量、稳定且无需复杂环境。如果你需要构建一个企业级 PACS、云影像平台或者需要处理海量并发连接、自定义存储逻辑如存入对象存储dcm4chestorescp 是更合适的选择其高并发能力和 Java 生态的扩展性是巨大优势。我下载了dcm4che-5.34.3-bin.zip解压到了E:\dcm4che\dcm4che-5.34.3storescp.py、storescp_config.json、logs是我后加的后面介绍。下载jdk17放到了E:\dcm4che目录下。由于我的电脑已经设置了JAVA-HOME为jdk1.8的dcm4che-5.34.3需要jdk11以上所以单独放到这个目录给dcm4che-5.34.3专用。因此修改storescp.bat等文件强制使用我指定的jdk。修改后storescp.bat主要加了set LOCAL_JDK..\..\jdk\jdk-17.0.19_10echo off rem ------------------------------------------------------------------------- rem storescp Launcher rem ------------------------------------------------------------------------- if not %ECHO% echo %ECHO% if %OS% Windows_NT setlocal chcp 65001 nul set MAIN_CLASSorg.dcm4che3.tool.storescp.StoreSCP set MAIN_JARdcm4che-tool-storescp-5.34.3.jar set DIRNAME.\ if %OS% Windows_NT set DIRNAME%~dp0% rem 强制指定JDK路径 set LOCAL_JDK..\..\jdk\jdk-17.0.19_10 set JAVA%LOCAL_JDK%\bin\java.exe if not exist %JAVA% ( echo ERROR: JDK不存在 %JAVA% pause exit /b ) rem 收集启动参数 set ARGS :loop if [%1] [] goto end set ARGS%ARGS% %1 shift goto loop :end if not %DCM4CHE_HOME% goto HAVE_DCM4CHE_HOME set DCM4CHE_HOME%DIRNAME%.. :HAVE_DCM4CHE_HOME rem ClassPath 保持官方原生配置 set CP%DCM4CHE_HOME%\etc\storescp\ set CP%CP%;%DCM4CHE_HOME%\etc\certs\ set CP%CP%;%DCM4CHE_HOME%\lib\%MAIN_JAR% set CP%CP%;%DCM4CHE_HOME%\lib\dcm4che-core-5.34.3.jar set CP%CP%;%DCM4CHE_HOME%\lib\dcm4che-net-5.34.3.jar set CP%CP%;%DCM4CHE_HOME%\lib\dcm4che-tool-common-5.34.3.jar set CP%CP%;%DCM4CHE_HOME%\lib\slf4j-api-2.0.17.jar set CP%CP%;%DCM4CHE_HOME%\lib\logback-core-1.5.19.jar set CP%CP%;%DCM4CHE_HOME%\lib\logback-classic-1.5.19.jar set CP%CP%;%DCM4CHE_HOME%\lib\commons-cli-1.9.0.jar echo 启动参数 echo 参数列表: %ARGS% echo %JAVA% %JAVA_OPTS% -cp %CP% %MAIN_CLASS% %ARGS%写个批处理run_scp.bat启动scp服务:echo off chcp 65001 nul cd /d E:\dcm4che\dcm4che-5.34.3\bin storescp ^ --directory E:\dcm4che\storage ^ --filepath {00080020}/{00080050}/{0020000D}/{0020000E}/{00080018}.dcm ^ -b PACS_BAIJUN127.0.0.1:11112 ^ --max-pdulen-rcv 65536 ^ --idle-timeout 60000 echo. echo echo DICOM STORESCP 已启动 echo 监听端口11112 echo Called AEPACS_BAIJUN echo 存储根目录E:\dcm4che\storage echo 分层格式StudyDate(YYYYMMDD)\AccessionNumber\StudyUID\SeriesUID\SOPUID.dcm echo Tag对应说明 echo {00080020} StudyDate 检查日期YYYYMMDD echo {00080050} AccessionNumber 申请号/流水号 echo {0020000D} StudyInstanceUID echo {0020000E} SeriesInstanceUID echo {00080018} SOPInstanceUID文件名 echo 前置说明StudyDate、AccessionNumber空值由前端拦截理论不会产生!NO_VALUE!目录 echo echo 按 CtrlC 停止服务 pause然后用storescu.bat测试发送dcm文件REM 单个文件 REM storescu.bat -b TEST_SCU -c PACS_BAIJUN127.0.0.1:11112 D:\tmp\ccc.dcm REM 目录传输 storescu.bat -b TEST_SCU -c PACS_BAIJUN127.0.0.1:11112 D:\tmp storescu.bat -b TEST_SCU -c PACS_BAIJUN127.0.0.1:11112 E:\Dicom\XieMeiLing storescu.bat -b TEST_SCU -c ORTHANC11479127.0.0.1:4242 E:\Dicom\XieMeiLing几秒钟就完成了上千个dcm文件的发送接收在配置规则指定路径中创建了文件后续还可通过watchdog转存到自定义规则的存储路径、或服务器规划自定义功能我测试将几个患者的影像模拟多台CT机同时推图传送那是嘎嘎滴快老旧系统基本都是单线程接收排队一个一个的接收存好了返回个状态再存下一个。dcm4che的storescp异步非阻塞 I/O框架方式保存文件功确实强大。赞赞赞作为影像接收服务器不可能一直开个命令窗口运行storescp.bat接收上百台设备传送过来的dcm文件所有我用python做成GUI界面管理服务。没有用nssm做系统服务不方便看日志运行界面配置文件storescp_config.jsonstorescp.py代码import tkinter as tk from tkinter import ttk, scrolledtext, filedialog import subprocess import threading import json import os import re import sys from queue import Queue from datetime import datetime, timedelta # filepath 常用 DICOM 标签ggggeeee 十六进制 # 00100020 PatientID 患者 ID # 0020000D StudyInstanceUID 检查 UID # 0020000E SeriesInstanceUID 序列 UID # 00080018 SOPInstanceUID 文件 UID # 00080060 Modality 设备类型 CT/MR/US # 固定路径配置不存入配置文件 SCRIPT_DIR os.path.dirname(os.path.abspath(sys.argv[0])) DCM4CHE_BIN_PATH os.path.join(SCRIPT_DIR, bin) STORESCP_BAT os.path.join(DCM4CHE_BIN_PATH, storescp.bat) FILEPATH_TPL {00080020}/{00080050}/{0020000D}/{0020000E}/{00080018}.dcm CONFIG_FILE os.path.join(SCRIPT_DIR, storescp_config.json) LOG_FOLDER os.path.join(SCRIPT_DIR, logs) # 内置默认参数附带注释字段注释字段不会参与程序逻辑 DEFAULT_CONFIG { _comment_aet: DICOM服务AET名称, aet: PACS_BAIJUN, _comment_port: 监听端口号, port: 11112, _comment_storage_path: DICOM影像保存根目录, storage_path: rE:\dcm4che\storage, _comment_save_log: 是否启用日志持久化 true/false, save_log: True, _comment_log_buffer_lines: 日志内存缓冲行数达到该行数批量写入磁盘减少IO, log_buffer_lines: 50, _comment_log_flush_interval_ms: 强制刷新缓冲区间隔(毫秒)防止日志长期留在内存, log_flush_interval_ms: 2000, _comment_log_clean_days: 自动清理N天以前的历史日志文件, log_clean_days: 7, _comment_log_rotate_max_lines: 单个日志文件最大行数到达阈值自动新建日志文件, log_rotate_max_lines: 20000, _comment_max_ui_log_lines: 界面日志最大行数超出自动清空窗口日志, max_ui_log_lines: 2000, _comment_ui_refresh_interval_ms: 界面日志刷新间隔(毫秒)调低增加CPU占用, ui_refresh_interval_ms: 50, _comment_auto_start_delay_ms: 程序启动后延迟多少毫秒自动启动SCP服务, auto_start_delay_ms: 3000 } # 消息队列 UI_LOG_QUEUE Queue(maxsize5000) FILE_LOG_QUEUE Queue(maxsize10000) # class StorescpGUI: def __init__(self, root): self.root root self.root.title(DICOM StoreSCP 接收服务管理工具) self.root.geometry(780x560) self.root.minsize(720, 480) self.process None self.running False self.log_line_count 0 # 运行时配置仅保存业务参数剔除注释字段 self.cfg {} self.aet_var tk.StringVar() self.port_var tk.StringVar() self.storage_var tk.StringVar() self.save_log tk.BooleanVar() # 文件写入线程变量 self.file_write_thread None self.write_buffer [] self.last_write_time datetime.now() self.log_file_handle None self.file_thread_running False self.current_log_path self.current_file_line_counter 0 # 当前日志文件已写入行数 # 初始化日志目录 os.makedirs(LOG_FOLDER, exist_okTrue) self.load_config() self.clean_expired_log() self.build_ui() # 窗口关闭事件 self.root.protocol(WM_DELETE_WINDOW, self.on_window_close) # 启动UI队列轮询 self.poll_log_queue() # 启动文件日志后台线程 self.start_file_log_thread() # 延时自动启动服务 self.root.after(self.cfg[auto_start_delay_ms], self.auto_start_task) def get_new_log_file_path(self): 生成全新日志文件名storescp_年月日_时分秒.txt timestamp_str datetime.now().strftime(%Y%m%d_%H%M%S) filename fstorescp_{timestamp_str}.txt return os.path.join(LOG_FOLDER, filename) def need_rotate_log(self): 判断是否需要切换新日志文件 if self.current_log_path : return True # 条件1当前文件行数到达阈值 if self.current_file_line_counter self.cfg[log_rotate_max_lines]: return True # 条件2跨自然日 today_str datetime.now().strftime(%Y%m%d) file_name os.path.basename(self.current_log_path) try: file_date_str file_name.split(_)[1] if file_date_str[:8] ! today_str: return True except: return True return False def clean_expired_log(self): 删除N天前日志 expire_days self.cfg[log_clean_days] expire_time datetime.now() - timedelta(daysexpire_days) try: for fname in os.listdir(LOG_FOLDER): path os.path.join(LOG_FOLDER, fname) mtime datetime.fromtimestamp(os.path.getmtime(path)) if mtime expire_time: os.remove(path) self.ui_log(f️ 过期日志清理{fname}) except Exception as e: self.ui_log(f日志清理异常:{e}) def start_file_log_thread(self): self.file_thread_running True self.file_write_thread threading.Thread(targetself.file_log_worker, daemonTrue) self.file_write_thread.start() def file_log_worker(self): 日志写入循环批量缓冲落盘 自动轮转 while self.file_thread_running: try: # 读取队列消息 while not FILE_LOG_QUEUE.empty(): msg FILE_LOG_QUEUE.get_nowait() time_str datetime.now().strftime(%Y-%m-%d %H:%M:%S) self.write_buffer.append(f[{time_str}] {msg}) now datetime.now() need_flush False buffer_lines self.cfg[log_buffer_lines] flush_interval self.cfg[log_flush_interval_ms] if len(self.write_buffer) buffer_lines: need_flush True if (now - self.last_write_time).total_seconds() * 1000 flush_interval: need_flush True if need_flush and len(self.write_buffer) 0: self.flush_log_buffer() self.write_buffer.clear() self.last_write_time now except Exception: pass threading.Event().wait(0.05) # 退出前刷新剩余缓存 self.flush_log_buffer() if self.log_file_handle: try: self.log_file_handle.close() except: pass self.log_file_handle None def flush_log_buffer(self): 缓冲区写入磁盘自动处理日志轮转 if not self.save_log.get() or len(self.write_buffer) 0: return # 检查是否需要新建日志文件 if self.need_rotate_log(): if self.log_file_handle: try: self.log_file_handle.close() except: pass self.log_file_handle None self.current_log_path self.get_new_log_file_path() self.current_file_line_counter 0 try: if self.log_file_handle is None: self.log_file_handle open(self.current_log_path, a, encodingutf-8) write_content \n.join(self.write_buffer) \n self.log_file_handle.write(write_content) # 累加行数计数器 self.current_file_line_counter len(self.write_buffer) except Exception: if self.log_file_handle: try: self.log_file_handle.close() except: pass self.log_file_handle None def ui_log(self, msg): try: UI_LOG_QUEUE.put_nowait(msg) except: pass def global_log(self, msg): self.ui_log(msg) if self.save_log.get(): try: FILE_LOG_QUEUE.put_nowait(msg) except: pass def build_ui(self): main_frame ttk.Frame(self.root, padding16) main_frame.pack(filltk.BOTH, expandTrue) row_idx 0 ttk.Label(main_frame, textCalled AET, width12).grid(rowrow_idx, column0, stickyw) ttk.Entry(main_frame, textvariableself.aet_var).grid(rowrow_idx, column1, columnspan3, stickyew, padx8) row_idx 1 ttk.Label(main_frame, text监听端口, width12).grid(rowrow_idx, column0, stickyw, pady10 0) port_entry ttk.Entry(main_frame, textvariableself.port_var) port_entry.grid(rowrow_idx, column1, columnspan3, stickyew, padx8) row_idx 1 ttk.Label(main_frame, text存储根路径, width12).grid(rowrow_idx, column0, stickyw, pady10 0) path_entry ttk.Entry(main_frame, textvariableself.storage_var) path_entry.grid(rowrow_idx, column1, columnspan2, stickyew, padx8) ttk.Button(main_frame, text选择目录, commandself.select_folder, width10).grid(rowrow_idx, column3) row_idx 1 ttk.Label(main_frame, text文件存储模板, width12).grid(rowrow_idx, column0, stickyw, pady10 0) filepath_label ttk.Label(main_frame, textFILEPATH_TPL, foreground#00509E) filepath_label.grid(rowrow_idx, column1, columnspan3, stickyw, padx8) row_idx 1 log_check ttk.Checkbutton(main_frame, text启用日志持久化保存, variableself.save_log) log_check.grid(rowrow_idx, column0, stickyw, pady6 0) row_idx 1 btn_frame ttk.Frame(main_frame) btn_frame.grid(rowrow_idx, column0, columnspan4, stickyw, pady8 8) self.btn_start ttk.Button(btn_frame, text▶ 启动服务, commandself.start_service, width12) self.btn_start.pack(sidetk.LEFT, padx4) self.btn_stop ttk.Button(btn_frame, text■ 停止服务, commandself.stop_service, statetk.DISABLED, width12) self.btn_stop.pack(sidetk.LEFT, padx4) ttk.Button(btn_frame, text保存配置, commandself.save_config, width10).pack(sidetk.LEFT, padx4) ttk.Button(btn_frame, text清空日志, commandself.clear_log, width10).pack(sidetk.LEFT, padx4) row_idx 1 ttk.Label(main_frame, text运行日志).grid(rowrow_idx, column0, columnspan4, stickyw) row_idx 1 self.log_text scrolledtext.ScrolledText(main_frame) self.log_text.grid(rowrow_idx, column0, columnspan4, stickynsew, pady6 0) self.log_text.config(statetk.DISABLED) main_frame.columnconfigure(1, weight1) main_frame.columnconfigure(2, weight1) main_frame.rowconfigure(row_idx, weight1) self.global_log(f 程序目录{SCRIPT_DIR}) self.global_log(f Bin目录{DCM4CHE_BIN_PATH}) self.global_log(f 日志目录{LOG_FOLDER}) self.global_log(f 单日志最大行数阈值{self.cfg[log_rotate_max_lines]}) if os.path.exists(STORESCP_BAT): self.global_log(✅ 成功识别storescp.bat) else: self.global_log(❌ 警告未找到 storescp.bat请确认程序放在bin目录上层) def poll_log_queue(self): buffer_lines [] while not UI_LOG_QUEUE.empty(): try: msg UI_LOG_QUEUE.get_nowait() buffer_lines.append(msg) except: break if buffer_lines: self.log_text.config(statetk.NORMAL) max_ui_line self.cfg[max_ui_log_lines] for line in buffer_lines: self.log_text.insert(tk.END, f{line}\n) self.log_line_count 1 if self.log_line_count max_ui_line: self.log_text.delete(1.0, tk.END) self.log_line_count 0 self.log_text.insert(tk.END, f 界面日志达到{max_ui_line}行上限已自动清空\n) self.log_text.see(tk.END) self.log_text.config(statetk.DISABLED) self.root.after(self.cfg[ui_refresh_interval_ms], self.poll_log_queue) def clear_log(self): self.log_text.config(statetk.NORMAL) self.log_text.delete(1.0, tk.END) self.log_text.config(statetk.DISABLED) self.log_line_count 0 self.global_log(✅ 界面日志已手动清空) def on_window_close(self): self.global_log(⏹ 收到关闭指令正在停止服务...) self.file_thread_running False if self.running: self.stop_service() # 等待进程结束再销毁窗口 self.root.after(800, self.root.destroy) def select_folder(self): path filedialog.askdirectory() if path: self.storage_var.set(path) def save_config(self): 保存配置连同注释字段一起写入json save_data DEFAULT_CONFIG.copy() # 覆盖业务参数保留所有注释 save_data[aet] self.aet_var.get() save_data[port] self.port_var.get() save_data[storage_path] self.storage_var.get() save_data[save_log] self.save_log.get() # 运行时性能参数直接沿用内存cfg save_data[log_buffer_lines] self.cfg[log_buffer_lines] save_data[log_flush_interval_ms] self.cfg[log_flush_interval_ms] save_data[log_clean_days] self.cfg[log_clean_days] save_data[log_rotate_max_lines] self.cfg[log_rotate_max_lines] save_data[max_ui_log_lines] self.cfg[max_ui_log_lines] save_data[ui_refresh_interval_ms] self.cfg[ui_refresh_interval_ms] save_data[auto_start_delay_ms] self.cfg[auto_start_delay_ms] try: with open(CONFIG_FILE, w, encodingutf-8) as f: json.dump(save_data, f, ensure_asciiFalse, indent2) self.global_log(✅ 配置已保存附带中文注释) except Exception as e: self.global_log(f❌ 保存配置失败{e}) def load_config(self): 加载配置自动过滤注释字段 loaded_raw {} if os.path.exists(CONFIG_FILE): try: with open(CONFIG_FILE, r, encodingutf-8) as f: loaded_raw json.load(f) except Exception as e: self.ui_log(f配置文件读取失败使用默认配置{e}) # 合并默认配置过滤掉注释字段key以 _comment_ 开头 work_config {} for k, v in DEFAULT_CONFIG.items(): if not k.startswith(_comment_): work_config[k] v # 使用外部配置覆盖 for k, v in loaded_raw.items(): if not k.startswith(_comment_): work_config[k] v self.cfg work_config self.aet_var.set(self.cfg[aet]) self.port_var.set(self.cfg[port]) self.storage_var.set(self.cfg[storage_path]) self.save_log.set(self.cfg[save_log]) def get_pid_by_port(self, port): 只获取监听端口对应的PID不再无脑杀掉所有进程 try: output subprocess.check_output( [netstat, -ano, -p, tcp], shellTrue, textTrue, encodinggbk ) pids set() for line in output.splitlines(): if f:{port} in line and LISTENING in line: match re.search(r\s(\d)$, line.strip()) if match: pids.add(match.group(1)) return list(pids) except subprocess.CalledProcessError: return [] def kill_port_process(self, port): pid_list self.get_pid_by_port(port) if not pid_list: return True self.global_log(f⚠️ 端口{port}处于监听状态准备终止进程 PID:{pid_list}) for pid in pid_list: try: subprocess.run([taskkill, /F, /PID, pid], shellTrue, checkFalse) except Exception as e: self.global_log(f终止PID {pid} 失败:{e}) return True def auto_start_task(self): delay_sec self.cfg[auto_start_delay_ms] / 1000 self.global_log(f⏱ 等待{delay_sec:.1f}秒完成自动启动服务...) self.start_service() def start_service(self): if self.running: self.global_log(ℹ️ 服务已经正在运行无需重复启动) return aet self.aet_var.get().strip() port self.port_var.get().strip() storage self.storage_var.get().strip() # 参数校验 if not all([aet, port, storage]): self.global_log(❌ AET、端口、存储路径不能为空) return if not port.isdigit(): self.global_log(❌ 端口必须为纯数字) return port_num int(port) if not (1 port_num 65535): self.global_log(❌ 端口范围必须在1~65535之间) return if not os.path.isdir(storage): os.makedirs(storage, exist_okTrue) self.global_log(f 自动创建存储目录{storage}) if not os.path.exists(STORESCP_BAT): self.global_log(f❌ 找不到bat脚本{STORESCP_BAT}) return # 清理端口占用 self.kill_port_process(port) # 使用列表参数避免shell转义BUG关键优化 cmd_args [ STORESCP_BAT, --directory, storage, --filepath, FILEPATH_TPL, -b, f{aet}0.0.0.0:{port}, --max-pdulen-rcv, 65536, --idle-timeout, 60000 ] self.global_log() self.global_log(f 启动storescp | AET:{aet} PORT:{port}) self.global_log(f存储目录{storage}) self.global_log(f启动参数{cmd_args}) self.global_log() try: self.process subprocess.Popen( cmd_args, shellFalse, cwdDCM4CHE_BIN_PATH, stdoutsubprocess.PIPE, stderrsubprocess.PIPE, encodingutf-8, errorsreplace, creationflagssubprocess.CREATE_NO_WINDOW ) self.running True self.btn_start.config(statetk.DISABLED) self.btn_stop.config(statetk.NORMAL) threading.Thread(targetself.read_output, daemonTrue).start() except Exception as e: self.global_log(f❌ 启动失败{str(e)}) def read_output(self): 非阻塞读取stdout/stderr while self.running and self.process and self.process.poll() is None: try: line self.process.stdout.readline() if line: self.global_log(line.rstrip()) except Exception: continue # 进程退出读取剩余错误 if self.process: err_data self.process.stderr.read() if err_data.strip(): self.global_log(f⚠️ 进程错误输出:\n{err_data}) self.global_log( Storescp 进程已退出) self.running False self.root.after(0, lambda: ( self.btn_start.config(statetk.NORMAL), self.btn_stop.config(statetk.DISABLED) )) def stop_service(self): if self.process and self.running: self.global_log(⏹ 正在停止服务...) try: self.process.terminate() except Exception as e: self.global_log(fterminate异常{e}) # 等待短暂时间强制kill兜底 try: self.process.wait(timeout2) except subprocess.TimeoutExpired: self.process.kill() port self.port_var.get().strip() self.kill_port_process(port) self.running False self.btn_start.config(statetk.NORMAL) self.btn_stop.config(statetk.DISABLED) if __name__ __main__: window tk.Tk() app StorescpGUI(window) window.mainloop()这样一个强大的多线程纯接收dcm文件永久存储服务器就搭好了架构部分已经是目前最完美了。其他的就看你的硬件强不强了。至于在线阅片、三维服务器可以使用orthancpgsql或其他便捷的pacs服务器。我计划将一台orthancpgsql服务器作为热图阅片服务器只存最近一年的热数据超过一年的自动删除不占用服务器空间如果调阅历史影像则由dcm4che永久储存服务器共享目录中去按规则拉取对应患者文件即可也做定时清理保持阅片性能。这些都当缓存服务器使用。灵活的使用storescu转发功能完全不再依靠单台pacs服务器的数据库性能规划好热服务器历史服务器单台多台集群即可只使用他们的在线阅片三维重建dicom-web服务等功能即用即取。一个数据库表存患者基本信息检查号检查日期缓存服务器IP和端口开发程序查询患者根据检查号日期匹配dcm存储规则找到物理路径判断是否已缓存到阅片服务器已缓存则直接跳转到阅片地址未缓存则读取文件推送到集群里的某个阅片服务器中标识为已缓存记录服务器IP端口方便下次直接阅片。这种模式上亿患者影像存储调阅也不成问题。