dicom-synth 1.12.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)`,
@@ -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,
@@ -435,6 +436,14 @@ function validateDatasetSpec(raw) {
435
436
  };
436
437
  }
437
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
+
438
447
  // src/describe/describe.ts
439
448
  var dcmjsAny = dcmjs;
440
449
  var HEX_TAG_RE2 = /^[0-9a-fA-F]{8}$/;
@@ -592,6 +601,53 @@ function extractTags(record, keepKeywords) {
592
601
  }
593
602
  return found ? tags : void 0;
594
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
+ }
595
651
  function sizeKbOf(bytes) {
596
652
  return Math.max(1, Math.round(bytes / 1024));
597
653
  }
@@ -696,7 +752,9 @@ function seriesSpecFromAccum(accum) {
696
752
  }
697
753
  function describeDirectory(dir, options = {}) {
698
754
  const keepKeywords = options.keepTags ? resolveKeepTags(options.keepTags) : [];
699
- const keepTags = keepKeywords.length > 0;
755
+ const preserveUids = options.preserveUids ?? true;
756
+ const uidMap = /* @__PURE__ */ new Map();
757
+ const useRecords = keepKeywords.length > 0 || preserveUids;
700
758
  const tolerance = options.sizeTolerance ?? 0;
701
759
  if (!Number.isFinite(tolerance) || tolerance < 0) {
702
760
  throw new Error(
@@ -734,9 +792,16 @@ function describeDirectory(dir, options = {}) {
734
792
  stats.unknownModality++;
735
793
  }
736
794
  }
737
- 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;
738
797
  const bytes = large ? size : Math.min(bucketBytes(size, tolerance), MAX_PIXEL_BYTES);
739
- const fileRecord = { large, bytes, modality, tags };
798
+ const fileRecord = {
799
+ large,
800
+ bytes,
801
+ modality,
802
+ tags: keptTags,
803
+ uidOriginals
804
+ };
740
805
  let study = studies.get(studyUid);
741
806
  if (!study) {
742
807
  study = /* @__PURE__ */ new Map();
@@ -744,7 +809,7 @@ function describeDirectory(dir, options = {}) {
744
809
  }
745
810
  let series = study.get(seriesUid);
746
811
  if (!series) {
747
- series = keepTags ? [] : /* @__PURE__ */ new Map();
812
+ series = useRecords ? [] : /* @__PURE__ */ new Map();
748
813
  study.set(seriesUid, series);
749
814
  }
750
815
  if (Array.isArray(series)) {
@@ -753,13 +818,45 @@ function describeDirectory(dir, options = {}) {
753
818
  accumulate(series, fileRecord);
754
819
  }
755
820
  }
756
- const studySpecs = [...studies.values()].map((study) => ({
757
- series: [...study.values()].map(seriesSpecFromAccum)
758
- }));
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
+ );
759
852
  if (studySpecs.length === 0) {
760
853
  throw new Error(`describe: no DICOM series found in ${dir}`);
761
854
  }
762
- const spec = { studies: studySpecs, layout: "hierarchical" };
855
+ const spec = {
856
+ studies: studySpecs,
857
+ layout: "hierarchical",
858
+ ...salt !== void 0 ? { uidSalt: salt } : {}
859
+ };
763
860
  validateDatasetSpec(spec);
764
861
  return { spec, stats };
765
862
  }
package/dist/esm/index.js CHANGED
@@ -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 as createHash2 } 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 = createHash2("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.`
@@ -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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dicom-synth",
3
- "version": "1.12.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",