如何用FoDICOM构建DICOM CStoreSCU 工具,批量发送DICOM文件

DICOM 网关(DICOM Gateway)是一种专门用于在不同医疗信息系统之间转换、路由、适配或处理 DICOM数据流的中间件系统。 它通常部署在网络边界或系统集成点,起到“协议转换器”、“数据路由器”和“数据预处理器”的作用。 一、DICOM 网关是什么? 从概念上讲,DICOM 网关是传统网络“网关”在医学影像领域的具体应用: 连接不同协议/格式系统:例如将传统的 DICOM C-STORE(基于 TCP 的 DIMSE 协议)转换为现代的 DICOMweb(基于 HTTP/REST 的 WADO/QIDO/STOW)。 实现跨系统互操作性:如连接老旧 PACS 与云原生影像平台(如 Azure Health Data Services、Google Healthcare API)。 执行数据预处理:如去标识化(de-identification)、元数据标准化、图像压缩、格式转换等。 二、DICOM 网关需要实现哪些核心功能? 一个完整的 DICOM 网关通常需支持以下功能模块: DICOM DIMSE 支持(传统协议) 接收/发送 C-ECHO、C-STORE、C-FIND、C-MOVE 等服务类用户(SCU/SCP)请求。 支持 AETitle(应用实体标题)认证与过滤。 处理 DICOM 关联(Association)建立与释放。 DICOMweb 支持(现代 Web API) 实现 STOW-RS(上传)、WADO-RS(下载)、QIDO-RS(查询)。 支持 multipart/related 或 single-part 上传。 兼容 FHIR ImagingStudy 等扩展标准(可选)。 数据路由与转发 根据规则(如 Modality、StudyInstanceUID、AETitle)将 DICOM 实例转发到一个或多个目标(PACS、云服务、AI引擎等)。 支持失败重试、队列管理、流量控制。 数据处理与转换 去标识化(De-identification):移除或替换患者隐私信息(如 PatientName, PatientID),符合 HIPAA 或 GDPR。 元数据标准化:统一不同设备厂商的标签写法(如将 Siemens 的私有标签映射为标准标签)。 图像格式转换:如将 JPEG2000 转为 JPEG-LS,或将多帧转为单帧序列。 日志、监控与审计 记录所有接收/转发事件。 提供健康检查接口(如 /healthz)。 支持 Prometheus/Grafana 监控(现代网关常见)。 安全机制 TLS 加密(DICOM over TLS、HTTPS)。 身份认证(API Key、OAuth2、JWT)。 IP 白名单/AETitle 白名单。 三、有哪些开源的 DICOM 网关项目可供参考? ...

November 20, 2025

DICOM 医疗影像系统的 Rust 实践:自定义类型安全封装|构建可扩展的云DICOM-WEB服务

