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/dist/esm/index.js CHANGED
@@ -49,7 +49,6 @@ var TYPE_CAPABILITIES = {
49
49
  "valid-image": { image: true },
50
50
  "invalid-uid-image": { image: true },
51
51
  "vendor-warnings-image": { image: true },
52
- "large-image": { image: true },
53
52
  "fake-signature": { image: false },
54
53
  "non-dicom": { image: false },
55
54
  dicomdir: { image: false }
@@ -86,6 +85,25 @@ function validatePositiveInt(value, path) {
86
85
  );
87
86
  }
88
87
  }
88
+ function validateSeed(value, path) {
89
+ if (typeof value !== "number" || !Number.isFinite(value)) {
90
+ throw new Error(
91
+ `${path}: must be a finite number; got ${JSON.stringify(value)}`
92
+ );
93
+ }
94
+ }
95
+ function validateSizeKbLimit(kb, path) {
96
+ if (kb * 1024 > MAX_PIXEL_BYTES) {
97
+ throw new Error(`${path}: exceeds the 512 MB limit`);
98
+ }
99
+ }
100
+ function validateEnum(value, valid, path) {
101
+ if (typeof value !== "string" || !Object.hasOwn(valid, value)) {
102
+ throw new Error(
103
+ `${path}: must be one of ${Object.keys(valid).join(", ")}; got ${JSON.stringify(value)}`
104
+ );
105
+ }
106
+ }
89
107
  function validateTagKey(key, path) {
90
108
  if (!HEX_TAG_RE.test(key) && !KEYWORD_RE.test(key)) {
91
109
  throw new Error(
@@ -98,16 +116,16 @@ function validateEntry(entry, path) {
98
116
  throw new Error(`${path}: must be an object`);
99
117
  }
100
118
  const e = entry;
101
- if (typeof e.type !== "string" || !Object.hasOwn(TYPE_CAPABILITIES, e.type)) {
102
- throw new Error(
103
- `${path}.type: must be one of ${Object.keys(TYPE_CAPABILITIES).join(", ")}; got ${JSON.stringify(e.type)}`
104
- );
105
- }
119
+ validateEnum(e.type, TYPE_CAPABILITIES, `${path}.type`);
106
120
  const type = e.type;
107
121
  const caps = TYPE_CAPABILITIES[type];
108
122
  if ("count" in e) validatePositiveInt(e.count, `${path}.count`);
109
123
  for (const field of [
110
124
  "modality",
125
+ "rows",
126
+ "columns",
127
+ "frames",
128
+ "targetSizeKb",
111
129
  "tags",
112
130
  "violations",
113
131
  "transferSyntax"
@@ -116,18 +134,14 @@ function validateEntry(entry, path) {
116
134
  throw new Error(`${path}.${field}: not supported for type "${type}"`);
117
135
  }
118
136
  if ("transferSyntax" in e) {
119
- if (!Object.hasOwn(VALID_TRANSFER_SYNTAXES, e.transferSyntax)) {
120
- throw new Error(
121
- `${path}.transferSyntax: must be one of ${Object.keys(VALID_TRANSFER_SYNTAXES).join(", ")}`
122
- );
123
- }
137
+ validateEnum(
138
+ e.transferSyntax,
139
+ VALID_TRANSFER_SYNTAXES,
140
+ `${path}.transferSyntax`
141
+ );
124
142
  }
125
143
  if ("modality" in e) {
126
- if (typeof e.modality !== "string" || !Object.hasOwn(VALID_MODALITIES, e.modality)) {
127
- throw new Error(
128
- `${path}.modality: must be one of ${Object.keys(VALID_MODALITIES).join(", ")}; got ${JSON.stringify(e.modality)}`
129
- );
130
- }
144
+ validateEnum(e.modality, VALID_MODALITIES, `${path}.modality`);
131
145
  }
132
146
  if ("tags" in e) validateTags(e.tags, `${path}.tags`);
133
147
  if ("violations" in e) {
@@ -135,14 +149,22 @@ function validateEntry(entry, path) {
135
149
  throw new Error(`${path}.violations: must be an array`);
136
150
  }
137
151
  for (const [i, v] of e.violations.entries()) {
138
- if (typeof v !== "string" || !Object.hasOwn(VALID_VIOLATIONS, v)) {
139
- throw new Error(
140
- `${path}.violations[${i}]: must be one of ${Object.keys(VALID_VIOLATIONS).join(", ")}; got ${JSON.stringify(v)}`
141
- );
142
- }
152
+ validateEnum(v, VALID_VIOLATIONS, `${path}.violations[${i}]`);
143
153
  }
144
154
  }
145
- if (type === "large-image") {
155
+ const hasDims = "rows" in e || "columns" in e || "frames" in e;
156
+ if ("targetSizeKb" in e) {
157
+ if (hasDims) {
158
+ throw new Error(
159
+ `${path}: targetSizeKb cannot be combined with rows/columns/frames \u2014 use one sizing mechanism`
160
+ );
161
+ }
162
+ validatePositiveInt(e.targetSizeKb, `${path}.targetSizeKb`);
163
+ validateSizeKbLimit(e.targetSizeKb, `${path}.targetSizeKb`);
164
+ } else if (hasDims) {
165
+ if (!("rows" in e) || !("columns" in e)) {
166
+ throw new Error(`${path}: rows and columns must be provided together`);
167
+ }
146
168
  validatePositiveInt(e.rows, `${path}.rows`);
147
169
  validatePositiveInt(e.columns, `${path}.columns`);
148
170
  if ("frames" in e) validatePositiveInt(e.frames, `${path}.frames`);
@@ -223,23 +245,104 @@ function validateDatasetSpec(raw) {
223
245
  const studies = rawStudies.map(
224
246
  (study, i) => validateStudy(study, `studies[${i}]`)
225
247
  );
226
- if ("seed" in r) {
227
- if (typeof r.seed !== "number" || !Number.isFinite(r.seed)) {
248
+ if ("seed" in r) validateSeed(r.seed, "DatasetSpec.seed");
249
+ if ("layout" in r) {
250
+ validateEnum(r.layout, VALID_LAYOUTS, "DatasetSpec.layout");
251
+ }
252
+ return {
253
+ ...entries.length > 0 ? { entries } : {},
254
+ ...studies.length > 0 ? { studies } : {},
255
+ ...r.seed !== void 0 ? { seed: r.seed } : {},
256
+ ...r.layout !== void 0 ? { layout: r.layout } : {}
257
+ };
258
+ }
259
+ function validateRange(value, path) {
260
+ if (typeof value === "number") {
261
+ validatePositiveInt(value, path);
262
+ return value;
263
+ }
264
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
265
+ throw new Error(
266
+ `${path}: must be a positive integer or { min, max }; got ${JSON.stringify(value)}`
267
+ );
268
+ }
269
+ const r = value;
270
+ validatePositiveInt(r.min, `${path}.min`);
271
+ validatePositiveInt(r.max, `${path}.max`);
272
+ if (r.min > r.max) {
273
+ throw new Error(`${path}: min (${r.min}) must be \u2264 max (${r.max})`);
274
+ }
275
+ return value;
276
+ }
277
+ function rangeMax(range) {
278
+ return typeof range === "number" ? range : range.max;
279
+ }
280
+ function validateParametricStudies(studies, path) {
281
+ if (typeof studies !== "object" || studies === null || Array.isArray(studies)) {
282
+ throw new Error(`${path}: must be an object`);
283
+ }
284
+ const s = studies;
285
+ for (const field of ["count", "seriesPerStudy", "filesPerSeries"]) {
286
+ if (!(field in s)) throw new Error(`${path}.${field}: is required`);
287
+ validateRange(s[field], `${path}.${field}`);
288
+ }
289
+ if ("fileSizeKb" in s) {
290
+ validateRange(s.fileSizeKb, `${path}.fileSizeKb`);
291
+ validateSizeKbLimit(rangeMax(s.fileSizeKb), `${path}.fileSizeKb`);
292
+ }
293
+ if ("modalities" in s) {
294
+ if (!Array.isArray(s.modalities) || s.modalities.length === 0) {
295
+ throw new Error(`${path}.modalities: must be a non-empty array`);
296
+ }
297
+ for (const [i, m] of s.modalities.entries()) {
298
+ validateEnum(m, VALID_MODALITIES, `${path}.modalities[${i}]`);
299
+ }
300
+ }
301
+ if ("tags" in s) validateTags(s.tags, `${path}.tags`);
302
+ return s;
303
+ }
304
+ function validateEdgeCase(edgeCase, path) {
305
+ const e = validateEntry(edgeCase, path);
306
+ if ("frequency" in e) {
307
+ if ("count" in e) {
228
308
  throw new Error(
229
- `DatasetSpec.seed: must be a finite number; got ${JSON.stringify(r.seed)}`
309
+ `${path}: frequency cannot be combined with count \u2014 frequency draws the count at resolution time`
230
310
  );
231
311
  }
232
- }
233
- if ("layout" in r) {
234
- if (typeof r.layout !== "string" || !Object.hasOwn(VALID_LAYOUTS, r.layout)) {
312
+ const f = e.frequency;
313
+ if (typeof f !== "number" || !(f >= 0 && f <= 1)) {
235
314
  throw new Error(
236
- `DatasetSpec.layout: must be one of ${Object.keys(VALID_LAYOUTS).join(", ")}; got ${JSON.stringify(r.layout)}`
315
+ `${path}.frequency: must be a number between 0 and 1; got ${JSON.stringify(f)}`
237
316
  );
238
317
  }
239
318
  }
319
+ return e;
320
+ }
321
+ function validateParametricSpec(raw) {
322
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
323
+ throw new Error("ParametricSpec: must be a JSON object");
324
+ }
325
+ const r = raw;
326
+ if (!("studies" in r)) {
327
+ throw new Error("ParametricSpec.studies: is required");
328
+ }
329
+ const studies = validateParametricStudies(r.studies, "studies");
330
+ const edgeCases = [];
331
+ if ("edgeCases" in r) {
332
+ if (!Array.isArray(r.edgeCases)) {
333
+ throw new Error("ParametricSpec.edgeCases: must be an array");
334
+ }
335
+ for (const [i, e] of r.edgeCases.entries()) {
336
+ edgeCases.push(validateEdgeCase(e, `edgeCases[${i}]`));
337
+ }
338
+ }
339
+ if ("seed" in r) validateSeed(r.seed, "ParametricSpec.seed");
340
+ if ("layout" in r) {
341
+ validateEnum(r.layout, VALID_LAYOUTS, "ParametricSpec.layout");
342
+ }
240
343
  return {
241
- ...entries.length > 0 ? { entries } : {},
242
- ...studies.length > 0 ? { studies } : {},
344
+ studies,
345
+ ...edgeCases.length > 0 ? { edgeCases } : {},
243
346
  ...r.seed !== void 0 ? { seed: r.seed } : {},
244
347
  ...r.layout !== void 0 ? { layout: r.layout } : {}
245
348
  };
@@ -355,36 +458,46 @@ function serializeDict(meta, dataset) {
355
458
  );
356
459
  return Buffer.from(dicomDict.write({ allowInvalidVRLength: true }));
357
460
  }
358
- function buildValidImageBuffer(uid, modality, tags, transferSyntax) {
359
- const dataset = buildBaseImageDataset(uid, modality);
360
- applyTagOverrides(dataset, tags);
361
- return serializeDict(buildMeta(uid, modality, transferSyntax), dataset);
461
+ var MAX_DIMENSION = 65535;
462
+ function applySizing(dataset, meta, spec) {
463
+ if (spec.targetSizeKb !== void 0) {
464
+ const clampDim = (n) => Math.min(MAX_DIMENSION, Math.max(1, Math.round(n)));
465
+ const minimalPixelBytes = dataset.PixelData.byteLength;
466
+ const overhead = serializeDict(meta, dataset).length - minimalPixelBytes;
467
+ const cells = Math.max(
468
+ 1,
469
+ Math.round((spec.targetSizeKb * 1024 - overhead) / 2)
470
+ );
471
+ const rows = clampDim(Math.sqrt(cells));
472
+ const columns = clampDim(cells / rows);
473
+ dataset.Rows = rows;
474
+ dataset.Columns = columns;
475
+ dataset.PixelData = new ArrayBuffer(rows * columns * 2);
476
+ } else if (spec.rows !== void 0 && spec.columns !== void 0) {
477
+ dataset.Rows = spec.rows;
478
+ dataset.Columns = spec.columns;
479
+ if (spec.frames !== void 0) dataset.NumberOfFrames = spec.frames;
480
+ dataset.PixelData = new ArrayBuffer(
481
+ spec.rows * spec.columns * (spec.frames ?? 1) * 2
482
+ );
483
+ }
362
484
  }
363
- function buildInvalidUidImageBuffer(uid, modality, tags, transferSyntax) {
364
- const invalidUid = {
485
+ function buildImageBuffer(spec, uid) {
486
+ const modality = spec.modality ?? "CT";
487
+ const effectiveUid = spec.type === "invalid-uid-image" ? {
365
488
  study: `2.25.invalid.study.${uid.study.slice(-6)}`,
366
489
  series: `2.25.invalid.series.${uid.series.slice(-6)}`,
367
490
  sop: `2.25.invalid.sop.${uid.sop.slice(-6)}`
368
- };
369
- const dataset = buildBaseImageDataset(invalidUid, modality);
370
- applyTagOverrides(dataset, tags);
371
- return serializeDict(buildMeta(invalidUid, modality, transferSyntax), dataset);
372
- }
373
- function buildVendorWarningsImageBuffer(uid, modality, tags, transferSyntax) {
374
- const dataset = buildBaseImageDataset(uid, modality);
375
- dataset.Laterality = "";
376
- dataset.PatientWeight = "0";
377
- applyTagOverrides(dataset, tags);
378
- return serializeDict(buildMeta(uid, modality, transferSyntax), dataset);
379
- }
380
- function buildLargeImageBuffer(uid, modality, rows, columns, frames, tags, transferSyntax) {
381
- const dataset = buildBaseImageDataset(uid, modality);
382
- dataset.Rows = rows;
383
- dataset.Columns = columns;
384
- dataset.NumberOfFrames = frames;
385
- dataset.PixelData = new Uint8Array(rows * columns * frames * 2).buffer;
386
- applyTagOverrides(dataset, tags);
387
- return serializeDict(buildMeta(uid, modality, transferSyntax), dataset);
491
+ } : uid;
492
+ const dataset = buildBaseImageDataset(effectiveUid, modality);
493
+ if (spec.type === "vendor-warnings-image") {
494
+ dataset.Laterality = "";
495
+ dataset.PatientWeight = "0";
496
+ }
497
+ applyTagOverrides(dataset, spec.tags);
498
+ const meta = buildMeta(effectiveUid, modality, spec.transferSyntax);
499
+ applySizing(dataset, meta, spec);
500
+ return serializeDict(meta, dataset);
388
501
  }
389
502
  function buildFakeSignatureBuffer() {
390
503
  const buf = Buffer.alloc(200, 0);
@@ -397,36 +510,9 @@ function buildNonDicomBuffer(content = "not dicom") {
397
510
  function buildBufferForSpec(spec, uid) {
398
511
  switch (spec.type) {
399
512
  case "valid-image":
400
- return buildValidImageBuffer(
401
- uid,
402
- spec.modality ?? "CT",
403
- spec.tags,
404
- spec.transferSyntax
405
- );
406
513
  case "invalid-uid-image":
407
- return buildInvalidUidImageBuffer(
408
- uid,
409
- spec.modality ?? "CT",
410
- spec.tags,
411
- spec.transferSyntax
412
- );
413
514
  case "vendor-warnings-image":
414
- return buildVendorWarningsImageBuffer(
415
- uid,
416
- spec.modality ?? "CT",
417
- spec.tags,
418
- spec.transferSyntax
419
- );
420
- case "large-image":
421
- return buildLargeImageBuffer(
422
- uid,
423
- spec.modality ?? "CT",
424
- spec.rows,
425
- spec.columns,
426
- spec.frames ?? 1,
427
- spec.tags,
428
- spec.transferSyntax
429
- );
515
+ return buildImageBuffer(spec, uid);
430
516
  case "fake-signature":
431
517
  return buildFakeSignatureBuffer();
432
518
  case "non-dicom":
@@ -457,6 +543,7 @@ function seededUid(next, role) {
457
543
  return formatUid(next(), next(), role);
458
544
  }
459
545
  var GROUP_STREAM_OFFSET = 2654435769;
546
+ var PARAMETRIC_STREAM_OFFSET = 2246822507;
460
547
  function seededStream(seed, offset, a, b) {
461
548
  return lcg(seed * 999983 + offset + a * 1000003 + b * 7919 >>> 0);
462
549
  }
@@ -793,6 +880,80 @@ async function fetchPublicCaseToCache(record, cacheRoot = DEFAULT_CACHE_ROOT) {
793
880
  writeFileSync2(dest, buf);
794
881
  return dest;
795
882
  }
883
+
884
+ // src/schema/parametric.ts
885
+ import { randomBytes as randomBytes2 } from "node:crypto";
886
+ function sample(next, range) {
887
+ if (typeof range === "number") return range;
888
+ return range.min + next() % (range.max - range.min + 1);
889
+ }
890
+ function pick(next, items) {
891
+ return items[next() % items.length];
892
+ }
893
+ function fraction(next) {
894
+ return next() / 4294967296;
895
+ }
896
+ function resolveParametricSpec(spec) {
897
+ const validated = validateParametricSpec(spec);
898
+ const seed = validated.seed ?? randomBytes2(4).readUInt32BE(0);
899
+ const next = seededStream(seed, PARAMETRIC_STREAM_OFFSET, 0, 0);
900
+ const params = validated.studies;
901
+ const studies = [];
902
+ let totalFiles = 0;
903
+ const studyCount = sample(next, params.count);
904
+ for (let st = 0; st < studyCount; st++) {
905
+ const seriesCount = sample(next, params.seriesPerStudy);
906
+ const series = [];
907
+ for (let se = 0; se < seriesCount; se++) {
908
+ const fileCount = sample(next, params.filesPerSeries);
909
+ totalFiles += fileCount;
910
+ const modality = params.modalities ? pick(next, params.modalities) : void 0;
911
+ const base = {
912
+ type: "valid-image",
913
+ ...modality !== void 0 ? { modality } : {}
914
+ };
915
+ const entries2 = [];
916
+ if (params.fileSizeKb !== void 0 && typeof params.fileSizeKb !== "number") {
917
+ for (let f = 0; f < fileCount; f++) {
918
+ entries2.push({
919
+ ...base,
920
+ targetSizeKb: sample(next, params.fileSizeKb)
921
+ });
922
+ }
923
+ } else {
924
+ entries2.push({
925
+ ...base,
926
+ ...params.fileSizeKb !== void 0 ? { targetSizeKb: params.fileSizeKb } : {},
927
+ ...fileCount > 1 ? { count: fileCount } : {}
928
+ });
929
+ }
930
+ series.push({ entries: entries2 });
931
+ }
932
+ studies.push({
933
+ series,
934
+ ...params.tags !== void 0 ? { tags: params.tags } : {}
935
+ });
936
+ }
937
+ const entries = [];
938
+ for (const edgeCase of validated.edgeCases ?? []) {
939
+ const { frequency, ...entry } = edgeCase;
940
+ if (frequency === void 0) {
941
+ entries.push(entry);
942
+ continue;
943
+ }
944
+ let drawn = 0;
945
+ for (let i = 0; i < totalFiles; i++) {
946
+ if (fraction(next) < frequency) drawn++;
947
+ }
948
+ if (drawn > 0) entries.push({ ...entry, count: drawn });
949
+ }
950
+ return {
951
+ ...entries.length > 0 ? { entries } : {},
952
+ studies,
953
+ seed,
954
+ ...validated.layout !== void 0 ? { layout: validated.layout } : {}
955
+ };
956
+ }
796
957
  export {
797
958
  caseCachePath,
798
959
  defaultPublicCasesPath,
@@ -802,7 +963,9 @@ export {
802
963
  loadCaseById,
803
964
  loadCasesFromJson,
804
965
  loadDefaultCases,
966
+ resolveParametricSpec,
805
967
  validateDatasetSpec,
968
+ validateParametricSpec,
806
969
  verifySha256,
807
970
  writeCollectionFromSpec
808
971
  };