dicom-synth 1.15.0 → 1.17.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 +75 -25
- package/dist/esm/describe/describe.js +277 -39
- package/dist/esm/index.js +346 -64
- package/dist/esm/schema/parametric.js +108 -2
- package/dist/esm/schema/validate.js +114 -3
- package/dist/esm/syntheticFixtures/generator.js +55 -16
- package/dist/esm/syntheticFixtures/streamWrite.js +50 -11
- package/dist/esm/syntheticFixtures/violations.js +9 -3
- package/dist/types/describe/describe.d.ts +2 -0
- package/dist/types/index.d.ts +1 -1
- package/dist/types/schema/types.d.ts +4 -0
- package/dist/types/schema/validate.d.ts +5 -0
- package/dist/types/syntheticFixtures/generator.d.ts +12 -3
- package/dist/types/syntheticFixtures/violations.d.ts +2 -1
- package/package.json +3 -1
package/dist/esm/index.js
CHANGED
|
@@ -95,6 +95,92 @@ var VALID_VIOLATIONS = {
|
|
|
95
95
|
};
|
|
96
96
|
var HEX_TAG_RE = /^[0-9a-fA-F]{8}$/;
|
|
97
97
|
var KEYWORD_RE = /^[A-Z][A-Za-z0-9]*$/;
|
|
98
|
+
var VALID_VRS = /* @__PURE__ */ new Set([
|
|
99
|
+
"AE",
|
|
100
|
+
"AS",
|
|
101
|
+
"AT",
|
|
102
|
+
"CS",
|
|
103
|
+
"DA",
|
|
104
|
+
"DS",
|
|
105
|
+
"DT",
|
|
106
|
+
"FL",
|
|
107
|
+
"FD",
|
|
108
|
+
"IS",
|
|
109
|
+
"LO",
|
|
110
|
+
"LT",
|
|
111
|
+
"OB",
|
|
112
|
+
"OD",
|
|
113
|
+
"OF",
|
|
114
|
+
"OL",
|
|
115
|
+
"OV",
|
|
116
|
+
"OW",
|
|
117
|
+
"PN",
|
|
118
|
+
"SH",
|
|
119
|
+
"SL",
|
|
120
|
+
"SQ",
|
|
121
|
+
"SS",
|
|
122
|
+
"ST",
|
|
123
|
+
"SV",
|
|
124
|
+
"TM",
|
|
125
|
+
"UC",
|
|
126
|
+
"UI",
|
|
127
|
+
"UL",
|
|
128
|
+
"UN",
|
|
129
|
+
"UR",
|
|
130
|
+
"US",
|
|
131
|
+
"UT",
|
|
132
|
+
"UV"
|
|
133
|
+
]);
|
|
134
|
+
function isPrivateHexTag(key) {
|
|
135
|
+
return HEX_TAG_RE.test(key) && Number.parseInt(key.slice(0, 4), 16) % 2 === 1;
|
|
136
|
+
}
|
|
137
|
+
function isRawTagValue(value) {
|
|
138
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) && typeof value.vr === "string";
|
|
139
|
+
}
|
|
140
|
+
function validateRawTagValue(raw, path) {
|
|
141
|
+
for (const key of Object.keys(raw)) {
|
|
142
|
+
if (key !== "vr" && key !== "value") {
|
|
143
|
+
throw new Error(
|
|
144
|
+
`${path}: raw tag form accepts only "vr" and "value"; got "${key}"`
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
const vr = raw.vr;
|
|
149
|
+
if (!VALID_VRS.has(vr)) {
|
|
150
|
+
throw new Error(`${path}.vr: "${vr}" is not a standard DICOM VR`);
|
|
151
|
+
}
|
|
152
|
+
if (!("value" in raw)) {
|
|
153
|
+
throw new Error(`${path}.value: is required in the raw tag form`);
|
|
154
|
+
}
|
|
155
|
+
if (vr === "SQ") {
|
|
156
|
+
if (!Array.isArray(raw.value)) {
|
|
157
|
+
throw new Error(`${path}.value: an SQ raw tag requires an array of items`);
|
|
158
|
+
}
|
|
159
|
+
raw.value.forEach((item, i) => {
|
|
160
|
+
if (typeof item !== "object" || item === null || Array.isArray(item)) {
|
|
161
|
+
throw new Error(
|
|
162
|
+
`${path}.value[${i}]: must be an object of hex-tag entries`
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
for (const [tag, nested] of Object.entries(item)) {
|
|
166
|
+
if (!HEX_TAG_RE.test(tag)) {
|
|
167
|
+
throw new Error(
|
|
168
|
+
`${path}.value[${i}]: invalid item tag "${tag}" \u2014 SQ items use 8-hex-char tags`
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
if (!isRawTagValue(nested)) {
|
|
172
|
+
throw new Error(
|
|
173
|
+
`${path}.value[${i}].${tag}: SQ item entries must use the raw { vr, value } form`
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
validateRawTagValue(
|
|
177
|
+
nested,
|
|
178
|
+
`${path}.value[${i}].${tag}`
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
}
|
|
98
184
|
function validatePositiveInt(value, path) {
|
|
99
185
|
if (typeof value !== "number" || !Number.isInteger(value) || value < 1) {
|
|
100
186
|
throw new Error(
|
|
@@ -299,13 +385,33 @@ function validateEntry(entry, path) {
|
|
|
299
385
|
return e;
|
|
300
386
|
}
|
|
301
387
|
var MODALITY_TAG = "00080060";
|
|
302
|
-
function
|
|
388
|
+
function validateModalityGuard(key, candidate, path) {
|
|
303
389
|
const isModalityKey = key === "Modality" || key.toUpperCase() === MODALITY_TAG;
|
|
304
|
-
if (isModalityKey && typeof
|
|
390
|
+
if (isModalityKey && typeof candidate === "string" && Object.hasOwn(VALID_MODALITIES, candidate)) {
|
|
305
391
|
throw new Error(
|
|
306
392
|
`${path}.${key}: set a preset modality (${Object.keys(VALID_MODALITIES).join("/")}) via the "modality" field, not tags \u2014 it is coupled to SOPClassUID and type-1 attributes`
|
|
307
393
|
);
|
|
308
394
|
}
|
|
395
|
+
}
|
|
396
|
+
function validateTagValue(key, value, path) {
|
|
397
|
+
if (isRawTagValue(value)) {
|
|
398
|
+
if (!HEX_TAG_RE.test(key)) {
|
|
399
|
+
throw new Error(
|
|
400
|
+
`${path}.${key}: the raw { vr, value } form requires an 8-hex-char tag key \u2014 keyword tags take plain values`
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
validateRawTagValue(value, `${path}.${key}`);
|
|
404
|
+
const raw = value.value;
|
|
405
|
+
const scalar = Array.isArray(raw) && raw.length === 1 ? raw[0] : raw;
|
|
406
|
+
validateModalityGuard(key, scalar, path);
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
if (isPrivateHexTag(key)) {
|
|
410
|
+
throw new Error(
|
|
411
|
+
`${path}.${key}: private tags have no dictionary VR \u2014 use the raw form, e.g. { "vr": "LO", "value": \u2026 }`
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
validateModalityGuard(key, value, path);
|
|
309
415
|
if (typeof value === "string") {
|
|
310
416
|
for (const name of findTemplateTokens(value)) {
|
|
311
417
|
if (!TEMPLATE_VOCAB.includes(name)) {
|
|
@@ -441,6 +547,9 @@ function validateStudy(study, path) {
|
|
|
441
547
|
if ("tags" in st) validateTags(st.tags, `${path}.tags`);
|
|
442
548
|
return st;
|
|
443
549
|
}
|
|
550
|
+
function positionalName(prefix, ordinal) {
|
|
551
|
+
return `${prefix}-${String(ordinal).padStart(3, "0")}`;
|
|
552
|
+
}
|
|
444
553
|
var VALID_TREE_ROLES = {
|
|
445
554
|
study: true,
|
|
446
555
|
series: true
|
|
@@ -521,7 +630,7 @@ function validateTreeChildren(children, basePath, inStudy, inSeries) {
|
|
|
521
630
|
let name = "name" in n ? n.name : void 0;
|
|
522
631
|
if ("children" in n) {
|
|
523
632
|
dirOrdinal++;
|
|
524
|
-
name ?? (name =
|
|
633
|
+
name ?? (name = positionalName("dir", dirOrdinal));
|
|
525
634
|
}
|
|
526
635
|
if (name === void 0) return;
|
|
527
636
|
const prior = claimed.get(name);
|
|
@@ -799,20 +908,51 @@ function buildTagToKeyword() {
|
|
|
799
908
|
return map;
|
|
800
909
|
}
|
|
801
910
|
var tagToKeyword = buildTagToKeyword();
|
|
911
|
+
function toRawElement(raw) {
|
|
912
|
+
const value = raw.vr === "SQ" ? raw.value.map(
|
|
913
|
+
(item) => Object.fromEntries(
|
|
914
|
+
Object.entries(item).map(([tag, nested]) => [
|
|
915
|
+
tag.toUpperCase(),
|
|
916
|
+
toRawElement(nested)
|
|
917
|
+
])
|
|
918
|
+
)
|
|
919
|
+
) : raw.value;
|
|
920
|
+
return { vr: raw.vr, Value: Array.isArray(value) ? value : [value] };
|
|
921
|
+
}
|
|
802
922
|
function applyTagOverrides(dataset, tags) {
|
|
803
|
-
if (!tags) return;
|
|
923
|
+
if (!tags) return void 0;
|
|
924
|
+
let raw;
|
|
804
925
|
for (const [key, value] of Object.entries(tags)) {
|
|
805
|
-
if (
|
|
926
|
+
if (isRawTagValue(value)) {
|
|
927
|
+
raw ?? (raw = {});
|
|
928
|
+
raw[key.toUpperCase()] = toRawElement(value);
|
|
929
|
+
} else if (HEX_TAG_RE.test(key)) {
|
|
806
930
|
const keyword = tagToKeyword.get(key.toUpperCase());
|
|
807
|
-
if (keyword) {
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
931
|
+
if (!keyword) {
|
|
932
|
+
throw new Error(
|
|
933
|
+
`dicom-synth: unknown hex tag "${key}" \u2014 dictionary tags take plain values; private/unknown tags require the raw { vr, value } form`
|
|
934
|
+
);
|
|
811
935
|
}
|
|
936
|
+
dataset[keyword] = value;
|
|
812
937
|
} else {
|
|
813
938
|
dataset[key] = value;
|
|
814
939
|
}
|
|
815
940
|
}
|
|
941
|
+
return raw;
|
|
942
|
+
}
|
|
943
|
+
function rawScalarString(rawElements, tag) {
|
|
944
|
+
const value = rawElements?.[tag]?.Value;
|
|
945
|
+
return value?.length === 1 && typeof value[0] === "string" ? value[0] : void 0;
|
|
946
|
+
}
|
|
947
|
+
function syncMetaWithDataset(meta, dataset, rawElements) {
|
|
948
|
+
const sopInstance = rawScalarString(rawElements, "00080018") ?? (typeof dataset.SOPInstanceUID === "string" ? dataset.SOPInstanceUID : void 0);
|
|
949
|
+
if (sopInstance !== void 0) {
|
|
950
|
+
meta.MediaStorageSOPInstanceUID = sopInstance;
|
|
951
|
+
}
|
|
952
|
+
const sopClass = rawScalarString(rawElements, "00080016") ?? (typeof dataset.SOPClassUID === "string" ? dataset.SOPClassUID : void 0);
|
|
953
|
+
if (sopClass !== void 0) {
|
|
954
|
+
meta.MediaStorageSOPClassUID = sopClass;
|
|
955
|
+
}
|
|
816
956
|
}
|
|
817
957
|
function buildMeta(uid, modality, transferSyntax = "explicit-vr-little-endian") {
|
|
818
958
|
return {
|
|
@@ -851,7 +991,7 @@ function buildBaseImageDataset(uid, modality) {
|
|
|
851
991
|
...preset.attributes
|
|
852
992
|
};
|
|
853
993
|
}
|
|
854
|
-
function serializeDict(meta, dataset) {
|
|
994
|
+
function serializeDict(meta, dataset, rawElements) {
|
|
855
995
|
const dicomDict = new dcmjs.data.DicomDict(
|
|
856
996
|
dcmjs.data.DicomMetaDictionary.denaturalizeDataset(
|
|
857
997
|
meta
|
|
@@ -860,14 +1000,17 @@ function serializeDict(meta, dataset) {
|
|
|
860
1000
|
dicomDict.dict = dcmjs.data.DicomMetaDictionary.denaturalizeDataset(
|
|
861
1001
|
dataset
|
|
862
1002
|
);
|
|
1003
|
+
if (rawElements) {
|
|
1004
|
+
Object.assign(dicomDict.dict, rawElements);
|
|
1005
|
+
}
|
|
863
1006
|
return Buffer.from(dicomDict.write({ allowInvalidVRLength: true }));
|
|
864
1007
|
}
|
|
865
1008
|
var MAX_DIMENSION = 65535;
|
|
866
|
-
function applySizing(dataset, meta, spec) {
|
|
1009
|
+
function applySizing(dataset, meta, spec, rawElements) {
|
|
867
1010
|
if (spec.targetSizeKb !== void 0) {
|
|
868
1011
|
const clampDim = (n) => Math.min(MAX_DIMENSION, Math.max(1, Math.round(n)));
|
|
869
1012
|
const minimalPixelBytes = dataset.PixelData.byteLength;
|
|
870
|
-
const overhead = serializeDict(meta, dataset).length - minimalPixelBytes;
|
|
1013
|
+
const overhead = serializeDict(meta, dataset, rawElements).length - minimalPixelBytes;
|
|
871
1014
|
const cells = Math.max(
|
|
872
1015
|
1,
|
|
873
1016
|
Math.round((spec.targetSizeKb * 1024 - overhead) / 2)
|
|
@@ -898,10 +1041,11 @@ function buildImageBuffer(spec, uid) {
|
|
|
898
1041
|
dataset.Laterality = "";
|
|
899
1042
|
dataset.PatientWeight = "0";
|
|
900
1043
|
}
|
|
901
|
-
applyTagOverrides(dataset, spec.tags);
|
|
1044
|
+
const rawElements = applyTagOverrides(dataset, spec.tags);
|
|
902
1045
|
const meta = buildMeta(effectiveUid, modality, spec.transferSyntax);
|
|
903
|
-
|
|
904
|
-
|
|
1046
|
+
syncMetaWithDataset(meta, dataset, rawElements);
|
|
1047
|
+
applySizing(dataset, meta, spec, rawElements);
|
|
1048
|
+
return { buffer: serializeDict(meta, dataset, rawElements), rawElements };
|
|
905
1049
|
}
|
|
906
1050
|
function buildFakeSignatureBuffer() {
|
|
907
1051
|
const buf = Buffer.alloc(200, 0);
|
|
@@ -923,11 +1067,11 @@ function buildBufferForSpec(spec, uid) {
|
|
|
923
1067
|
case "vendor-warnings-image":
|
|
924
1068
|
return buildImageBuffer(spec, uid);
|
|
925
1069
|
case "fake-signature":
|
|
926
|
-
return buildFakeSignatureBuffer();
|
|
1070
|
+
return { buffer: buildFakeSignatureBuffer() };
|
|
927
1071
|
case "non-dicom":
|
|
928
|
-
return buildNonDicomBuffer(spec);
|
|
1072
|
+
return { buffer: buildNonDicomBuffer(spec) };
|
|
929
1073
|
case "dicomdir":
|
|
930
|
-
return buildDicomdirBuffer();
|
|
1074
|
+
return { buffer: buildDicomdirBuffer() };
|
|
931
1075
|
case "large-image":
|
|
932
1076
|
throw new Error(
|
|
933
1077
|
"large-image cannot be generated in memory \u2014 use writeCollectionFromSpec (disk-only streaming)"
|
|
@@ -1019,11 +1163,12 @@ function writeNative(outPath, params, dims, zeroChunk) {
|
|
|
1019
1163
|
const { modality, uid, tags } = params;
|
|
1020
1164
|
const meta = buildMeta(uid, modality);
|
|
1021
1165
|
const dataset = buildBaseImageDataset(uid, modality);
|
|
1022
|
-
applyTagOverrides(dataset, tags);
|
|
1166
|
+
const rawElements = applyTagOverrides(dataset, tags);
|
|
1167
|
+
syncMetaWithDataset(meta, dataset, rawElements);
|
|
1023
1168
|
dataset.Rows = dims.rows;
|
|
1024
1169
|
dataset.Columns = dims.columns;
|
|
1025
1170
|
const minimalPixelBytes = dataset.PixelData.byteLength;
|
|
1026
|
-
const probe = serializeDict(meta, dataset);
|
|
1171
|
+
const probe = serializeDict(meta, dataset, rawElements);
|
|
1027
1172
|
const pixelElement = Buffer.alloc(12);
|
|
1028
1173
|
PIXEL_DATA_OW_HEADER.copy(pixelElement);
|
|
1029
1174
|
pixelElement.writeUInt32LE(minimalPixelBytes, 8);
|
|
@@ -1052,10 +1197,11 @@ function writeEncapsulated(outPath, params, pixelBytes, fragmentBytes, zeroChunk
|
|
|
1052
1197
|
TransferSyntaxUID: RLE_TRANSFER_SYNTAX_UID
|
|
1053
1198
|
};
|
|
1054
1199
|
const dataset = buildBaseImageDataset(uid, modality);
|
|
1055
|
-
applyTagOverrides(dataset, tags);
|
|
1200
|
+
const rawElements = applyTagOverrides(dataset, tags);
|
|
1201
|
+
syncMetaWithDataset(meta, dataset, rawElements);
|
|
1056
1202
|
dataset.PixelData = [new Uint8Array([]).buffer, new Uint8Array([0, 0]).buffer];
|
|
1057
1203
|
dataset._vrMap = { PixelData: "OB" };
|
|
1058
|
-
const probe = serializeDict(meta, dataset);
|
|
1204
|
+
const probe = serializeDict(meta, dataset, rawElements);
|
|
1059
1205
|
const idx = probe.indexOf(PIXEL_DATA_OB_HEADER);
|
|
1060
1206
|
if (idx < 0 || probe.readUInt32LE(idx + 8) !== 4294967295) {
|
|
1061
1207
|
throw new Error("failed to locate encapsulated PixelData element header");
|
|
@@ -1173,7 +1319,7 @@ var VIOLATION_LEVEL = {
|
|
|
1173
1319
|
"missing-meta-header": "byte",
|
|
1174
1320
|
"malformed-sq-delimiter": "byte"
|
|
1175
1321
|
};
|
|
1176
|
-
function applyTagLevel(buffer, violations) {
|
|
1322
|
+
function applyTagLevel(buffer, violations, rawElements) {
|
|
1177
1323
|
if (violations.length === 0) return buffer;
|
|
1178
1324
|
let parsed;
|
|
1179
1325
|
try {
|
|
@@ -1207,6 +1353,9 @@ function applyTagLevel(buffer, violations) {
|
|
|
1207
1353
|
}
|
|
1208
1354
|
}
|
|
1209
1355
|
parsed.dict = dcmjsAny.data.DicomMetaDictionary.denaturalizeDataset(natural);
|
|
1356
|
+
if (rawElements) {
|
|
1357
|
+
Object.assign(parsed.dict, rawElements);
|
|
1358
|
+
}
|
|
1210
1359
|
if (violations.includes("uid-too-long")) {
|
|
1211
1360
|
;
|
|
1212
1361
|
parsed.dict["00080018"] = {
|
|
@@ -1239,11 +1388,14 @@ function applyByteLevel(buffer, violations) {
|
|
|
1239
1388
|
}
|
|
1240
1389
|
return result;
|
|
1241
1390
|
}
|
|
1242
|
-
function applyViolations(buffer, violations) {
|
|
1391
|
+
function applyViolations(buffer, violations, rawElements) {
|
|
1243
1392
|
if (violations.length === 0) return buffer;
|
|
1244
1393
|
const tagViolations = violations.filter((v) => VIOLATION_LEVEL[v] === "tag");
|
|
1245
1394
|
const byteViolations = violations.filter((v) => VIOLATION_LEVEL[v] === "byte");
|
|
1246
|
-
return applyByteLevel(
|
|
1395
|
+
return applyByteLevel(
|
|
1396
|
+
applyTagLevel(buffer, tagViolations, rawElements),
|
|
1397
|
+
byteViolations
|
|
1398
|
+
);
|
|
1247
1399
|
}
|
|
1248
1400
|
|
|
1249
1401
|
// src/collection/writer.ts
|
|
@@ -1317,10 +1469,11 @@ async function generateFile(spec, options) {
|
|
|
1317
1469
|
...spec,
|
|
1318
1470
|
tags: resolveTagTemplates(spec.tags, { index, ...options?.context })
|
|
1319
1471
|
} : spec;
|
|
1320
|
-
|
|
1472
|
+
const built = buildBufferForSpec(resolvedSpec, uid);
|
|
1473
|
+
let buffer = built.buffer;
|
|
1321
1474
|
const violations = "violations" in spec ? spec.violations : void 0;
|
|
1322
1475
|
if (violations?.length) {
|
|
1323
|
-
buffer = applyViolations(buffer, violations);
|
|
1476
|
+
buffer = applyViolations(buffer, violations, built.rawElements);
|
|
1324
1477
|
}
|
|
1325
1478
|
const name = filename(spec.type, index, padWidth);
|
|
1326
1479
|
return {
|
|
@@ -1409,7 +1562,7 @@ function* planTree(nodes, scope, env) {
|
|
|
1409
1562
|
for (const node of nodes) {
|
|
1410
1563
|
if (isDirNode(node)) {
|
|
1411
1564
|
dirOrdinal++;
|
|
1412
|
-
const dirName = node.name ??
|
|
1565
|
+
const dirName = node.name ?? positionalName("dir", dirOrdinal);
|
|
1413
1566
|
const child = {
|
|
1414
1567
|
...scope,
|
|
1415
1568
|
dirs: [...scope.dirs, dirName],
|
|
@@ -1640,7 +1793,7 @@ import {
|
|
|
1640
1793
|
readSync,
|
|
1641
1794
|
statSync
|
|
1642
1795
|
} from "node:fs";
|
|
1643
|
-
import { join } from "node:path";
|
|
1796
|
+
import { basename, extname, join, relative, sep } from "node:path";
|
|
1644
1797
|
var dcmjsAny2 = dcmjs;
|
|
1645
1798
|
var HEX_TAG_RE2 = /^[0-9a-fA-F]{8}$/;
|
|
1646
1799
|
var SUPPORTED_MODALITIES = /* @__PURE__ */ new Set(["CT", "PT", "MR", "CR"]);
|
|
@@ -1904,6 +2057,37 @@ function mergeTags(a, b) {
|
|
|
1904
2057
|
if (!b) return a;
|
|
1905
2058
|
return { ...a, ...b };
|
|
1906
2059
|
}
|
|
2060
|
+
function scanFile(path) {
|
|
2061
|
+
let size;
|
|
2062
|
+
try {
|
|
2063
|
+
size = statSync(path).size;
|
|
2064
|
+
} catch {
|
|
2065
|
+
return null;
|
|
2066
|
+
}
|
|
2067
|
+
if (size > MAX_STREAM_BYTES) return null;
|
|
2068
|
+
const large = size > MAX_PIXEL_BYTES;
|
|
2069
|
+
return { size, large, record: large ? readHeaderOnly(path) : parseFull(path) };
|
|
2070
|
+
}
|
|
2071
|
+
function presetModalityOf(record) {
|
|
2072
|
+
if (typeof record.Modality !== "string") return void 0;
|
|
2073
|
+
return SUPPORTED_MODALITIES.has(record.Modality) ? record.Modality : "unsupported";
|
|
2074
|
+
}
|
|
2075
|
+
function hashRecordUids(rec, salt, uidMap, sweptKeepTags) {
|
|
2076
|
+
if (!rec.uidOriginals) return;
|
|
2077
|
+
if (rec.tags) {
|
|
2078
|
+
for (const keyword of Object.keys(rec.uidOriginals)) {
|
|
2079
|
+
if (keyword in rec.tags) sweptKeepTags.add(keyword);
|
|
2080
|
+
}
|
|
2081
|
+
}
|
|
2082
|
+
rec.tags = mergeTags(rec.tags, hashUidTags(rec.uidOriginals, salt, uidMap));
|
|
2083
|
+
}
|
|
2084
|
+
function warnSweptKeepTags(sweptKeepTags) {
|
|
2085
|
+
for (const keyword of sweptKeepTags) {
|
|
2086
|
+
console.warn(
|
|
2087
|
+
`describe: keep-tag "${keyword}" contains UID references \u2014 kept as its hashed UID skeleton, not verbatim (disable with preserveUids: false)`
|
|
2088
|
+
);
|
|
2089
|
+
}
|
|
2090
|
+
}
|
|
1907
2091
|
function sizeKbOf(bytes) {
|
|
1908
2092
|
return Math.max(1, Math.round(bytes / 1024));
|
|
1909
2093
|
}
|
|
@@ -2006,9 +2190,128 @@ function buildSeriesSpec(records) {
|
|
|
2006
2190
|
function seriesSpecFromAccum(accum) {
|
|
2007
2191
|
return Array.isArray(accum) ? buildSeriesSpec(accum) : { entries: [...accum.values()].map(entryOf) };
|
|
2008
2192
|
}
|
|
2193
|
+
function dirAccumFor(root, dirs) {
|
|
2194
|
+
let node = root;
|
|
2195
|
+
for (const name of dirs) {
|
|
2196
|
+
let child = node.dirs.get(name);
|
|
2197
|
+
if (!child) {
|
|
2198
|
+
child = { dirs: /* @__PURE__ */ new Map(), files: [] };
|
|
2199
|
+
node.dirs.set(name, child);
|
|
2200
|
+
}
|
|
2201
|
+
node = child;
|
|
2202
|
+
}
|
|
2203
|
+
return node;
|
|
2204
|
+
}
|
|
2205
|
+
var SAFE_EXTENSION_RE = /^\.[A-Za-z0-9]{1,8}$/;
|
|
2206
|
+
function safeExtension(path) {
|
|
2207
|
+
const ext = extname(basename(path));
|
|
2208
|
+
return SAFE_EXTENSION_RE.test(ext) ? ext : "";
|
|
2209
|
+
}
|
|
2210
|
+
function emitTree(acc) {
|
|
2211
|
+
const nodes = [];
|
|
2212
|
+
let otherOrdinal = 0;
|
|
2213
|
+
for (const leaf of acc.files) {
|
|
2214
|
+
if (leaf.kind === "other") {
|
|
2215
|
+
otherOrdinal++;
|
|
2216
|
+
const name = `${positionalName("file", otherOrdinal)}${leaf.ext}`;
|
|
2217
|
+
nodes.push(
|
|
2218
|
+
leaf.bytes > 0 ? { type: "non-dicom", name, targetBytes: leaf.bytes } : { type: "non-dicom", name, content: "" }
|
|
2219
|
+
);
|
|
2220
|
+
} else {
|
|
2221
|
+
nodes.push(entryOf({ ...leaf.rec, count: 1 }));
|
|
2222
|
+
}
|
|
2223
|
+
}
|
|
2224
|
+
for (const sub of acc.dirs.values()) {
|
|
2225
|
+
nodes.push({ children: emitTree(sub) });
|
|
2226
|
+
}
|
|
2227
|
+
return nodes;
|
|
2228
|
+
}
|
|
2229
|
+
function describeTree(dir, options, keepKeywords) {
|
|
2230
|
+
const root = { dirs: /* @__PURE__ */ new Map(), files: [] };
|
|
2231
|
+
const stats = {
|
|
2232
|
+
dicomFiles: 0,
|
|
2233
|
+
skipped: 0,
|
|
2234
|
+
unknownModality: 0,
|
|
2235
|
+
nonDicomFiles: 0
|
|
2236
|
+
};
|
|
2237
|
+
const studyUids = /* @__PURE__ */ new Set();
|
|
2238
|
+
const dicomLeaves = [];
|
|
2239
|
+
for (const path of [...walkFiles(dir)].sort()) {
|
|
2240
|
+
const scanned = scanFile(path);
|
|
2241
|
+
if (!scanned) {
|
|
2242
|
+
stats.skipped++;
|
|
2243
|
+
continue;
|
|
2244
|
+
}
|
|
2245
|
+
const { size, large, record } = scanned;
|
|
2246
|
+
const studyUid = record?.StudyInstanceUID;
|
|
2247
|
+
const seriesUid = record?.SeriesInstanceUID;
|
|
2248
|
+
const relDirs = relative(dir, path).split(sep).slice(0, -1);
|
|
2249
|
+
if (!record || typeof studyUid !== "string" || typeof seriesUid !== "string") {
|
|
2250
|
+
if (large) {
|
|
2251
|
+
stats.skipped++;
|
|
2252
|
+
continue;
|
|
2253
|
+
}
|
|
2254
|
+
stats.nonDicomFiles++;
|
|
2255
|
+
dirAccumFor(root, relDirs).files.push({
|
|
2256
|
+
kind: "other",
|
|
2257
|
+
bytes: size,
|
|
2258
|
+
ext: safeExtension(path)
|
|
2259
|
+
});
|
|
2260
|
+
continue;
|
|
2261
|
+
}
|
|
2262
|
+
stats.dicomFiles++;
|
|
2263
|
+
studyUids.add(studyUid);
|
|
2264
|
+
const preset = presetModalityOf(record);
|
|
2265
|
+
if (preset === "unsupported") stats.unknownModality++;
|
|
2266
|
+
const modality = preset === "unsupported" ? void 0 : preset;
|
|
2267
|
+
const instanceNumber = typeof record.InstanceNumber === "number" ? { InstanceNumber: record.InstanceNumber } : typeof record.InstanceNumber === "string" ? { InstanceNumber: escapeTagTemplate(record.InstanceNumber) } : void 0;
|
|
2268
|
+
const leaf = {
|
|
2269
|
+
kind: "dicom",
|
|
2270
|
+
rec: {
|
|
2271
|
+
large,
|
|
2272
|
+
bytes: size,
|
|
2273
|
+
modality,
|
|
2274
|
+
tags: mergeTags(
|
|
2275
|
+
instanceNumber,
|
|
2276
|
+
keepKeywords.length ? extractTags(record, keepKeywords) : void 0
|
|
2277
|
+
),
|
|
2278
|
+
uidOriginals: collectUidOriginals(record)
|
|
2279
|
+
},
|
|
2280
|
+
studyUid,
|
|
2281
|
+
seriesUid
|
|
2282
|
+
};
|
|
2283
|
+
dirAccumFor(root, relDirs).files.push(leaf);
|
|
2284
|
+
dicomLeaves.push(leaf);
|
|
2285
|
+
}
|
|
2286
|
+
if (root.files.length === 0 && root.dirs.size === 0) {
|
|
2287
|
+
throw new Error(`describe: no files found in ${dir}`);
|
|
2288
|
+
}
|
|
2289
|
+
const salt = options.uidSalt ?? deriveSalt([...studyUids]);
|
|
2290
|
+
const uidMap = /* @__PURE__ */ new Map();
|
|
2291
|
+
const sweptKeepTags = /* @__PURE__ */ new Set();
|
|
2292
|
+
for (const { rec, studyUid, seriesUid } of dicomLeaves) {
|
|
2293
|
+
hashRecordUids(rec, salt, uidMap, sweptKeepTags);
|
|
2294
|
+
rec.tags = mergeTags(rec.tags, {
|
|
2295
|
+
StudyInstanceUID: hashUidVia(uidMap, salt, studyUid),
|
|
2296
|
+
SeriesInstanceUID: hashUidVia(uidMap, salt, seriesUid)
|
|
2297
|
+
});
|
|
2298
|
+
}
|
|
2299
|
+
warnSweptKeepTags(sweptKeepTags);
|
|
2300
|
+
const spec = { tree: emitTree(root), uidSalt: salt };
|
|
2301
|
+
validateDatasetSpec(spec);
|
|
2302
|
+
return { spec, stats };
|
|
2303
|
+
}
|
|
2009
2304
|
function describeDirectory(dir, options = {}) {
|
|
2010
2305
|
const keepKeywords = options.keepTags ? resolveKeepTags(options.keepTags) : [];
|
|
2011
2306
|
const preserveUids = options.preserveUids ?? true;
|
|
2307
|
+
if (options.captureTree) {
|
|
2308
|
+
if (!preserveUids) {
|
|
2309
|
+
throw new Error(
|
|
2310
|
+
"describe: captureTree requires preserveUids \u2014 the captured tree carries its grouping as hashed UID tags"
|
|
2311
|
+
);
|
|
2312
|
+
}
|
|
2313
|
+
return describeTree(dir, options, keepKeywords);
|
|
2314
|
+
}
|
|
2012
2315
|
const uidMap = /* @__PURE__ */ new Map();
|
|
2013
2316
|
const useRecords = keepKeywords.length > 0 || preserveUids;
|
|
2014
2317
|
const tolerance = options.sizeTolerance ?? 0;
|
|
@@ -2018,21 +2321,19 @@ function describeDirectory(dir, options = {}) {
|
|
|
2018
2321
|
);
|
|
2019
2322
|
}
|
|
2020
2323
|
const studies = /* @__PURE__ */ new Map();
|
|
2021
|
-
const stats = {
|
|
2324
|
+
const stats = {
|
|
2325
|
+
dicomFiles: 0,
|
|
2326
|
+
skipped: 0,
|
|
2327
|
+
unknownModality: 0,
|
|
2328
|
+
nonDicomFiles: 0
|
|
2329
|
+
};
|
|
2022
2330
|
for (const path of [...walkFiles(dir)].sort()) {
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
size = statSync(path).size;
|
|
2026
|
-
} catch {
|
|
2331
|
+
const scanned = scanFile(path);
|
|
2332
|
+
if (!scanned) {
|
|
2027
2333
|
stats.skipped++;
|
|
2028
2334
|
continue;
|
|
2029
2335
|
}
|
|
2030
|
-
|
|
2031
|
-
stats.skipped++;
|
|
2032
|
-
continue;
|
|
2033
|
-
}
|
|
2034
|
-
const large = size > MAX_PIXEL_BYTES;
|
|
2035
|
-
const record = large ? readHeaderOnly(path) : parseFull(path);
|
|
2336
|
+
const { size, large, record } = scanned;
|
|
2036
2337
|
const studyUid = record?.StudyInstanceUID;
|
|
2037
2338
|
const seriesUid = record?.SeriesInstanceUID;
|
|
2038
2339
|
if (!record || typeof studyUid !== "string" || typeof seriesUid !== "string") {
|
|
@@ -2040,14 +2341,9 @@ function describeDirectory(dir, options = {}) {
|
|
|
2040
2341
|
continue;
|
|
2041
2342
|
}
|
|
2042
2343
|
stats.dicomFiles++;
|
|
2043
|
-
|
|
2044
|
-
if (
|
|
2045
|
-
|
|
2046
|
-
modality = record.Modality;
|
|
2047
|
-
} else {
|
|
2048
|
-
stats.unknownModality++;
|
|
2049
|
-
}
|
|
2050
|
-
}
|
|
2344
|
+
const preset = presetModalityOf(record);
|
|
2345
|
+
if (preset === "unsupported") stats.unknownModality++;
|
|
2346
|
+
const modality = preset === "unsupported" ? void 0 : preset;
|
|
2051
2347
|
const keptTags = keepKeywords.length ? extractTags(record, keepKeywords) : void 0;
|
|
2052
2348
|
const uidOriginals = preserveUids ? collectUidOriginals(record) : void 0;
|
|
2053
2349
|
const bytes = large ? size : Math.min(bucketBytes(size, tolerance), MAX_PIXEL_BYTES);
|
|
@@ -2081,25 +2377,11 @@ function describeDirectory(dir, options = {}) {
|
|
|
2081
2377
|
for (const series of study.values()) {
|
|
2082
2378
|
if (!Array.isArray(series)) continue;
|
|
2083
2379
|
for (const rec of series) {
|
|
2084
|
-
|
|
2085
|
-
if (rec.tags) {
|
|
2086
|
-
for (const keyword of Object.keys(rec.uidOriginals)) {
|
|
2087
|
-
if (keyword in rec.tags) sweptKeepTags.add(keyword);
|
|
2088
|
-
}
|
|
2089
|
-
}
|
|
2090
|
-
rec.tags = mergeTags(
|
|
2091
|
-
rec.tags,
|
|
2092
|
-
hashUidTags(rec.uidOriginals, salt, uidMap)
|
|
2093
|
-
);
|
|
2094
|
-
}
|
|
2380
|
+
hashRecordUids(rec, salt, uidMap, sweptKeepTags);
|
|
2095
2381
|
}
|
|
2096
2382
|
}
|
|
2097
2383
|
}
|
|
2098
|
-
|
|
2099
|
-
console.warn(
|
|
2100
|
-
`describe: keep-tag "${keyword}" contains UID references \u2014 kept as its hashed UID skeleton, not verbatim (disable with preserveUids: false)`
|
|
2101
|
-
);
|
|
2102
|
-
}
|
|
2384
|
+
warnSweptKeepTags(sweptKeepTags);
|
|
2103
2385
|
}
|
|
2104
2386
|
const withGroupUid = (spec2, seriesUid) => salt === void 0 ? spec2 : {
|
|
2105
2387
|
...spec2,
|