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
|
@@ -2,21 +2,46 @@
|
|
|
2
2
|
import { readFileSync, writeFileSync } from 'node:fs'
|
|
3
3
|
import { resolve } from 'node:path'
|
|
4
4
|
import {
|
|
5
|
-
|
|
5
|
+
MAX_PIXEL_BYTES,
|
|
6
|
+
MAX_STREAM_BYTES,
|
|
7
|
+
previewCollection,
|
|
6
8
|
resolveParametricSpec,
|
|
7
9
|
validateDatasetSpec,
|
|
10
|
+
withResolvedSeed,
|
|
8
11
|
writeCollectionFromSpec,
|
|
12
|
+
writeLargeImageFile,
|
|
9
13
|
} from '../dist/esm/index.js'
|
|
10
14
|
|
|
11
15
|
function usage() {
|
|
12
16
|
console.error(
|
|
13
|
-
'Usage: dicom-synth-generate --schema <path> [--out <dir>] [--dry-run]\n' +
|
|
14
|
-
' dicom-synth-generate --schema-inline <json> [--out <dir>] [--dry-run]\n' +
|
|
15
|
-
' dicom-synth-generate --parametric <path> [--out <dir>] [--emit-spec <path>] [--dry-run]'
|
|
17
|
+
'Usage: dicom-synth-generate --schema <path> [--out <dir>] [--emit-spec <path>] [--dry-run]\n' +
|
|
18
|
+
' dicom-synth-generate --schema-inline <json> [--out <dir>] [--emit-spec <path>] [--dry-run]\n' +
|
|
19
|
+
' dicom-synth-generate --parametric <path> [--out <dir>] [--emit-spec <path>] [--dry-run]\n' +
|
|
20
|
+
' dicom-synth-generate --large-file <path> --size <600mb|50gb> [--modality CT|PT|MR|CR]\n' +
|
|
21
|
+
'\n' +
|
|
22
|
+
'A seed is drawn when the spec omits one; pass --emit-spec to capture the\n' +
|
|
23
|
+
'effective spec (seed included) so the dataset can be re-created.\n' +
|
|
24
|
+
'\n' +
|
|
25
|
+
'--large-file streams a single DICOM (512 MB–50 GB) to disk; files above\n' +
|
|
26
|
+
'~4 GB use encapsulated fragments (valid container, zero pixel content).',
|
|
16
27
|
)
|
|
17
28
|
process.exit(1)
|
|
18
29
|
}
|
|
19
30
|
|
|
31
|
+
const SIZE_UNITS = { b: 1, kb: 1024, mb: 1024 * 1024, gb: 1024 * 1024 * 1024 }
|
|
32
|
+
|
|
33
|
+
function parseSizeToBytes(s) {
|
|
34
|
+
// Unit is required so a bare number (e.g. "600", meant as MB) is not silently
|
|
35
|
+
// read as 600 bytes.
|
|
36
|
+
const m = /^([0-9]*\.?[0-9]+)\s*(b|kb|mb|gb)$/i.exec(s.trim())
|
|
37
|
+
if (!m) {
|
|
38
|
+
throw new Error(
|
|
39
|
+
`invalid --size "${s}" — include a unit, e.g. 600mb or 50gb`,
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
return Math.round(Number.parseFloat(m[1]) * SIZE_UNITS[m[2].toLowerCase()])
|
|
43
|
+
}
|
|
44
|
+
|
|
20
45
|
const args = process.argv.slice(2)
|
|
21
46
|
|
|
22
47
|
if (args.length === 0) usage()
|
|
@@ -26,6 +51,9 @@ let specSource
|
|
|
26
51
|
let outDir = resolve('./fixtures/generated')
|
|
27
52
|
let emitSpecPath
|
|
28
53
|
let dryRun = false
|
|
54
|
+
let largeFilePath
|
|
55
|
+
let largeFileSize
|
|
56
|
+
let largeFileModality
|
|
29
57
|
|
|
30
58
|
function readSpecArg(flag, value) {
|
|
31
59
|
if (rawSpec !== undefined) {
|
|
@@ -66,12 +94,73 @@ for (let i = 0; i < args.length; i++) {
|
|
|
66
94
|
emitSpecPath = resolve(args[++i])
|
|
67
95
|
} else if (args[i] === '--dry-run') {
|
|
68
96
|
dryRun = true
|
|
97
|
+
} else if (args[i] === '--large-file' && args[i + 1]) {
|
|
98
|
+
largeFilePath = resolve(args[++i])
|
|
99
|
+
} else if (args[i] === '--size' && args[i + 1]) {
|
|
100
|
+
largeFileSize = args[++i]
|
|
101
|
+
} else if (args[i] === '--modality' && args[i + 1]) {
|
|
102
|
+
largeFileModality = args[++i]
|
|
69
103
|
} else {
|
|
70
104
|
console.error(`Unknown argument: ${args[i]}`)
|
|
71
105
|
usage()
|
|
72
106
|
}
|
|
73
107
|
}
|
|
74
108
|
|
|
109
|
+
if (largeFilePath !== undefined) {
|
|
110
|
+
if (rawSpec !== undefined) {
|
|
111
|
+
console.error('Error: --large-file cannot be combined with a spec mode')
|
|
112
|
+
process.exit(1)
|
|
113
|
+
}
|
|
114
|
+
if (largeFileSize === undefined) {
|
|
115
|
+
console.error('Error: --large-file requires --size')
|
|
116
|
+
usage()
|
|
117
|
+
}
|
|
118
|
+
if (
|
|
119
|
+
largeFileModality !== undefined &&
|
|
120
|
+
!['CT', 'PT', 'MR', 'CR'].includes(largeFileModality)
|
|
121
|
+
) {
|
|
122
|
+
console.error(`Error: invalid --modality "${largeFileModality}"`)
|
|
123
|
+
process.exit(1)
|
|
124
|
+
}
|
|
125
|
+
let targetBytes
|
|
126
|
+
try {
|
|
127
|
+
targetBytes = parseSizeToBytes(largeFileSize)
|
|
128
|
+
} catch (err) {
|
|
129
|
+
console.error(`Error: ${err.message}`)
|
|
130
|
+
process.exit(1)
|
|
131
|
+
}
|
|
132
|
+
// Validate bounds here so --dry-run reflects what a real run would accept.
|
|
133
|
+
if (targetBytes <= MAX_PIXEL_BYTES) {
|
|
134
|
+
console.error(
|
|
135
|
+
'Error: --size must exceed the 512 MB in-memory limit — use a spec with targetSizeKb for smaller files',
|
|
136
|
+
)
|
|
137
|
+
process.exit(1)
|
|
138
|
+
}
|
|
139
|
+
if (targetBytes > MAX_STREAM_BYTES) {
|
|
140
|
+
console.error('Error: --size exceeds the 50 GB limit')
|
|
141
|
+
process.exit(1)
|
|
142
|
+
}
|
|
143
|
+
if (dryRun) {
|
|
144
|
+
console.log(
|
|
145
|
+
`Dry run: would write ~${(targetBytes / 1024 / 1024).toFixed(1)} MB to ${largeFilePath} — nothing written`,
|
|
146
|
+
)
|
|
147
|
+
process.exit(0)
|
|
148
|
+
}
|
|
149
|
+
try {
|
|
150
|
+
const result = writeLargeImageFile(
|
|
151
|
+
{ targetBytes, modality: largeFileModality },
|
|
152
|
+
largeFilePath,
|
|
153
|
+
)
|
|
154
|
+
console.log(
|
|
155
|
+
`Wrote ${(result.bytesWritten / 1024 / 1024).toFixed(1)} MB ${result.encoding} DICOM to ${result.path}`,
|
|
156
|
+
)
|
|
157
|
+
} catch (err) {
|
|
158
|
+
console.error(`Error writing large file: ${err.message}`)
|
|
159
|
+
process.exit(1)
|
|
160
|
+
}
|
|
161
|
+
process.exit(0)
|
|
162
|
+
}
|
|
163
|
+
|
|
75
164
|
if (rawSpec === undefined) usage()
|
|
76
165
|
|
|
77
166
|
function printTypeCounts(counts) {
|
|
@@ -80,22 +169,27 @@ function printTypeCounts(counts) {
|
|
|
80
169
|
}
|
|
81
170
|
}
|
|
82
171
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
process.exit(1)
|
|
86
|
-
}
|
|
172
|
+
const seedProvided =
|
|
173
|
+
rawSpec !== null && typeof rawSpec === 'object' && 'seed' in rawSpec
|
|
87
174
|
|
|
88
175
|
let spec
|
|
89
176
|
try {
|
|
90
177
|
spec =
|
|
91
178
|
specSource === '--parametric'
|
|
92
179
|
? resolveParametricSpec(rawSpec)
|
|
93
|
-
: validateDatasetSpec(rawSpec)
|
|
180
|
+
: withResolvedSeed(validateDatasetSpec(rawSpec))
|
|
94
181
|
} catch (err) {
|
|
95
182
|
console.error(`Schema validation error: ${err.message}`)
|
|
96
183
|
process.exit(1)
|
|
97
184
|
}
|
|
98
185
|
|
|
186
|
+
// Surface the drawn seed so a run is reproducible even without --emit-spec.
|
|
187
|
+
if (!seedProvided) {
|
|
188
|
+
console.error(
|
|
189
|
+
`Generated with seed ${spec.seed} (pass --emit-spec to capture)`,
|
|
190
|
+
)
|
|
191
|
+
}
|
|
192
|
+
|
|
99
193
|
if (emitSpecPath !== undefined) {
|
|
100
194
|
try {
|
|
101
195
|
writeFileSync(emitSpecPath, `${JSON.stringify(spec, null, 2)}\n`)
|
|
@@ -111,13 +205,13 @@ if (dryRun) {
|
|
|
111
205
|
let fileCount = 0
|
|
112
206
|
let byteCount = 0
|
|
113
207
|
try {
|
|
114
|
-
for await (const file of
|
|
208
|
+
for await (const file of previewCollection(spec)) {
|
|
115
209
|
console.log(
|
|
116
|
-
`${file.relativePath} (${file.type}, ${(file.
|
|
210
|
+
`${file.relativePath} (${file.type}, ${(file.approxBytes / 1024).toFixed(1)} KB)`,
|
|
117
211
|
)
|
|
118
212
|
counts[file.type] = (counts[file.type] ?? 0) + 1
|
|
119
213
|
fileCount++
|
|
120
|
-
byteCount += file.
|
|
214
|
+
byteCount += file.approxBytes
|
|
121
215
|
}
|
|
122
216
|
} catch (err) {
|
|
123
217
|
console.error(`Error generating collection: ${err.message}`)
|
|
@@ -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";
|
|
@@ -45,7 +50,6 @@ function buildDicomdirBuffer() {
|
|
|
45
50
|
}
|
|
46
51
|
|
|
47
52
|
// src/schema/validate.ts
|
|
48
|
-
var MAX_PIXEL_BYTES = 512 * 1024 * 1024;
|
|
49
53
|
var HEX_TAG_RE = /^[0-9a-fA-F]{8}$/;
|
|
50
54
|
|
|
51
55
|
// src/syntheticFixtures/generator.ts
|
|
@@ -219,9 +223,17 @@ function buildBufferForSpec(spec, uid) {
|
|
|
219
223
|
return buildNonDicomBuffer(spec.content);
|
|
220
224
|
case "dicomdir":
|
|
221
225
|
return buildDicomdirBuffer();
|
|
226
|
+
case "large-image":
|
|
227
|
+
throw new Error(
|
|
228
|
+
"large-image cannot be generated in memory \u2014 use writeCollectionFromSpec (disk-only streaming)"
|
|
229
|
+
);
|
|
222
230
|
}
|
|
223
231
|
}
|
|
224
232
|
|
|
233
|
+
// src/syntheticFixtures/streamWrite.ts
|
|
234
|
+
import { closeSync, mkdirSync as mkdirSync2, openSync, writeSync } from "node:fs";
|
|
235
|
+
import { resolve as resolve2 } from "node:path";
|
|
236
|
+
|
|
225
237
|
// src/syntheticFixtures/uid.ts
|
|
226
238
|
import { randomBytes } from "node:crypto";
|
|
227
239
|
var ROLE_CODE = { study: 1, series: 2, sop: 3 };
|
|
@@ -287,6 +299,153 @@ function makeUidGenerator(seed) {
|
|
|
287
299
|
};
|
|
288
300
|
}
|
|
289
301
|
|
|
302
|
+
// src/syntheticFixtures/streamWrite.ts
|
|
303
|
+
var OW_MAX_BYTES = 4294967294;
|
|
304
|
+
var FRAGMENT_MAX_BYTES = 4294967294;
|
|
305
|
+
var DEFAULT_CHUNK_BYTES = 8 * 1024 * 1024;
|
|
306
|
+
var RLE_TRANSFER_SYNTAX_UID = "1.2.840.10008.1.2.5";
|
|
307
|
+
var PIXEL_DATA_OW_HEADER = Buffer.from([224, 127, 16, 0, 79, 87]);
|
|
308
|
+
var PIXEL_DATA_OB_HEADER = Buffer.from([224, 127, 16, 0, 79, 66]);
|
|
309
|
+
var ITEM_TAG_GROUP = 65534;
|
|
310
|
+
var ITEM_TAG_ELEMENT = 57344;
|
|
311
|
+
var SEQUENCE_DELIMITER_ELEMENT = 57565;
|
|
312
|
+
function itemTag(element, byteLength = 0) {
|
|
313
|
+
const b = Buffer.alloc(8);
|
|
314
|
+
b.writeUInt16LE(ITEM_TAG_GROUP, 0);
|
|
315
|
+
b.writeUInt16LE(element, 2);
|
|
316
|
+
b.writeUInt32LE(byteLength, 4);
|
|
317
|
+
return b;
|
|
318
|
+
}
|
|
319
|
+
function nativeDimensions(pixelBytes) {
|
|
320
|
+
const cells = Math.max(1, Math.floor(pixelBytes / 2));
|
|
321
|
+
const rows = Math.min(65535, Math.max(1, Math.round(Math.sqrt(cells))));
|
|
322
|
+
const columns = Math.min(65535, Math.max(1, Math.floor(cells / rows)));
|
|
323
|
+
return { rows, columns, bytes: rows * columns * 2 };
|
|
324
|
+
}
|
|
325
|
+
function streamZeros(fd, totalBytes, zeroChunk) {
|
|
326
|
+
let remaining = totalBytes;
|
|
327
|
+
while (remaining > 0) {
|
|
328
|
+
const n = Math.min(zeroChunk.length, remaining);
|
|
329
|
+
writeSync(fd, zeroChunk, 0, n);
|
|
330
|
+
remaining -= n;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
function writeNative(outPath, params, dims, zeroChunk) {
|
|
334
|
+
const { modality, uid, tags } = params;
|
|
335
|
+
const meta = buildMeta(uid, modality);
|
|
336
|
+
const dataset = buildBaseImageDataset(uid, modality);
|
|
337
|
+
applyTagOverrides(dataset, tags);
|
|
338
|
+
dataset.Rows = dims.rows;
|
|
339
|
+
dataset.Columns = dims.columns;
|
|
340
|
+
const minimalPixelBytes = dataset.PixelData.byteLength;
|
|
341
|
+
const probe = serializeDict(meta, dataset);
|
|
342
|
+
const pixelElement = Buffer.alloc(12);
|
|
343
|
+
PIXEL_DATA_OW_HEADER.copy(pixelElement);
|
|
344
|
+
pixelElement.writeUInt32LE(minimalPixelBytes, 8);
|
|
345
|
+
const owIdx = probe.indexOf(pixelElement);
|
|
346
|
+
if (owIdx < 0) {
|
|
347
|
+
throw new Error("failed to locate native PixelData element header");
|
|
348
|
+
}
|
|
349
|
+
const dataStart = owIdx + 12;
|
|
350
|
+
const header = Buffer.from(probe.subarray(0, dataStart));
|
|
351
|
+
header.writeUInt32LE(dims.bytes, owIdx + 8);
|
|
352
|
+
const trailing = probe.subarray(dataStart + minimalPixelBytes);
|
|
353
|
+
const fd = openSync(outPath, "w");
|
|
354
|
+
try {
|
|
355
|
+
writeSync(fd, header);
|
|
356
|
+
streamZeros(fd, dims.bytes, zeroChunk);
|
|
357
|
+
if (trailing.length > 0) writeSync(fd, trailing);
|
|
358
|
+
} finally {
|
|
359
|
+
closeSync(fd);
|
|
360
|
+
}
|
|
361
|
+
return header.length + dims.bytes + trailing.length;
|
|
362
|
+
}
|
|
363
|
+
function writeEncapsulated(outPath, params, pixelBytes, fragmentBytes, zeroChunk) {
|
|
364
|
+
const { modality, uid, tags } = params;
|
|
365
|
+
const meta = {
|
|
366
|
+
...buildMeta(uid, modality),
|
|
367
|
+
TransferSyntaxUID: RLE_TRANSFER_SYNTAX_UID
|
|
368
|
+
};
|
|
369
|
+
const dataset = buildBaseImageDataset(uid, modality);
|
|
370
|
+
applyTagOverrides(dataset, tags);
|
|
371
|
+
dataset.PixelData = [new Uint8Array([]).buffer, new Uint8Array([0, 0]).buffer];
|
|
372
|
+
dataset._vrMap = { PixelData: "OB" };
|
|
373
|
+
const probe = serializeDict(meta, dataset);
|
|
374
|
+
const idx = probe.indexOf(PIXEL_DATA_OB_HEADER);
|
|
375
|
+
if (idx < 0 || probe.readUInt32LE(idx + 8) !== 4294967295) {
|
|
376
|
+
throw new Error("failed to locate encapsulated PixelData element header");
|
|
377
|
+
}
|
|
378
|
+
const prefix = Buffer.from(probe.subarray(0, idx + 12));
|
|
379
|
+
const fd = openSync(outPath, "w");
|
|
380
|
+
let bytesWritten = 0;
|
|
381
|
+
try {
|
|
382
|
+
writeSync(fd, prefix);
|
|
383
|
+
bytesWritten += prefix.length;
|
|
384
|
+
const bot = itemTag(ITEM_TAG_ELEMENT, 0);
|
|
385
|
+
writeSync(fd, bot);
|
|
386
|
+
bytesWritten += bot.length;
|
|
387
|
+
let remaining = pixelBytes;
|
|
388
|
+
while (remaining > 0) {
|
|
389
|
+
const fragLen = Math.min(fragmentBytes, remaining);
|
|
390
|
+
const fh = itemTag(ITEM_TAG_ELEMENT, fragLen);
|
|
391
|
+
writeSync(fd, fh);
|
|
392
|
+
streamZeros(fd, fragLen, zeroChunk);
|
|
393
|
+
bytesWritten += fh.length + fragLen;
|
|
394
|
+
remaining -= fragLen;
|
|
395
|
+
}
|
|
396
|
+
const delimiter = itemTag(SEQUENCE_DELIMITER_ELEMENT);
|
|
397
|
+
writeSync(fd, delimiter);
|
|
398
|
+
bytesWritten += delimiter.length;
|
|
399
|
+
} finally {
|
|
400
|
+
closeSync(fd);
|
|
401
|
+
}
|
|
402
|
+
return bytesWritten;
|
|
403
|
+
}
|
|
404
|
+
function streamLargeImage(outPath, options) {
|
|
405
|
+
const {
|
|
406
|
+
targetBytes,
|
|
407
|
+
chunkBytes = DEFAULT_CHUNK_BYTES,
|
|
408
|
+
owMaxBytes = OW_MAX_BYTES,
|
|
409
|
+
fragmentBytes = FRAGMENT_MAX_BYTES
|
|
410
|
+
} = options;
|
|
411
|
+
const modality = options.modality ?? "CT";
|
|
412
|
+
const uid = options.uid ?? makeUidGenerator(options.seed)(0);
|
|
413
|
+
const identity = { modality, uid, tags: options.tags };
|
|
414
|
+
mkdirSync2(resolve2(outPath, ".."), { recursive: true });
|
|
415
|
+
const zeroChunk = Buffer.alloc(Math.max(1, chunkBytes));
|
|
416
|
+
const probe = serializeDict(
|
|
417
|
+
buildMeta(uid, modality),
|
|
418
|
+
buildBaseImageDataset(uid, modality)
|
|
419
|
+
);
|
|
420
|
+
const nativeHeaderLen = probe.length - 2;
|
|
421
|
+
const requestedPixel = Math.max(2, targetBytes - nativeHeaderLen);
|
|
422
|
+
if (requestedPixel <= owMaxBytes) {
|
|
423
|
+
const dims = nativeDimensions(requestedPixel);
|
|
424
|
+
const bytesWritten2 = writeNative(outPath, identity, dims, zeroChunk);
|
|
425
|
+
return {
|
|
426
|
+
path: outPath,
|
|
427
|
+
bytesWritten: bytesWritten2,
|
|
428
|
+
encoding: "native",
|
|
429
|
+
pixelBytes: dims.bytes
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
const estimateFrags = Math.max(
|
|
433
|
+
1,
|
|
434
|
+
Math.ceil((targetBytes - nativeHeaderLen - 16) / fragmentBytes)
|
|
435
|
+
);
|
|
436
|
+
const overhead = nativeHeaderLen + 8 + estimateFrags * 8 + 8;
|
|
437
|
+
let pixelBytes = Math.max(2, targetBytes - overhead);
|
|
438
|
+
pixelBytes -= pixelBytes % 2;
|
|
439
|
+
const bytesWritten = writeEncapsulated(
|
|
440
|
+
outPath,
|
|
441
|
+
identity,
|
|
442
|
+
pixelBytes,
|
|
443
|
+
fragmentBytes - fragmentBytes % 2,
|
|
444
|
+
zeroChunk
|
|
445
|
+
);
|
|
446
|
+
return { path: outPath, bytesWritten, encoding: "encapsulated", pixelBytes };
|
|
447
|
+
}
|
|
448
|
+
|
|
290
449
|
// src/syntheticFixtures/violations.ts
|
|
291
450
|
var dcmjsAny = dcmjs;
|
|
292
451
|
function parseBuffer(buffer) {
|
|
@@ -400,11 +559,13 @@ function studyFileCount(studies) {
|
|
|
400
559
|
0
|
|
401
560
|
);
|
|
402
561
|
}
|
|
562
|
+
function resolveUid(seed, index, override) {
|
|
563
|
+
return { ...makeUidGenerator(seed)(index), ...override };
|
|
564
|
+
}
|
|
403
565
|
async function generateFile(spec, options) {
|
|
404
566
|
const index = options?.index ?? 0;
|
|
405
567
|
const padWidth = options?.padWidth ?? 3;
|
|
406
|
-
const
|
|
407
|
-
const uid = { ...uidGen(index), ...options?.uid };
|
|
568
|
+
const uid = resolveUid(options?.seed, index, options?.uid);
|
|
408
569
|
let buffer = buildBufferForSpec(spec, uid);
|
|
409
570
|
const violations = "violations" in spec ? spec.violations : void 0;
|
|
410
571
|
if (violations?.length) {
|
|
@@ -469,25 +630,45 @@ function hierarchicalName(studyOrdinal, seriesIndex, instanceNumber) {
|
|
|
469
630
|
relativePath: `study-${pad3(studyOrdinal + 1)}/series-${pad3(seriesIndex + 1)}/${name}`
|
|
470
631
|
};
|
|
471
632
|
}
|
|
472
|
-
|
|
633
|
+
function withResolvedSeed(spec) {
|
|
634
|
+
return spec.seed === void 0 ? { ...spec, seed: randomBytes2(4).readUInt32BE(0) } : spec;
|
|
635
|
+
}
|
|
636
|
+
function computeNames(type, index, padWidth, grouped, quirks) {
|
|
637
|
+
let names;
|
|
638
|
+
if (grouped) {
|
|
639
|
+
names = hierarchicalName(
|
|
640
|
+
grouped.studyOrdinal,
|
|
641
|
+
grouped.seriesIndex,
|
|
642
|
+
grouped.instanceNumber
|
|
643
|
+
);
|
|
644
|
+
} else {
|
|
645
|
+
const name = filename(type, index, padWidth);
|
|
646
|
+
names = { filename: name, relativePath: name };
|
|
647
|
+
}
|
|
648
|
+
return quirks.length === 0 ? names : applyPathQuirks(names.relativePath, quirks);
|
|
649
|
+
}
|
|
650
|
+
function* planCollection(spec) {
|
|
473
651
|
const flatEntries = spec.entries ?? [];
|
|
474
652
|
const studies = spec.studies ?? [];
|
|
475
653
|
const totalFiles = entryCount(flatEntries) + studyFileCount(studies);
|
|
476
654
|
const padWidth = String(totalFiles).length;
|
|
477
655
|
const hierarchical = spec.layout === "hierarchical";
|
|
478
656
|
const quirks = spec.pathQuirks ?? [];
|
|
479
|
-
const decorate = (file) => quirks.length === 0 ? file : { ...file, ...applyPathQuirks(file.relativePath, quirks) };
|
|
480
657
|
let globalIndex = 0;
|
|
481
658
|
for (const entry of flatEntries) {
|
|
482
659
|
const { count = 1, ...fileSpec } = entry;
|
|
483
660
|
for (let i = 0; i < count; i++) {
|
|
484
|
-
yield
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
661
|
+
yield {
|
|
662
|
+
fileSpec,
|
|
663
|
+
index: globalIndex,
|
|
664
|
+
...computeNames(
|
|
665
|
+
fileSpec.type,
|
|
666
|
+
globalIndex,
|
|
667
|
+
padWidth,
|
|
668
|
+
void 0,
|
|
669
|
+
quirks
|
|
670
|
+
)
|
|
671
|
+
};
|
|
491
672
|
globalIndex++;
|
|
492
673
|
}
|
|
493
674
|
}
|
|
@@ -510,25 +691,18 @@ async function* generateCollectionFromSpec(spec) {
|
|
|
510
691
|
...series.tags,
|
|
511
692
|
...fileSpec.tags
|
|
512
693
|
};
|
|
513
|
-
|
|
514
|
-
{ ...fileSpec, tags },
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
694
|
+
yield {
|
|
695
|
+
fileSpec: { ...fileSpec, tags },
|
|
696
|
+
index: globalIndex,
|
|
697
|
+
uidOverride: { study: studyUid, series: seriesUid },
|
|
698
|
+
...computeNames(
|
|
699
|
+
fileSpec.type,
|
|
700
|
+
globalIndex,
|
|
518
701
|
padWidth,
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
hierarchical ? {
|
|
524
|
-
...file,
|
|
525
|
-
...hierarchicalName(
|
|
526
|
-
studyOrdinal,
|
|
527
|
-
seriesIndex,
|
|
528
|
-
instanceNumber
|
|
529
|
-
)
|
|
530
|
-
} : file
|
|
531
|
-
);
|
|
702
|
+
hierarchical ? { studyOrdinal, seriesIndex, instanceNumber } : void 0,
|
|
703
|
+
quirks
|
|
704
|
+
)
|
|
705
|
+
};
|
|
532
706
|
globalIndex++;
|
|
533
707
|
}
|
|
534
708
|
}
|
|
@@ -537,25 +711,84 @@ async function* generateCollectionFromSpec(spec) {
|
|
|
537
711
|
}
|
|
538
712
|
}
|
|
539
713
|
}
|
|
714
|
+
async function materialisePlan(plan, seed) {
|
|
715
|
+
const file = await generateFile(plan.fileSpec, {
|
|
716
|
+
index: plan.index,
|
|
717
|
+
seed,
|
|
718
|
+
uid: plan.uidOverride
|
|
719
|
+
});
|
|
720
|
+
return { ...file, filename: plan.filename, relativePath: plan.relativePath };
|
|
721
|
+
}
|
|
722
|
+
async function* generateCollectionFromSpec(spec) {
|
|
723
|
+
for (const plan of planCollection(spec)) {
|
|
724
|
+
yield await materialisePlan(plan, spec.seed);
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
async function* previewCollection(spec) {
|
|
728
|
+
for (const plan of planCollection(spec)) {
|
|
729
|
+
if (plan.fileSpec.type === "large-image") {
|
|
730
|
+
yield {
|
|
731
|
+
relativePath: plan.relativePath,
|
|
732
|
+
type: "large-image",
|
|
733
|
+
index: plan.index,
|
|
734
|
+
approxBytes: plan.fileSpec.targetBytes
|
|
735
|
+
};
|
|
736
|
+
} else {
|
|
737
|
+
const file = await materialisePlan(plan, spec.seed);
|
|
738
|
+
yield {
|
|
739
|
+
relativePath: plan.relativePath,
|
|
740
|
+
type: plan.fileSpec.type,
|
|
741
|
+
index: plan.index,
|
|
742
|
+
approxBytes: file.buffer.length
|
|
743
|
+
};
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
}
|
|
540
747
|
async function writeCollectionFromSpec(spec, outDir) {
|
|
541
|
-
const root =
|
|
542
|
-
|
|
748
|
+
const root = resolve3(outDir);
|
|
749
|
+
mkdirSync3(root, { recursive: true });
|
|
543
750
|
const manifest = [];
|
|
544
751
|
const createdDirs = /* @__PURE__ */ new Set([root]);
|
|
545
|
-
|
|
546
|
-
const filePath = resolve2(root, file.relativePath);
|
|
752
|
+
const ensureDir = (filePath) => {
|
|
547
753
|
const dir = dirname(filePath);
|
|
548
754
|
if (!createdDirs.has(dir)) {
|
|
549
|
-
|
|
755
|
+
mkdirSync3(dir, { recursive: true });
|
|
550
756
|
createdDirs.add(dir);
|
|
551
757
|
}
|
|
552
|
-
|
|
553
|
-
|
|
758
|
+
};
|
|
759
|
+
for (const plan of planCollection(spec)) {
|
|
760
|
+
const filePath = resolve3(root, plan.relativePath);
|
|
761
|
+
ensureDir(filePath);
|
|
762
|
+
if (plan.fileSpec.type === "large-image") {
|
|
763
|
+
const large = plan.fileSpec;
|
|
764
|
+
if (large.targetBytes > MAX_STREAM_BYTES) {
|
|
765
|
+
throw new Error(
|
|
766
|
+
`large-image targetBytes (${large.targetBytes}) exceeds the 50 GB limit`
|
|
767
|
+
);
|
|
768
|
+
}
|
|
769
|
+
const uid = resolveUid(spec.seed, plan.index, plan.uidOverride);
|
|
770
|
+
streamLargeImage(filePath, {
|
|
771
|
+
targetBytes: large.targetBytes,
|
|
772
|
+
modality: large.modality,
|
|
773
|
+
uid,
|
|
774
|
+
tags: large.tags
|
|
775
|
+
});
|
|
776
|
+
} else {
|
|
777
|
+
const file = await materialisePlan(plan, spec.seed);
|
|
778
|
+
await writeFile(filePath, file.buffer);
|
|
779
|
+
}
|
|
780
|
+
manifest.push({
|
|
781
|
+
path: filePath,
|
|
782
|
+
type: plan.fileSpec.type,
|
|
783
|
+
index: plan.index
|
|
784
|
+
});
|
|
554
785
|
}
|
|
555
786
|
return manifest;
|
|
556
787
|
}
|
|
557
788
|
export {
|
|
558
789
|
generateCollectionFromSpec,
|
|
559
790
|
generateFile,
|
|
791
|
+
previewCollection,
|
|
792
|
+
withResolvedSeed,
|
|
560
793
|
writeCollectionFromSpec
|
|
561
794
|
};
|