Need help with implementing custom viewport in OHIF

Describe Your Question

  • A clear and concise description of what problem you are having.
    Hello, I have been using OHIF source code and I’m trying to implement a Custom Viewport in my Custom extension for my Custom mode. The problem is my viewport sometimes not showing up and I’m not sure why
  • Which OHIF version I’m using
    3.11

What steps can we follow to reproduce the bug?

  1. First step
  2. Second step

This is my Mode extension code :

import { id } from './id';
import initToolGroups from './initToolGroups';
import toolbarButtons from './toolbarButtons';
import type { ViewportGridService } from '@ohif/core';
// import { boundedGuards } from './utils/BoundedExtension';

const NON_IMAGE_MODALITIES = ['ECG', 'SEG', 'RTSTRUCT', 'RTPLAN', 'PR'];
const LARGE_SCREEN_WIDTH = 1360;

const ohif = {
  layout: 'sotamed.layoutTemplateModule.customViewerLayout',
  // layout: '@ohif/extension-default.layoutTemplateModule.viewerLayout',
  sopClassHandler: '@ohif/extension-default.sopClassHandlerModule.stack',
  thumbnailList: '@ohif/extension-default.panelModule.seriesList',
  wsiSopClassHandler:
    '@ohif/extension-cornerstone.sopClassHandlerModule.DicomMicroscopySopClassHandler',
};

const cornerstone = {
  measurements: '@ohif/extension-cornerstone.panelModule.panelMeasurement',
  segmentation: '@ohif/extension-cornerstone.panelModule.panelSegmentationWithTools',
};

const tracked = {
  measurements: '@ohif/extension-measurement-tracking.panelModule.trackedMeasurements',
  thumbnailList: '@ohif/extension-measurement-tracking.panelModule.seriesList',
  viewport: '@ohif/extension-measurement-tracking.viewportModule.cornerstone-tracked',
};

const sotamedPanel = {
  SotamedPanel: 'sotamed.panelModule.SotamedPanel',
};

const dicomsr = {
  sopClassHandler: '@ohif/extension-cornerstone-dicom-sr.sopClassHandlerModule.dicom-sr',
  sopClassHandler3D: '@ohif/extension-cornerstone-dicom-sr.sopClassHandlerModule.dicom-sr-3d',
  viewport: '@ohif/extension-cornerstone-dicom-sr.viewportModule.dicom-sr',
};

const dicomvideo = {
  sopClassHandler: '@ohif/extension-dicom-video.sopClassHandlerModule.dicom-video',
  viewport: '@ohif/extension-dicom-video.viewportModule.dicom-video',
};

const dicompdf = {
  sopClassHandler: '@ohif/extension-dicom-pdf.sopClassHandlerModule.dicom-pdf',
  viewport: '@ohif/extension-dicom-pdf.viewportModule.dicom-pdf',
};

const dicomSeg = {
  sopClassHandler: '@ohif/extension-cornerstone-dicom-seg.sopClassHandlerModule.dicom-seg',
  viewport: '@ohif/extension-cornerstone-dicom-seg.viewportModule.dicom-seg',
};

const dicomPmap = {
  sopClassHandler: '@ohif/extension-cornerstone-dicom-pmap.sopClassHandlerModule.dicom-pmap',
  viewport: '@ohif/extension-cornerstone-dicom-pmap.viewportModule.dicom-pmap',
};

const dicomRT = {
  viewport: '@ohif/extension-cornerstone-dicom-rt.viewportModule.dicom-rt',
  sopClassHandler: '@ohif/extension-cornerstone-dicom-rt.sopClassHandlerModule.dicom-rt',
};

const endoscopy3d = {
  viewport: 'sotamed.viewportModule.3dViewportEndoscopy',
}

const extensionDependencies = {
  '@ohif/extension-default': '^3.0.0',
  '@ohif/extension-cornerstone': '^3.0.0',
  '@ohif/extension-measurement-tracking': '^3.0.0',
  '@ohif/extension-cornerstone-dicom-sr': '^3.0.0',
  '@ohif/extension-cornerstone-dicom-seg': '^3.0.0',
  '@ohif/extension-cornerstone-dicom-pmap': '^3.0.0',
  '@ohif/extension-cornerstone-dicom-rt': '^3.0.0',
  '@ohif/extension-dicom-pdf': '^3.0.1',
  '@ohif/extension-dicom-video': '^3.0.1',
  sotamed: '0.0.1',
};

