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.
- package/bin/dicom-synth-describe.mjs +48 -11
- package/dist/esm/collection/writer.js +99 -27
- package/dist/esm/describe/describe.js +287 -49
- package/dist/esm/index.js +377 -75
- package/dist/esm/schema/parametric.js +36 -1
- package/dist/esm/schema/validate.js +126 -9
- package/dist/esm/syntheticFixtures/tagTemplate.js +49 -0
- package/dist/types/collection/writer.d.ts +2 -0
- package/dist/types/describe/describe.d.ts +2 -0
- package/dist/types/index.d.ts +2 -1
- package/dist/types/schema/types.d.ts +9 -1
- package/dist/types/syntheticFixtures/tagTemplate.d.ts +11 -0
- package/package.json +1 -1
|
@@ -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
|
|
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
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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,
|
|
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 +
|
|
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
|
-
|
|
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
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
instanceNumber
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
}
|
|
694
|
-
|
|
695
|
-
|
|
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
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
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);
|