简介:这套C#核心开发的轻量级PACS系统设计源码,定位为中文开源社区内的医学影像DICOM工具箱,面向中小医疗机构及医疗信息化开发人员,解决影像存储、传输与管理问题。包体共约2000个文件,其中1809个SVG矢量图形用于界面图标与流程示意,85个JavaScript实现动态交互,34个CSS负责样式布局,另有HTML、JSON、TXT、Map等配置与辅助文件,压缩包约96.89MB,目录按Services、Controllers等模块划分,便于二次开发。已有156人学习下载。资源完整提供C#服务端与前端源码,并包含应用配置、格式规范及使用说明等文档,帮助读者理解系统架构、调整参数并快速部署,适合预算有限但需要自主搭建PACS系统的团队。
1. 轻量级PACS落地:为什么C#与JavaScript的组合值得中小医院尝试
影像科的老PACS即将到期,商业系统续保报价高,基层医院预算根本扛不住;自己用开源组件拼一套,又担心影像数据出错。标题里提到的这套基于C#与JavaScript的轻量级PACS设计,正是这条路的参考答案:C#在服务端配合成熟的DICOM类库处理影像协议,稳定性有保障;JavaScript在浏览器端直接渲染影像,省去给每台电脑装客户端的麻烦。两者组合起来,可以把一套能用的PACS压缩到一台普通服务器甚至工作站上。它适合预算有限的中小型医院、医联体边缘节点,也适合正在啃DICOM协议的开发者——理解这套设计,相当于把PACS最核心的几条链路亲手走了一遍。
2. 拆解PACS核心链路:DICOM解析、C# SCP服务与存储选型
不管前端用什么框架,PACS的价值都在于把影像安全收进来,再让医生方便地调出来。收进来靠的是DICOM协议,调出来靠的是数据库索引和文件管理。这一章先把C#服务端这条主链路铺开。
2.1 DICOM文件解析:从Tag读取到像素矩阵提取
DICOM文件的基础结构是数据集,数据由一个个Tag组成。每个Tag是一个二元组:组号(Group)和元素号(Element),例如患者姓名是(0010,0010),像素数据是(7FE0,0010)。解析文件时要跳过开头的128字节前导和4字节的"DICM"标记,从第132字节开始读数据集。
常见做法是先行读取Transfer Syntax标签(0002,0010),确认编码是显式VR还是隐式VR。显式VR下每个元素自带VR类型,隐式VR则需要查表推断。下面的代码展示显式VR路径的核心循环:
public class DicomReader { public async Task<DicomDataset> ParseAsync(string filePath) { var dataset = new DicomDataset(); using var fs = File.OpenRead(filePath); fs.Position = 132; // 跳过128字节前导和"DICM" while (fs.Position < fs.Length) { var tag = await ReadTagAsync(fs); // 组号+元素号,共4字节 var vr = await ReadVRAsync(fs, tag); // 显式VR占2字节 var length = await ReadLengthAsync(fs, vr); // 普通VR长度4字节 var value = await ReadValueAsync(fs, length); dataset[tag] = DecodeValue(vr, value); } return dataset; } }这里有两个必须说明的参数细节。第一,显式VR下除了默认的4字节长度,OB、OW、OF、SQ、UT这类特殊VR还会在后面追加2字节预留位,然后才是真实长度,解析时容易漏读。第二,DICOM默认字节序是小端(Little Endian),但如果Transfer Syntax表明是Big Endian,所有数值都要做字节序翻转。我之前在这两个地方翻过车,解析出的像素值全是噪声,最后打印Tag头才发现字节序没判断。
解析后的像素数据需要单独提取。CT、MR等影像的像素数据在(7FE0,0010),配合行数(0028,0010)、列数(0028,0011)、分配位数(0028,0100)等参数,才能正确还原图像。如果BitsAllocated是16且像素表示是带符号整数,需要按Int16类型逐点读取,直接按无符号处理会导致窗宽窗位全部偏移。
2.2 用C#实现DICOM SCP服务:接收、校验与存档一体化
PACS要接收影像设备(CT、MR、DR等)推送的数据,设备作为SCU发起C-STORE请求,PACS作为SCP应答。端口默认是104,但Linux下绑定1024以下端口需要root权限,开发环境常用映射方式。C#生态里DICOM类库已经把PDU层打包好了,常见做法是直接继承服务基类,注册一个C-STORE处理入口。
public class CStoreScp : DicomService, IDicomServiceProvider, IDicomCStoreProvider { public async Task OnReceiveCStoreRequest(DicomCStoreRequest request) { var dataset = request.Dataset; var patientId = dataset.GetString(DicomTag.PatientID); if (string.IsNullOrEmpty(patientId)) { // 缺少患者ID直接拒绝,避免脏数据入库 await SendCStoreResponseAsync(request, new DicomCStoreResponse(request, DicomStatus.CannotUnderstand)); return; } var studyUid = dataset.GetString(DicomTag.StudyInstanceUID); var seriesUid = dataset.GetString(DicomTag.SeriesInstanceUID); var sopUid = dataset.GetString(DicomTag.SOPInstanceUID); // 按 UID 组合生成存储路径,天然保证文件不重复 var dir = Path.Combine(_rootPath, patientId, studyUid, seriesUid); Directory.CreateDirectory(dir); var filePath = Path.Combine(dir, $"{sopUid}.dcm"); // 保存前先写数据库事务,再落盘,避免数据不一致 await _repository.InsertInstanceAsync(BuildInstanceRecord(dataset, filePath)); await dataset.SaveAsync(filePath); await SendCStoreResponseAsync(request, new DicomCStoreResponse(request, DicomStatus.Success)); } }这里的关键在于存储路径规则:PatientID、StudyInstanceUID、SeriesInstanceUID、SOPInstanceUID四级目录,文件直接以SOPInstanceUID命名。这套规则能防止重复接收同一影像时产生副本,也能在数据库丢失时通过文件目录反查。还有一条败经验:先写数据库还是先存文件的顺序要想清楚。先落盘再写库,如果写库失败会出现孤儿文件;先写库再落盘,则可能数据库有记录但文件缺失。
2.3 存储引擎选型:文件系统加数据库双轨方案的取舍
轻量级PACS的存储方案常见的是文件系统加关系数据库双轨:DICOM文件原样存磁盘,数据库只存索引和业务字段。文件系统负责大文件吞吐,数据库负责复杂查询。我看到有一些学生项目把DICOM文件解包后直接塞进数据库的BLOB字段,几十个检查就能让数据库体积膨胀到GB级,备份和恢复都会很痛苦。
对比如下:
| 方案 | 优点 | 缺点 | 适合场景 |
|---|---|---|---|
| 文件系统 + 数据库索引 | 读写快,备份灵活,DICOM文件可直接给第三方工具用 | 需保证文件与数据库一致性 | 多数轻量级PACS、中小医院 |
| 全量存数据库BLOB | 事务性强,不易产生孤儿文件 | 体积膨胀快,备份恢复慢 | 原型演示、文件量极小 |
| 对象存储 + 数据库索引 | 扩展性好,适合上云 | 依赖中间件,运维成本高 | 医联体、云PACS |
数据库表设计不需要太多花活。患者表存PatientID和姓名;检查表存StudyInstanceUID、检查日期、检查类型;序列表存SeriesInstanceUID、序列描述;实例表存SOPInstanceUID、文件路径、SOPClassUID。四个表通过外键关联,查询时从检查表入手连查序列和实例。字段类型上,UID用字符串类型且长度要够,DICOM UID最长64字符,很多数据库默认varchar(50)会截断,这个坑会导致同一个Study下的序列在界面上分散成多个检查。
3. JavaScript端Web阅片器:从DICOM数据渲染到移动端适配
服务端收进来的影像,最终要在浏览器里显示给医生看。传统PACS靠ActiveX或独立客户端,现在主流方向是纯Web方案。JavaScript在浏览器端做像素级渲染,性能瓶颈明显,但合理设计后完全能胜任日常阅片。
3.1 前端DICOM渲染管线:像素数据、窗宽窗位与灰度映射
浏览器不能直接解DICOM,常见做法是前端先请求服务端提供解析后的像素数据结构(或直接在服务端完成解析后返回JPEG/PNG)。但纯Web阅片器的体验标杆是调窗,如果预先转成JPEG就失去了调整窗宽窗位的能力,所以大多数实现会请求原始像素缓冲,在前端自己做灰度映射。
function applyWindowing(pixelArray, rows, cols, windowCenter, windowWidth) { const output = new Uint8ClampedArray(rows * cols * 4); const low = windowCenter - windowWidth / 2; const high = windowCenter + windowWidth / 2; for (let i = 0; i < rows * cols; i++) { let value = pixelArray[i]; let gray = 0; if (value <= low) { gray = 0; } else if (value >= high) { gray = 255; } else { gray = ((value - low) / windowWidth) * 255; } output[i * 4] = gray; // R output[i * 4 + 1] = gray; // G output[i * 4 + 2] = gray; // B output[i * 4 + 3] = 255; // A } return output; }上面的函数是窗宽窗位映射的标准写法:小于窗位下限的像素压成纯黑,大于上限的压成纯白,中间区域线性拉伸。windowCenter和windowWidth来自DICOM标签(0028,1050)和(0028,1051),但很多设备不写这两个值,需要根据像素统计值算默认窗宽窗位。我一般会取像素直方图的三分位区间来兜底,效果比固定值更稳。
需要注意的细节是像素数组的格式。服务端返回的数组如果直接用Uint16Array传给前端,浏览器端要手动处理字节序;如果服务端已经按平台字节序转换好,前端直接用就行。传输协议上我习惯用二进制流加自定义JSON头,避免JSON序号数组带来的内存放大。
3.2 基于Canvas和WebGL的交互阅片组件设计
灰度映射完成后,剩下的就是如何把像素画到画布上并且支持缩放、平移、翻片。Canvas 2D的putImageData方案简单可靠,性能足够日常交互;对超大数据集或需要平滑缩放时,用WebGL纹理渲染更合适,但复杂度会上升一个量级。
function drawFrame(canvas, rgbaBuffer, meta) { const ctx = canvas.getContext('2d'); const imageData = ctx.createImageData(meta.cols, meta.rows); imageData.data.set(rgbaBuffer); canvas.width = meta.cols; canvas.height = meta.rows; ctx.putImageData(imageData, 0, 0); }这里有一个容易被忽略的细节:每次drawFrame都重新给canvas.width赋值会清空画布状态,同时也会触发浏览器重排,连续拖动时会感到卡顿。改进方法是在初始化时固定canvas尺寸,绘制时用CSS transform做缩放;只有影像尺寸变化时才改动canvas.width。另外,频繁调用createImageData会产生垃圾回收压力,建议复用同一个ImageData对象,只在尺寸变化时重建。
交互层我一般会封装三个基本动作:拖动平移、滚轮缩放、双击定位。缩放中心点要用鼠标位置而不是画布中心,否则医生追着病灶看时视角会乱跳。这是我第一次做阅片器时被影像科同事吐槽最多的地方。
3.3 中文兼容性处理:字符编码、字体与移动端触控
中文环境下最头疼的是编码问题。DICOM标准默认字符集是ASCII,但国产设备写患者姓名时常用GB2312或GBK。如果服务端读取Tag时没有正确处理字符集,存进数据库的就是乱码。规范的做法是解析时先看SpecificCharacterSet标签(0008,0005),该标签声明了数据集的字符编码;但很多设备这个标签根本不是ISO_IR 6,而是"GB18030"。我现在的处理原则是:数据库统一UTF-8,读取DICOM时按字符集标签转换,标签缺失时优先尝试UTF-8,再用GB18030兜底。
移动端适配还需要处理触控事件的差异。滚轮缩放要映射成双指捏合,拖动要处理touch事件的被动监听特性。Chrome在安卓上默认把touch事件标为被动,如果在回调里执行preventDefault会直接报错,阅片器的双指缩放甚至会引发页面回弹。解决方案是在注册事件时显式传{ passive: false }。
4. 轻量级架构设计:API契约、权限模型与数据闭环
前后端分离后,接口契约决定了整个系统的可维护性。轻量级PACS的API设计不需要特别复杂,但有几个接口必须做到位:检查列表查询、序列列表查询、影像文件下载、DICOM文件导出。下面是我的常用接口设计。
| 接口 | 方法 | 用途 | 关键参数 |
|---|---|---|---|
| /api/studies | GET | 按条件查询检查列表 | patientName、startDate、endDate、page |
| /api/studies/{uid}/series | GET | 查询检查下的序列 | studyInstanceUID |
| /api/series/{uid}/instances | GET | 查询序列下的影像实例 | seriesInstanceUID |
| /api/instances/{uid}/pixel | GET | 获取像素缓冲用于前端渲染 | sopInstanceUID、window |
| /api/instances/{uid}/file | GET | 下载原始DICOM文件 | sopInstanceUID |
4.1 前后端通信协议:RESTful API与DICOMweb的取舍
直接用RESTful API是轻量级PACS最快捷的通信方式,前端拿JSON渲染列表,拿二进制流渲染影像。DICOMweb(WADO-RS、QIDO-RS)是DICOM标准组织的Web化规范,好处是兼容标准,坏处是很多设备厂商的实现并不完整,调试成本高。我的建议是:内部系统直接用RESTful API,对外开放或需要对接第三方PACS时再看DICOMweb。
接口设计时要注意分页和查询条件的组合。检查列表是查询频率最高的接口,很多初稿只做了全量返回,影像量大的时候前端直接卡死。我在Service层固定分页大小,默认20条,并且支持按检查日期倒序排序。
4.2 C#后端分层结构与关键代码骨架
C#后端按Controller-Service-Repository三层拆开,Controller只做参数校验和视图组装,Service放业务逻辑,Repository操作数据库。这套分层看似笨重,但后期加缓存、加权限、加审计时非常顺利。
public class StudyService { private readonly IStudyRepository _repository; public async Task<PagedResult<StudyDto>> QueryStudiesAsync(StudyQuery query) { var q = _repository.BuildStudyQuery(); if (!string.IsNullOrEmpty(query.PatientName)) { // 患者姓名模糊查询,注意大小写和中文编码 q = q.Where(s => s.PatientName.Contains(query.PatientName)); } if (query.StartDate.HasValue) { // DICOM日期是yyyyMMdd字符串,数据库字段务必同格式存储 q = q.Where(s => s.StudyDate >= query.StartDate.Value.ToString("yyyyMMdd")); } var total = await q.CountAsync(); var items = await q.OrderByDescending(s => s.StudyDate) .Skip((query.Page - 1) * query.PageSize) .Take(query.PageSize) .ToListAsync(); return new PagedResult<StudyDto>(items, total); } }参数上面有一处容易翻车的细节:StudyDate在DICOM里是字符串"yyyyMMdd",前端传"yyyy-MM-dd"时如果直接比较,要么查不出数据,要么需要数据库函数做转换。我的做法是约定接口层统一接收"DICOM风格"的日期格式,避免在SQL里隐式转换导致索引失效。
4.3 用户角色与操作审计:从科室权限说起
PACS权限模型比普通业务系统敏感,主要是影像数据涉及患者隐私。轻量级方案至少要有三种角色:管理员负责系统配置和用户管理,技师负责影像上传和修正,医生负责阅片和报告。权限控制落在API层,用ASP.NET Core的JWT做认证,角色声明放进Token里。
审计日志这块不能省,至少记录三种操作:登录登出、影像上传、影像导出。影像导出最容易出合规问题,建议在导出接口里强制写审计记录,内容包括操作人、患者ID、时间戳、目标文件名。这不会增加多少开发量,但被问到谁导出过影像时,能直接拿数据出来说话。
5. 部署与性能避坑:从开发机到生产环境的排错实录
轻量级PACS开发得再完善,部署到生产环境后依然会暴露出一批真实环境特有的问题。这一章把我踩过且值得记录的四个坑按现象、原因、解决写出来,每一条都是可以回溯的排错经验。
5.1 影像数据“存进去找不回”:SCU/SCP超时与重试机制
现象:设备端显示影像传输成功,但PACS客户端里查不到该检查;数据库实例表里也没有记录。某设备厂商工程师来现场联调,两台设备都正常,就其中一台型号死活传不进来。
原因:设备作为SCU推送影像时,对SCP的响应时间有超时限制。C-STORE请求处理中如果没有及时返回响应,设备端会认为传输失败,自动重传或直接放弃。我当时在SCP回调里加了数据库写入逻辑,数据库偶尔慢查询导致响应延迟超过设备阈值。
解决:把响应动作和落库动作拆分。SCP收到请求后先同步回Success,再把影像存储和数据库写入放到异步队列里执行。这样设备端不会因超时而中断,数据一致性由队列补偿机制保证。
5.2 中文患者姓名乱码:字符集与Tag VR类型不一致
现象:服务端日志里患者姓名正常,浏览器端显示却是乱码,尤其以"王"字开头的姓名变成"鐜嬩粺"。有的记录直接显示问号。
原因:DICOM里患者姓名是PN类型,理论上用SpecificCharacterSet标签区分编码,但很多国产设备不写这个标签或直接写GBK。我用UTF-8解码GBK字节流,自然产生乱码。另一个情况是数据库表编码设置成了latin1,写入UTF-8字符串再读出来就变成了问号。
解决:处理分两层。解析层在读取PN值时先看SpecificCharacterSet,有值按对应编码解码,无值时依次尝试UTF-8和GB18030;持久层建表时显式指定utf8mb4,连接字符串里加CharSet=utf8mb4。
5.3 移动端白屏与内存泄漏:DICOM超大帧渲染问题
现象:平板电脑上打开一个CT检查,翻片到第30张左右页面白屏,安卓端比iOS更频繁,有些机型直接崩溃。桌面端正常,只有移动端复现。
原因:一个CT序列往往300张以上,每帧512×512×2字节,全序列加载进内存要几百MB。移动浏览器对内存容量有限制,在像素解析和Canvas绘制两层分别复制了像素缓冲,内存峰值翻倍。
解决:改按需加载策略。前端只保留当前帧和前后两帧的像素缓冲,翻片时异步请求新的帧数据并释放远端帧。像素解析改用Web Worker,避免阻塞主线程导致白屏,同时把putImageData的像素缓冲复用于多帧绘制。
5.4 浏览器兼容性降级:WebGL不支持时的Canvas备选
现象:医院门诊大楼的老电脑、某些深度定制的国产浏览器,阅片器打开后黑屏,控制台报错显示WebGL context创建失败。但同一套代码在开发环境没有任何问题。
原因:做WebGL渲染时没有处理context创建失败的场景。老显卡、禁用硬件加速的浏览器、虚拟机环境都可能导致WebGL不可用。
解决:启动时先做特性检测,获取WebGL context失败则降级到Canvas 2D。降级后需要禁掉依赖WebGL的高级渲染逻辑,例如MIP贴图和体绘制,但基础的窗宽窗位调整和翻片功能必须保留。为了不让医生在多套浏览器之间体验差太多,我干脆默认统一用Canvas 2D,只在性能测试超标时才切WebGL。
6. 进阶方向:从单机阅片到DICOMweb对接与AI辅助诊断
轻量级PACS跑通之后,再往前走有三条路值得投入。
第一条路是对接第三方系统。给体检中心或医联体上级医院开放数据时,DICOMweb协议是绕不开的。我一般先在内部实现WADO-RS的WebAR - Retrieve接口,让第三方系统通过GET请求按StudyUID或SOPInstanceUID拉取影像;这样对方不用装任何客户端,只要按标准拼URL就能取图。实现过程中要重点处理返回格式,普通帧返回image/jpeg或image/png,多帧影像要支持application/dicom格式的分段返回。
第二条路是接入AI辅助诊断。C#后端提供影像数据给AI服务,AI推理结果按DICOM标准里的Segmentation对象返回,前端在Canvas上叠加半透明色块即可。实际项目中我给肺结节检测做过标注叠加,判读灵敏度提升明显。关键是用DICOM SEG对象而不是自定义JSON,这样结果可以在不同PACS间无损流转。
第三条路是从单机走向双活。轻量级PACS在单台机器上跑得很顺,但断电或磁盘故障时影像数据就卡住了。我建议至少做离线备份,把DICOM文件目录和数据库定期打包到外置存储。做到实时容灾则需要主从数据同步,文件系统用rsync或分布式文件系统,数据库用主从复制。这一层我还没有在生产环境完全落地,但备份缓存策略一定是从第一天就要考虑的。
这些年折腾PACS最大的教训是:协议和业务模型都比想象中复杂,真正的坑从来不在框架选择上,而在字符编码、字节序、超时重试这些看似基础的细节里。另一个习惯是每次联调前先抓DICOM文件头,多看一眼Transfer Syntax和SpecificCharacterSet,能省下半天排错时间。这套C#与JavaScript的组合方案,是中小医院低成本进入数字化影像管理最平滑的路径之一,希望帮到你。
本文还有配套的精品资源,点击获取