dicom-synth 1.8.0 → 1.10.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 +28 -9
- package/bin/dicom-synth-generate.mjs +90 -5
- package/dist/esm/collection/writer.js +313 -39
- package/dist/esm/describe/describe.js +232 -41
- package/dist/esm/index.js +631 -86
- package/dist/esm/schema/limits.js +7 -0
- package/dist/esm/schema/parametric.js +156 -7
- package/dist/esm/schema/validate.js +126 -7
- package/dist/esm/syntheticFixtures/generator.js +13 -2
- package/dist/esm/syntheticFixtures/streamWrite.js +356 -0
- package/dist/esm/syntheticFixtures/tagTemplate.js +49 -0
- package/dist/types/collection/writer.d.ts +9 -0
- package/dist/types/describe/describe.d.ts +1 -0
- package/dist/types/index.d.ts +5 -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/dist/types/syntheticFixtures/tagTemplate.d.ts +11 -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";
|
|
@@ -45,11 +49,56 @@ function buildDicomdirBuffer() {
|
|
|
45
49
|
return buf.subarray(0, off);
|
|
46
50
|
}
|
|
47
51
|
|
|
52
|
+
// src/syntheticFixtures/tagTemplate.ts
|
|
53
|
+
var TEMPLATE_VOCAB = [
|
|
54
|
+
"index",
|
|
55
|
+
"studyIndex",
|
|
56
|
+
"seriesIndex",
|
|
57
|
+
"instanceNumber"
|
|
58
|
+
];
|
|
59
|
+
var TEMPLATE_PART_RE = /\{\{|\}\}|\{([a-zA-Z]+)\}/g;
|
|
60
|
+
function escapeTagTemplate(value) {
|
|
61
|
+
return value.replace(/\{/g, "{{").replace(/\}/g, "}}");
|
|
62
|
+
}
|
|
63
|
+
function findTemplateTokens(value) {
|
|
64
|
+
const names = [];
|
|
65
|
+
for (const m of value.matchAll(TEMPLATE_PART_RE)) {
|
|
66
|
+
if (m[1] !== void 0) names.push(m[1]);
|
|
67
|
+
}
|
|
68
|
+
return names;
|
|
69
|
+
}
|
|
70
|
+
function resolveTagTemplates(tags, context) {
|
|
71
|
+
if (!tags) return tags;
|
|
72
|
+
const resolved = {};
|
|
73
|
+
for (const [key, value] of Object.entries(tags)) {
|
|
74
|
+
resolved[key] = typeof value === "string" ? resolveString(value, context) : value;
|
|
75
|
+
}
|
|
76
|
+
return resolved;
|
|
77
|
+
}
|
|
78
|
+
function resolveString(value, context) {
|
|
79
|
+
return value.replace(TEMPLATE_PART_RE, (match, name) => {
|
|
80
|
+
if (name === void 0) return match === "{{" ? "{" : "}";
|
|
81
|
+
if (!TEMPLATE_VOCAB.includes(name)) {
|
|
82
|
+
throw new Error(
|
|
83
|
+
`tag template: unknown placeholder "{${name}}" \u2014 allowed: ${TEMPLATE_VOCAB.map((v) => `{${v}}`).join(", ")}`
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
const resolved = context[name];
|
|
87
|
+
if (resolved === void 0) {
|
|
88
|
+
throw new Error(
|
|
89
|
+
`tag template: placeholder "{${name}}" is not available here \u2014 it only applies inside grouped studies`
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
return String(resolved);
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
48
96
|
// src/schema/validate.ts
|
|
49
97
|
var TYPE_CAPABILITIES = {
|
|
50
98
|
"valid-image": { image: true },
|
|
51
99
|
"invalid-uid-image": { image: true },
|
|
52
100
|
"vendor-warnings-image": { image: true },
|
|
101
|
+
"large-image": { image: true },
|
|
53
102
|
"fake-signature": { image: false },
|
|
54
103
|
"non-dicom": { image: false },
|
|
55
104
|
dicomdir: { image: false }
|
|
@@ -74,7 +123,6 @@ var VALID_TRANSFER_SYNTAXES = {
|
|
|
74
123
|
"explicit-vr-little-endian": true,
|
|
75
124
|
"implicit-vr-little-endian": true
|
|
76
125
|
};
|
|
77
|
-
var MAX_PIXEL_BYTES = 512 * 1024 * 1024;
|
|
78
126
|
var VALID_VIOLATIONS = {
|
|
79
127
|
"uid-too-long": true,
|
|
80
128
|
"non-conformant-uid": true,
|
|
@@ -92,6 +140,13 @@ function validatePositiveInt(value, path) {
|
|
|
92
140
|
);
|
|
93
141
|
}
|
|
94
142
|
}
|
|
143
|
+
function validateNonNegativeInt(value, path) {
|
|
144
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < 0) {
|
|
145
|
+
throw new Error(
|
|
146
|
+
`${path}: must be a non-negative integer; got ${JSON.stringify(value)}`
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
95
150
|
function validateSeed(value, path) {
|
|
96
151
|
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
97
152
|
throw new Error(
|
|
@@ -104,6 +159,43 @@ function validateSizeKbLimit(kb, path) {
|
|
|
104
159
|
throw new Error(`${path}: exceeds the 512 MB limit`);
|
|
105
160
|
}
|
|
106
161
|
}
|
|
162
|
+
function validateLargeTargetBytes(value, path) {
|
|
163
|
+
if (typeof value !== "number" || !Number.isInteger(value)) {
|
|
164
|
+
throw new Error(`${path}: must be an integer; got ${JSON.stringify(value)}`);
|
|
165
|
+
}
|
|
166
|
+
if (value <= MAX_PIXEL_BYTES) {
|
|
167
|
+
throw new Error(
|
|
168
|
+
`${path}: must exceed 512 MB \u2014 use targetSizeKb for smaller files`
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
if (value > MAX_STREAM_BYTES) {
|
|
172
|
+
throw new Error(`${path}: exceeds the 50 GB limit`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
function validateLargeImageEntry(e, path) {
|
|
176
|
+
for (const field of [
|
|
177
|
+
"rows",
|
|
178
|
+
"columns",
|
|
179
|
+
"frames",
|
|
180
|
+
"targetSizeKb",
|
|
181
|
+
"violations",
|
|
182
|
+
"transferSyntax"
|
|
183
|
+
]) {
|
|
184
|
+
if (field in e) {
|
|
185
|
+
throw new Error(
|
|
186
|
+
`${path}.${field}: not supported for type "large-image" \u2014 use targetBytes`
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
if (!("targetBytes" in e)) {
|
|
191
|
+
throw new Error(`${path}.targetBytes: is required for type "large-image"`);
|
|
192
|
+
}
|
|
193
|
+
validateLargeTargetBytes(e.targetBytes, `${path}.targetBytes`);
|
|
194
|
+
if ("modality" in e) {
|
|
195
|
+
validateEnum(e.modality, VALID_MODALITIES, `${path}.modality`);
|
|
196
|
+
}
|
|
197
|
+
if ("tags" in e) validateTags(e.tags, `${path}.tags`);
|
|
198
|
+
}
|
|
107
199
|
function validateEnum(value, valid, path) {
|
|
108
200
|
if (typeof value !== "string" || !Object.hasOwn(valid, value)) {
|
|
109
201
|
throw new Error(
|
|
@@ -127,6 +219,15 @@ function validateEntry(entry, path) {
|
|
|
127
219
|
const type = e.type;
|
|
128
220
|
const caps = TYPE_CAPABILITIES[type];
|
|
129
221
|
if ("count" in e) validatePositiveInt(e.count, `${path}.count`);
|
|
222
|
+
if (type === "large-image") {
|
|
223
|
+
validateLargeImageEntry(e, path);
|
|
224
|
+
return e;
|
|
225
|
+
}
|
|
226
|
+
if ("targetBytes" in e) {
|
|
227
|
+
throw new Error(
|
|
228
|
+
`${path}.targetBytes: only supported for type "large-image"`
|
|
229
|
+
);
|
|
230
|
+
}
|
|
130
231
|
for (const field of [
|
|
131
232
|
"modality",
|
|
132
233
|
"rows",
|
|
@@ -186,12 +287,28 @@ function validateEntry(entry, path) {
|
|
|
186
287
|
}
|
|
187
288
|
return e;
|
|
188
289
|
}
|
|
290
|
+
var MODALITY_TAG = "00080060";
|
|
189
291
|
function validateTags(tags, path) {
|
|
190
292
|
if (typeof tags !== "object" || tags === null || Array.isArray(tags)) {
|
|
191
293
|
throw new Error(`${path}: must be an object`);
|
|
192
294
|
}
|
|
193
|
-
for (const key of Object.
|
|
295
|
+
for (const [key, value] of Object.entries(tags)) {
|
|
194
296
|
validateTagKey(key, path);
|
|
297
|
+
const isModalityKey = key === "Modality" || key.toUpperCase() === MODALITY_TAG;
|
|
298
|
+
if (isModalityKey && typeof value === "string" && Object.hasOwn(VALID_MODALITIES, value)) {
|
|
299
|
+
throw new Error(
|
|
300
|
+
`${path}.${key}: set a preset modality (${Object.keys(VALID_MODALITIES).join("/")}) via the "modality" field, not tags \u2014 it is coupled to SOPClassUID and type-1 attributes`
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
if (typeof value === "string") {
|
|
304
|
+
for (const name of findTemplateTokens(value)) {
|
|
305
|
+
if (!TEMPLATE_VOCAB.includes(name)) {
|
|
306
|
+
throw new Error(
|
|
307
|
+
`${path}.${key}: unknown placeholder "{${name}}" \u2014 allowed: ${TEMPLATE_VOCAB.map((v) => `{${v}}`).join(", ")}`
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
195
312
|
}
|
|
196
313
|
}
|
|
197
314
|
function validateSeries(series, path) {
|
|
@@ -272,19 +389,20 @@ function validateDatasetSpec(raw) {
|
|
|
272
389
|
...r.pathQuirks !== void 0 ? { pathQuirks: r.pathQuirks } : {}
|
|
273
390
|
};
|
|
274
391
|
}
|
|
275
|
-
function validateRange(value, path) {
|
|
392
|
+
function validateRange(value, path, options = {}) {
|
|
393
|
+
const checkInt = options.allowZero ? validateNonNegativeInt : validatePositiveInt;
|
|
276
394
|
if (typeof value === "number") {
|
|
277
|
-
|
|
395
|
+
checkInt(value, path);
|
|
278
396
|
return value;
|
|
279
397
|
}
|
|
280
398
|
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
281
399
|
throw new Error(
|
|
282
|
-
`${path}: must be
|
|
400
|
+
`${path}: must be an integer or { min, max }; got ${JSON.stringify(value)}`
|
|
283
401
|
);
|
|
284
402
|
}
|
|
285
403
|
const r = value;
|
|
286
|
-
|
|
287
|
-
|
|
404
|
+
checkInt(r.min, `${path}.min`);
|
|
405
|
+
checkInt(r.max, `${path}.max`);
|
|
288
406
|
if (r.min > r.max) {
|
|
289
407
|
throw new Error(`${path}: min (${r.min}) must be \u2264 max (${r.max})`);
|
|
290
408
|
}
|
|
@@ -293,6 +411,9 @@ function validateRange(value, path) {
|
|
|
293
411
|
function rangeMax(range) {
|
|
294
412
|
return typeof range === "number" ? range : range.max;
|
|
295
413
|
}
|
|
414
|
+
function rangeMin(range) {
|
|
415
|
+
return typeof range === "number" ? range : range.min;
|
|
416
|
+
}
|
|
296
417
|
function validateParametricStudies(studies, path) {
|
|
297
418
|
if (typeof studies !== "object" || studies === null || Array.isArray(studies)) {
|
|
298
419
|
throw new Error(`${path}: must be an object`);
|
|
@@ -306,6 +427,32 @@ function validateParametricStudies(studies, path) {
|
|
|
306
427
|
validateRange(s.fileSizeKb, `${path}.fileSizeKb`);
|
|
307
428
|
validateSizeKbLimit(rangeMax(s.fileSizeKb), `${path}.fileSizeKb`);
|
|
308
429
|
}
|
|
430
|
+
if ("largeFilesPerSeries" in s) {
|
|
431
|
+
validateRange(s.largeFilesPerSeries, `${path}.largeFilesPerSeries`, {
|
|
432
|
+
allowZero: true
|
|
433
|
+
});
|
|
434
|
+
if (!("largeFileBytes" in s)) {
|
|
435
|
+
throw new Error(
|
|
436
|
+
`${path}.largeFileBytes: is required when largeFilesPerSeries is set`
|
|
437
|
+
);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
if ("largeFileBytes" in s) {
|
|
441
|
+
if (!("largeFilesPerSeries" in s)) {
|
|
442
|
+
throw new Error(
|
|
443
|
+
`${path}.largeFilesPerSeries: is required when largeFileBytes is set`
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
validateRange(s.largeFileBytes, `${path}.largeFileBytes`);
|
|
447
|
+
validateLargeTargetBytes(
|
|
448
|
+
rangeMin(s.largeFileBytes),
|
|
449
|
+
`${path}.largeFileBytes (min)`
|
|
450
|
+
);
|
|
451
|
+
validateLargeTargetBytes(
|
|
452
|
+
rangeMax(s.largeFileBytes),
|
|
453
|
+
`${path}.largeFileBytes (max)`
|
|
454
|
+
);
|
|
455
|
+
}
|
|
309
456
|
if ("modalities" in s) {
|
|
310
457
|
if (!Array.isArray(s.modalities) || s.modalities.length === 0) {
|
|
311
458
|
throw new Error(`${path}.modalities: must be a non-empty array`);
|
|
@@ -535,9 +682,17 @@ function buildBufferForSpec(spec, uid) {
|
|
|
535
682
|
return buildNonDicomBuffer(spec.content);
|
|
536
683
|
case "dicomdir":
|
|
537
684
|
return buildDicomdirBuffer();
|
|
685
|
+
case "large-image":
|
|
686
|
+
throw new Error(
|
|
687
|
+
"large-image cannot be generated in memory \u2014 use writeCollectionFromSpec (disk-only streaming)"
|
|
688
|
+
);
|
|
538
689
|
}
|
|
539
690
|
}
|
|
540
691
|
|
|
692
|
+
// src/syntheticFixtures/streamWrite.ts
|
|
693
|
+
import { closeSync, mkdirSync as mkdirSync2, openSync, writeSync } from "node:fs";
|
|
694
|
+
import { resolve as resolve2 } from "node:path";
|
|
695
|
+
|
|
541
696
|
// src/syntheticFixtures/uid.ts
|
|
542
697
|
import { randomBytes } from "node:crypto";
|
|
543
698
|
var ROLE_CODE = { study: 1, series: 2, sop: 3 };
|
|
@@ -604,6 +759,173 @@ function makeUidGenerator(seed) {
|
|
|
604
759
|
};
|
|
605
760
|
}
|
|
606
761
|
|
|
762
|
+
// src/syntheticFixtures/streamWrite.ts
|
|
763
|
+
var OW_MAX_BYTES = 4294967294;
|
|
764
|
+
var FRAGMENT_MAX_BYTES = 4294967294;
|
|
765
|
+
var DEFAULT_CHUNK_BYTES = 8 * 1024 * 1024;
|
|
766
|
+
var RLE_TRANSFER_SYNTAX_UID = "1.2.840.10008.1.2.5";
|
|
767
|
+
var PIXEL_DATA_OW_HEADER = Buffer.from([224, 127, 16, 0, 79, 87]);
|
|
768
|
+
var PIXEL_DATA_OB_HEADER = Buffer.from([224, 127, 16, 0, 79, 66]);
|
|
769
|
+
var ITEM_TAG_GROUP = 65534;
|
|
770
|
+
var ITEM_TAG_ELEMENT = 57344;
|
|
771
|
+
var SEQUENCE_DELIMITER_ELEMENT = 57565;
|
|
772
|
+
function itemTag(element, byteLength = 0) {
|
|
773
|
+
const b = Buffer.alloc(8);
|
|
774
|
+
b.writeUInt16LE(ITEM_TAG_GROUP, 0);
|
|
775
|
+
b.writeUInt16LE(element, 2);
|
|
776
|
+
b.writeUInt32LE(byteLength, 4);
|
|
777
|
+
return b;
|
|
778
|
+
}
|
|
779
|
+
function nativeDimensions(pixelBytes) {
|
|
780
|
+
const cells = Math.max(1, Math.floor(pixelBytes / 2));
|
|
781
|
+
const rows = Math.min(65535, Math.max(1, Math.round(Math.sqrt(cells))));
|
|
782
|
+
const columns = Math.min(65535, Math.max(1, Math.floor(cells / rows)));
|
|
783
|
+
return { rows, columns, bytes: rows * columns * 2 };
|
|
784
|
+
}
|
|
785
|
+
function streamZeros(fd, totalBytes, zeroChunk) {
|
|
786
|
+
let remaining = totalBytes;
|
|
787
|
+
while (remaining > 0) {
|
|
788
|
+
const n = Math.min(zeroChunk.length, remaining);
|
|
789
|
+
writeSync(fd, zeroChunk, 0, n);
|
|
790
|
+
remaining -= n;
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
function writeNative(outPath, params, dims, zeroChunk) {
|
|
794
|
+
const { modality, uid, tags } = params;
|
|
795
|
+
const meta = buildMeta(uid, modality);
|
|
796
|
+
const dataset = buildBaseImageDataset(uid, modality);
|
|
797
|
+
applyTagOverrides(dataset, tags);
|
|
798
|
+
dataset.Rows = dims.rows;
|
|
799
|
+
dataset.Columns = dims.columns;
|
|
800
|
+
const minimalPixelBytes = dataset.PixelData.byteLength;
|
|
801
|
+
const probe = serializeDict(meta, dataset);
|
|
802
|
+
const pixelElement = Buffer.alloc(12);
|
|
803
|
+
PIXEL_DATA_OW_HEADER.copy(pixelElement);
|
|
804
|
+
pixelElement.writeUInt32LE(minimalPixelBytes, 8);
|
|
805
|
+
const owIdx = probe.indexOf(pixelElement);
|
|
806
|
+
if (owIdx < 0) {
|
|
807
|
+
throw new Error("failed to locate native PixelData element header");
|
|
808
|
+
}
|
|
809
|
+
const dataStart = owIdx + 12;
|
|
810
|
+
const header = Buffer.from(probe.subarray(0, dataStart));
|
|
811
|
+
header.writeUInt32LE(dims.bytes, owIdx + 8);
|
|
812
|
+
const trailing = probe.subarray(dataStart + minimalPixelBytes);
|
|
813
|
+
const fd = openSync(outPath, "w");
|
|
814
|
+
try {
|
|
815
|
+
writeSync(fd, header);
|
|
816
|
+
streamZeros(fd, dims.bytes, zeroChunk);
|
|
817
|
+
if (trailing.length > 0) writeSync(fd, trailing);
|
|
818
|
+
} finally {
|
|
819
|
+
closeSync(fd);
|
|
820
|
+
}
|
|
821
|
+
return header.length + dims.bytes + trailing.length;
|
|
822
|
+
}
|
|
823
|
+
function writeEncapsulated(outPath, params, pixelBytes, fragmentBytes, zeroChunk) {
|
|
824
|
+
const { modality, uid, tags } = params;
|
|
825
|
+
const meta = {
|
|
826
|
+
...buildMeta(uid, modality),
|
|
827
|
+
TransferSyntaxUID: RLE_TRANSFER_SYNTAX_UID
|
|
828
|
+
};
|
|
829
|
+
const dataset = buildBaseImageDataset(uid, modality);
|
|
830
|
+
applyTagOverrides(dataset, tags);
|
|
831
|
+
dataset.PixelData = [new Uint8Array([]).buffer, new Uint8Array([0, 0]).buffer];
|
|
832
|
+
dataset._vrMap = { PixelData: "OB" };
|
|
833
|
+
const probe = serializeDict(meta, dataset);
|
|
834
|
+
const idx = probe.indexOf(PIXEL_DATA_OB_HEADER);
|
|
835
|
+
if (idx < 0 || probe.readUInt32LE(idx + 8) !== 4294967295) {
|
|
836
|
+
throw new Error("failed to locate encapsulated PixelData element header");
|
|
837
|
+
}
|
|
838
|
+
const prefix = Buffer.from(probe.subarray(0, idx + 12));
|
|
839
|
+
const fd = openSync(outPath, "w");
|
|
840
|
+
let bytesWritten = 0;
|
|
841
|
+
try {
|
|
842
|
+
writeSync(fd, prefix);
|
|
843
|
+
bytesWritten += prefix.length;
|
|
844
|
+
const bot = itemTag(ITEM_TAG_ELEMENT, 0);
|
|
845
|
+
writeSync(fd, bot);
|
|
846
|
+
bytesWritten += bot.length;
|
|
847
|
+
let remaining = pixelBytes;
|
|
848
|
+
while (remaining > 0) {
|
|
849
|
+
const fragLen = Math.min(fragmentBytes, remaining);
|
|
850
|
+
const fh = itemTag(ITEM_TAG_ELEMENT, fragLen);
|
|
851
|
+
writeSync(fd, fh);
|
|
852
|
+
streamZeros(fd, fragLen, zeroChunk);
|
|
853
|
+
bytesWritten += fh.length + fragLen;
|
|
854
|
+
remaining -= fragLen;
|
|
855
|
+
}
|
|
856
|
+
const delimiter = itemTag(SEQUENCE_DELIMITER_ELEMENT);
|
|
857
|
+
writeSync(fd, delimiter);
|
|
858
|
+
bytesWritten += delimiter.length;
|
|
859
|
+
} finally {
|
|
860
|
+
closeSync(fd);
|
|
861
|
+
}
|
|
862
|
+
return bytesWritten;
|
|
863
|
+
}
|
|
864
|
+
function streamLargeImage(outPath, options) {
|
|
865
|
+
const {
|
|
866
|
+
targetBytes,
|
|
867
|
+
chunkBytes = DEFAULT_CHUNK_BYTES,
|
|
868
|
+
owMaxBytes = OW_MAX_BYTES,
|
|
869
|
+
fragmentBytes = FRAGMENT_MAX_BYTES
|
|
870
|
+
} = options;
|
|
871
|
+
const modality = options.modality ?? "CT";
|
|
872
|
+
const uid = options.uid ?? makeUidGenerator(options.seed)(0);
|
|
873
|
+
const identity = { modality, uid, tags: options.tags };
|
|
874
|
+
mkdirSync2(resolve2(outPath, ".."), { recursive: true });
|
|
875
|
+
const zeroChunk = Buffer.alloc(Math.max(1, chunkBytes));
|
|
876
|
+
const probe = serializeDict(
|
|
877
|
+
buildMeta(uid, modality),
|
|
878
|
+
buildBaseImageDataset(uid, modality)
|
|
879
|
+
);
|
|
880
|
+
const nativeHeaderLen = probe.length - 2;
|
|
881
|
+
const requestedPixel = Math.max(2, targetBytes - nativeHeaderLen);
|
|
882
|
+
if (requestedPixel <= owMaxBytes) {
|
|
883
|
+
const dims = nativeDimensions(requestedPixel);
|
|
884
|
+
const bytesWritten2 = writeNative(outPath, identity, dims, zeroChunk);
|
|
885
|
+
return {
|
|
886
|
+
path: outPath,
|
|
887
|
+
bytesWritten: bytesWritten2,
|
|
888
|
+
encoding: "native",
|
|
889
|
+
pixelBytes: dims.bytes
|
|
890
|
+
};
|
|
891
|
+
}
|
|
892
|
+
const estimateFrags = Math.max(
|
|
893
|
+
1,
|
|
894
|
+
Math.ceil((targetBytes - nativeHeaderLen - 16) / fragmentBytes)
|
|
895
|
+
);
|
|
896
|
+
const overhead = nativeHeaderLen + 8 + estimateFrags * 8 + 8;
|
|
897
|
+
let pixelBytes = Math.max(2, targetBytes - overhead);
|
|
898
|
+
pixelBytes -= pixelBytes % 2;
|
|
899
|
+
const bytesWritten = writeEncapsulated(
|
|
900
|
+
outPath,
|
|
901
|
+
identity,
|
|
902
|
+
pixelBytes,
|
|
903
|
+
fragmentBytes - fragmentBytes % 2,
|
|
904
|
+
zeroChunk
|
|
905
|
+
);
|
|
906
|
+
return { path: outPath, bytesWritten, encoding: "encapsulated", pixelBytes };
|
|
907
|
+
}
|
|
908
|
+
function writeLargeImageFile(spec, outPath, options = {}) {
|
|
909
|
+
const { targetBytes } = spec;
|
|
910
|
+
if (!Number.isInteger(targetBytes)) {
|
|
911
|
+
throw new Error("writeLargeImageFile: targetBytes must be an integer");
|
|
912
|
+
}
|
|
913
|
+
if (targetBytes <= MAX_PIXEL_BYTES) {
|
|
914
|
+
throw new Error(
|
|
915
|
+
"writeLargeImageFile is for files above the 512 MB in-memory limit \u2014 use targetSizeKb for smaller files"
|
|
916
|
+
);
|
|
917
|
+
}
|
|
918
|
+
if (targetBytes > MAX_STREAM_BYTES) {
|
|
919
|
+
throw new Error("writeLargeImageFile: targetBytes exceeds the 50 GB limit");
|
|
920
|
+
}
|
|
921
|
+
return streamLargeImage(outPath, {
|
|
922
|
+
targetBytes,
|
|
923
|
+
modality: spec.modality,
|
|
924
|
+
seed: spec.seed,
|
|
925
|
+
chunkBytes: options.chunkBytes
|
|
926
|
+
});
|
|
927
|
+
}
|
|
928
|
+
|
|
607
929
|
// src/syntheticFixtures/violations.ts
|
|
608
930
|
var dcmjsAny = dcmjs;
|
|
609
931
|
function parseBuffer(buffer) {
|
|
@@ -717,12 +1039,18 @@ function studyFileCount(studies) {
|
|
|
717
1039
|
0
|
|
718
1040
|
);
|
|
719
1041
|
}
|
|
1042
|
+
function resolveUid(seed, index, override) {
|
|
1043
|
+
return { ...makeUidGenerator(seed)(index), ...override };
|
|
1044
|
+
}
|
|
720
1045
|
async function generateFile(spec, options) {
|
|
721
1046
|
const index = options?.index ?? 0;
|
|
722
1047
|
const padWidth = options?.padWidth ?? 3;
|
|
723
|
-
const
|
|
724
|
-
const
|
|
725
|
-
|
|
1048
|
+
const uid = resolveUid(options?.seed, index, options?.uid);
|
|
1049
|
+
const resolvedSpec = "tags" in spec && spec.tags ? {
|
|
1050
|
+
...spec,
|
|
1051
|
+
tags: resolveTagTemplates(spec.tags, { index, ...options?.context })
|
|
1052
|
+
} : spec;
|
|
1053
|
+
let buffer = buildBufferForSpec(resolvedSpec, uid);
|
|
726
1054
|
const violations = "violations" in spec ? spec.violations : void 0;
|
|
727
1055
|
if (violations?.length) {
|
|
728
1056
|
buffer = applyViolations(buffer, violations);
|
|
@@ -789,25 +1117,43 @@ function hierarchicalName(studyOrdinal, seriesIndex, instanceNumber) {
|
|
|
789
1117
|
function withResolvedSeed(spec) {
|
|
790
1118
|
return spec.seed === void 0 ? { ...spec, seed: randomBytes2(4).readUInt32BE(0) } : spec;
|
|
791
1119
|
}
|
|
792
|
-
|
|
1120
|
+
function computeNames(type, index, padWidth, grouped, quirks) {
|
|
1121
|
+
let names;
|
|
1122
|
+
if (grouped) {
|
|
1123
|
+
names = hierarchicalName(
|
|
1124
|
+
grouped.studyOrdinal,
|
|
1125
|
+
grouped.seriesIndex,
|
|
1126
|
+
grouped.instanceNumber
|
|
1127
|
+
);
|
|
1128
|
+
} else {
|
|
1129
|
+
const name = filename(type, index, padWidth);
|
|
1130
|
+
names = { filename: name, relativePath: name };
|
|
1131
|
+
}
|
|
1132
|
+
return quirks.length === 0 ? names : applyPathQuirks(names.relativePath, quirks);
|
|
1133
|
+
}
|
|
1134
|
+
function* planCollection(spec) {
|
|
793
1135
|
const flatEntries = spec.entries ?? [];
|
|
794
1136
|
const studies = spec.studies ?? [];
|
|
795
1137
|
const totalFiles = entryCount(flatEntries) + studyFileCount(studies);
|
|
796
1138
|
const padWidth = String(totalFiles).length;
|
|
797
1139
|
const hierarchical = spec.layout === "hierarchical";
|
|
798
1140
|
const quirks = spec.pathQuirks ?? [];
|
|
799
|
-
const decorate = (file) => quirks.length === 0 ? file : { ...file, ...applyPathQuirks(file.relativePath, quirks) };
|
|
800
1141
|
let globalIndex = 0;
|
|
801
1142
|
for (const entry of flatEntries) {
|
|
802
1143
|
const { count = 1, ...fileSpec } = entry;
|
|
803
1144
|
for (let i = 0; i < count; i++) {
|
|
804
|
-
yield
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
1145
|
+
yield {
|
|
1146
|
+
fileSpec,
|
|
1147
|
+
index: globalIndex,
|
|
1148
|
+
context: { index: globalIndex },
|
|
1149
|
+
...computeNames(
|
|
1150
|
+
fileSpec.type,
|
|
1151
|
+
globalIndex,
|
|
1152
|
+
padWidth,
|
|
1153
|
+
void 0,
|
|
1154
|
+
quirks
|
|
1155
|
+
)
|
|
1156
|
+
};
|
|
811
1157
|
globalIndex++;
|
|
812
1158
|
}
|
|
813
1159
|
}
|
|
@@ -830,25 +1176,24 @@ async function* generateCollectionFromSpec(spec) {
|
|
|
830
1176
|
...series.tags,
|
|
831
1177
|
...fileSpec.tags
|
|
832
1178
|
};
|
|
833
|
-
|
|
834
|
-
{ ...fileSpec, tags },
|
|
835
|
-
|
|
1179
|
+
yield {
|
|
1180
|
+
fileSpec: { ...fileSpec, tags },
|
|
1181
|
+
index: globalIndex,
|
|
1182
|
+
uidOverride: { study: studyUid, series: seriesUid },
|
|
1183
|
+
context: {
|
|
836
1184
|
index: globalIndex,
|
|
837
|
-
|
|
1185
|
+
studyIndex: studyOrdinal,
|
|
1186
|
+
seriesIndex,
|
|
1187
|
+
instanceNumber
|
|
1188
|
+
},
|
|
1189
|
+
...computeNames(
|
|
1190
|
+
fileSpec.type,
|
|
1191
|
+
globalIndex,
|
|
838
1192
|
padWidth,
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
hierarchical ? {
|
|
844
|
-
...file,
|
|
845
|
-
...hierarchicalName(
|
|
846
|
-
studyOrdinal,
|
|
847
|
-
seriesIndex,
|
|
848
|
-
instanceNumber
|
|
849
|
-
)
|
|
850
|
-
} : file
|
|
851
|
-
);
|
|
1193
|
+
hierarchical ? { studyOrdinal, seriesIndex, instanceNumber } : void 0,
|
|
1194
|
+
quirks
|
|
1195
|
+
)
|
|
1196
|
+
};
|
|
852
1197
|
globalIndex++;
|
|
853
1198
|
}
|
|
854
1199
|
}
|
|
@@ -857,30 +1202,102 @@ async function* generateCollectionFromSpec(spec) {
|
|
|
857
1202
|
}
|
|
858
1203
|
}
|
|
859
1204
|
}
|
|
1205
|
+
async function materialisePlan(plan, seed) {
|
|
1206
|
+
const file = await generateFile(plan.fileSpec, {
|
|
1207
|
+
index: plan.index,
|
|
1208
|
+
seed,
|
|
1209
|
+
uid: plan.uidOverride,
|
|
1210
|
+
context: plan.context
|
|
1211
|
+
});
|
|
1212
|
+
return { ...file, filename: plan.filename, relativePath: plan.relativePath };
|
|
1213
|
+
}
|
|
1214
|
+
async function* generateCollectionFromSpec(spec) {
|
|
1215
|
+
for (const plan of planCollection(spec)) {
|
|
1216
|
+
yield await materialisePlan(plan, spec.seed);
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
async function* previewCollection(spec) {
|
|
1220
|
+
for (const plan of planCollection(spec)) {
|
|
1221
|
+
if (plan.fileSpec.type === "large-image") {
|
|
1222
|
+
yield {
|
|
1223
|
+
relativePath: plan.relativePath,
|
|
1224
|
+
type: "large-image",
|
|
1225
|
+
index: plan.index,
|
|
1226
|
+
approxBytes: plan.fileSpec.targetBytes
|
|
1227
|
+
};
|
|
1228
|
+
} else {
|
|
1229
|
+
const file = await materialisePlan(plan, spec.seed);
|
|
1230
|
+
yield {
|
|
1231
|
+
relativePath: plan.relativePath,
|
|
1232
|
+
type: plan.fileSpec.type,
|
|
1233
|
+
index: plan.index,
|
|
1234
|
+
approxBytes: file.buffer.length
|
|
1235
|
+
};
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
860
1239
|
async function writeCollectionFromSpec(spec, outDir) {
|
|
861
|
-
const root =
|
|
862
|
-
|
|
1240
|
+
const root = resolve3(outDir);
|
|
1241
|
+
mkdirSync3(root, { recursive: true });
|
|
863
1242
|
const manifest = [];
|
|
864
1243
|
const createdDirs = /* @__PURE__ */ new Set([root]);
|
|
865
|
-
|
|
866
|
-
const filePath = resolve2(root, file.relativePath);
|
|
1244
|
+
const ensureDir = (filePath) => {
|
|
867
1245
|
const dir = dirname(filePath);
|
|
868
1246
|
if (!createdDirs.has(dir)) {
|
|
869
|
-
|
|
1247
|
+
mkdirSync3(dir, { recursive: true });
|
|
870
1248
|
createdDirs.add(dir);
|
|
871
1249
|
}
|
|
872
|
-
|
|
873
|
-
|
|
1250
|
+
};
|
|
1251
|
+
for (const plan of planCollection(spec)) {
|
|
1252
|
+
const filePath = resolve3(root, plan.relativePath);
|
|
1253
|
+
ensureDir(filePath);
|
|
1254
|
+
if (plan.fileSpec.type === "large-image") {
|
|
1255
|
+
const large = plan.fileSpec;
|
|
1256
|
+
if (large.targetBytes > MAX_STREAM_BYTES) {
|
|
1257
|
+
throw new Error(
|
|
1258
|
+
`large-image targetBytes (${large.targetBytes}) exceeds the 50 GB limit`
|
|
1259
|
+
);
|
|
1260
|
+
}
|
|
1261
|
+
const uid = resolveUid(spec.seed, plan.index, plan.uidOverride);
|
|
1262
|
+
streamLargeImage(filePath, {
|
|
1263
|
+
targetBytes: large.targetBytes,
|
|
1264
|
+
modality: large.modality,
|
|
1265
|
+
uid,
|
|
1266
|
+
tags: resolveTagTemplates(large.tags, plan.context)
|
|
1267
|
+
});
|
|
1268
|
+
} else {
|
|
1269
|
+
const file = await materialisePlan(plan, spec.seed);
|
|
1270
|
+
await writeFile(filePath, file.buffer);
|
|
1271
|
+
}
|
|
1272
|
+
manifest.push({
|
|
1273
|
+
path: filePath,
|
|
1274
|
+
type: plan.fileSpec.type,
|
|
1275
|
+
index: plan.index
|
|
1276
|
+
});
|
|
874
1277
|
}
|
|
875
1278
|
return manifest;
|
|
876
1279
|
}
|
|
877
1280
|
|
|
878
1281
|
// src/describe/describe.ts
|
|
879
|
-
import {
|
|
1282
|
+
import {
|
|
1283
|
+
closeSync as closeSync2,
|
|
1284
|
+
openSync as openSync2,
|
|
1285
|
+
readdirSync,
|
|
1286
|
+
readFileSync,
|
|
1287
|
+
readSync,
|
|
1288
|
+
statSync
|
|
1289
|
+
} from "node:fs";
|
|
880
1290
|
import { join } from "node:path";
|
|
881
1291
|
var dcmjsAny2 = dcmjs;
|
|
882
1292
|
var HEX_TAG_RE2 = /^[0-9a-fA-F]{8}$/;
|
|
883
1293
|
var SUPPORTED_MODALITIES = /* @__PURE__ */ new Set(["CT", "PT", "MR", "CR"]);
|
|
1294
|
+
var RESERVED_KEEP_KEYWORDS = /* @__PURE__ */ new Set([
|
|
1295
|
+
"Units",
|
|
1296
|
+
"DecayCorrection",
|
|
1297
|
+
"CorrectedImage",
|
|
1298
|
+
"ScanningSequence",
|
|
1299
|
+
"SequenceVariant"
|
|
1300
|
+
]);
|
|
884
1301
|
function buildHexToKeyword() {
|
|
885
1302
|
const map = /* @__PURE__ */ new Map();
|
|
886
1303
|
const nm = dcmjsAny2.data.DicomMetaDictionary.nameMap;
|
|
@@ -896,20 +1313,37 @@ function buildHexToKeyword() {
|
|
|
896
1313
|
function resolveKeepTags(keepTags) {
|
|
897
1314
|
const nameMap = dcmjsAny2.data.DicomMetaDictionary.nameMap;
|
|
898
1315
|
let hexToKeyword;
|
|
899
|
-
|
|
1316
|
+
const kept = [];
|
|
1317
|
+
for (const tag of keepTags) {
|
|
1318
|
+
let keyword;
|
|
900
1319
|
if (HEX_TAG_RE2.test(tag)) {
|
|
901
1320
|
hexToKeyword ?? (hexToKeyword = buildHexToKeyword());
|
|
902
|
-
const
|
|
903
|
-
if (!
|
|
1321
|
+
const mapped = hexToKeyword.get(tag.toUpperCase());
|
|
1322
|
+
if (!mapped) {
|
|
904
1323
|
throw new Error(`describe: unknown hex tag "${tag}"`);
|
|
905
1324
|
}
|
|
906
|
-
|
|
1325
|
+
keyword = mapped;
|
|
1326
|
+
} else {
|
|
1327
|
+
if (nameMap && !Object.hasOwn(nameMap, tag)) {
|
|
1328
|
+
throw new Error(`describe: unknown tag keyword "${tag}"`);
|
|
1329
|
+
}
|
|
1330
|
+
keyword = tag;
|
|
907
1331
|
}
|
|
908
|
-
if (
|
|
909
|
-
|
|
1332
|
+
if (RESERVED_KEEP_KEYWORDS.has(keyword)) {
|
|
1333
|
+
console.warn(
|
|
1334
|
+
`describe: ignoring keep-tag "${keyword}" \u2014 modality-coupled tags are reproduced from the modality field`
|
|
1335
|
+
);
|
|
1336
|
+
continue;
|
|
910
1337
|
}
|
|
911
|
-
|
|
912
|
-
|
|
1338
|
+
if (nameMap?.[keyword]?.vr === "UI") {
|
|
1339
|
+
console.warn(
|
|
1340
|
+
`describe: ignoring keep-tag "${keyword}" \u2014 UID-valued tags are not kept verbatim`
|
|
1341
|
+
);
|
|
1342
|
+
continue;
|
|
1343
|
+
}
|
|
1344
|
+
kept.push(keyword);
|
|
1345
|
+
}
|
|
1346
|
+
return kept;
|
|
913
1347
|
}
|
|
914
1348
|
function* walkFiles(dir) {
|
|
915
1349
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
@@ -921,18 +1355,77 @@ function* walkFiles(dir) {
|
|
|
921
1355
|
}
|
|
922
1356
|
}
|
|
923
1357
|
}
|
|
924
|
-
function
|
|
1358
|
+
function toArrayBuffer(buf) {
|
|
1359
|
+
return buf.buffer.slice(
|
|
1360
|
+
buf.byteOffset,
|
|
1361
|
+
buf.byteOffset + buf.byteLength
|
|
1362
|
+
);
|
|
1363
|
+
}
|
|
1364
|
+
function naturalize(ab) {
|
|
1365
|
+
const parsed = dcmjsAny2.data.DicomMessage.readFile(ab);
|
|
1366
|
+
return dcmjsAny2.data.DicomMetaDictionary.naturalizeDataset(parsed.dict);
|
|
1367
|
+
}
|
|
1368
|
+
function parseFull(path) {
|
|
925
1369
|
try {
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
1370
|
+
return naturalize(toArrayBuffer(readFileSync(path)));
|
|
1371
|
+
} catch {
|
|
1372
|
+
return null;
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
var PIXEL_DATA_OW = Buffer.from([224, 127, 16, 0, 79, 87]);
|
|
1376
|
+
var PIXEL_DATA_OB = Buffer.from([224, 127, 16, 0, 79, 66]);
|
|
1377
|
+
var EMPTY_OW_PIXEL_DATA = Buffer.from([
|
|
1378
|
+
224,
|
|
1379
|
+
127,
|
|
1380
|
+
16,
|
|
1381
|
+
0,
|
|
1382
|
+
79,
|
|
1383
|
+
87,
|
|
1384
|
+
0,
|
|
1385
|
+
0,
|
|
1386
|
+
0,
|
|
1387
|
+
0,
|
|
1388
|
+
0,
|
|
1389
|
+
0
|
|
1390
|
+
]);
|
|
1391
|
+
var HEADER_READ_BYTES = 2 * 1024 * 1024;
|
|
1392
|
+
function findPixelDataOffset(prefix) {
|
|
1393
|
+
let best = -1;
|
|
1394
|
+
for (const pattern of [PIXEL_DATA_OW, PIXEL_DATA_OB]) {
|
|
1395
|
+
for (let from = 0; ; ) {
|
|
1396
|
+
const i = prefix.indexOf(pattern, from);
|
|
1397
|
+
if (i < 0) break;
|
|
1398
|
+
if (prefix[i + 6] === 0 && prefix[i + 7] === 0) {
|
|
1399
|
+
if (best < 0 || i < best) best = i;
|
|
1400
|
+
break;
|
|
1401
|
+
}
|
|
1402
|
+
from = i + 1;
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
return best;
|
|
1406
|
+
}
|
|
1407
|
+
function readHeaderOnly(path) {
|
|
1408
|
+
let prefix;
|
|
1409
|
+
try {
|
|
1410
|
+
const fd = openSync2(path, "r");
|
|
1411
|
+
try {
|
|
1412
|
+
const buf = Buffer.alloc(HEADER_READ_BYTES);
|
|
1413
|
+
const n = readSync(fd, buf, 0, HEADER_READ_BYTES, 0);
|
|
1414
|
+
prefix = buf.subarray(0, n);
|
|
1415
|
+
} finally {
|
|
1416
|
+
closeSync2(fd);
|
|
1417
|
+
}
|
|
1418
|
+
} catch {
|
|
1419
|
+
return null;
|
|
1420
|
+
}
|
|
1421
|
+
const tagIdx = findPixelDataOffset(prefix);
|
|
1422
|
+
if (tagIdx < 0) return null;
|
|
1423
|
+
const headerOnly = Buffer.concat([
|
|
1424
|
+
prefix.subarray(0, tagIdx),
|
|
1425
|
+
EMPTY_OW_PIXEL_DATA
|
|
1426
|
+
]);
|
|
1427
|
+
try {
|
|
1428
|
+
return naturalize(toArrayBuffer(headerOnly));
|
|
936
1429
|
} catch {
|
|
937
1430
|
return null;
|
|
938
1431
|
}
|
|
@@ -942,10 +1435,12 @@ function extractTags(record, keepKeywords) {
|
|
|
942
1435
|
let found = false;
|
|
943
1436
|
for (const keyword of keepKeywords) {
|
|
944
1437
|
const value = record[keyword];
|
|
945
|
-
if (value
|
|
946
|
-
|
|
947
|
-
|
|
1438
|
+
if (value === void 0) continue;
|
|
1439
|
+
if (keyword === "Modality" && typeof value === "string" && SUPPORTED_MODALITIES.has(value)) {
|
|
1440
|
+
continue;
|
|
948
1441
|
}
|
|
1442
|
+
tags[keyword] = typeof value === "string" ? escapeTagTemplate(value) : value;
|
|
1443
|
+
found = true;
|
|
949
1444
|
}
|
|
950
1445
|
return found ? tags : void 0;
|
|
951
1446
|
}
|
|
@@ -954,14 +1449,25 @@ function describeDirectory(dir, options = {}) {
|
|
|
954
1449
|
const studies = /* @__PURE__ */ new Map();
|
|
955
1450
|
const stats = { dicomFiles: 0, skipped: 0, unknownModality: 0 };
|
|
956
1451
|
for (const path of [...walkFiles(dir)].sort()) {
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
1452
|
+
let size;
|
|
1453
|
+
try {
|
|
1454
|
+
size = statSync(path).size;
|
|
1455
|
+
} catch {
|
|
1456
|
+
stats.skipped++;
|
|
1457
|
+
continue;
|
|
1458
|
+
}
|
|
1459
|
+
if (size > MAX_STREAM_BYTES) {
|
|
1460
|
+
stats.skipped++;
|
|
1461
|
+
continue;
|
|
1462
|
+
}
|
|
1463
|
+
const large = size > MAX_PIXEL_BYTES;
|
|
1464
|
+
const record = large ? readHeaderOnly(path) : parseFull(path);
|
|
1465
|
+
const studyUid = record?.StudyInstanceUID;
|
|
1466
|
+
const seriesUid = record?.SeriesInstanceUID;
|
|
1467
|
+
if (!record || typeof studyUid !== "string" || typeof seriesUid !== "string") {
|
|
961
1468
|
stats.skipped++;
|
|
962
1469
|
continue;
|
|
963
1470
|
}
|
|
964
|
-
const { record } = parsed;
|
|
965
1471
|
stats.dicomFiles++;
|
|
966
1472
|
let modality;
|
|
967
1473
|
if (typeof record.Modality === "string") {
|
|
@@ -971,7 +1477,7 @@ function describeDirectory(dir, options = {}) {
|
|
|
971
1477
|
stats.unknownModality++;
|
|
972
1478
|
}
|
|
973
1479
|
}
|
|
974
|
-
const sizeKb = Math.max(1, Math.round(
|
|
1480
|
+
const sizeKb = Math.max(1, Math.round(size / 1024));
|
|
975
1481
|
const tags = keepKeywords.length ? extractTags(record, keepKeywords) : void 0;
|
|
976
1482
|
let study = studies.get(studyUid);
|
|
977
1483
|
if (!study) {
|
|
@@ -983,25 +1489,27 @@ function describeDirectory(dir, options = {}) {
|
|
|
983
1489
|
series = /* @__PURE__ */ new Map();
|
|
984
1490
|
study.set(seriesUid, series);
|
|
985
1491
|
}
|
|
986
|
-
const collapseKey = JSON.stringify([
|
|
1492
|
+
const collapseKey = JSON.stringify([
|
|
1493
|
+
large,
|
|
1494
|
+
modality ?? null,
|
|
1495
|
+
large ? size : sizeKb,
|
|
1496
|
+
tags ?? null
|
|
1497
|
+
]);
|
|
987
1498
|
const existing = series.get(collapseKey);
|
|
988
1499
|
if (existing) {
|
|
989
1500
|
existing.count++;
|
|
990
1501
|
} else {
|
|
991
|
-
series.set(collapseKey, {
|
|
992
|
-
modality,
|
|
993
|
-
targetSizeKb: sizeKb,
|
|
994
|
-
tags,
|
|
995
|
-
count: 1
|
|
996
|
-
});
|
|
1502
|
+
series.set(collapseKey, { modality, large, bytes: size, tags, count: 1 });
|
|
997
1503
|
}
|
|
998
1504
|
}
|
|
999
1505
|
const studySpecs = [...studies.values()].map((study) => {
|
|
1000
1506
|
const seriesSpecs = [...study.values()].map((series) => {
|
|
1001
1507
|
const entries = [...series.values()].map((acc) => ({
|
|
1002
|
-
type: "valid-image",
|
|
1003
1508
|
...acc.modality !== void 0 ? { modality: acc.modality } : {},
|
|
1004
|
-
|
|
1509
|
+
...acc.large ? { type: "large-image", targetBytes: acc.bytes } : {
|
|
1510
|
+
type: "valid-image",
|
|
1511
|
+
targetSizeKb: Math.max(1, Math.round(acc.bytes / 1024))
|
|
1512
|
+
},
|
|
1005
1513
|
...acc.tags !== void 0 ? { tags: acc.tags } : {},
|
|
1006
1514
|
...acc.count > 1 ? { count: acc.count } : {}
|
|
1007
1515
|
}));
|
|
@@ -1050,7 +1558,7 @@ function loadCaseById(casesJsonPath, id) {
|
|
|
1050
1558
|
|
|
1051
1559
|
// src/public-fixtures/fetch.ts
|
|
1052
1560
|
import { createHash } from "node:crypto";
|
|
1053
|
-
import { existsSync, mkdirSync as
|
|
1561
|
+
import { existsSync, mkdirSync as mkdirSync4, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "node:fs";
|
|
1054
1562
|
import { homedir } from "node:os";
|
|
1055
1563
|
import { join as join3 } from "node:path";
|
|
1056
1564
|
var DEFAULT_CACHE_ROOT = join3(homedir(), ".cache", "dicom-synth-testcases");
|
|
@@ -1077,7 +1585,7 @@ async function fetchPublicCaseToCache(record, cacheRoot = DEFAULT_CACHE_ROOT) {
|
|
|
1077
1585
|
verifySha256(buf2, record.sha256);
|
|
1078
1586
|
return dest;
|
|
1079
1587
|
}
|
|
1080
|
-
|
|
1588
|
+
mkdirSync4(join3(cacheRoot, record.sha256), { recursive: true });
|
|
1081
1589
|
const res = await fetch(record.source.url);
|
|
1082
1590
|
if (!res.ok) {
|
|
1083
1591
|
throw new Error(
|
|
@@ -1096,6 +1604,12 @@ function sample(next, range) {
|
|
|
1096
1604
|
if (typeof range === "number") return range;
|
|
1097
1605
|
return range.min + next() % (range.max - range.min + 1);
|
|
1098
1606
|
}
|
|
1607
|
+
var BYTES_PER_MB = 1024 * 1024;
|
|
1608
|
+
function sampleBytes(next, range) {
|
|
1609
|
+
if (typeof range === "number") return range;
|
|
1610
|
+
const spanMb = Math.floor((range.max - range.min) / BYTES_PER_MB) + 1;
|
|
1611
|
+
return range.min + next() % spanMb * BYTES_PER_MB;
|
|
1612
|
+
}
|
|
1099
1613
|
function pick(next, items) {
|
|
1100
1614
|
return items[next() % items.length];
|
|
1101
1615
|
}
|
|
@@ -1136,6 +1650,30 @@ function resolveParametricSpec(spec) {
|
|
|
1136
1650
|
...fileCount > 1 ? { count: fileCount } : {}
|
|
1137
1651
|
});
|
|
1138
1652
|
}
|
|
1653
|
+
if (params.largeFilesPerSeries !== void 0 && params.largeFileBytes !== void 0) {
|
|
1654
|
+
const largeCount = sample(next, params.largeFilesPerSeries);
|
|
1655
|
+
const largeBase = {
|
|
1656
|
+
type: "large-image",
|
|
1657
|
+
targetBytes: 0,
|
|
1658
|
+
...modality !== void 0 ? { modality } : {}
|
|
1659
|
+
};
|
|
1660
|
+
if (typeof params.largeFileBytes === "number") {
|
|
1661
|
+
if (largeCount > 0) {
|
|
1662
|
+
entries2.push({
|
|
1663
|
+
...largeBase,
|
|
1664
|
+
targetBytes: params.largeFileBytes,
|
|
1665
|
+
...largeCount > 1 ? { count: largeCount } : {}
|
|
1666
|
+
});
|
|
1667
|
+
}
|
|
1668
|
+
} else {
|
|
1669
|
+
for (let lf = 0; lf < largeCount; lf++) {
|
|
1670
|
+
entries2.push({
|
|
1671
|
+
...largeBase,
|
|
1672
|
+
targetBytes: sampleBytes(next, params.largeFileBytes)
|
|
1673
|
+
});
|
|
1674
|
+
}
|
|
1675
|
+
}
|
|
1676
|
+
}
|
|
1139
1677
|
series.push({ entries: entries2 });
|
|
1140
1678
|
}
|
|
1141
1679
|
studies.push({
|
|
@@ -1164,6 +1702,9 @@ function resolveParametricSpec(spec) {
|
|
|
1164
1702
|
};
|
|
1165
1703
|
}
|
|
1166
1704
|
export {
|
|
1705
|
+
MAX_PIXEL_BYTES,
|
|
1706
|
+
MAX_STREAM_BYTES,
|
|
1707
|
+
TEMPLATE_VOCAB,
|
|
1167
1708
|
caseCachePath,
|
|
1168
1709
|
defaultPublicCasesPath,
|
|
1169
1710
|
describeDirectory,
|
|
@@ -1173,10 +1714,14 @@ export {
|
|
|
1173
1714
|
loadCaseById,
|
|
1174
1715
|
loadCasesFromJson,
|
|
1175
1716
|
loadDefaultCases,
|
|
1717
|
+
previewCollection,
|
|
1176
1718
|
resolveParametricSpec,
|
|
1719
|
+
resolveTagTemplates,
|
|
1720
|
+
streamLargeImage,
|
|
1177
1721
|
validateDatasetSpec,
|
|
1178
1722
|
validateParametricSpec,
|
|
1179
1723
|
verifySha256,
|
|
1180
1724
|
withResolvedSeed,
|
|
1181
|
-
writeCollectionFromSpec
|
|
1725
|
+
writeCollectionFromSpec,
|
|
1726
|
+
writeLargeImageFile
|
|
1182
1727
|
};
|