dicom-synth 1.10.0 → 1.12.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,11 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  import { readFileSync, writeFileSync } from 'node:fs'
3
3
  import { resolve } from 'node:path'
4
+ import { gzipSync } from 'node:zlib'
4
5
  import { describeDirectory } from '../dist/esm/index.js'
5
6
 
6
7
  function usage() {
7
8
  console.error(
8
- 'Usage: dicom-synth-describe <dir> [--out <spec.json>] [--keep-tags <a,b,...>] [--keep-tags-file <path>]\n' +
9
+ 'Usage: dicom-synth-describe <dir> [--out <spec.json[.gz]>] [--keep-tags <a,b,...>] [--keep-tags-file <path>] [--size-tolerance <fraction>] [--gzip]\n' +
9
10
  '\n' +
10
11
  'Scans a DICOM tree and emits a DatasetSpec describing its shape\n' +
11
12
  '(studies/series, per-file size, modality).\n' +
@@ -15,7 +16,10 @@ function usage() {
15
16
  "verbatim; if a kept tag holds PHI, handling it is the caller's\n" +
16
17
  "responsibility, not this tool's.\n" +
17
18
  '--keep-tags-file reads the same allow-list from a file (one tag per\n' +
18
- 'line, # comments); merged with any --keep-tags.',
19
+ 'line, # comments); merged with any --keep-tags.\n' +
20
+ '--size-tolerance folds near-identical file sizes (fraction, e.g. 0.05\n' +
21
+ '= ±5%) into one entry; default exact.\n' +
22
+ '--gzip (or a .gz --out suffix) writes the spec gzip-compressed.',
19
23
  )
20
24
  process.exit(1)
21
25
  }
@@ -33,6 +37,8 @@ if (args.length === 0) usage()
33
37
 
34
38
  let dir
35
39
  let outPath
40
+ let sizeTolerance
41
+ let gzip = false
36
42
  const keepTags = []
37
43
 
38
44
  for (let i = 0; i < args.length; i++) {
@@ -52,6 +58,14 @@ for (let i = 0; i < args.length; i++) {
52
58
  console.error(`Error reading keep-tags file: ${err.message}`)
53
59
  process.exit(1)
54
60
  }
61
+ } else if (args[i] === '--size-tolerance' && args[i + 1]) {
62
+ sizeTolerance = Number(args[++i])
63
+ if (!Number.isFinite(sizeTolerance) || sizeTolerance < 0) {
64
+ console.error('--size-tolerance must be a non-negative number')
65
+ process.exit(1)
66
+ }
67
+ } else if (args[i] === '--gzip') {
68
+ gzip = true
55
69
  } else if (args[i].startsWith('--')) {
56
70
  console.error(`Unknown argument: ${args[i]}`)
57
71
  usage()
@@ -67,18 +81,22 @@ if (dir === undefined) usage()
67
81
 
68
82
  let result
69
83
  try {
70
- result = describeDirectory(dir, keepTags.length ? { keepTags } : {})
84
+ result = describeDirectory(dir, {
85
+ ...(keepTags.length ? { keepTags } : {}),
86
+ ...(sizeTolerance !== undefined ? { sizeTolerance } : {}),
87
+ })
71
88
  } catch (err) {
72
89
  console.error(`Error: ${err.message}`)
73
90
  process.exit(1)
74
91
  }
75
92
 
76
93
  const json = `${JSON.stringify(result.spec, null, 2)}\n`
94
+ const compress = gzip || outPath?.endsWith('.gz')
77
95
  if (outPath) {
78
- writeFileSync(outPath, json)
96
+ writeFileSync(outPath, compress ? gzipSync(json) : json)
79
97
  console.error(`Wrote DatasetSpec to ${outPath}`)
80
98
  } else {
81
- process.stdout.write(json)
99
+ process.stdout.write(compress ? gzipSync(json) : json)
82
100
  }
83
101
 
84
102
  const { dicomFiles, skipped, unknownModality } = result.stats
@@ -269,68 +269,37 @@ import { closeSync, mkdirSync as mkdirSync2, openSync, writeSync } from "node:fs
269
269
  import { resolve as resolve2 } from "node:path";
270
270
 
271
271
  // src/syntheticFixtures/uid.ts
272
- import { randomBytes } from "node:crypto";
273
- var ROLE_CODE = { study: 1, series: 2, sop: 3 };
274
- function lcg(seed) {
275
- let s = seed >>> 0;
276
- return () => {
277
- s = Math.imul(1664525, s) + 1013904223 >>> 0;
278
- return s;
279
- };
280
- }
281
- function formatUid(a, b, role) {
282
- return `2.25.${a}.${b}.${ROLE_CODE[role]}`;
283
- }
284
- function randomUid(role) {
285
- const buf = randomBytes(8);
286
- return formatUid(buf.readUInt32BE(0), buf.readUInt32BE(4), role);
272
+ import { createHash, randomBytes } from "node:crypto";
273
+ function hashUid(salt, key) {
274
+ const digest = createHash("sha256").update(salt).update(" ").update(key).digest();
275
+ const n = BigInt(`0x${digest.subarray(0, 16).toString("hex")}`);
276
+ return `2.25.${n.toString()}`;
287
277
  }
288
- function seededUid(next, role) {
289
- return formatUid(next(), next(), role);
278
+ function randomUid() {
279
+ const n = BigInt(`0x${randomBytes(16).toString("hex")}`);
280
+ return `2.25.${n.toString()}`;
290
281
  }
291
- var GROUP_STREAM_OFFSET = 2654435769;
292
- function seededStream(seed, offset, a, b) {
293
- return lcg(seed * 999983 + offset + a * 1000003 + b * 7919 >>> 0);
282
+ function seedToSalt(seed) {
283
+ return seed === void 0 ? void 0 : `seed:${seed}`;
294
284
  }
295
- function makeGroupUidGenerator(seed) {
296
- if (seed === void 0) {
297
- return {
298
- study: () => randomUid("study"),
299
- series: () => randomUid("series")
300
- };
285
+ function makeGroupUidGenerator(salt) {
286
+ if (salt === void 0) {
287
+ return { study: () => randomUid(), series: () => randomUid() };
301
288
  }
302
289
  return {
303
- study: (studyIndex) => seededUid(
304
- seededStream(seed, GROUP_STREAM_OFFSET, studyIndex + 1, 0),
305
- "study"
306
- ),
307
- series: (studyIndex, seriesIndex) => seededUid(
308
- seededStream(
309
- seed,
310
- GROUP_STREAM_OFFSET,
311
- studyIndex + 1,
312
- seriesIndex + 1
313
- ),
314
- "series"
315
- )
290
+ study: (studyIndex) => hashUid(salt, `study:${studyIndex}`),
291
+ series: (studyIndex, seriesIndex) => hashUid(salt, `series:${studyIndex}:${seriesIndex}`)
316
292
  };
317
293
  }
318
- function makeUidGenerator(seed) {
319
- if (seed === void 0) {
320
- return () => ({
321
- study: randomUid("study"),
322
- series: randomUid("series"),
323
- sop: randomUid("sop")
324
- });
294
+ function makeUidGenerator(salt) {
295
+ if (salt === void 0) {
296
+ return () => ({ study: randomUid(), series: randomUid(), sop: randomUid() });
325
297
  }
326
- return (fileIndex) => {
327
- const next = seededStream(seed, 0, fileIndex, 0);
328
- return {
329
- study: seededUid(next, "study"),
330
- series: seededUid(next, "series"),
331
- sop: seededUid(next, "sop")
332
- };
333
- };
298
+ return (fileIndex) => ({
299
+ study: hashUid(salt, `study:flat:${fileIndex}`),
300
+ series: hashUid(salt, `series:flat:${fileIndex}`),
301
+ sop: hashUid(salt, `sop:${fileIndex}`)
302
+ });
334
303
  }
335
304
 
336
305
  // src/syntheticFixtures/streamWrite.ts
@@ -443,7 +412,7 @@ function streamLargeImage(outPath, options) {
443
412
  fragmentBytes = FRAGMENT_MAX_BYTES
444
413
  } = options;
445
414
  const modality = options.modality ?? "CT";
446
- const uid = options.uid ?? makeUidGenerator(options.seed)(0);
415
+ const uid = options.uid ?? makeUidGenerator(options.uidSalt ?? seedToSalt(options.seed))(0);
447
416
  const identity = { modality, uid, tags: options.tags };
448
417
  mkdirSync2(resolve2(outPath, ".."), { recursive: true });
449
418
  const zeroChunk = Buffer.alloc(Math.max(1, chunkBytes));
@@ -587,19 +556,51 @@ function filename(type, index, padWidth) {
587
556
  function entryCount(entries) {
588
557
  return entries.reduce((sum, e) => sum + (e.count ?? 1), 0);
589
558
  }
559
+ function seriesFileCount(series) {
560
+ return series.instances ? series.instances.count : entryCount(series.entries ?? []);
561
+ }
590
562
  function studyFileCount(studies) {
591
563
  return studies.reduce(
592
- (sum, study) => sum + (study.count ?? 1) * study.series.reduce((s, series) => s + entryCount(series.entries), 0),
564
+ (sum, study) => sum + (study.count ?? 1) * study.series.reduce((s, series) => s + seriesFileCount(series), 0),
593
565
  0
594
566
  );
595
567
  }
596
- function resolveUid(seed, index, override) {
597
- return { ...makeUidGenerator(seed)(index), ...override };
568
+ function* seriesFiles(series) {
569
+ if (series.instances) {
570
+ const inst = series.instances;
571
+ for (let i = 0; i < inst.count; i++) {
572
+ const modality = Array.isArray(inst.modality) ? inst.modality[i] : inst.modality;
573
+ const baseSpec = {
574
+ ...inst.targetBytes ? { type: "large-image", targetBytes: inst.targetBytes[i] } : { type: "valid-image", targetSizeKb: inst.targetSizeKb?.[i] },
575
+ ...modality !== void 0 ? { modality } : {}
576
+ };
577
+ const instanceTags = {};
578
+ if (inst.tags) {
579
+ for (const [key, values] of Object.entries(inst.tags)) {
580
+ instanceTags[key] = values[i];
581
+ }
582
+ }
583
+ yield { baseSpec, instanceTags };
584
+ }
585
+ return;
586
+ }
587
+ for (const entry of series.entries ?? []) {
588
+ const { count = 1, tags, ...baseSpec } = entry;
589
+ for (let i = 0; i < count; i++) {
590
+ yield { baseSpec, instanceTags: tags ?? {} };
591
+ }
592
+ }
593
+ }
594
+ function resolveUid(salt, index, override) {
595
+ return { ...makeUidGenerator(salt)(index), ...override };
596
+ }
597
+ function effectiveSalt(spec) {
598
+ return spec.uidSalt ?? seedToSalt(spec.seed);
598
599
  }
599
600
  async function generateFile(spec, options) {
600
601
  const index = options?.index ?? 0;
601
602
  const padWidth = options?.padWidth ?? 3;
602
- const uid = resolveUid(options?.seed, index, options?.uid);
603
+ const uid = resolveUid(effectiveSalt(options ?? {}), index, options?.uid);
603
604
  const resolvedSpec = "tags" in spec && spec.tags ? {
604
605
  ...spec,
605
606
  tags: resolveTagTemplates(spec.tags, { index, ...options?.context })
@@ -671,6 +672,9 @@ function hierarchicalName(studyOrdinal, seriesIndex, instanceNumber) {
671
672
  function withResolvedSeed(spec) {
672
673
  return spec.seed === void 0 ? { ...spec, seed: randomBytes2(4).readUInt32BE(0) } : spec;
673
674
  }
675
+ function withResolvedSalt(spec) {
676
+ return spec.uidSalt === void 0 ? { ...spec, uidSalt: randomBytes2(16).toString("hex") } : spec;
677
+ }
674
678
  function computeNames(type, index, padWidth, grouped, quirks) {
675
679
  let names;
676
680
  if (grouped) {
@@ -711,7 +715,7 @@ function* planCollection(spec) {
711
715
  globalIndex++;
712
716
  }
713
717
  }
714
- const groupUids = makeGroupUidGenerator(spec.seed);
718
+ const groupUids = makeGroupUidGenerator(effectiveSalt(spec));
715
719
  let studyOrdinal = 0;
716
720
  for (const study of studies) {
717
721
  const copies = study.count ?? 1;
@@ -720,57 +724,56 @@ function* planCollection(spec) {
720
724
  for (const [seriesIndex, series] of study.series.entries()) {
721
725
  const seriesUid = groupUids.series(studyOrdinal, seriesIndex);
722
726
  let instanceNumber = 0;
723
- for (const entry of series.entries) {
724
- const { count = 1, ...fileSpec } = entry;
725
- for (let i = 0; i < count; i++) {
726
- instanceNumber++;
727
- const tags = {
728
- InstanceNumber: instanceNumber,
729
- ...study.tags,
730
- ...series.tags,
731
- ...fileSpec.tags
732
- };
733
- yield {
734
- fileSpec: { ...fileSpec, tags },
727
+ for (const { baseSpec, instanceTags } of seriesFiles(series)) {
728
+ instanceNumber++;
729
+ const tags = {
730
+ InstanceNumber: instanceNumber,
731
+ ...study.tags,
732
+ ...series.tags,
733
+ ...instanceTags
734
+ };
735
+ yield {
736
+ fileSpec: { ...baseSpec, tags },
737
+ index: globalIndex,
738
+ uidOverride: { study: studyUid, series: seriesUid },
739
+ context: {
735
740
  index: globalIndex,
736
- uidOverride: { study: studyUid, series: seriesUid },
737
- context: {
738
- index: globalIndex,
739
- studyIndex: studyOrdinal,
740
- seriesIndex,
741
- instanceNumber
742
- },
743
- ...computeNames(
744
- fileSpec.type,
745
- globalIndex,
746
- padWidth,
747
- hierarchical ? { studyOrdinal, seriesIndex, instanceNumber } : void 0,
748
- quirks
749
- )
750
- };
751
- globalIndex++;
752
- }
741
+ studyIndex: studyOrdinal,
742
+ seriesIndex,
743
+ instanceNumber
744
+ },
745
+ ...computeNames(
746
+ baseSpec.type,
747
+ globalIndex,
748
+ padWidth,
749
+ hierarchical ? { studyOrdinal, seriesIndex, instanceNumber } : void 0,
750
+ quirks
751
+ )
752
+ };
753
+ globalIndex++;
753
754
  }
754
755
  }
755
756
  studyOrdinal++;
756
757
  }
757
758
  }
758
759
  }
759
- async function materialisePlan(plan, seed) {
760
+ async function materialisePlan(plan, salt) {
760
761
  const file = await generateFile(plan.fileSpec, {
761
762
  index: plan.index,
762
- seed,
763
+ uidSalt: salt,
763
764
  uid: plan.uidOverride,
764
765
  context: plan.context
765
766
  });
766
767
  return { ...file, filename: plan.filename, relativePath: plan.relativePath };
767
768
  }
768
769
  async function* generateCollectionFromSpec(spec) {
770
+ const salt = effectiveSalt(spec);
769
771
  for (const plan of planCollection(spec)) {
770
- yield await materialisePlan(plan, spec.seed);
772
+ yield await materialisePlan(plan, salt);
771
773
  }
772
774
  }
773
775
  async function* previewCollection(spec) {
776
+ const salt = effectiveSalt(spec);
774
777
  for (const plan of planCollection(spec)) {
775
778
  if (plan.fileSpec.type === "large-image") {
776
779
  yield {
@@ -780,7 +783,7 @@ async function* previewCollection(spec) {
780
783
  approxBytes: plan.fileSpec.targetBytes
781
784
  };
782
785
  } else {
783
- const file = await materialisePlan(plan, spec.seed);
786
+ const file = await materialisePlan(plan, salt);
784
787
  yield {
785
788
  relativePath: plan.relativePath,
786
789
  type: plan.fileSpec.type,
@@ -794,6 +797,7 @@ async function writeCollectionFromSpec(spec, outDir) {
794
797
  const root = resolve3(outDir);
795
798
  mkdirSync3(root, { recursive: true });
796
799
  const manifest = [];
800
+ const salt = effectiveSalt(spec);
797
801
  const createdDirs = /* @__PURE__ */ new Set([root]);
798
802
  const ensureDir = (filePath) => {
799
803
  const dir = dirname(filePath);
@@ -812,7 +816,7 @@ async function writeCollectionFromSpec(spec, outDir) {
812
816
  `large-image targetBytes (${large.targetBytes}) exceeds the 50 GB limit`
813
817
  );
814
818
  }
815
- const uid = resolveUid(spec.seed, plan.index, plan.uidOverride);
819
+ const uid = resolveUid(salt, plan.index, plan.uidOverride);
816
820
  streamLargeImage(filePath, {
817
821
  targetBytes: large.targetBytes,
818
822
  modality: large.modality,
@@ -820,7 +824,7 @@ async function writeCollectionFromSpec(spec, outDir) {
820
824
  tags: resolveTagTemplates(large.tags, plan.context)
821
825
  });
822
826
  } else {
823
- const file = await materialisePlan(plan, spec.seed);
827
+ const file = await materialisePlan(plan, salt);
824
828
  await writeFile(filePath, file.buffer);
825
829
  }
826
830
  manifest.push({
@@ -835,6 +839,7 @@ export {
835
839
  generateCollectionFromSpec,
836
840
  generateFile,
837
841
  previewCollection,
842
+ withResolvedSalt,
838
843
  withResolvedSeed,
839
844
  writeCollectionFromSpec
840
845
  };