dicom-synth 1.8.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/bin/dicom-synth-generate.mjs +90 -5
- package/dist/esm/collection/writer.js +267 -39
- package/dist/esm/describe/describe.js +159 -29
- package/dist/esm/index.js +519 -74
- 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 +7 -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,8 +1,12 @@
|
|
|
1
1
|
// src/collection/writer.ts
|
|
2
2
|
import { randomBytes as randomBytes2 } from "node:crypto";
|
|
3
|
-
import { mkdirSync as
|
|
3
|
+
import { mkdirSync as mkdirSync3 } from "node:fs";
|
|
4
4
|
import { writeFile } from "node:fs/promises";
|
|
5
|
-
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;
|
|
6
10
|
|
|
7
11
|
// src/loadDcmjs.ts
|
|
8
12
|
import dcmjsDefaultImport, * as dcmjsNamespace from "dcmjs";
|
|
@@ -50,6 +54,7 @@ var TYPE_CAPABILITIES = {
|
|
|
50
54
|
"valid-image": { image: true },
|
|
51
55
|
"invalid-uid-image": { image: true },
|
|
52
56
|
"vendor-warnings-image": { image: true },
|
|
57
|
+
"large-image": { image: true },
|
|
53
58
|
"fake-signature": { image: false },
|
|
54
59
|
"non-dicom": { image: false },
|
|
55
60
|
dicomdir: { image: false }
|
|
@@ -74,7 +79,6 @@ var VALID_TRANSFER_SYNTAXES = {
|
|
|
74
79
|
"explicit-vr-little-endian": true,
|
|
75
80
|
"implicit-vr-little-endian": true
|
|
76
81
|
};
|
|
77
|
-
var MAX_PIXEL_BYTES = 512 * 1024 * 1024;
|
|
78
82
|
var VALID_VIOLATIONS = {
|
|
79
83
|
"uid-too-long": true,
|
|
80
84
|
"non-conformant-uid": true,
|
|
@@ -92,6 +96,13 @@ function validatePositiveInt(value, path) {
|
|
|
92
96
|
);
|
|
93
97
|
}
|
|
94
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
|
+
}
|
|
95
106
|
function validateSeed(value, path) {
|
|
96
107
|
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
97
108
|
throw new Error(
|
|
@@ -104,6 +115,43 @@ function validateSizeKbLimit(kb, path) {
|
|
|
104
115
|
throw new Error(`${path}: exceeds the 512 MB limit`);
|
|
105
116
|
}
|
|
106
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
|
+
}
|
|
107
155
|
function validateEnum(value, valid, path) {
|
|
108
156
|
if (typeof value !== "string" || !Object.hasOwn(valid, value)) {
|
|
109
157
|
throw new Error(
|
|
@@ -127,6 +175,15 @@ function validateEntry(entry, path) {
|
|
|
127
175
|
const type = e.type;
|
|
128
176
|
const caps = TYPE_CAPABILITIES[type];
|
|
129
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
|
+
}
|
|
130
187
|
for (const field of [
|
|
131
188
|
"modality",
|
|
132
189
|
"rows",
|
|
@@ -272,19 +329,20 @@ function validateDatasetSpec(raw) {
|
|
|
272
329
|
...r.pathQuirks !== void 0 ? { pathQuirks: r.pathQuirks } : {}
|
|
273
330
|
};
|
|
274
331
|
}
|
|
275
|
-
function validateRange(value, path) {
|
|
332
|
+
function validateRange(value, path, options = {}) {
|
|
333
|
+
const checkInt = options.allowZero ? validateNonNegativeInt : validatePositiveInt;
|
|
276
334
|
if (typeof value === "number") {
|
|
277
|
-
|
|
335
|
+
checkInt(value, path);
|
|
278
336
|
return value;
|
|
279
337
|
}
|
|
280
338
|
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
281
339
|
throw new Error(
|
|
282
|
-
`${path}: must be
|
|
340
|
+
`${path}: must be an integer or { min, max }; got ${JSON.stringify(value)}`
|
|
283
341
|
);
|
|
284
342
|
}
|
|
285
343
|
const r = value;
|
|
286
|
-
|
|
287
|
-
|
|
344
|
+
checkInt(r.min, `${path}.min`);
|
|
345
|
+
checkInt(r.max, `${path}.max`);
|
|
288
346
|
if (r.min > r.max) {
|
|
289
347
|
throw new Error(`${path}: min (${r.min}) must be \u2264 max (${r.max})`);
|
|
290
348
|
}
|
|
@@ -293,6 +351,9 @@ function validateRange(value, path) {
|
|
|
293
351
|
function rangeMax(range) {
|
|
294
352
|
return typeof range === "number" ? range : range.max;
|
|
295
353
|
}
|
|
354
|
+
function rangeMin(range) {
|
|
355
|
+
return typeof range === "number" ? range : range.min;
|
|
356
|
+
}
|
|
296
357
|
function validateParametricStudies(studies, path) {
|
|
297
358
|
if (typeof studies !== "object" || studies === null || Array.isArray(studies)) {
|
|
298
359
|
throw new Error(`${path}: must be an object`);
|
|
@@ -306,6 +367,32 @@ function validateParametricStudies(studies, path) {
|
|
|
306
367
|
validateRange(s.fileSizeKb, `${path}.fileSizeKb`);
|
|
307
368
|
validateSizeKbLimit(rangeMax(s.fileSizeKb), `${path}.fileSizeKb`);
|
|
308
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
|
+
}
|
|
309
396
|
if ("modalities" in s) {
|
|
310
397
|
if (!Array.isArray(s.modalities) || s.modalities.length === 0) {
|
|
311
398
|
throw new Error(`${path}.modalities: must be a non-empty array`);
|
|
@@ -535,9 +622,17 @@ function buildBufferForSpec(spec, uid) {
|
|
|
535
622
|
return buildNonDicomBuffer(spec.content);
|
|
536
623
|
case "dicomdir":
|
|
537
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
|
+
);
|
|
538
629
|
}
|
|
539
630
|
}
|
|
540
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
|
+
|
|
541
636
|
// src/syntheticFixtures/uid.ts
|
|
542
637
|
import { randomBytes } from "node:crypto";
|
|
543
638
|
var ROLE_CODE = { study: 1, series: 2, sop: 3 };
|
|
@@ -604,6 +699,173 @@ function makeUidGenerator(seed) {
|
|
|
604
699
|
};
|
|
605
700
|
}
|
|
606
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
|
+
|
|
607
869
|
// src/syntheticFixtures/violations.ts
|
|
608
870
|
var dcmjsAny = dcmjs;
|
|
609
871
|
function parseBuffer(buffer) {
|
|
@@ -717,11 +979,13 @@ function studyFileCount(studies) {
|
|
|
717
979
|
0
|
|
718
980
|
);
|
|
719
981
|
}
|
|
982
|
+
function resolveUid(seed, index, override) {
|
|
983
|
+
return { ...makeUidGenerator(seed)(index), ...override };
|
|
984
|
+
}
|
|
720
985
|
async function generateFile(spec, options) {
|
|
721
986
|
const index = options?.index ?? 0;
|
|
722
987
|
const padWidth = options?.padWidth ?? 3;
|
|
723
|
-
const
|
|
724
|
-
const uid = { ...uidGen(index), ...options?.uid };
|
|
988
|
+
const uid = resolveUid(options?.seed, index, options?.uid);
|
|
725
989
|
let buffer = buildBufferForSpec(spec, uid);
|
|
726
990
|
const violations = "violations" in spec ? spec.violations : void 0;
|
|
727
991
|
if (violations?.length) {
|
|
@@ -789,25 +1053,42 @@ function hierarchicalName(studyOrdinal, seriesIndex, instanceNumber) {
|
|
|
789
1053
|
function withResolvedSeed(spec) {
|
|
790
1054
|
return spec.seed === void 0 ? { ...spec, seed: randomBytes2(4).readUInt32BE(0) } : spec;
|
|
791
1055
|
}
|
|
792
|
-
|
|
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) {
|
|
793
1071
|
const flatEntries = spec.entries ?? [];
|
|
794
1072
|
const studies = spec.studies ?? [];
|
|
795
1073
|
const totalFiles = entryCount(flatEntries) + studyFileCount(studies);
|
|
796
1074
|
const padWidth = String(totalFiles).length;
|
|
797
1075
|
const hierarchical = spec.layout === "hierarchical";
|
|
798
1076
|
const quirks = spec.pathQuirks ?? [];
|
|
799
|
-
const decorate = (file) => quirks.length === 0 ? file : { ...file, ...applyPathQuirks(file.relativePath, quirks) };
|
|
800
1077
|
let globalIndex = 0;
|
|
801
1078
|
for (const entry of flatEntries) {
|
|
802
1079
|
const { count = 1, ...fileSpec } = entry;
|
|
803
1080
|
for (let i = 0; i < count; i++) {
|
|
804
|
-
yield
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
1081
|
+
yield {
|
|
1082
|
+
fileSpec,
|
|
1083
|
+
index: globalIndex,
|
|
1084
|
+
...computeNames(
|
|
1085
|
+
fileSpec.type,
|
|
1086
|
+
globalIndex,
|
|
1087
|
+
padWidth,
|
|
1088
|
+
void 0,
|
|
1089
|
+
quirks
|
|
1090
|
+
)
|
|
1091
|
+
};
|
|
811
1092
|
globalIndex++;
|
|
812
1093
|
}
|
|
813
1094
|
}
|
|
@@ -830,25 +1111,18 @@ async function* generateCollectionFromSpec(spec) {
|
|
|
830
1111
|
...series.tags,
|
|
831
1112
|
...fileSpec.tags
|
|
832
1113
|
};
|
|
833
|
-
|
|
834
|
-
{ ...fileSpec, tags },
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
1114
|
+
yield {
|
|
1115
|
+
fileSpec: { ...fileSpec, tags },
|
|
1116
|
+
index: globalIndex,
|
|
1117
|
+
uidOverride: { study: studyUid, series: seriesUid },
|
|
1118
|
+
...computeNames(
|
|
1119
|
+
fileSpec.type,
|
|
1120
|
+
globalIndex,
|
|
838
1121
|
padWidth,
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
hierarchical ? {
|
|
844
|
-
...file,
|
|
845
|
-
...hierarchicalName(
|
|
846
|
-
studyOrdinal,
|
|
847
|
-
seriesIndex,
|
|
848
|
-
instanceNumber
|
|
849
|
-
)
|
|
850
|
-
} : file
|
|
851
|
-
);
|
|
1122
|
+
hierarchical ? { studyOrdinal, seriesIndex, instanceNumber } : void 0,
|
|
1123
|
+
quirks
|
|
1124
|
+
)
|
|
1125
|
+
};
|
|
852
1126
|
globalIndex++;
|
|
853
1127
|
}
|
|
854
1128
|
}
|
|
@@ -857,26 +1131,90 @@ async function* generateCollectionFromSpec(spec) {
|
|
|
857
1131
|
}
|
|
858
1132
|
}
|
|
859
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
|
+
}
|
|
860
1167
|
async function writeCollectionFromSpec(spec, outDir) {
|
|
861
|
-
const root =
|
|
862
|
-
|
|
1168
|
+
const root = resolve3(outDir);
|
|
1169
|
+
mkdirSync3(root, { recursive: true });
|
|
863
1170
|
const manifest = [];
|
|
864
1171
|
const createdDirs = /* @__PURE__ */ new Set([root]);
|
|
865
|
-
|
|
866
|
-
const filePath = resolve2(root, file.relativePath);
|
|
1172
|
+
const ensureDir = (filePath) => {
|
|
867
1173
|
const dir = dirname(filePath);
|
|
868
1174
|
if (!createdDirs.has(dir)) {
|
|
869
|
-
|
|
1175
|
+
mkdirSync3(dir, { recursive: true });
|
|
870
1176
|
createdDirs.add(dir);
|
|
871
1177
|
}
|
|
872
|
-
|
|
873
|
-
|
|
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
|
+
});
|
|
874
1205
|
}
|
|
875
1206
|
return manifest;
|
|
876
1207
|
}
|
|
877
1208
|
|
|
878
1209
|
// src/describe/describe.ts
|
|
879
|
-
import {
|
|
1210
|
+
import {
|
|
1211
|
+
closeSync as closeSync2,
|
|
1212
|
+
openSync as openSync2,
|
|
1213
|
+
readdirSync,
|
|
1214
|
+
readFileSync,
|
|
1215
|
+
readSync,
|
|
1216
|
+
statSync
|
|
1217
|
+
} from "node:fs";
|
|
880
1218
|
import { join } from "node:path";
|
|
881
1219
|
var dcmjsAny2 = dcmjs;
|
|
882
1220
|
var HEX_TAG_RE2 = /^[0-9a-fA-F]{8}$/;
|
|
@@ -921,18 +1259,77 @@ function* walkFiles(dir) {
|
|
|
921
1259
|
}
|
|
922
1260
|
}
|
|
923
1261
|
}
|
|
924
|
-
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) {
|
|
925
1273
|
try {
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
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;
|
|
1313
|
+
try {
|
|
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));
|
|
936
1333
|
} catch {
|
|
937
1334
|
return null;
|
|
938
1335
|
}
|
|
@@ -954,14 +1351,25 @@ function describeDirectory(dir, options = {}) {
|
|
|
954
1351
|
const studies = /* @__PURE__ */ new Map();
|
|
955
1352
|
const stats = { dicomFiles: 0, skipped: 0, unknownModality: 0 };
|
|
956
1353
|
for (const path of [...walkFiles(dir)].sort()) {
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
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") {
|
|
961
1370
|
stats.skipped++;
|
|
962
1371
|
continue;
|
|
963
1372
|
}
|
|
964
|
-
const { record } = parsed;
|
|
965
1373
|
stats.dicomFiles++;
|
|
966
1374
|
let modality;
|
|
967
1375
|
if (typeof record.Modality === "string") {
|
|
@@ -971,7 +1379,7 @@ function describeDirectory(dir, options = {}) {
|
|
|
971
1379
|
stats.unknownModality++;
|
|
972
1380
|
}
|
|
973
1381
|
}
|
|
974
|
-
const sizeKb = Math.max(1, Math.round(
|
|
1382
|
+
const sizeKb = Math.max(1, Math.round(size / 1024));
|
|
975
1383
|
const tags = keepKeywords.length ? extractTags(record, keepKeywords) : void 0;
|
|
976
1384
|
let study = studies.get(studyUid);
|
|
977
1385
|
if (!study) {
|
|
@@ -983,25 +1391,27 @@ function describeDirectory(dir, options = {}) {
|
|
|
983
1391
|
series = /* @__PURE__ */ new Map();
|
|
984
1392
|
study.set(seriesUid, series);
|
|
985
1393
|
}
|
|
986
|
-
const collapseKey = JSON.stringify([
|
|
1394
|
+
const collapseKey = JSON.stringify([
|
|
1395
|
+
large,
|
|
1396
|
+
modality ?? null,
|
|
1397
|
+
large ? size : sizeKb,
|
|
1398
|
+
tags ?? null
|
|
1399
|
+
]);
|
|
987
1400
|
const existing = series.get(collapseKey);
|
|
988
1401
|
if (existing) {
|
|
989
1402
|
existing.count++;
|
|
990
1403
|
} else {
|
|
991
|
-
series.set(collapseKey, {
|
|
992
|
-
modality,
|
|
993
|
-
targetSizeKb: sizeKb,
|
|
994
|
-
tags,
|
|
995
|
-
count: 1
|
|
996
|
-
});
|
|
1404
|
+
series.set(collapseKey, { modality, large, bytes: size, tags, count: 1 });
|
|
997
1405
|
}
|
|
998
1406
|
}
|
|
999
1407
|
const studySpecs = [...studies.values()].map((study) => {
|
|
1000
1408
|
const seriesSpecs = [...study.values()].map((series) => {
|
|
1001
1409
|
const entries = [...series.values()].map((acc) => ({
|
|
1002
|
-
type: "valid-image",
|
|
1003
1410
|
...acc.modality !== void 0 ? { modality: acc.modality } : {},
|
|
1004
|
-
|
|
1411
|
+
...acc.large ? { type: "large-image", targetBytes: acc.bytes } : {
|
|
1412
|
+
type: "valid-image",
|
|
1413
|
+
targetSizeKb: Math.max(1, Math.round(acc.bytes / 1024))
|
|
1414
|
+
},
|
|
1005
1415
|
...acc.tags !== void 0 ? { tags: acc.tags } : {},
|
|
1006
1416
|
...acc.count > 1 ? { count: acc.count } : {}
|
|
1007
1417
|
}));
|
|
@@ -1050,7 +1460,7 @@ function loadCaseById(casesJsonPath, id) {
|
|
|
1050
1460
|
|
|
1051
1461
|
// src/public-fixtures/fetch.ts
|
|
1052
1462
|
import { createHash } from "node:crypto";
|
|
1053
|
-
import { existsSync, mkdirSync as
|
|
1463
|
+
import { existsSync, mkdirSync as mkdirSync4, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "node:fs";
|
|
1054
1464
|
import { homedir } from "node:os";
|
|
1055
1465
|
import { join as join3 } from "node:path";
|
|
1056
1466
|
var DEFAULT_CACHE_ROOT = join3(homedir(), ".cache", "dicom-synth-testcases");
|
|
@@ -1077,7 +1487,7 @@ async function fetchPublicCaseToCache(record, cacheRoot = DEFAULT_CACHE_ROOT) {
|
|
|
1077
1487
|
verifySha256(buf2, record.sha256);
|
|
1078
1488
|
return dest;
|
|
1079
1489
|
}
|
|
1080
|
-
|
|
1490
|
+
mkdirSync4(join3(cacheRoot, record.sha256), { recursive: true });
|
|
1081
1491
|
const res = await fetch(record.source.url);
|
|
1082
1492
|
if (!res.ok) {
|
|
1083
1493
|
throw new Error(
|
|
@@ -1096,6 +1506,12 @@ function sample(next, range) {
|
|
|
1096
1506
|
if (typeof range === "number") return range;
|
|
1097
1507
|
return range.min + next() % (range.max - range.min + 1);
|
|
1098
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
|
+
}
|
|
1099
1515
|
function pick(next, items) {
|
|
1100
1516
|
return items[next() % items.length];
|
|
1101
1517
|
}
|
|
@@ -1136,6 +1552,30 @@ function resolveParametricSpec(spec) {
|
|
|
1136
1552
|
...fileCount > 1 ? { count: fileCount } : {}
|
|
1137
1553
|
});
|
|
1138
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
|
+
}
|
|
1139
1579
|
series.push({ entries: entries2 });
|
|
1140
1580
|
}
|
|
1141
1581
|
studies.push({
|
|
@@ -1164,6 +1604,8 @@ function resolveParametricSpec(spec) {
|
|
|
1164
1604
|
};
|
|
1165
1605
|
}
|
|
1166
1606
|
export {
|
|
1607
|
+
MAX_PIXEL_BYTES,
|
|
1608
|
+
MAX_STREAM_BYTES,
|
|
1167
1609
|
caseCachePath,
|
|
1168
1610
|
defaultPublicCasesPath,
|
|
1169
1611
|
describeDirectory,
|
|
@@ -1173,10 +1615,13 @@ export {
|
|
|
1173
1615
|
loadCaseById,
|
|
1174
1616
|
loadCasesFromJson,
|
|
1175
1617
|
loadDefaultCases,
|
|
1618
|
+
previewCollection,
|
|
1176
1619
|
resolveParametricSpec,
|
|
1620
|
+
streamLargeImage,
|
|
1177
1621
|
validateDatasetSpec,
|
|
1178
1622
|
validateParametricSpec,
|
|
1179
1623
|
verifySha256,
|
|
1180
1624
|
withResolvedSeed,
|
|
1181
|
-
writeCollectionFromSpec
|
|
1625
|
+
writeCollectionFromSpec,
|
|
1626
|
+
writeLargeImageFile
|
|
1182
1627
|
};
|