dicom-synth 1.9.0 → 1.11.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 +48 -11
- package/dist/esm/collection/writer.js +99 -27
- package/dist/esm/describe/describe.js +287 -49
- package/dist/esm/index.js +377 -75
- package/dist/esm/schema/parametric.js +36 -1
- package/dist/esm/schema/validate.js +126 -9
- package/dist/esm/syntheticFixtures/tagTemplate.js +49 -0
- package/dist/types/collection/writer.d.ts +2 -0
- package/dist/types/describe/describe.d.ts +2 -0
- package/dist/types/index.d.ts +2 -1
- package/dist/types/schema/types.d.ts +9 -1
- package/dist/types/syntheticFixtures/tagTemplate.d.ts +11 -0
- package/package.json +1 -1
|
@@ -32,6 +32,25 @@ var dcmjs = loadDcmjs();
|
|
|
32
32
|
var MAX_PIXEL_BYTES = 512 * 1024 * 1024;
|
|
33
33
|
var MAX_STREAM_BYTES = 50 * 1024 * 1024 * 1024;
|
|
34
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
|
+
|
|
35
54
|
// src/schema/validate.ts
|
|
36
55
|
var TYPE_CAPABILITIES = {
|
|
37
56
|
"valid-image": { image: true },
|
|
@@ -219,12 +238,102 @@ function validateEntry(entry, path) {
|
|
|
219
238
|
}
|
|
220
239
|
return e;
|
|
221
240
|
}
|
|
241
|
+
var MODALITY_TAG = "00080060";
|
|
242
|
+
function validateTagValue(key, value, path) {
|
|
243
|
+
const isModalityKey = key === "Modality" || key.toUpperCase() === MODALITY_TAG;
|
|
244
|
+
if (isModalityKey && typeof value === "string" && Object.hasOwn(VALID_MODALITIES, value)) {
|
|
245
|
+
throw new Error(
|
|
246
|
+
`${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`
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
if (typeof value === "string") {
|
|
250
|
+
for (const name of findTemplateTokens(value)) {
|
|
251
|
+
if (!TEMPLATE_VOCAB.includes(name)) {
|
|
252
|
+
throw new Error(
|
|
253
|
+
`${path}.${key}: unknown placeholder "{${name}}" \u2014 allowed: ${TEMPLATE_VOCAB.map((v) => `{${v}}`).join(", ")}`
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
222
259
|
function validateTags(tags, path) {
|
|
223
260
|
if (typeof tags !== "object" || tags === null || Array.isArray(tags)) {
|
|
224
261
|
throw new Error(`${path}: must be an object`);
|
|
225
262
|
}
|
|
226
|
-
for (const key of Object.
|
|
263
|
+
for (const [key, value] of Object.entries(tags)) {
|
|
227
264
|
validateTagKey(key, path);
|
|
265
|
+
validateTagValue(key, value, path);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
function validateArrayOfLength(value, count, path) {
|
|
269
|
+
if (!Array.isArray(value)) {
|
|
270
|
+
throw new Error(`${path}: must be an array`);
|
|
271
|
+
}
|
|
272
|
+
if (value.length !== count) {
|
|
273
|
+
throw new Error(
|
|
274
|
+
`${path}: length (${value.length}) must equal count (${count})`
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
return value;
|
|
278
|
+
}
|
|
279
|
+
function validateInstances(instances, path) {
|
|
280
|
+
if (typeof instances !== "object" || instances === null || Array.isArray(instances)) {
|
|
281
|
+
throw new Error(`${path}: must be an object`);
|
|
282
|
+
}
|
|
283
|
+
const inst = instances;
|
|
284
|
+
validatePositiveInt(inst.count, `${path}.count`);
|
|
285
|
+
const count = inst.count;
|
|
286
|
+
const hasKb = "targetSizeKb" in inst;
|
|
287
|
+
const hasBytes = "targetBytes" in inst;
|
|
288
|
+
if (hasKb === hasBytes) {
|
|
289
|
+
throw new Error(
|
|
290
|
+
`${path}: exactly one of "targetSizeKb" or "targetBytes" is required`
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
if (hasKb) {
|
|
294
|
+
const sizes = validateArrayOfLength(
|
|
295
|
+
inst.targetSizeKb,
|
|
296
|
+
count,
|
|
297
|
+
`${path}.targetSizeKb`
|
|
298
|
+
);
|
|
299
|
+
sizes.forEach((kb, i) => {
|
|
300
|
+
validatePositiveInt(kb, `${path}.targetSizeKb[${i}]`);
|
|
301
|
+
validateSizeKbLimit(kb, `${path}.targetSizeKb[${i}]`);
|
|
302
|
+
});
|
|
303
|
+
} else {
|
|
304
|
+
const sizes = validateArrayOfLength(
|
|
305
|
+
inst.targetBytes,
|
|
306
|
+
count,
|
|
307
|
+
`${path}.targetBytes`
|
|
308
|
+
);
|
|
309
|
+
sizes.forEach((b, i) => {
|
|
310
|
+
validateLargeTargetBytes(b, `${path}.targetBytes[${i}]`);
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
if ("modality" in inst) {
|
|
314
|
+
if (Array.isArray(inst.modality)) {
|
|
315
|
+
validateArrayOfLength(inst.modality, count, `${path}.modality`).forEach(
|
|
316
|
+
(m, i) => {
|
|
317
|
+
validateEnum(m, VALID_MODALITIES, `${path}.modality[${i}]`);
|
|
318
|
+
}
|
|
319
|
+
);
|
|
320
|
+
} else {
|
|
321
|
+
validateEnum(inst.modality, VALID_MODALITIES, `${path}.modality`);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
if ("tags" in inst) {
|
|
325
|
+
const tags = inst.tags;
|
|
326
|
+
if (typeof tags !== "object" || tags === null || Array.isArray(tags)) {
|
|
327
|
+
throw new Error(`${path}.tags: must be an object`);
|
|
328
|
+
}
|
|
329
|
+
for (const [key, arr] of Object.entries(tags)) {
|
|
330
|
+
validateTagKey(key, `${path}.tags`);
|
|
331
|
+
validateArrayOfLength(arr, count, `${path}.tags.${key}`).forEach(
|
|
332
|
+
(value, i) => {
|
|
333
|
+
validateTagValue(key, value, `${path}.tags[${i}]`);
|
|
334
|
+
}
|
|
335
|
+
);
|
|
336
|
+
}
|
|
228
337
|
}
|
|
229
338
|
}
|
|
230
339
|
function validateSeries(series, path) {
|
|
@@ -232,16 +341,27 @@ function validateSeries(series, path) {
|
|
|
232
341
|
throw new Error(`${path}: must be an object`);
|
|
233
342
|
}
|
|
234
343
|
const s = series;
|
|
235
|
-
|
|
236
|
-
|
|
344
|
+
const hasEntries = "entries" in s;
|
|
345
|
+
const hasInstances = "instances" in s;
|
|
346
|
+
if (hasEntries === hasInstances) {
|
|
347
|
+
throw new Error(
|
|
348
|
+
`${path}: must have exactly one of "entries" or "instances"`
|
|
349
|
+
);
|
|
237
350
|
}
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
throw new Error(
|
|
242
|
-
`${path}.entries[${i}].type: "${e.type}" cannot be used inside a series \u2014 only image-generating types can be grouped`
|
|
243
|
-
);
|
|
351
|
+
if (hasEntries) {
|
|
352
|
+
if (!Array.isArray(s.entries) || s.entries.length === 0) {
|
|
353
|
+
throw new Error(`${path}.entries: must be a non-empty array`);
|
|
244
354
|
}
|
|
355
|
+
for (let i = 0; i < s.entries.length; i++) {
|
|
356
|
+
const e = validateEntry(s.entries[i], `${path}.entries[${i}]`);
|
|
357
|
+
if (!TYPE_CAPABILITIES[e.type].image) {
|
|
358
|
+
throw new Error(
|
|
359
|
+
`${path}.entries[${i}].type: "${e.type}" cannot be used inside a series \u2014 only image-generating types can be grouped`
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
} else {
|
|
364
|
+
validateInstances(s.instances, `${path}.instances`);
|
|
245
365
|
}
|
|
246
366
|
if ("tags" in s) validateTags(s.tags, `${path}.tags`);
|
|
247
367
|
return s;
|
|
@@ -310,6 +430,13 @@ function validateDatasetSpec(raw) {
|
|
|
310
430
|
var dcmjsAny = dcmjs;
|
|
311
431
|
var HEX_TAG_RE2 = /^[0-9a-fA-F]{8}$/;
|
|
312
432
|
var SUPPORTED_MODALITIES = /* @__PURE__ */ new Set(["CT", "PT", "MR", "CR"]);
|
|
433
|
+
var RESERVED_KEEP_KEYWORDS = /* @__PURE__ */ new Set([
|
|
434
|
+
"Units",
|
|
435
|
+
"DecayCorrection",
|
|
436
|
+
"CorrectedImage",
|
|
437
|
+
"ScanningSequence",
|
|
438
|
+
"SequenceVariant"
|
|
439
|
+
]);
|
|
313
440
|
function buildHexToKeyword() {
|
|
314
441
|
const map = /* @__PURE__ */ new Map();
|
|
315
442
|
const nm = dcmjsAny.data.DicomMetaDictionary.nameMap;
|
|
@@ -325,20 +452,37 @@ function buildHexToKeyword() {
|
|
|
325
452
|
function resolveKeepTags(keepTags) {
|
|
326
453
|
const nameMap = dcmjsAny.data.DicomMetaDictionary.nameMap;
|
|
327
454
|
let hexToKeyword;
|
|
328
|
-
|
|
455
|
+
const kept = [];
|
|
456
|
+
for (const tag of keepTags) {
|
|
457
|
+
let keyword;
|
|
329
458
|
if (HEX_TAG_RE2.test(tag)) {
|
|
330
459
|
hexToKeyword ?? (hexToKeyword = buildHexToKeyword());
|
|
331
|
-
const
|
|
332
|
-
if (!
|
|
460
|
+
const mapped = hexToKeyword.get(tag.toUpperCase());
|
|
461
|
+
if (!mapped) {
|
|
333
462
|
throw new Error(`describe: unknown hex tag "${tag}"`);
|
|
334
463
|
}
|
|
335
|
-
|
|
464
|
+
keyword = mapped;
|
|
465
|
+
} else {
|
|
466
|
+
if (nameMap && !Object.hasOwn(nameMap, tag)) {
|
|
467
|
+
throw new Error(`describe: unknown tag keyword "${tag}"`);
|
|
468
|
+
}
|
|
469
|
+
keyword = tag;
|
|
470
|
+
}
|
|
471
|
+
if (RESERVED_KEEP_KEYWORDS.has(keyword)) {
|
|
472
|
+
console.warn(
|
|
473
|
+
`describe: ignoring keep-tag "${keyword}" \u2014 modality-coupled tags are reproduced from the modality field`
|
|
474
|
+
);
|
|
475
|
+
continue;
|
|
336
476
|
}
|
|
337
|
-
if (nameMap
|
|
338
|
-
|
|
477
|
+
if (nameMap?.[keyword]?.vr === "UI") {
|
|
478
|
+
console.warn(
|
|
479
|
+
`describe: ignoring keep-tag "${keyword}" \u2014 UID-valued tags are not kept verbatim`
|
|
480
|
+
);
|
|
481
|
+
continue;
|
|
339
482
|
}
|
|
340
|
-
|
|
341
|
-
}
|
|
483
|
+
kept.push(keyword);
|
|
484
|
+
}
|
|
485
|
+
return kept;
|
|
342
486
|
}
|
|
343
487
|
function* walkFiles(dir) {
|
|
344
488
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
@@ -430,15 +574,126 @@ function extractTags(record, keepKeywords) {
|
|
|
430
574
|
let found = false;
|
|
431
575
|
for (const keyword of keepKeywords) {
|
|
432
576
|
const value = record[keyword];
|
|
433
|
-
if (value
|
|
434
|
-
|
|
435
|
-
|
|
577
|
+
if (value === void 0) continue;
|
|
578
|
+
if (keyword === "Modality" && typeof value === "string" && SUPPORTED_MODALITIES.has(value)) {
|
|
579
|
+
continue;
|
|
436
580
|
}
|
|
581
|
+
tags[keyword] = typeof value === "string" ? escapeTagTemplate(value) : value;
|
|
582
|
+
found = true;
|
|
437
583
|
}
|
|
438
584
|
return found ? tags : void 0;
|
|
439
585
|
}
|
|
586
|
+
function sizeKbOf(bytes) {
|
|
587
|
+
return Math.max(1, Math.round(bytes / 1024));
|
|
588
|
+
}
|
|
589
|
+
function bucketBytes(bytes, tolerance) {
|
|
590
|
+
if (tolerance <= 0 || bytes < 1) return bytes;
|
|
591
|
+
const factor = 1 + tolerance;
|
|
592
|
+
const bucket = Math.floor(Math.log(bytes) / Math.log(factor));
|
|
593
|
+
return Math.max(1, Math.round(factor ** (bucket + 0.5)));
|
|
594
|
+
}
|
|
595
|
+
function collapseKeyOf(r) {
|
|
596
|
+
return JSON.stringify([
|
|
597
|
+
r.large,
|
|
598
|
+
r.modality ?? null,
|
|
599
|
+
r.large ? r.bytes : sizeKbOf(r.bytes),
|
|
600
|
+
r.tags ?? null
|
|
601
|
+
]);
|
|
602
|
+
}
|
|
603
|
+
function accumulate(byKey, r) {
|
|
604
|
+
const key = collapseKeyOf(r);
|
|
605
|
+
const existing = byKey.get(key);
|
|
606
|
+
if (existing) {
|
|
607
|
+
existing.count++;
|
|
608
|
+
} else {
|
|
609
|
+
byKey.set(key, {
|
|
610
|
+
modality: r.modality,
|
|
611
|
+
large: r.large,
|
|
612
|
+
bytes: r.bytes,
|
|
613
|
+
tags: r.tags,
|
|
614
|
+
count: 1
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
function entryOf(acc) {
|
|
619
|
+
return {
|
|
620
|
+
...acc.modality !== void 0 ? { modality: acc.modality } : {},
|
|
621
|
+
...acc.large ? { type: "large-image", targetBytes: acc.bytes } : { type: "valid-image", targetSizeKb: sizeKbOf(acc.bytes) },
|
|
622
|
+
...acc.tags !== void 0 ? { tags: acc.tags } : {},
|
|
623
|
+
...acc.count > 1 ? { count: acc.count } : {}
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
function collapseEntries(records) {
|
|
627
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
628
|
+
for (const r of records) accumulate(byKey, r);
|
|
629
|
+
return [...byKey.values()].map(entryOf);
|
|
630
|
+
}
|
|
631
|
+
function tagValuesEqual(a, b) {
|
|
632
|
+
if (a === b) return true;
|
|
633
|
+
if (typeof a === "object" && a !== null && typeof b === "object" && b !== null)
|
|
634
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
635
|
+
return false;
|
|
636
|
+
}
|
|
637
|
+
function tryInstancesForm(records) {
|
|
638
|
+
const count = records.length;
|
|
639
|
+
if (count === 0) return null;
|
|
640
|
+
const large = records[0]?.large ?? false;
|
|
641
|
+
const modality = records[0]?.modality;
|
|
642
|
+
const columns = /* @__PURE__ */ new Map();
|
|
643
|
+
for (let i = 0; i < count; i++) {
|
|
644
|
+
const r = records[i];
|
|
645
|
+
if (r.large !== large || r.modality !== modality) return null;
|
|
646
|
+
if (!r.tags) continue;
|
|
647
|
+
for (const [k, v] of Object.entries(r.tags)) {
|
|
648
|
+
let col = columns.get(k);
|
|
649
|
+
if (!col) {
|
|
650
|
+
col = { values: new Array(count), present: 0 };
|
|
651
|
+
columns.set(k, col);
|
|
652
|
+
}
|
|
653
|
+
col.values[i] = v;
|
|
654
|
+
col.present++;
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
if (columns.size === 0) return null;
|
|
658
|
+
const sharedTags = {};
|
|
659
|
+
const perInstanceTags = {};
|
|
660
|
+
for (const [k, col] of columns) {
|
|
661
|
+
if (col.present !== count) return null;
|
|
662
|
+
const first = col.values[0];
|
|
663
|
+
if (col.values.every((v) => tagValuesEqual(v, first))) {
|
|
664
|
+
sharedTags[k] = first;
|
|
665
|
+
} else {
|
|
666
|
+
perInstanceTags[k] = col.values;
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
if (Object.keys(perInstanceTags).length === 0) return null;
|
|
670
|
+
const instances = {
|
|
671
|
+
count,
|
|
672
|
+
...large ? { targetBytes: records.map((r) => r.bytes) } : { targetSizeKb: records.map((r) => sizeKbOf(r.bytes)) },
|
|
673
|
+
...modality !== void 0 ? { modality } : {},
|
|
674
|
+
// perInstanceTags is non-empty here (we returned null above otherwise).
|
|
675
|
+
tags: perInstanceTags
|
|
676
|
+
};
|
|
677
|
+
return {
|
|
678
|
+
instances,
|
|
679
|
+
...Object.keys(sharedTags).length ? { tags: sharedTags } : {}
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
function buildSeriesSpec(records) {
|
|
683
|
+
return tryInstancesForm(records) ?? { entries: collapseEntries(records) };
|
|
684
|
+
}
|
|
685
|
+
function seriesSpecFromAccum(accum) {
|
|
686
|
+
return Array.isArray(accum) ? buildSeriesSpec(accum) : { entries: [...accum.values()].map(entryOf) };
|
|
687
|
+
}
|
|
440
688
|
function describeDirectory(dir, options = {}) {
|
|
441
689
|
const keepKeywords = options.keepTags ? resolveKeepTags(options.keepTags) : [];
|
|
690
|
+
const keepTags = keepKeywords.length > 0;
|
|
691
|
+
const tolerance = options.sizeTolerance ?? 0;
|
|
692
|
+
if (!Number.isFinite(tolerance) || tolerance < 0) {
|
|
693
|
+
throw new Error(
|
|
694
|
+
`describe: sizeTolerance must be a non-negative fraction; got ${JSON.stringify(options.sizeTolerance)}`
|
|
695
|
+
);
|
|
696
|
+
}
|
|
442
697
|
const studies = /* @__PURE__ */ new Map();
|
|
443
698
|
const stats = { dicomFiles: 0, skipped: 0, unknownModality: 0 };
|
|
444
699
|
for (const path of [...walkFiles(dir)].sort()) {
|
|
@@ -470,8 +725,9 @@ function describeDirectory(dir, options = {}) {
|
|
|
470
725
|
stats.unknownModality++;
|
|
471
726
|
}
|
|
472
727
|
}
|
|
473
|
-
const
|
|
474
|
-
const
|
|
728
|
+
const tags = keepTags ? extractTags(record, keepKeywords) : void 0;
|
|
729
|
+
const bytes = large ? size : Math.min(bucketBytes(size, tolerance), MAX_PIXEL_BYTES);
|
|
730
|
+
const fileRecord = { large, bytes, modality, tags };
|
|
475
731
|
let study = studies.get(studyUid);
|
|
476
732
|
if (!study) {
|
|
477
733
|
study = /* @__PURE__ */ new Map();
|
|
@@ -479,37 +735,18 @@ function describeDirectory(dir, options = {}) {
|
|
|
479
735
|
}
|
|
480
736
|
let series = study.get(seriesUid);
|
|
481
737
|
if (!series) {
|
|
482
|
-
series = /* @__PURE__ */ new Map();
|
|
738
|
+
series = keepTags ? [] : /* @__PURE__ */ new Map();
|
|
483
739
|
study.set(seriesUid, series);
|
|
484
740
|
}
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
modality ?? null,
|
|
488
|
-
large ? size : sizeKb,
|
|
489
|
-
tags ?? null
|
|
490
|
-
]);
|
|
491
|
-
const existing = series.get(collapseKey);
|
|
492
|
-
if (existing) {
|
|
493
|
-
existing.count++;
|
|
741
|
+
if (Array.isArray(series)) {
|
|
742
|
+
series.push(fileRecord);
|
|
494
743
|
} else {
|
|
495
|
-
series
|
|
744
|
+
accumulate(series, fileRecord);
|
|
496
745
|
}
|
|
497
746
|
}
|
|
498
|
-
const studySpecs = [...studies.values()].map((study) => {
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
...acc.modality !== void 0 ? { modality: acc.modality } : {},
|
|
502
|
-
...acc.large ? { type: "large-image", targetBytes: acc.bytes } : {
|
|
503
|
-
type: "valid-image",
|
|
504
|
-
targetSizeKb: Math.max(1, Math.round(acc.bytes / 1024))
|
|
505
|
-
},
|
|
506
|
-
...acc.tags !== void 0 ? { tags: acc.tags } : {},
|
|
507
|
-
...acc.count > 1 ? { count: acc.count } : {}
|
|
508
|
-
}));
|
|
509
|
-
return { entries };
|
|
510
|
-
});
|
|
511
|
-
return { series: seriesSpecs };
|
|
512
|
-
});
|
|
747
|
+
const studySpecs = [...studies.values()].map((study) => ({
|
|
748
|
+
series: [...study.values()].map(seriesSpecFromAccum)
|
|
749
|
+
}));
|
|
513
750
|
if (studySpecs.length === 0) {
|
|
514
751
|
throw new Error(`describe: no DICOM series found in ${dir}`);
|
|
515
752
|
}
|
|
@@ -519,5 +756,6 @@ function describeDirectory(dir, options = {}) {
|
|
|
519
756
|
}
|
|
520
757
|
export {
|
|
521
758
|
describeDirectory,
|
|
522
|
-
readHeaderOnly
|
|
759
|
+
readHeaderOnly,
|
|
760
|
+
tagValuesEqual
|
|
523
761
|
};
|