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,5 +1,12 @@
|
|
|
1
1
|
// src/describe/describe.ts
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
closeSync,
|
|
4
|
+
openSync,
|
|
5
|
+
readdirSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
readSync,
|
|
8
|
+
statSync
|
|
9
|
+
} from "node:fs";
|
|
3
10
|
import { join } from "node:path";
|
|
4
11
|
|
|
5
12
|
// src/loadDcmjs.ts
|
|
@@ -21,11 +28,35 @@ function loadDcmjs() {
|
|
|
21
28
|
}
|
|
22
29
|
var dcmjs = loadDcmjs();
|
|
23
30
|
|
|
31
|
+
// src/schema/limits.ts
|
|
32
|
+
var MAX_PIXEL_BYTES = 512 * 1024 * 1024;
|
|
33
|
+
var MAX_STREAM_BYTES = 50 * 1024 * 1024 * 1024;
|
|
34
|
+
|
|
35
|
+
// src/syntheticFixtures/tagTemplate.ts
|
|
36
|
+
var TEMPLATE_VOCAB = [
|
|
37
|
+
"index",
|
|
38
|
+
"studyIndex",
|
|
39
|
+
"seriesIndex",
|
|
40
|
+
"instanceNumber"
|
|
41
|
+
];
|
|
42
|
+
var TEMPLATE_PART_RE = /\{\{|\}\}|\{([a-zA-Z]+)\}/g;
|
|
43
|
+
function escapeTagTemplate(value) {
|
|
44
|
+
return value.replace(/\{/g, "{{").replace(/\}/g, "}}");
|
|
45
|
+
}
|
|
46
|
+
function findTemplateTokens(value) {
|
|
47
|
+
const names = [];
|
|
48
|
+
for (const m of value.matchAll(TEMPLATE_PART_RE)) {
|
|
49
|
+
if (m[1] !== void 0) names.push(m[1]);
|
|
50
|
+
}
|
|
51
|
+
return names;
|
|
52
|
+
}
|
|
53
|
+
|
|
24
54
|
// src/schema/validate.ts
|
|
25
55
|
var TYPE_CAPABILITIES = {
|
|
26
56
|
"valid-image": { image: true },
|
|
27
57
|
"invalid-uid-image": { image: true },
|
|
28
58
|
"vendor-warnings-image": { image: true },
|
|
59
|
+
"large-image": { image: true },
|
|
29
60
|
"fake-signature": { image: false },
|
|
30
61
|
"non-dicom": { image: false },
|
|
31
62
|
dicomdir: { image: false }
|
|
@@ -50,7 +81,6 @@ var VALID_TRANSFER_SYNTAXES = {
|
|
|
50
81
|
"explicit-vr-little-endian": true,
|
|
51
82
|
"implicit-vr-little-endian": true
|
|
52
83
|
};
|
|
53
|
-
var MAX_PIXEL_BYTES = 512 * 1024 * 1024;
|
|
54
84
|
var VALID_VIOLATIONS = {
|
|
55
85
|
"uid-too-long": true,
|
|
56
86
|
"non-conformant-uid": true,
|
|
@@ -80,6 +110,43 @@ function validateSizeKbLimit(kb, path) {
|
|
|
80
110
|
throw new Error(`${path}: exceeds the 512 MB limit`);
|
|
81
111
|
}
|
|
82
112
|
}
|
|
113
|
+
function validateLargeTargetBytes(value, path) {
|
|
114
|
+
if (typeof value !== "number" || !Number.isInteger(value)) {
|
|
115
|
+
throw new Error(`${path}: must be an integer; got ${JSON.stringify(value)}`);
|
|
116
|
+
}
|
|
117
|
+
if (value <= MAX_PIXEL_BYTES) {
|
|
118
|
+
throw new Error(
|
|
119
|
+
`${path}: must exceed 512 MB \u2014 use targetSizeKb for smaller files`
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
if (value > MAX_STREAM_BYTES) {
|
|
123
|
+
throw new Error(`${path}: exceeds the 50 GB limit`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
function validateLargeImageEntry(e, path) {
|
|
127
|
+
for (const field of [
|
|
128
|
+
"rows",
|
|
129
|
+
"columns",
|
|
130
|
+
"frames",
|
|
131
|
+
"targetSizeKb",
|
|
132
|
+
"violations",
|
|
133
|
+
"transferSyntax"
|
|
134
|
+
]) {
|
|
135
|
+
if (field in e) {
|
|
136
|
+
throw new Error(
|
|
137
|
+
`${path}.${field}: not supported for type "large-image" \u2014 use targetBytes`
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
if (!("targetBytes" in e)) {
|
|
142
|
+
throw new Error(`${path}.targetBytes: is required for type "large-image"`);
|
|
143
|
+
}
|
|
144
|
+
validateLargeTargetBytes(e.targetBytes, `${path}.targetBytes`);
|
|
145
|
+
if ("modality" in e) {
|
|
146
|
+
validateEnum(e.modality, VALID_MODALITIES, `${path}.modality`);
|
|
147
|
+
}
|
|
148
|
+
if ("tags" in e) validateTags(e.tags, `${path}.tags`);
|
|
149
|
+
}
|
|
83
150
|
function validateEnum(value, valid, path) {
|
|
84
151
|
if (typeof value !== "string" || !Object.hasOwn(valid, value)) {
|
|
85
152
|
throw new Error(
|
|
@@ -103,6 +170,15 @@ function validateEntry(entry, path) {
|
|
|
103
170
|
const type = e.type;
|
|
104
171
|
const caps = TYPE_CAPABILITIES[type];
|
|
105
172
|
if ("count" in e) validatePositiveInt(e.count, `${path}.count`);
|
|
173
|
+
if (type === "large-image") {
|
|
174
|
+
validateLargeImageEntry(e, path);
|
|
175
|
+
return e;
|
|
176
|
+
}
|
|
177
|
+
if ("targetBytes" in e) {
|
|
178
|
+
throw new Error(
|
|
179
|
+
`${path}.targetBytes: only supported for type "large-image"`
|
|
180
|
+
);
|
|
181
|
+
}
|
|
106
182
|
for (const field of [
|
|
107
183
|
"modality",
|
|
108
184
|
"rows",
|
|
@@ -162,12 +238,28 @@ function validateEntry(entry, path) {
|
|
|
162
238
|
}
|
|
163
239
|
return e;
|
|
164
240
|
}
|
|
241
|
+
var MODALITY_TAG = "00080060";
|
|
165
242
|
function validateTags(tags, path) {
|
|
166
243
|
if (typeof tags !== "object" || tags === null || Array.isArray(tags)) {
|
|
167
244
|
throw new Error(`${path}: must be an object`);
|
|
168
245
|
}
|
|
169
|
-
for (const key of Object.
|
|
246
|
+
for (const [key, value] of Object.entries(tags)) {
|
|
170
247
|
validateTagKey(key, path);
|
|
248
|
+
const isModalityKey = key === "Modality" || key.toUpperCase() === MODALITY_TAG;
|
|
249
|
+
if (isModalityKey && typeof value === "string" && Object.hasOwn(VALID_MODALITIES, value)) {
|
|
250
|
+
throw new Error(
|
|
251
|
+
`${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`
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
if (typeof value === "string") {
|
|
255
|
+
for (const name of findTemplateTokens(value)) {
|
|
256
|
+
if (!TEMPLATE_VOCAB.includes(name)) {
|
|
257
|
+
throw new Error(
|
|
258
|
+
`${path}.${key}: unknown placeholder "{${name}}" \u2014 allowed: ${TEMPLATE_VOCAB.map((v) => `{${v}}`).join(", ")}`
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
171
263
|
}
|
|
172
264
|
}
|
|
173
265
|
function validateSeries(series, path) {
|
|
@@ -253,6 +345,13 @@ function validateDatasetSpec(raw) {
|
|
|
253
345
|
var dcmjsAny = dcmjs;
|
|
254
346
|
var HEX_TAG_RE2 = /^[0-9a-fA-F]{8}$/;
|
|
255
347
|
var SUPPORTED_MODALITIES = /* @__PURE__ */ new Set(["CT", "PT", "MR", "CR"]);
|
|
348
|
+
var RESERVED_KEEP_KEYWORDS = /* @__PURE__ */ new Set([
|
|
349
|
+
"Units",
|
|
350
|
+
"DecayCorrection",
|
|
351
|
+
"CorrectedImage",
|
|
352
|
+
"ScanningSequence",
|
|
353
|
+
"SequenceVariant"
|
|
354
|
+
]);
|
|
256
355
|
function buildHexToKeyword() {
|
|
257
356
|
const map = /* @__PURE__ */ new Map();
|
|
258
357
|
const nm = dcmjsAny.data.DicomMetaDictionary.nameMap;
|
|
@@ -268,20 +367,37 @@ function buildHexToKeyword() {
|
|
|
268
367
|
function resolveKeepTags(keepTags) {
|
|
269
368
|
const nameMap = dcmjsAny.data.DicomMetaDictionary.nameMap;
|
|
270
369
|
let hexToKeyword;
|
|
271
|
-
|
|
370
|
+
const kept = [];
|
|
371
|
+
for (const tag of keepTags) {
|
|
372
|
+
let keyword;
|
|
272
373
|
if (HEX_TAG_RE2.test(tag)) {
|
|
273
374
|
hexToKeyword ?? (hexToKeyword = buildHexToKeyword());
|
|
274
|
-
const
|
|
275
|
-
if (!
|
|
375
|
+
const mapped = hexToKeyword.get(tag.toUpperCase());
|
|
376
|
+
if (!mapped) {
|
|
276
377
|
throw new Error(`describe: unknown hex tag "${tag}"`);
|
|
277
378
|
}
|
|
278
|
-
|
|
379
|
+
keyword = mapped;
|
|
380
|
+
} else {
|
|
381
|
+
if (nameMap && !Object.hasOwn(nameMap, tag)) {
|
|
382
|
+
throw new Error(`describe: unknown tag keyword "${tag}"`);
|
|
383
|
+
}
|
|
384
|
+
keyword = tag;
|
|
385
|
+
}
|
|
386
|
+
if (RESERVED_KEEP_KEYWORDS.has(keyword)) {
|
|
387
|
+
console.warn(
|
|
388
|
+
`describe: ignoring keep-tag "${keyword}" \u2014 modality-coupled tags are reproduced from the modality field`
|
|
389
|
+
);
|
|
390
|
+
continue;
|
|
279
391
|
}
|
|
280
|
-
if (nameMap
|
|
281
|
-
|
|
392
|
+
if (nameMap?.[keyword]?.vr === "UI") {
|
|
393
|
+
console.warn(
|
|
394
|
+
`describe: ignoring keep-tag "${keyword}" \u2014 UID-valued tags are not kept verbatim`
|
|
395
|
+
);
|
|
396
|
+
continue;
|
|
282
397
|
}
|
|
283
|
-
|
|
284
|
-
}
|
|
398
|
+
kept.push(keyword);
|
|
399
|
+
}
|
|
400
|
+
return kept;
|
|
285
401
|
}
|
|
286
402
|
function* walkFiles(dir) {
|
|
287
403
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
@@ -293,18 +409,77 @@ function* walkFiles(dir) {
|
|
|
293
409
|
}
|
|
294
410
|
}
|
|
295
411
|
}
|
|
296
|
-
function
|
|
412
|
+
function toArrayBuffer(buf) {
|
|
413
|
+
return buf.buffer.slice(
|
|
414
|
+
buf.byteOffset,
|
|
415
|
+
buf.byteOffset + buf.byteLength
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
function naturalize(ab) {
|
|
419
|
+
const parsed = dcmjsAny.data.DicomMessage.readFile(ab);
|
|
420
|
+
return dcmjsAny.data.DicomMetaDictionary.naturalizeDataset(parsed.dict);
|
|
421
|
+
}
|
|
422
|
+
function parseFull(path) {
|
|
297
423
|
try {
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
424
|
+
return naturalize(toArrayBuffer(readFileSync(path)));
|
|
425
|
+
} catch {
|
|
426
|
+
return null;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
var PIXEL_DATA_OW = Buffer.from([224, 127, 16, 0, 79, 87]);
|
|
430
|
+
var PIXEL_DATA_OB = Buffer.from([224, 127, 16, 0, 79, 66]);
|
|
431
|
+
var EMPTY_OW_PIXEL_DATA = Buffer.from([
|
|
432
|
+
224,
|
|
433
|
+
127,
|
|
434
|
+
16,
|
|
435
|
+
0,
|
|
436
|
+
79,
|
|
437
|
+
87,
|
|
438
|
+
0,
|
|
439
|
+
0,
|
|
440
|
+
0,
|
|
441
|
+
0,
|
|
442
|
+
0,
|
|
443
|
+
0
|
|
444
|
+
]);
|
|
445
|
+
var HEADER_READ_BYTES = 2 * 1024 * 1024;
|
|
446
|
+
function findPixelDataOffset(prefix) {
|
|
447
|
+
let best = -1;
|
|
448
|
+
for (const pattern of [PIXEL_DATA_OW, PIXEL_DATA_OB]) {
|
|
449
|
+
for (let from = 0; ; ) {
|
|
450
|
+
const i = prefix.indexOf(pattern, from);
|
|
451
|
+
if (i < 0) break;
|
|
452
|
+
if (prefix[i + 6] === 0 && prefix[i + 7] === 0) {
|
|
453
|
+
if (best < 0 || i < best) best = i;
|
|
454
|
+
break;
|
|
455
|
+
}
|
|
456
|
+
from = i + 1;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
return best;
|
|
460
|
+
}
|
|
461
|
+
function readHeaderOnly(path) {
|
|
462
|
+
let prefix;
|
|
463
|
+
try {
|
|
464
|
+
const fd = openSync(path, "r");
|
|
465
|
+
try {
|
|
466
|
+
const buf = Buffer.alloc(HEADER_READ_BYTES);
|
|
467
|
+
const n = readSync(fd, buf, 0, HEADER_READ_BYTES, 0);
|
|
468
|
+
prefix = buf.subarray(0, n);
|
|
469
|
+
} finally {
|
|
470
|
+
closeSync(fd);
|
|
471
|
+
}
|
|
472
|
+
} catch {
|
|
473
|
+
return null;
|
|
474
|
+
}
|
|
475
|
+
const tagIdx = findPixelDataOffset(prefix);
|
|
476
|
+
if (tagIdx < 0) return null;
|
|
477
|
+
const headerOnly = Buffer.concat([
|
|
478
|
+
prefix.subarray(0, tagIdx),
|
|
479
|
+
EMPTY_OW_PIXEL_DATA
|
|
480
|
+
]);
|
|
481
|
+
try {
|
|
482
|
+
return naturalize(toArrayBuffer(headerOnly));
|
|
308
483
|
} catch {
|
|
309
484
|
return null;
|
|
310
485
|
}
|
|
@@ -314,10 +489,12 @@ function extractTags(record, keepKeywords) {
|
|
|
314
489
|
let found = false;
|
|
315
490
|
for (const keyword of keepKeywords) {
|
|
316
491
|
const value = record[keyword];
|
|
317
|
-
if (value
|
|
318
|
-
|
|
319
|
-
|
|
492
|
+
if (value === void 0) continue;
|
|
493
|
+
if (keyword === "Modality" && typeof value === "string" && SUPPORTED_MODALITIES.has(value)) {
|
|
494
|
+
continue;
|
|
320
495
|
}
|
|
496
|
+
tags[keyword] = typeof value === "string" ? escapeTagTemplate(value) : value;
|
|
497
|
+
found = true;
|
|
321
498
|
}
|
|
322
499
|
return found ? tags : void 0;
|
|
323
500
|
}
|
|
@@ -326,14 +503,25 @@ function describeDirectory(dir, options = {}) {
|
|
|
326
503
|
const studies = /* @__PURE__ */ new Map();
|
|
327
504
|
const stats = { dicomFiles: 0, skipped: 0, unknownModality: 0 };
|
|
328
505
|
for (const path of [...walkFiles(dir)].sort()) {
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
506
|
+
let size;
|
|
507
|
+
try {
|
|
508
|
+
size = statSync(path).size;
|
|
509
|
+
} catch {
|
|
510
|
+
stats.skipped++;
|
|
511
|
+
continue;
|
|
512
|
+
}
|
|
513
|
+
if (size > MAX_STREAM_BYTES) {
|
|
514
|
+
stats.skipped++;
|
|
515
|
+
continue;
|
|
516
|
+
}
|
|
517
|
+
const large = size > MAX_PIXEL_BYTES;
|
|
518
|
+
const record = large ? readHeaderOnly(path) : parseFull(path);
|
|
519
|
+
const studyUid = record?.StudyInstanceUID;
|
|
520
|
+
const seriesUid = record?.SeriesInstanceUID;
|
|
521
|
+
if (!record || typeof studyUid !== "string" || typeof seriesUid !== "string") {
|
|
333
522
|
stats.skipped++;
|
|
334
523
|
continue;
|
|
335
524
|
}
|
|
336
|
-
const { record } = parsed;
|
|
337
525
|
stats.dicomFiles++;
|
|
338
526
|
let modality;
|
|
339
527
|
if (typeof record.Modality === "string") {
|
|
@@ -343,7 +531,7 @@ function describeDirectory(dir, options = {}) {
|
|
|
343
531
|
stats.unknownModality++;
|
|
344
532
|
}
|
|
345
533
|
}
|
|
346
|
-
const sizeKb = Math.max(1, Math.round(
|
|
534
|
+
const sizeKb = Math.max(1, Math.round(size / 1024));
|
|
347
535
|
const tags = keepKeywords.length ? extractTags(record, keepKeywords) : void 0;
|
|
348
536
|
let study = studies.get(studyUid);
|
|
349
537
|
if (!study) {
|
|
@@ -355,25 +543,27 @@ function describeDirectory(dir, options = {}) {
|
|
|
355
543
|
series = /* @__PURE__ */ new Map();
|
|
356
544
|
study.set(seriesUid, series);
|
|
357
545
|
}
|
|
358
|
-
const collapseKey = JSON.stringify([
|
|
546
|
+
const collapseKey = JSON.stringify([
|
|
547
|
+
large,
|
|
548
|
+
modality ?? null,
|
|
549
|
+
large ? size : sizeKb,
|
|
550
|
+
tags ?? null
|
|
551
|
+
]);
|
|
359
552
|
const existing = series.get(collapseKey);
|
|
360
553
|
if (existing) {
|
|
361
554
|
existing.count++;
|
|
362
555
|
} else {
|
|
363
|
-
series.set(collapseKey, {
|
|
364
|
-
modality,
|
|
365
|
-
targetSizeKb: sizeKb,
|
|
366
|
-
tags,
|
|
367
|
-
count: 1
|
|
368
|
-
});
|
|
556
|
+
series.set(collapseKey, { modality, large, bytes: size, tags, count: 1 });
|
|
369
557
|
}
|
|
370
558
|
}
|
|
371
559
|
const studySpecs = [...studies.values()].map((study) => {
|
|
372
560
|
const seriesSpecs = [...study.values()].map((series) => {
|
|
373
561
|
const entries = [...series.values()].map((acc) => ({
|
|
374
|
-
type: "valid-image",
|
|
375
562
|
...acc.modality !== void 0 ? { modality: acc.modality } : {},
|
|
376
|
-
|
|
563
|
+
...acc.large ? { type: "large-image", targetBytes: acc.bytes } : {
|
|
564
|
+
type: "valid-image",
|
|
565
|
+
targetSizeKb: Math.max(1, Math.round(acc.bytes / 1024))
|
|
566
|
+
},
|
|
377
567
|
...acc.tags !== void 0 ? { tags: acc.tags } : {},
|
|
378
568
|
...acc.count > 1 ? { count: acc.count } : {}
|
|
379
569
|
}));
|
|
@@ -389,5 +579,6 @@ function describeDirectory(dir, options = {}) {
|
|
|
389
579
|
return { spec, stats };
|
|
390
580
|
}
|
|
391
581
|
export {
|
|
392
|
-
describeDirectory
|
|
582
|
+
describeDirectory,
|
|
583
|
+
readHeaderOnly
|
|
393
584
|
};
|