Skip to content

Commit

Permalink
feat: dicom tag browser (#248)
Browse files Browse the repository at this point in the history
* Init

* Use antd

* Remove unused components

* Fix style

* Fix cache

* Address types

* Revert changes

* Add tree like structure

* Address style issues

* Remove ellipsis

* Update styles

* Fix alignment

* Use lib to render table

* Remove unnused code

* Adjuments

* Address errors

* Scope the styles

* Address lint

* Address lint

* Address lint

* Address lint errors

* Address lint errors

* Remove unused file

* Address comments

* Move to antd

* Fix search

* Remove unused code

* Remove unused packages

* Address lint

* Use inline style

* Add items level

* Address style

* Lint

* Lint

* Stringify objects

* Refactor
  • Loading branch information
igoroctaviano authored Oct 31, 2024
1 parent b3b7060 commit 177ed8f
Show file tree
Hide file tree
Showing 16 changed files with 851 additions and 209 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

# testing
/coverage
.env

# production
/build
Expand Down
1 change: 1 addition & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -452,6 +452,7 @@ class App extends React.Component<AppProps, AppState> {
showWorklistButton={false}
onServerSelection={this.handleServerSelection}
showServerSelectionButton={false}
clients={this.state.clients}
/>
<Layout.Content style={layoutContentStyle}>
<FaSpinner />
Expand Down
2 changes: 1 addition & 1 deletion src/components/AnnotationGroupItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -360,7 +360,7 @@ class AnnotationGroupItem extends React.Component<AnnotationGroupItemProps, Anno
size='small'
disabled={!this.props.isVisible}
>
{}
<></>
</Select.Option>
)

Expand Down
293 changes: 98 additions & 195 deletions src/components/CaseViewer.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
import React from 'react'
import { Routes, Route, useLocation, useParams } from 'react-router-dom'
import { Layout, Menu } from 'antd'

import * as dmv from 'dicom-microscopy-viewer'

import { AnnotationSettings } from '../AppConfig'
import ClinicalTrial from './ClinicalTrial'
import DicomWebManager from '../DicomWebManager'
Expand All @@ -13,13 +10,9 @@ import SlideList from './SlideList'
import SlideViewer from './SlideViewer'

import { User } from '../auth'
import { Slide, createSlides } from '../data/slides'
import { StorageClasses } from '../data/uids'
import { Slide } from '../data/slides'
import { RouteComponentProps, withRouter } from '../utils/router'
import { CustomError, errorTypes } from '../utils/CustomError'
import NotificationMiddleware, {
NotificationMiddlewareContext
} from '../services/NotificationMiddleware'
import { useSlides } from '../hooks/useSlides'

function ParametrizedSlideViewer ({
clients,
Expand Down Expand Up @@ -97,216 +90,126 @@ interface ViewerProps extends RouteComponentProps {
}
}

interface ViewerState {
slides: Slide[]
isLoading: boolean
}

class Viewer extends React.Component<ViewerProps, ViewerState> {
state = {
slides: [],
isLoading: true
}

constructor (props: ViewerProps) {
super(props)
this.handleSeriesSelection = this.handleSeriesSelection.bind(this)
}
function Viewer (props: ViewerProps): JSX.Element | null {
const { clients, studyInstanceUID, location, navigate } = props
const { slides, isLoading } = useSlides({ clients, studyInstanceUID })

componentDidMount (): void {
this.fetchImageMetadata().then(
(metadata: dmv.metadata.VLWholeSlideMicroscopyImage[][]) => {
this.setState({
slides: createSlides(metadata),
isLoading: false
})
}
).catch((error) => {
console.error(error)
// eslint-disable-next-line @typescript-eslint/no-floating-promises
NotificationMiddleware.onError(
NotificationMiddlewareContext.SLIM,
new CustomError(
errorTypes.ENCODINGANDDECODING,
'Image metadata could not be retrieved or decoded.')
)
this.setState({ isLoading: false })
})
}

/**
* Fetch metadata for VL Whole Slide Microscopy Image instances of the study.
*
* @returns Metadata of image instances of the study grouped per series
*/
async fetchImageMetadata (): Promise<dmv.metadata.VLWholeSlideMicroscopyImage[][]> {
const images: dmv.metadata.VLWholeSlideMicroscopyImage[][] = []
const studyInstanceUID = this.props.studyInstanceUID
console.info(`search for series of study "${studyInstanceUID}"...`)
const client = this.props.clients[
StorageClasses.VL_WHOLE_SLIDE_MICROSCOPY_IMAGE
]
const matchedSeries = await client.searchForSeries({
queryParams: {
Modality: 'SM',
StudyInstanceUID: studyInstanceUID
}
})

await Promise.all(matchedSeries.map(async (s) => {
const { dataset } = dmv.metadata.formatMetadata(s)
const loadingSeries = dataset as dmv.metadata.Series
console.info(
`retrieve metadata of series "${loadingSeries.SeriesInstanceUID}"`
)
const retrievedMetadata = await client.retrieveSeriesMetadata({
studyInstanceUID: this.props.studyInstanceUID,
seriesInstanceUID: loadingSeries.SeriesInstanceUID
})

const seriesImages: dmv.metadata.VLWholeSlideMicroscopyImage[] = []
retrievedMetadata.forEach((item, index) => {
if (item['00080016'] != null) {
const values = item['00080016'].Value
if (values != null) {
const sopClassUID = values[0]
if (sopClassUID === StorageClasses.VL_WHOLE_SLIDE_MICROSCOPY_IMAGE) {
const image = new dmv.metadata.VLWholeSlideMicroscopyImage({
metadata: item
})
seriesImages.push(image)
}
}
}
})

if (seriesImages.length > 0) {
images.push(seriesImages)
}
}))

return images
}

handleSeriesSelection (
{ seriesInstanceUID }: { seriesInstanceUID: string }
): void {
const handleSeriesSelection = ({ seriesInstanceUID }: { seriesInstanceUID: string }): void => {
console.info(`switch to series "${seriesInstanceUID}"`)
let urlPath = (
`/studies/${this.props.studyInstanceUID}` +
`/studies/${studyInstanceUID}` +
`/series/${seriesInstanceUID}`
)

if (this.props.location.pathname.includes('/projects/')) {
urlPath = this.props.location.pathname
if (!this.props.location.pathname.includes('/series/')) {
if (location.pathname.includes('/projects/')) {
urlPath = location.pathname
if (!location.pathname.includes('/series/')) {
urlPath += `/series/${seriesInstanceUID}`
} else {
urlPath = urlPath.replace(/\/series\/[^/]+/, `/series/${seriesInstanceUID}`)
}
}

if (
this.props.location.pathname.includes('/series/') &&
this.props.location.search != null
location.pathname.includes('/series/') &&
location.search != null
) {
urlPath += this.props.location.search
urlPath += location.search
}

this.props.navigate(urlPath, { replace: true })
navigate(urlPath, { replace: true })
}

render (): React.ReactNode {
if (this.state.isLoading) {
return null
}

if (this.state.slides.length === 0) {
return null
}
const firstSlide = this.state.slides[0] as Slide
const volumeInstances = firstSlide.volumeImages
if (volumeInstances.length === 0) {
return null
}
const refImage = volumeInstances[0]
if (isLoading) {
return null
}

/* If a series is encoded in the path, route the viewer to this series.
* Otherwise select the first series correspondent to
* the first slide contained in the study.
*/
let selectedSeriesInstanceUID: string
if (this.props.location.pathname.includes('series/')) {
const seriesFragment = this.props.location.pathname.split('series/')[1]
selectedSeriesInstanceUID = seriesFragment.includes('/') ? seriesFragment.split('/')[0] : seriesFragment
} else {
selectedSeriesInstanceUID = volumeInstances[0].SeriesInstanceUID
}
if (slides.length === 0) {
return null
}

let clinicalTrialMenu
if (refImage.ClinicalTrialSponsorName != null) {
clinicalTrialMenu = (
<Menu.SubMenu key='clinical-trial' title='Clinical Trial'>
<ClinicalTrial metadata={refImage} />
</Menu.SubMenu>
)
}
const firstSlide = slides[0]
const volumeInstances = firstSlide.volumeImages
if (volumeInstances.length === 0) {
return null
}
const refImage = volumeInstances[0]

return (
<Layout style={{ height: '100%' }} hasSider>
<Layout.Sider
width={300}
style={{
height: '100%',
borderRight: 'solid',
borderRightWidth: 0.25,
overflow: 'hidden',
background: 'none'
}}
>
<Menu
mode='inline'
defaultOpenKeys={['patient', 'study', 'clinical-trial', 'slides']}
style={{ height: '100%' }}
inlineIndent={14}
>
<Menu.SubMenu key='patient' title='Patient'>
<Patient metadata={refImage} />
</Menu.SubMenu>
<Menu.SubMenu key='study' title='Study'>
<Study metadata={refImage} />
</Menu.SubMenu>
{clinicalTrialMenu}
<Menu.SubMenu key='slides' title='Slides'>
<SlideList
clients={this.props.clients}
metadata={this.state.slides}
selectedSeriesInstanceUID={selectedSeriesInstanceUID}
onSeriesSelection={this.handleSeriesSelection}
/>
</Menu.SubMenu>
</Menu>
</Layout.Sider>
/* If a series is encoded in the path, route the viewer to this series.
* Otherwise select the first series correspondent to
* the first slide contained in the study.
*/
let selectedSeriesInstanceUID: string
if (location.pathname.includes('series/')) {
const seriesFragment = location.pathname.split('series/')[1]
selectedSeriesInstanceUID = seriesFragment.includes('/') ? seriesFragment.split('/')[0] : seriesFragment
} else {
selectedSeriesInstanceUID = volumeInstances[0].SeriesInstanceUID
}

<Routes>
<Route
path='/series/:seriesInstanceUID'
element={
<ParametrizedSlideViewer
clients={this.props.clients}
slides={this.state.slides}
preload={this.props.preload}
annotations={this.props.annotations}
enableAnnotationTools={this.props.enableAnnotationTools}
app={this.props.app}
user={this.props.user}
/>
}
/>
</Routes>
</Layout>
let clinicalTrialMenu
if (refImage.ClinicalTrialSponsorName != null) {
clinicalTrialMenu = (
<Menu.SubMenu key='clinical-trial' title='Clinical Trial'>
<ClinicalTrial metadata={refImage} />
</Menu.SubMenu>
)
}

return (
<Layout style={{ height: '100%' }} hasSider>
<Layout.Sider
width={300}
style={{
height: '100%',
borderRight: 'solid',
borderRightWidth: 0.25,
overflow: 'hidden',
background: 'none'
}}
>
<Menu
mode='inline'
defaultOpenKeys={['patient', 'study', 'clinical-trial', 'slides']}
style={{ height: '100%' }}
inlineIndent={14}
>
<Menu.SubMenu key='patient' title='Patient'>
<Patient metadata={refImage} />
</Menu.SubMenu>
<Menu.SubMenu key='study' title='Study'>
<Study metadata={refImage} />
</Menu.SubMenu>
{clinicalTrialMenu}
<Menu.SubMenu key='slides' title='Slides'>
<SlideList
clients={props.clients}
metadata={slides}
selectedSeriesInstanceUID={selectedSeriesInstanceUID}
onSeriesSelection={handleSeriesSelection}
/>
</Menu.SubMenu>
</Menu>
</Layout.Sider>

<Routes>
<Route
path='/series/:seriesInstanceUID'
element={
<ParametrizedSlideViewer
clients={props.clients}
slides={slides}
preload={props.preload}
annotations={props.annotations}
enableAnnotationTools={props.enableAnnotationTools}
app={props.app}
user={props.user}
/>
}
/>
</Routes>
</Layout>
)
}

export default withRouter(Viewer)
8 changes: 8 additions & 0 deletions src/components/DicomTagBrowser/DicomTagBrowser.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
.dicom-tag-browser .ant-table-wrapper {
border: 1px solid #f0f0f0;
border-radius: 2px;
}

.dicom-tag-browser .ant-table-cell {
word-break: break-word;
}
Loading

0 comments on commit 177ed8f

Please sign in to comment.