OHIF Display Set 排序机制深入解析:sortStudy、getLatestInstanceDateTime 与保存时间戳
2026/9/18 16:27:24 网站建设 项目流程

OHIF Display Set 排序机制深入解析:sortStudy、getLatestInstanceDateTime 与保存时间戳

【免费下载链接】ViewersOHIF zero-footprint DICOM viewer and oncology specific Lesion Tracker, plus shared extension packages项目地址: https://gitcode.com/GitHub_Trending/vi/Viewers

导读

本文以 OHIF 官方开发文档《Notes and Requirements for general OHIF behaviour》中关于 Series 与 Display Set 排序的规范为核心,结合platform/core的实际源码与测试,完整讲解 OHIF 是如何对系列、显示集和实例进行排序的:包括addSameSeriesCompare同系列比较器注册机制、sortVector系列拆分排序向量、getLatestInstanceDateTime的日期时间选取规则、updateNewInstanceMetadata的保存打戳逻辑,以及它们背后隐藏的排序一致性问题。读完本文,你将理解 OHIF 中「报告、分割、结构集排在图像之后并按创建时间倒序」这一行为的全部实现细节,并能在开发自定义 SOP Class Handler 或拆分系列时写出正确、稳定的排序代码。

为什么需要显式的排序规则

用户经常希望看到排好序的系列列表,更一般地说,是排好序的Display Set(显示集)列表。Series 是原始数据,可以被拆分成多个 Display Set,但二者本质上属于同一类排序对象。

文档给出了两个典型场景:

  • 一个 MR 系列可能同时包含 T1 和 T2 回波,用户希望 T2 排在 T1 之后;
  • 一个系列可能包含 4 个乳腺摄影视图:LCCRCCLMLORMLO,用户希望所有CC视图排在前面,而在同一CC子类型内部,左侧视图又排在右侧之前。

这类"同一系列内部"的排序需求无法靠单一的全局比较函数满足,因此 OHIF 设计了按名称注册同系列比较器的机制,即sortStudy.ts中的addSameSeriesCompare

同系列比较器:addSameSeriesCompare

注册机制

在 platform/core/src/utils/sortStudy.ts 中,比较器存放在一个Map<string, CompareSameSeries>中,CompareSameSeries包含priority(优先级数值)和compare(比较函数)两个字段:

type CompareSameSeries = { priority: number; compare: (a, b) => number; }; const mapCompareSameSeries = new Map<string, CompareSameSeries>(); export function addSameSeriesCompare(name: string, compareF: (a, b) => number, priority: number) { if (!compareF) { mapCompareSameSeries.delete(name); } else { mapCompareSameSeries.set(name, { compare: compareF, priority }); } }

注意两点:

  • 删除注册:传入null作为比较函数即可删除注册(mapCompareSameSeries.delete(name))。在迁移指南 display-set-ordering.md 中明确提到,若你希望恢复 3.13 的旧行为(仅按实例号排序),可以这样取消注册:addSameSeriesCompare(name, null, priority);
  • priority 参数:每个比较器附带一个默认优先级,用于两个 Display Set 注册了不同比较器名时的跨比较器排序。

文档中的注册示例:

addSameSeriesCompare('mammographyCompare', mammographyCompare, 5) addSameSeriesCompare('mrT1T2Compare', mrT1T2Compare, 7);

然后,对应的 Display Set 需要通过compareSameSeries字段声明它使用哪个比较器:

displaySet = { ..., compareSameSeries: 'mammographyCompare', }

compareSameSeries字段在 platform/core/src/types/DisplaySet.ts 中有正式类型定义:compareSameSeries?: string,其含义是"当比较来自同一 seriesInstanceUID 的显示集时,使用的比较函数名称"。

比较器如何生效

核心函数是 compareSameSeriesDisplaySet:

export const compareSameSeriesDisplaySet = (a, b) => { const { compareSameSeries: compareAName = 'default' } = a; const { compareSameSeries: compareBName = 'default' } = b; const compareA = mapCompareSameSeries.get(compareAName); const compareB = mapCompareSameSeries.get(compareBName); if (compareA && compareB) { const compareValue = compareA === compareB ? compareA.compare(a, b) : compare(compareA.priority, compareB.priority); if (compareValue) { return compareValue; } } return sortByInstanceNumber(a.instance, b.instance); };

