dicom-synth 1.4.0 → 1.6.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 +104 -4
- package/bin/dicom-synth-describe.mjs +73 -0
- package/bin/dicom-synth-generate.mjs +100 -40
- package/dist/esm/describe/describe.js +378 -0
- package/dist/esm/index.js +348 -42
- package/dist/esm/schema/parametric.js +327 -0
- package/dist/esm/schema/validate.js +120 -33
- package/dist/esm/syntheticFixtures/uid.js +4 -1
- package/dist/types/describe/describe.d.ts +13 -0
- package/dist/types/index.d.ts +4 -2
- package/dist/types/schema/parametric.d.ts +2 -0
- package/dist/types/schema/types.d.ts +21 -0
- package/dist/types/schema/validate.d.ts +2 -1
- package/dist/types/syntheticFixtures/uid.d.ts +2 -0
- package/package.json +4 -3
|
@@ -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
|
+
};
|
|
@@ -39,6 +39,25 @@ function validatePositiveInt(value, path) {
|
|
|
39
39
|
);
|
|
40
40
|
}
|
|
41
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
|
+
}
|
|
42
61
|
function validateTagKey(key, path) {
|
|
43
62
|
if (!HEX_TAG_RE.test(key) && !KEYWORD_RE.test(key)) {
|
|
44
63
|
throw new Error(
|
|
@@ -51,11 +70,7 @@ function validateEntry(entry, path) {
|
|
|
51
70
|
throw new Error(`${path}: must be an object`);
|
|
52
71
|
}
|
|
53
72
|
const e = entry;
|
|
54
|
-
|
|
55
|
-
throw new Error(
|
|
56
|
-
`${path}.type: must be one of ${Object.keys(TYPE_CAPABILITIES).join(", ")}; got ${JSON.stringify(e.type)}`
|
|
57
|
-
);
|
|
58
|
-
}
|
|
73
|
+
validateEnum(e.type, TYPE_CAPABILITIES, `${path}.type`);
|
|
59
74
|
const type = e.type;
|
|
60
75
|
const caps = TYPE_CAPABILITIES[type];
|
|
61
76
|
if ("count" in e) validatePositiveInt(e.count, `${path}.count`);
|
|
@@ -73,18 +88,14 @@ function validateEntry(entry, path) {
|
|
|
73
88
|
throw new Error(`${path}.${field}: not supported for type "${type}"`);
|
|
74
89
|
}
|
|
75
90
|
if ("transferSyntax" in e) {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
91
|
+
validateEnum(
|
|
92
|
+
e.transferSyntax,
|
|
93
|
+
VALID_TRANSFER_SYNTAXES,
|
|
94
|
+
`${path}.transferSyntax`
|
|
95
|
+
);
|
|
81
96
|
}
|
|
82
97
|
if ("modality" in e) {
|
|
83
|
-
|
|
84
|
-
throw new Error(
|
|
85
|
-
`${path}.modality: must be one of ${Object.keys(VALID_MODALITIES).join(", ")}; got ${JSON.stringify(e.modality)}`
|
|
86
|
-
);
|
|
87
|
-
}
|
|
98
|
+
validateEnum(e.modality, VALID_MODALITIES, `${path}.modality`);
|
|
88
99
|
}
|
|
89
100
|
if ("tags" in e) validateTags(e.tags, `${path}.tags`);
|
|
90
101
|
if ("violations" in e) {
|
|
@@ -92,11 +103,7 @@ function validateEntry(entry, path) {
|
|
|
92
103
|
throw new Error(`${path}.violations: must be an array`);
|
|
93
104
|
}
|
|
94
105
|
for (const [i, v] of e.violations.entries()) {
|
|
95
|
-
|
|
96
|
-
throw new Error(
|
|
97
|
-
`${path}.violations[${i}]: must be one of ${Object.keys(VALID_VIOLATIONS).join(", ")}; got ${JSON.stringify(v)}`
|
|
98
|
-
);
|
|
99
|
-
}
|
|
106
|
+
validateEnum(v, VALID_VIOLATIONS, `${path}.violations[${i}]`);
|
|
100
107
|
}
|
|
101
108
|
}
|
|
102
109
|
const hasDims = "rows" in e || "columns" in e || "frames" in e;
|
|
@@ -107,9 +114,7 @@ function validateEntry(entry, path) {
|
|
|
107
114
|
);
|
|
108
115
|
}
|
|
109
116
|
validatePositiveInt(e.targetSizeKb, `${path}.targetSizeKb`);
|
|
110
|
-
|
|
111
|
-
throw new Error(`${path}.targetSizeKb: exceeds the 512 MB limit`);
|
|
112
|
-
}
|
|
117
|
+
validateSizeKbLimit(e.targetSizeKb, `${path}.targetSizeKb`);
|
|
113
118
|
} else if (hasDims) {
|
|
114
119
|
if (!("rows" in e) || !("columns" in e)) {
|
|
115
120
|
throw new Error(`${path}: rows and columns must be provided together`);
|
|
@@ -194,28 +199,110 @@ function validateDatasetSpec(raw) {
|
|
|
194
199
|
const studies = rawStudies.map(
|
|
195
200
|
(study, i) => validateStudy(study, `studies[${i}]`)
|
|
196
201
|
);
|
|
197
|
-
if ("seed" in r)
|
|
198
|
-
|
|
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) {
|
|
199
262
|
throw new Error(
|
|
200
|
-
|
|
263
|
+
`${path}: frequency cannot be combined with count \u2014 frequency draws the count at resolution time`
|
|
201
264
|
);
|
|
202
265
|
}
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
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)) {
|
|
206
268
|
throw new Error(
|
|
207
|
-
|
|
269
|
+
`${path}.frequency: must be a number between 0 and 1; got ${JSON.stringify(f)}`
|
|
208
270
|
);
|
|
209
271
|
}
|
|
210
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
|
+
}
|
|
211
297
|
return {
|
|
212
|
-
|
|
213
|
-
...
|
|
298
|
+
studies,
|
|
299
|
+
...edgeCases.length > 0 ? { edgeCases } : {},
|
|
214
300
|
...r.seed !== void 0 ? { seed: r.seed } : {},
|
|
215
301
|
...r.layout !== void 0 ? { layout: r.layout } : {}
|
|
216
302
|
};
|
|
217
303
|
}
|
|
218
304
|
export {
|
|
219
305
|
HEX_TAG_RE,
|
|
220
|
-
validateDatasetSpec
|
|
306
|
+
validateDatasetSpec,
|
|
307
|
+
validateParametricSpec
|
|
221
308
|
};
|
|
@@ -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
|
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { DatasetSpec } from '../schema/types.js';
|
|
2
|
+
export type DescribeOptions = {
|
|
3
|
+
keepTags?: string[];
|
|
4
|
+
};
|
|
5
|
+
export type DescribeResult = {
|
|
6
|
+
spec: DatasetSpec;
|
|
7
|
+
stats: {
|
|
8
|
+
dicomFiles: number;
|
|
9
|
+
skipped: number;
|
|
10
|
+
unknownModality: number;
|
|
11
|
+
};
|
|
12
|
+
};
|
|
13
|
+
export declare function describeDirectory(dir: string, options?: DescribeOptions): DescribeResult;
|
package/dist/types/index.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export { type CollectionManifest, type GeneratedFile, generateCollectionFromSpec, generateFile, writeCollectionFromSpec, } from './collection/writer.js';
|
|
2
|
+
export { type DescribeOptions, type DescribeResult, describeDirectory, } from './describe/describe.js';
|
|
2
3
|
export { defaultPublicCasesPath, loadCaseById, loadCasesFromJson, loadDefaultCases, type PublicCaseRecord, type PublicCaseSource, } from './public-fixtures/catalog.js';
|
|
3
4
|
export { caseCachePath, fetchPublicCaseToCache, verifySha256, } from './public-fixtures/fetch.js';
|
|
4
|
-
export
|
|
5
|
-
export {
|
|
5
|
+
export { resolveParametricSpec } from './schema/parametric.js';
|
|
6
|
+
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';
|
|
7
|
+
export { validateDatasetSpec, validateParametricSpec, } from './schema/validate.js';
|
|
@@ -61,4 +61,25 @@ export type DatasetSpec = {
|
|
|
61
61
|
seed?: number;
|
|
62
62
|
layout?: DatasetLayout;
|
|
63
63
|
};
|
|
64
|
+
export type Range = number | {
|
|
65
|
+
min: number;
|
|
66
|
+
max: number;
|
|
67
|
+
};
|
|
68
|
+
export type ParametricStudies = {
|
|
69
|
+
count: Range;
|
|
70
|
+
seriesPerStudy: Range;
|
|
71
|
+
filesPerSeries: Range;
|
|
72
|
+
fileSizeKb?: Range;
|
|
73
|
+
modalities?: Modality[];
|
|
74
|
+
tags?: DicomTagOverrides;
|
|
75
|
+
};
|
|
76
|
+
export type ParametricEdgeCase = EntrySpec & {
|
|
77
|
+
frequency?: number;
|
|
78
|
+
};
|
|
79
|
+
export type ParametricSpec = {
|
|
80
|
+
seed?: number;
|
|
81
|
+
studies: ParametricStudies;
|
|
82
|
+
edgeCases?: ParametricEdgeCase[];
|
|
83
|
+
layout?: DatasetLayout;
|
|
84
|
+
};
|
|
64
85
|
export {};
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
-
import type { DatasetSpec } from './types.js';
|
|
1
|
+
import type { DatasetSpec, ParametricSpec } from './types.js';
|
|
2
2
|
export declare const HEX_TAG_RE: RegExp;
|
|
3
3
|
export declare function validateDatasetSpec(raw: unknown): DatasetSpec;
|
|
4
|
+
export declare function validateParametricSpec(raw: unknown): ParametricSpec;
|
|
@@ -3,6 +3,8 @@ export type UidSet = {
|
|
|
3
3
|
series: string;
|
|
4
4
|
sop: string;
|
|
5
5
|
};
|
|
6
|
+
export declare const PARAMETRIC_STREAM_OFFSET = 2246822507;
|
|
7
|
+
export declare function seededStream(seed: number, offset: number, a: number, b: number): () => number;
|
|
6
8
|
export type GroupUidGenerator = {
|
|
7
9
|
study: (studyIndex: number) => string;
|
|
8
10
|
series: (studyIndex: number, seriesIndex: number) => string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dicom-synth",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.0",
|
|
4
4
|
"description": "Toolkit for synthetic DICOM fixtures and public fixture fetch/cache.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -23,7 +23,8 @@
|
|
|
23
23
|
"types": "dist/types/index.d.ts",
|
|
24
24
|
"bin": {
|
|
25
25
|
"dicom-synth-generate": "./bin/dicom-synth-generate.mjs",
|
|
26
|
-
"dicom-synth-fetch": "./bin/dicom-synth-fetch.mjs"
|
|
26
|
+
"dicom-synth-fetch": "./bin/dicom-synth-fetch.mjs",
|
|
27
|
+
"dicom-synth-describe": "./bin/dicom-synth-describe.mjs"
|
|
27
28
|
},
|
|
28
29
|
"exports": {
|
|
29
30
|
".": {
|
|
@@ -82,7 +83,7 @@
|
|
|
82
83
|
"@typescript-eslint/eslint-plugin": "^8.46.1",
|
|
83
84
|
"@typescript-eslint/parser": "^8.46.1",
|
|
84
85
|
"dcmjs": "^0.51.1",
|
|
85
|
-
"esbuild": "^0.
|
|
86
|
+
"esbuild": "^0.28.1",
|
|
86
87
|
"eslint": "^9.38.0",
|
|
87
88
|
"globals": "^16.4.0",
|
|
88
89
|
"husky": "^9.1.7",
|