dicom-synth 1.3.0 → 1.5.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 +81 -16
- package/bin/dicom-synth-generate.mjs +100 -40
- package/dist/esm/collection/writer.js +37 -54
- package/dist/esm/index.js +248 -85
- package/dist/esm/schema/parametric.js +327 -0
- package/dist/esm/schema/validate.js +136 -32
- package/dist/esm/syntheticFixtures/generator.js +37 -54
- package/dist/esm/syntheticFixtures/uid.js +4 -1
- package/dist/types/index.d.ts +3 -2
- package/dist/types/schema/parametric.d.ts +2 -0
- package/dist/types/schema/types.d.ts +26 -7
- package/dist/types/schema/validate.d.ts +2 -1
- package/dist/types/syntheticFixtures/uid.d.ts +2 -0
- package/package.json +2 -2
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
// src/schema/parametric.ts
|
|
2
|
+
import { randomBytes as randomBytes2 } from "node:crypto";
|
|
3
|
+
|
|
4
|
+
// src/syntheticFixtures/uid.ts
|
|
5
|
+
import { randomBytes } from "node:crypto";
|
|
6
|
+
function lcg(seed) {
|
|
7
|
+
let s = seed >>> 0;
|
|
8
|
+
return () => {
|
|
9
|
+
s = Math.imul(1664525, s) + 1013904223 >>> 0;
|
|
10
|
+
return s;
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
var PARAMETRIC_STREAM_OFFSET = 2246822507;
|
|
14
|
+
function seededStream(seed, offset, a, b) {
|
|
15
|
+
return lcg(seed * 999983 + offset + a * 1000003 + b * 7919 >>> 0);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// src/schema/validate.ts
|
|
19
|
+
var TYPE_CAPABILITIES = {
|
|
20
|
+
"valid-image": { image: true },
|
|
21
|
+
"invalid-uid-image": { image: true },
|
|
22
|
+
"vendor-warnings-image": { image: true },
|
|
23
|
+
"fake-signature": { image: false },
|
|
24
|
+
"non-dicom": { image: false },
|
|
25
|
+
dicomdir: { image: false }
|
|
26
|
+
};
|
|
27
|
+
var VALID_MODALITIES = {
|
|
28
|
+
CT: true,
|
|
29
|
+
PT: true,
|
|
30
|
+
MR: true,
|
|
31
|
+
CR: true
|
|
32
|
+
};
|
|
33
|
+
var VALID_LAYOUTS = {
|
|
34
|
+
flat: true,
|
|
35
|
+
hierarchical: true
|
|
36
|
+
};
|
|
37
|
+
var VALID_TRANSFER_SYNTAXES = {
|
|
38
|
+
"explicit-vr-little-endian": true,
|
|
39
|
+
"implicit-vr-little-endian": true
|
|
40
|
+
};
|
|
41
|
+
var MAX_PIXEL_BYTES = 512 * 1024 * 1024;
|
|
42
|
+
var VALID_VIOLATIONS = {
|
|
43
|
+
"uid-too-long": true,
|
|
44
|
+
"non-conformant-uid": true,
|
|
45
|
+
"missing-meta-header": true,
|
|
46
|
+
"malformed-sq-delimiter": true,
|
|
47
|
+
"vr-max-length-exceeded": true,
|
|
48
|
+
"missing-type1-tag": true
|
|
49
|
+
};
|
|
50
|
+
var HEX_TAG_RE = /^[0-9a-fA-F]{8}$/;
|
|
51
|
+
var KEYWORD_RE = /^[A-Z][A-Za-z0-9]*$/;
|
|
52
|
+
function validatePositiveInt(value, path) {
|
|
53
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < 1) {
|
|
54
|
+
throw new Error(
|
|
55
|
+
`${path}: must be a positive integer; got ${JSON.stringify(value)}`
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
function validateSeed(value, path) {
|
|
60
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
61
|
+
throw new Error(
|
|
62
|
+
`${path}: must be a finite number; got ${JSON.stringify(value)}`
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
function validateSizeKbLimit(kb, path) {
|
|
67
|
+
if (kb * 1024 > MAX_PIXEL_BYTES) {
|
|
68
|
+
throw new Error(`${path}: exceeds the 512 MB limit`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
function validateEnum(value, valid, path) {
|
|
72
|
+
if (typeof value !== "string" || !Object.hasOwn(valid, value)) {
|
|
73
|
+
throw new Error(
|
|
74
|
+
`${path}: must be one of ${Object.keys(valid).join(", ")}; got ${JSON.stringify(value)}`
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
function validateTagKey(key, path) {
|
|
79
|
+
if (!HEX_TAG_RE.test(key) && !KEYWORD_RE.test(key)) {
|
|
80
|
+
throw new Error(
|
|
81
|
+
`${path}: invalid tag key "${key}" \u2014 must be a DICOM keyword name (e.g. "Modality") or an 8-hex-char tag (e.g. "00080060")`
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
function validateEntry(entry, path) {
|
|
86
|
+
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
|
|
87
|
+
throw new Error(`${path}: must be an object`);
|
|
88
|
+
}
|
|
89
|
+
const e = entry;
|
|
90
|
+
validateEnum(e.type, TYPE_CAPABILITIES, `${path}.type`);
|
|
91
|
+
const type = e.type;
|
|
92
|
+
const caps = TYPE_CAPABILITIES[type];
|
|
93
|
+
if ("count" in e) validatePositiveInt(e.count, `${path}.count`);
|
|
94
|
+
for (const field of [
|
|
95
|
+
"modality",
|
|
96
|
+
"rows",
|
|
97
|
+
"columns",
|
|
98
|
+
"frames",
|
|
99
|
+
"targetSizeKb",
|
|
100
|
+
"tags",
|
|
101
|
+
"violations",
|
|
102
|
+
"transferSyntax"
|
|
103
|
+
]) {
|
|
104
|
+
if (!caps.image && field in e)
|
|
105
|
+
throw new Error(`${path}.${field}: not supported for type "${type}"`);
|
|
106
|
+
}
|
|
107
|
+
if ("transferSyntax" in e) {
|
|
108
|
+
validateEnum(
|
|
109
|
+
e.transferSyntax,
|
|
110
|
+
VALID_TRANSFER_SYNTAXES,
|
|
111
|
+
`${path}.transferSyntax`
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
if ("modality" in e) {
|
|
115
|
+
validateEnum(e.modality, VALID_MODALITIES, `${path}.modality`);
|
|
116
|
+
}
|
|
117
|
+
if ("tags" in e) validateTags(e.tags, `${path}.tags`);
|
|
118
|
+
if ("violations" in e) {
|
|
119
|
+
if (!Array.isArray(e.violations)) {
|
|
120
|
+
throw new Error(`${path}.violations: must be an array`);
|
|
121
|
+
}
|
|
122
|
+
for (const [i, v] of e.violations.entries()) {
|
|
123
|
+
validateEnum(v, VALID_VIOLATIONS, `${path}.violations[${i}]`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
const hasDims = "rows" in e || "columns" in e || "frames" in e;
|
|
127
|
+
if ("targetSizeKb" in e) {
|
|
128
|
+
if (hasDims) {
|
|
129
|
+
throw new Error(
|
|
130
|
+
`${path}: targetSizeKb cannot be combined with rows/columns/frames \u2014 use one sizing mechanism`
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
validatePositiveInt(e.targetSizeKb, `${path}.targetSizeKb`);
|
|
134
|
+
validateSizeKbLimit(e.targetSizeKb, `${path}.targetSizeKb`);
|
|
135
|
+
} else if (hasDims) {
|
|
136
|
+
if (!("rows" in e) || !("columns" in e)) {
|
|
137
|
+
throw new Error(`${path}: rows and columns must be provided together`);
|
|
138
|
+
}
|
|
139
|
+
validatePositiveInt(e.rows, `${path}.rows`);
|
|
140
|
+
validatePositiveInt(e.columns, `${path}.columns`);
|
|
141
|
+
if ("frames" in e) validatePositiveInt(e.frames, `${path}.frames`);
|
|
142
|
+
const rows = e.rows;
|
|
143
|
+
const columns = e.columns;
|
|
144
|
+
const frames = typeof e.frames === "number" ? e.frames : 1;
|
|
145
|
+
if (rows * columns * frames * 2 > MAX_PIXEL_BYTES) {
|
|
146
|
+
throw new Error(
|
|
147
|
+
`${path}: pixel data (${rows}\xD7${columns}\xD7${frames}\xD72 bytes) exceeds the 512 MB limit`
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return e;
|
|
152
|
+
}
|
|
153
|
+
function validateTags(tags, path) {
|
|
154
|
+
if (typeof tags !== "object" || tags === null || Array.isArray(tags)) {
|
|
155
|
+
throw new Error(`${path}: must be an object`);
|
|
156
|
+
}
|
|
157
|
+
for (const key of Object.keys(tags)) {
|
|
158
|
+
validateTagKey(key, path);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
function validateRange(value, path) {
|
|
162
|
+
if (typeof value === "number") {
|
|
163
|
+
validatePositiveInt(value, path);
|
|
164
|
+
return value;
|
|
165
|
+
}
|
|
166
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
167
|
+
throw new Error(
|
|
168
|
+
`${path}: must be a positive integer or { min, max }; got ${JSON.stringify(value)}`
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
const r = value;
|
|
172
|
+
validatePositiveInt(r.min, `${path}.min`);
|
|
173
|
+
validatePositiveInt(r.max, `${path}.max`);
|
|
174
|
+
if (r.min > r.max) {
|
|
175
|
+
throw new Error(`${path}: min (${r.min}) must be \u2264 max (${r.max})`);
|
|
176
|
+
}
|
|
177
|
+
return value;
|
|
178
|
+
}
|
|
179
|
+
function rangeMax(range) {
|
|
180
|
+
return typeof range === "number" ? range : range.max;
|
|
181
|
+
}
|
|
182
|
+
function validateParametricStudies(studies, path) {
|
|
183
|
+
if (typeof studies !== "object" || studies === null || Array.isArray(studies)) {
|
|
184
|
+
throw new Error(`${path}: must be an object`);
|
|
185
|
+
}
|
|
186
|
+
const s = studies;
|
|
187
|
+
for (const field of ["count", "seriesPerStudy", "filesPerSeries"]) {
|
|
188
|
+
if (!(field in s)) throw new Error(`${path}.${field}: is required`);
|
|
189
|
+
validateRange(s[field], `${path}.${field}`);
|
|
190
|
+
}
|
|
191
|
+
if ("fileSizeKb" in s) {
|
|
192
|
+
validateRange(s.fileSizeKb, `${path}.fileSizeKb`);
|
|
193
|
+
validateSizeKbLimit(rangeMax(s.fileSizeKb), `${path}.fileSizeKb`);
|
|
194
|
+
}
|
|
195
|
+
if ("modalities" in s) {
|
|
196
|
+
if (!Array.isArray(s.modalities) || s.modalities.length === 0) {
|
|
197
|
+
throw new Error(`${path}.modalities: must be a non-empty array`);
|
|
198
|
+
}
|
|
199
|
+
for (const [i, m] of s.modalities.entries()) {
|
|
200
|
+
validateEnum(m, VALID_MODALITIES, `${path}.modalities[${i}]`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
if ("tags" in s) validateTags(s.tags, `${path}.tags`);
|
|
204
|
+
return s;
|
|
205
|
+
}
|
|
206
|
+
function validateEdgeCase(edgeCase, path) {
|
|
207
|
+
const e = validateEntry(edgeCase, path);
|
|
208
|
+
if ("frequency" in e) {
|
|
209
|
+
if ("count" in e) {
|
|
210
|
+
throw new Error(
|
|
211
|
+
`${path}: frequency cannot be combined with count \u2014 frequency draws the count at resolution time`
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
const f = e.frequency;
|
|
215
|
+
if (typeof f !== "number" || !(f >= 0 && f <= 1)) {
|
|
216
|
+
throw new Error(
|
|
217
|
+
`${path}.frequency: must be a number between 0 and 1; got ${JSON.stringify(f)}`
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return e;
|
|
222
|
+
}
|
|
223
|
+
function validateParametricSpec(raw) {
|
|
224
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
225
|
+
throw new Error("ParametricSpec: must be a JSON object");
|
|
226
|
+
}
|
|
227
|
+
const r = raw;
|
|
228
|
+
if (!("studies" in r)) {
|
|
229
|
+
throw new Error("ParametricSpec.studies: is required");
|
|
230
|
+
}
|
|
231
|
+
const studies = validateParametricStudies(r.studies, "studies");
|
|
232
|
+
const edgeCases = [];
|
|
233
|
+
if ("edgeCases" in r) {
|
|
234
|
+
if (!Array.isArray(r.edgeCases)) {
|
|
235
|
+
throw new Error("ParametricSpec.edgeCases: must be an array");
|
|
236
|
+
}
|
|
237
|
+
for (const [i, e] of r.edgeCases.entries()) {
|
|
238
|
+
edgeCases.push(validateEdgeCase(e, `edgeCases[${i}]`));
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
if ("seed" in r) validateSeed(r.seed, "ParametricSpec.seed");
|
|
242
|
+
if ("layout" in r) {
|
|
243
|
+
validateEnum(r.layout, VALID_LAYOUTS, "ParametricSpec.layout");
|
|
244
|
+
}
|
|
245
|
+
return {
|
|
246
|
+
studies,
|
|
247
|
+
...edgeCases.length > 0 ? { edgeCases } : {},
|
|
248
|
+
...r.seed !== void 0 ? { seed: r.seed } : {},
|
|
249
|
+
...r.layout !== void 0 ? { layout: r.layout } : {}
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// src/schema/parametric.ts
|
|
254
|
+
function sample(next, range) {
|
|
255
|
+
if (typeof range === "number") return range;
|
|
256
|
+
return range.min + next() % (range.max - range.min + 1);
|
|
257
|
+
}
|
|
258
|
+
function pick(next, items) {
|
|
259
|
+
return items[next() % items.length];
|
|
260
|
+
}
|
|
261
|
+
function fraction(next) {
|
|
262
|
+
return next() / 4294967296;
|
|
263
|
+
}
|
|
264
|
+
function resolveParametricSpec(spec) {
|
|
265
|
+
const validated = validateParametricSpec(spec);
|
|
266
|
+
const seed = validated.seed ?? randomBytes2(4).readUInt32BE(0);
|
|
267
|
+
const next = seededStream(seed, PARAMETRIC_STREAM_OFFSET, 0, 0);
|
|
268
|
+
const params = validated.studies;
|
|
269
|
+
const studies = [];
|
|
270
|
+
let totalFiles = 0;
|
|
271
|
+
const studyCount = sample(next, params.count);
|
|
272
|
+
for (let st = 0; st < studyCount; st++) {
|
|
273
|
+
const seriesCount = sample(next, params.seriesPerStudy);
|
|
274
|
+
const series = [];
|
|
275
|
+
for (let se = 0; se < seriesCount; se++) {
|
|
276
|
+
const fileCount = sample(next, params.filesPerSeries);
|
|
277
|
+
totalFiles += fileCount;
|
|
278
|
+
const modality = params.modalities ? pick(next, params.modalities) : void 0;
|
|
279
|
+
const base = {
|
|
280
|
+
type: "valid-image",
|
|
281
|
+
...modality !== void 0 ? { modality } : {}
|
|
282
|
+
};
|
|
283
|
+
const entries2 = [];
|
|
284
|
+
if (params.fileSizeKb !== void 0 && typeof params.fileSizeKb !== "number") {
|
|
285
|
+
for (let f = 0; f < fileCount; f++) {
|
|
286
|
+
entries2.push({
|
|
287
|
+
...base,
|
|
288
|
+
targetSizeKb: sample(next, params.fileSizeKb)
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
} else {
|
|
292
|
+
entries2.push({
|
|
293
|
+
...base,
|
|
294
|
+
...params.fileSizeKb !== void 0 ? { targetSizeKb: params.fileSizeKb } : {},
|
|
295
|
+
...fileCount > 1 ? { count: fileCount } : {}
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
series.push({ entries: entries2 });
|
|
299
|
+
}
|
|
300
|
+
studies.push({
|
|
301
|
+
series,
|
|
302
|
+
...params.tags !== void 0 ? { tags: params.tags } : {}
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
const entries = [];
|
|
306
|
+
for (const edgeCase of validated.edgeCases ?? []) {
|
|
307
|
+
const { frequency, ...entry } = edgeCase;
|
|
308
|
+
if (frequency === void 0) {
|
|
309
|
+
entries.push(entry);
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
let drawn = 0;
|
|
313
|
+
for (let i = 0; i < totalFiles; i++) {
|
|
314
|
+
if (fraction(next) < frequency) drawn++;
|
|
315
|
+
}
|
|
316
|
+
if (drawn > 0) entries.push({ ...entry, count: drawn });
|
|
317
|
+
}
|
|
318
|
+
return {
|
|
319
|
+
...entries.length > 0 ? { entries } : {},
|
|
320
|
+
studies,
|
|
321
|
+
seed,
|
|
322
|
+
...validated.layout !== void 0 ? { layout: validated.layout } : {}
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
export {
|
|
326
|
+
resolveParametricSpec
|
|
327
|
+
};
|
|
@@ -3,7 +3,6 @@ var TYPE_CAPABILITIES = {
|
|
|
3
3
|
"valid-image": { image: true },
|
|
4
4
|
"invalid-uid-image": { image: true },
|
|
5
5
|
"vendor-warnings-image": { image: true },
|
|
6
|
-
"large-image": { image: true },
|
|
7
6
|
"fake-signature": { image: false },
|
|
8
7
|
"non-dicom": { image: false },
|
|
9
8
|
dicomdir: { image: false }
|
|
@@ -40,6 +39,25 @@ function validatePositiveInt(value, path) {
|
|
|
40
39
|
);
|
|
41
40
|
}
|
|
42
41
|
}
|
|
42
|
+
function validateSeed(value, path) {
|
|
43
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
44
|
+
throw new Error(
|
|
45
|
+
`${path}: must be a finite number; got ${JSON.stringify(value)}`
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function validateSizeKbLimit(kb, path) {
|
|
50
|
+
if (kb * 1024 > MAX_PIXEL_BYTES) {
|
|
51
|
+
throw new Error(`${path}: exceeds the 512 MB limit`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function validateEnum(value, valid, path) {
|
|
55
|
+
if (typeof value !== "string" || !Object.hasOwn(valid, value)) {
|
|
56
|
+
throw new Error(
|
|
57
|
+
`${path}: must be one of ${Object.keys(valid).join(", ")}; got ${JSON.stringify(value)}`
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
43
61
|
function validateTagKey(key, path) {
|
|
44
62
|
if (!HEX_TAG_RE.test(key) && !KEYWORD_RE.test(key)) {
|
|
45
63
|
throw new Error(
|
|
@@ -52,16 +70,16 @@ function validateEntry(entry, path) {
|
|
|
52
70
|
throw new Error(`${path}: must be an object`);
|
|
53
71
|
}
|
|
54
72
|
const e = entry;
|
|
55
|
-
|
|
56
|
-
throw new Error(
|
|
57
|
-
`${path}.type: must be one of ${Object.keys(TYPE_CAPABILITIES).join(", ")}; got ${JSON.stringify(e.type)}`
|
|
58
|
-
);
|
|
59
|
-
}
|
|
73
|
+
validateEnum(e.type, TYPE_CAPABILITIES, `${path}.type`);
|
|
60
74
|
const type = e.type;
|
|
61
75
|
const caps = TYPE_CAPABILITIES[type];
|
|
62
76
|
if ("count" in e) validatePositiveInt(e.count, `${path}.count`);
|
|
63
77
|
for (const field of [
|
|
64
78
|
"modality",
|
|
79
|
+
"rows",
|
|
80
|
+
"columns",
|
|
81
|
+
"frames",
|
|
82
|
+
"targetSizeKb",
|
|
65
83
|
"tags",
|
|
66
84
|
"violations",
|
|
67
85
|
"transferSyntax"
|
|
@@ -70,18 +88,14 @@ function validateEntry(entry, path) {
|
|
|
70
88
|
throw new Error(`${path}.${field}: not supported for type "${type}"`);
|
|
71
89
|
}
|
|
72
90
|
if ("transferSyntax" in e) {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
91
|
+
validateEnum(
|
|
92
|
+
e.transferSyntax,
|
|
93
|
+
VALID_TRANSFER_SYNTAXES,
|
|
94
|
+
`${path}.transferSyntax`
|
|
95
|
+
);
|
|
78
96
|
}
|
|
79
97
|
if ("modality" in e) {
|
|
80
|
-
|
|
81
|
-
throw new Error(
|
|
82
|
-
`${path}.modality: must be one of ${Object.keys(VALID_MODALITIES).join(", ")}; got ${JSON.stringify(e.modality)}`
|
|
83
|
-
);
|
|
84
|
-
}
|
|
98
|
+
validateEnum(e.modality, VALID_MODALITIES, `${path}.modality`);
|
|
85
99
|
}
|
|
86
100
|
if ("tags" in e) validateTags(e.tags, `${path}.tags`);
|
|
87
101
|
if ("violations" in e) {
|
|
@@ -89,14 +103,22 @@ function validateEntry(entry, path) {
|
|
|
89
103
|
throw new Error(`${path}.violations: must be an array`);
|
|
90
104
|
}
|
|
91
105
|
for (const [i, v] of e.violations.entries()) {
|
|
92
|
-
|
|
93
|
-
throw new Error(
|
|
94
|
-
`${path}.violations[${i}]: must be one of ${Object.keys(VALID_VIOLATIONS).join(", ")}; got ${JSON.stringify(v)}`
|
|
95
|
-
);
|
|
96
|
-
}
|
|
106
|
+
validateEnum(v, VALID_VIOLATIONS, `${path}.violations[${i}]`);
|
|
97
107
|
}
|
|
98
108
|
}
|
|
99
|
-
|
|
109
|
+
const hasDims = "rows" in e || "columns" in e || "frames" in e;
|
|
110
|
+
if ("targetSizeKb" in e) {
|
|
111
|
+
if (hasDims) {
|
|
112
|
+
throw new Error(
|
|
113
|
+
`${path}: targetSizeKb cannot be combined with rows/columns/frames \u2014 use one sizing mechanism`
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
validatePositiveInt(e.targetSizeKb, `${path}.targetSizeKb`);
|
|
117
|
+
validateSizeKbLimit(e.targetSizeKb, `${path}.targetSizeKb`);
|
|
118
|
+
} else if (hasDims) {
|
|
119
|
+
if (!("rows" in e) || !("columns" in e)) {
|
|
120
|
+
throw new Error(`${path}: rows and columns must be provided together`);
|
|
121
|
+
}
|
|
100
122
|
validatePositiveInt(e.rows, `${path}.rows`);
|
|
101
123
|
validatePositiveInt(e.columns, `${path}.columns`);
|
|
102
124
|
if ("frames" in e) validatePositiveInt(e.frames, `${path}.frames`);
|
|
@@ -177,28 +199,110 @@ function validateDatasetSpec(raw) {
|
|
|
177
199
|
const studies = rawStudies.map(
|
|
178
200
|
(study, i) => validateStudy(study, `studies[${i}]`)
|
|
179
201
|
);
|
|
180
|
-
if ("seed" in r)
|
|
181
|
-
|
|
202
|
+
if ("seed" in r) validateSeed(r.seed, "DatasetSpec.seed");
|
|
203
|
+
if ("layout" in r) {
|
|
204
|
+
validateEnum(r.layout, VALID_LAYOUTS, "DatasetSpec.layout");
|
|
205
|
+
}
|
|
206
|
+
return {
|
|
207
|
+
...entries.length > 0 ? { entries } : {},
|
|
208
|
+
...studies.length > 0 ? { studies } : {},
|
|
209
|
+
...r.seed !== void 0 ? { seed: r.seed } : {},
|
|
210
|
+
...r.layout !== void 0 ? { layout: r.layout } : {}
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
function validateRange(value, path) {
|
|
214
|
+
if (typeof value === "number") {
|
|
215
|
+
validatePositiveInt(value, path);
|
|
216
|
+
return value;
|
|
217
|
+
}
|
|
218
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
219
|
+
throw new Error(
|
|
220
|
+
`${path}: must be a positive integer or { min, max }; got ${JSON.stringify(value)}`
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
const r = value;
|
|
224
|
+
validatePositiveInt(r.min, `${path}.min`);
|
|
225
|
+
validatePositiveInt(r.max, `${path}.max`);
|
|
226
|
+
if (r.min > r.max) {
|
|
227
|
+
throw new Error(`${path}: min (${r.min}) must be \u2264 max (${r.max})`);
|
|
228
|
+
}
|
|
229
|
+
return value;
|
|
230
|
+
}
|
|
231
|
+
function rangeMax(range) {
|
|
232
|
+
return typeof range === "number" ? range : range.max;
|
|
233
|
+
}
|
|
234
|
+
function validateParametricStudies(studies, path) {
|
|
235
|
+
if (typeof studies !== "object" || studies === null || Array.isArray(studies)) {
|
|
236
|
+
throw new Error(`${path}: must be an object`);
|
|
237
|
+
}
|
|
238
|
+
const s = studies;
|
|
239
|
+
for (const field of ["count", "seriesPerStudy", "filesPerSeries"]) {
|
|
240
|
+
if (!(field in s)) throw new Error(`${path}.${field}: is required`);
|
|
241
|
+
validateRange(s[field], `${path}.${field}`);
|
|
242
|
+
}
|
|
243
|
+
if ("fileSizeKb" in s) {
|
|
244
|
+
validateRange(s.fileSizeKb, `${path}.fileSizeKb`);
|
|
245
|
+
validateSizeKbLimit(rangeMax(s.fileSizeKb), `${path}.fileSizeKb`);
|
|
246
|
+
}
|
|
247
|
+
if ("modalities" in s) {
|
|
248
|
+
if (!Array.isArray(s.modalities) || s.modalities.length === 0) {
|
|
249
|
+
throw new Error(`${path}.modalities: must be a non-empty array`);
|
|
250
|
+
}
|
|
251
|
+
for (const [i, m] of s.modalities.entries()) {
|
|
252
|
+
validateEnum(m, VALID_MODALITIES, `${path}.modalities[${i}]`);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
if ("tags" in s) validateTags(s.tags, `${path}.tags`);
|
|
256
|
+
return s;
|
|
257
|
+
}
|
|
258
|
+
function validateEdgeCase(edgeCase, path) {
|
|
259
|
+
const e = validateEntry(edgeCase, path);
|
|
260
|
+
if ("frequency" in e) {
|
|
261
|
+
if ("count" in e) {
|
|
182
262
|
throw new Error(
|
|
183
|
-
|
|
263
|
+
`${path}: frequency cannot be combined with count \u2014 frequency draws the count at resolution time`
|
|
184
264
|
);
|
|
185
265
|
}
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
if (typeof r.layout !== "string" || !Object.hasOwn(VALID_LAYOUTS, r.layout)) {
|
|
266
|
+
const f = e.frequency;
|
|
267
|
+
if (typeof f !== "number" || !(f >= 0 && f <= 1)) {
|
|
189
268
|
throw new Error(
|
|
190
|
-
|
|
269
|
+
`${path}.frequency: must be a number between 0 and 1; got ${JSON.stringify(f)}`
|
|
191
270
|
);
|
|
192
271
|
}
|
|
193
272
|
}
|
|
273
|
+
return e;
|
|
274
|
+
}
|
|
275
|
+
function validateParametricSpec(raw) {
|
|
276
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
277
|
+
throw new Error("ParametricSpec: must be a JSON object");
|
|
278
|
+
}
|
|
279
|
+
const r = raw;
|
|
280
|
+
if (!("studies" in r)) {
|
|
281
|
+
throw new Error("ParametricSpec.studies: is required");
|
|
282
|
+
}
|
|
283
|
+
const studies = validateParametricStudies(r.studies, "studies");
|
|
284
|
+
const edgeCases = [];
|
|
285
|
+
if ("edgeCases" in r) {
|
|
286
|
+
if (!Array.isArray(r.edgeCases)) {
|
|
287
|
+
throw new Error("ParametricSpec.edgeCases: must be an array");
|
|
288
|
+
}
|
|
289
|
+
for (const [i, e] of r.edgeCases.entries()) {
|
|
290
|
+
edgeCases.push(validateEdgeCase(e, `edgeCases[${i}]`));
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
if ("seed" in r) validateSeed(r.seed, "ParametricSpec.seed");
|
|
294
|
+
if ("layout" in r) {
|
|
295
|
+
validateEnum(r.layout, VALID_LAYOUTS, "ParametricSpec.layout");
|
|
296
|
+
}
|
|
194
297
|
return {
|
|
195
|
-
|
|
196
|
-
...
|
|
298
|
+
studies,
|
|
299
|
+
...edgeCases.length > 0 ? { edgeCases } : {},
|
|
197
300
|
...r.seed !== void 0 ? { seed: r.seed } : {},
|
|
198
301
|
...r.layout !== void 0 ? { layout: r.layout } : {}
|
|
199
302
|
};
|
|
200
303
|
}
|
|
201
304
|
export {
|
|
202
305
|
HEX_TAG_RE,
|
|
203
|
-
validateDatasetSpec
|
|
306
|
+
validateDatasetSpec,
|
|
307
|
+
validateParametricSpec
|
|
204
308
|
};
|
|
@@ -153,36 +153,46 @@ function serializeDict(meta, dataset) {
|
|
|
153
153
|
);
|
|
154
154
|
return Buffer.from(dicomDict.write({ allowInvalidVRLength: true }));
|
|
155
155
|
}
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
156
|
+
var MAX_DIMENSION = 65535;
|
|
157
|
+
function applySizing(dataset, meta, spec) {
|
|
158
|
+
if (spec.targetSizeKb !== void 0) {
|
|
159
|
+
const clampDim = (n) => Math.min(MAX_DIMENSION, Math.max(1, Math.round(n)));
|
|
160
|
+
const minimalPixelBytes = dataset.PixelData.byteLength;
|
|
161
|
+
const overhead = serializeDict(meta, dataset).length - minimalPixelBytes;
|
|
162
|
+
const cells = Math.max(
|
|
163
|
+
1,
|
|
164
|
+
Math.round((spec.targetSizeKb * 1024 - overhead) / 2)
|
|
165
|
+
);
|
|
166
|
+
const rows = clampDim(Math.sqrt(cells));
|
|
167
|
+
const columns = clampDim(cells / rows);
|
|
168
|
+
dataset.Rows = rows;
|
|
169
|
+
dataset.Columns = columns;
|
|
170
|
+
dataset.PixelData = new ArrayBuffer(rows * columns * 2);
|
|
171
|
+
} else if (spec.rows !== void 0 && spec.columns !== void 0) {
|
|
172
|
+
dataset.Rows = spec.rows;
|
|
173
|
+
dataset.Columns = spec.columns;
|
|
174
|
+
if (spec.frames !== void 0) dataset.NumberOfFrames = spec.frames;
|
|
175
|
+
dataset.PixelData = new ArrayBuffer(
|
|
176
|
+
spec.rows * spec.columns * (spec.frames ?? 1) * 2
|
|
177
|
+
);
|
|
178
|
+
}
|
|
160
179
|
}
|
|
161
|
-
function
|
|
162
|
-
const
|
|
180
|
+
function buildImageBuffer(spec, uid) {
|
|
181
|
+
const modality = spec.modality ?? "CT";
|
|
182
|
+
const effectiveUid = spec.type === "invalid-uid-image" ? {
|
|
163
183
|
study: `2.25.invalid.study.${uid.study.slice(-6)}`,
|
|
164
184
|
series: `2.25.invalid.series.${uid.series.slice(-6)}`,
|
|
165
185
|
sop: `2.25.invalid.sop.${uid.sop.slice(-6)}`
|
|
166
|
-
};
|
|
167
|
-
const dataset = buildBaseImageDataset(
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
dataset
|
|
175
|
-
|
|
176
|
-
return serializeDict(buildMeta(uid, modality, transferSyntax), dataset);
|
|
177
|
-
}
|
|
178
|
-
function buildLargeImageBuffer(uid, modality, rows, columns, frames, tags, transferSyntax) {
|
|
179
|
-
const dataset = buildBaseImageDataset(uid, modality);
|
|
180
|
-
dataset.Rows = rows;
|
|
181
|
-
dataset.Columns = columns;
|
|
182
|
-
dataset.NumberOfFrames = frames;
|
|
183
|
-
dataset.PixelData = new Uint8Array(rows * columns * frames * 2).buffer;
|
|
184
|
-
applyTagOverrides(dataset, tags);
|
|
185
|
-
return serializeDict(buildMeta(uid, modality, transferSyntax), dataset);
|
|
186
|
+
} : uid;
|
|
187
|
+
const dataset = buildBaseImageDataset(effectiveUid, modality);
|
|
188
|
+
if (spec.type === "vendor-warnings-image") {
|
|
189
|
+
dataset.Laterality = "";
|
|
190
|
+
dataset.PatientWeight = "0";
|
|
191
|
+
}
|
|
192
|
+
applyTagOverrides(dataset, spec.tags);
|
|
193
|
+
const meta = buildMeta(effectiveUid, modality, spec.transferSyntax);
|
|
194
|
+
applySizing(dataset, meta, spec);
|
|
195
|
+
return serializeDict(meta, dataset);
|
|
186
196
|
}
|
|
187
197
|
function buildFakeSignatureBuffer() {
|
|
188
198
|
const buf = Buffer.alloc(200, 0);
|
|
@@ -195,36 +205,9 @@ function buildNonDicomBuffer(content = "not dicom") {
|
|
|
195
205
|
function buildBufferForSpec(spec, uid) {
|
|
196
206
|
switch (spec.type) {
|
|
197
207
|
case "valid-image":
|
|
198
|
-
return buildValidImageBuffer(
|
|
199
|
-
uid,
|
|
200
|
-
spec.modality ?? "CT",
|
|
201
|
-
spec.tags,
|
|
202
|
-
spec.transferSyntax
|
|
203
|
-
);
|
|
204
208
|
case "invalid-uid-image":
|
|
205
|
-
return buildInvalidUidImageBuffer(
|
|
206
|
-
uid,
|
|
207
|
-
spec.modality ?? "CT",
|
|
208
|
-
spec.tags,
|
|
209
|
-
spec.transferSyntax
|
|
210
|
-
);
|
|
211
209
|
case "vendor-warnings-image":
|
|
212
|
-
return
|
|
213
|
-
uid,
|
|
214
|
-
spec.modality ?? "CT",
|
|
215
|
-
spec.tags,
|
|
216
|
-
spec.transferSyntax
|
|
217
|
-
);
|
|
218
|
-
case "large-image":
|
|
219
|
-
return buildLargeImageBuffer(
|
|
220
|
-
uid,
|
|
221
|
-
spec.modality ?? "CT",
|
|
222
|
-
spec.rows,
|
|
223
|
-
spec.columns,
|
|
224
|
-
spec.frames ?? 1,
|
|
225
|
-
spec.tags,
|
|
226
|
-
spec.transferSyntax
|
|
227
|
-
);
|
|
210
|
+
return buildImageBuffer(spec, uid);
|
|
228
211
|
case "fake-signature":
|
|
229
212
|
return buildFakeSignatureBuffer();
|
|
230
213
|
case "non-dicom":
|
|
@@ -19,6 +19,7 @@ function seededUid(next, role) {
|
|
|
19
19
|
return formatUid(next(), next(), role);
|
|
20
20
|
}
|
|
21
21
|
var GROUP_STREAM_OFFSET = 2654435769;
|
|
22
|
+
var PARAMETRIC_STREAM_OFFSET = 2246822507;
|
|
22
23
|
function seededStream(seed, offset, a, b) {
|
|
23
24
|
return lcg(seed * 999983 + offset + a * 1000003 + b * 7919 >>> 0);
|
|
24
25
|
}
|
|
@@ -63,6 +64,8 @@ function makeUidGenerator(seed) {
|
|
|
63
64
|
};
|
|
64
65
|
}
|
|
65
66
|
export {
|
|
67
|
+
PARAMETRIC_STREAM_OFFSET,
|
|
66
68
|
makeGroupUidGenerator,
|
|
67
|
-
makeUidGenerator
|
|
69
|
+
makeUidGenerator,
|
|
70
|
+
seededStream
|
|
68
71
|
};
|
package/dist/types/index.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
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
|
|
5
|
-
export {
|
|
4
|
+
export { resolveParametricSpec } from './schema/parametric.js';
|
|
5
|
+
export type { DatasetLayout, DatasetSpec, DicomdirSpec, DicomTagOverrides, FakeSignatureSpec, FileSpec, ImageSpec, InvalidUidImageSpec, Modality, NonDicomSpec, ParametricEdgeCase, ParametricSpec, ParametricStudies, Range, SeriesEntrySpec, SeriesSpec, StudySpec, TransferSyntax, ValidImageSpec, VendorWarningsImageSpec, ViolationClass, } from './schema/types.js';
|
|
6
|
+
export { validateDatasetSpec, validateParametricSpec, } from './schema/validate.js';
|