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/core与extensions/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)下另一个已加载系列添加新版本,通过下拉选择其描述 | 没有已加载的同模态系列时不可用 |
关于三种目标,文档特别强调了两点语义:
- 三种目标存储的是同一个对象:即当前服务中保存的全部数据——测量服务里的测量、分割服务里的分割段。目标只决定对象归属哪个系列、取代哪个实例。
Replace existing不是合并:它既不加载目标系列中已存的数据来并入,也不会漏掉当前数据中的任何一项。被取代的实例被保留,只是不再作为默认加载的实例。
已存在的系列保留自己的系列号和系列描述,因此对Save to current与Replace existing而言,这两者都是只读的,显示值随所选系列而定。
存入某系列后,该系列成为"该数据最后一次存储为"的系列,因此在一次Replace existing之后再次保存,默认会落到Save to current,指向刚被替换的系列。
2.2 对话框标题与底部布局
三个对话框的标题随行为更新:
| 3.13 | 3.14 |
|---|---|
Store Segmentation | Save Segmentation |
Store Contours | Save Contours |
Create Report | Save Measurements |
若调用方自己传入title,或某个 mode 设置了defaultSaveTitle,则不受影响。底部布局也从InputDialog.Actions提供的右对齐按钮簇,改为一个FooterAction:左侧是Download,右侧是Cancel与主操作按钮。
2.3createReportDialogPrompt的新增输入参数
itemName与defaultSeriesDescription都是新增且可选的参数,官方给出的调用示例(保存分割)如下:
// `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。itemType与rememberedDescriptionCount:同样新增且可选,控制系列描述记忆,见 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时,SeriesDescription与SeriesNumber会被忽略,因为前驱的系列数据会被应用到生成的实例上——这正是对话框在扩展系列时把它们显示为只读的原因。
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)
用于存储某类条目的系列描述会被记住,以便下次存储同类型条目时再次提供。数据存放在localStorage的ohif.seriesDescriptionHistory键下,按"最近使用优先"排序,并以被存储条目的类型为键,因此分割、轮廓、报告各自拥有独立的列表。
两个新增可选输入控制该机制:
itemType:描述被记住所用的键。默认取modality——对仓库内调用方(SEG、RTSTRUCT、SR)已经足够区分,因此只在"一个模态覆盖多种需要各自列表的条目"时才需要显式传入。rememberedDescriptionCount:记住多少条,默认5。0 会禁用该功能:什么都不记住、什么都不提供、不显示下拉框,退化为普通描述输入框。不希望在本地存储中持久化任何数据的部署应传rememberedDescriptionCount: 0。
对话框的Save as new描述字段行为:
- 初始值取四个名称中的第一个:
itemName→ 数据加载来源的描述 → 该类型条目上次使用的描述 →defaultSeriesDescription;被清空的字段会回退到同样的名称序列; - 提供一个按上述顺序排列的下拉列表,记住的描述按最近使用在前排列;
- 列表会随输入收窄为可补全的条目,Tab补全到第一项,方向键加 Enter 选择。
记忆规则:仅在用于创建新系列时记录(包括下载场景);存入已存在的系列不记录(该系列保留自身描述);使用defaultSeriesDescription的保存也不记录(调用方每次保存都提供该名称,对话框无论如何都会提供它)——因此自动生成的分割标签不会进入历史。重复使用某描述会把它移回列表最前而非重复记录,匹配时忽略大小写。
底层实现见 seriesDescriptionHistory.ts:
getSeriesDescriptionHistory(itemType, maxCount)返回某类型最近使用的描述,maxCount <= 0直接返回空数组;rememberSeriesDescription(itemType, description, maxCount)去重(大小写不敏感)后把新描述插到队首并截断到maxCount;- 所有读写都做了防御性处理——
readHistory用try/catch包裹localStorage.getItem,因为部分浏览器配置下 localStorage 不可用且内容不可信,读取失败仅意味着"没有历史"。
2.7 替换自定义对话框组件
自定义的ohif.createReportDialog组件会收到新的defaultSeriesDescription、itemType和rememberedDescriptionCountprops,其onSave回调负载在既有的reportName、dataSource、series、priorSeriesNumber之外新增了seriesNumber。只创建新系列的对话框可以传series: null并自行指定seriesNumber。
描述记忆是对话框自身的行为,通过extensions/default/src/utils/seriesDescriptionHistory.ts中的getSeriesDescriptionHistory与rememberSeriesDescription完成。替换对话框若想保持相同行为应调用这两个函数;不需要的则直接忽略itemType与rememberedDescriptionCount即可。
三、显示集日期/时间排序:派生系列按创建时刻排序
派生系列(报告、分割、结构集)现在按各自创建时间排序,最近创建的离图像最近。要得到正确顺序,涉及两个你可能依赖了旧行为的变化。
关于显示集日期/时间如何选定、用于何处,可参阅 开发笔记:显示集日期与时间。
3.1 显示集的SeriesDate/SeriesTime现在就是显示集自身的日期/时间
compareSeriesDateTime仍然比较两侧的SeriesDate/SeriesTime,dateTimeSortKey仍然从显示集读取它们。变的是SOP class handler 写入这两个字段的值:handler 现在写入显示集所展示实例的getLatestInstanceDateTime——从该实例携带的全部创建属性中选择:InstanceCreationDate/Time、ContentDate/Time、AcquisitionDate/Time(或AcquisitionDateTime)、StructureSetDate/Time、PresentationCreationDate/Time、SeriesDate/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 没有addInstances,DisplaySetService给新实例自己的显示集,该显示集占据保存发生的位置。若想保持拆分系列相邻,则给拆分的每个显示集赋系列级值,并注册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 都有。对象自身的创建日期/时间取决于模态:
| 模态 | 属性对 | 定义它的模块 |
|---|---|---|
RTSTRUCT | StructureSetDate/Time | Structure Set |
PR | PresentationCreationDate/Time | Presentation State Identification |
| 其他所有模态 | ContentDate/Time | Multi-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。
对你的影响:在每个调用处改名。两个函数的行为与之前完全一致。
| Before | Now |
|---|---|
utils.getSeriesDateTime | utils.getLatestInstanceDateTime |
utils.getSeriesDateTimeSortKey | utils.getLatestInstanceDateTimeSortKey |
类型SeriesDateTime | 类型LatestInstanceDateTime |
模块platform/core/src/utils/seriesDateTime | 模块platform/core/src/utils/latestInstanceDateTime |
类型中的两个字段仍叫SeriesDate和SeriesTime,因为 handler 会把这两个字段赋给显示集,而显示集正是在这两个名字下持有它们。getSeriesDateTime不提供别名,因为该名称从未进入 OHIF 的稳定发布——只存在于 3.14.0-beta.25 这一行。
此外,extensions/default/src/utils/getCurrentDicomDateTime.ts也曾导出一个getSeriesDateTime(该函数返回当前日期与时间)。没有任何模块导入该文件,本版本已删除它。需要当前日期和时间请使用platform/core的utils.getCurrentDicomDateTime。
四、迁移核对清单
升级到 3.14 时,按以下清单逐项检查:
- 调用方迁移:所有使用
createReportDialogPrompt的地方,把SeriesNumber: 1 + priorSeriesNumber改为SeriesNumber: seriesNumber,并为分割保存补传itemName/defaultSeriesDescription。 - 自定义对话框:
ohif.createReportDialog组件补上新 props,onSave负载按需读取seriesNumber;不想在 localStorage 留下痕迹的部署传rememberedDescriptionCount: 0。 - SOP class handler:派生显示集的 handler 需要写入
getLatestInstanceDateTime的结果,并在addInstances后重写;图像显示集 handler 仍直接写instance.SeriesDate/SeriesTime(详见 latestInstanceDateTime.ts 的注释警告——图像 handler 若误用该函数不会报错,但会把系列列表排错)。 addSameSeriesCompare:注册的比较器现在会真正改变顺序,确认这是预期行为;否则传null移除。- 重命名导出:把
getSeriesDateTime相关调用改为getLatestInstanceDateTime/getLatestInstanceDateTimeSortKey,类型改名为LatestInstanceDateTime。
本指南的全部行为细节与实现依据分别位于 迁移指南目录、保存对话框行为文档 以及上文引用的platform/core与extensions/default源码文件,可按需深入查阅。
【免费下载链接】ViewersOHIF zero-footprint DICOM viewer and oncology specific Lesion Tracker, plus shared extension packages项目地址: https://gitcode.com/GitHub_Trending/vi/Viewers
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考