引言 在 DICOM 医疗影像系统中,数据的准确性和一致性至关重要。本文将深入探讨如何使用 Rust 的类型系统来创建自定义的安全类型封装,以防止运行时错误并提高代码的可维护性。 BoundedString:长度限制字符串 BoundedString<N> 是一个泛型结构体,用于确保字符串长度不超过指定的 N 个字符。 FixedLengthString:固定长度字符串 FixedLengthString<N> 确保字符串长度恰好为 N 个字符,这在处理某些 DICOM 字段时非常有用。 DicomDateString:DICOM 日期格式 DICOM 标准要求日期字段使用 YYYYMMDD 格式,DicomDateString 类型专门用于处理这种格式。 类型系统的优势 1. 编译时保证 通过这些自定义类型,许多数据验证错误可以在编译时就被发现,而不是在运行时才暴露。 2. 明确的意图表达 类型名称本身就表达了数据的约束条件,使代码更具可读性和自文档化。 3. 防止错误传递 一旦数据通过类型验证,后续代码可以假设数据格式是正确的,无需重复验证。 具体实现: 1.dicom_dbtype.rs use serde::{Deserialize, Serialize}; use snafu::Snafu; use std::fmt; use std::hash::Hash; #[derive(Debug, Snafu)] #[non_exhaustive] pub enum BoundedStringError { #[snafu(display("String too long: {} > {}", len, max))] TooLong { max: usize, len: usize }, #[snafu(display("String length is: {} and expected: {}", len, fixlen))] LengthError { fixlen: usize, len: usize }, } type BoundedResult<T, E = BoundedStringError> = Result<T, E>; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(transparent)] #[derive(Default)] pub struct BoundedString<const N: usize> { value: String, } impl<const N: usize> fmt::Display for BoundedString<N> { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", self.value) } } impl<const N: usize> BoundedString<N> { pub fn new(s: String) -> BoundedResult<BoundedString<N>> { if s.len() > N { Err(BoundedStringError::TooLong { max: N, len: s.len(), }) } else { Ok(Self { value: s }) } } pub fn from_str(s: &str) -> BoundedResult<BoundedString<N>> { if s.len() > N { Err(BoundedStringError::TooLong { max: N, len: s.len(), }) } else { Ok(Self { value: s.to_string(), }) } } pub fn from_string(s: &String) -> BoundedResult<BoundedString<N>> { if s.len() > N { Err(BoundedStringError::TooLong { max: N, len: s.len(), }) } else { Ok(Self { value: s.clone() }) } } pub fn as_str(&self) -> &str { &self.value } // 使用 deref 方式访问 pub fn as_ref(&self) -> &String { &self.value } } impl<const N: usize> Hash for BoundedString<N> { fn hash<H: std::hash::Hasher>(&self, state: &mut H) { self.value.hash(state); } } impl<const N: usize> PartialEq for BoundedString<N> { fn eq(&self, other: &Self) -> bool { self.value == other.value } } impl<const N: usize> Eq for BoundedString<N> {} impl<const N: usize> TryFrom<&str> for BoundedString<N> { type Error = BoundedStringError; fn try_from(s: &str) -> BoundedResult<Self> { BoundedString::from_str(s) } } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(transparent)] pub struct FixedLengthString<const N: usize> { pub(crate) value: String, } impl<const N: usize> Hash for FixedLengthString<N> { fn hash<H: std::hash::Hasher>(&self, state: &mut H) { self.value.hash(state); } } impl<const N: usize> PartialEq for FixedLengthString<N> { fn eq(&self, other: &Self) -> bool { self.value == other.value } } impl<const N: usize> Eq for FixedLengthString<N> {} /// DICOM文件中的表示日期的字符串,格式为 YYYYMMDD, 长度为 8, 例如 "20231005" #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] #[serde(transparent)] pub struct DicomDateString { pub(crate) value: String, } // 为 DicomDateString 实现 Display trait impl fmt::Display for DicomDateString { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", self.value) } } impl TryFrom<&str> for DicomDateString { type Error = BoundedStringError; fn try_from(s: &str) -> BoundedResult<Self> { // 使用 NaiveDate 验证日期格式和有效性 chrono::NaiveDate::parse_from_str(&s, "%Y%m%d").map_err(|_| { BoundedStringError::LengthError { fixlen: 8, len: s.len(), } })?; Ok(Self { value: s.to_string(), }) } } impl TryFrom<&String> for DicomDateString { type Error = BoundedStringError; fn try_from(s: &String) -> BoundedResult<Self> { // 使用 NaiveDate 验证日期格式和有效性 chrono::NaiveDate::parse_from_str(&s, "%Y%m%d").map_err(|_| { BoundedStringError::LengthError { fixlen: 8, len: s.len(), } })?; Ok(Self { value: s.clone() }) } } impl<const N: usize> FixedLengthString<N> { pub fn new(s: String) -> BoundedResult<FixedLengthString<N>> { if s.len() != N { Err(BoundedStringError::LengthError { fixlen: N, len: s.len(), }) } else { Ok(Self { value: s }) } } pub fn from_str(s: &str) -> BoundedResult<FixedLengthString<N>> { if s.len() != N { Err(BoundedStringError::LengthError { fixlen: N, len: s.len(), }) } else { Ok(Self { value: s.to_string(), }) } } pub fn from_string(s: &String) -> BoundedResult<FixedLengthString<N>> { if s.len() != N { Err(BoundedStringError::LengthError { fixlen: N, len: s.len(), }) } else { Ok(Self { value: s.clone() }) } } pub fn as_str(&self) -> &str { &self.value } } impl DicomDateString { pub fn as_str(&self) -> &str { self.value.as_str() } pub(crate) fn from_db(s: &str) -> Self { // 使用 NaiveDate 验证日期格式和有效性 chrono::NaiveDate::parse_from_str(&s, "%Y%m%d") .map_err(|_| BoundedStringError::LengthError { fixlen: 8, len: s.len(), }) .expect( format!( "DicomDateString::make_from_db only support YYYYMMDD format, but got {}", s ) .as_str(), ); Self { value: s.to_string(), } } } dicom_meta.rs ...

