dicom-synth 1.1.0 → 1.2.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/dist/esm/collection/writer.js +105 -7
- package/dist/esm/index.js +190 -41
- package/dist/esm/schema/validate.js +83 -32
- 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 +18 -2
- package/dist/types/syntheticFixtures/uid.d.ts +5 -0
- package/package.json +1 -1
|
@@ -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";
|
|
@@ -213,6 +213,33 @@ function randomUid(role) {
|
|
|
213
213
|
function seededUid(next, role) {
|
|
214
214
|
return formatUid(next(), next(), role);
|
|
215
215
|
}
|
|
216
|
+
var GROUP_STREAM_OFFSET = 2654435769;
|
|
217
|
+
function seededStream(seed, offset, a, b) {
|
|
218
|
+
return lcg(seed * 999983 + offset + a * 1000003 + b * 7919 >>> 0);
|
|
219
|
+
}
|
|
220
|
+
function makeGroupUidGenerator(seed) {
|
|
221
|
+
if (seed === void 0) {
|
|
222
|
+
return {
|
|
223
|
+
study: () => randomUid("study"),
|
|
224
|
+
series: () => randomUid("series")
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
return {
|
|
228
|
+
study: (studyIndex) => seededUid(
|
|
229
|
+
seededStream(seed, GROUP_STREAM_OFFSET, studyIndex + 1, 0),
|
|
230
|
+
"study"
|
|
231
|
+
),
|
|
232
|
+
series: (studyIndex, seriesIndex) => seededUid(
|
|
233
|
+
seededStream(
|
|
234
|
+
seed,
|
|
235
|
+
GROUP_STREAM_OFFSET,
|
|
236
|
+
studyIndex + 1,
|
|
237
|
+
seriesIndex + 1
|
|
238
|
+
),
|
|
239
|
+
"series"
|
|
240
|
+
)
|
|
241
|
+
};
|
|
242
|
+
}
|
|
216
243
|
function makeUidGenerator(seed) {
|
|
217
244
|
if (seed === void 0) {
|
|
218
245
|
return () => ({
|
|
@@ -222,7 +249,7 @@ function makeUidGenerator(seed) {
|
|
|
222
249
|
});
|
|
223
250
|
}
|
|
224
251
|
return (fileIndex) => {
|
|
225
|
-
const next =
|
|
252
|
+
const next = seededStream(seed, 0, fileIndex, 0);
|
|
226
253
|
return {
|
|
227
254
|
study: seededUid(next, "study"),
|
|
228
255
|
series: seededUid(next, "series"),
|
|
@@ -335,28 +362,50 @@ function filename(type, index, padWidth) {
|
|
|
335
362
|
const base = `${type}-${padded}`;
|
|
336
363
|
return NO_EXTENSION_TYPES.has(type) ? base : `${base}.dcm`;
|
|
337
364
|
}
|
|
365
|
+
function entryCount(entries) {
|
|
366
|
+
return entries.reduce((sum, e) => sum + (e.count ?? 1), 0);
|
|
367
|
+
}
|
|
368
|
+
function studyFileCount(studies) {
|
|
369
|
+
return studies.reduce(
|
|
370
|
+
(sum, study) => sum + (study.count ?? 1) * study.series.reduce((s, series) => s + entryCount(series.entries), 0),
|
|
371
|
+
0
|
|
372
|
+
);
|
|
373
|
+
}
|
|
338
374
|
async function generateFile(spec, options) {
|
|
339
375
|
const index = options?.index ?? 0;
|
|
340
376
|
const padWidth = options?.padWidth ?? 3;
|
|
341
377
|
const uidGen = makeUidGenerator(options?.seed);
|
|
342
|
-
const uid = uidGen(index);
|
|
378
|
+
const uid = { ...uidGen(index), ...options?.uid };
|
|
343
379
|
let buffer = buildBufferForSpec(spec, uid);
|
|
344
380
|
const violations = "violations" in spec ? spec.violations : void 0;
|
|
345
381
|
if (violations?.length) {
|
|
346
382
|
buffer = applyViolations(buffer, violations);
|
|
347
383
|
}
|
|
384
|
+
const name = filename(spec.type, index, padWidth);
|
|
348
385
|
return {
|
|
349
|
-
filename:
|
|
386
|
+
filename: name,
|
|
387
|
+
relativePath: name,
|
|
350
388
|
buffer,
|
|
351
389
|
type: spec.type,
|
|
352
390
|
index
|
|
353
391
|
};
|
|
354
392
|
}
|
|
393
|
+
function hierarchicalName(studyOrdinal, seriesIndex, instanceNumber) {
|
|
394
|
+
const pad3 = (n) => String(n).padStart(3, "0");
|
|
395
|
+
const name = `${String(instanceNumber).padStart(5, "0")}.dcm`;
|
|
396
|
+
return {
|
|
397
|
+
filename: name,
|
|
398
|
+
relativePath: `study-${pad3(studyOrdinal + 1)}/series-${pad3(seriesIndex + 1)}/${name}`
|
|
399
|
+
};
|
|
400
|
+
}
|
|
355
401
|
async function* generateCollectionFromSpec(spec) {
|
|
356
|
-
const
|
|
402
|
+
const flatEntries = spec.entries ?? [];
|
|
403
|
+
const studies = spec.studies ?? [];
|
|
404
|
+
const totalFiles = entryCount(flatEntries) + studyFileCount(studies);
|
|
357
405
|
const padWidth = String(totalFiles).length;
|
|
406
|
+
const hierarchical = spec.layout === "hierarchical";
|
|
358
407
|
let globalIndex = 0;
|
|
359
|
-
for (const entry of
|
|
408
|
+
for (const entry of flatEntries) {
|
|
360
409
|
const { count = 1, ...fileSpec } = entry;
|
|
361
410
|
for (let i = 0; i < count; i++) {
|
|
362
411
|
yield generateFile(fileSpec, {
|
|
@@ -367,13 +416,62 @@ async function* generateCollectionFromSpec(spec) {
|
|
|
367
416
|
globalIndex++;
|
|
368
417
|
}
|
|
369
418
|
}
|
|
419
|
+
const groupUids = makeGroupUidGenerator(spec.seed);
|
|
420
|
+
let studyOrdinal = 0;
|
|
421
|
+
for (const study of studies) {
|
|
422
|
+
const copies = study.count ?? 1;
|
|
423
|
+
for (let copy = 0; copy < copies; copy++) {
|
|
424
|
+
const studyUid = groupUids.study(studyOrdinal);
|
|
425
|
+
for (const [seriesIndex, series] of study.series.entries()) {
|
|
426
|
+
const seriesUid = groupUids.series(studyOrdinal, seriesIndex);
|
|
427
|
+
let instanceNumber = 0;
|
|
428
|
+
for (const entry of series.entries) {
|
|
429
|
+
const { count = 1, ...fileSpec } = entry;
|
|
430
|
+
for (let i = 0; i < count; i++) {
|
|
431
|
+
instanceNumber++;
|
|
432
|
+
const tags = {
|
|
433
|
+
InstanceNumber: instanceNumber,
|
|
434
|
+
...study.tags,
|
|
435
|
+
...series.tags,
|
|
436
|
+
...fileSpec.tags
|
|
437
|
+
};
|
|
438
|
+
const file = await generateFile(
|
|
439
|
+
{ ...fileSpec, tags },
|
|
440
|
+
{
|
|
441
|
+
index: globalIndex,
|
|
442
|
+
seed: spec.seed,
|
|
443
|
+
padWidth,
|
|
444
|
+
uid: { study: studyUid, series: seriesUid }
|
|
445
|
+
}
|
|
446
|
+
);
|
|
447
|
+
yield hierarchical ? {
|
|
448
|
+
...file,
|
|
449
|
+
...hierarchicalName(
|
|
450
|
+
studyOrdinal,
|
|
451
|
+
seriesIndex,
|
|
452
|
+
instanceNumber
|
|
453
|
+
)
|
|
454
|
+
} : file;
|
|
455
|
+
globalIndex++;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
studyOrdinal++;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
370
462
|
}
|
|
371
463
|
async function writeCollectionFromSpec(spec, outDir) {
|
|
372
464
|
const root = resolve2(outDir);
|
|
373
465
|
mkdirSync2(root, { recursive: true });
|
|
374
466
|
const manifest = [];
|
|
467
|
+
const createdDirs = /* @__PURE__ */ new Set([root]);
|
|
375
468
|
for await (const file of generateCollectionFromSpec(spec)) {
|
|
376
|
-
const filePath = resolve2(root, file.
|
|
469
|
+
const filePath = resolve2(root, file.relativePath);
|
|
470
|
+
const dir = dirname(filePath);
|
|
471
|
+
if (!createdDirs.has(dir)) {
|
|
472
|
+
mkdirSync2(dir, { recursive: true });
|
|
473
|
+
createdDirs.add(dir);
|
|
474
|
+
}
|
|
377
475
|
await writeFile(filePath, file.buffer);
|
|
378
476
|
manifest.push({ path: filePath, type: file.type, index: file.index });
|
|
379
477
|
}
|
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";
|
|
@@ -54,6 +54,10 @@ var TYPE_CAPABILITIES = {
|
|
|
54
54
|
"non-dicom": { tags: false, violations: false, transferSyntax: false },
|
|
55
55
|
dicomdir: { tags: false, violations: false, transferSyntax: false }
|
|
56
56
|
};
|
|
57
|
+
var VALID_LAYOUTS = {
|
|
58
|
+
flat: true,
|
|
59
|
+
hierarchical: true
|
|
60
|
+
};
|
|
57
61
|
var VALID_TRANSFER_SYNTAXES = {
|
|
58
62
|
"explicit-vr-little-endian": true,
|
|
59
63
|
"implicit-vr-little-endian": true
|
|
@@ -69,6 +73,13 @@ var VALID_VIOLATIONS = {
|
|
|
69
73
|
};
|
|
70
74
|
var HEX_TAG_RE = /^[0-9a-fA-F]{8}$/;
|
|
71
75
|
var KEYWORD_RE = /^[A-Z][A-Za-z0-9]*$/;
|
|
76
|
+
function validatePositiveInt(value, path) {
|
|
77
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < 1) {
|
|
78
|
+
throw new Error(
|
|
79
|
+
`${path}: must be a positive integer; got ${JSON.stringify(value)}`
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
72
83
|
function validateTagKey(key, path) {
|
|
73
84
|
if (!HEX_TAG_RE.test(key) && !KEYWORD_RE.test(key)) {
|
|
74
85
|
throw new Error(
|
|
@@ -88,13 +99,7 @@ function validateEntry(entry, path) {
|
|
|
88
99
|
}
|
|
89
100
|
const type = e.type;
|
|
90
101
|
const caps = TYPE_CAPABILITIES[type];
|
|
91
|
-
if ("count" in e) {
|
|
92
|
-
if (typeof e.count !== "number" || !Number.isInteger(e.count) || e.count < 1) {
|
|
93
|
-
throw new Error(
|
|
94
|
-
`${path}.count: must be a positive integer; got ${JSON.stringify(e.count)}`
|
|
95
|
-
);
|
|
96
|
-
}
|
|
97
|
-
}
|
|
102
|
+
if ("count" in e) validatePositiveInt(e.count, `${path}.count`);
|
|
98
103
|
if ("transferSyntax" in e) {
|
|
99
104
|
if (!caps.transferSyntax) {
|
|
100
105
|
throw new Error(
|
|
@@ -111,14 +116,7 @@ function validateEntry(entry, path) {
|
|
|
111
116
|
throw new Error(`${path}.tags: not supported for type "${type}"`);
|
|
112
117
|
if (!caps.violations && "violations" in e)
|
|
113
118
|
throw new Error(`${path}.violations: not supported for type "${type}"`);
|
|
114
|
-
if ("tags" in e) {
|
|
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`);
|
|
120
|
-
}
|
|
121
|
-
}
|
|
119
|
+
if ("tags" in e) validateTags(e.tags, `${path}.tags`);
|
|
122
120
|
if ("violations" in e) {
|
|
123
121
|
if (!Array.isArray(e.violations)) {
|
|
124
122
|
throw new Error(`${path}.violations: must be an array`);
|
|
@@ -132,17 +130,9 @@ function validateEntry(entry, path) {
|
|
|
132
130
|
}
|
|
133
131
|
}
|
|
134
132
|
if (type === "large-ct") {
|
|
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
|
-
}
|
|
133
|
+
validatePositiveInt(e.rows, `${path}.rows`);
|
|
134
|
+
validatePositiveInt(e.columns, `${path}.columns`);
|
|
135
|
+
if ("frames" in e) validatePositiveInt(e.frames, `${path}.frames`);
|
|
146
136
|
const rows = e.rows;
|
|
147
137
|
const columns = e.columns;
|
|
148
138
|
const frames = typeof e.frames === "number" ? e.frames : 1;
|
|
@@ -154,20 +144,72 @@ function validateEntry(entry, path) {
|
|
|
154
144
|
}
|
|
155
145
|
return e;
|
|
156
146
|
}
|
|
147
|
+
function validateTags(tags, path) {
|
|
148
|
+
if (typeof tags !== "object" || tags === null || Array.isArray(tags)) {
|
|
149
|
+
throw new Error(`${path}: must be an object`);
|
|
150
|
+
}
|
|
151
|
+
for (const key of Object.keys(tags)) {
|
|
152
|
+
validateTagKey(key, path);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
function validateSeries(series, path) {
|
|
156
|
+
if (typeof series !== "object" || series === null || Array.isArray(series)) {
|
|
157
|
+
throw new Error(`${path}: must be an object`);
|
|
158
|
+
}
|
|
159
|
+
const s = series;
|
|
160
|
+
if (!Array.isArray(s.entries) || s.entries.length === 0) {
|
|
161
|
+
throw new Error(`${path}.entries: must be a non-empty array`);
|
|
162
|
+
}
|
|
163
|
+
for (let i = 0; i < s.entries.length; i++) {
|
|
164
|
+
const e = validateEntry(s.entries[i], `${path}.entries[${i}]`);
|
|
165
|
+
if (!TYPE_CAPABILITIES[e.type].tags) {
|
|
166
|
+
throw new Error(
|
|
167
|
+
`${path}.entries[${i}].type: "${e.type}" cannot be used inside a series \u2014 only types that support tags can be grouped`
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if ("tags" in s) validateTags(s.tags, `${path}.tags`);
|
|
172
|
+
return s;
|
|
173
|
+
}
|
|
174
|
+
function validateStudy(study, path) {
|
|
175
|
+
if (typeof study !== "object" || study === null || Array.isArray(study)) {
|
|
176
|
+
throw new Error(`${path}: must be an object`);
|
|
177
|
+
}
|
|
178
|
+
const st = study;
|
|
179
|
+
if (!Array.isArray(st.series) || st.series.length === 0) {
|
|
180
|
+
throw new Error(`${path}.series: must be a non-empty array`);
|
|
181
|
+
}
|
|
182
|
+
for (let i = 0; i < st.series.length; i++) {
|
|
183
|
+
validateSeries(st.series[i], `${path}.series[${i}]`);
|
|
184
|
+
}
|
|
185
|
+
if ("count" in st) validatePositiveInt(st.count, `${path}.count`);
|
|
186
|
+
if ("tags" in st) validateTags(st.tags, `${path}.tags`);
|
|
187
|
+
return st;
|
|
188
|
+
}
|
|
157
189
|
function validateDatasetSpec(raw) {
|
|
158
190
|
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
159
191
|
throw new Error("DatasetSpec: must be a JSON object");
|
|
160
192
|
}
|
|
161
193
|
const r = raw;
|
|
162
|
-
if (!Array.isArray(r.entries)) {
|
|
194
|
+
if ("entries" in r && !Array.isArray(r.entries)) {
|
|
163
195
|
throw new Error("DatasetSpec.entries: must be an array");
|
|
164
196
|
}
|
|
165
|
-
if (r.
|
|
166
|
-
throw new Error("DatasetSpec.
|
|
197
|
+
if ("studies" in r && !Array.isArray(r.studies)) {
|
|
198
|
+
throw new Error("DatasetSpec.studies: must be an array");
|
|
199
|
+
}
|
|
200
|
+
const rawEntries = r.entries ?? [];
|
|
201
|
+
const rawStudies = r.studies ?? [];
|
|
202
|
+
if (rawEntries.length === 0 && rawStudies.length === 0) {
|
|
203
|
+
throw new Error(
|
|
204
|
+
'DatasetSpec: must contain at least one of "entries" or "studies" (non-empty)'
|
|
205
|
+
);
|
|
167
206
|
}
|
|
168
|
-
const entries =
|
|
207
|
+
const entries = rawEntries.map(
|
|
169
208
|
(entry, i) => validateEntry(entry, `entries[${i}]`)
|
|
170
209
|
);
|
|
210
|
+
const studies = rawStudies.map(
|
|
211
|
+
(study, i) => validateStudy(study, `studies[${i}]`)
|
|
212
|
+
);
|
|
171
213
|
if ("seed" in r) {
|
|
172
214
|
if (typeof r.seed !== "number" || !Number.isFinite(r.seed)) {
|
|
173
215
|
throw new Error(
|
|
@@ -175,9 +217,18 @@ function validateDatasetSpec(raw) {
|
|
|
175
217
|
);
|
|
176
218
|
}
|
|
177
219
|
}
|
|
220
|
+
if ("layout" in r) {
|
|
221
|
+
if (typeof r.layout !== "string" || !Object.hasOwn(VALID_LAYOUTS, r.layout)) {
|
|
222
|
+
throw new Error(
|
|
223
|
+
`DatasetSpec.layout: must be one of ${Object.keys(VALID_LAYOUTS).join(", ")}; got ${JSON.stringify(r.layout)}`
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
178
227
|
return {
|
|
179
|
-
entries,
|
|
180
|
-
...
|
|
228
|
+
...entries.length > 0 ? { entries } : {},
|
|
229
|
+
...studies.length > 0 ? { studies } : {},
|
|
230
|
+
...r.seed !== void 0 ? { seed: r.seed } : {},
|
|
231
|
+
...r.layout !== void 0 ? { layout: r.layout } : {}
|
|
181
232
|
};
|
|
182
233
|
}
|
|
183
234
|
|
|
@@ -346,6 +397,33 @@ function randomUid(role) {
|
|
|
346
397
|
function seededUid(next, role) {
|
|
347
398
|
return formatUid(next(), next(), role);
|
|
348
399
|
}
|
|
400
|
+
var GROUP_STREAM_OFFSET = 2654435769;
|
|
401
|
+
function seededStream(seed, offset, a, b) {
|
|
402
|
+
return lcg(seed * 999983 + offset + a * 1000003 + b * 7919 >>> 0);
|
|
403
|
+
}
|
|
404
|
+
function makeGroupUidGenerator(seed) {
|
|
405
|
+
if (seed === void 0) {
|
|
406
|
+
return {
|
|
407
|
+
study: () => randomUid("study"),
|
|
408
|
+
series: () => randomUid("series")
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
return {
|
|
412
|
+
study: (studyIndex) => seededUid(
|
|
413
|
+
seededStream(seed, GROUP_STREAM_OFFSET, studyIndex + 1, 0),
|
|
414
|
+
"study"
|
|
415
|
+
),
|
|
416
|
+
series: (studyIndex, seriesIndex) => seededUid(
|
|
417
|
+
seededStream(
|
|
418
|
+
seed,
|
|
419
|
+
GROUP_STREAM_OFFSET,
|
|
420
|
+
studyIndex + 1,
|
|
421
|
+
seriesIndex + 1
|
|
422
|
+
),
|
|
423
|
+
"series"
|
|
424
|
+
)
|
|
425
|
+
};
|
|
426
|
+
}
|
|
349
427
|
function makeUidGenerator(seed) {
|
|
350
428
|
if (seed === void 0) {
|
|
351
429
|
return () => ({
|
|
@@ -355,7 +433,7 @@ function makeUidGenerator(seed) {
|
|
|
355
433
|
});
|
|
356
434
|
}
|
|
357
435
|
return (fileIndex) => {
|
|
358
|
-
const next =
|
|
436
|
+
const next = seededStream(seed, 0, fileIndex, 0);
|
|
359
437
|
return {
|
|
360
438
|
study: seededUid(next, "study"),
|
|
361
439
|
series: seededUid(next, "series"),
|
|
@@ -468,28 +546,50 @@ function filename(type, index, padWidth) {
|
|
|
468
546
|
const base = `${type}-${padded}`;
|
|
469
547
|
return NO_EXTENSION_TYPES.has(type) ? base : `${base}.dcm`;
|
|
470
548
|
}
|
|
549
|
+
function entryCount(entries) {
|
|
550
|
+
return entries.reduce((sum, e) => sum + (e.count ?? 1), 0);
|
|
551
|
+
}
|
|
552
|
+
function studyFileCount(studies) {
|
|
553
|
+
return studies.reduce(
|
|
554
|
+
(sum, study) => sum + (study.count ?? 1) * study.series.reduce((s, series) => s + entryCount(series.entries), 0),
|
|
555
|
+
0
|
|
556
|
+
);
|
|
557
|
+
}
|
|
471
558
|
async function generateFile(spec, options) {
|
|
472
559
|
const index = options?.index ?? 0;
|
|
473
560
|
const padWidth = options?.padWidth ?? 3;
|
|
474
561
|
const uidGen = makeUidGenerator(options?.seed);
|
|
475
|
-
const uid = uidGen(index);
|
|
562
|
+
const uid = { ...uidGen(index), ...options?.uid };
|
|
476
563
|
let buffer = buildBufferForSpec(spec, uid);
|
|
477
564
|
const violations = "violations" in spec ? spec.violations : void 0;
|
|
478
565
|
if (violations?.length) {
|
|
479
566
|
buffer = applyViolations(buffer, violations);
|
|
480
567
|
}
|
|
568
|
+
const name = filename(spec.type, index, padWidth);
|
|
481
569
|
return {
|
|
482
|
-
filename:
|
|
570
|
+
filename: name,
|
|
571
|
+
relativePath: name,
|
|
483
572
|
buffer,
|
|
484
573
|
type: spec.type,
|
|
485
574
|
index
|
|
486
575
|
};
|
|
487
576
|
}
|
|
577
|
+
function hierarchicalName(studyOrdinal, seriesIndex, instanceNumber) {
|
|
578
|
+
const pad3 = (n) => String(n).padStart(3, "0");
|
|
579
|
+
const name = `${String(instanceNumber).padStart(5, "0")}.dcm`;
|
|
580
|
+
return {
|
|
581
|
+
filename: name,
|
|
582
|
+
relativePath: `study-${pad3(studyOrdinal + 1)}/series-${pad3(seriesIndex + 1)}/${name}`
|
|
583
|
+
};
|
|
584
|
+
}
|
|
488
585
|
async function* generateCollectionFromSpec(spec) {
|
|
489
|
-
const
|
|
586
|
+
const flatEntries = spec.entries ?? [];
|
|
587
|
+
const studies = spec.studies ?? [];
|
|
588
|
+
const totalFiles = entryCount(flatEntries) + studyFileCount(studies);
|
|
490
589
|
const padWidth = String(totalFiles).length;
|
|
590
|
+
const hierarchical = spec.layout === "hierarchical";
|
|
491
591
|
let globalIndex = 0;
|
|
492
|
-
for (const entry of
|
|
592
|
+
for (const entry of flatEntries) {
|
|
493
593
|
const { count = 1, ...fileSpec } = entry;
|
|
494
594
|
for (let i = 0; i < count; i++) {
|
|
495
595
|
yield generateFile(fileSpec, {
|
|
@@ -500,13 +600,62 @@ async function* generateCollectionFromSpec(spec) {
|
|
|
500
600
|
globalIndex++;
|
|
501
601
|
}
|
|
502
602
|
}
|
|
603
|
+
const groupUids = makeGroupUidGenerator(spec.seed);
|
|
604
|
+
let studyOrdinal = 0;
|
|
605
|
+
for (const study of studies) {
|
|
606
|
+
const copies = study.count ?? 1;
|
|
607
|
+
for (let copy = 0; copy < copies; copy++) {
|
|
608
|
+
const studyUid = groupUids.study(studyOrdinal);
|
|
609
|
+
for (const [seriesIndex, series] of study.series.entries()) {
|
|
610
|
+
const seriesUid = groupUids.series(studyOrdinal, seriesIndex);
|
|
611
|
+
let instanceNumber = 0;
|
|
612
|
+
for (const entry of series.entries) {
|
|
613
|
+
const { count = 1, ...fileSpec } = entry;
|
|
614
|
+
for (let i = 0; i < count; i++) {
|
|
615
|
+
instanceNumber++;
|
|
616
|
+
const tags = {
|
|
617
|
+
InstanceNumber: instanceNumber,
|
|
618
|
+
...study.tags,
|
|
619
|
+
...series.tags,
|
|
620
|
+
...fileSpec.tags
|
|
621
|
+
};
|
|
622
|
+
const file = await generateFile(
|
|
623
|
+
{ ...fileSpec, tags },
|
|
624
|
+
{
|
|
625
|
+
index: globalIndex,
|
|
626
|
+
seed: spec.seed,
|
|
627
|
+
padWidth,
|
|
628
|
+
uid: { study: studyUid, series: seriesUid }
|
|
629
|
+
}
|
|
630
|
+
);
|
|
631
|
+
yield hierarchical ? {
|
|
632
|
+
...file,
|
|
633
|
+
...hierarchicalName(
|
|
634
|
+
studyOrdinal,
|
|
635
|
+
seriesIndex,
|
|
636
|
+
instanceNumber
|
|
637
|
+
)
|
|
638
|
+
} : file;
|
|
639
|
+
globalIndex++;
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
studyOrdinal++;
|
|
644
|
+
}
|
|
645
|
+
}
|
|
503
646
|
}
|
|
504
647
|
async function writeCollectionFromSpec(spec, outDir) {
|
|
505
648
|
const root = resolve2(outDir);
|
|
506
649
|
mkdirSync2(root, { recursive: true });
|
|
507
650
|
const manifest = [];
|
|
651
|
+
const createdDirs = /* @__PURE__ */ new Set([root]);
|
|
508
652
|
for await (const file of generateCollectionFromSpec(spec)) {
|
|
509
|
-
const filePath = resolve2(root, file.
|
|
653
|
+
const filePath = resolve2(root, file.relativePath);
|
|
654
|
+
const dir = dirname(filePath);
|
|
655
|
+
if (!createdDirs.has(dir)) {
|
|
656
|
+
mkdirSync2(dir, { recursive: true });
|
|
657
|
+
createdDirs.add(dir);
|
|
658
|
+
}
|
|
510
659
|
await writeFile(filePath, file.buffer);
|
|
511
660
|
manifest.push({ path: filePath, type: file.type, index: file.index });
|
|
512
661
|
}
|
|
@@ -515,10 +664,10 @@ async function writeCollectionFromSpec(spec, outDir) {
|
|
|
515
664
|
|
|
516
665
|
// src/public-fixtures/catalog.ts
|
|
517
666
|
import { readFileSync } from "node:fs";
|
|
518
|
-
import { dirname, join } from "node:path";
|
|
667
|
+
import { dirname as dirname2, join } from "node:path";
|
|
519
668
|
import { fileURLToPath } from "node:url";
|
|
520
669
|
var bundledCatalogPath = join(
|
|
521
|
-
|
|
670
|
+
dirname2(fileURLToPath(import.meta.url)),
|
|
522
671
|
"../../data/public-cases.json"
|
|
523
672
|
);
|
|
524
673
|
function defaultPublicCasesPath() {
|
|
@@ -8,6 +8,10 @@ var TYPE_CAPABILITIES = {
|
|
|
8
8
|
"non-dicom": { tags: false, violations: false, transferSyntax: false },
|
|
9
9
|
dicomdir: { tags: false, violations: false, transferSyntax: false }
|
|
10
10
|
};
|
|
11
|
+
var VALID_LAYOUTS = {
|
|
12
|
+
flat: true,
|
|
13
|
+
hierarchical: true
|
|
14
|
+
};
|
|
11
15
|
var VALID_TRANSFER_SYNTAXES = {
|
|
12
16
|
"explicit-vr-little-endian": true,
|
|
13
17
|
"implicit-vr-little-endian": true
|
|
@@ -23,6 +27,13 @@ var VALID_VIOLATIONS = {
|
|
|
23
27
|
};
|
|
24
28
|
var HEX_TAG_RE = /^[0-9a-fA-F]{8}$/;
|
|
25
29
|
var KEYWORD_RE = /^[A-Z][A-Za-z0-9]*$/;
|
|
30
|
+
function validatePositiveInt(value, path) {
|
|
31
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < 1) {
|
|
32
|
+
throw new Error(
|
|
33
|
+
`${path}: must be a positive integer; got ${JSON.stringify(value)}`
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
26
37
|
function validateTagKey(key, path) {
|
|
27
38
|
if (!HEX_TAG_RE.test(key) && !KEYWORD_RE.test(key)) {
|
|
28
39
|
throw new Error(
|
|
@@ -42,13 +53,7 @@ function validateEntry(entry, path) {
|
|
|
42
53
|
}
|
|
43
54
|
const type = e.type;
|
|
44
55
|
const caps = TYPE_CAPABILITIES[type];
|
|
45
|
-
if ("count" in e) {
|
|
46
|
-
if (typeof e.count !== "number" || !Number.isInteger(e.count) || e.count < 1) {
|
|
47
|
-
throw new Error(
|
|
48
|
-
`${path}.count: must be a positive integer; got ${JSON.stringify(e.count)}`
|
|
49
|
-
);
|
|
50
|
-
}
|
|
51
|
-
}
|
|
56
|
+
if ("count" in e) validatePositiveInt(e.count, `${path}.count`);
|
|
52
57
|
if ("transferSyntax" in e) {
|
|
53
58
|
if (!caps.transferSyntax) {
|
|
54
59
|
throw new Error(
|
|
@@ -65,14 +70,7 @@ function validateEntry(entry, path) {
|
|
|
65
70
|
throw new Error(`${path}.tags: not supported for type "${type}"`);
|
|
66
71
|
if (!caps.violations && "violations" in e)
|
|
67
72
|
throw new Error(`${path}.violations: not supported for type "${type}"`);
|
|
68
|
-
if ("tags" in e) {
|
|
69
|
-
if (typeof e.tags !== "object" || e.tags === null || Array.isArray(e.tags)) {
|
|
70
|
-
throw new Error(`${path}.tags: must be an object`);
|
|
71
|
-
}
|
|
72
|
-
for (const key of Object.keys(e.tags)) {
|
|
73
|
-
validateTagKey(key, `${path}.tags`);
|
|
74
|
-
}
|
|
75
|
-
}
|
|
73
|
+
if ("tags" in e) validateTags(e.tags, `${path}.tags`);
|
|
76
74
|
if ("violations" in e) {
|
|
77
75
|
if (!Array.isArray(e.violations)) {
|
|
78
76
|
throw new Error(`${path}.violations: must be an array`);
|
|
@@ -86,17 +84,9 @@ function validateEntry(entry, path) {
|
|
|
86
84
|
}
|
|
87
85
|
}
|
|
88
86
|
if (type === "large-ct") {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
}
|
|
92
|
-
if (typeof e.columns !== "number" || !Number.isInteger(e.columns) || e.columns < 1) {
|
|
93
|
-
throw new Error(`${path}.columns: must be a positive integer`);
|
|
94
|
-
}
|
|
95
|
-
if ("frames" in e) {
|
|
96
|
-
if (typeof e.frames !== "number" || !Number.isInteger(e.frames) || e.frames < 1) {
|
|
97
|
-
throw new Error(`${path}.frames: must be a positive integer`);
|
|
98
|
-
}
|
|
99
|
-
}
|
|
87
|
+
validatePositiveInt(e.rows, `${path}.rows`);
|
|
88
|
+
validatePositiveInt(e.columns, `${path}.columns`);
|
|
89
|
+
if ("frames" in e) validatePositiveInt(e.frames, `${path}.frames`);
|
|
100
90
|
const rows = e.rows;
|
|
101
91
|
const columns = e.columns;
|
|
102
92
|
const frames = typeof e.frames === "number" ? e.frames : 1;
|
|
@@ -108,20 +98,72 @@ function validateEntry(entry, path) {
|
|
|
108
98
|
}
|
|
109
99
|
return e;
|
|
110
100
|
}
|
|
101
|
+
function validateTags(tags, path) {
|
|
102
|
+
if (typeof tags !== "object" || tags === null || Array.isArray(tags)) {
|
|
103
|
+
throw new Error(`${path}: must be an object`);
|
|
104
|
+
}
|
|
105
|
+
for (const key of Object.keys(tags)) {
|
|
106
|
+
validateTagKey(key, path);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
function validateSeries(series, path) {
|
|
110
|
+
if (typeof series !== "object" || series === null || Array.isArray(series)) {
|
|
111
|
+
throw new Error(`${path}: must be an object`);
|
|
112
|
+
}
|
|
113
|
+
const s = series;
|
|
114
|
+
if (!Array.isArray(s.entries) || s.entries.length === 0) {
|
|
115
|
+
throw new Error(`${path}.entries: must be a non-empty array`);
|
|
116
|
+
}
|
|
117
|
+
for (let i = 0; i < s.entries.length; i++) {
|
|
118
|
+
const e = validateEntry(s.entries[i], `${path}.entries[${i}]`);
|
|
119
|
+
if (!TYPE_CAPABILITIES[e.type].tags) {
|
|
120
|
+
throw new Error(
|
|
121
|
+
`${path}.entries[${i}].type: "${e.type}" cannot be used inside a series \u2014 only types that support tags can be grouped`
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if ("tags" in s) validateTags(s.tags, `${path}.tags`);
|
|
126
|
+
return s;
|
|
127
|
+
}
|
|
128
|
+
function validateStudy(study, path) {
|
|
129
|
+
if (typeof study !== "object" || study === null || Array.isArray(study)) {
|
|
130
|
+
throw new Error(`${path}: must be an object`);
|
|
131
|
+
}
|
|
132
|
+
const st = study;
|
|
133
|
+
if (!Array.isArray(st.series) || st.series.length === 0) {
|
|
134
|
+
throw new Error(`${path}.series: must be a non-empty array`);
|
|
135
|
+
}
|
|
136
|
+
for (let i = 0; i < st.series.length; i++) {
|
|
137
|
+
validateSeries(st.series[i], `${path}.series[${i}]`);
|
|
138
|
+
}
|
|
139
|
+
if ("count" in st) validatePositiveInt(st.count, `${path}.count`);
|
|
140
|
+
if ("tags" in st) validateTags(st.tags, `${path}.tags`);
|
|
141
|
+
return st;
|
|
142
|
+
}
|
|
111
143
|
function validateDatasetSpec(raw) {
|
|
112
144
|
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
113
145
|
throw new Error("DatasetSpec: must be a JSON object");
|
|
114
146
|
}
|
|
115
147
|
const r = raw;
|
|
116
|
-
if (!Array.isArray(r.entries)) {
|
|
148
|
+
if ("entries" in r && !Array.isArray(r.entries)) {
|
|
117
149
|
throw new Error("DatasetSpec.entries: must be an array");
|
|
118
150
|
}
|
|
119
|
-
if (r.
|
|
120
|
-
throw new Error("DatasetSpec.
|
|
151
|
+
if ("studies" in r && !Array.isArray(r.studies)) {
|
|
152
|
+
throw new Error("DatasetSpec.studies: must be an array");
|
|
153
|
+
}
|
|
154
|
+
const rawEntries = r.entries ?? [];
|
|
155
|
+
const rawStudies = r.studies ?? [];
|
|
156
|
+
if (rawEntries.length === 0 && rawStudies.length === 0) {
|
|
157
|
+
throw new Error(
|
|
158
|
+
'DatasetSpec: must contain at least one of "entries" or "studies" (non-empty)'
|
|
159
|
+
);
|
|
121
160
|
}
|
|
122
|
-
const entries =
|
|
161
|
+
const entries = rawEntries.map(
|
|
123
162
|
(entry, i) => validateEntry(entry, `entries[${i}]`)
|
|
124
163
|
);
|
|
164
|
+
const studies = rawStudies.map(
|
|
165
|
+
(study, i) => validateStudy(study, `studies[${i}]`)
|
|
166
|
+
);
|
|
125
167
|
if ("seed" in r) {
|
|
126
168
|
if (typeof r.seed !== "number" || !Number.isFinite(r.seed)) {
|
|
127
169
|
throw new Error(
|
|
@@ -129,9 +171,18 @@ function validateDatasetSpec(raw) {
|
|
|
129
171
|
);
|
|
130
172
|
}
|
|
131
173
|
}
|
|
174
|
+
if ("layout" in r) {
|
|
175
|
+
if (typeof r.layout !== "string" || !Object.hasOwn(VALID_LAYOUTS, r.layout)) {
|
|
176
|
+
throw new Error(
|
|
177
|
+
`DatasetSpec.layout: must be one of ${Object.keys(VALID_LAYOUTS).join(", ")}; got ${JSON.stringify(r.layout)}`
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
132
181
|
return {
|
|
133
|
-
entries,
|
|
134
|
-
...
|
|
182
|
+
...entries.length > 0 ? { entries } : {},
|
|
183
|
+
...studies.length > 0 ? { studies } : {},
|
|
184
|
+
...r.seed !== void 0 ? { seed: r.seed } : {},
|
|
185
|
+
...r.layout !== void 0 ? { layout: r.layout } : {}
|
|
135
186
|
};
|
|
136
187
|
}
|
|
137
188
|
export {
|
|
@@ -18,6 +18,33 @@ function randomUid(role) {
|
|
|
18
18
|
function seededUid(next, role) {
|
|
19
19
|
return formatUid(next(), next(), role);
|
|
20
20
|
}
|
|
21
|
+
var GROUP_STREAM_OFFSET = 2654435769;
|
|
22
|
+
function seededStream(seed, offset, a, b) {
|
|
23
|
+
return lcg(seed * 999983 + offset + a * 1000003 + b * 7919 >>> 0);
|
|
24
|
+
}
|
|
25
|
+
function makeGroupUidGenerator(seed) {
|
|
26
|
+
if (seed === void 0) {
|
|
27
|
+
return {
|
|
28
|
+
study: () => randomUid("study"),
|
|
29
|
+
series: () => randomUid("series")
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
study: (studyIndex) => seededUid(
|
|
34
|
+
seededStream(seed, GROUP_STREAM_OFFSET, studyIndex + 1, 0),
|
|
35
|
+
"study"
|
|
36
|
+
),
|
|
37
|
+
series: (studyIndex, seriesIndex) => seededUid(
|
|
38
|
+
seededStream(
|
|
39
|
+
seed,
|
|
40
|
+
GROUP_STREAM_OFFSET,
|
|
41
|
+
studyIndex + 1,
|
|
42
|
+
seriesIndex + 1
|
|
43
|
+
),
|
|
44
|
+
"series"
|
|
45
|
+
)
|
|
46
|
+
};
|
|
47
|
+
}
|
|
21
48
|
function makeUidGenerator(seed) {
|
|
22
49
|
if (seed === void 0) {
|
|
23
50
|
return () => ({
|
|
@@ -27,7 +54,7 @@ function makeUidGenerator(seed) {
|
|
|
27
54
|
});
|
|
28
55
|
}
|
|
29
56
|
return (fileIndex) => {
|
|
30
|
-
const next =
|
|
57
|
+
const next = seededStream(seed, 0, fileIndex, 0);
|
|
31
58
|
return {
|
|
32
59
|
study: seededUid(next, "study"),
|
|
33
60
|
series: seededUid(next, "series"),
|
|
@@ -36,5 +63,6 @@ function makeUidGenerator(seed) {
|
|
|
36
63
|
};
|
|
37
64
|
}
|
|
38
65
|
export {
|
|
66
|
+
makeGroupUidGenerator,
|
|
39
67
|
makeUidGenerator
|
|
40
68
|
};
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { DatasetSpec, FileSpec } from '../schema/types.js';
|
|
2
|
+
import type { UidSet } from '../syntheticFixtures/uid.js';
|
|
2
3
|
export type GeneratedFile = {
|
|
3
4
|
filename: string;
|
|
5
|
+
relativePath: string;
|
|
4
6
|
buffer: Buffer;
|
|
5
7
|
type: FileSpec['type'];
|
|
6
8
|
index: number;
|
|
@@ -14,6 +16,7 @@ export declare function generateFile(spec: FileSpec, options?: {
|
|
|
14
16
|
index?: number;
|
|
15
17
|
seed?: number;
|
|
16
18
|
padWidth?: number;
|
|
19
|
+
uid?: Partial<UidSet>;
|
|
17
20
|
}): Promise<GeneratedFile>;
|
|
18
21
|
export declare function generateCollectionFromSpec(spec: DatasetSpec): AsyncGenerator<GeneratedFile>;
|
|
19
22
|
export declare function writeCollectionFromSpec(spec: DatasetSpec, outDir: string): Promise<CollectionManifest>;
|
package/dist/types/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { type CollectionManifest, type GeneratedFile, generateCollectionFromSpec, generateFile, writeCollectionFromSpec, } from './collection/writer.js';
|
|
2
2
|
export { defaultPublicCasesPath, loadCaseById, loadCasesFromJson, loadDefaultCases, type PublicCaseRecord, type PublicCaseSource, } from './public-fixtures/catalog.js';
|
|
3
3
|
export { caseCachePath, fetchPublicCaseToCache, verifySha256, } from './public-fixtures/fetch.js';
|
|
4
|
-
export type { DatasetSpec, DicomdirSpec, DicomTagOverrides, FakeSignatureSpec, FileSpec, InvalidUidCtSpec, LargeCtSpec, NonDicomSpec, TransferSyntax, ValidCtSpec, VendorCtSpec, ViolationClass, } from './schema/types.js';
|
|
4
|
+
export type { DatasetLayout, DatasetSpec, DicomdirSpec, DicomTagOverrides, FakeSignatureSpec, FileSpec, ImageSpec, InvalidUidCtSpec, LargeCtSpec, NonDicomSpec, SeriesEntrySpec, SeriesSpec, StudySpec, TransferSyntax, ValidCtSpec, VendorCtSpec, ViolationClass, } from './schema/types.js';
|
|
5
5
|
export { validateDatasetSpec } from './schema/validate.js';
|
|
@@ -37,12 +37,28 @@ export type NonDicomSpec = {
|
|
|
37
37
|
export type DicomdirSpec = {
|
|
38
38
|
type: 'dicomdir';
|
|
39
39
|
};
|
|
40
|
-
export type
|
|
40
|
+
export type ImageSpec = ValidCtSpec | InvalidUidCtSpec | VendorCtSpec | LargeCtSpec;
|
|
41
|
+
export type FileSpec = ImageSpec | FakeSignatureSpec | NonDicomSpec | DicomdirSpec;
|
|
41
42
|
export type EntrySpec = FileSpec & {
|
|
42
43
|
count?: number;
|
|
43
44
|
};
|
|
45
|
+
export type SeriesEntrySpec = ImageSpec & {
|
|
46
|
+
count?: number;
|
|
47
|
+
};
|
|
48
|
+
export type SeriesSpec = {
|
|
49
|
+
entries: SeriesEntrySpec[];
|
|
50
|
+
tags?: DicomTagOverrides;
|
|
51
|
+
};
|
|
52
|
+
export type StudySpec = {
|
|
53
|
+
series: SeriesSpec[];
|
|
54
|
+
tags?: DicomTagOverrides;
|
|
55
|
+
count?: number;
|
|
56
|
+
};
|
|
57
|
+
export type DatasetLayout = 'flat' | 'hierarchical';
|
|
44
58
|
export type DatasetSpec = {
|
|
45
|
-
entries
|
|
59
|
+
entries?: EntrySpec[];
|
|
60
|
+
studies?: StudySpec[];
|
|
46
61
|
seed?: number;
|
|
62
|
+
layout?: DatasetLayout;
|
|
47
63
|
};
|
|
48
64
|
export {};
|
|
@@ -3,5 +3,10 @@ export type UidSet = {
|
|
|
3
3
|
series: string;
|
|
4
4
|
sop: string;
|
|
5
5
|
};
|
|
6
|
+
export type GroupUidGenerator = {
|
|
7
|
+
study: (studyIndex: number) => string;
|
|
8
|
+
series: (studyIndex: number, seriesIndex: number) => string;
|
|
9
|
+
};
|
|
10
|
+
export declare function makeGroupUidGenerator(seed?: number): GroupUidGenerator;
|
|
6
11
|
export type UidGenerator = (fileIndex: number) => UidSet;
|
|
7
12
|
export declare function makeUidGenerator(seed?: number): UidGenerator;
|