OHIF Viewer 3.13 到 3.14 迁移指南:保存/报告对话框与显示集日期时间排序的完整改造
2026/9/18 16:11:09 网站建设 项目流程

OHIF Viewer 3.13 到 3.14 迁移指南:保存/报告对话框与显示集日期时间排序的完整改造

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

导读

本文围绕 OHIF Viewer(zero-footprint DICOM viewer)从 3.13 升级到 3.14 时必须关注的两大行为变更展开:其一是保存测量、分割与轮廓(contour)时的保存/报告对话框createReportDialogPrompt)从单一系列下拉框改为"Save to current / Save as new / Replace existing"三种明确目标;其二是显示集(display set)的日期/时间排序规则全面重写,派生系列按对象创建时刻排序,且addSameSeriesCompare注册的比较器终于真正生效。读完本文,你将掌握新对话框 API 的输入输出契约、系列描述记忆机制的配置方法、显示集日期时间的底层选择算法,以及迁移后需要逐一核查的代码改动点。

本文以 3p13-to-3p14 迁移指南总览 为骨架,其两个子文档 保存/报告对话框目标 与 显示集日期/时间排序 为双主体,并对照platform/coreextensions/default中的真实源码展开底层原理。

一、迁移总览:3.13 -> 3.14 改了什么

3.13 到 3.14 的迁移主要覆盖两块相互独立但都影响日常操作体验的改动:

  • 保存/报告对话框:用于存储测量、分割和轮廓的对话框,用三段式选择控件(segmented control)取代了原来混合了"新建系列"与"存入已有系列"两种完全不同操作的单一系列下拉框,让用户明确看到本次保存的去向与后果。
  • 显示集日期/时间排序:派生显示集(报告、分割、结构集)改为按其展示实例的创建日期/时间排序,而不是按系列自身的SeriesDate/SeriesTime排序;同时修复了addSameSeriesCompare注册比较器"注册了却不生效"的缺陷,并让保存的报告与分割被盖上创建日期/时间和实例号。

下方两个小节分别展开这两块改动的细节、参数与迁移动作。

二、保存/报告对话框:三种明确的目标选择

2.1 为什么改:旧下拉框的语义混淆

旧版本中,createReportDialogPrompt(以及它渲染的ohif.createReportDialog定制组件)里的系列下拉框把两种差异很大的操作混在了一起:新建一个系列,与存入一个已经存在的系列。用户无从得知即将发生的是哪一种、会对已有数据造成什么影响。

3.14 将其改为在Series行上通过分段控件(segmented control)明确三选一,控件下方有一行帮助文字,且提交保存的按钮上也重复同样的措辞:

目标行为可用性
Save to current为数据加载来源的系列(由predecessorImageId标识)添加一个新版本;本次存储的实例成为该系列默认加载的实例,已有数据保留但不再是默认存在这样的系列时默认选中;否则不可用
Save as new创建一个独立的新系列,无前驱(predecessor)数据尚未保存过时的默认选择;始终可用
Replace existing为该模态(modality)下另一个已加载系列添加新版本,通过下拉选择其描述没有已加载的同模态系列时不可用

关于三种目标,文档特别强调了两点语义:

  1. 三种目标存储的是同一个对象:即当前服务中保存的全部数据——测量服务里的测量、分割服务里的分割段。目标只决定对象归属哪个系列、取代哪个实例。
  2. Replace existing不是合并:它既不加载目标系列中已存的数据来并入,也不会漏掉当前数据中的任何一项。被取代的实例被保留,只是不再作为默认加载的实例。

已存在的系列保留自己的系列号和系列描述,因此对Save to currentReplace existing而言,这两者都是只读的,显示值随所选系列而定。

存入某系列后,该系列成为"该数据最后一次存储为"的系列,因此在一次Replace existing之后再次保存,默认会落到Save to current,指向刚被替换的系列。

2.2 对话框标题与底部布局

三个对话框的标题随行为更新:

3.133.14
Store SegmentationSave Segmentation
Store ContoursSave Contours
Create ReportSave Measurements

