Skip to content

Commit

Permalink
feat(CSPS): add preliminary support for CSPS
Browse files Browse the repository at this point in the history
Add preliminary support for Color SoftCopy Presentation State (CSPS).
Referenced image is read as-is without any color transformations.
ICC color profile associated with the presentation state is provided
separately as a property value (base64 string) in the output JSON.
  • Loading branch information
jadh4v committed Jun 2, 2023
1 parent 01cfe1d commit 25c5419
Show file tree
Hide file tree
Showing 8 changed files with 167 additions and 50 deletions.
2 changes: 2 additions & 0 deletions include/itkPipeline.h
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@

// Parse options while allowing extra flags, not exiting with help flags, and clearning parse state after finished.
// Use this to parse some positionals or options before all options have been added.
// WARNING: It is best to only add the pre-parse options and read them through ITK_WASM_PRE_PARSE before adding other options,
// as you may face issues(EXCEPTIONS) generating bindings (bindgen), if you add "required" options/flags before the PRE_PARSE.
#define ITK_WASM_PRE_PARSE(pipeline) \
try { \
(pipeline).set_help_flag(); \
Expand Down
113 changes: 80 additions & 33 deletions packages/dicom/apply-presentation-state-to-image.cxx
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@
#include "dcmtk/dcmdata/cmdlnarg.h"
#include "dcmtk/ofstd/ofcmdln.h"
#include "dcmtk/ofstd/ofconapp.h"
#include "dcmtk/ofstd/ofvector.h"
#include "dcmtk/dcmdata/dcuid.h" /* for dcmtk version name */

#include "cpp-base64/base64.h"
Expand Down Expand Up @@ -145,6 +146,15 @@ static void dumpPresentationState(STD_NAMESPACE ostream &out, DVPresentationStat
doc.AddMember("CurrentVOIDescription", Value(StringRef(ps.getCurrentVOIDescription())), alloc);
}

// ICC color Profile
const OFVector<Uint8> iccProfile = ps.getICCProfile();
if (!iccProfile.empty())
{
// Encode the binary color profile data as a base64 string
std::string iccProfileAsString(iccProfile.begin(), iccProfile.end());
doc.AddMember("ICCProfile", Value(base64_encode(iccProfileAsString, false).c_str(), alloc), alloc);
}

doc.AddMember("Flip", Value(ps.getFlip()), alloc);
int rotation = 0;
switch (ps.getRotation())
Expand Down Expand Up @@ -468,10 +478,59 @@ static void dumpPresentationState(STD_NAMESPACE ostream &out, DVPresentationStat
out << buffer.GetString();
}

constexpr unsigned int Dimension = 2;
using GrayPixelType = uint8_t;
using GrayImageType = itk::Image<GrayPixelType, Dimension>;
using OutputGrayImageType = itk::wasm::OutputImage<GrayImageType>;

using ColorPixelType = itk::RGBPixel<uint8_t>;
using ColorImageType = itk::Image<ColorPixelType, Dimension>;
using OutputColorImageType = itk::wasm::OutputImage<ColorImageType>;

template<typename OutputImageType, typename PixelType, unsigned int Dim>
int GenerateOutputImage(OutputImageType& outputImage, const unsigned long width, const unsigned long height, const std::array<double, 2>& pixelSpacing, const void* pixelData)
{
using ImportFilterType = itk::ImportImageFilter<PixelType, Dim>;
auto importFilter = itk::ImportImageFilter<PixelType, Dim>::New();
typename ImportFilterType::SizeType size;
size[0] = width;
size[1] = height;

typename itk::ImportImageFilter<PixelType, Dim>::IndexType start;
start.Fill(0);

typename itk::ImportImageFilter<PixelType, Dim>::RegionType region;
region.SetIndex(start);
region.SetSize(size);
importFilter->SetRegion(region);

const itk::SpacePrecisionType origin[Dim] = { 0.0, 0.0 };
importFilter->SetOrigin(origin);
const itk::SpacePrecisionType spacing[Dim] = { pixelSpacing[0], pixelSpacing[1] };
importFilter->SetSpacing(spacing);
const unsigned int numberOfPixels = size[0] * size[1];
// ColorImageType::Internal
importFilter->SetImportPointer((PixelType*)pixelData, numberOfPixels, false);
importFilter->Update();

// set as output image
outputImage.Set(importFilter->GetOutput());
return EXIT_SUCCESS;
}

template int GenerateOutputImage<OutputGrayImageType, GrayPixelType, 2U>(OutputGrayImageType&, const unsigned long, const unsigned long, const std::array<double, 2>&, const void*);
template int GenerateOutputImage<OutputColorImageType, ColorPixelType, 2U>(OutputColorImageType&, const unsigned long, const unsigned long, const std::array<double, 2>&, const void*);

int main(int argc, char *argv[])
{
itk::wasm::Pipeline pipeline("apply-presentation-state-to-image", "Apply a presentation state to a given DICOM image and render output as pgm bitmap or dicom file.", argc, argv);
itk::wasm::Pipeline pipeline("apply-presentation-state-to-image", "Apply a presentation state to a given DICOM image and render output as bitmap, or dicom file.", argc, argv);

// Expecting color output
bool colorOutput{false};
pipeline.add_flag("--color-output", colorOutput, "output image as RGB (default: false)");

// pre-parse command line to determine if we need color or gray output image
ITK_WASM_PRE_PARSE(pipeline);

// Inputs
std::string imageIn;
Expand All @@ -485,14 +544,6 @@ int main(int argc, char *argv[])
itk::wasm::OutputTextStream pstateOutStream;
pipeline.add_option("presentation-state-out-stream", pstateOutStream, "Output overlay information")->type_name("OUTPUT_JSON");

// Processed output image
constexpr unsigned int Dimension = 2;
using PixelType = unsigned char;
using ImageType = itk::Image<PixelType, Dimension>;
using OutputImageType = itk::wasm::OutputImage<ImageType>;
OutputImageType outputImage;
pipeline.add_option("output-image", outputImage, "Output image")->type_name("OUTPUT_IMAGE");

// Parameters
std::string configFile;
pipeline.add_option("--config-file", configFile, "filename: string. Process using settings from configuration file");
Expand All @@ -506,6 +557,18 @@ int main(int argc, char *argv[])
bool noBitmapOutput{false};
pipeline.add_flag("--no-bitmap-output", noBitmapOutput, "Do not get resulting image as bitmap output stream.");

// Define output image and bind to CLI option
OutputGrayImageType outputGrayImage;
OutputColorImageType outputColorImage;
if (colorOutput)
{
pipeline.add_option("output-image", outputColorImage, "Output image")->type_name("OUTPUT_IMAGE");
}
else
{
pipeline.add_option("output-image", outputGrayImage, "Output image")->type_name("OUTPUT_IMAGE");
}

// DICOM output is currently not supported.
// bool outputFormatPGM{true};
// pipeline.add_flag("--pgm", outputFormatPGM, "save image as PGM (default)");
Expand Down Expand Up @@ -585,30 +648,14 @@ int main(int argc, char *argv[])
}
else
{
using ImportFilterType = itk::ImportImageFilter<PixelType, Dimension>;
auto importFilter = ImportFilterType::New();
ImportFilterType::SizeType size;
size[0] = width;
size[1] = height;

ImportFilterType::IndexType start;
start.Fill(0);

ImportFilterType::RegionType region;
region.SetIndex(start);
region.SetSize(size);
importFilter->SetRegion(region);

const itk::SpacePrecisionType origin[Dimension] = { 0.0, 0.0 };
importFilter->SetOrigin(origin);
const itk::SpacePrecisionType spacing[Dimension] = { pixelSpacing[0], pixelSpacing[1] };
importFilter->SetSpacing(spacing);
const unsigned int numberOfPixels = size[0] * size[1];
importFilter->SetImportPointer((PixelType*)pixelData, numberOfPixels, false);
importFilter->Update();

// set as output image
outputImage.Set(importFilter->GetOutput());
if (colorOutput)
{
return GenerateOutputImage<OutputColorImageType, ColorPixelType, 2U>(outputColorImage, width, height, pixelSpacing, pixelData);
}
else
{
return GenerateOutputImage<OutputGrayImageType, GrayPixelType, 2U>(outputGrayImage, width, height, pixelSpacing, pixelData);
}
}
}
else
Expand Down
2 changes: 1 addition & 1 deletion packages/dicom/typescript/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
"author": "",
"license": "Apache-2.0",
"dependencies": {
"itk-wasm": "^1.0.0-b.101"
"itk-wasm": "../../.."
},
"devDependencies": {
"@rollup/plugin-commonjs": "^24.0.0",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import ApplyPresentationStateToImageNodeResult from './apply-presentation-state-
import path from 'path'

/**
* Apply a presentation state to a given DICOM image and render output as pgm bitmap or dicom file.
* Apply a presentation state to a given DICOM image and render output as bitmap, or dicom file.
*
* @param {BinaryFile} imageIn - Input DICOM file
* @param {BinaryFile} presentationStateFile - Process using presentation state file
Expand Down Expand Up @@ -48,6 +48,9 @@ async function applyPresentationStateToImageNode(
args.push('1')
// Options
args.push('--memory-io')
if (typeof options.colorOutput !== "undefined") {
args.push('--color-output')
}
if (typeof options.configFile !== "undefined") {
args.push('--config-file', options.configFile.toString())
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
interface ApplyPresentationStateToImageOptions {
/** output image as RGB (default: false) */
colorOutput?: boolean

/** filename: string. Process using settings from configuration file */
configFile?: string

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import { getPipelinesBaseUrl } from './pipelines-base-url.js'
import { getPipelineWorkerUrl } from './pipeline-worker-url.js'

/**
* Apply a presentation state to a given DICOM image and render output as pgm bitmap or dicom file.
* Apply a presentation state to a given DICOM image and render output as bitmap, or dicom file.
*
* @param {BinaryFile} imageIn - Input DICOM file
* @param {BinaryFile} presentationStateFile - Process using presentation state file
Expand Down Expand Up @@ -52,6 +52,9 @@ async function applyPresentationStateToImage(
args.push('1')
// Options
args.push('--memory-io')
if (typeof options.colorOutput !== "undefined") {
args.push('--color-output')
}
if (typeof options.configFile !== "undefined") {
args.push('--config-file', options.configFile.toString())
}
Expand Down
81 changes: 70 additions & 11 deletions packages/dicom/typescript/test/node.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,13 @@ import {
return (a.length === b.length && a.every((val, idx) => val === b[idx]))
}

const testPathPrefix = '../test/data/input/';
const baselinePathPrefix = '../test/data/baseline/';

test('structuredReportToText', async t => {

const fileName = '88.33-comprehensive-SR.dcm'
const testFilePath = `../test/data/input/${fileName}`
const testFilePath = testPathPrefix + fileName

const dicomFileBuffer = fs.readFileSync(testFilePath)
const dicomFile = new Uint8Array(dicomFileBuffer)
Expand All @@ -31,7 +34,7 @@ test('structuredReportToText', async t => {
test('structuredReportToHtml', async t => {

const fileName = '88.33-comprehensive-SR.dcm'
const testFilePath = `../test/data/input/${fileName}`
const testFilePath = testPathPrefix + fileName

const dicomFileBuffer = fs.readFileSync(testFilePath)
const dicomFile = new Uint8Array(dicomFileBuffer)
Expand All @@ -57,7 +60,7 @@ test('structuredReportToHtml', async t => {
test('read Radiation Dose SR', async t => {

const fileName = '88.67-radiation-dose-SR.dcm'
const testFilePath = `../test/data/input/${fileName}`
const testFilePath = testPathPrefix + fileName

const dicomFileBuffer = fs.readFileSync(testFilePath)
const dicomFile = new Uint8Array(dicomFileBuffer)
Expand All @@ -71,7 +74,7 @@ test('read Radiation Dose SR', async t => {
test('readDicomEncapsulatedPdfNode', async t => {

const fileName = '104.1-SR-printed-to-pdf.dcm'
const testFilePath = `../test/data/input/${fileName}`
const testFilePath = testPathPrefix + fileName
const dicomFileBuffer = fs.readFileSync(testFilePath)
const dicomFile = new Uint8Array(dicomFileBuffer)
const { pdfBinaryOutput: outputBinaryStream } = await readDicomEncapsulatedPdfNode({ data: dicomFile, path: fileName })
Expand All @@ -82,7 +85,7 @@ test('readDicomEncapsulatedPdfNode', async t => {
test('read Key Object Selection SR', async t => {

const fileName = '88.59-KeyObjectSelection-SR.dcm'
const testFilePath = `../test/data/input/${fileName}`
const testFilePath = testPathPrefix + fileName
const dicomFileBuffer = fs.readFileSync(testFilePath)
const dicomFile = new Uint8Array(dicomFileBuffer)

Expand All @@ -98,7 +101,7 @@ test('read Key Object Selection SR', async t => {
t.assert(outputText.includes(`<link rel="stylesheet" type="text/css" href="https://css-host/dir/subdir/my-first-style.css">`))

const cssfileName = 'test-style.css'
const testCssFilePath = `../test/data/input/${cssfileName}`
const testCssFilePath = testPathPrefix + cssfileName
const cssFileBuffer = fs.readFileSync(testCssFilePath)

const { outputText: outputWithCSSFile } = await structuredReportToHtmlNode(
Expand All @@ -116,17 +119,21 @@ test('Apply presentation state to dicom image.', async t => {

// Read the input image file
const inputFile = 'gsps-pstate-test-input-image.dcm'
const inputFilePath = `../test/data/input/${inputFile}`
const inputFilePath = testPathPrefix + inputFile
const dicomFileBuffer = fs.readFileSync(inputFilePath)
const inputImage = new Uint8Array(dicomFileBuffer)

// Read the presentation state file (that references the above image internally using its SOPInstanceUID).
const pstateFile = 'gsps-pstate-test-input-pstate.dcm'
const pstateFilePath = `../test/data/input/${pstateFile}`
const pstateFilePath = testPathPrefix + pstateFile
const pstateFileBuffer = fs.readFileSync(pstateFilePath)
const inputPState = new Uint8Array(pstateFileBuffer)

const { presentationStateOutStream: pstateJsonOut, outputImage } = await applyPresentationStateToImageNode({ data: inputImage, path: inputFile }, { data: inputPState, path: pstateFile })
const { presentationStateOutStream: pstateJsonOut, outputImage } = await applyPresentationStateToImageNode(
{ data: inputImage, path: inputFile },
{ data: inputPState, path: pstateFile }
)


t.assert(pstateJsonOut != null)
t.assert(outputImage != null)
Expand All @@ -142,21 +149,73 @@ test('Apply presentation state to dicom image.', async t => {
t.assert(arrayEquals(outputImage.size, [512, 512]))

const baselineJsonFile = 'gsps-pstate-baseline.json'
const baselineJsonFilePath = `../test/data/baseline/${baselineJsonFile}`
const baselineJsonFilePath = baselinePathPrefix + baselineJsonFile
const baselineJsonFileBuffer = fs.readFileSync(baselineJsonFilePath)
// the slice operation removes the last EOF char from the baseline file.
const baselineJsonString = baselineJsonFileBuffer.toString().slice(0, -1)
const baselineJsonObject = JSON.parse(baselineJsonString)

t.assert(baselineJsonObject.PresentationLabel === pstateJsonOut.PresentationLabel)
t.assert(baselineJsonObject.PresentationSizeMode === pstateJsonOut.PresentationSizeMode)
t.assert(baselineJsonObject.toString() === pstateJsonOut.toString())

const baselineImage = 'gsps-pstate-image-baseline.pgm'
const baselineImageFilePath = `../test/data/baseline/${baselineImage}`
const baselineImageFilePath = baselinePathPrefix + baselineImage
const baselineImageFileBuffer = fs.readFileSync(baselineImageFilePath)
// slice to get only the pixel buffer from the baseline image (pgm file)
const baselinePixels = baselineImageFileBuffer.slice(15)
t.assert(baselinePixels.length === outputImage.data.length)
t.assert(Buffer.compare(baselinePixels, outputImage.data) === 0)

})

test('Apply color presentation state (CSPS) to a color dicom image.', async t => {

// Read the input image file
const inputFile = 'csps-input-image.dcm'
const inputFilePath = testPathPrefix + inputFile
const dicomFileBuffer = fs.readFileSync(inputFilePath)
const inputImage = new Uint8Array(dicomFileBuffer)

// Read the presentation state file (that references the above image internally using its SOPInstanceUID).
const pstateFile = 'csps-input-pstate.dcm'
const pstateFilePath = testPathPrefix + pstateFile
const pstateFileBuffer = fs.readFileSync(pstateFilePath)
const inputPState = new Uint8Array(pstateFileBuffer)

const { presentationStateOutStream: pstateJsonOut, outputImage } = await applyPresentationStateToImageNode(
{ data: inputImage, path: inputFile }, { data: inputPState, path: pstateFile },
{ frame: 1, colorOutput: true }
)

t.assert(pstateJsonOut != null)
t.assert(outputImage != null)
t.assert(outputImage.imageType.dimension === 2)
t.assert(outputImage.imageType.componentType === 'uint8')
t.assert(outputImage.imageType.pixelType === 'RGB')
t.assert(outputImage.imageType.components === 3)

t.assert(arrayEquals(outputImage.origin, [0, 0]))
t.assert(arrayEquals(outputImage.spacing, [0.683, 0.683]))
t.assert(arrayEquals(outputImage.direction, [1, 0, 0, 1]))
t.assert(arrayEquals(outputImage.size, [768, 1024]))

const baselineJsonFile = 'csps-pstate-baseline.json'
const baselineJsonFilePath = baselinePathPrefix + baselineJsonFile
const baselineJsonFileBuffer = fs.readFileSync(baselineJsonFilePath)
// the slice operation removes the last EOF char from the baseline file.
const baselineJsonString = baselineJsonFileBuffer.toString().slice(0, -1)
const baselineJsonObject = JSON.parse(baselineJsonString)

t.assert(baselineJsonObject.PresentationLabel === pstateJsonOut.PresentationLabel)
t.assert(baselineJsonObject.PresentationSizeMode === pstateJsonOut.PresentationSizeMode)
t.assert(baselineJsonObject.toString() === pstateJsonOut.toString())

const baselineImage = 'csps-output-image-baseline.bmp'
const baselineImageFilePath = baselinePathPrefix + baselineImage
const baselineImageFileBuffer = fs.readFileSync(baselineImageFilePath)
// Slice to get only the pixel buffer from the baseline image (BMP file has 54 bytes of header data before pixel buffer begins).
const baselinePixels = baselineImageFileBuffer.slice(54)
t.assert(baselinePixels.length === outputImage.data.length)
t.assert(Buffer.compare(baselinePixels, outputImage.data) === 0)
})
Loading

0 comments on commit 25c5419

Please sign in to comment.