dicom-synth 1.10.0 → 1.12.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 +23 -5
- package/dist/esm/collection/writer.js +100 -95
- package/dist/esm/describe/describe.js +234 -48
- package/dist/esm/index.js +345 -137
- package/dist/esm/schema/parametric.js +31 -16
- package/dist/esm/schema/validate.js +115 -19
- package/dist/esm/syntheticFixtures/streamWrite.js +20 -35
- package/dist/esm/syntheticFixtures/uid.js +28 -47
- package/dist/types/collection/writer.d.ts +2 -0
- package/dist/types/describe/describe.d.ts +2 -0
- package/dist/types/index.d.ts +3 -2
- package/dist/types/schema/types.d.ts +11 -1
- package/dist/types/syntheticFixtures/streamWrite.d.ts +6 -2
- package/dist/types/syntheticFixtures/uid.d.ts +4 -2
- package/package.json +1 -1
|
@@ -105,6 +105,13 @@ function validateSeed(value, path) {
|
|
|
105
105
|
);
|
|
106
106
|
}
|
|
107
107
|
}
|
|
108
|
+
function validateUidSalt(value, path) {
|
|
109
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
110
|
+
throw new Error(
|
|
111
|
+
`${path}: must be a non-empty string; got ${JSON.stringify(value)}`
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
108
115
|
function validateSizeKbLimit(kb, path) {
|
|
109
116
|
if (kb * 1024 > MAX_PIXEL_BYTES) {
|
|
110
117
|
throw new Error(`${path}: exceeds the 512 MB limit`);
|
|
@@ -239,26 +246,100 @@ function validateEntry(entry, path) {
|
|
|
239
246
|
return e;
|
|
240
247
|
}
|
|
241
248
|
var MODALITY_TAG = "00080060";
|
|
249
|
+
function validateTagValue(key, value, path) {
|
|
250
|
+
const isModalityKey = key === "Modality" || key.toUpperCase() === MODALITY_TAG;
|
|
251
|
+
if (isModalityKey && typeof value === "string" && Object.hasOwn(VALID_MODALITIES, value)) {
|
|
252
|
+
throw new Error(
|
|
253
|
+
`${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`
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
if (typeof value === "string") {
|
|
257
|
+
for (const name of findTemplateTokens(value)) {
|
|
258
|
+
if (!TEMPLATE_VOCAB.includes(name)) {
|
|
259
|
+
throw new Error(
|
|
260
|
+
`${path}.${key}: unknown placeholder "{${name}}" \u2014 allowed: ${TEMPLATE_VOCAB.map((v) => `{${v}}`).join(", ")}`
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
242
266
|
function validateTags(tags, path) {
|
|
243
267
|
if (typeof tags !== "object" || tags === null || Array.isArray(tags)) {
|
|
244
268
|
throw new Error(`${path}: must be an object`);
|
|
245
269
|
}
|
|
246
270
|
for (const [key, value] of Object.entries(tags)) {
|
|
247
271
|
validateTagKey(key, path);
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
272
|
+
validateTagValue(key, value, path);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
function validateArrayOfLength(value, count, path) {
|
|
276
|
+
if (!Array.isArray(value)) {
|
|
277
|
+
throw new Error(`${path}: must be an array`);
|
|
278
|
+
}
|
|
279
|
+
if (value.length !== count) {
|
|
280
|
+
throw new Error(
|
|
281
|
+
`${path}: length (${value.length}) must equal count (${count})`
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
return value;
|
|
285
|
+
}
|
|
286
|
+
function validateInstances(instances, path) {
|
|
287
|
+
if (typeof instances !== "object" || instances === null || Array.isArray(instances)) {
|
|
288
|
+
throw new Error(`${path}: must be an object`);
|
|
289
|
+
}
|
|
290
|
+
const inst = instances;
|
|
291
|
+
validatePositiveInt(inst.count, `${path}.count`);
|
|
292
|
+
const count = inst.count;
|
|
293
|
+
const hasKb = "targetSizeKb" in inst;
|
|
294
|
+
const hasBytes = "targetBytes" in inst;
|
|
295
|
+
if (hasKb === hasBytes) {
|
|
296
|
+
throw new Error(
|
|
297
|
+
`${path}: exactly one of "targetSizeKb" or "targetBytes" is required`
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
if (hasKb) {
|
|
301
|
+
const sizes = validateArrayOfLength(
|
|
302
|
+
inst.targetSizeKb,
|
|
303
|
+
count,
|
|
304
|
+
`${path}.targetSizeKb`
|
|
305
|
+
);
|
|
306
|
+
sizes.forEach((kb, i) => {
|
|
307
|
+
validatePositiveInt(kb, `${path}.targetSizeKb[${i}]`);
|
|
308
|
+
validateSizeKbLimit(kb, `${path}.targetSizeKb[${i}]`);
|
|
309
|
+
});
|
|
310
|
+
} else {
|
|
311
|
+
const sizes = validateArrayOfLength(
|
|
312
|
+
inst.targetBytes,
|
|
313
|
+
count,
|
|
314
|
+
`${path}.targetBytes`
|
|
315
|
+
);
|
|
316
|
+
sizes.forEach((b, i) => {
|
|
317
|
+
validateLargeTargetBytes(b, `${path}.targetBytes[${i}]`);
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
if ("modality" in inst) {
|
|
321
|
+
if (Array.isArray(inst.modality)) {
|
|
322
|
+
validateArrayOfLength(inst.modality, count, `${path}.modality`).forEach(
|
|
323
|
+
(m, i) => {
|
|
324
|
+
validateEnum(m, VALID_MODALITIES, `${path}.modality[${i}]`);
|
|
325
|
+
}
|
|
252
326
|
);
|
|
327
|
+
} else {
|
|
328
|
+
validateEnum(inst.modality, VALID_MODALITIES, `${path}.modality`);
|
|
253
329
|
}
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
330
|
+
}
|
|
331
|
+
if ("tags" in inst) {
|
|
332
|
+
const tags = inst.tags;
|
|
333
|
+
if (typeof tags !== "object" || tags === null || Array.isArray(tags)) {
|
|
334
|
+
throw new Error(`${path}.tags: must be an object`);
|
|
335
|
+
}
|
|
336
|
+
for (const [key, arr] of Object.entries(tags)) {
|
|
337
|
+
validateTagKey(key, `${path}.tags`);
|
|
338
|
+
validateArrayOfLength(arr, count, `${path}.tags.${key}`).forEach(
|
|
339
|
+
(value, i) => {
|
|
340
|
+
validateTagValue(key, value, `${path}.tags[${i}]`);
|
|
260
341
|
}
|
|
261
|
-
|
|
342
|
+
);
|
|
262
343
|
}
|
|
263
344
|
}
|
|
264
345
|
}
|
|
@@ -267,16 +348,27 @@ function validateSeries(series, path) {
|
|
|
267
348
|
throw new Error(`${path}: must be an object`);
|
|
268
349
|
}
|
|
269
350
|
const s = series;
|
|
270
|
-
|
|
271
|
-
|
|
351
|
+
const hasEntries = "entries" in s;
|
|
352
|
+
const hasInstances = "instances" in s;
|
|
353
|
+
if (hasEntries === hasInstances) {
|
|
354
|
+
throw new Error(
|
|
355
|
+
`${path}: must have exactly one of "entries" or "instances"`
|
|
356
|
+
);
|
|
272
357
|
}
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
throw new Error(
|
|
277
|
-
`${path}.entries[${i}].type: "${e.type}" cannot be used inside a series \u2014 only image-generating types can be grouped`
|
|
278
|
-
);
|
|
358
|
+
if (hasEntries) {
|
|
359
|
+
if (!Array.isArray(s.entries) || s.entries.length === 0) {
|
|
360
|
+
throw new Error(`${path}.entries: must be a non-empty array`);
|
|
279
361
|
}
|
|
362
|
+
for (let i = 0; i < s.entries.length; i++) {
|
|
363
|
+
const e = validateEntry(s.entries[i], `${path}.entries[${i}]`);
|
|
364
|
+
if (!TYPE_CAPABILITIES[e.type].image) {
|
|
365
|
+
throw new Error(
|
|
366
|
+
`${path}.entries[${i}].type: "${e.type}" cannot be used inside a series \u2014 only image-generating types can be grouped`
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
} else {
|
|
371
|
+
validateInstances(s.instances, `${path}.instances`);
|
|
280
372
|
}
|
|
281
373
|
if ("tags" in s) validateTags(s.tags, `${path}.tags`);
|
|
282
374
|
return s;
|
|
@@ -321,6 +413,7 @@ function validateDatasetSpec(raw) {
|
|
|
321
413
|
(study, i) => validateStudy(study, `studies[${i}]`)
|
|
322
414
|
);
|
|
323
415
|
if ("seed" in r) validateSeed(r.seed, "DatasetSpec.seed");
|
|
416
|
+
if ("uidSalt" in r) validateUidSalt(r.uidSalt, "DatasetSpec.uidSalt");
|
|
324
417
|
if ("layout" in r) {
|
|
325
418
|
validateEnum(r.layout, VALID_LAYOUTS, "DatasetSpec.layout");
|
|
326
419
|
}
|
|
@@ -336,6 +429,7 @@ function validateDatasetSpec(raw) {
|
|
|
336
429
|
...entries.length > 0 ? { entries } : {},
|
|
337
430
|
...studies.length > 0 ? { studies } : {},
|
|
338
431
|
...r.seed !== void 0 ? { seed: r.seed } : {},
|
|
432
|
+
...r.uidSalt !== void 0 ? { uidSalt: r.uidSalt } : {},
|
|
339
433
|
...r.layout !== void 0 ? { layout: r.layout } : {},
|
|
340
434
|
...r.pathQuirks !== void 0 ? { pathQuirks: r.pathQuirks } : {}
|
|
341
435
|
};
|
|
@@ -498,8 +592,117 @@ function extractTags(record, keepKeywords) {
|
|
|
498
592
|
}
|
|
499
593
|
return found ? tags : void 0;
|
|
500
594
|
}
|
|
595
|
+
function sizeKbOf(bytes) {
|
|
596
|
+
return Math.max(1, Math.round(bytes / 1024));
|
|
597
|
+
}
|
|
598
|
+
function bucketBytes(bytes, tolerance) {
|
|
599
|
+
if (tolerance <= 0 || bytes < 1) return bytes;
|
|
600
|
+
const factor = 1 + tolerance;
|
|
601
|
+
const bucket = Math.floor(Math.log(bytes) / Math.log(factor));
|
|
602
|
+
return Math.max(1, Math.round(factor ** (bucket + 0.5)));
|
|
603
|
+
}
|
|
604
|
+
function collapseKeyOf(r) {
|
|
605
|
+
return JSON.stringify([
|
|
606
|
+
r.large,
|
|
607
|
+
r.modality ?? null,
|
|
608
|
+
r.large ? r.bytes : sizeKbOf(r.bytes),
|
|
609
|
+
r.tags ?? null
|
|
610
|
+
]);
|
|
611
|
+
}
|
|
612
|
+
function accumulate(byKey, r) {
|
|
613
|
+
const key = collapseKeyOf(r);
|
|
614
|
+
const existing = byKey.get(key);
|
|
615
|
+
if (existing) {
|
|
616
|
+
existing.count++;
|
|
617
|
+
} else {
|
|
618
|
+
byKey.set(key, {
|
|
619
|
+
modality: r.modality,
|
|
620
|
+
large: r.large,
|
|
621
|
+
bytes: r.bytes,
|
|
622
|
+
tags: r.tags,
|
|
623
|
+
count: 1
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
function entryOf(acc) {
|
|
628
|
+
return {
|
|
629
|
+
...acc.modality !== void 0 ? { modality: acc.modality } : {},
|
|
630
|
+
...acc.large ? { type: "large-image", targetBytes: acc.bytes } : { type: "valid-image", targetSizeKb: sizeKbOf(acc.bytes) },
|
|
631
|
+
...acc.tags !== void 0 ? { tags: acc.tags } : {},
|
|
632
|
+
...acc.count > 1 ? { count: acc.count } : {}
|
|
633
|
+
};
|
|
634
|
+
}
|
|
635
|
+
function collapseEntries(records) {
|
|
636
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
637
|
+
for (const r of records) accumulate(byKey, r);
|
|
638
|
+
return [...byKey.values()].map(entryOf);
|
|
639
|
+
}
|
|
640
|
+
function tagValuesEqual(a, b) {
|
|
641
|
+
if (a === b) return true;
|
|
642
|
+
if (typeof a === "object" && a !== null && typeof b === "object" && b !== null)
|
|
643
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
644
|
+
return false;
|
|
645
|
+
}
|
|
646
|
+
function tryInstancesForm(records) {
|
|
647
|
+
const count = records.length;
|
|
648
|
+
if (count === 0) return null;
|
|
649
|
+
const large = records[0]?.large ?? false;
|
|
650
|
+
const modality = records[0]?.modality;
|
|
651
|
+
const columns = /* @__PURE__ */ new Map();
|
|
652
|
+
for (let i = 0; i < count; i++) {
|
|
653
|
+
const r = records[i];
|
|
654
|
+
if (r.large !== large || r.modality !== modality) return null;
|
|
655
|
+
if (!r.tags) continue;
|
|
656
|
+
for (const [k, v] of Object.entries(r.tags)) {
|
|
657
|
+
let col = columns.get(k);
|
|
658
|
+
if (!col) {
|
|
659
|
+
col = { values: new Array(count), present: 0 };
|
|
660
|
+
columns.set(k, col);
|
|
661
|
+
}
|
|
662
|
+
col.values[i] = v;
|
|
663
|
+
col.present++;
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
if (columns.size === 0) return null;
|
|
667
|
+
const sharedTags = {};
|
|
668
|
+
const perInstanceTags = {};
|
|
669
|
+
for (const [k, col] of columns) {
|
|
670
|
+
if (col.present !== count) return null;
|
|
671
|
+
const first = col.values[0];
|
|
672
|
+
if (col.values.every((v) => tagValuesEqual(v, first))) {
|
|
673
|
+
sharedTags[k] = first;
|
|
674
|
+
} else {
|
|
675
|
+
perInstanceTags[k] = col.values;
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
if (Object.keys(perInstanceTags).length === 0) return null;
|
|
679
|
+
const instances = {
|
|
680
|
+
count,
|
|
681
|
+
...large ? { targetBytes: records.map((r) => r.bytes) } : { targetSizeKb: records.map((r) => sizeKbOf(r.bytes)) },
|
|
682
|
+
...modality !== void 0 ? { modality } : {},
|
|
683
|
+
// perInstanceTags is non-empty here (we returned null above otherwise).
|
|
684
|
+
tags: perInstanceTags
|
|
685
|
+
};
|
|
686
|
+
return {
|
|
687
|
+
instances,
|
|
688
|
+
...Object.keys(sharedTags).length ? { tags: sharedTags } : {}
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
function buildSeriesSpec(records) {
|
|
692
|
+
return tryInstancesForm(records) ?? { entries: collapseEntries(records) };
|
|
693
|
+
}
|
|
694
|
+
function seriesSpecFromAccum(accum) {
|
|
695
|
+
return Array.isArray(accum) ? buildSeriesSpec(accum) : { entries: [...accum.values()].map(entryOf) };
|
|
696
|
+
}
|
|
501
697
|
function describeDirectory(dir, options = {}) {
|
|
502
698
|
const keepKeywords = options.keepTags ? resolveKeepTags(options.keepTags) : [];
|
|
699
|
+
const keepTags = keepKeywords.length > 0;
|
|
700
|
+
const tolerance = options.sizeTolerance ?? 0;
|
|
701
|
+
if (!Number.isFinite(tolerance) || tolerance < 0) {
|
|
702
|
+
throw new Error(
|
|
703
|
+
`describe: sizeTolerance must be a non-negative fraction; got ${JSON.stringify(options.sizeTolerance)}`
|
|
704
|
+
);
|
|
705
|
+
}
|
|
503
706
|
const studies = /* @__PURE__ */ new Map();
|
|
504
707
|
const stats = { dicomFiles: 0, skipped: 0, unknownModality: 0 };
|
|
505
708
|
for (const path of [...walkFiles(dir)].sort()) {
|
|
@@ -531,8 +734,9 @@ function describeDirectory(dir, options = {}) {
|
|
|
531
734
|
stats.unknownModality++;
|
|
532
735
|
}
|
|
533
736
|
}
|
|
534
|
-
const
|
|
535
|
-
const
|
|
737
|
+
const tags = keepTags ? extractTags(record, keepKeywords) : void 0;
|
|
738
|
+
const bytes = large ? size : Math.min(bucketBytes(size, tolerance), MAX_PIXEL_BYTES);
|
|
739
|
+
const fileRecord = { large, bytes, modality, tags };
|
|
536
740
|
let study = studies.get(studyUid);
|
|
537
741
|
if (!study) {
|
|
538
742
|
study = /* @__PURE__ */ new Map();
|
|
@@ -540,37 +744,18 @@ function describeDirectory(dir, options = {}) {
|
|
|
540
744
|
}
|
|
541
745
|
let series = study.get(seriesUid);
|
|
542
746
|
if (!series) {
|
|
543
|
-
series = /* @__PURE__ */ new Map();
|
|
747
|
+
series = keepTags ? [] : /* @__PURE__ */ new Map();
|
|
544
748
|
study.set(seriesUid, series);
|
|
545
749
|
}
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
modality ?? null,
|
|
549
|
-
large ? size : sizeKb,
|
|
550
|
-
tags ?? null
|
|
551
|
-
]);
|
|
552
|
-
const existing = series.get(collapseKey);
|
|
553
|
-
if (existing) {
|
|
554
|
-
existing.count++;
|
|
750
|
+
if (Array.isArray(series)) {
|
|
751
|
+
series.push(fileRecord);
|
|
555
752
|
} else {
|
|
556
|
-
series
|
|
753
|
+
accumulate(series, fileRecord);
|
|
557
754
|
}
|
|
558
755
|
}
|
|
559
|
-
const studySpecs = [...studies.values()].map((study) => {
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
...acc.modality !== void 0 ? { modality: acc.modality } : {},
|
|
563
|
-
...acc.large ? { type: "large-image", targetBytes: acc.bytes } : {
|
|
564
|
-
type: "valid-image",
|
|
565
|
-
targetSizeKb: Math.max(1, Math.round(acc.bytes / 1024))
|
|
566
|
-
},
|
|
567
|
-
...acc.tags !== void 0 ? { tags: acc.tags } : {},
|
|
568
|
-
...acc.count > 1 ? { count: acc.count } : {}
|
|
569
|
-
}));
|
|
570
|
-
return { entries };
|
|
571
|
-
});
|
|
572
|
-
return { series: seriesSpecs };
|
|
573
|
-
});
|
|
756
|
+
const studySpecs = [...studies.values()].map((study) => ({
|
|
757
|
+
series: [...study.values()].map(seriesSpecFromAccum)
|
|
758
|
+
}));
|
|
574
759
|
if (studySpecs.length === 0) {
|
|
575
760
|
throw new Error(`describe: no DICOM series found in ${dir}`);
|
|
576
761
|
}
|
|
@@ -580,5 +765,6 @@ function describeDirectory(dir, options = {}) {
|
|
|
580
765
|
}
|
|
581
766
|
export {
|
|
582
767
|
describeDirectory,
|
|
583
|
-
readHeaderOnly
|
|
768
|
+
readHeaderOnly,
|
|
769
|
+
tagValuesEqual
|
|
584
770
|
};
|