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
|
@@ -6,7 +6,7 @@ import { describeDirectory } from '../dist/esm/index.js'
|
|
|
6
6
|
|
|
7
7
|
function usage() {
|
|
8
8
|
console.error(
|
|
9
|
-
'Usage: dicom-synth-describe <dir> [--out <spec.json[.gz]>] [--keep-tags <a,b,...>] [--keep-tags-file <path>] [--size-tolerance <fraction>] [--no-preserve-uids] [--uid-salt <hex>] [--gzip]\n' +
|
|
9
|
+
'Usage: dicom-synth-describe <dir> [--out <spec.json[.gz]>] [--tree] [--keep-tags <a,b,...>] [--keep-tags-file <path>] [--size-tolerance <fraction>] [--no-preserve-uids] [--uid-salt <hex>] [--gzip]\n' +
|
|
10
10
|
'\n' +
|
|
11
11
|
'Scans a DICOM tree and emits a DatasetSpec describing its shape\n' +
|
|
12
12
|
'(studies/series, per-file size, modality).\n' +
|
|
@@ -14,6 +14,9 @@ function usage() {
|
|
|
14
14
|
'UIDs are preserved by default as hash-derived synthetic UIDs, keeping\n' +
|
|
15
15
|
'cross-file links (FrameOfReferenceUID etc.) without emitting any original\n' +
|
|
16
16
|
'UID. Other tags are not copied unless named.\n' +
|
|
17
|
+
'--tree captures the real directory structure as a generic tree spec\n' +
|
|
18
|
+
'(anonymised names, sized non-DICOM placeholders included) instead of\n' +
|
|
19
|
+
'the compact studies/series form. Requires UID preservation.\n' +
|
|
17
20
|
'--keep-tags copies the named tags (DICOM keywords or 8-hex tags)\n' +
|
|
18
21
|
"verbatim; if a kept tag holds PHI, handling it is the caller's\n" +
|
|
19
22
|
"responsibility, not this tool's.\n" +
|
|
@@ -48,6 +51,7 @@ let sizeTolerance
|
|
|
48
51
|
let preserveUids = true
|
|
49
52
|
let uidSalt
|
|
50
53
|
let gzip = false
|
|
54
|
+
let captureTree = false
|
|
51
55
|
const keepTags = []
|
|
52
56
|
|
|
53
57
|
for (let i = 0; i < args.length; i++) {
|
|
@@ -75,6 +79,8 @@ for (let i = 0; i < args.length; i++) {
|
|
|
75
79
|
}
|
|
76
80
|
} else if (args[i] === '--no-preserve-uids') {
|
|
77
81
|
preserveUids = false
|
|
82
|
+
} else if (args[i] === '--tree') {
|
|
83
|
+
captureTree = true
|
|
78
84
|
} else if (args[i] === '--uid-salt' && args[i + 1]) {
|
|
79
85
|
uidSalt = args[++i]
|
|
80
86
|
} else if (args[i] === '--gzip') {
|
|
@@ -104,6 +110,7 @@ try {
|
|
|
104
110
|
...(sizeTolerance !== undefined ? { sizeTolerance } : {}),
|
|
105
111
|
preserveUids,
|
|
106
112
|
...(uidSalt !== undefined ? { uidSalt } : {}),
|
|
113
|
+
...(captureTree ? { captureTree: true } : {}),
|
|
107
114
|
})
|
|
108
115
|
} catch (err) {
|
|
109
116
|
console.error(`Error: ${err.message}`)
|
|
@@ -119,10 +126,15 @@ if (outPath) {
|
|
|
119
126
|
process.stdout.write(compress ? gzipSync(json) : json)
|
|
120
127
|
}
|
|
121
128
|
|
|
122
|
-
const { dicomFiles, skipped, unknownModality } = result.stats
|
|
129
|
+
const { dicomFiles, skipped, unknownModality, nonDicomFiles } = result.stats
|
|
123
130
|
console.error(
|
|
124
131
|
`Described ${dicomFiles} DICOM file(s); skipped ${skipped} non-DICOM/ungroupable file(s)`,
|
|
125
132
|
)
|
|
133
|
+
if (nonDicomFiles > 0) {
|
|
134
|
+
console.error(
|
|
135
|
+
` ${nonDicomFiles} non-DICOM file(s) captured as sized placeholders`,
|
|
136
|
+
)
|
|
137
|
+
}
|
|
126
138
|
if (result.spec.uidSalt) {
|
|
127
139
|
console.error(
|
|
128
140
|
` UIDs hash-derived with salt ${result.spec.uidSalt} (reuse via --uid-salt to reproduce)`,
|
|
@@ -56,6 +56,12 @@ function isImageType(type) {
|
|
|
56
56
|
return TYPE_CAPABILITIES[type].image;
|
|
57
57
|
}
|
|
58
58
|
var HEX_TAG_RE = /^[0-9a-fA-F]{8}$/;
|
|
59
|
+
function isRawTagValue(value) {
|
|
60
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) && typeof value.vr === "string";
|
|
61
|
+
}
|
|
62
|
+
function positionalName(prefix, ordinal) {
|
|
63
|
+
return `${prefix}-${String(ordinal).padStart(3, "0")}`;
|
|
64
|
+
}
|
|
59
65
|
|
|
60
66
|
// src/loadDcmjs.ts
|
|
61
67
|
import dcmjsDefaultImport, * as dcmjsNamespace from "dcmjs";
|
|
@@ -145,20 +151,51 @@ function buildTagToKeyword() {
|
|
|
145
151
|
return map;
|
|
146
152
|
}
|
|
147
153
|
var tagToKeyword = buildTagToKeyword();
|
|
154
|
+
function toRawElement(raw) {
|
|
155
|
+
const value = raw.vr === "SQ" ? raw.value.map(
|
|
156
|
+
(item) => Object.fromEntries(
|
|
157
|
+
Object.entries(item).map(([tag, nested]) => [
|
|
158
|
+
tag.toUpperCase(),
|
|
159
|
+
toRawElement(nested)
|
|
160
|
+
])
|
|
161
|
+
)
|
|
162
|
+
) : raw.value;
|
|
163
|
+
return { vr: raw.vr, Value: Array.isArray(value) ? value : [value] };
|
|
164
|
+
}
|
|
148
165
|
function applyTagOverrides(dataset, tags) {
|
|
149
|
-
if (!tags) return;
|
|
166
|
+
if (!tags) return void 0;
|
|
167
|
+
let raw;
|
|
150
168
|
for (const [key, value] of Object.entries(tags)) {
|
|
151
|
-
if (
|
|
169
|
+
if (isRawTagValue(value)) {
|
|
170
|
+
raw ?? (raw = {});
|
|
171
|
+
raw[key.toUpperCase()] = toRawElement(value);
|
|
172
|
+
} else if (HEX_TAG_RE.test(key)) {
|
|
152
173
|
const keyword = tagToKeyword.get(key.toUpperCase());
|
|
153
|
-
if (keyword) {
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
174
|
+
if (!keyword) {
|
|
175
|
+
throw new Error(
|
|
176
|
+
`dicom-synth: unknown hex tag "${key}" \u2014 dictionary tags take plain values; private/unknown tags require the raw { vr, value } form`
|
|
177
|
+
);
|
|
157
178
|
}
|
|
179
|
+
dataset[keyword] = value;
|
|
158
180
|
} else {
|
|
159
181
|
dataset[key] = value;
|
|
160
182
|
}
|
|
161
183
|
}
|
|
184
|
+
return raw;
|
|
185
|
+
}
|
|
186
|
+
function rawScalarString(rawElements, tag) {
|
|
187
|
+
const value = rawElements?.[tag]?.Value;
|
|
188
|
+
return value?.length === 1 && typeof value[0] === "string" ? value[0] : void 0;
|
|
189
|
+
}
|
|
190
|
+
function syncMetaWithDataset(meta, dataset, rawElements) {
|
|
191
|
+
const sopInstance = rawScalarString(rawElements, "00080018") ?? (typeof dataset.SOPInstanceUID === "string" ? dataset.SOPInstanceUID : void 0);
|
|
192
|
+
if (sopInstance !== void 0) {
|
|
193
|
+
meta.MediaStorageSOPInstanceUID = sopInstance;
|
|
194
|
+
}
|
|
195
|
+
const sopClass = rawScalarString(rawElements, "00080016") ?? (typeof dataset.SOPClassUID === "string" ? dataset.SOPClassUID : void 0);
|
|
196
|
+
if (sopClass !== void 0) {
|
|
197
|
+
meta.MediaStorageSOPClassUID = sopClass;
|
|
198
|
+
}
|
|
162
199
|
}
|
|
163
200
|
function buildMeta(uid, modality, transferSyntax = "explicit-vr-little-endian") {
|
|
164
201
|
return {
|
|
@@ -197,7 +234,7 @@ function buildBaseImageDataset(uid, modality) {
|
|
|
197
234
|
...preset.attributes
|
|
198
235
|
};
|
|
199
236
|
}
|
|
200
|
-
function serializeDict(meta, dataset) {
|
|
237
|
+
function serializeDict(meta, dataset, rawElements) {
|
|
201
238
|
const dicomDict = new dcmjs.data.DicomDict(
|
|
202
239
|
dcmjs.data.DicomMetaDictionary.denaturalizeDataset(
|
|
203
240
|
meta
|
|
@@ -206,14 +243,17 @@ function serializeDict(meta, dataset) {
|
|
|
206
243
|
dicomDict.dict = dcmjs.data.DicomMetaDictionary.denaturalizeDataset(
|
|
207
244
|
dataset
|
|
208
245
|
);
|
|
246
|
+
if (rawElements) {
|
|
247
|
+
Object.assign(dicomDict.dict, rawElements);
|
|
248
|
+
}
|
|
209
249
|
return Buffer.from(dicomDict.write({ allowInvalidVRLength: true }));
|
|
210
250
|
}
|
|
211
251
|
var MAX_DIMENSION = 65535;
|
|
212
|
-
function applySizing(dataset, meta, spec) {
|
|
252
|
+
function applySizing(dataset, meta, spec, rawElements) {
|
|
213
253
|
if (spec.targetSizeKb !== void 0) {
|
|
214
254
|
const clampDim = (n) => Math.min(MAX_DIMENSION, Math.max(1, Math.round(n)));
|
|
215
255
|
const minimalPixelBytes = dataset.PixelData.byteLength;
|
|
216
|
-
const overhead = serializeDict(meta, dataset).length - minimalPixelBytes;
|
|
256
|
+
const overhead = serializeDict(meta, dataset, rawElements).length - minimalPixelBytes;
|
|
217
257
|
const cells = Math.max(
|
|
218
258
|
1,
|
|
219
259
|
Math.round((spec.targetSizeKb * 1024 - overhead) / 2)
|
|
@@ -244,10 +284,11 @@ function buildImageBuffer(spec, uid) {
|
|
|
244
284
|
dataset.Laterality = "";
|
|
245
285
|
dataset.PatientWeight = "0";
|
|
246
286
|
}
|
|
247
|
-
applyTagOverrides(dataset, spec.tags);
|
|
287
|
+
const rawElements = applyTagOverrides(dataset, spec.tags);
|
|
248
288
|
const meta = buildMeta(effectiveUid, modality, spec.transferSyntax);
|
|
249
|
-
|
|
250
|
-
|
|
289
|
+
syncMetaWithDataset(meta, dataset, rawElements);
|
|
290
|
+
applySizing(dataset, meta, spec, rawElements);
|
|
291
|
+
return { buffer: serializeDict(meta, dataset, rawElements), rawElements };
|
|
251
292
|
}
|
|
252
293
|
function buildFakeSignatureBuffer() {
|
|
253
294
|
const buf = Buffer.alloc(200, 0);
|
|
@@ -269,11 +310,11 @@ function buildBufferForSpec(spec, uid) {
|
|
|
269
310
|
case "vendor-warnings-image":
|
|
270
311
|
return buildImageBuffer(spec, uid);
|
|
271
312
|
case "fake-signature":
|
|
272
|
-
return buildFakeSignatureBuffer();
|
|
313
|
+
return { buffer: buildFakeSignatureBuffer() };
|
|
273
314
|
case "non-dicom":
|
|
274
|
-
return buildNonDicomBuffer(spec);
|
|
315
|
+
return { buffer: buildNonDicomBuffer(spec) };
|
|
275
316
|
case "dicomdir":
|
|
276
|
-
return buildDicomdirBuffer();
|
|
317
|
+
return { buffer: buildDicomdirBuffer() };
|
|
277
318
|
case "large-image":
|
|
278
319
|
throw new Error(
|
|
279
320
|
"large-image cannot be generated in memory \u2014 use writeCollectionFromSpec (disk-only streaming)"
|
|
@@ -354,11 +395,12 @@ function writeNative(outPath, params, dims, zeroChunk) {
|
|
|
354
395
|
const { modality, uid, tags } = params;
|
|
355
396
|
const meta = buildMeta(uid, modality);
|
|
356
397
|
const dataset = buildBaseImageDataset(uid, modality);
|
|
357
|
-
applyTagOverrides(dataset, tags);
|
|
398
|
+
const rawElements = applyTagOverrides(dataset, tags);
|
|
399
|
+
syncMetaWithDataset(meta, dataset, rawElements);
|
|
358
400
|
dataset.Rows = dims.rows;
|
|
359
401
|
dataset.Columns = dims.columns;
|
|
360
402
|
const minimalPixelBytes = dataset.PixelData.byteLength;
|
|
361
|
-
const probe = serializeDict(meta, dataset);
|
|
403
|
+
const probe = serializeDict(meta, dataset, rawElements);
|
|
362
404
|
const pixelElement = Buffer.alloc(12);
|
|
363
405
|
PIXEL_DATA_OW_HEADER.copy(pixelElement);
|
|
364
406
|
pixelElement.writeUInt32LE(minimalPixelBytes, 8);
|
|
@@ -387,10 +429,11 @@ function writeEncapsulated(outPath, params, pixelBytes, fragmentBytes, zeroChunk
|
|
|
387
429
|
TransferSyntaxUID: RLE_TRANSFER_SYNTAX_UID
|
|
388
430
|
};
|
|
389
431
|
const dataset = buildBaseImageDataset(uid, modality);
|
|
390
|
-
applyTagOverrides(dataset, tags);
|
|
432
|
+
const rawElements = applyTagOverrides(dataset, tags);
|
|
433
|
+
syncMetaWithDataset(meta, dataset, rawElements);
|
|
391
434
|
dataset.PixelData = [new Uint8Array([]).buffer, new Uint8Array([0, 0]).buffer];
|
|
392
435
|
dataset._vrMap = { PixelData: "OB" };
|
|
393
|
-
const probe = serializeDict(meta, dataset);
|
|
436
|
+
const probe = serializeDict(meta, dataset, rawElements);
|
|
394
437
|
const idx = probe.indexOf(PIXEL_DATA_OB_HEADER);
|
|
395
438
|
if (idx < 0 || probe.readUInt32LE(idx + 8) !== 4294967295) {
|
|
396
439
|
throw new Error("failed to locate encapsulated PixelData element header");
|
|
@@ -487,7 +530,7 @@ var VIOLATION_LEVEL = {
|
|
|
487
530
|
"missing-meta-header": "byte",
|
|
488
531
|
"malformed-sq-delimiter": "byte"
|
|
489
532
|
};
|
|
490
|
-
function applyTagLevel(buffer, violations) {
|
|
533
|
+
function applyTagLevel(buffer, violations, rawElements) {
|
|
491
534
|
if (violations.length === 0) return buffer;
|
|
492
535
|
let parsed;
|
|
493
536
|
try {
|
|
@@ -521,6 +564,9 @@ function applyTagLevel(buffer, violations) {
|
|
|
521
564
|
}
|
|
522
565
|
}
|
|
523
566
|
parsed.dict = dcmjsAny.data.DicomMetaDictionary.denaturalizeDataset(natural);
|
|
567
|
+
if (rawElements) {
|
|
568
|
+
Object.assign(parsed.dict, rawElements);
|
|
569
|
+
}
|
|
524
570
|
if (violations.includes("uid-too-long")) {
|
|
525
571
|
;
|
|
526
572
|
parsed.dict["00080018"] = {
|
|
@@ -553,11 +599,14 @@ function applyByteLevel(buffer, violations) {
|
|
|
553
599
|
}
|
|
554
600
|
return result;
|
|
555
601
|
}
|
|
556
|
-
function applyViolations(buffer, violations) {
|
|
602
|
+
function applyViolations(buffer, violations, rawElements) {
|
|
557
603
|
if (violations.length === 0) return buffer;
|
|
558
604
|
const tagViolations = violations.filter((v) => VIOLATION_LEVEL[v] === "tag");
|
|
559
605
|
const byteViolations = violations.filter((v) => VIOLATION_LEVEL[v] === "byte");
|
|
560
|
-
return applyByteLevel(
|
|
606
|
+
return applyByteLevel(
|
|
607
|
+
applyTagLevel(buffer, tagViolations, rawElements),
|
|
608
|
+
byteViolations
|
|
609
|
+
);
|
|
561
610
|
}
|
|
562
611
|
|
|
563
612
|
// src/collection/writer.ts
|
|
@@ -631,10 +680,11 @@ async function generateFile(spec, options) {
|
|
|
631
680
|
...spec,
|
|
632
681
|
tags: resolveTagTemplates(spec.tags, { index, ...options?.context })
|
|
633
682
|
} : spec;
|
|
634
|
-
|
|
683
|
+
const built = buildBufferForSpec(resolvedSpec, uid);
|
|
684
|
+
let buffer = built.buffer;
|
|
635
685
|
const violations = "violations" in spec ? spec.violations : void 0;
|
|
636
686
|
if (violations?.length) {
|
|
637
|
-
buffer = applyViolations(buffer, violations);
|
|
687
|
+
buffer = applyViolations(buffer, violations, built.rawElements);
|
|
638
688
|
}
|
|
639
689
|
const name = filename(spec.type, index, padWidth);
|
|
640
690
|
return {
|
|
@@ -723,7 +773,7 @@ function* planTree(nodes, scope, env) {
|
|
|
723
773
|
for (const node of nodes) {
|
|
724
774
|
if (isDirNode(node)) {
|
|
725
775
|
dirOrdinal++;
|
|
726
|
-
const dirName = node.name ??
|
|
776
|
+
const dirName = node.name ?? positionalName("dir", dirOrdinal);
|
|
727
777
|
const child = {
|
|
728
778
|
...scope,
|
|
729
779
|
dirs: [...scope.dirs, dirName],
|
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
readSync,
|
|
9
9
|
statSync
|
|
10
10
|
} from "node:fs";
|
|
11
|
-
import { join } from "node:path";
|
|
11
|
+
import { basename, extname, join, relative, sep } from "node:path";
|
|
12
12
|
|
|
13
13
|
// src/loadDcmjs.ts
|
|
14
14
|
import dcmjsDefaultImport, * as dcmjsNamespace from "dcmjs";
|
|
@@ -92,6 +92,92 @@ var VALID_VIOLATIONS = {
|
|
|
92
92
|
};
|
|
93
93
|
var HEX_TAG_RE = /^[0-9a-fA-F]{8}$/;
|
|
94
94
|
var KEYWORD_RE = /^[A-Z][A-Za-z0-9]*$/;
|
|
95
|
+
var VALID_VRS = /* @__PURE__ */ new Set([
|
|
96
|
+
"AE",
|
|
97
|
+
"AS",
|
|
98
|
+
"AT",
|
|
99
|
+
"CS",
|
|
100
|
+
"DA",
|
|
101
|
+
"DS",
|
|
102
|
+
"DT",
|
|
103
|
+
"FL",
|
|
104
|
+
"FD",
|
|
105
|
+
"IS",
|
|
106
|
+
"LO",
|
|
107
|
+
"LT",
|
|
108
|
+
"OB",
|
|
109
|
+
"OD",
|
|
110
|
+
"OF",
|
|
111
|
+
"OL",
|
|
112
|
+
"OV",
|
|
113
|
+
"OW",
|
|
114
|
+
"PN",
|
|
115
|
+
"SH",
|
|
116
|
+
"SL",
|
|
117
|
+
"SQ",
|
|
118
|
+
"SS",
|
|
119
|
+
"ST",
|
|
120
|
+
"SV",
|
|
121
|
+
"TM",
|
|
122
|
+
"UC",
|
|
123
|
+
"UI",
|
|
124
|
+
"UL",
|
|
125
|
+
"UN",
|
|
126
|
+
"UR",
|
|
127
|
+
"US",
|
|
128
|
+
"UT",
|
|
129
|
+
"UV"
|
|
130
|
+
]);
|
|
131
|
+
function isPrivateHexTag(key) {
|
|
132
|
+
return HEX_TAG_RE.test(key) && Number.parseInt(key.slice(0, 4), 16) % 2 === 1;
|
|
133
|
+
}
|
|
134
|
+
function isRawTagValue(value) {
|
|
135
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) && typeof value.vr === "string";
|
|
136
|
+
}
|
|
137
|
+
function validateRawTagValue(raw, path) {
|
|
138
|
+
for (const key of Object.keys(raw)) {
|
|
139
|
+
if (key !== "vr" && key !== "value") {
|
|
140
|
+
throw new Error(
|
|
141
|
+
`${path}: raw tag form accepts only "vr" and "value"; got "${key}"`
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
const vr = raw.vr;
|
|
146
|
+
if (!VALID_VRS.has(vr)) {
|
|
147
|
+
throw new Error(`${path}.vr: "${vr}" is not a standard DICOM VR`);
|
|
148
|
+
}
|
|
149
|
+
if (!("value" in raw)) {
|
|
150
|
+
throw new Error(`${path}.value: is required in the raw tag form`);
|
|
151
|
+
}
|
|
152
|
+
if (vr === "SQ") {
|
|
153
|
+
if (!Array.isArray(raw.value)) {
|
|
154
|
+
throw new Error(`${path}.value: an SQ raw tag requires an array of items`);
|
|
155
|
+
}
|
|
156
|
+
raw.value.forEach((item, i) => {
|
|
157
|
+
if (typeof item !== "object" || item === null || Array.isArray(item)) {
|
|
158
|
+
throw new Error(
|
|
159
|
+
`${path}.value[${i}]: must be an object of hex-tag entries`
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
for (const [tag, nested] of Object.entries(item)) {
|
|
163
|
+
if (!HEX_TAG_RE.test(tag)) {
|
|
164
|
+
throw new Error(
|
|
165
|
+
`${path}.value[${i}]: invalid item tag "${tag}" \u2014 SQ items use 8-hex-char tags`
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
if (!isRawTagValue(nested)) {
|
|
169
|
+
throw new Error(
|
|
170
|
+
`${path}.value[${i}].${tag}: SQ item entries must use the raw { vr, value } form`
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
validateRawTagValue(
|
|
174
|
+
nested,
|
|
175
|
+
`${path}.value[${i}].${tag}`
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
}
|
|
95
181
|
function validatePositiveInt(value, path) {
|
|
96
182
|
if (typeof value !== "number" || !Number.isInteger(value) || value < 1) {
|
|
97
183
|
throw new Error(
|
|
@@ -289,13 +375,33 @@ function validateEntry(entry, path) {
|
|
|
289
375
|
return e;
|
|
290
376
|
}
|
|
291
377
|
var MODALITY_TAG = "00080060";
|
|
292
|
-
function
|
|
378
|
+
function validateModalityGuard(key, candidate, path) {
|
|
293
379
|
const isModalityKey = key === "Modality" || key.toUpperCase() === MODALITY_TAG;
|
|
294
|
-
if (isModalityKey && typeof
|
|
380
|
+
if (isModalityKey && typeof candidate === "string" && Object.hasOwn(VALID_MODALITIES, candidate)) {
|
|
295
381
|
throw new Error(
|
|
296
382
|
`${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`
|
|
297
383
|
);
|
|
298
384
|
}
|
|
385
|
+
}
|
|
386
|
+
function validateTagValue(key, value, path) {
|
|
387
|
+
if (isRawTagValue(value)) {
|
|
388
|
+
if (!HEX_TAG_RE.test(key)) {
|
|
389
|
+
throw new Error(
|
|
390
|
+
`${path}.${key}: the raw { vr, value } form requires an 8-hex-char tag key \u2014 keyword tags take plain values`
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
validateRawTagValue(value, `${path}.${key}`);
|
|
394
|
+
const raw = value.value;
|
|
395
|
+
const scalar = Array.isArray(raw) && raw.length === 1 ? raw[0] : raw;
|
|
396
|
+
validateModalityGuard(key, scalar, path);
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
if (isPrivateHexTag(key)) {
|
|
400
|
+
throw new Error(
|
|
401
|
+
`${path}.${key}: private tags have no dictionary VR \u2014 use the raw form, e.g. { "vr": "LO", "value": \u2026 }`
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
validateModalityGuard(key, value, path);
|
|
299
405
|
if (typeof value === "string") {
|
|
300
406
|
for (const name of findTemplateTokens(value)) {
|
|
301
407
|
if (!TEMPLATE_VOCAB.includes(name)) {
|
|
@@ -431,6 +537,9 @@ function validateStudy(study, path) {
|
|
|
431
537
|
if ("tags" in st) validateTags(st.tags, `${path}.tags`);
|
|
432
538
|
return st;
|
|
433
539
|
}
|
|
540
|
+
function positionalName(prefix, ordinal) {
|
|
541
|
+
return `${prefix}-${String(ordinal).padStart(3, "0")}`;
|
|
542
|
+
}
|
|
434
543
|
var VALID_TREE_ROLES = {
|
|
435
544
|
study: true,
|
|
436
545
|
series: true
|
|
@@ -511,7 +620,7 @@ function validateTreeChildren(children, basePath, inStudy, inSeries) {
|
|
|
511
620
|
let name = "name" in n ? n.name : void 0;
|
|
512
621
|
if ("children" in n) {
|
|
513
622
|
dirOrdinal++;
|
|
514
|
-
name ?? (name =
|
|
623
|
+
name ?? (name = positionalName("dir", dirOrdinal));
|
|
515
624
|
}
|
|
516
625
|
if (name === void 0) return;
|
|
517
626
|
const prior = claimed.get(name);
|
|
@@ -850,6 +959,37 @@ function mergeTags(a, b) {
|
|
|
850
959
|
if (!b) return a;
|
|
851
960
|
return { ...a, ...b };
|
|
852
961
|
}
|
|
962
|
+
function scanFile(path) {
|
|
963
|
+
let size;
|
|
964
|
+
try {
|
|
965
|
+
size = statSync(path).size;
|
|
966
|
+
} catch {
|
|
967
|
+
return null;
|
|
968
|
+
}
|
|
969
|
+
if (size > MAX_STREAM_BYTES) return null;
|
|
970
|
+
const large = size > MAX_PIXEL_BYTES;
|
|
971
|
+
return { size, large, record: large ? readHeaderOnly(path) : parseFull(path) };
|
|
972
|
+
}
|
|
973
|
+
function presetModalityOf(record) {
|
|
974
|
+
if (typeof record.Modality !== "string") return void 0;
|
|
975
|
+
return SUPPORTED_MODALITIES.has(record.Modality) ? record.Modality : "unsupported";
|
|
976
|
+
}
|
|
977
|
+
function hashRecordUids(rec, salt, uidMap, sweptKeepTags) {
|
|
978
|
+
if (!rec.uidOriginals) return;
|
|
979
|
+
if (rec.tags) {
|
|
980
|
+
for (const keyword of Object.keys(rec.uidOriginals)) {
|
|
981
|
+
if (keyword in rec.tags) sweptKeepTags.add(keyword);
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
rec.tags = mergeTags(rec.tags, hashUidTags(rec.uidOriginals, salt, uidMap));
|
|
985
|
+
}
|
|
986
|
+
function warnSweptKeepTags(sweptKeepTags) {
|
|
987
|
+
for (const keyword of sweptKeepTags) {
|
|
988
|
+
console.warn(
|
|
989
|
+
`describe: keep-tag "${keyword}" contains UID references \u2014 kept as its hashed UID skeleton, not verbatim (disable with preserveUids: false)`
|
|
990
|
+
);
|
|
991
|
+
}
|
|
992
|
+
}
|
|
853
993
|
function sizeKbOf(bytes) {
|
|
854
994
|
return Math.max(1, Math.round(bytes / 1024));
|
|
855
995
|
}
|
|
@@ -952,9 +1092,128 @@ function buildSeriesSpec(records) {
|
|
|
952
1092
|
function seriesSpecFromAccum(accum) {
|
|
953
1093
|
return Array.isArray(accum) ? buildSeriesSpec(accum) : { entries: [...accum.values()].map(entryOf) };
|
|
954
1094
|
}
|
|
1095
|
+
function dirAccumFor(root, dirs) {
|
|
1096
|
+
let node = root;
|
|
1097
|
+
for (const name of dirs) {
|
|
1098
|
+
let child = node.dirs.get(name);
|
|
1099
|
+
if (!child) {
|
|
1100
|
+
child = { dirs: /* @__PURE__ */ new Map(), files: [] };
|
|
1101
|
+
node.dirs.set(name, child);
|
|
1102
|
+
}
|
|
1103
|
+
node = child;
|
|
1104
|
+
}
|
|
1105
|
+
return node;
|
|
1106
|
+
}
|
|
1107
|
+
var SAFE_EXTENSION_RE = /^\.[A-Za-z0-9]{1,8}$/;
|
|
1108
|
+
function safeExtension(path) {
|
|
1109
|
+
const ext = extname(basename(path));
|
|
1110
|
+
return SAFE_EXTENSION_RE.test(ext) ? ext : "";
|
|
1111
|
+
}
|
|
1112
|
+
function emitTree(acc) {
|
|
1113
|
+
const nodes = [];
|
|
1114
|
+
let otherOrdinal = 0;
|
|
1115
|
+
for (const leaf of acc.files) {
|
|
1116
|
+
if (leaf.kind === "other") {
|
|
1117
|
+
otherOrdinal++;
|
|
1118
|
+
const name = `${positionalName("file", otherOrdinal)}${leaf.ext}`;
|
|
1119
|
+
nodes.push(
|
|
1120
|
+
leaf.bytes > 0 ? { type: "non-dicom", name, targetBytes: leaf.bytes } : { type: "non-dicom", name, content: "" }
|
|
1121
|
+
);
|
|
1122
|
+
} else {
|
|
1123
|
+
nodes.push(entryOf({ ...leaf.rec, count: 1 }));
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
for (const sub of acc.dirs.values()) {
|
|
1127
|
+
nodes.push({ children: emitTree(sub) });
|
|
1128
|
+
}
|
|
1129
|
+
return nodes;
|
|
1130
|
+
}
|
|
1131
|
+
function describeTree(dir, options, keepKeywords) {
|
|
1132
|
+
const root = { dirs: /* @__PURE__ */ new Map(), files: [] };
|
|
1133
|
+
const stats = {
|
|
1134
|
+
dicomFiles: 0,
|
|
1135
|
+
skipped: 0,
|
|
1136
|
+
unknownModality: 0,
|
|
1137
|
+
nonDicomFiles: 0
|
|
1138
|
+
};
|
|
1139
|
+
const studyUids = /* @__PURE__ */ new Set();
|
|
1140
|
+
const dicomLeaves = [];
|
|
1141
|
+
for (const path of [...walkFiles(dir)].sort()) {
|
|
1142
|
+
const scanned = scanFile(path);
|
|
1143
|
+
if (!scanned) {
|
|
1144
|
+
stats.skipped++;
|
|
1145
|
+
continue;
|
|
1146
|
+
}
|
|
1147
|
+
const { size, large, record } = scanned;
|
|
1148
|
+
const studyUid = record?.StudyInstanceUID;
|
|
1149
|
+
const seriesUid = record?.SeriesInstanceUID;
|
|
1150
|
+
const relDirs = relative(dir, path).split(sep).slice(0, -1);
|
|
1151
|
+
if (!record || typeof studyUid !== "string" || typeof seriesUid !== "string") {
|
|
1152
|
+
if (large) {
|
|
1153
|
+
stats.skipped++;
|
|
1154
|
+
continue;
|
|
1155
|
+
}
|
|
1156
|
+
stats.nonDicomFiles++;
|
|
1157
|
+
dirAccumFor(root, relDirs).files.push({
|
|
1158
|
+
kind: "other",
|
|
1159
|
+
bytes: size,
|
|
1160
|
+
ext: safeExtension(path)
|
|
1161
|
+
});
|
|
1162
|
+
continue;
|
|
1163
|
+
}
|
|
1164
|
+
stats.dicomFiles++;
|
|
1165
|
+
studyUids.add(studyUid);
|
|
1166
|
+
const preset = presetModalityOf(record);
|
|
1167
|
+
if (preset === "unsupported") stats.unknownModality++;
|
|
1168
|
+
const modality = preset === "unsupported" ? void 0 : preset;
|
|
1169
|
+
const instanceNumber = typeof record.InstanceNumber === "number" ? { InstanceNumber: record.InstanceNumber } : typeof record.InstanceNumber === "string" ? { InstanceNumber: escapeTagTemplate(record.InstanceNumber) } : void 0;
|
|
1170
|
+
const leaf = {
|
|
1171
|
+
kind: "dicom",
|
|
1172
|
+
rec: {
|
|
1173
|
+
large,
|
|
1174
|
+
bytes: size,
|
|
1175
|
+
modality,
|
|
1176
|
+
tags: mergeTags(
|
|
1177
|
+
instanceNumber,
|
|
1178
|
+
keepKeywords.length ? extractTags(record, keepKeywords) : void 0
|
|
1179
|
+
),
|
|
1180
|
+
uidOriginals: collectUidOriginals(record)
|
|
1181
|
+
},
|
|
1182
|
+
studyUid,
|
|
1183
|
+
seriesUid
|
|
1184
|
+
};
|
|
1185
|
+
dirAccumFor(root, relDirs).files.push(leaf);
|
|
1186
|
+
dicomLeaves.push(leaf);
|
|
1187
|
+
}
|
|
1188
|
+
if (root.files.length === 0 && root.dirs.size === 0) {
|
|
1189
|
+
throw new Error(`describe: no files found in ${dir}`);
|
|
1190
|
+
}
|
|
1191
|
+
const salt = options.uidSalt ?? deriveSalt([...studyUids]);
|
|
1192
|
+
const uidMap = /* @__PURE__ */ new Map();
|
|
1193
|
+
const sweptKeepTags = /* @__PURE__ */ new Set();
|
|
1194
|
+
for (const { rec, studyUid, seriesUid } of dicomLeaves) {
|
|
1195
|
+
hashRecordUids(rec, salt, uidMap, sweptKeepTags);
|
|
1196
|
+
rec.tags = mergeTags(rec.tags, {
|
|
1197
|
+
StudyInstanceUID: hashUidVia(uidMap, salt, studyUid),
|
|
1198
|
+
SeriesInstanceUID: hashUidVia(uidMap, salt, seriesUid)
|
|
1199
|
+
});
|
|
1200
|
+
}
|
|
1201
|
+
warnSweptKeepTags(sweptKeepTags);
|
|
1202
|
+
const spec = { tree: emitTree(root), uidSalt: salt };
|
|
1203
|
+
validateDatasetSpec(spec);
|
|
1204
|
+
return { spec, stats };
|
|
1205
|
+
}
|
|
955
1206
|
function describeDirectory(dir, options = {}) {
|
|
956
1207
|
const keepKeywords = options.keepTags ? resolveKeepTags(options.keepTags) : [];
|
|
957
1208
|
const preserveUids = options.preserveUids ?? true;
|
|
1209
|
+
if (options.captureTree) {
|
|
1210
|
+
if (!preserveUids) {
|
|
1211
|
+
throw new Error(
|
|
1212
|
+
"describe: captureTree requires preserveUids \u2014 the captured tree carries its grouping as hashed UID tags"
|
|
1213
|
+
);
|
|
1214
|
+
}
|
|
1215
|
+
return describeTree(dir, options, keepKeywords);
|
|
1216
|
+
}
|
|
958
1217
|
const uidMap = /* @__PURE__ */ new Map();
|
|
959
1218
|
const useRecords = keepKeywords.length > 0 || preserveUids;
|
|
960
1219
|
const tolerance = options.sizeTolerance ?? 0;
|
|
@@ -964,21 +1223,19 @@ function describeDirectory(dir, options = {}) {
|
|
|
964
1223
|
);
|
|
965
1224
|
}
|
|
966
1225
|
const studies = /* @__PURE__ */ new Map();
|
|
967
|
-
const stats = {
|
|
1226
|
+
const stats = {
|
|
1227
|
+
dicomFiles: 0,
|
|
1228
|
+
skipped: 0,
|
|
1229
|
+
unknownModality: 0,
|
|
1230
|
+
nonDicomFiles: 0
|
|
1231
|
+
};
|
|
968
1232
|
for (const path of [...walkFiles(dir)].sort()) {
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
size = statSync(path).size;
|
|
972
|
-
} catch {
|
|
973
|
-
stats.skipped++;
|
|
974
|
-
continue;
|
|
975
|
-
}
|
|
976
|
-
if (size > MAX_STREAM_BYTES) {
|
|
1233
|
+
const scanned = scanFile(path);
|
|
1234
|
+
if (!scanned) {
|
|
977
1235
|
stats.skipped++;
|
|
978
1236
|
continue;
|
|
979
1237
|
}
|
|
980
|
-
const large
|
|
981
|
-
const record = large ? readHeaderOnly(path) : parseFull(path);
|
|
1238
|
+
const { size, large, record } = scanned;
|
|
982
1239
|
const studyUid = record?.StudyInstanceUID;
|
|
983
1240
|
const seriesUid = record?.SeriesInstanceUID;
|
|
984
1241
|
if (!record || typeof studyUid !== "string" || typeof seriesUid !== "string") {
|
|
@@ -986,14 +1243,9 @@ function describeDirectory(dir, options = {}) {
|
|
|
986
1243
|
continue;
|
|
987
1244
|
}
|
|
988
1245
|
stats.dicomFiles++;
|
|
989
|
-
|
|
990
|
-
if (
|
|
991
|
-
|
|
992
|
-
modality = record.Modality;
|
|
993
|
-
} else {
|
|
994
|
-
stats.unknownModality++;
|
|
995
|
-
}
|
|
996
|
-
}
|
|
1246
|
+
const preset = presetModalityOf(record);
|
|
1247
|
+
if (preset === "unsupported") stats.unknownModality++;
|
|
1248
|
+
const modality = preset === "unsupported" ? void 0 : preset;
|
|
997
1249
|
const keptTags = keepKeywords.length ? extractTags(record, keepKeywords) : void 0;
|
|
998
1250
|
const uidOriginals = preserveUids ? collectUidOriginals(record) : void 0;
|
|
999
1251
|
const bytes = large ? size : Math.min(bucketBytes(size, tolerance), MAX_PIXEL_BYTES);
|
|
@@ -1027,25 +1279,11 @@ function describeDirectory(dir, options = {}) {
|
|
|
1027
1279
|
for (const series of study.values()) {
|
|
1028
1280
|
if (!Array.isArray(series)) continue;
|
|
1029
1281
|
for (const rec of series) {
|
|
1030
|
-
|
|
1031
|
-
if (rec.tags) {
|
|
1032
|
-
for (const keyword of Object.keys(rec.uidOriginals)) {
|
|
1033
|
-
if (keyword in rec.tags) sweptKeepTags.add(keyword);
|
|
1034
|
-
}
|
|
1035
|
-
}
|
|
1036
|
-
rec.tags = mergeTags(
|
|
1037
|
-
rec.tags,
|
|
1038
|
-
hashUidTags(rec.uidOriginals, salt, uidMap)
|
|
1039
|
-
);
|
|
1040
|
-
}
|
|
1282
|
+
hashRecordUids(rec, salt, uidMap, sweptKeepTags);
|
|
1041
1283
|
}
|
|
1042
1284
|
}
|
|
1043
1285
|
}
|
|
1044
|
-
|
|
1045
|
-
console.warn(
|
|
1046
|
-
`describe: keep-tag "${keyword}" contains UID references \u2014 kept as its hashed UID skeleton, not verbatim (disable with preserveUids: false)`
|
|
1047
|
-
);
|
|
1048
|
-
}
|
|
1286
|
+
warnSweptKeepTags(sweptKeepTags);
|
|
1049
1287
|
}
|
|
1050
1288
|
const withGroupUid = (spec2, seriesUid) => salt === void 0 ? spec2 : {
|
|
1051
1289
|
...spec2,
|