dicom-synth 1.12.0 → 1.14.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 +27 -2
- package/dist/esm/describe/describe.js +176 -8
- package/dist/esm/index.js +170 -10
- package/dist/types/describe/describe.d.ts +2 -0
- package/package.json +1 -1
|
@@ -6,12 +6,14 @@ 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>] [--gzip]\n' +
|
|
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' +
|
|
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' +
|
|
13
13
|
'\n' +
|
|
14
|
-
'
|
|
14
|
+
'UIDs are preserved by default as hash-derived synthetic UIDs, keeping\n' +
|
|
15
|
+
'cross-file links (FrameOfReferenceUID etc.) without emitting any original\n' +
|
|
16
|
+
'UID. Other tags are not copied unless named.\n' +
|
|
15
17
|
'--keep-tags copies the named tags (DICOM keywords or 8-hex tags)\n' +
|
|
16
18
|
"verbatim; if a kept tag holds PHI, handling it is the caller's\n" +
|
|
17
19
|
"responsibility, not this tool's.\n" +
|
|
@@ -19,6 +21,11 @@ function usage() {
|
|
|
19
21
|
'line, # comments); merged with any --keep-tags.\n' +
|
|
20
22
|
'--size-tolerance folds near-identical file sizes (fraction, e.g. 0.05\n' +
|
|
21
23
|
'= ±5%) into one entry; default exact.\n' +
|
|
24
|
+
'--no-preserve-uids emits shape only with freshly minted UIDs (no link\n' +
|
|
25
|
+
'preservation).\n' +
|
|
26
|
+
'--uid-salt fixes the hashing salt (hex) for a specific namespace; when\n' +
|
|
27
|
+
"omitted the salt derives from the dataset's own study UIDs (reproducible,\n" +
|
|
28
|
+
'and disjoint across datasets). The salt is reported on stderr.\n' +
|
|
22
29
|
'--gzip (or a .gz --out suffix) writes the spec gzip-compressed.',
|
|
23
30
|
)
|
|
24
31
|
process.exit(1)
|
|
@@ -38,6 +45,8 @@ if (args.length === 0) usage()
|
|
|
38
45
|
let dir
|
|
39
46
|
let outPath
|
|
40
47
|
let sizeTolerance
|
|
48
|
+
let preserveUids = true
|
|
49
|
+
let uidSalt
|
|
41
50
|
let gzip = false
|
|
42
51
|
const keepTags = []
|
|
43
52
|
|
|
@@ -64,6 +73,10 @@ for (let i = 0; i < args.length; i++) {
|
|
|
64
73
|
console.error('--size-tolerance must be a non-negative number')
|
|
65
74
|
process.exit(1)
|
|
66
75
|
}
|
|
76
|
+
} else if (args[i] === '--no-preserve-uids') {
|
|
77
|
+
preserveUids = false
|
|
78
|
+
} else if (args[i] === '--uid-salt' && args[i + 1]) {
|
|
79
|
+
uidSalt = args[++i]
|
|
67
80
|
} else if (args[i] === '--gzip') {
|
|
68
81
|
gzip = true
|
|
69
82
|
} else if (args[i].startsWith('--')) {
|
|
@@ -79,11 +92,18 @@ for (let i = 0; i < args.length; i++) {
|
|
|
79
92
|
|
|
80
93
|
if (dir === undefined) usage()
|
|
81
94
|
|
|
95
|
+
if (uidSalt !== undefined && !preserveUids) {
|
|
96
|
+
console.error('--uid-salt cannot be combined with --no-preserve-uids')
|
|
97
|
+
process.exit(1)
|
|
98
|
+
}
|
|
99
|
+
|
|
82
100
|
let result
|
|
83
101
|
try {
|
|
84
102
|
result = describeDirectory(dir, {
|
|
85
103
|
...(keepTags.length ? { keepTags } : {}),
|
|
86
104
|
...(sizeTolerance !== undefined ? { sizeTolerance } : {}),
|
|
105
|
+
preserveUids,
|
|
106
|
+
...(uidSalt !== undefined ? { uidSalt } : {}),
|
|
87
107
|
})
|
|
88
108
|
} catch (err) {
|
|
89
109
|
console.error(`Error: ${err.message}`)
|
|
@@ -103,6 +123,11 @@ const { dicomFiles, skipped, unknownModality } = result.stats
|
|
|
103
123
|
console.error(
|
|
104
124
|
`Described ${dicomFiles} DICOM file(s); skipped ${skipped} non-DICOM/ungroupable file(s)`,
|
|
105
125
|
)
|
|
126
|
+
if (result.spec.uidSalt) {
|
|
127
|
+
console.error(
|
|
128
|
+
` UIDs hash-derived with salt ${result.spec.uidSalt} (reuse via --uid-salt to reproduce)`,
|
|
129
|
+
)
|
|
130
|
+
}
|
|
106
131
|
if (unknownModality > 0) {
|
|
107
132
|
console.error(
|
|
108
133
|
` ${unknownModality} file(s) had an unsupported Modality — emitted as default (CT)`,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// src/describe/describe.ts
|
|
2
|
+
import { createHash as createHash2 } from "node:crypto";
|
|
2
3
|
import {
|
|
3
4
|
closeSync,
|
|
4
5
|
openSync,
|
|
@@ -435,6 +436,14 @@ function validateDatasetSpec(raw) {
|
|
|
435
436
|
};
|
|
436
437
|
}
|
|
437
438
|
|
|
439
|
+
// src/syntheticFixtures/uid.ts
|
|
440
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
441
|
+
function hashUid(salt, key) {
|
|
442
|
+
const digest = createHash("sha256").update(salt).update(" ").update(key).digest();
|
|
443
|
+
const n = BigInt(`0x${digest.subarray(0, 16).toString("hex")}`);
|
|
444
|
+
return `2.25.${n.toString()}`;
|
|
445
|
+
}
|
|
446
|
+
|
|
438
447
|
// src/describe/describe.ts
|
|
439
448
|
var dcmjsAny = dcmjs;
|
|
440
449
|
var HEX_TAG_RE2 = /^[0-9a-fA-F]{8}$/;
|
|
@@ -592,6 +601,113 @@ function extractTags(record, keepKeywords) {
|
|
|
592
601
|
}
|
|
593
602
|
return found ? tags : void 0;
|
|
594
603
|
}
|
|
604
|
+
var DICOM_ORG_ROOT = "1.2.840.10008.";
|
|
605
|
+
var UID_SWEEP_EXCLUDE = /* @__PURE__ */ new Set([
|
|
606
|
+
"StudyInstanceUID",
|
|
607
|
+
"SeriesInstanceUID",
|
|
608
|
+
"SOPClassUID"
|
|
609
|
+
]);
|
|
610
|
+
var vrCache = /* @__PURE__ */ new Map();
|
|
611
|
+
function vrOf(keyword) {
|
|
612
|
+
if (vrCache.has(keyword)) return vrCache.get(keyword);
|
|
613
|
+
const nm = dcmjsAny.data.DicomMetaDictionary.nameMap;
|
|
614
|
+
const vr = (nm?.[keyword] ?? nm?.[`RETIRED_${keyword}`])?.vr;
|
|
615
|
+
vrCache.set(keyword, vr);
|
|
616
|
+
return vr;
|
|
617
|
+
}
|
|
618
|
+
function hashUidVia(map, salt, original) {
|
|
619
|
+
let synthetic = map.get(original);
|
|
620
|
+
if (synthetic === void 0) {
|
|
621
|
+
synthetic = hashUid(salt, original);
|
|
622
|
+
map.set(original, synthetic);
|
|
623
|
+
}
|
|
624
|
+
return synthetic;
|
|
625
|
+
}
|
|
626
|
+
function isUidLeaf(value) {
|
|
627
|
+
return typeof value === "string" || Array.isArray(value) && value.every((v) => typeof v === "string");
|
|
628
|
+
}
|
|
629
|
+
function hasHashableUid(value) {
|
|
630
|
+
const uids = typeof value === "string" ? [value] : value;
|
|
631
|
+
return uids.some((v) => !v.startsWith(DICOM_ORG_ROOT));
|
|
632
|
+
}
|
|
633
|
+
function pruneUidSequence(value) {
|
|
634
|
+
const items = Array.isArray(value) ? value : [value];
|
|
635
|
+
const pruned = [];
|
|
636
|
+
for (const item of items) {
|
|
637
|
+
if (typeof item !== "object" || item === null || Array.isArray(item))
|
|
638
|
+
continue;
|
|
639
|
+
const skeleton = {};
|
|
640
|
+
let hashable = false;
|
|
641
|
+
for (const [keyword, v] of Object.entries(item)) {
|
|
642
|
+
if (keyword === "_vrMap") continue;
|
|
643
|
+
const vr = vrOf(keyword);
|
|
644
|
+
if (vr === "UI") {
|
|
645
|
+
if (isUidLeaf(v)) {
|
|
646
|
+
skeleton[keyword] = v;
|
|
647
|
+
if (hasHashableUid(v)) hashable = true;
|
|
648
|
+
}
|
|
649
|
+
} else if (vr === "SQ") {
|
|
650
|
+
const nested = pruneUidSequence(v);
|
|
651
|
+
if (nested) {
|
|
652
|
+
skeleton[keyword] = nested;
|
|
653
|
+
hashable = true;
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
if (hashable) pruned.push(skeleton);
|
|
658
|
+
}
|
|
659
|
+
return pruned.length > 0 ? pruned : void 0;
|
|
660
|
+
}
|
|
661
|
+
function collectUidOriginals(record) {
|
|
662
|
+
const uids = {};
|
|
663
|
+
let found = false;
|
|
664
|
+
for (const [keyword, value] of Object.entries(record)) {
|
|
665
|
+
if (UID_SWEEP_EXCLUDE.has(keyword)) continue;
|
|
666
|
+
const vr = vrOf(keyword);
|
|
667
|
+
if (vr === "UI") {
|
|
668
|
+
if (isUidLeaf(value) && hasHashableUid(value)) {
|
|
669
|
+
uids[keyword] = value;
|
|
670
|
+
found = true;
|
|
671
|
+
}
|
|
672
|
+
} else if (vr === "SQ") {
|
|
673
|
+
const pruned = pruneUidSequence(value);
|
|
674
|
+
if (pruned) {
|
|
675
|
+
uids[keyword] = pruned;
|
|
676
|
+
found = true;
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
return found ? uids : void 0;
|
|
681
|
+
}
|
|
682
|
+
function hashUidValue(value, salt, map) {
|
|
683
|
+
if (typeof value === "string") {
|
|
684
|
+
return value.startsWith(DICOM_ORG_ROOT) ? value : hashUidVia(map, salt, value);
|
|
685
|
+
}
|
|
686
|
+
if (Array.isArray(value)) return value.map((v) => hashUidValue(v, salt, map));
|
|
687
|
+
return Object.fromEntries(
|
|
688
|
+
Object.entries(value).map(([k, v]) => [
|
|
689
|
+
k,
|
|
690
|
+
hashUidValue(v, salt, map)
|
|
691
|
+
])
|
|
692
|
+
);
|
|
693
|
+
}
|
|
694
|
+
function hashUidTags(uids, salt, map) {
|
|
695
|
+
const tags = {};
|
|
696
|
+
for (const [keyword, original] of Object.entries(uids)) {
|
|
697
|
+
tags[keyword] = hashUidValue(original, salt, map);
|
|
698
|
+
}
|
|
699
|
+
return tags;
|
|
700
|
+
}
|
|
701
|
+
function deriveSalt(studyUids) {
|
|
702
|
+
const h = createHash2("sha256");
|
|
703
|
+
for (const uid of [...studyUids].sort()) h.update(uid).update("\n");
|
|
704
|
+
return h.digest("hex");
|
|
705
|
+
}
|
|
706
|
+
function mergeTags(a, b) {
|
|
707
|
+
if (!a) return b;
|
|
708
|
+
if (!b) return a;
|
|
709
|
+
return { ...a, ...b };
|
|
710
|
+
}
|
|
595
711
|
function sizeKbOf(bytes) {
|
|
596
712
|
return Math.max(1, Math.round(bytes / 1024));
|
|
597
713
|
}
|
|
@@ -696,7 +812,9 @@ function seriesSpecFromAccum(accum) {
|
|
|
696
812
|
}
|
|
697
813
|
function describeDirectory(dir, options = {}) {
|
|
698
814
|
const keepKeywords = options.keepTags ? resolveKeepTags(options.keepTags) : [];
|
|
699
|
-
const
|
|
815
|
+
const preserveUids = options.preserveUids ?? true;
|
|
816
|
+
const uidMap = /* @__PURE__ */ new Map();
|
|
817
|
+
const useRecords = keepKeywords.length > 0 || preserveUids;
|
|
700
818
|
const tolerance = options.sizeTolerance ?? 0;
|
|
701
819
|
if (!Number.isFinite(tolerance) || tolerance < 0) {
|
|
702
820
|
throw new Error(
|
|
@@ -734,9 +852,16 @@ function describeDirectory(dir, options = {}) {
|
|
|
734
852
|
stats.unknownModality++;
|
|
735
853
|
}
|
|
736
854
|
}
|
|
737
|
-
const
|
|
855
|
+
const keptTags = keepKeywords.length ? extractTags(record, keepKeywords) : void 0;
|
|
856
|
+
const uidOriginals = preserveUids ? collectUidOriginals(record) : void 0;
|
|
738
857
|
const bytes = large ? size : Math.min(bucketBytes(size, tolerance), MAX_PIXEL_BYTES);
|
|
739
|
-
const fileRecord = {
|
|
858
|
+
const fileRecord = {
|
|
859
|
+
large,
|
|
860
|
+
bytes,
|
|
861
|
+
modality,
|
|
862
|
+
tags: keptTags,
|
|
863
|
+
uidOriginals
|
|
864
|
+
};
|
|
740
865
|
let study = studies.get(studyUid);
|
|
741
866
|
if (!study) {
|
|
742
867
|
study = /* @__PURE__ */ new Map();
|
|
@@ -744,7 +869,7 @@ function describeDirectory(dir, options = {}) {
|
|
|
744
869
|
}
|
|
745
870
|
let series = study.get(seriesUid);
|
|
746
871
|
if (!series) {
|
|
747
|
-
series =
|
|
872
|
+
series = useRecords ? [] : /* @__PURE__ */ new Map();
|
|
748
873
|
study.set(seriesUid, series);
|
|
749
874
|
}
|
|
750
875
|
if (Array.isArray(series)) {
|
|
@@ -753,13 +878,56 @@ function describeDirectory(dir, options = {}) {
|
|
|
753
878
|
accumulate(series, fileRecord);
|
|
754
879
|
}
|
|
755
880
|
}
|
|
756
|
-
const
|
|
757
|
-
|
|
758
|
-
|
|
881
|
+
const salt = preserveUids ? options.uidSalt ?? deriveSalt([...studies.keys()]) : void 0;
|
|
882
|
+
if (salt !== void 0) {
|
|
883
|
+
const sweptKeepTags = /* @__PURE__ */ new Set();
|
|
884
|
+
for (const study of studies.values()) {
|
|
885
|
+
for (const series of study.values()) {
|
|
886
|
+
if (!Array.isArray(series)) continue;
|
|
887
|
+
for (const rec of series) {
|
|
888
|
+
if (rec.uidOriginals) {
|
|
889
|
+
if (rec.tags) {
|
|
890
|
+
for (const keyword of Object.keys(rec.uidOriginals)) {
|
|
891
|
+
if (keyword in rec.tags) sweptKeepTags.add(keyword);
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
rec.tags = mergeTags(
|
|
895
|
+
rec.tags,
|
|
896
|
+
hashUidTags(rec.uidOriginals, salt, uidMap)
|
|
897
|
+
);
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
for (const keyword of sweptKeepTags) {
|
|
903
|
+
console.warn(
|
|
904
|
+
`describe: keep-tag "${keyword}" contains UID references \u2014 kept as its hashed UID skeleton, not verbatim (disable with preserveUids: false)`
|
|
905
|
+
);
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
const withGroupUid = (spec2, seriesUid) => salt === void 0 ? spec2 : {
|
|
909
|
+
...spec2,
|
|
910
|
+
tags: {
|
|
911
|
+
...spec2.tags,
|
|
912
|
+
SeriesInstanceUID: hashUidVia(uidMap, salt, seriesUid)
|
|
913
|
+
}
|
|
914
|
+
};
|
|
915
|
+
const studySpecs = [...studies.entries()].map(
|
|
916
|
+
([studyUid, study]) => ({
|
|
917
|
+
...salt !== void 0 ? { tags: { StudyInstanceUID: hashUidVia(uidMap, salt, studyUid) } } : {},
|
|
918
|
+
series: [...study.entries()].map(
|
|
919
|
+
([seriesUid, accum]) => withGroupUid(seriesSpecFromAccum(accum), seriesUid)
|
|
920
|
+
)
|
|
921
|
+
})
|
|
922
|
+
);
|
|
759
923
|
if (studySpecs.length === 0) {
|
|
760
924
|
throw new Error(`describe: no DICOM series found in ${dir}`);
|
|
761
925
|
}
|
|
762
|
-
const spec = {
|
|
926
|
+
const spec = {
|
|
927
|
+
studies: studySpecs,
|
|
928
|
+
layout: "hierarchical",
|
|
929
|
+
...salt !== void 0 ? { uidSalt: salt } : {}
|
|
930
|
+
};
|
|
763
931
|
validateDatasetSpec(spec);
|
|
764
932
|
return { spec, stats };
|
|
765
933
|
}
|
package/dist/esm/index.js
CHANGED
|
@@ -1390,6 +1390,7 @@ async function writeCollectionFromSpec(spec, outDir) {
|
|
|
1390
1390
|
}
|
|
1391
1391
|
|
|
1392
1392
|
// src/describe/describe.ts
|
|
1393
|
+
import { createHash as createHash2 } from "node:crypto";
|
|
1393
1394
|
import {
|
|
1394
1395
|
closeSync as closeSync2,
|
|
1395
1396
|
openSync as openSync2,
|
|
@@ -1555,6 +1556,113 @@ function extractTags(record, keepKeywords) {
|
|
|
1555
1556
|
}
|
|
1556
1557
|
return found ? tags : void 0;
|
|
1557
1558
|
}
|
|
1559
|
+
var DICOM_ORG_ROOT = "1.2.840.10008.";
|
|
1560
|
+
var UID_SWEEP_EXCLUDE = /* @__PURE__ */ new Set([
|
|
1561
|
+
"StudyInstanceUID",
|
|
1562
|
+
"SeriesInstanceUID",
|
|
1563
|
+
"SOPClassUID"
|
|
1564
|
+
]);
|
|
1565
|
+
var vrCache = /* @__PURE__ */ new Map();
|
|
1566
|
+
function vrOf(keyword) {
|
|
1567
|
+
if (vrCache.has(keyword)) return vrCache.get(keyword);
|
|
1568
|
+
const nm = dcmjsAny2.data.DicomMetaDictionary.nameMap;
|
|
1569
|
+
const vr = (nm?.[keyword] ?? nm?.[`RETIRED_${keyword}`])?.vr;
|
|
1570
|
+
vrCache.set(keyword, vr);
|
|
1571
|
+
return vr;
|
|
1572
|
+
}
|
|
1573
|
+
function hashUidVia(map, salt, original) {
|
|
1574
|
+
let synthetic = map.get(original);
|
|
1575
|
+
if (synthetic === void 0) {
|
|
1576
|
+
synthetic = hashUid(salt, original);
|
|
1577
|
+
map.set(original, synthetic);
|
|
1578
|
+
}
|
|
1579
|
+
return synthetic;
|
|
1580
|
+
}
|
|
1581
|
+
function isUidLeaf(value) {
|
|
1582
|
+
return typeof value === "string" || Array.isArray(value) && value.every((v) => typeof v === "string");
|
|
1583
|
+
}
|
|
1584
|
+
function hasHashableUid(value) {
|
|
1585
|
+
const uids = typeof value === "string" ? [value] : value;
|
|
1586
|
+
return uids.some((v) => !v.startsWith(DICOM_ORG_ROOT));
|
|
1587
|
+
}
|
|
1588
|
+
function pruneUidSequence(value) {
|
|
1589
|
+
const items = Array.isArray(value) ? value : [value];
|
|
1590
|
+
const pruned = [];
|
|
1591
|
+
for (const item of items) {
|
|
1592
|
+
if (typeof item !== "object" || item === null || Array.isArray(item))
|
|
1593
|
+
continue;
|
|
1594
|
+
const skeleton = {};
|
|
1595
|
+
let hashable = false;
|
|
1596
|
+
for (const [keyword, v] of Object.entries(item)) {
|
|
1597
|
+
if (keyword === "_vrMap") continue;
|
|
1598
|
+
const vr = vrOf(keyword);
|
|
1599
|
+
if (vr === "UI") {
|
|
1600
|
+
if (isUidLeaf(v)) {
|
|
1601
|
+
skeleton[keyword] = v;
|
|
1602
|
+
if (hasHashableUid(v)) hashable = true;
|
|
1603
|
+
}
|
|
1604
|
+
} else if (vr === "SQ") {
|
|
1605
|
+
const nested = pruneUidSequence(v);
|
|
1606
|
+
if (nested) {
|
|
1607
|
+
skeleton[keyword] = nested;
|
|
1608
|
+
hashable = true;
|
|
1609
|
+
}
|
|
1610
|
+
}
|
|
1611
|
+
}
|
|
1612
|
+
if (hashable) pruned.push(skeleton);
|
|
1613
|
+
}
|
|
1614
|
+
return pruned.length > 0 ? pruned : void 0;
|
|
1615
|
+
}
|
|
1616
|
+
function collectUidOriginals(record) {
|
|
1617
|
+
const uids = {};
|
|
1618
|
+
let found = false;
|
|
1619
|
+
for (const [keyword, value] of Object.entries(record)) {
|
|
1620
|
+
if (UID_SWEEP_EXCLUDE.has(keyword)) continue;
|
|
1621
|
+
const vr = vrOf(keyword);
|
|
1622
|
+
if (vr === "UI") {
|
|
1623
|
+
if (isUidLeaf(value) && hasHashableUid(value)) {
|
|
1624
|
+
uids[keyword] = value;
|
|
1625
|
+
found = true;
|
|
1626
|
+
}
|
|
1627
|
+
} else if (vr === "SQ") {
|
|
1628
|
+
const pruned = pruneUidSequence(value);
|
|
1629
|
+
if (pruned) {
|
|
1630
|
+
uids[keyword] = pruned;
|
|
1631
|
+
found = true;
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1635
|
+
return found ? uids : void 0;
|
|
1636
|
+
}
|
|
1637
|
+
function hashUidValue(value, salt, map) {
|
|
1638
|
+
if (typeof value === "string") {
|
|
1639
|
+
return value.startsWith(DICOM_ORG_ROOT) ? value : hashUidVia(map, salt, value);
|
|
1640
|
+
}
|
|
1641
|
+
if (Array.isArray(value)) return value.map((v) => hashUidValue(v, salt, map));
|
|
1642
|
+
return Object.fromEntries(
|
|
1643
|
+
Object.entries(value).map(([k, v]) => [
|
|
1644
|
+
k,
|
|
1645
|
+
hashUidValue(v, salt, map)
|
|
1646
|
+
])
|
|
1647
|
+
);
|
|
1648
|
+
}
|
|
1649
|
+
function hashUidTags(uids, salt, map) {
|
|
1650
|
+
const tags = {};
|
|
1651
|
+
for (const [keyword, original] of Object.entries(uids)) {
|
|
1652
|
+
tags[keyword] = hashUidValue(original, salt, map);
|
|
1653
|
+
}
|
|
1654
|
+
return tags;
|
|
1655
|
+
}
|
|
1656
|
+
function deriveSalt(studyUids) {
|
|
1657
|
+
const h = createHash2("sha256");
|
|
1658
|
+
for (const uid of [...studyUids].sort()) h.update(uid).update("\n");
|
|
1659
|
+
return h.digest("hex");
|
|
1660
|
+
}
|
|
1661
|
+
function mergeTags(a, b) {
|
|
1662
|
+
if (!a) return b;
|
|
1663
|
+
if (!b) return a;
|
|
1664
|
+
return { ...a, ...b };
|
|
1665
|
+
}
|
|
1558
1666
|
function sizeKbOf(bytes) {
|
|
1559
1667
|
return Math.max(1, Math.round(bytes / 1024));
|
|
1560
1668
|
}
|
|
@@ -1659,7 +1767,9 @@ function seriesSpecFromAccum(accum) {
|
|
|
1659
1767
|
}
|
|
1660
1768
|
function describeDirectory(dir, options = {}) {
|
|
1661
1769
|
const keepKeywords = options.keepTags ? resolveKeepTags(options.keepTags) : [];
|
|
1662
|
-
const
|
|
1770
|
+
const preserveUids = options.preserveUids ?? true;
|
|
1771
|
+
const uidMap = /* @__PURE__ */ new Map();
|
|
1772
|
+
const useRecords = keepKeywords.length > 0 || preserveUids;
|
|
1663
1773
|
const tolerance = options.sizeTolerance ?? 0;
|
|
1664
1774
|
if (!Number.isFinite(tolerance) || tolerance < 0) {
|
|
1665
1775
|
throw new Error(
|
|
@@ -1697,9 +1807,16 @@ function describeDirectory(dir, options = {}) {
|
|
|
1697
1807
|
stats.unknownModality++;
|
|
1698
1808
|
}
|
|
1699
1809
|
}
|
|
1700
|
-
const
|
|
1810
|
+
const keptTags = keepKeywords.length ? extractTags(record, keepKeywords) : void 0;
|
|
1811
|
+
const uidOriginals = preserveUids ? collectUidOriginals(record) : void 0;
|
|
1701
1812
|
const bytes = large ? size : Math.min(bucketBytes(size, tolerance), MAX_PIXEL_BYTES);
|
|
1702
|
-
const fileRecord = {
|
|
1813
|
+
const fileRecord = {
|
|
1814
|
+
large,
|
|
1815
|
+
bytes,
|
|
1816
|
+
modality,
|
|
1817
|
+
tags: keptTags,
|
|
1818
|
+
uidOriginals
|
|
1819
|
+
};
|
|
1703
1820
|
let study = studies.get(studyUid);
|
|
1704
1821
|
if (!study) {
|
|
1705
1822
|
study = /* @__PURE__ */ new Map();
|
|
@@ -1707,7 +1824,7 @@ function describeDirectory(dir, options = {}) {
|
|
|
1707
1824
|
}
|
|
1708
1825
|
let series = study.get(seriesUid);
|
|
1709
1826
|
if (!series) {
|
|
1710
|
-
series =
|
|
1827
|
+
series = useRecords ? [] : /* @__PURE__ */ new Map();
|
|
1711
1828
|
study.set(seriesUid, series);
|
|
1712
1829
|
}
|
|
1713
1830
|
if (Array.isArray(series)) {
|
|
@@ -1716,13 +1833,56 @@ function describeDirectory(dir, options = {}) {
|
|
|
1716
1833
|
accumulate(series, fileRecord);
|
|
1717
1834
|
}
|
|
1718
1835
|
}
|
|
1719
|
-
const
|
|
1720
|
-
|
|
1721
|
-
|
|
1836
|
+
const salt = preserveUids ? options.uidSalt ?? deriveSalt([...studies.keys()]) : void 0;
|
|
1837
|
+
if (salt !== void 0) {
|
|
1838
|
+
const sweptKeepTags = /* @__PURE__ */ new Set();
|
|
1839
|
+
for (const study of studies.values()) {
|
|
1840
|
+
for (const series of study.values()) {
|
|
1841
|
+
if (!Array.isArray(series)) continue;
|
|
1842
|
+
for (const rec of series) {
|
|
1843
|
+
if (rec.uidOriginals) {
|
|
1844
|
+
if (rec.tags) {
|
|
1845
|
+
for (const keyword of Object.keys(rec.uidOriginals)) {
|
|
1846
|
+
if (keyword in rec.tags) sweptKeepTags.add(keyword);
|
|
1847
|
+
}
|
|
1848
|
+
}
|
|
1849
|
+
rec.tags = mergeTags(
|
|
1850
|
+
rec.tags,
|
|
1851
|
+
hashUidTags(rec.uidOriginals, salt, uidMap)
|
|
1852
|
+
);
|
|
1853
|
+
}
|
|
1854
|
+
}
|
|
1855
|
+
}
|
|
1856
|
+
}
|
|
1857
|
+
for (const keyword of sweptKeepTags) {
|
|
1858
|
+
console.warn(
|
|
1859
|
+
`describe: keep-tag "${keyword}" contains UID references \u2014 kept as its hashed UID skeleton, not verbatim (disable with preserveUids: false)`
|
|
1860
|
+
);
|
|
1861
|
+
}
|
|
1862
|
+
}
|
|
1863
|
+
const withGroupUid = (spec2, seriesUid) => salt === void 0 ? spec2 : {
|
|
1864
|
+
...spec2,
|
|
1865
|
+
tags: {
|
|
1866
|
+
...spec2.tags,
|
|
1867
|
+
SeriesInstanceUID: hashUidVia(uidMap, salt, seriesUid)
|
|
1868
|
+
}
|
|
1869
|
+
};
|
|
1870
|
+
const studySpecs = [...studies.entries()].map(
|
|
1871
|
+
([studyUid, study]) => ({
|
|
1872
|
+
...salt !== void 0 ? { tags: { StudyInstanceUID: hashUidVia(uidMap, salt, studyUid) } } : {},
|
|
1873
|
+
series: [...study.entries()].map(
|
|
1874
|
+
([seriesUid, accum]) => withGroupUid(seriesSpecFromAccum(accum), seriesUid)
|
|
1875
|
+
)
|
|
1876
|
+
})
|
|
1877
|
+
);
|
|
1722
1878
|
if (studySpecs.length === 0) {
|
|
1723
1879
|
throw new Error(`describe: no DICOM series found in ${dir}`);
|
|
1724
1880
|
}
|
|
1725
|
-
const spec = {
|
|
1881
|
+
const spec = {
|
|
1882
|
+
studies: studySpecs,
|
|
1883
|
+
layout: "hierarchical",
|
|
1884
|
+
...salt !== void 0 ? { uidSalt: salt } : {}
|
|
1885
|
+
};
|
|
1726
1886
|
validateDatasetSpec(spec);
|
|
1727
1887
|
return { spec, stats };
|
|
1728
1888
|
}
|
|
@@ -1759,7 +1919,7 @@ function loadCaseById(casesJsonPath, id) {
|
|
|
1759
1919
|
}
|
|
1760
1920
|
|
|
1761
1921
|
// src/public-fixtures/fetch.ts
|
|
1762
|
-
import { createHash as
|
|
1922
|
+
import { createHash as createHash3 } from "node:crypto";
|
|
1763
1923
|
import { existsSync, mkdirSync as mkdirSync4, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "node:fs";
|
|
1764
1924
|
import { homedir } from "node:os";
|
|
1765
1925
|
import { join as join3 } from "node:path";
|
|
@@ -1768,7 +1928,7 @@ function caseCachePath(sha256, root = DEFAULT_CACHE_ROOT) {
|
|
|
1768
1928
|
return join3(root, sha256, "file.dcm");
|
|
1769
1929
|
}
|
|
1770
1930
|
function verifySha256(buffer, expected) {
|
|
1771
|
-
const h =
|
|
1931
|
+
const h = createHash3("sha256").update(buffer).digest("hex");
|
|
1772
1932
|
if (h !== expected) {
|
|
1773
1933
|
throw new Error(
|
|
1774
1934
|
`SHA256 mismatch: expected ${expected}, got ${h}. Upstream may have changed; bump metadata after review.`
|