@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
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
const path = require("path");
|
|
2
|
+
const fs = require("fs");
|
|
3
|
+
const hashFactory = require("node-object-hash");
|
|
4
|
+
const { JSONReader } = require("@radicalimaging/static-wado-util");
|
|
5
|
+
const Tags = require("../dictionary/Tags");
|
|
6
|
+
const TagLists = require("../model/TagLists");
|
|
7
|
+
const JSONWriter = require("../writer/JSONWriter");
|
|
8
|
+
|
|
9
|
+
const {getValue, setValue, getList, setList} = Tags;
|
|
10
|
+
const hasher = hashFactory();
|
|
11
|
+
|
|
12
|
+
const getSeriesInstanceUid = (seriesInstance) =>
|
|
13
|
+
getValue(seriesInstance,Tags.SeriesInstanceUID);
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* StudyData contains information about the grouped study data. It is used to create
|
|
17
|
+
* study level information such as study or series level metadata, and to group things
|
|
18
|
+
* together during processing, such as handling the study level deduplicated group
|
|
19
|
+
* files.
|
|
20
|
+
*
|
|
21
|
+
* It also has methods on performing study level operations such as patient name updates
|
|
22
|
+
* or instance remove operations, and has methods to handle avoiding needing to store instance
|
|
23
|
+
* level data multiple times when it already exists.
|
|
24
|
+
*/
|
|
25
|
+
class StudyData {
|
|
26
|
+
constructor({ studyInstanceUid, studyPath, deduplicatedPath, deduplicatedInstancesPath }, { isGroup, clean }) {
|
|
27
|
+
this.studyInstanceUid = studyInstanceUid;
|
|
28
|
+
this.studyPath = studyPath;
|
|
29
|
+
this.isGroup = isGroup;
|
|
30
|
+
this.deduplicatedPath = deduplicatedPath;
|
|
31
|
+
this.deduplicatedInstancesPath = deduplicatedInstancesPath;
|
|
32
|
+
this.clean = clean;
|
|
33
|
+
this.clear();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
clear() {
|
|
37
|
+
this.deduplicated = [];
|
|
38
|
+
this.extractData = {};
|
|
39
|
+
// The list of already existing files read in to create this object
|
|
40
|
+
this.existingFiles = [];
|
|
41
|
+
// The read hashes is the hashes of the files that are read, both as groups and internally
|
|
42
|
+
this.readHashes = {};
|
|
43
|
+
// The deduplicated hashes are just the hashes for individual items
|
|
44
|
+
this.deduplicatedHashes = {};
|
|
45
|
+
this.sopInstances = {};
|
|
46
|
+
|
|
47
|
+
// Used to track if new instances have been added.
|
|
48
|
+
this.newInstancesAdded = 0;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Clean the directory, and/or read existing data in the isInstances or isGroup files.
|
|
53
|
+
* TODO - implement this.
|
|
54
|
+
*/
|
|
55
|
+
async init({ clean }) {
|
|
56
|
+
if (clean) {
|
|
57
|
+
// Wipe out the study directory entirely, as well as the deduplicatedRoot and instancesRoot
|
|
58
|
+
}
|
|
59
|
+
if (this.deduplicatedPath) {
|
|
60
|
+
await this.readDeduplicated(this.deduplicatedPath);
|
|
61
|
+
}
|
|
62
|
+
if (this.deduplicatedInstancesPath) {
|
|
63
|
+
await this.readDeduplicated(this.deduplicatedInstancesPath);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
get numberOfInstances() {
|
|
68
|
+
return this.deduplicated.length;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Indicate if this study is 'dirty', that is, has been updated.
|
|
73
|
+
* That is NOT to say that the metadata files generated from this study data are up to date, that is
|
|
74
|
+
* a separate type of check.
|
|
75
|
+
*/
|
|
76
|
+
get dirty() {
|
|
77
|
+
return this.newInstancesAdded > 0 || this.existingFiles.length > 1;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async dirtyMetadata() {
|
|
81
|
+
if (this.dirty) {
|
|
82
|
+
// console.log("Study data is dirty - need to write updated file");
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
try {
|
|
86
|
+
const deduplicatedTopFile = await JSONReader(this.studyPath, "deduplicated.gz", null);
|
|
87
|
+
if (!deduplicatedTopFile) {
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
const info = deduplicatedTopFile[0];
|
|
91
|
+
if (!info || getValue(info,Tags.DeduppedHash) || getValue(info,Tags.DeduppedType) != "info") {
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
const hashValue = getValue(info,Tags.DeduppedHash);
|
|
95
|
+
return this.existingFiles[0].indexOf(hashValue) == -1;
|
|
96
|
+
} catch (e) {
|
|
97
|
+
console.log("Assume study metadata is dirty", e);
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async reject(seriesInstanceUid, sopInstanceUid, reason) {
|
|
103
|
+
console.log("Rejecting series instance UID", seriesInstanceUid, "because", reason);
|
|
104
|
+
// TODO - actually add a reject note...
|
|
105
|
+
this.newInstancesAdded += 1;
|
|
106
|
+
for (let i = 0; i < this.deduplicated.length; i++) {
|
|
107
|
+
const recombined = await this.recombine(i);
|
|
108
|
+
const reSeriesUid = getValue(recombined,Tags.SeriesInstanceUID);
|
|
109
|
+
if (reSeriesUid !== seriesInstanceUid) continue;
|
|
110
|
+
const reSop = getValue(recombined,Tags.SOPInstanceUID);
|
|
111
|
+
if (!reSop) continue;
|
|
112
|
+
if (reSop !== sopInstanceUid) continue;
|
|
113
|
+
setValue(this.deduplicated[i], Tags.DeduppedType,"deleted");
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async delete() {
|
|
118
|
+
await fs.rmSync(this.studyPath, { recursive: true, force: true });
|
|
119
|
+
await fs.rmSync(this.deduplicatedInstancesPath, { recursive: true, force: true });
|
|
120
|
+
await fs.rmSync(this.deduplicatedPath, { recursive: true, force: true });
|
|
121
|
+
this.clear();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async getOrLoadExtract(hashKey) {
|
|
125
|
+
let item = this.extractData[hashKey];
|
|
126
|
+
if (!item) {
|
|
127
|
+
item = await JSONReader.readHashData(this.studyPath, hashKey);
|
|
128
|
+
if (!item) {
|
|
129
|
+
console.error("Unable to read hashdata", hashKey);
|
|
130
|
+
return item;
|
|
131
|
+
}
|
|
132
|
+
this.extractData[hashKey] = item;
|
|
133
|
+
}
|
|
134
|
+
return item;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
assignUndefined(ret, item) {
|
|
138
|
+
if (!item) return;
|
|
139
|
+
Object.keys(item).forEach((key) => {
|
|
140
|
+
if (ret[key]) return;
|
|
141
|
+
ret[key] = item[key];
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Create a full study instance data for instance at index
|
|
147
|
+
*/
|
|
148
|
+
async recombine(index) {
|
|
149
|
+
const deduplicated = this.deduplicated[index];
|
|
150
|
+
if (index < 0 || index >= this.deduplicated.length) {
|
|
151
|
+
throw new Error(`Can't read index ${index}, out of bounds [0..${this.deduplicated.length})`);
|
|
152
|
+
}
|
|
153
|
+
const refs = getList(deduplicated,Tags.DeduppedRef);
|
|
154
|
+
if (!refs) {
|
|
155
|
+
console.log("No refs for", deduplicated);
|
|
156
|
+
return deduplicated;
|
|
157
|
+
}
|
|
158
|
+
const ret = { ...deduplicated };
|
|
159
|
+
for (const hashKey of refs) {
|
|
160
|
+
const item = await this.getOrLoadExtract(hashKey);
|
|
161
|
+
this.assignUndefined(ret, item);
|
|
162
|
+
}
|
|
163
|
+
return ret;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async addExtracted(callback, hashKey, item) {
|
|
167
|
+
if (this.extractData[hashKey]) {
|
|
168
|
+
if( this.verbose ) console.log("Already have extracted", hashKey, getValue(item,Tags.DeduppedType));
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
await callback.bulkdata(this, hashKey, item);
|
|
172
|
+
this.extractData[hashKey] = item;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
sopExists(sopUID) {
|
|
176
|
+
const sopValue = (sopUID && sopUID.Value && sopUID.Value[0]) || sopUID;
|
|
177
|
+
return sopValue && this.sopInstances[sopValue] !== undefined;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
addDeduplicated(data, filename) {
|
|
181
|
+
if (!this.isGroup) return;
|
|
182
|
+
// TODO - check the hash code on the added data, if it has already been seen then ignore this item.
|
|
183
|
+
if (this.internalAddDeduplicated(data, filename)) {
|
|
184
|
+
this.newInstancesAdded += 1;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Add the instance if not already present */
|
|
189
|
+
internalAddDeduplicated(data, filename = "internal") {
|
|
190
|
+
const hashValue = TagLists.addHash(data, Tags.InstanceType);
|
|
191
|
+
if (!hashValue || this.deduplicatedHashes[hashValue]) {
|
|
192
|
+
// console.log('Not adding', hashValue, 'because the hash exists');
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
const sopValue = getValue(data,Tags.SOPInstanceUID);
|
|
196
|
+
const sopIndex = this.sopInstances[sopValue];
|
|
197
|
+
if (!sopValue) {
|
|
198
|
+
console.warn("No sop value in", filename, "reading", data);
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
this.deduplicatedHashes[hashValue] = data;
|
|
202
|
+
this.readHashes[hashValue] = filename;
|
|
203
|
+
if (sopIndex !== undefined) {
|
|
204
|
+
console.log("Replacing SOP", sopValue, "at index", sopIndex);
|
|
205
|
+
this.deduplicated[sopIndex] = data;
|
|
206
|
+
} else {
|
|
207
|
+
this.sopInstances[sopValue] = this.deduplicated.length;
|
|
208
|
+
this.deduplicated.push(data);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return !!hashValue;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async listJsonFiles(dir) {
|
|
215
|
+
const names = await fs.promises.readdir(dir);
|
|
216
|
+
if (!names || names.length == 0) return [];
|
|
217
|
+
const gzNames = names.filter((name) => name.indexOf(".gz") > 0);
|
|
218
|
+
const statInfo = gzNames
|
|
219
|
+
.map((name) => {
|
|
220
|
+
const stat = fs.statSync(path.join(dir, name));
|
|
221
|
+
return {
|
|
222
|
+
...stat,
|
|
223
|
+
name,
|
|
224
|
+
hash: this.removeGz(name),
|
|
225
|
+
};
|
|
226
|
+
})
|
|
227
|
+
.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
228
|
+
return statInfo;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Reads the deduplicated directory, finding the newest file in the directory of type JSON,
|
|
233
|
+
* and then reads it in. It then reads in all un-referenced files, and adds references to those to the
|
|
234
|
+
* current path, NOT changing the hash value of the top level element.
|
|
235
|
+
*
|
|
236
|
+
* Records the files read in referencedFiles list.
|
|
237
|
+
* Sets the dirty deduplicated flag if at least two instances were read.
|
|
238
|
+
*
|
|
239
|
+
* Does NOT call the readInstances, which is a separate step which can be executed after this one.
|
|
240
|
+
*
|
|
241
|
+
* @param {*} deduplicatedDirectory
|
|
242
|
+
*/
|
|
243
|
+
async readDeduplicated(deduplicatedDirectory) {
|
|
244
|
+
try {
|
|
245
|
+
const files = await this.listJsonFiles(deduplicatedDirectory);
|
|
246
|
+
if (!files || !files.length) {
|
|
247
|
+
console.log("No deduplicated for", deduplicatedDirectory);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
console.log("There are", files.length, "files to check");
|
|
251
|
+
for (let i = 0; i < files.length; i++) {
|
|
252
|
+
const stat = files[i];
|
|
253
|
+
const { hash } = stat;
|
|
254
|
+
if (this.readHashes[hash]) {
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
await this.readDeduplicatedFile(deduplicatedDirectory, stat);
|
|
258
|
+
}
|
|
259
|
+
console.log("Done checking", deduplicatedDirectory);
|
|
260
|
+
} catch (e) {
|
|
261
|
+
// No-op console.log(e);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async readDeduplicatedFile(dir, stat) {
|
|
266
|
+
const { hash, name } = stat;
|
|
267
|
+
try {
|
|
268
|
+
if (this.verbose) console.log("Reading deduplicated file", name);
|
|
269
|
+
const data = await JSONReader(dir, name);
|
|
270
|
+
this.readHashes[hash] = name;
|
|
271
|
+
this.existingFiles.push(name);
|
|
272
|
+
const listData = (Array.isArray(data) && data) || [data];
|
|
273
|
+
listData.forEach((item) => {
|
|
274
|
+
const type = getValue(item, Tags.DeduppedType);
|
|
275
|
+
if (type == Tags.InstanceType) {
|
|
276
|
+
this.internalAddDeduplicated(item, name);
|
|
277
|
+
} else if (type == "info") {
|
|
278
|
+
const refs = getList(item,Tags.DeduppedRef);
|
|
279
|
+
if (refs) {
|
|
280
|
+
refs.forEach((hashValue) => {
|
|
281
|
+
this.readHashes[hashValue] = `${hashValue}.gz`;
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
} else {
|
|
285
|
+
const hashValue = getValue(item,Tags.DeduppedHash);
|
|
286
|
+
if (hashValue) {
|
|
287
|
+
this.extractData[hashValue] = item;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
} catch (e) {
|
|
292
|
+
console.error("Unable to read", dir, name);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async writeMetadata() {
|
|
297
|
+
const anInstance = await this.recombine(0);
|
|
298
|
+
const series = {};
|
|
299
|
+
|
|
300
|
+
for (let i = 0; i < this.numberOfInstances; i++) {
|
|
301
|
+
const seriesInstance = await this.recombine(i);
|
|
302
|
+
const type = getValue(seriesInstance,Tags.DeduppedType);
|
|
303
|
+
if (type == "deleted") {
|
|
304
|
+
console.log("Skipping", type);
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
const seriesInstanceUid = getSeriesInstanceUid(seriesInstance);
|
|
308
|
+
if (!seriesInstanceUid) {
|
|
309
|
+
console.log("Cant get seriesUid from", Tags.SeriesInstanceUID, seriesInstance);
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
if (!series[seriesInstanceUid]) {
|
|
313
|
+
const seriesQuery = TagLists.extract(seriesInstance, "series", TagLists.SeriesQuery);
|
|
314
|
+
const seriesPath = path.join(this.studyPath, "series", seriesInstanceUid);
|
|
315
|
+
series[seriesInstanceUid] = {
|
|
316
|
+
seriesPath,
|
|
317
|
+
seriesQuery,
|
|
318
|
+
instances: [],
|
|
319
|
+
instancesQuery: [],
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
series[seriesInstanceUid].instances.push(seriesInstance);
|
|
323
|
+
series[seriesInstanceUid].instancesQuery.push(TagLists.extract(seriesInstance, "instance", TagLists.InstanceQuery));
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const seriesList = [];
|
|
327
|
+
const modalitiesInStudy = [];
|
|
328
|
+
let numberOfInstances = 0;
|
|
329
|
+
let numberOfSeries = 0;
|
|
330
|
+
for (const seriesUid of Object.keys(series)) {
|
|
331
|
+
const singleSeries = series[seriesUid];
|
|
332
|
+
const { seriesQuery, seriesPath, instances, instancesQuery } = singleSeries;
|
|
333
|
+
seriesQuery[Tags.NumberOfSeriesRelatedInstances] = {
|
|
334
|
+
vr: "IS",
|
|
335
|
+
Value: [instances.length],
|
|
336
|
+
};
|
|
337
|
+
numberOfInstances += instances.length;
|
|
338
|
+
numberOfSeries += 1;
|
|
339
|
+
seriesList.push(seriesQuery);
|
|
340
|
+
const modality = getValue(seriesQuery,Tags.Modality);
|
|
341
|
+
if (modalitiesInStudy.indexOf(modality) == -1) modalitiesInStudy.push(modality);
|
|
342
|
+
await JSONWriter(seriesPath, "metadata", instances, {
|
|
343
|
+
gzip: true,
|
|
344
|
+
index: false,
|
|
345
|
+
});
|
|
346
|
+
await JSONWriter(seriesPath, "series", [seriesQuery]);
|
|
347
|
+
await JSONWriter(seriesPath, "instances", instancesQuery);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
await JSONWriter(this.studyPath, "series", seriesList);
|
|
351
|
+
console.log("Wrote series with", seriesList.length);
|
|
352
|
+
|
|
353
|
+
const studyQuery = TagLists.extract(anInstance, "study", TagLists.PatientStudyQuery);
|
|
354
|
+
studyQuery[Tags.ModalitiesInStudy] = { Value: modalitiesInStudy, vr: "CS" };
|
|
355
|
+
studyQuery[Tags.NumberOfStudyRelatedInstances] = {
|
|
356
|
+
Value: [numberOfInstances],
|
|
357
|
+
vr: "IS",
|
|
358
|
+
};
|
|
359
|
+
studyQuery[Tags.NumberOfStudyRelatedSeries] = {
|
|
360
|
+
Value: [numberOfSeries],
|
|
361
|
+
vr: "IS",
|
|
362
|
+
};
|
|
363
|
+
// Write the index.json directly in the studyPath directory.
|
|
364
|
+
await JSONWriter(this.studyPath, "index.json", [studyQuery], {
|
|
365
|
+
gzip: true,
|
|
366
|
+
index: false,
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
const infoItem = this.createInfo();
|
|
370
|
+
console.log("Writing deduplicated study data with", Object.values(this.extractData).length, "extract items and", this.deduplicated.length, "instance items");
|
|
371
|
+
await JSONWriter(this.studyPath, "deduplicated", [infoItem, ...Object.values(this.extractData), ...this.deduplicated]);
|
|
372
|
+
|
|
373
|
+
return studyQuery;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Creates an information item containing the hash value of this item type.
|
|
378
|
+
*/
|
|
379
|
+
createInfo() {
|
|
380
|
+
const data = {};
|
|
381
|
+
const hashValue = hasher.hash(this.deduplicated);
|
|
382
|
+
setValue(data,Tags.DeduppedHash,hashValue);
|
|
383
|
+
setValue(data,Tags.DeduppedType,"info");
|
|
384
|
+
return data;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/* eslint-disable-next-line class-methods-use-this */
|
|
388
|
+
removeGz(name) {
|
|
389
|
+
const gzIndex = name.indexOf(".gz");
|
|
390
|
+
return (gzIndex > 0 && name.substring(0, gzIndex)) || name;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** Writes the deduplicated group */
|
|
394
|
+
async writeDeduplicatedGroup() {
|
|
395
|
+
const data = this.createInfo();
|
|
396
|
+
const hashValue = getValue(data,Tags.DeduppedHash);
|
|
397
|
+
console.log(
|
|
398
|
+
"Writing deduplicated hash data set data with",
|
|
399
|
+
Object.values(this.extractData).length,
|
|
400
|
+
"extract items and",
|
|
401
|
+
this.deduplicated.length,
|
|
402
|
+
"instance items"
|
|
403
|
+
);
|
|
404
|
+
setList(data,Tags.DeduppedRef,
|
|
405
|
+
Object.keys(this.readHashes).filter(() => this.deduplicatedHashes[hashValue] == undefined));
|
|
406
|
+
const deduplicatedList = [data, ...Object.values(this.extractData), ...this.deduplicated];
|
|
407
|
+
// const naturalList = deduplicatedList.map(Tags.naturalizeDataset);
|
|
408
|
+
// // console.log("naturalList=", JSON.stringify(naturalList,null,2));
|
|
409
|
+
// console.log("Going to write study data", naturalList.length);
|
|
410
|
+
// await JSONWriter(this.deduplicatedPath, hashValue, naturalList, { gzip: true, index: false });
|
|
411
|
+
await JSONWriter(this.deduplicatedPath, hashValue, deduplicatedList, { gzip: true, index: false });
|
|
412
|
+
console.log("Wrote naturalized dataset");
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
module.exports = StudyData;
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
const path = require("path");
|
|
2
|
+
const glob = require("glob");
|
|
3
|
+
const dicomCodec = require("@cornerstonejs/dicom-codec");
|
|
4
|
+
const staticCS = require("@radicalimaging/static-cs-lite");
|
|
5
|
+
const fs = require("fs");
|
|
6
|
+
const { exec } = require("child_process");
|
|
7
|
+
const decodeImage = require("./adapter/decodeImage");
|
|
8
|
+
const { shouldThumbUseTranscoded } = require("./adapter/transcodeImage");
|
|
9
|
+
const { isVideo } = require("../writer/VideoWriter");
|
|
10
|
+
const Tags = require("../dictionary/Tags");
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Return the middle index of given list
|
|
14
|
+
* @param {*} listThumbs
|
|
15
|
+
* @returns
|
|
16
|
+
*/
|
|
17
|
+
function getThumbIndex(listThumbs) {
|
|
18
|
+
return Math.trunc(listThumbs / 2);
|
|
19
|
+
}
|
|
20
|
+
function internalGenerateThumbnail(originalImageFrame, dataset, metadata, transferSyntaxUid, doneCallback) {
|
|
21
|
+
decodeImage(originalImageFrame, dataset, transferSyntaxUid)
|
|
22
|
+
.then((decodeResult = {}) => {
|
|
23
|
+
if (isVideo(transferSyntaxUid)) {
|
|
24
|
+
console.log("Video data - no thumbnail generator yet");
|
|
25
|
+
} else {
|
|
26
|
+
const { imageFrame, imageInfo } = decodeResult;
|
|
27
|
+
const pixelData = dicomCodec.getPixelData(imageFrame, imageInfo, transferSyntaxUid);
|
|
28
|
+
staticCS.getRenderedBuffer(transferSyntaxUid, pixelData, metadata, doneCallback);
|
|
29
|
+
}
|
|
30
|
+
})
|
|
31
|
+
.catch((error) => {
|
|
32
|
+
console.log(`Error while generating thumbnail:: ${error}`);
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* ThumbObj wrapper containing both original and transcoded content.
|
|
38
|
+
*
|
|
39
|
+
* @typedef {Object} ThumbObjWrapper
|
|
40
|
+
* @property {Object} id object containing study properties such as seriesUid, instanceUid
|
|
41
|
+
* @property {*} transcodedId transcoded object containing study properties such as seriesUid, instanceUid (this might be the same as original id depending on options)
|
|
42
|
+
* @property {*} imageFrame original image frame
|
|
43
|
+
* @property {*} transcodedImageFrame transcoded image frame (this might be the same as original frame depending on options)
|
|
44
|
+
* @property {number} frameIndex
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* ThumbObj containing imageFrame, id to be used.
|
|
49
|
+
*
|
|
50
|
+
* @typedef {Object} ThumbObj
|
|
51
|
+
* @property {Object} id object containing study properties such as seriesUid, instanceUid
|
|
52
|
+
* @property {*} imageFrame
|
|
53
|
+
* @property {number} frameIndex
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Service to handle thumbnail creation.
|
|
58
|
+
* @class
|
|
59
|
+
* @constructor
|
|
60
|
+
* @property {Array<ThumbObj>} framesThumbnailObj Auxiliary list that contains all thumb obj.
|
|
61
|
+
* @property {ThumbObj} favoriteThumbnailObj Favorite thumb obj to be used on thumbnail creation
|
|
62
|
+
* @property {String} thumbFileName thumb file (with extension) to be used when creating the file.
|
|
63
|
+
* @public
|
|
64
|
+
*/
|
|
65
|
+
class ThumbnailService {
|
|
66
|
+
constructor() {
|
|
67
|
+
this.framesThumbnailObj = [];
|
|
68
|
+
this.favoriteThumbnailObj = {};
|
|
69
|
+
this.thumbFileName = "thumbnail";
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Add thumbObj to list of possible thumbs.
|
|
74
|
+
* It might use original content or transcoded depending on options flags
|
|
75
|
+
* @param {ThumbObjWrapper} thumbObjWrapper
|
|
76
|
+
* @param {Object} programOpts
|
|
77
|
+
*/
|
|
78
|
+
queueThumbnail(thumbObjWrapper, programOpts) {
|
|
79
|
+
const { id, imageFrame, transcodedId, transcodedImageFrame, frameIndex } = thumbObjWrapper;
|
|
80
|
+
const getThumbContent = (originalContent, trancodedContent) => (shouldThumbUseTranscoded(id, programOpts) ? trancodedContent : originalContent);
|
|
81
|
+
|
|
82
|
+
const thumbObj = {
|
|
83
|
+
imageFrame: getThumbContent(imageFrame, transcodedImageFrame),
|
|
84
|
+
id: getThumbContent(id, transcodedId),
|
|
85
|
+
frameIndex,
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
this.framesThumbnailObj.push(thumbObj);
|
|
89
|
+
|
|
90
|
+
this.setFavoriteThumbnailObj();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
setFavoriteThumbnailObj() {
|
|
94
|
+
const favIndex = getThumbIndex(this.framesThumbnailObj.length);
|
|
95
|
+
|
|
96
|
+
this.favoriteThumbnailObj = this.framesThumbnailObj[favIndex];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
ffmpeg(input, output) {
|
|
100
|
+
exec(`ffmpeg -i "${input}" -vf "thumbnail,scale=640:360" -frames:v 1 -f singlejpeg "${output}"`, (error, stdout, stderr) => {
|
|
101
|
+
if (error) {
|
|
102
|
+
console.log(`error: ${error.message}`);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (stderr) {
|
|
106
|
+
console.log(`stderr: ${stderr}`);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
console.log(`stdout: ${stdout}`);
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Generates thumbnails for the levels: instances, series, study
|
|
115
|
+
*
|
|
116
|
+
* @param {*} dataSet
|
|
117
|
+
* @param {*} metadata
|
|
118
|
+
* @param {*} callback
|
|
119
|
+
*/
|
|
120
|
+
generateThumbnails(itemId, dataSet, metadata, callback) {
|
|
121
|
+
const { imageFrame, id } = this.favoriteThumbnailObj;
|
|
122
|
+
|
|
123
|
+
// There are various reasons no thumbnails might be generated, so just return
|
|
124
|
+
if (!id) {
|
|
125
|
+
const pixelData = metadata[Tags.PixelData];
|
|
126
|
+
if (pixelData) {
|
|
127
|
+
const { BulkDataURI } = pixelData;
|
|
128
|
+
if (BulkDataURI?.indexOf("mp4")) {
|
|
129
|
+
const mp4Path = path.join(itemId.sopInstanceRootPath, "pixeldata.mp4");
|
|
130
|
+
const thumbPath = path.join(itemId.sopInstanceRootPath, "thumbnail");
|
|
131
|
+
console.log("MP4 - converting video format", mp4Path);
|
|
132
|
+
this.ffmpeg(mp4Path, thumbPath);
|
|
133
|
+
} else {
|
|
134
|
+
console.log("pixelData = ", pixelData, Tags.PixelData);
|
|
135
|
+
}
|
|
136
|
+
} else {
|
|
137
|
+
console.log("Series is of other type...", metadata[Tags.Modality]);
|
|
138
|
+
}
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
internalGenerateThumbnail(imageFrame, dataSet, metadata, id.transferSyntaxUid, async (thumbBuffer) => {
|
|
142
|
+
if (thumbBuffer) {
|
|
143
|
+
await callback.thumbWriter(id.sopInstanceRootPath, this.thumbFileName, thumbBuffer);
|
|
144
|
+
|
|
145
|
+
this.copySyncThumbnail(id.sopInstanceRootPath, id.seriesRootPath);
|
|
146
|
+
this.copySyncThumbnail(id.seriesRootPath, id.studyPath);
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Copy thumbnail from sourceFolder to targetFolderPath. It usually is used to copy from child to parent folder.
|
|
153
|
+
* If there is already some existing thumbnails under targetFolderPath it actually get thumb on the index defined by getThumbIndex.
|
|
154
|
+
* Copy is a syncrhonous process.
|
|
155
|
+
*
|
|
156
|
+
* @param {*} sourceFolderPath
|
|
157
|
+
* @param {*} targetFolderPath
|
|
158
|
+
* @returns
|
|
159
|
+
*/
|
|
160
|
+
async copySyncThumbnail(sourceFolderPath, targetFolderPath) {
|
|
161
|
+
const parentPathLevel = path.join(sourceFolderPath, "../");
|
|
162
|
+
const thumbFilesPath = glob.sync(`${parentPathLevel}*/${this.thumbFileName}`);
|
|
163
|
+
|
|
164
|
+
const thumbIndex = getThumbIndex(thumbFilesPath.length);
|
|
165
|
+
const thumbFilePath = thumbFilesPath[thumbIndex];
|
|
166
|
+
|
|
167
|
+
if (!fs.existsSync(thumbFilePath)) {
|
|
168
|
+
console.log("Thumbnail to copy does not exists");
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
try {
|
|
173
|
+
if (!fs.lstatSync(targetFolderPath).isDirectory()) {
|
|
174
|
+
throw new Error(`Target path: ${targetFolderPath} is not a directory`);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
fs.copyFileSync(thumbFilePath, `${targetFolderPath}/${this.thumbFileName}`);
|
|
178
|
+
} catch (e) {
|
|
179
|
+
console.log("The file could not be copied", e);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
module.exports = ThumbnailService;
|