若调用方自己传入title,或某个 mode 设置了defaultSaveTitle,则不受影响。底部布局也从InputDialog.Actions提供的右对齐按钮簇,改为一个FooterAction:左侧是Download,右侧是Cancel与主操作按钮。

2.3createReportDialogPrompt的新增输入参数

itemNamedefaultSeriesDescription都是新增且可选的参数,官方给出的调用示例(保存分割)如下:

// `labelIsGenerated` 表示服务自动生成了标签,说明用户没有为这个分割命名。 // 自动生成的名称进入 `defaultSeriesDescription`,用户选择的名称进入 `itemName`。 const { label, labelIsGenerated } = segmentation; const { value, series, seriesNumber, dataSourceName, action } = await createReportDialogPrompt({ servicesManager, extensionManager, title: 'Save Segmentation', modality: 'SEG', predecessorImageId, // 新增:用户为该条目选择的名称,新建系列时优先提供 itemName: labelIsGenerated ? '' : label, // 新增:条目没有其他名称时兜底使用的名称,新建系列时最后提供 defaultSeriesDescription: (labelIsGenerated && label) || 'Segmentation', enableDownload: true, });

各参数语义如下:

  • itemName:用户为被存储条目选择的名称。新建系列时优先提供itemName,因此保存前用户做的重命名能直接进入描述字段。没有此类名称的调用方可以不传:测量报告不传itemName;当标签仍是服务自动生成的名字时,storeSegmentation也不传。自动生成的名称应归入defaultSeriesDescription,避免它压过记忆中的描述——完整规则见 行为文档。
  • Segmentation.labelIsGenerated:这是@cornerstonejs/tools新增的标志,由SegmentationPublicInput.config携带。创建者发明标签时在标签旁传labelIsGenerated: true;完全不提供标签的创建者也会获得该标志(因为没有用户选择的名称);携带label但不带labelIsGenerated的更新会清除该标志,从而让重命名获得"用户已命名"的身份。3.13 时代查看器会向分割状态写入私有属性generatedLabel供调用方比对字符串,现在调用方直接读取标志即可。
  • defaultSeriesDescription:条目没有任何其他名称时使用的名字,新建系列时最后提供。仓库内的调用方分别传入Segmentation(SEG)、Contours(RTSTRUCT 导出)和Measurements(测量报告)。这样用户得到的是有意义的系列描述而不是空字段——3.13 中未编辑的描述会把对话框标题存为系列描述,例如未输入名称的测量报告会被存成Create Report
  • itemTyperememberedDescriptionCount:同样新增且可选,控制系列描述记忆,见 2.6 节。
  • predecessorImageId语义不变,但现在它决定"扩展已有系列"是否可能,而不只是下拉框的默认值。若predecessorImageId不属于给定模态的已加载系列,则回退为新建系列,而不会声称更新一个无法描述的系列。对话框只在两种条件下把已加载系列作为目标:该系列持有正在存储的模态,且系列拥有predecessorImageId值。对话框不会回退使用显示集的SeriesInstanceUID——因为PredecessorSequenceprovider 遇到 UID 会抛错。上传实例携带的本地 id 的完整规则同样见 行为文档。

2.4createReportDialogPrompt的输出变化

新增seriesNumber返回项,并对既有返回项调整语义:

  • seriesNumber:存储该实例所使用的系列号——即对话框中显示的值,包括用户对其做的编辑。应优先于1 + priorSeriesNumber,后者看不到用户的编辑。
  • priorSeriesNumber:现在是seriesNumber - 1,因此既有调用方计算1 + priorSeriesNumber依然工作,并能拾取编辑后的系列号。它不再是该模态已存在的最大系列号。
  • value:系列描述。扩展已有系列时,它就是该系列既有的描述(已有系列保留自身描述)。
  • series:语义不变——被扩展的系列(以predecessorImageId值表示),新建系列时为 falsy。

迁移前后的调用对照:

Before (3.13):

options: { SeriesDescription, SeriesNumber: 1 + priorSeriesNumber, predecessorImageId: series, }

After (3.14):