function modeFactory({ modeConfiguration }) {
  let _activatePanelTriggersSubscriptions = [];
  let _resizeListener = null;

  // Define tool configurations for responsive behavior
  const measurementToolsForMobile = [
    'Length',
    'ClearAllMeasurements',
    'Crosshairs',
    'rotate-right',
    'Probe',
    'EllipticalROI',
    'RectangleROI',
    'ArrowAnnotate',
    'Undo',
    'Redo',
  ];

  const primaryToolsForDesktop = [
    'AiSegmentLung',
    'AiSegmentLiver',
    'Zoom',
    'Pan',
    'WindowLevel',
    'Crosshairs',
    'rotate-right',
    'MoreTools',
    'Length',
    'Probe',
    'EllipticalROI',
    'RectangleROI',
    'ArrowAnnotate',
    'Undo',
    'Redo',
    'ClearAllMeasurements',
    'MeasurementTools',
    'TrackballRotate',
    'Layout',
    'FullScreenTool',
    'SaveKeyImages',
    'ExportDICOM',
  ];

  const primaryToolsForMobile = [
    'AiSegmentLung',
    'AiSegmentLiver',
    'Zoom',
    'Pan',
    'WindowLevel',
    'MoreTools',
    'MeasurementTools',
    'TrackballRotate',
    'Layout',
    'FullScreenTool',
    'SaveKeyImages',
    'ExportDICOM',
  ];

  const measurementToolsForDesktop = [
    'Angle',
    'CobbAngle',
    'Bidirectional',
    'CircleROI',
    'PlanarFreehandROI',
    'SplineROI',
    'LivewireContour',
  ];

  const updateToolbarForScreenSize = toolbarService => {
    const isLargeScreen = window.innerWidth >= LARGE_SCREEN_WIDTH; // lg breakpoint (1024px)

    toolbarService.clearButtonSection(toolbarService.sections.primary);
    toolbarService.clearButtonSection('MeasurementTools');
    if (isLargeScreen) {
      // Desktop: original configuration with measurement tools in primary toolbar
      toolbarService.updateSection(toolbarService.sections.primary, primaryToolsForDesktop);
      toolbarService.updateSection('MeasurementTools', measurementToolsForDesktop);
    } else {
      // Mobile: move selected measurement tools from primary to MeasurementTools section
      toolbarService.updateSection(toolbarService.sections.primary, primaryToolsForMobile);
      toolbarService.updateSection('MeasurementTools', [
        ...measurementToolsForMobile,
        ...measurementToolsForDesktop,
      ]);
    }
  };

  // Get StudyInstanceUIDs from current URL
  const urlParams = new URLSearchParams(window.location.search);
  const studyInstanceUIDs = urlParams.getAll('StudyInstanceUIDs');
  const isCompareStudy = studyInstanceUIDs[0]?.includes(',');

  return {
    id,
    routeName: 'pac-viewer',
    displayName: 'PAC Viewer Sotamed',
    onModeEnter: function ({ servicesManager, extensionManager, commandsManager }: withAppTypes) {
      const { measurementService, toolbarService, toolGroupService, customizationService } =
        servicesManager.services;

      measurementService.clearMeasurements();

      initToolGroups(extensionManager, toolGroupService, commandsManager);

      const viewportGridService = servicesManager.services
        .viewportGridService as ViewportGridService;

      // const enableGuards = () => {
      //   const disposers = boundedGuards({
      //     toolGroupService,
      //   });

      //   (this as any)._boundedDisposers = [
      //     ...((this as any)._boundedDisposers || []),
      //     ...disposers,
      //   ];
      // };

      // const subscriptions = [
      //   viewportGridService.subscribe(
      //     (viewportGridService as any).EVENTS.VIEWPORTS_READY,
      //     enableGuards
      //   ),
      //   viewportGridService.subscribe(
      //     (viewportGridService as any).EVENTS.GRID_STATE_CHANGED,
      //     enableGuards
      //   ),
      // ];

      // (this as any)._boundedSubs = subscriptions;

      // enableGuards();

      toolbarService.register(toolbarButtons as any);
      toolbarService.updateSection(toolbarService.sections.primary, [
        'Zoom',
        'Pan',
        'WindowLevel',
        'Crosshairs',
        'rotate-right',
        'MoreTools',
        'Length',
        'Probe',
        'EllipticalROI',
        'RectangleROI',
        'ArrowAnnotate',
        'Undo',
        'Redo',
        'ClearAllMeasurements',
        'MeasurementTools',
        'TrackballRotate',
        'Layout',
        'FullScreenTool',
        'SaveKeyImages',
        'ExportDICOM',
      ]);
      toolbarService.register(toolbarButtons);

      // Set up responsive toolbar based on current screen size
      updateToolbarForScreenSize(toolbarService);

      // Add resize listener for responsive behavior
      _resizeListener = () => {
        updateToolbarForScreenSize(toolbarService);
      };

      window.addEventListener('resize', _resizeListener);

      toolbarService.updateSection(toolbarService.sections.viewportActionMenu.topLeft, [
        'orientationMenu',
        'dataOverlayMenu',
      ]);

      toolbarService.updateSection(toolbarService.sections.viewportActionMenu.bottomMiddle, [
        'AdvancedRenderingControls',
      ]);

      toolbarService.updateSection('AdvancedRenderingControls', [
        'windowLevelMenuEmbedded',
        'voiManualControlMenu',
        'Colorbar',
        'opacityMenu',
        'thresholdMenu',
      ]);

      toolbarService.updateSection(toolbarService.sections.viewportActionMenu.topRight, [
        'modalityLoadBadge',
        'trackingStatus',
        'navigationComponent',
      ]);

      toolbarService.updateSection(toolbarService.sections.viewportActionMenu.bottomLeft, [
        'windowLevelMenu',
      ]);

      toolbarService.updateSection('MeasurementTools', [
        'Angle',
        'CobbAngle',
        'Bidirectional',
        'CircleROI',
        'PlanarFreehandROI',
        'SplineROI',
        'LivewireContour',
      ]);

      toolbarService.updateSection('MoreTools', [
        'Reset',
        'flipHorizontal',
        'ImageSliceSync',
        'ReferenceLines',
        'ImageOverlayViewer',
        'StackScroll',
        'invert',
        'Cine',
        'Magnify',
        'CalibrationLine',
        'TagBrowser',
        'AdvancedMagnify',
        'UltrasoundDirectionalTool',
        'WindowLevelRegion',
        'SegmentLabelTool',
      ]);

      // Segmentation Toolbox sections
      toolbarService.updateSection(toolbarService.sections.segmentationToolbox, [
        'SegmentationUtilities',
        'SegmentationTools',
      ]);

      toolbarService.updateSection('SegmentationUtilities', [
        'LabelmapSlicePropagation',
        'InterpolateLabelmap',
        'SegmentBidirectional',
      ]);

      toolbarService.updateSection('SegmentationTools', [
        'BrushTools',
        'MarkerLabelmap',
        'RegionSegmentPlus',
        'Shapes',
      ]);

      toolbarService.updateSection('BrushTools', ['Brush', 'Eraser', 'Threshold']);

      // Enable segmentation editing
      customizationService.setCustomizations({
        'panelSegmentation.disableEditing': { $set: false },
      });
    },
    onModeExit: ({ servicesManager }: withAppTypes) => {
      const {
        toolGroupService,
        syncGroupService,
        segmentationService,
        cornerstoneViewportService,
        uiDialogService,
        uiModalService,
      } = servicesManager.services;

      // CLEANUP guards
      const ds = (this as any)._boundedDisposers as Array<() => void> | undefined;
      ds?.forEach(fn => fn());
      (this as any)._boundedDisposers = undefined;
      const subs = (this as any)._boundedSubs as Array<any> | undefined;
      subs?.forEach(s => s?.unsubscribe?.());
      (this as any)._boundedSubs = undefined;

      // Clean up resize listener
      if (_resizeListener) {
        window.removeEventListener('resize', _resizeListener);
        _resizeListener = null;
      }

      uiDialogService.hideAll();
      uiModalService.hide();
      toolGroupService.destroy();
      syncGroupService.destroy();
      segmentationService.destroy();
      cornerstoneViewportService.destroy();
    },
    validationTags: { study: [], series: [] },

    isValidMode: function ({ modalities }) {
      const modalities_list = modalities.split('\\\\');
      return {
        valid: !!modalities_list.filter(modality => NON_IMAGE_MODALITIES.indexOf(modality) === -1)
          .length,
        description:
          'The mode does not support studies that ONLY include the following modalities: SM, ECG, SEG, RTSTRUCT',
      };
    },
    routes: [
      {
        path: 'pac-viewer',
        layoutTemplate: () => {
          return {
            id: ohif.layout,
            props: {
              rightPanels: [tracked.thumbnailList],
              rightPanelResizable: true,
              rightPanelInitialExpandedWidth: window.innerWidth >= LARGE_SCREEN_WIDTH ? 141 : 120,
              rightPanelMinimumExpandedWidth: 141,
              leftPanels:
                window.innerWidth >= LARGE_SCREEN_WIDTH
                  ? [sotamedPanel.SotamedPanel, cornerstone.segmentation, tracked.measurements]
                  : [sotamedPanel.SotamedPanel],
              leftPanelResizable: window.innerWidth >= LARGE_SCREEN_WIDTH ? true : false,
              leftPanelInitialExpandedWidth: window.innerWidth >= LARGE_SCREEN_WIDTH ? 242 : 60,
              leftPanelMinimumExpandedWidth: window.innerWidth >= LARGE_SCREEN_WIDTH ? 120 : 60,
              viewports: [
                {
                  namespace: tracked.viewport,
                  displaySetsToDisplay: [
                    ohif.sopClassHandler,
                    dicomvideo.sopClassHandler,
                    ohif.wsiSopClassHandler,
                  ],
                },
                {
                  namespace: dicomsr.viewport,
                  displaySetsToDisplay: [dicomsr.sopClassHandler, dicomsr.sopClassHandler3D],
                },
                { namespace: dicompdf.viewport, displaySetsToDisplay: [dicompdf.sopClassHandler] },
                { namespace: dicomSeg.viewport, displaySetsToDisplay: [dicomSeg.sopClassHandler] },
                {
                  namespace: dicomPmap.viewport,
                  displaySetsToDisplay: [dicomPmap.sopClassHandler],
                },
                { namespace: dicomRT.viewport, displaySetsToDisplay: [dicomRT.sopClassHandler] },
                { namespace: endoscopy3d.viewport, displaySetsToDisplay: ['sotamed.sopClassHandlerModule.endoscopy-handler'] }
              ],
            },
          };
        },
      },
    ],
    extensions: extensionDependencies,
    hangingProtocol: isCompareStudy ? 'pacsHpMNCompare' : 'default',
    sopClassHandlers: [
      'sotamed.sopClassHandlerModule.endoscopy-handler',
      dicomvideo.sopClassHandler,
      dicomSeg.sopClassHandler,
      dicomPmap.sopClassHandler,
      ohif.sopClassHandler,
      ohif.wsiSopClassHandler,
      dicompdf.sopClassHandler,
      dicomsr.sopClassHandler3D,
      dicomsr.sopClassHandler,
      dicomRT.sopClassHandler,
    ],
    ...modeConfiguration,
  };
}

