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.
@@ -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 = lcg(seed * 999983 + fileIndex * 1000003 >>> 0);
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: filename(spec.type, index, padWidth),
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 totalFiles = spec.entries.reduce((sum, e) => sum + (e.count ?? 1), 0);
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 spec.entries) {
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.filename);
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
- if (typeof e.rows !== "number" || !Number.isInteger(e.rows) || e.rows < 1) {
136
- throw new Error(`${path}.rows: must be a positive integer`);
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.entries.length === 0) {
166
- throw new Error("DatasetSpec.entries: must not be empty");
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 = r.entries.map(
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
- ...r.seed !== void 0 ? { seed: r.seed } : {}
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 = lcg(seed * 999983 + fileIndex * 1000003 >>> 0);
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: filename(spec.type, index, padWidth),
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 totalFiles = spec.entries.reduce((sum, e) => sum + (e.count ?? 1), 0);
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 spec.entries) {
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.filename);
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
- dirname(fileURLToPath(import.meta.url)),
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
- if (typeof e.rows !== "number" || !Number.isInteger(e.rows) || e.rows < 1) {
90
- throw new Error(`${path}.rows: must be a positive integer`);
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.entries.length === 0) {
120
- throw new Error("DatasetSpec.entries: must not be empty");
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 = r.entries.map(
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
- ...r.seed !== void 0 ? { seed: r.seed } : {}
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 = lcg(seed * 999983 + fileIndex * 1000003 >>> 0);
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>;
@@ -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 FileSpec = ValidCtSpec | InvalidUidCtSpec | VendorCtSpec | LargeCtSpec | FakeSignatureSpec | NonDicomSpec | DicomdirSpec;
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: EntrySpec[];
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dicom-synth",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Toolkit for synthetic DICOM fixtures and public fixture fetch/cache.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",