dicom-synth 1.11.0 → 1.13.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.
@@ -6,12 +6,14 @@ import { describeDirectory } from '../dist/esm/index.js'
6
6
 
7
7
  function usage() {
8
8
  console.error(
9
- 'Usage: dicom-synth-describe <dir> [--out <spec.json[.gz]>] [--keep-tags <a,b,...>] [--keep-tags-file <path>] [--size-tolerance <fraction>] [--gzip]\n' +
9
+ 'Usage: dicom-synth-describe <dir> [--out <spec.json[.gz]>] [--keep-tags <a,b,...>] [--keep-tags-file <path>] [--size-tolerance <fraction>] [--no-preserve-uids] [--uid-salt <hex>] [--gzip]\n' +
10
10
  '\n' +
11
11
  'Scans a DICOM tree and emits a DatasetSpec describing its shape\n' +
12
12
  '(studies/series, per-file size, modality).\n' +
13
13
  '\n' +
14
- 'By default only the shape is reproduced no tags are copied.\n' +
14
+ 'UIDs are preserved by default as hash-derived synthetic UIDs, keeping\n' +
15
+ 'cross-file links (FrameOfReferenceUID etc.) without emitting any original\n' +
16
+ 'UID. Other tags are not copied unless named.\n' +
15
17
  '--keep-tags copies the named tags (DICOM keywords or 8-hex tags)\n' +
16
18
  "verbatim; if a kept tag holds PHI, handling it is the caller's\n" +
17
19
  "responsibility, not this tool's.\n" +
@@ -19,6 +21,11 @@ function usage() {
19
21
  'line, # comments); merged with any --keep-tags.\n' +
20
22
  '--size-tolerance folds near-identical file sizes (fraction, e.g. 0.05\n' +
21
23
  '= ±5%) into one entry; default exact.\n' +
24
+ '--no-preserve-uids emits shape only with freshly minted UIDs (no link\n' +
25
+ 'preservation).\n' +
26
+ '--uid-salt fixes the hashing salt (hex) for a specific namespace; when\n' +
27
+ "omitted the salt derives from the dataset's own study UIDs (reproducible,\n" +
28
+ 'and disjoint across datasets). The salt is reported on stderr.\n' +
22
29
  '--gzip (or a .gz --out suffix) writes the spec gzip-compressed.',
23
30
  )
24
31
  process.exit(1)
@@ -38,6 +45,8 @@ if (args.length === 0) usage()
38
45
  let dir
39
46
  let outPath
40
47
  let sizeTolerance
48
+ let preserveUids = true
49
+ let uidSalt
41
50
  let gzip = false
42
51
  const keepTags = []
43
52
 
@@ -64,6 +73,10 @@ for (let i = 0; i < args.length; i++) {
64
73
  console.error('--size-tolerance must be a non-negative number')
65
74
  process.exit(1)
66
75
  }
76
+ } else if (args[i] === '--no-preserve-uids') {
77
+ preserveUids = false
78
+ } else if (args[i] === '--uid-salt' && args[i + 1]) {
79
+ uidSalt = args[++i]
67
80
  } else if (args[i] === '--gzip') {
68
81
  gzip = true
69
82
  } else if (args[i].startsWith('--')) {
@@ -79,11 +92,18 @@ for (let i = 0; i < args.length; i++) {
79
92
 
80
93
  if (dir === undefined) usage()
81
94
 
95
+ if (uidSalt !== undefined && !preserveUids) {
96
+ console.error('--uid-salt cannot be combined with --no-preserve-uids')
97
+ process.exit(1)
98
+ }
99
+
82
100
  let result
83
101
  try {
84
102
  result = describeDirectory(dir, {
85
103
  ...(keepTags.length ? { keepTags } : {}),
86
104
  ...(sizeTolerance !== undefined ? { sizeTolerance } : {}),
105
+ preserveUids,
106
+ ...(uidSalt !== undefined ? { uidSalt } : {}),
87
107
  })
88
108
  } catch (err) {
89
109
  console.error(`Error: ${err.message}`)
@@ -103,6 +123,11 @@ const { dicomFiles, skipped, unknownModality } = result.stats
103
123
  console.error(
104
124
  `Described ${dicomFiles} DICOM file(s); skipped ${skipped} non-DICOM/ungroupable file(s)`,
105
125
  )
126
+ if (result.spec.uidSalt) {
127
+ console.error(
128
+ ` UIDs hash-derived with salt ${result.spec.uidSalt} (reuse via --uid-salt to reproduce)`,
129
+ )
130
+ }
106
131
  if (unknownModality > 0) {
107
132
  console.error(
108
133
  ` ${unknownModality} file(s) had an unsupported Modality — emitted as default (CT)`,
@@ -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]}`;
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()}`;
283
277
  }
284
- function randomUid(role) {
285
- const buf = randomBytes(8);
286
- return formatUid(buf.readUInt32BE(0), buf.readUInt32BE(4), role);
278
+ function randomUid() {
279
+ const n = BigInt(`0x${randomBytes(16).toString("hex")}`);
280
+ return `2.25.${n.toString()}`;
287
281
  }
288
- function seededUid(next, role) {
289
- return formatUid(next(), next(), role);
282
+ function seedToSalt(seed) {
283
+ return seed === void 0 ? void 0 : `seed:${seed}`;
290
284
  }
291
- var GROUP_STREAM_OFFSET = 2654435769;
292
- function seededStream(seed, offset, a, b) {
293
- return lcg(seed * 999983 + offset + a * 1000003 + b * 7919 >>> 0);
294
- }
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));
@@ -622,13 +591,16 @@ function* seriesFiles(series) {
622
591
  }
623
592
  }
624
593
  }
625
- function resolveUid(seed, index, override) {
626
- return { ...makeUidGenerator(seed)(index), ...override };
594
+ function resolveUid(salt, index, override) {
595
+ return { ...makeUidGenerator(salt)(index), ...override };
596
+ }
597
+ function effectiveSalt(spec) {
598
+ return spec.uidSalt ?? seedToSalt(spec.seed);
627
599
  }
628
600
  async function generateFile(spec, options) {
629
601
  const index = options?.index ?? 0;
630
602
  const padWidth = options?.padWidth ?? 3;
631
- const uid = resolveUid(options?.seed, index, options?.uid);
603
+ const uid = resolveUid(effectiveSalt(options ?? {}), index, options?.uid);
632
604
  const resolvedSpec = "tags" in spec && spec.tags ? {
633
605
  ...spec,
634
606
  tags: resolveTagTemplates(spec.tags, { index, ...options?.context })
@@ -700,6 +672,9 @@ function hierarchicalName(studyOrdinal, seriesIndex, instanceNumber) {
700
672
  function withResolvedSeed(spec) {
701
673
  return spec.seed === void 0 ? { ...spec, seed: randomBytes2(4).readUInt32BE(0) } : spec;
702
674
  }
675
+ function withResolvedSalt(spec) {
676
+ return spec.uidSalt === void 0 ? { ...spec, uidSalt: randomBytes2(16).toString("hex") } : spec;
677
+ }
703
678
  function computeNames(type, index, padWidth, grouped, quirks) {
704
679
  let names;
705
680
  if (grouped) {
@@ -740,7 +715,7 @@ function* planCollection(spec) {
740
715
  globalIndex++;
741
716
  }
742
717
  }
743
- const groupUids = makeGroupUidGenerator(spec.seed);
718
+ const groupUids = makeGroupUidGenerator(effectiveSalt(spec));
744
719
  let studyOrdinal = 0;
745
720
  for (const study of studies) {
746
721
  const copies = study.count ?? 1;
@@ -782,21 +757,23 @@ function* planCollection(spec) {
782
757
  }
783
758
  }
784
759
  }
785
- async function materialisePlan(plan, seed) {
760
+ async function materialisePlan(plan, salt) {
786
761
  const file = await generateFile(plan.fileSpec, {
787
762
  index: plan.index,
788
- seed,
763
+ uidSalt: salt,
789
764
  uid: plan.uidOverride,
790
765
  context: plan.context
791
766
  });
792
767
  return { ...file, filename: plan.filename, relativePath: plan.relativePath };
793
768
  }
794
769
  async function* generateCollectionFromSpec(spec) {
770
+ const salt = effectiveSalt(spec);
795
771
  for (const plan of planCollection(spec)) {
796
- yield await materialisePlan(plan, spec.seed);
772
+ yield await materialisePlan(plan, salt);
797
773
  }
798
774
  }
799
775
  async function* previewCollection(spec) {
776
+ const salt = effectiveSalt(spec);
800
777
  for (const plan of planCollection(spec)) {
801
778
  if (plan.fileSpec.type === "large-image") {
802
779
  yield {
@@ -806,7 +783,7 @@ async function* previewCollection(spec) {
806
783
  approxBytes: plan.fileSpec.targetBytes
807
784
  };
808
785
  } else {
809
- const file = await materialisePlan(plan, spec.seed);
786
+ const file = await materialisePlan(plan, salt);
810
787
  yield {
811
788
  relativePath: plan.relativePath,
812
789
  type: plan.fileSpec.type,
@@ -820,6 +797,7 @@ async function writeCollectionFromSpec(spec, outDir) {
820
797
  const root = resolve3(outDir);
821
798
  mkdirSync3(root, { recursive: true });
822
799
  const manifest = [];
800
+ const salt = effectiveSalt(spec);
823
801
  const createdDirs = /* @__PURE__ */ new Set([root]);
824
802
  const ensureDir = (filePath) => {
825
803
  const dir = dirname(filePath);
@@ -838,7 +816,7 @@ async function writeCollectionFromSpec(spec, outDir) {
838
816
  `large-image targetBytes (${large.targetBytes}) exceeds the 50 GB limit`
839
817
  );
840
818
  }
841
- const uid = resolveUid(spec.seed, plan.index, plan.uidOverride);
819
+ const uid = resolveUid(salt, plan.index, plan.uidOverride);
842
820
  streamLargeImage(filePath, {
843
821
  targetBytes: large.targetBytes,
844
822
  modality: large.modality,
@@ -846,7 +824,7 @@ async function writeCollectionFromSpec(spec, outDir) {
846
824
  tags: resolveTagTemplates(large.tags, plan.context)
847
825
  });
848
826
  } else {
849
- const file = await materialisePlan(plan, spec.seed);
827
+ const file = await materialisePlan(plan, salt);
850
828
  await writeFile(filePath, file.buffer);
851
829
  }
852
830
  manifest.push({
@@ -861,6 +839,7 @@ export {
861
839
  generateCollectionFromSpec,
862
840
  generateFile,
863
841
  previewCollection,
842
+ withResolvedSalt,
864
843
  withResolvedSeed,
865
844
  writeCollectionFromSpec
866
845
  };
@@ -1,4 +1,5 @@
1
1
  // src/describe/describe.ts
2
+ import { createHash as createHash2 } from "node:crypto";
2
3
  import {
3
4
  closeSync,
4
5
  openSync,
@@ -105,6 +106,13 @@ function validateSeed(value, path) {
105
106
  );
106
107
  }
107
108
  }
109
+ function validateUidSalt(value, path) {
110
+ if (typeof value !== "string" || value.length === 0) {
111
+ throw new Error(
112
+ `${path}: must be a non-empty string; got ${JSON.stringify(value)}`
113
+ );
114
+ }
115
+ }
108
116
  function validateSizeKbLimit(kb, path) {
109
117
  if (kb * 1024 > MAX_PIXEL_BYTES) {
110
118
  throw new Error(`${path}: exceeds the 512 MB limit`);
@@ -406,6 +414,7 @@ function validateDatasetSpec(raw) {
406
414
  (study, i) => validateStudy(study, `studies[${i}]`)
407
415
  );
408
416
  if ("seed" in r) validateSeed(r.seed, "DatasetSpec.seed");
417
+ if ("uidSalt" in r) validateUidSalt(r.uidSalt, "DatasetSpec.uidSalt");
409
418
  if ("layout" in r) {
410
419
  validateEnum(r.layout, VALID_LAYOUTS, "DatasetSpec.layout");
411
420
  }
@@ -421,11 +430,20 @@ function validateDatasetSpec(raw) {
421
430
  ...entries.length > 0 ? { entries } : {},
422
431
  ...studies.length > 0 ? { studies } : {},
423
432
  ...r.seed !== void 0 ? { seed: r.seed } : {},
433
+ ...r.uidSalt !== void 0 ? { uidSalt: r.uidSalt } : {},
424
434
  ...r.layout !== void 0 ? { layout: r.layout } : {},
425
435
  ...r.pathQuirks !== void 0 ? { pathQuirks: r.pathQuirks } : {}
426
436
  };
427
437
  }
428
438
 
439
+ // src/syntheticFixtures/uid.ts
440
+ import { createHash, randomBytes } from "node:crypto";
441
+ function hashUid(salt, key) {
442
+ const digest = createHash("sha256").update(salt).update(" ").update(key).digest();
443
+ const n = BigInt(`0x${digest.subarray(0, 16).toString("hex")}`);
444
+ return `2.25.${n.toString()}`;
445
+ }
446
+
429
447
  // src/describe/describe.ts
430
448
  var dcmjsAny = dcmjs;
431
449
  var HEX_TAG_RE2 = /^[0-9a-fA-F]{8}$/;
@@ -583,6 +601,53 @@ function extractTags(record, keepKeywords) {
583
601
  }
584
602
  return found ? tags : void 0;
585
603
  }
604
+ var DICOM_ORG_ROOT = "1.2.840.10008.";
605
+ var UID_SWEEP_EXCLUDE = /* @__PURE__ */ new Set([
606
+ "StudyInstanceUID",
607
+ "SeriesInstanceUID",
608
+ "SOPClassUID"
609
+ ]);
610
+ function vrOf(keyword) {
611
+ const nm = dcmjsAny.data.DicomMetaDictionary.nameMap;
612
+ return (nm?.[keyword] ?? nm?.[`RETIRED_${keyword}`])?.vr;
613
+ }
614
+ function hashUidVia(map, salt, original) {
615
+ let synthetic = map.get(original);
616
+ if (synthetic === void 0) {
617
+ synthetic = hashUid(salt, original);
618
+ map.set(original, synthetic);
619
+ }
620
+ return synthetic;
621
+ }
622
+ function collectUidOriginals(record) {
623
+ const uids = {};
624
+ let found = false;
625
+ for (const [keyword, value] of Object.entries(record)) {
626
+ if (UID_SWEEP_EXCLUDE.has(keyword)) continue;
627
+ if (typeof value !== "string" || value.startsWith(DICOM_ORG_ROOT)) continue;
628
+ if (vrOf(keyword) !== "UI") continue;
629
+ uids[keyword] = value;
630
+ found = true;
631
+ }
632
+ return found ? uids : void 0;
633
+ }
634
+ function hashUidTags(uids, salt, map) {
635
+ const tags = {};
636
+ for (const [keyword, original] of Object.entries(uids)) {
637
+ tags[keyword] = hashUidVia(map, salt, original);
638
+ }
639
+ return tags;
640
+ }
641
+ function deriveSalt(studyUids) {
642
+ const h = createHash2("sha256");
643
+ for (const uid of [...studyUids].sort()) h.update(uid).update("\n");
644
+ return h.digest("hex");
645
+ }
646
+ function mergeTags(a, b) {
647
+ if (!a) return b;
648
+ if (!b) return a;
649
+ return { ...a, ...b };
650
+ }
586
651
  function sizeKbOf(bytes) {
587
652
  return Math.max(1, Math.round(bytes / 1024));
588
653
  }
@@ -687,7 +752,9 @@ function seriesSpecFromAccum(accum) {
687
752
  }
688
753
  function describeDirectory(dir, options = {}) {
689
754
  const keepKeywords = options.keepTags ? resolveKeepTags(options.keepTags) : [];
690
- const keepTags = keepKeywords.length > 0;
755
+ const preserveUids = options.preserveUids ?? true;
756
+ const uidMap = /* @__PURE__ */ new Map();
757
+ const useRecords = keepKeywords.length > 0 || preserveUids;
691
758
  const tolerance = options.sizeTolerance ?? 0;
692
759
  if (!Number.isFinite(tolerance) || tolerance < 0) {
693
760
  throw new Error(
@@ -725,9 +792,16 @@ function describeDirectory(dir, options = {}) {
725
792
  stats.unknownModality++;
726
793
  }
727
794
  }
728
- const tags = keepTags ? extractTags(record, keepKeywords) : void 0;
795
+ const keptTags = keepKeywords.length ? extractTags(record, keepKeywords) : void 0;
796
+ const uidOriginals = preserveUids ? collectUidOriginals(record) : void 0;
729
797
  const bytes = large ? size : Math.min(bucketBytes(size, tolerance), MAX_PIXEL_BYTES);
730
- const fileRecord = { large, bytes, modality, tags };
798
+ const fileRecord = {
799
+ large,
800
+ bytes,
801
+ modality,
802
+ tags: keptTags,
803
+ uidOriginals
804
+ };
731
805
  let study = studies.get(studyUid);
732
806
  if (!study) {
733
807
  study = /* @__PURE__ */ new Map();
@@ -735,7 +809,7 @@ function describeDirectory(dir, options = {}) {
735
809
  }
736
810
  let series = study.get(seriesUid);
737
811
  if (!series) {
738
- series = keepTags ? [] : /* @__PURE__ */ new Map();
812
+ series = useRecords ? [] : /* @__PURE__ */ new Map();
739
813
  study.set(seriesUid, series);
740
814
  }
741
815
  if (Array.isArray(series)) {
@@ -744,13 +818,45 @@ function describeDirectory(dir, options = {}) {
744
818
  accumulate(series, fileRecord);
745
819
  }
746
820
  }
747
- const studySpecs = [...studies.values()].map((study) => ({
748
- series: [...study.values()].map(seriesSpecFromAccum)
749
- }));
821
+ const salt = preserveUids ? options.uidSalt ?? deriveSalt([...studies.keys()]) : void 0;
822
+ if (salt !== void 0) {
823
+ for (const study of studies.values()) {
824
+ for (const series of study.values()) {
825
+ if (!Array.isArray(series)) continue;
826
+ for (const rec of series) {
827
+ if (rec.uidOriginals) {
828
+ rec.tags = mergeTags(
829
+ rec.tags,
830
+ hashUidTags(rec.uidOriginals, salt, uidMap)
831
+ );
832
+ }
833
+ }
834
+ }
835
+ }
836
+ }
837
+ const withGroupUid = (spec2, seriesUid) => salt === void 0 ? spec2 : {
838
+ ...spec2,
839
+ tags: {
840
+ ...spec2.tags,
841
+ SeriesInstanceUID: hashUidVia(uidMap, salt, seriesUid)
842
+ }
843
+ };
844
+ const studySpecs = [...studies.entries()].map(
845
+ ([studyUid, study]) => ({
846
+ ...salt !== void 0 ? { tags: { StudyInstanceUID: hashUidVia(uidMap, salt, studyUid) } } : {},
847
+ series: [...study.entries()].map(
848
+ ([seriesUid, accum]) => withGroupUid(seriesSpecFromAccum(accum), seriesUid)
849
+ )
850
+ })
851
+ );
750
852
  if (studySpecs.length === 0) {
751
853
  throw new Error(`describe: no DICOM series found in ${dir}`);
752
854
  }
753
- const spec = { studies: studySpecs, layout: "hierarchical" };
855
+ const spec = {
856
+ studies: studySpecs,
857
+ layout: "hierarchical",
858
+ ...salt !== void 0 ? { uidSalt: salt } : {}
859
+ };
754
860
  validateDatasetSpec(spec);
755
861
  return { spec, stats };
756
862
  }
package/dist/esm/index.js CHANGED
@@ -154,6 +154,13 @@ function validateSeed(value, path) {
154
154
  );
155
155
  }
156
156
  }
157
+ function validateUidSalt(value, path) {
158
+ if (typeof value !== "string" || value.length === 0) {
159
+ throw new Error(
160
+ `${path}: must be a non-empty string; got ${JSON.stringify(value)}`
161
+ );
162
+ }
163
+ }
157
164
  function validateSizeKbLimit(kb, path) {
158
165
  if (kb * 1024 > MAX_PIXEL_BYTES) {
159
166
  throw new Error(`${path}: exceeds the 512 MB limit`);
@@ -455,6 +462,7 @@ function validateDatasetSpec(raw) {
455
462
  (study, i) => validateStudy(study, `studies[${i}]`)
456
463
  );
457
464
  if ("seed" in r) validateSeed(r.seed, "DatasetSpec.seed");
465
+ if ("uidSalt" in r) validateUidSalt(r.uidSalt, "DatasetSpec.uidSalt");
458
466
  if ("layout" in r) {
459
467
  validateEnum(r.layout, VALID_LAYOUTS, "DatasetSpec.layout");
460
468
  }
@@ -470,6 +478,7 @@ function validateDatasetSpec(raw) {
470
478
  ...entries.length > 0 ? { entries } : {},
471
479
  ...studies.length > 0 ? { studies } : {},
472
480
  ...r.seed !== void 0 ? { seed: r.seed } : {},
481
+ ...r.uidSalt !== void 0 ? { uidSalt: r.uidSalt } : {},
473
482
  ...r.layout !== void 0 ? { layout: r.layout } : {},
474
483
  ...r.pathQuirks !== void 0 ? { pathQuirks: r.pathQuirks } : {}
475
484
  };
@@ -585,6 +594,7 @@ function validateParametricSpec(raw) {
585
594
  }
586
595
  }
587
596
  if ("seed" in r) validateSeed(r.seed, "ParametricSpec.seed");
597
+ if ("uidSalt" in r) validateUidSalt(r.uidSalt, "ParametricSpec.uidSalt");
588
598
  if ("layout" in r) {
589
599
  validateEnum(r.layout, VALID_LAYOUTS, "ParametricSpec.layout");
590
600
  }
@@ -592,6 +602,7 @@ function validateParametricSpec(raw) {
592
602
  studies,
593
603
  ...edgeCases.length > 0 ? { edgeCases } : {},
594
604
  ...r.seed !== void 0 ? { seed: r.seed } : {},
605
+ ...r.uidSalt !== void 0 ? { uidSalt: r.uidSalt } : {},
595
606
  ...r.layout !== void 0 ? { layout: r.layout } : {}
596
607
  };
597
608
  }
@@ -779,8 +790,19 @@ import { closeSync, mkdirSync as mkdirSync2, openSync, writeSync } from "node:fs
779
790
  import { resolve as resolve2 } from "node:path";
780
791
 
781
792
  // src/syntheticFixtures/uid.ts
782
- import { randomBytes } from "node:crypto";
783
- var ROLE_CODE = { study: 1, series: 2, sop: 3 };
793
+ import { createHash, randomBytes } from "node:crypto";
794
+ function hashUid(salt, key) {
795
+ const digest = createHash("sha256").update(salt).update(" ").update(key).digest();
796
+ const n = BigInt(`0x${digest.subarray(0, 16).toString("hex")}`);
797
+ return `2.25.${n.toString()}`;
798
+ }
799
+ function randomUid() {
800
+ const n = BigInt(`0x${randomBytes(16).toString("hex")}`);
801
+ return `2.25.${n.toString()}`;
802
+ }
803
+ function seedToSalt(seed) {
804
+ return seed === void 0 ? void 0 : `seed:${seed}`;
805
+ }
784
806
  function lcg(seed) {
785
807
  let s = seed >>> 0;
786
808
  return () => {
@@ -788,60 +810,28 @@ function lcg(seed) {
788
810
  return s;
789
811
  };
790
812
  }
791
- function formatUid(a, b, role) {
792
- return `2.25.${a}.${b}.${ROLE_CODE[role]}`;
793
- }
794
- function randomUid(role) {
795
- const buf = randomBytes(8);
796
- return formatUid(buf.readUInt32BE(0), buf.readUInt32BE(4), role);
797
- }
798
- function seededUid(next, role) {
799
- return formatUid(next(), next(), role);
800
- }
801
- var GROUP_STREAM_OFFSET = 2654435769;
802
813
  var PARAMETRIC_STREAM_OFFSET = 2246822507;
803
814
  function seededStream(seed, offset, a, b) {
804
815
  return lcg(seed * 999983 + offset + a * 1000003 + b * 7919 >>> 0);
805
816
  }
806
- function makeGroupUidGenerator(seed) {
807
- if (seed === void 0) {
808
- return {
809
- study: () => randomUid("study"),
810
- series: () => randomUid("series")
811
- };
817
+ function makeGroupUidGenerator(salt) {
818
+ if (salt === void 0) {
819
+ return { study: () => randomUid(), series: () => randomUid() };
812
820
  }
813
821
  return {
814
- study: (studyIndex) => seededUid(
815
- seededStream(seed, GROUP_STREAM_OFFSET, studyIndex + 1, 0),
816
- "study"
817
- ),
818
- series: (studyIndex, seriesIndex) => seededUid(
819
- seededStream(
820
- seed,
821
- GROUP_STREAM_OFFSET,
822
- studyIndex + 1,
823
- seriesIndex + 1
824
- ),
825
- "series"
826
- )
822
+ study: (studyIndex) => hashUid(salt, `study:${studyIndex}`),
823
+ series: (studyIndex, seriesIndex) => hashUid(salt, `series:${studyIndex}:${seriesIndex}`)
827
824
  };
828
825
  }
829
- function makeUidGenerator(seed) {
830
- if (seed === void 0) {
831
- return () => ({
832
- study: randomUid("study"),
833
- series: randomUid("series"),
834
- sop: randomUid("sop")
835
- });
826
+ function makeUidGenerator(salt) {
827
+ if (salt === void 0) {
828
+ return () => ({ study: randomUid(), series: randomUid(), sop: randomUid() });
836
829
  }
837
- return (fileIndex) => {
838
- const next = seededStream(seed, 0, fileIndex, 0);
839
- return {
840
- study: seededUid(next, "study"),
841
- series: seededUid(next, "series"),
842
- sop: seededUid(next, "sop")
843
- };
844
- };
830
+ return (fileIndex) => ({
831
+ study: hashUid(salt, `study:flat:${fileIndex}`),
832
+ series: hashUid(salt, `series:flat:${fileIndex}`),
833
+ sop: hashUid(salt, `sop:${fileIndex}`)
834
+ });
845
835
  }
846
836
 
847
837
  // src/syntheticFixtures/streamWrite.ts
@@ -954,7 +944,7 @@ function streamLargeImage(outPath, options) {
954
944
  fragmentBytes = FRAGMENT_MAX_BYTES
955
945
  } = options;
956
946
  const modality = options.modality ?? "CT";
957
- const uid = options.uid ?? makeUidGenerator(options.seed)(0);
947
+ const uid = options.uid ?? makeUidGenerator(options.uidSalt ?? seedToSalt(options.seed))(0);
958
948
  const identity = { modality, uid, tags: options.tags };
959
949
  mkdirSync2(resolve2(outPath, ".."), { recursive: true });
960
950
  const zeroChunk = Buffer.alloc(Math.max(1, chunkBytes));
@@ -1006,6 +996,7 @@ function writeLargeImageFile(spec, outPath, options = {}) {
1006
996
  return streamLargeImage(outPath, {
1007
997
  targetBytes,
1008
998
  modality: spec.modality,
999
+ uidSalt: spec.uidSalt,
1009
1000
  seed: spec.seed,
1010
1001
  chunkBytes: options.chunkBytes
1011
1002
  });
@@ -1153,13 +1144,16 @@ function* seriesFiles(series) {
1153
1144
  }
1154
1145
  }
1155
1146
  }
1156
- function resolveUid(seed, index, override) {
1157
- return { ...makeUidGenerator(seed)(index), ...override };
1147
+ function resolveUid(salt, index, override) {
1148
+ return { ...makeUidGenerator(salt)(index), ...override };
1149
+ }
1150
+ function effectiveSalt(spec) {
1151
+ return spec.uidSalt ?? seedToSalt(spec.seed);
1158
1152
  }
1159
1153
  async function generateFile(spec, options) {
1160
1154
  const index = options?.index ?? 0;
1161
1155
  const padWidth = options?.padWidth ?? 3;
1162
- const uid = resolveUid(options?.seed, index, options?.uid);
1156
+ const uid = resolveUid(effectiveSalt(options ?? {}), index, options?.uid);
1163
1157
  const resolvedSpec = "tags" in spec && spec.tags ? {
1164
1158
  ...spec,
1165
1159
  tags: resolveTagTemplates(spec.tags, { index, ...options?.context })
@@ -1231,6 +1225,9 @@ function hierarchicalName(studyOrdinal, seriesIndex, instanceNumber) {
1231
1225
  function withResolvedSeed(spec) {
1232
1226
  return spec.seed === void 0 ? { ...spec, seed: randomBytes2(4).readUInt32BE(0) } : spec;
1233
1227
  }
1228
+ function withResolvedSalt(spec) {
1229
+ return spec.uidSalt === void 0 ? { ...spec, uidSalt: randomBytes2(16).toString("hex") } : spec;
1230
+ }
1234
1231
  function computeNames(type, index, padWidth, grouped, quirks) {
1235
1232
  let names;
1236
1233
  if (grouped) {
@@ -1271,7 +1268,7 @@ function* planCollection(spec) {
1271
1268
  globalIndex++;
1272
1269
  }
1273
1270
  }
1274
- const groupUids = makeGroupUidGenerator(spec.seed);
1271
+ const groupUids = makeGroupUidGenerator(effectiveSalt(spec));
1275
1272
  let studyOrdinal = 0;
1276
1273
  for (const study of studies) {
1277
1274
  const copies = study.count ?? 1;
@@ -1313,21 +1310,23 @@ function* planCollection(spec) {
1313
1310
  }
1314
1311
  }
1315
1312
  }
1316
- async function materialisePlan(plan, seed) {
1313
+ async function materialisePlan(plan, salt) {
1317
1314
  const file = await generateFile(plan.fileSpec, {
1318
1315
  index: plan.index,
1319
- seed,
1316
+ uidSalt: salt,
1320
1317
  uid: plan.uidOverride,
1321
1318
  context: plan.context
1322
1319
  });
1323
1320
  return { ...file, filename: plan.filename, relativePath: plan.relativePath };
1324
1321
  }
1325
1322
  async function* generateCollectionFromSpec(spec) {
1323
+ const salt = effectiveSalt(spec);
1326
1324
  for (const plan of planCollection(spec)) {
1327
- yield await materialisePlan(plan, spec.seed);
1325
+ yield await materialisePlan(plan, salt);
1328
1326
  }
1329
1327
  }
1330
1328
  async function* previewCollection(spec) {
1329
+ const salt = effectiveSalt(spec);
1331
1330
  for (const plan of planCollection(spec)) {
1332
1331
  if (plan.fileSpec.type === "large-image") {
1333
1332
  yield {
@@ -1337,7 +1336,7 @@ async function* previewCollection(spec) {
1337
1336
  approxBytes: plan.fileSpec.targetBytes
1338
1337
  };
1339
1338
  } else {
1340
- const file = await materialisePlan(plan, spec.seed);
1339
+ const file = await materialisePlan(plan, salt);
1341
1340
  yield {
1342
1341
  relativePath: plan.relativePath,
1343
1342
  type: plan.fileSpec.type,
@@ -1351,6 +1350,7 @@ async function writeCollectionFromSpec(spec, outDir) {
1351
1350
  const root = resolve3(outDir);
1352
1351
  mkdirSync3(root, { recursive: true });
1353
1352
  const manifest = [];
1353
+ const salt = effectiveSalt(spec);
1354
1354
  const createdDirs = /* @__PURE__ */ new Set([root]);
1355
1355
  const ensureDir = (filePath) => {
1356
1356
  const dir = dirname(filePath);
@@ -1369,7 +1369,7 @@ async function writeCollectionFromSpec(spec, outDir) {
1369
1369
  `large-image targetBytes (${large.targetBytes}) exceeds the 50 GB limit`
1370
1370
  );
1371
1371
  }
1372
- const uid = resolveUid(spec.seed, plan.index, plan.uidOverride);
1372
+ const uid = resolveUid(salt, plan.index, plan.uidOverride);
1373
1373
  streamLargeImage(filePath, {
1374
1374
  targetBytes: large.targetBytes,
1375
1375
  modality: large.modality,
@@ -1377,7 +1377,7 @@ async function writeCollectionFromSpec(spec, outDir) {
1377
1377
  tags: resolveTagTemplates(large.tags, plan.context)
1378
1378
  });
1379
1379
  } else {
1380
- const file = await materialisePlan(plan, spec.seed);
1380
+ const file = await materialisePlan(plan, salt);
1381
1381
  await writeFile(filePath, file.buffer);
1382
1382
  }
1383
1383
  manifest.push({
@@ -1390,6 +1390,7 @@ async function writeCollectionFromSpec(spec, outDir) {
1390
1390
  }
1391
1391
 
1392
1392
  // src/describe/describe.ts
1393
+ import { createHash as createHash2 } from "node:crypto";
1393
1394
  import {
1394
1395
  closeSync as closeSync2,
1395
1396
  openSync as openSync2,
@@ -1555,6 +1556,53 @@ function extractTags(record, keepKeywords) {
1555
1556
  }
1556
1557
  return found ? tags : void 0;
1557
1558
  }
1559
+ var DICOM_ORG_ROOT = "1.2.840.10008.";
1560
+ var UID_SWEEP_EXCLUDE = /* @__PURE__ */ new Set([
1561
+ "StudyInstanceUID",
1562
+ "SeriesInstanceUID",
1563
+ "SOPClassUID"
1564
+ ]);
1565
+ function vrOf(keyword) {
1566
+ const nm = dcmjsAny2.data.DicomMetaDictionary.nameMap;
1567
+ return (nm?.[keyword] ?? nm?.[`RETIRED_${keyword}`])?.vr;
1568
+ }
1569
+ function hashUidVia(map, salt, original) {
1570
+ let synthetic = map.get(original);
1571
+ if (synthetic === void 0) {
1572
+ synthetic = hashUid(salt, original);
1573
+ map.set(original, synthetic);
1574
+ }
1575
+ return synthetic;
1576
+ }
1577
+ function collectUidOriginals(record) {
1578
+ const uids = {};
1579
+ let found = false;
1580
+ for (const [keyword, value] of Object.entries(record)) {
1581
+ if (UID_SWEEP_EXCLUDE.has(keyword)) continue;
1582
+ if (typeof value !== "string" || value.startsWith(DICOM_ORG_ROOT)) continue;
1583
+ if (vrOf(keyword) !== "UI") continue;
1584
+ uids[keyword] = value;
1585
+ found = true;
1586
+ }
1587
+ return found ? uids : void 0;
1588
+ }
1589
+ function hashUidTags(uids, salt, map) {
1590
+ const tags = {};
1591
+ for (const [keyword, original] of Object.entries(uids)) {
1592
+ tags[keyword] = hashUidVia(map, salt, original);
1593
+ }
1594
+ return tags;
1595
+ }
1596
+ function deriveSalt(studyUids) {
1597
+ const h = createHash2("sha256");
1598
+ for (const uid of [...studyUids].sort()) h.update(uid).update("\n");
1599
+ return h.digest("hex");
1600
+ }
1601
+ function mergeTags(a, b) {
1602
+ if (!a) return b;
1603
+ if (!b) return a;
1604
+ return { ...a, ...b };
1605
+ }
1558
1606
  function sizeKbOf(bytes) {
1559
1607
  return Math.max(1, Math.round(bytes / 1024));
1560
1608
  }
@@ -1659,7 +1707,9 @@ function seriesSpecFromAccum(accum) {
1659
1707
  }
1660
1708
  function describeDirectory(dir, options = {}) {
1661
1709
  const keepKeywords = options.keepTags ? resolveKeepTags(options.keepTags) : [];
1662
- const keepTags = keepKeywords.length > 0;
1710
+ const preserveUids = options.preserveUids ?? true;
1711
+ const uidMap = /* @__PURE__ */ new Map();
1712
+ const useRecords = keepKeywords.length > 0 || preserveUids;
1663
1713
  const tolerance = options.sizeTolerance ?? 0;
1664
1714
  if (!Number.isFinite(tolerance) || tolerance < 0) {
1665
1715
  throw new Error(
@@ -1697,9 +1747,16 @@ function describeDirectory(dir, options = {}) {
1697
1747
  stats.unknownModality++;
1698
1748
  }
1699
1749
  }
1700
- const tags = keepTags ? extractTags(record, keepKeywords) : void 0;
1750
+ const keptTags = keepKeywords.length ? extractTags(record, keepKeywords) : void 0;
1751
+ const uidOriginals = preserveUids ? collectUidOriginals(record) : void 0;
1701
1752
  const bytes = large ? size : Math.min(bucketBytes(size, tolerance), MAX_PIXEL_BYTES);
1702
- const fileRecord = { large, bytes, modality, tags };
1753
+ const fileRecord = {
1754
+ large,
1755
+ bytes,
1756
+ modality,
1757
+ tags: keptTags,
1758
+ uidOriginals
1759
+ };
1703
1760
  let study = studies.get(studyUid);
1704
1761
  if (!study) {
1705
1762
  study = /* @__PURE__ */ new Map();
@@ -1707,7 +1764,7 @@ function describeDirectory(dir, options = {}) {
1707
1764
  }
1708
1765
  let series = study.get(seriesUid);
1709
1766
  if (!series) {
1710
- series = keepTags ? [] : /* @__PURE__ */ new Map();
1767
+ series = useRecords ? [] : /* @__PURE__ */ new Map();
1711
1768
  study.set(seriesUid, series);
1712
1769
  }
1713
1770
  if (Array.isArray(series)) {
@@ -1716,13 +1773,45 @@ function describeDirectory(dir, options = {}) {
1716
1773
  accumulate(series, fileRecord);
1717
1774
  }
1718
1775
  }
1719
- const studySpecs = [...studies.values()].map((study) => ({
1720
- series: [...study.values()].map(seriesSpecFromAccum)
1721
- }));
1776
+ const salt = preserveUids ? options.uidSalt ?? deriveSalt([...studies.keys()]) : void 0;
1777
+ if (salt !== void 0) {
1778
+ for (const study of studies.values()) {
1779
+ for (const series of study.values()) {
1780
+ if (!Array.isArray(series)) continue;
1781
+ for (const rec of series) {
1782
+ if (rec.uidOriginals) {
1783
+ rec.tags = mergeTags(
1784
+ rec.tags,
1785
+ hashUidTags(rec.uidOriginals, salt, uidMap)
1786
+ );
1787
+ }
1788
+ }
1789
+ }
1790
+ }
1791
+ }
1792
+ const withGroupUid = (spec2, seriesUid) => salt === void 0 ? spec2 : {
1793
+ ...spec2,
1794
+ tags: {
1795
+ ...spec2.tags,
1796
+ SeriesInstanceUID: hashUidVia(uidMap, salt, seriesUid)
1797
+ }
1798
+ };
1799
+ const studySpecs = [...studies.entries()].map(
1800
+ ([studyUid, study]) => ({
1801
+ ...salt !== void 0 ? { tags: { StudyInstanceUID: hashUidVia(uidMap, salt, studyUid) } } : {},
1802
+ series: [...study.entries()].map(
1803
+ ([seriesUid, accum]) => withGroupUid(seriesSpecFromAccum(accum), seriesUid)
1804
+ )
1805
+ })
1806
+ );
1722
1807
  if (studySpecs.length === 0) {
1723
1808
  throw new Error(`describe: no DICOM series found in ${dir}`);
1724
1809
  }
1725
- const spec = { studies: studySpecs, layout: "hierarchical" };
1810
+ const spec = {
1811
+ studies: studySpecs,
1812
+ layout: "hierarchical",
1813
+ ...salt !== void 0 ? { uidSalt: salt } : {}
1814
+ };
1726
1815
  validateDatasetSpec(spec);
1727
1816
  return { spec, stats };
1728
1817
  }
@@ -1759,7 +1848,7 @@ function loadCaseById(casesJsonPath, id) {
1759
1848
  }
1760
1849
 
1761
1850
  // src/public-fixtures/fetch.ts
1762
- import { createHash } from "node:crypto";
1851
+ import { createHash as createHash3 } from "node:crypto";
1763
1852
  import { existsSync, mkdirSync as mkdirSync4, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "node:fs";
1764
1853
  import { homedir } from "node:os";
1765
1854
  import { join as join3 } from "node:path";
@@ -1768,7 +1857,7 @@ function caseCachePath(sha256, root = DEFAULT_CACHE_ROOT) {
1768
1857
  return join3(root, sha256, "file.dcm");
1769
1858
  }
1770
1859
  function verifySha256(buffer, expected) {
1771
- const h = createHash("sha256").update(buffer).digest("hex");
1860
+ const h = createHash3("sha256").update(buffer).digest("hex");
1772
1861
  if (h !== expected) {
1773
1862
  throw new Error(
1774
1863
  `SHA256 mismatch: expected ${expected}, got ${h}. Upstream may have changed; bump metadata after review.`
@@ -1900,6 +1989,9 @@ function resolveParametricSpec(spec) {
1900
1989
  ...entries.length > 0 ? { entries } : {},
1901
1990
  studies,
1902
1991
  seed,
1992
+ // Pass an explicit salt through; when absent, UIDs derive from `seed` so the
1993
+ // resolved spec stays deterministic ("same seed → identical resolved spec").
1994
+ ...validated.uidSalt !== void 0 ? { uidSalt: validated.uidSalt } : {},
1903
1995
  ...validated.layout !== void 0 ? { layout: validated.layout } : {}
1904
1996
  };
1905
1997
  }
@@ -1913,16 +2005,19 @@ export {
1913
2005
  fetchPublicCaseToCache,
1914
2006
  generateCollectionFromSpec,
1915
2007
  generateFile,
2008
+ hashUid,
1916
2009
  loadCaseById,
1917
2010
  loadCasesFromJson,
1918
2011
  loadDefaultCases,
1919
2012
  previewCollection,
1920
2013
  resolveParametricSpec,
1921
2014
  resolveTagTemplates,
2015
+ seedToSalt,
1922
2016
  streamLargeImage,
1923
2017
  validateDatasetSpec,
1924
2018
  validateParametricSpec,
1925
2019
  verifySha256,
2020
+ withResolvedSalt,
1926
2021
  withResolvedSeed,
1927
2022
  writeCollectionFromSpec,
1928
2023
  writeLargeImageFile
@@ -2,7 +2,7 @@
2
2
  import { randomBytes as randomBytes2 } from "node:crypto";
3
3
 
4
4
  // src/syntheticFixtures/uid.ts
5
- import { randomBytes } from "node:crypto";
5
+ import { createHash, randomBytes } from "node:crypto";
6
6
  function lcg(seed) {
7
7
  let s = seed >>> 0;
8
8
  return () => {
@@ -90,6 +90,13 @@ function validateSeed(value, path) {
90
90
  );
91
91
  }
92
92
  }
93
+ function validateUidSalt(value, path) {
94
+ if (typeof value !== "string" || value.length === 0) {
95
+ throw new Error(
96
+ `${path}: must be a non-empty string; got ${JSON.stringify(value)}`
97
+ );
98
+ }
99
+ }
93
100
  function validateSizeKbLimit(kb, path) {
94
101
  if (kb * 1024 > MAX_PIXEL_BYTES) {
95
102
  throw new Error(`${path}: exceeds the 512 MB limit`);
@@ -361,6 +368,7 @@ function validateParametricSpec(raw) {
361
368
  }
362
369
  }
363
370
  if ("seed" in r) validateSeed(r.seed, "ParametricSpec.seed");
371
+ if ("uidSalt" in r) validateUidSalt(r.uidSalt, "ParametricSpec.uidSalt");
364
372
  if ("layout" in r) {
365
373
  validateEnum(r.layout, VALID_LAYOUTS, "ParametricSpec.layout");
366
374
  }
@@ -368,6 +376,7 @@ function validateParametricSpec(raw) {
368
376
  studies,
369
377
  ...edgeCases.length > 0 ? { edgeCases } : {},
370
378
  ...r.seed !== void 0 ? { seed: r.seed } : {},
379
+ ...r.uidSalt !== void 0 ? { uidSalt: r.uidSalt } : {},
371
380
  ...r.layout !== void 0 ? { layout: r.layout } : {}
372
381
  };
373
382
  }
@@ -471,6 +480,9 @@ function resolveParametricSpec(spec) {
471
480
  ...entries.length > 0 ? { entries } : {},
472
481
  studies,
473
482
  seed,
483
+ // Pass an explicit salt through; when absent, UIDs derive from `seed` so the
484
+ // resolved spec stays deterministic ("same seed → identical resolved spec").
485
+ ...validated.uidSalt !== void 0 ? { uidSalt: validated.uidSalt } : {},
474
486
  ...validated.layout !== void 0 ? { layout: validated.layout } : {}
475
487
  };
476
488
  }
@@ -79,6 +79,13 @@ function validateSeed(value, path) {
79
79
  );
80
80
  }
81
81
  }
82
+ function validateUidSalt(value, path) {
83
+ if (typeof value !== "string" || value.length === 0) {
84
+ throw new Error(
85
+ `${path}: must be a non-empty string; got ${JSON.stringify(value)}`
86
+ );
87
+ }
88
+ }
82
89
  function validateSizeKbLimit(kb, path) {
83
90
  if (kb * 1024 > MAX_PIXEL_BYTES) {
84
91
  throw new Error(`${path}: exceeds the 512 MB limit`);
@@ -380,6 +387,7 @@ function validateDatasetSpec(raw) {
380
387
  (study, i) => validateStudy(study, `studies[${i}]`)
381
388
  );
382
389
  if ("seed" in r) validateSeed(r.seed, "DatasetSpec.seed");
390
+ if ("uidSalt" in r) validateUidSalt(r.uidSalt, "DatasetSpec.uidSalt");
383
391
  if ("layout" in r) {
384
392
  validateEnum(r.layout, VALID_LAYOUTS, "DatasetSpec.layout");
385
393
  }
@@ -395,6 +403,7 @@ function validateDatasetSpec(raw) {
395
403
  ...entries.length > 0 ? { entries } : {},
396
404
  ...studies.length > 0 ? { studies } : {},
397
405
  ...r.seed !== void 0 ? { seed: r.seed } : {},
406
+ ...r.uidSalt !== void 0 ? { uidSalt: r.uidSalt } : {},
398
407
  ...r.layout !== void 0 ? { layout: r.layout } : {},
399
408
  ...r.pathQuirks !== void 0 ? { pathQuirks: r.pathQuirks } : {}
400
409
  };
@@ -510,6 +519,7 @@ function validateParametricSpec(raw) {
510
519
  }
511
520
  }
512
521
  if ("seed" in r) validateSeed(r.seed, "ParametricSpec.seed");
522
+ if ("uidSalt" in r) validateUidSalt(r.uidSalt, "ParametricSpec.uidSalt");
513
523
  if ("layout" in r) {
514
524
  validateEnum(r.layout, VALID_LAYOUTS, "ParametricSpec.layout");
515
525
  }
@@ -517,6 +527,7 @@ function validateParametricSpec(raw) {
517
527
  studies,
518
528
  ...edgeCases.length > 0 ? { edgeCases } : {},
519
529
  ...r.seed !== void 0 ? { seed: r.seed } : {},
530
+ ...r.uidSalt !== void 0 ? { uidSalt: r.uidSalt } : {},
520
531
  ...r.layout !== void 0 ? { layout: r.layout } : {}
521
532
  };
522
533
  }
@@ -144,44 +144,28 @@ function serializeDict(meta, dataset) {
144
144
  }
145
145
 
146
146
  // src/syntheticFixtures/uid.ts
147
- import { randomBytes } from "node:crypto";
148
- var ROLE_CODE = { study: 1, series: 2, sop: 3 };
149
- function lcg(seed) {
150
- let s = seed >>> 0;
151
- return () => {
152
- s = Math.imul(1664525, s) + 1013904223 >>> 0;
153
- return s;
154
- };
155
- }
156
- function formatUid(a, b, role) {
157
- return `2.25.${a}.${b}.${ROLE_CODE[role]}`;
147
+ import { createHash, randomBytes } from "node:crypto";
148
+ function hashUid(salt, key) {
149
+ const digest = createHash("sha256").update(salt).update(" ").update(key).digest();
150
+ const n = BigInt(`0x${digest.subarray(0, 16).toString("hex")}`);
151
+ return `2.25.${n.toString()}`;
158
152
  }
159
- function randomUid(role) {
160
- const buf = randomBytes(8);
161
- return formatUid(buf.readUInt32BE(0), buf.readUInt32BE(4), role);
153
+ function randomUid() {
154
+ const n = BigInt(`0x${randomBytes(16).toString("hex")}`);
155
+ return `2.25.${n.toString()}`;
162
156
  }
163
- function seededUid(next, role) {
164
- return formatUid(next(), next(), role);
157
+ function seedToSalt(seed) {
158
+ return seed === void 0 ? void 0 : `seed:${seed}`;
165
159
  }
166
- function seededStream(seed, offset, a, b) {
167
- return lcg(seed * 999983 + offset + a * 1000003 + b * 7919 >>> 0);
168
- }
169
- function makeUidGenerator(seed) {
170
- if (seed === void 0) {
171
- return () => ({
172
- study: randomUid("study"),
173
- series: randomUid("series"),
174
- sop: randomUid("sop")
175
- });
160
+ function makeUidGenerator(salt) {
161
+ if (salt === void 0) {
162
+ return () => ({ study: randomUid(), series: randomUid(), sop: randomUid() });
176
163
  }
177
- return (fileIndex) => {
178
- const next = seededStream(seed, 0, fileIndex, 0);
179
- return {
180
- study: seededUid(next, "study"),
181
- series: seededUid(next, "series"),
182
- sop: seededUid(next, "sop")
183
- };
184
- };
164
+ return (fileIndex) => ({
165
+ study: hashUid(salt, `study:flat:${fileIndex}`),
166
+ series: hashUid(salt, `series:flat:${fileIndex}`),
167
+ sop: hashUid(salt, `sop:${fileIndex}`)
168
+ });
185
169
  }
186
170
 
187
171
  // src/syntheticFixtures/streamWrite.ts
@@ -294,7 +278,7 @@ function streamLargeImage(outPath, options) {
294
278
  fragmentBytes = FRAGMENT_MAX_BYTES
295
279
  } = options;
296
280
  const modality = options.modality ?? "CT";
297
- const uid = options.uid ?? makeUidGenerator(options.seed)(0);
281
+ const uid = options.uid ?? makeUidGenerator(options.uidSalt ?? seedToSalt(options.seed))(0);
298
282
  const identity = { modality, uid, tags: options.tags };
299
283
  mkdirSync2(resolve2(outPath, ".."), { recursive: true });
300
284
  const zeroChunk = Buffer.alloc(Math.max(1, chunkBytes));
@@ -346,6 +330,7 @@ function writeLargeImageFile(spec, outPath, options = {}) {
346
330
  return streamLargeImage(outPath, {
347
331
  targetBytes,
348
332
  modality: spec.modality,
333
+ uidSalt: spec.uidSalt,
349
334
  seed: spec.seed,
350
335
  chunkBytes: options.chunkBytes
351
336
  });
@@ -1,6 +1,17 @@
1
1
  // src/syntheticFixtures/uid.ts
2
- import { randomBytes } from "node:crypto";
3
- var ROLE_CODE = { study: 1, series: 2, sop: 3 };
2
+ import { createHash, randomBytes } from "node:crypto";
3
+ function hashUid(salt, key) {
4
+ const digest = createHash("sha256").update(salt).update(" ").update(key).digest();
5
+ const n = BigInt(`0x${digest.subarray(0, 16).toString("hex")}`);
6
+ return `2.25.${n.toString()}`;
7
+ }
8
+ function randomUid() {
9
+ const n = BigInt(`0x${randomBytes(16).toString("hex")}`);
10
+ return `2.25.${n.toString()}`;
11
+ }
12
+ function seedToSalt(seed) {
13
+ return seed === void 0 ? void 0 : `seed:${seed}`;
14
+ }
4
15
  function lcg(seed) {
5
16
  let s = seed >>> 0;
6
17
  return () => {
@@ -8,64 +19,34 @@ function lcg(seed) {
8
19
  return s;
9
20
  };
10
21
  }
11
- function formatUid(a, b, role) {
12
- return `2.25.${a}.${b}.${ROLE_CODE[role]}`;
13
- }
14
- function randomUid(role) {
15
- const buf = randomBytes(8);
16
- return formatUid(buf.readUInt32BE(0), buf.readUInt32BE(4), role);
17
- }
18
- function seededUid(next, role) {
19
- return formatUid(next(), next(), role);
20
- }
21
- var GROUP_STREAM_OFFSET = 2654435769;
22
22
  var PARAMETRIC_STREAM_OFFSET = 2246822507;
23
23
  function seededStream(seed, offset, a, b) {
24
24
  return lcg(seed * 999983 + offset + a * 1000003 + b * 7919 >>> 0);
25
25
  }
26
- function makeGroupUidGenerator(seed) {
27
- if (seed === void 0) {
28
- return {
29
- study: () => randomUid("study"),
30
- series: () => randomUid("series")
31
- };
26
+ function makeGroupUidGenerator(salt) {
27
+ if (salt === void 0) {
28
+ return { study: () => randomUid(), series: () => randomUid() };
32
29
  }
33
30
  return {
34
- study: (studyIndex) => seededUid(
35
- seededStream(seed, GROUP_STREAM_OFFSET, studyIndex + 1, 0),
36
- "study"
37
- ),
38
- series: (studyIndex, seriesIndex) => seededUid(
39
- seededStream(
40
- seed,
41
- GROUP_STREAM_OFFSET,
42
- studyIndex + 1,
43
- seriesIndex + 1
44
- ),
45
- "series"
46
- )
31
+ study: (studyIndex) => hashUid(salt, `study:${studyIndex}`),
32
+ series: (studyIndex, seriesIndex) => hashUid(salt, `series:${studyIndex}:${seriesIndex}`)
47
33
  };
48
34
  }
49
- function makeUidGenerator(seed) {
50
- if (seed === void 0) {
51
- return () => ({
52
- study: randomUid("study"),
53
- series: randomUid("series"),
54
- sop: randomUid("sop")
55
- });
35
+ function makeUidGenerator(salt) {
36
+ if (salt === void 0) {
37
+ return () => ({ study: randomUid(), series: randomUid(), sop: randomUid() });
56
38
  }
57
- return (fileIndex) => {
58
- const next = seededStream(seed, 0, fileIndex, 0);
59
- return {
60
- study: seededUid(next, "study"),
61
- series: seededUid(next, "series"),
62
- sop: seededUid(next, "sop")
63
- };
64
- };
39
+ return (fileIndex) => ({
40
+ study: hashUid(salt, `study:flat:${fileIndex}`),
41
+ series: hashUid(salt, `series:flat:${fileIndex}`),
42
+ sop: hashUid(salt, `sop:${fileIndex}`)
43
+ });
65
44
  }
66
45
  export {
67
46
  PARAMETRIC_STREAM_OFFSET,
47
+ hashUid,
68
48
  makeGroupUidGenerator,
69
49
  makeUidGenerator,
50
+ seedToSalt,
70
51
  seededStream
71
52
  };
@@ -16,11 +16,13 @@ export type CollectionManifest = Array<{
16
16
  export declare function generateFile(spec: FileSpec, options?: {
17
17
  index?: number;
18
18
  seed?: number;
19
+ uidSalt?: string;
19
20
  padWidth?: number;
20
21
  uid?: Partial<UidSet>;
21
22
  context?: Partial<TagContext>;
22
23
  }): Promise<GeneratedFile>;
23
24
  export declare function withResolvedSeed(spec: DatasetSpec): DatasetSpec;
25
+ export declare function withResolvedSalt(spec: DatasetSpec): DatasetSpec;
24
26
  export declare function generateCollectionFromSpec(spec: DatasetSpec): AsyncGenerator<GeneratedFile>;
25
27
  export type CollectionPreviewItem = {
26
28
  relativePath: string;
@@ -2,6 +2,8 @@ import type { DatasetSpec } from '../schema/types.js';
2
2
  export type DescribeOptions = {
3
3
  keepTags?: string[];
4
4
  sizeTolerance?: number;
5
+ preserveUids?: boolean;
6
+ uidSalt?: string;
5
7
  };
6
8
  export type DescribeResult = {
7
9
  spec: DatasetSpec;
@@ -1,4 +1,4 @@
1
- export { type CollectionManifest, type CollectionPreviewItem, type GeneratedFile, generateCollectionFromSpec, generateFile, previewCollection, withResolvedSeed, writeCollectionFromSpec, } from './collection/writer.js';
1
+ export { type CollectionManifest, type CollectionPreviewItem, type GeneratedFile, generateCollectionFromSpec, generateFile, previewCollection, withResolvedSalt, withResolvedSeed, writeCollectionFromSpec, } from './collection/writer.js';
2
2
  export { type DescribeOptions, type DescribeResult, describeDirectory, } from './describe/describe.js';
3
3
  export { defaultPublicCasesPath, loadCaseById, loadCasesFromJson, loadDefaultCases, type PublicCaseRecord, type PublicCaseSource, } from './public-fixtures/catalog.js';
4
4
  export { caseCachePath, fetchPublicCaseToCache, verifySha256, } from './public-fixtures/fetch.js';
@@ -8,3 +8,4 @@ export type { DatasetLayout, DatasetSpec, DicomdirSpec, DicomTagOverrides, FakeS
8
8
  export { validateDatasetSpec, validateParametricSpec, } from './schema/validate.js';
9
9
  export { type LargeFileEncoding, type LargeFileResult, type LargeImageSpec, type StreamLargeImageOptions, streamLargeImage, writeLargeImageFile, } from './syntheticFixtures/streamWrite.js';
10
10
  export { resolveTagTemplates, type TagContext, TEMPLATE_VOCAB, } from './syntheticFixtures/tagTemplate.js';
11
+ export { hashUid, seedToSalt } from './syntheticFixtures/uid.js';
@@ -74,6 +74,7 @@ export type DatasetSpec = {
74
74
  entries?: EntrySpec[];
75
75
  studies?: StudySpec[];
76
76
  seed?: number;
77
+ uidSalt?: string;
77
78
  layout?: DatasetLayout;
78
79
  pathQuirks?: PathQuirk[];
79
80
  };
@@ -96,6 +97,7 @@ export type ParametricEdgeCase = EntrySpec & {
96
97
  };
97
98
  export type ParametricSpec = {
98
99
  seed?: number;
100
+ uidSalt?: string;
99
101
  studies: ParametricStudies;
100
102
  edgeCases?: ParametricEdgeCase[];
101
103
  layout?: DatasetLayout;
@@ -4,7 +4,9 @@ export type LargeImageSpec = {
4
4
  /** Approximate total file size in bytes (must exceed the 512 MB cap). */
5
5
  targetBytes: number;
6
6
  modality?: Modality;
7
- /** Drawn when omitted; fixes the instance UIDs for reproducibility. */
7
+ /** UID namespace (hash-derived UIDs); random when both salt and seed omitted. */
8
+ uidSalt?: string;
9
+ /** Bridged to a salt when uidSalt is omitted; fixes the instance UIDs. */
8
10
  seed?: number;
9
11
  };
10
12
  export type LargeFileEncoding = 'native' | 'encapsulated';
@@ -18,7 +20,9 @@ export type StreamLargeImageOptions = {
18
20
  /** Approximate total file size in bytes (no floor — small values are valid). */
19
21
  targetBytes: number;
20
22
  modality?: Modality;
21
- /** Drawn when omitted; fixes the instance UIDs for reproducibility. */
23
+ /** UID namespace (hash-derived UIDs); random when both salt and seed omitted. */
24
+ uidSalt?: string;
25
+ /** Bridged to a salt when uidSalt is omitted; fixes the instance UIDs. */
22
26
  seed?: number;
23
27
  /** Explicit UID set — used as-is (e.g. collection threading shared study/series UIDs). */
24
28
  uid?: UidSet;
@@ -3,12 +3,14 @@ export type UidSet = {
3
3
  series: string;
4
4
  sop: string;
5
5
  };
6
+ export declare function hashUid(salt: string, key: string): string;
7
+ export declare function seedToSalt(seed: number | undefined): string | undefined;
6
8
  export declare const PARAMETRIC_STREAM_OFFSET = 2246822507;
7
9
  export declare function seededStream(seed: number, offset: number, a: number, b: number): () => number;
8
10
  export type GroupUidGenerator = {
9
11
  study: (studyIndex: number) => string;
10
12
  series: (studyIndex: number, seriesIndex: number) => string;
11
13
  };
12
- export declare function makeGroupUidGenerator(seed?: number): GroupUidGenerator;
14
+ export declare function makeGroupUidGenerator(salt?: string): GroupUidGenerator;
13
15
  export type UidGenerator = (fileIndex: number) => UidSet;
14
- export declare function makeUidGenerator(seed?: number): UidGenerator;
16
+ export declare function makeUidGenerator(salt?: string): UidGenerator;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dicom-synth",
3
- "version": "1.11.0",
3
+ "version": "1.13.0",
4
4
  "description": "Toolkit for synthetic DICOM fixtures and public fixture fetch/cache.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",