diff --git a/karma.conf.js b/karma.conf.js index 5a63888cc5..a51d8ddacc 100644 --- a/karma.conf.js +++ b/karma.conf.js @@ -50,7 +50,9 @@ if (!process.env.CHROME_BIN) { * karma-webpack config * https://github.com/codymikol/karma-webpack?tab=readme-ov-file#default-webpack-configuration */ -const outputPath = path.join(os.tmpdir(), '_karma_webpack_') + Math.floor(Math.random() * 1000000) +const outputPath = + path.join(os.tmpdir(), '_karma_webpack_') + + Math.floor(Math.random() * 1000000); const forceCompat = process.env.FORCE_COMPAT === 'true'; const forceCpuRendering = process.env.FORCE_CPU_RENDERING === 'true'; const grepPattern = process.env.KARMA_GREP; @@ -108,21 +110,30 @@ module.exports = function (config) { ], frameworks: ['jasmine', 'webpack'], files: [ - ...(process.env.KARMA_PACKAGE === 'core' ? [] : ['packages/tools/test/**/*_test.js']), - ...(process.env.KARMA_PACKAGE === 'tools' ? [] : ['packages/core/test/**/*_test.js']), + ...(process.env.KARMA_PACKAGE === 'core' + ? [] + : ['packages/tools/test/**/*_test.js']), + ...(process.env.KARMA_PACKAGE === 'tools' + ? [] + : ['packages/core/test/**/*_test.js']), + // Named individually rather than globbed: the rest of + // packages/dicomImageLoader/test predates the move to jasmine and still + // uses mocha's before()/chai, so a glob would switch on suites that + // cannot run. + 'packages/dicomImageLoader/test/decoders_test.ts', // Serve dicomImageLoad test images { pattern: 'packages/dicomImageLoader/testImages/**/*', watched: false, included: false, - served: true + served: true, }, // Compat-mode baselines for dynamic comparison { pattern: 'karma-baselines/**/*.png', watched: false, included: false, - served: true + served: true, }, /** * Required to allow karma to load wasm and worker files built via webpack. @@ -132,8 +143,8 @@ module.exports = function (config) { pattern: `${outputPath}/**/*`, included: false, served: true, - watched: false - } + watched: false, + }, ], proxies: { '/testImages/': '/base/packages/dicomImageLoader/testImages/', @@ -142,6 +153,7 @@ module.exports = function (config) { preprocessors: { 'packages/core/test/**/*_test.js': ['webpack'], 'packages/tools/test/**/*_test.js': ['webpack'], + 'packages/dicomImageLoader/test/decoders_test.ts': ['webpack'], }, coverageReporter: { type: 'html', @@ -208,7 +220,7 @@ module.exports = function (config) { ], }, experiments: { - asyncWebAssembly: true + asyncWebAssembly: true, }, resolve: { extensions: ['.ts', '.tsx', '.js', '.jsx'], @@ -223,8 +235,12 @@ module.exports = function (config) { alias: { '@cornerstonejs/core': path.resolve('packages/core/src/index'), '@cornerstonejs/tools': path.resolve('packages/tools/src/index'), - '@cornerstonejs/dicom-image-loader': path.resolve('packages/dicomImageLoader/src/index'), - '@cornerstonejs/metadata': path.resolve('packages/metadata/src/index'), + '@cornerstonejs/dicom-image-loader': path.resolve( + 'packages/dicomImageLoader/src/index' + ), + '@cornerstonejs/metadata': path.resolve( + 'packages/metadata/src/index' + ), '@cornerstonejs/utils': path.resolve('packages/utils/src/index'), }, }, diff --git a/karma.decoders.conf.js b/karma.decoders.conf.js new file mode 100644 index 0000000000..5dc41fd946 --- /dev/null +++ b/karma.decoders.conf.js @@ -0,0 +1,133 @@ +/* eslint-disable */ +// @ts-check + +/** + * Karma configuration for the transfer syntax decode tests only. + * + * `karma.conf.js` runs every browser test in the repository, and a full run + * takes several minutes. This configuration runs + * `packages/dicomImageLoader/test/decoders_test.ts` alone, so that a person who + * works on a codec gets an answer in about one minute: + * + * pnpm test:decoders + * + * The configuration also lets you replace the JPEG Lossless decoder with a + * different build of that decoder. `packages/dicomImageLoader` depends on + * `@cornerstonejs/jpeg-lossless-decoder-js` directly, so a fix in that decoder + * reaches cornerstone3D through a version bump only. To test a fix before its + * release, point this configuration at the build that holds the fix: + * + * pnpm test:decoders --jpeg-lossless-build ../codecs/packages/dicom-codec/src/vendor/jpeg-lossless-decoder-js/lossless.cjs + * + * The environment variable `JPEG_LOSSLESS_BUILD` does the same thing. Use the + * environment variable in a shell script, and use the option on the command + * line. The option wins if you give both. The path can be relative to the root + * of the repository, or absolute. The build must be a CommonJS module or an ES + * module that exports `Decoder` - the published package's own entry point, + * `release/cjs/lossless.cjs`, is one. + * + * Give no override, and the tests use the + * `@cornerstonejs/jpeg-lossless-decoder-js` version that `pnpm install` + * resolved. That is what CI tests. + */ + +const path = require('path'); +const base = require('./karma.conf.js'); + +/** + * Reads the path of the decoder build from `--jpeg-lossless-build ` or + * from the environment variable `JPEG_LOSSLESS_BUILD`. + * + * @returns { string | undefined } the path, or undefined for no override + */ +function readJpegLosslessBuild() { + const flag = '--jpeg-lossless-build'; + const index = process.argv.indexOf(flag); + + if (index !== -1) { + const value = process.argv[index + 1]; + + if (!value || value.startsWith('--')) { + throw new Error(`${flag} needs the path of a decoder build.`); + } + + return value; + } + + const inlineArgument = process.argv.find((argument) => + argument.startsWith(`${flag}=`) + ); + + if (inlineArgument) { + return inlineArgument.slice(flag.length + 1); + } + + return process.env.JPEG_LOSSLESS_BUILD || undefined; +} + +/** + * @param { import("karma").Config } config - karma config + */ +module.exports = function (config) { + base(config); + + const jpegLosslessBuild = readJpegLosslessBuild(); + + if (jpegLosslessBuild) { + console.log( + `[karma.decoders.conf.js] @cornerstonejs/jpeg-lossless-decoder-js resolves to ${path.resolve( + jpegLosslessBuild + )}` + ); + } + + config.set({ + files: [ + 'packages/dicomImageLoader/test/decoders_test.ts', + /** + * `karma.conf.js` computes the served asset patterns, and one of those + * patterns holds a random webpack output directory. Keep the patterns + * that the base configuration computed. Every entry that is an object is + * a served asset pattern, and every entry that is a string is a spec + * glob that this configuration replaces. + */ + ...config.files.filter((file) => typeof file === 'object'), + ], + /** + * The suite decodes through five wasm codecs and a web worker, so the first + * spec waits for a long compile. The default timeouts of karma stop the + * browser before that compile ends. + */ + browserNoActivityTimeout: 300000, + browserDisconnectTimeout: 60000, + captureTimeout: 120000, + browsers: ['ChromeHeadlessNoSandbox'], + singleRun: true, + reporters: ['spec'], + plugins: [ + 'karma-webpack', + 'karma-jasmine', + 'karma-chrome-launcher', + 'karma-spec-reporter', + ], + webpack: { + ...config.webpack, + resolve: { + ...config.webpack.resolve, + alias: { + ...config.webpack.resolve.alias, + ...(jpegLosslessBuild + ? { + // Must name the same specifier decodeJPEGLossless.ts imports. + // An alias key that no import names matches nothing, and + // webpack reports no error for it, so the override would + // silently test the installed decoder instead of this build. + '@cornerstonejs/jpeg-lossless-decoder-js': + path.resolve(jpegLosslessBuild), + } + : {}), + }, + }, + }, + }); +}; diff --git a/package.json b/package.json index 9d51948661..414eb31ea4 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "test:unit:no-coverage": "jest", "rebuild:canvas": "npm rebuild canvas", "test:debug": "karma start ./karma.conf.js --browsers Chrome --no-single-run", + "test:decoders": "karma start ./karma.decoders.conf.js", "lint-staged": "lint-staged", "lint": "oxlint packages/**/src --quiet", "webpack:watch": "pnpm -r --filter=!docs run webpack:watch" diff --git a/packages/dicomImageLoader/package.json b/packages/dicomImageLoader/package.json index 3e94d55303..e9c3c998e8 100644 --- a/packages/dicomImageLoader/package.json +++ b/packages/dicomImageLoader/package.json @@ -111,13 +111,14 @@ "prepublishOnly": "pnpm run build:loader" }, "dependencies": { - "@cornerstonejs/codec-charls": "1.2.6", - "@cornerstonejs/codec-libjpeg-turbo-8bit": "1.2.5", - "@cornerstonejs/codec-openjpeg": "1.3.3", - "@cornerstonejs/codec-openjph": "2.4.10", + "@cornerstonejs/codec-charls": "1.2.7", + "@cornerstonejs/codec-libjpeg-turbo-8bit": "1.2.7", + "@cornerstonejs/codec-libjxl": "1.1.1", + "@cornerstonejs/codec-openjpeg": "1.3.6", + "@cornerstonejs/codec-openjph": "2.4.11", + "@cornerstonejs/jpeg-lossless-decoder-js": "2.2.1", "comlink": "4.4.2", "dicom-parser": "1.8.21", - "jpeg-lossless-decoder-js": "2.1.2", "pako": "2.1.0", "uuid": "9.0.1" }, diff --git a/packages/dicomImageLoader/src/__tests__/encapsulatedAndDeflatedFrames.spec.ts b/packages/dicomImageLoader/src/__tests__/encapsulatedAndDeflatedFrames.spec.ts new file mode 100644 index 0000000000..694e12036b --- /dev/null +++ b/packages/dicomImageLoader/src/__tests__/encapsulatedAndDeflatedFrames.spec.ts @@ -0,0 +1,148 @@ +import pako from 'pako'; + +import decodeEncapsulatedUncompressed from '../shared/decoders/decodeEncapsulatedUncompressed'; +import decodeDeflatedFrame from '../shared/decoders/decodeDeflatedFrame'; +import { + nativeFrameLength, + trimToNativeFrame, +} from '../shared/decoders/nativeFrameBytes'; + +/** A minimal image frame, enough for the native-length maths and decoding. */ +function frame(overrides = {}) { + return { + rows: 2, + columns: 2, + samplesPerPixel: 1, + bitsAllocated: 16, + pixelRepresentation: 0, + ...overrides, + } as never; +} + +/** Little endian bytes for a 2x2 16 bit frame. */ +function bytes16(values: number[]) { + const out = new Uint8Array(values.length * 2); + new DataView(out.buffer).setUint16(0, values[0], true); + values.forEach((v, i) => new DataView(out.buffer).setUint16(i * 2, v, true)); + return out; +} + +describe('nativeFrameLength', () => { + it('sizes a 16 bit grayscale frame', () => { + expect(nativeFrameLength(frame({ rows: 4, columns: 8 }))).toBe(64); + }); + + it('sizes an 8 bit RGB frame by samples per pixel', () => { + expect( + nativeFrameLength( + frame({ rows: 4, columns: 8, samplesPerPixel: 3, bitsAllocated: 8 }) + ) + ).toBe(96); + }); + + it('rounds a bit-packed frame up to whole bytes', () => { + // 3x3 single bit samples is 9 bits, which occupies 2 bytes. + expect( + nativeFrameLength(frame({ rows: 3, columns: 3, bitsAllocated: 1 })) + ).toBe(2); + }); +}); + +describe('trimToNativeFrame', () => { + it('returns the frame unchanged when it is exactly the right size', () => { + const data = new Uint8Array(8); + expect(trimToNativeFrame(frame(), data, 'test')).toBe(data); + }); + + it('drops the even-length padding byte an encapsulated fragment carries', () => { + const padded = new Uint8Array(9); + const trimmed = trimToNativeFrame(frame(), padded, 'test'); + expect(trimmed.length).toBe(8); + }); + + it('throws on a frame shorter than its pixel data rather than rendering it', () => { + expect(() => trimToNativeFrame(frame(), new Uint8Array(6), 'ctx')).toThrow( + /ctx: frame is 6 bytes, expected 8/ + ); + }); +}); + +describe('decodeEncapsulatedUncompressed (1.2.840.10008.1.2.1.98)', () => { + it('reads the fragment as native little endian pixel data', async () => { + const imageFrame = frame(); + const result = await decodeEncapsulatedUncompressed( + imageFrame, + bytes16([1, 2, 3, 4]) + ); + + expect(Array.from(result.pixelData)).toEqual([1, 2, 3, 4]); + }); + + it('ignores the pad byte on an odd length frame', async () => { + // 3 single-byte samples pad to 4 bytes in the fragment. + const imageFrame = frame({ + rows: 1, + columns: 3, + bitsAllocated: 8, + }); + const padded = new Uint8Array([10, 20, 30, 0]); + + const result = await decodeEncapsulatedUncompressed(imageFrame, padded); + + expect(Array.from(result.pixelData)).toEqual([10, 20, 30]); + }); + + it('reads signed pixel data according to pixelRepresentation', async () => { + const imageFrame = frame({ rows: 1, columns: 2, pixelRepresentation: 1 }); + const data = new Uint8Array(4); + const view = new DataView(data.buffer); + view.setInt16(0, -300, true); + view.setInt16(2, 300, true); + + const result = await decodeEncapsulatedUncompressed(imageFrame, data); + + expect(Array.from(result.pixelData)).toEqual([-300, 300]); + }); +}); + +describe('decodeDeflatedFrame (1.2.840.10008.1.2.8.1)', () => { + it('inflates a raw deflate frame and reads it as little endian', async () => { + const imageFrame = frame(); + const deflated = pako.deflateRaw(bytes16([5, 6, 7, 8])); + + const result = await decodeDeflatedFrame(imageFrame, deflated); + + expect(Array.from(result.pixelData)).toEqual([5, 6, 7, 8]); + }); + + it('uses raw deflate, not the zlib wrapper', async () => { + // A zlib-wrapped stream of the same payload must not decode, otherwise the + // implementation is not following RFC 1951 as PS3.5 A.4.13 requires. + const zlibWrapped = pako.deflate(bytes16([5, 6, 7, 8])); + + await expect(decodeDeflatedFrame(frame(), zlibWrapped)).rejects.toThrow( + /could not inflate/ + ); + }); + + it('decodes a frame that is a view onto a larger buffer', async () => { + // Frames arrive as views into the encapsulated pixel data, not as buffers + // of their own, so the offset has to be respected. + const deflated = pako.deflateRaw(bytes16([9, 10, 11, 12])); + const backing = new Uint8Array(deflated.length + 16); + backing.set(deflated, 8); + const view = new Uint8Array(backing.buffer, 8, deflated.length); + + const result = await decodeDeflatedFrame(frame(), view); + + expect(Array.from(result.pixelData)).toEqual([9, 10, 11, 12]); + }); + + it('throws when the inflated frame is too short for the image', async () => { + const deflated = pako.deflateRaw(new Uint8Array(4)); + + await expect(decodeDeflatedFrame(frame(), deflated)).rejects.toThrow( + /frame is 4 bytes, expected 8/ + ); + }); +}); diff --git a/packages/dicomImageLoader/src/__tests__/getPixelData.spec.ts b/packages/dicomImageLoader/src/__tests__/getPixelData.spec.ts index 4b19f496bd..af4e34b5bf 100644 --- a/packages/dicomImageLoader/src/__tests__/getPixelData.spec.ts +++ b/packages/dicomImageLoader/src/__tests__/getPixelData.spec.ts @@ -56,9 +56,24 @@ describe('getPixelData', () => { ).toEqual(new Uint16Array([0, 65535])); }); + it('reads a 16 bit JPEG XL frame as signed on the override alone', () => { + // JPEG XL has no signed sample type at all, so its decoder always reports + // isSigned false and PixelRepresentation is the only thing that can say + // otherwise. Trusting the frame info here would render every signed JPEG XL + // image - CT among them - as large positive values. + expect( + getPixelData( + { bitsPerSample: 16, isSigned: false, componentCount: 1 }, + decodedBuffer([0x00, 0x80, 0xff, 0xff]), + true + ) + ).toEqual(new Int16Array([-32768, -1])); + }); + it('prefers the metadata signedness over the frame info when given', () => { - // JPEG-LS takes signedness from Pixel Representation rather than from the - // codestream, so an explicit override has to win in both directions. + // JPEG-LS and JPEG XL both take signedness from Pixel Representation rather + // than from the codestream, so an explicit override has to win in both + // directions. expect( getPixelData( { bitsPerSample: 8, isSigned: false, componentCount: 1 }, diff --git a/packages/dicomImageLoader/src/__tests__/scalingAndParsing.spec.ts b/packages/dicomImageLoader/src/__tests__/scalingAndParsing.spec.ts index 0aa7553ac2..af4772d694 100644 --- a/packages/dicomImageLoader/src/__tests__/scalingAndParsing.spec.ts +++ b/packages/dicomImageLoader/src/__tests__/scalingAndParsing.spec.ts @@ -286,7 +286,10 @@ describe('getTransferSyntaxForContentType', () => { 'image/jp2': '1.2.840.10008.1.2.4.90', 'image/jpx': '1.2.840.10008.1.2.4.92', 'image/jphc': '3.2.840.10008.1.2.4.96', - 'image/jxl': '1.2.840.10008.1.2.4.140', + // PS3.18 Table 8.7.3-5: image/jxl defaults to JPEG XL Lossless. The + // .140 this used to assert is not a JPEG XL UID at all. + 'image/jxl': '1.2.840.10008.1.2.4.110', + 'application/x-deflate': '1.2.840.10008.1.2.8.1', }; for (const [contentType, transferSyntax] of Object.entries(table)) { diff --git a/packages/dicomImageLoader/src/constants/transferSyntaxes.js b/packages/dicomImageLoader/src/constants/transferSyntaxes.js index b67de7fa5c..3b61ef1c86 100644 --- a/packages/dicomImageLoader/src/constants/transferSyntaxes.js +++ b/packages/dicomImageLoader/src/constants/transferSyntaxes.js @@ -1,6 +1,7 @@ const transferSyntaxes = { IMPLICIT_VR_LITTLE_ENDIAN: '1.2.840.10008.1.2', EXPLICIT_VR_LITTLE_ENDIAN: '1.2.840.10008.1.2.1', + ENCAPSULATED_UNCOMPRESSED_EXPLICIT_VR_LITTLE_ENDIAN: '1.2.840.10008.1.2.1.98', DEFLATED_EXPLICIT_VR_LITTLE_ENDIAN: '1.2.840.10008.1.2.1.99', EXPLICIT_VR_BIG_ENDIAN: '1.2.840.10008.1.2.2', JPEG_BASELINE_PROCESS_1: '1.2.840.10008.1.2.4.50', @@ -45,6 +46,11 @@ const transferSyntaxes = { MPEG4_AVC_H264_HIGH_PROFILE_FOR_3D_VIDEO: '1.2.840.10008.1.2.4.104', JPIP_LOSSLESS: '1.2.840.10008.1.2.4.96', JPIP_PART2_MULTICOMPONENT_IMAGE_COMPRESSION: '1.2.840.10008.1.2.4.97', + // JPEG XL, from PS3.5 A.4.12 (Supplement 232). Note these are the .110-.112 + // block, not the .140+ range some pre-ratification drafts used. + JPEG_XL_LOSSLESS: '1.2.840.10008.1.2.4.110', + JPEG_XL_JPEG_RECOMPRESSION: '1.2.840.10008.1.2.4.111', + JPEG_XL: '1.2.840.10008.1.2.4.112', RFC_2557_MIME_ENCAPSULATION: '1.2.840.10008.1.2.6.1', JPEG_XR_IMAGE_COMPRESSION: '1.2.840.10008.1.2.6.2', JPEG_2000_IMAGE_COMPRESSION_LOSSLESS_ONLY_RETIRED: '1.2.840.10008.1.2.4.90R', @@ -53,6 +59,7 @@ const transferSyntaxes = { '1.2.840.10008.1.2.4.92R', JPEG_2000_PART_2_MULTICOMPONENT_IMAGE_COMPRESSION_RETIRED: '1.2.840.10008.1.2.4.93R', + DEFLATED_IMAGE_FRAME_COMPRESSION: '1.2.840.10008.1.2.8.1', }; export default transferSyntaxes; diff --git a/packages/dicomImageLoader/src/decodeImageFrameWorker.js b/packages/dicomImageLoader/src/decodeImageFrameWorker.js index 4e84cd6a84..602bbfea8a 100644 --- a/packages/dicomImageLoader/src/decodeImageFrameWorker.js +++ b/packages/dicomImageLoader/src/decodeImageFrameWorker.js @@ -13,6 +13,9 @@ import decodeJPEGLossless from './shared/decoders/decodeJPEGLossless'; import decodeJPEGLS from './shared/decoders/decodeJPEGLS'; import decodeJPEG2000 from './shared/decoders/decodeJPEG2000'; import decodeHTJ2K from './shared/decoders/decodeHTJ2K'; +import decodeEncapsulatedUncompressed from './shared/decoders/decodeEncapsulatedUncompressed'; +import decodeDeflatedFrame from './shared/decoders/decodeDeflatedFrame'; +import decodeJPEGXL from './shared/decoders/decodeJPEGXL'; // Note that the scaling is pixel value scaling, which is applying a modality LUT import applyModalityLUT from './shared/scaling/scaleArray'; import { setWasmBasePathFromConfig } from './shared/wasmBasePath'; @@ -392,9 +395,19 @@ export async function decodeImageFrame( decodePromise = decodeBigEndian(imageFrame, pixelData); break; case '1.2.840.10008.1.2.1.99': - // Deflate transfer syntax (deflated by dicomParser) + // Deflated Explicit VR Little Endian - the whole data set is deflated, so + // dicomParser has already inflated it by the time a frame reaches here. decodePromise = decodeLittleEndian(imageFrame, pixelData); break; + case '1.2.840.10008.1.2.1.98': + // Encapsulated Uncompressed Explicit VR Little Endian + decodePromise = decodeEncapsulatedUncompressed(imageFrame, pixelData); + break; + case '1.2.840.10008.1.2.8.1': + // Deflated Image Frame Compression - unlike 1.99 above, each frame is + // deflated separately, so inflating is per frame and happens here. + decodePromise = decodeDeflatedFrame(imageFrame, pixelData); + break; case '1.2.840.10008.1.2.5': // RLE Lossless decodePromise = decodeRLE(imageFrame, pixelData); @@ -476,6 +489,21 @@ export async function decodeImageFrame( decodePromise = decodeHTJ2K(pixelData, opts); break; + case '1.2.840.10008.1.2.4.110': + case '1.2.840.10008.1.2.4.111': + case '1.2.840.10008.1.2.4.112': + // JPEG XL - lossless, JPEG recompression and general. All three read the + // same way; they differ in what the encoder was allowed to do. + opts = { + // JPEG XL carries no signed sample type, so signedness comes from + // PixelRepresentation rather than from the codestream. + signed: imageFrame.pixelRepresentation === 1, + bytesPerPixel: imageFrame.bitsAllocated <= 8 ? 1 : 2, + ...imageFrame, + }; + + decodePromise = decodeJPEGXL(pixelData, opts); + break; default: throw new Error(`no decoder for transfer syntax ${transferSyntax}`); } diff --git a/packages/dicomImageLoader/src/imageLoader/decodeImageFrame.ts b/packages/dicomImageLoader/src/imageLoader/decodeImageFrame.ts index 6b4388a229..3562c3bae1 100644 --- a/packages/dicomImageLoader/src/imageLoader/decodeImageFrame.ts +++ b/packages/dicomImageLoader/src/imageLoader/decodeImageFrame.ts @@ -83,7 +83,25 @@ function decodeImageFrame( decodeConfig ); case '1.2.840.10008.1.2.1.99': - // Deflate transfer syntax (deflated by dicomParser) + // Deflated Explicit VR Little Endian (inflated by dicomParser) + return processDecodeTask( + imageFrame, + transferSyntax, + pixelData, + options, + decodeConfig + ); + case '1.2.840.10008.1.2.1.98': + // Encapsulated Uncompressed Explicit VR Little Endian + return processDecodeTask( + imageFrame, + transferSyntax, + pixelData, + options, + decodeConfig + ); + case '1.2.840.10008.1.2.8.1': + // Deflated Image Frame Compression return processDecodeTask( imageFrame, transferSyntax, @@ -195,6 +213,17 @@ function decodeImageFrame( options, decodeConfig ); + case '1.2.840.10008.1.2.4.110': + case '1.2.840.10008.1.2.4.111': + case '1.2.840.10008.1.2.4.112': + // JPEG XL: lossless, JPEG recompression and general + return processDecodeTask( + imageFrame, + transferSyntax, + pixelData, + options, + decodeConfig + ); } /* Don't know if these work... diff --git a/packages/dicomImageLoader/src/imageLoader/wadors/loadImage.ts b/packages/dicomImageLoader/src/imageLoader/wadors/loadImage.ts index 8dc355dc21..d50fe5b24b 100644 --- a/packages/dicomImageLoader/src/imageLoader/wadors/loadImage.ts +++ b/packages/dicomImageLoader/src/imageLoader/wadors/loadImage.ts @@ -65,9 +65,13 @@ export function getTransferSyntaxForContentType(contentType: string): string { 'image/jll': '1.2.840.10008.1.2.4.70', 'image/jp2': '1.2.840.10008.1.2.4.90', 'image/jpx': '1.2.840.10008.1.2.4.92', + // image/jxl covers all three JPEG XL syntaxes; PS3.18 Table 8.7.3-5 makes + // JPEG XL Lossless the default when the response carries no + // transfer-syntax parameter to disambiguate. + 'image/jxl': '1.2.840.10008.1.2.4.110', + 'application/x-deflate': '1.2.840.10008.1.2.8.1', // Temporary types, until ratified by DICOM committed - TODO 'image/jphc': '3.2.840.10008.1.2.4.96', - 'image/jxl': '1.2.840.10008.1.2.4.140', }; if (params['transfer-syntax']) { diff --git a/packages/dicomImageLoader/src/imageLoader/wadouri/getEncapsulatedImageFrame.ts b/packages/dicomImageLoader/src/imageLoader/wadouri/getEncapsulatedImageFrame.ts index 106143e5d2..9be1580d0c 100644 --- a/packages/dicomImageLoader/src/imageLoader/wadouri/getEncapsulatedImageFrame.ts +++ b/packages/dicomImageLoader/src/imageLoader/wadouri/getEncapsulatedImageFrame.ts @@ -5,7 +5,12 @@ import * as dicomParser from 'dicom-parser'; */ function framesAreFragmented(dataSet: DataSet) { - const numberOfFrames = dataSet.intString('x00280008'); + // NumberOfFrames is absent on single frame images, where it defaults to 1. + // Without this, a single frame image read `undefined !== 1` as "fragmented" + // and fell through to the JPEG marker scan below - which finds nothing in a + // syntax that is not JPEG, such as Encapsulated Uncompressed, Deflated Image + // Frame Compression or JPEG XL. + const numberOfFrames = dataSet.intString('x00280008') || 1; const pixelDataElement = dataSet.elements.x7fe00010; return numberOfFrames !== pixelDataElement.fragments.length; diff --git a/packages/dicomImageLoader/src/shared/decoders/decodeDeflatedFrame.ts b/packages/dicomImageLoader/src/shared/decoders/decodeDeflatedFrame.ts new file mode 100644 index 0000000000..7931b45d2f --- /dev/null +++ b/packages/dicomImageLoader/src/shared/decoders/decodeDeflatedFrame.ts @@ -0,0 +1,47 @@ +import type { ByteArray } from 'dicom-parser'; +import type { Types } from '@cornerstonejs/core'; +import pako from 'pako'; + +import decodeLittleEndian from './decodeLittleEndian'; +import { trimToNativeFrame } from './nativeFrameBytes'; + +/** + * Decodes one frame of Deflated Image Frame Compression + * (1.2.840.10008.1.2.8.1). + * + * Each frame is separately compressed with the raw Deflate algorithm of + * RFC 1951 and encapsulated as a single fragment (PS3.5 A.4.13). Raw here is + * load bearing: the frame carries no zlib header or Adler-32 trailer, so it is + * `inflateRaw` rather than `inflate`. This is the same framing the whole- + * dataset Deflated Explicit VR Little Endian syntax uses, applied per frame + * instead of once over the data set - which is why that syntax is handled by + * dicomParser at parse time and never reaches here. + * + * Inflating yields the frame's native uncompressed pixel data, so the result + * is interpreted exactly as Explicit VR Little Endian would be. + */ +async function decodeDeflatedFrame( + imageFrame: Types.IImageFrame, + pixelData: ByteArray +): Promise { + let inflated: Uint8Array; + + try { + inflated = pako.inflateRaw( + // pako wants a plain Uint8Array view; a dicom-parser ByteArray already is + // one, but may be a view onto a much larger buffer. + new Uint8Array(pixelData.buffer, pixelData.byteOffset, pixelData.length) + ); + } catch (error) { + throw new Error( + `decodeDeflatedFrame: could not inflate the frame (${error})` + ); + } + + return decodeLittleEndian( + imageFrame, + trimToNativeFrame(imageFrame, inflated, 'decodeDeflatedFrame') + ); +} + +export default decodeDeflatedFrame; diff --git a/packages/dicomImageLoader/src/shared/decoders/decodeEncapsulatedUncompressed.ts b/packages/dicomImageLoader/src/shared/decoders/decodeEncapsulatedUncompressed.ts new file mode 100644 index 0000000000..966da71f03 --- /dev/null +++ b/packages/dicomImageLoader/src/shared/decoders/decodeEncapsulatedUncompressed.ts @@ -0,0 +1,28 @@ +import type { ByteArray } from 'dicom-parser'; +import type { Types } from '@cornerstonejs/core'; + +import decodeLittleEndian from './decodeLittleEndian'; +import { trimToNativeFrame } from './nativeFrameBytes'; + +/** + * Decodes one frame of Encapsulated Uncompressed Explicit VR Little Endian + * (1.2.840.10008.1.2.1.98). + * + * Nothing is compressed here: the syntax exists so that uncompressed pixel + * data can use the encapsulated format, one frame per fragment, which makes a + * single frame addressable without reading the whole Pixel Data element + * (PS3.5 A.4.11). Each fragment holds that frame's native little endian pixel + * data, padded to an even length, so the only work is dropping the padding and + * interpreting the rest as Explicit VR Little Endian. + */ +async function decodeEncapsulatedUncompressed( + imageFrame: Types.IImageFrame, + pixelData: ByteArray +): Promise { + return decodeLittleEndian( + imageFrame, + trimToNativeFrame(imageFrame, pixelData, 'decodeEncapsulatedUncompressed') + ); +} + +export default decodeEncapsulatedUncompressed; diff --git a/packages/dicomImageLoader/src/shared/decoders/decodeJPEGLossless.ts b/packages/dicomImageLoader/src/shared/decoders/decodeJPEGLossless.ts index 667917b2c9..6dabc39ed0 100644 --- a/packages/dicomImageLoader/src/shared/decoders/decodeJPEGLossless.ts +++ b/packages/dicomImageLoader/src/shared/decoders/decodeJPEGLossless.ts @@ -17,7 +17,7 @@ export function initialize( } return new Promise((resolve, reject) => { - import('jpeg-lossless-decoder-js').then(({ Decoder }) => { + import('@cornerstonejs/jpeg-lossless-decoder-js').then(({ Decoder }) => { local.DecoderClass = Decoder; resolve(); }, reject); diff --git a/packages/dicomImageLoader/src/shared/decoders/decodeJPEGXL.ts b/packages/dicomImageLoader/src/shared/decoders/decodeJPEGXL.ts new file mode 100644 index 0000000000..8a64f69292 --- /dev/null +++ b/packages/dicomImageLoader/src/shared/decoders/decodeJPEGXL.ts @@ -0,0 +1,135 @@ +// @ts-ignore - the published codec ships no type declarations +import jpegxlFactory from '@cornerstonejs/codec-libjxl/decodewasmjs'; +// @ts-ignore +const jpegxlWasm = new URL( + '@cornerstonejs/codec-libjxl/decodewasm', + import.meta.url +); + +import type { Types } from '@cornerstonejs/core'; +import type { WebWorkerDecodeConfig } from '../../types'; +import getPixelData from './getPixelData'; +import { resolveWasmUrl, setWasmBasePathFromConfig } from '../wasmBasePath'; + +const local: { + codec; + decoder; + decodeConfig: WebWorkerDecodeConfig; +} = { + codec: undefined, + decoder: undefined, + decodeConfig: {} as WebWorkerDecodeConfig, +}; + +function getExceptionMessage(exception) { + // Emscripten hands a C++ exception to JS as a pointer when exception catching + // is compiled in, so the message has to be looked up rather than read. + return typeof exception === 'number' && local.codec?.getExceptionMessage + ? local.codec.getExceptionMessage(exception) + : exception; +} + +export function initialize( + decodeConfig?: WebWorkerDecodeConfig +): Promise { + local.decodeConfig = decodeConfig; + setWasmBasePathFromConfig(decodeConfig); + + if (local.codec) { + return Promise.resolve(); + } + + const jpegxlModule = jpegxlFactory({ + locateFile: (f) => { + if (f.endsWith('.wasm')) { + return resolveWasmUrl('jpegxlwasm_decode.wasm', jpegxlWasm); + } + + return f; + }, + }); + + return new Promise((resolve, reject) => { + jpegxlModule.then((instance) => { + local.codec = instance; + local.decoder = new instance.JpegXLDecoder(); + resolve(); + }, reject); + }); +} + +/** + * Decodes a JPEG XL frame - transfer syntaxes 1.2.840.10008.1.2.4.110 + * (lossless), .111 (JPEG recompression) and .112 (any mode). + * + * All three decode identically: the difference between them is what the encoder + * was allowed to do, not how the codestream is read, so one decoder covers the + * set (PS3.5 A.4.12). + * + * @param compressedImageFrame - one frame's codestream, which for JPEG XL is + * one fragment of the encapsulated pixel data + * @param imageInfo - the image frame, with `signed` taken from + * PixelRepresentation by the caller + */ +async function decodeAsync( + compressedImageFrame, + imageInfo +): Promise { + try { + await initialize(); + const decoder = local.decoder; + + // get pointer to the source/encoded bit stream buffer in WASM memory + // that can hold the encoded bitstream + const encodedBufferInWASM = decoder.getEncodedBuffer( + compressedImageFrame.length + ); + + // copy the encoded bitstream into WASM memory buffer + encodedBufferInWASM.set(compressedImageFrame); + + // decode it + decoder.decode(); + + // get information about the decoded image + const frameInfo = decoder.getFrameInfo(); + + // get the decoded pixels + const decodedPixelsInWASM = decoder.getDecodedBuffer(); + + const encodedImageInfo = { + columns: frameInfo.width, + rows: frameInfo.height, + bitsPerPixel: frameInfo.bitsPerSample, + signed: imageInfo.signed, + bytesPerPixel: imageInfo.bytesPerPixel, + componentsPerPixel: frameInfo.componentCount, + }; + + // JPEG XL has no signed sample type, so the decoder always reports + // isSigned false and the signedness has to come from PixelRepresentation + // in the data set - the same arrangement JPEG-LS uses. + const pixelData = getPixelData( + frameInfo, + decodedPixelsInWASM, + imageInfo.signed + ); + + const encodeOptions = { + frameInfo, + }; + + return { + ...imageInfo, + pixelData, + imageInfo: encodedImageInfo, + encodeOptions, + ...encodeOptions, + ...encodedImageInfo, + }; + } catch (error) { + throw getExceptionMessage(error); + } +} + +export default decodeAsync; diff --git a/packages/dicomImageLoader/src/shared/decoders/nativeFrameBytes.ts b/packages/dicomImageLoader/src/shared/decoders/nativeFrameBytes.ts new file mode 100644 index 0000000000..312d8ce260 --- /dev/null +++ b/packages/dicomImageLoader/src/shared/decoders/nativeFrameBytes.ts @@ -0,0 +1,54 @@ +import type { ByteArray } from 'dicom-parser'; +import type { Types } from '@cornerstonejs/core'; + +/** + * Bytes one frame of native (uncompressed) pixel data occupies. + * + * Bits Allocated of 1 is bit packed, so a frame occupies a whole number of + * bytes rounded up rather than one byte per pixel. + */ +export function nativeFrameLength(imageFrame: Types.IImageFrame): number { + const { rows, columns, samplesPerPixel, bitsAllocated } = imageFrame; + const samples = rows * columns * samplesPerPixel; + + if (bitsAllocated === 1) { + return Math.ceil(samples / 8); + } + + return samples * (bitsAllocated / 8); +} + +/** + * Trims the padding an encapsulated frame may carry. + * + * Encapsulated transfer syntaxes pad a fragment to an even length, and + * Deflated Image Frame Compression additionally appends a NULL when the + * deflated stream itself is odd (PS3.5 A.4.11, A.4.13). Either way the frame + * that arrives can be longer than the pixel data it carries, and handing the + * surplus on would put a byte count the target buffer does not expect through + * the rest of the pipeline. + * + * A frame *shorter* than its pixel data is a truncation rather than padding, + * and is thrown rather than silently rendered as a partial image. + */ +export function trimToNativeFrame( + imageFrame: Types.IImageFrame, + pixelData: ByteArray, + context: string +): ByteArray { + const expected = nativeFrameLength(imageFrame); + + if (pixelData.length === expected) { + return pixelData; + } + + if (pixelData.length < expected) { + throw new Error( + `${context}: frame is ${pixelData.length} bytes, expected ${expected} for ` + + `${imageFrame.rows}x${imageFrame.columns} with ${imageFrame.samplesPerPixel} ` + + `sample(s) at ${imageFrame.bitsAllocated} bits` + ); + } + + return pixelData.subarray(0, expected); +} diff --git a/packages/dicomImageLoader/test/decoders_test.ts b/packages/dicomImageLoader/test/decoders_test.ts index 10b524383d..d3233b2821 100644 --- a/packages/dicomImageLoader/test/decoders_test.ts +++ b/packages/dicomImageLoader/test/decoders_test.ts @@ -1,124 +1,341 @@ -/* eslint import/extensions: 0 */ -import { should } from 'chai'; -import getPixelData from '../src/imageLoader/wadouri/getPixelData.js'; -import xhrRequest from '../src/imageLoader/internal/xhrRequest.js'; -import dataSetCacheManager from '../src/imageLoader/wadouri/dataSetCacheManager.js'; -import parseImageId from '../src/imageLoader/wadouri/parseImageId.js'; -import createImage from '../src/imageLoader/createImage.js'; -import init from '../src/imageLoader/init.js'; - -should(); +import { imageLoader, metaData } from '@cornerstonejs/core'; +import dataSetCacheManager from '../src/imageLoader/wadouri/dataSetCacheManager'; +import init from '../src/init'; +/** + * Every entry here is a lossless re-encoding of testImages/CTImage.dcm, so each + * one has to decode to pixels identical to the uncompressed original. That + * makes this a ground truth test rather than a smoke test: a decoder that + * produces a plausible but wrong image fails. + * + * CTImage.dcm is signed (PixelRepresentation 1), which is worth knowing because + * it is the case that catches a decoder taking signedness from its codec rather + * than from the data set - JPEG XL and JPEG-LS both always report unsigned. + * + * `pnpm test:decoders` runs this suite alone, in about one minute rather than + * the several minutes of a full `pnpm test`. That script also takes + * `--jpeg-lossless-build `, which replaces + * @cornerstonejs/jpeg-lossless-decoder-js with a different build of that + * decoder, so you can test a decoder fix before its release. See + * karma.decoders.conf.js. + */ const transferSyntaxes = { '1.2.840.10008.1.2': 'LittleEndianImplicitTransferSyntax', '1.2.840.10008.1.2.1': 'LittleEndianExplicitTransferSyntax', - '1.2.840.10008.1.2.1.99': 'DeflatedExplicitVRLittleEndianTransferSyntax', '1.2.840.10008.1.2.2': 'BigEndianExplicitTransferSyntax', - // retired? Do we care? - // '1.2.840.10008.1.2.4.53': 'JPEGProcess6_8TransferSyntax', - // '1.2.840.10008.1.2.4.55': 'JPEGProcess10_12TransferSyntax', - '1.2.840.10008.1.2.4.57': 'JPEGProcess14TransferSyntax', '1.2.840.10008.1.2.4.70': 'JPEGProcess14SV1TransferSyntax', '1.2.840.10008.1.2.4.80': 'JPEGLSLosslessTransferSyntax', '1.2.840.10008.1.2.4.90': 'JPEG2000LosslessOnlyTransferSyntax', + '1.2.840.10008.1.2.4.201': 'HTJ2KLosslessTransferSyntax', '1.2.840.10008.1.2.5': 'RLELosslessTransferSyntax', + + '1.2.840.10008.1.2.1.98': + 'EncapsulatedUncompressedExplicitVRLittleEndianTransferSyntax', + '1.2.840.10008.1.2.8.1': 'DeflatedImageFrameCompressionTransferSyntax', + '1.2.840.10008.1.2.4.110': 'JPEGXLLosslessTransferSyntax', +}; + +/** + * Syntaxes with a fixture but a pre-existing failure, reported as pending so + * they stay visible rather than being quietly dropped from the list. It is not + * related to the syntaxes added alongside this suite being enabled. + * + * 1.2.840.10008.1.2.4.70 used to sit here: exactly one sample was wrong, the + * last, decoding as -1024 instead of -3024, because jpeg-lossless-decoder-js + * 2.1.2 read the 0xFF introducing EOI as entropy coded data whenever the final + * Huffman code ended on a byte boundary - which is what DCMTK writes for a + * frame ending in a run of one value. + * + * The fix reaches this repository under a different name rather than through a + * version bump, which is the part worth knowing. The original package, + * rii-mango/jpeg-lossless-decoder-js, last published 2.1.2 in April 2024, and + * npm still gives that version as latest. The fix lives in the fork + * cornerstonejs/JPEGLosslessDecoderJS, published as + * @cornerstonejs/jpeg-lossless-decoder-js, which is what + * packages/dicomImageLoader now depends on. Upgrading the unscoped package + * would find nothing; the dependency had to be swapped. + */ +const pendingTransferSyntaxes = { + '1.2.840.10008.1.2.1.99': [ + 'DeflatedExplicitVRLittleEndianTransferSyntax', + // The whole data set is deflated, and dataSetCacheManager inflates it via + // parseDicomWithInflater - but the naturalized path hands the raw + // ArrayBuffer to addDicomPart10Instance, which cannot parse it, so nothing + // reaches NATURALIZED. Passes through the legacy metadata provider. + 'addDicomPart10Instance does not inflate a deflated data set', + ], +}; + +/** + * The colour set. Not every syntax is duplicated in colour - the point is to + * cover each decoder whose colour handling differs from its grayscale + * handling, which is a smaller set than the list of syntaxes. + * + * Encapsulated uncompressed is deliberately absent: its fragment is the same + * native little endian pixel data Explicit VR LE already carries, so a colour + * case would re-test the base image rather than a decoder, at 1.2MB. + * + * ColorImage.dcm is kodim23 from the Kodak True Color suite - 768x512 + * interleaved RGB, PlanarConfiguration 0 - so unlike the CT it is unsigned and + * multi-sample. The .57, .80 and .201 fixtures are that same image as encoded + * by viewer-testdata's colorEncode corpus, which verifies all twelve of its + * encodings decode to one reference; the rest are produced by + * testImages/make-fixtures.py. + */ +const colorTransferSyntaxes = { + // Deflated frames: three samples per pixel changes the frame length + // arithmetic the inflate path checks against. + '1.2.840.10008.1.2.8.1': 'DeflatedImageFrameCompressionTransferSyntax', + // The three codecs whose colour handling is a separate path from grayscale, + // one per decoder rather than one per syntax: + // .57 decodeJPEGLossless, a JavaScript decoder + // .80 decodeJPEGLS, charls, which has interleave modes + // .201 decodeHTJ2K, OpenJPH, stored as YBR_RCT so the codec has to undo + // the reversible colour transform to give back RGB + // .110 decodeJPEGXL, libjxl, three channels rather than one + '1.2.840.10008.1.2.4.57': 'JPEGProcess14TransferSyntax', + '1.2.840.10008.1.2.4.80': 'JPEGLSLosslessTransferSyntax', + '1.2.840.10008.1.2.4.201': 'HTJ2KLosslessTransferSyntax', + '1.2.840.10008.1.2.4.110': 'JPEGXLLosslessTransferSyntax', +}; + +/** + * Lossy syntaxes, which by definition cannot be compared bit-for-bit against + * the uncompressed original, so each one carries the largest per sample + * difference it is allowed to produce. + * + * .50 is the one syntax libjpeg-turbo decodes here, so it is the only case that + * covers that codec at all - every entry in the lossless list above goes to a + * different decoder. + * + * It runs against GrayImage.dcm rather than either of the other two bases, and + * both of those would be the wrong image for it: + * + * CTImage.dcm is 16 bit, while JPEG Baseline is an 8 bit process, so the + * fixture would be an 8 bit frame compared against 16 bit CT values - not + * the same value space, which is why the older + * test/lossyImagesDecoding_test.ts needed a tolerance of 100 and recorded a + * TODO against it. + * + * ColorImage.dcm would not reach this codec at all. decodeImageFrame.ts + * sends 8 bit .50 with three or four samples per pixel to the browser's own + * JPEG decoder, so a colour fixture tests the browser rather than + * libjpeg-turbo. + * + * Against a matched 8 bit grayscale base the bound is a real one. The encode is + * quality 90, whose worst sample lands 14 off; 20 leaves room for two libjpeg + * derived decoders to differ slightly through their IDCT without making the + * bound meaningless. + */ +const lossyTransferSyntaxes = { + '1.2.840.10008.1.2.4.50': { + name: 'JPEGProcess1TransferSyntax', + tolerance: 20, + }, }; const base = 'CTImage.dcm'; -const url = 'dicomweb://localhost:9876/base/testImages/'; +const colorBase = 'ColorImage.dcm'; +const grayBase = 'GrayImage.dcm'; +// Karma serves the repository under /base, so this is the real path to the +// fixtures. +const url = + 'dicomweb://localhost:9876/base/packages/dicomImageLoader/testImages/'; + +/** + * Loads an image the way an application does - through the registered image + * loader, which takes its metadata from the naturalized cache that + * dataSetCacheManager populates while reading the Part 10 file - and returns + * its samples. + */ +async function decodeSamples(imageId: string) { + const image = await imageLoader.loadImage(imageId); + const { transferSyntaxUID } = metaData.get('transferSyntax', imageId) || {}; + + return { transferSyntaxUID, samples: image.voxelManager.getScalarData() }; +} describe('Test lossless TransferSyntaxes decoding', function () { - let uncompressedPixelData = null; + let uncompressedSamples = null; + + beforeAll(async function () { + init({ + beforeSend(/* xhr, imageId */) {}, + imageCreated(/* image */) {}, + strict: false, + decodeConfig: {}, + }); + + ({ samples: uncompressedSamples } = await decodeSamples(`${url}${base}`)); + }); + + afterAll(function () { + dataSetCacheManager.purge(); + }); + + Object.keys(transferSyntaxes).forEach((transferSyntaxUid) => { + const name = transferSyntaxes[transferSyntaxUid]; + const filename = `${base}_${name}_${transferSyntaxUid}.dcm`; - let uncompressedImage = null; + it(`should properly decode ${name}`, async function () { + const { transferSyntaxUID, samples } = await decodeSamples( + `${url}${filename}` + ); - before(function (done) { - this.timeout(5000); - // loads uncompressed study (the original one) - const imageId = `${url}${base}`; - const parsedImageId = parseImageId(imageId); + // Guards against a fixture that is not the syntax its name claims, which + // would otherwise pass here while testing the wrong decoder. + expect(transferSyntaxUID).toBe(transferSyntaxUid); + expect(samples.length).toBe(uncompressedSamples.length); + + let firstDifference = -1; + for (let i = 0; i < samples.length; i++) { + if (samples[i] !== uncompressedSamples[i]) { + firstDifference = i; + break; + } + } + + if (firstDifference !== -1) { + fail( + `${name}: pixel ${firstDifference} is ${samples[firstDifference]}, ` + + `expected ${uncompressedSamples[firstDifference]}` + ); + } + }); + }); + Object.keys(pendingTransferSyntaxes).forEach((transferSyntaxUid) => { + const [name, reason] = pendingTransferSyntaxes[transferSyntaxUid]; + + it(`should properly decode ${name}`, function () { + pending(reason); + }); + }); +}); + +describe('Test lossless TransferSyntaxes decoding of colour', function () { + let uncompressedSamples = null; + + beforeAll(async function () { init({ - // callback allowing customization of the xhr (e.g. adding custom auth headers, cors, etc) beforeSend(/* xhr, imageId */) {}, - // callback allowing modification of newly created image objects imageCreated(/* image */) {}, strict: false, decodeConfig: {}, }); - dataSetCacheManager - .load(parsedImageId.url, xhrRequest, imageId) - .then((dataSet) => { - const transferSyntax = dataSet.string('x00020010'); + ({ samples: uncompressedSamples } = await decodeSamples( + `${url}${colorBase}` + )); + + // Three interleaved samples per pixel over 768x512, so a decoder that drops + // or reorders a channel shows up as a length or value difference rather + // than passing quietly. + expect(uncompressedSamples.length).toBe(768 * 512 * 3); + }); + + afterAll(function () { + dataSetCacheManager.purge(); + }); + + Object.keys(colorTransferSyntaxes).forEach((transferSyntaxUid) => { + const name = colorTransferSyntaxes[transferSyntaxUid]; + const filename = `${colorBase}_${name}_${transferSyntaxUid}.dcm`; + + it(`should properly decode colour ${name}`, async function () { + const { transferSyntaxUID, samples } = await decodeSamples( + `${url}${filename}` + ); - uncompressedPixelData = getPixelData(dataSet); + expect(transferSyntaxUID).toBe(transferSyntaxUid); + expect(samples.length).toBe(uncompressedSamples.length); - createImage(imageId, uncompressedPixelData, transferSyntax, {}).then( - (image) => { - uncompressedImage = image; - } + let firstDifference = -1; + for (let i = 0; i < samples.length; i++) { + if (samples[i] !== uncompressedSamples[i]) { + firstDifference = i; + break; + } + } + + if (firstDifference !== -1) { + // Reported as pixel and channel, since a wrong colour transform + // typically shows as a consistent offset in one channel. + const pixel = Math.floor(firstDifference / 3); + const channel = 'RGB'[firstDifference % 3]; + fail( + `${name}: pixel ${pixel} channel ${channel} is ` + + `${samples[firstDifference]}, expected ` + + `${uncompressedSamples[firstDifference]}` ); + } + }); + }); +}); + +describe('Test lossy TransferSyntaxes decoding', function () { + let uncompressedSamples = null; + + beforeAll(async function () { + init({ + beforeSend(/* xhr, imageId */) {}, + imageCreated(/* image */) {}, + strict: false, + decodeConfig: {}, + }); - done(); - }) - .catch(done); + ({ samples: uncompressedSamples } = await decodeSamples( + `${url}${grayBase}` + )); + + // 768x512 single sample, so a fixture that decoded as colour, or that lost + // a row, shows up as a length difference rather than passing quietly. + expect(uncompressedSamples.length).toBe(768 * 512); }); - after(function () { + afterAll(function () { dataSetCacheManager.purge(); }); - Object.keys(transferSyntaxes).forEach((transferSyntaxUid) => { - const name = transferSyntaxes[transferSyntaxUid]; - const filename = `${base}_${name}_${transferSyntaxUid}.dcm`; + Object.keys(lossyTransferSyntaxes).forEach((transferSyntaxUid) => { + const { name, tolerance } = lossyTransferSyntaxes[transferSyntaxUid]; + const filename = `${grayBase}_${name}_${transferSyntaxUid}.dcm`; - it(`should properly decode ${name}`, function (done) { - this.timeout(5000); - const imageId = `${url}${filename}`; - const parsedImageId = parseImageId(imageId); - const dataSetPromise = dataSetCacheManager.load( - parsedImageId.url, - xhrRequest, - imageId + it(`should properly decode ${name}`, async function () { + const { transferSyntaxUID, samples } = await decodeSamples( + `${url}${filename}` ); - dataSetPromise.then((dataSet) => { - try { - const pixelData = getPixelData(dataSet); - const curTransferSyntax = dataSet.string('x00020010'); - - curTransferSyntax.should.to.be.equals(transferSyntaxUid); - - createImage(imageId, pixelData, curTransferSyntax, {}) - .then((image) => { - const uncompressedImagePixelData = - uncompressedimage.voxelManager.getScalarData(); - const curPixelData = image.voxelManager.getScalarData(); - - uncompressedImagePixelData.length.should.to.be.equals( - curPixelData.length - ); - - for (let i = 0; i < curPixelData.length - 1; i++) { - if (curPixelData[i] !== uncompressedImagePixelData[i]) { - curPixelData[i] - .should(`Pixel data is not equal at position: ${i}`) - .to.equal(uncompressedImagePixelData[i]); - } - } - - done(); - }) - .catch(done); - } catch (error) { - done(error); + // Guards against a fixture that is not the syntax its name claims, which + // would otherwise pass here while testing the wrong decoder. + expect(transferSyntaxUID).toBe(transferSyntaxUid); + expect(samples.length).toBe(uncompressedSamples.length); + + // The whole frame is scanned rather than stopping at the first sample + // over the tolerance, so the failure message reports the worst sample in + // the image instead of whichever one happens to come first. + let worstIndex = -1; + let worstDifference = 0; + for (let i = 0; i < samples.length; i++) { + const difference = Math.abs(samples[i] - uncompressedSamples[i]); + + if (difference > worstDifference) { + worstDifference = difference; + worstIndex = i; } - }, done); + } + + if (worstDifference > tolerance) { + fail( + `${name}: pixel ${worstIndex} is ${samples[worstIndex]}, expected ` + + `${uncompressedSamples[worstIndex]} within ${tolerance} ` + + `(difference ${worstDifference})` + ); + } }); }); }); diff --git a/packages/dicomImageLoader/testImages/CTImage.dcm_DeflatedImageFrameCompressionTransferSyntax_1.2.840.10008.1.2.8.1.dcm b/packages/dicomImageLoader/testImages/CTImage.dcm_DeflatedImageFrameCompressionTransferSyntax_1.2.840.10008.1.2.8.1.dcm new file mode 100644 index 0000000000..66a91df5c4 Binary files /dev/null and b/packages/dicomImageLoader/testImages/CTImage.dcm_DeflatedImageFrameCompressionTransferSyntax_1.2.840.10008.1.2.8.1.dcm differ diff --git a/packages/dicomImageLoader/testImages/CTImage.dcm_EncapsulatedUncompressedExplicitVRLittleEndianTransferSyntax_1.2.840.10008.1.2.1.98.dcm b/packages/dicomImageLoader/testImages/CTImage.dcm_EncapsulatedUncompressedExplicitVRLittleEndianTransferSyntax_1.2.840.10008.1.2.1.98.dcm new file mode 100644 index 0000000000..5bb1b0941f Binary files /dev/null and b/packages/dicomImageLoader/testImages/CTImage.dcm_EncapsulatedUncompressedExplicitVRLittleEndianTransferSyntax_1.2.840.10008.1.2.1.98.dcm differ diff --git a/packages/dicomImageLoader/testImages/CTImage.dcm_HTJ2KLosslessTransferSyntax_1.2.840.10008.1.2.4.201.dcm b/packages/dicomImageLoader/testImages/CTImage.dcm_HTJ2KLosslessTransferSyntax_1.2.840.10008.1.2.4.201.dcm new file mode 100644 index 0000000000..a87c16242a Binary files /dev/null and b/packages/dicomImageLoader/testImages/CTImage.dcm_HTJ2KLosslessTransferSyntax_1.2.840.10008.1.2.4.201.dcm differ diff --git a/packages/dicomImageLoader/testImages/CTImage.dcm_JPEGXLLosslessTransferSyntax_1.2.840.10008.1.2.4.110.dcm b/packages/dicomImageLoader/testImages/CTImage.dcm_JPEGXLLosslessTransferSyntax_1.2.840.10008.1.2.4.110.dcm new file mode 100644 index 0000000000..b88f8600ff Binary files /dev/null and b/packages/dicomImageLoader/testImages/CTImage.dcm_JPEGXLLosslessTransferSyntax_1.2.840.10008.1.2.4.110.dcm differ diff --git a/packages/dicomImageLoader/testImages/ColorImage.dcm b/packages/dicomImageLoader/testImages/ColorImage.dcm new file mode 100644 index 0000000000..099a12bfc8 Binary files /dev/null and b/packages/dicomImageLoader/testImages/ColorImage.dcm differ diff --git a/packages/dicomImageLoader/testImages/ColorImage.dcm_DeflatedImageFrameCompressionTransferSyntax_1.2.840.10008.1.2.8.1.dcm b/packages/dicomImageLoader/testImages/ColorImage.dcm_DeflatedImageFrameCompressionTransferSyntax_1.2.840.10008.1.2.8.1.dcm new file mode 100644 index 0000000000..bc10d4d12f Binary files /dev/null and b/packages/dicomImageLoader/testImages/ColorImage.dcm_DeflatedImageFrameCompressionTransferSyntax_1.2.840.10008.1.2.8.1.dcm differ diff --git a/packages/dicomImageLoader/testImages/ColorImage.dcm_HTJ2KLosslessTransferSyntax_1.2.840.10008.1.2.4.201.dcm b/packages/dicomImageLoader/testImages/ColorImage.dcm_HTJ2KLosslessTransferSyntax_1.2.840.10008.1.2.4.201.dcm new file mode 100644 index 0000000000..8ca1ecba6a Binary files /dev/null and b/packages/dicomImageLoader/testImages/ColorImage.dcm_HTJ2KLosslessTransferSyntax_1.2.840.10008.1.2.4.201.dcm differ diff --git a/packages/dicomImageLoader/testImages/ColorImage.dcm_JPEGLSLosslessTransferSyntax_1.2.840.10008.1.2.4.80.dcm b/packages/dicomImageLoader/testImages/ColorImage.dcm_JPEGLSLosslessTransferSyntax_1.2.840.10008.1.2.4.80.dcm new file mode 100644 index 0000000000..82eb1461dd Binary files /dev/null and b/packages/dicomImageLoader/testImages/ColorImage.dcm_JPEGLSLosslessTransferSyntax_1.2.840.10008.1.2.4.80.dcm differ diff --git a/packages/dicomImageLoader/testImages/ColorImage.dcm_JPEGProcess14TransferSyntax_1.2.840.10008.1.2.4.57.dcm b/packages/dicomImageLoader/testImages/ColorImage.dcm_JPEGProcess14TransferSyntax_1.2.840.10008.1.2.4.57.dcm new file mode 100644 index 0000000000..3f82c69427 Binary files /dev/null and b/packages/dicomImageLoader/testImages/ColorImage.dcm_JPEGProcess14TransferSyntax_1.2.840.10008.1.2.4.57.dcm differ diff --git a/packages/dicomImageLoader/testImages/ColorImage.dcm_JPEGXLLosslessTransferSyntax_1.2.840.10008.1.2.4.110.dcm b/packages/dicomImageLoader/testImages/ColorImage.dcm_JPEGXLLosslessTransferSyntax_1.2.840.10008.1.2.4.110.dcm new file mode 100644 index 0000000000..d4ca3240f3 Binary files /dev/null and b/packages/dicomImageLoader/testImages/ColorImage.dcm_JPEGXLLosslessTransferSyntax_1.2.840.10008.1.2.4.110.dcm differ diff --git a/packages/dicomImageLoader/testImages/GrayImage.dcm b/packages/dicomImageLoader/testImages/GrayImage.dcm new file mode 100644 index 0000000000..a1a85dee57 Binary files /dev/null and b/packages/dicomImageLoader/testImages/GrayImage.dcm differ diff --git a/packages/dicomImageLoader/testImages/GrayImage.dcm_JPEGProcess1TransferSyntax_1.2.840.10008.1.2.4.50.dcm b/packages/dicomImageLoader/testImages/GrayImage.dcm_JPEGProcess1TransferSyntax_1.2.840.10008.1.2.4.50.dcm new file mode 100644 index 0000000000..aae3833523 Binary files /dev/null and b/packages/dicomImageLoader/testImages/GrayImage.dcm_JPEGProcess1TransferSyntax_1.2.840.10008.1.2.4.50.dcm differ diff --git a/packages/dicomImageLoader/testImages/make-fixtures.py b/packages/dicomImageLoader/testImages/make-fixtures.py new file mode 100644 index 0000000000..55cf313560 --- /dev/null +++ b/packages/dicomImageLoader/testImages/make-fixtures.py @@ -0,0 +1,266 @@ +#!/usr/bin/env python3 +"""Transcode a testImages base image into the transfer syntaxes cs3d now decodes. + +decoders_test.ts compares every variant against the decoded base image. A +lossless fixture has to be a re-encoding of that exact image and nothing else; +a lossy one has to stay inside the tolerance the test asserts. Every file +written here is read back, decoded, and compared with the source pixels; a file +that does not round-trip, or that exceeds its tolerance, is not left behind. + + python make-fixtures.py [base.dcm ...] + +Defaults to all three base images: CTImage.dcm (512x512 16 bit signed +grayscale), ColorImage.dcm (768x512 8 bit interleaved RGB) and GrayImage.dcm +(768x512 8 bit grayscale, derived here from ColorImage.dcm). The colour one +matters because three samples per pixel exercises the frame length arithmetic, +and because JPEG XL colour is a different code path in the codec from JPEG XL +grayscale. The grayscale one exists for JPEG Baseline; see GRAY_BASE below. + +ColorImage.dcm is kodim23 from the Kodak True Color suite, released for +unrestricted use, wrapped in a synthetic Secondary Capture header - not medical +data, no human subject. Each file carries that attribution in (0008,2111) +DerivationDescription. Taken from viewer-testdata dcm/colorEncode. +""" + +from __future__ import annotations + +import sys +import zlib +from pathlib import Path + +import numpy as np +import pydicom +from pydicom.encaps import encapsulate, generate_frames +from pydicom.uid import UID +from pydicom._uid_dict import UID_dictionary + +# pydicom 3.0's UID dictionary predates these supplements, so it rejects them as +# "not a valid transfer syntax" when writing. Every is_* property on UID derives +# from this table, so registering the PS3.6 rows makes them first-class. This is +# the same registration viewer-testdata's encode-samples.py does. +for _uid, _name, _keyword in ( + ("1.2.840.10008.1.2.4.110", "JPEG XL Lossless", "JPEGXLLossless"), + ("1.2.840.10008.1.2.4.111", "JPEG XL JPEG Recompression", "JPEGXLJPEGRecompression"), + ("1.2.840.10008.1.2.4.112", "JPEG XL", "JPEGXL"), + ("1.2.840.10008.1.2.8.1", "Deflated Image Frame Compression", "DeflatedImageFrameCompression"), +): + UID_dictionary.setdefault(_uid, (_name, "Transfer Syntax", "", "", _keyword)) + +# name -> (uid, suffix used by decoders_test.ts) +TARGETS = { + "1.2.840.10008.1.2.1.98": "EncapsulatedUncompressedExplicitVRLittleEndianTransferSyntax", + "1.2.840.10008.1.2.8.1": "DeflatedImageFrameCompressionTransferSyntax", + "1.2.840.10008.1.2.4.110": "JPEGXLLosslessTransferSyntax", +} + +# Lossy targets, which cannot round-trip, so each carries the largest per sample +# difference the encode is allowed to produce. Kept apart from TARGETS because +# verify() checks them against a bound rather than for equality. +# The bound is 20 against a measured worst sample of 14 for this image at +# quality 90. The margin is deliberate: this check decodes with Pillow, while +# decoders_test.ts decodes with libjpeg-turbo, and two libjpeg derived decoders +# can land a sample or two apart on the same stream through their IDCT alone. +LOSSY_TARGETS = { + "1.2.840.10008.1.2.4.50": ("JPEGProcess1TransferSyntax", 20), +} + +# JPEG Baseline is an 8 bit process, and cs3d only routes it to libjpeg-turbo +# when the frame is single sample - decodeImageFrame.ts sends 8 bit .50 with +# three or four samples per pixel to the browser's own JPEG decoder instead. So +# the only fixture that exercises the codec is 8 bit grayscale, which neither +# base image is: CTImage.dcm is 16 bit, ColorImage.dcm is three sample. This +# derives that missing base from ColorImage.dcm rather than adding an unrelated +# third image, so the whole corpus still comes from kodim23 and the CT. +GRAY_BASE = "GrayImage.dcm" + +JPEG_QUALITY = 90 + + +def even(data: bytes) -> bytes: + """DICOM items must be an even number of bytes.""" + return data if len(data) % 2 == 0 else data + b"\x00" + + +def encode_encapsulated_uncompressed(frame: bytes) -> bytes: + # PS3.5 A.4.11: the fragment holds the frame's native little endian pixel + # data, padded to an even length. Nothing is compressed. + return even(frame) + + +def encode_deflated_frame(frame: bytes) -> bytes: + # PS3.5 A.4.13: raw DEFLATE per RFC 1951 - no zlib header or Adler-32 + # trailer, hence wbits=-15 - with a trailing NULL if the result is odd. + compressor = zlib.compressobj(9, zlib.DEFLATED, -15) + return even(compressor.compress(frame) + compressor.flush()) + + +def encode_jpegxl(pixels: np.ndarray) -> bytes: + import imagecodecs + + # JPEG XL has no signed sample type, so the two's complement bit pattern is + # encoded as unsigned and the reader reinterprets it per + # PixelRepresentation. This is the convention the released corpus uses. + unsigned = np.ascontiguousarray(pixels).view( + np.uint16 if pixels.dtype.itemsize == 2 else np.uint8 + ) + # Colour arrives as (rows, columns, samples) and is encoded as such, so the + # codec writes a three channel image rather than a wider grayscale one. + return even(imagecodecs.jpegxl_encode(unsigned, lossless=True)) + + +def encode_jpeg_baseline(pixels: np.ndarray) -> bytes: + """JPEG Baseline process 1, the syntax libjpeg-turbo decodes for .50.""" + import io + + from PIL import Image + + if pixels.ndim != 2 or pixels.dtype != np.uint8: + raise SystemExit("JPEG Baseline needs an 8 bit single sample frame") + + buffer = io.BytesIO() + Image.fromarray(pixels, mode="L").save( + buffer, format="JPEG", quality=JPEG_QUALITY + ) + return even(buffer.getvalue()) + + +def ensure_gray_base(test_images: Path) -> Path: + """Derive the 8 bit grayscale base from ColorImage.dcm, once. + + kodim23 converted to luminance with the ITU-R BT.601 weights, which is what + a JPEG encoder uses for its own Y channel, so the image stays natural rather + than becoming a synthetic gradient. The header is ColorImage.dcm's, changed + to one sample of MONOCHROME2 and given its own SOP Instance UID. + """ + out = test_images / GRAY_BASE + if out.exists(): + return out + + ds = pydicom.dcmread(test_images / "ColorImage.dcm") + rgb = ds.pixel_array.astype(np.float64) + gray = np.rint( + 0.299 * rgb[..., 0] + 0.587 * rgb[..., 1] + 0.114 * rgb[..., 2] + ).clip(0, 255).astype(np.uint8) + + ds.SamplesPerPixel = 1 + ds.PhotometricInterpretation = "MONOCHROME2" + if "PlanarConfiguration" in ds: + del ds.PlanarConfiguration + ds.BitsAllocated = 8 + ds.BitsStored = 8 + ds.HighBit = 7 + ds.PixelRepresentation = 0 + ds.PixelData = even(gray.tobytes()) + ds.SOPInstanceUID = pydicom.uid.generate_uid() + ds.file_meta.MediaStorageSOPInstanceUID = ds.SOPInstanceUID + ds.file_meta.TransferSyntaxUID = UID("1.2.840.10008.1.2.1") + ds.DerivationDescription = ( + "kodim23 from the Kodak True Color suite, released for unrestricted " + "use, converted to BT.601 luminance. Not medical data, no human " + "subject." + ) + ds.save_as(out, implicit_vr=False, little_endian=True, enforce_file_format=True) + + print(f" {out.name} derived from ColorImage.dcm, {gray.shape[1]}x{gray.shape[0]} 8 bit grayscale") + return out + + +def build(source: pydicom.Dataset, uid: str, out: Path) -> None: + frame = source.PixelData + pixels = source.pixel_array + + if uid == "1.2.840.10008.1.2.1.98": + fragment = encode_encapsulated_uncompressed(frame) + elif uid == "1.2.840.10008.1.2.8.1": + fragment = encode_deflated_frame(frame) + elif uid == "1.2.840.10008.1.2.4.110": + fragment = encode_jpegxl(pixels) + elif uid == "1.2.840.10008.1.2.4.50": + fragment = encode_jpeg_baseline(pixels) + else: + raise SystemExit(f"no encoder for {uid}") + + ds = pydicom.dcmread(source.filename) + ds.file_meta.TransferSyntaxUID = UID(uid) + ds.PixelData = encapsulate([fragment]) + ds["PixelData"].is_undefined_length = True + ds.save_as(out, implicit_vr=False, little_endian=True, enforce_file_format=True) + + verify(out, uid, pixels) + + +def verify(path: Path, uid: str, expected: np.ndarray) -> None: + ds = pydicom.dcmread(path) + if str(ds.file_meta.TransferSyntaxUID) != uid: + raise SystemExit(f"{path.name}: wrote {ds.file_meta.TransferSyntaxUID}, not {uid}") + + frame = next(generate_frames(ds.PixelData, number_of_frames=1)) + dtype = expected.dtype + + if uid in LOSSY_TARGETS: + import io + + from PIL import Image + + _, tolerance = LOSSY_TARGETS[uid] + got = np.asarray(Image.open(io.BytesIO(frame))) + if got.shape != expected.shape: + raise SystemExit(f"{path.name}: decoded {got.shape}, expected {expected.shape}") + + worst = int(np.abs(got.astype(np.int32) - expected.astype(np.int32)).max()) + if worst > tolerance: + raise SystemExit( + f"{path.name}: worst sample differs by {worst}, over the " + f"tolerance of {tolerance} that decoders_test.ts asserts" + ) + + print(f" {path.name} {len(frame)} frame bytes, worst sample differs by {worst} (tolerance {tolerance})") + return + + if uid == "1.2.840.10008.1.2.1.98": + raw = frame[: expected.nbytes] + elif uid == "1.2.840.10008.1.2.8.1": + raw = zlib.decompress(frame, -15)[: expected.nbytes] + else: + import imagecodecs + + decoded = np.squeeze(imagecodecs.jpegxl_decode(frame)) + raw = np.ascontiguousarray(decoded).tobytes()[: expected.nbytes] + + got = np.frombuffer(raw, dtype=dtype).reshape(expected.shape) + if not np.array_equal(got, expected): + raise SystemExit(f"{path.name}: pixels do not round-trip") + + print(f" {path.name} {len(frame)} frame bytes, round-trips") + + +def main() -> None: + test_images = Path(sys.argv[1]) + bases = sys.argv[2:] or ["CTImage.dcm", "ColorImage.dcm", GRAY_BASE] + + for base in bases: + if base == GRAY_BASE: + ensure_gray_base(test_images) + + source = pydicom.dcmread(test_images / base) + print( + f"source {base}: {source.Rows}x{source.Columns} " + f"{source.BitsAllocated}bit spp={source.SamplesPerPixel} " + f"pi={source.PhotometricInterpretation} " + f"pr={source.PixelRepresentation}" + ) + + if base == GRAY_BASE: + # This base exists only for the JPEG Baseline case. The lossless + # syntaxes are already covered by the other two bases, and a third + # copy of each would add run time without adding coverage. + targets = {uid: name for uid, (name, _) in LOSSY_TARGETS.items()} + else: + targets = dict(TARGETS) + + for uid, name in targets.items(): + build(source, uid, test_images / f"{base}_{name}_{uid}.dcm") + + +if __name__ == "__main__": + main() diff --git a/packages/dicomImageLoader/testImages/make-htj2k-fixture.mjs b/packages/dicomImageLoader/testImages/make-htj2k-fixture.mjs new file mode 100644 index 0000000000..720c90f523 --- /dev/null +++ b/packages/dicomImageLoader/testImages/make-htj2k-fixture.mjs @@ -0,0 +1,116 @@ +#!/usr/bin/env node +// Builds the HTJ2K Lossless (1.2.840.10008.1.2.4.201) fixture from CTImage.dcm. +// +// This one is separate from make-fixtures.py because nothing in the Python +// stack encodes HTJ2K: imagecodecs' OpenJPEG build decodes it but will not +// write it, so the encoder has to be the OpenJPH already installed here as +// @cornerstonejs/codec-openjph. +// +// Encoding and decoding with the same implementation would let a matched +// encoder/decoder bug pass unnoticed, so the fixture is verified with +// OpenJPEG - a different implementation - before it is kept. That check lives +// in make-fixtures.py's verify step, which this script defers to by writing the +// codestream where it can pick it up: +// +// node make-htj2k-fixture.mjs +// +// Requires a `python3` with pydicom, numpy and imagecodecs on PATH. + +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, readFileSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { createRequire } from 'node:module'; + +const testImages = process.argv[2]; + +if (!testImages) { + console.error('usage: node make-htj2k-fixture.mjs '); + process.exit(1); +} + +const scratch = mkdtempSync(join(tmpdir(), 'htj2k-fixture-')); + +try { + // Pull the source frame and its geometry out of the DICOM. + const info = JSON.parse( + execFileSync( + 'python3', + [ + '-c', + `import pydicom, json, sys +ds = pydicom.dcmread(sys.argv[1]) +open(sys.argv[2], 'wb').write(ds.PixelData) +print(json.dumps({ + 'width': int(ds.Columns), 'height': int(ds.Rows), + 'bitsPerSample': int(ds.BitsAllocated), + 'componentCount': int(ds.SamplesPerPixel), + 'isSigned': int(ds.PixelRepresentation) == 1, +}))`, + join(testImages, 'CTImage.dcm'), + join(scratch, 'frame.raw'), + ], + { encoding: 'utf8' } + ) + ); + + const require = createRequire(import.meta.url); + const factory = ( + await import( + pathToFileURL(require.resolve('@cornerstonejs/codec-openjph/wasmjs')).href + ) + ).default; + + const codec = await factory(); + const encoder = new codec.HTJ2KEncoder(); + + encoder + .getDecodedBuffer({ ...info, isUsingColorTransform: false }) + .set(new Uint8Array(readFileSync(join(scratch, 'frame.raw')))); + encoder.setQuality(true, 0.001); // lossless + encoder.setDecompositions(5); + encoder.encode(); + + const encoded = Buffer.from(encoder.getEncodedBuffer()); + writeFileSync(join(scratch, 'frame.j2c'), encoded); + + // Wrap and verify. A file that does not round-trip through OpenJPEG is not + // left behind, matching make-fixtures.py. + execFileSync( + 'python3', + [ + '-c', + `import numpy as np, pydicom, imagecodecs, sys +from pathlib import Path +from pydicom.encaps import encapsulate, generate_frames +from pydicom.uid import UID + +test_images, codestream_path = Path(sys.argv[1]), Path(sys.argv[2]) +expected = pydicom.dcmread(test_images / 'CTImage.dcm').pixel_array + +codestream = codestream_path.read_bytes() +if len(codestream) % 2: + codestream += b'\\x00' + +ds = pydicom.dcmread(test_images / 'CTImage.dcm') +ds.file_meta.TransferSyntaxUID = UID('1.2.840.10008.1.2.4.201') +ds.PixelData = encapsulate([codestream]) +ds['PixelData'].is_undefined_length = True +out = test_images / 'CTImage.dcm_HTJ2KLosslessTransferSyntax_1.2.840.10008.1.2.4.201.dcm' +ds.save_as(out, implicit_vr=False, little_endian=True, enforce_file_format=True) + +frame = next(generate_frames(pydicom.dcmread(out).PixelData, number_of_frames=1)) +got = np.asarray(imagecodecs.jpeg2k_decode(frame)).reshape(expected.shape).astype(expected.dtype) +if not np.array_equal(got, expected): + out.unlink() + raise SystemExit('HTJ2K fixture does not round-trip through OpenJPEG') +print(f' {out.name} {len(frame)} frame bytes, round-trips')`, + testImages, + join(scratch, 'frame.j2c'), + ], + { stdio: 'inherit' } + ); +} finally { + rmSync(scratch, { recursive: true, force: true }); +} diff --git a/packages/docs/docs/concepts/progressive-loading/retrieve-Configuration.md b/packages/docs/docs/concepts/progressive-loading/retrieve-Configuration.md index 0d1187315f..842cde3a47 100644 --- a/packages/docs/docs/concepts/progressive-loading/retrieve-Configuration.md +++ b/packages/docs/docs/concepts/progressive-loading/retrieve-Configuration.md @@ -132,6 +132,14 @@ The list is a module-level constant rather than a setting, so adding a syntax to it means changing that file — appropriate only for a codec that can genuinely decode a truncated codestream, since one that cannot will throw instead. +:::note +JPEG XL is a case in point. The format supports progressive decoding, but +`@cornerstonejs/codec-libjxl` closes its input up front and rejects a truncated +codestream, so JPEG XL is deliberately **absent** from the list even though it +decodes normally. Adding it would need the codec to adopt libjxl's +`JxlDecoderSetProgressiveDetail`/`JxlDecoderFlushImage` path first. +::: + ### Streaming Options #### Options diff --git a/packages/docs/docs/migration-guides/5x/1-migration-notes.md b/packages/docs/docs/migration-guides/5x/1-migration-notes.md index 1247c81fb1..be5760f433 100644 --- a/packages/docs/docs/migration-guides/5x/1-migration-notes.md +++ b/packages/docs/docs/migration-guides/5x/1-migration-notes.md @@ -397,3 +397,75 @@ rather than an uncaught error, so it is reported rather than silent. `@cornerstonejs/codec-openjph` below 2.4.10, remove the pin or raise it. - **Prefer `decodeLevel: 0` for HTJ2K partial retrieves,** and keep sub-resolution levels for genuinely small renditions such as JLS thumbnails. + +## Encapsulated Uncompressed and Deflated Image Frame Compression, and the JPEG XL UIDs + +### What Changed + +Two transfer syntaxes now decode: + +- **Encapsulated Uncompressed Explicit VR Little Endian** + (`1.2.840.10008.1.2.1.98`). Nothing is compressed; the syntax exists so that + uncompressed pixel data can use the encapsulated format, one frame per + fragment, which makes a single frame addressable without reading the whole + Pixel Data element (PS3.5 A.4.11). +- **Deflated Image Frame Compression** (`1.2.840.10008.1.2.8.1`). Each frame is + separately compressed with raw Deflate per RFC 1951 - no zlib header or + Adler-32 trailer - and encapsulated as one fragment (PS3.5 A.4.13). This is + per frame, unlike Deflated Explicit VR Little Endian + (`1.2.840.10008.1.2.1.99`), which deflates the whole data set and is inflated + by dicomParser before a frame is ever decoded. + +Separately, the `image/jxl` media type mapping was **corrected**. It previously +resolved to `1.2.840.10008.1.2.4.140`, which is not a JPEG XL UID. JPEG XL was +ratified in Supplement 232 as: + +| UID | Name | +| ------------------------- | -------------------------- | +| `1.2.840.10008.1.2.4.110` | JPEG XL Lossless | +| `1.2.840.10008.1.2.4.111` | JPEG XL JPEG Recompression | +| `1.2.840.10008.1.2.4.112` | JPEG XL | + +`image/jxl` now resolves to `1.2.840.10008.1.2.4.110`, which PS3.18 +Table 8.7.3-5 gives as the default when a response carries no +`transfer-syntax` parameter to disambiguate. `application/x-deflate` was added +for Deflated Image Frame Compression from the same table. + +All three JPEG XL syntaxes now **decode**, through +`@cornerstonejs/codec-libjxl`. They share one decoder: the difference between +them is what the encoder was allowed to do, not how the codestream is read. + +Two limits worth knowing: + +- **Signedness comes from `PixelRepresentation`, not the codestream.** JPEG XL + has no signed sample type, so its decoder always reports unsigned and the + loader applies `PixelRepresentation` itself - the same arrangement JPEG-LS + uses. A JPEG XL frame whose metadata says signed is read correctly; a frame + with no metadata is read as unsigned. +- **Partial decoding is not supported.** The codec rejects a truncated + codestream rather than decoding what arrived, so JPEG XL is deliberately not + in `streamableTransferSyntaxes` and byte range or streaming retrieves of it + decode once, when the frame is complete. The format does support progressive + decoding; this codec build does not use it yet. + +### Why This Matters + +Both new syntaxes reach the decoder as one fragment per frame, and both pad: +encapsulated fragments are padded to an even length, and Deflated Image Frame +Compression appends a NULL when the deflated stream is odd. The decoders trim +that padding to the frame's native pixel length, and treat a frame _shorter_ +than its pixel data as an error rather than rendering it partially. + +This release also fixes a latent bug in encapsulated frame extraction: a single +frame image has no `NumberOfFrames` element, which the fragment reader compared +against the fragment count and concluded the frames were fragmented, falling +back to a scan for JPEG SOI markers. That scan finds nothing in a syntax that +is not JPEG. `NumberOfFrames` now defaults to 1, so a conformant single frame +image of any encapsulated syntax takes the direct fragment path. + +### Migration Guidance + +- No action is required to read the two new syntaxes; they are decoded like any + other. +- If you special-cased `1.2.840.10008.1.2.4.140` as JPEG XL anywhere in your own + code, change it to the `.110`/`.111`/`.112` block. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9295578c5e..9f16c8ed83 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -48,10 +48,10 @@ overrides: js-yaml@>=4.0.0 <4.3.2: 4.3.2 linkify-it@<=5.0.1: 5.0.2 postcss@<8.5.26: 8.5.26 - '@cornerstonejs/codec-charls': 1.2.6 - '@cornerstonejs/codec-libjpeg-turbo-8bit': 1.2.5 - '@cornerstonejs/codec-openjpeg': 1.3.3 - '@cornerstonejs/codec-openjph': 2.4.10 + '@cornerstonejs/codec-charls': 1.2.7 + '@cornerstonejs/codec-libjpeg-turbo-8bit': 1.2.7 + '@cornerstonejs/codec-openjpeg': 1.3.6 + '@cornerstonejs/codec-openjph': 2.4.11 importers: .: @@ -485,26 +485,29 @@ importers: packages/dicomImageLoader: dependencies: '@cornerstonejs/codec-charls': - specifier: 1.2.6 - version: 1.2.6 + specifier: 1.2.7 + version: 1.2.7 '@cornerstonejs/codec-libjpeg-turbo-8bit': - specifier: 1.2.5 - version: 1.2.5 + specifier: 1.2.7 + version: 1.2.7 + '@cornerstonejs/codec-libjxl': + specifier: 1.1.1 + version: 1.1.1 '@cornerstonejs/codec-openjpeg': - specifier: 1.3.3 - version: 1.3.3 + specifier: 1.3.6 + version: 1.3.6 '@cornerstonejs/codec-openjph': - specifier: 2.4.10 - version: 2.4.10 + specifier: 2.4.11 + version: 2.4.11 + '@cornerstonejs/jpeg-lossless-decoder-js': + specifier: 2.2.1 + version: 2.2.1 comlink: specifier: 4.4.2 version: 4.4.2 dicom-parser: specifier: 1.8.21 version: 1.8.21 - jpeg-lossless-decoder-js: - specifier: 2.1.2 - version: 2.1.2 pako: specifier: 2.1.0 version: 2.1.0 @@ -2062,34 +2065,47 @@ packages: } engines: { node: '>=10' } - '@cornerstonejs/codec-charls@1.2.6': + '@cornerstonejs/codec-charls@1.2.7': resolution: { - integrity: sha512-uXV0UzYaLgn9NiPnM2nibNp1HzdEOB2aLJGsHNd6XcmiOMjz2GW7TuSEbDyavtB8bdN8RHcVssJgyN+0cuG5IA==, + integrity: sha512-kNfQ7J8LIrfQUbt56fPJ0SEvevLlAAbsLHjqPS4DUDWVSuAHb5Fn0cMIV/U0SYkJRJqKvzyLJYF1hl7/xQGqLQ==, } engines: { node: '>=0.14' } - '@cornerstonejs/codec-libjpeg-turbo-8bit@1.2.5': + '@cornerstonejs/codec-libjpeg-turbo-8bit@1.2.7': resolution: { - integrity: sha512-rz6+M0F98bsxbPDLsD6mGfXvISOeJ2RenESroQaoVhTYRr5SKixp/sdIns2tMWDLn1ctW2HNlU9U1QmOWL8Vwg==, + integrity: sha512-TINxDtm+RQuMvWNyNR6z8lCkqDKguZBMrPJ3+oAvLc1XQ3tQsIGIIUWzveedpyr8gp7DYsQqoPLhCl2mYTSv3w==, } engines: { node: '>=0.14' } - '@cornerstonejs/codec-openjpeg@1.3.3': + '@cornerstonejs/codec-libjxl@1.1.1': + resolution: + { + integrity: sha512-Gp5AXNDuqX9CggJ6VJzjEuPowL2IsKGkvUPo3I7ZmCaY1vNDTwOdJUFrHACl3RIMDm+EL9EUw0Lhsmbs5b0fvA==, + } + engines: { node: '>=24' } + + '@cornerstonejs/codec-openjpeg@1.3.6': resolution: { - integrity: sha512-GhKsXdzULk7UgPNQNx+oJbRs3DDvn2YQvUTe5x6SlobjAf3UYDv/wZ/RLcXt5+HkVKqlDW6EHoqUoE5OjkDuRg==, + integrity: sha512-bSdZXF+2GxfBkWHUr7yGLQtIwbAm9wOl3e4ARee/9Z9lfKyVqhUCixI6+ZjIn9YKTIuDcn/NZNK94XEUg1SBtw==, } engines: { node: '>=0.14' } - '@cornerstonejs/codec-openjph@2.4.10': + '@cornerstonejs/codec-openjph@2.4.11': resolution: { - integrity: sha512-E9lV25u8xxn3wgWNzNMz8S08QxLeMgzqFAOtIYjCYaPzeg+2UkXwPTDd+MmqXAJPHujgPmMuJGPdayU70GJNHQ==, + integrity: sha512-NzwCYevGSj2JOuP7BztxrOd5WnJ86gOGLv3sq36jZxxHUXoDo1YhzizHiYz+T7GQqkzbfaQcwSyxsRogzGFIPQ==, } engines: { node: '>=0.14' } + '@cornerstonejs/jpeg-lossless-decoder-js@2.2.1': + resolution: + { + integrity: sha512-cDCv7dm3qw9kbxCydvj0UGbNQcsPmTwcyd9SQXkBt34CDgSBeb8Xvj0Gz9HlJ+7DIKOFMTazF1oDJSu0pcCKmg==, + } + '@cspotcode/source-map-support@0.8.1': resolution: { @@ -4439,15 +4455,6 @@ packages: rollup: optional: true - '@rollup/rollup-linux-x64-gnu@4.13.0': - resolution: - { - integrity: sha512-yUD/8wMffnTKuiIsl6xU+4IA8UNhQ/f1sAnQebmE/lyQ8abjsVyDkyRkWop0kdMhKMprpNIhPmYlCxgHrPoXoA==, - } - cpu: [x64] - os: [linux] - libc: [glibc] - '@rspack/binding-darwin-arm64@1.6.0': resolution: { @@ -11318,12 +11325,6 @@ packages: } engines: { node: '>=10' } - jpeg-lossless-decoder-js@2.1.2: - resolution: - { - integrity: sha512-fYf/plymnuKwVw+s8gJ8O/BPw8y8OLaN11MBvEcT05kuy3m45o7BEC4J0JbxMNUYyO+MdK/I/jq0q1gkVYZm2Q==, - } - js-sha3@0.9.3: resolution: { @@ -19112,13 +19113,17 @@ snapshots: '@cornerstonejs/calculate-suv@1.0.3': {} - '@cornerstonejs/codec-charls@1.2.6': {} + '@cornerstonejs/codec-charls@1.2.7': {} + + '@cornerstonejs/codec-libjpeg-turbo-8bit@1.2.7': {} - '@cornerstonejs/codec-libjpeg-turbo-8bit@1.2.5': {} + '@cornerstonejs/codec-libjxl@1.1.1': {} - '@cornerstonejs/codec-openjpeg@1.3.3': {} + '@cornerstonejs/codec-openjpeg@1.3.6': {} - '@cornerstonejs/codec-openjph@2.4.10': {} + '@cornerstonejs/codec-openjph@2.4.11': {} + + '@cornerstonejs/jpeg-lossless-decoder-js@2.2.1': {} '@cspotcode/source-map-support@0.8.1': dependencies: @@ -21438,9 +21443,6 @@ snapshots: optionalDependencies: rollup: 3.30.0 - '@rollup/rollup-linux-x64-gnu@4.13.0': - optional: true - '@rspack/binding-darwin-arm64@1.6.0': optional: true @@ -23807,10 +23809,10 @@ snapshots: dicom-microscopy-viewer@0.48.6: dependencies: - '@cornerstonejs/codec-charls': 1.2.6 - '@cornerstonejs/codec-libjpeg-turbo-8bit': 1.2.5 - '@cornerstonejs/codec-openjpeg': 1.3.3 - '@cornerstonejs/codec-openjph': 2.4.10 + '@cornerstonejs/codec-charls': 1.2.7 + '@cornerstonejs/codec-libjpeg-turbo-8bit': 1.2.7 + '@cornerstonejs/codec-openjpeg': 1.3.6 + '@cornerstonejs/codec-openjph': 2.4.11 colormap: 2.3.2 dcmjs: 0.41.0 dicomicc: 0.1.0 @@ -25935,10 +25937,6 @@ snapshots: joycon@3.1.1: {} - jpeg-lossless-decoder-js@2.1.2: - optionalDependencies: - '@rollup/rollup-linux-x64-gnu': 4.13.0 - js-sha3@0.9.3: {} js-tokens@4.0.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index de3aac950c..fef8982ed0 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -75,10 +75,10 @@ overrides: # picks them up instead. codec-openjph 2.4.7 throws "_setThrew is not defined" on a # truncated codestream, which stops a streaming HTJ2K retrieve from showing anything # until most of the frame has arrived. One version of each codec removes the choice. - '@cornerstonejs/codec-charls': 1.2.6 - '@cornerstonejs/codec-libjpeg-turbo-8bit': 1.2.5 - '@cornerstonejs/codec-openjpeg': 1.3.3 - '@cornerstonejs/codec-openjph': 2.4.10 + '@cornerstonejs/codec-charls': 1.2.7 + '@cornerstonejs/codec-libjpeg-turbo-8bit': 1.2.7 + '@cornerstonejs/codec-openjpeg': 1.3.6 + '@cornerstonejs/codec-openjph': 2.4.11 auditConfig: ignoreGhsas: @@ -104,7 +104,9 @@ allowBuilds: unrs-resolver: false minimumReleaseAgeExclude: - - '@cornerstonejs/codec-charls@1.2.6' - - '@cornerstonejs/codec-libjpeg-turbo-8bit@1.2.5' - - '@cornerstonejs/codec-openjpeg@1.3.3' - - '@cornerstonejs/codec-openjph@2.4.10' + - '@cornerstonejs/codec-charls@1.2.7' + - '@cornerstonejs/codec-libjpeg-turbo-8bit@1.2.7' + - '@cornerstonejs/codec-libjxl@1.1.1' + - '@cornerstonejs/codec-openjpeg@1.3.6' + - '@cornerstonejs/codec-openjph@2.4.11' +