dicom-synth 1.7.0 → 1.9.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/README.md +17 -601
- package/bin/dicom-synth-generate.mjs +106 -12
- package/dist/esm/collection/writer.js +272 -39
- package/dist/esm/describe/describe.js +159 -29
- package/dist/esm/index.js +526 -76
- package/dist/esm/schema/limits.js +7 -0
- package/dist/esm/schema/parametric.js +123 -6
- package/dist/esm/schema/validate.js +93 -6
- package/dist/esm/syntheticFixtures/generator.js +13 -2
- package/dist/esm/syntheticFixtures/streamWrite.js +356 -0
- package/dist/types/collection/writer.d.ts +8 -0
- package/dist/types/describe/describe.d.ts +1 -0
- package/dist/types/index.d.ts +4 -2
- package/dist/types/schema/limits.d.ts +2 -0
- package/dist/types/schema/types.d.ts +10 -2
- package/dist/types/syntheticFixtures/generator.d.ts +5 -1
- package/dist/types/syntheticFixtures/streamWrite.d.ts +49 -0
- package/package.json +1 -1
package/dist/esm/index.js
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
// src/collection/writer.ts
|
|
2
|
-
import {
|
|
2
|
+
import { randomBytes as randomBytes2 } from "node:crypto";
|
|
3
|
+
import { mkdirSync as mkdirSync3 } from "node:fs";
|
|
3
4
|
import { writeFile } from "node:fs/promises";
|
|
4
|
-
import { dirname, resolve as
|
|
5
|
+
import { dirname, resolve as resolve3 } from "node:path";
|
|
6
|
+
|
|
7
|
+
// src/schema/limits.ts
|
|
8
|
+
var MAX_PIXEL_BYTES = 512 * 1024 * 1024;
|
|
9
|
+
var MAX_STREAM_BYTES = 50 * 1024 * 1024 * 1024;
|
|
5
10
|
|
|
6
11
|
// src/loadDcmjs.ts
|
|
7
12
|
import dcmjsDefaultImport, * as dcmjsNamespace from "dcmjs";
|
|
@@ -49,6 +54,7 @@ var TYPE_CAPABILITIES = {
|
|
|
49
54
|
"valid-image": { image: true },
|
|
50
55
|
"invalid-uid-image": { image: true },
|
|
51
56
|
"vendor-warnings-image": { image: true },
|
|
57
|
+
"large-image": { image: true },
|
|
52
58
|
"fake-signature": { image: false },
|
|
53
59
|
"non-dicom": { image: false },
|
|
54
60
|
dicomdir: { image: false }
|
|
@@ -73,7 +79,6 @@ var VALID_TRANSFER_SYNTAXES = {
|
|
|
73
79
|
"explicit-vr-little-endian": true,
|
|
74
80
|
"implicit-vr-little-endian": true
|
|
75
81
|
};
|
|
76
|
-
var MAX_PIXEL_BYTES = 512 * 1024 * 1024;
|
|
77
82
|
var VALID_VIOLATIONS = {
|
|
78
83
|
"uid-too-long": true,
|
|
79
84
|
"non-conformant-uid": true,
|
|
@@ -91,6 +96,13 @@ function validatePositiveInt(value, path) {
|
|
|
91
96
|
);
|
|
92
97
|
}
|
|
93
98
|
}
|
|
99
|
+
function validateNonNegativeInt(value, path) {
|
|
100
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < 0) {
|
|
101
|
+
throw new Error(
|
|
102
|
+
`${path}: must be a non-negative integer; got ${JSON.stringify(value)}`
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
94
106
|
function validateSeed(value, path) {
|
|
95
107
|
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
96
108
|
throw new Error(
|
|
@@ -103,6 +115,43 @@ function validateSizeKbLimit(kb, path) {
|
|
|
103
115
|
throw new Error(`${path}: exceeds the 512 MB limit`);
|
|
104
116
|
}
|
|
105
117
|
}
|
|
118
|
+
function validateLargeTargetBytes(value, path) {
|
|
119
|
+
if (typeof value !== "number" || !Number.isInteger(value)) {
|
|
120
|
+
throw new Error(`${path}: must be an integer; got ${JSON.stringify(value)}`);
|
|
121
|
+
}
|
|
122
|
+
if (value <= MAX_PIXEL_BYTES) {
|
|
123
|
+
throw new Error(
|
|
124
|
+
`${path}: must exceed 512 MB \u2014 use targetSizeKb for smaller files`
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
if (value > MAX_STREAM_BYTES) {
|
|
128
|
+
throw new Error(`${path}: exceeds the 50 GB limit`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
function validateLargeImageEntry(e, path) {
|
|
132
|
+
for (const field of [
|
|
133
|
+
"rows",
|
|
134
|
+
"columns",
|
|
135
|
+
"frames",
|
|
136
|
+
"targetSizeKb",
|
|
137
|
+
"violations",
|
|
138
|
+
"transferSyntax"
|
|
139
|
+
]) {
|
|
140
|
+
if (field in e) {
|
|
141
|
+
throw new Error(
|
|
142
|
+
`${path}.${field}: not supported for type "large-image" \u2014 use targetBytes`
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (!("targetBytes" in e)) {
|
|
147
|
+
throw new Error(`${path}.targetBytes: is required for type "large-image"`);
|
|
148
|
+
}
|
|
149
|
+
validateLargeTargetBytes(e.targetBytes, `${path}.targetBytes`);
|
|
150
|
+
if ("modality" in e) {
|
|
151
|
+
validateEnum(e.modality, VALID_MODALITIES, `${path}.modality`);
|
|
152
|
+
}
|
|
153
|
+
if ("tags" in e) validateTags(e.tags, `${path}.tags`);
|
|
154
|
+
}
|
|
106
155
|
function validateEnum(value, valid, path) {
|
|
107
156
|
if (typeof value !== "string" || !Object.hasOwn(valid, value)) {
|
|
108
157
|
throw new Error(
|
|
@@ -126,6 +175,15 @@ function validateEntry(entry, path) {
|
|
|
126
175
|
const type = e.type;
|
|
127
176
|
const caps = TYPE_CAPABILITIES[type];
|
|
128
177
|
if ("count" in e) validatePositiveInt(e.count, `${path}.count`);
|
|
178
|
+
if (type === "large-image") {
|
|
179
|
+
validateLargeImageEntry(e, path);
|
|
180
|
+
return e;
|
|
181
|
+
}
|
|
182
|
+
if ("targetBytes" in e) {
|
|
183
|
+
throw new Error(
|
|
184
|
+
`${path}.targetBytes: only supported for type "large-image"`
|
|
185
|
+
);
|
|
186
|
+
}
|
|
129
187
|
for (const field of [
|
|
130
188
|
"modality",
|
|
131
189
|
"rows",
|
|
@@ -271,19 +329,20 @@ function validateDatasetSpec(raw) {
|
|
|
271
329
|
...r.pathQuirks !== void 0 ? { pathQuirks: r.pathQuirks } : {}
|
|
272
330
|
};
|
|
273
331
|
}
|
|
274
|
-
function validateRange(value, path) {
|
|
332
|
+
function validateRange(value, path, options = {}) {
|
|
333
|
+
const checkInt = options.allowZero ? validateNonNegativeInt : validatePositiveInt;
|
|
275
334
|
if (typeof value === "number") {
|
|
276
|
-
|
|
335
|
+
checkInt(value, path);
|
|
277
336
|
return value;
|
|
278
337
|
}
|
|
279
338
|
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
280
339
|
throw new Error(
|
|
281
|
-
`${path}: must be
|
|
340
|
+
`${path}: must be an integer or { min, max }; got ${JSON.stringify(value)}`
|
|
282
341
|
);
|
|
283
342
|
}
|
|
284
343
|
const r = value;
|
|
285
|
-
|
|
286
|
-
|
|
344
|
+
checkInt(r.min, `${path}.min`);
|
|
345
|
+
checkInt(r.max, `${path}.max`);
|
|
287
346
|
if (r.min > r.max) {
|
|
288
347
|
throw new Error(`${path}: min (${r.min}) must be \u2264 max (${r.max})`);
|
|
289
348
|
}
|
|
@@ -292,6 +351,9 @@ function validateRange(value, path) {
|
|
|
292
351
|
function rangeMax(range) {
|
|
293
352
|
return typeof range === "number" ? range : range.max;
|
|
294
353
|
}
|
|
354
|
+
function rangeMin(range) {
|
|
355
|
+
return typeof range === "number" ? range : range.min;
|
|
356
|
+
}
|
|
295
357
|
function validateParametricStudies(studies, path) {
|
|
296
358
|
if (typeof studies !== "object" || studies === null || Array.isArray(studies)) {
|
|
297
359
|
throw new Error(`${path}: must be an object`);
|
|
@@ -305,6 +367,32 @@ function validateParametricStudies(studies, path) {
|
|
|
305
367
|
validateRange(s.fileSizeKb, `${path}.fileSizeKb`);
|
|
306
368
|
validateSizeKbLimit(rangeMax(s.fileSizeKb), `${path}.fileSizeKb`);
|
|
307
369
|
}
|
|
370
|
+
if ("largeFilesPerSeries" in s) {
|
|
371
|
+
validateRange(s.largeFilesPerSeries, `${path}.largeFilesPerSeries`, {
|
|
372
|
+
allowZero: true
|
|
373
|
+
});
|
|
374
|
+
if (!("largeFileBytes" in s)) {
|
|
375
|
+
throw new Error(
|
|
376
|
+
`${path}.largeFileBytes: is required when largeFilesPerSeries is set`
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
if ("largeFileBytes" in s) {
|
|
381
|
+
if (!("largeFilesPerSeries" in s)) {
|
|
382
|
+
throw new Error(
|
|
383
|
+
`${path}.largeFilesPerSeries: is required when largeFileBytes is set`
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
validateRange(s.largeFileBytes, `${path}.largeFileBytes`);
|
|
387
|
+
validateLargeTargetBytes(
|
|
388
|
+
rangeMin(s.largeFileBytes),
|
|
389
|
+
`${path}.largeFileBytes (min)`
|
|
390
|
+
);
|
|
391
|
+
validateLargeTargetBytes(
|
|
392
|
+
rangeMax(s.largeFileBytes),
|
|
393
|
+
`${path}.largeFileBytes (max)`
|
|
394
|
+
);
|
|
395
|
+
}
|
|
308
396
|
if ("modalities" in s) {
|
|
309
397
|
if (!Array.isArray(s.modalities) || s.modalities.length === 0) {
|
|
310
398
|
throw new Error(`${path}.modalities: must be a non-empty array`);
|
|
@@ -534,9 +622,17 @@ function buildBufferForSpec(spec, uid) {
|
|
|
534
622
|
return buildNonDicomBuffer(spec.content);
|
|
535
623
|
case "dicomdir":
|
|
536
624
|
return buildDicomdirBuffer();
|
|
625
|
+
case "large-image":
|
|
626
|
+
throw new Error(
|
|
627
|
+
"large-image cannot be generated in memory \u2014 use writeCollectionFromSpec (disk-only streaming)"
|
|
628
|
+
);
|
|
537
629
|
}
|
|
538
630
|
}
|
|
539
631
|
|
|
632
|
+
// src/syntheticFixtures/streamWrite.ts
|
|
633
|
+
import { closeSync, mkdirSync as mkdirSync2, openSync, writeSync } from "node:fs";
|
|
634
|
+
import { resolve as resolve2 } from "node:path";
|
|
635
|
+
|
|
540
636
|
// src/syntheticFixtures/uid.ts
|
|
541
637
|
import { randomBytes } from "node:crypto";
|
|
542
638
|
var ROLE_CODE = { study: 1, series: 2, sop: 3 };
|
|
@@ -603,6 +699,173 @@ function makeUidGenerator(seed) {
|
|
|
603
699
|
};
|
|
604
700
|
}
|
|
605
701
|
|
|
702
|
+
// src/syntheticFixtures/streamWrite.ts
|
|
703
|
+
var OW_MAX_BYTES = 4294967294;
|
|
704
|
+
var FRAGMENT_MAX_BYTES = 4294967294;
|
|
705
|
+
var DEFAULT_CHUNK_BYTES = 8 * 1024 * 1024;
|
|
706
|
+
var RLE_TRANSFER_SYNTAX_UID = "1.2.840.10008.1.2.5";
|
|
707
|
+
var PIXEL_DATA_OW_HEADER = Buffer.from([224, 127, 16, 0, 79, 87]);
|
|
708
|
+
var PIXEL_DATA_OB_HEADER = Buffer.from([224, 127, 16, 0, 79, 66]);
|
|
709
|
+
var ITEM_TAG_GROUP = 65534;
|
|
710
|
+
var ITEM_TAG_ELEMENT = 57344;
|
|
711
|
+
var SEQUENCE_DELIMITER_ELEMENT = 57565;
|
|
712
|
+
function itemTag(element, byteLength = 0) {
|
|
713
|
+
const b = Buffer.alloc(8);
|
|
714
|
+
b.writeUInt16LE(ITEM_TAG_GROUP, 0);
|
|
715
|
+
b.writeUInt16LE(element, 2);
|
|
716
|
+
b.writeUInt32LE(byteLength, 4);
|
|
717
|
+
return b;
|
|
718
|
+
}
|
|
719
|
+
function nativeDimensions(pixelBytes) {
|
|
720
|
+
const cells = Math.max(1, Math.floor(pixelBytes / 2));
|
|
721
|
+
const rows = Math.min(65535, Math.max(1, Math.round(Math.sqrt(cells))));
|
|
722
|
+
const columns = Math.min(65535, Math.max(1, Math.floor(cells / rows)));
|
|
723
|
+
return { rows, columns, bytes: rows * columns * 2 };
|
|
724
|
+
}
|
|
725
|
+
function streamZeros(fd, totalBytes, zeroChunk) {
|
|
726
|
+
let remaining = totalBytes;
|
|
727
|
+
while (remaining > 0) {
|
|
728
|
+
const n = Math.min(zeroChunk.length, remaining);
|
|
729
|
+
writeSync(fd, zeroChunk, 0, n);
|
|
730
|
+
remaining -= n;
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
function writeNative(outPath, params, dims, zeroChunk) {
|
|
734
|
+
const { modality, uid, tags } = params;
|
|
735
|
+
const meta = buildMeta(uid, modality);
|
|
736
|
+
const dataset = buildBaseImageDataset(uid, modality);
|
|
737
|
+
applyTagOverrides(dataset, tags);
|
|
738
|
+
dataset.Rows = dims.rows;
|
|
739
|
+
dataset.Columns = dims.columns;
|
|
740
|
+
const minimalPixelBytes = dataset.PixelData.byteLength;
|
|
741
|
+
const probe = serializeDict(meta, dataset);
|
|
742
|
+
const pixelElement = Buffer.alloc(12);
|
|
743
|
+
PIXEL_DATA_OW_HEADER.copy(pixelElement);
|
|
744
|
+
pixelElement.writeUInt32LE(minimalPixelBytes, 8);
|
|
745
|
+
const owIdx = probe.indexOf(pixelElement);
|
|
746
|
+
if (owIdx < 0) {
|
|
747
|
+
throw new Error("failed to locate native PixelData element header");
|
|
748
|
+
}
|
|
749
|
+
const dataStart = owIdx + 12;
|
|
750
|
+
const header = Buffer.from(probe.subarray(0, dataStart));
|
|
751
|
+
header.writeUInt32LE(dims.bytes, owIdx + 8);
|
|
752
|
+
const trailing = probe.subarray(dataStart + minimalPixelBytes);
|
|
753
|
+
const fd = openSync(outPath, "w");
|
|
754
|
+
try {
|
|
755
|
+
writeSync(fd, header);
|
|
756
|
+
streamZeros(fd, dims.bytes, zeroChunk);
|
|
757
|
+
if (trailing.length > 0) writeSync(fd, trailing);
|
|
758
|
+
} finally {
|
|
759
|
+
closeSync(fd);
|
|
760
|
+
}
|
|
761
|
+
return header.length + dims.bytes + trailing.length;
|
|
762
|
+
}
|
|
763
|
+
function writeEncapsulated(outPath, params, pixelBytes, fragmentBytes, zeroChunk) {
|
|
764
|
+
const { modality, uid, tags } = params;
|
|
765
|
+
const meta = {
|
|
766
|
+
...buildMeta(uid, modality),
|
|
767
|
+
TransferSyntaxUID: RLE_TRANSFER_SYNTAX_UID
|
|
768
|
+
};
|
|
769
|
+
const dataset = buildBaseImageDataset(uid, modality);
|
|
770
|
+
applyTagOverrides(dataset, tags);
|
|
771
|
+
dataset.PixelData = [new Uint8Array([]).buffer, new Uint8Array([0, 0]).buffer];
|
|
772
|
+
dataset._vrMap = { PixelData: "OB" };
|
|
773
|
+
const probe = serializeDict(meta, dataset);
|
|
774
|
+
const idx = probe.indexOf(PIXEL_DATA_OB_HEADER);
|
|
775
|
+
if (idx < 0 || probe.readUInt32LE(idx + 8) !== 4294967295) {
|
|
776
|
+
throw new Error("failed to locate encapsulated PixelData element header");
|
|
777
|
+
}
|
|
778
|
+
const prefix = Buffer.from(probe.subarray(0, idx + 12));
|
|
779
|
+
const fd = openSync(outPath, "w");
|
|
780
|
+
let bytesWritten = 0;
|
|
781
|
+
try {
|
|
782
|
+
writeSync(fd, prefix);
|
|
783
|
+
bytesWritten += prefix.length;
|
|
784
|
+
const bot = itemTag(ITEM_TAG_ELEMENT, 0);
|
|
785
|
+
writeSync(fd, bot);
|
|
786
|
+
bytesWritten += bot.length;
|
|
787
|
+
let remaining = pixelBytes;
|
|
788
|
+
while (remaining > 0) {
|
|
789
|
+
const fragLen = Math.min(fragmentBytes, remaining);
|
|
790
|
+
const fh = itemTag(ITEM_TAG_ELEMENT, fragLen);
|
|
791
|
+
writeSync(fd, fh);
|
|
792
|
+
streamZeros(fd, fragLen, zeroChunk);
|
|
793
|
+
bytesWritten += fh.length + fragLen;
|
|
794
|
+
remaining -= fragLen;
|
|
795
|
+
}
|
|
796
|
+
const delimiter = itemTag(SEQUENCE_DELIMITER_ELEMENT);
|
|
797
|
+
writeSync(fd, delimiter);
|
|
798
|
+
bytesWritten += delimiter.length;
|
|
799
|
+
} finally {
|
|
800
|
+
closeSync(fd);
|
|
801
|
+
}
|
|
802
|
+
return bytesWritten;
|
|
803
|
+
}
|
|
804
|
+
function streamLargeImage(outPath, options) {
|
|
805
|
+
const {
|
|
806
|
+
targetBytes,
|
|
807
|
+
chunkBytes = DEFAULT_CHUNK_BYTES,
|
|
808
|
+
owMaxBytes = OW_MAX_BYTES,
|
|
809
|
+
fragmentBytes = FRAGMENT_MAX_BYTES
|
|
810
|
+
} = options;
|
|
811
|
+
const modality = options.modality ?? "CT";
|
|
812
|
+
const uid = options.uid ?? makeUidGenerator(options.seed)(0);
|
|
813
|
+
const identity = { modality, uid, tags: options.tags };
|
|
814
|
+
mkdirSync2(resolve2(outPath, ".."), { recursive: true });
|
|
815
|
+
const zeroChunk = Buffer.alloc(Math.max(1, chunkBytes));
|
|
816
|
+
const probe = serializeDict(
|
|
817
|
+
buildMeta(uid, modality),
|
|
818
|
+
buildBaseImageDataset(uid, modality)
|
|
819
|
+
);
|
|
820
|
+
const nativeHeaderLen = probe.length - 2;
|
|
821
|
+
const requestedPixel = Math.max(2, targetBytes - nativeHeaderLen);
|
|
822
|
+
if (requestedPixel <= owMaxBytes) {
|
|
823
|
+
const dims = nativeDimensions(requestedPixel);
|
|
824
|
+
const bytesWritten2 = writeNative(outPath, identity, dims, zeroChunk);
|
|
825
|
+
return {
|
|
826
|
+
path: outPath,
|
|
827
|
+
bytesWritten: bytesWritten2,
|
|
828
|
+
encoding: "native",
|
|
829
|
+
pixelBytes: dims.bytes
|
|
830
|
+
};
|
|
831
|
+
}
|
|
832
|
+
const estimateFrags = Math.max(
|
|
833
|
+
1,
|
|
834
|
+
Math.ceil((targetBytes - nativeHeaderLen - 16) / fragmentBytes)
|
|
835
|
+
);
|
|
836
|
+
const overhead = nativeHeaderLen + 8 + estimateFrags * 8 + 8;
|
|
837
|
+
let pixelBytes = Math.max(2, targetBytes - overhead);
|
|
838
|
+
pixelBytes -= pixelBytes % 2;
|
|
839
|
+
const bytesWritten = writeEncapsulated(
|
|
840
|
+
outPath,
|
|
841
|
+
identity,
|
|
842
|
+
pixelBytes,
|
|
843
|
+
fragmentBytes - fragmentBytes % 2,
|
|
844
|
+
zeroChunk
|
|
845
|
+
);
|
|
846
|
+
return { path: outPath, bytesWritten, encoding: "encapsulated", pixelBytes };
|
|
847
|
+
}
|
|
848
|
+
function writeLargeImageFile(spec, outPath, options = {}) {
|
|
849
|
+
const { targetBytes } = spec;
|
|
850
|
+
if (!Number.isInteger(targetBytes)) {
|
|
851
|
+
throw new Error("writeLargeImageFile: targetBytes must be an integer");
|
|
852
|
+
}
|
|
853
|
+
if (targetBytes <= MAX_PIXEL_BYTES) {
|
|
854
|
+
throw new Error(
|
|
855
|
+
"writeLargeImageFile is for files above the 512 MB in-memory limit \u2014 use targetSizeKb for smaller files"
|
|
856
|
+
);
|
|
857
|
+
}
|
|
858
|
+
if (targetBytes > MAX_STREAM_BYTES) {
|
|
859
|
+
throw new Error("writeLargeImageFile: targetBytes exceeds the 50 GB limit");
|
|
860
|
+
}
|
|
861
|
+
return streamLargeImage(outPath, {
|
|
862
|
+
targetBytes,
|
|
863
|
+
modality: spec.modality,
|
|
864
|
+
seed: spec.seed,
|
|
865
|
+
chunkBytes: options.chunkBytes
|
|
866
|
+
});
|
|
867
|
+
}
|
|
868
|
+
|
|
606
869
|
// src/syntheticFixtures/violations.ts
|
|
607
870
|
var dcmjsAny = dcmjs;
|
|
608
871
|
function parseBuffer(buffer) {
|
|
@@ -716,11 +979,13 @@ function studyFileCount(studies) {
|
|
|
716
979
|
0
|
|
717
980
|
);
|
|
718
981
|
}
|
|
982
|
+
function resolveUid(seed, index, override) {
|
|
983
|
+
return { ...makeUidGenerator(seed)(index), ...override };
|
|
984
|
+
}
|
|
719
985
|
async function generateFile(spec, options) {
|
|
720
986
|
const index = options?.index ?? 0;
|
|
721
987
|
const padWidth = options?.padWidth ?? 3;
|
|
722
|
-
const
|
|
723
|
-
const uid = { ...uidGen(index), ...options?.uid };
|
|
988
|
+
const uid = resolveUid(options?.seed, index, options?.uid);
|
|
724
989
|
let buffer = buildBufferForSpec(spec, uid);
|
|
725
990
|
const violations = "violations" in spec ? spec.violations : void 0;
|
|
726
991
|
if (violations?.length) {
|
|
@@ -785,25 +1050,45 @@ function hierarchicalName(studyOrdinal, seriesIndex, instanceNumber) {
|
|
|
785
1050
|
relativePath: `study-${pad3(studyOrdinal + 1)}/series-${pad3(seriesIndex + 1)}/${name}`
|
|
786
1051
|
};
|
|
787
1052
|
}
|
|
788
|
-
|
|
1053
|
+
function withResolvedSeed(spec) {
|
|
1054
|
+
return spec.seed === void 0 ? { ...spec, seed: randomBytes2(4).readUInt32BE(0) } : spec;
|
|
1055
|
+
}
|
|
1056
|
+
function computeNames(type, index, padWidth, grouped, quirks) {
|
|
1057
|
+
let names;
|
|
1058
|
+
if (grouped) {
|
|
1059
|
+
names = hierarchicalName(
|
|
1060
|
+
grouped.studyOrdinal,
|
|
1061
|
+
grouped.seriesIndex,
|
|
1062
|
+
grouped.instanceNumber
|
|
1063
|
+
);
|
|
1064
|
+
} else {
|
|
1065
|
+
const name = filename(type, index, padWidth);
|
|
1066
|
+
names = { filename: name, relativePath: name };
|
|
1067
|
+
}
|
|
1068
|
+
return quirks.length === 0 ? names : applyPathQuirks(names.relativePath, quirks);
|
|
1069
|
+
}
|
|
1070
|
+
function* planCollection(spec) {
|
|
789
1071
|
const flatEntries = spec.entries ?? [];
|
|
790
1072
|
const studies = spec.studies ?? [];
|
|
791
1073
|
const totalFiles = entryCount(flatEntries) + studyFileCount(studies);
|
|
792
1074
|
const padWidth = String(totalFiles).length;
|
|
793
1075
|
const hierarchical = spec.layout === "hierarchical";
|
|
794
1076
|
const quirks = spec.pathQuirks ?? [];
|
|
795
|
-
const decorate = (file) => quirks.length === 0 ? file : { ...file, ...applyPathQuirks(file.relativePath, quirks) };
|
|
796
1077
|
let globalIndex = 0;
|
|
797
1078
|
for (const entry of flatEntries) {
|
|
798
1079
|
const { count = 1, ...fileSpec } = entry;
|
|
799
1080
|
for (let i = 0; i < count; i++) {
|
|
800
|
-
yield
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
1081
|
+
yield {
|
|
1082
|
+
fileSpec,
|
|
1083
|
+
index: globalIndex,
|
|
1084
|
+
...computeNames(
|
|
1085
|
+
fileSpec.type,
|
|
1086
|
+
globalIndex,
|
|
1087
|
+
padWidth,
|
|
1088
|
+
void 0,
|
|
1089
|
+
quirks
|
|
1090
|
+
)
|
|
1091
|
+
};
|
|
807
1092
|
globalIndex++;
|
|
808
1093
|
}
|
|
809
1094
|
}
|
|
@@ -826,25 +1111,18 @@ async function* generateCollectionFromSpec(spec) {
|
|
|
826
1111
|
...series.tags,
|
|
827
1112
|
...fileSpec.tags
|
|
828
1113
|
};
|
|
829
|
-
|
|
830
|
-
{ ...fileSpec, tags },
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
1114
|
+
yield {
|
|
1115
|
+
fileSpec: { ...fileSpec, tags },
|
|
1116
|
+
index: globalIndex,
|
|
1117
|
+
uidOverride: { study: studyUid, series: seriesUid },
|
|
1118
|
+
...computeNames(
|
|
1119
|
+
fileSpec.type,
|
|
1120
|
+
globalIndex,
|
|
834
1121
|
padWidth,
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
hierarchical ? {
|
|
840
|
-
...file,
|
|
841
|
-
...hierarchicalName(
|
|
842
|
-
studyOrdinal,
|
|
843
|
-
seriesIndex,
|
|
844
|
-
instanceNumber
|
|
845
|
-
)
|
|
846
|
-
} : file
|
|
847
|
-
);
|
|
1122
|
+
hierarchical ? { studyOrdinal, seriesIndex, instanceNumber } : void 0,
|
|
1123
|
+
quirks
|
|
1124
|
+
)
|
|
1125
|
+
};
|
|
848
1126
|
globalIndex++;
|
|
849
1127
|
}
|
|
850
1128
|
}
|
|
@@ -853,26 +1131,90 @@ async function* generateCollectionFromSpec(spec) {
|
|
|
853
1131
|
}
|
|
854
1132
|
}
|
|
855
1133
|
}
|
|
1134
|
+
async function materialisePlan(plan, seed) {
|
|
1135
|
+
const file = await generateFile(plan.fileSpec, {
|
|
1136
|
+
index: plan.index,
|
|
1137
|
+
seed,
|
|
1138
|
+
uid: plan.uidOverride
|
|
1139
|
+
});
|
|
1140
|
+
return { ...file, filename: plan.filename, relativePath: plan.relativePath };
|
|
1141
|
+
}
|
|
1142
|
+
async function* generateCollectionFromSpec(spec) {
|
|
1143
|
+
for (const plan of planCollection(spec)) {
|
|
1144
|
+
yield await materialisePlan(plan, spec.seed);
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
async function* previewCollection(spec) {
|
|
1148
|
+
for (const plan of planCollection(spec)) {
|
|
1149
|
+
if (plan.fileSpec.type === "large-image") {
|
|
1150
|
+
yield {
|
|
1151
|
+
relativePath: plan.relativePath,
|
|
1152
|
+
type: "large-image",
|
|
1153
|
+
index: plan.index,
|
|
1154
|
+
approxBytes: plan.fileSpec.targetBytes
|
|
1155
|
+
};
|
|
1156
|
+
} else {
|
|
1157
|
+
const file = await materialisePlan(plan, spec.seed);
|
|
1158
|
+
yield {
|
|
1159
|
+
relativePath: plan.relativePath,
|
|
1160
|
+
type: plan.fileSpec.type,
|
|
1161
|
+
index: plan.index,
|
|
1162
|
+
approxBytes: file.buffer.length
|
|
1163
|
+
};
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
856
1167
|
async function writeCollectionFromSpec(spec, outDir) {
|
|
857
|
-
const root =
|
|
858
|
-
|
|
1168
|
+
const root = resolve3(outDir);
|
|
1169
|
+
mkdirSync3(root, { recursive: true });
|
|
859
1170
|
const manifest = [];
|
|
860
1171
|
const createdDirs = /* @__PURE__ */ new Set([root]);
|
|
861
|
-
|
|
862
|
-
const filePath = resolve2(root, file.relativePath);
|
|
1172
|
+
const ensureDir = (filePath) => {
|
|
863
1173
|
const dir = dirname(filePath);
|
|
864
1174
|
if (!createdDirs.has(dir)) {
|
|
865
|
-
|
|
1175
|
+
mkdirSync3(dir, { recursive: true });
|
|
866
1176
|
createdDirs.add(dir);
|
|
867
1177
|
}
|
|
868
|
-
|
|
869
|
-
|
|
1178
|
+
};
|
|
1179
|
+
for (const plan of planCollection(spec)) {
|
|
1180
|
+
const filePath = resolve3(root, plan.relativePath);
|
|
1181
|
+
ensureDir(filePath);
|
|
1182
|
+
if (plan.fileSpec.type === "large-image") {
|
|
1183
|
+
const large = plan.fileSpec;
|
|
1184
|
+
if (large.targetBytes > MAX_STREAM_BYTES) {
|
|
1185
|
+
throw new Error(
|
|
1186
|
+
`large-image targetBytes (${large.targetBytes}) exceeds the 50 GB limit`
|
|
1187
|
+
);
|
|
1188
|
+
}
|
|
1189
|
+
const uid = resolveUid(spec.seed, plan.index, plan.uidOverride);
|
|
1190
|
+
streamLargeImage(filePath, {
|
|
1191
|
+
targetBytes: large.targetBytes,
|
|
1192
|
+
modality: large.modality,
|
|
1193
|
+
uid,
|
|
1194
|
+
tags: large.tags
|
|
1195
|
+
});
|
|
1196
|
+
} else {
|
|
1197
|
+
const file = await materialisePlan(plan, spec.seed);
|
|
1198
|
+
await writeFile(filePath, file.buffer);
|
|
1199
|
+
}
|
|
1200
|
+
manifest.push({
|
|
1201
|
+
path: filePath,
|
|
1202
|
+
type: plan.fileSpec.type,
|
|
1203
|
+
index: plan.index
|
|
1204
|
+
});
|
|
870
1205
|
}
|
|
871
1206
|
return manifest;
|
|
872
1207
|
}
|
|
873
1208
|
|
|
874
1209
|
// src/describe/describe.ts
|
|
875
|
-
import {
|
|
1210
|
+
import {
|
|
1211
|
+
closeSync as closeSync2,
|
|
1212
|
+
openSync as openSync2,
|
|
1213
|
+
readdirSync,
|
|
1214
|
+
readFileSync,
|
|
1215
|
+
readSync,
|
|
1216
|
+
statSync
|
|
1217
|
+
} from "node:fs";
|
|
876
1218
|
import { join } from "node:path";
|
|
877
1219
|
var dcmjsAny2 = dcmjs;
|
|
878
1220
|
var HEX_TAG_RE2 = /^[0-9a-fA-F]{8}$/;
|
|
@@ -917,18 +1259,77 @@ function* walkFiles(dir) {
|
|
|
917
1259
|
}
|
|
918
1260
|
}
|
|
919
1261
|
}
|
|
920
|
-
function
|
|
1262
|
+
function toArrayBuffer(buf) {
|
|
1263
|
+
return buf.buffer.slice(
|
|
1264
|
+
buf.byteOffset,
|
|
1265
|
+
buf.byteOffset + buf.byteLength
|
|
1266
|
+
);
|
|
1267
|
+
}
|
|
1268
|
+
function naturalize(ab) {
|
|
1269
|
+
const parsed = dcmjsAny2.data.DicomMessage.readFile(ab);
|
|
1270
|
+
return dcmjsAny2.data.DicomMetaDictionary.naturalizeDataset(parsed.dict);
|
|
1271
|
+
}
|
|
1272
|
+
function parseFull(path) {
|
|
1273
|
+
try {
|
|
1274
|
+
return naturalize(toArrayBuffer(readFileSync(path)));
|
|
1275
|
+
} catch {
|
|
1276
|
+
return null;
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
var PIXEL_DATA_OW = Buffer.from([224, 127, 16, 0, 79, 87]);
|
|
1280
|
+
var PIXEL_DATA_OB = Buffer.from([224, 127, 16, 0, 79, 66]);
|
|
1281
|
+
var EMPTY_OW_PIXEL_DATA = Buffer.from([
|
|
1282
|
+
224,
|
|
1283
|
+
127,
|
|
1284
|
+
16,
|
|
1285
|
+
0,
|
|
1286
|
+
79,
|
|
1287
|
+
87,
|
|
1288
|
+
0,
|
|
1289
|
+
0,
|
|
1290
|
+
0,
|
|
1291
|
+
0,
|
|
1292
|
+
0,
|
|
1293
|
+
0
|
|
1294
|
+
]);
|
|
1295
|
+
var HEADER_READ_BYTES = 2 * 1024 * 1024;
|
|
1296
|
+
function findPixelDataOffset(prefix) {
|
|
1297
|
+
let best = -1;
|
|
1298
|
+
for (const pattern of [PIXEL_DATA_OW, PIXEL_DATA_OB]) {
|
|
1299
|
+
for (let from = 0; ; ) {
|
|
1300
|
+
const i = prefix.indexOf(pattern, from);
|
|
1301
|
+
if (i < 0) break;
|
|
1302
|
+
if (prefix[i + 6] === 0 && prefix[i + 7] === 0) {
|
|
1303
|
+
if (best < 0 || i < best) best = i;
|
|
1304
|
+
break;
|
|
1305
|
+
}
|
|
1306
|
+
from = i + 1;
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
return best;
|
|
1310
|
+
}
|
|
1311
|
+
function readHeaderOnly(path) {
|
|
1312
|
+
let prefix;
|
|
921
1313
|
try {
|
|
922
|
-
const
|
|
923
|
-
|
|
924
|
-
buf.
|
|
925
|
-
buf
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
return
|
|
1314
|
+
const fd = openSync2(path, "r");
|
|
1315
|
+
try {
|
|
1316
|
+
const buf = Buffer.alloc(HEADER_READ_BYTES);
|
|
1317
|
+
const n = readSync(fd, buf, 0, HEADER_READ_BYTES, 0);
|
|
1318
|
+
prefix = buf.subarray(0, n);
|
|
1319
|
+
} finally {
|
|
1320
|
+
closeSync2(fd);
|
|
1321
|
+
}
|
|
1322
|
+
} catch {
|
|
1323
|
+
return null;
|
|
1324
|
+
}
|
|
1325
|
+
const tagIdx = findPixelDataOffset(prefix);
|
|
1326
|
+
if (tagIdx < 0) return null;
|
|
1327
|
+
const headerOnly = Buffer.concat([
|
|
1328
|
+
prefix.subarray(0, tagIdx),
|
|
1329
|
+
EMPTY_OW_PIXEL_DATA
|
|
1330
|
+
]);
|
|
1331
|
+
try {
|
|
1332
|
+
return naturalize(toArrayBuffer(headerOnly));
|
|
932
1333
|
} catch {
|
|
933
1334
|
return null;
|
|
934
1335
|
}
|
|
@@ -950,14 +1351,25 @@ function describeDirectory(dir, options = {}) {
|
|
|
950
1351
|
const studies = /* @__PURE__ */ new Map();
|
|
951
1352
|
const stats = { dicomFiles: 0, skipped: 0, unknownModality: 0 };
|
|
952
1353
|
for (const path of [...walkFiles(dir)].sort()) {
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
1354
|
+
let size;
|
|
1355
|
+
try {
|
|
1356
|
+
size = statSync(path).size;
|
|
1357
|
+
} catch {
|
|
1358
|
+
stats.skipped++;
|
|
1359
|
+
continue;
|
|
1360
|
+
}
|
|
1361
|
+
if (size > MAX_STREAM_BYTES) {
|
|
1362
|
+
stats.skipped++;
|
|
1363
|
+
continue;
|
|
1364
|
+
}
|
|
1365
|
+
const large = size > MAX_PIXEL_BYTES;
|
|
1366
|
+
const record = large ? readHeaderOnly(path) : parseFull(path);
|
|
1367
|
+
const studyUid = record?.StudyInstanceUID;
|
|
1368
|
+
const seriesUid = record?.SeriesInstanceUID;
|
|
1369
|
+
if (!record || typeof studyUid !== "string" || typeof seriesUid !== "string") {
|
|
957
1370
|
stats.skipped++;
|
|
958
1371
|
continue;
|
|
959
1372
|
}
|
|
960
|
-
const { record } = parsed;
|
|
961
1373
|
stats.dicomFiles++;
|
|
962
1374
|
let modality;
|
|
963
1375
|
if (typeof record.Modality === "string") {
|
|
@@ -967,7 +1379,7 @@ function describeDirectory(dir, options = {}) {
|
|
|
967
1379
|
stats.unknownModality++;
|
|
968
1380
|
}
|
|
969
1381
|
}
|
|
970
|
-
const sizeKb = Math.max(1, Math.round(
|
|
1382
|
+
const sizeKb = Math.max(1, Math.round(size / 1024));
|
|
971
1383
|
const tags = keepKeywords.length ? extractTags(record, keepKeywords) : void 0;
|
|
972
1384
|
let study = studies.get(studyUid);
|
|
973
1385
|
if (!study) {
|
|
@@ -979,25 +1391,27 @@ function describeDirectory(dir, options = {}) {
|
|
|
979
1391
|
series = /* @__PURE__ */ new Map();
|
|
980
1392
|
study.set(seriesUid, series);
|
|
981
1393
|
}
|
|
982
|
-
const collapseKey = JSON.stringify([
|
|
1394
|
+
const collapseKey = JSON.stringify([
|
|
1395
|
+
large,
|
|
1396
|
+
modality ?? null,
|
|
1397
|
+
large ? size : sizeKb,
|
|
1398
|
+
tags ?? null
|
|
1399
|
+
]);
|
|
983
1400
|
const existing = series.get(collapseKey);
|
|
984
1401
|
if (existing) {
|
|
985
1402
|
existing.count++;
|
|
986
1403
|
} else {
|
|
987
|
-
series.set(collapseKey, {
|
|
988
|
-
modality,
|
|
989
|
-
targetSizeKb: sizeKb,
|
|
990
|
-
tags,
|
|
991
|
-
count: 1
|
|
992
|
-
});
|
|
1404
|
+
series.set(collapseKey, { modality, large, bytes: size, tags, count: 1 });
|
|
993
1405
|
}
|
|
994
1406
|
}
|
|
995
1407
|
const studySpecs = [...studies.values()].map((study) => {
|
|
996
1408
|
const seriesSpecs = [...study.values()].map((series) => {
|
|
997
1409
|
const entries = [...series.values()].map((acc) => ({
|
|
998
|
-
type: "valid-image",
|
|
999
1410
|
...acc.modality !== void 0 ? { modality: acc.modality } : {},
|
|
1000
|
-
|
|
1411
|
+
...acc.large ? { type: "large-image", targetBytes: acc.bytes } : {
|
|
1412
|
+
type: "valid-image",
|
|
1413
|
+
targetSizeKb: Math.max(1, Math.round(acc.bytes / 1024))
|
|
1414
|
+
},
|
|
1001
1415
|
...acc.tags !== void 0 ? { tags: acc.tags } : {},
|
|
1002
1416
|
...acc.count > 1 ? { count: acc.count } : {}
|
|
1003
1417
|
}));
|
|
@@ -1046,7 +1460,7 @@ function loadCaseById(casesJsonPath, id) {
|
|
|
1046
1460
|
|
|
1047
1461
|
// src/public-fixtures/fetch.ts
|
|
1048
1462
|
import { createHash } from "node:crypto";
|
|
1049
|
-
import { existsSync, mkdirSync as
|
|
1463
|
+
import { existsSync, mkdirSync as mkdirSync4, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "node:fs";
|
|
1050
1464
|
import { homedir } from "node:os";
|
|
1051
1465
|
import { join as join3 } from "node:path";
|
|
1052
1466
|
var DEFAULT_CACHE_ROOT = join3(homedir(), ".cache", "dicom-synth-testcases");
|
|
@@ -1073,7 +1487,7 @@ async function fetchPublicCaseToCache(record, cacheRoot = DEFAULT_CACHE_ROOT) {
|
|
|
1073
1487
|
verifySha256(buf2, record.sha256);
|
|
1074
1488
|
return dest;
|
|
1075
1489
|
}
|
|
1076
|
-
|
|
1490
|
+
mkdirSync4(join3(cacheRoot, record.sha256), { recursive: true });
|
|
1077
1491
|
const res = await fetch(record.source.url);
|
|
1078
1492
|
if (!res.ok) {
|
|
1079
1493
|
throw new Error(
|
|
@@ -1087,11 +1501,17 @@ async function fetchPublicCaseToCache(record, cacheRoot = DEFAULT_CACHE_ROOT) {
|
|
|
1087
1501
|
}
|
|
1088
1502
|
|
|
1089
1503
|
// src/schema/parametric.ts
|
|
1090
|
-
import { randomBytes as
|
|
1504
|
+
import { randomBytes as randomBytes3 } from "node:crypto";
|
|
1091
1505
|
function sample(next, range) {
|
|
1092
1506
|
if (typeof range === "number") return range;
|
|
1093
1507
|
return range.min + next() % (range.max - range.min + 1);
|
|
1094
1508
|
}
|
|
1509
|
+
var BYTES_PER_MB = 1024 * 1024;
|
|
1510
|
+
function sampleBytes(next, range) {
|
|
1511
|
+
if (typeof range === "number") return range;
|
|
1512
|
+
const spanMb = Math.floor((range.max - range.min) / BYTES_PER_MB) + 1;
|
|
1513
|
+
return range.min + next() % spanMb * BYTES_PER_MB;
|
|
1514
|
+
}
|
|
1095
1515
|
function pick(next, items) {
|
|
1096
1516
|
return items[next() % items.length];
|
|
1097
1517
|
}
|
|
@@ -1100,7 +1520,7 @@ function fraction(next) {
|
|
|
1100
1520
|
}
|
|
1101
1521
|
function resolveParametricSpec(spec) {
|
|
1102
1522
|
const validated = validateParametricSpec(spec);
|
|
1103
|
-
const seed = validated.seed ??
|
|
1523
|
+
const seed = validated.seed ?? randomBytes3(4).readUInt32BE(0);
|
|
1104
1524
|
const next = seededStream(seed, PARAMETRIC_STREAM_OFFSET, 0, 0);
|
|
1105
1525
|
const params = validated.studies;
|
|
1106
1526
|
const studies = [];
|
|
@@ -1132,6 +1552,30 @@ function resolveParametricSpec(spec) {
|
|
|
1132
1552
|
...fileCount > 1 ? { count: fileCount } : {}
|
|
1133
1553
|
});
|
|
1134
1554
|
}
|
|
1555
|
+
if (params.largeFilesPerSeries !== void 0 && params.largeFileBytes !== void 0) {
|
|
1556
|
+
const largeCount = sample(next, params.largeFilesPerSeries);
|
|
1557
|
+
const largeBase = {
|
|
1558
|
+
type: "large-image",
|
|
1559
|
+
targetBytes: 0,
|
|
1560
|
+
...modality !== void 0 ? { modality } : {}
|
|
1561
|
+
};
|
|
1562
|
+
if (typeof params.largeFileBytes === "number") {
|
|
1563
|
+
if (largeCount > 0) {
|
|
1564
|
+
entries2.push({
|
|
1565
|
+
...largeBase,
|
|
1566
|
+
targetBytes: params.largeFileBytes,
|
|
1567
|
+
...largeCount > 1 ? { count: largeCount } : {}
|
|
1568
|
+
});
|
|
1569
|
+
}
|
|
1570
|
+
} else {
|
|
1571
|
+
for (let lf = 0; lf < largeCount; lf++) {
|
|
1572
|
+
entries2.push({
|
|
1573
|
+
...largeBase,
|
|
1574
|
+
targetBytes: sampleBytes(next, params.largeFileBytes)
|
|
1575
|
+
});
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
}
|
|
1135
1579
|
series.push({ entries: entries2 });
|
|
1136
1580
|
}
|
|
1137
1581
|
studies.push({
|
|
@@ -1160,6 +1604,8 @@ function resolveParametricSpec(spec) {
|
|
|
1160
1604
|
};
|
|
1161
1605
|
}
|
|
1162
1606
|
export {
|
|
1607
|
+
MAX_PIXEL_BYTES,
|
|
1608
|
+
MAX_STREAM_BYTES,
|
|
1163
1609
|
caseCachePath,
|
|
1164
1610
|
defaultPublicCasesPath,
|
|
1165
1611
|
describeDirectory,
|
|
@@ -1169,9 +1615,13 @@ export {
|
|
|
1169
1615
|
loadCaseById,
|
|
1170
1616
|
loadCasesFromJson,
|
|
1171
1617
|
loadDefaultCases,
|
|
1618
|
+
previewCollection,
|
|
1172
1619
|
resolveParametricSpec,
|
|
1620
|
+
streamLargeImage,
|
|
1173
1621
|
validateDatasetSpec,
|
|
1174
1622
|
validateParametricSpec,
|
|
1175
1623
|
verifySha256,
|
|
1176
|
-
|
|
1624
|
+
withResolvedSeed,
|
|
1625
|
+
writeCollectionFromSpec,
|
|
1626
|
+
writeLargeImageFile
|
|
1177
1627
|
};
|