逻辑分三层:

  1. 两侧 Display Set 的compareSameSeries名称相同 → 使用注册在该名称下的比较函数;
  2. 名称不同 → 使用两个比较器的priority值大小(priority 小者在前);
  3. 以上比较结果为 0(平局)或未注册 → 回退到按实例号比较sortByInstanceNumber

在 3.14 版本之前,compareSameSeriesDisplaySet存在一个 bug:只有比较器返回0时才采用其结果,真正分出先后时反而丢弃并回退到实例号比较,导致注册的比较器完全不生效。3.14 修复后按文档语义生效,详见迁移指南 addSameSeriesCompare comparators now run。

系列排序的完整比较链

compareSeriesUID将 UID 比较与同系列比较器串联起来(sortStudy.ts#L64-L65):

export const compareSeriesUID = (a, b) => compare(a.SeriesInstanceUID, b.SeriesInstanceUID) || compareSameSeriesDisplaySet(a, b);

也就是说:先按 SeriesInstanceUID 分组,UID 相同的 Display Set 才进入同系列比较器。这是整个排序体系的入口。

从系列拆分指定排序顺序:sortVector

getSopClassHandlerModule(系列拆分规则)可以在创建 Display Set 时通过添加一个sortVector字段来指定同一系列内 Display Set 的自定义顺序。

规则如下:

  • 具有相同 SeriesInstanceUID 的 Display Set 之间使用 sortVector 进行比较;
  • 向量的第一个元素是该类型值在所有排序类型中的总体排序优先级,必须是数值
  • 向量中剩余的值,对所有第一个值相同的 sortVector 必须保持一致(即向量长度与后续各位置的语义要统一)。

文档给出的乳腺摄影示例:

// LCC view [25, 'CC', 'L', 'XO'] // BCC view [24, 'CC', 'B']

这里主排序值25表示该系列的总体排序位置,随后三个值分别表示 view type、sub type 和 side。而"双侧(both)"视图希望排在所有视图之前,因此其主值被赋为小于25的数值(如24)。由于compareSameSeriesDisplaySet在两 Display Set 注册比较器不同时会先比较 priority,sortVector本质上提供了一种数据驱动的、无需注册 JS 比较函数的同类排序途径。

注:sortVector目前仅在本文档中给出规格说明(见 notes-requirements.md),使用它时需要保证同一系列内所有 Display Set 的首元素数值一致可比、剩余元素语义一致。

Display Set 的日期与时间:getLatestInstanceDateTime

为什么需要"显示集自己的日期时间"

派生系列(报告 SR、分割 SEG、结构集 RTSTRUCT)在系列列表中排在图像之后,并按日期/时间倒序排列,使最新创建的派生对象离图像最近。要做到这一点,每个 Display Set 需要一个统一的"创建时间"。

关键问题在于:Display Set 是从其系列中的某一个实例创建的,而这个实例本身携带的是系列的SeriesDate/SeriesTime。例如,向一个已存在的 SR 系列中再保存一份报告,这份新报告的SeriesDate/SeriesTime仍然是该系列首次创建时的日期——只有实例级的日期/时间才能说明"这份报告是刚刚生成的"。因此排序必须依据实例级创建时间,而不是系列级日期。

日期时间属性的选取规则

getLatestInstanceDateTime的实现位于 platform/core/src/utils/latestInstanceDateTime.ts。DICOM 用多组属性对记录"何时创建",具体出现哪一组取决于模态和写入方,因此该函数从以下候选属性中选一组(dateTimeAttributes):

属性对说明
InstanceCreationDate/InstanceCreationTime实例级,SOP Common 模块提供
ContentDate/ContentTime实例级
AcquisitionDate/AcquisitionTime实例级
AcquisitionDateTime(组合 DT 值)增强型多帧对象常用
StructureSetDate/StructureSetTimeRTSTRUCT 专属
PresentationCreationDate/PresentationCreationTimePR 专属
SeriesDate/SeriesTime系列级

选取规则(latestInstanceDateTime.ts#L238-L255 中的consider逻辑):

  • 日期取所有这些属性中最晚的日期
  • 时间携带该完全相同日期的属性中最晚的时间——时间永远不会与一个不是它一起到达的日期组合,因此结果总是真实发生过的一个日期/时间;
  • 当胜出日期没有任何属性携带时间时,时间为空,排序精度只到天——这是数据允许的最好结果;
  • StudyDate/StudyTime不参与:同一 Study 的每个系列都共享它们,无法区分系列;
  • 非 DICOM DA 格式的值视为无日期。例如某些系列级元数据携带已格式化用于显示的日期,19-Jan-2026若被当成数字会读成192026(按月中某日排序),因此必须拒绝;
  • 带 UTC 偏移&ZZXXAcquisitionDateTime会通过expandDicomDateTime移动到查看器的时区,读出的日期/时间是同一时刻的本地挂钟读数。

getLatestInstanceDateTime还接受数组输入——多实例派生系列的日期/时间取其最新创建的实例的日期/时间(latestInstanceDateTime.ts#L231-L232)。

UTC 偏移的处理:expandDicomDateTime

AcquisitionDateTime是 DICOM DT 值,可以YYYYMMDDHHMMSS.ffffff&ZZXX形式携带 UTC 偏移。expandDicomDateTime(latestInstanceDateTime.ts#L104-L159)负责将其换算到查看器本地时区:

  • 声明了偏移的 DT → 移动到查看器时区,返回本地挂钟读数。例如-0400的正午是 UTC 16:00,而 UTC 16:00 在-0600时区是 10:00;
  • 未声明偏移的 DT → 原样读取(无从得知其所属时区,与其它裸 DA/TM 的处理一致);
  • 只含日期的 DT → 表示该日的开始,移动后必然带回时间;即使它声明的是查看器自身偏移也带回时间——两个命名同一时刻的 DT 值必须给出同一答案,否则纯日期会排在带时间的等价时刻之前;
  • 查看器在该时刻的偏移被使用,因此跨季节(夏令时)采集也能正确换算。

对应的测试见 latestInstanceDateTime.test.js,其中验证了20260819100000.000000-050020260819150000.000000+0000在任意查看器时区下都得到相同的排序键。

排序键的生成:getDateTimeSortKey 与 getLatestInstanceDateTimeSortKey

为了可比较,日期和时间会转为定宽字符串键(latestInstanceDateTime.ts#L281-L296):

  • getDateTimeSortKey(date, time):无日期返回''(排在最旧),无时间则排在同日期所有带时间值之前;
  • 时间被补齐到定宽,因为HHMMHHMMSS命名同一时刻却无法直接比较;
  • getLatestInstanceDateTimeSortKey(source)即"先取getLatestInstanceDateTime,再转排序键"。

排序键必须从 Display Set 读取,而非 displaySet.instance

compareSeriesDateTimedateTimeSortKey读取的是Display Set 自身的seriesDate/SeriesTime,绝不去读displaySet.instance(sortStudy.ts#L67-L100)。原因有二:

  1. 键不一致会掩盖同系列比较compareSameSeriesDisplaySet只在键平局时才运行。getLatestInstanceDateTime(instance)在一个被拆分的系列的不同 Display Set 之间是不同的(每个 Display Set 展示不同实例),于是它们会按各自展示的实例排序,注册的同系列比较器永远不会运行;
  2. 比较器不一致会产生循环:若系列内用一个键、系列间用另一个键,设系列 A 的两个 Display Set 夹住另一系列的 Display Set B,会得到 A1 < B、B < A2 且 A2 < A1 的循环,Array.prototype.sort对同一输入返回不同结果。

由此产生两个有意为之的推论(在DisplaySet.ts的类型注释 platform/core/src/types/DisplaySet.ts#L66-L103 中有完整合同):

  • 图像 Display Set直接取实例的SeriesDate/SeriesTime,一个系列的所有实例携带完全相同的值,因此同一系列的所有 Display Set 都持有相同值、键平局、在系列列表中保持相邻——这正是 extensions/default/src/getSopClassHandlerModule.js#L102-L103 中图像 Handler 的做法(SeriesDate: instance.SeriesDate)。图像 Display Set 的 Handler 绝不能调用getLatestInstanceDateTime,因为它还会读取每个实例各不相同的AcquisitionDate/AcquisitionTime,导致同一系列不同 Display Set 键不一致、列表顺序错误;
  • 派生 Display Set(SEG、RTSTRUCT、SR、PMAP、PDF、视频、图表)取实例的创建日期/时间:向已存在的 SEG 系列再保存一个分割时,DisplaySetService会给新实例分配自己的 Display Set(SEG/RTSTRUCT/PMAP Handler 没有addInstances),该 Display Set 必须占据"保存时刻"的位置。而需要保持拆分的 Display Set 相邻时,应给它们同一个值(系列的值),并注册addSameSeriesCompare比较器来排列彼此。

另外,若某个 Handler 的addInstances会推进 Display Set 展示的实例(SR Handler 与 chart Handler 会追加到已有 Display Set 而非新建),则必须重写这两个字段,否则显示的日期仍是它所替换的报告的日期,与系列列表刚排好的位置相矛盾。

实例排序:sortByInstanceNumber

实例(instance)层面的排序在 sortByInstanceNumber 中:

  1. 默认按实例号递增排序;仅当实例号无法裁决(平局或双方都无实例号)时,才回退到下面的创建日期/时间,最后再回退到 SOP Instance UID;
  2. 系列的最后一个实例被视为最近创建的——因此实例号无法指出谁最新时,必须用创建时间替代;
  3. 同一实例的多帧(frame)共享该实例的所有日期/时间,因此只按 frameNumber 排序(这同时也让大体积多帧系列排序保持廉价:所有帧共享同一实例号,每对帧都会走到这一步,若先排除 SOPInstanceUID 相同的对,就能提前返回);
  4. 无 SOP Instance UID 的源(如 Display Set 视图模型,日期已格式化用于显示而不可比较)保持原样。

3.14 之前,实例号之后直接回退到 SOP Instance UID;现在改为先回退到创建日期/时间(见迁移指南 Instances tie-break by creation date/time)。

保存时打戳:updateNewInstanceMetadata

为了让上述排序对新保存的对象也成立,updateNewInstanceMetadata(platform/core/src/utils/updateNewInstanceMetadata.ts)在 OHIF 保存每个报告、分割和结构集时打上:

  1. 当前日期/时间InstanceCreationDate/InstanceCreationTime,SOP Common 模块保证每个 IOD 都有);
  2. 该模态 IOD 定义的创建日期/时间对
  3. 比系列中所有既有实例都高 1 的实例号

模态专属的创建属性

updateNewInstanceMetadata.ts中的 modalityDateTimeAttributes 决定了写哪一对:

Modality属性对定义模块
RTSTRUCTStructureSetDate/StructureSetTimeStructure Set 模块
PRPresentationCreationDate/PresentationCreationTimePresentation State Identification 模块
其它所有模态ContentDate/ContentTimeMulti-frame Functional Groups(SEG)、SR Document General(SR)、General Image(图像系列)

关键约束:RTSTRUCT 与 PR 的 IOD 不定义 ContentDate/ContentTime,若给它们写上该属性,严格校验器或归档系统可能拒绝该实例。而getLatestInstanceDateTime会读取全部三类属性对,因此无论模态拿到哪一对,排序结果一致。

实例号必须基于全系列最高值

实例号被设为"系列内所有既有实例的最高实例号 + 1"(updateNewInstanceMetadata.ts#L107-L111)。不能只从单一前驱实例推导:系列中最近创建的实例不一定是实例号最大的那个,若从单个前驱推导1 +其编号,可能与已存在实例冲突。

系列级日期时间由 store 命令生成

实例级打戳只覆盖"对象加入既有系列"时会移动的属性。而系列被创建时的日期/时间必须随对象一起生成(updateNewInstanceMetadata无法区分"新系列"与"既有系列"两种情况),由getCurrentDicomDateTime(updateNewInstanceMetadata.ts#L16-L32)计算,并通过 store 命令传入。原因是 dcmjs 与适配器默认以UTC打上SeriesDate/SeriesTimeStructureSetDate/StructureSetTime——在其它时区这是错误的挂钟读数,午夜附近还会差一天。生成时使用对象自身的时区(有TimezoneOffsetFromUTC用之,否则用本地时区),这样 OHIF 保存的对象在全链路读取时都表现为同一个挂钟时刻。

排序入口与默认行为

sortStudy.ts对外暴露了从顶层到细节的完整排序入口:

  • sortStudy:按排序标准(默认 series 按 SeriesNumber 升序、instance 按 InstanceNumber 升序)对 study 的 series 与 instances 就地排序,deepSort控制是否深入排序实例;
  • sortStudySeries:排序 series/Display Set 列表,可注入自定义seriesSortingCriteria
  • sortStudyInstances:排序实例列表;
  • seriesSortCriteria:defaultseriesInfoSortingCriteria(低优先级模态——如 SR/SEG 等——移到列表末尾,且其中最新的排最前,因为它通常最受关注;其余按默认系列排序,见 seriesInfoSortingCriteria);
  • sortDisplaySetsCopy:返回排序后的 Display Set副本(不修改输入),支持studyInstanceUIDFirst选项把指定 Study 的 Display Set 排到最前、其余保持加载顺序;
  • sortImagesByPatientPosition:当图像具备一致的ImagePositionPatientImageOrientationPatient时,按扫描轴法向的投影距离做患者位置排序。

seriesSortCriteria对象还被作为可替换策略使用,应用层可通过自定义seriesSortingCriteria覆盖默认排序(见SortDisplaySetsCopyOptions的注释 sortStudy.ts#L195-L203)。

与 3.13 → 3.14 迁移的衔接

上述日期/时间排序规则与保存打戳行为自 3.14 起生效,迁移指南 display-set-ordering.md 汇总了行为变化:

  • 派生 Display Set 若其实例级日期/时间与系列日期/时间不同,位置会改变;系列本身不受影响(系列排序始终是朴素的系列日期/时间排序);
  • 如果你编写 SOP Class Handler:两个字段都要写,且addInstances推进实例时要重写;什么都不写则回退到系列日期/时间(即 3.13 行为);
  • 如果你拆分系列为多个 Display Set:给它们同一个值(系列的值),否则排序键不一致会掩盖addSameSeriesCompare比较器并产生循环;
  • 存储对象新增了此前可能没有的属性;若你曾依赖"实例号取自单一前驱实例",请注意现在基于全系列最高实例号推导;
  • 导出名调整:utils.getSeriesDateTimeutils.getLatestInstanceDateTimeutils.getSeriesDateTimeSortKeyutils.getLatestInstanceDateTimeSortKey,类型SeriesDateTimeLatestInstanceDateTime,模块seriesDateTimelatestInstanceDateTime。两个字段名SeriesDate/SeriesTime保持不变(因为 Handler 需要把这两个字段赋给 Display Set)。

小结

OHIF 的显示集排序是一套分层、可配置且对 DICOM 语义敏感的体系:

  • 顶层SeriesInstanceUID分组,同组内由addSameSeriesCompare注册的比较器或sortVector决定次序,平局回退实例号;
  • 日期/时间键一律从 Display Set 自身读取,图像与派生 Display Set 分别使用系列日期与getLatestInstanceDateTime,从而保证"同系列 Display Set 键平局、比较器能运行"且比较器自洽无循环;
  • 保存侧updateNewInstanceMetadata打上实例级创建时间与全系列最高实例号 + 1,由getCurrentDicomDateTime保证系列级日期时间使用正确的挂钟时区。

无论是为自定义模态编写 SOP Class Handler、拆分系列,还是处理多时区增强型数据,这套规则都提供了确定性的排序结果,值得作为 OHIF 扩展开发的必读规范。

【免费下载链接】ViewersOHIF zero-footprint DICOM viewer and oncology specific Lesion Tracker, plus shared extension packages项目地址: https://gitcode.com/GitHub_Trending/vi/Viewers

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询