Last active
March 19, 2024 13:18
-
-
Save PantelisGeorgiadis/fb9e305610c92633fd93fcc3e973d8a2 to your computer and use it in GitHub Desktop.
DICOM Whole Slide Imaging dataset generator for Node.js
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// dcmjs-wsi-gen | |
// This Node.js utility can generate DICOM Whole Slide Imaging datasets | |
// with baseline images (higher resolution only) for testing purposes. | |
// Custom tile parameters could be defined (count in each row/column, | |
// width, height) as well as slice thickness, pixel spacing and slide offset. | |
// The following npm packages are required: | |
// canvas | |
// dcmjs | |
// faker | |
// moment | |
// parse-float | |
// parse-int | |
// e.g. npm install canvas dcmjs faker@5.5.3 moment parse-float parse-int | |
// Usage: | |
// [OPTIONS] node dcmjs-wsi-gen.js | |
// [OPTIONS]: | |
// TILE_WIDTH: Tile width (default: 512). | |
// TILE_HEIGHT: Tile height (default: 512). | |
// TILES_X: Number of tiles in each row (default: 3). | |
// TILES_Y: Number of tiles in each columns (default: 3). | |
// SLICE_THICKNESS: Slice thickness (default: 1.0). | |
// PIXEL_SPACING_X: Pixel spacing in the horizontal axis (default: 1.0). | |
// PIXEL_SPACING_Y: Pixel spacing in the vertical axis (default: 1.0). | |
// SLIDE_OFFSET_X : Horizontal axis offset in the slide coordinate system (default: 0.0). | |
// SLIDE_OFFSET_Y : Vertical axis offset in the slide coordinate system (default: 0.0). | |
// OUTPUT_FILE: Output file path (default: wsi.dcm). | |
// e.g. TILE_WIDTH=256 TILE_HEIGHT=256 TILES_X=5 TILES_Y=5 node dcmjs-wsi-gen.js | |
const fs = require('fs'); | |
const parseInt = require('parse-int'); | |
const parseFloat = require('parse-float'); | |
const faker = require('faker'); | |
const moment = require('moment'); | |
const { createCanvas } = require('canvas'); | |
const dcmjs = require('dcmjs'); | |
const { DicomMetaDictionary, DicomDict } = dcmjs.data; | |
////////////////////////////////////////////////////////////////////////// | |
// Constant UIDs | |
/** | |
* Whole Slide Imaging SOP class UID. | |
* @constant {string} | |
*/ | |
const WsiSopClassUid = '1.2.840.10008.5.1.4.1.1.77.1.6'; | |
/** | |
* Baseline JPEG transfer syntax UID. | |
* @constant {string} | |
*/ | |
const JpegBaselineTransferSyntaxUid = '1.2.840.10008.1.2.4.50'; | |
/** | |
* Implementation UID (using fo-dicom's!). | |
* @constant {string} | |
*/ | |
const ImplementationUid = '1.3.6.1.4.1.30071.8'; | |
////////////////////////////////////////////////////////////////////////// | |
// Functions | |
/** | |
* Generates a DICOM WSI dataset. | |
* @method | |
* @param {number} tileWidth - The tile width. | |
* @param {number} tileHeight - The tile height. | |
* @param {number} tilesX - Number of tiles in each row. | |
* @param {number} tilesY - Number of tiles in each column. | |
* @param {number} sliceThickness -The slice thickness. | |
* @param {number} pixelSpacingX - The pixel spacing in the horizontal axis. | |
* @param {number} pixelSpacingY - The pixel spacing in the vertical axis. | |
* @param {number} slideOffsetX - The horizontal axis offset in the slide coordinate system. | |
* @param {number} slideOffsetY - The vertical axis offset in the slide coordinate system. | |
* @returns {Buffer} The DICOM WSI dataset buffer. | |
*/ | |
function generateWsiDataset( | |
tileWidth, | |
tileHeight, | |
tilesX, | |
tilesY, | |
sliceThickness, | |
pixelSpacingX, | |
pixelSpacingY, | |
slideOffsetX, | |
slideOffsetY | |
) { | |
// Create dataset dates and times | |
const birthDate = `${moment(faker.date.between('1950-01-01', '2000-01-01')).format('YYYYMMDD')}`; | |
const date = `${moment(faker.date.between('1950-01-01', '2010-01-01')).format('YYYYMMDD')}`; | |
const time = '000000'; | |
// Create dataset UIDs | |
const studyInstanceUid = DicomMetaDictionary.uid(); | |
const seriesInstanceUid = DicomMetaDictionary.uid(); | |
const sopInstanceUid = DicomMetaDictionary.uid(); | |
const dimensionOrganizationUid = DicomMetaDictionary.uid(); | |
const frameOfReferenceUid = DicomMetaDictionary.uid(); | |
const specimenUid = DicomMetaDictionary.uid(); | |
const containerAndSpecimenIdentifier = `${faker.random.alphaNumeric(8).toUpperCase()}`; | |
const opticalPathIdentifier = `${faker.datatype.number(100)}`; | |
// Create JPEG tile buffers | |
const tileBuffers = []; | |
for (let i = 0; i < tilesY; i++) { | |
for (let j = 0; j < tilesX; j++) { | |
let tileBuffer = generateWsiTile(tileWidth, tileHeight, `${i}-${j}`); | |
if (tileBuffer.length & 1) { | |
tileBuffer = Buffer.concat([tileBuffer, Buffer.from([0x00])]); | |
} | |
tileBuffers.push(tileBuffer.buffer); | |
} | |
} | |
// Create position sequence values | |
let dimensionIndexValues = 1; | |
let offsetX = slideOffsetX; | |
let offsetY = slideOffsetY; | |
const perFrameFunctionalGroupsSequenceItems = []; | |
for (let x = 0; x < tilesX; x++) { | |
offsetY = slideOffsetY; | |
for (let y = 0; y < tilesY; y++) { | |
const item = { | |
FrameContentSequence: [ | |
{ | |
DimensionIndexValues: dimensionIndexValues, | |
}, | |
], | |
PlanePositionSlideSequence: [ | |
{ | |
XOffsetInSlideCoordinateSystem: `${offsetX}`, | |
YOffsetInSlideCoordinateSystem: `${offsetY}`, | |
ZOffsetInSlideCoordinateSystem: '0', | |
ColumnPositionInTotalImagePixelMatrix: `${x * tileWidth + 1}`, | |
RowPositionInTotalImagePixelMatrix: `${y * tileHeight + 1}`, | |
}, | |
], | |
}; | |
perFrameFunctionalGroupsSequenceItems.push(item); | |
offsetY = offsetY - tileHeight * pixelSpacingY; | |
dimensionIndexValues++; | |
} | |
offsetX -= tileWidth * pixelSpacingX; | |
} | |
// Create dataset | |
const dataset = { | |
_vrMap: { | |
PixelData: 'OB', | |
}, | |
_meta: { | |
_vrMap: {}, | |
FileMetaInformationVersion: new Uint8Array([0, 1]).buffer, | |
MediaStorageSOPClassUID: WsiSopClassUid, | |
MediaStorageSOPInstanceUID: sopInstanceUid, | |
TransferSyntaxUID: JpegBaselineTransferSyntaxUid, | |
ImplementationClassUID: ImplementationUid, | |
}, | |
// Patient Module Attributes | |
PatientID: `${faker.random.alphaNumeric(6).toUpperCase()}`, | |
PatientName: `${faker.name.firstName().toUpperCase()}^${faker.name.lastName().toUpperCase()}`, | |
PatientBirthDate: birthDate, | |
PatientSex: 'O', | |
// General Study Module Attributes | |
StudyInstanceUID: studyInstanceUid, | |
StudyDate: date, | |
StudyTime: time, | |
StudyID: `${faker.random.alphaNumeric(3).toUpperCase()}`, | |
AccessionNumber: `${faker.random.alphaNumeric(6).toUpperCase()}`, | |
ReferringPhysicianName: `${faker.name.firstName().toUpperCase()}^${faker.name | |
.lastName() | |
.toUpperCase()}`, | |
// General Series Module Attributes | |
Modality: 'SM', | |
SeriesInstanceUID: seriesInstanceUid, | |
SeriesNumber: `${faker.datatype.number(100)}`, | |
// General Equipment Module Attributes | |
Manufacturer: `${faker.company.companyName().toUpperCase()}`, | |
ManufacturerModelName: `${faker.commerce.productName().toUpperCase()}`, | |
DeviceSerialNumber: `${faker.random.alphaNumeric(6).toUpperCase()}`, | |
SoftwareVersions: `${faker.random.alphaNumeric(1).toUpperCase()}.${faker.random | |
.alphaNumeric(2) | |
.toUpperCase()}`, | |
// General Image Module Attributes | |
InstanceNumber: '1', | |
AcquisitionDateTime: `${date}${time}`, | |
ContentDate: date, | |
ContentTime: time, | |
BurnedInAnnotation: 'NO', | |
// Image Pixel Module Attributes | |
PixelData: tileBuffers, | |
// Acquisition Context Module Attributes | |
AcquisitionContextSequence: [], | |
// VL Image Module Attributes | |
ImageType: ['DERIVED', 'PRIMARY', 'VOLUME', 'NONE'], | |
SamplesPerPixel: 3, | |
PhotometricInterpretation: 'YBR_FULL_422', | |
PlanarConfiguration: 0, | |
NumberOfFrames: `${tilesX * tilesY}`, | |
Rows: tileHeight, | |
Columns: tileWidth, | |
BitsAllocated: 8, | |
BitsStored: 8, | |
HighBit: 7, | |
PixelRepresentation: 0, | |
LossyImageCompression: '01', | |
LossyImageCompressionRatio: ['1', '1'], | |
LossyImageCompressionMethod: ['ISO_10918_1', 'ISO_10918_1'], | |
VolumetricProperties: 'VOLUME', | |
AcquisitionDuration: faker.datatype.number({ min: 100, max: 200 }), | |
ImagedVolumeWidth: tilesX * tileWidth * pixelSpacingX, | |
ImagedVolumeHeight: tilesY * tileHeight * pixelSpacingY, | |
ImagedVolumeDepth: sliceThickness, | |
TotalPixelMatrixColumns: tilesX * tileWidth, | |
TotalPixelMatrixRows: tilesY * tileHeight, | |
TotalPixelMatrixOriginSequence: [ | |
{ | |
XOffsetInSlideCoordinateSystem: `${slideOffsetX}`, | |
YOffsetInSlideCoordinateSystem: `${slideOffsetY}`, | |
}, | |
], | |
SpecimenLabelInImage: 'NO', | |
FocusMethod: 'MANUAL', | |
ExtendedDepthOfField: 'NO', | |
ImageOrientationSlide: ['0', '1', '0', '1', '0', '0'], | |
// SOP Common Module Attributes | |
SOPClassUID: WsiSopClassUid, | |
SOPInstanceUID: sopInstanceUid, | |
SpecificCharacterSet: 'ISO_IR 100', | |
// Specimen Module Attributes | |
ContainerIdentifier: containerAndSpecimenIdentifier, | |
IssuerOfTheContainerIdentifierSequence: [], | |
SpecimenDescriptionSequence: [ | |
{ | |
SpecimenIdentifier: containerAndSpecimenIdentifier, | |
SpecimenUID: specimenUid, | |
SpecimenPreparationSequence: [], | |
IssuerOfTheSpecimenIdentifierSequence: [], | |
}, | |
], | |
ContainerTypeCodeSequence: [], | |
// Optical Path Module Attributes | |
OpticalPathSequence: [ | |
{ | |
IlluminationTypeCodeSequence: [ | |
{ | |
CodeValue: '111741', | |
CodingSchemeDesignator: 'DCM', | |
CodeMeaning: 'Transmission illumination', | |
}, | |
], | |
ICCProfile: '', | |
OpticalPathIdentifier: opticalPathIdentifier, | |
OpticalPathDescription: '', | |
IlluminationColorCodeSequence: [ | |
{ | |
CodeValue: '414298005', | |
CodingSchemeDesignator: 'SCT', | |
CodeMeaning: 'Full Spectrum', | |
}, | |
], | |
}, | |
], | |
// Frame of Reference Module Attributes | |
FrameOfReferenceUID: frameOfReferenceUid, | |
PositionReferenceIndicator: 'SLIDE_CORNER', | |
// Shared Functional Groups Sequence Attribute | |
SharedFunctionalGroupsSequence: [ | |
{ | |
PixelMeasuresSequence: [ | |
{ | |
SliceThickness: `${sliceThickness}`, | |
PixelSpacing: [`${pixelSpacingX}`, `${pixelSpacingY}`], | |
}, | |
], | |
OpticalPathIdentificationSequence: [ | |
{ | |
OpticalPathIdentifier: opticalPathIdentifier, | |
}, | |
], | |
WholeSlideMicroscopyImageFrameTypeSequence: [ | |
{ | |
FrameType: ['DERIVED', 'PRIMARY', 'VOLUME', 'NONE'], | |
}, | |
], | |
}, | |
], | |
// Multi-frame Functional Groups Module | |
PerFrameFunctionalGroupsSequence: perFrameFunctionalGroupsSequenceItems, | |
// Multi-frame Dimension Module | |
DimensionOrganizationSequence: [{ DimensionOrganizationUID: dimensionOrganizationUid }], | |
DimensionIndexSequence: [ | |
{ | |
DimensionOrganizationUID: dimensionOrganizationUid, | |
DimensionIndexPointer: 0x0048021a, | |
DimensionDescriptionLabel: 'Plane Position (Slide)', | |
}, | |
], | |
}; | |
// Convert the JSON dataset into a DICOM part10 file | |
const denaturalizedMetaHeader = DicomMetaDictionary.denaturalizeDataset(dataset._meta); | |
const dicomDict = new DicomDict(denaturalizedMetaHeader); | |
dicomDict.dict = DicomMetaDictionary.denaturalizeDataset(dataset); | |
return Buffer.from(dicomDict.write({ fragmentMultiframe: false })); | |
} | |
/** | |
* Generates a DICOM WSI tile. | |
* @method | |
* @param {number} tileWidth - The tile width. | |
* @param {number} tileHeight - The tile height. | |
* @param {string} text - Text to write on tile. | |
* @returns {Buffer} The tile buffer. | |
*/ | |
function generateWsiTile(tileWidth, tileHeight, text) { | |
const canvas = createCanvas(tileWidth, tileHeight); | |
const context = canvas.getContext('2d'); | |
context.font = 'bold 50pt Arial'; | |
context.textAlign = 'center'; | |
context.textBaseline = 'middle'; | |
context.fillStyle = '#fff'; | |
context.fillText(text, tileWidth / 2, tileHeight / 2); | |
context.strokeStyle = '#fff'; | |
context.setLineDash([3, 3]); | |
context.strokeRect(0, 0, tileWidth, tileHeight); | |
return canvas.toBuffer('image/jpeg', 1); | |
} | |
////////////////////////////////////////////////////////////////////////// | |
// Gather params | |
const tileWidth = parseInt(process.env.TILE_WIDTH) || 512; | |
const tileHeight = parseInt(process.env.TILE_HEIGHT) || 512; | |
const tilesX = parseInt(process.env.TILES_X) || 3; | |
const tilesY = parseInt(process.env.TILES_Y) || 3; | |
const sliceThickness = parseFloat(process.env.SLICE_THICKNESS) || 1.0; | |
const pixelSpacingX = parseFloat(process.env.PIXEL_SPACING_X) || 1.0; | |
const pixelSpacingY = parseFloat(process.env.PIXEL_SPACING_Y) || 1.0; | |
const slideOffsetX = parseFloat(process.env.SLIDE_OFFSET_X) || 0.0; | |
const slideOffsetY = parseFloat(process.env.SLIDE_OFFSET_Y) || 0.0; | |
const outFile = process.env.OUTPUT_FILE || 'wsi.dcm'; | |
////////////////////////////////////////////////////////////////////////// | |
// Generate WSI dataset and persist it to file | |
const wsiDatasetBuffer = generateWsiDataset( | |
tileWidth, | |
tileHeight, | |
tilesX, | |
tilesY, | |
sliceThickness, | |
pixelSpacingX, | |
pixelSpacingY, | |
slideOffsetX, | |
slideOffsetY | |
); | |
fs.writeFileSync(outFile, wsiDatasetBuffer); |
Hi @CloudWoR! There is a good chance the Carestream PACS to be configured not to accept lossy information, such as the JPEG Baseline datasets generated by this script. What do you mean with the "tag information cannot be read correctly"? The tags are not there or the values are "scrambled"? I don't believe that PixelsData needs further conversion.
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Hello, I used your method to successfully convert the jpeg image to a dcm file. However, when I tried to transfer the dcm file to pacs, Carestream's pacs system encountered an error message, indicating that TransferSyntaxUID is not supported. The dcm file can be loaded on other imaging workstations, but the tag information cannot be read correctly. Is it because PixelsData needs further conversion? Or do we need to process the jpeg images first?