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 个乳腺摄影视图:
LCC、RCC、LMLO、RMLO,用户希望所有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); };逻辑分三层:
- 两侧 Display Set 的
compareSameSeries名称相同 → 使用注册在该名称下的比较函数; - 名称不同 → 使用两个比较器的
priority值大小(priority 小者在前); - 以上比较结果为 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/StructureSetTime | RTSTRUCT 专属 |
PresentationCreationDate/PresentationCreationTime | PR 专属 |
SeriesDate/SeriesTime | 系列级 |
选取规则(latestInstanceDateTime.ts#L238-L255 中的consider逻辑):
- 日期取所有这些属性中最晚的日期;
- 时间取携带该完全相同日期的属性中最晚的时间——时间永远不会与一个不是它一起到达的日期组合,因此结果总是真实发生过的一个日期/时间;
- 当胜出日期没有任何属性携带时间时,时间为空,排序精度只到天——这是数据允许的最好结果;
StudyDate/StudyTime不参与:同一 Study 的每个系列都共享它们,无法区分系列;- 非 DICOM DA 格式的值视为无日期。例如某些系列级元数据携带已格式化用于显示的日期,
19-Jan-2026若被当成数字会读成192026(按月中某日排序),因此必须拒绝; - 带 UTC 偏移
&ZZXX的AcquisitionDateTime会通过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-0500与20260819150000.000000+0000在任意查看器时区下都得到相同的排序键。
排序键的生成:getDateTimeSortKey 与 getLatestInstanceDateTimeSortKey
为了可比较,日期和时间会转为定宽字符串键(latestInstanceDateTime.ts#L281-L296):
getDateTimeSortKey(date, time):无日期返回''(排在最旧),无时间则排在同日期所有带时间值之前;- 时间被补齐到定宽,因为
HHMM与HHMMSS命名同一时刻却无法直接比较; getLatestInstanceDateTimeSortKey(source)即"先取getLatestInstanceDateTime,再转排序键"。
排序键必须从 Display Set 读取,而非 displaySet.instance
compareSeriesDateTime与dateTimeSortKey读取的是Display Set 自身的seriesDate/SeriesTime,绝不去读displaySet.instance(sortStudy.ts#L67-L100)。原因有二:
- 键不一致会掩盖同系列比较:
compareSameSeriesDisplaySet只在键平局时才运行。getLatestInstanceDateTime(instance)在一个被拆分的系列的不同 Display Set 之间是不同的(每个 Display Set 展示不同实例),于是它们会按各自展示的实例排序,注册的同系列比较器永远不会运行; - 比较器不一致会产生循环:若系列内用一个键、系列间用另一个键,设系列 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 中:
- 默认按实例号递增排序;仅当实例号无法裁决(平局或双方都无实例号)时,才回退到下面的创建日期/时间,最后再回退到 SOP Instance UID;
- 系列的最后一个实例被视为最近创建的——因此实例号无法指出谁最新时,必须用创建时间替代;
- 同一实例的多帧(frame)共享该实例的所有日期/时间,因此只按 frameNumber 排序(这同时也让大体积多帧系列排序保持廉价:所有帧共享同一实例号,每对帧都会走到这一步,若先排除 SOPInstanceUID 相同的对,就能提前返回);
- 无 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 保存每个报告、分割和结构集时打上:
- 当前日期/时间(
InstanceCreationDate/InstanceCreationTime,SOP Common 模块保证每个 IOD 都有); - 该模态 IOD 定义的创建日期/时间对;
- 比系列中所有既有实例都高 1 的实例号。
模态专属的创建属性
updateNewInstanceMetadata.ts中的 modalityDateTimeAttributes 决定了写哪一对:
| Modality | 属性对 | 定义模块 |
|---|---|---|
RTSTRUCT | StructureSetDate/StructureSetTime | Structure Set 模块 |
PR | PresentationCreationDate/PresentationCreationTime | Presentation State Identification 模块 |
| 其它所有模态 | ContentDate/ContentTime | Multi-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/SeriesTime和StructureSetDate/StructureSetTime——在其它时区这是错误的挂钟读数,午夜附近还会差一天。生成时使用对象自身的时区(有TimezoneOffsetFromUTC用之,否则用本地时区),这样 OHIF 保存的对象在全链路读取时都表现为同一个挂钟时刻。
排序入口与默认行为
sortStudy.ts对外暴露了从顶层到细节的完整排序入口:
- sortStudy:按排序标准(默认 series 按 SeriesNumber 升序、instance 按 InstanceNumber 升序)对 study 的 series 与 instances 就地排序,
deepSort控制是否深入排序实例; - sortStudySeries:排序 series/Display Set 列表,可注入自定义
seriesSortingCriteria; - sortStudyInstances:排序实例列表;
- seriesSortCriteria:
default即seriesInfoSortingCriteria(低优先级模态——如 SR/SEG 等——移到列表末尾,且其中最新的排最前,因为它通常最受关注;其余按默认系列排序,见 seriesInfoSortingCriteria); - sortDisplaySetsCopy:返回排序后的 Display Set副本(不修改输入),支持
studyInstanceUIDFirst选项把指定 Study 的 Display Set 排到最前、其余保持加载顺序; - sortImagesByPatientPosition:当图像具备一致的
ImagePositionPatient与ImageOrientationPatient时,按扫描轴法向的投影距离做患者位置排序。
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.getSeriesDateTime→utils.getLatestInstanceDateTime,utils.getSeriesDateTimeSortKey→utils.getLatestInstanceDateTimeSortKey,类型SeriesDateTime→LatestInstanceDateTime,模块seriesDateTime→latestInstanceDateTime。两个字段名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),仅供参考