dicom-synth 1.17.0 → 1.18.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.
|
@@ -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]>] [--tree] [--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>] [--keep-private-tags] [--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' +
|
|
@@ -22,6 +22,13 @@ function usage() {
|
|
|
22
22
|
"responsibility, not this tool's.\n" +
|
|
23
23
|
'--keep-tags-file reads the same allow-list from a file (one tag per\n' +
|
|
24
24
|
'line, # comments); merged with any --keep-tags.\n' +
|
|
25
|
+
'--keep-private-tags captures ALL private (odd-group) elements as-is as\n' +
|
|
26
|
+
'raw { vr, value } tag forms — creators, scalars, nested private\n' +
|
|
27
|
+
'sequences. Private blocks routinely carry PHI/device detail: handling\n' +
|
|
28
|
+
"that is the caller's responsibility. UID-valued leaves are still\n" +
|
|
29
|
+
'hashed unless --no-preserve-uids. Individual private tags can instead\n' +
|
|
30
|
+
'be named (8-hex) in --keep-tags. Binary/unparseable payloads are\n' +
|
|
31
|
+
'skipped and counted.\n' +
|
|
25
32
|
'--size-tolerance folds near-identical file sizes (fraction, e.g. 0.05\n' +
|
|
26
33
|
'= ±5%) into one entry; default exact.\n' +
|
|
27
34
|
'--no-preserve-uids emits shape only with freshly minted UIDs (no link\n' +
|
|
@@ -52,6 +59,7 @@ let preserveUids = true
|
|
|
52
59
|
let uidSalt
|
|
53
60
|
let gzip = false
|
|
54
61
|
let captureTree = false
|
|
62
|
+
let keepPrivateTags = false
|
|
55
63
|
const keepTags = []
|
|
56
64
|
|
|
57
65
|
for (let i = 0; i < args.length; i++) {
|
|
@@ -77,6 +85,8 @@ for (let i = 0; i < args.length; i++) {
|
|
|
77
85
|
console.error('--size-tolerance must be a non-negative number')
|
|
78
86
|
process.exit(1)
|
|
79
87
|
}
|
|
88
|
+
} else if (args[i] === '--keep-private-tags') {
|
|
89
|
+
keepPrivateTags = true
|
|
80
90
|
} else if (args[i] === '--no-preserve-uids') {
|
|
81
91
|
preserveUids = false
|
|
82
92
|
} else if (args[i] === '--tree') {
|
|
@@ -107,6 +117,7 @@ let result
|
|
|
107
117
|
try {
|
|
108
118
|
result = describeDirectory(dir, {
|
|
109
119
|
...(keepTags.length ? { keepTags } : {}),
|
|
120
|
+
...(keepPrivateTags ? { keepPrivateTags: true } : {}),
|
|
110
121
|
...(sizeTolerance !== undefined ? { sizeTolerance } : {}),
|
|
111
122
|
preserveUids,
|
|
112
123
|
...(uidSalt !== undefined ? { uidSalt } : {}),
|
|
@@ -718,13 +718,28 @@ function buildHexToKeyword() {
|
|
|
718
718
|
}
|
|
719
719
|
return map;
|
|
720
720
|
}
|
|
721
|
+
function buildKeepPlan(options) {
|
|
722
|
+
const { keywords, privateHex } = options.keepTags ? resolveKeepTags(options.keepTags) : { keywords: [], privateHex: /* @__PURE__ */ new Set() };
|
|
723
|
+
const allPrivate = options.keepPrivateTags === true;
|
|
724
|
+
return {
|
|
725
|
+
keywords,
|
|
726
|
+
privateHex,
|
|
727
|
+
allPrivate,
|
|
728
|
+
capturingPrivate: allPrivate || privateHex.size > 0
|
|
729
|
+
};
|
|
730
|
+
}
|
|
721
731
|
function resolveKeepTags(keepTags) {
|
|
722
732
|
const nameMap = dcmjsAny.data.DicomMetaDictionary.nameMap;
|
|
723
733
|
let hexToKeyword;
|
|
724
734
|
const kept = [];
|
|
735
|
+
const privateHex = /* @__PURE__ */ new Set();
|
|
725
736
|
for (const tag of keepTags) {
|
|
726
737
|
let keyword;
|
|
727
738
|
if (HEX_TAG_RE2.test(tag)) {
|
|
739
|
+
if (isPrivateHexTag(tag)) {
|
|
740
|
+
privateHex.add(tag.toUpperCase());
|
|
741
|
+
continue;
|
|
742
|
+
}
|
|
728
743
|
hexToKeyword ?? (hexToKeyword = buildHexToKeyword());
|
|
729
744
|
const mapped = hexToKeyword.get(tag.toUpperCase());
|
|
730
745
|
if (!mapped) {
|
|
@@ -751,7 +766,71 @@ function resolveKeepTags(keepTags) {
|
|
|
751
766
|
}
|
|
752
767
|
kept.push(keyword);
|
|
753
768
|
}
|
|
754
|
-
return kept;
|
|
769
|
+
return { keywords: kept, privateHex };
|
|
770
|
+
}
|
|
771
|
+
function isUnrepresentableValue(value) {
|
|
772
|
+
return value === null || value === void 0 || typeof value === "number" && !Number.isFinite(value) || value instanceof ArrayBuffer || ArrayBuffer.isView(value);
|
|
773
|
+
}
|
|
774
|
+
function toRawCapture(el, skipped) {
|
|
775
|
+
const vr = el.vr;
|
|
776
|
+
if (typeof vr !== "string") {
|
|
777
|
+
skipped.count++;
|
|
778
|
+
return null;
|
|
779
|
+
}
|
|
780
|
+
if (vr === "SQ") {
|
|
781
|
+
const items = [];
|
|
782
|
+
for (const item of el.Value ?? []) {
|
|
783
|
+
if (typeof item !== "object" || item === null) continue;
|
|
784
|
+
const converted = {};
|
|
785
|
+
for (const [tag, nested] of Object.entries(item)) {
|
|
786
|
+
if (!HEX_TAG_RE2.test(tag)) continue;
|
|
787
|
+
const capture = toRawCapture(nested, skipped);
|
|
788
|
+
if (capture) converted[tag.toUpperCase()] = capture;
|
|
789
|
+
}
|
|
790
|
+
items.push(converted);
|
|
791
|
+
}
|
|
792
|
+
return { vr, value: items };
|
|
793
|
+
}
|
|
794
|
+
const values = el.Value ?? [];
|
|
795
|
+
if (values.some(isUnrepresentableValue)) {
|
|
796
|
+
skipped.count++;
|
|
797
|
+
return null;
|
|
798
|
+
}
|
|
799
|
+
return { vr, value: values.length === 1 ? values[0] : values };
|
|
800
|
+
}
|
|
801
|
+
function capturePrivateTags(rawDict, keep, skipped) {
|
|
802
|
+
const captured = {};
|
|
803
|
+
let found = false;
|
|
804
|
+
for (const [tag, el] of Object.entries(rawDict)) {
|
|
805
|
+
if (!HEX_TAG_RE2.test(tag) || !isPrivateHexTag(tag)) continue;
|
|
806
|
+
if (!keep.allPrivate && !keep.privateHex.has(tag.toUpperCase())) continue;
|
|
807
|
+
const capture = toRawCapture(el, skipped);
|
|
808
|
+
if (capture) {
|
|
809
|
+
captured[tag.toUpperCase()] = capture;
|
|
810
|
+
found = true;
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
return found ? captured : void 0;
|
|
814
|
+
}
|
|
815
|
+
function hashRawCaptureUids(capture, salt, map) {
|
|
816
|
+
if (capture.vr === "UI") {
|
|
817
|
+
return isUidLeaf(capture.value) ? { vr: "UI", value: hashUidValue(capture.value, salt, map) } : capture;
|
|
818
|
+
}
|
|
819
|
+
if (capture.vr === "SQ") {
|
|
820
|
+
const items = capture.value;
|
|
821
|
+
return {
|
|
822
|
+
vr: "SQ",
|
|
823
|
+
value: items.map(
|
|
824
|
+
(item) => Object.fromEntries(
|
|
825
|
+
Object.entries(item).map(([tag, nested]) => [
|
|
826
|
+
tag,
|
|
827
|
+
hashRawCaptureUids(nested, salt, map)
|
|
828
|
+
])
|
|
829
|
+
)
|
|
830
|
+
)
|
|
831
|
+
};
|
|
832
|
+
}
|
|
833
|
+
return capture;
|
|
755
834
|
}
|
|
756
835
|
function* walkFiles(dir) {
|
|
757
836
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
@@ -769,13 +848,16 @@ function toArrayBuffer(buf) {
|
|
|
769
848
|
buf.byteOffset + buf.byteLength
|
|
770
849
|
);
|
|
771
850
|
}
|
|
772
|
-
function
|
|
851
|
+
function parseParts(ab) {
|
|
773
852
|
const parsed = dcmjsAny.data.DicomMessage.readFile(ab);
|
|
774
|
-
return
|
|
853
|
+
return {
|
|
854
|
+
record: dcmjsAny.data.DicomMetaDictionary.naturalizeDataset(parsed.dict),
|
|
855
|
+
rawDict: parsed.dict
|
|
856
|
+
};
|
|
775
857
|
}
|
|
776
858
|
function parseFull(path) {
|
|
777
859
|
try {
|
|
778
|
-
return
|
|
860
|
+
return parseParts(toArrayBuffer(readFileSync(path)));
|
|
779
861
|
} catch {
|
|
780
862
|
return null;
|
|
781
863
|
}
|
|
@@ -833,7 +915,7 @@ function readHeaderOnly(path) {
|
|
|
833
915
|
EMPTY_OW_PIXEL_DATA
|
|
834
916
|
]);
|
|
835
917
|
try {
|
|
836
|
-
return
|
|
918
|
+
return parseParts(toArrayBuffer(headerOnly));
|
|
837
919
|
} catch {
|
|
838
920
|
return null;
|
|
839
921
|
}
|
|
@@ -959,6 +1041,20 @@ function mergeTags(a, b) {
|
|
|
959
1041
|
if (!b) return a;
|
|
960
1042
|
return { ...a, ...b };
|
|
961
1043
|
}
|
|
1044
|
+
function mergePrivateTags(rec, salt, uidMap) {
|
|
1045
|
+
if (!rec.privateTags) return;
|
|
1046
|
+
const hashed = Object.fromEntries(
|
|
1047
|
+
Object.entries(rec.privateTags).map(([tag, capture]) => [
|
|
1048
|
+
tag,
|
|
1049
|
+
salt === void 0 ? capture : hashRawCaptureUids(
|
|
1050
|
+
capture,
|
|
1051
|
+
salt,
|
|
1052
|
+
uidMap
|
|
1053
|
+
)
|
|
1054
|
+
])
|
|
1055
|
+
);
|
|
1056
|
+
rec.tags = mergeTags(rec.tags, hashed);
|
|
1057
|
+
}
|
|
962
1058
|
function scanFile(path) {
|
|
963
1059
|
let size;
|
|
964
1060
|
try {
|
|
@@ -968,7 +1064,7 @@ function scanFile(path) {
|
|
|
968
1064
|
}
|
|
969
1065
|
if (size > MAX_STREAM_BYTES) return null;
|
|
970
1066
|
const large = size > MAX_PIXEL_BYTES;
|
|
971
|
-
return { size, large,
|
|
1067
|
+
return { size, large, parsed: large ? readHeaderOnly(path) : parseFull(path) };
|
|
972
1068
|
}
|
|
973
1069
|
function presetModalityOf(record) {
|
|
974
1070
|
if (typeof record.Modality !== "string") return void 0;
|
|
@@ -983,6 +1079,13 @@ function hashRecordUids(rec, salt, uidMap, sweptKeepTags) {
|
|
|
983
1079
|
}
|
|
984
1080
|
rec.tags = mergeTags(rec.tags, hashUidTags(rec.uidOriginals, salt, uidMap));
|
|
985
1081
|
}
|
|
1082
|
+
function warnPrivateBinarySkipped(count) {
|
|
1083
|
+
if (count > 0) {
|
|
1084
|
+
console.warn(
|
|
1085
|
+
`describe: ${count} private element(s) with binary payloads were not captured \u2014 binary values cannot ride a JSON spec`
|
|
1086
|
+
);
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
986
1089
|
function warnSweptKeepTags(sweptKeepTags) {
|
|
987
1090
|
for (const keyword of sweptKeepTags) {
|
|
988
1091
|
console.warn(
|
|
@@ -1128,14 +1231,16 @@ function emitTree(acc) {
|
|
|
1128
1231
|
}
|
|
1129
1232
|
return nodes;
|
|
1130
1233
|
}
|
|
1131
|
-
function describeTree(dir, options,
|
|
1234
|
+
function describeTree(dir, options, keep) {
|
|
1132
1235
|
const root = { dirs: /* @__PURE__ */ new Map(), files: [] };
|
|
1133
1236
|
const stats = {
|
|
1134
1237
|
dicomFiles: 0,
|
|
1135
1238
|
skipped: 0,
|
|
1136
1239
|
unknownModality: 0,
|
|
1137
|
-
nonDicomFiles: 0
|
|
1240
|
+
nonDicomFiles: 0,
|
|
1241
|
+
privateBinarySkipped: 0
|
|
1138
1242
|
};
|
|
1243
|
+
const privateSkipped = { count: 0 };
|
|
1139
1244
|
const studyUids = /* @__PURE__ */ new Set();
|
|
1140
1245
|
const dicomLeaves = [];
|
|
1141
1246
|
for (const path of [...walkFiles(dir)].sort()) {
|
|
@@ -1144,7 +1249,8 @@ function describeTree(dir, options, keepKeywords) {
|
|
|
1144
1249
|
stats.skipped++;
|
|
1145
1250
|
continue;
|
|
1146
1251
|
}
|
|
1147
|
-
const { size, large,
|
|
1252
|
+
const { size, large, parsed } = scanned;
|
|
1253
|
+
const record = parsed?.record;
|
|
1148
1254
|
const studyUid = record?.StudyInstanceUID;
|
|
1149
1255
|
const seriesUid = record?.SeriesInstanceUID;
|
|
1150
1256
|
const relDirs = relative(dir, path).split(sep).slice(0, -1);
|
|
@@ -1175,9 +1281,10 @@ function describeTree(dir, options, keepKeywords) {
|
|
|
1175
1281
|
modality,
|
|
1176
1282
|
tags: mergeTags(
|
|
1177
1283
|
instanceNumber,
|
|
1178
|
-
|
|
1284
|
+
keep.keywords.length ? extractTags(record, keep.keywords) : void 0
|
|
1179
1285
|
),
|
|
1180
|
-
uidOriginals: collectUidOriginals(record)
|
|
1286
|
+
uidOriginals: collectUidOriginals(record),
|
|
1287
|
+
privateTags: keep.capturingPrivate && parsed ? capturePrivateTags(parsed.rawDict, keep, privateSkipped) : void 0
|
|
1181
1288
|
},
|
|
1182
1289
|
studyUid,
|
|
1183
1290
|
seriesUid
|
|
@@ -1193,18 +1300,21 @@ function describeTree(dir, options, keepKeywords) {
|
|
|
1193
1300
|
const sweptKeepTags = /* @__PURE__ */ new Set();
|
|
1194
1301
|
for (const { rec, studyUid, seriesUid } of dicomLeaves) {
|
|
1195
1302
|
hashRecordUids(rec, salt, uidMap, sweptKeepTags);
|
|
1303
|
+
mergePrivateTags(rec, salt, uidMap);
|
|
1196
1304
|
rec.tags = mergeTags(rec.tags, {
|
|
1197
1305
|
StudyInstanceUID: hashUidVia(uidMap, salt, studyUid),
|
|
1198
1306
|
SeriesInstanceUID: hashUidVia(uidMap, salt, seriesUid)
|
|
1199
1307
|
});
|
|
1200
1308
|
}
|
|
1201
1309
|
warnSweptKeepTags(sweptKeepTags);
|
|
1310
|
+
stats.privateBinarySkipped = privateSkipped.count;
|
|
1311
|
+
warnPrivateBinarySkipped(privateSkipped.count);
|
|
1202
1312
|
const spec = { tree: emitTree(root), uidSalt: salt };
|
|
1203
1313
|
validateDatasetSpec(spec);
|
|
1204
1314
|
return { spec, stats };
|
|
1205
1315
|
}
|
|
1206
1316
|
function describeDirectory(dir, options = {}) {
|
|
1207
|
-
const
|
|
1317
|
+
const keep = buildKeepPlan(options);
|
|
1208
1318
|
const preserveUids = options.preserveUids ?? true;
|
|
1209
1319
|
if (options.captureTree) {
|
|
1210
1320
|
if (!preserveUids) {
|
|
@@ -1212,10 +1322,10 @@ function describeDirectory(dir, options = {}) {
|
|
|
1212
1322
|
"describe: captureTree requires preserveUids \u2014 the captured tree carries its grouping as hashed UID tags"
|
|
1213
1323
|
);
|
|
1214
1324
|
}
|
|
1215
|
-
return describeTree(dir, options,
|
|
1325
|
+
return describeTree(dir, options, keep);
|
|
1216
1326
|
}
|
|
1217
1327
|
const uidMap = /* @__PURE__ */ new Map();
|
|
1218
|
-
const useRecords =
|
|
1328
|
+
const useRecords = keep.keywords.length > 0 || preserveUids || keep.capturingPrivate;
|
|
1219
1329
|
const tolerance = options.sizeTolerance ?? 0;
|
|
1220
1330
|
if (!Number.isFinite(tolerance) || tolerance < 0) {
|
|
1221
1331
|
throw new Error(
|
|
@@ -1227,15 +1337,18 @@ function describeDirectory(dir, options = {}) {
|
|
|
1227
1337
|
dicomFiles: 0,
|
|
1228
1338
|
skipped: 0,
|
|
1229
1339
|
unknownModality: 0,
|
|
1230
|
-
nonDicomFiles: 0
|
|
1340
|
+
nonDicomFiles: 0,
|
|
1341
|
+
privateBinarySkipped: 0
|
|
1231
1342
|
};
|
|
1343
|
+
const privateSkipped = { count: 0 };
|
|
1232
1344
|
for (const path of [...walkFiles(dir)].sort()) {
|
|
1233
1345
|
const scanned = scanFile(path);
|
|
1234
1346
|
if (!scanned) {
|
|
1235
1347
|
stats.skipped++;
|
|
1236
1348
|
continue;
|
|
1237
1349
|
}
|
|
1238
|
-
const { size, large,
|
|
1350
|
+
const { size, large, parsed } = scanned;
|
|
1351
|
+
const record = parsed?.record;
|
|
1239
1352
|
const studyUid = record?.StudyInstanceUID;
|
|
1240
1353
|
const seriesUid = record?.SeriesInstanceUID;
|
|
1241
1354
|
if (!record || typeof studyUid !== "string" || typeof seriesUid !== "string") {
|
|
@@ -1246,7 +1359,7 @@ function describeDirectory(dir, options = {}) {
|
|
|
1246
1359
|
const preset = presetModalityOf(record);
|
|
1247
1360
|
if (preset === "unsupported") stats.unknownModality++;
|
|
1248
1361
|
const modality = preset === "unsupported" ? void 0 : preset;
|
|
1249
|
-
const keptTags =
|
|
1362
|
+
const keptTags = keep.keywords.length ? extractTags(record, keep.keywords) : void 0;
|
|
1250
1363
|
const uidOriginals = preserveUids ? collectUidOriginals(record) : void 0;
|
|
1251
1364
|
const bytes = large ? size : Math.min(bucketBytes(size, tolerance), MAX_PIXEL_BYTES);
|
|
1252
1365
|
const fileRecord = {
|
|
@@ -1254,7 +1367,8 @@ function describeDirectory(dir, options = {}) {
|
|
|
1254
1367
|
bytes,
|
|
1255
1368
|
modality,
|
|
1256
1369
|
tags: keptTags,
|
|
1257
|
-
uidOriginals
|
|
1370
|
+
uidOriginals,
|
|
1371
|
+
privateTags: keep.capturingPrivate && parsed ? capturePrivateTags(parsed.rawDict, keep, privateSkipped) : void 0
|
|
1258
1372
|
};
|
|
1259
1373
|
let study = studies.get(studyUid);
|
|
1260
1374
|
if (!study) {
|
|
@@ -1273,18 +1387,23 @@ function describeDirectory(dir, options = {}) {
|
|
|
1273
1387
|
}
|
|
1274
1388
|
}
|
|
1275
1389
|
const salt = preserveUids ? options.uidSalt ?? deriveSalt([...studies.keys()]) : void 0;
|
|
1276
|
-
if (salt !== void 0) {
|
|
1390
|
+
if (salt !== void 0 || keep.capturingPrivate) {
|
|
1277
1391
|
const sweptKeepTags = /* @__PURE__ */ new Set();
|
|
1278
1392
|
for (const study of studies.values()) {
|
|
1279
1393
|
for (const series of study.values()) {
|
|
1280
1394
|
if (!Array.isArray(series)) continue;
|
|
1281
1395
|
for (const rec of series) {
|
|
1282
|
-
|
|
1396
|
+
if (salt !== void 0) {
|
|
1397
|
+
hashRecordUids(rec, salt, uidMap, sweptKeepTags);
|
|
1398
|
+
}
|
|
1399
|
+
mergePrivateTags(rec, salt, uidMap);
|
|
1283
1400
|
}
|
|
1284
1401
|
}
|
|
1285
1402
|
}
|
|
1286
1403
|
warnSweptKeepTags(sweptKeepTags);
|
|
1287
1404
|
}
|
|
1405
|
+
stats.privateBinarySkipped = privateSkipped.count;
|
|
1406
|
+
warnPrivateBinarySkipped(privateSkipped.count);
|
|
1288
1407
|
const withGroupUid = (spec2, seriesUid) => salt === void 0 ? spec2 : {
|
|
1289
1408
|
...spec2,
|
|
1290
1409
|
tags: {
|
package/dist/esm/index.js
CHANGED
|
@@ -1816,13 +1816,28 @@ function buildHexToKeyword() {
|
|
|
1816
1816
|
}
|
|
1817
1817
|
return map;
|
|
1818
1818
|
}
|
|
1819
|
+
function buildKeepPlan(options) {
|
|
1820
|
+
const { keywords, privateHex } = options.keepTags ? resolveKeepTags(options.keepTags) : { keywords: [], privateHex: /* @__PURE__ */ new Set() };
|
|
1821
|
+
const allPrivate = options.keepPrivateTags === true;
|
|
1822
|
+
return {
|
|
1823
|
+
keywords,
|
|
1824
|
+
privateHex,
|
|
1825
|
+
allPrivate,
|
|
1826
|
+
capturingPrivate: allPrivate || privateHex.size > 0
|
|
1827
|
+
};
|
|
1828
|
+
}
|
|
1819
1829
|
function resolveKeepTags(keepTags) {
|
|
1820
1830
|
const nameMap = dcmjsAny2.data.DicomMetaDictionary.nameMap;
|
|
1821
1831
|
let hexToKeyword;
|
|
1822
1832
|
const kept = [];
|
|
1833
|
+
const privateHex = /* @__PURE__ */ new Set();
|
|
1823
1834
|
for (const tag of keepTags) {
|
|
1824
1835
|
let keyword;
|
|
1825
1836
|
if (HEX_TAG_RE2.test(tag)) {
|
|
1837
|
+
if (isPrivateHexTag(tag)) {
|
|
1838
|
+
privateHex.add(tag.toUpperCase());
|
|
1839
|
+
continue;
|
|
1840
|
+
}
|
|
1826
1841
|
hexToKeyword ?? (hexToKeyword = buildHexToKeyword());
|
|
1827
1842
|
const mapped = hexToKeyword.get(tag.toUpperCase());
|
|
1828
1843
|
if (!mapped) {
|
|
@@ -1849,7 +1864,71 @@ function resolveKeepTags(keepTags) {
|
|
|
1849
1864
|
}
|
|
1850
1865
|
kept.push(keyword);
|
|
1851
1866
|
}
|
|
1852
|
-
return kept;
|
|
1867
|
+
return { keywords: kept, privateHex };
|
|
1868
|
+
}
|
|
1869
|
+
function isUnrepresentableValue(value) {
|
|
1870
|
+
return value === null || value === void 0 || typeof value === "number" && !Number.isFinite(value) || value instanceof ArrayBuffer || ArrayBuffer.isView(value);
|
|
1871
|
+
}
|
|
1872
|
+
function toRawCapture(el, skipped) {
|
|
1873
|
+
const vr = el.vr;
|
|
1874
|
+
if (typeof vr !== "string") {
|
|
1875
|
+
skipped.count++;
|
|
1876
|
+
return null;
|
|
1877
|
+
}
|
|
1878
|
+
if (vr === "SQ") {
|
|
1879
|
+
const items = [];
|
|
1880
|
+
for (const item of el.Value ?? []) {
|
|
1881
|
+
if (typeof item !== "object" || item === null) continue;
|
|
1882
|
+
const converted = {};
|
|
1883
|
+
for (const [tag, nested] of Object.entries(item)) {
|
|
1884
|
+
if (!HEX_TAG_RE2.test(tag)) continue;
|
|
1885
|
+
const capture = toRawCapture(nested, skipped);
|
|
1886
|
+
if (capture) converted[tag.toUpperCase()] = capture;
|
|
1887
|
+
}
|
|
1888
|
+
items.push(converted);
|
|
1889
|
+
}
|
|
1890
|
+
return { vr, value: items };
|
|
1891
|
+
}
|
|
1892
|
+
const values = el.Value ?? [];
|
|
1893
|
+
if (values.some(isUnrepresentableValue)) {
|
|
1894
|
+
skipped.count++;
|
|
1895
|
+
return null;
|
|
1896
|
+
}
|
|
1897
|
+
return { vr, value: values.length === 1 ? values[0] : values };
|
|
1898
|
+
}
|
|
1899
|
+
function capturePrivateTags(rawDict, keep, skipped) {
|
|
1900
|
+
const captured = {};
|
|
1901
|
+
let found = false;
|
|
1902
|
+
for (const [tag, el] of Object.entries(rawDict)) {
|
|
1903
|
+
if (!HEX_TAG_RE2.test(tag) || !isPrivateHexTag(tag)) continue;
|
|
1904
|
+
if (!keep.allPrivate && !keep.privateHex.has(tag.toUpperCase())) continue;
|
|
1905
|
+
const capture = toRawCapture(el, skipped);
|
|
1906
|
+
if (capture) {
|
|
1907
|
+
captured[tag.toUpperCase()] = capture;
|
|
1908
|
+
found = true;
|
|
1909
|
+
}
|
|
1910
|
+
}
|
|
1911
|
+
return found ? captured : void 0;
|
|
1912
|
+
}
|
|
1913
|
+
function hashRawCaptureUids(capture, salt, map) {
|
|
1914
|
+
if (capture.vr === "UI") {
|
|
1915
|
+
return isUidLeaf(capture.value) ? { vr: "UI", value: hashUidValue(capture.value, salt, map) } : capture;
|
|
1916
|
+
}
|
|
1917
|
+
if (capture.vr === "SQ") {
|
|
1918
|
+
const items = capture.value;
|
|
1919
|
+
return {
|
|
1920
|
+
vr: "SQ",
|
|
1921
|
+
value: items.map(
|
|
1922
|
+
(item) => Object.fromEntries(
|
|
1923
|
+
Object.entries(item).map(([tag, nested]) => [
|
|
1924
|
+
tag,
|
|
1925
|
+
hashRawCaptureUids(nested, salt, map)
|
|
1926
|
+
])
|
|
1927
|
+
)
|
|
1928
|
+
)
|
|
1929
|
+
};
|
|
1930
|
+
}
|
|
1931
|
+
return capture;
|
|
1853
1932
|
}
|
|
1854
1933
|
function* walkFiles(dir) {
|
|
1855
1934
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
@@ -1867,13 +1946,16 @@ function toArrayBuffer(buf) {
|
|
|
1867
1946
|
buf.byteOffset + buf.byteLength
|
|
1868
1947
|
);
|
|
1869
1948
|
}
|
|
1870
|
-
function
|
|
1949
|
+
function parseParts(ab) {
|
|
1871
1950
|
const parsed = dcmjsAny2.data.DicomMessage.readFile(ab);
|
|
1872
|
-
return
|
|
1951
|
+
return {
|
|
1952
|
+
record: dcmjsAny2.data.DicomMetaDictionary.naturalizeDataset(parsed.dict),
|
|
1953
|
+
rawDict: parsed.dict
|
|
1954
|
+
};
|
|
1873
1955
|
}
|
|
1874
1956
|
function parseFull(path) {
|
|
1875
1957
|
try {
|
|
1876
|
-
return
|
|
1958
|
+
return parseParts(toArrayBuffer(readFileSync(path)));
|
|
1877
1959
|
} catch {
|
|
1878
1960
|
return null;
|
|
1879
1961
|
}
|
|
@@ -1931,7 +2013,7 @@ function readHeaderOnly(path) {
|
|
|
1931
2013
|
EMPTY_OW_PIXEL_DATA
|
|
1932
2014
|
]);
|
|
1933
2015
|
try {
|
|
1934
|
-
return
|
|
2016
|
+
return parseParts(toArrayBuffer(headerOnly));
|
|
1935
2017
|
} catch {
|
|
1936
2018
|
return null;
|
|
1937
2019
|
}
|
|
@@ -2057,6 +2139,20 @@ function mergeTags(a, b) {
|
|
|
2057
2139
|
if (!b) return a;
|
|
2058
2140
|
return { ...a, ...b };
|
|
2059
2141
|
}
|
|
2142
|
+
function mergePrivateTags(rec, salt, uidMap) {
|
|
2143
|
+
if (!rec.privateTags) return;
|
|
2144
|
+
const hashed = Object.fromEntries(
|
|
2145
|
+
Object.entries(rec.privateTags).map(([tag, capture]) => [
|
|
2146
|
+
tag,
|
|
2147
|
+
salt === void 0 ? capture : hashRawCaptureUids(
|
|
2148
|
+
capture,
|
|
2149
|
+
salt,
|
|
2150
|
+
uidMap
|
|
2151
|
+
)
|
|
2152
|
+
])
|
|
2153
|
+
);
|
|
2154
|
+
rec.tags = mergeTags(rec.tags, hashed);
|
|
2155
|
+
}
|
|
2060
2156
|
function scanFile(path) {
|
|
2061
2157
|
let size;
|
|
2062
2158
|
try {
|
|
@@ -2066,7 +2162,7 @@ function scanFile(path) {
|
|
|
2066
2162
|
}
|
|
2067
2163
|
if (size > MAX_STREAM_BYTES) return null;
|
|
2068
2164
|
const large = size > MAX_PIXEL_BYTES;
|
|
2069
|
-
return { size, large,
|
|
2165
|
+
return { size, large, parsed: large ? readHeaderOnly(path) : parseFull(path) };
|
|
2070
2166
|
}
|
|
2071
2167
|
function presetModalityOf(record) {
|
|
2072
2168
|
if (typeof record.Modality !== "string") return void 0;
|
|
@@ -2081,6 +2177,13 @@ function hashRecordUids(rec, salt, uidMap, sweptKeepTags) {
|
|
|
2081
2177
|
}
|
|
2082
2178
|
rec.tags = mergeTags(rec.tags, hashUidTags(rec.uidOriginals, salt, uidMap));
|
|
2083
2179
|
}
|
|
2180
|
+
function warnPrivateBinarySkipped(count) {
|
|
2181
|
+
if (count > 0) {
|
|
2182
|
+
console.warn(
|
|
2183
|
+
`describe: ${count} private element(s) with binary payloads were not captured \u2014 binary values cannot ride a JSON spec`
|
|
2184
|
+
);
|
|
2185
|
+
}
|
|
2186
|
+
}
|
|
2084
2187
|
function warnSweptKeepTags(sweptKeepTags) {
|
|
2085
2188
|
for (const keyword of sweptKeepTags) {
|
|
2086
2189
|
console.warn(
|
|
@@ -2226,14 +2329,16 @@ function emitTree(acc) {
|
|
|
2226
2329
|
}
|
|
2227
2330
|
return nodes;
|
|
2228
2331
|
}
|
|
2229
|
-
function describeTree(dir, options,
|
|
2332
|
+
function describeTree(dir, options, keep) {
|
|
2230
2333
|
const root = { dirs: /* @__PURE__ */ new Map(), files: [] };
|
|
2231
2334
|
const stats = {
|
|
2232
2335
|
dicomFiles: 0,
|
|
2233
2336
|
skipped: 0,
|
|
2234
2337
|
unknownModality: 0,
|
|
2235
|
-
nonDicomFiles: 0
|
|
2338
|
+
nonDicomFiles: 0,
|
|
2339
|
+
privateBinarySkipped: 0
|
|
2236
2340
|
};
|
|
2341
|
+
const privateSkipped = { count: 0 };
|
|
2237
2342
|
const studyUids = /* @__PURE__ */ new Set();
|
|
2238
2343
|
const dicomLeaves = [];
|
|
2239
2344
|
for (const path of [...walkFiles(dir)].sort()) {
|
|
@@ -2242,7 +2347,8 @@ function describeTree(dir, options, keepKeywords) {
|
|
|
2242
2347
|
stats.skipped++;
|
|
2243
2348
|
continue;
|
|
2244
2349
|
}
|
|
2245
|
-
const { size, large,
|
|
2350
|
+
const { size, large, parsed } = scanned;
|
|
2351
|
+
const record = parsed?.record;
|
|
2246
2352
|
const studyUid = record?.StudyInstanceUID;
|
|
2247
2353
|
const seriesUid = record?.SeriesInstanceUID;
|
|
2248
2354
|
const relDirs = relative(dir, path).split(sep).slice(0, -1);
|
|
@@ -2273,9 +2379,10 @@ function describeTree(dir, options, keepKeywords) {
|
|
|
2273
2379
|
modality,
|
|
2274
2380
|
tags: mergeTags(
|
|
2275
2381
|
instanceNumber,
|
|
2276
|
-
|
|
2382
|
+
keep.keywords.length ? extractTags(record, keep.keywords) : void 0
|
|
2277
2383
|
),
|
|
2278
|
-
uidOriginals: collectUidOriginals(record)
|
|
2384
|
+
uidOriginals: collectUidOriginals(record),
|
|
2385
|
+
privateTags: keep.capturingPrivate && parsed ? capturePrivateTags(parsed.rawDict, keep, privateSkipped) : void 0
|
|
2279
2386
|
},
|
|
2280
2387
|
studyUid,
|
|
2281
2388
|
seriesUid
|
|
@@ -2291,18 +2398,21 @@ function describeTree(dir, options, keepKeywords) {
|
|
|
2291
2398
|
const sweptKeepTags = /* @__PURE__ */ new Set();
|
|
2292
2399
|
for (const { rec, studyUid, seriesUid } of dicomLeaves) {
|
|
2293
2400
|
hashRecordUids(rec, salt, uidMap, sweptKeepTags);
|
|
2401
|
+
mergePrivateTags(rec, salt, uidMap);
|
|
2294
2402
|
rec.tags = mergeTags(rec.tags, {
|
|
2295
2403
|
StudyInstanceUID: hashUidVia(uidMap, salt, studyUid),
|
|
2296
2404
|
SeriesInstanceUID: hashUidVia(uidMap, salt, seriesUid)
|
|
2297
2405
|
});
|
|
2298
2406
|
}
|
|
2299
2407
|
warnSweptKeepTags(sweptKeepTags);
|
|
2408
|
+
stats.privateBinarySkipped = privateSkipped.count;
|
|
2409
|
+
warnPrivateBinarySkipped(privateSkipped.count);
|
|
2300
2410
|
const spec = { tree: emitTree(root), uidSalt: salt };
|
|
2301
2411
|
validateDatasetSpec(spec);
|
|
2302
2412
|
return { spec, stats };
|
|
2303
2413
|
}
|
|
2304
2414
|
function describeDirectory(dir, options = {}) {
|
|
2305
|
-
const
|
|
2415
|
+
const keep = buildKeepPlan(options);
|
|
2306
2416
|
const preserveUids = options.preserveUids ?? true;
|
|
2307
2417
|
if (options.captureTree) {
|
|
2308
2418
|
if (!preserveUids) {
|
|
@@ -2310,10 +2420,10 @@ function describeDirectory(dir, options = {}) {
|
|
|
2310
2420
|
"describe: captureTree requires preserveUids \u2014 the captured tree carries its grouping as hashed UID tags"
|
|
2311
2421
|
);
|
|
2312
2422
|
}
|
|
2313
|
-
return describeTree(dir, options,
|
|
2423
|
+
return describeTree(dir, options, keep);
|
|
2314
2424
|
}
|
|
2315
2425
|
const uidMap = /* @__PURE__ */ new Map();
|
|
2316
|
-
const useRecords =
|
|
2426
|
+
const useRecords = keep.keywords.length > 0 || preserveUids || keep.capturingPrivate;
|
|
2317
2427
|
const tolerance = options.sizeTolerance ?? 0;
|
|
2318
2428
|
if (!Number.isFinite(tolerance) || tolerance < 0) {
|
|
2319
2429
|
throw new Error(
|
|
@@ -2325,15 +2435,18 @@ function describeDirectory(dir, options = {}) {
|
|
|
2325
2435
|
dicomFiles: 0,
|
|
2326
2436
|
skipped: 0,
|
|
2327
2437
|
unknownModality: 0,
|
|
2328
|
-
nonDicomFiles: 0
|
|
2438
|
+
nonDicomFiles: 0,
|
|
2439
|
+
privateBinarySkipped: 0
|
|
2329
2440
|
};
|
|
2441
|
+
const privateSkipped = { count: 0 };
|
|
2330
2442
|
for (const path of [...walkFiles(dir)].sort()) {
|
|
2331
2443
|
const scanned = scanFile(path);
|
|
2332
2444
|
if (!scanned) {
|
|
2333
2445
|
stats.skipped++;
|
|
2334
2446
|
continue;
|
|
2335
2447
|
}
|
|
2336
|
-
const { size, large,
|
|
2448
|
+
const { size, large, parsed } = scanned;
|
|
2449
|
+
const record = parsed?.record;
|
|
2337
2450
|
const studyUid = record?.StudyInstanceUID;
|
|
2338
2451
|
const seriesUid = record?.SeriesInstanceUID;
|
|
2339
2452
|
if (!record || typeof studyUid !== "string" || typeof seriesUid !== "string") {
|
|
@@ -2344,7 +2457,7 @@ function describeDirectory(dir, options = {}) {
|
|
|
2344
2457
|
const preset = presetModalityOf(record);
|
|
2345
2458
|
if (preset === "unsupported") stats.unknownModality++;
|
|
2346
2459
|
const modality = preset === "unsupported" ? void 0 : preset;
|
|
2347
|
-
const keptTags =
|
|
2460
|
+
const keptTags = keep.keywords.length ? extractTags(record, keep.keywords) : void 0;
|
|
2348
2461
|
const uidOriginals = preserveUids ? collectUidOriginals(record) : void 0;
|
|
2349
2462
|
const bytes = large ? size : Math.min(bucketBytes(size, tolerance), MAX_PIXEL_BYTES);
|
|
2350
2463
|
const fileRecord = {
|
|
@@ -2352,7 +2465,8 @@ function describeDirectory(dir, options = {}) {
|
|
|
2352
2465
|
bytes,
|
|
2353
2466
|
modality,
|
|
2354
2467
|
tags: keptTags,
|
|
2355
|
-
uidOriginals
|
|
2468
|
+
uidOriginals,
|
|
2469
|
+
privateTags: keep.capturingPrivate && parsed ? capturePrivateTags(parsed.rawDict, keep, privateSkipped) : void 0
|
|
2356
2470
|
};
|
|
2357
2471
|
let study = studies.get(studyUid);
|
|
2358
2472
|
if (!study) {
|
|
@@ -2371,18 +2485,23 @@ function describeDirectory(dir, options = {}) {
|
|
|
2371
2485
|
}
|
|
2372
2486
|
}
|
|
2373
2487
|
const salt = preserveUids ? options.uidSalt ?? deriveSalt([...studies.keys()]) : void 0;
|
|
2374
|
-
if (salt !== void 0) {
|
|
2488
|
+
if (salt !== void 0 || keep.capturingPrivate) {
|
|
2375
2489
|
const sweptKeepTags = /* @__PURE__ */ new Set();
|
|
2376
2490
|
for (const study of studies.values()) {
|
|
2377
2491
|
for (const series of study.values()) {
|
|
2378
2492
|
if (!Array.isArray(series)) continue;
|
|
2379
2493
|
for (const rec of series) {
|
|
2380
|
-
|
|
2494
|
+
if (salt !== void 0) {
|
|
2495
|
+
hashRecordUids(rec, salt, uidMap, sweptKeepTags);
|
|
2496
|
+
}
|
|
2497
|
+
mergePrivateTags(rec, salt, uidMap);
|
|
2381
2498
|
}
|
|
2382
2499
|
}
|
|
2383
2500
|
}
|
|
2384
2501
|
warnSweptKeepTags(sweptKeepTags);
|
|
2385
2502
|
}
|
|
2503
|
+
stats.privateBinarySkipped = privateSkipped.count;
|
|
2504
|
+
warnPrivateBinarySkipped(privateSkipped.count);
|
|
2386
2505
|
const withGroupUid = (spec2, seriesUid) => salt === void 0 ? spec2 : {
|
|
2387
2506
|
...spec2,
|
|
2388
2507
|
tags: {
|
|
@@ -4,6 +4,7 @@ export type DescribeOptions = {
|
|
|
4
4
|
sizeTolerance?: number;
|
|
5
5
|
preserveUids?: boolean;
|
|
6
6
|
uidSalt?: string;
|
|
7
|
+
keepPrivateTags?: boolean;
|
|
7
8
|
captureTree?: boolean;
|
|
8
9
|
};
|
|
9
10
|
export type DescribeResult = {
|
|
@@ -13,8 +14,18 @@ export type DescribeResult = {
|
|
|
13
14
|
skipped: number;
|
|
14
15
|
unknownModality: number;
|
|
15
16
|
nonDicomFiles: number;
|
|
17
|
+
privateBinarySkipped: number;
|
|
16
18
|
};
|
|
17
19
|
};
|
|
18
|
-
|
|
20
|
+
type RawDictElement = {
|
|
21
|
+
vr?: string;
|
|
22
|
+
Value?: unknown[];
|
|
23
|
+
};
|
|
24
|
+
export type ParsedFile = {
|
|
25
|
+
record: Record<string, unknown>;
|
|
26
|
+
rawDict: Record<string, RawDictElement>;
|
|
27
|
+
};
|
|
28
|
+
export declare function readHeaderOnly(path: string): ParsedFile | null;
|
|
19
29
|
export declare function tagValuesEqual(a: unknown, b: unknown): boolean;
|
|
20
30
|
export declare function describeDirectory(dir: string, options?: DescribeOptions): DescribeResult;
|
|
31
|
+
export {};
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { DatasetSpec, FileSpec, ParametricSpec } from './types.js';
|
|
2
2
|
export declare function isImageType(type: FileSpec['type']): boolean;
|
|
3
3
|
export declare const HEX_TAG_RE: RegExp;
|
|
4
|
+
export declare function isPrivateHexTag(key: string): boolean;
|
|
4
5
|
export declare function isRawTagValue(value: unknown): value is {
|
|
5
6
|
vr: string;
|
|
6
7
|
value: unknown;
|