feat(OHIF):Add DICOM Video Display

* feat(OHIFv3):Add a video viewport

* Fixed a couple of viewing issues

* fix(OHIF):If not all instances are used by a given provider, then continue to add items

* Added video data

* Add a basic e2e test for video display

* Adding a screen shot as requested
This commit is contained in:
Bill Wallace 2022-02-24 11:55:12 -05:00 committed by GitHub
parent 73aa738c48
commit ca9ed62a5f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 543 additions and 5 deletions

View File

@ -186,7 +186,8 @@ const commandsModule = ({ servicesManager, commandsManager }) => {
}
const viewportInfo = getEnabledElement(i);
const hasCornerstoneContext = viewportInfo.context &&
if (!viewportInfo) continue;
const hasCornerstoneContext =
viewportInfo.context === 'ACTIVE_VIEWPORT::CORNERSTONE';
if (hasCornerstoneContext) {

View File

@ -149,6 +149,33 @@ function createDicomWebApi(dicomWebConfig, UserAuthenticationService) {
},
},
retrieve: {
/* Generates a URL that can be used for direct retrieve of the bulkdata */
directURL: (params) => {
const { instance, tag = "PixelData", defaultPath = "/pixeldata", defaultType = "video/mp4" } = params;
const { StudyInstanceUID, SeriesInstanceUID, SOPInstanceUID } = instance;
// If the BulkDataURI isn't present, then assume it uses the pixeldata endpoint
// The standard isn't quite clear on that, but appears to be what is expected
const value = instance[tag];
const BulkDataURI =
(value && value.BulkDataURI) ||
`series/${SeriesInstanceUID}/instances/${SOPInstanceUID}${defaultPath}`;
const hasQuery = BulkDataURI.indexOf('?') != -1;
const hasAccept = BulkDataURI.indexOf('accept=') != -1;
const acceptUri =
BulkDataURI +
(hasAccept ? '' : (hasQuery ? '&' : '?') + `accept=${defaultType}`);
if (BulkDataURI.indexOf('http') == 0) return acceptUri;
if (BulkDataURI.indexOf('/') == 0) {
return wadoRoot + acceptUri;
}
if (BulkDataURI.indexOf('series/') == 0) {
return `${wadoRoot}/studies/${StudyInstanceUID}/${acceptUri}`;
}
if (BulkDataURI.indexOf('instances/') === 0) {
return `${wadoRoot}/studies/${StudyInstanceUID}/series/${SeriesInstanceUID}/${acceptUri}`;
}
throw new Error('BulkDataURI in unknown format:' + BulkDataURI);
},
series: {
metadata: async ({
StudyInstanceUID,

View File

@ -0,0 +1,8 @@
const path = require('path');
const webpackCommon = require('./../../../.webpack/webpack.commonjs.js');
const SRC_DIR = path.join(__dirname, '../src');
const DIST_DIR = path.join(__dirname, '../dist');
module.exports = (env, argv) => {
return webpackCommon(env, argv, { SRC_DIR, DIST_DIR });
};

View File

@ -0,0 +1,44 @@
const webpack = require('webpack');
const merge = require('webpack-merge');
const path = require('path');
const webpackCommon = require('./../../../.webpack/webpack.commonjs.js');
const pkg = require('./../package.json');
const ROOT_DIR = path.join(__dirname, './..');
const SRC_DIR = path.join(__dirname, '../src');
const DIST_DIR = path.join(__dirname, '../dist');
module.exports = (env, argv) => {
const commonConfig = webpackCommon(env, argv, { SRC_DIR, DIST_DIR });
return merge(commonConfig, {
devtool: 'source-map',
stats: {
colors: true,
hash: true,
timings: true,
assets: true,
chunks: false,
chunkModules: false,
modules: false,
children: false,
warnings: true,
},
optimization: {
minimize: true,
sideEffects: true,
},
output: {
path: ROOT_DIR,
library: 'OHIFExtDICOMSR',
libraryTarget: 'umd',
libraryExport: 'default',
filename: pkg.main,
},
plugins: [
new webpack.optimize.LimitChunkCountPlugin({
maxChunks: 1,
}),
],
});
};

View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2018 Open Health Imaging Foundation
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -0,0 +1,15 @@
# DICOM Video
This extension adds support for displaying DICOM video objects in a script tag.
The video data must currently be available as video/mp4 on the BulkDataURI that
is provided in the DICOMweb metadata response, and the video must have one of the
specified SOP Class UID's in order to be recognized by the SOP class handler.
Those are:
* Video Microscop Image Storage
* Video Photographic Image Storage
* Video Endoscopic Image Storage
* Secondary Capture Image Storage
* Multiframe True Color Secondary Capture Image Storage
The extension is a "standard" extension in that it is installed and available
by default.

View File

@ -0,0 +1 @@
module.exports = require('../../babel.config.js');

View File

@ -0,0 +1,49 @@
{
"name": "@ohif/extension-dicom-video",
"version": "0.0.1",
"description": "OHIF extension for video display",
"author": "OHIF",
"license": "MIT",
"repository": "OHIF/Viewers",
"main": "dist/index.umd.js",
"module": "src/index.js",
"engines": {
"node": ">=10",
"npm": ">=6",
"yarn": ">=1.16.0"
},
"files": [
"dist",
"README.md"
],
"publishConfig": {
"access": "public"
},
"scripts": {
"dev": "cross-env NODE_ENV=development webpack --config .webpack/webpack.dev.js --watch --debug --output-pathinfo",
"dev:cornerstone": "yarn run dev",
"build": "cross-env NODE_ENV=production webpack --config .webpack/webpack.prod.js",
"build:package": "yarn run build",
"start": "yarn run dev",
"test:unit": "jest --watchAll",
"test:unit:ci": "jest --ci --runInBand --collectCoverage --passWithNoTests"
},
"peerDependencies": {
"@ohif/core": "^0.50.0",
"@ohif/ui": "^0.50.0",
"cornerstone-core": "^2.6.0",
"cornerstone-math": "^0.1.9",
"cornerstone-tools": "6.0.2",
"cornerstone-wado-image-loader": "4.0.4",
"dcmjs": "0.16.1",
"dicom-parser": "^1.8.9",
"hammerjs": "^2.0.8",
"prop-types": "^15.6.2",
"react": "^17.0.2",
"react-cornerstone-viewport": "4.1.2"
},
"dependencies": {
"@babel/runtime": "7.7.6",
"classnames": "^2.2.6"
}
}

View File

@ -0,0 +1,84 @@
import { SOPClassHandlerName, SOPClassHandlerId } from './id';
import { utils, classes } from '@ohif/core';
const { ImageSet } = classes;
const SOP_CLASS_UIDS = {
VIDEO_MICROSCOPIC_IMAGE_STORAGE: '1.2.840.10008.5.1.4.1.1.77.1.2.1',
VIDEO_PHOTOGRAPHIC_IMAGE_STORAGE: '1.2.840.10008.5.1.4.1.1.77.1.4.1',
VIDEO_ENDOSCOPIC_IMAGE_STORAGE: '1.2.840.10008.5.1.4.1.1.77.1.1.1',
/** Need to use fallback, could be video or image */
SECONDARY_CAPTURE_IMAGE_STORAGE: '1.2.840.10008.5.1.4.1.1.7',
MULTIFRAME_TRUE_COLOR_SECONDARY_CAPTURE_IMAGE_STORAGE:
'1.2.840.10008.5.1.4.1.1.7.4',
};
const sopClassUids = Object.values(SOP_CLASS_UIDS);
const SupportedTransferSyntaxes = {
MPEG4_AVC_264_HIGH_PROFILE: '1.2.840.10008.1.2.4.102',
MPEG4_AVC_264_BD_COMPATIBLE_HIGH_PROFILE: '1.2.840.10008.1.2.4.103',
MPEG4_AVC_264_HIGH_PROFILE_FOR_2D_VIDEO: '1.2.840.10008.1.2.4.104',
MPEG4_AVC_264_HIGH_PROFILE_FOR_3D_VIDEO: '1.2.840.10008.1.2.4.105',
MPEG4_AVC_264_STEREO_HIGH_PROFILE: '1.2.840.10008.1.2.4.106',
HEVC_265_MAIN_PROFILE: '1.2.840.10008.1.2.4.107',
HEVC_265_MAIN_10_PROFILE: '1.2.840.10008.1.2.4.108',
};
const supportedTransferSyntaxUIDs = Object.values(SupportedTransferSyntaxes);
const _getDisplaySetsFromSeries = (instances, servicesManager, extensionManager) => {
const dataSource = extensionManager.getActiveDataSource()[0];
return instances
.filter(metadata => {
const tsuid = metadata.AvailableTransferSyntaxUID ||
metadata.TransferSyntaxUID || metadata['00083002'];
return supportedTransferSyntaxUIDs.includes(tsuid);
})
.map(instance => {
const { Modality, FrameOfReferenceUID, SOPInstanceUID } = instance;
const { SeriesDescription, ContentDate, ContentTime } = instance;
const { SeriesNumber, SeriesDate, SeriesInstanceUID, StudyInstanceUID, NumberOfFrames } = instance;
const displaySet = {
//plugin: id,
Modality,
displaySetInstanceUID: utils.guid(),
SeriesDescription,
SeriesNumber,
SeriesDate,
SOPInstanceUID,
SeriesInstanceUID,
StudyInstanceUID,
SOPClassHandlerId,
referencedImages: null,
measurements: null,
videoUrl: dataSource.retrieve.directURL({ instance }),
others: [instance],
thumbnailSrc: dataSource.retrieve.directURL({ instance, defaultPath: "/thumbnail", defaultType: "image/jpeg", tag: "Absent" }),
isDerivedDisplaySet: true,
isLoaded: false,
sopClassUids,
numImageFrames: NumberOfFrames,
instance,
};
return displaySet;
});
};
export default function getSopClassHandlerModule({ servicesManager, extensionManager }) {
const getDisplaySetsFromSeries = instances => {
return _getDisplaySetsFromSeries(
instances,
servicesManager,
extensionManager
);
};
return [
{
name: SOPClassHandlerName,
sopClassUids,
getDisplaySetsFromSeries,
},
];
}

View File

@ -0,0 +1,7 @@
const id = 'org.ohif.dicom-video';
export default id;
const SOPClassHandlerName = 'dicom-video';
const SOPClassHandlerId = `${id}.sopClassHandlerModule.${SOPClassHandlerName}`;
export { SOPClassHandlerName, SOPClassHandlerId };

View File

@ -0,0 +1,99 @@
import React from 'react';
import getSopClassHandlerModule from './getSopClassHandlerModule';
import id from './id.js';
const Component = React.lazy(() => {
return import(
/* webpackPrefetch: true */ './viewports/OHIFCornerstoneVideoViewport'
);
});
const OHIFCornerstoneVideoViewport = props => {
return (
<React.Suspense fallback={<div>Loading...</div>}>
<Component {...props} />
</React.Suspense>
);
};
/**
*
*/
export default {
/**
* Only required property. Should be a unique value across all extensions.
*/
id,
dependencies: [
// TODO -> This isn't used anywhere yet, but we do have a hard dependency, and need to check for these in the future.
// OHIF-229
{
id: 'org.ohif.cornerstone',
version: '3.0.0',
},
{
id: 'org.ohif.measurement-tracking',
version: '^0.0.1',
},
],
preRegistration({ servicesManager, configuration = {} }) {
// No-op for now
},
/**
*
*
* @param {object} [configuration={}]
* @param {object|array} [configuration.csToolsConfig] - Passed directly to `initCornerstoneTools`
*/
getViewportModule({ servicesManager, extensionManager }) {
const ExtendedOHIFCornerstoneVideoViewport = props => {
return (
<OHIFCornerstoneVideoViewport
servicesManager={servicesManager}
extensionManager={extensionManager}
{...props}
/>
);
};
return [{ name: 'dicom-video', component: ExtendedOHIFCornerstoneVideoViewport }];
},
getCommandsModule({ servicesManager }) {
return {
definitions: {
setToolActive: {
commandFn: ({ toolName, element }) => {
if (!toolName) {
console.warn('No toolname provided to setToolActive command');
}
// Set same tool or alt tool
const toolAlias = _getToolAlias(toolName);
cornerstoneTools.setToolActiveForElement(element, toolAlias, {
mouseButtonMask: 1,
});
},
storeContexts: [],
options: {},
},
},
defaultContext: 'ACTIVE_VIEWPORT::VIDEO',
};
},
getSopClassHandlerModule,
};
function _getToolAlias(toolName) {
let toolAlias = toolName;
switch (toolName) {
case 'EllipticalRoi':
toolAlias = 'SREllipticalRoi';
break;
}
return toolAlias;
}

View File

@ -0,0 +1,61 @@
import cornerstone from 'cornerstone-core';
const state = {
TrackingUniqueIdentifier: null,
trackingIdentifiersByEnabledElementUUID: {},
};
function setTrackingUniqueIdentifiersForElement(
element,
trackingUniqueIdentifiers,
activeIndex = 0
) {
const enabledElement = cornerstone.getEnabledElement(element);
const { uuid } = enabledElement;
state.trackingIdentifiersByEnabledElementUUID[uuid] = {
trackingUniqueIdentifiers,
activeIndex,
};
}
function setActiveTrackingUniqueIdentifierForElement(
element,
TrackingUniqueIdentifier
) {
const enabledElement = cornerstone.getEnabledElement(element);
const { uuid } = enabledElement;
const trackingIdentifiersForElement =
state.trackingIdentifiersByEnabledElementUUID[uuid];
if (trackingIdentifiersForElement) {
const activeIndex = trackingIdentifiersForElement.trackingUniqueIdentifiers.findIndex(
tuid => tuid === TrackingUniqueIdentifier
);
trackingIdentifiersForElement.activeIndex = activeIndex;
}
}
function getTrackingUniqueIdentifiersForElement(element) {
const enabledElement = cornerstone.getEnabledElement(element);
const { uuid } = enabledElement;
if (state.trackingIdentifiersByEnabledElementUUID[uuid]) {
return state.trackingIdentifiersByEnabledElementUUID[uuid];
}
return { trackingUniqueIdentifiers: [] };
}
export default {
state,
getters: {
trackingUniqueIdentifiersForElement: getTrackingUniqueIdentifiersForElement,
},
setters: {
trackingUniqueIdentifiersForElement: setTrackingUniqueIdentifiersForElement,
activeTrackingUniqueIdentifierForElement: setActiveTrackingUniqueIdentifierForElement,
},
};

View File

@ -0,0 +1,51 @@
import React, { useCallback, useContext, useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import cornerstoneTools from 'cornerstone-tools';
import cornerstone from 'cornerstone-core';
import CornerstoneViewport from 'react-cornerstone-viewport';
import OHIF, { DicomMetadataStore, utils } from '@ohif/core';
import {
Notification,
ViewportActionBar,
useViewportGrid,
useViewportDialog,
} from '@ohif/ui';
import { adapters } from 'dcmjs';
import id from '../id';
function OHIFCornerstoneVideoViewport({
children,
dataSource,
displaySet,
viewportIndex,
servicesManager,
extensionManager,
}) {
const {
DisplaySetService,
MeasurementService,
ToolBarService,
} = servicesManager.services;
const { videoUrl } = displaySet;
const mimeType = "video/mp4";
// Need to copies of the source to fix a firefox bug
return (
<div className="bg-primary-black w-full h-full">
<video
src={videoUrl}
controls
controlsList="nodownload"
preload="auto"
className="w-full h-full"
>
<source src={videoUrl} type={mimeType} />
<source src={videoUrl} type={mimeType} />
Video src/type not supported: <a href={videoUrl}>{videoUrl} of type {mimeType}</a>
</video>
</div>
)
}
export default OHIFCornerstoneVideoViewport;

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

View File

@ -18,6 +18,11 @@ const dicomsr = {
viewport: 'org.ohif.dicom-sr.viewportModule.dicom-sr',
};
const dicomvideo = {
sopClassHandler: 'org.ohif.dicom-video.sopClassHandlerModule.dicom-video',
viewport: 'org.ohif.dicom-video.viewportModule.dicom-video',
}
export default function mode({ modeConfiguration }) {
return {
// TODO: We're using this as a route segment
@ -87,6 +92,10 @@ export default function mode({ modeConfiguration }) {
namespace: dicomsr.viewport,
displaySetsToDisplay: [dicomsr.sopClassHandler],
},
{
namespace: dicomvideo.viewport,
displaySetsToDisplay: [dicomvideo.sopClassHandler],
},
],
},
};
@ -98,9 +107,10 @@ export default function mode({ modeConfiguration }) {
'org.ohif.cornerstone',
'org.ohif.measurement-tracking',
'org.ohif.dicom-sr',
'org.ohif.dicom-video',
],
hangingProtocols: [ohif.hangingProtocols],
sopClassHandlers: [ohif.sopClassHandler, dicomsr.sopClassHandler],
sopClassHandlers: [dicomvideo.sopClassHandler, ohif.sopClassHandler, dicomsr.sopClassHandler,],
hotkeys: [...hotkeys.defaults.hotkeyBindings],
};
}

View File

@ -3,6 +3,31 @@ import EVENTS from './EVENTS';
const displaySetCache = [];
/**
* Find an instance in a list of instances, comparing by SOP instance UID
*/
const findInSet = (instance, list) => {
if (!list) return false;
for (const elem of list) {
if (!elem) continue;
if (elem === instance) return true;
if (elem.SOPInstanceUID === instance.SOPInstanceUID) return true;
}
return false;
};
/**
* Find an instance in a display set
* @returns true if found
*/
const findInstance = (instance, displaySets) => {
for (const displayset of displaySets) {
if (findInSet(instance, displayset.images)) return true;
if (findInSet(instance, displayset.others)) return true;
}
return false;
}
export default class DisplaySetService {
constructor() {
this.activeDisplaySets = [];
@ -151,13 +176,15 @@ export default class DisplaySetService {
}
};
makeDisplaySetForInstances(instances, settings) {
makeDisplaySetForInstances(instancesSrc, settings) {
let instances = instancesSrc;
const instance = instances[0];
const existingDisplaySets =
this.getDisplaySetsForSeries(instance.SeriesInstanceUID) || [];
const SOPClassHandlerIds = this.SOPClassHandlerIds;
let allDisplaySets;
for (let i = 0; i < SOPClassHandlerIds.length; i++) {
const SOPClassHandlerId = SOPClassHandlerIds[i];
@ -174,6 +201,8 @@ export default class DisplaySetService {
} else {
displaySets = handler.getDisplaySetsFromSeries(instances);
if (!displaySets || !displaySets.length) continue;
// applying hp-defined viewport settings to the displaysets
displaySets.forEach(ds => {
Object.keys(settings).forEach(key => {
@ -183,10 +212,15 @@ export default class DisplaySetService {
this._addDisplaySetsToCache(displaySets);
this._addActiveDisplaySets(displaySets);
instances = instances.filter(instance => !findInstance(instance, displaySets))
}
return displaySets;
allDisplaySets = allDisplaySets ? [...allDisplaySets, ...displaySets] : displaySets;
if (!instances.length) return allDisplaySets;
}
}
return allDisplaySets;
}
}

View File

@ -0,0 +1,24 @@
describe('OHIF Video Display', function () {
before(() => {
cy.openStudyInViewer(
'2.25.96975534054447904995905761963464388233'
);
});
beforeEach(function () {
cy.resetViewport().wait(50);
});
it('checks if series thumbnails are being displayed', function () {
cy.get('[data-cy="study-browser-thumbnail"]')
.its('length')
.should('be.gt', 1);
});
it('performs double-click to load thumbnail in active viewport', () => {
cy.get('[data-cy="study-browser-thumbnail"]:nth-child(2)').dblclick();
//const expectedText = 'Ser: 3';
//cy.get('@viewportInfoBottomLeft').should('contains.text', expectedText);
});
});

View File

@ -23,6 +23,7 @@ import OHIFDefaultExtension from '@ohif/extension-default';
import OHIFCornerstoneExtension from '@ohif/extension-cornerstone';
import OHIFMeasurementTrackingExtension from '@ohif/extension-measurement-tracking';
import OHIFDICOMSRExtension from '@ohif/extension-dicom-sr';
import OHIFDICOMVIDEOExtension from '@ohif/extension-dicom-video';
/** Combine our appConfiguration and "baked-in" extensions */
const appProps = {
@ -32,6 +33,7 @@ const appProps = {
OHIFCornerstoneExtension,
OHIFMeasurementTrackingExtension,
OHIFDICOMSRExtension,
OHIFDICOMVIDEOExtension,
],
};

@ -1 +1 @@
Subproject commit 47d51e15a0d41c02f806570d8cf78ab39e782387
Subproject commit e8e18cb65ff77e37273a8553f0f651ba4fd1a731