ARTICLE DETAIL

资讯详情

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

C#与JavaScript打造开源轻量级PACS系统全解析

C#与JavaScript打造开源轻量级PACS系统全解析 简介这是一套基于C#与JavaScript构建的轻量级PACS系统设计源码面向需要医学影像存储、传输与管理方案的中小型医疗机构及医疗信息化开发者适用于医院影像科、医疗信息化项目等场景。系统遵循DICOM国际标准采用前后端分离模式核心逻辑以C#实现JavaScript负责动态交互HTML/CSS完成界面呈现整体架构兼顾扩展性与二次开发便利性同时提供了服务层、配置模块、控制器等清晰划分便于理解和维护。压缩包共2000个文件其中SVG矢量资源1809个另含JavaScript脚本、CSS样式、PNG图像、文本说明、JSON配置等包体约96.89MB目录按功能模块组织方便按需检索代码。目前已有156人浏览学习适合具备一定开发基础的读者研究PACS业务流程并参考其项目搭建思路可复用其中的DICOM工具箱集成、前端资源组织与目录结构设计等实践经验。1. C# 与 JavaScript 搭建的开源轻量级 PACS不只是影像排片工具做医疗信息化的同行应该都有体会一套正经的商业 PACS 授权费动辄几十万中小医院和体检中心往往只能看着。这个中文开源社区里流转的轻量级 PACS 系统源码用 C# 做核心、JavaScript 做前端交互把 DICOM 影像的接收、存储、调阅、管理一条链路压进了能跑在普通服务器上的体量。源码包有 2572 个文件其中 C# 文件 37 个JavaScript 文件 85 个SVG 图形文件占了两千多个——这套系统的“重”都压在图标资源上真正的代码骨架非常克制。它不是一个放在 GitHub 上吃灰的课程设计而是一个能应对真实影像数据流的小型 PACS支持 DICOM 标准通信具备上传、解析、存储、预览能力还配套了配置文件和模块化的 Services、Controllers 目录给二开留了余地。适合两类人一类是医院信息科或集成商想低成本给院区搭一套院内影像调阅系统另一类是接医学影像项目的开发者想找一个没有历史包袱的 DICOM 工具箱作为参照。下面我会从技术架构、目录设计、运行配置到避坑排查把这套源码拆开讲清楚。2. 轻量级 PACS 技术选型C# 后端、JavaScript 前端与文件结构的真实分工2.1 为什么是 C# JavaScript而不是全栈 Java 或 PythonPACS 系统的核心是 DICOM 协议通信而这套系统选 C# 做主力语言我认为有它的现实理由。DICOM 层负责解析影像元数据、处理像素数据、维护 modality worklist这些操作对类型安全和内存管理要求很高C# 的强类型特性在解析 DICOM TAG 时不容易出现隐式类型转换造成的数据错位。而且 .NET 生态里成熟的 DICOM 库比如 fo-dicom已经能覆盖大部分 C-ECHO、C-STORE、C-FIND 的协议实现37 个 C# 文件做成独立后端服务体量刚好够用。前端选择 JavaScript 则是因为这套系统定位是“轻量”——不要求装客户端打开浏览器就能调阅影像。85 个 JavaScript 文件承担了页面交互影像列表刷新、窗宽窗位调整、缩略图懒加载、序列切换这些都是典型的 DOM 操作场景用原生 JS 加少量框架代码比引入一套重前端框架更划算。源码包里大量 SVG 文件也是这个逻辑医学影像系统的按钮、图标、示意标记都可以做成矢量图不依赖图片服务器而且 2177 个 SVG 文件分布在 Bootstrap Icons 这一类图标库里二次开发时直接复用不用重新切图。2.2 2572 个文件不是废话SVG、JS、CSS 与散落配置的分布逻辑拿到源码包第一件事不是去找主程序而是先盘一遍文件结构。我习惯按类型把 2572 个文件分成三组看。第一组是前端静态资源2177 个 SVG、91 个 PNG、91 个 Map 文件、34 个 CSS 文件。SVG 是矢量图标Map 文件通常是 Leaflet 一类地图库的瓦片数据PNG 是位图素材CSS 控制布局。这一组加起来占了总文件数的九成以上但它们几乎不需要你改动。第二组是核心逻辑37 个 C# 文件、85 个 JavaScript 文件以及 34 个 CSS 文件。C# 文件里重点看 Services 和 Controllers 目录前者处理 DICOM 文件解析、存储路径管理、影像序列分组后者暴露 HTTP 接口给前端调用。第三组是工程化辅助文件.editorconfig 控制代码缩进与换行风格appsettings.json 存数据库连接和存储路径readme.txt 是项目说明。我碰到过同事拿到源码先删“没用”的 SVG 文件结果界面图标全部变空白排查半天才发现 Bootstrap Icons 是依赖文件路径引用的。结论是在这个源码里不要凭直觉清理文件。2.3 Services、Controllers、Properties 的职责边界谁在管影像存储谁在管协议应答打开源码目录Properties 是 ASP.NET Core 的项目属性配置Controllers 是 MVC 的入口层接收前端的 HTTP 请求Services 才是真正干活的层。以一个典型的影像上传流程为例前端把 DICOM 文件 POST 到某个 Controller 的 Action这个 Action 不直接写文件而是调用 Services 里的影像存储服务由后者校验 DICOM 格式、提取患者 ID 和检查号、分配存储子目录最后返回一个保存路径。Services 目录建议拆成 DICOM 解析服务、存储管理服务、序列组织服务三个模块。DICOM 解析服务负责读取文件头里的 PatientName、StudyInstanceUID、SeriesInstanceUID 等关键 TAG存储管理服务负责生成文件名——我一般在命名时加上时间戳防止不同检查的序列因为同名而互相覆盖序列组织服务负责把同一 Study 下的多个 Series 关联起来这样前端展示的时候才能以“检查—序列—影像”三层结构来呈现。Controller 里只做参数校验和结果包装不写业务逻辑这是我评估这套源码可维护性的第一标准。3. 把源码跑起来开发环境与首轮启动的配置要点3.1 环境准备.NET SDK 选择与前端资源的关系这套系统是 C# 核心 JavaScript 辅助运行环境需要安装 .NET SDK 和 Node.js编译前端静态资源时用。SDK 版本我建议选 .NET 6 或更新的 LTS 版本因为源码里的配置文件和项目文件大概率是按现代 ASP.NET Core 模板生成的老版本可能出现 NuGet 包还原失败。Node.js 的作用不一定是构建整套前端——很多情况下前端静态文件JS、CSS、SVG已经以源码形式躺在 wwwroot 里不需要经过打包。Node 只在需要修改 JavaScript 后重新合并压缩时才派得上用场。如果你只做部署不动前端Node 不是硬性依赖。3.2 打开项目后首先要改的三个配置参数端口、存储路径与序列化设置用 Visual Studio 或 Rider 打开解决方案文件先别急着按 F5。打开 appsettings.json这个文件的默认配置大概率不完全匹配你的机器。我一般按下面的结构做首次修改{ Kestrel: { Endpoints: { Http: { Url: http://0.0.0.0:9090 }, Https: { Url: https://0.0.0.0:9091 } } }, StorageSettings: { DicomStorePath: D:\\PacsData\\DicomStore, ThumbnailPath: D:\\PacsData\\Thumbnails }, ConnectionStrings: { PacsDatabase: Data SourceD:\\PacsData\\pacs.db } }参数说明port 绑定 0.0.0.0 是为了让局域网内的工作站能用 IP 访问默认的 localhost 只允许本机调阅。DicomStorePath 是原始 DICOM 文件落盘路径这个目录建议放到专门的存储盘不要放在系统盘因为影像文件增长快且体积大。PacsDatabase 建议用 SQLite 文件库起步中小规模影像量完全够用避免一开始就上 SQL Server 增加运维负担。需要提醒的是Kestrel 端口和 IIS Express 端口不是一回事。如果你用 IIS Express 启动默认端口由 launchSettings.json 决定appsettings.json 里这个端口配置不一定生效。我的做法是直接用命令行启动项目跳过 IIS Express 的端口转发减少一层凑合。3.3 首次启动的完整流程从 dotnet restore 到第一个 DICOM 文件上传启动项目的关键步骤按顺序执行一遍避免跳过某个前置环节导致运行时“翻车”。cd /path/to/PacsSourceCode dotnet restore dotnet build dotnet run --project ./YourPacs.Web.csproj逻辑说明restore 是还原 NuGet 包build 是编译整个解决方案run 指定 Web 项目启动。如果你的操作系统没有安装 SDK命令行会直接报“找不到 dotnet 命令”这不是项目问题是环境变量没配好。启动后浏览器访问http://localhost:9090不出意外能看到一个带有侧边栏菜单的影像管理系统界面。先不急着上传大文件第一件事是找一个体积很小的 DICOM 文件CT 单张通常几百 KB上传验证整条链路通不通。如果上传后影像能正常显示在预览区说明 C# 后端的解析服务和前端 JavaScript 的渲染逻辑都已经跑通再考虑接真正的影像设备。3.4 前端资源加载失败的判断方法F12 里的 404 与 Content-Type第一次打开页面如果样式错乱或图标不显示八成不是后端代码问题而是静态资源路径没对上。按 F12 打开浏览器开发者工具看 Network 面板里有没有 404 记录——常见得像bootstrap.min.css、app.bundle.css文件名被静态文件中间件拦截。这时候检查两处第一wwwroot 目录下是否有这些文件注意源码包里文件路径是全小写还是大小写混合Linux 部署时大小写敏感容易踩坑第二检查 Program.cs 或 Startup.cs 是否调用了app.UseStaticFiles()这是 ASP.NET Core 暴露静态资源的默认开关漏掉这行写得再好的 CSS 都是摆设。之后再去检查 Controllers如果路由写错了接口返回的是 404 HTML 页面而不是 JSON问题定位顺序就反了。4. DICOM 核心流程拆解从文件上传到 Web 影像预览的实现路径4.1 DICOM 工具箱的真正用途面向对象化的医学影像数据容器DICOM 不只是文件格式它定义了一整套信息模型。一个 DICOM 文件里既有患者姓名、检查号、检查日期这样的元数据也有像素矩阵这样的二进制块。这套系统把它做成工具箱意味着你要能读、能写、能转、能显示。常见做法是引入 fo-dicom 库它把 DICOM 文件抽象成一个 Dataset 对象C# 代码里可以直接按 TAG 取值。using Dicom; var file DicomFile.Open(dicomFilePath); var dataset file.Dataset; var patientId dataset.GetSingleValuestring(DicomTag.PatientID); var studyUid dataset.GetSingleValuestring(DicomTag.StudyInstanceUID); var seriesUid dataset.GetSingleValuestring(DicomTag.SeriesInstanceUID); var instanceUid dataset.GetSingleValuestring(DicomTag.SOPInstanceUID);逻辑说明DicomFile.Open在 fo-dicom 中负责读取整个文件支持流式读取以避免占用大内存。GetSingleValueT方法按 DICOM 标准 TAG 取出指定数据元的内容返回值类型在尖括号中指定。PatientID 标识患者StudyInstanceUID 标识一次检查SeriesInstanceUID 标识检查里的一个序列SOPInstanceUID 唯一标识一张影像。参数说明这四个 UID 是 DICOM 体系的关键索引。后续存储管理服务会以 StudyInstanceUID 建一级目录、SeriesInstanceUID 建二级目录这样文件管理逻辑与影像语义对齐避免直接平铺存储造成的大量碎片文件。提取时要注意 TAG 的数据类型PatientID 是字符串PNPixelData 是大字节数组OB/OW混用类型会导致运行时异常。4.2 影像保存路径的设计按 Study/Series/Instance 三级组织服务器端接收到 DICOM 文件后不能把文件随手存到一个大文件夹里必须按层级组织路径否则半年后几万张影像堆在一个目录里文件系统遍历就会卡死。我一般会设计一个路径生成服务public string BuildStoragePath(string studyUid, string seriesUid, string instanceUid) { var safeStudy Sanitize(studyUid.Replace(., _)); var safeSeries Sanitize(seriesUid.Replace(., _)); var safeInstance Sanitize(instanceUid.Replace(., _)); return Path.Combine(_storeRoot, safeStudy, safeSeries, ${safeInstance}.dcm); } private string Sanitize(string input) { return string.Join(_, input.Split(Path.GetInvalidFileNameChars())); }逻辑说明Replace(., _)是为了去掉 DICOM UID 中的点号避免 Windows 文件系统把带点的长目录名误判为扩展名。Sanitize方法过滤文件系统不允许的字符这是兼容性关键——不同厂商的设备导出的 UID 偶尔会带异常字符直接拼接会报路径错误。参数说明_storeRoot 对应前面配置文件里的 DicomStorePath。二级目录的方式在文件数量达到百万级时仍然可以保持良好性能。如果你所在的存储设备 RAID 策略支持小文件高 IOPS这个结构不需要绝对路径做哈希打散因为在影像系统里按检查检索是常态按 InstanceUID 检索是少数场景目录过深反而影响命中率。4.3 Web 预览的实现路径从内存像素到浏览器 CanvasDICOM 文件不能直接交给浏览器显示需要先把像素数据解码成 Bitmap 或裸像素数组。DICOM 压缩格式有三种情况无压缩Raw、RLE 压缩、JPEG 压缩。这个工具集一般会囊括这三种的解析能力否则碰到老型号设备导出的 JPEG 压缩影像会直接黑屏。using Dicom.Imaging; var pixelData DicomPixelData.Create(dataset); var renderer new GrayscaleRenderer(); var image renderer.Render(pixelData); var bitmap image.AsSharpBitmap(); using var ms new MemoryStream(); bitmap.Save(ms, System.Drawing.Imaging.ImageFormat.Png); var base64 Convert.ToBase64String(ms.ToArray());逻辑说明DicomPixelData.Create获取像素数据访问器GrayscaleRenderer把 12 位或 16 位的 CT 像素值映射到 8 位灰度范围这是显示 DICOM 影像必需的一步否则图像会过暗或过曝。最后转成 PNG 的 Base64 字符串直接嵌入前端的 img 标签。参数说明窗宽窗位调整就是改渲染器的映射参数而不是改像素原始值。GrayscaleRenderer 可以接收 WindowCenter 和 WindowWidth 参数前端滑块改变这两个值后后端的渲染结果会随之变化。这个流程每次请求都做渲染并发高时有 CPU 压力但中小机构同时调阅人数不超过三十个完全可以接受。4.4 前端交互的自定义脚本fetch 调用与序列切换在浏览器端85 个 JavaScript 文件里最有价值的是一个叫影像诊断工作列表的模块它负责从服务器请求序列列表并在用户点击某个序列时展示对应影像。async function loadSeries(studyUid) { const response await fetch(/api/study/${studyUid}/series, { method: GET, headers: { Accept: application/json } }); if (!response.ok) throw new Error(HTTP ${response.status}); const seriesList await response.json(); const container document.getElementById(series-tabs); container.innerHTML ; seriesList.forEach((series, index) { const tab document.createElement(div); tab.className series-tab; tab.textContent series.seriesDescription || Series ${index 1}; tab.addEventListener(click, () renderFirstInstance(series.seriesUid)); container.appendChild(tab); }); }逻辑说明fetch(/api/study/ studyUid /series)是向后端 Controller 发 GET 请求响应是 JSON 格式的序列列表。每一行数据包含 seriesUid 和 seriesDescription前端渲染成一个个可点击的页签。参数说明Accept 头指定 application/json后端会根据这个头决定返回 JSON 还是 HTML。renderFirstInstance 是另一个负责显示影像的函数只取序列的第一张做缩略展示等点击后再加载其余影像这是轻量级系统节约带宽的常规策略。5. 避坑与排查轻量级 PACS 落地时最容易翻车的五个地方5.1 高频踩坑点C-ECHO 通不过、中文乱码、存储盘爆满、跨域、并发上传第一条坑C-ECHO 测试通不过。DICOM 设备接入时设备端会发 C-ECHO 验证连接很多情况下通不过的原因不是 AE Title 不对而是监听端口被防火墙拦截。现象是设备日志显示“连接被拒绝”解决路径是先关闭防火墙测试通了再逐步开放端口千万别一上来就以为是代码问题。第二条坑影像换床单时的中文记录乱码。老设备的 DICOM 文件里患者姓名用的是 GB2312 编码而 .NET 默认 UTF-8 解码会直接变成问号。现象是患者列表里名字全是“”或乱码原因是 DICOM 的 Specific Character Set TAG0008,0005没有正确识别解决方法是读取该 TAG遇到 ISO_IR 192 之外的编码手动指定编码方式。第三条坑存储盘被占满导致服务假死。PACS 影像文件比普通业务系统的图片大很多CT 一个序列动辄上百 MBMRI 更多。现象是服务不报错但写入超时原因是没有做磁盘容量告警。解决方法是写一个定时任务轮询 DicomStorePath 所在盘剩余空间低于阈值就触发清理过期影像或暂停接收。第四条坑前端调接口遇到跨域报错。这套系统前后端分离Web 页面和 API 如果不在同一个域Chrome 会拦截请求。现象是 Network 面板里接口显示 CORS error解决方法是后端启用 CORS 中间件把允许的源配置成前端服务器的地址不要用星号通配。第五条坑并发上传一定数量文件时出现文件锁冲突。多个线程同时写同一个目录或文件时抛 IOException。现象是高负载时段影像上传失败率高原因是 DICOM 存储服务缺少互斥锁或没启用临时文件先写后改名的策略。解决方法是写入时先写.tmp文件写完再改正式文件名同时按 Study UID 做二级目录分散写压力。5.2 遇到问题怎么排查先看日志再查端口最后确认静态资源排查顺序我一般固定为三步。第一步看系统日志目录下有没有运行日志这套源码如果启用了 ILogger 会在控制台输出详细错误堆栈不要只看页面 500 就盲改代码。第二步确认端口真的在监听用 netstat -ano | findstr 9090 查看很多“系统没反应”其实是端口被占用冲突。第三步检查前端静态资源是不是被项目路径改变影响了尤其是改了 appsettings.json 里的 Url 之后绑定端口变了但引用资源的相对路径没变浏览器会有大量 404。这三步按顺序走完大部分问题都能锁定到具体环节而不是在代码里大海捞针。5.3 开发模式与部署模式的行为差异UseUrls 和 UseStaticFiles 的环境判断aspnetcore 环境变量控制着很多中间件是否启用。Development 环境下 UseDeveloperExceptionPage 会把异常堆栈直接抛给页面生产模式则会隐藏这些信息。如果你在部署时误把环境设为 Development内网机器也能看到完整的堆栈回溯存在信息暴露风险。我一般会在 Program.cs 里显式判断环境再启用异常页面生产环境下改成返回 JSON 格式的错误信息。另外UseStaticFiles 在生产环境是必须的如果漏掉前端资源全部返回 404界面如同白板。把这两个中间件的环境差异记住部署时能少走很多冤枉路。6. 进阶用法与验证用一份样张 DICOM 做二次开发的基准测试源码跑通你大概已经能确认“这个系统值得用”。但真正开始接设备做二次开发之前我建议先建立一套属于自己的验证基准尤其是当你需要改动 C# 解析层或前端渲染层代码的时候。我的做法是准备三份不同模态的 DICOM 样张——一份 CT 无压缩一份 MRI RLE 压缩一份从 PACS 导出的 JPEG 压缩这三份文件放在测试目录里每次改动代码后跑一遍上传、解析、预览全流程比对几张关键影像的像素显示是否正常。这套基准测试的价值在于它能快速发现改动是否破坏了不同压缩格式的兼容性。比如你调整了 GrayscaleRenderer 的窗宽窗位映射逻辑无压缩的 CT 片看起来正常但 JPEG 压缩的影像可能因为解码精度丢失出现条纹。三份样张能覆盖最常见的格式差异比单一文件验证全面得多。接着可以试着做两个小扩展一是给系统加一个接收 DICOM 设备推送的 C-STORE 服务监听让设备按 DICOM 协议直接把影像推到这套系统上而不是靠人工在网页上传——这是 PACS 落地的真正形态。二是给前端加一个标记功能在影像上用 JavaScript 画矩形或圆把坐标存储在数据库里为将来做测量和报告打底。这两个扩展做完你对这套源码的把握程度就远超“能跑起来”这一层。回想起我做第一个 PACS 项目时没有先做样张基准测试直接改了核心解析代码跑真机数据结果一上午收到十几张黑片最后发现是灰度映射参数写错了。从那以后我每次动 DICOM 解析或渲染相关的代码都强制自己先跑三样张测试再用真机样本验证。这个习惯帮我躲过了不少次改动引起的影像显示异常也让我拿到任何一份新 PACS 源码时都能快速摸清它的可靠边界。希望这个从源码拆解到现场排障的完整流程帮到你拿到这套 C# 轻量级 PACS 源码时能少走几步冤枉路。本文还有配套的精品资源点击获取
返回列表