dicom-synth 1.1.0 → 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 +115 -39
- package/dist/esm/collection/writer.js +178 -34
- package/dist/esm/index.js +290 -82
- package/dist/esm/schema/validate.js +110 -46
- package/dist/esm/syntheticFixtures/generator.js +73 -27
- package/dist/esm/syntheticFixtures/uid.js +29 -1
- package/dist/types/collection/writer.d.ts +3 -0
- package/dist/types/index.d.ts +1 -1
- package/dist/types/schema/types.d.ts +29 -11
- package/dist/types/syntheticFixtures/uid.d.ts +5 -0
- package/package.json +1 -1
package/dist/esm/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// src/collection/writer.ts
|
|
2
2
|
import { mkdirSync as mkdirSync2 } from "node:fs";
|
|
3
3
|
import { writeFile } from "node:fs/promises";
|
|
4
|
-
import { resolve as resolve2 } from "node:path";
|
|
4
|
+
import { dirname, resolve as resolve2 } from "node:path";
|
|
5
5
|
|
|
6
6
|
// src/loadDcmjs.ts
|
|
7
7
|
import dcmjsDefaultImport, * as dcmjsNamespace from "dcmjs";
|
|
@@ -46,13 +46,23 @@ function buildDicomdirBuffer() {
|
|
|
46
46
|
|
|
47
47
|
// src/schema/validate.ts
|
|
48
48
|
var TYPE_CAPABILITIES = {
|
|
49
|
-
"valid-
|
|
50
|
-
"invalid-uid-
|
|
51
|
-
"vendor-warnings-
|
|
52
|
-
"large-
|
|
53
|
-
"fake-signature": {
|
|
54
|
-
"non-dicom": {
|
|
55
|
-
dicomdir: {
|
|
49
|
+
"valid-image": { image: true },
|
|
50
|
+
"invalid-uid-image": { image: true },
|
|
51
|
+
"vendor-warnings-image": { image: true },
|
|
52
|
+
"large-image": { image: true },
|
|
53
|
+
"fake-signature": { image: false },
|
|
54
|
+
"non-dicom": { image: false },
|
|
55
|
+
dicomdir: { image: false }
|
|
56
|
+
};
|
|
57
|
+
var VALID_MODALITIES = {
|
|
58
|
+
CT: true,
|
|
59
|
+
PT: true,
|
|
60
|
+
MR: true,
|
|
61
|
+
CR: true
|
|
62
|
+
};
|
|
63
|
+
var VALID_LAYOUTS = {
|
|
64
|
+
flat: true,
|
|
65
|
+
hierarchical: true
|
|
56
66
|
};
|
|
57
67
|
var VALID_TRANSFER_SYNTAXES = {
|
|
58
68
|
"explicit-vr-little-endian": true,
|
|
@@ -69,6 +79,13 @@ var VALID_VIOLATIONS = {
|
|
|
69
79
|
};
|
|
70
80
|
var HEX_TAG_RE = /^[0-9a-fA-F]{8}$/;
|
|
71
81
|
var KEYWORD_RE = /^[A-Z][A-Za-z0-9]*$/;
|
|
82
|
+
function validatePositiveInt(value, path) {
|
|
83
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < 1) {
|
|
84
|
+
throw new Error(
|
|
85
|
+
`${path}: must be a positive integer; got ${JSON.stringify(value)}`
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
72
89
|
function validateTagKey(key, path) {
|
|
73
90
|
if (!HEX_TAG_RE.test(key) && !KEYWORD_RE.test(key)) {
|
|
74
91
|
throw new Error(
|
|
@@ -88,37 +105,31 @@ function validateEntry(entry, path) {
|
|
|
88
105
|
}
|
|
89
106
|
const type = e.type;
|
|
90
107
|
const caps = TYPE_CAPABILITIES[type];
|
|
91
|
-
if ("count" in e) {
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
108
|
+
if ("count" in e) validatePositiveInt(e.count, `${path}.count`);
|
|
109
|
+
for (const field of [
|
|
110
|
+
"modality",
|
|
111
|
+
"tags",
|
|
112
|
+
"violations",
|
|
113
|
+
"transferSyntax"
|
|
114
|
+
]) {
|
|
115
|
+
if (!caps.image && field in e)
|
|
116
|
+
throw new Error(`${path}.${field}: not supported for type "${type}"`);
|
|
97
117
|
}
|
|
98
118
|
if ("transferSyntax" in e) {
|
|
99
|
-
if (!caps.transferSyntax) {
|
|
100
|
-
throw new Error(
|
|
101
|
-
`${path}.transferSyntax: not supported for type "${type}"`
|
|
102
|
-
);
|
|
103
|
-
}
|
|
104
119
|
if (!Object.hasOwn(VALID_TRANSFER_SYNTAXES, e.transferSyntax)) {
|
|
105
120
|
throw new Error(
|
|
106
121
|
`${path}.transferSyntax: must be one of ${Object.keys(VALID_TRANSFER_SYNTAXES).join(", ")}`
|
|
107
122
|
);
|
|
108
123
|
}
|
|
109
124
|
}
|
|
110
|
-
if (
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
if (typeof e.tags !== "object" || e.tags === null || Array.isArray(e.tags)) {
|
|
116
|
-
throw new Error(`${path}.tags: must be an object`);
|
|
117
|
-
}
|
|
118
|
-
for (const key of Object.keys(e.tags)) {
|
|
119
|
-
validateTagKey(key, `${path}.tags`);
|
|
125
|
+
if ("modality" in e) {
|
|
126
|
+
if (typeof e.modality !== "string" || !Object.hasOwn(VALID_MODALITIES, e.modality)) {
|
|
127
|
+
throw new Error(
|
|
128
|
+
`${path}.modality: must be one of ${Object.keys(VALID_MODALITIES).join(", ")}; got ${JSON.stringify(e.modality)}`
|
|
129
|
+
);
|
|
120
130
|
}
|
|
121
131
|
}
|
|
132
|
+
if ("tags" in e) validateTags(e.tags, `${path}.tags`);
|
|
122
133
|
if ("violations" in e) {
|
|
123
134
|
if (!Array.isArray(e.violations)) {
|
|
124
135
|
throw new Error(`${path}.violations: must be an array`);
|
|
@@ -131,18 +142,10 @@ function validateEntry(entry, path) {
|
|
|
131
142
|
}
|
|
132
143
|
}
|
|
133
144
|
}
|
|
134
|
-
if (type === "large-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
}
|
|
138
|
-
if (typeof e.columns !== "number" || !Number.isInteger(e.columns) || e.columns < 1) {
|
|
139
|
-
throw new Error(`${path}.columns: must be a positive integer`);
|
|
140
|
-
}
|
|
141
|
-
if ("frames" in e) {
|
|
142
|
-
if (typeof e.frames !== "number" || !Number.isInteger(e.frames) || e.frames < 1) {
|
|
143
|
-
throw new Error(`${path}.frames: must be a positive integer`);
|
|
144
|
-
}
|
|
145
|
-
}
|
|
145
|
+
if (type === "large-image") {
|
|
146
|
+
validatePositiveInt(e.rows, `${path}.rows`);
|
|
147
|
+
validatePositiveInt(e.columns, `${path}.columns`);
|
|
148
|
+
if ("frames" in e) validatePositiveInt(e.frames, `${path}.frames`);
|
|
146
149
|
const rows = e.rows;
|
|
147
150
|
const columns = e.columns;
|
|
148
151
|
const frames = typeof e.frames === "number" ? e.frames : 1;
|
|
@@ -154,20 +157,72 @@ function validateEntry(entry, path) {
|
|
|
154
157
|
}
|
|
155
158
|
return e;
|
|
156
159
|
}
|
|
160
|
+
function validateTags(tags, path) {
|
|
161
|
+
if (typeof tags !== "object" || tags === null || Array.isArray(tags)) {
|
|
162
|
+
throw new Error(`${path}: must be an object`);
|
|
163
|
+
}
|
|
164
|
+
for (const key of Object.keys(tags)) {
|
|
165
|
+
validateTagKey(key, path);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
function validateSeries(series, path) {
|
|
169
|
+
if (typeof series !== "object" || series === null || Array.isArray(series)) {
|
|
170
|
+
throw new Error(`${path}: must be an object`);
|
|
171
|
+
}
|
|
172
|
+
const s = series;
|
|
173
|
+
if (!Array.isArray(s.entries) || s.entries.length === 0) {
|
|
174
|
+
throw new Error(`${path}.entries: must be a non-empty array`);
|
|
175
|
+
}
|
|
176
|
+
for (let i = 0; i < s.entries.length; i++) {
|
|
177
|
+
const e = validateEntry(s.entries[i], `${path}.entries[${i}]`);
|
|
178
|
+
if (!TYPE_CAPABILITIES[e.type].image) {
|
|
179
|
+
throw new Error(
|
|
180
|
+
`${path}.entries[${i}].type: "${e.type}" cannot be used inside a series \u2014 only image-generating types can be grouped`
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if ("tags" in s) validateTags(s.tags, `${path}.tags`);
|
|
185
|
+
return s;
|
|
186
|
+
}
|
|
187
|
+
function validateStudy(study, path) {
|
|
188
|
+
if (typeof study !== "object" || study === null || Array.isArray(study)) {
|
|
189
|
+
throw new Error(`${path}: must be an object`);
|
|
190
|
+
}
|
|
191
|
+
const st = study;
|
|
192
|
+
if (!Array.isArray(st.series) || st.series.length === 0) {
|
|
193
|
+
throw new Error(`${path}.series: must be a non-empty array`);
|
|
194
|
+
}
|
|
195
|
+
for (let i = 0; i < st.series.length; i++) {
|
|
196
|
+
validateSeries(st.series[i], `${path}.series[${i}]`);
|
|
197
|
+
}
|
|
198
|
+
if ("count" in st) validatePositiveInt(st.count, `${path}.count`);
|
|
199
|
+
if ("tags" in st) validateTags(st.tags, `${path}.tags`);
|
|
200
|
+
return st;
|
|
201
|
+
}
|
|
157
202
|
function validateDatasetSpec(raw) {
|
|
158
203
|
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
159
204
|
throw new Error("DatasetSpec: must be a JSON object");
|
|
160
205
|
}
|
|
161
206
|
const r = raw;
|
|
162
|
-
if (!Array.isArray(r.entries)) {
|
|
207
|
+
if ("entries" in r && !Array.isArray(r.entries)) {
|
|
163
208
|
throw new Error("DatasetSpec.entries: must be an array");
|
|
164
209
|
}
|
|
165
|
-
if (r.
|
|
166
|
-
throw new Error("DatasetSpec.
|
|
210
|
+
if ("studies" in r && !Array.isArray(r.studies)) {
|
|
211
|
+
throw new Error("DatasetSpec.studies: must be an array");
|
|
167
212
|
}
|
|
168
|
-
const
|
|
213
|
+
const rawEntries = r.entries ?? [];
|
|
214
|
+
const rawStudies = r.studies ?? [];
|
|
215
|
+
if (rawEntries.length === 0 && rawStudies.length === 0) {
|
|
216
|
+
throw new Error(
|
|
217
|
+
'DatasetSpec: must contain at least one of "entries" or "studies" (non-empty)'
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
const entries = rawEntries.map(
|
|
169
221
|
(entry, i) => validateEntry(entry, `entries[${i}]`)
|
|
170
222
|
);
|
|
223
|
+
const studies = rawStudies.map(
|
|
224
|
+
(study, i) => validateStudy(study, `studies[${i}]`)
|
|
225
|
+
);
|
|
171
226
|
if ("seed" in r) {
|
|
172
227
|
if (typeof r.seed !== "number" || !Number.isFinite(r.seed)) {
|
|
173
228
|
throw new Error(
|
|
@@ -175,14 +230,51 @@ function validateDatasetSpec(raw) {
|
|
|
175
230
|
);
|
|
176
231
|
}
|
|
177
232
|
}
|
|
233
|
+
if ("layout" in r) {
|
|
234
|
+
if (typeof r.layout !== "string" || !Object.hasOwn(VALID_LAYOUTS, r.layout)) {
|
|
235
|
+
throw new Error(
|
|
236
|
+
`DatasetSpec.layout: must be one of ${Object.keys(VALID_LAYOUTS).join(", ")}; got ${JSON.stringify(r.layout)}`
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
178
240
|
return {
|
|
179
|
-
entries,
|
|
180
|
-
...
|
|
241
|
+
...entries.length > 0 ? { entries } : {},
|
|
242
|
+
...studies.length > 0 ? { studies } : {},
|
|
243
|
+
...r.seed !== void 0 ? { seed: r.seed } : {},
|
|
244
|
+
...r.layout !== void 0 ? { layout: r.layout } : {}
|
|
181
245
|
};
|
|
182
246
|
}
|
|
183
247
|
|
|
184
248
|
// src/syntheticFixtures/generator.ts
|
|
185
|
-
var
|
|
249
|
+
var MODALITY_PRESETS = {
|
|
250
|
+
CT: {
|
|
251
|
+
sopClassUid: "1.2.840.10008.5.1.4.1.1.2",
|
|
252
|
+
// CT Image Storage
|
|
253
|
+
attributes: {}
|
|
254
|
+
},
|
|
255
|
+
PT: {
|
|
256
|
+
sopClassUid: "1.2.840.10008.5.1.4.1.1.128",
|
|
257
|
+
// PET Image Storage
|
|
258
|
+
attributes: {
|
|
259
|
+
Units: "BQML",
|
|
260
|
+
DecayCorrection: "NONE",
|
|
261
|
+
CorrectedImage: ["ATTN", "DECY"]
|
|
262
|
+
}
|
|
263
|
+
},
|
|
264
|
+
MR: {
|
|
265
|
+
sopClassUid: "1.2.840.10008.5.1.4.1.1.4",
|
|
266
|
+
// MR Image Storage
|
|
267
|
+
attributes: {
|
|
268
|
+
ScanningSequence: "SE",
|
|
269
|
+
SequenceVariant: "NONE"
|
|
270
|
+
}
|
|
271
|
+
},
|
|
272
|
+
CR: {
|
|
273
|
+
sopClassUid: "1.2.840.10008.5.1.4.1.1.1",
|
|
274
|
+
// CR Image Storage
|
|
275
|
+
attributes: {}
|
|
276
|
+
}
|
|
277
|
+
};
|
|
186
278
|
var TRANSFER_SYNTAX_UID = {
|
|
187
279
|
"explicit-vr-little-endian": "1.2.840.10008.1.2.1",
|
|
188
280
|
"implicit-vr-little-endian": "1.2.840.10008.1.2"
|
|
@@ -215,22 +307,23 @@ function applyTagOverrides(dataset, tags) {
|
|
|
215
307
|
}
|
|
216
308
|
}
|
|
217
309
|
}
|
|
218
|
-
function buildMeta(uid, transferSyntax = "explicit-vr-little-endian") {
|
|
310
|
+
function buildMeta(uid, modality, transferSyntax = "explicit-vr-little-endian") {
|
|
219
311
|
return {
|
|
220
312
|
FileMetaInformationVersion: new Uint8Array([0, 1]).buffer,
|
|
221
|
-
MediaStorageSOPClassUID:
|
|
313
|
+
MediaStorageSOPClassUID: MODALITY_PRESETS[modality].sopClassUid,
|
|
222
314
|
MediaStorageSOPInstanceUID: uid.sop,
|
|
223
315
|
TransferSyntaxUID: TRANSFER_SYNTAX_UID[transferSyntax],
|
|
224
316
|
ImplementationClassUID: "1.2.3.4",
|
|
225
317
|
ImplementationVersionName: "SYNTH"
|
|
226
318
|
};
|
|
227
319
|
}
|
|
228
|
-
function
|
|
320
|
+
function buildBaseImageDataset(uid, modality) {
|
|
321
|
+
const preset = MODALITY_PRESETS[modality];
|
|
229
322
|
return {
|
|
230
323
|
PatientName: "SYNTH^SUBJECT",
|
|
231
324
|
PatientID: "SYNTH_PID",
|
|
232
|
-
Modality:
|
|
233
|
-
SOPClassUID:
|
|
325
|
+
Modality: modality,
|
|
326
|
+
SOPClassUID: preset.sopClassUid,
|
|
234
327
|
SOPInstanceUID: uid.sop,
|
|
235
328
|
SeriesInstanceUID: uid.series,
|
|
236
329
|
StudyInstanceUID: uid.study,
|
|
@@ -247,7 +340,8 @@ function buildBaseCtDataset(uid) {
|
|
|
247
340
|
PixelData: new Uint8Array([0, 0]).buffer,
|
|
248
341
|
// Declares PixelData's VR explicitly — without it dcmjs logs
|
|
249
342
|
// "No value representation given for PixelData" and falls back to OW anyway.
|
|
250
|
-
_vrMap: { PixelData: "OW" }
|
|
343
|
+
_vrMap: { PixelData: "OW" },
|
|
344
|
+
...preset.attributes
|
|
251
345
|
};
|
|
252
346
|
}
|
|
253
347
|
function serializeDict(meta, dataset) {
|
|
@@ -261,36 +355,36 @@ function serializeDict(meta, dataset) {
|
|
|
261
355
|
);
|
|
262
356
|
return Buffer.from(dicomDict.write({ allowInvalidVRLength: true }));
|
|
263
357
|
}
|
|
264
|
-
function
|
|
265
|
-
const dataset =
|
|
358
|
+
function buildValidImageBuffer(uid, modality, tags, transferSyntax) {
|
|
359
|
+
const dataset = buildBaseImageDataset(uid, modality);
|
|
266
360
|
applyTagOverrides(dataset, tags);
|
|
267
|
-
return serializeDict(buildMeta(uid, transferSyntax), dataset);
|
|
361
|
+
return serializeDict(buildMeta(uid, modality, transferSyntax), dataset);
|
|
268
362
|
}
|
|
269
|
-
function
|
|
363
|
+
function buildInvalidUidImageBuffer(uid, modality, tags, transferSyntax) {
|
|
270
364
|
const invalidUid = {
|
|
271
365
|
study: `2.25.invalid.study.${uid.study.slice(-6)}`,
|
|
272
366
|
series: `2.25.invalid.series.${uid.series.slice(-6)}`,
|
|
273
367
|
sop: `2.25.invalid.sop.${uid.sop.slice(-6)}`
|
|
274
368
|
};
|
|
275
|
-
const dataset =
|
|
369
|
+
const dataset = buildBaseImageDataset(invalidUid, modality);
|
|
276
370
|
applyTagOverrides(dataset, tags);
|
|
277
|
-
return serializeDict(buildMeta(invalidUid, transferSyntax), dataset);
|
|
371
|
+
return serializeDict(buildMeta(invalidUid, modality, transferSyntax), dataset);
|
|
278
372
|
}
|
|
279
|
-
function
|
|
280
|
-
const dataset =
|
|
373
|
+
function buildVendorWarningsImageBuffer(uid, modality, tags, transferSyntax) {
|
|
374
|
+
const dataset = buildBaseImageDataset(uid, modality);
|
|
281
375
|
dataset.Laterality = "";
|
|
282
376
|
dataset.PatientWeight = "0";
|
|
283
377
|
applyTagOverrides(dataset, tags);
|
|
284
|
-
return serializeDict(buildMeta(uid, transferSyntax), dataset);
|
|
378
|
+
return serializeDict(buildMeta(uid, modality, transferSyntax), dataset);
|
|
285
379
|
}
|
|
286
|
-
function
|
|
287
|
-
const dataset =
|
|
380
|
+
function buildLargeImageBuffer(uid, modality, rows, columns, frames, tags, transferSyntax) {
|
|
381
|
+
const dataset = buildBaseImageDataset(uid, modality);
|
|
288
382
|
dataset.Rows = rows;
|
|
289
383
|
dataset.Columns = columns;
|
|
290
384
|
dataset.NumberOfFrames = frames;
|
|
291
385
|
dataset.PixelData = new Uint8Array(rows * columns * frames * 2).buffer;
|
|
292
386
|
applyTagOverrides(dataset, tags);
|
|
293
|
-
return serializeDict(buildMeta(uid, transferSyntax), dataset);
|
|
387
|
+
return serializeDict(buildMeta(uid, modality, transferSyntax), dataset);
|
|
294
388
|
}
|
|
295
389
|
function buildFakeSignatureBuffer() {
|
|
296
390
|
const buf = Buffer.alloc(200, 0);
|
|
@@ -302,15 +396,31 @@ function buildNonDicomBuffer(content = "not dicom") {
|
|
|
302
396
|
}
|
|
303
397
|
function buildBufferForSpec(spec, uid) {
|
|
304
398
|
switch (spec.type) {
|
|
305
|
-
case "valid-
|
|
306
|
-
return
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
399
|
+
case "valid-image":
|
|
400
|
+
return buildValidImageBuffer(
|
|
401
|
+
uid,
|
|
402
|
+
spec.modality ?? "CT",
|
|
403
|
+
spec.tags,
|
|
404
|
+
spec.transferSyntax
|
|
405
|
+
);
|
|
406
|
+
case "invalid-uid-image":
|
|
407
|
+
return buildInvalidUidImageBuffer(
|
|
408
|
+
uid,
|
|
409
|
+
spec.modality ?? "CT",
|
|
410
|
+
spec.tags,
|
|
411
|
+
spec.transferSyntax
|
|
412
|
+
);
|
|
413
|
+
case "vendor-warnings-image":
|
|
414
|
+
return buildVendorWarningsImageBuffer(
|
|
415
|
+
uid,
|
|
416
|
+
spec.modality ?? "CT",
|
|
417
|
+
spec.tags,
|
|
418
|
+
spec.transferSyntax
|
|
419
|
+
);
|
|
420
|
+
case "large-image":
|
|
421
|
+
return buildLargeImageBuffer(
|
|
313
422
|
uid,
|
|
423
|
+
spec.modality ?? "CT",
|
|
314
424
|
spec.rows,
|
|
315
425
|
spec.columns,
|
|
316
426
|
spec.frames ?? 1,
|
|
@@ -346,6 +456,33 @@ function randomUid(role) {
|
|
|
346
456
|
function seededUid(next, role) {
|
|
347
457
|
return formatUid(next(), next(), role);
|
|
348
458
|
}
|
|
459
|
+
var GROUP_STREAM_OFFSET = 2654435769;
|
|
460
|
+
function seededStream(seed, offset, a, b) {
|
|
461
|
+
return lcg(seed * 999983 + offset + a * 1000003 + b * 7919 >>> 0);
|
|
462
|
+
}
|
|
463
|
+
function makeGroupUidGenerator(seed) {
|
|
464
|
+
if (seed === void 0) {
|
|
465
|
+
return {
|
|
466
|
+
study: () => randomUid("study"),
|
|
467
|
+
series: () => randomUid("series")
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
return {
|
|
471
|
+
study: (studyIndex) => seededUid(
|
|
472
|
+
seededStream(seed, GROUP_STREAM_OFFSET, studyIndex + 1, 0),
|
|
473
|
+
"study"
|
|
474
|
+
),
|
|
475
|
+
series: (studyIndex, seriesIndex) => seededUid(
|
|
476
|
+
seededStream(
|
|
477
|
+
seed,
|
|
478
|
+
GROUP_STREAM_OFFSET,
|
|
479
|
+
studyIndex + 1,
|
|
480
|
+
seriesIndex + 1
|
|
481
|
+
),
|
|
482
|
+
"series"
|
|
483
|
+
)
|
|
484
|
+
};
|
|
485
|
+
}
|
|
349
486
|
function makeUidGenerator(seed) {
|
|
350
487
|
if (seed === void 0) {
|
|
351
488
|
return () => ({
|
|
@@ -355,7 +492,7 @@ function makeUidGenerator(seed) {
|
|
|
355
492
|
});
|
|
356
493
|
}
|
|
357
494
|
return (fileIndex) => {
|
|
358
|
-
const next =
|
|
495
|
+
const next = seededStream(seed, 0, fileIndex, 0);
|
|
359
496
|
return {
|
|
360
497
|
study: seededUid(next, "study"),
|
|
361
498
|
series: seededUid(next, "series"),
|
|
@@ -468,28 +605,50 @@ function filename(type, index, padWidth) {
|
|
|
468
605
|
const base = `${type}-${padded}`;
|
|
469
606
|
return NO_EXTENSION_TYPES.has(type) ? base : `${base}.dcm`;
|
|
470
607
|
}
|
|
608
|
+
function entryCount(entries) {
|
|
609
|
+
return entries.reduce((sum, e) => sum + (e.count ?? 1), 0);
|
|
610
|
+
}
|
|
611
|
+
function studyFileCount(studies) {
|
|
612
|
+
return studies.reduce(
|
|
613
|
+
(sum, study) => sum + (study.count ?? 1) * study.series.reduce((s, series) => s + entryCount(series.entries), 0),
|
|
614
|
+
0
|
|
615
|
+
);
|
|
616
|
+
}
|
|
471
617
|
async function generateFile(spec, options) {
|
|
472
618
|
const index = options?.index ?? 0;
|
|
473
619
|
const padWidth = options?.padWidth ?? 3;
|
|
474
620
|
const uidGen = makeUidGenerator(options?.seed);
|
|
475
|
-
const uid = uidGen(index);
|
|
621
|
+
const uid = { ...uidGen(index), ...options?.uid };
|
|
476
622
|
let buffer = buildBufferForSpec(spec, uid);
|
|
477
623
|
const violations = "violations" in spec ? spec.violations : void 0;
|
|
478
624
|
if (violations?.length) {
|
|
479
625
|
buffer = applyViolations(buffer, violations);
|
|
480
626
|
}
|
|
627
|
+
const name = filename(spec.type, index, padWidth);
|
|
481
628
|
return {
|
|
482
|
-
filename:
|
|
629
|
+
filename: name,
|
|
630
|
+
relativePath: name,
|
|
483
631
|
buffer,
|
|
484
632
|
type: spec.type,
|
|
485
633
|
index
|
|
486
634
|
};
|
|
487
635
|
}
|
|
636
|
+
function hierarchicalName(studyOrdinal, seriesIndex, instanceNumber) {
|
|
637
|
+
const pad3 = (n) => String(n).padStart(3, "0");
|
|
638
|
+
const name = `${String(instanceNumber).padStart(5, "0")}.dcm`;
|
|
639
|
+
return {
|
|
640
|
+
filename: name,
|
|
641
|
+
relativePath: `study-${pad3(studyOrdinal + 1)}/series-${pad3(seriesIndex + 1)}/${name}`
|
|
642
|
+
};
|
|
643
|
+
}
|
|
488
644
|
async function* generateCollectionFromSpec(spec) {
|
|
489
|
-
const
|
|
645
|
+
const flatEntries = spec.entries ?? [];
|
|
646
|
+
const studies = spec.studies ?? [];
|
|
647
|
+
const totalFiles = entryCount(flatEntries) + studyFileCount(studies);
|
|
490
648
|
const padWidth = String(totalFiles).length;
|
|
649
|
+
const hierarchical = spec.layout === "hierarchical";
|
|
491
650
|
let globalIndex = 0;
|
|
492
|
-
for (const entry of
|
|
651
|
+
for (const entry of flatEntries) {
|
|
493
652
|
const { count = 1, ...fileSpec } = entry;
|
|
494
653
|
for (let i = 0; i < count; i++) {
|
|
495
654
|
yield generateFile(fileSpec, {
|
|
@@ -500,13 +659,62 @@ async function* generateCollectionFromSpec(spec) {
|
|
|
500
659
|
globalIndex++;
|
|
501
660
|
}
|
|
502
661
|
}
|
|
662
|
+
const groupUids = makeGroupUidGenerator(spec.seed);
|
|
663
|
+
let studyOrdinal = 0;
|
|
664
|
+
for (const study of studies) {
|
|
665
|
+
const copies = study.count ?? 1;
|
|
666
|
+
for (let copy = 0; copy < copies; copy++) {
|
|
667
|
+
const studyUid = groupUids.study(studyOrdinal);
|
|
668
|
+
for (const [seriesIndex, series] of study.series.entries()) {
|
|
669
|
+
const seriesUid = groupUids.series(studyOrdinal, seriesIndex);
|
|
670
|
+
let instanceNumber = 0;
|
|
671
|
+
for (const entry of series.entries) {
|
|
672
|
+
const { count = 1, ...fileSpec } = entry;
|
|
673
|
+
for (let i = 0; i < count; i++) {
|
|
674
|
+
instanceNumber++;
|
|
675
|
+
const tags = {
|
|
676
|
+
InstanceNumber: instanceNumber,
|
|
677
|
+
...study.tags,
|
|
678
|
+
...series.tags,
|
|
679
|
+
...fileSpec.tags
|
|
680
|
+
};
|
|
681
|
+
const file = await generateFile(
|
|
682
|
+
{ ...fileSpec, tags },
|
|
683
|
+
{
|
|
684
|
+
index: globalIndex,
|
|
685
|
+
seed: spec.seed,
|
|
686
|
+
padWidth,
|
|
687
|
+
uid: { study: studyUid, series: seriesUid }
|
|
688
|
+
}
|
|
689
|
+
);
|
|
690
|
+
yield hierarchical ? {
|
|
691
|
+
...file,
|
|
692
|
+
...hierarchicalName(
|
|
693
|
+
studyOrdinal,
|
|
694
|
+
seriesIndex,
|
|
695
|
+
instanceNumber
|
|
696
|
+
)
|
|
697
|
+
} : file;
|
|
698
|
+
globalIndex++;
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
studyOrdinal++;
|
|
703
|
+
}
|
|
704
|
+
}
|
|
503
705
|
}
|
|
504
706
|
async function writeCollectionFromSpec(spec, outDir) {
|
|
505
707
|
const root = resolve2(outDir);
|
|
506
708
|
mkdirSync2(root, { recursive: true });
|
|
507
709
|
const manifest = [];
|
|
710
|
+
const createdDirs = /* @__PURE__ */ new Set([root]);
|
|
508
711
|
for await (const file of generateCollectionFromSpec(spec)) {
|
|
509
|
-
const filePath = resolve2(root, file.
|
|
712
|
+
const filePath = resolve2(root, file.relativePath);
|
|
713
|
+
const dir = dirname(filePath);
|
|
714
|
+
if (!createdDirs.has(dir)) {
|
|
715
|
+
mkdirSync2(dir, { recursive: true });
|
|
716
|
+
createdDirs.add(dir);
|
|
717
|
+
}
|
|
510
718
|
await writeFile(filePath, file.buffer);
|
|
511
719
|
manifest.push({ path: filePath, type: file.type, index: file.index });
|
|
512
720
|
}
|
|
@@ -515,10 +723,10 @@ async function writeCollectionFromSpec(spec, outDir) {
|
|
|
515
723
|
|
|
516
724
|
// src/public-fixtures/catalog.ts
|
|
517
725
|
import { readFileSync } from "node:fs";
|
|
518
|
-
import { dirname, join } from "node:path";
|
|
726
|
+
import { dirname as dirname2, join } from "node:path";
|
|
519
727
|
import { fileURLToPath } from "node:url";
|
|
520
728
|
var bundledCatalogPath = join(
|
|
521
|
-
|
|
729
|
+
dirname2(fileURLToPath(import.meta.url)),
|
|
522
730
|
"../../data/public-cases.json"
|
|
523
731
|
);
|
|
524
732
|
function defaultPublicCasesPath() {
|