November 20, 2025

DICOM 医疗影像系统的 Rust 实践:类型安全的数据库设计|构建可扩展的云DICOM-WEB服务

本项目展示了如何在 Rust 中构建一个健壮、类型安全的数据库访问层,特别适用于需要严格数据验证的医疗领域应用。通过抽象接口和具体实现分离的设计,系统可以轻松扩展支持更多类型的数据库,同时保证了代码的可维护性和可测试性。 核心设计概念 类型安全的数据结构 项目中定义了几个关键的类型安全封装: BoundedString: 限制长度的字符串类型 FixedLengthString: 固定长度的字符串类型 DicomDateString: 专门用于 DICOM 日期格式的字符串类型 // 示例:BoundedString 的定义 #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(transparent)] pub struct BoundedString<const N: usize> { value: String, } 统一的数据库访问接口 通过 DbProvider trait 抽象了数据库操作接口: #[async_trait] pub trait DbProvider: Send + Sync { async fn save_state_info(&self, state_meta: &DicomStateMeta) -> Result<(), DbError>; async fn save_state_list(&self, state_meta: &[DicomStateMeta]) -> Result<(), DbError>; async fn save_json_list(&self, state_meta: &[DicomJsonMeta]) -> Result<(), DbError>; // ... 其他方法 } 数据库类型适配 MySQL MySqlDbProvider 结构体实现了针对 MySQL 数据库的操作: ...

November 20, 2025

构建可扩展的云DICOM-WEB服务的前期准备工作

本文介绍了一个基于 Rust 语言开发的 DICOM 医疗影像系统架构设计,该系统采用现代化的技术栈,包括 PostgreSQL 作为主索引数据库、Apache Doris 用于日志存储、RedPanda 作为消息队列以及 Redis 作为缓存。系统设计支持单机运行和分布式扩展,充分利用了 Rust 语言的安全性和性能优势。 DICOM 医疗影像系统架构概览及运行环境介绍 核心组件 PostgreSQL: 主索引数据库,存储患者、检查、序列等核心元数据 Apache Doris: 日志存储,记录 DICOM CStoreSCP 服务和 WADO-RS 服务访问日志 RedPanda: 消息队列,处理系统间异步通信 Redis: 缓存层,提升系统响应速度 Rust: 开发语言,利用 dicom-rs 库处理 DICOM 数据 服务模块 wado-storescp: DICOM CStoreSCP 服务,接收 DICOM 文件并写入磁盘 wado-consumer: 消费消息队列中的存储事件,提取元数据并写入数据库 wado-server: DICOM WEB WADO-RS API 接口实现 wado-webworker: 定期生成 JSON 格式的元数据用于加速访问 数据库设计 PostgreSQL 主索引数据库 PostgreSQL 作为主索引数据库存储核心元数据,包括: dicom_state_meta: 存储患者、检查、序列级别的元数据 dicom_json_meta: 记录需要生成 JSON 格式元数据的序列信息 Apache Doris 日志存储 ...

November 19, 2025

如何用FoDICOM构建DICOM CStoreSCU 工具,批量发送DICOM文件