options: { SeriesDescription, SeriesNumber: seriesNumber, predecessorImageId: series, }

注意:当设置了predecessorImageId时,SeriesDescriptionSeriesNumber会被忽略,因为前驱的系列数据会被应用到生成的实例上——这正是对话框在扩展系列时把它们显示为只读的原因。

2.5 保存后,被存储对象成为前驱(predecessor)

向当前系列保存依赖数据知道自己"最后一次存成哪个实例"——即其predecessorImageId。3.13 中该信息只对从存储加载的数据可知,因此分割或报告首次保存会新建系列,第二次、第三次保存依然各自新建系列,每次都产生一个新系列。

3.14 中,从查看器存储的实例采用与数据源加载的实例相同的方式标识:registerStoredInstanceImageId在实例加入元数据存储前,赋予它"重新加载回来时会用到的 imageId",并将该 imageId 映射到其 UID 上。由存储实例生成的显示集因此带有predecessorImageId,两种保存类型据此记录:

  • 分割与轮廓:保存后从刚写入的系列重新加载(保存会移除内存中的分割并显示存储的版本),重载的分割通过正常加载路径从该显示集拾取前驱;
  • 测量:不会从它们被存入的报告重新加载,因此新增的recordMeasurementsPredecessor命令(CORNERSTONE)直接在测量上记录前驱——同时记录在测量及其派生的标注上,这样之后编辑测量不会丢失该信息。

实际效果是:对同一数据连续保存两次时,第二次默认落到Save to current,指向第一次保存创建的系列。相关实现见 registerStoredInstanceImageId.ts:它通过dataSource.getImageIdsForInstance推导 imageId(失败则回退到本地 wadouri 文件管理器写入的url),随后调用metadataProvider.addImageIdToUIDs建立 imageId 到StudyInstanceUID/SeriesInstanceUID/SOPInstanceUID的映射。

2.6 记住的系列描述(Remembered series descriptions)

用于存储某类条目的系列描述会被记住,以便下次存储同类型条目时再次提供。数据存放在localStorageohif.seriesDescriptionHistory键下,按"最近使用优先"排序,并以被存储条目的类型为键,因此分割、轮廓、报告各自拥有独立的列表。

两个新增可选输入控制该机制:

  • itemType:描述被记住所用的键。默认取modality——对仓库内调用方(SEGRTSTRUCTSR)已经足够区分,因此只在"一个模态覆盖多种需要各自列表的条目"时才需要显式传入。
  • rememberedDescriptionCount:记住多少条,默认50 会禁用该功能:什么都不记住、什么都不提供、不显示下拉框,退化为普通描述输入框。不希望在本地存储中持久化任何数据的部署应传rememberedDescriptionCount: 0

对话框的Save as new描述字段行为:

  • 初始值取四个名称中的第一个:itemName→ 数据加载来源的描述 → 该类型条目上次使用的描述 →defaultSeriesDescription;被清空的字段会回退到同样的名称序列;
  • 提供一个按上述顺序排列的下拉列表,记住的描述按最近使用在前排列;
  • 列表会随输入收窄为可补全的条目,Tab补全到第一项,方向键加 Enter 选择。

记忆规则:仅在用于创建新系列时记录(包括下载场景);存入已存在的系列不记录(该系列保留自身描述);使用defaultSeriesDescription的保存也不记录(调用方每次保存都提供该名称,对话框无论如何都会提供它)——因此自动生成的分割标签不会进入历史。重复使用某描述会把它移回列表最前而非重复记录,匹配时忽略大小写。

底层实现见 seriesDescriptionHistory.ts:

  • getSeriesDescriptionHistory(itemType, maxCount)返回某类型最近使用的描述,maxCount <= 0直接返回空数组;
  • rememberSeriesDescription(itemType, description, maxCount)去重(大小写不敏感)后把新描述插到队首并截断到maxCount
  • 所有读写都做了防御性处理——readHistorytry/catch包裹localStorage.getItem,因为部分浏览器配置下 localStorage 不可用且内容不可信,读取失败仅意味着"没有历史"。

2.7 替换自定义对话框组件