const mode = { id, modeFactory, extensionDependencies };

export default mode;
export { initToolGroups, toolbarButtons };

This is my getViewportModule code:

import React from 'react';
import TrameViewport from './components/trame/TrameViewport';

const wrappedViewport = props => {
  return (
    <TrameViewport
      {...props}
      onEvent={data => {
      }}
    />
  );
};

function getViewportModule({
  commandsManager,
}) {
  return [{ name: '3dViewportEndoscopy', component: wrappedViewport }];
}

export default getViewportModule

This is my getSopClassHandlerModule code:


import { utils } from '@ohif/core';

const ENDOSCOPY_HANDLER_ID = 'sotamed.sopClassHandlerModule.endoscopy-handler';

const SOP_CLASS_UIDS = {
  CT_IMAGE_STORAGE: '1.2.840.10008.5.1.4.1.1.2',
  ENHANCED_CT_IMAGE_STORAGE: '1.2.840.10008.5.1.4.1.1.2.1',
};

const sopClassUids = Object.values(SOP_CLASS_UIDS);

export default function getEndoscopySopClassHandlerModule() {
  return [
    {
      name: 'endoscopy-handler',
      sopClassUids,
      getDisplaySetsFromSeries: (instances) => {
        if (!instances || !instances.length) {
          return [];
        }

        const firstInstance = instances[0];

        // Only create endoscopy display sets for CT series
        if (firstInstance.Modality !== 'CT') {
          return [];
        }

        // Create display set with minimal data needed for Trame server
        return [{
          // Required for OHIF routing
          SOPClassHandlerId: ENDOSCOPY_HANDLER_ID,
          displaySetInstanceUID: utils.guid(),

          // IDs needed by your Trame server
          SeriesInstanceUID: firstInstance.SeriesInstanceUID,
          StudyInstanceUID: firstInstance.StudyInstanceUID,

          // Metadata for OHIF UI (thumbnails, series browser)
          // SeriesDescription: firstInstance.SeriesDescription || 'Endoscopy 3D',
          // SeriesNumber: firstInstance.SeriesNumber,
          // SeriesDate: firstInstance.SeriesDate,
          // Modality: firstInstance.Modality,

          // Optional: for UI display
          // isDerivedDisplaySet: true,
          // numInstances: instances.length,
          isDisplaySetFromUrl: false,
          unsupported: false,
          excludeFromThumbnailBrowser: true,

        }];
      },
    },
  ];
}

