dicom-synth 1.10.0 → 1.12.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 +23 -5
- package/dist/esm/collection/writer.js +100 -95
- package/dist/esm/describe/describe.js +234 -48
- package/dist/esm/index.js +345 -137
- package/dist/esm/schema/parametric.js +31 -16
- package/dist/esm/schema/validate.js +115 -19
- package/dist/esm/syntheticFixtures/streamWrite.js +20 -35
- package/dist/esm/syntheticFixtures/uid.js +28 -47
- package/dist/types/collection/writer.d.ts +2 -0
- package/dist/types/describe/describe.d.ts +2 -0
- package/dist/types/index.d.ts +3 -2
- package/dist/types/schema/types.d.ts +11 -1
- package/dist/types/syntheticFixtures/streamWrite.d.ts +6 -2
- package/dist/types/syntheticFixtures/uid.d.ts +4 -2
- package/package.json +1 -1
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
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,...>] [--keep-tags-file <path>]\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' +
|
|
@@ -15,7 +16,10 @@ function usage() {
|
|
|
15
16
|
"verbatim; if a kept tag holds PHI, handling it is the caller's\n" +
|
|
16
17
|
"responsibility, not this tool's.\n" +
|
|
17
18
|
'--keep-tags-file reads the same allow-list from a file (one tag per\n' +
|
|
18
|
-
'line, # comments); merged with any --keep-tags
|
|
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.',
|
|
19
23
|
)
|
|
20
24
|
process.exit(1)
|
|
21
25
|
}
|
|
@@ -33,6 +37,8 @@ if (args.length === 0) usage()
|
|
|
33
37
|
|
|
34
38
|
let dir
|
|
35
39
|
let outPath
|
|
40
|
+
let sizeTolerance
|
|
41
|
+
let gzip = false
|
|
36
42
|
const keepTags = []
|
|
37
43
|
|
|
38
44
|
for (let i = 0; i < args.length; i++) {
|
|
@@ -52,6 +58,14 @@ for (let i = 0; i < args.length; i++) {
|
|
|
52
58
|
console.error(`Error reading keep-tags file: ${err.message}`)
|
|
53
59
|
process.exit(1)
|
|
54
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
|
|
55
69
|
} else if (args[i].startsWith('--')) {
|
|
56
70
|
console.error(`Unknown argument: ${args[i]}`)
|
|
57
71
|
usage()
|
|
@@ -67,18 +81,22 @@ if (dir === undefined) usage()
|
|
|
67
81
|
|
|
68
82
|
let result
|
|
69
83
|
try {
|
|
70
|
-
result = describeDirectory(dir,
|
|
84
|
+
result = describeDirectory(dir, {
|
|
85
|
+
...(keepTags.length ? { keepTags } : {}),
|
|
86
|
+
...(sizeTolerance !== undefined ? { sizeTolerance } : {}),
|
|
87
|
+
})
|
|
71
88
|
} catch (err) {
|
|
72
89
|
console.error(`Error: ${err.message}`)
|
|
73
90
|
process.exit(1)
|
|
74
91
|
}
|
|
75
92
|
|
|
76
93
|
const json = `${JSON.stringify(result.spec, null, 2)}\n`
|
|
94
|
+
const compress = gzip || outPath?.endsWith('.gz')
|
|
77
95
|
if (outPath) {
|
|
78
|
-
writeFileSync(outPath, json)
|
|
96
|
+
writeFileSync(outPath, compress ? gzipSync(json) : json)
|
|
79
97
|
console.error(`Wrote DatasetSpec to ${outPath}`)
|
|
80
98
|
} else {
|
|
81
|
-
process.stdout.write(json)
|
|
99
|
+
process.stdout.write(compress ? gzipSync(json) : json)
|
|
82
100
|
}
|
|
83
101
|
|
|
84
102
|
const { dicomFiles, skipped, unknownModality } = result.stats
|
|
@@ -269,68 +269,37 @@ import { closeSync, mkdirSync as mkdirSync2, openSync, writeSync } from "node:fs
|
|
|
269
269
|
import { resolve as resolve2 } from "node:path";
|
|
270
270
|
|
|
271
271
|
// src/syntheticFixtures/uid.ts
|
|
272
|
-
import { randomBytes } from "node:crypto";
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
return ()
|
|
277
|
-
s = Math.imul(1664525, s) + 1013904223 >>> 0;
|
|
278
|
-
return s;
|
|
279
|
-
};
|
|
280
|
-
}
|
|
281
|
-
function formatUid(a, b, role) {
|
|
282
|
-
return `2.25.${a}.${b}.${ROLE_CODE[role]}`;
|
|
283
|
-
}
|
|
284
|
-
function randomUid(role) {
|
|
285
|
-
const buf = randomBytes(8);
|
|
286
|
-
return formatUid(buf.readUInt32BE(0), buf.readUInt32BE(4), role);
|
|
272
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
273
|
+
function hashUid(salt, key) {
|
|
274
|
+
const digest = createHash("sha256").update(salt).update(" ").update(key).digest();
|
|
275
|
+
const n = BigInt(`0x${digest.subarray(0, 16).toString("hex")}`);
|
|
276
|
+
return `2.25.${n.toString()}`;
|
|
287
277
|
}
|
|
288
|
-
function
|
|
289
|
-
|
|
278
|
+
function randomUid() {
|
|
279
|
+
const n = BigInt(`0x${randomBytes(16).toString("hex")}`);
|
|
280
|
+
return `2.25.${n.toString()}`;
|
|
290
281
|
}
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
return lcg(seed * 999983 + offset + a * 1000003 + b * 7919 >>> 0);
|
|
282
|
+
function seedToSalt(seed) {
|
|
283
|
+
return seed === void 0 ? void 0 : `seed:${seed}`;
|
|
294
284
|
}
|
|
295
|
-
function makeGroupUidGenerator(
|
|
296
|
-
if (
|
|
297
|
-
return {
|
|
298
|
-
study: () => randomUid("study"),
|
|
299
|
-
series: () => randomUid("series")
|
|
300
|
-
};
|
|
285
|
+
function makeGroupUidGenerator(salt) {
|
|
286
|
+
if (salt === void 0) {
|
|
287
|
+
return { study: () => randomUid(), series: () => randomUid() };
|
|
301
288
|
}
|
|
302
289
|
return {
|
|
303
|
-
study: (studyIndex) =>
|
|
304
|
-
|
|
305
|
-
"study"
|
|
306
|
-
),
|
|
307
|
-
series: (studyIndex, seriesIndex) => seededUid(
|
|
308
|
-
seededStream(
|
|
309
|
-
seed,
|
|
310
|
-
GROUP_STREAM_OFFSET,
|
|
311
|
-
studyIndex + 1,
|
|
312
|
-
seriesIndex + 1
|
|
313
|
-
),
|
|
314
|
-
"series"
|
|
315
|
-
)
|
|
290
|
+
study: (studyIndex) => hashUid(salt, `study:${studyIndex}`),
|
|
291
|
+
series: (studyIndex, seriesIndex) => hashUid(salt, `series:${studyIndex}:${seriesIndex}`)
|
|
316
292
|
};
|
|
317
293
|
}
|
|
318
|
-
function makeUidGenerator(
|
|
319
|
-
if (
|
|
320
|
-
return () => ({
|
|
321
|
-
study: randomUid("study"),
|
|
322
|
-
series: randomUid("series"),
|
|
323
|
-
sop: randomUid("sop")
|
|
324
|
-
});
|
|
294
|
+
function makeUidGenerator(salt) {
|
|
295
|
+
if (salt === void 0) {
|
|
296
|
+
return () => ({ study: randomUid(), series: randomUid(), sop: randomUid() });
|
|
325
297
|
}
|
|
326
|
-
return (fileIndex) => {
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
sop: seededUid(next, "sop")
|
|
332
|
-
};
|
|
333
|
-
};
|
|
298
|
+
return (fileIndex) => ({
|
|
299
|
+
study: hashUid(salt, `study:flat:${fileIndex}`),
|
|
300
|
+
series: hashUid(salt, `series:flat:${fileIndex}`),
|
|
301
|
+
sop: hashUid(salt, `sop:${fileIndex}`)
|
|
302
|
+
});
|
|
334
303
|
}
|
|
335
304
|
|
|
336
305
|
// src/syntheticFixtures/streamWrite.ts
|
|
@@ -443,7 +412,7 @@ function streamLargeImage(outPath, options) {
|
|
|
443
412
|
fragmentBytes = FRAGMENT_MAX_BYTES
|
|
444
413
|
} = options;
|
|
445
414
|
const modality = options.modality ?? "CT";
|
|
446
|
-
const uid = options.uid ?? makeUidGenerator(options.seed)(0);
|
|
415
|
+
const uid = options.uid ?? makeUidGenerator(options.uidSalt ?? seedToSalt(options.seed))(0);
|
|
447
416
|
const identity = { modality, uid, tags: options.tags };
|
|
448
417
|
mkdirSync2(resolve2(outPath, ".."), { recursive: true });
|
|
449
418
|
const zeroChunk = Buffer.alloc(Math.max(1, chunkBytes));
|
|
@@ -587,19 +556,51 @@ function filename(type, index, padWidth) {
|
|
|
587
556
|
function entryCount(entries) {
|
|
588
557
|
return entries.reduce((sum, e) => sum + (e.count ?? 1), 0);
|
|
589
558
|
}
|
|
559
|
+
function seriesFileCount(series) {
|
|
560
|
+
return series.instances ? series.instances.count : entryCount(series.entries ?? []);
|
|
561
|
+
}
|
|
590
562
|
function studyFileCount(studies) {
|
|
591
563
|
return studies.reduce(
|
|
592
|
-
(sum, study) => sum + (study.count ?? 1) * study.series.reduce((s, series) => s +
|
|
564
|
+
(sum, study) => sum + (study.count ?? 1) * study.series.reduce((s, series) => s + seriesFileCount(series), 0),
|
|
593
565
|
0
|
|
594
566
|
);
|
|
595
567
|
}
|
|
596
|
-
function
|
|
597
|
-
|
|
568
|
+
function* seriesFiles(series) {
|
|
569
|
+
if (series.instances) {
|
|
570
|
+
const inst = series.instances;
|
|
571
|
+
for (let i = 0; i < inst.count; i++) {
|
|
572
|
+
const modality = Array.isArray(inst.modality) ? inst.modality[i] : inst.modality;
|
|
573
|
+
const baseSpec = {
|
|
574
|
+
...inst.targetBytes ? { type: "large-image", targetBytes: inst.targetBytes[i] } : { type: "valid-image", targetSizeKb: inst.targetSizeKb?.[i] },
|
|
575
|
+
...modality !== void 0 ? { modality } : {}
|
|
576
|
+
};
|
|
577
|
+
const instanceTags = {};
|
|
578
|
+
if (inst.tags) {
|
|
579
|
+
for (const [key, values] of Object.entries(inst.tags)) {
|
|
580
|
+
instanceTags[key] = values[i];
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
yield { baseSpec, instanceTags };
|
|
584
|
+
}
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
for (const entry of series.entries ?? []) {
|
|
588
|
+
const { count = 1, tags, ...baseSpec } = entry;
|
|
589
|
+
for (let i = 0; i < count; i++) {
|
|
590
|
+
yield { baseSpec, instanceTags: tags ?? {} };
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
function resolveUid(salt, index, override) {
|
|
595
|
+
return { ...makeUidGenerator(salt)(index), ...override };
|
|
596
|
+
}
|
|
597
|
+
function effectiveSalt(spec) {
|
|
598
|
+
return spec.uidSalt ?? seedToSalt(spec.seed);
|
|
598
599
|
}
|
|
599
600
|
async function generateFile(spec, options) {
|
|
600
601
|
const index = options?.index ?? 0;
|
|
601
602
|
const padWidth = options?.padWidth ?? 3;
|
|
602
|
-
const uid = resolveUid(options
|
|
603
|
+
const uid = resolveUid(effectiveSalt(options ?? {}), index, options?.uid);
|
|
603
604
|
const resolvedSpec = "tags" in spec && spec.tags ? {
|
|
604
605
|
...spec,
|
|
605
606
|
tags: resolveTagTemplates(spec.tags, { index, ...options?.context })
|
|
@@ -671,6 +672,9 @@ function hierarchicalName(studyOrdinal, seriesIndex, instanceNumber) {
|
|
|
671
672
|
function withResolvedSeed(spec) {
|
|
672
673
|
return spec.seed === void 0 ? { ...spec, seed: randomBytes2(4).readUInt32BE(0) } : spec;
|
|
673
674
|
}
|
|
675
|
+
function withResolvedSalt(spec) {
|
|
676
|
+
return spec.uidSalt === void 0 ? { ...spec, uidSalt: randomBytes2(16).toString("hex") } : spec;
|
|
677
|
+
}
|
|
674
678
|
function computeNames(type, index, padWidth, grouped, quirks) {
|
|
675
679
|
let names;
|
|
676
680
|
if (grouped) {
|
|
@@ -711,7 +715,7 @@ function* planCollection(spec) {
|
|
|
711
715
|
globalIndex++;
|
|
712
716
|
}
|
|
713
717
|
}
|
|
714
|
-
const groupUids = makeGroupUidGenerator(spec
|
|
718
|
+
const groupUids = makeGroupUidGenerator(effectiveSalt(spec));
|
|
715
719
|
let studyOrdinal = 0;
|
|
716
720
|
for (const study of studies) {
|
|
717
721
|
const copies = study.count ?? 1;
|
|
@@ -720,57 +724,56 @@ function* planCollection(spec) {
|
|
|
720
724
|
for (const [seriesIndex, series] of study.series.entries()) {
|
|
721
725
|
const seriesUid = groupUids.series(studyOrdinal, seriesIndex);
|
|
722
726
|
let instanceNumber = 0;
|
|
723
|
-
for (const
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
instanceNumber
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
}
|
|
733
|
-
|
|
734
|
-
|
|
727
|
+
for (const { baseSpec, instanceTags } of seriesFiles(series)) {
|
|
728
|
+
instanceNumber++;
|
|
729
|
+
const tags = {
|
|
730
|
+
InstanceNumber: instanceNumber,
|
|
731
|
+
...study.tags,
|
|
732
|
+
...series.tags,
|
|
733
|
+
...instanceTags
|
|
734
|
+
};
|
|
735
|
+
yield {
|
|
736
|
+
fileSpec: { ...baseSpec, tags },
|
|
737
|
+
index: globalIndex,
|
|
738
|
+
uidOverride: { study: studyUid, series: seriesUid },
|
|
739
|
+
context: {
|
|
735
740
|
index: globalIndex,
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
)
|
|
750
|
-
};
|
|
751
|
-
globalIndex++;
|
|
752
|
-
}
|
|
741
|
+
studyIndex: studyOrdinal,
|
|
742
|
+
seriesIndex,
|
|
743
|
+
instanceNumber
|
|
744
|
+
},
|
|
745
|
+
...computeNames(
|
|
746
|
+
baseSpec.type,
|
|
747
|
+
globalIndex,
|
|
748
|
+
padWidth,
|
|
749
|
+
hierarchical ? { studyOrdinal, seriesIndex, instanceNumber } : void 0,
|
|
750
|
+
quirks
|
|
751
|
+
)
|
|
752
|
+
};
|
|
753
|
+
globalIndex++;
|
|
753
754
|
}
|
|
754
755
|
}
|
|
755
756
|
studyOrdinal++;
|
|
756
757
|
}
|
|
757
758
|
}
|
|
758
759
|
}
|
|
759
|
-
async function materialisePlan(plan,
|
|
760
|
+
async function materialisePlan(plan, salt) {
|
|
760
761
|
const file = await generateFile(plan.fileSpec, {
|
|
761
762
|
index: plan.index,
|
|
762
|
-
|
|
763
|
+
uidSalt: salt,
|
|
763
764
|
uid: plan.uidOverride,
|
|
764
765
|
context: plan.context
|
|
765
766
|
});
|
|
766
767
|
return { ...file, filename: plan.filename, relativePath: plan.relativePath };
|
|
767
768
|
}
|
|
768
769
|
async function* generateCollectionFromSpec(spec) {
|
|
770
|
+
const salt = effectiveSalt(spec);
|
|
769
771
|
for (const plan of planCollection(spec)) {
|
|
770
|
-
yield await materialisePlan(plan,
|
|
772
|
+
yield await materialisePlan(plan, salt);
|
|
771
773
|
}
|
|
772
774
|
}
|
|
773
775
|
async function* previewCollection(spec) {
|
|
776
|
+
const salt = effectiveSalt(spec);
|
|
774
777
|
for (const plan of planCollection(spec)) {
|
|
775
778
|
if (plan.fileSpec.type === "large-image") {
|
|
776
779
|
yield {
|
|
@@ -780,7 +783,7 @@ async function* previewCollection(spec) {
|
|
|
780
783
|
approxBytes: plan.fileSpec.targetBytes
|
|
781
784
|
};
|
|
782
785
|
} else {
|
|
783
|
-
const file = await materialisePlan(plan,
|
|
786
|
+
const file = await materialisePlan(plan, salt);
|
|
784
787
|
yield {
|
|
785
788
|
relativePath: plan.relativePath,
|
|
786
789
|
type: plan.fileSpec.type,
|
|
@@ -794,6 +797,7 @@ async function writeCollectionFromSpec(spec, outDir) {
|
|
|
794
797
|
const root = resolve3(outDir);
|
|
795
798
|
mkdirSync3(root, { recursive: true });
|
|
796
799
|
const manifest = [];
|
|
800
|
+
const salt = effectiveSalt(spec);
|
|
797
801
|
const createdDirs = /* @__PURE__ */ new Set([root]);
|
|
798
802
|
const ensureDir = (filePath) => {
|
|
799
803
|
const dir = dirname(filePath);
|
|
@@ -812,7 +816,7 @@ async function writeCollectionFromSpec(spec, outDir) {
|
|
|
812
816
|
`large-image targetBytes (${large.targetBytes}) exceeds the 50 GB limit`
|
|
813
817
|
);
|
|
814
818
|
}
|
|
815
|
-
const uid = resolveUid(
|
|
819
|
+
const uid = resolveUid(salt, plan.index, plan.uidOverride);
|
|
816
820
|
streamLargeImage(filePath, {
|
|
817
821
|
targetBytes: large.targetBytes,
|
|
818
822
|
modality: large.modality,
|
|
@@ -820,7 +824,7 @@ async function writeCollectionFromSpec(spec, outDir) {
|
|
|
820
824
|
tags: resolveTagTemplates(large.tags, plan.context)
|
|
821
825
|
});
|
|
822
826
|
} else {
|
|
823
|
-
const file = await materialisePlan(plan,
|
|
827
|
+
const file = await materialisePlan(plan, salt);
|
|
824
828
|
await writeFile(filePath, file.buffer);
|
|
825
829
|
}
|
|
826
830
|
manifest.push({
|
|
@@ -835,6 +839,7 @@ export {
|
|
|
835
839
|
generateCollectionFromSpec,
|
|
836
840
|
generateFile,
|
|
837
841
|
previewCollection,
|
|
842
|
+
withResolvedSalt,
|
|
838
843
|
withResolvedSeed,
|
|
839
844
|
writeCollectionFromSpec
|
|
840
845
|
};
|