Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
4de2cc2
Fix paths to use newer server for progressive render/htj2k tests
wayfarer3130 Aug 27, 2026
e798ee2
feat(dicomImageLoader): decode truncated HTJ2K at full resolution
wayfarer3130 Sep 1, 2026
e109f3c
Merge origin/main into fix/progressive-render
wayfarer3130 Sep 1, 2026
6ffd196
fix(dicomImageLoader): bound repeat full resolution decodes, route ex…
wayfarer3130 Sep 3, 2026
9bf33a3
Merge origin/main into fix/progressive-render
wayfarer3130 Sep 3, 2026
6a8ac17
feat(dicomImageLoader): split initial and subsequent chunk sizes, thr…
wayfarer3130 Sep 3, 2026
1827f3b
docs(progressive-loading): document that partial decoding is HTJ2K only
wayfarer3130 Sep 3, 2026
fc14807
feat(dicomImageLoader): decode Encapsulated Uncompressed and Deflated…
wayfarer3130 Sep 4, 2026
b4a8bf6
fix(dicomImageLoader): resolve a callback chunkSize before using it a…
wayfarer3130 Sep 4, 2026
0834304
Merge fix/progressive-render into feat/jxl-encapsulated-uncompressed-…
wayfarer3130 Sep 4, 2026
0192e53
feat(dicomImageLoader): decode JPEG XL
wayfarer3130 Sep 4, 2026
c184839
test(dicomImageLoader): revive decoders_test and cover the three new …
wayfarer3130 Sep 4, 2026
efccf45
test(dicomImageLoader): cover the colour path for the new transfer sy…
wayfarer3130 Sep 4, 2026
c811fa5
test(dicomImageLoader): cover JPEG, JPEG-LS and HTJ2K colour, drop en…
wayfarer3130 Sep 4, 2026
225ecd3
test(dicomImageLoader): cover HTJ2K Lossless in grayscale as well as …
wayfarer3130 Sep 4, 2026
4259746
docs(test): record what the pending 4.70 case actually shows
wayfarer3130 Sep 4, 2026
a41a7b2
test(dicomImageLoader): enable the JPEG Lossless SV1 decode case
wayfarer3130 Sep 8, 2026
457beed
test(dicomImageLoader): cover JPEG Baseline decoding against an 8 bit…
wayfarer3130 Sep 10, 2026
0444661
chore(deps): update the cornerstone codec packages to their current r…
wayfarer3130 Sep 10, 2026
3bf52d3
Merge remote-tracking branch 'origin/main' into feat/jxl-encapsulated…
wayfarer3130 Sep 10, 2026
8faccdb
test(dicomImageLoader): add a decoders-only karma script with a decod…
wayfarer3130 Sep 10, 2026
91a259a
Merge remote-tracking branch 'origin/main' into feat/jxl-encapsulated…
jbocce Sep 21, 2026
22e9574
Merge remote-tracking branch 'origin/main' into feat/jxl-encapsulated…
jbocce Sep 21, 2026
bb9d195
fix(dicomImageLoader): decode the last sample of a JPEG Lossless frame
jbocce Sep 21, 2026
2268308
Merge remote-tracking branch 'origin/main' into feat/jxl-encapsulated…
wayfarer3130 Sep 23, 2026
81fed41
fix(dicomImageLoader): remove the second DEFAULT_MS_BETWEEN_DECODE af…
wayfarer3130 Sep 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 26 additions & 10 deletions karma.conf.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand All @@ -132,8 +143,8 @@ module.exports = function (config) {
pattern: `${outputPath}/**/*`,
included: false,
served: true,
watched: false
}
watched: false,
},
],
proxies: {
'/testImages/': '/base/packages/dicomImageLoader/testImages/',
Expand All @@ -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',
Expand Down Expand Up @@ -208,7 +220,7 @@ module.exports = function (config) {
],
},
experiments: {
asyncWebAssembly: true
asyncWebAssembly: true,
},
resolve: {
extensions: ['.ts', '.tsx', '.js', '.jsx'],
Expand All @@ -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'),
},
},
Expand Down
133 changes: 133 additions & 0 deletions karma.decoders.conf.js
Original file line number Diff line number Diff line change
@@ -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 <path>` 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),
}
: {}),
},
},
},
});
};
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
11 changes: 6 additions & 5 deletions packages/dicomImageLoader/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
Original file line number Diff line number Diff line change
@@ -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/
);
});
});
Loading
Loading