OHIF Viewer 分割与轮廓显示行为规格:SEG/RTSTRUCT 的加载、Hydration 与展示全解析
【免费下载链接】ViewersOHIF zero-footprint DICOM viewer and oncology specific Lesion Tracker, plus shared extension packages项目地址: https://gitcode.com/GitHub_Trending/vi/Viewers
导读:DICOM SEG(labelmap 分割)与 RTSTRUCT(轮廓)如何在 OHIF Viewer 中上屏、数据何时被拉取、以及"从屏幕上移除"到底意味着什么,是医学影像前端中极易产生歧义的领域。本文以仓库中的行为规格文档 specs/segmentation-and-contour-display.ears.md 为骨架,结合 cornerstone 扩展、dicom-seg/dicom-rt 扩展与默认扩展的命令实现,系统讲解 overlay 显示集模型、自动/手动展示、hydration 机制、资格判定(eligibility)规则、移除与删除的语义差异,以及 PR #5996 引入的"hydration 是显示集层面的陈述"这一设计转向,帮助开发者理解并能实际配置 OHIF 中分割数据的完整生命周期。
一、一个核心事实:分割永远是叠加层,而不是视口的"主图"
整份行为规格的第一条规则(SEG-1)也是理解一切后续行为的基石:
分割永远不是一个视口的"画面"(picture),它始终是绘制在其引用序列之上的叠加层(overlay)。
在 OHIF 中,SEG 与 RTSTRUCT 的显示集被标记为overlay display set(叠加显示集)。这意味着:
- 它们没有属于自己的主像素(primary pixels)可供独立显示;
- 它们携带一个指向派生来源序列的引用(
referencedDisplaySetInstanceUID); - 当 OHIF 被要求"显示一个分割"时,它真正做的是:显示被引用的序列,然后把分割叠加在上面。
这一条事实解释了文档中的大部分行为,包括"把一个 SEG 拖入视口会明显改变该视口正在显示的内容"这一现象——因为视口底层已经被切换成了被引用的序列。
从源码中可以验证这一模型。在 cornerstone-dicom-seg/src/getSopClassHandlerModule.ts 中,SEG 显示集创建时被显式标记:
const displaySet = { Modality: 'SEG', isReconstructable: false, // 分割自身不构成可重建体积 isDerivedDisplaySet: true, // 派生显示集 isOverlayDisplaySet: true, // 叠加显示集:绝不作为视口自身内容渲染 isLoaded: false, isHydrated: false, referencedDisplaySetInstanceUID: null, // 稍后由引用序列解析回填 // ... };而专门的 SEG 预览视口 OHIFCornerstoneSEGViewport.tsx 在组装底层视口时,也总是同时传入引用序列与分割本身:
return ( <OHIFCornerstoneViewport {...props} displaySets={[referencedDisplaySet, segDisplaySet]} // 引用序列作背景,SEG 作叠加 viewportOptions={{ viewportType, toolGroupId, orientation, viewportId, presentationIds }} /> );叠加层的数据模型:引用如何被解析
在 getSopClassHandlerModule.ts 中,SEG 实例通过 DICOM 的ReferencedSeriesSequence解析其引用序列:
- 从
ReferencedSeriesSequence[0]读取SeriesInstanceUID; - 通过
displaySetService.getDisplaySetsForReferences(...)找到对应的引用显示集; - 一旦找到,就回填
referencedDisplaySetInstanceUID、isReconstructable和FrameOfReferenceUID(这三个属性从引用显示集拷贝而来,见 isDisplaySetOverlayable.ts 的注释)。
如果引用显示集当时尚未创建(例如 SEG 序列先于其引用的图像序列加载),处理器会订阅DISPLAY_SETS_ADDED事件,等引用序列出现后再完成关联——这保证了"先 SEG 后图像"的加载顺序也能正常工作。
二、分割数据的加载时机:首次进入视口时,且仅此一次
规格中的 SEG-2、SEG-3、SEG-4 定义了加载时机规则:
- SEG-2:在显示集被放入视口之前,系统不得获取或解码分割数据;
- SEG-3:显示集一旦被放入视口,系统应加载它、报告加载进度,并将其注册到分割服务(segmentation service);
- SEG-4:一旦加载完成,后续所有展示都应复用已解码的数据。
换句话说:打开一个研究只会根据元数据创建 SEG/RTSTRUCT 显示集——足够生成缩略图和面板条目——但不会下载或解码任何像素数据。真正的数据获取发生在显示集首次到达视口的那一刻,并且每个分割只加载一次:后续的传播展示、布局变化后的恢复,都复用已解码的数据。
源码层面的实现印证
这一"按需加载 + 幂等去重"的行为在 loadDisplaySetData.ts 中体现为两个关键设计:
- 加载与展示解耦:
loadDisplaySetData只负责"取数据、解码、注册到分割状态",它既不关心也不感知哪个视口会显示这个分割——"结果在哪里显示、如何挂载表示(representation)"是另一个视口范围内的独立步骤。正是这种解耦让"决定哪些叠加层属于哪些视口"可以在视口组装之外完成,而无需把加载作为副作用。 - 加载失败被吞掉:
displaySet.load失败时,向用户弹出Error loading displaySet通知,然后被捕获消化——"一个无法加载的显示集不能阻止请求它的流程完成"。
而 getSopClassHandlerModule.ts 中的_load实现展示了幂等与并发安全细节:
function _load(segDisplaySet, servicesManager, extensionManager, headers) { // 若已在加载或已加载,直接返回同一个 promise,避免重复触发加载 if ( (segDisplaySet.loading || segDisplaySet.isLoaded) && loadPromises[SOPInstanceUID] && _segmentationExists(segDisplaySet) ) { return loadPromises[SOPInstanceUID]; } segDisplaySet.loading = true; // 不触发多次加载:等待第一个完成,并把同一个 promise 返回给其他调用方 loadPromises[SOPInstanceUID] = new Promise(async (resolve, reject) => { // ... 先解码 segment 数据,再 createSegmentationForSEGDisplaySet }); segDisplaySet.loadingPromise = loadPromises[SOPInstanceUID]; // 暴露给等待方 return loadPromises[SOPInstanceUID]; }值得注意的实现细节还有:
- 多帧 SEG 以 Part 10 整体预取(默认开启):SEG 帧又小又多,逐帧请求几百次不如一次批量获取划算。由
loadMultiframeAsPart10控制(数据源配置或cornerstone.segmentation.loadMultiframeAsPart10定制项,默认true),预取"等到完成或失败"——失败会快速回退到逐帧加载,慢的大文件仍然是到达全部帧的最快方式; - 并发解码上限:
SEG_FRAME_DECODE_CONCURRENCY = 16(getSopClassHandlerModule.ts),当前硬编码,计划后续可配置化; - 加载进度:监听
SEGMENTATION_LOAD_PROGRESS事件,将percentComplete广播为SEGMENT_LOADING_COMPLETE,视口据此显示百分比与段数(见 OHIFCornerstoneSEGViewport.tsx); - 颜色:优先采用 DICOM 的
RecommendedDisplayCIELabValue转 RGB,缺失时回退到默认颜色 LUT 并给出警告通知。
失败与超时行为(SEG-22、SEG-23)
- SEG-22:若分割没有引用序列,系统应委托给
missingReferenceDisplaySetHandler定制项,否则跳过渲染。仓库中的默认实现位于 extensions/default/src/customizations/missingReferenceDisplaySetHandler.ts,其默认行为是Promise.resolve({ handled: false })——即不处理、由调用方跳过渲染。SEG/RT 预览视口正是据此在"无引用显示集"(例如通过 SeriesInstanceUID 直接启动 SEG 序列)时优雅跳过渲染,避免视口崩溃(见 OHIFCornerstoneSEGViewport.tsx)。 - SEG-23:若分割加载失败,或两分钟内未完成,系统应跳过绘制该分割,但允许视口完成其余渲染,而不是挂起。
三、自动展示:Hydration(水合)机制
自动展示发生在两种情形中,而这两种都不包含"打开研究时"(SEG-5:除非悬挂协议显式选中,否则研究打开时系统不得显示分割)。
3.1 情形一:用户刚选择查看的分割
当 SEG/RTSTRUCT 被放入视口时,OHIF 渲染一个专门的 SEG/RT 预览视口(OHIFCornerstoneSEGViewport/OHIFCornerstoneRTViewport),它显示被引用序列加上叠加的分割。之后:
- 数据加载完成后,且仅当该视口是活动视口时(SEG-8),系统询问是否要hydrate(水合)这个分割(SEG-7);
- 默认弹出一个对话框,但安装方可以设置
disableConfirmationPrompts,此时水合静默发生(SEG-9); - 水合的含义(SEG-10):把"预览一个派生对象"切换为"把该分割作为引用序列的分割"——视口内容切换为引用序列本身,分割出现在分割面板中,变得可编辑、可导出。
3.2 情形二:传播与恢复
一旦分割在某处被渲染,OHIF 会把它传播到显示同一体积或同一 Frame of Reference(帧参照系)的其他视口——这就是大多数悬挂协议声明的hydrateseg同步组干的活。同时,OHIF 会按视口记住正在渲染哪些分割。
这个记忆是以底层序列为键的(SEG-30:视口的展示身份 = 其非叠加显示集的displaySetInstanceUID连接串),因此:
- 改变布局、
- 切换悬挂协议阶段、
- 重定向 MPR 视图、
- 或把引用序列拖进另一个窗格,
都会自动把分割带回来。而视口方向(orientation)不参与该身份(SEG-30)。
3.3 水合对话框的源码实现
promptHydrationDialog.ts 是水合提示的完整实现,几个关键点:
决定是否询问(对应 SEG-9):
// 对于 RT 和 SEG,检查 disableConfirmationPrompts // 对于 SR,检查 standardMode const shouldPrompt = type === HydrationType.SR ? standardMode : !appConfig?.disableConfirmationPrompts; const promptResult = shouldPrompt ? await _askHydrate(uiViewportDialogService, customizationService, viewportId, type) : RESPONSE.HYDRATE; // 静默水合按类型选择消息与对话框:getCustomizationMessageKey与getDialogId分别映射 SEG / RTSTRUCT / SR 三类水合的自定义消息键(viewportNotification.hydrateSEGMessage等)与对话框 ID。
对话框交互:提供No(RESPONSE.CANCEL)与Yes(RESPONSE.HYDRATE)两个动作,支持回车确认、点击外部取消。
水合执行:确认后先执行preHydrateCallbacks,再通过setTimeout(..., 0)异步调用hydrateCallback——SEG 与 RTSTRUCT 的回调参数结构不同(segDisplaySetvsrtDisplaySet),SR 则走完全独立的结果结构HydrationSRResult。
对话框的onOutsideClick会取消水合并 resolve 为CANCEL——这就是规格中"被用户 dismiss 的提示"的来源,也正是 LOAD 徽章(见下文 4.3)存在的意义。
3.4 水合命令的实际行为
hydrateSecondaryDisplaySet(commandsModule.ts)展示了水合的完整副作用序列:
// 记录表示类型提示(在视口渲染时会被纠正) const segmentationType = displaySet.Modality !== 'SEG' ? SegmentationRepresentations.Contour : viewport && isVolume3DViewportType(viewport) ? SegmentationRepresentations.Surface : SegmentationRepresentations.Labelmap; commandsManager.runCommand('updateStoredSegmentationPresentation', { displaySet, type: segmentationType }); // isHydrated 的含义是"把该显示集作为标准视图的一部分展示" // 该决策在这里做出,且不依赖任何视口是否已存在 displaySet.isHydrated = true; // SEG / RTSTRUCT:把引用序列加载进视口 const results = commandsManager.runCommand('loadSegmentationDisplaySetsForViewport', { viewportId, displaySetInstanceUIDs: [referencedDisplaySet.displaySetInstanceUID], derivedDisplaySetInstanceUID: displaySet.displaySetInstanceUID, viewportType: getHydrationViewportTypeForModality(displaySet.Modality), }); // panelSegmentation.disableEditing 配置为 true 时锁定所有段(SEG-13) const disableEditing = customizationService.getCustomization('panelSegmentation.disableEditing'); if (disableEditing) { const segmentationId = displaySet.displaySetInstanceUID; const segmentation = segmentationService.getSegmentation(segmentationId); Object.keys(segmentation?.segments ?? {}).forEach(segmentIndex => { segmentationService.setSegmentLocked(segmentationId, parseInt(segmentIndex), true); }); }其中loadSegmentationDisplaySetsForViewport(commandsModule.ts)负责计算"哪些视口需要被更新"并完成更新:
const updatedViewports = getUpdatedViewportsForSegmentation({ viewportId, servicesManager, displaySetInstanceUIDs, derivedDisplaySetInstanceUID, }); // ...对每个目标视口 setNeedsRender actions.setDisplaySetsForViewports({ viewportsToUpdate: updatedViewports.map(...) });注意其中的渲染模式钉扎逻辑(SEG-53):viewportType(RTSTRUCT 在原生视口路径请求stack)只应用于水合被调用的那个窗格(其背景正在被替换为引用图像);其他窗格是因为已经显示合格背景才被匹配到的,保留自己的渲染模式——MPR 窗格不会被强制翻转成 stack。
3.5 水合目标视口的计算
hydrationUtils.ts 中的getUpdatedViewportsForSegmentation定义了水合会触及哪些视口(SEG-25/SEG-26/SEG-46/SEG-47):
- 首先通过
hangingProtocolService.getViewportsRequireUpdate(targetViewportId, displaySetInstanceUIDs[0], isHangingProtocolLayout)让悬挂协议匹配引用显示集所选择的视口; - 然后通过
mergeMatchingViewports合并所有网格窗格中已列出该体积(displaySetInstanceUIDs精确匹配)的窗格,以及所有其背景满足isDisplaySetOverlayable(即共享帧参照系)的窗格; - 关键的合并语义:只有精确的
displaySetInstanceUID匹配才强制把引用显示集放进窗格;仅共享帧参照系的窗格保留它已有的显示集——这正是 SEG-26/SEG-47 保护的场景:"把不同的 UID 强制塞进体积视口可能导致它空白"。
// 悬挂协议 pass 已经运行,对它点名的窗格具有权威性——它知道引用显示集 // 必须被*加载*进水合目标;用窗格现有 UID 覆盖它会丢弃该指令。 if (byId.has(vp.viewportId)) return; const uids = vp.displaySetInstanceUIDs || []; // 只有精确 displaySetInstanceUID 匹配才强制把引用体积放到窗格上; // 仅共享帧参照系的窗格保留其已有显示集 if (volumeUid && uids.includes(volumeUid)) { add(vp.viewportId, [volumeUid]); return; } if (isEligiblePane?.(vp)) { add(vp.viewportId, uids); }同时makeEligiblePanePredicate组合了两道闸门:视口类型必须被isAutoHydrateViewportType允许(SEG-50),且背景显示集必须满足isDisplaySetOverlayable。目标视口可能已经不存在(提示框打开期间布局变了)或从未存在过(从研究面板驱动水合)——此时不 dereference 视口,而是回退到资格匹配(SEG-49)。
四、手动展示:三种入口
手动展示分为两类语义:"给我看这个分割"(替换视口内容)与"把这个分割加到我正在看的东西上"(保持背景、叠加图层)。
4.1 缩略图拖放/双击(SEG-14、SEG-34)
双击或把 SEG/RTSTRUCT 缩略图拖入视口,会接管该视口,进入 3.1 的流程(预览 → 水合询问)。手动缩略图放置不受 SEG-31~33 的叠加资格约束——它是替换视口内容(SEG-6),而非叠加。
4.2 Add as Layer 与视口叠加菜单(SEG-15、SEG-16)
"保持当前背景、把分割叠加上去"有两个入口:
- 缩略图的Add as Layer菜单项;
- 视口的data-overlay 菜单(数据叠加菜单):列出其他显示集,并标记哪些可以合法叠加。
叠加资格由 SEG-31~SEG-33 决定:
- SEG-31:只有当视口的背景显示集可重建(reconstructable)且候选未被标记为 unsupported 时,才提供手动叠加;
- SEG-32:候选声明了
FrameOfReferenceUID时必须与背景一致;未声明的则不做限制(缺失信息是宽松的); - SEG-33:SEG 与 RTSTRUCT豁免于图像叠加的体积形状约束(背景必须是有效体积、候选必须是多帧或有效体积)。
一旦作为图层添加,其表示会在视口挂载时立即被添加——没有提示,因为用户已经表达了意图。
4.3 LOAD 徽章(SEG-17)
如果视口正在显示一个未水合的分割,角落会有一个小的SEG/RTSTRUCT徽章,带一个LOAD按钮。这个按钮就是对话框提供的水合动作本身,服务于"关闭了对话框、或从未被展示过对话框"的用户。
五、资格判定(Eligibility):不是单一谓词,而是四道独立闸门
规格明确指出:资格不是单一谓词。一个分割要穿过至多四道相互独立的闸门——水合更新哪些视口、传播到哪些视口、恢复到哪些视口、表示最终能否被挂载——而且它们使用的测试各不相同。
| 闸门 | 规则 | 测试对象 |
|---|---|---|
| 水合 fan-out | SEG-25/SEG-26 | 悬挂协议为引用显示集匹配的视口,或已包含引用显示集的网格窗格;按精确displaySetInstanceUID匹配 |
| 传播(同步组) | SEG-27~SEG-29 | 目标与源共享显示集,或报告相同FrameOfReferenceUID;已持有该分割表示的视口不再添加 |
| 恢复(展示身份) | SEG-30 | 视口展示身份(非叠加显示集 UID 连接串)等于记录时的身份;方向不参与 |
| 表示挂载兼容性 | SEG-35~SEG-39 | 视口状态:显示的 imageIds、视口帧参照系、表示类型 |
5.1 传播同步器:hydrateseg 同步组
createHydrateSegmentationSynchronizer.ts 实现了 SEG-27~29 的传播逻辑。它监听SEGMENTATION_REPRESENTATION_MODIFIED事件,回调segmentationRepresentationModifiedCallback:
const sharedDisplaySetExists = isAnyDisplaySetCommon(sourceDisplaySetUIDs, targetDisplaySetUIDs); // 不共享显示集且目标无帧参照系 → 不传播(SEG-28) if (!sharedDisplaySetExists && !targetFrameOfReferenceUID) return; // 不共享显示集且帧参照系不一致 → 不传播(SEG-28) if (!sharedDisplaySetExists && targetFrameOfReferenceUID !== sourceFrameOfReferenceUID) return; // 目标已持有该分割表示 → 不重复添加(SEG-29) if (targetViewportRepresentation.length > 0) return; // 表示类型与目标视口类型对齐:3D 视口用 Surface,否则沿用源类型、兜底 Labelmap const type = is3D ? Surface : requestedRepresentation && requestedRepresentation !== Surface ? requestedRepresentation : Labelmap; await segmentationService.addSegmentationRepresentation(targetViewportId, { segmentationId, type, config });5.2 表示/视口兼容性:单一决策点(SEG-35~SEG-39)
SEG-35 要求在同一个地方决定"分割表示能否挂载进视口",让所有调用方应用同一条规则。规格给出了该规则的内容:
- SEG-36:Contour、Surface 和未类型化的表示没有任何视口兼容性约束——因此 RTSTRUCT 绝不会被这道闸门抑制;
- SEG-37:labelmap 只能挂载到 stack 视口,且仅当该视口显示了 labelmap 的至少一个源图像;
- SEG-38:labelmap 可以挂载到体积视口,除非 labelmap 声明的
FrameOfReferenceUID与视口的不同;由视口未显示的序列派生的 labelmap 仍兼容,因为体积视口按几何重采样; - SEG-39:无法确定兼容性时(分割未知、源图像或帧参照系尚不可推导、视口未就绪、查询抛异常),一律视为兼容——缺失信息是宽松的。
规格中给出的源码位置是isSegmentationOverlayCompatible(位于 cornerstonejs tools 包的stateManagement/segmentation/helpers/下),当前仓库中该 helper 位于第三方依赖内,读者可在 node_modules 中按同名文件查找。
5.3 资格规则的四个不一致点(设计红线)
规格专门列出了基线中资格规则互相矛盾的观察点,对任何想要重构这块逻辑的人至关重要:
- 帧参照系既被拒绝又被依赖:SEG-26 拒绝基于共享帧参照系推断资格(会空白体积视口),SEG-27 却恰恰基于该推断传播,SEG-38 又基于该依据允许挂载——同一个关系在"选择更新哪些视口"时不安全,在"扩散到它们"时却足够。
- 手动叠加与渲染时兼容性使用不同测试:叠加菜单(SEG-31~33)测显示集元数据(可重建背景、声明的帧参照系、体积形状);挂载闸门(SEG-36~38)测视口状态(imageIds、视口帧参照系),并按表示类型分支——菜单从不考虑类型。因此一个显示集可能被菜单提供却挂载不上,也可能经由从未咨询菜单的路径被挂载。
- 缺失帧参照系扩大而非缩小资格:SEG-32 对未声明
FrameOfReferenceUID的候选对任何背景都提供;SEG-39 对无法确定的视为兼容。缺失信息全程宽松——这是有意为之,但也意味着资格不能作为正确性保证。 - 轮廓在挂载时不被检查:SEG-36 豁免所有非 labelmap 表示,所以只要前面的任何闸门放行,RTSTRUCT 就会到达视口。
六、移除(Remove)与删除(Delete):作用域完全不同
6.1 从视口移除(SEG-19)
Remove from Viewport(来自分割面板菜单,或移除图层)只影响一个视口:
- 丢弃该视口的表示;
- 面板不再为该视口列出它;
- 因为移除发生在视口状态被存储之前,它在之后的布局变化中保持移除状态,不会被恢复。
分割本身毫发无损:仍然已加载、仍然在拥有它的其他视口中渲染、随时可以被加回来。
6.2 删除(SEG-20、SEG-21)
Delete则处处摧毁分割:
- 从每个列出它的视口移除;
- 如果是客户端创建的分割(用户画的 labelmap 或轮廓),其显示集也会被删除——从研究面板消失,除非先导出过,否则真正消失(SEG-20);
- 从服务器来的分割在一种意义上幸存于自身删除:SEG 序列仍在研究面板中,可以简单地重新加载——因为删除只丢弃已解码的工作副本(SEG-21)。
6.3 源码印证:分割事件处理器
setUpSegmentationEventHandlers.ts 实现了删除路径。当分割从分割服务中移除时(SEGMENTATION_REMOVED事件):
// 全局移除路径:分割已从状态中消失(例如从分割面板删除), // 因此清除全局的"显示它到它逻辑上该在的地方"状态。 displaySet.isHydrated = false; commandsManager.runCommand('updateStoredSegmentationPresentation', { displaySet, hydrated: false }); // 遍历所有携带该分割图层的视口,逐个移除图层 for (const [viewportId, viewport] of viewports.entries()) { if (displaySetInstanceUIDs.includes(segmentationId)) { commandsManager.runCommand('removeDisplaySetLayer', { viewportId, displaySetInstanceUID: segmentationId }); } } // 客户端创建的分割才删除显示集(服务端分割保留序列,可重新加载) if (displaySet.madeInClient) { displaySetService.deleteDisplaySet(segmentationId); }同一个文件还处理客户端创建的分割的显示集生成(SEG-18):监听SEGMENTATION_ADDED,为没有对应显示集的客户端分割构造一个madeInClient: true的 overlay 显示集并注册,使其表现得如同已加载的分割一样。
七、PR #5996 的转向:Hydration 是关于显示集的陈述,而非关于视口的陈述
文档的后半部分是分支(PR #5996)相对于基线的变更说明。组织性思想是:
水合是对一个显示集的陈述("把这个分割显示在它逻辑上该在的地方"),而不是对一个视口的陈述。
这把基线中单一的"移除/添加"概念拆成了每视口动作与全局动作两个层级,并让资格问题可以在根本不存在任何视口的情况下被回答。
7.1 每视口显示 vs 水合(SEG-40~SEG-45)
- SEG-40:在视口中添加/移除分割图层只影响该视口:不改变显示集的水合状态,所以之后重建的视口会重新应用水合所说的状态,而不是继承这个每视口动作。这就是视口>isDisplaySetOverlayable(displaySet, backgroundDisplaySet): 1. 任一为空 → false 2. 二者 UID 相同 → false 3. displaySet.unsupported → false 4. 候选声明了 FOR 且与背景不一致 → false 5. 候选是 SEG/RTSTRUCT: a. 候选的引用 = 背景 → true(不受可重建约束) b. 双方可重建 且 候选有 FOR → true(仅此越出自身引用) 6. 其他(colormap 叠加): 背景可重建 + 有效体积,且候选为多帧或有效体积 → true
注释中明确指出了两个易被误解的设计决策:
- 派生显示集从其引用的显示集拷贝
isReconstructable和FrameOfReferenceUID,所以这两者都是关于引用的答案; - 叠加所依据的那个显示集不必可重建——"RTSTRUCT 叠加在 stack 上"正是水合时
getHydrationViewportTypeForModality钉扎到stack的情形,可重建闸门不能适用于此。
7.4 表示类型的自动纠正
hydrateSecondaryDisplaySet(commandsModule.ts)在记录类型时明确标注"仅作提示":SEG 在 3D 视口记录为Surface,否则记录为Labelmap;非 SEG(即 RTSTRUCT)记录为Contour。注释指出:Recorded as a hint only:
_setSegmentationPresentationcorrects Labelmap <-> Surface against the viewport that actually renders it, so this does not have to be resolved against a viewport here.这与 SEG-51 完全对应。
7.5 展示存储与恢复的源码实现
store 结构:useSegmentationPresentationStore.ts 是基于 zustand 的展示存储,以
presentationId(= 视口非叠加显示集 UID 的连接串,见_getSegmentationPresentationId)为键,每个条目是一个SegmentationPresentationItem[]:// segmentationPresentationItem: { // segmentationId: string; // type: SegmentationRepresentations; // hydrated: boolean | null; // config?: unknown; // }三个写入动作各有精确语义:
addSegmentationPresentationItem:按segmentationIdupsert(同一分割的 hydrate → remove → hydrate 不会累积重复条目,否则视口会把同一个表示添加多次);setHydrationForSegmentation:重述某分割在所有已含其条目的展示中的水合,不新建条目——因为客户端分割没有引用显示集就没有自己的键(对应 SEG-45);syncSegmentationPresentation:记录视口当前渲染的表示,但保留已记录的水合、只刷新 type/config——水合是显示集全局的("显示在它逻辑上该在的地方"),归水合路径所有;被闸门挡住的窗格或用户移除叠加层的窗格不能抹掉其他窗格的记录(对应 SEG-44)。无条目时调用方传入的hydrated才生效。
读取恢复:getViewportPresentations.ts 实现了 SEG-52 的"关系式读取":先做键控查找(
segmentationPresentationStore[segmentationPresentationId]),再扫描其余条目,用isDisplaySetOverlayable判断哪些分割也能叠加到本视口背景上;键控条目胜出(它是分割自身的显示集,类型与水合状态具有权威性)。该文件配有一份完整的单元测试 getViewportPresentations.test.ts,测试数据覆盖了"同一帧参照系中的不同序列"(volume-1/volume-2/volume-3共享for-1)、"无关的非可重建序列共享帧参照系"(stack-1/stack-2共享for-s)以及 SEG/RT 叠加显示集等场景。syncSegmentationPresentation在 CornerstoneViewportService.ts 中被调用,且每项的水合初值由_getInitialHydrationForSync(同文件 L498-L504)给出:// 有引用显示集 → null("无陈述",留给水合提示/面板/研究浏览器决定) // 无引用显示集(客户端绘制或未注册)→ true(视口是权威,记录为已显示) return displaySet?.referencedDisplaySetInstanceUID ? null : true;7.6 渲染模式钉扎(SEG-53)
nextViewportPolicies.ts 集中定义了 legacy 与原生("next")视口路径之间的行为策略差异,其中水合相关的策略是:
export function getHydrationViewportTypeForModality(modality: string): 'stack' | undefined { return modality === 'RTSTRUCT' && isNextViewportsEnabled() ? 'stack' : undefined; }注释解释了原因:RTSTRUCT 轮廓在原生 stack/vtkImage 视口上渲染正确且滚动快,所以水合时引用图像保持 stack 模式而非提升为体积切片(性能验收标准禁止)。而
loadSegmentationDisplaySetsForViewport(commandsModule.ts)只在目标视口(水合被调用者)上应用这个钉扎,并特别注明:合并条目不携带 viewportOptions,若把裸{ viewportType }直接合并到窗格真实选项上,会把 MPR 窗格翻成 stack——所以必须按viewport.viewportId === targetViewportId精确过滤。八、可配置项速查
配置项 位置/默认值 作用 disableConfirmationPrompts应用配置(appConfig) 为 true时 SEG/RT 水合不再弹对话框,静默执行(SEG-9)panelSegmentation.disableEditingsegmentationPanelCustomization.tsx,默认 false水合完成后锁定所有段(SEG-13) cornerstone.segmentation.autoHydrateViewportTypessegmentationHydrationCustomization.ts,默认 null列出允许自动显示已水合分割的视口类型(如 ['stack','volume']可将 3D 视口排除在自动水合之外),null= 所有能渲染的类型(SEG-50)cornerstone.segmentation.loadMultiframeAsPart10定制项,默认 true多帧 SEG 是否整实例 Part 10 预取后本地供帧( false走逐帧请求)cornerstone.modalityOverlayDefaultColorMaps定制项 叠加层的默认 colormap 与不透明度(默认 hsv/ 0.5~0.9)missingReferenceDisplaySetHandlerextensions/default/src/customizations/missingReferenceDisplaySetHandler.ts 无引用序列时的兜底处理(默认 { handled: false },即跳过渲染)其中
autoHydrateViewportTypes的类型词汇是悬挂协议viewportOptions.viewportType的词汇(stack/volume/volume3d/...),见 autoHydrateViewportTypes.ts。其注释解释了一个易踩的坑:cornerstone 自己的枚举词汇不可用作键——orthographic是volume视口的实际形态,而在useNextViewports下所有平面视口(stack 与 MPR 一视同仁)都会塌缩为planarNext,任何配置列表都无法指名它,因此planarnext被特意映射为undefined(含义真正歧义,猜错会误伤站点不想排除的视口)。九、已知的未解决问题(设计边界)
规格在"Still open"一节中如实列出了两个遗留问题:
isHydrated标志被直接突变、无事件:消费者(研究浏览器缩略图、LOAD 徽章)如果直接渲染该标志,可能显示陈旧状态。有进行中的工作要把该标志正式挪到显示集上。- 展示身份跨方向共享:SEG-44 防止共享展示条目被抹掉,但身份仍跨方向共享(SEG-30:方向不参与)。真正随窗格不同而不同的状态——2D 窗格的 labelmap vs 3D 窗格的 surface——没有自己的容身之处,这正是 SEG-51 在挂载时纠正类型的原因。
十、核心实现文件索引
关注点 位置 水合提示与命令 promptHydrationDialog.ts、commandsModule.ts 中 hydrateSecondaryDisplaySet水合触及哪些视口 hydrationUtils.ts 手动叠加资格 ViewportDataOverlaySettingMenu/utils.ts 中 getEnhancedDisplaySets叠加资格统一规则(SEG-46~48) isDisplaySetOverlayable.ts 自动水合视口类型(SEG-50) autoHydrateViewportTypes.ts、segmentationHydrationCustomization.ts 视口展示解析(SEG-52) getViewportPresentations.ts 水合渲染模式钉扎(SEG-53) nextViewportPolicies.ts、commandsModule.ts 中 loadSegmentationDisplaySetsForViewport记录已显示内容 useSegmentationPresentationStore.ts、CornerstoneViewportService.ts 向其他视口传播 createHydrateSegmentationSynchronizer.ts 图层添加与移除 commandsModule.ts、layerConfigurationUtils.ts 加载 CornerstoneCacheService.ts、SEG/RT 的 SOP class handlers 预览视口 OHIFCornerstoneSEGViewport.tsx、OHIFCornerstoneRTViewport.tsx 删除与客户端创建的分割 setUpSegmentationEventHandlers.ts 结语
SEG 与 RTSTRUCT 在 OHIF 中的完整生命周期,由一条根本事实统领——分割是叠加层,永远绘制在被引用序列之上——并由"加载一次、展示多次"的按需加载、自动/手动两条展示路径、四道相互独立的资格闸门、以及"移除≠删除"的作用域区分共同支撑。PR #5996 的转向进一步把水合重新定义为对显示集的全局陈述,使资格判定不再依赖任何视口的存在,同时保留了每视口图层操作的局部性。理解这些规则,无论是排查"分割为什么没出现/为什么被恢复了",还是定制自动水合范围、静默水合或只读面板,都能直接定位到上述源码位置,做出有依据的决策。
【免费下载链接】ViewersOHIF zero-footprint DICOM viewer and oncology specific Lesion Tracker, plus shared extension packages
项目地址: https://gitcode.com/GitHub_Trending/vi/Viewers
- 派生显示集从其引用的显示集拷贝
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考