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.
- package/bin/dicom-synth-describe.mjs +14 -2
- package/dist/esm/collection/writer.js +167 -45
- package/dist/esm/describe/describe.js +314 -40
- package/dist/esm/index.js +473 -89
- package/dist/esm/schema/parametric.js +44 -2
- package/dist/esm/schema/validate.js +154 -4
- package/dist/esm/syntheticFixtures/generator.js +20 -4
- package/dist/esm/syntheticFixtures/streamWrite.js +10 -0
- package/dist/types/describe/describe.d.ts +2 -0
- package/dist/types/index.d.ts +1 -1
- package/dist/types/schema/types.d.ts +13 -0
- package/dist/types/schema/validate.d.ts +3 -1
- package/dist/types/syntheticFixtures/generator.d.ts +3 -1
- package/package.json +1 -1
|
@@ -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
|
|
246
|
-
return
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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:
|
|
903
|
+
type: plan.fileSpec.type,
|
|
782
904
|
index: plan.index,
|
|
783
|
-
approxBytes:
|
|
905
|
+
approxBytes: knownBytes
|
|
784
906
|
};
|
|
785
907
|
} else {
|
|
786
908
|
const file = await materialisePlan(plan, salt);
|