From 7a1d3a3ae7b0e7db0d54c1eb184ac88cbd99caef Mon Sep 17 00:00:00 2001 From: Alireza Date: Fri, 28 Jul 2023 11:50:11 -0400 Subject: [PATCH] feat(segmentation export): add new cornerstone3D segmentation export adapter (#692) * chore(adapters): ability to cache and build ESM modules * wip * refactoring * remove bound ijk which is unnecessary since we moved to world * add data set module to the metadata providers * add a new method to segmentation state * add the new parser * work without rle encode * fix demo for export color * add centroid calculations * add trigger event for segmentation load progress * update dcmjs * update api * fix example * remove the promise await * apply review comments * add adapters section to examples * add adapters dep to docs * add eslint rule * example deploy --- .eslintrc.json | 2 + common/reviews/api/tools.api.md | 4 + nx.json | 2 +- package.json | 1 + packages/adapters/config/webpack/index.js | 4 - packages/adapters/config/webpack/merge.js | 15 - .../adapters/config/webpack/webpack-base.js | 30 - .../adapters/config/webpack/webpack-dev.js | 16 - .../adapters/config/webpack/webpack-prod.js | 16 - packages/adapters/examples/index.html | 21 - .../examples/segmentationExport/index.ts | 320 +++++++++ packages/adapters/netlify.toml | 20 - .../cornerstoneDICOMSR/index.html | 0 packages/adapters/package.json | 21 +- packages/adapters/rollup.config.mjs | 11 +- .../adapters/Cornerstone/Segmentation_4X.js | 630 ++++++++++++------ .../src/adapters/Cornerstone/index.ts | 12 +- .../Segmentation/generateLabelMaps2DFrom3D.ts | 68 ++ .../Segmentation/generateSegmentation.ts | 59 ++ .../Segmentation/generateToolState.ts | 36 + .../Cornerstone3D/Segmentation/index.ts | 5 + .../src/adapters/Cornerstone3D/index.ts | 9 +- .../src/adapters/VTKjs/Segmentation.js | 4 +- packages/adapters/src/adapters/VTKjs/index.ts | 4 +- .../adapters/src/adapters/enums/Events.ts | 11 + packages/adapters/src/adapters/enums/index.ts | 3 + packages/adapters/src/adapters/helpers.js | 19 - .../src/adapters/helpers/codeMeaningEquals.ts | 16 + .../src/adapters/helpers/graphicTypeEquals.ts | 12 + .../adapters/src/adapters/helpers/index.ts | 5 + .../adapters/src/adapters/helpers/toArray.ts | 3 + packages/adapters/src/adapters/index.ts | 23 +- packages/adapters/src/index.ts | 5 +- packages/adapters/tsconfig.json | 12 +- packages/core/package.json | 1 + .../core/src/RenderingEngine/StackViewport.ts | 4 +- packages/dicomImageLoader/package.json | 1 - .../src/imageLoader/getInstanceModule.ts | 57 ++ .../wadors/metaData/metaDataProvider.ts | 9 + .../wadouri/metaData/metaDataProvider.ts | 8 + packages/docs/package.json | 3 +- .../package.json | 1 + .../src/BaseStreamingImageVolume.ts | 59 +- packages/tools/package.json | 1 + .../segmentation/SegmentationStateManager.ts | 19 + .../segmentation/segmentationState.ts | 17 + .../segmentation/strategies/eraseRectangle.ts | 4 - .../segmentation/strategies/fillCircle.ts | 4 - .../segmentation/strategies/fillRectangle.ts | 4 - utils/ExampleRunner/build-all-examples-cli.js | 4 + utils/ExampleRunner/example-info.json | 9 + utils/ExampleRunner/example-runner-cli.js | 4 + utils/ExampleRunner/template-config.js | 2 + .../template-multiexample-config.js | 2 + yarn.lock | 40 +- 55 files changed, 1261 insertions(+), 411 deletions(-) delete mode 100644 packages/adapters/config/webpack/index.js delete mode 100644 packages/adapters/config/webpack/merge.js delete mode 100644 packages/adapters/config/webpack/webpack-base.js delete mode 100644 packages/adapters/config/webpack/webpack-dev.js delete mode 100644 packages/adapters/config/webpack/webpack-prod.js delete mode 100755 packages/adapters/examples/index.html create mode 100644 packages/adapters/examples/segmentationExport/index.ts delete mode 100644 packages/adapters/netlify.toml rename packages/adapters/{examples => old-examples}/cornerstoneDICOMSR/index.html (100%) create mode 100644 packages/adapters/src/adapters/Cornerstone3D/Segmentation/generateLabelMaps2DFrom3D.ts create mode 100644 packages/adapters/src/adapters/Cornerstone3D/Segmentation/generateSegmentation.ts create mode 100644 packages/adapters/src/adapters/Cornerstone3D/Segmentation/generateToolState.ts create mode 100644 packages/adapters/src/adapters/Cornerstone3D/Segmentation/index.ts create mode 100644 packages/adapters/src/adapters/enums/Events.ts create mode 100644 packages/adapters/src/adapters/enums/index.ts delete mode 100644 packages/adapters/src/adapters/helpers.js create mode 100644 packages/adapters/src/adapters/helpers/codeMeaningEquals.ts create mode 100644 packages/adapters/src/adapters/helpers/graphicTypeEquals.ts create mode 100644 packages/adapters/src/adapters/helpers/index.ts create mode 100644 packages/adapters/src/adapters/helpers/toArray.ts create mode 100644 packages/dicomImageLoader/src/imageLoader/getInstanceModule.ts diff --git a/.eslintrc.json b/.eslintrc.json index 42a6c1fcc9..34122cc969 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -53,6 +53,8 @@ "tsconfigRootDir": "./" }, "rules": { + // Enforce consistent brace style for all control statements for readability + "curly": "error", "tsdoc/syntax": "warn", "@typescript-eslint/ban-ts-comment": "off", "no-console": [ diff --git a/common/reviews/api/tools.api.md b/common/reviews/api/tools.api.md index 40d03ebe6a..68980ed57a 100644 --- a/common/reviews/api/tools.api.md +++ b/common/reviews/api/tools.api.md @@ -1981,6 +1981,9 @@ function getActiveSegmentationRepresentation(toolGroupId: string): ToolGroupSpec // @public (undocumented) function getActiveSegmentIndex(segmentationId: string): number | undefined; +// @public (undocumented) +function getAllSegmentationRepresentations(): Record; + // @public (undocumented) function getAllSynchronizers(): Array; @@ -4863,6 +4866,7 @@ declare namespace state_3 { getSegmentSpecificRepresentationConfig, setSegmentSpecificRepresentationConfig, getToolGroupIdsWithSegmentation, + getAllSegmentationRepresentations, getSegmentationRepresentationByUID, addColorLUT, getColorLUT, diff --git a/nx.json b/nx.json index 259c24b9cc..e9f8ae07e1 100644 --- a/nx.json +++ b/nx.json @@ -3,7 +3,7 @@ "default": { "runner": "nx/tasks-runners/default", "options": { - "cacheableOperations": ["build", "test"] + "cacheableOperations": ["build", "test", "build:esm"] } } }, diff --git a/package.json b/package.json index 50c68e06cf..360121021f 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "build": "npx lerna run build --stream", "build:esm": "npx lerna run build:esm --stream", "build:umd": "npx lerna run build:umd --stream", + "watch": "npx lerna watch -- lerna run build --scope=$LERNA_PACKAGE_NAME --include-dependents", "build:update-api:core": "cd packages/core && npm run build:update-api", "build:update-api:tools": "cd packages/tools && npm run build:update-api", "build:update-api:adapters": "cd packages/adapters && npm run build:update-api", diff --git a/packages/adapters/config/webpack/index.js b/packages/adapters/config/webpack/index.js deleted file mode 100644 index 29688123cb..0000000000 --- a/packages/adapters/config/webpack/index.js +++ /dev/null @@ -1,4 +0,0 @@ -const env = process.env.ENV || 'dev'; -const config = require(`./webpack-${env}`); - -module.exports = config; \ No newline at end of file diff --git a/packages/adapters/config/webpack/merge.js b/packages/adapters/config/webpack/merge.js deleted file mode 100644 index 1c754acd94..0000000000 --- a/packages/adapters/config/webpack/merge.js +++ /dev/null @@ -1,15 +0,0 @@ -const _ = require('lodash'); - -// Merge two objects -// Instead of merging array objects index by index (n-th source -// item with n-th object item) it concatenates both arrays -module.exports = function(object, source) { - const clone = _.cloneDeep(object); - const merged = _.mergeWith(clone, source, function(objValue, srcValue, key, object, source, stack) { - if(objValue && srcValue && _.isArray(objValue) && _.isArray(srcValue)) { - return _.concat(objValue, srcValue); - } - }); - - return merged; -} \ No newline at end of file diff --git a/packages/adapters/config/webpack/webpack-base.js b/packages/adapters/config/webpack/webpack-base.js deleted file mode 100644 index e8c5f9e9de..0000000000 --- a/packages/adapters/config/webpack/webpack-base.js +++ /dev/null @@ -1,30 +0,0 @@ -const path = require('path'); -const rootPath = process.cwd(); -const context = path.join(rootPath, "src"); -const outputPath = path.join(rootPath, 'build'); - -module.exports = { - context, - entry: { - dcmjs: './dcmjs.js' - }, - output: { - filename: 'dcmjs.js', - library: 'dcmjs', - libraryTarget: 'umd', - path: outputPath, - umdNamedDefine: true - }, - devtool: 'source-map', - module: { - rules: [ - { - test: /\.js$/, - exclude: /(node_modules)/, - use: [{ - loader: 'babel-loader' - }] - } - ] - } -}; diff --git a/packages/adapters/config/webpack/webpack-dev.js b/packages/adapters/config/webpack/webpack-dev.js deleted file mode 100644 index 97c6e4dc47..0000000000 --- a/packages/adapters/config/webpack/webpack-dev.js +++ /dev/null @@ -1,16 +0,0 @@ -const webpack = require('webpack'); -const merge = require('./merge'); -const baseConfig = require('./webpack-base'); - -const devConfig = { - mode: "development", - devServer: { - hot: true, - publicPath: '/build/' - }, - plugins: [ - new webpack.HotModuleReplacementPlugin({}) - ] -}; - -module.exports = merge(baseConfig, devConfig); \ No newline at end of file diff --git a/packages/adapters/config/webpack/webpack-prod.js b/packages/adapters/config/webpack/webpack-prod.js deleted file mode 100644 index faccc09acd..0000000000 --- a/packages/adapters/config/webpack/webpack-prod.js +++ /dev/null @@ -1,16 +0,0 @@ -const merge = require('./merge'); -const baseConfig = require('./webpack-base'); -const UglifyJSPlugin = require('uglifyjs-webpack-plugin'); - -const prodConfig = { - mode: "production", - optimization: { - minimizer: [ - new UglifyJSPlugin({ - sourceMap: true - }) - ] - }, -}; - -module.exports = merge(baseConfig, prodConfig); \ No newline at end of file diff --git a/packages/adapters/examples/index.html b/packages/adapters/examples/index.html deleted file mode 100755 index b0f9e7cd7f..0000000000 --- a/packages/adapters/examples/index.html +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - -
- - -
    -
-
- - diff --git a/packages/adapters/examples/segmentationExport/index.ts b/packages/adapters/examples/segmentationExport/index.ts new file mode 100644 index 0000000000..c44896a1f8 --- /dev/null +++ b/packages/adapters/examples/segmentationExport/index.ts @@ -0,0 +1,320 @@ +import { + RenderingEngine, + Types, + Enums, + setVolumesForViewports, + volumeLoader, + cache, + metaData +} from "@cornerstonejs/core"; +import { + initDemo, + setTitleAndDescription, + addButtonToToolbar, + createImageIdsAndCacheMetaData +} from "../../../../utils/demo/helpers"; +import * as cornerstoneTools from "@cornerstonejs/tools"; +import { adaptersSEG } from "@cornerstonejs/adapters"; +import dcmjs from "dcmjs"; + +// This is for debugging purposes +console.warn( + "Click on index.ts to open source code for this example --------->" +); + +const { Cornerstone3D } = adaptersSEG; + +const { + SegmentationDisplayTool, + StackScrollMouseWheelTool, + ToolGroupManager, + Enums: csToolsEnums, + segmentation +} = cornerstoneTools; + +const { ViewportType } = Enums; + +// Define a unique id for the volume +const volumeName = "CT_VOLUME_ID"; // Id of the volume less loader prefix +const volumeLoaderScheme = "cornerstoneStreamingImageVolume"; // Loader id which defines which volume loader to use +const volumeId = `${volumeLoaderScheme}:${volumeName}`; // VolumeId with loader id + volume id +const segmentationId = "MY_SEGMENTATION_ID"; +const toolGroupId = "MY_TOOLGROUP_ID"; +// Create the viewports +const viewportId1 = "CT_AXIAL"; +const viewportId2 = "CT_SAGITTAL"; +const viewportId3 = "CT_CORONAL"; + +// ======== Set up page ======== // +setTitleAndDescription( + "DICOM SEG Export", + "Here we demonstrate how to export a DICOM SEG from a Cornerstone3D volume." +); + +const size = "500px"; +const content = document.getElementById("content"); +const viewportGrid = document.createElement("div"); + +viewportGrid.style.display = "flex"; +viewportGrid.style.display = "flex"; +viewportGrid.style.flexDirection = "row"; + +const element1 = document.createElement("div"); +const element2 = document.createElement("div"); +const element3 = document.createElement("div"); +element1.style.width = size; +element1.style.height = size; +element2.style.width = size; +element2.style.height = size; +element3.style.width = size; +element3.style.height = size; + +viewportGrid.appendChild(element1); +viewportGrid.appendChild(element2); +viewportGrid.appendChild(element3); + +content.appendChild(viewportGrid); +let renderingEngine; +let segmentationVolume; +let segmentationRepresentationUID; + +function generateMockMetadata(segmentIndex, color) { + const RecommendedDisplayCIELabValue = dcmjs.data.Colors.rgb2DICOMLAB( + color.slice(0, 3).map(value => value / 255) + ).map(value => Math.round(value)); + + return { + SegmentedPropertyCategoryCodeSequence: { + CodeValue: "T-D0050", + CodingSchemeDesignator: "SRT", + CodeMeaning: "Tissue" + }, + SegmentNumber: segmentIndex.toString(), + SegmentLabel: "Tissue " + segmentIndex.toString(), + SegmentAlgorithmType: "SEMIAUTOMATIC", + SegmentAlgorithmName: "Slicer Prototype", + RecommendedDisplayCIELabValue, + SegmentedPropertyTypeCodeSequence: { + CodeValue: "T-D0050", + CodingSchemeDesignator: "SRT", + CodeMeaning: "Tissue" + } + }; +} + +// ============================= // + +addButtonToToolbar({ + title: "Create SEG", + onClick: async () => { + const volume = cache.getVolume(volumeId); + const segUID = segmentationRepresentationUID[0]; + + const images = volume.getCornerstoneImages(); + + const labelmapObj = + Cornerstone3D.Segmentation.generateLabelMaps2DFrom3D( + segmentationVolume + ); + + // Generate fake metadata as an example + labelmapObj.metadata = []; + labelmapObj.segmentsOnLabelmap.forEach(segmentIndex => { + const color = segmentation.config.color.getColorForSegmentIndex( + toolGroupId, + segUID, + segmentIndex + ); + + const segmentMetadata = generateMockMetadata(segmentIndex, color); + labelmapObj.metadata[segmentIndex] = segmentMetadata; + }); + + const segBlob = Cornerstone3D.Segmentation.generateSegmentation( + images, + labelmapObj, + metaData + ); + + //Create a URL for the binary. + const objectUrl = URL.createObjectURL(segBlob); + window.open(objectUrl); + } +}); + +/** + * Adds two concentric circles to each axial slice of the demo segmentation. + */ +function createMockEllipsoidSegmentation( + segmentationVolume, + outerRadius = 20, + innerRadius = 10, + center, + labels = [1, 2] +) { + const { dimensions, scalarData } = segmentationVolume; + + const centerToUse = + center === "center" + ? [dimensions[0] / 2, dimensions[1] / 2, dimensions[2] / 2] + : center; + + let voxelIndex = 0; + + for (let z = 0; z < dimensions[2]; z++) { + for (let y = 0; y < dimensions[1]; y++) { + for (let x = 0; x < dimensions[0]; x++) { + const distanceFromCenter = Math.sqrt( + (x - centerToUse[0]) * (x - centerToUse[0]) + + (y - centerToUse[1]) * (y - centerToUse[1]) + + (z - centerToUse[2]) * (z - centerToUse[2]) + ); + if (distanceFromCenter < innerRadius) { + scalarData[voxelIndex] = labels[0]; + } else if (distanceFromCenter < outerRadius) { + scalarData[voxelIndex] = labels[1]; + } + + voxelIndex++; + } + } + } +} + +async function addSegmentationsToState() { + // Create a segmentation of the same resolution as the source data + // using volumeLoader.createAndCacheDerivedVolume. + segmentationVolume = await volumeLoader.createAndCacheDerivedVolume( + volumeId, + { + volumeId: segmentationId + } + ); + + // Add the segmentations to state + segmentation.addSegmentations([ + { + segmentationId, + representation: { + // The type of segmentation + type: csToolsEnums.SegmentationRepresentations.Labelmap, + // The actual segmentation data, in the case of labelmap this is a + // reference to the source volume of the segmentation. + data: { + volumeId: segmentationId + } + } + } + ]); + + // Add some data to the segmentations + // createMockEllipsoidSegmentation(segmentationVolume); + createMockEllipsoidSegmentation( + segmentationVolume, + 40, + 20, + [250, 100, 125], + [1, 2] + ); +} + +/** + * Runs the demo + */ +async function run() { + // Init Cornerstone and related libraries + await initDemo(); + + // Add tools to Cornerstone3D + cornerstoneTools.addTool(SegmentationDisplayTool); + cornerstoneTools.addTool(StackScrollMouseWheelTool); + + // Define tool groups to add the segmentation display tool to + const toolGroup = ToolGroupManager.createToolGroup(toolGroupId); + + toolGroup.addTool(SegmentationDisplayTool.toolName); + toolGroup.addTool(StackScrollMouseWheelTool.toolName); + toolGroup.setToolEnabled(SegmentationDisplayTool.toolName); + toolGroup.setToolActive(StackScrollMouseWheelTool.toolName); + + // Get Cornerstone imageIds for the source data and fetch metadata into RAM + const imageIds = await createImageIdsAndCacheMetaData({ + StudyInstanceUID: + "1.3.6.1.4.1.14519.5.2.1.7009.2403.334240657131972136850343327463", + SeriesInstanceUID: + "1.3.6.1.4.1.14519.5.2.1.7009.2403.226151125820845824875394858561", + wadoRsRoot: "https://d3t6nz73ql33tx.cloudfront.net/dicomweb" + }); + + // Define a volume in memory + const volume = await volumeLoader.createAndCacheVolume(volumeId, { + imageIds + }); + + // Add some segmentations based on the source data volume + await addSegmentationsToState(); + + // Instantiate a rendering engine + const renderingEngineId = "myRenderingEngine"; + renderingEngine = new RenderingEngine(renderingEngineId); + + const viewportInputArray = [ + { + viewportId: viewportId1, + type: ViewportType.ORTHOGRAPHIC, + element: element1, + defaultOptions: { + orientation: Enums.OrientationAxis.AXIAL, + background: [0.2, 0, 0.2] + } + }, + { + viewportId: viewportId2, + type: ViewportType.ORTHOGRAPHIC, + element: element2, + defaultOptions: { + orientation: Enums.OrientationAxis.SAGITTAL, + background: [0.2, 0, 0.2] + } + }, + { + viewportId: viewportId3, + type: ViewportType.ORTHOGRAPHIC, + element: element3, + defaultOptions: { + orientation: Enums.OrientationAxis.CORONAL, + background: [0.2, 0, 0.2] + } + } + ]; + + renderingEngine.setViewports(viewportInputArray); + + toolGroup.addViewport(viewportId1, renderingEngineId); + toolGroup.addViewport(viewportId2, renderingEngineId); + toolGroup.addViewport(viewportId3, renderingEngineId); + + // Set the volume to load + volume.load(); + + // Set volumes on the viewports + await setVolumesForViewports( + renderingEngine, + [{ volumeId }], + [viewportId1, viewportId2, viewportId3] + ); + + // // Add the segmentation representation to the toolgroup + segmentationRepresentationUID = + await segmentation.addSegmentationRepresentations(toolGroupId, [ + { + segmentationId, + type: csToolsEnums.SegmentationRepresentations.Labelmap + } + ]); + + // Render the image + renderingEngine.renderViewports([viewportId1, viewportId2, viewportId3]); +} + +run(); diff --git a/packages/adapters/netlify.toml b/packages/adapters/netlify.toml deleted file mode 100644 index 22e53a37b0..0000000000 --- a/packages/adapters/netlify.toml +++ /dev/null @@ -1,20 +0,0 @@ -# Netlify Config -# -# TOML Reference: -# https://www.netlify.com/docs/netlify-toml-reference/ - -# Settings in the [build] context are global and are applied to all contexts -# unless otherwise overridden by more specific contexts. -[build] - # Directory to change to before starting a build. - # This is where we will look for package.json/.nvmrc/etc. - base = "" - publish = "examples/" - command = "npm run build:examples" - -# Deploy Preview context: all deploys generated from a pull/merge request will -# inherit these settings. -[context.deploy-preview] - base = "" - publish = "examples/" - command = "npm run build:examples" diff --git a/packages/adapters/examples/cornerstoneDICOMSR/index.html b/packages/adapters/old-examples/cornerstoneDICOMSR/index.html similarity index 100% rename from packages/adapters/examples/cornerstoneDICOMSR/index.html rename to packages/adapters/old-examples/cornerstoneDICOMSR/index.html diff --git a/packages/adapters/package.json b/packages/adapters/package.json index 15a9a90b94..b87d52d167 100644 --- a/packages/adapters/package.json +++ b/packages/adapters/package.json @@ -9,23 +9,24 @@ "dist" ], "directories": { - "example": "examples", "build": "dist" }, "exports": { - ".": "./dist/@cornerstonejs/adapters.es.js" + "import": "./dist/adapters.es.js" }, - "types": "./dist/@cornerstonejs/dts/index.d.ts", + "types": "./dist/types/index.d.ts", "publishConfig": { "access": "public" }, "scripts": { "test": "jest --testTimeout 60000", "build": "rollup -c rollup.config.mjs", + "build:esm": "rollup -c rollup.config.mjs", + "build:esm:watch": "rollup --watch -c rollup.config.mjs", + "dev": "rollup --watch -c rollup.config.mjs", "build:all": "yarn build", - "build:examples": "npm run build && npx cpx 'dist/**/*.{js,map}' examples/js", "build:update-api": "yarn run build && api-extractor run --local", - "start": "rollup -c -w", + "start": "rollup --watch -c rollup.config.mjs", "format": "prettier --write 'src/**/*.js' 'test/**/*.js'", "lint": "eslint --fix ." }, @@ -41,9 +42,15 @@ "homepage": "https://github.com/cornerstonejs/cornerstone3D/blob/main/packages/adapters/README.md", "dependencies": { "@babel/runtime-corejs2": "^7.17.8", - "dcmjs": "^0.29.5", + "dcmjs": "^0.29.8", "gl-matrix": "^3.4.3", "lodash.clonedeep": "^4.5.0", - "ndarray": "^1.0.19" + "ndarray": "^1.0.19", + "buffer": "^6.0.3" + }, + "devDependencies": { + "@cornerstonejs/core": "^1.2.6", + "@cornerstonejs/tools": "^1.2.6", + "@cornerstonejs/streaming-image-volume-loader": "^1.2.6" } } diff --git a/packages/adapters/rollup.config.mjs b/packages/adapters/rollup.config.mjs index ca5c98f54f..40312df9a3 100644 --- a/packages/adapters/rollup.config.mjs +++ b/packages/adapters/rollup.config.mjs @@ -18,7 +18,7 @@ export default { // sourcemap: true // }, { - file: `dist/${pkg.name}.es.js`, + file: `dist/adapters.es.js`, format: "es", sourcemap: true } @@ -28,11 +28,14 @@ export default { browser: true }), commonjs(), - typescript(), - // globals(), + typescript({ + tsconfig: "./tsconfig.json" + }), + // globals(), // builtins(), babel({ - exclude: "node_modules/**" + exclude: "node_modules/**", + babelHelpers: "bundled" }), json() ] diff --git a/packages/adapters/src/adapters/Cornerstone/Segmentation_4X.js b/packages/adapters/src/adapters/Cornerstone/Segmentation_4X.js index e4920c5f18..bf030d8c25 100644 --- a/packages/adapters/src/adapters/Cornerstone/Segmentation_4X.js +++ b/packages/adapters/src/adapters/Cornerstone/Segmentation_4X.js @@ -1,6 +1,15 @@ -import { log, utilities, normalizers, derivations } from "dcmjs"; +import { + log, + data as dcmjsData, + utilities, + normalizers, + derivations +} from "dcmjs"; import ndarray from "ndarray"; import cloneDeep from "lodash.clonedeep"; +import { Buffer } from "buffer"; + +import { Events } from "../enums"; const { rotateDirectionCosinesInPlane, @@ -10,22 +19,14 @@ const { nearlyEqual } = utilities.orientation; -const { datasetToBlob, BitArray, DicomMessage, DicomMetaDictionary } = - utilities; +const { datasetToDict, BitArray, DicomMessage, DicomMetaDictionary } = + dcmjsData; const { Normalizer } = normalizers; const { Segmentation: SegmentationDerivation } = derivations; const { encode, decode } = utilities.compression; -const Segmentation = { - generateSegmentation, - generateToolState, - fillSegmentation -}; - -export default Segmentation; - /** * * @typedef {Object} BrushData @@ -33,10 +34,9 @@ export default Segmentation; * @property {Object[]} segments - The cornerstoneTools segment metadata that corresponds to the * seriesInstanceUid. */ - const generateSegmentationDefaultOptions = { includeSliceSpacing: true, - rleEncode: true + rleEncode: false }; /** @@ -146,7 +146,7 @@ function fillSegmentation(segmentation, inputLabelmaps3D, userOptions = {}) { } ); const segmentMetadata = metadata[segmentIndex]; - const labelmaps = _getLabelmapsFromRefernecedFrameIndicies( + const labelmaps = _getLabelmapsFromReferencedFrameIndicies( labelmap3D, referencedFrameIndicies ); @@ -160,7 +160,6 @@ function fillSegmentation(segmentation, inputLabelmaps3D, userOptions = {}) { } } } - if (options.rleEncode) { const rleEncodedFrames = encode( segmentation.dataset.PixelData, @@ -192,12 +191,13 @@ function fillSegmentation(segmentation, inputLabelmaps3D, userOptions = {}) { segmentation.bitPackPixelData(); } - const segBlob = datasetToBlob(segmentation.dataset); + const buffer = Buffer.from(datasetToDict(segmentation.dataset).write()); + const segBlob = new Blob([buffer], { type: "application/dicom" }); return segBlob; } -function _getLabelmapsFromRefernecedFrameIndicies( +function _getLabelmapsFromReferencedFrameIndicies( labelmap3D, referencedFrameIndicies ) { @@ -260,8 +260,7 @@ function _createSegFromImages(images, isMultiframe, options) { * @param {string[]} imageIds - An array of the imageIds. * @param {ArrayBuffer} arrayBuffer - The SEG arrayBuffer. * @param {*} metadataProvider. - * @param {bool} skipOverlapping - skip checks for overlapping segs, default value false. - * @param {number} tolerance - default value 1.e-3. + * @param {obj} options - Options object. * * @return {[]ArrayBuffer}a list of array buffer for each labelMap * @return {Object} an object from which the segment metadata can be derived @@ -269,13 +268,20 @@ function _createSegFromImages(images, isMultiframe, options) { * @return {[][][]} 3D list containing the track of segments per frame for each labelMap * (available only for the overlapping case). */ -function generateToolState( +async function generateToolState( imageIds, arrayBuffer, metadataProvider, - skipOverlapping = false, - tolerance = 1e-3 + options ) { + const { + skipOverlapping = false, + tolerance = 1e-3, + TypedArrayConstructor = Uint8Array, + maxBytesPerChunk = 199000000, + eventTarget, + triggerEvent + } = options; const dicomData = DicomMessage.readFile(arrayBuffer); const dataset = DicomMetaDictionary.naturalizeDataset(dicomData.dict); dataset._meta = DicomMetaDictionary.namifyDataset(dicomData.meta); @@ -317,6 +323,7 @@ function generateToolState( const TransferSyntaxUID = multiframe._meta.TransferSyntaxUID.Value[0]; let pixelData; + let pixelDataChunks; if (TransferSyntaxUID === "1.2.840.10008.1.2.5") { const rleEncodedFrames = Array.isArray(multiframe.PixelData) @@ -334,10 +341,13 @@ function generateToolState( return; } + + // Todo: need to test this with rle data + pixelDataChunks = [pixelData]; } else { - pixelData = unpackPixelData(multiframe); + pixelDataChunks = unpackPixelData(multiframe, { maxBytesPerChunk }); - if (!pixelData) { + if (!pixelDataChunks) { throw new Error("Fractional segmentations are not yet supported"); } } @@ -349,15 +359,29 @@ function generateToolState( tolerance ); + // Pre-compute the sop UID to imageId index map so that in the for loop + // we don't have to call metadataProvider.get() for each imageId over + // and over again. + const sopUIDImageIdIndexMap = imageIds.reduce((acc, imageId) => { + const { sopInstanceUid } = metadataProvider.get( + "generalImageModule", + imageId + ); + acc[sopInstanceUid] = imageId; + return acc; + }, {}); + let overlapping = false; if (!skipOverlapping) { overlapping = checkSEGsOverlapping( - pixelData, + pixelDataChunks, multiframe, imageIds, validOrientations, metadataProvider, - tolerance + tolerance, + TypedArrayConstructor, + sopUIDImageIdIndexMap ); } @@ -392,27 +416,68 @@ function generateToolState( segmentsOnFrameArray[0] = []; const segmentsOnFrame = []; - const arrayBufferLength = sliceLength * imageIds.length * 2; // 2 bytes per label voxel in cst4. + const arrayBufferLength = + sliceLength * imageIds.length * TypedArrayConstructor.BYTES_PER_ELEMENT; const labelmapBufferArray = []; labelmapBufferArray[0] = new ArrayBuffer(arrayBufferLength); - insertFunction( + // Precompute the indices and metadata so that we don't have to call + // a function for each imageId in the for loop. + const imageIdMaps = imageIds.reduce( + (acc, curr, index) => { + acc.indices[curr] = index; + acc.metadata[curr] = metadataProvider.get("instance", curr); + return acc; + }, + { indices: {}, metadata: {} } + ); + + // This is the centroid calculation for each segment Index, the data structure + // is a Map with key = segmentIndex and value = {imageIdIndex: centroid, ...} + // later on we will use this data structure to calculate the centroid of the + // segment in the labelmapBuffer + const segmentsPixelIndices = new Map(); + + await insertFunction( segmentsOnFrame, segmentsOnFrameArray, labelmapBufferArray, - pixelData, + pixelDataChunks, multiframe, imageIds, validOrientations, metadataProvider, - tolerance + tolerance, + TypedArrayConstructor, + segmentsPixelIndices, + sopUIDImageIdIndexMap, + imageIdMaps, + eventTarget, + triggerEvent ); + // calculate the centroid of each segment + const centroidXYZ = new Map(); + + segmentsPixelIndices.forEach((imageIdIndexBufferIndex, segmentIndex) => { + const { xAcc, yAcc, zAcc, count } = calculateCentroid( + imageIdIndexBufferIndex, + multiframe + ); + + centroidXYZ.set(segmentIndex, { + x: Math.floor(xAcc / count), + y: Math.floor(yAcc / count), + z: Math.floor(zAcc / count) + }); + }); + return { labelmapBufferArray, segMetadata, segmentsOnFrame, - segmentsOnFrameArray + segmentsOnFrameArray, + centroids: centroidXYZ }; } @@ -593,8 +658,7 @@ function generateToolState( * @param {Object} multiframe dicom metadata * @param {Int} frameSegment frame dicom index * @param {String[]} imageIds A list of imageIds. - * @param {Object} metadataProvider A Cornerstone metadataProvider to query - * metadata from imageIds. + * @param {Object} sopUIDImageIdIndexMap A map of SOPInstanceUID to imageId * @param {Float} tolerance The tolerance parameter * * @returns {String} Returns the imageId @@ -604,7 +668,8 @@ function findReferenceSourceImageId( frameSegment, imageIds, metadataProvider, - tolerance + tolerance, + sopUIDImageIdIndexMap ) { let imageId = undefined; @@ -661,10 +726,9 @@ function findReferenceSourceImageId( } if (frameSourceImageSequence) { - imageId = getImageIdOfSourceImagebySourceImageSequence( + imageId = getImageIdOfSourceImageBySourceImageSequence( frameSourceImageSequence, - imageIds, - metadataProvider + sopUIDImageIdIndexMap ); } @@ -699,7 +763,9 @@ function checkSEGsOverlapping( imageIds, validOrientations, metadataProvider, - tolerance + tolerance, + TypedArrayConstructor, + sopUIDImageIdIndexMap ) { const { SharedFunctionalGroupsSequence, @@ -745,7 +811,8 @@ function checkSEGsOverlapping( frameSegment, imageIds, metadataProvider, - tolerance + tolerance, + sopUIDImageIdIndexMap ); if (!imageId) { @@ -771,7 +838,7 @@ function checkSEGsOverlapping( } for (let [, role] of frameSegmentsMapping.entries()) { - let temp2DArray = new Uint16Array(sliceLength).fill(0); + let temp2DArray = new TypedArrayConstructor(sliceLength).fill(0); for (let i = 0; i < role.length; ++i) { const frameSegment = role[i]; @@ -784,15 +851,14 @@ function checkSEGsOverlapping( PerFrameFunctionalGroups.PlaneOrientationSequence .ImageOrientationPatient; - const pixelDataI2D = ndarray( - new Uint8Array( - pixelData.buffer, - frameSegment * sliceLength, - sliceLength - ), - [Rows, Columns] + const view = readFromUnpackedChunks( + pixelData, + frameSegment * sliceLength, + sliceLength ); + const pixelDataI2D = ndarray(view, [Rows, Columns]); + const alignedPixelDataI = alignPixelDataWithSourceData( pixelDataI2D, ImageOrientationPatientI, @@ -831,7 +897,10 @@ function insertOverlappingPixelDataPlanar( imageIds, validOrientations, metadataProvider, - tolerance + tolerance, + TypedArrayConstructor, + segmentsPixelIndices, + sopUIDImageIdIndexMap ) { const { SharedFunctionalGroupsSequence, @@ -846,8 +915,8 @@ function insertOverlappingPixelDataPlanar( .ImageOrientationPatient : undefined; const sliceLength = Columns * Rows; - const arrayBufferLength = sliceLength * imageIds.length * 2; // 2 bytes per label voxel in cst4. - + const arrayBufferLength = + sliceLength * imageIds.length * TypedArrayConstructor.BYTES_PER_ELEMENT; // indicate the number of labelMaps let M = 1; @@ -897,11 +966,17 @@ function insertOverlappingPixelDataPlanar( PerFrameFunctionalGroups.PlaneOrientationSequence .ImageOrientationPatient; - const pixelDataI2D = ndarray( - new Uint8Array(pixelData.buffer, i * sliceLength, sliceLength), - [Rows, Columns] + // Since we moved to the chunks approach, we need to read the data + // and handle scenarios where the portion of data is in one chunk + // and the other portion is in another chunk + const view = readFromUnpackedChunks( + pixelData, + i * sliceLength, + sliceLength ); + const pixelDataI2D = ndarray(view, [Rows, Columns]); + const alignedPixelDataI = alignPixelDataWithSourceData( pixelDataI2D, ImageOrientationPatientI, @@ -921,7 +996,8 @@ function insertOverlappingPixelDataPlanar( i, imageIds, metadataProvider, - tolerance + tolerance, + sopUIDImageIdIndexMap ); if (!imageId) { @@ -951,9 +1027,12 @@ function insertOverlappingPixelDataPlanar( const imageIdIndex = imageIds.findIndex( element => element === imageId ); - const byteOffset = sliceLength * 2 * imageIdIndex; // 2 bytes/pixel + const byteOffset = + sliceLength * + imageIdIndex * + TypedArrayConstructor.BYTES_PER_ELEMENT; - const labelmap2DView = new Uint16Array( + const labelmap2DView = new TypedArrayConstructor( tempBuffer, byteOffset, sliceLength @@ -1035,7 +1114,13 @@ function insertPixelDataPlanar( imageIds, validOrientations, metadataProvider, - tolerance + tolerance, + TypedArrayConstructor, + segmentsPixelIndices, + sopUIDImageIdIndexMap, + imageIdMaps, + eventTarget, + triggerEvent ) { const { SharedFunctionalGroupsSequence, @@ -1051,99 +1136,155 @@ function insertPixelDataPlanar( : undefined; const sliceLength = Columns * Rows; - for ( - let i = 0, groupsLen = PerFrameFunctionalGroupsSequence.length; - i < groupsLen; - ++i - ) { - const PerFrameFunctionalGroups = PerFrameFunctionalGroupsSequence[i]; + let i = 0; + const groupsLen = PerFrameFunctionalGroupsSequence.length; + const chunkSize = Math.ceil(groupsLen / 10); // 10% of total length + + const shouldTriggerEvent = triggerEvent && eventTarget; + + // Below, we chunk the processing of the frames to avoid blocking the main thread + // if the segmentation is large. We also use a promise to allow the caller to + // wait for the processing to finish. + return new Promise(resolve => { + function processInChunks() { + // process one chunk + for (let end = Math.min(i + chunkSize, groupsLen); i < end; ++i) { + const PerFrameFunctionalGroups = + PerFrameFunctionalGroupsSequence[i]; + + const ImageOrientationPatientI = + sharedImageOrientationPatient || + PerFrameFunctionalGroups.PlaneOrientationSequence + .ImageOrientationPatient; + + const view = readFromUnpackedChunks( + pixelData, + i * sliceLength, + sliceLength + ); - const ImageOrientationPatientI = - sharedImageOrientationPatient || - PerFrameFunctionalGroups.PlaneOrientationSequence - .ImageOrientationPatient; + const pixelDataI2D = ndarray(view, [Rows, Columns]); - const pixelDataI2D = ndarray( - new Uint8Array(pixelData.buffer, i * sliceLength, sliceLength), - [Rows, Columns] - ); + const alignedPixelDataI = alignPixelDataWithSourceData( + pixelDataI2D, + ImageOrientationPatientI, + validOrientations, + tolerance + ); - const alignedPixelDataI = alignPixelDataWithSourceData( - pixelDataI2D, - ImageOrientationPatientI, - validOrientations, - tolerance - ); + if (!alignedPixelDataI) { + throw new Error( + "Individual SEG frames are out of plane with respect to the first SEG frame. " + + "This is not yet supported. Aborting segmentation loading." + ); + } - if (!alignedPixelDataI) { - throw new Error( - "Individual SEG frames are out of plane with respect to the first SEG frame. " + - "This is not yet supported. Aborting segmentation loading." - ); - } + const segmentIndex = getSegmentIndex(multiframe, i); - const segmentIndex = getSegmentIndex(multiframe, i); - if (segmentIndex === undefined) { - throw new Error( - "Could not retrieve the segment index. Aborting segmentation loading." - ); - } + if (segmentIndex === undefined) { + throw new Error( + "Could not retrieve the segment index. Aborting segmentation loading." + ); + } - const imageId = findReferenceSourceImageId( - multiframe, - i, - imageIds, - metadataProvider, - tolerance - ); + if (!segmentsPixelIndices.has(segmentIndex)) { + segmentsPixelIndices.set(segmentIndex, {}); + } - if (!imageId) { - console.warn( - "Image not present in stack, can't import frame : " + i + "." - ); - continue; - } + const imageId = findReferenceSourceImageId( + multiframe, + i, + imageIds, + metadataProvider, + tolerance, + sopUIDImageIdIndexMap + ); - const sourceImageMetadata = metadataProvider.get("instance", imageId); - if ( - Rows !== sourceImageMetadata.Rows || - Columns !== sourceImageMetadata.Columns - ) { - throw new Error( - "Individual SEG frames have different geometry dimensions (Rows and Columns) " + - "respect to the source image reference frame. This is not yet supported. " + - "Aborting segmentation loading. " - ); - } + if (!imageId) { + console.warn( + "Image not present in stack, can't import frame : " + + i + + "." + ); + continue; + } - const imageIdIndex = imageIds.findIndex(element => element === imageId); - const byteOffset = sliceLength * 2 * imageIdIndex; // 2 bytes/pixel + const sourceImageMetadata = imageIdMaps.metadata[imageId]; + if ( + Rows !== sourceImageMetadata.Rows || + Columns !== sourceImageMetadata.Columns + ) { + throw new Error( + "Individual SEG frames have different geometry dimensions (Rows and Columns) " + + "respect to the source image reference frame. This is not yet supported. " + + "Aborting segmentation loading. " + ); + } - const labelmap2DView = new Uint16Array( - labelmapBufferArray[0], - byteOffset, - sliceLength - ); + const imageIdIndex = imageIdMaps.indices[imageId]; + + const byteOffset = + sliceLength * + imageIdIndex * + TypedArrayConstructor.BYTES_PER_ELEMENT; - const data = alignedPixelDataI.data; - for (let j = 0, len = alignedPixelDataI.data.length; j < len; ++j) { - if (data[j]) { - for (let x = j; x < len; ++x) { - if (data[x]) { - labelmap2DView[x] = segmentIndex; + const labelmap2DView = new TypedArrayConstructor( + labelmapBufferArray[0], + byteOffset, + sliceLength + ); + + const data = alignedPixelDataI.data; + + const indexCache = []; + for ( + let j = 0, len = alignedPixelDataI.data.length; + j < len; + ++j + ) { + if (data[j]) { + for (let x = j; x < len; ++x) { + if (data[x]) { + labelmap2DView[x] = segmentIndex; + indexCache.push(x); + } + } + + if (!segmentsOnFrame[imageIdIndex]) { + segmentsOnFrame[imageIdIndex] = []; + } + + segmentsOnFrame[imageIdIndex].push(segmentIndex); + + break; } } - if (!segmentsOnFrame[imageIdIndex]) { - segmentsOnFrame[imageIdIndex] = []; - } + const segmentIndexObject = + segmentsPixelIndices.get(segmentIndex); + segmentIndexObject[imageIdIndex] = indexCache; + segmentsPixelIndices.set(segmentIndex, segmentIndexObject); + } - segmentsOnFrame[imageIdIndex].push(segmentIndex); + // trigger an event after each chunk + if (shouldTriggerEvent) { + const percentComplete = Math.round((i / groupsLen) * 100); + triggerEvent(eventTarget, Events.SEGMENTATION_LOAD_PROGRESS, { + percentComplete + }); + } - break; + // schedule next chunk + if (i < groupsLen) { + setTimeout(processInChunks, 0); + } else { + // resolve the Promise when all chunks have been processed + resolve(); } } - } + + processInChunks(); + }); } function checkOrientation( @@ -1215,12 +1356,13 @@ function checkIfPerpendicular(iop1, iop2, tolerance) { } /** - * unpackPixelData - Unpacks bitpacked pixelData if the Segmentation is BINARY. + * unpackPixelData - Unpacks bit packed pixelData if the Segmentation is BINARY. * * @param {Object} multiframe The multiframe dataset. + * @param {Object} options Options for the unpacking. * @return {Uint8Array} The unpacked pixelData. */ -function unpackPixelData(multiframe) { +function unpackPixelData(multiframe, options) { const segType = multiframe.SegmentationType; let data; @@ -1235,7 +1377,10 @@ function unpackPixelData(multiframe) { } if (segType === "BINARY") { - return BitArray.unpack(data); + // For extreme big data, we can't unpack the data at once and we need to + // chunk it and unpack each chunk separately. + // MAX 2GB is the limit right now to allocate a buffer + return getUnpackedChunks(data, options.maxBytesPerChunk); } const pixelData = new Uint8Array(data); @@ -1257,19 +1402,40 @@ function unpackPixelData(multiframe) { return pixelData; } +function getUnpackedChunks(data, maxBytesPerChunk) { + var bitArray = new Uint8Array(data); + var chunks = []; + + var maxBitsPerChunk = maxBytesPerChunk * 8; + var numberOfChunks = Math.ceil((bitArray.length * 8) / maxBitsPerChunk); + + for (var i = 0; i < numberOfChunks; i++) { + var startBit = i * maxBitsPerChunk; + var endBit = Math.min(startBit + maxBitsPerChunk, bitArray.length * 8); + + var startByte = Math.floor(startBit / 8); + var endByte = Math.ceil(endBit / 8); + + var chunk = bitArray.slice(startByte, endByte); + var unpackedChunk = BitArray.unpack(chunk); + + chunks.push(unpackedChunk); + } + + return chunks; +} + /** - * getImageIdOfSourceImagebySourceImageSequence - Returns the Cornerstone imageId of the source image. + * getImageIdOfSourceImageBySourceImageSequence - Returns the Cornerstone imageId of the source image. * * @param {Object} SourceImageSequence Sequence describing the source image. * @param {String[]} imageIds A list of imageIds. - * @param {Object} metadataProvider A Cornerstone metadataProvider to query - * metadata from imageIds. + * @param {Object} sopUIDImageIdIndexMap A map of SOPInstanceUIDs to imageIds. * @return {String} The corresponding imageId. */ -function getImageIdOfSourceImagebySourceImageSequence( +function getImageIdOfSourceImageBySourceImageSequence( SourceImageSequence, - imageIds, - metadataProvider + sopUIDImageIdIndexMap ) { const { ReferencedSOPInstanceUID, ReferencedFrameNumber } = SourceImageSequence; @@ -1278,14 +1444,9 @@ function getImageIdOfSourceImagebySourceImageSequence( ? getImageIdOfReferencedFrame( ReferencedSOPInstanceUID, ReferencedFrameNumber, - imageIds, - metadataProvider + sopUIDImageIdIndexMap ) - : getImageIdOfReferencedSingleFramedSOPInstance( - ReferencedSOPInstanceUID, - imageIds, - metadataProvider - ); + : sopUIDImageIdIndexMap[ReferencedSOPInstanceUID]; } /** @@ -1295,7 +1456,7 @@ function getImageIdOfSourceImagebySourceImageSequence( * @param {String} FrameOfReferenceUID Frame of reference. * @param {Object} PerFrameFunctionalGroup Sequence describing segmentation reference attributes per frame. * @param {String[]} imageIds A list of imageIds. - * @param {Object} metadataProvider A Cornerstone metadataProvider to query + * @param {Object} sopUIDImageIdIndexMap A map of SOPInstanceUIDs to imageIds. * @param {Float} tolerance The tolerance parameter * * @return {String} The corresponding imageId. @@ -1351,34 +1512,6 @@ function getImageIdOfSourceImagebyGeometry( } } -/** - * getImageIdOfReferencedSingleFramedSOPInstance - Returns the imageId - * corresponding to the specified sopInstanceUid for single-frame images. - * - * @param {String} sopInstanceUid The sopInstanceUid of the desired image. - * @param {String[]} imageIds The list of imageIds. - * @param {Object} metadataProvider The metadataProvider to obtain sopInstanceUids - * from the cornerstone imageIds. - * @return {String} The imageId that corresponds to the sopInstanceUid. - */ -function getImageIdOfReferencedSingleFramedSOPInstance( - sopInstanceUid, - imageIds, - metadataProvider -) { - return imageIds.find(imageId => { - const sopCommonModule = metadataProvider.get( - "sopCommonModule", - imageId - ); - if (!sopCommonModule) { - return; - } - - return sopCommonModule.sopInstanceUID === sopInstanceUid; - }); -} - /** * getImageIdOfReferencedFrame - Returns the imageId corresponding to the * specified sopInstanceUid and frameNumber for multi-frame images. @@ -1386,35 +1519,23 @@ function getImageIdOfReferencedSingleFramedSOPInstance( * @param {String} sopInstanceUid The sopInstanceUid of the desired image. * @param {Number} frameNumber The frame number. * @param {String} imageIds The list of imageIds. - * @param {Object} metadataProvider The metadataProvider to obtain sopInstanceUids - * from the cornerstone imageIds. + * @param {Object} sopUIDImageIdIndexMap A map of SOPInstanceUIDs to imageIds. * @return {String} The imageId that corresponds to the sopInstanceUid. */ function getImageIdOfReferencedFrame( sopInstanceUid, frameNumber, - imageIds, - metadataProvider + sopUIDImageIdIndexMap ) { - const imageId = imageIds.find(imageId => { - const sopCommonModule = metadataProvider.get( - "sopCommonModule", - imageId - ); - if (!sopCommonModule) { - return; - } + const imageId = sopUIDImageIdIndexMap[sopInstanceUid]; - const imageIdFrameNumber = Number(imageId.split("frame=")[1]); + if (!imageId) { + return; + } - return ( - //frameNumber is zero indexed for cornerstoneDICOMImageLoader image Ids. - sopCommonModule.sopInstanceUID === sopInstanceUid && - imageIdFrameNumber === frameNumber - 1 - ); - }); + const imageIdFrameNumber = Number(imageId.split("frame=")[1]); - return imageId; + return imageIdFrameNumber === frameNumber - 1 ? imageId : undefined; } /** @@ -1541,3 +1662,118 @@ function getSegmentMetadata(multiframe, seriesInstanceUid) { data }; } + +/** + * Reads a range of bytes from an array of ArrayBuffer chunks and + * aggregate them into a new Uint8Array. + * + * @param {ArrayBuffer[]} chunks - An array of ArrayBuffer chunks. + * @param {number} offset - The offset of the first byte to read. + * @param {number} length - The number of bytes to read. + * @returns {Uint8Array} A new Uint8Array containing the requested bytes. + */ +function readFromUnpackedChunks(chunks, offset, length) { + const mapping = getUnpackedOffsetAndLength(chunks, offset, length); + + // If all the data is in one chunk, we can just slice that chunk + if (mapping.start.chunkIndex === mapping.end.chunkIndex) { + return new Uint8Array( + chunks[mapping.start.chunkIndex].buffer, + mapping.start.offset, + length + ); + } else { + // If the data spans multiple chunks, we need to create a new Uint8Array and copy the data from each chunk + let result = new Uint8Array(length); + let resultOffset = 0; + + for ( + let i = mapping.start.chunkIndex; + i <= mapping.end.chunkIndex; + i++ + ) { + let start = + i === mapping.start.chunkIndex ? mapping.start.offset : 0; + let end = + i === mapping.end.chunkIndex + ? mapping.end.offset + : chunks[i].length; + + result.set( + new Uint8Array(chunks[i].buffer, start, end - start), + resultOffset + ); + resultOffset += end - start; + } + + return result; + } +} + +function getUnpackedOffsetAndLength(chunks, offset, length) { + var totalBytes = chunks.reduce((total, chunk) => total + chunk.length, 0); + + if (offset < 0 || offset + length > totalBytes) { + throw new Error("Offset and length out of bounds"); + } + + var startChunkIndex = 0; + var startOffsetInChunk = offset; + + while (startOffsetInChunk >= chunks[startChunkIndex].length) { + startOffsetInChunk -= chunks[startChunkIndex].length; + startChunkIndex++; + } + + var endChunkIndex = startChunkIndex; + var endOffsetInChunk = startOffsetInChunk + length; + + while (endOffsetInChunk > chunks[endChunkIndex].length) { + endOffsetInChunk -= chunks[endChunkIndex].length; + endChunkIndex++; + } + + return { + start: { chunkIndex: startChunkIndex, offset: startOffsetInChunk }, + end: { chunkIndex: endChunkIndex, offset: endOffsetInChunk } + }; +} + +function calculateCentroid(imageIdIndexBufferIndex, multiframe) { + let xAcc = 0; + let yAcc = 0; + let zAcc = 0; + let count = 0; + + for (const [imageIdIndex, bufferIndices] of Object.entries( + imageIdIndexBufferIndex + )) { + const z = Number(imageIdIndex); + + if (!bufferIndices || bufferIndices.length === 0) { + continue; + } + + for (const bufferIndex of bufferIndices) { + const y = Math.floor(bufferIndex / multiframe.Rows); + const x = bufferIndex % multiframe.Rows; + + xAcc += x; + yAcc += y; + zAcc += z; + + count++; + } + } + + return { xAcc, yAcc, zAcc, count }; +} + +const Segmentation = { + generateSegmentation, + generateToolState, + fillSegmentation +}; + +export default Segmentation; +export { fillSegmentation, generateSegmentation, generateToolState }; diff --git a/packages/adapters/src/adapters/Cornerstone/index.ts b/packages/adapters/src/adapters/Cornerstone/index.ts index 8cb5f0f01a..dd7cd7e658 100644 --- a/packages/adapters/src/adapters/Cornerstone/index.ts +++ b/packages/adapters/src/adapters/Cornerstone/index.ts @@ -5,12 +5,13 @@ import Bidirectional from "./Bidirectional"; import EllipticalRoi from "./EllipticalRoi"; import CircleRoi from "./CircleRoi"; import ArrowAnnotate from "./ArrowAnnotate"; -import Segmentation from "./Segmentation"; import CobbAngle from "./CobbAngle"; import Angle from "./Angle"; import RectangleRoi from "./RectangleRoi"; +// Segmentation +import Segmentation from "./Segmentation"; -const Cornerstone = { +const CornerstoneSR = { Length, FreehandRoi, Bidirectional, @@ -18,10 +19,13 @@ const Cornerstone = { CircleRoi, ArrowAnnotate, MeasurementReport, - Segmentation, CobbAngle, Angle, RectangleRoi }; -export default Cornerstone; +const CornerstoneSEG = { + Segmentation +}; + +export { CornerstoneSR, CornerstoneSEG }; diff --git a/packages/adapters/src/adapters/Cornerstone3D/Segmentation/generateLabelMaps2DFrom3D.ts b/packages/adapters/src/adapters/Cornerstone3D/Segmentation/generateLabelMaps2DFrom3D.ts new file mode 100644 index 0000000000..7423409491 --- /dev/null +++ b/packages/adapters/src/adapters/Cornerstone3D/Segmentation/generateLabelMaps2DFrom3D.ts @@ -0,0 +1,68 @@ +/** + * Generates 2D label maps from a 3D label map. + * @param labelmap3D - The 3D label map object to generate 2D label maps from. It is derived + * from the volume labelmap. + * @returns The label map object containing the 2D label maps and segments on label maps. + */ +function generateLabelMaps2DFrom3D(labelmap3D): { + scalarData: number[]; + dimensions: number[]; + segmentsOnLabelmap: number[]; + labelmaps2D: { + segmentsOnLabelmap: number[]; + pixelData: number[]; + rows: number; + columns: number; + }[]; +} { + // 1. we need to generate labelmaps2D from labelmaps3D, a labelmap2D is for each + // slice + const { scalarData, dimensions } = labelmap3D; + + // scalarData is a flat array of all the pixels in the volume. + const labelmaps2D = []; + const segmentsOnLabelmap3D = new Set(); + + // X-Y are the row and column dimensions, Z is the number of slices. + for (let z = 0; z < dimensions[2]; z++) { + const pixelData = scalarData.slice( + z * dimensions[0] * dimensions[1], + (z + 1) * dimensions[0] * dimensions[1] + ); + + const segmentsOnLabelmap = []; + + for (let i = 0; i < pixelData.length; i++) { + const segment = pixelData[i]; + if (!segmentsOnLabelmap.includes(segment) && segment !== 0) { + segmentsOnLabelmap.push(segment); + } + } + + const labelmap2D = { + segmentsOnLabelmap, + pixelData, + rows: dimensions[1], + columns: dimensions[0] + }; + + if (segmentsOnLabelmap.length === 0) { + continue; + } + + segmentsOnLabelmap.forEach(segmentIndex => { + segmentsOnLabelmap3D.add(segmentIndex); + }); + + labelmaps2D[dimensions[2] - 1 - z] = labelmap2D; + } + + // remove segment 0 from segmentsOnLabelmap3D + labelmap3D.segmentsOnLabelmap = Array.from(segmentsOnLabelmap3D); + + labelmap3D.labelmaps2D = labelmaps2D; + + return labelmap3D; +} + +export { generateLabelMaps2DFrom3D }; diff --git a/packages/adapters/src/adapters/Cornerstone3D/Segmentation/generateSegmentation.ts b/packages/adapters/src/adapters/Cornerstone3D/Segmentation/generateSegmentation.ts new file mode 100644 index 0000000000..62ae07fb14 --- /dev/null +++ b/packages/adapters/src/adapters/Cornerstone3D/Segmentation/generateSegmentation.ts @@ -0,0 +1,59 @@ +import { normalizers, derivations } from "dcmjs"; +import { fillSegmentation } from "../../Cornerstone/Segmentation_4X"; + +const { Normalizer } = normalizers; +const { Segmentation: SegmentationDerivation } = derivations; + +/** + * generateSegmentation - Generates a DICOM Segmentation object given cornerstoneTools data. + * + * @param images - An array of the cornerstone image objects, which includes imageId and metadata + * @param labelmaps - An array of the 3D Volumes that contain the segmentation data. + */ +function generateSegmentation(images, labelmaps, metadata, options) { + const segmentation = _createMultiframeSegmentationFromReferencedImages( + images, + metadata, + options + ); + return fillSegmentation(segmentation, labelmaps, options); +} + +/** + * _createMultiframeSegmentationFromReferencedImages - description + * + * @param images - An array of the cornerstone image objects related to the reference + * series that the segmentation is derived from. You can use methods such as + * volume.getCornerstoneImages() to get this array. + * + * @param options - the options object for the SegmentationDerivation. + * @returns The Seg derived dataSet. + */ +function _createMultiframeSegmentationFromReferencedImages( + images, + metadata, + options +) { + const datasets = images.map(image => { + // add the sopClassUID to the dataset + const instance = metadata.get("instance", image.imageId); + return { + ...image, + ...instance, + // Todo: move to dcmjs tag style + SOPClassUID: instance.SopClassUID, + SOPInstanceUID: instance.SopInstanceUID, + PixelData: image.getPixelData(), + _vrMap: { + PixelData: "OW" + }, + _meta: {} + }; + }); + + const multiframe = Normalizer.normalizeToDataset(datasets); + + return new SegmentationDerivation([multiframe], options); +} + +export { generateSegmentation }; diff --git a/packages/adapters/src/adapters/Cornerstone3D/Segmentation/generateToolState.ts b/packages/adapters/src/adapters/Cornerstone3D/Segmentation/generateToolState.ts new file mode 100644 index 0000000000..4e50a7b29d --- /dev/null +++ b/packages/adapters/src/adapters/Cornerstone3D/Segmentation/generateToolState.ts @@ -0,0 +1,36 @@ +import { CornerstoneSEG } from "../../Cornerstone"; + +const { Segmentation } = CornerstoneSEG; +const { generateToolState: generateToolStateCornerstoneLegacy } = Segmentation; + +/** + * generateToolState - Given a set of cornerstoneTools imageIds and a Segmentation buffer, + * derive cornerstoneTools toolState and brush metadata. + * + * @param imageIds - An array of the imageIds. + * @param arrayBuffer - The SEG arrayBuffer. + * @param skipOverlapping - skip checks for overlapping segs, default value false. + * @param tolerance - default value 1.e-3. + * + * @returns a list of array buffer for each labelMap + * an object from which the segment metadata can be derived + * list containing the track of segments per frame + * list containing the track of segments per frame for each labelMap (available only for the overlapping case). + */ +function generateToolState( + imageIds, + arrayBuffer, + metadataProvider, + skipOverlapping = false, + tolerance = 1e-3 +) { + return generateToolStateCornerstoneLegacy( + imageIds, + arrayBuffer, + metadataProvider, + skipOverlapping, + tolerance + ); +} + +export { generateToolState }; diff --git a/packages/adapters/src/adapters/Cornerstone3D/Segmentation/index.ts b/packages/adapters/src/adapters/Cornerstone3D/Segmentation/index.ts new file mode 100644 index 0000000000..e124c65c12 --- /dev/null +++ b/packages/adapters/src/adapters/Cornerstone3D/Segmentation/index.ts @@ -0,0 +1,5 @@ +import { generateSegmentation } from "./generateSegmentation"; +import { generateLabelMaps2DFrom3D } from "./generateLabelMaps2DFrom3D"; +import { generateToolState } from "./generateToolState"; + +export { generateLabelMaps2DFrom3D, generateSegmentation, generateToolState }; diff --git a/packages/adapters/src/adapters/Cornerstone3D/index.ts b/packages/adapters/src/adapters/Cornerstone3D/index.ts index d9e1801c4b..979f9d8519 100644 --- a/packages/adapters/src/adapters/Cornerstone3D/index.ts +++ b/packages/adapters/src/adapters/Cornerstone3D/index.ts @@ -12,8 +12,9 @@ import RectangleROI from "./RectangleROI"; import Length from "./Length"; import PlanarFreehandROI from "./PlanarFreehandROI"; import Probe from "./Probe"; +import * as Segmentation from "./Segmentation"; -const Cornerstone3D = { +const Cornerstone3DSR = { Bidirectional, CobbAngle, Angle, @@ -29,4 +30,8 @@ const Cornerstone3D = { CORNERSTONE_3D_TAG }; -export default Cornerstone3D; +const Cornerstone3DSEG = { + Segmentation +}; + +export { Cornerstone3DSR, Cornerstone3DSEG }; diff --git a/packages/adapters/src/adapters/VTKjs/Segmentation.js b/packages/adapters/src/adapters/VTKjs/Segmentation.js index 7c08a0317f..58aa58b1a1 100644 --- a/packages/adapters/src/adapters/VTKjs/Segmentation.js +++ b/packages/adapters/src/adapters/VTKjs/Segmentation.js @@ -1,6 +1,6 @@ -import {data} from 'dcmjs'; +import { data } from "dcmjs"; -const {Colors, BitArray} = data; +const { Colors, BitArray } = data; // TODO: Is there a better name for this? RGBAInt? // Should we move it to Colors.js diff --git a/packages/adapters/src/adapters/VTKjs/index.ts b/packages/adapters/src/adapters/VTKjs/index.ts index 15edce964a..d6b5aaae88 100644 --- a/packages/adapters/src/adapters/VTKjs/index.ts +++ b/packages/adapters/src/adapters/VTKjs/index.ts @@ -1,7 +1,7 @@ import Segmentation from "./Segmentation"; -const VTKjs = { +const VTKjsSEG = { Segmentation }; -export default VTKjs; +export { VTKjsSEG }; diff --git a/packages/adapters/src/adapters/enums/Events.ts b/packages/adapters/src/adapters/enums/Events.ts new file mode 100644 index 0000000000..82d86437e2 --- /dev/null +++ b/packages/adapters/src/adapters/enums/Events.ts @@ -0,0 +1,11 @@ +/** + * Cornerstone adapters events + */ +enum Events { + /** + * Cornerstone segmentation load progress event + */ + SEGMENTATION_LOAD_PROGRESS = "CORNERSTONE_ADAPTER_SEGMENTATION_LOAD_PROGRESS" +} + +export default Events; diff --git a/packages/adapters/src/adapters/enums/index.ts b/packages/adapters/src/adapters/enums/index.ts new file mode 100644 index 0000000000..f5b115b416 --- /dev/null +++ b/packages/adapters/src/adapters/enums/index.ts @@ -0,0 +1,3 @@ +import Events from "./Events"; + +export { Events }; diff --git a/packages/adapters/src/adapters/helpers.js b/packages/adapters/src/adapters/helpers.js deleted file mode 100644 index 587d9fb259..0000000000 --- a/packages/adapters/src/adapters/helpers.js +++ /dev/null @@ -1,19 +0,0 @@ -const toArray = function (x) { - return Array.isArray(x) ? x : [x]; -}; - -const codeMeaningEquals = codeMeaningName => { - return contentItem => { - return ( - contentItem.ConceptNameCodeSequence.CodeMeaning === codeMeaningName - ); - }; -}; - -const graphicTypeEquals = graphicType => { - return contentItem => { - return contentItem && contentItem.GraphicType === graphicType; - }; -}; - -export { toArray, codeMeaningEquals, graphicTypeEquals }; diff --git a/packages/adapters/src/adapters/helpers/codeMeaningEquals.ts b/packages/adapters/src/adapters/helpers/codeMeaningEquals.ts new file mode 100644 index 0000000000..61c8bcf36c --- /dev/null +++ b/packages/adapters/src/adapters/helpers/codeMeaningEquals.ts @@ -0,0 +1,16 @@ +/** + * Returns a function that checks if a given content item's ConceptNameCodeSequence.CodeMeaning + * matches the provided codeMeaningName. + * @param codeMeaningName - The CodeMeaning to match against. + * @returns A function that takes a content item and returns a boolean indicating whether the + * content item's CodeMeaning matches the provided codeMeaningName. + */ +const codeMeaningEquals = (codeMeaningName: string) => { + return (contentItem: any) => { + return ( + contentItem.ConceptNameCodeSequence.CodeMeaning === codeMeaningName + ); + }; +}; + +export { codeMeaningEquals }; diff --git a/packages/adapters/src/adapters/helpers/graphicTypeEquals.ts b/packages/adapters/src/adapters/helpers/graphicTypeEquals.ts new file mode 100644 index 0000000000..1ec2597b99 --- /dev/null +++ b/packages/adapters/src/adapters/helpers/graphicTypeEquals.ts @@ -0,0 +1,12 @@ +/** + * Checks if a given content item's GraphicType property matches a specified value. + * @param {string} graphicType - The value to compare the content item's GraphicType property to. + * @returns {function} A function that takes a content item and returns a boolean indicating whether its GraphicType property matches the specified value. + */ +const graphicTypeEquals = graphicType => { + return contentItem => { + return contentItem && contentItem.GraphicType === graphicType; + }; +}; + +export { graphicTypeEquals }; diff --git a/packages/adapters/src/adapters/helpers/index.ts b/packages/adapters/src/adapters/helpers/index.ts new file mode 100644 index 0000000000..b852e82fef --- /dev/null +++ b/packages/adapters/src/adapters/helpers/index.ts @@ -0,0 +1,5 @@ +import { toArray } from "./toArray"; +import { codeMeaningEquals } from "./codeMeaningEquals"; +import { graphicTypeEquals } from "./graphicTypeEquals"; + +export { toArray, codeMeaningEquals, graphicTypeEquals }; diff --git a/packages/adapters/src/adapters/helpers/toArray.ts b/packages/adapters/src/adapters/helpers/toArray.ts new file mode 100644 index 0000000000..e91a900928 --- /dev/null +++ b/packages/adapters/src/adapters/helpers/toArray.ts @@ -0,0 +1,3 @@ +const toArray = x => (Array.isArray(x) ? x : [x]); + +export { toArray }; diff --git a/packages/adapters/src/adapters/index.ts b/packages/adapters/src/adapters/index.ts index 87f3c21877..a3e96f1d74 100644 --- a/packages/adapters/src/adapters/index.ts +++ b/packages/adapters/src/adapters/index.ts @@ -1,11 +1,18 @@ -import Cornerstone from "./Cornerstone"; -import Cornerstone3D from "./Cornerstone3D"; -import VTKjs from "./VTKjs"; +import { CornerstoneSR } from "./Cornerstone"; +import { Cornerstone3DSR, Cornerstone3DSEG } from "./Cornerstone3D"; +import { VTKjsSEG } from "./VTKjs"; +import * as Enums from "./enums"; +import * as helpers from "./helpers"; -const adapters = { - Cornerstone, - Cornerstone3D, - VTKjs +const adaptersSR = { + Cornerstone: CornerstoneSR, + Cornerstone3D: Cornerstone3DSR }; -export default adapters; +const adaptersSEG = { + Cornerstone: CornerstoneSR, + Cornerstone3D: Cornerstone3DSEG, + VTKjs: VTKjsSEG +}; + +export { adaptersSR, adaptersSEG, Enums, helpers }; diff --git a/packages/adapters/src/index.ts b/packages/adapters/src/index.ts index 61aa0800e9..cbbaada2a1 100644 --- a/packages/adapters/src/index.ts +++ b/packages/adapters/src/index.ts @@ -1,4 +1,3 @@ -import adaptersSR from "./adapters"; -export { adaptersSR }; +import { adaptersSR, adaptersSEG, Enums, helpers } from "./adapters"; -export default adaptersSR; +export { adaptersSR, adaptersSEG, Enums, helpers }; diff --git a/packages/adapters/tsconfig.json b/packages/adapters/tsconfig.json index da70734a4e..d45150d61e 100644 --- a/packages/adapters/tsconfig.json +++ b/packages/adapters/tsconfig.json @@ -1,7 +1,15 @@ { "compilerOptions": { "declaration": true, - "declarationDir": "dts", + "declarationDir": "./dist/types", "emitDeclarationOnly": true - } + }, + "exclude": [ + "node_modules", + "dist", + "examples", + "old-examples" + ] + + } diff --git a/packages/core/package.json b/packages/core/package.json index 6107cc0e75..4a99b84635 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -17,6 +17,7 @@ "scripts": { "build:cjs": "tsc --project ./tsconfig.cjs.json", "build:esm": "tsc --project ./tsconfig.esm.json", + "build:esm:watch": "tsc --project ./tsconfig.esm.json --watch", "build:umd": "cross-env NODE_ENV=production webpack --config .webpack/webpack.prod.js", "build:all": "yarn run build:umd && yarn run build:cjs && yarn run build:esm", "clean": "shx rm -rf dist", diff --git a/packages/core/src/RenderingEngine/StackViewport.ts b/packages/core/src/RenderingEngine/StackViewport.ts index 6050232021..34a5e06e5f 100644 --- a/packages/core/src/RenderingEngine/StackViewport.ts +++ b/packages/core/src/RenderingEngine/StackViewport.ts @@ -1757,8 +1757,8 @@ class StackViewport extends Viewport implements IStackViewport { //If Photometric Interpretation is not the same for the next image we are trying to load, invalidate the stack to recreate the VTK imageData if ( - this.csImage?.imageFrame?.photometricInterpretation !== - image.imageFrame?.photometricInterpretation + this.csImage?.imageFrame.photometricInterpretation !== + image?.imageFrame?.photometricInterpretation ) { this.stackInvalidated = true; } diff --git a/packages/dicomImageLoader/package.json b/packages/dicomImageLoader/package.json index 1228e52e45..df4501ca50 100644 --- a/packages/dicomImageLoader/package.json +++ b/packages/dicomImageLoader/package.json @@ -27,7 +27,6 @@ "scripts": { "build:umd:dynamic": "cross-env NODE_ENV=production webpack --config .webpack/webpack-dynamic-import.js", "build:umd:bundle": "cross-env NODE_ENV=production webpack --config .webpack/webpack-bundle.js", - "build:esm": "tsc --project ./tsconfig.esm.json", "build:all": "yarn run build:umd:dynamic & yarn run build:umd:bundle", "copy-dts": "echo 'not implemented yet'", "build": "yarn run build:all && yarn run copy-dts", diff --git a/packages/dicomImageLoader/src/imageLoader/getInstanceModule.ts b/packages/dicomImageLoader/src/imageLoader/getInstanceModule.ts new file mode 100644 index 0000000000..b8d3663b6f --- /dev/null +++ b/packages/dicomImageLoader/src/imageLoader/getInstanceModule.ts @@ -0,0 +1,57 @@ +/** + * Retrieves metadata from a DICOM image and returns it as an object with capitalized keys. + * @param imageId - the imageId + * @param metaDataProvider - The metadata provider either wadors or wadouri + * @param types - An array of metadata types to retrieve. + * @returns An object containing the retrieved metadata with capitalized keys. + */ +function getInstanceModule( + imageId: string, + metaDataProvider: any, + types: string[] +): object; +function getInstanceModule(imageId, metaDataProvider, types) { + const result = {}; + for (const t of types) { + try { + const data = metaDataProvider(t, imageId); + if (data) { + const capitalizedData = {}; + for (const key in data) { + if (key in data) { + // each tag should get capitalized to match dcmjs style. Todo: move all of the tags to dcmjs style + const capitalizedKey = capitalizeTag(key); + capitalizedData[capitalizedKey] = data[key]; + } + } + Object.assign(result, capitalizedData); + } + } catch (error) { + console.error(`Error retrieving ${t} data:`, error); + } + } + + return result; +} + +const capitalizeTag = (tag: string) => + tag.charAt(0).toUpperCase() + tag.slice(1); + +const instanceModuleNames = [ + 'multiframeModule', + 'generalSeriesModule', + 'patientStudyModule', + 'imagePlaneModule', + 'nmMultiframeGeometryModule', + 'imagePixelModule', + 'modalityLutModule', + 'voiLutModule', + 'sopCommonModule', + 'petIsotopeModule', + 'overlayPlaneModule', + 'transferSyntax', + 'petSeriesModule', + 'petImageModule', +]; + +export { getInstanceModule, instanceModuleNames }; diff --git a/packages/dicomImageLoader/src/imageLoader/wadors/metaData/metaDataProvider.ts b/packages/dicomImageLoader/src/imageLoader/wadors/metaData/metaDataProvider.ts index 9de5bdd538..419207f2fb 100644 --- a/packages/dicomImageLoader/src/imageLoader/wadors/metaData/metaDataProvider.ts +++ b/packages/dicomImageLoader/src/imageLoader/wadors/metaData/metaDataProvider.ts @@ -15,6 +15,10 @@ import { } from './extractPositioningFromMetadata'; import { getImageTypeSubItemFromMetadata } from './NMHelpers'; import isNMReconstructable from '../../isNMReconstructable'; +import { + getInstanceModule, + instanceModuleNames, +} from '../../getInstanceModule'; function metaDataProvider(type, imageId) { if (type === 'multiframeModule') { @@ -266,6 +270,11 @@ function metaDataProvider(type, imageId) { actualFrameDuration: getNumberValue(metaData['00181242']), }; } + + // This is used for gathering all the metadata for export + if (type === 'instance') { + return getInstanceModule(imageId, metaDataProvider, instanceModuleNames); + } } export default metaDataProvider; diff --git a/packages/dicomImageLoader/src/imageLoader/wadouri/metaData/metaDataProvider.ts b/packages/dicomImageLoader/src/imageLoader/wadouri/metaData/metaDataProvider.ts index d63d35fc41..981735f5e4 100644 --- a/packages/dicomImageLoader/src/imageLoader/wadouri/metaData/metaDataProvider.ts +++ b/packages/dicomImageLoader/src/imageLoader/wadouri/metaData/metaDataProvider.ts @@ -16,6 +16,10 @@ import { extractSliceThicknessFromDataset, } from './extractPositioningFromDataset'; import isNMReconstructable from '../../isNMReconstructable'; +import { + getInstanceModule, + instanceModuleNames, +} from '../../getInstanceModule'; function metaDataProvider(type, imageId) { const { dicomParser } = external; @@ -245,6 +249,10 @@ function metaDataProvider(type, imageId) { actualFrameDuration: dicomParser.intString(dataSet.string('x00181242')), }; } + + if (type === 'instance') { + return getInstanceModule(imageId, metaDataProvider, instanceModuleNames); + } } export default metaDataProvider; diff --git a/packages/docs/package.json b/packages/docs/package.json index cc9b750a60..ec59acd9ed 100644 --- a/packages/docs/package.json +++ b/packages/docs/package.json @@ -32,6 +32,7 @@ "@cornerstonejs/dicom-image-loader": "^1.7.2", "@cornerstonejs/streaming-image-volume-loader": "^1.7.2", "@cornerstonejs/tools": "^1.7.2", + "@cornerstonejs/adapters": "^1.7.2", "@docusaurus/core": "2.3.1", "@docusaurus/module-type-aliases": "2.3.1", "@docusaurus/plugin-google-gtag": "2.3.1", @@ -40,7 +41,7 @@ "@mdx-js/react": "^1.6.21", "@svgr/webpack": "^6.2.1", "clsx": "^1.1.1", - "dcmjs": "^0.24.4", + "dcmjs": "^0.29.8", "detect-gpu": "^5.0.22", "dicom-parser": "^1.8.11", "dicomweb-client": "^0.8.4", diff --git a/packages/streaming-image-volume-loader/package.json b/packages/streaming-image-volume-loader/package.json index 295079747d..6455031228 100644 --- a/packages/streaming-image-volume-loader/package.json +++ b/packages/streaming-image-volume-loader/package.json @@ -18,6 +18,7 @@ "api-check": "api-extractor --debug run", "build:cjs": "tsc --project ./tsconfig.cjs.json", "build:esm": "tsc --project ./tsconfig.esm.json", + "build:esm:watch": "tsc --project ./tsconfig.esm.json --watch", "build:umd": "cross-env NODE_ENV=production webpack --config .webpack/webpack.prod.js", "build:all": "yarn run build:umd && yarn run build:cjs && yarn run build:esm", "build": "yarn run build:all", diff --git a/packages/streaming-image-volume-loader/src/BaseStreamingImageVolume.ts b/packages/streaming-image-volume-loader/src/BaseStreamingImageVolume.ts index fcbad88257..88f5eebe6e 100644 --- a/packages/streaming-image-volume-loader/src/BaseStreamingImageVolume.ts +++ b/packages/streaming-image-volume-loader/src/BaseStreamingImageVolume.ts @@ -797,13 +797,12 @@ export default class BaseStreamingImageVolume extends ImageVolume { * * @param imageId - the imageId of the image to be converted * @param imageIdIndex - the index of the imageId in the imageIds array - * @returns imageLoadObject containing the promise that resolves - * to the cornerstone image + * @returns image object containing the pixel data, metadata, and other information */ - public convertToCornerstoneImage( + public getCornerstoneImage( imageId: string, imageIdIndex: number - ): Types.IImageLoadObject { + ): Types.IImage { const { imageIds } = this; const frameIndex = this._imageIdIndexToFrameIndex(imageIdIndex); @@ -865,7 +864,7 @@ export default class BaseStreamingImageVolume extends ImageVolume { ? modalityLutModule.rescaleIntercept : 0; - const image: Types.IImage = { + return { imageId, intercept, windowCenter, @@ -891,8 +890,42 @@ export default class BaseStreamingImageVolume extends ImageVolume { rowPixelSpacing: spacing[1], invert, }; + } + + /** + * Converts the requested imageId inside the volume to a cornerstoneImage + * object. It uses the typedArray set method to copy the pixelData from the + * correct offset in the scalarData to a new array for the image + * Duplicate of getCornerstoneImageLoadObject for legacy reasons + * + * @param imageId - the imageId of the image to be converted + * @param imageIdIndex - the index of the imageId in the imageIds array + * @returns imageLoadObject containing the promise that resolves + * to the cornerstone image + */ + public convertToCornerstoneImage( + imageId: string, + imageIdIndex: number + ): Types.IImageLoadObject { + return this.getCornerstoneImageLoadObject(imageId, imageIdIndex); + } + + /** + * Converts the requested imageId inside the volume to a cornerstoneImage + * object. It uses the typedArray set method to copy the pixelData from the + * correct offset in the scalarData to a new array for the image + * + * @param imageId - the imageId of the image to be converted + * @param imageIdIndex - the index of the imageId in the imageIds array + * @returns imageLoadObject containing the promise that resolves + * to the cornerstone image + */ + public getCornerstoneImageLoadObject( + imageId: string, + imageIdIndex: number + ): Types.IImageLoadObject { + const image = this.getCornerstoneImage(imageId, imageIdIndex); - // 5. Create the imageLoadObject const imageLoadObject = { promise: Promise.resolve(image), }; @@ -900,6 +933,20 @@ export default class BaseStreamingImageVolume extends ImageVolume { return imageLoadObject; } + /** + * Returns an array of all the volume's images as Cornerstone images. + * It iterates over all the imageIds and converts them to Cornerstone images. + * + * @returns An array of Cornerstone images. + */ + public getCornerstoneImages(): Types.IImage[] { + const { imageIds } = this; + + return imageIds.map((imageId, imageIdIndex) => { + return this.getCornerstoneImage(imageId, imageIdIndex); + }); + } + /** * Converts all the volume images (imageIds) to cornerstoneImages and caches them. * It iterates over all the imageIds and convert them until there is no diff --git a/packages/tools/package.json b/packages/tools/package.json index e9baa59d44..cf5cc54ae8 100644 --- a/packages/tools/package.json +++ b/packages/tools/package.json @@ -17,6 +17,7 @@ "scripts": { "build:cjs": "tsc --project ./tsconfig.cjs.json", "build:esm": "tsc --project ./tsconfig.esm.json", + "build:esm:watch": "tsc --project ./tsconfig.esm.json --watch", "build:umd": "cross-env NODE_ENV=production webpack --config .webpack/webpack.prod.js", "build:all": "yarn run build:umd && yarn run build:cjs && yarn run build:esm", "build": "yarn run build:all", diff --git a/packages/tools/src/stateManagement/segmentation/SegmentationStateManager.ts b/packages/tools/src/stateManagement/segmentation/SegmentationStateManager.ts index 787fab2d68..04156c5804 100644 --- a/packages/tools/src/stateManagement/segmentation/SegmentationStateManager.ts +++ b/packages/tools/src/stateManagement/segmentation/SegmentationStateManager.ts @@ -134,6 +134,25 @@ export default class SegmentationStateManager { return toolGroupSegRepresentationsWithConfig.segmentationRepresentations; } + /** + * Returns an array of all segmentation representations for all tool groups. + * @returns An array of ToolGroupSpecificRepresentations. + */ + getAllSegmentationRepresentations(): Record< + string, + ToolGroupSpecificRepresentation[] + > { + const toolGroupSegReps: Record = + {}; + Object.entries(this.state.toolGroups).forEach( + ([toolGroupId, toolGroupSegRepresentationsWithConfig]) => { + toolGroupSegReps[toolGroupId] = + toolGroupSegRepresentationsWithConfig.segmentationRepresentations; + } + ); + return toolGroupSegReps; + } + /** * Add a new segmentation representation to the toolGroup's segmentation representations. * @param toolGroupId - The Id of the tool group . diff --git a/packages/tools/src/stateManagement/segmentation/segmentationState.ts b/packages/tools/src/stateManagement/segmentation/segmentationState.ts index 6d458abec4..5093cdaf86 100644 --- a/packages/tools/src/stateManagement/segmentation/segmentationState.ts +++ b/packages/tools/src/stateManagement/segmentation/segmentationState.ts @@ -85,6 +85,18 @@ function getSegmentationRepresentations( return segmentationStateManager.getSegmentationRepresentations(toolGroupId); } +/** + * Get all segmentation representations in the state + * @returns An array of segmentation representation objects. + */ +function getAllSegmentationRepresentations(): Record< + string, + ToolGroupSpecificRepresentation[] +> { + const segmentationStateManager = getDefaultSegmentationStateManager(); + return segmentationStateManager.getAllSegmentationRepresentations(); +} + /** * Get the tool group IDs that have a segmentation representation with the given * segmentationId @@ -92,6 +104,10 @@ function getSegmentationRepresentations( * @returns An array of tool group IDs. */ function getToolGroupIdsWithSegmentation(segmentationId: string): string[] { + if (!segmentationId) { + throw new Error('getToolGroupIdsWithSegmentation: segmentationId is empty'); + } + const segmentationStateManager = getDefaultSegmentationStateManager(); const state = segmentationStateManager.getState(); const toolGroupIds = Object.keys(state.toolGroups); @@ -405,6 +421,7 @@ export { setSegmentSpecificRepresentationConfig, // helpers s getToolGroupIdsWithSegmentation, + getAllSegmentationRepresentations, getSegmentationRepresentationByUID, // color addColorLUT, diff --git a/packages/tools/src/tools/segmentation/strategies/eraseRectangle.ts b/packages/tools/src/tools/segmentation/strategies/eraseRectangle.ts index a3df8236ed..2b59d8f579 100644 --- a/packages/tools/src/tools/segmentation/strategies/eraseRectangle.ts +++ b/packages/tools/src/tools/segmentation/strategies/eraseRectangle.ts @@ -35,10 +35,6 @@ function eraseRectangle( const boundsIJK = getBoundingBoxAroundShape(rectangleCornersIJK, dimensions); - if (boundsIJK.every(([min, max]) => min !== max)) { - throw new Error('Oblique segmentation tools are not supported yet'); - } - // Since always all points inside the boundsIJK is inside the rectangle... const pointInShape = () => true; diff --git a/packages/tools/src/tools/segmentation/strategies/fillCircle.ts b/packages/tools/src/tools/segmentation/strategies/fillCircle.ts index 691023fd20..1eb997e649 100644 --- a/packages/tools/src/tools/segmentation/strategies/fillCircle.ts +++ b/packages/tools/src/tools/segmentation/strategies/fillCircle.ts @@ -68,10 +68,6 @@ function fillCircle( const boundsIJK = getBoundingBoxAroundShape(ellipsoidCornersIJK, dimensions); - if (boundsIJK.every(([min, max]) => min !== max)) { - throw new Error('Oblique segmentation tools are not supported yet'); - } - // using circle as a form of ellipse const ellipseObj = { center: center as Types.Point3, diff --git a/packages/tools/src/tools/segmentation/strategies/fillRectangle.ts b/packages/tools/src/tools/segmentation/strategies/fillRectangle.ts index 27762595ef..de5428a279 100644 --- a/packages/tools/src/tools/segmentation/strategies/fillRectangle.ts +++ b/packages/tools/src/tools/segmentation/strategies/fillRectangle.ts @@ -54,10 +54,6 @@ function fillRectangle( const boundsIJK = getBoundingBoxAroundShape(rectangleCornersIJK, dimensions); - if (boundsIJK.every(([min, max]) => min !== max)) { - throw new Error('Oblique segmentation tools are not supported yet'); - } - // Since always all points inside the boundsIJK is inside the rectangle... const pointInRectangle = () => true; diff --git a/utils/ExampleRunner/build-all-examples-cli.js b/utils/ExampleRunner/build-all-examples-cli.js index a0c8cbac92..a35b48fa7a 100644 --- a/utils/ExampleRunner/build-all-examples-cli.js +++ b/utils/ExampleRunner/build-all-examples-cli.js @@ -63,6 +63,10 @@ if (options.fromRoot === true) { path: 'packages/dicomImageLoader/examples', regexp: 'index.ts', }, + { + path: 'packages/adapters/examples', + regexp: 'index.ts', + }, ], }; } else { diff --git a/utils/ExampleRunner/example-info.json b/utils/ExampleRunner/example-info.json index 80c53b896e..ea9079ff83 100644 --- a/utils/ExampleRunner/example-info.json +++ b/utils/ExampleRunner/example-info.json @@ -17,6 +17,9 @@ }, "dicom-image-loader": { "description": "usage of cornerstone dicom image loader" + }, + "adapters": { + "description": "usage of cornerstone adapters" } }, "examplesByCategory": { @@ -285,6 +288,12 @@ "name": "WADO-URI (DICOM P10)", "description": "WADO-URI (DICOM P10 via HTTP GET) with different codecs" } + }, + "adapters": { + "segmentationExport": { + "name": "DICOM SEG export", + "description": "Demonstrates how to export a segmentation to DICOM SEG" + } } } } diff --git a/utils/ExampleRunner/example-runner-cli.js b/utils/ExampleRunner/example-runner-cli.js index e5e6927414..e979d84759 100755 --- a/utils/ExampleRunner/example-runner-cli.js +++ b/utils/ExampleRunner/example-runner-cli.js @@ -77,6 +77,10 @@ const configuration = { path: 'packages/dicomImageLoader/examples', regexp: 'index.ts', }, + { + path: 'packages/adapters/examples', + regexp: 'index.ts', + }, ], }; diff --git a/utils/ExampleRunner/template-config.js b/utils/ExampleRunner/template-config.js index f0c066ce36..c4baf9d01c 100644 --- a/utils/ExampleRunner/template-config.js +++ b/utils/ExampleRunner/template-config.js @@ -2,6 +2,7 @@ const path = require('path'); const csRenderBasePath = path.resolve('packages/core/src/index'); const csToolsBasePath = path.resolve('packages/tools/src/index'); +const csAdapters = path.resolve('packages/adapters/src/index'); const csStreamingBasePath = path.resolve( 'packages/streaming-image-volume-loader/src/index' ); @@ -68,6 +69,7 @@ module.exports = { alias: { '@cornerstonejs/core': '${csRenderBasePath.replace(/\\/g, '/')}', '@cornerstonejs/tools': '${csToolsBasePath.replace(/\\/g, '/')}', + '@cornerstonejs/adapters': '${csAdapters.replace(/\\/g, '/')}', '@cornerstonejs/streaming-image-volume-loader': '${csStreamingBasePath.replace( /\\/g, '//' diff --git a/utils/ExampleRunner/template-multiexample-config.js b/utils/ExampleRunner/template-multiexample-config.js index 29a7d3c309..2b162b14fa 100644 --- a/utils/ExampleRunner/template-multiexample-config.js +++ b/utils/ExampleRunner/template-multiexample-config.js @@ -5,6 +5,7 @@ const path = require('path'); // etc... const csRenderBasePath = path.resolve('./packages/core/src/index'); const csToolsBasePath = path.resolve('./packages/tools/src/index'); +const csAdaptersBasePath = path.resolve('./packages/adapters/src/index'); const csStreamingBasePath = path.resolve( './packages/streaming-image-volume-loader/src/index' ); @@ -97,6 +98,7 @@ module.exports = { alias: { '@cornerstonejs/core': '${csRenderBasePath.replace(/\\/g, '/')}', '@cornerstonejs/tools': '${csToolsBasePath.replace(/\\/g, '/')}', + '@cornerstonejs/adapters': '${csAdaptersBasePath.replace(/\\/g, '/')}', '@cornerstonejs/streaming-image-volume-loader': '${csStreamingBasePath.replace( /\\/g, '/' diff --git a/yarn.lock b/yarn.lock index 439a153b13..7b86be8fe8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1211,6 +1211,14 @@ core-js-pure "^3.25.1" regenerator-runtime "^0.13.11" +"@babel/runtime-corejs3@^7.22.5": + version "7.22.6" + resolved "https://registry.yarnpkg.com/@babel/runtime-corejs3/-/runtime-corejs3-7.22.6.tgz#e8e625eb3db29491e0326b3aeb9929c43b270ae4" + integrity sha512-M+37LLIRBTEVjktoJjbw4KVhupF0U/3PYUCbBwgAd9k17hoKhRu1n935QiG7Tuxv0LJOMrb2vuKEeYUlv0iyiw== + dependencies: + core-js-pure "^3.30.2" + regenerator-runtime "^0.13.11" + "@babel/runtime@7.17.9": version "7.17.9" resolved "https://registry.npmjs.org/@babel/runtime/-/runtime-7.17.9.tgz#d19fbf802d01a8cb6cf053a64e472d42c434ba72" @@ -5226,6 +5234,11 @@ address@^1.0.1, address@^1.1.2: resolved "https://registry.npmjs.org/address/-/address-1.2.2.tgz#2b5248dac5485a6390532c6a517fda2e3faac89e" integrity sha512-4B/qKCfeE/ODUaAUpSwfzazo5x29WD4r3vXiWsB7I2mSDAihwEqKO+g8GELZUQSSAo5e1XTYh3ZVfLyxBc12nA== +adm-zip@^0.5.10: + version "0.5.10" + resolved "https://registry.yarnpkg.com/adm-zip/-/adm-zip-0.5.10.tgz#4a51d5ab544b1f5ce51e1b9043139b639afff45b" + integrity sha512-x0HvcHqVJNTPk/Bw8JbLWlWoo6Wwnsug0fnYYro1HBrjxZ3G7/AZk7Ahv8JwDe1uIcz8eBqvu86FuF1POiG7vQ== + agent-base@6, agent-base@^6.0.2: version "6.0.2" resolved "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz#49fff58577cfee3f37176feab4c22e00f86d7f77" @@ -7661,6 +7674,11 @@ core-js-pure@^3.25.1: resolved "https://registry.npmjs.org/core-js-pure/-/core-js-pure-3.30.1.tgz#7d93dc89e7d47b8ef05d7e79f507b0e99ea77eec" integrity sha512-nXBEVpmUnNRhz83cHd9JRQC52cTMcuXAmR56+9dSMpRdpeA4I1PX6yjmhd71Eyc/wXNsdBdUDIj1QTIeZpU5Tg== +core-js-pure@^3.30.2: + version "3.31.1" + resolved "https://registry.yarnpkg.com/core-js-pure/-/core-js-pure-3.31.1.tgz#73d154958881873bc19381df80bddb20c8d0cdb5" + integrity sha512-w+C62kvWti0EPs4KPMCMVv9DriHSXfQOCQ94bGGBiEW5rrbtt/Rz8n5Krhfw9cpFyzXBjf3DB3QnPdEzGDY4Fw== + core-js@^2.4.0, core-js@^2.6.12: version "2.6.12" resolved "https://registry.npmjs.org/core-js/-/core-js-2.6.12.tgz#d9333dfa7b065e347cc5682219d6f690859cc2ec" @@ -8193,23 +8211,13 @@ dateformat@^3.0.0: resolved "https://registry.npmjs.org/dateformat/-/dateformat-3.0.3.tgz#a6e37499a4d9a9cf85ef5872044d62901c9889ae" integrity sha512-jyCETtSl3VMZMWeRo7iY1FL19ges1t55hMo5yaam4Jrsm5EPL89UQkoQRyiI+Yf4k8r2ZpdngkV8hr1lIdjb3Q== -dcmjs@^0.24.4: - version "0.24.10" - resolved "https://registry.npmjs.org/dcmjs/-/dcmjs-0.24.10.tgz#048a12a1905175763274545c17bb38b506cdfd00" - integrity sha512-kugEdBqwwTx7lqUxm4jdklkMJiQ8pZ0aqMw9WkszkDU5lDWHyq2kbRccACtcwqygC7j1d36w8uG/vNLMejeMBg== - dependencies: - "@babel/runtime-corejs2" "^7.17.8" - gl-matrix "^3.1.0" - lodash.clonedeep "^4.5.0" - loglevelnext "^3.0.1" - ndarray "^1.0.19" - -dcmjs@^0.29.5: - version "0.29.6" - resolved "https://registry.npmjs.org/dcmjs/-/dcmjs-0.29.6.tgz#6ca1543e74bae29657d1f7f2273407e23266c011" - integrity sha512-xMULkeFpxFNV8JZwWMOW3j1uL3iAe8r3pytlyWnlTxYc/OZ9KSu4wmQMVMBAgmY0GYpeWlji7GMRArVh8ISKhA== +dcmjs@^0.29.8: + version "0.29.8" + resolved "https://registry.yarnpkg.com/dcmjs/-/dcmjs-0.29.8.tgz#3daa5224f8e75b2e5b069590bef26a272741fa44" + integrity sha512-Y0/KZAmT1siVo7eH3KK4ZflEbNi61soUpD0N7lsXMVVJQ6IZkHlaSzb9DtqnEpMs7RJDfvZGr1uXpv1vBBIypQ== dependencies: - "@babel/runtime-corejs2" "^7.17.8" + "@babel/runtime-corejs3" "^7.22.5" + adm-zip "^0.5.10" gl-matrix "^3.1.0" lodash.clonedeep "^4.5.0" loglevelnext "^3.0.1"