import { frameUrl } from '../../services/ImagingFrameService.tsx'; import type { HealthImagingMetadata } from '../../services/ImagingFrameService.tsx'; // THIS TAG IS INFORMATIONAL, NOT LOAD-BEARING - AND THE DIFFERENCE MATTERS. // // An earlier draft of this file claimed Cornerstone's wadors loader picks its decoder from tag // 0002,0010 here in the registered metadata. That is FALSE, verified in the installed package: // `loadImage.js:82` calls `getTransferSyntaxForContentType(result.contentType)` - the decoder comes // from the HTTP RESPONSE Content-Type, and `getTransferSyntaxForContentType` falls back to Implicit // VR Little Endian for anything it does not recognise. // // What actually makes the pixels decode is the server sending // `Content-Type: application/octet-stream; transfer-syntax=`, because that function checks // `params['transfer-syntax']` first, ahead of every media-type heuristic. **Do not "simplify" that // header away on the assumption this tag covers it** - HTJ2K bytes would go to an implicit-VR path // and render blank or as noise. // // The tag is still registered, and registered correctly, because metadata that contradicts the bytes // is a trap for the next reader. // // AWS documents (HealthImaging API reference, GetImageFrame) that the format GetImageFrame returns is // determined by the image set's STORED transfer syntax: // 1.2.840.10008.1.2.1 -> application/octet-stream (raw, uncompressed - NOT HTJ2K) // 1.2.840.10008.1.2.4.50 -> image/jpeg // 1.2.840.10008.1.2.4.91 -> image/j2c // 1.2.840.10008.1.2.4.202 -> image/jph // 1.2.840.10008.1.2.4.203 -> image/jphc // (absent) -> image/jph // Hardcoding one value - an earlier draft of this plan hardcoded 1.2.840.10008.1.2.4.201, which is not // even in that list - would mislabel every frame whose image set was stored differently, and an // uncompressed image set would be handed to an HTJ2K decoder. const FALLBACK_TRANSFER_SYNTAX_UID = '1.2.840.10008.1.2.4.202'; // Exported for the tests and for the fallback assertion; prefer the per-instance value. export const HTJ2K_TRANSFER_SYNTAX_UID = FALLBACK_TRANSFER_SYNTAX_UID; export interface WadoRsFrame { imageId: string; metadata: Record; seriesInstanceUid: string; sopInstanceUid: string; instanceNumber: number; } type Dicom = Record; const asString = (value: unknown): string | undefined => typeof value === 'string' && value.length > 0 ? value : undefined; // A DICOM NUMBER IS OFTEN A STRING, AND VERIFIED SO IN THIS DATASTORE. `DS` (Decimal String) and // `IS` (Integer String) are string value representations by definition, and HealthImaging returns them // verbatim - trailing DICOM padding included. Captured live from the imported CT: // // InstanceNumber '156 ' // RescaleIntercept '-1024 ' // RescaleSlope '1 ' // SliceThickness '1.250000' // PixelSpacing ['007.031250e-01', '007.031250e-01 '] // WindowCenter ['50'] <- a LIST even with one element // // while `US`-typed fields (Rows, Columns, BitsAllocated, PixelRepresentation) really do arrive as // numbers, because US is a binary VR. A `typeof value === 'number'` guard therefore silently dropped // every DS/IS field: InstanceNumber gone meant every slice sorted equal and the stack fell back to SOP // Instance UID order (a scrambled CT); Rescale gone meant no Hounsfield conversion; Window gone meant // auto-windowing instead of the stored preset; PixelSpacing gone meant a wrong aspect ratio. The image // still rendered - wrong in four ways at once, with nothing raised. // // Number(' 1 ') is 1 and Number('') is 0, so the trim-and-reject-empty is load-bearing: without it a // padding-only value would become a fabricated 0, which is the very thing the omit rule exists to stop. const asNumber = (value: unknown): number | undefined => { if (typeof value === 'number') return Number.isFinite(value) ? value : undefined; if (typeof value === 'string') { const trimmed = value.trim(); if (trimmed === '') return undefined; const parsed = Number(trimmed); return Number.isFinite(parsed) ? parsed : undefined; } return undefined; }; const asNumbers = (value: unknown): number[] | undefined => { if (!Array.isArray(value) || value.length === 0) return undefined; const parsed = value.map(asNumber); // All or nothing: a partially-parsed PixelSpacing is worse than none, because a half-known pixel // geometry would silently scale one axis and not the other. return parsed.every((entry): entry is number => entry !== undefined) ? parsed : undefined; }; // Every put* helper OMITS the tag when the source value is absent, rather than writing a default. // Cornerstone's metaDataProvider treats a missing tag and a zero-valued tag very differently: a missing // RescaleSlope leaves the modality LUT alone, while a fabricated `0` would multiply every pixel to // zero and render a black image. Absent means absent. const putString = (target: Record, tag: string, vr: string, value: unknown): void => { const parsed = asString(value); if (parsed !== undefined) target[tag] = { vr, Value: [parsed] }; }; const putNumber = (target: Record, tag: string, vr: string, value: unknown): void => { const parsed = asNumber(value); if (parsed !== undefined) target[tag] = { vr, Value: [parsed] }; }; const putNumbers = (target: Record, tag: string, vr: string, value: unknown): void => { const parsed = asNumbers(value); if (parsed !== undefined) target[tag] = { vr, Value: parsed }; }; // FOR VM 1-n NUMERIC TAGS - WindowCenter (0028,1050) and WindowWidth (0028,1051) specifically. // Both are Value Multiplicity 1-n in the DICOM standard: a CT routinely ships several presets (lung, // soft tissue, bone) in one instance. Sending those through `putNumber` looks harmless but is not - // its `asNumber` guard rejects an array outright, so a multi-preset study emitted NO VOI LUT module at // all, Cornerstone fell back to auto-windowing from the pixel range, and the study rendered at // clinically wrong default contrast with no error anywhere. That is the "absent means absent" rule // quietly turning into "present but discarded", which is a different and worse thing. const putNumberOrNumbers = (target: Record, tag: string, vr: string, value: unknown): void => { const one = asNumber(value); if (one !== undefined) { target[tag] = { vr, Value: [one] }; return; } const many = asNumbers(value); if (many !== undefined) target[tag] = { vr, Value: many }; }; // Converts one HealthImaging image set's metadata into the DICOM JSON model // `wadors.metaDataManager.add` expects, one entry per image frame. // // Only the tags Cornerstone's own wadors metaDataProvider reads are emitted - the image pixel module, // the plane module, the VOI/modality LUT values, and identity. A full DICOM tag map is neither needed // nor attempted: every tag emitted here is one a viewport actually consumes, so an unread tag added // later would be dead weight nobody can prove is correct. export const healthImagingToWadoRs = ( metadata: HealthImagingMetadata, imageSetId: string, studyId: string ): WadoRsFrame[] => { const studyDicom: Dicom = metadata.Study?.DICOM ?? {}; const studyInstanceUid = asString(studyDicom.StudyInstanceUID); const frames: WadoRsFrame[] = []; for (const [seriesKey, series] of Object.entries(metadata.Study?.Series ?? {})) { const seriesDicom: Dicom = series.DICOM ?? {}; const seriesInstanceUid = asString(seriesDicom.SeriesInstanceUID) ?? seriesKey; for (const [instanceKey, instance] of Object.entries(series.Instances ?? {})) { const imageFrames = instance.ImageFrames ?? []; if (imageFrames.length === 0) continue; const dicom: Dicom = instance.DICOM ?? {}; const sopInstanceUid = asString(dicom.SOPInstanceUID) ?? instanceKey; const instanceNumber = asNumber(dicom.InstanceNumber) ?? 0; for (const imageFrame of imageFrames) { if (!imageFrame?.ID) continue; // `instance.StoredTransferSyntaxUID`, NOT `instance.DICOM.TransferSyntaxUID`. They are // different values and the difference is the whole point. Captured live from the // imported CT: // DICOM.TransferSyntaxUID = 1.2.840.10008.1.2 (Implicit VR LE) // StoredTransferSyntaxUID = 1.2.840.10008.1.2.4.202 (HTJ2K) // The first describes the file that was uploaded; the second describes what // HealthImaging actually stores and hands back from GetImageFrame. Registering the // former would declare the frames to be uncompressed when they are HTJ2K. // // Note this sits on the INSTANCE, one level above the DICOM block, alongside // ImageFrames and DICOMVRs. // // This tag is informational for Cornerstone, not load-bearing: the decoder is chosen // from the response Content-Type (loadImage.js:82), which the server sets from // HealthImaging's own contentType. It is registered correctly anyway - metadata that // contradicts the bytes is a trap for the next reader. const transferSyntaxUid = asString(instance.StoredTransferSyntaxUID) ?? FALLBACK_TRANSFER_SYNTAX_UID; const tags: Record = { '00020010': { vr: 'UI', Value: [transferSyntaxUid] } }; // Identity if (studyInstanceUid) tags['0020000D'] = { vr: 'UI', Value: [studyInstanceUid] }; tags['0020000E'] = { vr: 'UI', Value: [seriesInstanceUid] }; tags['00080018'] = { vr: 'UI', Value: [sopInstanceUid] }; putString(tags, '00080016', 'UI', dicom.SOPClassUID); putString(tags, '00080060', 'CS', seriesDicom.Modality); putNumber(tags, '00200011', 'IS', seriesDicom.SeriesNumber); putNumber(tags, '00200013', 'IS', dicom.InstanceNumber); // Image pixel module - how to interpret the decoded buffer putNumber(tags, '00280010', 'US', dicom.Rows); putNumber(tags, '00280011', 'US', dicom.Columns); putNumber(tags, '00280100', 'US', dicom.BitsAllocated); putNumber(tags, '00280101', 'US', dicom.BitsStored); putNumber(tags, '00280102', 'US', dicom.HighBit); putNumber(tags, '00280103', 'US', dicom.PixelRepresentation); putNumber(tags, '00280002', 'US', dicom.SamplesPerPixel); putString(tags, '00280004', 'CS', dicom.PhotometricInterpretation); putNumber(tags, '00280006', 'US', dicom.PlanarConfiguration); // Modality and VOI LUT - Hounsfield conversion and default windowing putNumber(tags, '00281052', 'DS', dicom.RescaleIntercept); putNumber(tags, '00281053', 'DS', dicom.RescaleSlope); // VM 1-n - putNumberOrNumbers, never putNumber. See that helper's comment. putNumberOrNumbers(tags, '00281050', 'DS', dicom.WindowCenter); putNumberOrNumbers(tags, '00281051', 'DS', dicom.WindowWidth); // Plane module - physical geometry, needed for correct aspect and measurements later putNumbers(tags, '00280030', 'DS', dicom.PixelSpacing); putNumbers(tags, '00200032', 'DS', dicom.ImagePositionPatient); putNumbers(tags, '00200037', 'DS', dicom.ImageOrientationPatient); // A radiograph has spacing but no plane. Without one the loader treats the spacing as a // default and labels mm-scaled lengths "px"; its own defaults make the spacing count. if (tags['00280030']) { if (!tags['00200037']) tags['00200037'] = { vr: 'DS', Value: [1, 0, 0, 0, 1, 0] }; if (!tags['00200032']) tags['00200032'] = { vr: 'DS', Value: [0, 0, 0] }; } putNumber(tags, '00180050', 'DS', dicom.SliceThickness); frames.push({ imageId: `wadors:${frameUrl(imageSetId, studyId, imageFrame.ID)}`, metadata: tags, seriesInstanceUid, sopInstanceUid, instanceNumber }); } } } // SERIES FIRST, THEN InstanceNumber - and never the order Object.entries walked the Instances // map, whose key order is SOP Instance UID string order and has no relationship to anatomical // position. Leaving it unsorted renders a CT stack in scrambled slice order, which looks like a // decoding bug rather than an ordering one. // // The series key is not decoration. Sorting on instanceNumber ALONE across a multi-series image set // interleaves them - seriesA#1, seriesB#1, seriesA#2, seriesB#2 - producing an alternating stack of // two unrelated anatomies, which is worse than no sort at all (that would at least keep each series // contiguous). Nothing downstream repairs it: the viewport calls setStack on this array as-is. // AWS's one-series-per-image-set convention makes it uncommon, not impossible - `Series` is a // Record and this function iterates it. // // SOP Instance UID is the final tiebreak so two instances sharing an InstanceNumber still order // deterministically rather than depending on sort stability. return frames.sort((a, b) => a.seriesInstanceUid.localeCompare(b.seriesInstanceUid) || a.instanceNumber - b.instanceNumber || a.sopInstanceUid.localeCompare(b.sopInstanceUid) ); };