拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

C#与JavaScript构建轻量级PACS:DICOM影像接收、存储与Web调阅实战

C#与JavaScript构建轻量级PACS:DICOM影像接收、存储与Web调阅实战 简介这是一份面向中小型医疗机构信息化建设者、医学影像方向开发者与C#学习者的轻量级PACS系统设计源码核心解决医学影像存储、传输与DICOM标准通信的落地问题适合具备一定前后端基础、希望二次开发或搭建影像管理平台的读者。压缩包共约2000个文件整体96.89MB以1809个SVG矢量图形、85个JavaScript动态交互脚本、34个CSS样式表为主另含22个Map映射文件、19个PNG位图、16个TXT说明及少量HTML、JSON、XML与editorconfig配置前端资源与后端C#框架分工明确。目前已有156人学习关注。源码按Services、Properties、Configuration、Controllers等目录组织模块化程度高便于理解系统架构与扩展维护配套的appsettings.json、readme.txt等文件可辅助快速搭建与配置是研究DICOM工具箱与轻量级PACS实现路径的实用参考。1. 从一台旧工作站说起为什么我要用 C# 加 JavaScript 搭一套轻量级 PACS科室里那台 2016 年的工作站还在跑内存 8G硬盘是机械盘但放射科每天几百张 CR、DR 影像都得从它上面过。商业 PACS 报价单我看过光并发授权就够买三台新服务器而且部署周期动辄两个月。真正让我下决心自己动手的是发现这套系统的核心需求其实很朴素DICOM 影像的接收、存储、调阅、归档加上一个能用的 Web 端阅片界面。C# 负责后端服务、DICOM 解析和文件管理JavaScript 负责浏览器端的影像渲染和交互这个组合在中文开源社区里能找到的完整方案并不多大多数要么是纯 Java 的 dcm4che 套壳要么是 Python 脚本拼凑的 demo真正能跑在 Windows 服务里、带 Web 访问、代码结构清晰的轻量级 PACS 源码确实稀缺。这篇文章面向的是手里有影像设备、想自己搭一套院内 PACS 的工程师或者正在做医疗信息化课程设计、需要一套能讲清楚架构的源码参考的开发者。我会把 C# 后端怎么接 DICOM、JavaScript 前端怎么渲染影像、两边怎么通过接口对齐、部署时哪些参数必须调按我实际踩过的顺序讲一遍。不保证你能直接复制粘贴上线但保证你照着走能跑通一个最小可用的影像调阅流程。2. C# 后端DICOM 接收、解析与存储的落地路径2.1 为什么选 fo-dicom 而不是自己解析 DICOM 字节流DICOM 协议本身不复杂但它的数据字典、传输语法、VR 类型、私有标签这些细节自己从头写解析器至少两周起步而且遇到压缩传输语法JPEG Lossless、JPEG 2000还得额外接编解码库。fo-dicom 是 .NET 生态里最成熟的开源 DICOM 库支持 DICOM 文件读写、网络传输C-STORE、C-FIND、C-MOVE、以及常见的压缩格式。我一般会用它做三件事监听 C-STORE 请求接收影像、解析 DICOM 标签提取患者信息和检查信息、把影像文件按规范路径落盘。安装方式用 NuGetdotnet add package fo-dicom dotnet add package fo-dicom.Imaging.Desktop第一个包是核心库第二个是桌面端影像渲染支持如果你只需要后端解析和存储第二个可以不装。版本上我习惯锁在 5.x4.x 和 5.x 的 API 差异不小网上很多示例代码是 4.x 的直接抄会报错。2.2 用 C-STORE SCP 接收影像最小服务端代码PACS 的核心入口是接收设备推送过来的影像。放射科设备通常配置一个 C-STORE SCP 的 AE Title、IP 和端口影像拍完自动推过来。下面是一个最小可用的接收服务using Dicom; using Dicom.Network; using System; using System.IO; class PacsStoreSCP : DicomService, IDicomServiceProvider, IDicomCStoreProvider { private static readonly string StoragePath D:\PACS\Storage; public PacsStoreSCP(INetworkStream stream, DicomEncoding encoding, ILogger log) : base(stream, encoding, log) { } public void OnReceiveAssociationRequest(DicomAssociation association) { foreach (var pc in association.PresentationContexts) { // 接受所有未压缩和常见压缩传输语法 pc.SetResult(DicomPresentationContextResult.Accept); } SendAssociationAccept(association); } public DicomCStoreResponse OnCStoreRequest(DicomCStoreRequest request) { var dataset request.Dataset; var sopInstanceUid dataset.GetString(DicomTag.SOPInstanceUID); var patientId dataset.GetString(DicomTag.PatientID); var studyDate dataset.GetString(DicomTag.StudyDate); // 按 患者ID/检查日期/ 的层级存储避免单目录文件过多 var dir Path.Combine(StoragePath, patientId, studyDate); Directory.CreateDirectory(dir); var filePath Path.Combine(dir, sopInstanceUid .dcm); var dicomFile new DicomFile(dataset); dicomFile.Save(filePath); Console.WriteLine($Received: {filePath}); return DicomCStoreResponse.Create(request, DicomStatus.Success); } public void OnReceiveAssociationReleaseRequest() { SendAssociationReleaseResponse(); } public void OnReceiveAbort(DicomAbortSource source, DicomAbortReason reason) { } public void OnConnectionClosed(Exception exception) { } }这段代码的关键点在OnCStoreRequest里。request.Dataset就是设备推过来的完整 DICOM 数据集你可以从中取任意标签。存储路径我按患者ID/检查日期/两级目录分原因是 Windows 单目录文件数超过几万后文件系统检索会明显变慢血泪经验。SOPInstanceUID作为文件名保证唯一性不要用患者姓名或检查号中文和特殊字符会出问题。启动服务的入口var server new DicomServerPacsStoreSCP(12345); Console.WriteLine(PACS C-STORE SCP started on port 12345); Console.ReadLine();端口 12345 是 DICOM 常用端口实际部署时确认防火墙放行。AE Title 的配置在DicomService层面可以通过DicomServer的构造参数指定默认是STORESCP设备端要填一致。2.3 从 DICOM 标签提取检查信息并写入数据库影像文件落盘后Web 端要按患者、检查、序列的层级展示就需要一个索引数据库。我一般用 SQLite 做轻量级存储够用且零配置。提取标签的代码public void IndexDicomFile(string filePath) { var dicomFile DicomFile.Open(filePath); var ds dicomFile.Dataset; var record new StudyRecord { PatientId ds.GetString(DicomTag.PatientID), PatientName ds.GetString(DicomTag.PatientName), StudyInstanceUid ds.GetString(DicomTag.StudyInstanceUID), StudyDate ds.GetString(DicomTag.StudyDate), StudyDescription ds.GetString(DicomTag.StudyDescription), Modality ds.GetString(DicomTag.Modality), SeriesInstanceUid ds.GetString(DicomTag.SeriesInstanceUID), SopInstanceUid ds.GetString(DicomTag.SOPInstanceUID), FilePath filePath }; // 用 INSERT OR IGNORE 避免重复索引 using var conn new SqliteConnection(Data Sourcepacs.db); conn.Open(); var cmd conn.CreateCommand(); cmd.CommandText INSERT OR IGNORE INTO Studies (PatientId, PatientName, StudyInstanceUid, StudyDate, StudyDescription, Modality, SeriesInstanceUid, SopInstanceUid, FilePath) VALUES (p1, p2, p3, p4, p5, p6, p7, p8, p9); // 参数绑定省略按顺序赋值即可 cmd.ExecuteNonQuery(); }GetString在标签不存在时会抛异常生产环境建议用TryGetString或先Contains判断。StudyInstanceUID是检查级别的唯一标识Web 端调阅时按这个字段聚合序列。3. JavaScript 前端Web 端影像调阅的渲染与交互3.1 为什么不用现成的 Cornerstone.js 直接套Cornerstone.js 是 Web 端 DICOM 渲染的事实标准功能全但它的体积和抽象层对于轻量级 PACS 来说偏重。我试过直接引入 cornerstone 全家桶打包后 2MB 起步而且它的 image loader 体系需要你实现一个符合它规范的 DICOM 解析器反而增加了后端接口的复杂度。后来我改成自己用 Canvas 做基础渲染配合后端返回的像素数据代码量可控调试也直观。核心思路是后端把 DICOM 的像素数据提取出来转成浏览器能处理的格式比如 16 位灰度数组前端用 Canvas 的ImageData渲染再叠加窗宽窗位调节、缩放、平移这些交互。3.2 后端像素数据接口把 DICOM 转成前端能吃的 JSONfo-dicom 提供了DicomImage类来渲染像素数据但 Web 端不需要它渲染成位图只需要原始像素值。下面是一个返回像素数组的接口[HttpGet(api/image/{sopInstanceUid}/pixels)] public IActionResult GetPixels(string sopInstanceUid) { var filePath _indexService.GetFilePath(sopInstanceUid); var dicomFile DicomFile.Open(filePath); var pixelData DicomPixelData.Create(dicomFile.Dataset); var width pixelData.Width; var height pixelData.Height; var bitsAllocated pixelData.BitsAllocated; // 只处理 16 位灰度彩色和压缩格式另行处理 var frame pixelData.GetFrame(0); var pixels new ushort[width * height]; Buffer.BlockCopy(frame.Data, 0, pixels, 0, frame.Data.Length); // 取窗宽窗位默认值 var windowCenter dicomFile.Dataset.GetValueOrDefault(DicomTag.WindowCenter, 0, 0.0); var windowWidth dicomFile.Dataset.GetValueOrDefault(DicomTag.WindowWidth, 0, 0.0); return Ok(new { width, height, bitsAllocated, windowCenter, windowWidth, pixels // 序列化为 JSON 数组 }); }这里有几个参数必须注意。BitsAllocated通常是 16但 CR 设备可能是 12 或 14 位前端渲染时要按实际位深做映射。WindowCenter和WindowWidth是 DICOM 标签里的默认窗值但不同部位肺、骨、软组织需要不同的窗前端要允许用户手动调。像素数组直接 JSON 序列化后体积很大一张 2048x2048 的 16 位影像约 8MB JSON实际部署时建议用二进制接口或压缩传输这里为了讲清楚流程先用 JSON。3.3 Canvas 渲染与窗宽窗位调节前端拿到像素数组后用 Canvas 渲染function renderImage(canvas, data) { const { width, height, pixels, windowCenter, windowWidth } data; const ctx canvas.getContext(2d); canvas.width width; canvas.height height; const imageData ctx.createImageData(width, height); const lower windowCenter - windowWidth / 2; const upper windowCenter windowWidth / 2; for (let i 0; i pixels.length; i) { let value pixels[i]; // 窗宽窗位映射到 0-255 let gray ((value - lower) / (upper - lower)) * 255; gray Math.max(0, Math.min(255, gray)); imageData.data[i * 4] gray; imageData.data[i * 4 1] gray; imageData.data[i * 4 2] gray; imageData.data[i * 4 3] 255; } ctx.putImageData(imageData, 0, 0); }窗宽窗位的映射公式是(像素值 - (窗位 - 窗宽/2)) / 窗宽 * 255这是 DICOM 标准里的线性映射。实际调窗时用户拖动鼠标改变windowCenter和windowWidth重新执行这段渲染逻辑即可。注意pixels数组如果是Uint16Array在 JSON 传输后会变成普通数组前端要确保数值范围正确。交互部分缩放用 Canvas 的scale变换平移用translate这些是 Canvas 基础操作不展开。关键是要把鼠标坐标转换成图像坐标再应用到变换矩阵上否则缩放中心会跑偏。4. 前后端联调接口对齐、性能与部署参数4.1 接口设计RESTful 还是 WebSocket影像调阅的接口分两类元数据查询患者列表、检查列表、序列列表用 RESTful 就够了数据量小缓存友好像素数据获取如果走 RESTful大影像会有明显延迟我一般用 WebSocket 或者分块传输。实际项目里元数据走 RESTful像素数据走一个单独的二进制接口前端用fetch拿ArrayBuffer再解析比 JSON 快很多。接口对齐时最容易出问题的是字段命名。C# 后端默认用 PascalCaseJavaScript 前端习惯 camelCase要么在序列化时统一转要么前端做映射。我一般用System.Text.Json的PropertyNamingPolicy JsonNamingPolicy.CamelCase统一转成 camelCase前端不用改。4.2 性能参数并发、缓存与影像预取轻量级 PACS 的性能瓶颈通常在两个地方一是 DICOM 文件解析二是像素数据传输。解析方面fo-dicom 打开一个文件大约 10-50ms如果一次调阅一个序列 200 张影像串行解析要 2-10 秒。我的做法是索引阶段就把关键标签提取好存数据库调阅时只读数据库像素数据按需加载。缓存方面用IMemoryCache缓存最近访问的影像像素数据设置滑动过期 10 分钟命中率能到 70% 以上。影像预取是个实用技巧当用户打开一个检查时前端先请求前 10 张影像的像素数据后台异步加载剩余序列。这样用户翻页时不会卡顿。预取的并发数控制在 3-5 个太多会打满后端 IO。4.3 部署清单Windows 服务、端口与存储规划部署到 Windows 服务器上C# 后端建议注册为 Windows 服务用sc create或NSSM都行。端口规划上DICOM SCP 用 12345Web API 用 5000前端静态文件用 Nginx 或 IIS 托管在 80。存储目录按患者ID/检查日期/分单目录文件数控制在 5000 以内。数据库用 SQLite 的话文件放在 SSD 上机械盘上并发写入会锁表。防火墙要放行 DICOM 端口和 Web 端口设备端的 AE Title、IP、端口三个参数必须和 SCP 配置完全一致大小写敏感。我遇到过设备端填了PACS但服务端默认是STORESCP结果关联被拒排查了半天。5. 避坑与排查那些让我加班到凌晨的坑5.1 坑一DICOM 文件打开报“传输语法不支持”现象是DicomFile.Open抛异常提示不支持的传输语法。原因通常是设备用了 JPEG 2000 或 JPEG Lossless 压缩而 fo-dicom 默认不加载对应的编解码器。解决方法是安装fo-dicom.Codecs包并在启动时注册new DicomSetupBuilder() .RegisterServices(s s.AddFellowOakDicom()) .Build();如果还是不行检查设备端的传输语法配置有些设备可以改成 Explicit VR Little Endian 未压缩格式兼容性最好。5.2 坑二中文患者姓名乱码现象是数据库里患者姓名显示为问号或乱码。原因是 DICOM 的SpecificCharacterSet标签指定了字符集fo-dicom 默认按 ISO-IR-6ASCII解析中文需要指定 GB18030 或 ISO-IR-192。解决方法是打开文件时传入字符集var dicomFile DicomFile.Open(filePath, DicomEncoding.GetEncoding(GB18030));或者在解析前先读SpecificCharacterSet标签动态选择编码。国内设备大多用 GB18030少数用 ISO-IR-192。5.3 坑三前端 Canvas 渲染出来全黑或全白现象是影像显示为纯黑或纯白调窗也没用。原因通常是窗宽窗位计算时用了错误的默认值或者像素数据的位深没对齐。排查步骤先确认后端返回的windowCenter和windowWidth是否合理肺窗约 1500/-600骨窗约 2000/300如果都是 0说明 DICOM 标签里没有默认窗值需要前端给一个经验值。再检查pixels数组的长度是否等于width * height如果不等说明像素数据解析有问题。5.4 坑四并发调阅时 SQLite 报“database is locked”现象是多个用户同时调阅影像时后端抛 SQLite 异常。原因是 SQLite 默认的写锁机制在并发写入时会阻塞。解决方法是开启 WAL 模式PRAGMA journal_modeWAL; PRAGMA busy_timeout5000;WAL 模式允许读写并发busy_timeout设置等待锁的超时时间。如果并发量再大建议换 PostgreSQL 或 SQL Server。5.5 坑五DICOM 端口被防火墙拦截但本地测试正常现象是本地用storescu工具测试能推影像但设备端推不过来。原因通常是 Windows 防火墙只放行了本地回环没有放行入站规则。解决方法是添加入站规则netsh advfirewall firewall add rule nameDICOM SCP dirin actionallow protocolTCP localport12345同时确认设备端的网络能通到服务器 IP有些科室的网络做了 VLAN 隔离需要网络组开通策略。6. 进阶技巧用 C# 的 Span 和 JavaScript 的 Web Worker 把调阅速度再提一档前面讲的方案跑通没问题但影像调阅的响应速度还有优化空间。我在实际项目里做了两个改动效果比较明显。第一个是 C# 后端用Spanbyte替代byte[]做像素数据的切片和转换。fo-dicom 的frame.Data返回的是byte[]但如果你要做位深转换比如 12 位转 16 位用Span可以避免额外的内存分配。下面是一个把 12 位像素数据扩展到 16 位的例子public ushort[] Convert12To16(byte[] rawData, int pixelCount) { var result new ushort[pixelCount]; var source new ReadOnlySpanbyte(rawData); var target new Spanushort(result); for (int i 0; i pixelCount; i) { // 12 位数据按小端序存储每两个字节包含一个像素 int byteIndex i * 2; ushort value (ushort)(source[byteIndex] | (source[byteIndex 1] 8)); target[i] (ushort)(value 0x0FFF); // 取低 12 位 } return result; }Span在这里的优势是零拷贝和栈上分配对于大影像的批量转换GC 压力会小很多。参数上注意pixelCount要等于width * heightrawData的长度要等于pixelCount * 2否则会越界。第二个是前端用 Web Worker 做像素数据的解析和窗宽窗位计算。Canvas 渲染在主线程做但像素数组的遍历和映射是纯计算放到 Worker 里可以避免阻塞 UI。代码结构// worker.js self.onmessage function(e) { const { pixels, windowCenter, windowWidth, width, height } e.data; const lower windowCenter - windowWidth / 2; const upper windowCenter windowWidth / 2; const result new Uint8ClampedArray(width * height * 4); for (let i 0; i pixels.length; i) { let gray ((pixels[i] - lower) / (upper - lower)) * 255; gray Math.max(0, Math.min(255, gray)); result[i * 4] gray; result[i * 4 1] gray; result[i * 4 2] gray; result[i * 4 3] 255; } self.postMessage({ imageData: result, width, height }, [result.buffer]); };主线程收到imageData后直接putImageData省去了主线程的计算时间。postMessage的第二个参数是 Transferable Objects把result.buffer转移给主线程避免拷贝。这个改动在低端工作站上效果最明显翻页从卡顿变成流畅。验证优化效果的方法很简单在 Chrome DevTools 的 Performance 面板录一段调阅操作看主线程的 Long Task 数量和时长。优化前通常有 3-5 个超过 200ms 的长任务优化后基本降到 50ms 以下。后端的话用dotnet-counters看 GC 的 Gen0 和 Gen1 回收频率Span改造后 Gen0 回收次数能降一半左右。这两个技巧不是必须的但如果你要在一台旧工作站上跑或者科室并发调阅人数超过 10 个值得花半天时间改。我自己是从一个“能跑就行”的版本开始后来被临床催着优化才一步步加上去的。希望帮到你。本文还有配套的精品资源点击获取
返回列表