自定义的ohif.createReportDialog组件会收到新的defaultSeriesDescriptionitemTyperememberedDescriptionCountprops,其onSave回调负载在既有的reportNamedataSourceseriespriorSeriesNumber之外新增了seriesNumber。只创建新系列的对话框可以传series: null并自行指定seriesNumber

描述记忆是对话框自身的行为,通过extensions/default/src/utils/seriesDescriptionHistory.ts中的getSeriesDescriptionHistoryrememberSeriesDescription完成。替换对话框若想保持相同行为应调用这两个函数;不需要的则直接忽略itemTyperememberedDescriptionCount即可。

三、显示集日期/时间排序:派生系列按创建时刻排序

派生系列(报告、分割、结构集)现在按各自创建时间排序,最近创建的离图像最近。要得到正确顺序,涉及两个你可能依赖了旧行为的变化。

关于显示集日期/时间如何选定、用于何处,可参阅 开发笔记:显示集日期与时间。

3.1 显示集的SeriesDate/SeriesTime现在就是显示集自身的日期/时间

compareSeriesDateTime仍然比较两侧的SeriesDate/SeriesTimedateTimeSortKey仍然从显示集读取它们。变的是SOP class handler 写入这两个字段的值:handler 现在写入显示集所展示实例的getLatestInstanceDateTime——从该实例携带的全部创建属性中选择:InstanceCreationDate/TimeContentDate/TimeAcquisitionDate/Time(或AcquisitionDateTime)、StructureSetDate/TimePresentationCreationDate/TimeSeriesDate/Time

因此这两个字段现在保存的是显示集的日期/时间,它不一定等于实例所属系列的日期/时间。系列自身的SeriesDate/SeriesTime——无论在实例元数据还是归档中——保持不变。同一系列的每个实例都携带该系列的SeriesDate/SeriesTime,所以 3.13 中第二次保存进既有 SR 系列的报告与第一次无法区分。

显示集类型写入的值
图像(CT、MR、MG、CR、DX、ECG、多帧)实例的SeriesDate/SeriesTime,同一系列每个实例都相同
派生(SEG、RTSTRUCT、SR、PMAP、PDF、视频、chart)显示集所展示实例的创建日期/时间

对你的影响:实例级日期/时间与系列级日期/时间不一致的派生系列显示集,其排序位置会变化。系列本身不受影响:对系列列表排序仍是原有的纯系列日期/时间排序。

迁移要点:

  • 如果你编写 SOP class handler:两个字段都要写,并且在addInstances移动显示集所展示的实例时再次写入。什么都不写的 handler 会得到系列日期/时间,排序行为与 3.13 相同。完整契约见DisplaySet类型的SeriesDate字段。
  • 如果你把一个系列拆成多个显示集:必须给它们同一个值。排序只读显示集、绝不读displaySet.instance,原因有二。其一,从实例读取的键在拆分系列的各个显示集之间不同,会按各自恰好展示的实例排序,导致仅在键相同(平局)时才运行的addSameSeriesCompare比较永远不执行;其二,从实例读键还会让比较器不一致——当系列 A 的两个显示集跨越另一系列 B 的一个显示集时,会出现 A1 < B、B < A2 且 A2 < A1,Array.prototype.sort会因输入顺序不同返回不同结果。

同一系列两个值不同的显示集按值排序,另一系列的显示集可以排在它们之间——这正是第二次分割保存进既有 SEG 系列所需的行为:SEG、RTSTRUCT、PMAP handler 没有addInstancesDisplaySetService给新实例自己的显示集,该显示集占据保存发生的位置。若想保持拆分系列相邻,则给拆分的每个显示集赋系列级值,并注册addSameSeriesCompare比较器来排序。

3.2 声明 UTC 偏移的 DT 值按查看器自身时区读取

AcquisitionDateTime是 DICOM DT,其末尾可能带&ZZXX形式的 UTC 偏移。旧实现取前 8 个字符作日期、其余全部作时间,把偏移数字读成了时间数字:20260819+0500变成凌晨五点,202608191030-0500中的05变成秒。

