From 557601eac8da908df159f0377ab582f4fbe5ed62 Mon Sep 17 00:00:00 2001 From: jgascona Date: Wed, 7 Oct 2026 11:20:13 +0200 Subject: [PATCH 1/5] Implement get histogram by segment functionality --- src/image/maskImage.js | 126 ++++++++++++++++++++++++++++++++++ tests/image/maskImage.test.js | 125 +++++++++++++++++++++++++++++++++ 2 files changed, 251 insertions(+) diff --git a/src/image/maskImage.js b/src/image/maskImage.js index b9ea7aede6..c2817c7453 100644 --- a/src/image/maskImage.js +++ b/src/image/maskImage.js @@ -5,11 +5,14 @@ import {LabelingThread} from './labelingThread.js'; import {SegmentCollection} from './segmentCollection.js'; import {ImageContour} from './imageContour.js'; import {Image} from './image.js'; +import {logger} from '../utils/logger.js'; +import {MaskSegmentHelper} from './maskSegmentHelper.js'; /** * @import {Geometry} from './geometry.js'; * @import {RGB} from '../utils/colour.js'; * @import {Label} from './label.js'; + * @import {MaskSegment} from '../dicom/dicomSegment.js'; */ const ML_PER_MM = 0.001; // ml/mm^3 @@ -529,4 +532,127 @@ export class MaskImage extends Image { return copy; } + /** + * Get per-volume histograms of reference-image intensities for one segment. + * + * For each volume of `image`, counts how often each rescaled intensity + * appears in voxels belonging to the given segment of this mask. + * Segmentations are 3D: the same mask is applied to every volume. + * Histogram arrays are dense from the min to max intensity found in + * that volume (`histogram[value] === 0` means that intensity is absent). + * + * @param {Image} image The reference image (intensities). + * @param {string} segmentId Segment tracking UID, or segment number as + * string. + * @returns {{volume: number, histogram: number[]}[]} One entry per volume. + */ + getHistogramBySegment(image, segmentId) { + const segment = this.#findSegmentById(segmentId); + if (typeof segment === 'undefined') { + logger.warn( + `getHistogramBySegment: unknown segment id: ${segmentId}`); + return []; + } + + const imageSize = image.getGeometry().getSize(); + const maskSize = this.getGeometry().getSize(); + this.#assertCompatibleHistogramSizes(maskSize, imageSize); + + const segmentValue = typeof segment.displayValue !== 'undefined' + ? segment.displayValue + : segment.number; + + const numberOfVolumes = imageSize.length() === 4 + ? imageSize.get(3) + : 1; + const volumeSize = imageSize.length() === 4 + ? imageSize.getDimSize(3) + : imageSize.getTotalSize(); + + const maskBuffer = this.getBuffer(); + const result = []; + + for (let v = 0; v < numberOfVolumes; ++v) { + const start = v * volumeSize; + const end = start + volumeSize; + /** @type {number[]} */ + const counts = []; + let rmin; + let rmax; + + for (let offset = start; offset < end; ++offset) { + // mask is always 3D spatial: reuse it for each volume + const maskOffset = offset - start; + if (maskBuffer[maskOffset] !== segmentValue) { + continue; + } + const value = image.getRescaledValueAtOffset(offset); + counts[value] = (counts[value] || 0) + 1; + if (typeof rmin === 'undefined' || value < rmin) { + rmin = value; + } + if (typeof rmax === 'undefined' || value > rmax) { + rmax = value; + } + } + + /** @type {number[]} */ + const histogram = []; + if (typeof rmin !== 'undefined') { + for (let b = rmin; b <= rmax; ++b) { + histogram[b] = counts[b] || 0; + } + } + + result.push({volume: v, histogram}); + } + + return result; + } + + /** + * Find a segment by tracking UID or by number as string. + * + * @param {string} segmentId The segment id. + * @returns {MaskSegment|undefined} The segment, or undefined if not found. + */ + #findSegmentById(segmentId) { + const helper = new MaskSegmentHelper(this); + const segments = this.getMeta()?.custom?.segments ?? []; + + for (const segment of segments) { + if (segment.trackingUid === segmentId) { + return segment; + } + } + for (const segment of segments) { + if (String(segment.number) === segmentId) { + return segment; + } + } + // also allow lookup when segments are only known via the helper + // (e.g. freshly added) using the same number-as-string rule + const asNumber = Number(segmentId); + if (!Number.isNaN(asNumber) && helper.hasSegment(asNumber)) { + return helper.getSegment(asNumber); + } + return undefined; + } + + /** + * Ensure mask and image spatial sizes (dims 0-2) are compatible. + * Segmentations are 3D; dim 3 of a 4D reference image is ignored here. + * + * @param {Size} maskSize The mask size. + * @param {Size} imageSize The reference image size. + */ + #assertCompatibleHistogramSizes(maskSize, imageSize) { + for (let i = 0; i < 3; ++i) { + if (maskSize.get(i) !== imageSize.get(i)) { + throw new Error( + 'getHistogramBySegment: mask and image spatial sizes differ.'); + } + } + } + } // MaskImage class diff --git a/tests/image/maskImage.test.js b/tests/image/maskImage.test.js index 11de3121e5..d23da37c7e 100644 --- a/tests/image/maskImage.test.js +++ b/tests/image/maskImage.test.js @@ -7,6 +7,7 @@ import {Matrix33} from '../../src/math/matrix.js'; import {Image} from '../../src/image/image.js'; import {MaskImage} from '../../src/image/maskImage.js'; import {SegmentCollection} from '../../src/image/segmentCollection.js'; +import {MaskSegment} from '../../src/dicom/dicomSegment.js'; /** * Tests for the 'image/maskImage.js' file. @@ -299,4 +300,128 @@ describe('MaskImage', () => { } ); + test( + 'getHistogramBySegment returns one volume histogram for 3D data', + () => { + // mask: segment 1 on first 3 voxels; image intensities beside it + const geom = new Geometry( + [new Point3D(0, 0, 0)], new Size([4, 1, 1]), new Spacing([1, 1, 1])); + const mask = new MaskImage( + geom, new Uint8Array([1, 1, 1, 0]), ['0']); + const segment = new MaskSegment(1, 'seg-1', 'MANUAL'); + mask.setMeta({custom: {segments: [segment]}}); + + const image = new Image( + geom, new Uint8Array([10, 10, 20, 99]), ['0']); + + const result = mask.getHistogramBySegment(image, '1'); + + assert.equal(result.length, 1, 'one volume for 3D'); + assert.equal(result[0].volume, 0); + assert.equal(result[0].histogram[10], 2, 'value 10 twice'); + assert.equal(result[0].histogram[20], 1, 'value 20 once'); + // holes between min and max are filled with zeros + assert.equal(result[0].histogram[15], 0, 'absent intensity is 0'); + assert.isUndefined( + result[0].histogram[99], 'outside-segment voxel ignored'); + } + ); + + test( + 'getHistogramBySegment resolves segment by trackingUid', + () => { + const geom = new Geometry( + [new Point3D(0, 0, 0)], new Size([3, 1, 1]), new Spacing([1, 1, 1])); + const mask = new MaskImage( + geom, new Uint8Array([2, 2, 0]), ['0']); + const segment = new MaskSegment(2, 'seg-2', 'MANUAL'); + segment.trackingUid = '1.2.3.segment-uid'; + mask.setMeta({custom: {segments: [segment]}}); + + const image = new Image( + geom, new Uint8Array([5, 7, 0]), ['0']); + + const result = mask.getHistogramBySegment( + image, '1.2.3.segment-uid'); + + assert.equal(result.length, 1); + assert.equal(result[0].histogram[5], 1); + assert.equal(result[0].histogram[6], 0, 'gap filled with zero'); + assert.equal(result[0].histogram[7], 1); + } + ); + + test( + 'getHistogramBySegment returns empty array for unknown segment', + () => { + const geom = new Geometry( + [new Point3D(0, 0, 0)], new Size([2, 1, 1]), new Spacing([1, 1, 1])); + const mask = new MaskImage( + geom, new Uint8Array([1, 0]), ['0']); + mask.setMeta({ + custom: {segments: [new MaskSegment(1, 'seg-1', 'MANUAL')]} + }); + const image = new Image(geom, new Uint8Array([3, 4]), ['0']); + + const result = mask.getHistogramBySegment(image, 'missing'); + + assert.deepEqual(result, []); + } + ); + + test( + 'getHistogramBySegment returns a histogram per 4D volume ' + + 'using a 3D mask', + () => { + // 3D mask reused on each volume of a 4D image + const maskGeom = new Geometry( + [new Point3D(0, 0, 0)], new Size([2, 1, 1]), new Spacing([1, 1, 1])); + const mask = new MaskImage( + maskGeom, new Uint8Array([1, 0]), ['0']); + mask.setMeta({ + custom: {segments: [new MaskSegment(1, 'seg-1', 'MANUAL')]} + }); + + const imageGeom = new Geometry( + [new Point3D(0, 0, 0)], + new Size([2, 1, 1, 2]), + new Spacing([1, 1, 1, 1])); + // vol0 intensities [10, 11], vol1 [20, 21] + const image = new Image( + imageGeom, new Uint8Array([10, 11, 20, 21]), ['0', '1']); + + const result = mask.getHistogramBySegment(image, '1'); + + assert.equal(result.length, 2, 'two volumes'); + assert.equal(result[0].volume, 0); + assert.equal(result[0].histogram[10], 1); + assert.isUndefined(result[0].histogram[11]); + assert.equal(result[1].volume, 1); + assert.equal(result[1].histogram[20], 1); + assert.isUndefined(result[1].histogram[21]); + } + ); + + test( + 'getHistogramBySegment throws when spatial sizes differ', + () => { + const maskGeom = new Geometry( + [new Point3D(0, 0, 0)], new Size([2, 1, 1]), new Spacing([1, 1, 1])); + const imageGeom = new Geometry( + [new Point3D(0, 0, 0)], new Size([3, 1, 1]), new Spacing([1, 1, 1])); + const mask = new MaskImage( + maskGeom, new Uint8Array([1, 0]), ['0']); + mask.setMeta({ + custom: {segments: [new MaskSegment(1, 'seg-1', 'MANUAL')]} + }); + const image = new Image( + imageGeom, new Uint8Array([1, 2, 3]), ['0']); + + assert.throws( + () => mask.getHistogramBySegment(image, '1'), + /spatial sizes differ/ + ); + } + ); + }); From 09901cf8e828d975b47c81ee869ac62e51d0bc31 Mon Sep 17 00:00:00 2001 From: jgascona Date: Wed, 7 Oct 2026 15:40:12 +0200 Subject: [PATCH 2/5] implement getHistogramIntervalPercentages functionality --- src/image/histogram.js | 164 ++++++++++++++++++++++++++++++++++ src/image/index.js | 2 + tests/image/histogram.test.js | 153 +++++++++++++++++++++++++++++++ 3 files changed, 319 insertions(+) create mode 100644 src/image/histogram.js create mode 100644 tests/image/histogram.test.js diff --git a/src/image/histogram.js b/src/image/histogram.js new file mode 100644 index 0000000000..7876a1beb0 --- /dev/null +++ b/src/image/histogram.js @@ -0,0 +1,164 @@ +/** + * Histogram utilities. + */ + +/** + * Percentage of a histogram that falls in one interval. + * + * @typedef {object} HistogramIntervalPercentage + * @property {string} name Interval name. + * @property {number} percentage Percentage of total counts in [0, 100]. + */ + +/** + * Per-volume histogram (e.g. from {@link MaskImage#getHistogramBySegment}). + * + * @typedef {object} VolumeHistogram + * @property {number} volume Volume index. + * @property {number[]} histogram Sparse/dense counts indexed by intensity. + */ + +/** + * Per-volume interval percentages. + * + * @typedef {object} VolumeHistogramIntervalPercentages + * @property {number} volume Volume index. + * @property {HistogramIntervalPercentage[]} intervals Percentages in + * input order. + */ + +/** + * Sum counts for intensities in the semi-open range [min, max). + * + * @param {number[]} histogram Intensity-indexed counts. + * @param {number} min Inclusive lower bound. + * @param {number} max Exclusive upper bound. + * @returns {number} Sum of counts in the range. + */ +function sumCountsInRange(histogram, min, max) { + let sum = 0; + for (let v = min; v < max; ++v) { + sum += histogram[v] || 0; + } + return sum; +} + +/** + * Sum all counts in a histogram. + * + * @param {number[]} histogram Intensity-indexed counts. + * @returns {number} Total count. + */ +function sumHistogramCounts(histogram) { + let total = 0; + for (let i = 0; i < histogram.length; ++i) { + total += histogram[i] || 0; + } + return total; +} + +/** + * Get the inclusive min intensity and exclusive max intensity present in a + * histogram (indices that are defined, including explicit zeros). + * + * @param {number[]} histogram Intensity-indexed counts. + * @returns {{min: number, maxExclusive: number}|undefined} Range, or + * undefined if the histogram has no defined bins. + */ +function getHistogramIntensityRange(histogram) { + let min; + let max; + for (let i = 0; i < histogram.length; ++i) { + if (typeof histogram[i] !== 'undefined') { + if (typeof min === 'undefined') { + min = i; + } + max = i; + } + } + if (typeof min === 'undefined') { + return undefined; + } + return {min, maxExclusive: max + 1}; +} + +/** + * Build semi-open intervals [min, t0), [t0, t1), ..., [tLast, max) from + * interior thresholds and the histogram intensity range. + * + * @param {number} min Inclusive histogram minimum intensity. + * @param {number} maxExclusive Exclusive histogram maximum intensity. + * @param {number[]} thresholds Interior cut points (ascending). + * @param {string[]} names One name per resulting interval + * (`thresholds.length + 1`). + * @returns {{name: string, min: number, max: number}[]} Named ranges. + */ +function buildIntervalsFromThresholds(min, maxExclusive, thresholds, names) { + const bounds = [min, ...thresholds, maxExclusive]; + const intervals = []; + for (let i = 0; i < names.length; ++i) { + intervals.push({ + name: names[i], + min: bounds[i], + max: bounds[i + 1] + }); + } + return intervals; +} + +/** + * Compute, per volume, the percentage of histogram counts that fall in each + * intensity interval. + * + * The caller only passes interior cut points (e.g. `[1, 5]`). For each volume + * the code builds semi-open intervals covering the histogram range: + * `[min, 1)`, `[1, 5)`, `[5, max)` where `min` / `max` come from that + * volume's histogram. Interval names are provided separately and must have + * length `thresholds.length + 1`. + * + * @param {VolumeHistogram[]} volumeHistograms Per-volume histograms (e.g. + * output of {@link MaskImage#getHistogramBySegment}). + * @param {number[]} thresholds Interior intensity cut points in ascending + * order (e.g. `[1, 5]`). + * @param {string[]} names Names for the resulting intervals + * (`thresholds.length + 1` entries). + * @returns {VolumeHistogramIntervalPercentages[]} One entry per volume, with + * interval percentages in the same order as `names`. + */ +export function getHistogramIntervalPercentages( + volumeHistograms, thresholds, names) { + if (names.length !== thresholds.length + 1) { + throw new Error( + 'getHistogramIntervalPercentages: names length must be ' + + 'thresholds.length + 1.'); + } + + return volumeHistograms.map((volumeHistogram) => { + const {volume, histogram} = volumeHistogram; + const total = sumHistogramCounts(histogram); + + if (total === 0) { + return { + volume, + intervals: names.map((name) => ({name, percentage: 0})) + }; + } + + const range = getHistogramIntensityRange(histogram); + const built = buildIntervalsFromThresholds( + range.min, range.maxExclusive, thresholds, names); + + const intervalResults = built.map((interval) => { + const count = sumCountsInRange(histogram, interval.min, interval.max); + return { + name: interval.name, + percentage: (count * 100) / total + }; + }); + + return { + volume, + intervals: intervalResults + }; + }); +} diff --git a/src/image/index.js b/src/image/index.js index f91822eedf..cef79a7c5d 100644 --- a/src/image/index.js +++ b/src/image/index.js @@ -45,6 +45,7 @@ import {MaskSegmentViewHelper} from './maskSegmentViewHelper.js'; import {SegmentCollection} from './segmentCollection.js'; import {Diameter, Diameters, Label} from './label.js'; import {WindowPreset} from './windowPreset.js'; +import {getHistogramIntervalPercentages} from './histogram.js'; export { Annotation, @@ -80,6 +81,7 @@ export { equalWl, getDefaultDicomSegJson, getDefaultDicomRTStructJson, + getHistogramIntervalPercentages, mergeMaskImages, bufferToPolygons, simplifyPolygon, diff --git a/tests/image/histogram.test.js b/tests/image/histogram.test.js new file mode 100644 index 0000000000..931a3c7c04 --- /dev/null +++ b/tests/image/histogram.test.js @@ -0,0 +1,153 @@ +import {describe, test, assert} from 'vitest'; +import {getHistogramIntervalPercentages} from '../../src/image/histogram.js'; + +/** + * Tests for the 'image/histogram.js' file. + */ + +/** + * Build a dense histogram array from intensity -> count pairs. + * + * @param {Record} counts Map of intensity to count. + * @returns {number[]} Histogram array. + */ +function buildHistogram(counts) { + const histogram = []; + const values = Object.keys(counts).map(Number); + const min = Math.min(...values); + const max = Math.max(...values); + for (let b = min; b <= max; ++b) { + histogram[b] = counts[b] || 0; + } + return histogram; +} + +describe('histogram', () => { + + test('getHistogramIntervalPercentages for one volume', () => { + // intensities: 0×25, 1×40, 5×20, 10×15 → total 100 + // thresholds [1, 5] → [0,1), [1,5), [5,11) + const volumeHistograms = [{ + volume: 0, + histogram: buildHistogram({0: 25, 1: 40, 5: 20, 10: 15}) + }]; + const thresholds = [1, 5]; + const names = ['intervalo1', 'intervalo2', 'intervalo3']; + + const result = getHistogramIntervalPercentages( + volumeHistograms, thresholds, names); + + assert.equal(result.length, 1); + assert.equal(result[0].volume, 0); + assert.equal(result[0].intervals[0].name, 'intervalo1'); + assert.equal(result[0].intervals[0].percentage, 25); + assert.equal(result[0].intervals[1].name, 'intervalo2'); + assert.equal(result[0].intervals[1].percentage, 40); + assert.equal(result[0].intervals[2].name, 'intervalo3'); + assert.equal(result[0].intervals[2].percentage, 35); + }); + + test( + 'getHistogramIntervalPercentages keeps volumes independent', + () => { + const volumeHistograms = [ + { + volume: 0, + histogram: buildHistogram({0: 10}) + }, + { + volume: 1, + histogram: buildHistogram({5: 4, 10: 6}) + } + ]; + const thresholds = [1, 5]; + const names = ['low', 'mid', 'high']; + + const result = getHistogramIntervalPercentages( + volumeHistograms, thresholds, names); + + assert.equal(result.length, 2); + assert.equal(result[0].volume, 0); + // vol0 range [0,1): only low gets mass + assert.equal(result[0].intervals[0].percentage, 100); + assert.equal(result[0].intervals[1].percentage, 0); + assert.equal(result[0].intervals[2].percentage, 0); + assert.equal(result[1].volume, 1); + // vol1 range [5,11): only high gets mass + assert.equal(result[1].intervals[0].percentage, 0); + assert.equal(result[1].intervals[1].percentage, 0); + assert.equal(result[1].intervals[2].percentage, 100); + } + ); + + test( + 'getHistogramIntervalPercentages returns 0 when total is 0', + () => { + const volumeHistograms = [{volume: 0, histogram: []}]; + const thresholds = [1, 5]; + const names = ['a', 'b', 'c']; + + const result = getHistogramIntervalPercentages( + volumeHistograms, thresholds, names); + + assert.equal(result[0].intervals.length, 3); + assert.equal(result[0].intervals[0].percentage, 0); + assert.equal(result[0].intervals[1].percentage, 0); + assert.equal(result[0].intervals[2].percentage, 0); + } + ); + + test( + 'getHistogramIntervalPercentages uses semi-open bounds [min, max)', + () => { + // value 5 sits on the cut: belongs to [5, max), not [min, 5) + const volumeHistograms = [{ + volume: 0, + histogram: buildHistogram({1: 0, 5: 10}) + }]; + const thresholds = [5]; + const names = ['before', 'atAndAfter']; + + const result = getHistogramIntervalPercentages( + volumeHistograms, thresholds, names); + + assert.equal(result[0].intervals[0].percentage, 0); + assert.equal(result[0].intervals[1].percentage, 100); + } + ); + + test( + 'getHistogramIntervalPercentages covers full histogram range', + () => { + // with auto min/max, all mass is assigned → percentages sum to 100 + const volumeHistograms = [{ + volume: 0, + histogram: buildHistogram({2: 50, 100: 50}) + }]; + const thresholds = [10]; + const names = ['low', 'high']; + + const result = getHistogramIntervalPercentages( + volumeHistograms, thresholds, names); + + assert.equal(result[0].intervals[0].percentage, 50); + assert.equal(result[0].intervals[1].percentage, 50); + } + ); + + test( + 'getHistogramIntervalPercentages throws if names length is wrong', + () => { + const volumeHistograms = [{ + volume: 0, + histogram: buildHistogram({0: 1}) + }]; + assert.throws( + () => getHistogramIntervalPercentages( + volumeHistograms, [1, 5], ['only-one']), + /names length must be/ + ); + } + ); + +}); From 3a6d453ac96e2bd5a2508704a090ce545b8f7a5b Mon Sep 17 00:00:00 2001 From: jgascona Date: Wed, 7 Oct 2026 16:11:50 +0200 Subject: [PATCH 3/5] Fix JSDoc sentence case in histogram comments. Co-authored-by: Cursor --- src/image/histogram.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/image/histogram.js b/src/image/histogram.js index 7876a1beb0..8e6a34198d 100644 --- a/src/image/histogram.js +++ b/src/image/histogram.js @@ -11,7 +11,7 @@ */ /** - * Per-volume histogram (e.g. from {@link MaskImage#getHistogramBySegment}). + * Per-volume histogram (for example from {@link MaskImage#getHistogramBySegment}). * * @typedef {object} VolumeHistogram * @property {number} volume Volume index. @@ -116,8 +116,8 @@ function buildIntervalsFromThresholds(min, maxExclusive, thresholds, names) { * volume's histogram. Interval names are provided separately and must have * length `thresholds.length + 1`. * - * @param {VolumeHistogram[]} volumeHistograms Per-volume histograms (e.g. - * output of {@link MaskImage#getHistogramBySegment}). + * @param {VolumeHistogram[]} volumeHistograms Per-volume histograms (for + * example the output of {@link MaskImage#getHistogramBySegment}). * @param {number[]} thresholds Interior intensity cut points in ascending * order (e.g. `[1, 5]`). * @param {string[]} names Names for the resulting intervals From 3c1bdc4e7f590cfaf986debb6ce0db48c94181ed Mon Sep 17 00:00:00 2001 From: jgascona Date: Wed, 7 Oct 2026 16:14:12 +0200 Subject: [PATCH 4/5] Wrap histogram JSDoc line to satisfy max length. Co-authored-by: Cursor --- src/image/histogram.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/image/histogram.js b/src/image/histogram.js index 8e6a34198d..bd5f5e0729 100644 --- a/src/image/histogram.js +++ b/src/image/histogram.js @@ -11,7 +11,8 @@ */ /** - * Per-volume histogram (for example from {@link MaskImage#getHistogramBySegment}). + * Per-volume histogram (for example from + * {@link MaskImage#getHistogramBySegment}). * * @typedef {object} VolumeHistogram * @property {number} volume Volume index. From bb5fb9ea5a94b3c04bf5b5e4e54061dae4a377dd Mon Sep 17 00:00:00 2001 From: jgascona Date: Wed, 7 Oct 2026 16:58:00 +0200 Subject: [PATCH 5/5] getHistogramBySegment requires mask and the image to match in spatial size, orientation, and origin for each slice. --- src/image/maskImage.js | 39 ++++++++++++++++++++++------ tests/image/maskImage.test.js | 49 +++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 8 deletions(-) diff --git a/src/image/maskImage.js b/src/image/maskImage.js index c2817c7453..92b98d4ab2 100644 --- a/src/image/maskImage.js +++ b/src/image/maskImage.js @@ -1,4 +1,5 @@ import {Index} from '../math/index.js'; +import {REAL_WORLD_EPSILON} from '../math/number.js'; import {Size} from './size.js'; import {valueRange} from './iterator.js'; import {LabelingThread} from './labelingThread.js'; @@ -554,9 +555,10 @@ export class MaskImage extends Image { return []; } - const imageSize = image.getGeometry().getSize(); - const maskSize = this.getGeometry().getSize(); - this.#assertCompatibleHistogramSizes(maskSize, imageSize); + const imageGeometry = image.getGeometry(); + const maskGeometry = this.getGeometry(); + this.#assertCompatibleHistogramGeometry(maskGeometry, imageGeometry); + const imageSize = imageGeometry.getSize(); const segmentValue = typeof segment.displayValue !== 'undefined' ? segment.displayValue @@ -640,19 +642,40 @@ export class MaskImage extends Image { } /** - * Ensure mask and image spatial sizes (dims 0-2) are compatible. - * Segmentations are 3D; dim 3 of a 4D reference image is ignored here. + * Ensure mask and image share spatial size (dims 0-2), origins and + * orientation. Segmentations are 3D; dim 3 of a 4D reference image + * is ignored here. * - * @param {Size} maskSize The mask size. - * @param {Size} imageSize The reference image size. + * @param {Geometry} maskGeometry The mask geometry. + * @param {Geometry} imageGeometry The reference image geometry. */ - #assertCompatibleHistogramSizes(maskSize, imageSize) { + #assertCompatibleHistogramGeometry(maskGeometry, imageGeometry) { + const maskSize = maskGeometry.getSize(); + const imageSize = imageGeometry.getSize(); for (let i = 0; i < 3; ++i) { if (maskSize.get(i) !== imageSize.get(i)) { throw new Error( 'getHistogramBySegment: mask and image spatial sizes differ.'); } } + if (!maskGeometry.getOrientation().isSimilar( + imageGeometry.getOrientation(), REAL_WORLD_EPSILON)) { + throw new Error( + 'getHistogramBySegment: mask and image orientations differ.'); + } + const maskOrigins = maskGeometry.getOrigins(); + const imageOrigins = imageGeometry.getOrigins(); + if (maskOrigins.length !== imageOrigins.length) { + throw new Error( + 'getHistogramBySegment: mask and image origins differ.'); + } + for (let i = 0; i < maskOrigins.length; ++i) { + if (!maskOrigins[i].isSimilar( + imageOrigins[i], REAL_WORLD_EPSILON)) { + throw new Error( + 'getHistogramBySegment: mask and image origins differ.'); + } + } } } // MaskImage class diff --git a/tests/image/maskImage.test.js b/tests/image/maskImage.test.js index d23da37c7e..cf9f64e00d 100644 --- a/tests/image/maskImage.test.js +++ b/tests/image/maskImage.test.js @@ -424,4 +424,53 @@ describe('MaskImage', () => { } ); + test( + 'getHistogramBySegment throws when orientations differ', + () => { + const size = new Size([2, 1, 1]); + const spacing = new Spacing([1, 1, 1]); + const origins = [new Point3D(0, 0, 0)]; + const maskGeom = new Geometry(origins, size, spacing); + const imageOrientation = new Matrix33([1, 0, 0, 0, 0, -1, 0, 1, 0]); + const imageGeom = new Geometry( + origins, size, spacing, imageOrientation); + const mask = new MaskImage( + maskGeom, new Uint8Array([1, 0]), ['0']); + mask.setMeta({ + custom: {segments: [new MaskSegment(1, 'seg-1', 'MANUAL')]} + }); + const image = new Image( + imageGeom, new Uint8Array([1, 2]), ['0']); + + assert.throws( + () => mask.getHistogramBySegment(image, '1'), + /orientations differ/ + ); + } + ); + + test( + 'getHistogramBySegment throws when origins differ', + () => { + const size = new Size([2, 1, 1]); + const spacing = new Spacing([1, 1, 1]); + const maskGeom = new Geometry( + [new Point3D(0, 0, 0)], size, spacing); + const imageGeom = new Geometry( + [new Point3D(0, 0, 5)], size, spacing); + const mask = new MaskImage( + maskGeom, new Uint8Array([1, 0]), ['0']); + mask.setMeta({ + custom: {segments: [new MaskSegment(1, 'seg-1', 'MANUAL')]} + }); + const image = new Image( + imageGeom, new Uint8Array([1, 2]), ['0']); + + assert.throws( + () => mask.getHistogramBySegment(image, '1'), + /origins differ/ + ); + } + ); + });