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
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { writeFileSync } from 'node:fs'
|
|
2
|
+
import { readFileSync, writeFileSync } from 'node:fs'
|
|
3
3
|
import { resolve } from 'node:path'
|
|
4
4
|
import { describeDirectory } from '../dist/esm/index.js'
|
|
5
5
|
|
|
6
6
|
function usage() {
|
|
7
7
|
console.error(
|
|
8
|
-
'Usage: dicom-synth-describe <dir> [--out <spec.json>] [--keep-tags <a,b,...>]\n' +
|
|
8
|
+
'Usage: dicom-synth-describe <dir> [--out <spec.json>] [--keep-tags <a,b,...>] [--keep-tags-file <path>]\n' +
|
|
9
9
|
'\n' +
|
|
10
10
|
'Scans a DICOM tree and emits a DatasetSpec describing its shape\n' +
|
|
11
11
|
'(studies/series, per-file size, modality).\n' +
|
|
@@ -13,26 +13,45 @@ function usage() {
|
|
|
13
13
|
'By default only the shape is reproduced — no tags are copied.\n' +
|
|
14
14
|
'--keep-tags copies the named tags (DICOM keywords or 8-hex tags)\n' +
|
|
15
15
|
"verbatim; if a kept tag holds PHI, handling it is the caller's\n" +
|
|
16
|
-
"responsibility, not this tool's
|
|
16
|
+
"responsibility, not this tool's.\n" +
|
|
17
|
+
'--keep-tags-file reads the same allow-list from a file (one tag per\n' +
|
|
18
|
+
'line, # comments); merged with any --keep-tags.',
|
|
17
19
|
)
|
|
18
20
|
process.exit(1)
|
|
19
21
|
}
|
|
20
22
|
|
|
23
|
+
// Parse a keep-tags file: one tag per line, blank lines and # comments ignored.
|
|
24
|
+
function parseKeepTagsFile(path) {
|
|
25
|
+
return readFileSync(path, 'utf8')
|
|
26
|
+
.split(/\r?\n/)
|
|
27
|
+
.map((line) => line.replace(/#.*$/, '').trim())
|
|
28
|
+
.filter(Boolean)
|
|
29
|
+
}
|
|
30
|
+
|
|
21
31
|
const args = process.argv.slice(2)
|
|
22
32
|
if (args.length === 0) usage()
|
|
23
33
|
|
|
24
34
|
let dir
|
|
25
35
|
let outPath
|
|
26
|
-
|
|
36
|
+
const keepTags = []
|
|
27
37
|
|
|
28
38
|
for (let i = 0; i < args.length; i++) {
|
|
29
39
|
if (args[i] === '--out' && args[i + 1]) {
|
|
30
40
|
outPath = resolve(args[++i])
|
|
31
41
|
} else if (args[i] === '--keep-tags' && args[i + 1]) {
|
|
32
|
-
keepTags
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
42
|
+
keepTags.push(
|
|
43
|
+
...args[++i]
|
|
44
|
+
.split(',')
|
|
45
|
+
.map((t) => t.trim())
|
|
46
|
+
.filter(Boolean),
|
|
47
|
+
)
|
|
48
|
+
} else if (args[i] === '--keep-tags-file' && args[i + 1]) {
|
|
49
|
+
try {
|
|
50
|
+
keepTags.push(...parseKeepTagsFile(resolve(args[++i])))
|
|
51
|
+
} catch (err) {
|
|
52
|
+
console.error(`Error reading keep-tags file: ${err.message}`)
|
|
53
|
+
process.exit(1)
|
|
54
|
+
}
|
|
36
55
|
} else if (args[i].startsWith('--')) {
|
|
37
56
|
console.error(`Unknown argument: ${args[i]}`)
|
|
38
57
|
usage()
|
|
@@ -48,7 +67,7 @@ if (dir === undefined) usage()
|
|
|
48
67
|
|
|
49
68
|
let result
|
|
50
69
|
try {
|
|
51
|
-
result = describeDirectory(dir, keepTags ? { keepTags } : {})
|
|
70
|
+
result = describeDirectory(dir, keepTags.length ? { keepTags } : {})
|
|
52
71
|
} catch (err) {
|
|
53
72
|
console.error(`Error: ${err.message}`)
|
|
54
73
|
process.exit(1)
|
|
@@ -2,11 +2,14 @@
|
|
|
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,
|
|
8
10
|
withResolvedSeed,
|
|
9
11
|
writeCollectionFromSpec,
|
|
12
|
+
writeLargeImageFile,
|
|
10
13
|
} from '../dist/esm/index.js'
|
|
11
14
|
|
|
12
15
|
function usage() {
|
|
@@ -14,13 +17,31 @@ function usage() {
|
|
|
14
17
|
'Usage: dicom-synth-generate --schema <path> [--out <dir>] [--emit-spec <path>] [--dry-run]\n' +
|
|
15
18
|
' dicom-synth-generate --schema-inline <json> [--out <dir>] [--emit-spec <path>] [--dry-run]\n' +
|
|
16
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' +
|
|
17
21
|
'\n' +
|
|
18
22
|
'A seed is drawn when the spec omits one; pass --emit-spec to capture the\n' +
|
|
19
|
-
'effective spec (seed included) so the dataset can be re-created
|
|
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).',
|
|
20
27
|
)
|
|
21
28
|
process.exit(1)
|
|
22
29
|
}
|
|
23
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
|
+
|
|
24
45
|
const args = process.argv.slice(2)
|
|
25
46
|
|
|
26
47
|
if (args.length === 0) usage()
|
|
@@ -30,6 +51,9 @@ let specSource
|
|
|
30
51
|
let outDir = resolve('./fixtures/generated')
|
|
31
52
|
let emitSpecPath
|
|
32
53
|
let dryRun = false
|
|
54
|
+
let largeFilePath
|
|
55
|
+
let largeFileSize
|
|
56
|
+
let largeFileModality
|
|
33
57
|
|
|
34
58
|
function readSpecArg(flag, value) {
|
|
35
59
|
if (rawSpec !== undefined) {
|
|
@@ -70,12 +94,73 @@ for (let i = 0; i < args.length; i++) {
|
|
|
70
94
|
emitSpecPath = resolve(args[++i])
|
|
71
95
|
} else if (args[i] === '--dry-run') {
|
|
72
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]
|
|
73
103
|
} else {
|
|
74
104
|
console.error(`Unknown argument: ${args[i]}`)
|
|
75
105
|
usage()
|
|
76
106
|
}
|
|
77
107
|
}
|
|
78
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
|
+
|
|
79
164
|
if (rawSpec === undefined) usage()
|
|
80
165
|
|
|
81
166
|
function printTypeCounts(counts) {
|
|
@@ -120,13 +205,13 @@ if (dryRun) {
|
|
|
120
205
|
let fileCount = 0
|
|
121
206
|
let byteCount = 0
|
|
122
207
|
try {
|
|
123
|
-
for await (const file of
|
|
208
|
+
for await (const file of previewCollection(spec)) {
|
|
124
209
|
console.log(
|
|
125
|
-
`${file.relativePath} (${file.type}, ${(file.
|
|
210
|
+
`${file.relativePath} (${file.type}, ${(file.approxBytes / 1024).toFixed(1)} KB)`,
|
|
126
211
|
)
|
|
127
212
|
counts[file.type] = (counts[file.type] ?? 0) + 1
|
|
128
213
|
fileCount++
|
|
129
|
-
byteCount += file.
|
|
214
|
+
byteCount += file.approxBytes
|
|
130
215
|
}
|
|
131
216
|
} catch (err) {
|
|
132
217
|
console.error(`Error generating collection: ${err.message}`)
|
|
@@ -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,8 +49,41 @@ 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 resolveTagTemplates(tags, context) {
|
|
61
|
+
if (!tags) return tags;
|
|
62
|
+
const resolved = {};
|
|
63
|
+
for (const [key, value] of Object.entries(tags)) {
|
|
64
|
+
resolved[key] = typeof value === "string" ? resolveString(value, context) : value;
|
|
65
|
+
}
|
|
66
|
+
return resolved;
|
|
67
|
+
}
|
|
68
|
+
function resolveString(value, context) {
|
|
69
|
+
return value.replace(TEMPLATE_PART_RE, (match, name) => {
|
|
70
|
+
if (name === void 0) return match === "{{" ? "{" : "}";
|
|
71
|
+
if (!TEMPLATE_VOCAB.includes(name)) {
|
|
72
|
+
throw new Error(
|
|
73
|
+
`tag template: unknown placeholder "{${name}}" \u2014 allowed: ${TEMPLATE_VOCAB.map((v) => `{${v}}`).join(", ")}`
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
const resolved = context[name];
|
|
77
|
+
if (resolved === void 0) {
|
|
78
|
+
throw new Error(
|
|
79
|
+
`tag template: placeholder "{${name}}" is not available here \u2014 it only applies inside grouped studies`
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
return String(resolved);
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
48
86
|
// src/schema/validate.ts
|
|
49
|
-
var MAX_PIXEL_BYTES = 512 * 1024 * 1024;
|
|
50
87
|
var HEX_TAG_RE = /^[0-9a-fA-F]{8}$/;
|
|
51
88
|
|
|
52
89
|
// src/syntheticFixtures/generator.ts
|
|
@@ -220,9 +257,17 @@ function buildBufferForSpec(spec, uid) {
|
|
|
220
257
|
return buildNonDicomBuffer(spec.content);
|
|
221
258
|
case "dicomdir":
|
|
222
259
|
return buildDicomdirBuffer();
|
|
260
|
+
case "large-image":
|
|
261
|
+
throw new Error(
|
|
262
|
+
"large-image cannot be generated in memory \u2014 use writeCollectionFromSpec (disk-only streaming)"
|
|
263
|
+
);
|
|
223
264
|
}
|
|
224
265
|
}
|
|
225
266
|
|
|
267
|
+
// src/syntheticFixtures/streamWrite.ts
|
|
268
|
+
import { closeSync, mkdirSync as mkdirSync2, openSync, writeSync } from "node:fs";
|
|
269
|
+
import { resolve as resolve2 } from "node:path";
|
|
270
|
+
|
|
226
271
|
// src/syntheticFixtures/uid.ts
|
|
227
272
|
import { randomBytes } from "node:crypto";
|
|
228
273
|
var ROLE_CODE = { study: 1, series: 2, sop: 3 };
|
|
@@ -288,6 +333,153 @@ function makeUidGenerator(seed) {
|
|
|
288
333
|
};
|
|
289
334
|
}
|
|
290
335
|
|
|
336
|
+
// src/syntheticFixtures/streamWrite.ts
|
|
337
|
+
var OW_MAX_BYTES = 4294967294;
|
|
338
|
+
var FRAGMENT_MAX_BYTES = 4294967294;
|
|
339
|
+
var DEFAULT_CHUNK_BYTES = 8 * 1024 * 1024;
|
|
340
|
+
var RLE_TRANSFER_SYNTAX_UID = "1.2.840.10008.1.2.5";
|
|
341
|
+
var PIXEL_DATA_OW_HEADER = Buffer.from([224, 127, 16, 0, 79, 87]);
|
|
342
|
+
var PIXEL_DATA_OB_HEADER = Buffer.from([224, 127, 16, 0, 79, 66]);
|
|
343
|
+
var ITEM_TAG_GROUP = 65534;
|
|
344
|
+
var ITEM_TAG_ELEMENT = 57344;
|
|
345
|
+
var SEQUENCE_DELIMITER_ELEMENT = 57565;
|
|
346
|
+
function itemTag(element, byteLength = 0) {
|
|
347
|
+
const b = Buffer.alloc(8);
|
|
348
|
+
b.writeUInt16LE(ITEM_TAG_GROUP, 0);
|
|
349
|
+
b.writeUInt16LE(element, 2);
|
|
350
|
+
b.writeUInt32LE(byteLength, 4);
|
|
351
|
+
return b;
|
|
352
|
+
}
|
|
353
|
+
function nativeDimensions(pixelBytes) {
|
|
354
|
+
const cells = Math.max(1, Math.floor(pixelBytes / 2));
|
|
355
|
+
const rows = Math.min(65535, Math.max(1, Math.round(Math.sqrt(cells))));
|
|
356
|
+
const columns = Math.min(65535, Math.max(1, Math.floor(cells / rows)));
|
|
357
|
+
return { rows, columns, bytes: rows * columns * 2 };
|
|
358
|
+
}
|
|
359
|
+
function streamZeros(fd, totalBytes, zeroChunk) {
|
|
360
|
+
let remaining = totalBytes;
|
|
361
|
+
while (remaining > 0) {
|
|
362
|
+
const n = Math.min(zeroChunk.length, remaining);
|
|
363
|
+
writeSync(fd, zeroChunk, 0, n);
|
|
364
|
+
remaining -= n;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
function writeNative(outPath, params, dims, zeroChunk) {
|
|
368
|
+
const { modality, uid, tags } = params;
|
|
369
|
+
const meta = buildMeta(uid, modality);
|
|
370
|
+
const dataset = buildBaseImageDataset(uid, modality);
|
|
371
|
+
applyTagOverrides(dataset, tags);
|
|
372
|
+
dataset.Rows = dims.rows;
|
|
373
|
+
dataset.Columns = dims.columns;
|
|
374
|
+
const minimalPixelBytes = dataset.PixelData.byteLength;
|
|
375
|
+
const probe = serializeDict(meta, dataset);
|
|
376
|
+
const pixelElement = Buffer.alloc(12);
|
|
377
|
+
PIXEL_DATA_OW_HEADER.copy(pixelElement);
|
|
378
|
+
pixelElement.writeUInt32LE(minimalPixelBytes, 8);
|
|
379
|
+
const owIdx = probe.indexOf(pixelElement);
|
|
380
|
+
if (owIdx < 0) {
|
|
381
|
+
throw new Error("failed to locate native PixelData element header");
|
|
382
|
+
}
|
|
383
|
+
const dataStart = owIdx + 12;
|
|
384
|
+
const header = Buffer.from(probe.subarray(0, dataStart));
|
|
385
|
+
header.writeUInt32LE(dims.bytes, owIdx + 8);
|
|
386
|
+
const trailing = probe.subarray(dataStart + minimalPixelBytes);
|
|
387
|
+
const fd = openSync(outPath, "w");
|
|
388
|
+
try {
|
|
389
|
+
writeSync(fd, header);
|
|
390
|
+
streamZeros(fd, dims.bytes, zeroChunk);
|
|
391
|
+
if (trailing.length > 0) writeSync(fd, trailing);
|
|
392
|
+
} finally {
|
|
393
|
+
closeSync(fd);
|
|
394
|
+
}
|
|
395
|
+
return header.length + dims.bytes + trailing.length;
|
|
396
|
+
}
|
|
397
|
+
function writeEncapsulated(outPath, params, pixelBytes, fragmentBytes, zeroChunk) {
|
|
398
|
+
const { modality, uid, tags } = params;
|
|
399
|
+
const meta = {
|
|
400
|
+
...buildMeta(uid, modality),
|
|
401
|
+
TransferSyntaxUID: RLE_TRANSFER_SYNTAX_UID
|
|
402
|
+
};
|
|
403
|
+
const dataset = buildBaseImageDataset(uid, modality);
|
|
404
|
+
applyTagOverrides(dataset, tags);
|
|
405
|
+
dataset.PixelData = [new Uint8Array([]).buffer, new Uint8Array([0, 0]).buffer];
|
|
406
|
+
dataset._vrMap = { PixelData: "OB" };
|
|
407
|
+
const probe = serializeDict(meta, dataset);
|
|
408
|
+
const idx = probe.indexOf(PIXEL_DATA_OB_HEADER);
|
|
409
|
+
if (idx < 0 || probe.readUInt32LE(idx + 8) !== 4294967295) {
|
|
410
|
+
throw new Error("failed to locate encapsulated PixelData element header");
|
|
411
|
+
}
|
|
412
|
+
const prefix = Buffer.from(probe.subarray(0, idx + 12));
|
|
413
|
+
const fd = openSync(outPath, "w");
|
|
414
|
+
let bytesWritten = 0;
|
|
415
|
+
try {
|
|
416
|
+
writeSync(fd, prefix);
|
|
417
|
+
bytesWritten += prefix.length;
|
|
418
|
+
const bot = itemTag(ITEM_TAG_ELEMENT, 0);
|
|
419
|
+
writeSync(fd, bot);
|
|
420
|
+
bytesWritten += bot.length;
|
|
421
|
+
let remaining = pixelBytes;
|
|
422
|
+
while (remaining > 0) {
|
|
423
|
+
const fragLen = Math.min(fragmentBytes, remaining);
|
|
424
|
+
const fh = itemTag(ITEM_TAG_ELEMENT, fragLen);
|
|
425
|
+
writeSync(fd, fh);
|
|
426
|
+
streamZeros(fd, fragLen, zeroChunk);
|
|
427
|
+
bytesWritten += fh.length + fragLen;
|
|
428
|
+
remaining -= fragLen;
|
|
429
|
+
}
|
|
430
|
+
const delimiter = itemTag(SEQUENCE_DELIMITER_ELEMENT);
|
|
431
|
+
writeSync(fd, delimiter);
|
|
432
|
+
bytesWritten += delimiter.length;
|
|
433
|
+
} finally {
|
|
434
|
+
closeSync(fd);
|
|
435
|
+
}
|
|
436
|
+
return bytesWritten;
|
|
437
|
+
}
|
|
438
|
+
function streamLargeImage(outPath, options) {
|
|
439
|
+
const {
|
|
440
|
+
targetBytes,
|
|
441
|
+
chunkBytes = DEFAULT_CHUNK_BYTES,
|
|
442
|
+
owMaxBytes = OW_MAX_BYTES,
|
|
443
|
+
fragmentBytes = FRAGMENT_MAX_BYTES
|
|
444
|
+
} = options;
|
|
445
|
+
const modality = options.modality ?? "CT";
|
|
446
|
+
const uid = options.uid ?? makeUidGenerator(options.seed)(0);
|
|
447
|
+
const identity = { modality, uid, tags: options.tags };
|
|
448
|
+
mkdirSync2(resolve2(outPath, ".."), { recursive: true });
|
|
449
|
+
const zeroChunk = Buffer.alloc(Math.max(1, chunkBytes));
|
|
450
|
+
const probe = serializeDict(
|
|
451
|
+
buildMeta(uid, modality),
|
|
452
|
+
buildBaseImageDataset(uid, modality)
|
|
453
|
+
);
|
|
454
|
+
const nativeHeaderLen = probe.length - 2;
|
|
455
|
+
const requestedPixel = Math.max(2, targetBytes - nativeHeaderLen);
|
|
456
|
+
if (requestedPixel <= owMaxBytes) {
|
|
457
|
+
const dims = nativeDimensions(requestedPixel);
|
|
458
|
+
const bytesWritten2 = writeNative(outPath, identity, dims, zeroChunk);
|
|
459
|
+
return {
|
|
460
|
+
path: outPath,
|
|
461
|
+
bytesWritten: bytesWritten2,
|
|
462
|
+
encoding: "native",
|
|
463
|
+
pixelBytes: dims.bytes
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
const estimateFrags = Math.max(
|
|
467
|
+
1,
|
|
468
|
+
Math.ceil((targetBytes - nativeHeaderLen - 16) / fragmentBytes)
|
|
469
|
+
);
|
|
470
|
+
const overhead = nativeHeaderLen + 8 + estimateFrags * 8 + 8;
|
|
471
|
+
let pixelBytes = Math.max(2, targetBytes - overhead);
|
|
472
|
+
pixelBytes -= pixelBytes % 2;
|
|
473
|
+
const bytesWritten = writeEncapsulated(
|
|
474
|
+
outPath,
|
|
475
|
+
identity,
|
|
476
|
+
pixelBytes,
|
|
477
|
+
fragmentBytes - fragmentBytes % 2,
|
|
478
|
+
zeroChunk
|
|
479
|
+
);
|
|
480
|
+
return { path: outPath, bytesWritten, encoding: "encapsulated", pixelBytes };
|
|
481
|
+
}
|
|
482
|
+
|
|
291
483
|
// src/syntheticFixtures/violations.ts
|
|
292
484
|
var dcmjsAny = dcmjs;
|
|
293
485
|
function parseBuffer(buffer) {
|
|
@@ -401,12 +593,18 @@ function studyFileCount(studies) {
|
|
|
401
593
|
0
|
|
402
594
|
);
|
|
403
595
|
}
|
|
596
|
+
function resolveUid(seed, index, override) {
|
|
597
|
+
return { ...makeUidGenerator(seed)(index), ...override };
|
|
598
|
+
}
|
|
404
599
|
async function generateFile(spec, options) {
|
|
405
600
|
const index = options?.index ?? 0;
|
|
406
601
|
const padWidth = options?.padWidth ?? 3;
|
|
407
|
-
const
|
|
408
|
-
const
|
|
409
|
-
|
|
602
|
+
const uid = resolveUid(options?.seed, index, options?.uid);
|
|
603
|
+
const resolvedSpec = "tags" in spec && spec.tags ? {
|
|
604
|
+
...spec,
|
|
605
|
+
tags: resolveTagTemplates(spec.tags, { index, ...options?.context })
|
|
606
|
+
} : spec;
|
|
607
|
+
let buffer = buildBufferForSpec(resolvedSpec, uid);
|
|
410
608
|
const violations = "violations" in spec ? spec.violations : void 0;
|
|
411
609
|
if (violations?.length) {
|
|
412
610
|
buffer = applyViolations(buffer, violations);
|
|
@@ -473,25 +671,43 @@ function hierarchicalName(studyOrdinal, seriesIndex, instanceNumber) {
|
|
|
473
671
|
function withResolvedSeed(spec) {
|
|
474
672
|
return spec.seed === void 0 ? { ...spec, seed: randomBytes2(4).readUInt32BE(0) } : spec;
|
|
475
673
|
}
|
|
476
|
-
|
|
674
|
+
function computeNames(type, index, padWidth, grouped, quirks) {
|
|
675
|
+
let names;
|
|
676
|
+
if (grouped) {
|
|
677
|
+
names = hierarchicalName(
|
|
678
|
+
grouped.studyOrdinal,
|
|
679
|
+
grouped.seriesIndex,
|
|
680
|
+
grouped.instanceNumber
|
|
681
|
+
);
|
|
682
|
+
} else {
|
|
683
|
+
const name = filename(type, index, padWidth);
|
|
684
|
+
names = { filename: name, relativePath: name };
|
|
685
|
+
}
|
|
686
|
+
return quirks.length === 0 ? names : applyPathQuirks(names.relativePath, quirks);
|
|
687
|
+
}
|
|
688
|
+
function* planCollection(spec) {
|
|
477
689
|
const flatEntries = spec.entries ?? [];
|
|
478
690
|
const studies = spec.studies ?? [];
|
|
479
691
|
const totalFiles = entryCount(flatEntries) + studyFileCount(studies);
|
|
480
692
|
const padWidth = String(totalFiles).length;
|
|
481
693
|
const hierarchical = spec.layout === "hierarchical";
|
|
482
694
|
const quirks = spec.pathQuirks ?? [];
|
|
483
|
-
const decorate = (file) => quirks.length === 0 ? file : { ...file, ...applyPathQuirks(file.relativePath, quirks) };
|
|
484
695
|
let globalIndex = 0;
|
|
485
696
|
for (const entry of flatEntries) {
|
|
486
697
|
const { count = 1, ...fileSpec } = entry;
|
|
487
698
|
for (let i = 0; i < count; i++) {
|
|
488
|
-
yield
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
699
|
+
yield {
|
|
700
|
+
fileSpec,
|
|
701
|
+
index: globalIndex,
|
|
702
|
+
context: { index: globalIndex },
|
|
703
|
+
...computeNames(
|
|
704
|
+
fileSpec.type,
|
|
705
|
+
globalIndex,
|
|
706
|
+
padWidth,
|
|
707
|
+
void 0,
|
|
708
|
+
quirks
|
|
709
|
+
)
|
|
710
|
+
};
|
|
495
711
|
globalIndex++;
|
|
496
712
|
}
|
|
497
713
|
}
|
|
@@ -514,25 +730,24 @@ async function* generateCollectionFromSpec(spec) {
|
|
|
514
730
|
...series.tags,
|
|
515
731
|
...fileSpec.tags
|
|
516
732
|
};
|
|
517
|
-
|
|
518
|
-
{ ...fileSpec, tags },
|
|
519
|
-
|
|
733
|
+
yield {
|
|
734
|
+
fileSpec: { ...fileSpec, tags },
|
|
735
|
+
index: globalIndex,
|
|
736
|
+
uidOverride: { study: studyUid, series: seriesUid },
|
|
737
|
+
context: {
|
|
520
738
|
index: globalIndex,
|
|
521
|
-
|
|
739
|
+
studyIndex: studyOrdinal,
|
|
740
|
+
seriesIndex,
|
|
741
|
+
instanceNumber
|
|
742
|
+
},
|
|
743
|
+
...computeNames(
|
|
744
|
+
fileSpec.type,
|
|
745
|
+
globalIndex,
|
|
522
746
|
padWidth,
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
hierarchical ? {
|
|
528
|
-
...file,
|
|
529
|
-
...hierarchicalName(
|
|
530
|
-
studyOrdinal,
|
|
531
|
-
seriesIndex,
|
|
532
|
-
instanceNumber
|
|
533
|
-
)
|
|
534
|
-
} : file
|
|
535
|
-
);
|
|
747
|
+
hierarchical ? { studyOrdinal, seriesIndex, instanceNumber } : void 0,
|
|
748
|
+
quirks
|
|
749
|
+
)
|
|
750
|
+
};
|
|
536
751
|
globalIndex++;
|
|
537
752
|
}
|
|
538
753
|
}
|
|
@@ -541,26 +756,85 @@ async function* generateCollectionFromSpec(spec) {
|
|
|
541
756
|
}
|
|
542
757
|
}
|
|
543
758
|
}
|
|
759
|
+
async function materialisePlan(plan, seed) {
|
|
760
|
+
const file = await generateFile(plan.fileSpec, {
|
|
761
|
+
index: plan.index,
|
|
762
|
+
seed,
|
|
763
|
+
uid: plan.uidOverride,
|
|
764
|
+
context: plan.context
|
|
765
|
+
});
|
|
766
|
+
return { ...file, filename: plan.filename, relativePath: plan.relativePath };
|
|
767
|
+
}
|
|
768
|
+
async function* generateCollectionFromSpec(spec) {
|
|
769
|
+
for (const plan of planCollection(spec)) {
|
|
770
|
+
yield await materialisePlan(plan, spec.seed);
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
async function* previewCollection(spec) {
|
|
774
|
+
for (const plan of planCollection(spec)) {
|
|
775
|
+
if (plan.fileSpec.type === "large-image") {
|
|
776
|
+
yield {
|
|
777
|
+
relativePath: plan.relativePath,
|
|
778
|
+
type: "large-image",
|
|
779
|
+
index: plan.index,
|
|
780
|
+
approxBytes: plan.fileSpec.targetBytes
|
|
781
|
+
};
|
|
782
|
+
} else {
|
|
783
|
+
const file = await materialisePlan(plan, spec.seed);
|
|
784
|
+
yield {
|
|
785
|
+
relativePath: plan.relativePath,
|
|
786
|
+
type: plan.fileSpec.type,
|
|
787
|
+
index: plan.index,
|
|
788
|
+
approxBytes: file.buffer.length
|
|
789
|
+
};
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
}
|
|
544
793
|
async function writeCollectionFromSpec(spec, outDir) {
|
|
545
|
-
const root =
|
|
546
|
-
|
|
794
|
+
const root = resolve3(outDir);
|
|
795
|
+
mkdirSync3(root, { recursive: true });
|
|
547
796
|
const manifest = [];
|
|
548
797
|
const createdDirs = /* @__PURE__ */ new Set([root]);
|
|
549
|
-
|
|
550
|
-
const filePath = resolve2(root, file.relativePath);
|
|
798
|
+
const ensureDir = (filePath) => {
|
|
551
799
|
const dir = dirname(filePath);
|
|
552
800
|
if (!createdDirs.has(dir)) {
|
|
553
|
-
|
|
801
|
+
mkdirSync3(dir, { recursive: true });
|
|
554
802
|
createdDirs.add(dir);
|
|
555
803
|
}
|
|
556
|
-
|
|
557
|
-
|
|
804
|
+
};
|
|
805
|
+
for (const plan of planCollection(spec)) {
|
|
806
|
+
const filePath = resolve3(root, plan.relativePath);
|
|
807
|
+
ensureDir(filePath);
|
|
808
|
+
if (plan.fileSpec.type === "large-image") {
|
|
809
|
+
const large = plan.fileSpec;
|
|
810
|
+
if (large.targetBytes > MAX_STREAM_BYTES) {
|
|
811
|
+
throw new Error(
|
|
812
|
+
`large-image targetBytes (${large.targetBytes}) exceeds the 50 GB limit`
|
|
813
|
+
);
|
|
814
|
+
}
|
|
815
|
+
const uid = resolveUid(spec.seed, plan.index, plan.uidOverride);
|
|
816
|
+
streamLargeImage(filePath, {
|
|
817
|
+
targetBytes: large.targetBytes,
|
|
818
|
+
modality: large.modality,
|
|
819
|
+
uid,
|
|
820
|
+
tags: resolveTagTemplates(large.tags, plan.context)
|
|
821
|
+
});
|
|
822
|
+
} else {
|
|
823
|
+
const file = await materialisePlan(plan, spec.seed);
|
|
824
|
+
await writeFile(filePath, file.buffer);
|
|
825
|
+
}
|
|
826
|
+
manifest.push({
|
|
827
|
+
path: filePath,
|
|
828
|
+
type: plan.fileSpec.type,
|
|
829
|
+
index: plan.index
|
|
830
|
+
});
|
|
558
831
|
}
|
|
559
832
|
return manifest;
|
|
560
833
|
}
|
|
561
834
|
export {
|
|
562
835
|
generateCollectionFromSpec,
|
|
563
836
|
generateFile,
|
|
837
|
+
previewCollection,
|
|
564
838
|
withResolvedSeed,
|
|
565
839
|
writeCollectionFromSpec
|
|
566
840
|
};
|