简介:这份基于C#与JavaScript开发的轻量级PACS医学影像系统源码,面向中小型医疗机构与医学影像信息化开发者,可作为DICOM标准下的完整工具箱使用。资源共包含2000个文件,压缩包约96.89MB,其中1809个SVG文件承担丰富的矢量图形与影像界面设计,85个JavaScript文件实现前端交互,34个CSS文件负责样式布局,另有Map、PNG、JSON等辅助资源,整体结构清晰,便于按模块检索与二次开发。已有156人学习下载。源码不仅提供C#核心服务框架,还包含Services、Properties、Configuration、Controllers等模块化目录,以及appsettings.json、readme.txt等配置说明文档,便于理解系统架构与快速部署。开发者可基于开源协议自由修改扩展,满足影像存储、传输和管理的实际需求,是学习PACS与DICOM技术的实用参考资料。
1. 轻量级 PACS 到底“轻”在哪:C# + JavaScript 双端方案的定位
PACS 听起来像大医院才玩得动的东西,实际上一个科室、一家小型影像中心也需要它,只是他们要的不是对接全院业务的大系统,而是能接收设备导出的 DICOM、把影像存下来、在浏览器里调阅、做简单测量的东西。这个标题给的路线正好是这类场景最常见的组合:C# 处理 DICOM 接收、存储与查询,JavaScript 在浏览器里完成渲染与交互。所谓“最完善”,在这个赛道上指的是从设备推图到影像上屏的主链路没有断点。适合三类人:医疗软件集成商想做科室级交付,影像设备厂商想自带查看器,个人开发者想基于开源源码做二次开发。下面按一条可以照做的路径展开,并重点指出 DICOM 网络层的几个坑。
2. 双端架构与技术选型:为什么是 C# 做服务、JavaScript 做界面
2.1 先定义边界:做到哪一级才算“完整”
我见过不少项目把轻量级做成“能看一张图”的 Demo,这不能算 PACS。一个能交付给科室用的轻量级系统,至少要覆盖六件事:接收设备推图、文件落盘、结构化索引、按条件检索、浏览器调阅、基础测量。把这些链路走通,代码量其实不大;真正让项目膨胀的是 RIS 联动、MPPS、SR 结构化报告、多院区同步这类外围功能。
边界一定要在动手之前定死。我一般把系统拆成“主链路”和“扩展点”:主链路必须完整,扩展点只留接口。比如后续要接报告系统,就在 Study 表里预留 report_status 字段和对应的修改接口,而不是一上来就设计一套报告工作流。这样,整个服务端代码可以控制在几千行以内,也才好意思叫“轻量级”。
为什么中文开源社区里大部分 PACS 源码不能直接用,问题也出在边界上。很多作者把影像渲染写得很好,但网络层只做了接收,没有检索;或者只做了调阅,没考虑中文患者名字符集。所以这篇会把主链路的每一段都拆开,讲清楚哪部分必须完整、哪部分可以偷懒。
2.2 服务端放到 C# 这边:强类型与 DICOM 处理是刚需
C# 在这个场景里最合适的理由不是“性能最好”,而是 DICOM 处理生态成熟。DICOM 是二进制协议,标签、传输语法、字符集映射都极依赖强类型定义。C# 的强类型模型让 DICOM 标签的读取、写入、校验都写在编译期,而不是靠字典魔法。加上现代 C# 的异步和 TcpListener 封装,写一个并发接收服务比用普通脚本语言要稳得多。
我比较看重 C# 的另外一个点是单文件发布。科室级 PACS 经常要装在一台普通的 Windows 工作站上,旁边接着 CT、DR 或超声设备。C# 后端发布成单个可执行文件,配上数据目录就是一个“绿色服务”,不用在医疗内网里折腾运行时和一堆依赖。这对终端部署的友好程度,是选型时很容易被忽略的一项。
后端骨架的常见做法就是开一个最小 Web 服务,把 REST 接口和 DICOM SCP 服务放在一个进程里:
dotnet new web -n Pacs.Api这个命令生成的是一个最小 ASP.NET Core 空模板,不引 MVC,不带数据库,正好用来起步。前端我习惯用 Vite 初始化一个原生 JavaScript 工程,避免一开始就陷入前端框架的配置陷阱:
npm create vite@latest pacs-web -- --template vanilla选 vanilla 模板不是因为 React/Vue 不好,而是渲染 DICOM 的核心是 Canvas 和像素操作,框架参与不多。先跑通主链路,后续再往某个框架迁移成本很低。
2.3 前端交给 JavaScript:Canvas 与浏览器分发是天然优势
PACS 的前端本质是“看图”,而浏览器里最快的像素绘制路径是 Canvas 2D 的 ImageData。JavaScript 做这块有天然优势:不需要安装客户端,输个地址就能看片,这对医生使用习惯非常友好。更关键的是,现在显示器分辨率越来越高,DICOM 图像用 Canvas 放大缩小时,浏览器自己管理缩放与滚动,省掉桌面客户端大量 UI 工作。
我也试过用 C# 做 WPF 客户端,渲染速度没问题,但“装到每台阅片电脑上”这一步就劝退了。科室里有医生值班机、主任机、报告机好几台终端,浏览器方案只需要部署一次服务端,客户端零安装。遇到需要离线阅片的场景,再用 PWA 把静态资源缓存下来,也能覆盖。
前端架构上不要只做一个页面。我习惯把“调阅器”拆成三个独立模块:DICOM 解析、图像渲染、交互工具。解析模块负责从二进制里取出像素数据和头信息;渲染模块只认“像素数组 + 窗宽窗位”两个输入;交互模块把鼠标事件翻译成操作指令。三个模块之间用普通函数调用,不引入状态管理库,调试起来非常直观。
2.4 中文环境差什么:字符集、码表与报告习惯
标题里“中文开源社区”几个字,不只是说代码注释是中文,而是指这套系统要真正适配中文医疗环境。第一个最实际的问题是字符集。DICOM 标准默认字符集是 ISO IR 6(ASCII),但国内的设备经常用 GB18030 或 GBK 写患者姓名,而且很多设备不写 (0008,0005) SpecificCharacterSet 这个标签。
如果服务端不处理这个差异,就会出现一个很经典的现象:设备界面上患者叫“张三”,到了 PACS 里变成“寮犱笁”之类的一堆乱码。这个问题在中文开源社区里被反复提起,解法不是“统一用 UTF-8”那么粗暴,而是要在接收时判断字符集,在存储时保留原始字节,在浏览器端按正确规则解码。细节放到第 4 章的踩坑里说。
中文环境下第二个问题是检查类型和身体部位的展示习惯。英文 PACS 显示 CT、MR、DR 这类 modality 就够了,中文科室往往要显示“胸部正位”“头颅平扫”这种中文检查描述,而这部分在 DICOM 里经常是拆分字段,需要服务端做一次组装。很多开源源码在这里直接显示英文缩写,不是因为做不了,而是没做适配,这也是我觉得“中文开源社区最完善”这个定位里最有价值的部分。
3. 轻量级存储设计:SQLite 结构化元数据与文件目录的落盘方案
3.1 三级索引表结构与关键字段
DICOM 的模型天然是三级嵌套:一个 Study(检查)包含多个 Series(序列),一个 Series 包含多个 Instance(图像)。轻量级系统没必要再造一套更复杂的模型,把这三层落到三张表里,所有查询就都有了抓手。我在标题里说这是“设计源码”,核心设计之一就在这里,索引表的字段取舍直接决定系统后面好不好扩展。
CREATE TABLE IF NOT EXISTS study ( study_uid TEXT PRIMARY KEY, patient_id TEXT NOT NULL DEFAULT '', patient_name TEXT NOT NULL DEFAULT '', study_date TEXT NOT NULL DEFAULT '', study_time TEXT NOT NULL DEFAULT '', modality TEXT NOT NULL DEFAULT '', body_part TEXT NOT NULL DEFAULT '', accession_number TEXT NOT NULL DEFAULT '', create_time TEXT NOT NULL DEFAULT (datetime('now', 'localtime')) ); CREATE TABLE IF NOT EXISTS series ( series_uid TEXT PRIMARY KEY, study_uid TEXT NOT NULL, series_no INTEGER NOT NULL DEFAULT 0, modality TEXT NOT NULL DEFAULT '', body_part TEXT NOT NULL DEFAULT '', series_desc TEXT NOT NULL DEFAULT '' ); CREATE TABLE IF NOT EXISTS instance ( instance_uid TEXT PRIMARY KEY, series_uid TEXT NOT NULL, instance_no INTEGER NOT NULL DEFAULT 0, file_path TEXT NOT NULL, file_size INTEGER NOT NULL DEFAULT 0 ); CREATE INDEX IF NOT EXISTS idx_study_date ON study(study_date); CREATE INDEX IF NOT EXISTS idx_series_study ON series(study_uid); CREATE INDEX IF NOT EXISTS idx_instance_series ON instance(series_uid);这张表的取舍核心是“索引文件夹不改影像”。study_uid、series_uid、instance_uid 三个 UID 都是 DICOM 标准里的唯一标识,拿它们做主键天然不会重复。patient_id 单独建字段而不是去解析文件名,是因为检索时经常按患者 ID 过滤,放到表里才能走索引。study_date 按 YYYYMMDD 存成 TEXT,相当于自带排序规则,查询“最近一周”时可以直接比较字符串。
3.2 DICOM 文件落盘与 SOPInstanceUID 去重
文件落盘不能直接把所有影像塞进同一个大目录,那样几万张图会卡住文件系统。我习惯按 study_uid 分一级目录、series_uid 分二级目录,文件名用 instance_uid,末尾保留 .dcm 后缀。这样不用查数据库也能凭路径快速定位一份影像,手动排查问题时特别有用。
落盘时的去重必须用 SOPInstanceUID 判断。不要用文件名,不要用文件大小,同一个病人重拍一次检查生成的 UID 一定不同,而相同 UID 的设备重传则应该自动忽略。这是 DICOM 领域最基本的常识,也是最容易被新手漏掉的逻辑。下面的 C# 代码展示了接收一份文件后的落盘与索引过程:
public class DicomStorageService { private readonly string _fileRoot; private readonly IDatabase _db; public DicomStorageService(string fileRoot, IDatabase db) { _fileRoot = fileRoot; _db = db; } /// <summary> /// 保存一份 DICOM 数据,并维护 study/series/instance 三级索引。 /// 以 SOPInstanceUID 为唯一依据,重复推送同一份影像时直接覆盖。 /// </summary> public async Task<SaveResult> SaveAsync(DicomData ds) { var studyUid = ds.GetString(DicomTag.StudyInstanceUID); var seriesUid = ds.GetString(DicomTag.SeriesInstanceUID); var sopUid = ds.GetString(DicomTag.SOPInstanceUID); if (string.IsNullOrEmpty(studyUid) || string.IsNullOrEmpty(seriesUid) || string.IsNullOrEmpty(sopUid)) return SaveResult.Fail("缺少 UID,设备推送的数据不完整"); // 按 study/series 分层建目录,文件名直接用 SOPInstanceUID var relativePath = Path.Combine(studyUid, seriesUid, sopUid + ".dcm"); var fullPath = Path.Combine(_fileRoot, relativePath); Directory.CreateDirectory(Path.GetDirectoryName(fullPath)!); // Save 可能比较耗时,放到线程池避免阻塞 DICOM 接收线程 await Task.Run(() => ds.Save(fullPath)); // 用 INSERT OR REPLACE 实现幂等写入,重复推图不会产生脏数据 await _db.ExecuteAsync(@" INSERT OR REPLACE INTO instance(instance_uid, series_uid, instance_no, file_path, file_size) VALUES(@sopUid, @seriesUid, @instanceNo, @fullPath, @fileSize)", new { sopUid = sopUid, seriesUid = seriesUid, instanceNo = ds.GetInt(DicomTag.InstanceNumber), fullPath = relativePath, fileSize = new FileInfo(fullPath).Length }); return SaveResult.Ok(relativePath); } }逻辑上要注意几点:ds.Save是 DICOM 库提供的序列化方法,背后会按传输语法重新编码。这里放进Task.Run是因为接收服务可能同时处理多个连接,文件写入不应卡住 DICOM 网络层的确认响应。INSERT OR REPLACE是幂等手段,解决同一份影像被设备重复推送时产生的重复记录;代价是如果文件内容有变化,旧文件会被覆盖,这也是符合预期的。
3.3 按患者/日期/检查号的检索实现与分页
检索接口是医生每天打开系统的第一入口,做得不好会直接影响“值不值得用”的第一印象。常见检索条件就三个:患者姓名(模糊匹配)、检查日期(范围)、检查号/设备编号(精确匹配)。后端对应的查询不需要搞搜索引擎,几条带索引的 SQL 就够。
public async Task<PageResult<StudyInfo>> QueryStudiesAsync( string? patientName, string? dateFrom, string? dateTo, string? accessionNumber, int page, int pageSize) { // 拼接 SQL 时全部走参数化查询,避免内网系统出现 SQL 注入 var sql = @" SELECT study_uid, patient_name, patient_id, study_date, study_time, modality, body_part, accession_number FROM study WHERE (@patientName = '' OR patient_name LIKE @kw) AND (@dateFrom = '' OR study_date >= @dateFrom) AND (@dateTo = '' OR study_date <= @dateTo) AND (@accessionNumber = '' OR accession_number = @accessionNumber) ORDER BY study_date DESC, study_time DESC LIMIT @limit OFFSET @offset"; var list = await _db.QueryAsync<StudyInfo>(sql, new { patientName = patientName ?? string.Empty, kw = "%" + patientName + "%", dateFrom = dateFrom ?? string.Empty, dateTo = dateTo ?? string.Empty, accessionNumber = accessionNumber ?? string.Empty, limit = pageSize, offset = (page - 1) * pageSize }); var total = await _db.ExecuteScalarAsync<int>(@" SELECT COUNT(*) FROM study WHERE (@patientName = '' OR patient_name LIKE @kw) AND (@dateFrom = '' OR study_date >= @dateFrom) AND (@dateTo = '' OR study_date <= @dateTo) AND (@accessionNumber = '' OR accession_number = @accessionNumber)", new { patientName = patientName ?? string.Empty, kw = "%" + patientName + "%", dateFrom = dateFrom ?? string.Empty, dateTo = dateTo ?? string.Empty, accessionNumber = accessionNumber ?? string.Empty }); return new PageResult<StudyInfo>(list, total, page, pageSize); }WHERE 子句里用@param = '' OR condition这种写法是为了让调用方可以少传参数,每个可空条件都独立。LIMIT/OFFSET 是 SQLite 原生支持的,数据量在几十万级别以内完全够用。如果哪天影像量超过百万,把 SQLite 换成 PostgreSQL 时这段 SQL 也不需要大改。
4. DICOM 网络层与并发处理的排查清单:4 个必踩的坑
4.1 从零监听 DICOM 端口:C# 侧的 AE Title、端口与 PDU 协商
DICOM 网络层不是普通 TCP 传输,设备之间先要完成一次“握手”——A-ASSOCIATE-RQ/RSP,协商双方的应用实体名(AE Title)和传输语法。C# 侧实现这个协议最省力的方式是直接用开源 DICOM 库,但它只会把收到的数据递给你;如果你不懂握手过程,连接失败时连日志都看不懂。
最典型的基础配置就两个参数:AE Title 和 TCP 端口。AE Title 是 PACS 在 DICOM 网络里的名字,设备端在设置推图目标时必须填同一个值,格式要求大写字母或数字,最长 16 个字符。端口方面 DICOM 标准默认是 104,但 104 是特权端口,普通用户启动的服务没权限监听,调试阶段我常用 11112,这个端口在 DICOM 社区里已经是事实标准的备选。
public class DicomScpHost { private TcpListener _listener; private CancellationTokenSource _cts; private readonly DicomHandler _handler; public DicomScpHost(string aeTitle, int port, DicomHandler handler) { _handler = handler; // 监听端口前把 AE Title 注册到库的全局配置里, // 设备握手时会拿请求里的 Called AE Title 和它做匹配 DicomServer.Configure(options => { options.AETitle = aeTitle; options.Port = port; options.StoreHandler = _handler.OnStore; options.FindHandler = _handler.OnFind; }); } public void Start() { _cts = new CancellationTokenSource(); _listener = new TcpListener(IPAddress.Any, 11112); _listener.Start(); // 真实场景这里会循环 Accept,收到连接后交给后台任务处理, // 避免一个设备连接慢拖死后续所有连接 while (!_cts.IsCancellationRequested) { var client = _listener.AcceptTcpClient(); _ = ProcessClientAsync(client, _cts.Token); } } }这段代码里DicomServer.Configure和DicomHandler属于你自己封装的一层接口,实际背后是某个 DICOM 库在维护 PDU 解析。_ = ProcessClientAsync(...)表示不等待单个连接处理完成,这是并发吞吐的关键。TcpListener 只负责接连接,真正的 DICOM 会话处理全部放到了异步任务里。
4.2 踩坑 1:Association Rejected,AE Title 永远对不上
现象是设备端配置好后开始推图,PACS 日志里出现 Association Rejected,设备界面提示“Called AE Title not recognized”,但把配置反复核对了几遍都觉得没错。
原因很可能出在大小写。DICOM 协议规定 AE Title 是大小写敏感的,设备端配置时常会把 PACS 侧的 AE Title 写错大小写,比如 PACS 配置为 MINIPACS,设备端填 MiniPacs,握手就会直接被拒。还有一种情况是中间加了防火墙,虽然端口通了,但设备做 DICOM 握手时的源端口被 NAT 改写,导致库内部校验失败。
解决方法是先确认设备端“正在发送”的 Called AE Title 到底是什么,而不是看设备配置界面上保存的值。我在调试时会在服务端把每次 A-ASSOCIATE-RQ 报文里的 AE Title 原样打印出来,和前一步封装好的白名单做比对。白名单里可以同时放大小写两种形态,但对外统一的规范还是“全部大写”,从源头消灭这类问题。
4.3 踩坑 2:中文患者名变成乱码,怎么调都是符号
现象是接收成功后,列表页里患者名显示成“寮犱笁”这类明显错的字符;更玄学的是同一个设备的某些病人正常、某些病人乱码,让人误以为是数据库编码坏了。
原因基本都在 DICOM 的字符集声明上。设备写中文时,正确做法是在 (0008,0005) SpecificCharacterSet 里声明编码,比如 GB18030 或 ISO_IR 192(UTF-8)。但不少国内设备图省事,头里不写这个标签,或者写了却与实际编码不一致。服务端如果按默认 ASCII 解析,中文就变成乱码。
解决套路分两步。第一步,接收时严格读取 (0008,0005),有声明就按声明解码;第二步,设备没声明时,把患者名、医院名等文本字段的 Raw Bytes 原样保留在内存里,并追加一个“疑似GBK”的探测标记。文件存储仍按 DICOM 标准写,但在数据库里额外存一份转码后的 UTF-8 文本,供前端列表展示。这样即使设备头消息不规范,列表和报告也能看到正确中文,原始 DICOM 文件不破坏。
4.4 踩坑 3:前端拿到 DICOM 后黑屏,日志里没有报错
现象是浏览器调阅时能拉到文件、能解析出元数据,界面也渲染出了图像区域,但屏幕全黑,或者只有一小条亮线。换几个文件试,有的能显示有的不能,传统 DICOM 库和前端渲染对不上。
根本原因是传输语法。很多设备推图时用 JPEG Lossless JPEG-LS 这类压缩传输语法,而轻量级前端解析库通常只实现了解压的原始像素格式(Uncompressed)。文件头能解析,但像素数据那一块还是压缩的,图像自然出不来。
解决思路是“服务端负责解压,前端只认解压后的数据”。在接收落盘时,一旦检测到传输语法不是 Uncompressed,就调用 C# 侧的 DICOM 库把像素解压成原始像素数组,并生成一份额外的“预览版本”文件。调阅接口优先返回预览文件;只有需要像素级操作时才去解析原始文件。这样前端复杂度大大降低,还能顺便提高列表加载速度。这个坑不踩一次很难意识到:PACS 的“显示”和“解析传输语法”是两个完全独立的问题。
4.5 踩坑 4:多台设备同时推图,内存直接被打满
现象是平时单设备推图没问题,一旦接到 CT、MR 两台设备同时大量推送,或者一台设备批量补传历史数据时,服务进程内存直线飙升,甚至把工作站卡死。
原因有两个:一是接收模块把整个 DICOM 文件读进 byte[] 再解析,遇到几百 MB 的 CT 序列就扛不住;二是每个 TCP 连接都开一个独立线程,线程一多上下文切换开销巨大,再加上文件写入,内存自然爆。
解决方法是把“接收”和“处理”分成两段。接收线程只负责把原始字节流写到磁盘上的临时目录,立刻给设备返回成功;然后由后台队列任务去读临时文件、解析、建立索引、移动到最终目录。队列并发数固定,比如 2 到 4 个 worker,防止同时处理太多大文件。DICOM 设备的推图超时通常比较苛刻,先落盘再处理这套“异步大法”能同时解决性能问题和设备超时重传问题。
5. JavaScript 影像渲染层:DICOM 像素解析与窗宽窗位交互的实现
5.1 从 DICOM 文件到 Canvas:解析与传输语法预处理
浏览器里渲染 DICOM 不是直接把文件丢给<img>,需要写成像素数组再灌进 Canvas。核心是读两件事:元数据里的行数、列数、像素数据类型,以及 (7FE0,0010) 像素数据本体。DICOM 的图像可能是有符号/无符号 16 位整数,像素值范围能到 0-4096 甚至更高,不能直接当普通图片处理。
实际项目中即使传输语法是解压的,也分“原始像素数据”和“需要做 Photometric Interpretation 转换”两种情况。比如 MONOCHROME1 的影像(多见于 DR)是白底黑字,要反转显示;RGB 的彩色图像要按每通道 8 位解析。所有这些判断都来自头信息,前端解析函数拿到 head 字典以后才能处理像素。
// 简化版:从解析好的 DICOM 对象里取出像素数组 function extractPixelData(dicom, rows, cols) { // dicom.pixelBuffer 是后端返回的 ArrayBuffer,已经被解压成原始像素 const bytesPerPixel = dicom.bitsStored > 8 ? 2 : 1; const pixelCount = rows * cols; if (bytesPerPixel === 1) { return new Uint8Array(dicom.pixelBuffer) } // 16 位影像在 DICOM 里默认小端字节序, // 个别传输语法可能是大端,这里按字节序统一处理 const littleEndian = dicom.transferSyntax.includes('LittleEndian'); return new Uint16Array(dicom.pixelBuffer, 0, pixelCount, littleEndian); }这段代码把像素转换成 TypedArray,后面窗宽窗位计算可以直接在上面做运算。参数的坑主要在 bitsStored:它不是 bitsAllocated,有的设备 bitsAllocated 是 16,但真实数据只用了 12 位,直接用 16 位解析不会错,但计算默认窗宽时会把没有实际意义的高位噪声算进去。稳妥做法是取 bitsStored 来切分像素范围。
5.2 窗宽窗位:默认值、滚轮交互与伪彩扩展
窗宽窗位是影像渲染里最影响“能不能看清病灶”的功能,中文社区里习惯叫窗宽窗位,英文是 Window Level。DICOM 文件头里通常会带 (0028,1050) WindowCenter 和 (0028,1051) WindowWidth 两个推荐值,但这只是设备扫描时的默认设置;医生阅片时会用鼠标滚轮不断调整,所以前端必须保证每次交互在几十毫秒内完成重映射。
function applyWindowLevel(pixelArray, windowCenter, windowWidth) { const low = windowCenter - windowWidth / 2; const high = windowCenter + windowWidth / 2; const scale = 255 / (high - low); const result = new Uint8ClampedArray(pixelArray.length); for (let i = 0; i < pixelArray.length; i++) { const value = pixelArray[i]; if (value <= low) { result[i] = 0; } else if (value >= high) { result[i] = 255; } else { result[i] = Math.round((value - low) * scale); } } return result; }这个函数是最经典的线性窗函数,复杂度 O(n),对 512×512 的 CT 影像就是 26 万个点,现代浏览器几毫秒就能跑完。问题往往出在实现方式:如果每次滚轮事件都重建一个 Uint8ClampedArray 并重新遍历全部像素,滚动起来会卡。我一般会把原始像素数组缓存起来,只对当前显示的窗值范围做一次 LUT 计算,然后用查表方式映射:
LUT[value] = 每个像素值对应的 8 位灰度这样遍历一次生成 4096 项查表,再对每个像素查表输出,速度比逐个算快得多。伪彩也是一样,把查表结果从灰度换成 RGB 三通道即可,代价只是输出数组变成三倍长度。
5.3 测量与标注:鼠标坐标到 DICOM 像素坐标的换算
医生在阅片时最常用的工具是“测量长度”和“打箭头”。这类功能看着简单,但踩坑点集中在坐标换算上:Canvas 上显示的图像经过了缩放,甚至可能有平移和旋转,如果不把鼠标坐标反算回 DICOM 原始像素坐标,测量结果就是错的,直接关系诊断。
正确的做法是维护一个“显示变换”对象,记录当前缩放比例、平移偏移。所有交互事件先经过变换反向换算,得到原始像素坐标,再基于原始像素坐标计算长度或存储标注。DICOM 里像素间距(Pixel Spacing (0028,0030))是毫米单位,有了原始像素坐标和像素间距,长度就真实可换算。
function canvasToDicom(clientX, clientY, viewport) { // viewport = { scale, panX, panY, x, y, rows, cols } const imageX = (clientX - viewport.panX) / viewport.scale; const imageY = (clientY - viewport.panY) / viewport.scale; return { row: Math.round(imageY), col: Math.round(imageX) }; } function calculateDistanceMillimeter(pointA, pointB, pixelSpacing) { // pixelSpacing 是 DICOM 标签 (0028,0030),一般形如 [rowSpacing, colSpacing] const rowDiff = Math.abs(pointA.row - pointB.row) * pixelSpacing[0]; const colDiff = Math.abs(pointA.col - pointB.col) * pixelSpacing[1]; return Math.round(Math.sqrt(rowDiff * rowDiff + colDiff * colDiff) * 10) / 10; }换算完成后,标注数据建议以 JSON 形式持久化,结构和 DICOM 的 Graphic Annotation SR 不必一致,但至少要存“坐标系 + 原始像素坐标 + 测量值”。我用的是{ tool: 'LENGTH', points: [{row, col}], valueInMm: 12.3 }这种结构,方便前端再次渲染,也方便后续对接结构化报告时转换。
6. 部署与验证:最小闭环怎么验收、性能指标怎么看
6.1 本地验收三步走
一套轻量级 PACS 能不能交付,先在自己的开发机上跑通三个最小动作:启动服务、推入模拟 DICOM 文件、在浏览器里调阅。不要一上来就用真实设备试,真实设备推图失败时错误信息往往不直观,先用模拟文件把链路打通。
第一步是生成一份带基本头信息的模拟 DICOM 文件,CT 类图像即可,不需要真实病人数据。第二步启动接收服务,用命令行把模拟文件“推”给服务端,这一步相当于模拟设备。第三步打开浏览器调阅页面,搜出刚推入的检查,确认影像能显示、窗宽窗位能动。
| 验收环节 | 操作 | 通过标准 |
|---|---|---|
| 接收 | 推送 1 个文件,查看后台日志 | DICOM 握手成功,存储成功 |
| 索引 | 查 SQLite 表中记录数 | study/series/instance 各 1 条 |
| 调阅 | 浏览器打开该 Study | 图像显示,无黑屏,中文名正确 |
| 检索 | 按日期或姓名模糊搜索 | 结果列表正确返回 |
| 测量 | 画一条长度标注 | 显示的毫米数与实际一致 |
6.2 调阅耗时的三个验收指标
轻量级 PACS 最容易被人诟病的是“慢”。慢不一定是后端性能差,往往是接口设计浪费了太多不必要的网络交互。我在验收时只看三个指标:列表页从输入条件到出结果的时间、点击 Study 后第一帧影像上屏的时间、切换下一张图的时间。
这三个指标分别对应检索、文件读取、图像解码与渲染三段链路。第一帧慢通常是 DICOM 文件大,前端还在等整个文件下载;解决办法是接口支持“只取头信息 + 生成预览图”,列表页展示预览缩略图,点进去再按需拉原始数据。切换序列慢往往是后端在每次请求都重新解析整个 DICOM 文件,可以加一层内存缓存,按 instance_uid 缓存已经解好的像素数组。
6.3 我对轻量级 PACS 的维护习惯
做这套东西越久越发现,最大的隐患不在功能多少,而在数据一致性。落盘文件和数据库记录不一致,时间长了就会变成“文件在但查不到”“列表有但打不开”的黑匣子问题。我现在保留一个习惯:每天晚上跑一遍校验任务,遍历文件目录与 instance 表比对,把孤立文件和不存在的记录找出来挂起,而不是直接删。数据恢复永远比数据清理更值钱。
如果再让我重做一次,我会在一开始就把“传输语法统一解压”和“中文患者名兼容”这两件事加进接收模块,而不是等用了三个月后一个个去补。前者能避免前端一大半黑屏问题,后者能避免内网环境里被骂“系统有问题”的口碑翻车。希望这篇能帮你在做 PACS 选型和落地时少踩几个坑。
本文还有配套的精品资源,点击获取