1-Step How To Use FoDICOM To Construct DICOM CStoreSCU Tool, Batch Send DICOM Files For Testing And Verification FoDICOM is a powerful open-source DICOM library written in C#. It provides a wide range of features for working with DICOM data, including support for reading, writing, and manipulating DICOM files. In this tutorial, we will use FoDICOM to construct a DICOM CStoreSCU tool, which can be used to batch send DICOM files for testing and verification purposes. ...

November 19, 2025

DOTNET CORE使用NLOG

在 .NET 项目中使用 NLog 记录日志的好处 NLog 是一个功能强大、灵活且高性能的日志记录框架,广泛应用于 .NET(包括 .NET Framework、.NET Core 和 .NET 5/6/7/8+)应用程序中。在 .NET 项目中集成 NLog 可显著提升系统的可观测性、可维护性和调试效率。以下是使用 NLog 的主要优势说明。 一、核心优势 1. 高性能与低开销 NLog 经过高度优化,采用异步写入、缓冲和批处理机制,对应用程序性能影响极小。 支持异步日志(async="true"),避免 I/O 阻塞主线程。 2. 灵活的配置方式 支持通过 XML 配置文件(如 nlog.config)进行外部化配置,无需重新编译代码即可调整日志行为。 也支持 代码方式配置,适用于动态场景或云原生环境。 3. 丰富的输出目标(Targets) NLog 支持将日志同时写入多种目标,包括: 文件(按日期/大小自动滚动) 控制台 数据库(SQL Server、PostgreSQL 等) Windows 事件日志 Elasticsearch、Graylog、Seq 等日志聚合平台 电子邮件、HTTP 端点、消息队列等 4. 强大的日志过滤与路由 可基于日志级别(Trace、Debug、Info、Warn、Error、Fatal)、日志来源(Logger 名称)、自定义条件进行精细路由。 支持“规则链”(Rules),实现复杂的日志分发逻辑。 5. 结构化日志支持 原生支持结构化日志(Structured Logging),便于与现代日志分析工具(如 Serilog + Seq、ELK)集成。 使用 ${message}、${event-properties:item=...} 等布局渲染器提取结构化字段。 6. 自动日志文件管理 内置日志文件归档策略:按时间(每日/每小时)、按大小(如 10MB)自动分割。 可配置最大保留文件数或总大小,防止磁盘爆满。 7. 跨平台兼容性 完全支持 .NET Standard,可在 Windows、Linux、macOS 上运行。 适用于 ASP.NET Core、WPF、WinForms、控制台应用、Azure Functions、MAUI 等各类 .NET 项目。 8. 活跃的社区与持续维护 开源(MIT 许可)、文档完善、更新频繁,拥有庞大的用户社区和丰富的插件生态。 二、典型应用场景 场景 NLog 优势体现 生产环境监控 异步写入 + 文件滚动 + 错误日志高亮,保障系统稳定 微服务调试 结构化日志 + TraceID 关联,便于分布式追踪 合规审计 日志内容加密、防篡改配置、长期归档支持 开发阶段快速定位问题 控制台实时输出 + 详细 Debug 信息 三、简单示例 1. 安装 NuGet 包 dotnet add package NLog dotnet add package NLog.Web.AspNetCore # ASP.NET Core 项目推荐 2. 创建配置文件 nlog.config <?xml version="1.0" encoding="utf-8"?> <nlog xmlns="http://www.nlog-project.org/schemas/NLog.xsd" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" autoReload="true" internalLogLevel="Info" internalLogFile=".\internal-nlog.txt"> <!-- 定义日志输出的 target --> <targets> <!-- 输出到文件,按大小滚动 --> <target name="logfile" xsi:type="File" fileName="logs/logfile.log" layout="${longdate}|${level:uppercase=true}|${logger}|${message} ${exception:format=tostring}" maxArchiveFiles="10" archiveAboveSize="5242880" archiveFileName="logs/archived/logfile.{#}.log" archiveNumbering="Sequence" keepFileOpen="false" concurrentWrites="true" enableFileDelete="true" /> <!-- 输出到文件,按大小滚动 --> <target name="errorFile" xsi:type="File" fileName="logs/error.log" layout="${longdate}|${level:uppercase=true}|${logger}|${message} ${exception:format=tostring}" maxArchiveFiles="10" archiveAboveSize="5242880" archiveFileName="logs/archived/logfile.{#}.log" archiveNumbering="Sequence" keepFileOpen="false" concurrentWrites="true" enableFileDelete="true" /> <target name="console" xsi:type="ColoredConsole" layout="${longdate}|${level:uppercase=true}|${logger}|${message} ${exception:format=tostring}"> <!-- 为不同日志级别配置不同颜色 --> <highlight-row condition="level == LogLevel.Debug" foregroundColor="DarkGray" /> <highlight-row condition="level == LogLevel.Info" foregroundColor="Gray" /> <highlight-row condition="level == LogLevel.Warn" foregroundColor="Yellow" /> <highlight-row condition="level == LogLevel.Error" foregroundColor="Red" /> <highlight-row condition="level == LogLevel.Fatal" foregroundColor="Red" backgroundColor="White" /> </target> </targets> <rules> <!-- 规则:所有日志都输出到 logfile --> <logger name="*" minlevel="Info" writeTo="logfile" /> <logger name="*" minlevel="Debug" writeTo="console" /> <logger name="*" minlevel="Error" writeTo="errorFile" /> </rules> </nlog> 3. 使用NLog using FellowOakDicom; using Microsoft.Extensions.Configuration; using NLog; namespace change_ts; internal class Program { // 获取当前类日志记录器 private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); static async Task Main() { // 在 DicomSetupBuilder 之前添加 System.Text.Encoding.RegisterProvider(System.Text.CodePagesEncodingProvider.Instance); // 创建 CancellationTokenSource 来处理 Ctrl+C 信号 var cancellationTokenSource = new CancellationTokenSource(); Console.CancelKeyPress += (_, e) => { e.Cancel = true; // 阻止程序立即退出 Logger.Info("收到 Ctrl+C 信号,正在优雅关闭..."); cancellationTokenSource.Cancel(); }; try { new DicomSetupBuilder() .RegisterServices(s => s.AddFellowOakDicom() .AddTranscoderManager<FellowOakDicom.Imaging.NativeCodec.NativeTranscoderManager>()) .SkipValidation() .Build(); // 确保日志目录存在 var logDir = Path.Combine(AppContext.BaseDirectory, "logs"); var archivedDir = Path.Combine(logDir, "archived"); if (!Directory.Exists(logDir)) Directory.CreateDirectory(logDir); if (!Directory.Exists(archivedDir)) Directory.CreateDirectory(archivedDir); Logger.Info("🚀 应用程序启动,NLog 日志配置成功!"); // 创建配置构建器来读取 appsettings.json var configuration = new ConfigurationBuilder() .SetBasePath(AppContext.BaseDirectory) .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true) .Build(); Console.WriteLine("XXXX启动中..."); Logger.Info("XXXX已启动,服务正在运行中... 按 Ctrl+C 退出"); // 等待取消信号 await Task.Delay(Timeout.Infinite, cancellationTokenSource.Token); } catch (OperationCanceledException) { // 正常的取消操作,不需要记录错误 Logger.Info("应用程序正在关闭..."); } catch (Exception ex) { // 记录内部异常到 NLog(如果配置了 internalLogFile 也会记录 NLog 自己的错误) Logger.Error(ex, "程序因异常停止"); throw; } finally { // 确保所有日志都写入磁盘并关闭资源 LogManager.Shutdown(); } } } 运行程序时,NLog 会自动创建日志目录和文件,并记录应用程序的启动和运行信息。当程序接收到 Ctrl+C 信号时,NLog 会记录应用程序正在关闭的信息。如果程序因为异常而停止,NLog 会记录异常信息。

