Skip to content

Commit

Permalink
feat: improve dicom tag browser (#251)
Browse files Browse the repository at this point in the history
* WIP: add metadata store and update dicom tag browser (instance slider is broken)

* Address derived display set

* Fix search

* Debounce search

* Address async request

* Bump dcmjs to fix missing ann tags

* Lint

* Use existent guid lib

* Extract debounce book

* Lit
  • Loading branch information
igoroctaviano authored Nov 20, 2024
1 parent 45e10cd commit 7e57859
Show file tree
Hide file tree
Showing 10 changed files with 776 additions and 99 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@
"classnames": "^2.2.6",
"copy-webpack-plugin": "^10.2.4",
"craco-less": "^2.0.0",
"dcmjs": "^0.29.8",
"dcmjs": "^0.35.0",
"detect-browser": "^5.2.1",
"dicom-microscopy-viewer": "^0.47.0",
"dicomweb-client": "^0.10.3",
Expand Down
23 changes: 20 additions & 3 deletions src/DicomWebManager.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import * as dwc from 'dicomweb-client'
import * as dcmjs from 'dcmjs'
import * as dmv from 'dicom-microscopy-viewer'

import { ServerSettings, DicomWebManagerErrorHandler } from './AppConfig'
import { joinUrl } from './utils/url'
Expand All @@ -7,6 +9,9 @@ import { CustomError, errorTypes } from './utils/CustomError'
import NotificationMiddleware, {
NotificationMiddlewareContext
} from './services/NotificationMiddleware'
import DicomMetadataStore, { Instance } from './services/DICOMMetadataStore'

const { naturalizeDataset } = dcmjs.data.DicomMetaDictionary

interface Store {
id: string
Expand Down Expand Up @@ -163,13 +168,21 @@ export default class DicomWebManager implements dwc.api.DICOMwebClient {
retrieveStudyMetadata = async (
options: dwc.api.RetrieveStudyMetadataOptions
): Promise<dwc.api.Metadata[]> => {
return await this.stores[0].client.retrieveStudyMetadata(options)
const studySummaryMetadata = await this.stores[0].client.retrieveStudyMetadata(options)
const naturalized = naturalizeDataset(studySummaryMetadata)
DicomMetadataStore.addStudy(naturalized)
return studySummaryMetadata
}

retrieveSeriesMetadata = async (
options: dwc.api.RetrieveSeriesMetadataOptions
): Promise<dwc.api.Metadata[]> => {
return await this.stores[0].client.retrieveSeriesMetadata(options)
const seriesSummaryMetadata = await this.stores[0].client.retrieveSeriesMetadata(options)
console.debug('seriesSummaryMetadata:', seriesSummaryMetadata)
const naturalized = seriesSummaryMetadata.map(naturalizeDataset)
console.debug('naturalized:', naturalized)
DicomMetadataStore.addSeriesMetadata(naturalized, true)
return seriesSummaryMetadata
}

retrieveInstanceMetadata = async (
Expand All @@ -181,7 +194,11 @@ export default class DicomWebManager implements dwc.api.DICOMwebClient {
retrieveInstance = async (
options: dwc.api.RetrieveInstanceOptions
): Promise<dwc.api.Dataset> => {
return await this.stores[0].client.retrieveInstance(options)
const instance = await this.stores[0].client.retrieveInstance(options)
const data = dcmjs.data.DicomMessage.readFile(instance)
const { dataset } = dmv.metadata.formatMetadata(data.dict)
DicomMetadataStore.addInstances([dataset as Instance])
return instance
}

retrieveInstanceFrames = async (
Expand Down
241 changes: 152 additions & 89 deletions src/components/DicomTagBrowser/DicomTagBrowser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,17 @@ import './DicomTagBrowser.css'
import { useSlides } from '../../hooks/useSlides'
import { getSortedTags } from './dicomTagUtils'
import { formatDicomDate } from '../../utils/formatDicomDate'
import DicomMetadataStore, { Series, Study } from '../../services/DICOMMetadataStore'
import { useDebounce } from '../../hooks/useDebounce'

const { Option } = Select

interface DisplaySet {
displaySetInstanceUID: number
SeriesDate: string
SeriesTime: string
SeriesNumber: number
SeriesDescription: string
SeriesDate?: string
SeriesTime?: string
SeriesNumber: string
SeriesDescription?: string
Modality: string
images: any[]
}
Expand All @@ -36,62 +38,116 @@ interface DicomTagBrowserProps {

const DicomTagBrowser = ({ clients, studyInstanceUID }: DicomTagBrowserProps): JSX.Element => {
const { slides, isLoading } = useSlides({ clients, studyInstanceUID })
const [study, setStudy] = useState<Study | undefined>(undefined)

const [displaySets, setDisplaySets] = useState<DisplaySet[]>([])
const [selectedDisplaySetInstanceUID, setSelectedDisplaySetInstanceUID] = useState(0)
const [instanceNumber, setInstanceNumber] = useState(1)
const [filterValue, setFilterValue] = useState('')
const [expandedKeys, setExpandedKeys] = useState<string[]>([])
const [searchExpandedKeys, setSearchExpandedKeys] = useState<string[]>([])
const [searchInput, setSearchInput] = useState('')

const debouncedSearchValue = useDebounce(searchInput, 300)

useEffect(() => {
if (slides.length === 0) return

const updatedDisplaySets = slides
.map((slide, index) => {
const { volumeImages } = slide
if (volumeImages?.[0] === undefined) return null

const {
SeriesDate,
SeriesTime,
SeriesNumber,
SeriesDescription,
Modality
} = volumeImages[0]

return {
displaySetInstanceUID: index,
SeriesDate,
SeriesTime,
SeriesNumber,
SeriesDescription,
Modality,
images: volumeImages
}
})
.filter((set): set is DisplaySet => set !== null)
setFilterValue(debouncedSearchValue)
}, [debouncedSearchValue])

useEffect(() => {
const handler = (event: any): void => {
const study: Study | undefined = Object.assign({}, DicomMetadataStore.getStudy(studyInstanceUID))
setStudy(study)
}
const seriesAddedSubscription = DicomMetadataStore.subscribe(DicomMetadataStore.EVENTS.SERIES_ADDED, handler)
const instancesAddedSubscription = DicomMetadataStore.subscribe(DicomMetadataStore.EVENTS.INSTANCES_ADDED, handler)

const study = Object.assign({}, DicomMetadataStore.getStudy(studyInstanceUID))
setStudy(study)

return () => {
seriesAddedSubscription.unsubscribe()
instancesAddedSubscription.unsubscribe()
}
}, [studyInstanceUID])

useEffect(() => {
let displaySets: DisplaySet[] = []
let derivedDisplaySets: DisplaySet[] = []
const processedSeries: string[] = []
let index = 0

if (slides.length > 0) {
displaySets = slides
.map((slide): DisplaySet | null => {
const { volumeImages } = slide
if (volumeImages?.[0] === undefined) return null

const {
SeriesDate,
SeriesTime,
SeriesNumber,
SeriesInstanceUID,
SeriesDescription,
Modality
} = volumeImages[0]

processedSeries.push(SeriesInstanceUID)

const ds: DisplaySet = {
displaySetInstanceUID: index,
SeriesDate,
SeriesTime,
SeriesInstanceUID,
// @ts-expect-error
SeriesNumber,
SeriesDescription,
Modality,
images: volumeImages
}
index++
return ds
})
.filter((set): set is DisplaySet => set !== null)
}

if (study !== undefined && study.series?.length > 0) {
derivedDisplaySets = study.series.filter(s => !processedSeries.includes(s.SeriesInstanceUID))
.map((series: Series): DisplaySet => {
const ds: DisplaySet = {
displaySetInstanceUID: index,
SeriesDate: series.SeriesDate,
SeriesTime: series.SeriesTime,
// @ts-expect-error
SeriesNumber: series.SeriesNumber,
SeriesDescription: series.SeriesDescription,
SeriesInstanceUID: series.SeriesInstanceUID,
Modality: series.Modality,
images: series?.instances?.length > 0 ? series.instances : [series]
}
index++
return ds
})
}

setDisplaySets(updatedDisplaySets)
}, [slides])
setDisplaySets([...displaySets, ...derivedDisplaySets])
}, [slides, study])

const displaySetList = useMemo(() => {
displaySets.sort((a, b) => a.SeriesNumber - b.SeriesNumber)
return displaySets.map((displaySet) => {
displaySets.sort((a, b) => Number(a.SeriesNumber) - Number(b.SeriesNumber))
return displaySets.map((displaySet, index) => {
const {
displaySetInstanceUID,
SeriesDate,
SeriesTime,
SeriesNumber,
SeriesDescription,
Modality
SeriesDate = '',
SeriesTime = '',
SeriesNumber = '',
SeriesDescription = '',
Modality = ''
} = displaySet

const dateStr = `${SeriesDate}:${SeriesTime}`.split('.')[0]
const displayDate = formatDicomDate(dateStr)

return {
value: displaySetInstanceUID,
value: index,
label: `${SeriesNumber} (${Modality}): ${SeriesDescription}`,
description: displayDate
}
Expand All @@ -101,6 +157,8 @@ const DicomTagBrowser = ({ clients, studyInstanceUID }: DicomTagBrowserProps): J
const showInstanceList =
displaySets[selectedDisplaySetInstanceUID]?.images.length > 1

console.debug('displaySets:', displaySets)

const instanceSliderMarks = useMemo(() => {
if (displaySets[selectedDisplaySetInstanceUID] === undefined) return {}
const totalInstances = displaySets[selectedDisplaySetInstanceUID].images.length
Expand Down Expand Up @@ -145,8 +203,9 @@ const DicomTagBrowser = ({ clients, studyInstanceUID }: DicomTagBrowserProps): J
const tableData = useMemo(() => {
const transformTagsToTableData = (tags: any[], parentKey = ''): TableDataItem[] => {
return tags.map((tag, index) => {
// Create a unique key that includes the parent path
const currentKey = parentKey !== undefined ? `${parentKey}-${index}` : `${index}`
// Create a unique key using tag value if available, otherwise use index
const keyBase: string = tag.tag !== '' ? tag.tag.replace(/[(),]/g, '') : index.toString()
const currentKey: string = parentKey !== '' ? `${parentKey}-${keyBase}` : keyBase

const item: TableDataItem = {
key: currentKey,
Expand All @@ -157,7 +216,6 @@ const DicomTagBrowser = ({ clients, studyInstanceUID }: DicomTagBrowserProps): J
}

if (tag.children !== undefined && tag.children.length > 0) {
// Pass the current key as parent for nested items
item.children = transformTagsToTableData(tag.children, currentKey)
}

Expand All @@ -171,56 +229,62 @@ const DicomTagBrowser = ({ clients, studyInstanceUID }: DicomTagBrowserProps): J
return transformTagsToTableData(tags)
}, [instanceNumber, selectedDisplaySetInstanceUID, displaySets])

// Reset expanded keys when search value changes
useEffect(() => {
setExpandedKeys([])
}, [filterValue])

const filteredData = useMemo(() => {
if (filterValue === undefined || filterValue === '') return tableData

const searchLower = filterValue.toLowerCase()
const newSearchExpandedKeys: string[] = []

const filterNodes = (nodes: TableDataItem[], parentKey = ''): TableDataItem[] => {
return nodes.map(node => {
const newNode = { ...node }

const matchesSearch =
(node.tag?.toLowerCase() ?? '').includes(searchLower) ||
(node.vr?.toLowerCase() ?? '').includes(searchLower) ||
(node.keyword?.toLowerCase() ?? '').includes(searchLower) ||
(node.value?.toString().toLowerCase() ?? '').includes(searchLower)

if (node.children != null) {
const filteredChildren = filterNodes(node.children, node.key)
newNode.children = filteredChildren

if (matchesSearch || filteredChildren.length > 0) {
// Add all parent keys to maintain the expansion chain
if (parentKey !== undefined) {
newSearchExpandedKeys.push(parentKey)
}
newSearchExpandedKeys.push(node.key)
return newNode
}
}

return matchesSearch ? newNode : null
}).filter((node): node is TableDataItem => node !== null)
const nodeMatches = (node: TableDataItem): boolean => {
return (
(node.tag?.toLowerCase() ?? '').includes(searchLower) ||
(node.vr?.toLowerCase() ?? '').includes(searchLower) ||
(node.keyword?.toLowerCase() ?? '').includes(searchLower) ||
(node.value?.toString().toLowerCase() ?? '').includes(searchLower)
)
}

const filtered = filterNodes(tableData)
setSearchExpandedKeys(newSearchExpandedKeys)
return filtered
}, [tableData, filterValue])
const findMatchingNodes = (nodes: TableDataItem[]): TableDataItem[] => {
const results: TableDataItem[] = []

const searchNode = (node: TableDataItem): void => {
if (nodeMatches(node)) {
// Create a new matching node with its original structure
const matchingNode: TableDataItem = {
key: node.key,
tag: node.tag,
vr: node.vr,
keyword: node.keyword,
value: node.value
}

// Reset search expanded keys when search is cleared
useEffect(() => {
if (filterValue === undefined || filterValue === '') {
setSearchExpandedKeys([])
// If the node has children, preserve them for expansion
matchingNode.children = node?.children?.map((child): TableDataItem => ({
key: child.key,
tag: child.tag,
vr: child.vr,
keyword: child.keyword,
value: child.value,
children: child.children
}))

results.push(matchingNode)
}

// Continue searching through children
node?.children?.forEach(searchNode)
}

nodes.forEach(searchNode)
return results
}
}, [filterValue])

// Combine manual expansion with search expansion
const allExpandedKeys = useMemo(() => {
return [...new Set([...expandedKeys, ...searchExpandedKeys])]
}, [expandedKeys, searchExpandedKeys])
return findMatchingNodes(tableData)
}, [tableData, filterValue])

if (isLoading) {
return <div>Loading...</div>
Expand All @@ -240,7 +304,6 @@ const DicomTagBrowser = ({ clients, studyInstanceUID }: DicomTagBrowserProps): J
<Select
style={{ width: '100%' }}
value={selectedDisplaySetInstanceUID}
defaultValue={0}
onChange={(value) => {
setSelectedDisplaySetInstanceUID(value)
setInstanceNumber(1)
Expand Down Expand Up @@ -286,16 +349,16 @@ const DicomTagBrowser = ({ clients, studyInstanceUID }: DicomTagBrowserProps): J
style={{ marginBottom: '20px' }}
placeholder='Search DICOM tags...'
prefix={<SearchOutlined />}
onChange={(e) => setFilterValue(e.target.value)}
value={filterValue}
onChange={(e) => setSearchInput(e.target.value)}
value={searchInput}
/>

<Table
columns={columns}
dataSource={filteredData}
pagination={false}
expandable={{
expandedRowKeys: allExpandedKeys,
expandedRowKeys: expandedKeys,
onExpandedRowsChange: (keys) => setExpandedKeys(keys as string[])
}}
size='small'
Expand Down
Loading

0 comments on commit 7e57859

Please sign in to comment.