This is my getHangingProtocolModule code:

import { Types } from '@ohif/core';

export const VOI_SYNC_GROUP = {
  type: 'voi',
  id: 'mpr',
  source: true,
  target: true,
  options: {
    syncColormap: true,
  },
};

export const HYDRATE_SEG_SYNC_GROUP = {
  type: 'hydrateseg',
  id: 'sameFORId',
  source: true,
  target: true,
  options: {
    matchingRules: ['sameFOR'],
  },
};

export const only3DEndoHangingProtocol = {
  id: '3d-endo-only',
  locked: true,
  name: '3D Endoscopy only',
  icon: 'layout-advanced-3d-only',
  isPreset: true,
  createdDate: '2023-03-15T10:29:44.894Z',
  modifiedDate: '2023-03-15T10:29:44.894Z',
  availableTo: {},
  editableBy: {},
  protocolMatchingRules: [
    // {
    //   attribute: 'ModalitiesInStudy',
    //   constraint: {
    //     contains: ['CT'],
    //   },
    // },
  ],
  imageLoadStrategy: 'interleaveCenter',
  displaySetSelectors: {
    endoscopyDisplaySet: {
      seriesMatchingRules: [
        {
          weight: 10,
          attribute: 'SOPClassHandlerId',
          constraint: {
            equals: {
              value: 'sotamed.sopClassHandlerModule.endoscopy-handler',
            },
          },
        },
        // {
        //   weight: 5,
        //   attribute: 'Modality',
        //   constraint: {
        //     equals: { value: 'CT' },
        //   },
        // },
      ],
    },
  },
  stages: [
    {
      id: 'only3DStage',
      name: 'only3D',
      viewportStructure: {
        layoutType: 'grid',
        properties: {
          rows: 1,
          columns: 1,
        },
      },
      viewports: [
        {
          viewportOptions: {
            id: '3d-endoscopy',
            viewportType: 'stack',
          },
          displaySets: [
            {
              id: 'endoscopyDisplaySet',
            },
          ],
        },
      ],
    },
  ],
};

Welcome to the forum @Sotatek-LinhNguyen14!

I suspect that you are only seeing your custom viewport whenever the DICOM being viewed matches one of the two SOP Class UIDs you specified in your SOP class handler. That is you will only see the custom viewport when one of these is viewed…

const SOP_CLASS_UIDS = {
  CT_IMAGE_STORAGE: '1.2.840.10008.5.1.4.1.1.2',
  ENHANCED_CT_IMAGE_STORAGE: '1.2.840.10008.5.1.4.1.1.2.1',
};

So the first thing to check the SOP class UID of one of your DICOM studies where the viewport is NOT displayed. If it does NOT match one of the above then that is the reason. If it does match then you will need to do more debugging.

Thanks for your question.