November 16, 2025

如何利用Fo-DICOM库修改DICOM文件的传输语法或是编码格式

修改 DICOM 文件传输语法的目的及好处 在医学影像处理与交换过程中,传输语法(Transfer Syntax) 是 DICOM 标准中用于定义数据编码方式的关键组成部分。它决定了 DICOM 文件中数据元素的字节序、值表示(VR)是否显式声明,以及像素数据是否经过压缩。因此,修改 DICOM 文件的传输语法是一种常见且重要的操作,具有明确的目的和显著的好处。 一、目的 1. 兼容性适配 不同厂商的 PACS(Picture Archiving and Communication System)、工作站或 DICOM 查看器可能仅支持特定的传输语法。将文件转换为接收方支持的语法,可确保顺利导入与显示。 2. 存储优化 通过将图像从无压缩格式(如 Implicit VR Little Endian)转换为压缩格式(如 JPEG Lossless 或 JPEG-LS),可大幅减小文件体积,节省存储空间与网络带宽。 3. 性能提升 某些系统对显式 VR(Explicit VR)格式解析更快;而另一些系统则偏好隐式 VR(Implicit VR)。调整传输语法可优化读取/写入性能。 4. 满足归档或传输标准 医院或区域医疗信息平台常规定统一的传输语法(如强制使用 Explicit VR Little Endian),以确保数据一致性与长期可读性。 二、好处 好处类别 说明 ✅ 提高互操作性 确保 DICOM 文件能在不同厂商设备间无缝交换,避免“无法识别”错误。 ✅ 节省存储成本 使用无损或有损压缩语法(如 JPEG 2000)可减少 50%~90% 的文件大小。 ✅ 加速网络传输 小体积文件在网络上传输更快,尤其适用于远程会诊或云 PACS 场景。 ✅ 增强系统稳定性 避免因不支持的传输语法导致解析失败、崩溃或数据损坏。 ✅ 符合合规要求 满足 HL7、IHE 或国家/地区医疗信息化规范中对数据格式的强制要求。 三、常见传输语法转换示例 原始语法 目标语法 应用场景 Implicit VR Little Endian (1.2.840.10008.1.2) Explicit VR Little Endian (1.2.840.10008.1.2.1) 提高可读性,便于调试与第三方工具解析 Explicit VR Little Endian JPEG Lossless (1.2.840.10008.1.2.4.70) 医院归档,节省存储空间 JPEG Lossy (1.2.840.10008.1.2.4.51) Implicit VR Little Endian 解压缩以便进行定量分析(如 CT 值测量) ⚠️ 注意:有损压缩(如 JPEG Baseline)会丢失原始像素信息,不适用于诊断用途;无损压缩(如 JPEG-LS、JPEG 2000 Lossless)则可在压缩的同时保留全部诊断信息。 ...

