src/components/viewer.js (206 lines of code) (raw):

/** * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import React from 'react'; import PropTypes from 'prop-types'; import { Box, LinearProgress, Typography, TextField, Button, } from '@material-ui/core'; import * as cornerstone from 'cornerstone-core'; import * as api from '../api.js'; import {DICOM_TAGS} from '../dicomValues.js'; import DicomImageSequencer from '../dicomImageSequencer.js'; /** * React Component for viewing medical images */ export default class Viewer extends React.Component { /** * Creates a new instance of Viewer component * @param {Object} props React props for this component * @param {string} props.project Project * @param {string} props.location Location * @param {string} props.dataset Dataset * @param {string} props.dicomStore Dicom Store * @param {Object} props.study Study * @param {Object} props.series Series */ constructor(props) { super(props); this.state = { instances: [], numReadyImages: 0, readyImagesProgress: 0, numRenderedImages: 0, renderedImagesProgress: 0, renderTimer: 0, fetchTimer: 0, totalTimer: 0, timeToFirstImage: 0, maxSimultaneousRequests: 20, isDisplaying: false, }; this.dicomSequencer = new DicomImageSequencer(this.props.project, this.props.location, this.props.dataset, this.props.dicomStore, this.props.study, this.props.series, ); this.totalImagesCount = 0; this.readyImages = []; this.readyImagesCount = 0; this.newSequence = false; this.fetchStartTime = 0; this.renderStartTime = 0, this.canvasElement; this.renderedImagesCount = 0; this.metricsIntervalId = 0; } /** * Set up cornerstone listeners and retrieve instance list on mount */ componentDidMount() { cornerstone.enable(this.canvasElement); this.canvasElement.addEventListener('cornerstoneimagerendered', () => this.onImageRendered()); } /** * Cancel ongoing fetches to avoid state change after unmount */ componentWillUnmount() { if (this.getInstancesPromise) { this.getInstancesPromise.cancel(); } this.dicomSequencer.cancel(); clearInterval(this.metricsIntervalId); cornerstone.disable(this.canvasElement); } /** * Runs when a new image is ready from the DicomImageSequencer * @param {Object} image Cornerstone image */ onImageReady(image) { this.readyImages.push(image); this.readyImagesCount++; if (this.newSequence) { // If this is the first image in the sequence, render immediately this.displayNextImage(); this.newSequence = false; } } /** * Runs when an image has been rendered to the cornerstone canvas */ onImageRendered() { this.renderedImagesCount++; if (this.renderedImagesCount == 1) { this.renderStartTime = Date.now(); this.setState({ timeToFirstImage: Date.now() - this.fetchStartTime, }); } if (this.renderedImagesCount == this.totalImagesCount) { // When last image is rendered, stop the // metrics interval and run one final time clearInterval(this.metricsIntervalId); this.updateMetrics(); this.setState({ isDisplaying: false, }); } this.displayNextImage(); } /** * Checks the queue of ready images and displays the next one if available */ displayNextImage() { if (this.readyImages.length > 0) { const image = this.readyImages.shift(); cornerstone.displayImage(this.canvasElement, image); } else { this.newSequence = true; } } /** * Updates the UI metrics for the currently running sequence */ updateMetrics() { // Update progress bar this.setState({ readyImagesProgress: this.readyImagesCount / this.totalImagesCount * 100, numReadyImages: this.readyImagesCount, renderedImagesProgress: this.renderedImagesCount / this.totalImagesCount * 100, renderTimer: Date.now() - this.renderStartTime, totalTimer: Date.now() - this.fetchStartTime, numRenderedImages: this.renderedImagesCount, }); } /** * Begins fetching dicom images in sequence */ startDisplayingInstances() { // Purge cornerstone cache cornerstone.imageCache.purgeCache(); // Initialize dicomSequencer and begin fetching this.dicomSequencer.maxSimultaneousRequests = this.state.maxSimultaneousRequests; this.dicomSequencer.setInstances(this.state.instances); this.totalImagesCount = this.dicomSequencer.fetchInstances((image) => this.onImageReady(image)); } /** * Retrieves a list of dicom instances in this series and then starts * fetching images */ getInstances() { // Reset metrics this.newSequence = true; this.renderedImagesCount = 0; this.readyImages = []; this.readyImagesCount = 0; this.fetchStartTime = Date.now(); this.setState({ renderTimer: 0, totalTimer: 0, fetchTimer: 0, numReadyImages: 0, readyImagesProgress: 0, numRenderedImages: 0, renderedImagesProgress: 0, timeToFirstImage: 0, isDisplaying: true, }); // Set up an interval for updating metrics (10 times per second) this.metricsIntervalId = setInterval(() => this.updateMetrics(), 100); // Create a cancelable promise to allow this request to be cancelled // if the component is unmounted this.getInstancesPromise = api.makeCancelable( api.fetchMetadata( this.props.project, this.props.location, this.props.dataset, this.props.dicomStore, this.props.study[DICOM_TAGS.STUDY_UID].Value[0], this.props.series[DICOM_TAGS.SERIES_UID].Value[0], ), ); // Fetch instances and then start displaying this.getInstancesPromise.promise .then((instances) => { this.setState({ instances, }); this.startDisplayingInstances(); }) .catch((reason) => { if (!reason.isCanceled) { console.error(reason); } }); } /** * Renders the component * @return {ReactComponent} <Viewer/> */ render() { return ( <Box p={2} display="flex" flexWrap="wrap"> <Box mr={2}> <div id="cornerstone-div" ref={(input) => { this.canvasElement = input; }} style={{ width: 500, height: 500, background: 'black', }} > <canvas className="cornerstone-canvas"></canvas> </div> <LinearProgress variant="buffer" value={this.state.renderedImagesProgress} valueBuffer={this.state.readyImagesProgress} /><br/> <TextField label="Max Simultaneous Requests" style={{width: 250}} defaultValue={this.state.maxSimultaneousRequests} onChange={(e) => { this.setState({maxSimultaneousRequests: Number(e.target.value)}); }} /><br/><br/> <Button variant="contained" color="primary" disabled={this.state.isDisplaying} onClick={() => this.getInstances()}> Start </Button> </Box> <Box> <Typography variant="h5"> Frames Loaded: {this.state.numReadyImages} </Typography> <Typography variant="h5"> Frames Displayed: {this.state.numRenderedImages} </Typography> <Typography variant="h5"> Total Time: {(this.state.totalTimer / 1000).toFixed(2)}s </Typography> <Typography variant="h5"> Time to First Image: { (this.state.timeToFirstImage / 1000).toFixed(2) }s </Typography> <Typography variant="h5"> Average FPS: {(this.state.numRenderedImages / (this.state.renderTimer / 1000)).toFixed(2)} </Typography> <Typography variant="body1"> Use your browser&apos;s developer tools to see bandwidth usage. </Typography> </Box> </Box> ); } } Viewer.propTypes = { project: PropTypes.string.isRequired, location: PropTypes.string.isRequired, dataset: PropTypes.string.isRequired, dicomStore: PropTypes.string.isRequired, study: PropTypes.object.isRequired, series: PropTypes.object.isRequired, };