@radicalimaging/static-wado-creator 0.6.4
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 +285 -0
- package/bin/mkdicomweb.js +9 -0
- package/lib/DeleteStudy.js +14 -0
- package/lib/RejectInstance.js +17 -0
- package/lib/StaticWado.js +206 -0
- package/lib/createMain.js +14 -0
- package/lib/deleteMain.js +12 -0
- package/lib/dictionary/Tags.js +111 -0
- package/lib/dictionary/dataDictionary.js +22268 -0
- package/lib/groupMain.js +14 -0
- package/lib/index.js +8 -0
- package/lib/instanceMain.js +13 -0
- package/lib/metadataMain.js +14 -0
- package/lib/mkdicomwebConfig.js +149 -0
- package/lib/model/TagLists.js +151 -0
- package/lib/model/uids.js +42 -0
- package/lib/operation/InstanceDeduplicate.js +100 -0
- package/lib/operation/ScanStudy.js +20 -0
- package/lib/operation/StudyData.js +416 -0
- package/lib/operation/ThumbnailService.js +184 -0
- package/lib/operation/adapter/asyncIterableToBuffer.js +112 -0
- package/lib/operation/adapter/decodeImage.js +9 -0
- package/lib/operation/adapter/getImageInfo.js +21 -0
- package/lib/operation/adapter/transcodeImage.js +303 -0
- package/lib/operation/extractImageFrames.js +41 -0
- package/lib/operation/getDataSet.js +60 -0
- package/lib/operation/getEncapsulatedImageFrame.js +23 -0
- package/lib/operation/getNumberOfFrames.js +13 -0
- package/lib/operation/getUncompressedImageFrame.js +7 -0
- package/lib/operation/getVR.js +16 -0
- package/lib/operation/getValue.js +193 -0
- package/lib/program/index.js +15 -0
- package/lib/rejectMain.js +12 -0
- package/lib/util/IdCreator.js +25 -0
- package/lib/util/adaptProgramOpts.js +58 -0
- package/lib/util/compressionOptionParser.js +13 -0
- package/lib/writer/CompleteStudyWriter.js +88 -0
- package/lib/writer/DeduplicateWriter.js +32 -0
- package/lib/writer/ExpandUriPath.js +15 -0
- package/lib/writer/HashDataWriter.js +59 -0
- package/lib/writer/ImageFrameWriter.js +31 -0
- package/lib/writer/JSONWriter.js +18 -0
- package/lib/writer/MultipartAttribute.js +8 -0
- package/lib/writer/MultipartHeader.js +14 -0
- package/lib/writer/ThumbnailWriter.js +21 -0
- package/lib/writer/VideoWriter.js +52 -0
- package/lib/writer/WriteMultipart.js +22 -0
- package/lib/writer/WriteStream.js +51 -0
- package/package.json +75 -0
package/README.md
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
# `@radicalimaging/static-wado-creator`
|
|
2
|
+
|
|
3
|
+
# static-wado-js
|
|
4
|
+
|
|
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:
|
|
149
|
+
```
|
|
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
|
+
|
|
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
|
+
|
|
158
|
+
### To Serve Instances As a Web Server
|
|
159
|
+
```
|
|
160
|
+
cd ~/dicomweb
|
|
161
|
+
npx http-server -p 5000 -g --cors
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
The -g option serves up compressed files ending in .gz as compressed http streams.
|
|
165
|
+
|
|
166
|
+
### To Create DICOM part 10 from DICOMweb files
|
|
167
|
+
TODO
|
|
168
|
+
|
|
169
|
+
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.
|
|
170
|
+
|
|
171
|
+
### To Update DICOM Metadata
|
|
172
|
+
TODO
|
|
173
|
+
|
|
174
|
+
Run the tool
|
|
175
|
+
```
|
|
176
|
+
mkdicomwebupdate -<delete/anonymize/patient/study/series/instance> <studyInstanceUID> (tag=newValue)*
|
|
177
|
+
```
|
|
178
|
+
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.
|
|
179
|
+
|
|
180
|
+
### To recompress
|
|
181
|
+
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).
|
|
182
|
+
|
|
183
|
+
By default the recompression occurs for incoming types: uncompressed, jp2
|
|
184
|
+
```
|
|
185
|
+
mkdicomweb ./folderName
|
|
186
|
+
```
|
|
187
|
+
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.
|
|
188
|
+
|
|
189
|
+
Define incoming types for recompression of: uncompressed,jp2,rle
|
|
190
|
+
```
|
|
191
|
+
mkdicomweb -r uncompressed jp2 rle ./folderName
|
|
192
|
+
```
|
|
193
|
+
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.
|
|
194
|
+
|
|
195
|
+
By default the destination compression type for recompression: jls
|
|
196
|
+
```
|
|
197
|
+
mkdicomweb ./folderName
|
|
198
|
+
```
|
|
199
|
+
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)
|
|
200
|
+
|
|
201
|
+
Define destination type for uncompressed encoding: lei
|
|
202
|
+
```
|
|
203
|
+
mkdicomweb -t lei ./folderName
|
|
204
|
+
```
|
|
205
|
+
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)
|
|
206
|
+
|
|
207
|
+
Force no recompression
|
|
208
|
+
```
|
|
209
|
+
mkdicomweb -r uncompressed jp2 rle --no-recompress ./folderName
|
|
210
|
+
```
|
|
211
|
+
It will NOT recompress any existing data but instead it will keep them in the original encoding.
|
|
212
|
+
|
|
213
|
+
Force no recompression
|
|
214
|
+
```
|
|
215
|
+
mkdicomweb --no-recompress ./folderName
|
|
216
|
+
```
|
|
217
|
+
It will NOT recompress any existing data but instead it will keep them in the original encoding.
|
|
218
|
+
|
|
219
|
+
Obs: --no-recompress forces no compression at all. Use it if you want to disable even the default behavior.
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
### Recompress aliases and input transfer syntaxes
|
|
223
|
+
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)
|
|
224
|
+
|
|
225
|
+
| TransferSyntaxUID | Name | Recompress alias | Target TransferSyntaxUID |
|
|
226
|
+
|------------------------ |--------------------------------------------------------- |------------------ |-------------------------- |
|
|
227
|
+
| 1.2.840.10008.1.2 | Implicit VR Endian | uncompressed | 1.2.840.10008.1.2 |
|
|
228
|
+
| 1.2.840.10008.1.2.1 | Explicit VR Little Endian | uncompressed | 1.2.840.10008.1.2.1 |
|
|
229
|
+
| 1.2.840.10008.1.2.2 | Explicit VR Big Endian | uncompressed | 1.2.840.10008.1.2.1 |
|
|
230
|
+
| 1.2.840.10008.1.2.4.57 | JPEG Lossless, Nonhierarchical (Processes 14) | jpeglossless | 1.2.840.10008.1.2.4.80 |
|
|
231
|
+
| 1.2.840.10008.1.2.4.70 | JPEG Lossless, Nonhierarchical, First- Order Prediction | jpeglossless | 1.2.840.10008.1.2.4.80 |
|
|
232
|
+
| 1.2.840.10008.1.2.4.90 | JPEG 2000 Image Compression (Lossless Only) | jp2 | 1.2.840.10008.1.2.4.80 |
|
|
233
|
+
| 1.2.840.10008.1.2.4.91 | JPEG 2000 Image Compression | jp2 | 1.2.840.10008.1.2.4.80 |
|
|
234
|
+
| 1.2.840.10008.1.2.5 | RLE Lossless | rle | 1.2.840.10008.1.2.4.80 |
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
### To recompress thumb
|
|
238
|
+
It tells thumbnail creation to use recompressed data or not.
|
|
239
|
+
The list of types MUST be necessarily a subset of recompress list.
|
|
240
|
+
|
|
241
|
+
Obs: --no-recompress-thumb forces to not use compression at all. Use it if you want to disable even the default behavior.
|
|
242
|
+
|
|
243
|
+
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.
|
|
244
|
+
|
|
245
|
+
```
|
|
246
|
+
mkdicomweb ./folderName
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
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).
|
|
250
|
+
```
|
|
251
|
+
mkdicomweb -r uncompressed jp2 rle --recompress-thumb jp2 ./folderName
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
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).
|
|
255
|
+
```
|
|
256
|
+
mkdicomweb -r uncompressed jp2 --recompress-thumb rle ./folderName
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
Force no recompression thumb. This will recompress the types: uncompressed jp2 rle, but for thumbnails creation will use original data.
|
|
260
|
+
```
|
|
261
|
+
mkdicomweb -r uncompressed jp2 rle --no-recompress-thumb ./folderName
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
## Development
|
|
268
|
+
|
|
269
|
+
## TODO (Looking for help here!!)
|
|
270
|
+
|
|
271
|
+
* Create docker container to encapsulate build environment
|
|
272
|
+
* Create test data (DICOM P10 and expected DICOMweb results) - Jordan working on this
|
|
273
|
+
* Document API
|
|
274
|
+
* Create CI + Publish to NPM
|
|
275
|
+
* Fix Bugs
|
|
276
|
+
* Get bulkdata refs written properly
|
|
277
|
+
* Enhance cli
|
|
278
|
+
* Add support for specifying bulkDataMinSize
|
|
279
|
+
* Add support for writing out DICOM P10 file
|
|
280
|
+
* Enhance library
|
|
281
|
+
* Write out "info" file
|
|
282
|
+
* P10 Header
|
|
283
|
+
* Data needed to recreate original P10 instance
|
|
284
|
+
* Create DICOMweb -> DICOM P10 tool
|
|
285
|
+
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
const { JSONReader } = require("@radicalimaging/static-wado-util");
|
|
2
|
+
const JSONWriter = require("./writer/JSONWriter");
|
|
3
|
+
const Tags = require("./dictionary/Tags");
|
|
4
|
+
|
|
5
|
+
module.exports = (options) =>
|
|
6
|
+
async function (studyInstanceUid) {
|
|
7
|
+
console.log("Delete Study", studyInstanceUid);
|
|
8
|
+
const studyData = await this.scanStudy("studies", studyInstanceUid);
|
|
9
|
+
studyData.delete();
|
|
10
|
+
const allStudies = await JSONReader(options.directoryName, "studies/index.json.gz", []);
|
|
11
|
+
const studiesWithoutDeleted = allStudies.filter((study) => studyInstanceUid != Tags.getValue(study,Tags.StudyInstanceUID));
|
|
12
|
+
await JSONWriter(options.directoryName, "studies", studiesWithoutDeleted);
|
|
13
|
+
delete this.studyData;
|
|
14
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
const dataExtractor = /^(.*studies)?[\\/]?([^/\\]+)[\\/](series[\\/])?([^/\\]+)([\\/]instances[\\/]([^/\\]*))?$/;
|
|
2
|
+
|
|
3
|
+
module.exports = (options) =>
|
|
4
|
+
async function (rejectPath, reason) {
|
|
5
|
+
console.log("Reject instance", rejectPath, reason);
|
|
6
|
+
const extracted = rejectPath.match(dataExtractor);
|
|
7
|
+
if (!extracted) {
|
|
8
|
+
console.log("Unable to reject", rejectPath, "because it isn't in the format studies/<studyUID>/series/<seriesUID>");
|
|
9
|
+
return;
|
|
10
|
+
}
|
|
11
|
+
const [, , studyInstanceUid, , seriesInstanceUid, , sopInstanceUid] = extracted;
|
|
12
|
+
const studyData = await this.scanStudy("studies", studyInstanceUid);
|
|
13
|
+
if (options.verbose) console.log("Trying to reject", seriesInstanceUid, "because", reason);
|
|
14
|
+
await studyData.reject(seriesInstanceUid, sopInstanceUid, reason);
|
|
15
|
+
await studyData.writeDeduplicatedGroup();
|
|
16
|
+
return studyData.writeMetadata();
|
|
17
|
+
};
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
const dicomCodec = require("@cornerstonejs/dicom-codec");
|
|
2
|
+
const { Stats, handleHomeRelative } = require("@radicalimaging/static-wado-util");
|
|
3
|
+
const dicomParser = require("dicom-parser");
|
|
4
|
+
const fs = require("fs");
|
|
5
|
+
const path = require("path");
|
|
6
|
+
const { dirScanner } = require("@radicalimaging/static-wado-util");
|
|
7
|
+
const asyncIterableToBuffer = require("./operation/adapter/asyncIterableToBuffer");
|
|
8
|
+
const getDataSet = require("./operation/getDataSet");
|
|
9
|
+
const InstanceDeduplicate = require("./operation/InstanceDeduplicate");
|
|
10
|
+
const DeduplicateWriter = require("./writer/DeduplicateWriter");
|
|
11
|
+
const ImageFrameWriter = require("./writer/ImageFrameWriter");
|
|
12
|
+
const CompleteStudyWriter = require("./writer/CompleteStudyWriter");
|
|
13
|
+
const IdCreator = require("./util/IdCreator");
|
|
14
|
+
const ScanStudy = require("./operation/ScanStudy");
|
|
15
|
+
const HashDataWriter = require("./writer/HashDataWriter");
|
|
16
|
+
const VideoWriter = require("./writer/VideoWriter");
|
|
17
|
+
const { transcodeImageFrame, transcodeId, transcodeMetadata } = require("./operation/adapter/transcodeImage");
|
|
18
|
+
const ThumbnailWriter = require("./writer/ThumbnailWriter");
|
|
19
|
+
const ThumbnailService = require("./operation/ThumbnailService");
|
|
20
|
+
const DeleteStudy = require("./DeleteStudy");
|
|
21
|
+
const RejectInstance = require("./RejectInstance");
|
|
22
|
+
|
|
23
|
+
function setStudyData(studyData) {
|
|
24
|
+
this.studyData = studyData;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
class StaticWado {
|
|
28
|
+
constructor(configuration) {
|
|
29
|
+
const { rootDir = "~/dicomweb", pathDeduplicated = "deduplicated", pathInstances = "instances", verbose } = configuration;
|
|
30
|
+
|
|
31
|
+
dicomCodec.setConfig({ verbose });
|
|
32
|
+
const directoryName = handleHomeRelative(rootDir);
|
|
33
|
+
|
|
34
|
+
this.options = {
|
|
35
|
+
...configuration,
|
|
36
|
+
directoryName,
|
|
37
|
+
deduplicatedRoot: path.join(directoryName, pathDeduplicated),
|
|
38
|
+
deduplicatedInstancesRoot: path.join(directoryName, pathInstances),
|
|
39
|
+
TransferSyntaxUID: "1.2.840.10008.1.2",
|
|
40
|
+
};
|
|
41
|
+
this.callback = {
|
|
42
|
+
uids: IdCreator(this.options),
|
|
43
|
+
bulkdata: HashDataWriter(this.options),
|
|
44
|
+
imageFrame: ImageFrameWriter(this.options),
|
|
45
|
+
videoWriter: VideoWriter(this.options),
|
|
46
|
+
thumbWriter: ThumbnailWriter(this.options),
|
|
47
|
+
completeStudy: CompleteStudyWriter(this.options),
|
|
48
|
+
metadata: InstanceDeduplicate(this.options),
|
|
49
|
+
deduplicated: DeduplicateWriter(this.options),
|
|
50
|
+
scanStudy: ScanStudy(this.options),
|
|
51
|
+
reject: RejectInstance(this.options),
|
|
52
|
+
delete: DeleteStudy(this.options),
|
|
53
|
+
setStudyData,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Rejects the given item, specified as studies/<studyUID>/series/<seriesUID>
|
|
59
|
+
* Does NOT remove any files, but adds a flag to the study indicating the deleted series and instances.
|
|
60
|
+
*
|
|
61
|
+
* @param {string} args listing the instances to remove (may remove entire series)
|
|
62
|
+
*/
|
|
63
|
+
async reject(args) {
|
|
64
|
+
args.forEach((removal) => {
|
|
65
|
+
this.callback.reject(removal);
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Deletes a study, as specified by the study instance UID by removing instance, deduplicated and studies files,
|
|
71
|
+
* as well as removing the object from the studies list.
|
|
72
|
+
*
|
|
73
|
+
* @param {string[]} args listing the studies to remove
|
|
74
|
+
*/
|
|
75
|
+
async delete(args) {
|
|
76
|
+
args.forEach((studyUID) => this.callback.delete(studyUID));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Processes a set of DICOM files, where the starting point is a list of directory names or file instances.
|
|
81
|
+
* This is used for importing DICOM files.
|
|
82
|
+
*
|
|
83
|
+
* @param {*} files
|
|
84
|
+
* @param {*} callback
|
|
85
|
+
* @param {*} params
|
|
86
|
+
*/
|
|
87
|
+
async processFiles(files, params) {
|
|
88
|
+
return dirScanner(files, {
|
|
89
|
+
...params,
|
|
90
|
+
callback: async (file) => {
|
|
91
|
+
try {
|
|
92
|
+
const dicomp10stream = fs.createReadStream(file);
|
|
93
|
+
await this.importBinaryDicom(dicomp10stream, { ...params, file });
|
|
94
|
+
Stats.StudyStats.add("DICOM P10", "Parse DICOM P10 file");
|
|
95
|
+
} catch (e) {
|
|
96
|
+
console.error("Couldn't process", file, e);
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Processes a study directory, matching up study instance UIDs. Either processes the
|
|
104
|
+
* deduplicated group directory or the instances directory, or the notifications directory.
|
|
105
|
+
* @param {*} params
|
|
106
|
+
*/
|
|
107
|
+
async processStudyDir(studyUids, params) {
|
|
108
|
+
return dirScanner(params[params.scanStudies], {
|
|
109
|
+
...params,
|
|
110
|
+
recursive: false,
|
|
111
|
+
callback: (dir, name) => this.callback.scanStudy(dir, name),
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async importBinaryDicom(dicomp10stream, params) {
|
|
116
|
+
// Read dicomp10 stream into buffer
|
|
117
|
+
const buffer = await asyncIterableToBuffer(dicomp10stream);
|
|
118
|
+
|
|
119
|
+
// Parse it
|
|
120
|
+
const dataSet = dicomParser.parseDicom(buffer, params);
|
|
121
|
+
|
|
122
|
+
const studyInstanceUid = dataSet.string("x0020000d");
|
|
123
|
+
|
|
124
|
+
if (!studyInstanceUid) {
|
|
125
|
+
console.log(`Can't import file ${params.file}`);
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Extract uids
|
|
130
|
+
const id = this.callback.uids(
|
|
131
|
+
{
|
|
132
|
+
studyInstanceUid,
|
|
133
|
+
seriesInstanceUid: dataSet.string("x0020000e"),
|
|
134
|
+
sopInstanceUid: dataSet.string("x00080018"),
|
|
135
|
+
transferSyntaxUid: dataSet.string("x00020010"),
|
|
136
|
+
},
|
|
137
|
+
params.file
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
const targetId = transcodeId(id, this.options);
|
|
141
|
+
|
|
142
|
+
let bulkDataIndex = 0;
|
|
143
|
+
let imageFrameIndex = 0;
|
|
144
|
+
const thumbnailService = new ThumbnailService();
|
|
145
|
+
|
|
146
|
+
const generator = {
|
|
147
|
+
bulkdata: async (bulkData, options) => {
|
|
148
|
+
const _bulkDataIndex = bulkDataIndex;
|
|
149
|
+
bulkDataIndex += 1;
|
|
150
|
+
return this.callback.bulkdata(targetId, _bulkDataIndex, bulkData, options);
|
|
151
|
+
},
|
|
152
|
+
imageFrame: async (originalImageFrame) => {
|
|
153
|
+
const { imageFrame: transcodedImageFrame, id: transcodedId } = await transcodeImageFrame(id, targetId, originalImageFrame, dataSet, this.options);
|
|
154
|
+
|
|
155
|
+
const currentImageFrameIndex = imageFrameIndex;
|
|
156
|
+
imageFrameIndex += 1;
|
|
157
|
+
|
|
158
|
+
thumbnailService.queueThumbnail(
|
|
159
|
+
{
|
|
160
|
+
imageFrame: originalImageFrame,
|
|
161
|
+
transcodedImageFrame,
|
|
162
|
+
transcodedId,
|
|
163
|
+
id,
|
|
164
|
+
frameIndex: currentImageFrameIndex,
|
|
165
|
+
},
|
|
166
|
+
this.options
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
return this.callback.imageFrame(transcodedId, currentImageFrameIndex, transcodedImageFrame);
|
|
170
|
+
},
|
|
171
|
+
videoWriter: async (_dataSet) => this.callback.videoWriter(id, _dataSet),
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
// convert to DICOMweb MetaData and BulkData
|
|
175
|
+
const result = await getDataSet(dataSet, generator, this.options);
|
|
176
|
+
|
|
177
|
+
const transcodedMeta = transcodeMetadata(result.metadata, id, this.options);
|
|
178
|
+
thumbnailService.generateThumbnails(id, dataSet, transcodedMeta, this.callback);
|
|
179
|
+
|
|
180
|
+
await this.callback.metadata(targetId, transcodedMeta);
|
|
181
|
+
|
|
182
|
+
// resolve promise with statistics
|
|
183
|
+
return {};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* The mkdicomweb command first runs mkdicomwebinstances, writing out the deduplicated data, and then runs the
|
|
188
|
+
* mkdicomwebstudy command, creating the deduplicated data set. This version, however, keeps the deduplicated
|
|
189
|
+
* data in memory by default on a study level, which avoids needing to run the load process.
|
|
190
|
+
*/
|
|
191
|
+
async executeCommand(input) {
|
|
192
|
+
if (this.options.scanStudies) {
|
|
193
|
+
// Scan one of the study directories - in this case, files is a set of study directories
|
|
194
|
+
await this.processStudyDir(input, this.options);
|
|
195
|
+
} else {
|
|
196
|
+
await this.processFiles(input, this.options);
|
|
197
|
+
}
|
|
198
|
+
await this.close();
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async close() {
|
|
202
|
+
await this.callback.completeStudy();
|
|
203
|
+
Stats.OverallStats.summarize("Completed Study Processing");
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
module.exports = StaticWado;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
const StaticWado = require("./StaticWado");
|
|
2
|
+
const adaptProgramOpts = require("./util/adaptProgramOpts");
|
|
3
|
+
|
|
4
|
+
module.exports = function createMain(options, program) {
|
|
5
|
+
const finalOptions = adaptProgramOpts(options, {
|
|
6
|
+
...this,
|
|
7
|
+
isInstance: false,
|
|
8
|
+
isGroup: true,
|
|
9
|
+
isDeduplicate: true,
|
|
10
|
+
isStudyData: true,
|
|
11
|
+
});
|
|
12
|
+
const importer = new StaticWado(finalOptions);
|
|
13
|
+
return importer.executeCommand(program.args);
|
|
14
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
const StaticWado = require("./StaticWado");
|
|
2
|
+
const adaptProgramOpts = require("./util/adaptProgramOpts");
|
|
3
|
+
|
|
4
|
+
module.exports = function deleteMain(options, program) {
|
|
5
|
+
const finalOptions = adaptProgramOpts(options, {
|
|
6
|
+
...this,
|
|
7
|
+
isGroup: true,
|
|
8
|
+
isStudyData: true,
|
|
9
|
+
});
|
|
10
|
+
const importer = new StaticWado(finalOptions);
|
|
11
|
+
return importer.delete(program.args);
|
|
12
|
+
};
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
const dataDictionary = require("./dataDictionary");
|
|
2
|
+
const dcmjs = require("dcmjs");
|
|
3
|
+
|
|
4
|
+
const { naturalizeDataset, denaturalizeDataset} = dcmjs.data.DicomMetaDictionary
|
|
5
|
+
|
|
6
|
+
/** Find the actual tag for a private value */
|
|
7
|
+
const findPrivate = (item, tagObject, create) => {
|
|
8
|
+
if( typeof tagObject === "string" ) return tagObject;
|
|
9
|
+
if( typeof tagObject === "number" ) return `00000000${tagObject.toString(16)}`.slice(-8);
|
|
10
|
+
const {creator,tag} = tagObject;
|
|
11
|
+
if( !creator ) return tag;
|
|
12
|
+
const start = tag.substring(0,4);
|
|
13
|
+
const end = tag.substring(6,8);
|
|
14
|
+
// Should technically go all the way up, but for now only test 0x30 items
|
|
15
|
+
let assignPosition;
|
|
16
|
+
for(let offset = 0x10; offset < 0x40; offset++) {
|
|
17
|
+
const testTag = `${start}00${offset.toString(16)}`
|
|
18
|
+
const testCreator = item[testTag];
|
|
19
|
+
if( testCreator===undefined && assignPosition===undefined ) assignPosition = offset;
|
|
20
|
+
if( testCreator && testCreator.Value && testCreator.Value[0]===creator ) {
|
|
21
|
+
return `${start}${offset.toString(16)}${end}`
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
if( create ) {
|
|
25
|
+
if( !assignPosition ) throw new Error(`Couldn't find any assign positions for ${creator} ${tag} in ${item}`);
|
|
26
|
+
const creatorTag = `${start}00${assignPosition.toString(16)}`
|
|
27
|
+
item[creatorTag] = { Value: [creator], vr: 'CS' };
|
|
28
|
+
return `${start}${assignPosition.toString(16)}${end}`
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const DeduppedCreator = "dedupped";
|
|
33
|
+
|
|
34
|
+
const Tags = {
|
|
35
|
+
// Raw tags have the x before them, not parsed yet
|
|
36
|
+
RawMinTag: "x00000000",
|
|
37
|
+
RawFirstBodyTag: "x00080000",
|
|
38
|
+
RawTransferSyntaxUID: "x00020010",
|
|
39
|
+
|
|
40
|
+
// This one isn't defined in the dataDictionary
|
|
41
|
+
AvailableTransferSyntaxUID: "00083002",
|
|
42
|
+
|
|
43
|
+
DeduppedCreator,
|
|
44
|
+
|
|
45
|
+
// The references to extract data included in this object, 1..n values
|
|
46
|
+
DeduppedRef: { creator: DeduppedCreator, tag: "00091010"},
|
|
47
|
+
|
|
48
|
+
// The hash value of THIS object
|
|
49
|
+
DeduppedHash: {creator: DeduppedCreator, tag: "00091011"},
|
|
50
|
+
|
|
51
|
+
// Type of hash instance
|
|
52
|
+
DeduppedType: {creator: DeduppedCreator, tag: "00091012"},
|
|
53
|
+
InstanceType: "instance",
|
|
54
|
+
InfoType: "info",
|
|
55
|
+
|
|
56
|
+
naturalizeDataset, denaturalizeDataset,
|
|
57
|
+
|
|
58
|
+
setValue: (item, tag, value) => {
|
|
59
|
+
const actualTag = findPrivate(item,tag,true);
|
|
60
|
+
item[actualTag] = { Value: Array.isArray(value) ? value : [value] };
|
|
61
|
+
return actualTag;
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
setList: (item, tag, value) => {
|
|
65
|
+
const actualTag = findPrivate(item,tag,true);
|
|
66
|
+
item[actualTag] = {Value: Array.isArray(value) ? value : [value]};
|
|
67
|
+
return actualTag;
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
pushList: (item, tag, value) => {
|
|
71
|
+
const actualTag = findPrivate(item,tag,true);
|
|
72
|
+
if( !item[actualTag] ) item[actualTag] = { Value: [] };
|
|
73
|
+
item[actualTag].Value.push(value);
|
|
74
|
+
return actualTag;
|
|
75
|
+
},
|
|
76
|
+
|
|
77
|
+
getValue: (item, tag) => {
|
|
78
|
+
const actualTag = findPrivate(item,tag);
|
|
79
|
+
if( !actualTag ) return undefined;
|
|
80
|
+
const value = item[actualTag];
|
|
81
|
+
if( value===undefined ) return undefined;
|
|
82
|
+
if( Array.isArray(value) ) return value.length==1 ? value[0] : (value.length===0 ? "" : value);
|
|
83
|
+
if( value.Value ) return value.Value.length==1 ? value.Value[0] : (value.Value.length===0 ? "" : value.Value);
|
|
84
|
+
return value;
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
getList: (item, tag) => {
|
|
88
|
+
const actualTag = findPrivate(item,tag);
|
|
89
|
+
if( !actualTag ) return undefined;
|
|
90
|
+
const value = item[actualTag];
|
|
91
|
+
if( value===undefined ) return undefined;
|
|
92
|
+
if( Array.isArray(value) ) return value;
|
|
93
|
+
if( value.Value ) return value.Value;
|
|
94
|
+
return [value];
|
|
95
|
+
},
|
|
96
|
+
|
|
97
|
+
removeValue: (item,tag) => {
|
|
98
|
+
const actualTag = findPrivate(item,tag);
|
|
99
|
+
if( actualTag ) delete item[actualTag];
|
|
100
|
+
// TODO, remove creator tag if not used any longer
|
|
101
|
+
},
|
|
102
|
+
|
|
103
|
+
findPrivate,
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
Object.keys(dataDictionary).forEach((key) => {
|
|
107
|
+
const value = dataDictionary[key];
|
|
108
|
+
Tags[value.name] = key;
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
module.exports = Tags;
|