November 15, 2025

DICOM文件中核心Infomation 结构及说明

DICOM基础概念 DICOM(Digital Imaging and Communications in Medicine)是医学影像及其相关信息的国际标准(ISO 12052),用于存储、交换和传输医学图像及相关数据。DICOM 文件不仅包含像素数据(即图像本身),还包含大量描述该图像的元数据,这些元数据以“数据元素”(Data Elements)的形式组织在“信息对象定义”(Information Object Definition, IOD)中。 ✅ 一、DICOM 文件的核心结构 文件头(File Meta Information) 固定长度为 128 字节的前导(通常全为 0x00,可选) 4 字节的 DICOM 前缀 “DICM” 文件元信息组(Group 0002),包含: (0002,0000) File Meta Information Group Length (0002,0001) File Meta Information Version (0002,0002) Media Storage SOP Class UID (0002,0003) Media Storage SOP Instance UID (0002,0010) Transfer Syntax UID (0002,0012) Implementation Class UID (0002,0013) Implementation Version Name 数据集(Dataset) 包含实际的图像信息和元数据,遵循特定的 IOD(如 CT Image IOD、MR Image IOD 等) 数据元素按标签(Tag)组织,格式为 (gggg,eeee),其中 gggg 是组号,eeee 是元素号 每个数据元素包括:Tag、VR(Value Representation)、Value Length、Value ✅ 二、核心信息对象(IOD)结构说明 ...

November 14, 2025