feat: cornerstone3D stack and volume viewports (#2787)

* feat: cs3d working stack viewport and tools (#19)

* Squashed everything

* fix weird react issue

* fix eslint / prettier stuff

* thumbnails work now with cpu rendering

* feat: use new loadImageToCanvas in cs3d

* make jump to measurement work

* fix active thumbnail

* fix measurement delete

* fix window level presets

* remove segmentation and sync groups for now

* fix the dicom pdf and dicom video

* fix cornerstone window assignment for cypress

* apply review comments

Co-authored-by: Erik <erik.sweed@gmail.com>

* feat: cs3d tools and toolGroups (#20)

* add more tools to work with cs3d mode

* fix hotkeys

* add stack manager usage for stach viewports

* add image scrollbar

* wip viewport overlay

* fix toAnnotation schema for tools

* fix the unnecessary size change that triggered resize

* hanging protocol improvement to allow unmatched errors

* study description matching for hanging protocol

* fix the displaysetOptions to work

* fix handle the active tool when a new viewport is added

* fix separate toolGroups for mode

* apply review comments

* apply review comments

* yarn lock

* feat: overlay component (#21)

* fix default displayset options

* add viewport overlay

* apply review comments

* feat: loading and orientation indicators (#22)

* add loading indicator

* add orientation marker initial work

* apply review comments

* fix: orientation markers (#23)

* finished the orientation markers

* fix various broken cypress tests

* apply review comments

* update yarn lock

* feat: re-working measurement tracking mode with cornerstone3d (#2805)

* feat: cs3d working stack viewport and tools (#19)

* Squashed everything

* fix weird react issue

* fix eslint / prettier stuff

* thumbnails work now with cpu rendering

* feat: use new loadImageToCanvas in cs3d

* make jump to measurement work

* fix active thumbnail

* fix measurement delete

* fix window level presets

* remove segmentation and sync groups for now

* fix the dicom pdf and dicom video

* fix cornerstone window assignment for cypress

* apply review comments

Co-authored-by: Erik <erik.sweed@gmail.com>

* feat: Add Measurement tracking mode with cs3D (#2789)

* feat: first render for cornerstone3d tracked viewport

* make tool active work

* wip for SR extension

* renamed dicom sr to cornerstone dicom sr

* remove cornerstone from dicom pdf and video

* move dicom sr logic to sr extension

* feat: Add hydration for the length tool

* fix SR display tool for length using cs3d

* fix default config

* fix: various bugs with sr viewport and tracking

* fix promptying to continue tracking for when SR is created

* feat: add keep trackign of unique identifiers

* fix hydration for same imageIds

* feat: Add SR toolGroup creation on modeEnter

* feat: remove the need for separate mapper for SR hydration

* add SR display for ellipse

* handle hydration of elliptical ROI tool

* remove cornerstone extension

* add arrow mapping

* feat: Add ArrowAnnotate SR display and hydration

* apply review comments

* apply review comments

* move viewport labels to the viewportData

* apply review comments

* fix: integration cypress tests with cornerstone3D and add CINE tool (#2795)

* fix: integration cypress tests with cornerstone3D

* revert to addOrUpdate as it makes more sense

* fix local drag and drop

* fix tests

* move dicomLoaderService to cornerstone extension

* fix various import bugs

* fix bug for local PT series

* fix various unit tests

* bump cs3d versions

* add angle and magnify tool

* bump dependencies to avoid broken peerDeps

* feat: add initial work for capture using cs3d

* feat: show annotations on the image capture

* feat: add svg layer export

* feat: Add CINE Tool

* feat: remove unnecessary viewport rendering for cine state changes

Co-authored-by: Erik Ziegler <erik.sweed@gmail.com>

* docs: modify and improve documentation (#2800)

* cleanup docs versionings

* feat: Add all version explanations

* version docs for 3.0

* wip for changing docs

* wip for updated docs

* add utility module documentation

* fix demo with nohoisting of history

* add slides and video to resources

* apply review comments

* fix: drag and drop SR into SR viewport (#2803)

* update yarn lock

Co-authored-by: Erik <erik.sweed@gmail.com>

* update pathnames to match v3-stable

* update the e2e pathname

* fix: various bugs with regard to tracking workflow (#2811)

* fix: various issues with measurement panel

* fix: update default tool style for annotations

* fix: annotatoin label getting removed

* feat: Add backward compatibility for SR hydration with legacy cornerstone

* fix: cursors and ellipse ROI max style

* fix: ArrowAnnotate SRDisplay

* apply review comments

* bump package versions

* fix: bugin rehydration of SR

* fix: e2e tests

* fix active viewport thickness and arrowTool ui

* add readme for measurement tracking

* use uploaded image for readme

* add back images

* try to fix e2e test

* fix: window level presets hotkeys

* Update README.md

* update yarn lock

* feat: volume api and TMTV mode (#2817)

* feat: volumeAPI and TMTV mode

* feat: use cs3d cache service to obtain viewportData

* wip for volume api

* wip: fusion viewport

* feat: add blend mode option

* wip for image scrollbar

* fix drag and drop thumbnail

* wip for image scrollbar for voluems

* fix: element mismatch bug for scroll

* feat: Add image scrollbar to volumes

* feat: add syncGroups to volume api

* feat: add tmtv mode initial setup

* feat: add initial image options for the stack viewports

* feat: Add custom load strategy for volume viewports via HP

* apply review comments

fix: Jump presets cs3d (#2812)

* feat: Add JumpPreset to OHIF for Cornerstone3D

* fix: accessing viewport service from servicesManager

feat: volume API and TMTV mode (#2814)

* fix: do not display overlays on mip viewports

* feat: add optional disableCommands for toggle buttons

* fix: toggleCrossharis

* feat: config the crosshairs

* feat: add PetSUV Panel for changing metadata

* feat: initial work for the rectangleROIThreshold panel

* wip: measurement service

* roi threshold working

* feat: Add displayText to segmentations

* feat: add remove segmentation

* feat: add csv export

* feat: add RT export for annotations in tmtv mode

* fix: fusion to use pt in tmtv mode and measuremet mappings

* fix: various bugs

* apply review comments

* apply review comments

* feat: add fusion color maps

* add readme to tmtv mode

* Update README.md

* fix: try to fix build

* fix unit tests

* Update README.md

* feat: add about to the panel

* fix: changing strategy in roi panel

* apply review comments

* update package versions

* wip for stackPrefetch

* fix: cornerstone3d hydration and renaming (#2818)

* update readme

* renamed cornerstone extension

* wip for renaming cornerstone3D variables

* wip for renaming cornerstone3D variables

* wip for fixing bugs for SR viewport

* fix: jumpToMeasurement and initial label after hydration

* fix: fileName capitalization

* feat: use the new prefetch stack in the cs3d (#2820)

* feat: use the new prefetch stack in the cs3d

* use viewport scroll api for stack viewport

* fix cine stop when scrollbar changes

* feat: use new prefetch events

* fix loading state to not show repeatedly

* fix: various bugs for tmtv mode thresholding and new icons (#2823)

* feat: make tmtv mode available in worklist

* feat: add new icons for tmtv mode

* feat: add fusion color icon

* fix: parallel scale calculation

* fix: bump Cornerstone to get large image support working

* fix: Fix issues with CPU viewport flipping, including config files

* fix: bump cornerstone to fix magnify tool

* fix: Bump Cornerstone version to fix resetCamera issue in StackViewport

* ci: Add _headers file to enable CORS headers for Netlify Drag/Drop deploys

* fix: WADO-URI was not working. PET Metadata was coming from the wrong place. Fixed some minor React errors

* feat(OHIF):Allow modes and extensions to be added after commpile time. (#2838)

Also works with the compile time add that the existing cli uses, so that both build types work.
Eric and I agreed this doesn't change existing functionality, but is almost entirely build issues/fixes.

* bump: dependency versions to fix hydration bugs (#2848)

* bump: dcmjs version to fix hydration bugs

* try to fix tests

* bump dependency versions

Co-authored-by: Alireza <ar.sedghi@gmail.com>
Co-authored-by: Bill Wallace <wayfarer3130@gmail.com>
This commit is contained in:
authored and GitHub committed 2022-07-27 12:39:04 -04:00
1 parent 86dcaf6293
commit bd1c38c00c
668 files changed
+41669 -13410

No files matched your search

@@ -13,7 +13,6 @@ module.exports = (env, argv) => {
const commonConfig = webpackCommon(env, argv, { SRC_DIR, DIST_DIR });
return merge(commonConfig, {
devtool: 'source-map',
stats: {
colors: true,
hash: true,
+3 -2
View File
@@ -32,7 +32,7 @@
"peerDependencies": {
"@ohif/core": "^3.0.0",
"@ohif/i18n": "^1.0.0",
"dcmjs": "0.16.1",
"dcmjs": "^0.24.5",
"dicomweb-client": "^0.6.0",
"prop-types": "^15.6.2",
"react": "^17.0.2",
@@ -42,6 +42,7 @@
"webpack-merge": "^5.7.3"
},
"dependencies": {
"@babel/runtime": "7.16.3"
"@babel/runtime": "7.16.3",
"@cornerstonejs/calculate-suv": "1.0.2"
}
}
@@ -128,6 +128,8 @@ function createDicomLocalApi(dicomLocalConfig) {
SOPInstanceUID,
} = instance;
instance.imageId = imageId;
// Add imageId specific mapping to this data as the URL isn't necessarily WADO-URI.
metadataProvider.addImageIdToUIDs(imageId, {
StudyInstanceUID,
@@ -1,4 +1,12 @@
import { api } from 'dicomweb-client';
import {
DicomMetadataStore,
IWebApiDataSource,
utils,
errorHandler,
classes,
} from '@ohif/core';
import {
mapParams,
search as qidoSearch,
@@ -7,12 +15,6 @@ import {
processSeriesResults,
} from './qido.js';
import dcm4cheeReject from './dcm4cheeReject';
import {
DicomMetadataStore,
IWebApiDataSource,
utils,
errorHandler,
} from '@ohif/core';
import getImageId from './utils/getImageId';
import dcmjs from 'dcmjs';
@@ -31,6 +33,8 @@ const ImplementationClassUID =
const ImplementationVersionName = 'OHIF-VIEWER-2.0.0';
const EXPLICIT_VR_LITTLE_ENDIAN = '1.2.840.10008.1.2.1';
const metadataProvider = classes.MetadataProvider;
/**
*
* @param {string} name - Data source name
@@ -94,7 +98,7 @@ function createDicomWebApi(dicomWebConfig, UserAuthenticationService) {
query: {
studies: {
mapParams: mapParams.bind(),
search: async function (origParams) {
search: async function(origParams) {
const headers = UserAuthenticationService.getAuthorizationHeader();
if (headers) {
qidoDicomWebClient.headers = headers;
@@ -119,7 +123,7 @@ function createDicomWebApi(dicomWebConfig, UserAuthenticationService) {
},
series: {
// mapParams: mapParams.bind(),
search: async function (studyInstanceUid) {
search: async function(studyInstanceUid) {
const headers = UserAuthenticationService.getAuthorizationHeader();
if (headers) {
qidoDicomWebClient.headers = headers;
@@ -163,13 +167,13 @@ function createDicomWebApi(dicomWebConfig, UserAuthenticationService) {
* @returns an absolute URL to the resource, if the absolute URL can be retrieved as singlepart,
* or is already retrieved, or a promise to a URL for such use if a BulkDataURI
*/
directURL: (params) => {
directURL: params => {
const {
instance,
tag = "PixelData",
defaultPath = "/pixeldata",
defaultType = "video/mp4",
singlepart: fetchPart = "video",
tag = 'PixelData',
defaultPath = '/pixeldata',
defaultType = 'video/mp4',
singlepart: fetchPart = 'video',
} = params;
const value = instance[tag];
if (!value) return undefined;
@@ -180,10 +184,15 @@ function createDicomWebApi(dicomWebConfig, UserAuthenticationService) {
value.DirectRetrieveURL = URL.createObjectURL(blob);
return value.DirectRetrieveURL;
}
if (!singlepart || singlepart !== true && singlepart.indexOf(fetchPart) === -1) {
if (
!singlepart ||
(singlepart !== true && singlepart.indexOf(fetchPart) === -1)
) {
if (value.retrieveBulkData) {
return value.retrieveBulkData().then(arr => {
value.DirectRetrieveURL = URL.createObjectURL(new Blob([arr], { type: defaultType }));
value.DirectRetrieveURL = URL.createObjectURL(
new Blob([arr], { type: defaultType })
);
return value.DirectRetrieveURL;
});
}
@@ -191,7 +200,11 @@ function createDicomWebApi(dicomWebConfig, UserAuthenticationService) {
return undefined;
}
const { StudyInstanceUID, SeriesInstanceUID, SOPInstanceUID } = instance;
const {
StudyInstanceUID,
SeriesInstanceUID,
SOPInstanceUID,
} = instance;
const BulkDataURI =
(value && value.BulkDataURI) ||
`series/${SeriesInstanceUID}/instances/${SOPInstanceUID}${defaultPath}`;
@@ -234,94 +247,27 @@ function createDicomWebApi(dicomWebConfig, UserAuthenticationService) {
);
}
// Get Series
const {
seriesSummaryMetadata,
seriesPromises,
} = await retrieveStudyMetadata(
wadoDicomWebClient,
StudyInstanceUID,
enableStudyLazyLoad,
filters,
sortCriteria,
sortFunction
);
/**
* naturalizes the dataset, and adds a retrieve bulkdata method
* to any values containing BulkDataURI.
* @param {*} instance
* @returns naturalized dataset, with retrieveBulkData methods
*/
const addRetrieveBulkData = instance => {
const naturalized = naturalizeDataset(instance);
Object.keys(naturalized).forEach(key => {
const value = naturalized[key];
// The value.Value will be set with the bulkdata read value
// in which case it isn't necessary to re-read this.
if (value && value.BulkDataURI && !value.Value) {
// Provide a method to fetch bulkdata
value.retrieveBulkData = () => {
const options = {
// The bulkdata fetches work with either multipart or
// singlepart, so set multipart to false to let the server
// decide which type to respond with.
multipart: false,
BulkDataURI: value.BulkDataURI,
// The study instance UID is required if the bulkdata uri
// is relative - that isn't disallowed by DICOMweb, but
// isn't well specified in the standard, but is needed in
// any implementation that stores static copies of the metadata
StudyInstanceUID: naturalized.StudyInstanceUID,
};
return qidoDicomWebClient
.retrieveBulkData(options)
.then(val => {
const ret = (val && val[0]) || undefined;
value.Value = ret;
return ret;
});
};
}
});
return naturalized;
};
// Async load series, store as retrieved
function storeInstances(instances) {
const naturalizedInstances = instances.map(addRetrieveBulkData);
DicomMetadataStore.addInstances(naturalizedInstances, madeInClient);
}
function setSuccessFlag() {
const study = DicomMetadataStore.getStudy(
if (enableStudyLazyLoad) {
return implementation._retrieveSeriesMetadataAsync(
StudyInstanceUID,
filters,
sortCriteria,
sortFunction,
madeInClient
);
study.isLoaded = true;
}
// Google Cloud Healthcare doesn't return StudyInstanceUID, so we need to add
// it manually here
seriesSummaryMetadata.forEach(aSeries => {
aSeries.StudyInstanceUID = StudyInstanceUID;
});
DicomMetadataStore.addSeriesMetadata(
seriesSummaryMetadata,
return implementation._retrieveSeriesMetadataSync(
StudyInstanceUID,
filters,
sortCriteria,
sortFunction,
madeInClient
);
const numberOfSeries = seriesPromises.length;
seriesPromises.forEach(async (seriesPromise, index) => {
const instances = await seriesPromise;
storeInstances(instances);
if (index === numberOfSeries - 1) setSuccessFlag();
});
},
},
},
store: {
dicom: async dataset => {
const headers = UserAuthenticationService.getAuthorizationHeader();
@@ -353,6 +299,187 @@ function createDicomWebApi(dicomWebConfig, UserAuthenticationService) {
await wadoDicomWebClient.storeInstances(options);
},
},
_retrieveSeriesMetadataSync: async (
StudyInstanceUID,
filters,
sortCriteria,
sortFunction,
madeInClient
) => {
const enableStudyLazyLoad = false;
// data is all SOPInstanceUIDs
const data = await retrieveStudyMetadata(
wadoDicomWebClient,
StudyInstanceUID,
enableStudyLazyLoad,
filters,
sortCriteria,
sortFunction
);
// first naturalize the data
const naturalizedInstancesMetadata = data.map(naturalizeDataset);
const seriesSummaryMetadata = {};
const instancesPerSeries = {};
naturalizedInstancesMetadata.forEach(instance => {
if (!seriesSummaryMetadata[instance.SeriesInstanceUID]) {
seriesSummaryMetadata[instance.SeriesInstanceUID] = {
StudyInstanceUID: instance.StudyInstanceUID,
StudyDescription: instance.StudyDescription,
SeriesInstanceUID: instance.SeriesInstanceUID,
SeriesDescription: instance.SeriesDescription,
SeriesNumber: instance.SeriesNumber,
SeriesTime: instance.SeriesTime,
SOPClassUID: instance.SOPClassUID,
ProtocolName: instance.ProtocolName,
Modality: instance.Modality,
};
}
if (!instancesPerSeries[instance.SeriesInstanceUID]) {
instancesPerSeries[instance.SeriesInstanceUID] = [];
}
const imageId = implementation.getImageIdsForInstance({
instance,
});
instance.imageId = imageId;
metadataProvider.addImageIdToUIDs(imageId, {
StudyInstanceUID,
SeriesInstanceUID: instance.SeriesInstanceUID,
SOPInstanceUID: instance.SOPInstanceUID,
});
instancesPerSeries[instance.SeriesInstanceUID].push(instance);
});
// grab all the series metadata
const seriesMetadata = Object.values(seriesSummaryMetadata);
DicomMetadataStore.addSeriesMetadata(seriesMetadata, madeInClient);
Object.keys(instancesPerSeries).forEach(seriesInstanceUID =>
DicomMetadataStore.addInstances(
instancesPerSeries[seriesInstanceUID],
madeInClient
)
);
},
_retrieveSeriesMetadataAsync: async (
StudyInstanceUID,
filters,
sortCriteria,
sortFunction,
madeInClient = false
) => {
const enableStudyLazyLoad = true;
// Get Series
const {
preLoadData: seriesSummaryMetadata,
promises: seriesPromises,
} = await retrieveStudyMetadata(
wadoDicomWebClient,
StudyInstanceUID,
enableStudyLazyLoad,
filters,
sortCriteria,
sortFunction
);
/**
* naturalizes the dataset, and adds a retrieve bulkdata method
* to any values containing BulkDataURI.
* @param {*} instance
* @returns naturalized dataset, with retrieveBulkData methods
*/
const addRetrieveBulkData = instance => {
const naturalized = naturalizeDataset(instance);
Object.keys(naturalized).forEach(key => {
const value = naturalized[key];
// The value.Value will be set with the bulkdata read value
// in which case it isn't necessary to re-read this.
if (value && value.BulkDataURI && !value.Value) {
// Provide a method to fetch bulkdata
value.retrieveBulkData = () => {
const options = {
// The bulkdata fetches work with either multipart or
// singlepart, so set multipart to false to let the server
// decide which type to respond with.
multipart: false,
BulkDataURI: value.BulkDataURI,
// The study instance UID is required if the bulkdata uri
// is relative - that isn't disallowed by DICOMweb, but
// isn't well specified in the standard, but is needed in
// any implementation that stores static copies of the metadata
StudyInstanceUID: naturalized.StudyInstanceUID,
};
return qidoDicomWebClient.retrieveBulkData(options).then(val => {
const ret = (val && val[0]) || undefined;
value.Value = ret;
return ret;
});
};
}
});
return naturalized;
};
// Async load series, store as retrieved
function storeInstances(instances) {
const naturalizedInstances = instances.map(addRetrieveBulkData);
// Adding instanceMetadata to OHIF MetadataProvider
naturalizedInstances.forEach((instance, index) => {
const imageId = implementation.getImageIdsForInstance({
instance,
});
// Adding imageId to each instance
// Todo: This is not the best way I can think of to let external
// metadata handlers know about the imageId that is stored in the store
instance.imageId = imageId;
// Adding UIDs to metadataProvider
// Note: storing imageURI in metadataProvider since stack viewports
// will use the same imageURI
metadataProvider.addImageIdToUIDs(imageId, {
StudyInstanceUID,
SeriesInstanceUID: instance.SeriesInstanceUID,
SOPInstanceUID: instance.SOPInstanceUID,
});
});
DicomMetadataStore.addInstances(naturalizedInstances, madeInClient);
}
function setSuccessFlag() {
const study = DicomMetadataStore.getStudy(
StudyInstanceUID,
madeInClient
);
study.isLoaded = true;
}
// Google Cloud Healthcare doesn't return StudyInstanceUID, so we need to add
// it manually here
seriesSummaryMetadata.forEach(aSeries => {
aSeries.StudyInstanceUID = StudyInstanceUID;
});
DicomMetadataStore.addSeriesMetadata(seriesSummaryMetadata, madeInClient);
const numberOfSeries = seriesPromises.length;
seriesPromises.forEach(async (seriesPromise, index) => {
const instances = await seriesPromise;
storeInstances(instances);
if (index === numberOfSeries - 1) setSuccessFlag();
});
},
deleteStudyMetadataPromise,
getImageIdsForDisplaySet(displaySet) {
const images = displaySet.images;
@@ -25,7 +25,7 @@ const seriesSortCriteria = {
};
const instancesSortCriteria = {
default: (a, b) => a.InstanceNumber - b.InstanceNumber,
default: (a, b) => parseInt(a.InstanceNumber) - parseInt(b.InstanceNumber),
};
const sortingCriteria = {
@@ -1,4 +1,4 @@
//import RetrieveMetadataLoaderSync from './retrieveMetadataLoaderSync';
import RetrieveMetadataLoaderSync from './retrieveMetadataLoaderSync';
import RetrieveMetadataLoaderAsync from './retrieveMetadataLoaderAsync';
/**
@@ -19,12 +19,10 @@ async function RetrieveMetadata(
sortCriteria,
sortFunction
) {
// const RetrieveMetadataLoader =
// enableStudyLazyLoad !== false
// ? RetrieveMetadataLoaderAsync
// : RetrieveMetadataLoaderSync;
const RetrieveMetadataLoader = RetrieveMetadataLoaderAsync;
const RetrieveMetadataLoader =
enableStudyLazyLoad !== false
? RetrieveMetadataLoaderAsync
: RetrieveMetadataLoaderSync;
const retrieveMetadataLoader = new RetrieveMetadataLoader(
dicomWebClient,
@@ -33,12 +31,9 @@ async function RetrieveMetadata(
sortCriteria,
sortFunction
);
const { preLoadData, promises } = await retrieveMetadataLoader.execLoad();
const data = await retrieveMetadataLoader.execLoad();
return {
seriesSummaryMetadata: preLoadData,
seriesPromises: promises,
};
return data;
}
export default RetrieveMetadata;
@@ -2,7 +2,6 @@ import dcmjs from 'dcmjs';
import { sortStudySeries, sortingCriteria } from '../utils/sortStudy';
import RetrieveMetadataLoader from './retrieveMetadataLoader';
/**
* Creates an immutable series loader object which loads each series sequentially using the iterator interface
* @param {DICOMWebClient} dicomWebClient The DICOMWebClient instance to be used for series load
@@ -60,7 +60,6 @@ export default class RetrieveMetadataLoaderSync extends RetrieveMetadataLoader {
}
async posLoad(loadData) {
const { server } = this;
return createStudyFromSOPInstanceList(server, loadData);
return loadData;
}
}
@@ -1,168 +0,0 @@
import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import { MeasurementTable } from '@ohif/ui';
import { DicomMetadataStore } from '@ohif/core';
import debounce from './debounce.js';
export default function PanelMeasurementTable({
servicesManager,
// commandsManager,
}) {
const { MeasurementService } = servicesManager.services;
const [displayMeasurements, setDisplayMeasurements] = useState([]);
useEffect(() => {
const debouncedSetDisplayMeasurements = debounce(
setDisplayMeasurements,
100
);
// ~~ Initial
setDisplayMeasurements(_getMappedMeasurements(MeasurementService));
// ~~ Subscription
const added = MeasurementService.EVENTS.MEASUREMENT_ADDED;
const addedRaw = MeasurementService.EVENTS.RAW_MEASUREMENT_ADDED;
const updated = MeasurementService.EVENTS.MEASUREMENT_UPDATED;
const removed = MeasurementService.EVENTS.MEASUREMENT_REMOVED;
const cleared = MeasurementService.EVENTS.MEASUREMENTS_CLEARED;
const subscriptions = [];
[added, addedRaw, updated, removed, cleared].forEach(evt => {
subscriptions.push(
MeasurementService.subscribe(evt, () => {
debouncedSetDisplayMeasurements(
_getMappedMeasurements(MeasurementService)
);
}).unsubscribe
);
});
return () => {
subscriptions.forEach(unsub => {
unsub();
});
};
}, [MeasurementService]);
return (
<div className="overflow-x-hidden overflow-y-auto invisible-scrollbar">
<MeasurementTable
title="Measurements"
amount={displayMeasurements.length}
data={displayMeasurements}
onClick={({ id }) => alert(`Click: ${id}`)}
onEdit={({ id }) => alert(`Edit: ${id}`)}
/>
</div>
);
}
PanelMeasurementTable.propTypes = {
servicesManager: PropTypes.shape({
services: PropTypes.shape({
MeasurementService: PropTypes.shape({
getMeasurements: PropTypes.func.isRequired,
subscribe: PropTypes.func.isRequired,
EVENTS: PropTypes.object.isRequired,
VALUE_TYPES: PropTypes.object.isRequired,
}).isRequired,
}).isRequired,
}).isRequired,
};
function _getMappedMeasurements(MeasurementService) {
const measurements = MeasurementService.getMeasurements();
const mappedMeasurements = measurements.map((m, index) =>
_mapMeasurementToDisplay(m, index, MeasurementService.VALUE_TYPES)
);
return mappedMeasurements;
}
function _mapMeasurementToDisplay(measurement, index, types) {
const {
id,
label,
description,
// Reference IDs
referenceStudyUID,
referenceSeriesUID,
SOPInstanceUID,
} = measurement;
const instance = DicomMetadataStore.getInstance(
referenceStudyUID,
referenceSeriesUID,
SOPInstanceUID
);
const { PixelSpacing, SeriesNumber, InstanceNumber } = instance;
return {
id: index + 1,
label: '(empty)', // 'Label short description',
displayText:
_getDisplayText(
measurement,
PixelSpacing,
SeriesNumber,
InstanceNumber,
types
) || [],
// TODO: handle one layer down
isActive: false, // activeMeasurementItem === i + 1,
};
}
function _getDisplayText(
measurement,
pixelSpacing,
seriesNumber,
instanceNumber,
types
) {
const { type, points } = measurement;
const hasPixelSpacing =
pixelSpacing !== undefined &&
Array.isArray(pixelSpacing) &&
pixelSpacing.length === 2;
const [rowPixelSpacing, colPixelSpacing] = hasPixelSpacing
? pixelSpacing
: [1, 1];
const unit = hasPixelSpacing ? 'mm' : 'px';
switch (type) {
case types.POLYLINE: {
const { length } = measurement;
const roundedLength = _round(length, 1);
return [
`${roundedLength} ${unit} (S:${seriesNumber}, I:${instanceNumber})`,
];
}
case types.BIDIRECTIONAL: {
const { shortestDiameter, longestDiameter } = measurement;
const roundedShortestDiameter = _round(shortestDiameter, 1);
const roundedLongestDiameter = _round(longestDiameter, 1);
return [
`l: ${roundedLongestDiameter} ${unit} (S:${seriesNumber}, I:${instanceNumber})`,
`s: ${roundedShortestDiameter} ${unit}`,
];
}
case types.ELLIPSE: {
const { area } = measurement;
const roundedArea = _round(area, 1);
return [
`${roundedArea} ${unit}2 (S:${seriesNumber}, I:${instanceNumber})`,
];
}
case types.POINT: {
const { text } = measurement;
return [`${text} (S:${seriesNumber}, I:${instanceNumber})`];
}
}
}
function _round(value, decimals) {
return Number(Math.round(value + 'e' + decimals) + 'e-' + decimals);
}
@@ -0,0 +1,40 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useTranslation } from 'react-i18next';
import { Button, ButtonGroup } from '@ohif/ui';
function ActionButtons({ onExportClick, onCreateReportClick }) {
const { t } = useTranslation('MeasurementTable');
return (
<React.Fragment>
<ButtonGroup color="black" size="inherit">
<Button className="px-2 py-2 text-base" onClick={onExportClick}>
{t('Export CSV')}
</Button>
</ButtonGroup>
{/* <Button
className="ml-2 text-base"
variant="outlined"
size="small"
color="black"
onClick={onCreateReportClick}
>
{t('Create Report')}
</Button> */}
</React.Fragment>
);
}
ActionButtons.propTypes = {
onExportClick: PropTypes.func,
onCreateReportClick: PropTypes.func,
};
ActionButtons.defaultProps = {
onExportClick: () => alert('Export'),
onCreateReportClick: () => alert('Create Report'),
};
export default ActionButtons;
@@ -0,0 +1,212 @@
import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';
import { MeasurementTable, Dialog, Input, useViewportGrid } from '@ohif/ui';
import ActionButtons from './ActionButtons';
import debounce from 'lodash.debounce';
import { utils } from '@ohif/core';
const { downloadCSVReport } = utils;
// tools with measurements to display inside the panel
const MEASUREMENT_TOOLS = [
'EllipticalROI',
'RectangleROI',
'Length',
'Bidirectional',
];
export default function PanelMeasurementTable({
servicesManager,
commandsManager,
}) {
const [viewportGrid, viewportGridService] = useViewportGrid();
const { MeasurementService, UIDialogService } = servicesManager.services;
const [displayMeasurements, setDisplayMeasurements] = useState([]);
useEffect(() => {
const debouncedSetDisplayMeasurements = debounce(
setDisplayMeasurements,
100
);
// ~~ Initial
setDisplayMeasurements(_getMappedMeasurements(MeasurementService));
// ~~ Subscription
const added = MeasurementService.EVENTS.MEASUREMENT_ADDED;
const addedRaw = MeasurementService.EVENTS.RAW_MEASUREMENT_ADDED;
const updated = MeasurementService.EVENTS.MEASUREMENT_UPDATED;
const removed = MeasurementService.EVENTS.MEASUREMENT_REMOVED;
const cleared = MeasurementService.EVENTS.MEASUREMENTS_CLEARED;
const subscriptions = [];
[added, addedRaw, updated, removed, cleared].forEach(evt => {
subscriptions.push(
MeasurementService.subscribe(evt, () => {
debouncedSetDisplayMeasurements(
_getMappedMeasurements(MeasurementService)
);
}).unsubscribe
);
});
return () => {
subscriptions.forEach(unsub => {
unsub();
});
debouncedSetDisplayMeasurements.cancel();
};
}, []);
async function exportReport() {
const measurements = MeasurementService.getMeasurements();
downloadCSVReport(measurements, MeasurementService);
}
const jumpToImage = ({ uid, isActive }) => {
MeasurementService.jumpToMeasurement(viewportGrid.activeViewportIndex, uid);
onMeasurementItemClickHandler({ uid, isActive });
};
const onMeasurementItemEditHandler = ({ uid, isActive }) => {
const measurement = MeasurementService.getMeasurement(uid);
//Todo: why we are jumping to image?
// jumpToImage({ id, isActive });
const onSubmitHandler = ({ action, value }) => {
switch (action.id) {
case 'save': {
MeasurementService.update(
uid,
{
...measurement,
...value,
},
true
);
}
}
UIDialogService.dismiss({ id: 'enter-annotation' });
};
UIDialogService.create({
id: 'enter-annotation',
centralize: true,
isDraggable: false,
showOverlay: true,
content: Dialog,
contentProps: {
title: 'Enter your annotation',
noCloseButton: true,
value: { label: measurement.label || '' },
body: ({ value, setValue }) => {
const onChangeHandler = event => {
event.persist();
setValue(value => ({ ...value, label: event.target.value }));
};
const onKeyPressHandler = event => {
if (event.key === 'Enter') {
onSubmitHandler({ value, action: { id: 'save' } });
}
};
return (
<div className="p-4 bg-primary-dark">
<Input
autoFocus
className="mt-2 bg-black border-primary-main"
type="text"
containerClassName="mr-2"
value={value.label}
onChange={onChangeHandler}
onKeyPress={onKeyPressHandler}
/>
</div>
);
},
actions: [
// temp: swap button types until colors are updated
{ id: 'cancel', text: 'Cancel', type: 'primary' },
{ id: 'save', text: 'Save', type: 'secondary' },
],
onSubmit: onSubmitHandler,
},
});
};
const onMeasurementItemClickHandler = ({ uid, isActive }) => {
if (!isActive) {
const measurements = [...displayMeasurements];
const measurement = measurements.find(m => m.uid === uid);
measurements.forEach(m => (m.isActive = m.uid !== uid ? false : true));
measurement.isActive = true;
setDisplayMeasurements(measurements);
}
};
return (
<>
<div
className="overflow-x-hidden overflow-y-auto invisible-scrollbar"
data-cy={'measurements-panel'}
>
<MeasurementTable
title="Measurements"
amount={displayMeasurements.length}
data={displayMeasurements}
onClick={jumpToImage}
onEdit={onMeasurementItemEditHandler}
/>
</div>
<div className="flex justify-center p-4">
<ActionButtons
onExportClick={exportReport}
onCreateReportClick={() => {}}
/>
</div>
</>
);
}
PanelMeasurementTable.propTypes = {
servicesManager: PropTypes.shape({
services: PropTypes.shape({
MeasurementService: PropTypes.shape({
getMeasurements: PropTypes.func.isRequired,
subscribe: PropTypes.func.isRequired,
EVENTS: PropTypes.object.isRequired,
VALUE_TYPES: PropTypes.object.isRequired,
}).isRequired,
}).isRequired,
}).isRequired,
};
function _getMappedMeasurements(MeasurementService) {
const measurements = MeasurementService.getMeasurements();
// filter out measurements whose toolName is not in MEASUREMENT_TOOLS
const measurementTools = measurements.filter(measurement =>
MEASUREMENT_TOOLS.includes(measurement.toolName)
);
const mappedMeasurements = measurementTools.map((m, index) =>
_mapMeasurementToDisplay(m, index, MeasurementService.VALUE_TYPES)
);
return mappedMeasurements;
}
function _mapMeasurementToDisplay(measurement, index, types) {
const { displayText, uid, label, type } = measurement;
return {
uid,
label: label || '(empty)',
measurementType: type,
displayText: displayText || [],
// TODO: handle one layer down
isActive: false, // activeMeasurementItem === i + 1,
};
}
@@ -1,6 +1,6 @@
import React, { useState, useEffect, useRef } from 'react';
import PropTypes from 'prop-types';
import { StudyBrowser, useImageViewer } from '@ohif/ui';
import { StudyBrowser, useImageViewer, useViewportGrid } from '@ohif/ui';
import { utils } from '@ohif/core';
const { formatDate } = utils;
@@ -20,6 +20,10 @@ function PanelStudyBrowser({
// doesn't have to have such an intense shape. This works well enough for now.
// Tabs --> Studies --> DisplaySets --> Thumbnails
const { StudyInstanceUIDs } = useImageViewer();
const [
{ activeViewportIndex, viewports },
viewportGridService,
] = useViewportGrid();
const [activeTabName, setActiveTabName] = useState('primary');
const [expandedStudyInstanceUIDs, setExpandedStudyInstanceUIDs] = useState([
...StudyInstanceUIDs,
@@ -29,6 +33,13 @@ function PanelStudyBrowser({
const [thumbnailImageSrcMap, setThumbnailImageSrcMap] = useState({});
const isMounted = useRef(true);
const onDoubleClickThumbnailHandler = displaySetInstanceUID => {
viewportGridService.setDisplaySetsForViewport({
viewportIndex: activeViewportIndex,
displaySetInstanceUIDs: [displaySetInstanceUID],
});
};
// ~~ studyDisplayList
useEffect(() => {
// Fetch all studies for the patient in each primary study
@@ -38,6 +49,7 @@ function PanelStudyBrowser({
// TODO: This should be "naturalized DICOM JSON" studies
const mappedStudies = _mapDataSourceStudies(qidoStudiesForPatient);
const actuallyMappedStudies = mappedStudies.map(qidoStudy => {
return {
studyInstanceUid: qidoStudy.StudyInstanceUID,
@@ -57,7 +69,7 @@ function PanelStudyBrowser({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [StudyInstanceUIDs, getStudiesForPatientByStudyInstanceUID]);
// ~~ Initial Thumbnails
// // ~~ Initial Thumbnails
useEffect(() => {
const currentDisplaySets = DisplaySetService.activeDisplaySets;
currentDisplaySets.forEach(async dSet => {
@@ -68,7 +80,7 @@ function PanelStudyBrowser({
const imageIds = dataSource.getImageIdsForDisplaySet(displaySet);
const imageId = imageIds[Math.floor(imageIds.length / 2)];
// TODO: Is it okay that imageIds are not returned here for SR displaysets?
// TODO: Is it okay that imageIds are not returned here for SR displaySets?
if (imageId) {
// When the image arrives, render it and store the result in the thumbnailImgSrcMap
newImageSrcEntry[dSet.displaySetInstanceUID] = await getImageSrc(
@@ -184,10 +196,15 @@ function PanelStudyBrowser({
}
}
const activeDisplaySetInstanceUIDs =
viewports[activeViewportIndex]?.displaySetInstanceUIDs;
return (
<StudyBrowser
tabs={tabs}
activeTabName={activeTabName}
onDoubleClickThumbnail={onDoubleClickThumbnailHandler}
activeDisplaySetInstanceUIDs={activeDisplaySetInstanceUIDs}
expandedStudyInstanceUIDs={expandedStudyInstanceUIDs}
onClickStudy={_handleStudyClick}
onClickTab={clickedTabName => {
@@ -26,7 +26,7 @@ function WrappedPanelStudyBrowser({
dataSource
);
const _getImageSrcFromImageId = _createGetImageSrcFromImageIdFn(
commandsManager.getCommand.bind(commandsManager)
extensionManager
);
const _requestDisplaySetCreationForStudy = requestDisplaySetCreationForStudy.bind(
null,
@@ -55,14 +55,13 @@ function WrappedPanelStudyBrowser({
* @returns {func} getImageSrcFromImageId - A utility function powered by
* cornerstone
*/
function _createGetImageSrcFromImageIdFn(getCommand) {
try {
const command = getCommand('getCornerstoneLibraries', 'VIEWER');
if (!command) {
return;
}
const { cornerstone } = command.commandFn();
function _createGetImageSrcFromImageIdFn(extensionManager) {
const utilities = extensionManager.getModuleEntry(
'@ohif/extension-cornerstone.utilityModule.common'
);
try {
const { cornerstone } = utilities.exports.getCornerstoneLibraries();
return getImageSrcFromImageId.bind(null, cornerstone);
} catch (ex) {
throw new Error('Required command not found');
File renamed without changes.
@@ -4,16 +4,13 @@
*/
function getImageSrcFromImageId(cornerstone, imageId) {
return new Promise((resolve, reject) => {
cornerstone
.loadAndCacheImage(imageId)
.then(image => {
const canvas = document.createElement('canvas');
cornerstone.renderToCanvas(canvas, image);
const canvas = document.createElement('canvas');
cornerstone.utilities
.loadImageToCanvas(canvas, imageId)
.then(imageId => {
resolve(canvas.toDataURL());
})
.catch(reject);
});
}
export default getImageSrcFromImageId;
@@ -21,6 +21,9 @@ async function getStudiesForPatientByStudyInstanceUID(
patientId: getStudyResult[0].mrn,
});
}
console.log('No mrn found for', getStudyResult);
// The original study we KNOW belongs to the same set, so just return it
return getStudyResult;
}
export default getStudiesForPatientByStudyInstanceUID;
+2 -1
View File
@@ -1,4 +1,5 @@
import PanelStudyBrowser from './PanelStudyBrowser';
import WrappedPanelStudyBrowser from './WrappedPanelStudyBrowser';
import PanelMeasurementTable from './PanelMeasurementTable';
export { PanelStudyBrowser, WrappedPanelStudyBrowser };
export { PanelStudyBrowser, WrappedPanelStudyBrowser, PanelMeasurementTable };
@@ -201,11 +201,11 @@ function ViewerLayout({
</ErrorBoundary>
</Header>
<div
className="flex flex-row items-stretch w-full overflow-hidden flex-nowrap"
className="bg-black flex flex-row items-stretch w-full overflow-hidden flex-nowrap"
style={{ height: 'calc(100vh - 52px' }}
>
{/* LEFT SIDEPANELS */}
{leftPanelComponents.length && (
{leftPanelComponents.length ? (
<ErrorBoundary context="Left Panel">
<SidePanel
side="left"
@@ -213,10 +213,10 @@ function ViewerLayout({
childComponents={leftPanelComponents}
/>
</ErrorBoundary>
)}
) : null}
{/* TOOLBAR + GRID */}
<div className="flex flex-col flex-1 h-full">
<div className="flex items-center justify-center flex-1 h-full -mt-1 overflow-hidden bg-black">
<div className="flex items-center justify-center flex-1 h-full overflow-hidden bg-black">
<ErrorBoundary context="Grid">
<ViewportGridComp
servicesManager={servicesManager}
@@ -226,7 +226,7 @@ function ViewerLayout({
</ErrorBoundary>
</div>
</div>
{rightPanelComponents.length && (
{rightPanelComponents.length ? (
<ErrorBoundary context="Right Panel">
<SidePanel
side="right"
@@ -234,7 +234,7 @@ function ViewerLayout({
childComponents={rightPanelComponents}
/>
</ErrorBoundary>
)}
) : null}
</div>
</div>
);
+10 -14
View File
@@ -1,25 +1,21 @@
const commandsModule = ({ servicesManager, commandsManager }) => {
const {
MeasurementService,
ViewportGridService,
ToolBarService,
HangingProtocolService,
CineService,
UINotificationService,
} = servicesManager.services;
const actions = {
displayNotification: ({ text, title, type }) => {
UINotificationService.show({
title: title,
message: text,
type: type,
});
},
clearMeasurements: () => {
MeasurementService.clear();
},
toggleCine: () => {
const { viewports } = ViewportGridService.getState();
const { isCineEnabled } = CineService.getState();
CineService.setIsCineEnabled(!isCineEnabled);
ToolBarService.setButton('Cine', { props: { isActive: !isCineEnabled } });
viewports.forEach((_, index) =>
CineService.setCine({ id: index, isPlaying: false })
);
},
nextStage: () => {
// next stage in hanging protocols
HangingProtocolService.nextProtocolStage();
@@ -35,8 +31,8 @@ const commandsModule = ({ servicesManager, commandsManager }) => {
storeContexts: [],
options: {},
},
toggleCine: {
commandFn: actions.toggleCine,
displayNotification: {
commandFn: actions.displayNotification,
storeContexts: [],
options: {},
},
@@ -1,186 +1,61 @@
const hangingProtocolName = 'petCT';
const petCTProtocol = {
id: 'PET/CT',
const defaultProtocol = {
id: 'default',
locked: true,
hasUpdatedPriorsInformation: false,
name: 'PET/CT',
createdDate: '2021-02-23T18:32:42.849Z',
modifiedDate: '2021-02-23T18:32:42.849Z',
name: 'Default',
createdDate: '2021-02-23T19:22:08.894Z',
modifiedDate: '2021-02-23T19:22:08.894Z',
availableTo: {},
editableBy: {},
protocolMatchingRules: [
{
id: 'wauZK2QNEfDPwcAQo',
weight: 1,
attribute: 'StudyInstanceUID',
constraint: {
equals: {
value: '1.3.6.1.4.1.25403.345050719074.3824.20170125112931.11',
},
},
required: true,
},
],
protocolMatchingRules: [],
toolGroupIds: ['default'],
stages: [
{
id: 'hYbmMy3b7pz7GLiaT',
name: 'oneByTwo',
name: 'default',
viewportStructure: {
type: 'grid',
properties: {
rows: 2,
columns: 2,
rows: 1,
columns: 1,
},
},
viewports: [
displaySets: [
{
viewportSettings: [
{
options: {
voi: {
windowWidth: 500,
windowCenter: 500,
},
},
commandName: '',
// Type can be viewport or prop
// viewport: It is most suited for settings that
// should be applied before the first render
// prop: It is the type of command that can be applied
// after the image render, such as tool activations.
type: 'viewport',
},
],
id: 'displaySet',
imageMatchingRules: [],
seriesMatchingRules: [
{
id: 'vSjk7NCYjtdS3XZAw',
weight: 1,
attribute: 'SeriesNumber',
constraint: {
equals: {
value: "4",
},
},
required: false,
},
],
seriesMatchingRules: [],
studyMatchingRules: [],
},
],
viewports: [
{
viewportSettings: [
viewportOptions: {
toolGroupId: 'default',
// initialImageOptions: {
// index: 180,
// preset: 'middle', // 'first', 'last', 'middle'
// },
},
displaySets: [
{
options: {
invert: true,
},
commandName: '',
type: 'viewport',
options: [],
id: 'displaySet',
},
],
imageMatchingRules: [
],
seriesMatchingRules: [
{
id: 'GPEYqFLv2dwzCM322',
weight: 1,
attribute: 'SeriesNumber',
constraint: {
equals: {
value: "1",
},
},
required: false,
},
],
studyMatchingRules: [
],
},
{
viewportSettings: [
{
options: {
invert: true,
},
commandName: '',
type: 'viewport',
},
],
imageMatchingRules: [
],
seriesMatchingRules: [
{
id: 'GPEYqFLv2dwzCM322',
weight: 1,
attribute: 'SeriesNumber',
constraint: {
equals: {
value: "6",
},
},
required: false,
},
],
studyMatchingRules: [
],
},
{
viewportSettings: [
{
options: {
invert: true,
},
commandName: '',
type: 'viewport',
},
],
imageMatchingRules: [
],
seriesMatchingRules: [
{
id: 'GPEYqFLv2dwzCM322',
weight: 1,
attribute: 'SeriesDescription',
constraint: {
contains: {
value: "Corrected",
},
},
required: false,
},
{
id: 'GPEYqFLv2dwzCM322',
weight: 2,
attribute: 'SeriesDescription',
constraint: {
doesNotContain: {
value: "Uncorrected",
},
},
required: false,
},
],
studyMatchingRules: [
],
},
],
createdDate: '2021-02-23T18:32:42.850Z',
},
],
numberOfPriorsReferenced: 0,
numberOfPriorsReferenced: -1,
};
function getHangingProtocolModule() {
return [
{
name: hangingProtocolName,
protocols: [petCTProtocol],
name: 'default',
protocols: [defaultProtocol],
},
];
}
@@ -5,11 +5,11 @@ import ViewerLayout from './ViewerLayout';
- Init layout based on the displaySets and the objects.
*/
export default function ({
export default function({
servicesManager,
extensionManager,
commandsManager,
hotkeysManager
hotkeysManager,
}) {
function ViewerLayoutWithServices(props) {
return ViewerLayout({
@@ -0,0 +1,128 @@
import OHIF from '@ohif/core';
import {
InstanceMetadata,
PhilipsPETPrivateGroup,
} from '@cornerstonejs/calculate-suv/src/types';
const metadataProvider = OHIF.classes.MetadataProvider;
export default function getPTImageIdInstanceMetadata(
imageId: string
): InstanceMetadata {
const dicomMetaData = metadataProvider.get('instance', imageId);
if (!dicomMetaData) {
throw new Error('dicom metadata are required');
}
if (
dicomMetaData.SeriesDate === undefined ||
dicomMetaData.SeriesTime === undefined ||
dicomMetaData.PatientWeight === undefined ||
dicomMetaData.CorrectedImage === undefined ||
dicomMetaData.Units === undefined ||
!dicomMetaData.RadiopharmaceuticalInformationSequence ||
!dicomMetaData.RadiopharmaceuticalInformationSequence.length ||
dicomMetaData.RadiopharmaceuticalInformationSequence[0]
.RadionuclideHalfLife === undefined ||
dicomMetaData.RadiopharmaceuticalInformationSequence[0]
.RadionuclideTotalDose === undefined ||
dicomMetaData.DecayCorrection === undefined ||
dicomMetaData.AcquisitionDate === undefined ||
dicomMetaData.AcquisitionTime === undefined ||
(dicomMetaData.RadiopharmaceuticalInformationSequence[0]
.RadiopharmaceuticalStartDateTime === undefined &&
dicomMetaData.RadiopharmaceuticalInformationSequence[0]
.RadiopharmaceuticalStartTime === undefined)
) {
throw new Error('required metadata are missing');
}
const instanceMetadata: InstanceMetadata = {
CorrectedImage: dicomMetaData.CorrectedImage,
Units: dicomMetaData.Units,
RadionuclideHalfLife:
dicomMetaData.RadiopharmaceuticalInformationSequence[0]
.RadionuclideHalfLife,
RadionuclideTotalDose:
dicomMetaData.RadiopharmaceuticalInformationSequence[0]
.RadionuclideTotalDose,
RadiopharmaceuticalStartDateTime:
dicomMetaData.RadiopharmaceuticalInformationSequence[0]
.RadiopharmaceuticalStartDateTime,
RadiopharmaceuticalStartTime:
dicomMetaData.RadiopharmaceuticalInformationSequence[0]
.RadiopharmaceuticalStartTime,
DecayCorrection: dicomMetaData.DecayCorrection,
PatientWeight: dicomMetaData.PatientWeight,
SeriesDate: dicomMetaData.SeriesDate,
SeriesTime: dicomMetaData.SeriesTime,
AcquisitionDate: dicomMetaData.AcquisitionDate,
AcquisitionTime: dicomMetaData.AcquisitionTime,
};
if (
dicomMetaData['70531000'] ||
dicomMetaData['70531000'] !== undefined ||
dicomMetaData['70531009'] ||
dicomMetaData['70531009'] !== undefined
) {
const philipsPETPrivateGroup: PhilipsPETPrivateGroup = {
SUVScaleFactor: dicomMetaData['70531000'],
ActivityConcentrationScaleFactor: dicomMetaData['70531009'],
};
instanceMetadata.PhilipsPETPrivateGroup = philipsPETPrivateGroup;
}
if (dicomMetaData['0009100d'] && dicomMetaData['0009100d'] !== undefined) {
instanceMetadata.GEPrivatePostInjectionDateTime = dicomMetaData['0009100d'];
}
if (
dicomMetaData.FrameReferenceTime &&
dicomMetaData.FrameReferenceTime !== undefined
) {
instanceMetadata.FrameReferenceTime = dicomMetaData.FrameReferenceTime;
}
if (
dicomMetaData.ActualFrameDuration &&
dicomMetaData.ActualFrameDuration !== undefined
) {
instanceMetadata.ActualFrameDuration = dicomMetaData.ActualFrameDuration;
}
if (dicomMetaData.PatientSex && dicomMetaData.PatientSex !== undefined) {
instanceMetadata.PatientSex = dicomMetaData.PatientSex;
}
if (dicomMetaData.PatientSize && dicomMetaData.PatientSize !== undefined) {
instanceMetadata.PatientSize = dicomMetaData.PatientSize;
}
return instanceMetadata;
}
function convertInterfaceTimeToString(time): string {
const hours = `${time.hours || '00'}`.padStart(2, '0');
const minutes = `${time.minutes || '00'}`.padStart(2, '0');
const seconds = `${time.seconds || '00'}`.padStart(2, '0');
const fractionalSeconds = `${time.fractionalSeconds || '000000'}`.padEnd(
6,
'0'
);
const timeString = `${hours}${minutes}${seconds}.${fractionalSeconds}`;
return timeString;
}
function convertInterfaceDateToString(date): string {
const month = `${date.month}`.padStart(2, '0');
const day = `${date.day}`.padStart(2, '0');
const dateString = `${date.year}${month}${day}`;
return dateString;
}
export { getPTImageIdInstanceMetadata };
@@ -1,7 +1,5 @@
import React from 'react';
import { WrappedPanelStudyBrowser } from './Panels';
import PanelMeasurementTable from './PanelMeasurementTable.js';
import { WrappedPanelStudyBrowser, PanelMeasurementTable } from './Panels';
// TODO:
// - No loading UI exists yet
@@ -13,6 +13,8 @@ const makeDisplaySet = instances => {
const instance = instances[0];
const imageSet = new ImageSet(instances);
const displayReconstructableInfo = isDisplaySetReconstructable(instances);
// set appropriate attributes to image set...
imageSet.setAttributes({
displaySetInstanceUID: imageSet.uid, // create a local alias for the imageSet UID
@@ -27,6 +29,7 @@ const makeDisplaySet = instances => {
isMultiFrame: isMultiFrame(instance),
numImageFrames: instances.length,
SOPClassHandlerId: `${id}.sopClassHandlerModule.${sopClassHandlerName}`,
isReconstructable: displayReconstructableInfo.value,
});
// Sort the images in this series if needed
@@ -1,7 +1,7 @@
import { ToolbarButton } from '@ohif/ui';
import ToolbarDivider from './Toolbar/ToolbarDivider.jsx';
import ToolbarLayoutSelector from './Toolbar/ToolbarLayoutSelector.jsx';
import ToolbarSplitButton from './Toolbar/ToolbarSplitButton.jsx';
import ToolbarDivider from './Toolbar/ToolbarDivider.tsx';
import ToolbarLayoutSelector from './Toolbar/ToolbarLayoutSelector.tsx';
import ToolbarSplitButton from './Toolbar/ToolbarSplitButton.tsx';
export default function getToolbarModule({ commandsManager, servicesManager }) {
const toolbarService = servicesManager.services.ToolBarService;
+20 -3
View File
@@ -1,17 +1,22 @@
import getDataSourcesModule from './getDataSourcesModule.js';
import getLayoutTemplateModule from './getLayoutTemplateModule.js';
import getPanelModule from './getPanelModule.js';
import getPanelModule from './getPanelModule';
import getSopClassHandlerModule from './getSopClassHandlerModule.js';
import getHangingProtocolModule from './getHangingProtocolModule.js';
import getToolbarModule from './getToolbarModule.js';
import getToolbarModule from './getToolbarModule';
import commandsModule from './commandsModule';
import getStudiesForPatientByStudyInstanceUID from './Panels/getStudiesForPatientByStudyInstanceUID';
import { id } from './id.js';
import init from './init';
export default {
const defaultExtension = {
/**
* Only required property. Should be a unique value across all extensions.
*/
id,
preRegistration: ({ servicesManager, configuration = {} }) => {
init({ servicesManager, configuration });
},
getDataSourcesModule,
getHangingProtocolModule,
getLayoutTemplateModule,
@@ -21,4 +26,16 @@ export default {
getCommandsModule({ servicesManager, commandsManager }) {
return commandsModule({ servicesManager, commandsManager });
},
getUtilityModule({ servicesManager }) {
return [
{
name: 'common',
exports: {
getStudiesForPatientByStudyInstanceUID,
},
},
];
},
};
export default defaultExtension;
+70
View File
@@ -0,0 +1,70 @@
import { DicomMetadataStore, classes } from '@ohif/core';
import { calculateSUVScalingFactors } from '@cornerstonejs/calculate-suv';
import getPTImageIdInstanceMetadata from './getPTImageIdInstanceMetadata';
const metadataProvider = classes.MetadataProvider;
/**
*
* @param {Object} servicesManager
* @param {Object} configuration
*/
export default function init({ servicesManager, configuration }) {
// Add
DicomMetadataStore.subscribe(
DicomMetadataStore.EVENTS.INSTANCES_ADDED,
handlePETImageMetadata
);
// If the metadata for PET has changed by the user (e.g. manually changing the PatientWeight)
// we need to recalculate the SUV Scaling Factors
DicomMetadataStore.subscribe(
DicomMetadataStore.EVENTS.SERIES_UPDATED,
handlePETImageMetadata
);
}
const handlePETImageMetadata = ({ SeriesInstanceUID, StudyInstanceUID }) => {
const { instances } = DicomMetadataStore.getSeries(
StudyInstanceUID,
SeriesInstanceUID
);
const modality = instances[0].Modality;
if (modality !== 'PT') {
return;
}
const imageIds = instances.map(instance => instance.imageId);
const InstanceMetadataArray = [];
imageIds.forEach(imageId => {
const instanceMetadata = getPTImageIdInstanceMetadata(imageId);
if (instanceMetadata) {
InstanceMetadataArray.push(instanceMetadata);
}
});
if (!InstanceMetadataArray.length) {
return;
}
// try except block to prevent errors when the metadata is not correct
let suvScalingFactors;
try {
suvScalingFactors = calculateSUVScalingFactors(InstanceMetadataArray);
} catch (error) {
console.log(error);
}
if (!suvScalingFactors) {
return;
}
InstanceMetadataArray.forEach((instanceMetadata, index) => {
metadataProvider.addCustomMetadata(
imageIds[index],
'scalingModule',
suvScalingFactors[index]
);
});
};