dicom-synth 1.9.0 → 1.11.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
- import { writeFileSync } from 'node:fs'
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,...>]\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' +
@@ -13,26 +14,58 @@ function usage() {
13
14
  'By default only the shape is reproduced — no tags are copied.\n' +
14
15
  '--keep-tags copies the named tags (DICOM keywords or 8-hex tags)\n' +
15
16
  "verbatim; if a kept tag holds PHI, handling it is the caller's\n" +
16
- "responsibility, not this tool's.",
17
+ "responsibility, not this tool's.\n" +
18
+ '--keep-tags-file reads the same allow-list from a file (one tag per\n' +
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.',
17
23
  )
18
24
  process.exit(1)
19
25
  }
20
26
 
27
+ // Parse a keep-tags file: one tag per line, blank lines and # comments ignored.
28
+ function parseKeepTagsFile(path) {
29
+ return readFileSync(path, 'utf8')
30
+ .split(/\r?\n/)
31
+ .map((line) => line.replace(/#.*$/, '').trim())
32
+ .filter(Boolean)
33
+ }
34
+
21
35
  const args = process.argv.slice(2)
22
36
  if (args.length === 0) usage()
23
37
 
24
38
  let dir
25
39
  let outPath
26
- let keepTags
40
+ let sizeTolerance
41
+ let gzip = false
42
+ const keepTags = []
27
43
 
28
44
  for (let i = 0; i < args.length; i++) {
29
45
  if (args[i] === '--out' && args[i + 1]) {
30
46
  outPath = resolve(args[++i])
31
47
  } else if (args[i] === '--keep-tags' && args[i + 1]) {
32
- keepTags = args[++i]
33
- .split(',')
34
- .map((t) => t.trim())
35
- .filter(Boolean)
48
+ keepTags.push(
49
+ ...args[++i]
50
+ .split(',')
51
+ .map((t) => t.trim())
52
+ .filter(Boolean),
53
+ )
54
+ } else if (args[i] === '--keep-tags-file' && args[i + 1]) {
55
+ try {
56
+ keepTags.push(...parseKeepTagsFile(resolve(args[++i])))
57
+ } catch (err) {
58
+ console.error(`Error reading keep-tags file: ${err.message}`)
59
+ process.exit(1)
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
36
69
  } else if (args[i].startsWith('--')) {
37
70
  console.error(`Unknown argument: ${args[i]}`)
38
71
  usage()
@@ -48,18 +81,22 @@ if (dir === undefined) usage()
48
81
 
49
82
  let result
50
83
  try {
51
- result = describeDirectory(dir, keepTags ? { keepTags } : {})
84
+ result = describeDirectory(dir, {
85
+ ...(keepTags.length ? { keepTags } : {}),
86
+ ...(sizeTolerance !== undefined ? { sizeTolerance } : {}),
87
+ })
52
88
  } catch (err) {
53
89
  console.error(`Error: ${err.message}`)
54
90
  process.exit(1)
55
91
  }
56
92
 
57
93
  const json = `${JSON.stringify(result.spec, null, 2)}\n`
94
+ const compress = gzip || outPath?.endsWith('.gz')
58
95
  if (outPath) {
59
- writeFileSync(outPath, json)
96
+ writeFileSync(outPath, compress ? gzipSync(json) : json)
60
97
  console.error(`Wrote DatasetSpec to ${outPath}`)
61
98
  } else {
62
- process.stdout.write(json)
99
+ process.stdout.write(compress ? gzipSync(json) : json)
63
100
  }
64
101
 
65
102
  const { dicomFiles, skipped, unknownModality } = result.stats
@@ -49,6 +49,40 @@ function buildDicomdirBuffer() {
49
49
  return buf.subarray(0, off);
50
50
  }
51
51
 
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
+
52
86
  // src/schema/validate.ts
53
87
  var HEX_TAG_RE = /^[0-9a-fA-F]{8}$/;
54
88
 
@@ -553,12 +587,41 @@ function filename(type, index, padWidth) {
553
587
  function entryCount(entries) {
554
588
  return entries.reduce((sum, e) => sum + (e.count ?? 1), 0);
555
589
  }
590
+ function seriesFileCount(series) {
591
+ return series.instances ? series.instances.count : entryCount(series.entries ?? []);
592
+ }
556
593
  function studyFileCount(studies) {
557
594
  return studies.reduce(
558
- (sum, study) => sum + (study.count ?? 1) * study.series.reduce((s, series) => s + entryCount(series.entries), 0),
595
+ (sum, study) => sum + (study.count ?? 1) * study.series.reduce((s, series) => s + seriesFileCount(series), 0),
559
596
  0
560
597
  );
561
598
  }
599
+ function* seriesFiles(series) {
600
+ if (series.instances) {
601
+ const inst = series.instances;
602
+ for (let i = 0; i < inst.count; i++) {
603
+ const modality = Array.isArray(inst.modality) ? inst.modality[i] : inst.modality;
604
+ const baseSpec = {
605
+ ...inst.targetBytes ? { type: "large-image", targetBytes: inst.targetBytes[i] } : { type: "valid-image", targetSizeKb: inst.targetSizeKb?.[i] },
606
+ ...modality !== void 0 ? { modality } : {}
607
+ };
608
+ const instanceTags = {};
609
+ if (inst.tags) {
610
+ for (const [key, values] of Object.entries(inst.tags)) {
611
+ instanceTags[key] = values[i];
612
+ }
613
+ }
614
+ yield { baseSpec, instanceTags };
615
+ }
616
+ return;
617
+ }
618
+ for (const entry of series.entries ?? []) {
619
+ const { count = 1, tags, ...baseSpec } = entry;
620
+ for (let i = 0; i < count; i++) {
621
+ yield { baseSpec, instanceTags: tags ?? {} };
622
+ }
623
+ }
624
+ }
562
625
  function resolveUid(seed, index, override) {
563
626
  return { ...makeUidGenerator(seed)(index), ...override };
564
627
  }
@@ -566,7 +629,11 @@ async function generateFile(spec, options) {
566
629
  const index = options?.index ?? 0;
567
630
  const padWidth = options?.padWidth ?? 3;
568
631
  const uid = resolveUid(options?.seed, index, options?.uid);
569
- let buffer = buildBufferForSpec(spec, uid);
632
+ const resolvedSpec = "tags" in spec && spec.tags ? {
633
+ ...spec,
634
+ tags: resolveTagTemplates(spec.tags, { index, ...options?.context })
635
+ } : spec;
636
+ let buffer = buildBufferForSpec(resolvedSpec, uid);
570
637
  const violations = "violations" in spec ? spec.violations : void 0;
571
638
  if (violations?.length) {
572
639
  buffer = applyViolations(buffer, violations);
@@ -661,6 +728,7 @@ function* planCollection(spec) {
661
728
  yield {
662
729
  fileSpec,
663
730
  index: globalIndex,
731
+ context: { index: globalIndex },
664
732
  ...computeNames(
665
733
  fileSpec.type,
666
734
  globalIndex,
@@ -681,30 +749,33 @@ function* planCollection(spec) {
681
749
  for (const [seriesIndex, series] of study.series.entries()) {
682
750
  const seriesUid = groupUids.series(studyOrdinal, seriesIndex);
683
751
  let instanceNumber = 0;
684
- for (const entry of series.entries) {
685
- const { count = 1, ...fileSpec } = entry;
686
- for (let i = 0; i < count; i++) {
687
- instanceNumber++;
688
- const tags = {
689
- InstanceNumber: instanceNumber,
690
- ...study.tags,
691
- ...series.tags,
692
- ...fileSpec.tags
693
- };
694
- yield {
695
- fileSpec: { ...fileSpec, tags },
752
+ for (const { baseSpec, instanceTags } of seriesFiles(series)) {
753
+ instanceNumber++;
754
+ const tags = {
755
+ InstanceNumber: instanceNumber,
756
+ ...study.tags,
757
+ ...series.tags,
758
+ ...instanceTags
759
+ };
760
+ yield {
761
+ fileSpec: { ...baseSpec, tags },
762
+ index: globalIndex,
763
+ uidOverride: { study: studyUid, series: seriesUid },
764
+ context: {
696
765
  index: globalIndex,
697
- uidOverride: { study: studyUid, series: seriesUid },
698
- ...computeNames(
699
- fileSpec.type,
700
- globalIndex,
701
- padWidth,
702
- hierarchical ? { studyOrdinal, seriesIndex, instanceNumber } : void 0,
703
- quirks
704
- )
705
- };
706
- globalIndex++;
707
- }
766
+ studyIndex: studyOrdinal,
767
+ seriesIndex,
768
+ instanceNumber
769
+ },
770
+ ...computeNames(
771
+ baseSpec.type,
772
+ globalIndex,
773
+ padWidth,
774
+ hierarchical ? { studyOrdinal, seriesIndex, instanceNumber } : void 0,
775
+ quirks
776
+ )
777
+ };
778
+ globalIndex++;
708
779
  }
709
780
  }
710
781
  studyOrdinal++;
@@ -715,7 +786,8 @@ async function materialisePlan(plan, seed) {
715
786
  const file = await generateFile(plan.fileSpec, {
716
787
  index: plan.index,
717
788
  seed,
718
- uid: plan.uidOverride
789
+ uid: plan.uidOverride,
790
+ context: plan.context
719
791
  });
720
792
  return { ...file, filename: plan.filename, relativePath: plan.relativePath };
721
793
  }
@@ -771,7 +843,7 @@ async function writeCollectionFromSpec(spec, outDir) {
771
843
  targetBytes: large.targetBytes,
772
844
  modality: large.modality,
773
845
  uid,
774
- tags: large.tags
846
+ tags: resolveTagTemplates(large.tags, plan.context)
775
847
  });
776
848
  } else {
777
849
  const file = await materialisePlan(plan, spec.seed);