Skip to content

Instantly share code, notes, and snippets.

@PantelisGeorgiadis
Last active March 19, 2024 13:18
Show Gist options
  • Save PantelisGeorgiadis/fb9e305610c92633fd93fcc3e973d8a2 to your computer and use it in GitHub Desktop.
Save PantelisGeorgiadis/fb9e305610c92633fd93fcc3e973d8a2 to your computer and use it in GitHub Desktop.
DICOM Whole Slide Imaging dataset generator for Node.js
// 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);
@CloudWoR
Copy link

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?

@PantelisGeorgiadis
Copy link
Author

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