From d853d1b85eb4e3f1f70580df622688e9bc239afe Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Sun, 18 Aug 2024 17:21:00 -0400 Subject: [PATCH] Using dicom-web infra for AHI --- src/core/ahi-api.ts | 126 ++++++++++++++++++++++++ src/core/dicomTags.ts | 1 + src/core/streaming/ahiChunkImage.ts | 22 +++-- src/io/import/awsAhi.ts | 17 ++-- src/store/dicom-web/dicom-meta-store.ts | 13 +-- src/store/dicom-web/dicom-web-store.ts | 60 ++++------- 6 files changed, 177 insertions(+), 62 deletions(-) create mode 100644 src/core/ahi-api.ts diff --git a/src/core/ahi-api.ts b/src/core/ahi-api.ts new file mode 100644 index 000000000..90bd98050 --- /dev/null +++ b/src/core/ahi-api.ts @@ -0,0 +1,126 @@ +import { NameToMeta } from './dicomTags'; +import { dicomSliceToImageUri, nameToMetaKey } from './streaming/ahiChunkImage'; + +export interface FetchImageSetOptions { + imageSet: string; +} + +export interface FetchSeriesOptions extends FetchImageSetOptions { + seriesInstanceUID: string; +} + +export interface FetchInstanceOptions extends FetchSeriesOptions { + sopInstanceUID: string; +} + +export type Instance = NameToMeta & { imageSet: string }; + +function parseInstance(instance: any) { + return Object.fromEntries( + Object.entries(nameToMetaKey).map(([key, value]) => { + return [key, instance[value]]; + }) + ); +} + +export async function searchForStudies(dicomWebRoot: string) { + const setResponse = await fetch(`${dicomWebRoot}/list-image-sets`); + const imageSetMeta = await setResponse.json(); + return imageSetMeta.map((set: any) => ({ + ...parseInstance(set), + imageSet: set.imageSetId, + })); +} + +export async function retrieveStudyMetadata( + dicomWebRoot: string, + options: FetchImageSetOptions +) { + const url = `${dicomWebRoot}/image-set/${options.imageSet}`; + const setResponse = await fetch(url); + const imageSetMeta = await setResponse.json(); + const patentTags = imageSetMeta.Patient.DICOM; + const studyTags = imageSetMeta.Study.DICOM; + const series = ( + Object.values(imageSetMeta.Study.Series) as { + DICOM: Record; + Instances: Record; + }[] + ).map((s) => s.DICOM); + const instances = series.map((s) => ({ ...patentTags, ...studyTags, ...s })); + return instances.map(parseInstance); +} + +export async function retrieveSeriesMetadata( + dicomWebRoot: string, + options: FetchSeriesOptions +) { + const url = `${dicomWebRoot}/image-set/${options.imageSet}`; + const setResponse = await fetch(url); + const imageSetMeta = await setResponse.json(); + const patentTags = imageSetMeta.Patient.DICOM; + const studyTags = imageSetMeta.Study.DICOM; + const series = Object.values(imageSetMeta.Study.Series) as { + DICOM: Record; + Instances: Record; + }[]; + const instances = series.flatMap((s) => { + return Object.values(s.Instances).map((i) => ({ + ...patentTags, + ...studyTags, + ...s.DICOM, + ...i.DICOM, + })); + }); + return instances.map(parseInstance); +} + +export async function fetchInstanceThumbnail( + dicomWebRoot: string, + apiParams: FetchInstanceOptions +) { + const url = `${dicomWebRoot}/image-set/${apiParams.imageSet}`; + const setResponse = await fetch(url); + const imageSetMeta = await setResponse.json(); + const series = Object.values(imageSetMeta.Study.Series) as { + DICOM: Record; + Instances: Record; + }[]; + const theSeries = series.find( + (s) => s.DICOM.SeriesInstanceUID === apiParams.seriesInstanceUID + ); + if (!theSeries) { + throw new Error('Series not found'); + } + const instanceRemote = theSeries.Instances[apiParams.sopInstanceUID]; + const id = instanceRemote.ImageFrames[0].ID; + + const request = await fetch(`${url}/${id}/pixel-data`); + const blob = await request.blob(); + return dicomSliceToImageUri(blob); +} + +const LEVELS = ['image-set'] as const; + +// takes a url like http://localhost:3000/dicom-web/studies/someid/series/anotherid +// returns { host: 'http://localhost:3000/dicom-web', studies: 'someid', series: 'anotherid' } +export function parseUrl(deepDicomWebUrl: string) { + // remove trailing slash + const sansSlash = deepDicomWebUrl.replace(/\/$/, ''); + + let paths = sansSlash.split('/'); + const parentIDs = LEVELS.reduce((idObj, dicomLevel) => { + const [urlLevel, dicomID] = paths.slice(-2); + if (urlLevel === dicomLevel) { + paths = paths.slice(0, -2); + return { [dicomLevel]: dicomID, ...idObj }; + } + return idObj; + }, {}); + + const pathsToSlice = Object.keys(parentIDs).length * 2; + const allPaths = sansSlash.split('/'); + const host = allPaths.slice(0, allPaths.length - pathsToSlice).join('/'); + + return { host, ...parentIDs }; +} diff --git a/src/core/dicomTags.ts b/src/core/dicomTags.ts index d36cb5501..367b64235 100644 --- a/src/core/dicomTags.ts +++ b/src/core/dicomTags.ts @@ -28,6 +28,7 @@ export const Tags = { RescaleIntercept: '0028|1052', RescaleSlope: '0028|1053', NumberOfFrames: '0028|0008', + InstanceNumber: '0020|0013', } as const; export type NameToMeta = { diff --git a/src/core/streaming/ahiChunkImage.ts b/src/core/streaming/ahiChunkImage.ts index 4662c425d..63ce3a5ab 100644 --- a/src/core/streaming/ahiChunkImage.ts +++ b/src/core/streaming/ahiChunkImage.ts @@ -30,6 +30,7 @@ export const nameToMetaKey = { SOPInstanceUID: 'SOPInstanceUID', ImagePositionPatient: 'ImagePositionPatient', ImageOrientationPatient: 'ImageOrientationPatient', + InstanceNumber: 'InstanceNumber', PixelSpacing: 'PixelSpacing', Rows: 'Rows', Columns: 'Columns', @@ -40,11 +41,11 @@ export const nameToMetaKey = { RescaleIntercept: 'RescaleIntercept', RescaleSlope: 'RescaleSlope', NumberOfFrames: 'NumberOfFrames', - PatientID: 'PatientID', + PatientID: 'PatientId', PatientName: 'PatientName', PatientBirthDate: 'PatientBirthDate', PatientSex: 'PatientSex', - StudyID: 'StudyID', + StudyID: 'StudyInstanceUID', StudyInstanceUID: 'StudyInstanceUID', StudyDate: 'StudyDate', StudyTime: 'StudyTime', @@ -92,7 +93,7 @@ function itkImageToURI(itkImage: Image) { return ''; } -async function dicomSliceToImageUri(blob: Blob) { +export async function dicomSliceToImageUri(blob: Blob) { const array = await blob.arrayBuffer(); const uint8Array = new Uint8Array(array); const result = await decode(uint8Array); @@ -263,16 +264,21 @@ export default class AhiChunkImage implements ChunkImage { const chunk = this.chunks[chunkIndex]; if (!chunk.dataBlob) throw new Error('Chunk does not have data'); - // await chunk.dataBlob.arrayBuffer() const array = await chunk.dataBlob.arrayBuffer(); const uint8Array = new Uint8Array(array); - // const result = await decode(uint8Array, { - // webWorker: getWorker(), - // }); const result = await decode(uint8Array); - if (!result.image.data) throw new Error('No data read from chunk'); + const meta = new Map(chunk.metadata); + const rescaleInterceptMeta = meta.get(nameToMetaKey.RescaleIntercept); + const rescaleIntercept = rescaleInterceptMeta + ? Number(rescaleInterceptMeta) + : 0; + const pixels = result.image.data; + for (let i = 0; i < pixels.length; i++) { + pixels[i] += rescaleIntercept; + } + const scalars = this.imageData.getPointData().getScalars(); const pixelData = scalars.getData() as TypedArray; diff --git a/src/io/import/awsAhi.ts b/src/io/import/awsAhi.ts index be576727f..2aade5ccf 100644 --- a/src/io/import/awsAhi.ts +++ b/src/io/import/awsAhi.ts @@ -52,8 +52,8 @@ class AhiDataLoader implements DataLoader { } } -const makeAhiChunk = (uri: string, frame: any) => { - const pixelDataUri = `${uri}/${frame.ID}/pixel-data`; +const makeAhiChunk = (imageSetUrl: string, frame: any) => { + const pixelDataUri = `${imageSetUrl}/${frame.ID}/pixel-data`; const metaLoader = new AhiMetaLoader(frame); const fetcher = new CachedStreamFetcher(pixelDataUri, { @@ -68,12 +68,15 @@ const makeAhiChunk = (uri: string, frame: any) => { }; const importAhiImageSet = async (uri: string) => { - const imageSetMetaUri = uri.replace('ahi:', 'http:'); - const setResponse = await fetch(imageSetMetaUri); + const withProto = uri.replace('ahi:', 'http:'); + const lastSlash = withProto.lastIndexOf('/'); + const seriesId = withProto.substring(lastSlash + 1); + const imageSetUrl = withProto.substring(0, lastSlash); + const setResponse = await fetch(imageSetUrl); const imageSetMeta = await setResponse.json(); const patentTags = imageSetMeta.Patient.DICOM; const studyTags = imageSetMeta.Study.DICOM; - const firstSeries = Object.entries(imageSetMeta.Study.Series)[0][1] as { + const firstSeries = imageSetMeta.Study.Series[seriesId] as { DICOM: Record; Instances: Record; }; @@ -88,9 +91,7 @@ const importAhiImageSet = async (uri: string) => { })) ); - const chunks = frames.map((frame: any) => - makeAhiChunk(imageSetMetaUri, frame) - ); + const chunks = frames.map((frame: any) => makeAhiChunk(imageSetUrl, frame)); const chunkStore = useChunkStore(); const image = new AhiChunkImage(seriesTags); diff --git a/src/store/dicom-web/dicom-meta-store.ts b/src/store/dicom-web/dicom-meta-store.ts index e84bcd13c..0d084dbe1 100644 --- a/src/store/dicom-web/dicom-meta-store.ts +++ b/src/store/dicom-web/dicom-meta-store.ts @@ -7,10 +7,10 @@ import { VolumeInfo, } from '../datasets-dicom'; import { pick, removeFromArray } from '../../utils'; -import { Instance } from '../../core/dicom-web-api'; +import { Instance } from '../../core/ahi-api'; interface InstanceInfo { - SopInstanceUID: string; + SOPInstanceUID: string; InstanceNumber: string; Rows: number; Columns: number; @@ -28,7 +28,7 @@ interface State { patientStudies: Record; // studyKey -> study info - studyInfo: Record; + studyInfo: Record; // studyKey -> array of volumeKeys studyVolumes: Record; @@ -79,7 +79,8 @@ export const useDicomMetaStore = defineStore('dicom-meta', { 'StudyDate', 'StudyTime', 'AccessionNumber', - 'StudyDescription' + 'StudyDescription', + 'imageSet' ); const volumeInfo = { @@ -97,7 +98,7 @@ export const useDicomMetaStore = defineStore('dicom-meta', { }; const instanceInfo = { - ...pick(info, 'SopInstanceUID', 'InstanceNumber'), + ...pick(info, 'SOPInstanceUID', 'InstanceNumber'), Rows: Number.parseInt(info.Rows, 10), Columns: Number.parseInt(info.Columns, 10), }; @@ -172,7 +173,7 @@ export const useDicomMetaStore = defineStore('dicom-meta', { this.studyVolumes[studyKey].push(volumeKey); } - const instanceKey = instance.SopInstanceUID; + const instanceKey = instance.SOPInstanceUID; if (instanceKey && !(instanceKey in this.instanceInfo)) { this.instanceInfo[instanceKey] = instance; this.instanceVolume[instanceKey] = volumeKey; diff --git a/src/store/dicom-web/dicom-web-store.ts b/src/store/dicom-web/dicom-web-store.ts index acbd0c56d..f9b343238 100644 --- a/src/store/dicom-web/dicom-web-store.ts +++ b/src/store/dicom-web/dicom-web-store.ts @@ -4,7 +4,6 @@ import { defineStore } from 'pinia'; import vtkURLExtract from '@kitware/vtk.js/Common/Core/URLExtract'; import { omit, remapKeys } from '@/src/utils'; -import { fileToDataSource } from '@/src/io/import/dataSource'; import { convertSuccessResultToDataSelection, importDataSources, @@ -15,12 +14,11 @@ import { useMessageStore } from '../messages'; import { useDicomMetaStore } from './dicom-meta-store'; import { searchForStudies, - fetchSeries, fetchInstanceThumbnail, retrieveStudyMetadata, retrieveSeriesMetadata, parseUrl, -} from '../../core/dicom-web-api'; +} from '../../core/ahi-api'; const DICOM_WEB_URL_PARAM = 'dicomweb'; @@ -41,18 +39,17 @@ export const isDownloadable = (progress?: VolumeProgress) => const fetchFunctions = { host: searchForStudies, - studies: retrieveStudyMetadata, - series: retrieveSeriesMetadata, + 'image-set': retrieveStudyMetadata, + // series: retrieveSeriesMetadata, + // 'image-set': retrieveSeriesMetadata, }; const levelToFetchKey = { - studies: 'studyInstanceUID', - series: 'seriesInstanceUID', + 'image-set': 'imageSet', }; const levelToMetaKey = { - studies: 'StudyInstanceUID', - series: 'SeriesInstanceUID', + 'image-set': 'imageSet', }; type InitialDicomListFetchProgress = 'Idle' | 'Pending' | 'Done'; @@ -104,7 +101,8 @@ export const useDicomWebStore = defineStore('dicom-web', () => { const instance = { studyInstanceUID: studyInfo.StudyInstanceUID, seriesInstanceUID: volumeInfo.SeriesInstanceUID, - sopInstanceUID: middleInstance.SopInstanceUID, + sopInstanceUID: middleInstance.SOPInstanceUID, + imageSet: studyInfo.imageSet, }; return fetchInstanceThumbnail(cleanHost.value, instance); }; @@ -116,7 +114,7 @@ export const useDicomWebStore = defineStore('dicom-web', () => { .map((studyKey) => dicoms.studyInfo[studyKey]) .map(async (studyMeta) => { const seriesMetas = await retrieveStudyMetadata(cleanHost.value, { - studyInstanceUID: studyMeta.StudyInstanceUID, + imageSet: studyMeta.imageSet, }); return seriesMetas.map((seriesMeta) => ({ ...studyMeta, @@ -134,7 +132,7 @@ export const useDicomWebStore = defineStore('dicom-web', () => { const volumeMeta = dicoms.volumeInfo[volumeKey]; const studyMeta = dicoms.studyInfo[dicoms.volumeStudy[volumeKey]]; const instanceMetas = await retrieveSeriesMetadata(cleanHost.value, { - studyInstanceUID: studyMeta.StudyInstanceUID, + imageSet: studyMeta.imageSet, seriesInstanceUID: volumeMeta.SeriesInstanceUID, }); return instanceMetas.map((instanceMeta) => ({ @@ -165,29 +163,17 @@ export const useDicomWebStore = defineStore('dicom-web', () => { const { SeriesInstanceUID: seriesInstanceUID } = dicoms.volumeInfo[volumeKey]; const studyKey = dicoms.volumeStudy[volumeKey]; - const { StudyInstanceUID: studyInstanceUID } = dicoms.studyInfo[studyKey]; - const seriesInfo = { studyInstanceUID, seriesInstanceUID }; - - const progressCallback = ({ loaded, total }: ProgressEvent) => { - volumes.value[volumeKey] = { - ...volumes.value[volumeKey], - loaded, - total, - }; - }; try { - const files = await fetchSeries( - cleanHost.value, - seriesInfo, - progressCallback - ); - - if (!files) { - throw new Error('Could not fetch series'); - } - - const [loadResult] = await importDataSources(files.map(fileToDataSource)); + const sanProtocol = cleanHost.value.split('://')[1]; + const ahiHost = `ahi://${sanProtocol}`; + const [loadResult] = await importDataSources([ + { + type: 'uri', + name: seriesInstanceUID, + uri: `${ahiHost}/image-set/${dicoms.studyInfo[studyKey].imageSet}/${seriesInstanceUID}`, + }, + ]); if (!loadResult) { throw new Error('Did not receive a load result'); } @@ -230,8 +216,7 @@ export const useDicomWebStore = defineStore('dicom-web', () => { parsedURL.value ).pop() as keyof typeof fetchFunctions; // at least host key guaranteed to exist - linkedToStudyOrSeries.value = - deepestLevel === 'studies' || deepestLevel === 'series'; + linkedToStudyOrSeries.value = deepestLevel === 'image-set'; const fetchFunc = fetchFunctions[deepestLevel]; const urlIDs = omit(parsedURL.value, 'host'); @@ -254,11 +239,6 @@ export const useDicomWebStore = defineStore('dicom-web', () => { console.error(error); } - if (deepestLevel === 'series') { - const seriesID = Object.values(parsedURL.value).pop() as string; - downloadVolume(seriesID); - } - fetchDicomsProgress.value = 'Done'; };