How to implement ECG viewer / hanging protocol not matched

Describe Your Question

  • A clear and concise description of what problem you are having.

I am trying to use the ECG viewer onto the release build, but when trying to open an ECG study, I get “The hanging protocol viewport is requesting to display defaultDisplaySetId displaySet that is not matched based on the provided criteria (e.g. matching rules).” on the console, with a black screen on the viewport.

I am using the mode ECG mode downloaded from the cli, and manually linked the extension from the @radicalimaging repo (https://github.com/RadicalImaging/OHIFExtensionsAndModes/tree/main/extensions/ecg-dicom).

  • Which OHIF version you are using?
    3.7 release

What steps can we follow to reproduce the bug?

  1. Download and link ecg-dicom extension
  2. Download ECG mode from yarn run cli search
  3. Connect the app to a DB with ECGs
    4.Try to open one and open the console
Please use code blocks to show formatted errors or code snippets

I believe I have the mode and extension properly set up, but might be missing addind a hanging protocol? Quite new to the app and still very lost, appreciate the help a lot.

I have changed some names from sopClassHandlers but made sure they are named properly inbetween files.

Mode set up:

import { hotkeys } from '@ohif/core';
import { id } from './id';
import { initToolGroups, toolbarButtons } from '@ohif/mode-longitudinal';

const ohif = {
  layout: '@ohif/extension-default.layoutTemplateModule.viewerLayout',
  sopClassHandler: '@ohif/extension-default.sopClassHandlerModule.stack',
  hangingProtocol: '@ohif/extension-default.hangingProtocolModule.default',
  leftPanel: '@ohif/extension-default.panelModule.seriesList',
  rightPanel: '@ohif/extension-default.panelModule.measure',
};

const cornerstone = {
  viewport: '@ohif/extension-cornerstone.viewportModule.cornerstone',
};

const modeECG = {
  sopClassHandler: 'ecg-extension.sopClassHandlerModule.ecg-dicom',
  viewport: 'ecg-extension.viewportModule.ecg-dicom',
};

/**
 * Just two dependencies to be able to render a viewport with panels in order
 * to make sure that the mode is working.
 */
const extensionDependencies = {
  '@ohif/extension-default': '^3.0.0',
  '@ohif/extension-cornerstone': '^3.0.0',
  'ecg-extension': "^0.0.1"
};

function modeFactory({ modeConfiguration }) {
  return {
    /**
     * Mode ID, which should be unique among modes used by the viewer. This ID
     * is used to identify the mode in the viewer's state.
     */
    id,
    routeName: 'ecg',
    /**
     * Mode name, which is displayed in the viewer's UI in the workList, for the
     * user to select the mode.
     */
    displayName: 'ECG',
    /**
     * Runs when the Mode Route is mounted to the DOM. Usually used to initialize
     * Services and other resources.
     */
    onModeEnter: ({ servicesManager, extensionManager, commandsManager }) => {
      const { measurementService, toolbarService, toolGroupService } = servicesManager.services;

      measurementService.clearMeasurements();

      // Init Default and SR ToolGroups
      initToolGroups(extensionManager, toolGroupService, commandsManager);

      let unsubscribe;

      const activateTool = () => {
        toolbarService.recordInteraction({
          groupId: 'WindowLevel',
          interactionType: 'tool',
          commands: [
            {
              commandName: 'setToolActive',
              commandOptions: {
                toolName: 'WindowLevel',
              },
              context: 'CORNERSTONE',
            },
          ],
        });

        // We don't need to reset the active tool whenever a viewport is getting
        // added to the toolGroup.
        unsubscribe();
      };

      // Since we only have one viewport for the basic cs3d mode and it has
      // only one hanging protocol, we can just use the first viewport
      ({ unsubscribe } = toolGroupService.subscribe(
        toolGroupService.EVENTS.VIEWPORT_ADDED,
        activateTool
      ));

      toolbarService.init(extensionManager);
      toolbarService.addButtons(toolbarButtons);
      toolbarService.createButtonSection('primary', [
        'MeasurementTools',
        'Zoom',
        'WindowLevel',
        'Pan',
        'Capture',
        'Layout',
        'MPR',
        'Crosshairs',
        'MoreTools',
      ]);
    },
    onModeExit: ({ servicesManager }) => {
      const {
        toolGroupService,
        syncGroupService,
        toolbarService,
        segmentationService,
        cornerstoneViewportService,
      } = servicesManager.services;

      toolGroupService.destroy();
      syncGroupService.destroy();
      segmentationService.destroy();
      cornerstoneViewportService.destroy();
    },
    /** */
    validationTags: {
      study: [],
      series: [],
    },
    /**
     * A boolean return value that indicates whether the mode is valid for the
     * modalities of the selected studies. For instance a PET/CT mode should be
     */
    isValidMode: ({ modalities }) => {
      const modalities_list = modalities.split('\\');

      return modalities_list.includes('ECG')
    },
    /**
     * Mode Routes are used to define the mode's behavior. A list of Mode Route
     * that includes the mode's path and the layout to be used. The layout will
     * include the components that are used in the layout. For instance, if the
     * default layoutTemplate is used (id: '@ohif/extension-default.layoutTemplateModule.viewerLayout')
     * it will include the leftPanels, rightPanels, and viewports. However, if
     * you define another layoutTemplate that includes a Footer for instance,
     * you should provide the Footer component here too. Note: We use Strings
     * to reference the component's ID as they are registered in the internal
     * ExtensionManager. The template for the string is:
     * `${extensionId}.{moduleType}.${componentId}`.
     */
    routes: [
      {
        path: 'ECG',
        layoutTemplate: ({ location, servicesManager }) => {
          return {
            id: ohif.layout,
            props: {
              leftPanels: [ohif.leftPanel],
              viewports: [
                {
                  namespace: modeECG.viewport,
                  displaySetsToDisplay: [modeECG.sopClassHandler],
                },
              ],
            },
          };
        },
      },
    ],
    /** List of extensions that are used by the mode */
    extensions: extensionDependencies,
    /** HangingProtocol used by the mode */
    hangingProtocol: 'default',
    /** SopClassHandlers used by the mode */
    sopClassHandlers: [modeECG.sopClassHandler, ohif.sopClassHandler],
    /** hotkeys for mode */
    hotkeys: [...hotkeys.defaults.hotkeyBindings],
  };
}

const mode = {
  id,
  modeFactory,
  extensionDependencies,
};

export default mode;

Extension src/index:

import { id } from './id';
import getSopClassHandlerModule from "./getSopClassHandlerModule";
import React, { Suspense } from "react";

const Component = React.lazy(() => {
  return import('./viewports/EcgViewport');
});

const EcgViewport = props => {
  return (
    <Suspense fallback={(<div>Loading...</div>)}>
      <Component {...props} />
    </Suspense>
  );
};

/**
 * You can remove any of the following modules if you don't need them.
 */
export default {
  /**
   * Only required property. Should be a unique value across all extensions.
   * You ID can be anything you want, but it should be unique.
   */
  id,

  /**
   * Perform any pre-registration tasks here. This is called before the extension
   * is registered. Usually we run tasks such as: configuring the libraries
   * (e.g. cornerstone, cornerstoneTools, ...) or registering any services that
   * this extension is providing.
   */
  preRegistration: ({
    servicesManager,
    commandsManager,
    configuration = {},
  }) => { },

  /**
   * ViewportModule should provide a list of viewports that will be available in OHIF
   * for Modes to consume and use in the viewports. Each viewport is defined by
   * {name, component} object. Example of a viewport module is the CornerstoneViewport
   * that is provided by the Cornerstone extension in OHIF.
   */
  getViewportModule({ servicesManager, extensionManager }) {
    const ExtendedEcgViewport = props => {
      return (
        <EcgViewport
          servicesManager={servicesManager}
          extensionManager={extensionManager}
          {...props}
        />
      );
    };

    return [{ name: 'ecg-dicom', component: ExtendedEcgViewport }];
  },

  /**
   * SopClassHandlerModule should provide a list of sop class handlers that will be
   * available in OHIF for Modes to consume and use to create displaySets from Series.
   * Each sop class handler is defined by a { name, sopClassUids, getDisplaySetsFromSeries}.
   * Examples include the default sop class handler provided by the default extension
   */
  getSopClassHandlerModule,
};

ecg-extension/src/getSopClassHandlerModule (If not hanging protocol issue might be here?):

import { utils, classes } from '@ohif/core';
import { SOPClassHandlerId } from './id';

const { ImageSet } = classes;

const SOP_CLASS_UIDS = {
  TWELVE_LEAD_WAVEFORM_STORAGE: '1.2.840.10008.5.1.4.1.1.9.1.1',
};

const sopClassUids = Object.values(SOP_CLASS_UIDS);

const _getDisplaySetsFromSeries = (instances, servicesManager, extensionManager) => {
  return instances
    .map(instance => {
      const { Modality, SOPInstanceUID, SeriesDescription = "ECG" } = instance;
      const { SeriesDate, SeriesNumber, SeriesInstanceUID, StudyInstanceUID } = instance;
      const displaySet = {
        //plugin: id,
        Modality,
        displaySetInstanceUID: utils.guid(),
        SeriesDescription,
        SeriesNumber: SeriesNumber || 1,
        SeriesDate,
        SOPInstanceUID,
        SeriesInstanceUID,
        StudyInstanceUID,
        SOPClassHandlerId,
        referencedImages: null,
        measurements: null,
        others: [instance],
        isDerivedDisplaySet: true,
        isLoaded: false,
        sopClassUids,
        numImageFrames: 0,
        instance,
      };
      return displaySet;
    });
};

export default function getSopClassHandlerModule({ servicesManager, extensionManager }) {
  const getDisplaySetsFromSeries = instances => {
    return _getDisplaySetsFromSeries(
      instances,
      servicesManager,
      extensionManager
    );
  };

  return [
    {
      name: 'ecg-dicom',
      sopClassUids,
      getDisplaySetsFromSeries,
    },
  ];
}

Thank you for your time!

Got it working with the DB that comes from the “Try it out”, seems like the issue comes from the DB I was working with from the hospital not sending the same response when asking for the metadata as the try out.

Apologies for the post, cannot see a way to remove it, if any staff can or mark as resolved/edit name to resolved.

Thank you!

Hi @haldawe2 , I am actually trying to create and ECG mode but I am not able to create it.It would be helpful if you could share how you were able to create the mode .
EcgViewport.tsx

import React, { useCallback, useContext, useEffect, useState } from 'react';
import multipartDecode from '../multipartDecode';
import WaveformView from './WaveformView';
import GridPattern from './GridPattern';

const convertBuffer = (dataSrc, numberOfChannels, numberOfSamples, bits, type) => {
  const ret = [];
  const data = new Uint8Array(dataSrc);
  const length = data.byteLength || data.length;
  console.log('data size', length, numberOfChannels, numberOfSamples);
  const expectedLength = (bits == 8 ? 1 : 2) * numberOfChannels * numberOfSamples;
  if (length != expectedLength) {
    console.warn("Data length is too short", data, length, expectedLength);
  }
  if (bits == 16) {
    if (type == "SS") {
      for (let channel = 0; channel < numberOfChannels; channel++) {
        const buffer = new Int16Array(numberOfSamples);
        ret.push(buffer);
        let sampleI = 0;
        for (let sample = 2 * channel; sample < length; sample += (2 * numberOfChannels)) {
          const sample0 = data[sample + 1];
          const sample1 = data[sample];
          const sign = sample0 & 0x80;
          buffer[sampleI++] = sign && (0xFFFF0000 | (sample0 << 8) | sample1) || ((sample0 << 8) | sample1);
          // buffer[sampleI++] = sample1 << 8 | sample0;
        }
      }
    } else {
      throw new Error(`Unsupported type ${type}`)
    }
  } else {
    throw new Error(`Unsupported bits ${bits}`);
  }
  return ret;
}

const str2ab = str => Uint8Array.from(atob(str), c => c.charCodeAt(0));

const getChannelData = async (data, numberOfChannels, numberOfSamples, bits, type, studyUID) => {
  if (data.Value) return data.Value;
  if (data.InlineBinary) {
    data.Value = convertBuffer(str2ab(data.InlineBinary), numberOfChannels, numberOfSamples, bits, type);
    return data.Value;
  }
  if (data.retrieveBulkData) {
    const bulkdata = await data.retrieveBulkData();
    console.log('bulkdata=', bulkdata);
    data.Value = convertBuffer(bulkdata, numberOfChannels, numberOfSamples, bits, type);
    return data.Value;
  }
  let {BulkDataURI: url} = data;
  if (url) {
    // older OHIF without retrieveBulkdata functionality
    if( url.indexOf(':')===-1 ) {
      url = `${window.config.dataSources[0].configuration.qidoRoot}/studies/${studyUID}/${url}`;
    }
    console.log("Retrieving", url);

    return new Promise( (resolve,reject) => {
      var xhr = new XMLHttpRequest();
      xhr.responseType = 'arraybuffer';
      xhr.open('GET', url);
      xhr.onload = function () {
        const decoded = multipartDecode(xhr.response)[0];
        data.Value = convertBuffer(decoded,numberOfChannels, numberOfSamples, bits, type);
        resolve(data.Value);
      };
      xhr.onerror = function () {
        reject(xhr.response);
      };
      xhr.send();
    });
  }
  console.log("Can't convert waveform", data);
  return [];
}


function EcgViewport(props) {
  const { displaySets } = props;
  console.log(displaySets);
  const { instances } = displaySets[0];
  const [channelData, setChannelData] = useState([]);


  // if (instances?.length) {
  //   return (<span className="text-red-700">No ECG in display set</span>)
  // }
  const waveform = instances[0].WaveformSequence[0];
  const {StudyInstanceUID: studyUID} = instances[0];

  if (!waveform) {
    return (
      <span className="text-red-700">Waveform data not found</span>
    )
  }

  const {
    MultiplexGroupLabel, WaveformSampleInterpretation,
    NumberOfWaveformChannels, NumberOfWaveformSamples,
    SamplingFrequency, WaveformData, WaveformBitsAllocated,
    ChannelDefinitionSequence = [],
  } = waveform;

  const secondsWidth = 150;
  const defaultItemHeight = 250;
  const pxWidth = Math.ceil(NumberOfWaveformSamples * secondsWidth / SamplingFrequency);
  const extraHeight = 5;

  useEffect(() => {
    getChannelData(WaveformData, NumberOfWaveformChannels, NumberOfWaveformSamples, WaveformBitsAllocated, WaveformSampleInterpretation,studyUID).then(res => {
      setChannelData(res);
    })
  }, [WaveformData])

  const groups = [];
  const scaleRange = 4000;
  const scale = defaultItemHeight / scaleRange;

  const subProps = {...props, scale, scaleRange, secondsWidth, defaultItemHeight, pxWidth, extraHeight};

  let pxHeight = 0;
  for (let i = 0; i < NumberOfWaveformChannels; i++) {
    const data = channelData[i];
    if( !data ) continue;
    const min = data.reduce( (prev, curr) => Math.min(prev,curr), 0);
    const max = data.reduce( (prev, curr) => Math.max(prev,curr), 0);;
    const itemHeight = (max-min)*scale*1.25;
    pxHeight += itemHeight + extraHeight;
    console.log("translate", pxHeight+min*scale, "pxHeight", pxHeight);
    groups.push(
      <g key={i} transform={`translate(0,${pxHeight + min*scale})`}>
        {WaveformView({
          secondsWidth, itemHeight, pxWidth,
          data, scale, min, max,
          channelDefinition: ChannelDefinitionSequence[i],
        })}
      </g>
    );
  }

  subProps.pxHeight = pxHeight;

  // Need to copies of the source to fix a firefox bug
  return (
    <div className="bg-primary-black w-full h-full overflow-hidden ohif-scrollbar">
      <span className="text-white">ECG {MultiplexGroupLabel}</span>
      <svg
        xmlns="http://www.w3.org/2000/svg"
        width={pxWidth}
        height={pxHeight}
        viewBox={`0 0 ${pxWidth} ${pxHeight}`}
      >
        <title>ECG</title>
        {GridPattern(subProps)}
        {groups}
      </svg>
    </div>
  )
}

export default EcgViewport;

getSopClassHandlerModule.ts

import { utils, classes } from '@ohif/core';
import { SOPClassHandlerId } from './id';

const { ImageSet } = classes;

const SOP_CLASS_UIDS = {
  TWELVE_LEAD_WAVEFORM_STORAGE: '1.2.840.10008.5.1.4.1.1.9.1.1',
};

const sopClassUids = Object.values(SOP_CLASS_UIDS);

const _getDisplaySetsFromSeries = (instances, servicesManager, extensionManager) => {
  return instances
    .map(instance => {
      const { Modality, SOPInstanceUID, SeriesDescription = "ECG" } = instance;
      const { SeriesDate, SeriesNumber, SeriesInstanceUID, StudyInstanceUID } = instance;
      const displaySet = {
        //plugin: id,
        Modality,
        displaySetInstanceUID: utils.guid(),
        SeriesDescription,
        SeriesNumber: SeriesNumber || 1,
        SeriesDate,
        SOPInstanceUID,
        SeriesInstanceUID,
        StudyInstanceUID,
        SOPClassHandlerId,
        referencedImages: null,
        measurements: null,
        instances: [instance],
        isDerivedDisplaySet: true,
        isLoaded: false,
        sopClassUids,
        numImageFrames: 0,
        instance,
      };
      return displaySet;
    });
};

function getSopClassHandlerModule({ servicesManager, extensionManager }) {
  const getDisplaySetsFromSeries = instances => {
    return _getDisplaySetsFromSeries(
      instances,
      servicesManager,
      extensionManager
    );
  };

  return [
    {
      name: 'ecg-dicom',
      sopClassUids,
      getDisplaySetsFromSeries,
    },
  ];
}

export default getSopClassHandlerModule;

Hi @Zach5 ,

To create the mode you will need two things, one is the mode itself (with is kind of a config file), and an extension, which is the actual thing that renders the ECG.

You can do that easily following the docs, you would need to do the create-mode and link-mode (I do not remember if the create-mode automatically links it, you can check if it does on platform > app > pluginConfig.json, if it is not there, use the link-mode, also the docs recommend not to touch that file manually, just in case).

For the extension, if you download the online one, you can use the list then add-extension, and that will also link it I think. If you want to copy paste your files, you can use create-extension and link-extension like in the mode. And remember to use yarn install after if you choose to use the online one.

I already completed the things which you have mentioned and attached some of screen shots issue which I am facing


Logs :

I did not encounter this bug when was developing the ECG :confused:

I would play with the mode config file, on the routes, just check it is okay (which it should since it renders the ECG RHYTHM text), the sopClassHandler file inside the extension, and maybe change the viewport disabling parts and check if any is creating the problem, sorry I cannot be a bit more specific.

1 Like

The issue encountered is can’t create waveform