@radicalimaging/static-wado-creator 1.2.8 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,287 +1,56 @@
1
- # `@radicalimaging/static-wado-creator`
1
+ # Static DICOMweb Creator
2
+ The Static DICOMweb Creator package converts DICOM Part 10 files to and from files in a DICOMweb like format.
2
3
 
3
- # static-wado-js
4
+ # Installation
5
+ Using npm, install the package:
4
6
 
5
- Status: Beta - in development (as of Oct 25, 2021)
6
-
7
- The scope of this project is to convert DICOM P10 into a format into a DICOMweb compatible format - specifically JSON metadata, frames and bulkdata. This library will enable the following:
8
- * Building of DICOMweb compliant services
9
- * Ability to stream the data for "on the fly" use cases
10
- * Ability to pregenerate DICOMweb responses and store them so they can be served up with a standard HTTP server to implement a subset of DICOMweb WADO-RS, specifically:
11
- * Retrieve instance metadata
12
- * Retrieve series level metadata
13
- * Retrieve frames in an instance
14
- * Retrieve bulk data
15
- * Retrieve study, series or instance query
16
- * To explore an alternative archive format that is web friendly (no need for a DICOM parser)
17
- * Should be able to recreate DICOM P10 file from this data (semantic equivalence or possibly bit for bit equivalent)
18
- * Will make updates more efficient (e.g. updating patient name just requires updating metadata)
19
- * More efficient image access (no need to scan through DICOM P10 to access frames, or store the offsets of each frame separately and seek to them)
20
-
21
- The scope of this library is limited to:
22
- * Taking one or more DICOM files as input, writing Bulkdata and Pixeldata
23
- * Creating either deduplicated representations or instance level metadata
24
- * Combining deduplicated representations into sets of deduplicated representations
25
- * Using the deduplicated representations to generate study/series/instance query results and series metadata
26
-
27
- View the [design rationale](docs/design.md) for more information on the scope
28
-
29
- View [frequently asked questions](docs/faq.md)
30
-
31
- View [specification](docs/spec.md)
32
-
33
- ## Pre-requisites
34
- View root pre-requisites section [pre-requisites](../../README.md#pre-requisites)
35
-
36
- ## Development
37
- View root development section [development](../../README.md#development)
38
-
39
- ## Running the CLI
40
- Install the CLI with either npm install -g . or npm install -g @radicalimaging/static-wado-js
41
-
42
- ## Usage
43
- Once you install the package the executables (i.e static-wado commands) are installed into to your PATH system, then you can use anywhere on your command line program.
44
-
45
- ### Api
46
- There are 5 different commands: mkdicomweb, mkdicomwebinstances, mkdicomwebstudy, mkdicomwebdeduplicated, mkdicomwebdeduplicatedgroup that can be used (see usage bellow). It can be run as:
47
-
48
- ```
49
- commnadName [options] [<input...>]
50
- ```
51
-
52
- Arguments are:
53
-
54
- input
55
- : List of directories/files separated by space.
56
- Required for commands: **mkdicomweb**, **mkdicomwebdeduplicated**, **mkdicomwebinstances**. Optional for the others. This argument should be placed at end of command invoking.
57
-
58
- The options are:
59
-
60
- -c, --clean
61
- : Clean the study output directory for these instances (default: false)
62
-
63
- -C, --remove-deduplicated-instances
64
- : Remove single instance deduplicated files after writing group files (default: false)
65
-
66
- -d, --deduplicate
67
- : Write deduplicate instance level data (default: false)
68
-
69
- -g, --group
70
- : Write combined deduplicate data (default: true)
71
-
72
- -i, --instances
73
- : Write instance metadata (default: false)
74
-
75
- -m, --maximum-inline-public-length <value>
76
- : Maximum length of public binary data (default: 131074)
77
-
78
- -M, --maximum-inline-private-length <value>
79
- : Maximum length of private binary data (default: 64)
80
-
81
- --no-recompress
82
- : Force no recompression
83
- [see](#to-recompress)
84
-
85
- --no-recompress-thumb
86
- : Force no recompression for thumbnail
87
- [see](#to-recompress-thumb)
88
-
89
- -o, --dir <value>
90
- : Set output directory (default: "~/dicomweb")
91
-
92
- --path-deduplicated <path>
93
- : Set the deduplicate data directory path (relative to dir) (default: "deduplicated")
94
-
95
- --path-instances <path>
96
- : Set the instances directory path (relative to dir) (default: "instances")
97
-
98
- -r, --recompress <listvalue...>
99
- : List of types to recompress separated by space (choices: "uncompressed", "jp2", "jpeglossless", "rle", default: "uncompressed jp2")
100
- [see](#to-recompress)
101
-
102
- --recompress-thumb <listvalue...>
103
- : List of types to recompress thumb separated by space (choices: "uncompressed", "jp2", "jpeglossless", "rle", default: "uncompressed jp2")
104
- [see](#to-recompress-thumb)
105
-
106
- -s, --study
107
- : Write study metadata - on provided instances only (TO FIX), (default: true)
108
-
109
- -t, --content-type <type>
110
- : Destination type to compress to (choices: "jls", "lei", "jls-lossy" or DICOM Transfer Syntax UID - default: "jls")
111
-
112
- -T, --colour-content-type <type>
113
- : Colour content type (default: null)
114
-
115
- -v, --verbose
116
- : Write verbose output (default: false)
117
-
118
- -V, --version
119
- : output the version number
120
-
121
- ### Help command
122
- You can output help information by:
123
- ```
124
- commandName help
125
- ```
126
-
127
- ```
128
- commandName -h
129
- ```
130
-
131
- ```
132
- commandName --help
133
- ```
134
-
135
- ### To create instances
136
- Run the tool:
137
- ```
138
- mkdicomwebinstances <directoryOfP10Files>
139
- ```
140
-
141
- ### To create a full DICOMweb output structure
142
- Run the tool:
143
- ```
144
- mkdicomweb <directoryOfP10Files>
145
- ```
146
-
147
- ### To run separated stages
148
- The mkdicomweb tool runs the three stages all together, on just the studies references. This can instead be done on separate files by running:
7
+ ```bash
8
+ npm install -g @radicalimaging/static-wado-creator
149
9
  ```
150
- mkdicomwebdeduplicated <directoryOfP10Files>
151
- mkdicomwebdeduplicatedgroup
152
- mkdicomwebstudy
153
- ```
154
- which creates a full study directory. The first stage writes to ~/dicomweb/instances/<studyUID>/ data about each instance as it is read in. The second stage then groups these files for more efficient compression into the ~/dicomweb/deduplicated/<studyUID>/ The last stage then creates the actual DICOMweb files.
155
10
 
156
- There is currently no notification of what studies have been updated between stages. The intent is to write notifications to ~/dicomweb/notifications/<studyUID> of what operations need to be applied/updated.
157
11
 
158
- ### To Serve Instances As a Web Server
159
- ```
160
- cd ~/dicomweb
161
- npx http-server -p 5000 -g --cors -e json
162
- ```
12
+ ## Additional Installs:
163
13
 
164
- The -g option serves up compressed files ending in .gz as compressed http streams.
14
+ * To render thumbnail/rendered responses, install the dcm2jpg command from [dcm4che](www.dcm4che.org)
15
+ * To generate thumbnails for video, instal ffmpeg.
165
16
 
166
- The -e option will mark json files as the default extension, so when requesting `GET dicomweb/studies/` the `./studies/index.json.gz` file will be served.
167
17
 
168
- ### To Create DICOM part 10 from DICOMweb files
169
- TODO
18
+ # Tools Usage
19
+ The tools will write to a dicomweb folder created by default in the users home directory (~) in the `~/dicomweb/` location in the locations as specified in [File Structure](../../file-structure.md).
170
20
 
171
- Run the tool mkdicomwebpart10 on the studyUID, and optionally on the series/instance UID's of interest to generate a local set of part 10 files.
21
+ ## Converting DICOM Part 10 to DICOMweb
172
22
 
173
- ### To Update DICOM Metadata
174
- TODO
23
+ DICOM part 10 files can be converted to DICOMweb. By default, LEI grayscale images will be converted to JPEG-LS compressed images and a thumbnail will be generated. This can be done with:
175
24
 
176
- Run the tool
25
+ ```bash
26
+ mkdicomweb create <DICOM-FILES-DIR>
177
27
  ```
178
- mkdicomwebupdate -<delete/anonymize/patient/study/series/instance> <studyInstanceUID> (tag=newValue)*
179
- ```
180
- to delete the given item or to update the specified attribute contained in the given level. Multiple mkdicomwebupdate commands may be run to perform updates on different attribute sets, or they may be grouped into a single file for bulk application.
181
28
 
182
- ### To recompress
183
- It allows commands to recompress data/metadata prior writing it to local. Currently, if recompress is active the designed data types will be transcoded [see](#recompress-aliases-and-input-transfer-syntaxes).
29
+ Note there is no glob support on the input parameter, so either directories or files need to be fully specified.
184
30
 
185
- By default the recompression occurs for incoming types: uncompressed, jp2
186
- ```
187
- mkdicomweb ./folderName
188
- ```
189
- It will recompress any existing data whose transfer syntaxes are 1.2.840.10008.1.2.4.90, 1.2.840.10008.1.2.4.91, 1.2.840.10008.1.2, 1.2.840.10008.1.2.1 and 1.2.840.10008.1.2.2.
190
-
191
- Define incoming types for recompression of: uncompressed,jp2,rle
192
- ```
193
- mkdicomweb -r uncompressed jp2 rle ./folderName
194
- ```
195
- It will recompress any existing data whose transfer syntaxes: 1.2.840.10008.1.2.4.90, 1.2.840.10008.1.2.4.91, 1.2.840.10008.1.2, 1.2.840.10008.1.2.1, 1.2.840.10008.1.2.2 and 1.2.840.10008.1.2.5 to transfer syntax 1.2.840.10008.1.2.4.80.
31
+ ## Converting Static DICOMweb to Part 10
196
32
 
197
- By default the destination compression type for recompression: jls
198
- ```
199
- mkdicomweb ./folderName
200
- ```
201
- It will recompress any existing data whose transfer syntaxes are 1.2.840.10008.1.2.4.90, 1.2.840.10008.1.2.4.91, 1.2.840.10008.1.2, 1.2.840.10008.1.2.1 and 1.2.840.10008.1.2.2 to jls (transfer syntax 1.2.840.10008.1.2.4.80)
33
+ Static DICOMweb files can be converted back into Part 10, provided:
202
34
 
203
- Define destination type for uncompressed encoding: lei
204
- ```
205
- mkdicomweb -t lei ./folderName
206
- ```
207
- It will decode any existing data whose transfer syntaxes are 1.2.840.10008.1.2.4.90, 1.2.840.10008.1.2.4.91, 1.2.840.10008.1.2, 1.2.840.10008.1.2.1 and 1.2.840.10008.1.2.2 and store uncompressed as LEI (transfer syntax 1.2.840.10008.1.2.1)
35
+ * Total DICOM Part 10 size is under 2 gb
36
+ * DICOMweb data is stored in a compressed transfer syntax
208
37
 
209
- Force no recompression
210
- ```
211
- mkdicomweb -r uncompressed jp2 rle --no-recompress ./folderName
212
- ```
213
- It will NOT recompress any existing data but instead it will keep them in the original encoding.
38
+ The commands is:
214
39
 
215
- Force no recompression
216
- ```
217
- mkdicomweb --no-recompress ./folderName
40
+ ```bash
41
+ mkdicomweb part10 <studyUID>
218
42
  ```
219
- It will NOT recompress any existing data but instead it will keep them in the original encoding.
220
-
221
- Obs: --no-recompress forces no compression at all. Use it if you want to disable even the default behavior.
222
-
223
43
 
224
- ### Recompress aliases and input transfer syntaxes
225
- The table below shows the support for transfer syntax recompression(i.e use "... -r jp2" if you want to recompress from 1.2.840.10008.1.2.4.90 to 1.2.840.10008.1.2.4.80)
226
-
227
- | TransferSyntaxUID | Name | Recompress alias | Target TransferSyntaxUID |
228
- |------------------------ |--------------------------------------------------------- |------------------ |-------------------------- |
229
- | 1.2.840.10008.1.2 | Implicit VR Endian | uncompressed | 1.2.840.10008.1.2 |
230
- | 1.2.840.10008.1.2.1 | Explicit VR Little Endian | uncompressed | 1.2.840.10008.1.2.1 |
231
- | 1.2.840.10008.1.2.2 | Explicit VR Big Endian | uncompressed | 1.2.840.10008.1.2.1 |
232
- | 1.2.840.10008.1.2.4.57 | JPEG Lossless, Nonhierarchical (Processes 14) | jpeglossless | 1.2.840.10008.1.2.4.80 |
233
- | 1.2.840.10008.1.2.4.70 | JPEG Lossless, Nonhierarchical, First- Order Prediction | jpeglossless | 1.2.840.10008.1.2.4.80 |
234
- | 1.2.840.10008.1.2.4.90 | JPEG 2000 Image Compression (Lossless Only) | jp2 | 1.2.840.10008.1.2.4.80 |
235
- | 1.2.840.10008.1.2.4.91 | JPEG 2000 Image Compression | jp2 | 1.2.840.10008.1.2.4.80 |
236
- | 1.2.840.10008.1.2.5 | RLE Lossless | rle | 1.2.840.10008.1.2.4.80 |
237
-
238
-
239
- ### To recompress thumb
240
- It tells thumbnail creation to use recompressed data or not.
241
- The list of types MUST be necessarily a subset of recompress list.
242
-
243
- Obs: --no-recompress-thumb forces to not use compression at all. Use it if you want to disable even the default behavior.
244
-
245
- By default the recompression occurs for incoming types: uncompressed, jp2. This will recompress the types mentioned and will generate the thumbnails with the result of data recompression.
44
+ ## Delete A Study
45
+ A study can be deleted using:
246
46
 
247
47
  ```
248
- mkdicomweb ./folderName
48
+ mkdicomweb delete <studyUID>
249
49
  ```
250
50
 
251
- Force recompression thumb. This will recompress the types: uncompressed jp2 rle, thumbnails creation will use original data for all types except jp2 (which will use recompress data).
252
- ```
253
- mkdicomweb -r uncompressed jp2 rle --recompress-thumb jp2 ./folderName
254
- ```
51
+ ## Reject A Series
52
+ A series can be "deleted", that is rejected, which removed it only from the indices by running:
255
53
 
256
- Skipping not recompressed data. This will recompress the types: uncompressed jp2, thumbnails creation will use original data for all types (since recompress-thumb list does not intersect with recompress list).
257
54
  ```
258
- mkdicomweb -r uncompressed jp2 --recompress-thumb rle ./folderName
259
- ```
260
-
261
- Force no recompression thumb. This will recompress the types: uncompressed jp2 rle, but for thumbnails creation will use original data.
55
+ mkdicomweb reject <studyUID>/seres/<seriesUID>
262
56
  ```
263
- mkdicomweb -r uncompressed jp2 rle --no-recompress-thumb ./folderName
264
- ```
265
-
266
-
267
-
268
-
269
- ## Development
270
-
271
- ## TODO (Looking for help here!!)
272
-
273
- * Create docker container to encapsulate build environment
274
- * Create test data (DICOM P10 and expected DICOMweb results) - Jordan working on this
275
- * Document API
276
- * Create CI + Publish to NPM
277
- * Fix Bugs
278
- * Get bulkdata refs written properly
279
- * Enhance cli
280
- * Add support for specifying bulkDataMinSize
281
- * Add support for writing out DICOM P10 file
282
- * Enhance library
283
- * Write out "info" file
284
- * P10 Header
285
- * Data needed to recreate original P10 instance
286
- * Create DICOMweb -> DICOM P10 tool
287
-
package/lib/StaticWado.js CHANGED
@@ -9,12 +9,13 @@ const getDataSet = require("./operation/getDataSet");
9
9
  const InstanceDeduplicate = require("./operation/InstanceDeduplicate");
10
10
  const DeduplicateWriter = require("./writer/DeduplicateWriter");
11
11
  const ImageFrameWriter = require("./writer/ImageFrameWriter");
12
+ const WriteStream = require("./writer/WriteStream");
12
13
  const CompleteStudyWriter = require("./writer/CompleteStudyWriter");
13
14
  const IdCreator = require("./util/IdCreator");
14
15
  const ScanStudy = require("./operation/ScanStudy");
15
16
  const HashDataWriter = require("./writer/HashDataWriter");
16
17
  const VideoWriter = require("./writer/VideoWriter");
17
- const { transcodeImageFrame, transcodeId, transcodeMetadata } = require("./operation/adapter/transcodeImage");
18
+ const { transcodeImageFrame, generateLossyImage, transcodeId, transcodeMetadata } = require("./operation/adapter/transcodeImage");
18
19
  const ThumbnailWriter = require("./writer/ThumbnailWriter");
19
20
  const decodeImage = require("./operation/adapter/decodeImage");
20
21
  const ThumbnailService = require("./operation/ThumbnailService");
@@ -168,14 +169,30 @@ class StaticWado {
168
169
  bulkdata: async (bulkData, options) => {
169
170
  const _bulkDataIndex = bulkDataIndex;
170
171
  bulkDataIndex += 1;
172
+ // TODO - handle other types here too as single part rendered
173
+ if (options?.mimeType === "application/pdf") {
174
+ console.log("Writing rendered mimeType", options.mimeType);
175
+ const writeStream = WriteStream(id.sopInstanceRootPath, "rendered.pdf", {
176
+ gzip: false,
177
+ mkdir: true,
178
+ });
179
+ await writeStream.write(bulkData);
180
+ await writeStream.close();
181
+ }
171
182
  return this.callback.bulkdata(targetId, _bulkDataIndex, bulkData, options);
172
183
  },
173
184
  imageFrame: async (originalImageFrame) => {
174
- const { imageFrame: transcodedImageFrame, id: transcodedId } = await transcodeImageFrame(id, targetId, originalImageFrame, dataSet, this.options);
185
+ const { imageFrame: transcodedImageFrame, decoded, id: transcodedId } = await transcodeImageFrame(id, targetId, originalImageFrame, dataSet, this.options);
186
+
187
+ const lossyImage = await generateLossyImage(id, decoded, this.options);
175
188
 
176
189
  const currentImageFrameIndex = imageFrameIndex;
177
190
  imageFrameIndex += 1;
178
191
 
192
+ if (lossyImage) {
193
+ await this.callback.imageFrame(lossyImage.id, currentImageFrameIndex, lossyImage.imageFrame);
194
+ }
195
+
179
196
  thumbnailService.queueThumbnail(
180
197
  {
181
198
  imageFrame: originalImageFrame,
@@ -0,0 +1,114 @@
1
+ /* eslint-disable no-param-reassign */
2
+ const { Tags, readBulkData } = require("@radicalimaging/static-wado-util");
3
+ const fs = require("fs");
4
+ const dcmjs = require("dcmjs");
5
+
6
+ const StaticWado = require("./StaticWado");
7
+ const adaptProgramOpts = require("./util/adaptProgramOpts");
8
+ const WriteStream = require("./writer/WriteStream");
9
+
10
+ const UncompressedLEIExplicit = "1.2.840.10008.1.2.1";
11
+ const { DicomDict, DicomMetaDictionary } = dcmjs.data;
12
+
13
+ const fileMetaInformationVersionArray = new Uint8Array(2);
14
+ fileMetaInformationVersionArray[1] = 1;
15
+
16
+ const createFmi = (instance) => {
17
+ // Assume the TSUID is in the value 0
18
+ const TransferSyntaxUID = Tags.getValue(instance, Tags.AvailableTransferSyntaxUID) || UncompressedLEIExplicit;
19
+ const MediaStorageSOPClassUID = Tags.getValue(instance, Tags.MediaStorageSOPClassUID);
20
+ const SOPInstanceUID = Tags.getValue(instance, Tags.SOPInstanceUID);
21
+ const naturalFmi = {
22
+ MediaStorageSOPClassUID,
23
+ MediaStorageSOPInstanceUID: SOPInstanceUID,
24
+ ImplementationVersionName: "mkdicomweb",
25
+ TransferSyntaxUID,
26
+ ImplementationClassUID: "2.25.984723498557234098.001",
27
+ FileMetaInformationVersion: fileMetaInformationVersionArray.buffer,
28
+ };
29
+ const denaturalized = DicomMetaDictionary.denaturalizeDataset(naturalFmi);
30
+ return denaturalized;
31
+ };
32
+
33
+ const readBulkDataValue = async (studyDir, instance, value) => {
34
+ const { BulkDataURI } = value;
35
+ value.vr = "OB";
36
+ const seriesUid = Tags.getValue(instance, Tags.SeriesInstanceUID);
37
+ const numberOfFrames = Tags.getValue(instance, Tags.NumberOfFrames) || 1;
38
+ const dir = `${studyDir}/series/${seriesUid}`;
39
+ if (BulkDataURI.indexOf("/frames") !== -1) {
40
+ // Really only support a limited number of frames based on memory size
41
+ value.Value = [];
42
+ for (let frame = 1; frame <= numberOfFrames; frame++) {
43
+ const bulk = await readBulkData(dir, BulkDataURI, frame);
44
+ if (!bulk) break;
45
+ value.Value.push(bulk);
46
+ }
47
+ } else {
48
+ value.Value = [new ArrayBuffer(await readBulkData(dir, BulkDataURI))];
49
+ }
50
+ };
51
+
52
+ const readBinaryData = async (dir, instance) => {
53
+ for (const tag of Object.keys(instance)) {
54
+ const v = instance[tag];
55
+ if (v.BulkDataURI) {
56
+ await readBulkDataValue(dir, instance, v);
57
+ continue;
58
+ }
59
+ if (!v.vr) {
60
+ const value0 = v.Value?.[0];
61
+ if (typeof value0 === "string") {
62
+ v.vr = "LT";
63
+ } else {
64
+ console.log("Deleting", tag, v.Value, v);
65
+ delete instance[tag];
66
+ }
67
+ continue;
68
+ }
69
+ if (v.vr === "SQ") {
70
+ await Promise.all(v.Values.map(readBinaryData));
71
+ continue;
72
+ }
73
+ }
74
+ };
75
+
76
+ const writeBuffer = (dir, fileName, buffer) => {
77
+ const writeStream = WriteStream(dir, fileName, {
78
+ mkdir: true,
79
+ gzip: false,
80
+ });
81
+ writeStream.write(buffer);
82
+ return writeStream.close();
83
+ };
84
+
85
+ module.exports = async function createMain(options, program) {
86
+ const finalOptions = adaptProgramOpts(options, {
87
+ ...this,
88
+ // Instance metadata is the instances/<sopUID>/metadata.gz files
89
+ isInstance: false,
90
+ // Deduplicated data is single instance deduplicated data
91
+ isDeduplicate: false,
92
+ // Group data is the group file directories
93
+ isGroup: false,
94
+ isStudyData: false,
95
+ isDeleteInstances: false,
96
+ });
97
+ const importer = new StaticWado(finalOptions);
98
+ const studyInstanceUid = program.args[0];
99
+ const dir = `${importer.options.rootDir}/studies/${studyInstanceUid}`;
100
+ console.log("Creating part 10 for", studyInstanceUid, dir);
101
+ const study = await importer.callback.scanStudy(studyInstanceUid);
102
+ const outputPath = `./studies/${studyInstanceUid}`;
103
+ for (const sop of study.getSopUids()) {
104
+ const instance = await study.recombine(sop);
105
+ const fmi = createFmi(instance);
106
+
107
+ console.log("Processing sop", sop);
108
+ const dicomDict = new DicomDict(fmi);
109
+ await readBinaryData(dir, instance);
110
+ dicomDict.dict = instance;
111
+ const buffer = Buffer.from(dicomDict.write());
112
+ writeBuffer(outputPath, sop, buffer);
113
+ }
114
+ };
@@ -1,6 +1,7 @@
1
1
  const ConfigPoint = require("config-point");
2
2
  const { staticWadoConfig } = require("@radicalimaging/static-wado-util");
3
3
  const createMain = require("./createMain");
4
+ const createPart10 = require("./createPart10");
4
5
  const deleteMain = require("./deleteMain");
5
6
  const rejectMain = require("./rejectMain");
6
7
  const instanceMain = require("./instanceMain");
@@ -21,7 +22,12 @@ const { mkdicomwebConfig } = ConfigPoint.register({
21
22
  isGroup: true,
22
23
  options: [
23
24
  {
24
- key: "-c, --clean",
25
+ key: "-c <configFile.json5>",
26
+ description: "Use an alternate configuration file",
27
+ defaultValue: "~/static-wado.json5",
28
+ },
29
+ {
30
+ key: "--clean",
25
31
  description: "Clean the outputs before generating/starting to write new values.",
26
32
  defaultValue: false,
27
33
  },
@@ -39,6 +45,25 @@ const { mkdicomwebConfig } = ConfigPoint.register({
39
45
  description: "Delete the imported instances",
40
46
  defaultValue: false,
41
47
  },
48
+ {
49
+ key: "--lossy",
50
+ description: "Generate lossy instances instead of lossless",
51
+ defaultValue: false,
52
+ },
53
+ {
54
+ key: "--alternate <type>",
55
+ description: "Generates an alternate representaton of the image generally in the /lossy sub-directory",
56
+ choices: ["jhc", "jls", "jhcLossless", "jlsLossless"],
57
+ },
58
+ {
59
+ key: "--alternate-thumbnail",
60
+ description: "Generates a thumbnail for the alternate representation",
61
+ },
62
+ {
63
+ key: "--alternate-name <dir>",
64
+ description: "Uses a given sub directory name",
65
+ defaultValue: "lossy",
66
+ },
42
67
  {
43
68
  key: "-v, --verbose",
44
69
  description: "Write verbose output",
@@ -47,7 +72,7 @@ const { mkdicomwebConfig } = ConfigPoint.register({
47
72
  {
48
73
  key: "-t, --content-type <type>",
49
74
  description: 'Destination type to compress to (choices: "jpeg", "jls", "lei", "jls-lossy", "jhc", "jxl" or DICOM Transfer Syntax UID - default: "jls")',
50
- defaultValue: "jls",
75
+ defaultValue: "jhc",
51
76
  customParser: compressionOptionParser,
52
77
  },
53
78
  {
@@ -170,6 +195,12 @@ const { mkdicomwebConfig } = ConfigPoint.register({
170
195
  "of deduplicated files each time a study instance UID is found, and only updates\n" +
171
196
  "those studies having at least one ",
172
197
  },
198
+ {
199
+ command: "part10",
200
+ arguments: ["studyUID"],
201
+ main: createPart10,
202
+ helpDescription: "Store the specified study as part 10 data",
203
+ },
173
204
  {
174
205
  command: "index",
175
206
  main: indexMain,
package/lib/model/uids.js CHANGED
@@ -33,7 +33,11 @@ const uids = {
33
33
  "1.2.840.10008.1.2.4.81": { contentType: jls, lossy: true },
34
34
  "1.2.840.10008.1.2.4.90": { contentType: jp2, extension: ".jp2" },
35
35
  "1.2.840.10008.1.2.4.91": { contentType: jp2 },
36
- "3.2.840.10008.1.2.4.96": { contentType: htj2k, extension: ".jhc" },
36
+ // Private/test transfer syntax for HTJ2K
37
+ "3.2.840.10008.1.2.4.96": { contentType: htj2k },
38
+ "1.2.840.10008.1.2.4.201": { contentType: htj2k },
39
+ "1.2.840.10008.1.2.4.202": { contentType: htj2k, extension: ".jhc" },
40
+ "1.2.840.10008.1.2.4.203": { contentType: htj2k, lossy: true },
37
41
  "1.2.840.10008.1.2.5": { contentType: rle },
38
42
  default: {},
39
43
  };
@@ -158,7 +158,8 @@ class StudyData {
158
158
  /**
159
159
  * Create a full study instance data for instance at index
160
160
  */
161
- async recombine(index) {
161
+ async recombine(indexOrSop) {
162
+ const index = typeof indexOrSop === "string" ? this.sopInstances[indexOrSop] : indexOrSop;
162
163
  const deduplicated = this.deduplicated[index];
163
164
  if (index < 0 || index >= this.deduplicated.length) {
164
165
  throw new Error(`Can't read index ${index}, out of bounds [0..${this.deduplicated.length})`);
@@ -461,6 +462,10 @@ class StudyData {
461
462
  await JSONWriter(this.deduplicatedPath, hashValue, deduplicatedList, { gzip: true, index: false });
462
463
  console.log("Wrote naturalized dataset");
463
464
  }
465
+
466
+ getSopUids() {
467
+ return Object.keys(this.sopInstances);
468
+ }
464
469
  }
465
470
 
466
471
  module.exports = StudyData;
@@ -120,9 +120,10 @@ class ThumbnailService {
120
120
  if (pixelData) {
121
121
  const { BulkDataURI } = pixelData;
122
122
  if (BulkDataURI?.indexOf("mp4")) {
123
+ fs.mkdirSync(`${itemId.sopInstanceRootPath}/rendered`, { recursive: true });
123
124
  const mp4Path = path.join(itemId.sopInstanceRootPath, "rendered/index.mp4");
124
125
  // Generate as rendered, as more back ends support that.
125
- const thumbPath = path.join(itemId.sopInstanceRootPath, "rendered");
126
+ const thumbPath = path.join(itemId.sopInstanceRootPath, "rendered/1.jpg");
126
127
  console.log("MP4 - converting video format", mp4Path);
127
128
  this.ffmpeg(mp4Path, thumbPath);
128
129
  return thumbPath;
@@ -132,26 +133,23 @@ class ThumbnailService {
132
133
  }
133
134
  return null;
134
135
  }
135
- if( options.dcm2jpg ) {
136
- return this.dcm2jpg(id.filename, id.imageFrameRootPath.replace(/frames/,"thumbnail"), {});
136
+ if (options.dcm2jpg) {
137
+ return this.dcm2jpg(id.filename, id.imageFrameRootPath.replace(/frames/, "thumbnail"), {});
137
138
  }
138
139
 
139
- console.log("Using CS2 to generate thumbnails", id, options);
140
-
141
140
  await callback.internalGenerateImage(imageFrame, dataSet, metadata, id.transferSyntaxUid, async (thumbBuffer) => {
142
- try {
143
- if (thumbBuffer) {
144
- await callback.thumbWriter(id.sopInstanceRootPath, this.thumbFileName, thumbBuffer);
145
-
146
- this.copySyncThumbnail(id.sopInstanceRootPath, id.seriesRootPath);
147
- this.copySyncThumbnail(id.seriesRootPath, id.studyPath);
148
- Stats.StudyStats.add("Thumbnail Write", `Write thumbnail ${this.thumbFileName}`, 100);
149
- }
150
- return this.thumbFileName;
151
- } catch(e) {
152
- console.log("Couldn't generate thumbnail", this.thumbFileName, e);
153
- }
141
+ try {
142
+ if (thumbBuffer) {
143
+ await callback.thumbWriter(id.sopInstanceRootPath, this.thumbFileName, thumbBuffer);
154
144
 
145
+ this.copySyncThumbnail(id.sopInstanceRootPath, id.seriesRootPath);
146
+ this.copySyncThumbnail(id.seriesRootPath, id.studyPath);
147
+ Stats.StudyStats.add("Thumbnail Write", `Write thumbnail ${this.thumbFileName}`, 100);
148
+ }
149
+ return this.thumbFileName;
150
+ } catch (e) {
151
+ console.log("Couldn't generate thumbnail", this.thumbFileName, e);
152
+ }
155
153
  });
156
154
  }
157
155
 
@@ -1,5 +1,5 @@
1
1
  const dicomCodec = require("@cornerstonejs/dicom-codec");
2
- const { Tags } = require("@radicalimaging/static-wado-util");
2
+ const { Tags, replicate } = require("@radicalimaging/static-wado-util");
3
3
  const getImageInfo = require("./getImageInfo");
4
4
 
5
5
  dicomCodec.setConfig("verbose: false");
@@ -36,7 +36,7 @@ const transcodeDestinationMap = {
36
36
  transcodeOp: transcodeOp.encode,
37
37
  },
38
38
  jhc: {
39
- transferSyntaxUid: "3.2.840.10008.1.2.4.96",
39
+ transferSyntaxUid: "1.2.840.10008.1.2.4.202",
40
40
  transcodeOp: transcodeOp.encode,
41
41
  },
42
42
  jpeg: {
@@ -67,6 +67,10 @@ const transcodeSourceMap = {
67
67
  alias: "jls",
68
68
  },
69
69
  "1.2.840.10008.1.2.4.70": {
70
+ transcodeOp: transcodeOp.decode,
71
+ alias: "jpll",
72
+ },
73
+ "1.2.840.10008.1.2.4.80": {
70
74
  transcodeOp: transcodeOp.decode,
71
75
  alias: "jls",
72
76
  },
@@ -82,6 +86,10 @@ const transcodeSourceMap = {
82
86
  transcodeOp: transcodeOp.decode,
83
87
  alias: "jhc",
84
88
  },
89
+ "1.2.840.10008.1.2.4.202": {
90
+ transcodeOp: transcodeOp.decode,
91
+ alias: "jhc",
92
+ },
85
93
  "1.2.840.10008.1.2.5": {
86
94
  transcodeOp: transcodeOp.decode,
87
95
  alias: "rle",
@@ -136,17 +144,20 @@ function shouldTranscodeImageFrame(id, options, samplesPerPixel) {
136
144
  return false;
137
145
  }
138
146
 
139
- function isValidTranscoder() {
140
- const { transferSyntaxUid } = id;
141
- const transcoder = getTranscoder(transferSyntaxUid, options, samplesPerPixel);
142
- return transcoder && transcoder.transferSyntaxUid && (recompress.includes("true") || recompress.includes(transcoder.alias));
147
+ const { transferSyntaxUid } = id;
148
+ const transcoder = getTranscoder(transferSyntaxUid, options, samplesPerPixel);
149
+ const validTranscoder = transcoder && transcoder.transferSyntaxUid;
150
+ if (!validTranscoder) {
151
+ console.verbose("Not transcoding because no decoder found for", transferSyntaxUid, samplesPerPixel);
152
+ return false;
143
153
  }
144
-
145
- const ret = isValidTranscoder();
146
- if (!ret) {
147
- console.verbose("Not transcoding");
154
+ const validRecompress = recompress.includes("true") || recompress.includes(transcoder.alias);
155
+ if (!validRecompress) {
156
+ console.verbose("Not transcoding because recompress", recompress, "does not include", transcoder.alias);
157
+ return false;
148
158
  }
149
- return ret;
159
+
160
+ return true;
150
161
  }
151
162
 
152
163
  /**
@@ -179,6 +190,102 @@ function transcodeLog(options, msg, error = "") {
179
190
  }
180
191
  }
181
192
 
193
+ const beforeEncode = (options, encoder) => {
194
+ const lossy = !!options.lossy;
195
+ const quality = lossy ? 0.002 : -1;
196
+ const delta = lossy ? 3 : 0;
197
+ // First value is to encode as reversible lossless colour
198
+ encoder.setQuality?.(!lossy, quality);
199
+ encoder.setNearLossless?.(delta);
200
+ };
201
+
202
+ function scale(imageFrame, imageInfo) {
203
+ const { rows, columns, bitsPerPixel, pixelRepresentation, samplesPerPixel } = imageInfo;
204
+ let arrayConstructor = Float32Array;
205
+ if (bitsPerPixel === 8) {
206
+ arrayConstructor = pixelRepresentation ? Int8Array : Uint8Array;
207
+ } else if (bitsPerPixel > 8 && bitsPerPixel <= 16) {
208
+ arrayConstructor = pixelRepresentation ? Int16Array : Uint16Array;
209
+ }
210
+ const src = {
211
+ pixelData: new arrayConstructor(imageFrame.buffer),
212
+ rows,
213
+ columns,
214
+ samplesPerPixel,
215
+ };
216
+ const dest = {
217
+ rows: Math.round(rows / 4),
218
+ columns: Math.round(columns / 4),
219
+ samplesPerPixel,
220
+ };
221
+ dest.pixelData = new arrayConstructor(dest.rows * dest.columns * samplesPerPixel);
222
+ replicate(src, dest);
223
+
224
+ return {
225
+ imageFrame: dest.pixelData,
226
+ imageInfo: {
227
+ ...imageInfo,
228
+ rows: dest.rows,
229
+ columns: dest.columns,
230
+ width: dest.columns,
231
+ height: dest.rows,
232
+ },
233
+ };
234
+ }
235
+
236
+ /** Generates a lossy version of the image.
237
+ * Options for lossy are:
238
+ * * htj2k - generates a full resolution but high loss htj2k representation of the frames
239
+ * * jls - generates a +/- 2 value, maximum 128 on an edge JLS representation
240
+ *
241
+ * In either case, saves it to <sopUID>/fsiz/frameNo as multipart related
242
+ */
243
+ async function generateLossyImage(id, decoded, options) {
244
+ if (!options.alternate) return;
245
+ if (!decoded?.imageFrame) return;
246
+
247
+ try {
248
+ let { imageFrame, imageInfo } = decoded;
249
+ const lossyId = {
250
+ ...id,
251
+ imageFrameRootPath: id.imageFrameRootPath.replace("frames", options.alternateName),
252
+ transferSyntaxUid: transcodeDestinationMap.jhc.transferSyntaxUid,
253
+ };
254
+
255
+ let lossy = true;
256
+ if (options.alternateThumbnail && imageInfo.rows >= 512) {
257
+ const scaled = scale(imageFrame, imageInfo);
258
+ if (!scaled) {
259
+ console.log("Couldn't scale");
260
+ return;
261
+ }
262
+ imageFrame = Buffer.from(scaled.imageFrame.buffer);
263
+ imageInfo = scaled.imageInfo;
264
+ }
265
+
266
+ if (options.alternate === "jls") {
267
+ lossyId.transferSyntaxUid = transcodeDestinationMap["jls-lossy"].transferSyntaxUid;
268
+ } else if (options.alternate === "jlsLossless") {
269
+ lossyId.transferSyntaxUid = transcodeDestinationMap.jls.transferSyntaxUid;
270
+ lossy = false;
271
+ } else if (options.alternate === "jhcLossless") {
272
+ lossy = false;
273
+ }
274
+
275
+ const encodeOptions = {
276
+ beforeEncode: beforeEncode.bind(null, {
277
+ lossy,
278
+ }),
279
+ };
280
+ const lossyEncoding = await dicomCodec.encode(imageFrame, imageInfo, lossyId.transferSyntaxUid, encodeOptions);
281
+ console.log("Encoded alternate", lossyId.transferSyntaxUid, "of size", lossyEncoding.imageFrame.length);
282
+ // eslint-disable-next-line consistent-return
283
+ return { id: lossyId, imageFrame: lossyEncoding.imageFrame };
284
+ } catch (e) {
285
+ console.warn("Unable to create alternate:", e);
286
+ }
287
+ }
288
+
182
289
  /**
183
290
  * Transcode imageFrame. It uses transcodeMap to define the target encoding based on source encoding.
184
291
  *
@@ -206,8 +313,8 @@ async function transcodeImageFrame(id, targetIdSrc, imageFrame, dataSet, options
206
313
 
207
314
  const transcoder = getTranscoder(id.transferSyntaxUid, options, samplesPerPixel);
208
315
 
209
- // last chance to prevent transcoding
210
- if (targetId.transferSyntaxUid !== transcoder.transferSyntaxUid) {
316
+ // Don't transcode if not required
317
+ if (targetId.transferSyntaxUid !== transcoder.transferSyntaxUid && !options.forceTranscode) {
211
318
  console.verbose("Image is already in", targetId.transferSyntaxUid, "not transcoding");
212
319
  return {
213
320
  id,
@@ -221,20 +328,28 @@ async function transcodeImageFrame(id, targetIdSrc, imageFrame, dataSet, options
221
328
  const imageInfo = getImageInfo(dataSet);
222
329
  let done = false;
223
330
  let processResultMsg = "";
331
+ const encodeOptions = {
332
+ beforeEncode: beforeEncode.bind(null, {
333
+ lossy: options.lossy,
334
+ }),
335
+ };
336
+ let decoded;
224
337
 
225
338
  try {
226
339
  switch (transcoder.transcodeOp) {
227
340
  case transcodeOp.transcode:
228
341
  transcodeLog(options, `Full transcoding image from \x1b[43m${id.transferSyntaxUid}\x1b[0m to \x1b[43m${targetId.transferSyntaxUid}\x1b[0m`);
229
342
 
230
- result = await dicomCodec.transcode(imageFrame, imageInfo, id.transferSyntaxUid, targetId.transferSyntaxUid);
343
+ decoded = await dicomCodec.decode(imageFrame, imageInfo, id.transferSyntaxUid);
344
+ result = await dicomCodec.encode(decoded.imageFrame, decoded.imageInfo, targetId.transferSyntaxUid, encodeOptions);
231
345
 
346
+ console.log("transcoded image to", targetId.transferSyntaxUid, "of size", result.imageFrame.length);
232
347
  processResultMsg = `Transcoding finished`;
233
348
  break;
234
349
  case transcodeOp.encode:
235
350
  transcodeLog(options, `Encoding image to \x1b[43m${targetId.transferSyntaxUid}\x1b[0m`);
236
351
 
237
- result = await dicomCodec.encode(imageFrame, imageInfo, targetId.transferSyntaxUid);
352
+ result = await dicomCodec.encode(imageFrame, imageInfo, targetId.transferSyntaxUid, encodeOptions);
238
353
 
239
354
  processResultMsg = `Encoding finished`;
240
355
  break;
@@ -270,6 +385,7 @@ async function transcodeImageFrame(id, targetIdSrc, imageFrame, dataSet, options
270
385
  id: targetId,
271
386
  imageFrame: _imageFrame,
272
387
  done,
388
+ decoded,
273
389
  };
274
390
  }
275
391
 
@@ -329,3 +445,4 @@ exports.transcodeId = transcodeId;
329
445
  exports.getDestinationTranscoder = getDestinationTranscoder;
330
446
  exports.transcodeImageFrame = transcodeImageFrame;
331
447
  exports.transcodeMetadata = transcodeMetadata;
448
+ exports.generateLossyImage = generateLossyImage;
@@ -151,10 +151,16 @@ const isPrivate = (attr) => {
151
151
  return chHex % 2 == 1;
152
152
  };
153
153
 
154
+ const RawEncapsulatedDocument = "x00420011";
155
+
154
156
  const isValueInline = (attr, options) => {
155
157
  if (isPrivate(attr)) {
156
158
  return attr.length <= options.maximumInlinePrivateLength;
157
159
  }
160
+ // For encapsulated documents, almost always have them be external
161
+ if (attr.tag === RawEncapsulatedDocument && attr.length > 4) {
162
+ return false;
163
+ }
158
164
  return !attr.length || attr.length <= options.maximumInlinePublicLength;
159
165
  };
160
166
 
@@ -198,6 +204,7 @@ const getValue = async (dataSet, attr, vr, getDataSet, callback, options, parent
198
204
  }
199
205
  const binaryValue = dataSet.byteArray.slice(attr.dataOffset, attr.dataOffset + attr.length);
200
206
  const mimeType = attr.tag == "x00420011" && dataSet.string("x00420012");
207
+
201
208
  const BulkDataURI = await callback.bulkdata(binaryValue, { mimeType });
202
209
  return { BulkDataURI, vr };
203
210
  };
@@ -16,19 +16,18 @@ const extensions = {
16
16
  };
17
17
 
18
18
  const baseHasher = hashFactory.hasher();
19
- const MAX_HASH = 1024*1024;
19
+ const MAX_HASH = 1024 * 1024;
20
20
 
21
21
  const hasher = {
22
22
  hash: (v) => {
23
23
  const type = v.constructor?.name;
24
24
  if (type === "Buffer") {
25
- return createHash('sha256').update(v).digest('hex');
25
+ return createHash("sha256").update(v).digest("hex");
26
26
  }
27
27
  return baseHasher.hash(v);
28
28
  },
29
29
  };
30
30
 
31
-
32
31
  /** Writes out JSON files to the given file name. Automatically GZips them, and adds the extension */
33
32
  const HashDataWriter =
34
33
  (options) =>
@@ -18,7 +18,7 @@ const ImageFrameWriter = (options) => {
18
18
  }
19
19
 
20
20
  if (encapsulatedImage || !extension) {
21
- const writeStream = WriteStream(id.imageFrameRootPath, `${1 + index}`, {
21
+ const writeStream = WriteStream(id.imageFrameRootPath, `${1 + index}.mht`, {
22
22
  gzip: type.gzip,
23
23
  mkdir: true,
24
24
  });
@@ -25,8 +25,8 @@ const VideoWriter = () =>
25
25
  async function run(id, dataSet) {
26
26
  console.log(`Writing video ${id.sopInstanceUid}`);
27
27
  const extension = VIDEO_TYPES[dataSet.string(Tags.RawTransferSyntaxUID)];
28
- const filename = `pixeldata.${extension}`;
29
- const writeStream = WriteStream(id.sopInstanceRootPath, filename, {
28
+ const filename = `index.${extension}`;
29
+ const writeStream = WriteStream(`${id.sopInstanceRootPath}/rendered`, filename, {
30
30
  mkdir: true,
31
31
  });
32
32
  let length = 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@radicalimaging/static-wado-creator",
3
- "version": "1.2.8",
3
+ "version": "1.3.0",
4
4
  "description": "Library to convert DICOMP10 to DICOMweb format. Original upstream: https://github.com/chafey/dicomp10-to-dicomweb-js",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -55,10 +55,10 @@
55
55
  "verbose": true
56
56
  },
57
57
  "dependencies": {
58
- "@cornerstonejs/dicom-codec": ">=0.1.6",
59
- "@radicalimaging/static-cs-lite": ">=1.2.1",
60
- "@radicalimaging/static-wado-util": ">=1.2.1",
61
- "canvas": "^2.11.0",
58
+ "@cornerstonejs/dicom-codec": ">=1.0.2",
59
+ "@radicalimaging/static-cs-lite": ">=1.3.0",
60
+ "@radicalimaging/static-wado-util": ">=1.3.0",
61
+ "canvas": "^2.11.1",
62
62
  "config-point": ">=0.5.1",
63
63
  "dcmjs": "^0.29.5",
64
64
  "dicom-parser": "^1.8.13",