dicom-synth 1.14.0 → 1.16.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 +167 -45
- package/dist/esm/describe/describe.js +314 -40
- package/dist/esm/index.js +473 -89
- package/dist/esm/schema/parametric.js +44 -2
- package/dist/esm/schema/validate.js +154 -4
- package/dist/esm/syntheticFixtures/generator.js +20 -4
- package/dist/esm/syntheticFixtures/streamWrite.js +10 -0
- package/dist/types/describe/describe.d.ts +2 -0
- package/dist/types/index.d.ts +1 -1
- package/dist/types/schema/types.d.ts +13 -0
- package/dist/types/schema/validate.d.ts +3 -1
- package/dist/types/syntheticFixtures/generator.d.ts +3 -1
- package/package.json +1 -1
package/dist/esm/index.js
CHANGED
|
@@ -8,47 +8,6 @@ import { dirname, resolve as resolve3 } from "node:path";
|
|
|
8
8
|
var MAX_PIXEL_BYTES = 512 * 1024 * 1024;
|
|
9
9
|
var MAX_STREAM_BYTES = 50 * 1024 * 1024 * 1024;
|
|
10
10
|
|
|
11
|
-
// src/loadDcmjs.ts
|
|
12
|
-
import dcmjsDefaultImport, * as dcmjsNamespace from "dcmjs";
|
|
13
|
-
function loadDcmjs() {
|
|
14
|
-
if (dcmjsNamespace.data?.DicomDict) {
|
|
15
|
-
return dcmjsNamespace;
|
|
16
|
-
}
|
|
17
|
-
const d = dcmjsDefaultImport;
|
|
18
|
-
if (d?.data?.DicomDict) {
|
|
19
|
-
return d;
|
|
20
|
-
}
|
|
21
|
-
if (d?.default?.data?.DicomDict) {
|
|
22
|
-
return d.default;
|
|
23
|
-
}
|
|
24
|
-
throw new Error(
|
|
25
|
-
"dcmjs failed to load (missing data.DicomDict). Install dcmjs >= 0.29."
|
|
26
|
-
);
|
|
27
|
-
}
|
|
28
|
-
var dcmjs = loadDcmjs();
|
|
29
|
-
|
|
30
|
-
// src/nonStandardDicom/dicomdir.ts
|
|
31
|
-
import { mkdirSync, writeFileSync } from "node:fs";
|
|
32
|
-
import { resolve } from "node:path";
|
|
33
|
-
var DICOMDIR_SOP_CLASS_UID = "1.2.840.10008.1.3.10";
|
|
34
|
-
function buildDicomdirBuffer() {
|
|
35
|
-
const uid = DICOMDIR_SOP_CLASS_UID;
|
|
36
|
-
const buf = Buffer.alloc(256, 0);
|
|
37
|
-
buf.write("DICM", 128, 4, "ascii");
|
|
38
|
-
let off = 132;
|
|
39
|
-
buf.writeUInt16LE(2, off);
|
|
40
|
-
off += 2;
|
|
41
|
-
buf.writeUInt16LE(2, off);
|
|
42
|
-
off += 2;
|
|
43
|
-
buf.write("UI", off, 2, "ascii");
|
|
44
|
-
off += 2;
|
|
45
|
-
buf.writeUInt16LE(uid.length, off);
|
|
46
|
-
off += 2;
|
|
47
|
-
buf.write(uid, off, uid.length, "ascii");
|
|
48
|
-
off += uid.length;
|
|
49
|
-
return buf.subarray(0, off);
|
|
50
|
-
}
|
|
51
|
-
|
|
52
11
|
// src/syntheticFixtures/tagTemplate.ts
|
|
53
12
|
var TEMPLATE_VOCAB = [
|
|
54
13
|
"index",
|
|
@@ -103,6 +62,9 @@ var TYPE_CAPABILITIES = {
|
|
|
103
62
|
"non-dicom": { image: false },
|
|
104
63
|
dicomdir: { image: false }
|
|
105
64
|
};
|
|
65
|
+
function isImageType(type) {
|
|
66
|
+
return TYPE_CAPABILITIES[type].image;
|
|
67
|
+
}
|
|
106
68
|
var VALID_MODALITIES = {
|
|
107
69
|
CT: true,
|
|
108
70
|
PT: true,
|
|
@@ -161,11 +123,14 @@ function validateUidSalt(value, path) {
|
|
|
161
123
|
);
|
|
162
124
|
}
|
|
163
125
|
}
|
|
164
|
-
function
|
|
165
|
-
if (
|
|
126
|
+
function validateInMemoryByteLimit(bytes, path) {
|
|
127
|
+
if (bytes > MAX_PIXEL_BYTES) {
|
|
166
128
|
throw new Error(`${path}: exceeds the 512 MB limit`);
|
|
167
129
|
}
|
|
168
130
|
}
|
|
131
|
+
function validateSizeKbLimit(kb, path) {
|
|
132
|
+
validateInMemoryByteLimit(kb * 1024, path);
|
|
133
|
+
}
|
|
169
134
|
function validateLargeTargetBytes(value, path) {
|
|
170
135
|
if (typeof value !== "number" || !Number.isInteger(value)) {
|
|
171
136
|
throw new Error(`${path}: must be an integer; got ${JSON.stringify(value)}`);
|
|
@@ -203,6 +168,29 @@ function validateLargeImageEntry(e, path) {
|
|
|
203
168
|
}
|
|
204
169
|
if ("tags" in e) validateTags(e.tags, `${path}.tags`);
|
|
205
170
|
}
|
|
171
|
+
function validateNonDicomEntry(e, path) {
|
|
172
|
+
const sizing = ["content", "targetSizeKb", "targetBytes"].filter(
|
|
173
|
+
(f) => f in e
|
|
174
|
+
);
|
|
175
|
+
if (sizing.length > 1) {
|
|
176
|
+
throw new Error(
|
|
177
|
+
`${path}: "content", "targetSizeKb" and "targetBytes" are mutually exclusive`
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
if ("content" in e && typeof e.content !== "string") {
|
|
181
|
+
throw new Error(
|
|
182
|
+
`${path}.content: must be a string; got ${JSON.stringify(e.content)}`
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
if ("targetSizeKb" in e) {
|
|
186
|
+
validatePositiveInt(e.targetSizeKb, `${path}.targetSizeKb`);
|
|
187
|
+
validateSizeKbLimit(e.targetSizeKb, `${path}.targetSizeKb`);
|
|
188
|
+
}
|
|
189
|
+
if ("targetBytes" in e) {
|
|
190
|
+
validatePositiveInt(e.targetBytes, `${path}.targetBytes`);
|
|
191
|
+
validateInMemoryByteLimit(e.targetBytes, `${path}.targetBytes`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
206
194
|
function validateEnum(value, valid, path) {
|
|
207
195
|
if (typeof value !== "string" || !Object.hasOwn(valid, value)) {
|
|
208
196
|
throw new Error(
|
|
@@ -230,6 +218,22 @@ function validateEntry(entry, path) {
|
|
|
230
218
|
validateLargeImageEntry(e, path);
|
|
231
219
|
return e;
|
|
232
220
|
}
|
|
221
|
+
if (type === "non-dicom") {
|
|
222
|
+
for (const field of [
|
|
223
|
+
"modality",
|
|
224
|
+
"rows",
|
|
225
|
+
"columns",
|
|
226
|
+
"frames",
|
|
227
|
+
"tags",
|
|
228
|
+
"violations",
|
|
229
|
+
"transferSyntax"
|
|
230
|
+
]) {
|
|
231
|
+
if (field in e)
|
|
232
|
+
throw new Error(`${path}.${field}: not supported for type "${type}"`);
|
|
233
|
+
}
|
|
234
|
+
validateNonDicomEntry(e, path);
|
|
235
|
+
return e;
|
|
236
|
+
}
|
|
233
237
|
if ("targetBytes" in e) {
|
|
234
238
|
throw new Error(
|
|
235
239
|
`${path}.targetBytes: only supported for type "large-image"`
|
|
@@ -437,6 +441,101 @@ function validateStudy(study, path) {
|
|
|
437
441
|
if ("tags" in st) validateTags(st.tags, `${path}.tags`);
|
|
438
442
|
return st;
|
|
439
443
|
}
|
|
444
|
+
function positionalName(prefix, ordinal) {
|
|
445
|
+
return `${prefix}-${String(ordinal).padStart(3, "0")}`;
|
|
446
|
+
}
|
|
447
|
+
var VALID_TREE_ROLES = {
|
|
448
|
+
study: true,
|
|
449
|
+
series: true
|
|
450
|
+
};
|
|
451
|
+
function validateNodeName(value, path) {
|
|
452
|
+
if (typeof value !== "string" || value.length === 0 || value === "." || value === ".." || /[/\\]/.test(value)) {
|
|
453
|
+
throw new Error(
|
|
454
|
+
`${path}: must be a non-empty name without path separators; got ${JSON.stringify(value)}`
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
function validateTreeNode(node, path, inStudy, inSeries) {
|
|
459
|
+
if (typeof node !== "object" || node === null || Array.isArray(node)) {
|
|
460
|
+
throw new Error(`${path}: must be an object`);
|
|
461
|
+
}
|
|
462
|
+
const n = node;
|
|
463
|
+
if ("children" in n) {
|
|
464
|
+
if ("type" in n) {
|
|
465
|
+
throw new Error(
|
|
466
|
+
`${path}: a node cannot have both "children" (directory) and "type" (file)`
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
if ("name" in n) validateNodeName(n.name, `${path}.name`);
|
|
470
|
+
if ("tags" in n) validateTags(n.tags, `${path}.tags`);
|
|
471
|
+
let childStudy = inStudy;
|
|
472
|
+
let childSeries = inSeries;
|
|
473
|
+
if ("role" in n) {
|
|
474
|
+
validateEnum(n.role, VALID_TREE_ROLES, `${path}.role`);
|
|
475
|
+
if (n.role === "study") {
|
|
476
|
+
if (inStudy) {
|
|
477
|
+
throw new Error(
|
|
478
|
+
`${path}.role: a study directory cannot nest inside another study`
|
|
479
|
+
);
|
|
480
|
+
}
|
|
481
|
+
childStudy = true;
|
|
482
|
+
} else {
|
|
483
|
+
if (!inStudy) {
|
|
484
|
+
throw new Error(
|
|
485
|
+
`${path}.role: a series directory requires an enclosing study-role directory`
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
if (inSeries) {
|
|
489
|
+
throw new Error(
|
|
490
|
+
`${path}.role: a series directory cannot nest inside another series`
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
childSeries = true;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
if (!Array.isArray(n.children) || n.children.length === 0) {
|
|
497
|
+
throw new Error(`${path}.children: must be a non-empty array`);
|
|
498
|
+
}
|
|
499
|
+
validateTreeChildren(
|
|
500
|
+
n.children,
|
|
501
|
+
`${path}.children`,
|
|
502
|
+
childStudy,
|
|
503
|
+
childSeries
|
|
504
|
+
);
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
const e = validateEntry(node, path);
|
|
508
|
+
if ("name" in n) {
|
|
509
|
+
validateNodeName(n.name, `${path}.name`);
|
|
510
|
+
if ((e.count ?? 1) > 1) {
|
|
511
|
+
throw new Error(
|
|
512
|
+
`${path}: "name" cannot be combined with count > 1 \u2014 the copies would collide on one path`
|
|
513
|
+
);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
function validateTreeChildren(children, basePath, inStudy, inSeries) {
|
|
518
|
+
const claimed = /* @__PURE__ */ new Map();
|
|
519
|
+
let dirOrdinal = 0;
|
|
520
|
+
children.forEach((child, i) => {
|
|
521
|
+
const path = `${basePath}[${i}]`;
|
|
522
|
+
validateTreeNode(child, path, inStudy, inSeries);
|
|
523
|
+
const n = child;
|
|
524
|
+
let name = "name" in n ? n.name : void 0;
|
|
525
|
+
if ("children" in n) {
|
|
526
|
+
dirOrdinal++;
|
|
527
|
+
name ?? (name = positionalName("dir", dirOrdinal));
|
|
528
|
+
}
|
|
529
|
+
if (name === void 0) return;
|
|
530
|
+
const prior = claimed.get(name);
|
|
531
|
+
if (prior !== void 0) {
|
|
532
|
+
throw new Error(
|
|
533
|
+
`${path}: name "${name}" collides with ${prior} \u2014 siblings must resolve to distinct paths`
|
|
534
|
+
);
|
|
535
|
+
}
|
|
536
|
+
claimed.set(name, path);
|
|
537
|
+
});
|
|
538
|
+
}
|
|
440
539
|
function validateDatasetSpec(raw) {
|
|
441
540
|
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
442
541
|
throw new Error("DatasetSpec: must be a JSON object");
|
|
@@ -448,11 +547,15 @@ function validateDatasetSpec(raw) {
|
|
|
448
547
|
if ("studies" in r && !Array.isArray(r.studies)) {
|
|
449
548
|
throw new Error("DatasetSpec.studies: must be an array");
|
|
450
549
|
}
|
|
550
|
+
if ("tree" in r && !Array.isArray(r.tree)) {
|
|
551
|
+
throw new Error("DatasetSpec.tree: must be an array");
|
|
552
|
+
}
|
|
451
553
|
const rawEntries = r.entries ?? [];
|
|
452
554
|
const rawStudies = r.studies ?? [];
|
|
453
|
-
|
|
555
|
+
const rawTree = r.tree ?? [];
|
|
556
|
+
if (rawEntries.length === 0 && rawStudies.length === 0 && rawTree.length === 0) {
|
|
454
557
|
throw new Error(
|
|
455
|
-
'DatasetSpec: must contain at least one of "entries" or "
|
|
558
|
+
'DatasetSpec: must contain at least one of "entries", "studies" or "tree" (non-empty)'
|
|
456
559
|
);
|
|
457
560
|
}
|
|
458
561
|
const entries = rawEntries.map(
|
|
@@ -461,6 +564,9 @@ function validateDatasetSpec(raw) {
|
|
|
461
564
|
const studies = rawStudies.map(
|
|
462
565
|
(study, i) => validateStudy(study, `studies[${i}]`)
|
|
463
566
|
);
|
|
567
|
+
if (rawTree.length > 0) {
|
|
568
|
+
validateTreeChildren(rawTree, "tree", false, false);
|
|
569
|
+
}
|
|
464
570
|
if ("seed" in r) validateSeed(r.seed, "DatasetSpec.seed");
|
|
465
571
|
if ("uidSalt" in r) validateUidSalt(r.uidSalt, "DatasetSpec.uidSalt");
|
|
466
572
|
if ("layout" in r) {
|
|
@@ -477,6 +583,7 @@ function validateDatasetSpec(raw) {
|
|
|
477
583
|
return {
|
|
478
584
|
...entries.length > 0 ? { entries } : {},
|
|
479
585
|
...studies.length > 0 ? { studies } : {},
|
|
586
|
+
...rawTree.length > 0 ? { tree: r.tree } : {},
|
|
480
587
|
...r.seed !== void 0 ? { seed: r.seed } : {},
|
|
481
588
|
...r.uidSalt !== void 0 ? { uidSalt: r.uidSalt } : {},
|
|
482
589
|
...r.layout !== void 0 ? { layout: r.layout } : {},
|
|
@@ -607,6 +714,47 @@ function validateParametricSpec(raw) {
|
|
|
607
714
|
};
|
|
608
715
|
}
|
|
609
716
|
|
|
717
|
+
// src/loadDcmjs.ts
|
|
718
|
+
import dcmjsDefaultImport, * as dcmjsNamespace from "dcmjs";
|
|
719
|
+
function loadDcmjs() {
|
|
720
|
+
if (dcmjsNamespace.data?.DicomDict) {
|
|
721
|
+
return dcmjsNamespace;
|
|
722
|
+
}
|
|
723
|
+
const d = dcmjsDefaultImport;
|
|
724
|
+
if (d?.data?.DicomDict) {
|
|
725
|
+
return d;
|
|
726
|
+
}
|
|
727
|
+
if (d?.default?.data?.DicomDict) {
|
|
728
|
+
return d.default;
|
|
729
|
+
}
|
|
730
|
+
throw new Error(
|
|
731
|
+
"dcmjs failed to load (missing data.DicomDict). Install dcmjs >= 0.29."
|
|
732
|
+
);
|
|
733
|
+
}
|
|
734
|
+
var dcmjs = loadDcmjs();
|
|
735
|
+
|
|
736
|
+
// src/nonStandardDicom/dicomdir.ts
|
|
737
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
738
|
+
import { resolve } from "node:path";
|
|
739
|
+
var DICOMDIR_SOP_CLASS_UID = "1.2.840.10008.1.3.10";
|
|
740
|
+
function buildDicomdirBuffer() {
|
|
741
|
+
const uid = DICOMDIR_SOP_CLASS_UID;
|
|
742
|
+
const buf = Buffer.alloc(256, 0);
|
|
743
|
+
buf.write("DICM", 128, 4, "ascii");
|
|
744
|
+
let off = 132;
|
|
745
|
+
buf.writeUInt16LE(2, off);
|
|
746
|
+
off += 2;
|
|
747
|
+
buf.writeUInt16LE(2, off);
|
|
748
|
+
off += 2;
|
|
749
|
+
buf.write("UI", off, 2, "ascii");
|
|
750
|
+
off += 2;
|
|
751
|
+
buf.writeUInt16LE(uid.length, off);
|
|
752
|
+
off += 2;
|
|
753
|
+
buf.write(uid, off, uid.length, "ascii");
|
|
754
|
+
off += uid.length;
|
|
755
|
+
return buf.subarray(0, off);
|
|
756
|
+
}
|
|
757
|
+
|
|
610
758
|
// src/syntheticFixtures/generator.ts
|
|
611
759
|
var MODALITY_PRESETS = {
|
|
612
760
|
CT: {
|
|
@@ -669,6 +817,14 @@ function applyTagOverrides(dataset, tags) {
|
|
|
669
817
|
}
|
|
670
818
|
}
|
|
671
819
|
}
|
|
820
|
+
function syncMetaWithDataset(meta, dataset) {
|
|
821
|
+
if (typeof dataset.SOPInstanceUID === "string") {
|
|
822
|
+
meta.MediaStorageSOPInstanceUID = dataset.SOPInstanceUID;
|
|
823
|
+
}
|
|
824
|
+
if (typeof dataset.SOPClassUID === "string") {
|
|
825
|
+
meta.MediaStorageSOPClassUID = dataset.SOPClassUID;
|
|
826
|
+
}
|
|
827
|
+
}
|
|
672
828
|
function buildMeta(uid, modality, transferSyntax = "explicit-vr-little-endian") {
|
|
673
829
|
return {
|
|
674
830
|
FileMetaInformationVersion: new Uint8Array([0, 1]).buffer,
|
|
@@ -755,6 +911,7 @@ function buildImageBuffer(spec, uid) {
|
|
|
755
911
|
}
|
|
756
912
|
applyTagOverrides(dataset, spec.tags);
|
|
757
913
|
const meta = buildMeta(effectiveUid, modality, spec.transferSyntax);
|
|
914
|
+
syncMetaWithDataset(meta, dataset);
|
|
758
915
|
applySizing(dataset, meta, spec);
|
|
759
916
|
return serializeDict(meta, dataset);
|
|
760
917
|
}
|
|
@@ -763,8 +920,13 @@ function buildFakeSignatureBuffer() {
|
|
|
763
920
|
buf.write("XXXX", 128, 4, "ascii");
|
|
764
921
|
return buf;
|
|
765
922
|
}
|
|
766
|
-
function
|
|
767
|
-
return
|
|
923
|
+
function sizedNonDicomBytes(spec) {
|
|
924
|
+
return spec.targetBytes ?? (spec.targetSizeKb !== void 0 ? spec.targetSizeKb * 1024 : void 0);
|
|
925
|
+
}
|
|
926
|
+
function buildNonDicomBuffer(spec) {
|
|
927
|
+
const bytes = sizedNonDicomBytes(spec);
|
|
928
|
+
if (bytes !== void 0) return Buffer.alloc(bytes, "not dicom ");
|
|
929
|
+
return Buffer.from(spec.content ?? "not dicom", "utf8");
|
|
768
930
|
}
|
|
769
931
|
function buildBufferForSpec(spec, uid) {
|
|
770
932
|
switch (spec.type) {
|
|
@@ -775,7 +937,7 @@ function buildBufferForSpec(spec, uid) {
|
|
|
775
937
|
case "fake-signature":
|
|
776
938
|
return buildFakeSignatureBuffer();
|
|
777
939
|
case "non-dicom":
|
|
778
|
-
return buildNonDicomBuffer(spec
|
|
940
|
+
return buildNonDicomBuffer(spec);
|
|
779
941
|
case "dicomdir":
|
|
780
942
|
return buildDicomdirBuffer();
|
|
781
943
|
case "large-image":
|
|
@@ -870,6 +1032,7 @@ function writeNative(outPath, params, dims, zeroChunk) {
|
|
|
870
1032
|
const meta = buildMeta(uid, modality);
|
|
871
1033
|
const dataset = buildBaseImageDataset(uid, modality);
|
|
872
1034
|
applyTagOverrides(dataset, tags);
|
|
1035
|
+
syncMetaWithDataset(meta, dataset);
|
|
873
1036
|
dataset.Rows = dims.rows;
|
|
874
1037
|
dataset.Columns = dims.columns;
|
|
875
1038
|
const minimalPixelBytes = dataset.PixelData.byteLength;
|
|
@@ -903,6 +1066,7 @@ function writeEncapsulated(outPath, params, pixelBytes, fragmentBytes, zeroChunk
|
|
|
903
1066
|
};
|
|
904
1067
|
const dataset = buildBaseImageDataset(uid, modality);
|
|
905
1068
|
applyTagOverrides(dataset, tags);
|
|
1069
|
+
syncMetaWithDataset(meta, dataset);
|
|
906
1070
|
dataset.PixelData = [new Uint8Array([]).buffer, new Uint8Array([0, 0]).buffer];
|
|
907
1071
|
dataset._vrMap = { PixelData: "OB" };
|
|
908
1072
|
const probe = serializeDict(meta, dataset);
|
|
@@ -1118,6 +1282,15 @@ function studyFileCount(studies) {
|
|
|
1118
1282
|
0
|
|
1119
1283
|
);
|
|
1120
1284
|
}
|
|
1285
|
+
function isDirNode(node) {
|
|
1286
|
+
return "children" in node;
|
|
1287
|
+
}
|
|
1288
|
+
function treeFileCount(nodes) {
|
|
1289
|
+
return nodes.reduce(
|
|
1290
|
+
(sum, node) => sum + (isDirNode(node) ? treeFileCount(node.children) : node.count ?? 1),
|
|
1291
|
+
0
|
|
1292
|
+
);
|
|
1293
|
+
}
|
|
1121
1294
|
function* seriesFiles(series) {
|
|
1122
1295
|
if (series.instances) {
|
|
1123
1296
|
const inst = series.instances;
|
|
@@ -1214,6 +1387,9 @@ function applyPathQuirks(relativePath, quirks) {
|
|
|
1214
1387
|
}
|
|
1215
1388
|
return { relativePath: [...dirs, newLeaf].join("/"), filename: newLeaf };
|
|
1216
1389
|
}
|
|
1390
|
+
function decorateNames(names, quirks) {
|
|
1391
|
+
return quirks.length === 0 ? names : applyPathQuirks(names.relativePath, quirks);
|
|
1392
|
+
}
|
|
1217
1393
|
function hierarchicalName(studyOrdinal, seriesIndex, instanceNumber) {
|
|
1218
1394
|
const pad3 = (n) => String(n).padStart(3, "0");
|
|
1219
1395
|
const name = `${String(instanceNumber).padStart(5, "0")}.dcm`;
|
|
@@ -1240,12 +1416,78 @@ function computeNames(type, index, padWidth, grouped, quirks) {
|
|
|
1240
1416
|
const name = filename(type, index, padWidth);
|
|
1241
1417
|
names = { filename: name, relativePath: name };
|
|
1242
1418
|
}
|
|
1243
|
-
return
|
|
1419
|
+
return decorateNames(names, quirks);
|
|
1420
|
+
}
|
|
1421
|
+
function* planTree(nodes, scope, env) {
|
|
1422
|
+
let dirOrdinal = 0;
|
|
1423
|
+
for (const node of nodes) {
|
|
1424
|
+
if (isDirNode(node)) {
|
|
1425
|
+
dirOrdinal++;
|
|
1426
|
+
const dirName = node.name ?? positionalName("dir", dirOrdinal);
|
|
1427
|
+
const child = {
|
|
1428
|
+
...scope,
|
|
1429
|
+
dirs: [...scope.dirs, dirName],
|
|
1430
|
+
tags: { ...scope.tags, ...node.tags }
|
|
1431
|
+
};
|
|
1432
|
+
if (node.role === "study") {
|
|
1433
|
+
const ordinal = env.counters.study++;
|
|
1434
|
+
child.study = {
|
|
1435
|
+
ordinal,
|
|
1436
|
+
uid: env.groupUids.study(ordinal),
|
|
1437
|
+
seriesCount: 0
|
|
1438
|
+
};
|
|
1439
|
+
} else if (node.role === "series" && child.study) {
|
|
1440
|
+
const index = child.study.seriesCount++;
|
|
1441
|
+
child.series = {
|
|
1442
|
+
index,
|
|
1443
|
+
uid: env.groupUids.series(child.study.ordinal, index),
|
|
1444
|
+
instanceCount: 0
|
|
1445
|
+
};
|
|
1446
|
+
}
|
|
1447
|
+
yield* planTree(node.children, child, env);
|
|
1448
|
+
continue;
|
|
1449
|
+
}
|
|
1450
|
+
const { count = 1, name, ...fileSpec } = node;
|
|
1451
|
+
const image = isImageType(fileSpec.type);
|
|
1452
|
+
for (let i = 0; i < count; i++) {
|
|
1453
|
+
const index = env.counters.file++;
|
|
1454
|
+
const instanceNumber = image && scope.series ? ++scope.series.instanceCount : void 0;
|
|
1455
|
+
const leaf = name ?? filename(fileSpec.type, index, env.padWidth);
|
|
1456
|
+
const names = decorateNames(
|
|
1457
|
+
{ filename: leaf, relativePath: [...scope.dirs, leaf].join("/") },
|
|
1458
|
+
env.quirks
|
|
1459
|
+
);
|
|
1460
|
+
const tags = image ? {
|
|
1461
|
+
...instanceNumber !== void 0 ? { InstanceNumber: instanceNumber } : {},
|
|
1462
|
+
...scope.tags,
|
|
1463
|
+
..."tags" in fileSpec ? fileSpec.tags : void 0
|
|
1464
|
+
} : void 0;
|
|
1465
|
+
yield {
|
|
1466
|
+
fileSpec: tags ? { ...fileSpec, tags } : fileSpec,
|
|
1467
|
+
index,
|
|
1468
|
+
...image && scope.study ? {
|
|
1469
|
+
uidOverride: {
|
|
1470
|
+
study: scope.study.uid,
|
|
1471
|
+
...scope.series ? { series: scope.series.uid } : {}
|
|
1472
|
+
}
|
|
1473
|
+
} : {},
|
|
1474
|
+
context: {
|
|
1475
|
+
index,
|
|
1476
|
+
...scope.study ? { studyIndex: scope.study.ordinal } : {},
|
|
1477
|
+
...scope.series ? { seriesIndex: scope.series.index } : {},
|
|
1478
|
+
...instanceNumber !== void 0 ? { instanceNumber } : {}
|
|
1479
|
+
},
|
|
1480
|
+
filename: names.filename,
|
|
1481
|
+
relativePath: names.relativePath
|
|
1482
|
+
};
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1244
1485
|
}
|
|
1245
1486
|
function* planCollection(spec) {
|
|
1246
1487
|
const flatEntries = spec.entries ?? [];
|
|
1247
1488
|
const studies = spec.studies ?? [];
|
|
1248
|
-
const
|
|
1489
|
+
const tree = spec.tree ?? [];
|
|
1490
|
+
const totalFiles = entryCount(flatEntries) + studyFileCount(studies) + treeFileCount(tree);
|
|
1249
1491
|
const padWidth = String(totalFiles).length;
|
|
1250
1492
|
const hierarchical = spec.layout === "hierarchical";
|
|
1251
1493
|
const quirks = spec.pathQuirks ?? [];
|
|
@@ -1309,6 +1551,18 @@ function* planCollection(spec) {
|
|
|
1309
1551
|
studyOrdinal++;
|
|
1310
1552
|
}
|
|
1311
1553
|
}
|
|
1554
|
+
if (tree.length > 0) {
|
|
1555
|
+
yield* planTree(
|
|
1556
|
+
tree,
|
|
1557
|
+
{ dirs: [], tags: {} },
|
|
1558
|
+
{
|
|
1559
|
+
groupUids,
|
|
1560
|
+
padWidth,
|
|
1561
|
+
quirks,
|
|
1562
|
+
counters: { file: globalIndex, study: studyOrdinal }
|
|
1563
|
+
}
|
|
1564
|
+
);
|
|
1565
|
+
}
|
|
1312
1566
|
}
|
|
1313
1567
|
async function materialisePlan(plan, salt) {
|
|
1314
1568
|
const file = await generateFile(plan.fileSpec, {
|
|
@@ -1328,12 +1582,13 @@ async function* generateCollectionFromSpec(spec) {
|
|
|
1328
1582
|
async function* previewCollection(spec) {
|
|
1329
1583
|
const salt = effectiveSalt(spec);
|
|
1330
1584
|
for (const plan of planCollection(spec)) {
|
|
1331
|
-
|
|
1585
|
+
const knownBytes = plan.fileSpec.type === "large-image" ? plan.fileSpec.targetBytes : plan.fileSpec.type === "non-dicom" ? sizedNonDicomBytes(plan.fileSpec) : void 0;
|
|
1586
|
+
if (knownBytes !== void 0) {
|
|
1332
1587
|
yield {
|
|
1333
1588
|
relativePath: plan.relativePath,
|
|
1334
|
-
type:
|
|
1589
|
+
type: plan.fileSpec.type,
|
|
1335
1590
|
index: plan.index,
|
|
1336
|
-
approxBytes:
|
|
1591
|
+
approxBytes: knownBytes
|
|
1337
1592
|
};
|
|
1338
1593
|
} else {
|
|
1339
1594
|
const file = await materialisePlan(plan, salt);
|
|
@@ -1399,7 +1654,7 @@ import {
|
|
|
1399
1654
|
readSync,
|
|
1400
1655
|
statSync
|
|
1401
1656
|
} from "node:fs";
|
|
1402
|
-
import { join } from "node:path";
|
|
1657
|
+
import { basename, extname, join, relative, sep } from "node:path";
|
|
1403
1658
|
var dcmjsAny2 = dcmjs;
|
|
1404
1659
|
var HEX_TAG_RE2 = /^[0-9a-fA-F]{8}$/;
|
|
1405
1660
|
var SUPPORTED_MODALITIES = /* @__PURE__ */ new Set(["CT", "PT", "MR", "CR"]);
|
|
@@ -1663,6 +1918,37 @@ function mergeTags(a, b) {
|
|
|
1663
1918
|
if (!b) return a;
|
|
1664
1919
|
return { ...a, ...b };
|
|
1665
1920
|
}
|
|
1921
|
+
function scanFile(path) {
|
|
1922
|
+
let size;
|
|
1923
|
+
try {
|
|
1924
|
+
size = statSync(path).size;
|
|
1925
|
+
} catch {
|
|
1926
|
+
return null;
|
|
1927
|
+
}
|
|
1928
|
+
if (size > MAX_STREAM_BYTES) return null;
|
|
1929
|
+
const large = size > MAX_PIXEL_BYTES;
|
|
1930
|
+
return { size, large, record: large ? readHeaderOnly(path) : parseFull(path) };
|
|
1931
|
+
}
|
|
1932
|
+
function presetModalityOf(record) {
|
|
1933
|
+
if (typeof record.Modality !== "string") return void 0;
|
|
1934
|
+
return SUPPORTED_MODALITIES.has(record.Modality) ? record.Modality : "unsupported";
|
|
1935
|
+
}
|
|
1936
|
+
function hashRecordUids(rec, salt, uidMap, sweptKeepTags) {
|
|
1937
|
+
if (!rec.uidOriginals) return;
|
|
1938
|
+
if (rec.tags) {
|
|
1939
|
+
for (const keyword of Object.keys(rec.uidOriginals)) {
|
|
1940
|
+
if (keyword in rec.tags) sweptKeepTags.add(keyword);
|
|
1941
|
+
}
|
|
1942
|
+
}
|
|
1943
|
+
rec.tags = mergeTags(rec.tags, hashUidTags(rec.uidOriginals, salt, uidMap));
|
|
1944
|
+
}
|
|
1945
|
+
function warnSweptKeepTags(sweptKeepTags) {
|
|
1946
|
+
for (const keyword of sweptKeepTags) {
|
|
1947
|
+
console.warn(
|
|
1948
|
+
`describe: keep-tag "${keyword}" contains UID references \u2014 kept as its hashed UID skeleton, not verbatim (disable with preserveUids: false)`
|
|
1949
|
+
);
|
|
1950
|
+
}
|
|
1951
|
+
}
|
|
1666
1952
|
function sizeKbOf(bytes) {
|
|
1667
1953
|
return Math.max(1, Math.round(bytes / 1024));
|
|
1668
1954
|
}
|
|
@@ -1765,9 +2051,128 @@ function buildSeriesSpec(records) {
|
|
|
1765
2051
|
function seriesSpecFromAccum(accum) {
|
|
1766
2052
|
return Array.isArray(accum) ? buildSeriesSpec(accum) : { entries: [...accum.values()].map(entryOf) };
|
|
1767
2053
|
}
|
|
2054
|
+
function dirAccumFor(root, dirs) {
|
|
2055
|
+
let node = root;
|
|
2056
|
+
for (const name of dirs) {
|
|
2057
|
+
let child = node.dirs.get(name);
|
|
2058
|
+
if (!child) {
|
|
2059
|
+
child = { dirs: /* @__PURE__ */ new Map(), files: [] };
|
|
2060
|
+
node.dirs.set(name, child);
|
|
2061
|
+
}
|
|
2062
|
+
node = child;
|
|
2063
|
+
}
|
|
2064
|
+
return node;
|
|
2065
|
+
}
|
|
2066
|
+
var SAFE_EXTENSION_RE = /^\.[A-Za-z0-9]{1,8}$/;
|
|
2067
|
+
function safeExtension(path) {
|
|
2068
|
+
const ext = extname(basename(path));
|
|
2069
|
+
return SAFE_EXTENSION_RE.test(ext) ? ext : "";
|
|
2070
|
+
}
|
|
2071
|
+
function emitTree(acc) {
|
|
2072
|
+
const nodes = [];
|
|
2073
|
+
let otherOrdinal = 0;
|
|
2074
|
+
for (const leaf of acc.files) {
|
|
2075
|
+
if (leaf.kind === "other") {
|
|
2076
|
+
otherOrdinal++;
|
|
2077
|
+
const name = `${positionalName("file", otherOrdinal)}${leaf.ext}`;
|
|
2078
|
+
nodes.push(
|
|
2079
|
+
leaf.bytes > 0 ? { type: "non-dicom", name, targetBytes: leaf.bytes } : { type: "non-dicom", name, content: "" }
|
|
2080
|
+
);
|
|
2081
|
+
} else {
|
|
2082
|
+
nodes.push(entryOf({ ...leaf.rec, count: 1 }));
|
|
2083
|
+
}
|
|
2084
|
+
}
|
|
2085
|
+
for (const sub of acc.dirs.values()) {
|
|
2086
|
+
nodes.push({ children: emitTree(sub) });
|
|
2087
|
+
}
|
|
2088
|
+
return nodes;
|
|
2089
|
+
}
|
|
2090
|
+
function describeTree(dir, options, keepKeywords) {
|
|
2091
|
+
const root = { dirs: /* @__PURE__ */ new Map(), files: [] };
|
|
2092
|
+
const stats = {
|
|
2093
|
+
dicomFiles: 0,
|
|
2094
|
+
skipped: 0,
|
|
2095
|
+
unknownModality: 0,
|
|
2096
|
+
nonDicomFiles: 0
|
|
2097
|
+
};
|
|
2098
|
+
const studyUids = /* @__PURE__ */ new Set();
|
|
2099
|
+
const dicomLeaves = [];
|
|
2100
|
+
for (const path of [...walkFiles(dir)].sort()) {
|
|
2101
|
+
const scanned = scanFile(path);
|
|
2102
|
+
if (!scanned) {
|
|
2103
|
+
stats.skipped++;
|
|
2104
|
+
continue;
|
|
2105
|
+
}
|
|
2106
|
+
const { size, large, record } = scanned;
|
|
2107
|
+
const studyUid = record?.StudyInstanceUID;
|
|
2108
|
+
const seriesUid = record?.SeriesInstanceUID;
|
|
2109
|
+
const relDirs = relative(dir, path).split(sep).slice(0, -1);
|
|
2110
|
+
if (!record || typeof studyUid !== "string" || typeof seriesUid !== "string") {
|
|
2111
|
+
if (large) {
|
|
2112
|
+
stats.skipped++;
|
|
2113
|
+
continue;
|
|
2114
|
+
}
|
|
2115
|
+
stats.nonDicomFiles++;
|
|
2116
|
+
dirAccumFor(root, relDirs).files.push({
|
|
2117
|
+
kind: "other",
|
|
2118
|
+
bytes: size,
|
|
2119
|
+
ext: safeExtension(path)
|
|
2120
|
+
});
|
|
2121
|
+
continue;
|
|
2122
|
+
}
|
|
2123
|
+
stats.dicomFiles++;
|
|
2124
|
+
studyUids.add(studyUid);
|
|
2125
|
+
const preset = presetModalityOf(record);
|
|
2126
|
+
if (preset === "unsupported") stats.unknownModality++;
|
|
2127
|
+
const modality = preset === "unsupported" ? void 0 : preset;
|
|
2128
|
+
const instanceNumber = typeof record.InstanceNumber === "number" ? { InstanceNumber: record.InstanceNumber } : typeof record.InstanceNumber === "string" ? { InstanceNumber: escapeTagTemplate(record.InstanceNumber) } : void 0;
|
|
2129
|
+
const leaf = {
|
|
2130
|
+
kind: "dicom",
|
|
2131
|
+
rec: {
|
|
2132
|
+
large,
|
|
2133
|
+
bytes: size,
|
|
2134
|
+
modality,
|
|
2135
|
+
tags: mergeTags(
|
|
2136
|
+
instanceNumber,
|
|
2137
|
+
keepKeywords.length ? extractTags(record, keepKeywords) : void 0
|
|
2138
|
+
),
|
|
2139
|
+
uidOriginals: collectUidOriginals(record)
|
|
2140
|
+
},
|
|
2141
|
+
studyUid,
|
|
2142
|
+
seriesUid
|
|
2143
|
+
};
|
|
2144
|
+
dirAccumFor(root, relDirs).files.push(leaf);
|
|
2145
|
+
dicomLeaves.push(leaf);
|
|
2146
|
+
}
|
|
2147
|
+
if (root.files.length === 0 && root.dirs.size === 0) {
|
|
2148
|
+
throw new Error(`describe: no files found in ${dir}`);
|
|
2149
|
+
}
|
|
2150
|
+
const salt = options.uidSalt ?? deriveSalt([...studyUids]);
|
|
2151
|
+
const uidMap = /* @__PURE__ */ new Map();
|
|
2152
|
+
const sweptKeepTags = /* @__PURE__ */ new Set();
|
|
2153
|
+
for (const { rec, studyUid, seriesUid } of dicomLeaves) {
|
|
2154
|
+
hashRecordUids(rec, salt, uidMap, sweptKeepTags);
|
|
2155
|
+
rec.tags = mergeTags(rec.tags, {
|
|
2156
|
+
StudyInstanceUID: hashUidVia(uidMap, salt, studyUid),
|
|
2157
|
+
SeriesInstanceUID: hashUidVia(uidMap, salt, seriesUid)
|
|
2158
|
+
});
|
|
2159
|
+
}
|
|
2160
|
+
warnSweptKeepTags(sweptKeepTags);
|
|
2161
|
+
const spec = { tree: emitTree(root), uidSalt: salt };
|
|
2162
|
+
validateDatasetSpec(spec);
|
|
2163
|
+
return { spec, stats };
|
|
2164
|
+
}
|
|
1768
2165
|
function describeDirectory(dir, options = {}) {
|
|
1769
2166
|
const keepKeywords = options.keepTags ? resolveKeepTags(options.keepTags) : [];
|
|
1770
2167
|
const preserveUids = options.preserveUids ?? true;
|
|
2168
|
+
if (options.captureTree) {
|
|
2169
|
+
if (!preserveUids) {
|
|
2170
|
+
throw new Error(
|
|
2171
|
+
"describe: captureTree requires preserveUids \u2014 the captured tree carries its grouping as hashed UID tags"
|
|
2172
|
+
);
|
|
2173
|
+
}
|
|
2174
|
+
return describeTree(dir, options, keepKeywords);
|
|
2175
|
+
}
|
|
1771
2176
|
const uidMap = /* @__PURE__ */ new Map();
|
|
1772
2177
|
const useRecords = keepKeywords.length > 0 || preserveUids;
|
|
1773
2178
|
const tolerance = options.sizeTolerance ?? 0;
|
|
@@ -1777,21 +2182,19 @@ function describeDirectory(dir, options = {}) {
|
|
|
1777
2182
|
);
|
|
1778
2183
|
}
|
|
1779
2184
|
const studies = /* @__PURE__ */ new Map();
|
|
1780
|
-
const stats = {
|
|
2185
|
+
const stats = {
|
|
2186
|
+
dicomFiles: 0,
|
|
2187
|
+
skipped: 0,
|
|
2188
|
+
unknownModality: 0,
|
|
2189
|
+
nonDicomFiles: 0
|
|
2190
|
+
};
|
|
1781
2191
|
for (const path of [...walkFiles(dir)].sort()) {
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
size = statSync(path).size;
|
|
1785
|
-
} catch {
|
|
2192
|
+
const scanned = scanFile(path);
|
|
2193
|
+
if (!scanned) {
|
|
1786
2194
|
stats.skipped++;
|
|
1787
2195
|
continue;
|
|
1788
2196
|
}
|
|
1789
|
-
|
|
1790
|
-
stats.skipped++;
|
|
1791
|
-
continue;
|
|
1792
|
-
}
|
|
1793
|
-
const large = size > MAX_PIXEL_BYTES;
|
|
1794
|
-
const record = large ? readHeaderOnly(path) : parseFull(path);
|
|
2197
|
+
const { size, large, record } = scanned;
|
|
1795
2198
|
const studyUid = record?.StudyInstanceUID;
|
|
1796
2199
|
const seriesUid = record?.SeriesInstanceUID;
|
|
1797
2200
|
if (!record || typeof studyUid !== "string" || typeof seriesUid !== "string") {
|
|
@@ -1799,14 +2202,9 @@ function describeDirectory(dir, options = {}) {
|
|
|
1799
2202
|
continue;
|
|
1800
2203
|
}
|
|
1801
2204
|
stats.dicomFiles++;
|
|
1802
|
-
|
|
1803
|
-
if (
|
|
1804
|
-
|
|
1805
|
-
modality = record.Modality;
|
|
1806
|
-
} else {
|
|
1807
|
-
stats.unknownModality++;
|
|
1808
|
-
}
|
|
1809
|
-
}
|
|
2205
|
+
const preset = presetModalityOf(record);
|
|
2206
|
+
if (preset === "unsupported") stats.unknownModality++;
|
|
2207
|
+
const modality = preset === "unsupported" ? void 0 : preset;
|
|
1810
2208
|
const keptTags = keepKeywords.length ? extractTags(record, keepKeywords) : void 0;
|
|
1811
2209
|
const uidOriginals = preserveUids ? collectUidOriginals(record) : void 0;
|
|
1812
2210
|
const bytes = large ? size : Math.min(bucketBytes(size, tolerance), MAX_PIXEL_BYTES);
|
|
@@ -1840,25 +2238,11 @@ function describeDirectory(dir, options = {}) {
|
|
|
1840
2238
|
for (const series of study.values()) {
|
|
1841
2239
|
if (!Array.isArray(series)) continue;
|
|
1842
2240
|
for (const rec of series) {
|
|
1843
|
-
|
|
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
|
-
}
|
|
2241
|
+
hashRecordUids(rec, salt, uidMap, sweptKeepTags);
|
|
1854
2242
|
}
|
|
1855
2243
|
}
|
|
1856
2244
|
}
|
|
1857
|
-
|
|
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
|
-
}
|
|
2245
|
+
warnSweptKeepTags(sweptKeepTags);
|
|
1862
2246
|
}
|
|
1863
2247
|
const withGroupUid = (spec2, seriesUid) => salt === void 0 ? spec2 : {
|
|
1864
2248
|
...spec2,
|