新的expandDicomDateTime先把偏移拆掉,再把值移动到查看器的时区,使日期与时间是同一时刻的本地钟面读数——-0400的正午是 16:00 UTC,16:00 UTC 在-0600是 10:00。不带偏移的 DT 要表达同一时刻就必须持有这个值,因为无偏移的值按本地时间读取。移动时使用该时刻查看器的偏移,因此跨季节的采集在夏令时上也是正确的。三个边界行为:

  • 未声明偏移的 DT原样读取——没有任何信息说明它写自哪个时区,所有裸 DA/TM 都是同样的静默处理;
  • 只含日期的 DT 表示该日零点,这正是移动所需的读数,返回时带本地该时刻的时间;午夜附近会连日期也跨天;
  • 声明了偏移的 DT 总是带回时间——即使偏移恰好是查看器自己的偏移。两个命名同一时刻的 DT 值必须给出同一个答案,只返回日期会把该值排在同一时刻其他写法之前。

对你的影响:日期/时间来自带偏移的AcquisitionDateTime的显示集位置会变化,且存储在其上的SeriesTime现在是合法的 DICOM TM。旧值曾是 DT 的原始余部(含偏移),如100000.000000-0500,缩略图详情行随后会把它传给formatTime

从源码看,latestInstanceDateTime.ts 中:

  • parseUTCOffset/^([+-])(\d{2})(\d{2})$/解析TimezoneOffsetFromUTC及 DT 末尾偏移,返回相对 UTC 的分钟数;
  • expandDicomDateTime/^(\d{8})(\d{0,6})(\.\d{1,6})?([+-]\d{4})?$/匹配 DT(少于 8 位日期的不匹配,因为"年/月"不是可排序的"日"),有偏移时通过Date对象按偏移移位后用本地 getter 读回钟面时间,秒与小数秒原样保留(UTC 偏移是整分钟,秒不受影响)。

3.3addSameSeriesCompare比较器现在真正生效

通过addSameSeriesCompare注册的比较器用于排序同一系列的两个显示集。此前compareSameSeriesDisplaySet只在比较器返回0时采用其结果,一旦比较器真正把两侧分出先后就丢弃、回退到实例比较——因此已注册的比较器对最终顺序毫无影响。

现在按文档语义应用:非零结果决定顺序,只有平局才回退到实例比较

对你的影响:注册过比较器的场景现在会生效,可能改变系列内显示集的顺序——此前它们只按实例号排序。若想保持旧顺序,把注册的比较函数改为null即可移除:

addSameSeriesCompare(name, null, priority);

该 API 定义于 sortStudy.ts,对应测试见 sortStudy.test.js。

3.4 实例按创建日期/时间平局决胜

sortByInstanceNumber原先按实例号排序、再按 SOP instance UID 排序。现在平局时先回退到创建日期/时间,再回退到 SOP instance UID

实例号仍是主键,图像系列给每个实例唯一的实例号,因此图像系列顺序不变。回退只在实例号相同或两者都无实例号时发生——这正是原先由 SOP instance UID(一个任意标识符)决定"系列中哪个实例最新"的地方。同一实例的两个帧被排除在外:它们共享实例唯一的日期/时间,因此只由帧号排序。

3.5 保存时新实例被盖章

updateNewInstanceMetadata为 OHIF 保存的每个报告、分割和结构集盖上:

  • InstanceCreationDate/Time
  • 该模态 IOD 定义的创建日期/时间对;
  • 一个比系列内已有每个实例都大 1 的实例号。

InstanceCreationDate/Time属于 SOP Common 模块,因此每个 IOD 都有。对象自身的创建日期/时间取决于模态:

模态属性对定义它的模块
RTSTRUCTStructureSetDate/TimeStructure Set
PRPresentationCreationDate/TimePresentation State Identification
其他所有模态ContentDate/TimeMulti-frame Functional Groups(SEG)、SR Document General(SR)、General Image(图像系列)

RTSTRUCT 与 PR 的 IOD 完全不定义内容日期/时间,因此给它们写ContentDate/Time是严格校验器或归档可以拒绝实例的属性。getLatestInstanceDateTime读取全部三对,所以无论模态使用哪一对,排序结果一致。

