dicom-synth 1.14.0 → 1.16.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,7 +6,7 @@ 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>] [--no-preserve-uids] [--uid-salt <hex>] [--gzip]\n' +
9
+ 'Usage: dicom-synth-describe <dir> [--out <spec.json[.gz]>] [--tree] [--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' +
@@ -14,6 +14,9 @@ function usage() {
14
14
  'UIDs are preserved by default as hash-derived synthetic UIDs, keeping\n' +
15
15
  'cross-file links (FrameOfReferenceUID etc.) without emitting any original\n' +
16
16
  'UID. Other tags are not copied unless named.\n' +
17
+ '--tree captures the real directory structure as a generic tree spec\n' +
18
+ '(anonymised names, sized non-DICOM placeholders included) instead of\n' +
19
+ 'the compact studies/series form. Requires UID preservation.\n' +
17
20
  '--keep-tags copies the named tags (DICOM keywords or 8-hex tags)\n' +
18
21
  "verbatim; if a kept tag holds PHI, handling it is the caller's\n" +
19
22
  "responsibility, not this tool's.\n" +
@@ -48,6 +51,7 @@ let sizeTolerance
48
51
  let preserveUids = true
49
52
  let uidSalt
50
53
  let gzip = false
54
+ let captureTree = false
51
55
  const keepTags = []
52
56
 
53
57
  for (let i = 0; i < args.length; i++) {
@@ -75,6 +79,8 @@ for (let i = 0; i < args.length; i++) {
75
79
  }
76
80
  } else if (args[i] === '--no-preserve-uids') {
77
81
  preserveUids = false
82
+ } else if (args[i] === '--tree') {
83
+ captureTree = true
78
84
  } else if (args[i] === '--uid-salt' && args[i + 1]) {
79
85
  uidSalt = args[++i]
80
86
  } else if (args[i] === '--gzip') {
@@ -104,6 +110,7 @@ try {
104
110
  ...(sizeTolerance !== undefined ? { sizeTolerance } : {}),
105
111
  preserveUids,
106
112
  ...(uidSalt !== undefined ? { uidSalt } : {}),
113
+ ...(captureTree ? { captureTree: true } : {}),
107
114
  })
108
115
  } catch (err) {
109
116
  console.error(`Error: ${err.message}`)
@@ -119,10 +126,15 @@ if (outPath) {
119
126
  process.stdout.write(compress ? gzipSync(json) : json)
120
127
  }
121
128
 
122
- const { dicomFiles, skipped, unknownModality } = result.stats
129
+ const { dicomFiles, skipped, unknownModality, nonDicomFiles } = result.stats
123
130
  console.error(
124
131
  `Described ${dicomFiles} DICOM file(s); skipped ${skipped} non-DICOM/ungroupable file(s)`,
125
132
  )
133
+ if (nonDicomFiles > 0) {
134
+ console.error(
135
+ ` ${nonDicomFiles} non-DICOM file(s) captured as sized placeholders`,
136
+ )
137
+ }
126
138
  if (result.spec.uidSalt) {
127
139
  console.error(
128
140
  ` UIDs hash-derived with salt ${result.spec.uidSalt} (reuse via --uid-salt to reproduce)`,
@@ -8,6 +8,58 @@ import { dirname, resolve as resolve3 } from "node:path";
8
8
  var MAX_PIXEL_BYTES = 512 * 1024 * 1024;
9
9
  var MAX_STREAM_BYTES = 50 * 1024 * 1024 * 1024;
10
10
 
11
+ // src/syntheticFixtures/tagTemplate.ts
12
+ var TEMPLATE_VOCAB = [
13
+ "index",
14
+ "studyIndex",
15
+ "seriesIndex",
16
+ "instanceNumber"
17
+ ];
18
+ var TEMPLATE_PART_RE = /\{\{|\}\}|\{([a-zA-Z]+)\}/g;
19
+ function resolveTagTemplates(tags, context) {
20
+ if (!tags) return tags;
21
+ const resolved = {};
22
+ for (const [key, value] of Object.entries(tags)) {
23
+ resolved[key] = typeof value === "string" ? resolveString(value, context) : value;
24
+ }
25
+ return resolved;
26
+ }
27
+ function resolveString(value, context) {
28
+ return value.replace(TEMPLATE_PART_RE, (match, name) => {
29
+ if (name === void 0) return match === "{{" ? "{" : "}";
30
+ if (!TEMPLATE_VOCAB.includes(name)) {
31
+ throw new Error(
32
+ `tag template: unknown placeholder "{${name}}" \u2014 allowed: ${TEMPLATE_VOCAB.map((v) => `{${v}}`).join(", ")}`
33
+ );
34
+ }
35
+ const resolved = context[name];
36
+ if (resolved === void 0) {
37
+ throw new Error(
38
+ `tag template: placeholder "{${name}}" is not available here \u2014 it only applies inside grouped studies`
39
+ );
40
+ }
41
+ return String(resolved);
42
+ });
43
+ }
44
+
45
+ // src/schema/validate.ts
46
+ var TYPE_CAPABILITIES = {
47
+ "valid-image": { image: true },
48
+ "invalid-uid-image": { image: true },
49
+ "vendor-warnings-image": { image: true },
50
+ "large-image": { image: true },
51
+ "fake-signature": { image: false },
52
+ "non-dicom": { image: false },
53
+ dicomdir: { image: false }
54
+ };
55
+ function isImageType(type) {
56
+ return TYPE_CAPABILITIES[type].image;
57
+ }
58
+ var HEX_TAG_RE = /^[0-9a-fA-F]{8}$/;
59
+ function positionalName(prefix, ordinal) {
60
+ return `${prefix}-${String(ordinal).padStart(3, "0")}`;
61
+ }
62
+
11
63
  // src/loadDcmjs.ts
12
64
  import dcmjsDefaultImport, * as dcmjsNamespace from "dcmjs";
13
65
  function loadDcmjs() {
@@ -49,43 +101,6 @@ function buildDicomdirBuffer() {
49
101
  return buf.subarray(0, off);
50
102
  }
51
103
 
52
- // src/syntheticFixtures/tagTemplate.ts
53
- var TEMPLATE_VOCAB = [
54
- "index",
55
- "studyIndex",
56
- "seriesIndex",
57
- "instanceNumber"
58
- ];
59
- var TEMPLATE_PART_RE = /\{\{|\}\}|\{([a-zA-Z]+)\}/g;
60
- function resolveTagTemplates(tags, context) {
61
- if (!tags) return tags;
62
- const resolved = {};
63
- for (const [key, value] of Object.entries(tags)) {
64
- resolved[key] = typeof value === "string" ? resolveString(value, context) : value;
65
- }
66
- return resolved;
67
- }
68
- function resolveString(value, context) {
69
- return value.replace(TEMPLATE_PART_RE, (match, name) => {
70
- if (name === void 0) return match === "{{" ? "{" : "}";
71
- if (!TEMPLATE_VOCAB.includes(name)) {
72
- throw new Error(
73
- `tag template: unknown placeholder "{${name}}" \u2014 allowed: ${TEMPLATE_VOCAB.map((v) => `{${v}}`).join(", ")}`
74
- );
75
- }
76
- const resolved = context[name];
77
- if (resolved === void 0) {
78
- throw new Error(
79
- `tag template: placeholder "{${name}}" is not available here \u2014 it only applies inside grouped studies`
80
- );
81
- }
82
- return String(resolved);
83
- });
84
- }
85
-
86
- // src/schema/validate.ts
87
- var HEX_TAG_RE = /^[0-9a-fA-F]{8}$/;
88
-
89
104
  // src/syntheticFixtures/generator.ts
90
105
  var MODALITY_PRESETS = {
91
106
  CT: {
@@ -148,6 +163,14 @@ function applyTagOverrides(dataset, tags) {
148
163
  }
149
164
  }
150
165
  }
166
+ function syncMetaWithDataset(meta, dataset) {
167
+ if (typeof dataset.SOPInstanceUID === "string") {
168
+ meta.MediaStorageSOPInstanceUID = dataset.SOPInstanceUID;
169
+ }
170
+ if (typeof dataset.SOPClassUID === "string") {
171
+ meta.MediaStorageSOPClassUID = dataset.SOPClassUID;
172
+ }
173
+ }
151
174
  function buildMeta(uid, modality, transferSyntax = "explicit-vr-little-endian") {
152
175
  return {
153
176
  FileMetaInformationVersion: new Uint8Array([0, 1]).buffer,
@@ -234,6 +257,7 @@ function buildImageBuffer(spec, uid) {
234
257
  }
235
258
  applyTagOverrides(dataset, spec.tags);
236
259
  const meta = buildMeta(effectiveUid, modality, spec.transferSyntax);
260
+ syncMetaWithDataset(meta, dataset);
237
261
  applySizing(dataset, meta, spec);
238
262
  return serializeDict(meta, dataset);
239
263
  }
@@ -242,8 +266,13 @@ function buildFakeSignatureBuffer() {
242
266
  buf.write("XXXX", 128, 4, "ascii");
243
267
  return buf;
244
268
  }
245
- function buildNonDicomBuffer(content = "not dicom") {
246
- return Buffer.from(content, "utf8");
269
+ function sizedNonDicomBytes(spec) {
270
+ return spec.targetBytes ?? (spec.targetSizeKb !== void 0 ? spec.targetSizeKb * 1024 : void 0);
271
+ }
272
+ function buildNonDicomBuffer(spec) {
273
+ const bytes = sizedNonDicomBytes(spec);
274
+ if (bytes !== void 0) return Buffer.alloc(bytes, "not dicom ");
275
+ return Buffer.from(spec.content ?? "not dicom", "utf8");
247
276
  }
248
277
  function buildBufferForSpec(spec, uid) {
249
278
  switch (spec.type) {
@@ -254,7 +283,7 @@ function buildBufferForSpec(spec, uid) {
254
283
  case "fake-signature":
255
284
  return buildFakeSignatureBuffer();
256
285
  case "non-dicom":
257
- return buildNonDicomBuffer(spec.content);
286
+ return buildNonDicomBuffer(spec);
258
287
  case "dicomdir":
259
288
  return buildDicomdirBuffer();
260
289
  case "large-image":
@@ -338,6 +367,7 @@ function writeNative(outPath, params, dims, zeroChunk) {
338
367
  const meta = buildMeta(uid, modality);
339
368
  const dataset = buildBaseImageDataset(uid, modality);
340
369
  applyTagOverrides(dataset, tags);
370
+ syncMetaWithDataset(meta, dataset);
341
371
  dataset.Rows = dims.rows;
342
372
  dataset.Columns = dims.columns;
343
373
  const minimalPixelBytes = dataset.PixelData.byteLength;
@@ -371,6 +401,7 @@ function writeEncapsulated(outPath, params, pixelBytes, fragmentBytes, zeroChunk
371
401
  };
372
402
  const dataset = buildBaseImageDataset(uid, modality);
373
403
  applyTagOverrides(dataset, tags);
404
+ syncMetaWithDataset(meta, dataset);
374
405
  dataset.PixelData = [new Uint8Array([]).buffer, new Uint8Array([0, 0]).buffer];
375
406
  dataset._vrMap = { PixelData: "OB" };
376
407
  const probe = serializeDict(meta, dataset);
@@ -565,6 +596,15 @@ function studyFileCount(studies) {
565
596
  0
566
597
  );
567
598
  }
599
+ function isDirNode(node) {
600
+ return "children" in node;
601
+ }
602
+ function treeFileCount(nodes) {
603
+ return nodes.reduce(
604
+ (sum, node) => sum + (isDirNode(node) ? treeFileCount(node.children) : node.count ?? 1),
605
+ 0
606
+ );
607
+ }
568
608
  function* seriesFiles(series) {
569
609
  if (series.instances) {
570
610
  const inst = series.instances;
@@ -661,6 +701,9 @@ function applyPathQuirks(relativePath, quirks) {
661
701
  }
662
702
  return { relativePath: [...dirs, newLeaf].join("/"), filename: newLeaf };
663
703
  }
704
+ function decorateNames(names, quirks) {
705
+ return quirks.length === 0 ? names : applyPathQuirks(names.relativePath, quirks);
706
+ }
664
707
  function hierarchicalName(studyOrdinal, seriesIndex, instanceNumber) {
665
708
  const pad3 = (n) => String(n).padStart(3, "0");
666
709
  const name = `${String(instanceNumber).padStart(5, "0")}.dcm`;
@@ -687,12 +730,78 @@ function computeNames(type, index, padWidth, grouped, quirks) {
687
730
  const name = filename(type, index, padWidth);
688
731
  names = { filename: name, relativePath: name };
689
732
  }
690
- return quirks.length === 0 ? names : applyPathQuirks(names.relativePath, quirks);
733
+ return decorateNames(names, quirks);
734
+ }
735
+ function* planTree(nodes, scope, env) {
736
+ let dirOrdinal = 0;
737
+ for (const node of nodes) {
738
+ if (isDirNode(node)) {
739
+ dirOrdinal++;
740
+ const dirName = node.name ?? positionalName("dir", dirOrdinal);
741
+ const child = {
742
+ ...scope,
743
+ dirs: [...scope.dirs, dirName],
744
+ tags: { ...scope.tags, ...node.tags }
745
+ };
746
+ if (node.role === "study") {
747
+ const ordinal = env.counters.study++;
748
+ child.study = {
749
+ ordinal,
750
+ uid: env.groupUids.study(ordinal),
751
+ seriesCount: 0
752
+ };
753
+ } else if (node.role === "series" && child.study) {
754
+ const index = child.study.seriesCount++;
755
+ child.series = {
756
+ index,
757
+ uid: env.groupUids.series(child.study.ordinal, index),
758
+ instanceCount: 0
759
+ };
760
+ }
761
+ yield* planTree(node.children, child, env);
762
+ continue;
763
+ }
764
+ const { count = 1, name, ...fileSpec } = node;
765
+ const image = isImageType(fileSpec.type);
766
+ for (let i = 0; i < count; i++) {
767
+ const index = env.counters.file++;
768
+ const instanceNumber = image && scope.series ? ++scope.series.instanceCount : void 0;
769
+ const leaf = name ?? filename(fileSpec.type, index, env.padWidth);
770
+ const names = decorateNames(
771
+ { filename: leaf, relativePath: [...scope.dirs, leaf].join("/") },
772
+ env.quirks
773
+ );
774
+ const tags = image ? {
775
+ ...instanceNumber !== void 0 ? { InstanceNumber: instanceNumber } : {},
776
+ ...scope.tags,
777
+ ..."tags" in fileSpec ? fileSpec.tags : void 0
778
+ } : void 0;
779
+ yield {
780
+ fileSpec: tags ? { ...fileSpec, tags } : fileSpec,
781
+ index,
782
+ ...image && scope.study ? {
783
+ uidOverride: {
784
+ study: scope.study.uid,
785
+ ...scope.series ? { series: scope.series.uid } : {}
786
+ }
787
+ } : {},
788
+ context: {
789
+ index,
790
+ ...scope.study ? { studyIndex: scope.study.ordinal } : {},
791
+ ...scope.series ? { seriesIndex: scope.series.index } : {},
792
+ ...instanceNumber !== void 0 ? { instanceNumber } : {}
793
+ },
794
+ filename: names.filename,
795
+ relativePath: names.relativePath
796
+ };
797
+ }
798
+ }
691
799
  }
692
800
  function* planCollection(spec) {
693
801
  const flatEntries = spec.entries ?? [];
694
802
  const studies = spec.studies ?? [];
695
- const totalFiles = entryCount(flatEntries) + studyFileCount(studies);
803
+ const tree = spec.tree ?? [];
804
+ const totalFiles = entryCount(flatEntries) + studyFileCount(studies) + treeFileCount(tree);
696
805
  const padWidth = String(totalFiles).length;
697
806
  const hierarchical = spec.layout === "hierarchical";
698
807
  const quirks = spec.pathQuirks ?? [];
@@ -756,6 +865,18 @@ function* planCollection(spec) {
756
865
  studyOrdinal++;
757
866
  }
758
867
  }
868
+ if (tree.length > 0) {
869
+ yield* planTree(
870
+ tree,
871
+ { dirs: [], tags: {} },
872
+ {
873
+ groupUids,
874
+ padWidth,
875
+ quirks,
876
+ counters: { file: globalIndex, study: studyOrdinal }
877
+ }
878
+ );
879
+ }
759
880
  }
760
881
  async function materialisePlan(plan, salt) {
761
882
  const file = await generateFile(plan.fileSpec, {
@@ -775,12 +896,13 @@ async function* generateCollectionFromSpec(spec) {
775
896
  async function* previewCollection(spec) {
776
897
  const salt = effectiveSalt(spec);
777
898
  for (const plan of planCollection(spec)) {
778
- if (plan.fileSpec.type === "large-image") {
899
+ const knownBytes = plan.fileSpec.type === "large-image" ? plan.fileSpec.targetBytes : plan.fileSpec.type === "non-dicom" ? sizedNonDicomBytes(plan.fileSpec) : void 0;
900
+ if (knownBytes !== void 0) {
779
901
  yield {
780
902
  relativePath: plan.relativePath,
781
- type: "large-image",
903
+ type: plan.fileSpec.type,
782
904
  index: plan.index,
783
- approxBytes: plan.fileSpec.targetBytes
905
+ approxBytes: knownBytes
784
906
  };
785
907
  } else {
786
908
  const file = await materialisePlan(plan, salt);