日期/时间按数据集中自身时区的钟面值读取(声明了TimezoneOffsetFromUTC用其偏移,否则用本地时区)——因为查看器正是这样显示它们的。

系列级日期/时间无法事后盖章:加入既有系列的对象必须保留该系列自身的系列级日期/时间。因此存储命令在生成对象时就带上了应有的系列日期/时间:新系列用SeriesDate/Time,结构集一律用StructureSetDate/Time,且都使用本地时区而非 dcmjs 与适配器默认的 UTC 值。

对你的影响:存储对象现在带上了此前可能没有的这些属性。如果你对保存的实例做后处理,并曾依赖"实例号来自单一前驱实例",请注意它现在由整个系列的最高实例号推导而来。

从 updateNewInstanceMetadata.ts 可以看到实现细节:priorInstanceNumber是对DicomMetadataStore中该系列全部实例InstanceNumber求最大值(不传priorInstances时从元数据存储读取),dataset.InstanceNumber = priorInstanceNumber + 1;随后通过getCurrentDicomDateTime(new Date(), dataset.TimezoneOffsetFromUTC)生成 DA/TM 值写入InstanceCreationDate/Time与模态对应的创建属性对。

3.6 导出名称改为getLatestInstanceDateTime

platform/core曾在 3.14.0-beta.25 中以getSeriesDateTime导出该函数。该名称暗示"给出系列的日期和时间",而函数实际并不做这件事——它给出的是实例携带属性中最新的日期,以及同一日期携带的最新时间。现在的导出名为getLatestInstanceDateTime,排序键导出名为getLatestInstanceDateTimeSortKey

对你的影响:在每个调用处改名。两个函数的行为与之前完全一致。

BeforeNow
utils.getSeriesDateTimeutils.getLatestInstanceDateTime
utils.getSeriesDateTimeSortKeyutils.getLatestInstanceDateTimeSortKey
类型SeriesDateTime类型LatestInstanceDateTime
模块platform/core/src/utils/seriesDateTime模块platform/core/src/utils/latestInstanceDateTime

类型中的两个字段仍叫SeriesDateSeriesTime,因为 handler 会把这两个字段赋给显示集,而显示集正是在这两个名字下持有它们。getSeriesDateTime不提供别名,因为该名称从未进入 OHIF 的稳定发布——只存在于 3.14.0-beta.25 这一行。

此外,extensions/default/src/utils/getCurrentDicomDateTime.ts也曾导出一个getSeriesDateTime(该函数返回当前日期与时间)。没有任何模块导入该文件,本版本已删除它。需要当前日期和时间请使用platform/coreutils.getCurrentDicomDateTime

四、迁移核对清单

升级到 3.14 时,按以下清单逐项检查:

  1. 调用方迁移:所有使用createReportDialogPrompt的地方,把SeriesNumber: 1 + priorSeriesNumber改为SeriesNumber: seriesNumber,并为分割保存补传itemName/defaultSeriesDescription
  2. 自定义对话框ohif.createReportDialog组件补上新 props,onSave负载按需读取seriesNumber;不想在 localStorage 留下痕迹的部署传rememberedDescriptionCount: 0
  3. SOP class handler:派生显示集的 handler 需要写入getLatestInstanceDateTime的结果,并在addInstances后重写;图像显示集 handler 仍直接写instance.SeriesDate/SeriesTime(详见 latestInstanceDateTime.ts 的注释警告——图像 handler 若误用该函数不会报错,但会把系列列表排错)。
  4. addSameSeriesCompare:注册的比较器现在会真正改变顺序,确认这是预期行为;否则传null移除。
  5. 重命名导出:把getSeriesDateTime相关调用改为getLatestInstanceDateTime/getLatestInstanceDateTimeSortKey,类型改名为LatestInstanceDateTime

本指南的全部行为细节与实现依据分别位于 迁移指南目录、保存对话框行为文档 以及上文引用的platform/coreextensions/default源码文件,可按需深入查阅。

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

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

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

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

立即咨询