dicom-synth 1.10.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 +23 -5
- package/dist/esm/collection/writer.js +56 -30
- package/dist/esm/describe/describe.js +225 -48
- package/dist/esm/index.js +279 -77
- package/dist/esm/schema/parametric.js +18 -15
- package/dist/esm/schema/validate.js +104 -19
- package/dist/types/describe/describe.d.ts +2 -0
- package/dist/types/index.d.ts +1 -1
- package/dist/types/schema/types.d.ts +9 -1
- package/package.json +1 -1
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { readFileSync, writeFileSync } from 'node:fs'
|
|
3
3
|
import { resolve } from 'node:path'
|
|
4
|
+
import { gzipSync } from 'node:zlib'
|
|
4
5
|
import { describeDirectory } from '../dist/esm/index.js'
|
|
5
6
|
|
|
6
7
|
function usage() {
|
|
7
8
|
console.error(
|
|
8
|
-
'Usage: dicom-synth-describe <dir> [--out <spec.json>] [--keep-tags <a,b,...>] [--keep-tags-file <path>]\n' +
|
|
9
|
+
'Usage: dicom-synth-describe <dir> [--out <spec.json[.gz]>] [--keep-tags <a,b,...>] [--keep-tags-file <path>] [--size-tolerance <fraction>] [--gzip]\n' +
|
|
9
10
|
'\n' +
|
|
10
11
|
'Scans a DICOM tree and emits a DatasetSpec describing its shape\n' +
|
|
11
12
|
'(studies/series, per-file size, modality).\n' +
|
|
@@ -15,7 +16,10 @@ function usage() {
|
|
|
15
16
|
"verbatim; if a kept tag holds PHI, handling it is the caller's\n" +
|
|
16
17
|
"responsibility, not this tool's.\n" +
|
|
17
18
|
'--keep-tags-file reads the same allow-list from a file (one tag per\n' +
|
|
18
|
-
'line, # comments); merged with any --keep-tags
|
|
19
|
+
'line, # comments); merged with any --keep-tags.\n' +
|
|
20
|
+
'--size-tolerance folds near-identical file sizes (fraction, e.g. 0.05\n' +
|
|
21
|
+
'= ±5%) into one entry; default exact.\n' +
|
|
22
|
+
'--gzip (or a .gz --out suffix) writes the spec gzip-compressed.',
|
|
19
23
|
)
|
|
20
24
|
process.exit(1)
|
|
21
25
|
}
|
|
@@ -33,6 +37,8 @@ if (args.length === 0) usage()
|
|
|
33
37
|
|
|
34
38
|
let dir
|
|
35
39
|
let outPath
|
|
40
|
+
let sizeTolerance
|
|
41
|
+
let gzip = false
|
|
36
42
|
const keepTags = []
|
|
37
43
|
|
|
38
44
|
for (let i = 0; i < args.length; i++) {
|
|
@@ -52,6 +58,14 @@ for (let i = 0; i < args.length; i++) {
|
|
|
52
58
|
console.error(`Error reading keep-tags file: ${err.message}`)
|
|
53
59
|
process.exit(1)
|
|
54
60
|
}
|
|
61
|
+
} else if (args[i] === '--size-tolerance' && args[i + 1]) {
|
|
62
|
+
sizeTolerance = Number(args[++i])
|
|
63
|
+
if (!Number.isFinite(sizeTolerance) || sizeTolerance < 0) {
|
|
64
|
+
console.error('--size-tolerance must be a non-negative number')
|
|
65
|
+
process.exit(1)
|
|
66
|
+
}
|
|
67
|
+
} else if (args[i] === '--gzip') {
|
|
68
|
+
gzip = true
|
|
55
69
|
} else if (args[i].startsWith('--')) {
|
|
56
70
|
console.error(`Unknown argument: ${args[i]}`)
|
|
57
71
|
usage()
|
|
@@ -67,18 +81,22 @@ if (dir === undefined) usage()
|
|
|
67
81
|
|
|
68
82
|
let result
|
|
69
83
|
try {
|
|
70
|
-
result = describeDirectory(dir,
|
|
84
|
+
result = describeDirectory(dir, {
|
|
85
|
+
...(keepTags.length ? { keepTags } : {}),
|
|
86
|
+
...(sizeTolerance !== undefined ? { sizeTolerance } : {}),
|
|
87
|
+
})
|
|
71
88
|
} catch (err) {
|
|
72
89
|
console.error(`Error: ${err.message}`)
|
|
73
90
|
process.exit(1)
|
|
74
91
|
}
|
|
75
92
|
|
|
76
93
|
const json = `${JSON.stringify(result.spec, null, 2)}\n`
|
|
94
|
+
const compress = gzip || outPath?.endsWith('.gz')
|
|
77
95
|
if (outPath) {
|
|
78
|
-
writeFileSync(outPath, json)
|
|
96
|
+
writeFileSync(outPath, compress ? gzipSync(json) : json)
|
|
79
97
|
console.error(`Wrote DatasetSpec to ${outPath}`)
|
|
80
98
|
} else {
|
|
81
|
-
process.stdout.write(json)
|
|
99
|
+
process.stdout.write(compress ? gzipSync(json) : json)
|
|
82
100
|
}
|
|
83
101
|
|
|
84
102
|
const { dicomFiles, skipped, unknownModality } = result.stats
|
|
@@ -587,12 +587,41 @@ function filename(type, index, padWidth) {
|
|
|
587
587
|
function entryCount(entries) {
|
|
588
588
|
return entries.reduce((sum, e) => sum + (e.count ?? 1), 0);
|
|
589
589
|
}
|
|
590
|
+
function seriesFileCount(series) {
|
|
591
|
+
return series.instances ? series.instances.count : entryCount(series.entries ?? []);
|
|
592
|
+
}
|
|
590
593
|
function studyFileCount(studies) {
|
|
591
594
|
return studies.reduce(
|
|
592
|
-
(sum, study) => sum + (study.count ?? 1) * study.series.reduce((s, series) => s +
|
|
595
|
+
(sum, study) => sum + (study.count ?? 1) * study.series.reduce((s, series) => s + seriesFileCount(series), 0),
|
|
593
596
|
0
|
|
594
597
|
);
|
|
595
598
|
}
|
|
599
|
+
function* seriesFiles(series) {
|
|
600
|
+
if (series.instances) {
|
|
601
|
+
const inst = series.instances;
|
|
602
|
+
for (let i = 0; i < inst.count; i++) {
|
|
603
|
+
const modality = Array.isArray(inst.modality) ? inst.modality[i] : inst.modality;
|
|
604
|
+
const baseSpec = {
|
|
605
|
+
...inst.targetBytes ? { type: "large-image", targetBytes: inst.targetBytes[i] } : { type: "valid-image", targetSizeKb: inst.targetSizeKb?.[i] },
|
|
606
|
+
...modality !== void 0 ? { modality } : {}
|
|
607
|
+
};
|
|
608
|
+
const instanceTags = {};
|
|
609
|
+
if (inst.tags) {
|
|
610
|
+
for (const [key, values] of Object.entries(inst.tags)) {
|
|
611
|
+
instanceTags[key] = values[i];
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
yield { baseSpec, instanceTags };
|
|
615
|
+
}
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
for (const entry of series.entries ?? []) {
|
|
619
|
+
const { count = 1, tags, ...baseSpec } = entry;
|
|
620
|
+
for (let i = 0; i < count; i++) {
|
|
621
|
+
yield { baseSpec, instanceTags: tags ?? {} };
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
}
|
|
596
625
|
function resolveUid(seed, index, override) {
|
|
597
626
|
return { ...makeUidGenerator(seed)(index), ...override };
|
|
598
627
|
}
|
|
@@ -720,36 +749,33 @@ function* planCollection(spec) {
|
|
|
720
749
|
for (const [seriesIndex, series] of study.series.entries()) {
|
|
721
750
|
const seriesUid = groupUids.series(studyOrdinal, seriesIndex);
|
|
722
751
|
let instanceNumber = 0;
|
|
723
|
-
for (const
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
instanceNumber
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
}
|
|
733
|
-
|
|
734
|
-
|
|
752
|
+
for (const { baseSpec, instanceTags } of seriesFiles(series)) {
|
|
753
|
+
instanceNumber++;
|
|
754
|
+
const tags = {
|
|
755
|
+
InstanceNumber: instanceNumber,
|
|
756
|
+
...study.tags,
|
|
757
|
+
...series.tags,
|
|
758
|
+
...instanceTags
|
|
759
|
+
};
|
|
760
|
+
yield {
|
|
761
|
+
fileSpec: { ...baseSpec, tags },
|
|
762
|
+
index: globalIndex,
|
|
763
|
+
uidOverride: { study: studyUid, series: seriesUid },
|
|
764
|
+
context: {
|
|
735
765
|
index: globalIndex,
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
)
|
|
750
|
-
};
|
|
751
|
-
globalIndex++;
|
|
752
|
-
}
|
|
766
|
+
studyIndex: studyOrdinal,
|
|
767
|
+
seriesIndex,
|
|
768
|
+
instanceNumber
|
|
769
|
+
},
|
|
770
|
+
...computeNames(
|
|
771
|
+
baseSpec.type,
|
|
772
|
+
globalIndex,
|
|
773
|
+
padWidth,
|
|
774
|
+
hierarchical ? { studyOrdinal, seriesIndex, instanceNumber } : void 0,
|
|
775
|
+
quirks
|
|
776
|
+
)
|
|
777
|
+
};
|
|
778
|
+
globalIndex++;
|
|
753
779
|
}
|
|
754
780
|
}
|
|
755
781
|
studyOrdinal++;
|
|
@@ -239,26 +239,100 @@ function validateEntry(entry, path) {
|
|
|
239
239
|
return e;
|
|
240
240
|
}
|
|
241
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
|
+
}
|
|
242
259
|
function validateTags(tags, path) {
|
|
243
260
|
if (typeof tags !== "object" || tags === null || Array.isArray(tags)) {
|
|
244
261
|
throw new Error(`${path}: must be an object`);
|
|
245
262
|
}
|
|
246
263
|
for (const [key, value] of Object.entries(tags)) {
|
|
247
264
|
validateTagKey(key, path);
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
+
}
|
|
252
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`);
|
|
253
328
|
}
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
);
|
|
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}]`);
|
|
260
334
|
}
|
|
261
|
-
|
|
335
|
+
);
|
|
262
336
|
}
|
|
263
337
|
}
|
|
264
338
|
}
|
|
@@ -267,16 +341,27 @@ function validateSeries(series, path) {
|
|
|
267
341
|
throw new Error(`${path}: must be an object`);
|
|
268
342
|
}
|
|
269
343
|
const s = series;
|
|
270
|
-
|
|
271
|
-
|
|
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
|
+
);
|
|
272
350
|
}
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
);
|
|
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`);
|
|
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
|
+
}
|
|
279
362
|
}
|
|
363
|
+
} else {
|
|
364
|
+
validateInstances(s.instances, `${path}.instances`);
|
|
280
365
|
}
|
|
281
366
|
if ("tags" in s) validateTags(s.tags, `${path}.tags`);
|
|
282
367
|
return s;
|
|
@@ -498,8 +583,117 @@ function extractTags(record, keepKeywords) {
|
|
|
498
583
|
}
|
|
499
584
|
return found ? tags : void 0;
|
|
500
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
|
+
}
|
|
501
688
|
function describeDirectory(dir, options = {}) {
|
|
502
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
|
+
}
|
|
503
697
|
const studies = /* @__PURE__ */ new Map();
|
|
504
698
|
const stats = { dicomFiles: 0, skipped: 0, unknownModality: 0 };
|
|
505
699
|
for (const path of [...walkFiles(dir)].sort()) {
|
|
@@ -531,8 +725,9 @@ function describeDirectory(dir, options = {}) {
|
|
|
531
725
|
stats.unknownModality++;
|
|
532
726
|
}
|
|
533
727
|
}
|
|
534
|
-
const
|
|
535
|
-
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 };
|
|
536
731
|
let study = studies.get(studyUid);
|
|
537
732
|
if (!study) {
|
|
538
733
|
study = /* @__PURE__ */ new Map();
|
|
@@ -540,37 +735,18 @@ function describeDirectory(dir, options = {}) {
|
|
|
540
735
|
}
|
|
541
736
|
let series = study.get(seriesUid);
|
|
542
737
|
if (!series) {
|
|
543
|
-
series = /* @__PURE__ */ new Map();
|
|
738
|
+
series = keepTags ? [] : /* @__PURE__ */ new Map();
|
|
544
739
|
study.set(seriesUid, series);
|
|
545
740
|
}
|
|
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++;
|
|
741
|
+
if (Array.isArray(series)) {
|
|
742
|
+
series.push(fileRecord);
|
|
555
743
|
} else {
|
|
556
|
-
series
|
|
744
|
+
accumulate(series, fileRecord);
|
|
557
745
|
}
|
|
558
746
|
}
|
|
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
|
-
});
|
|
747
|
+
const studySpecs = [...studies.values()].map((study) => ({
|
|
748
|
+
series: [...study.values()].map(seriesSpecFromAccum)
|
|
749
|
+
}));
|
|
574
750
|
if (studySpecs.length === 0) {
|
|
575
751
|
throw new Error(`describe: no DICOM series found in ${dir}`);
|
|
576
752
|
}
|
|
@@ -580,5 +756,6 @@ function describeDirectory(dir, options = {}) {
|
|
|
580
756
|
}
|
|
581
757
|
export {
|
|
582
758
|
describeDirectory,
|
|
583
|
-
readHeaderOnly
|
|
759
|
+
readHeaderOnly,
|
|
760
|
+
tagValuesEqual
|
|
584
761
|
};
|
package/dist/esm/index.js
CHANGED
|
@@ -288,26 +288,100 @@ function validateEntry(entry, path) {
|
|
|
288
288
|
return e;
|
|
289
289
|
}
|
|
290
290
|
var MODALITY_TAG = "00080060";
|
|
291
|
+
function validateTagValue(key, value, path) {
|
|
292
|
+
const isModalityKey = key === "Modality" || key.toUpperCase() === MODALITY_TAG;
|
|
293
|
+
if (isModalityKey && typeof value === "string" && Object.hasOwn(VALID_MODALITIES, value)) {
|
|
294
|
+
throw new Error(
|
|
295
|
+
`${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`
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
if (typeof value === "string") {
|
|
299
|
+
for (const name of findTemplateTokens(value)) {
|
|
300
|
+
if (!TEMPLATE_VOCAB.includes(name)) {
|
|
301
|
+
throw new Error(
|
|
302
|
+
`${path}.${key}: unknown placeholder "{${name}}" \u2014 allowed: ${TEMPLATE_VOCAB.map((v) => `{${v}}`).join(", ")}`
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
291
308
|
function validateTags(tags, path) {
|
|
292
309
|
if (typeof tags !== "object" || tags === null || Array.isArray(tags)) {
|
|
293
310
|
throw new Error(`${path}: must be an object`);
|
|
294
311
|
}
|
|
295
312
|
for (const [key, value] of Object.entries(tags)) {
|
|
296
313
|
validateTagKey(key, path);
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
314
|
+
validateTagValue(key, value, path);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
function validateArrayOfLength(value, count, path) {
|
|
318
|
+
if (!Array.isArray(value)) {
|
|
319
|
+
throw new Error(`${path}: must be an array`);
|
|
320
|
+
}
|
|
321
|
+
if (value.length !== count) {
|
|
322
|
+
throw new Error(
|
|
323
|
+
`${path}: length (${value.length}) must equal count (${count})`
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
return value;
|
|
327
|
+
}
|
|
328
|
+
function validateInstances(instances, path) {
|
|
329
|
+
if (typeof instances !== "object" || instances === null || Array.isArray(instances)) {
|
|
330
|
+
throw new Error(`${path}: must be an object`);
|
|
331
|
+
}
|
|
332
|
+
const inst = instances;
|
|
333
|
+
validatePositiveInt(inst.count, `${path}.count`);
|
|
334
|
+
const count = inst.count;
|
|
335
|
+
const hasKb = "targetSizeKb" in inst;
|
|
336
|
+
const hasBytes = "targetBytes" in inst;
|
|
337
|
+
if (hasKb === hasBytes) {
|
|
338
|
+
throw new Error(
|
|
339
|
+
`${path}: exactly one of "targetSizeKb" or "targetBytes" is required`
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
if (hasKb) {
|
|
343
|
+
const sizes = validateArrayOfLength(
|
|
344
|
+
inst.targetSizeKb,
|
|
345
|
+
count,
|
|
346
|
+
`${path}.targetSizeKb`
|
|
347
|
+
);
|
|
348
|
+
sizes.forEach((kb, i) => {
|
|
349
|
+
validatePositiveInt(kb, `${path}.targetSizeKb[${i}]`);
|
|
350
|
+
validateSizeKbLimit(kb, `${path}.targetSizeKb[${i}]`);
|
|
351
|
+
});
|
|
352
|
+
} else {
|
|
353
|
+
const sizes = validateArrayOfLength(
|
|
354
|
+
inst.targetBytes,
|
|
355
|
+
count,
|
|
356
|
+
`${path}.targetBytes`
|
|
357
|
+
);
|
|
358
|
+
sizes.forEach((b, i) => {
|
|
359
|
+
validateLargeTargetBytes(b, `${path}.targetBytes[${i}]`);
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
if ("modality" in inst) {
|
|
363
|
+
if (Array.isArray(inst.modality)) {
|
|
364
|
+
validateArrayOfLength(inst.modality, count, `${path}.modality`).forEach(
|
|
365
|
+
(m, i) => {
|
|
366
|
+
validateEnum(m, VALID_MODALITIES, `${path}.modality[${i}]`);
|
|
367
|
+
}
|
|
301
368
|
);
|
|
369
|
+
} else {
|
|
370
|
+
validateEnum(inst.modality, VALID_MODALITIES, `${path}.modality`);
|
|
302
371
|
}
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
372
|
+
}
|
|
373
|
+
if ("tags" in inst) {
|
|
374
|
+
const tags = inst.tags;
|
|
375
|
+
if (typeof tags !== "object" || tags === null || Array.isArray(tags)) {
|
|
376
|
+
throw new Error(`${path}.tags: must be an object`);
|
|
377
|
+
}
|
|
378
|
+
for (const [key, arr] of Object.entries(tags)) {
|
|
379
|
+
validateTagKey(key, `${path}.tags`);
|
|
380
|
+
validateArrayOfLength(arr, count, `${path}.tags.${key}`).forEach(
|
|
381
|
+
(value, i) => {
|
|
382
|
+
validateTagValue(key, value, `${path}.tags[${i}]`);
|
|
309
383
|
}
|
|
310
|
-
|
|
384
|
+
);
|
|
311
385
|
}
|
|
312
386
|
}
|
|
313
387
|
}
|
|
@@ -316,16 +390,27 @@ function validateSeries(series, path) {
|
|
|
316
390
|
throw new Error(`${path}: must be an object`);
|
|
317
391
|
}
|
|
318
392
|
const s = series;
|
|
319
|
-
|
|
320
|
-
|
|
393
|
+
const hasEntries = "entries" in s;
|
|
394
|
+
const hasInstances = "instances" in s;
|
|
395
|
+
if (hasEntries === hasInstances) {
|
|
396
|
+
throw new Error(
|
|
397
|
+
`${path}: must have exactly one of "entries" or "instances"`
|
|
398
|
+
);
|
|
321
399
|
}
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
);
|
|
400
|
+
if (hasEntries) {
|
|
401
|
+
if (!Array.isArray(s.entries) || s.entries.length === 0) {
|
|
402
|
+
throw new Error(`${path}.entries: must be a non-empty array`);
|
|
403
|
+
}
|
|
404
|
+
for (let i = 0; i < s.entries.length; i++) {
|
|
405
|
+
const e = validateEntry(s.entries[i], `${path}.entries[${i}]`);
|
|
406
|
+
if (!TYPE_CAPABILITIES[e.type].image) {
|
|
407
|
+
throw new Error(
|
|
408
|
+
`${path}.entries[${i}].type: "${e.type}" cannot be used inside a series \u2014 only image-generating types can be grouped`
|
|
409
|
+
);
|
|
410
|
+
}
|
|
328
411
|
}
|
|
412
|
+
} else {
|
|
413
|
+
validateInstances(s.instances, `${path}.instances`);
|
|
329
414
|
}
|
|
330
415
|
if ("tags" in s) validateTags(s.tags, `${path}.tags`);
|
|
331
416
|
return s;
|
|
@@ -1033,12 +1118,41 @@ function filename(type, index, padWidth) {
|
|
|
1033
1118
|
function entryCount(entries) {
|
|
1034
1119
|
return entries.reduce((sum, e) => sum + (e.count ?? 1), 0);
|
|
1035
1120
|
}
|
|
1121
|
+
function seriesFileCount(series) {
|
|
1122
|
+
return series.instances ? series.instances.count : entryCount(series.entries ?? []);
|
|
1123
|
+
}
|
|
1036
1124
|
function studyFileCount(studies) {
|
|
1037
1125
|
return studies.reduce(
|
|
1038
|
-
(sum, study) => sum + (study.count ?? 1) * study.series.reduce((s, series) => s +
|
|
1126
|
+
(sum, study) => sum + (study.count ?? 1) * study.series.reduce((s, series) => s + seriesFileCount(series), 0),
|
|
1039
1127
|
0
|
|
1040
1128
|
);
|
|
1041
1129
|
}
|
|
1130
|
+
function* seriesFiles(series) {
|
|
1131
|
+
if (series.instances) {
|
|
1132
|
+
const inst = series.instances;
|
|
1133
|
+
for (let i = 0; i < inst.count; i++) {
|
|
1134
|
+
const modality = Array.isArray(inst.modality) ? inst.modality[i] : inst.modality;
|
|
1135
|
+
const baseSpec = {
|
|
1136
|
+
...inst.targetBytes ? { type: "large-image", targetBytes: inst.targetBytes[i] } : { type: "valid-image", targetSizeKb: inst.targetSizeKb?.[i] },
|
|
1137
|
+
...modality !== void 0 ? { modality } : {}
|
|
1138
|
+
};
|
|
1139
|
+
const instanceTags = {};
|
|
1140
|
+
if (inst.tags) {
|
|
1141
|
+
for (const [key, values] of Object.entries(inst.tags)) {
|
|
1142
|
+
instanceTags[key] = values[i];
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
yield { baseSpec, instanceTags };
|
|
1146
|
+
}
|
|
1147
|
+
return;
|
|
1148
|
+
}
|
|
1149
|
+
for (const entry of series.entries ?? []) {
|
|
1150
|
+
const { count = 1, tags, ...baseSpec } = entry;
|
|
1151
|
+
for (let i = 0; i < count; i++) {
|
|
1152
|
+
yield { baseSpec, instanceTags: tags ?? {} };
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1042
1156
|
function resolveUid(seed, index, override) {
|
|
1043
1157
|
return { ...makeUidGenerator(seed)(index), ...override };
|
|
1044
1158
|
}
|
|
@@ -1166,36 +1280,33 @@ function* planCollection(spec) {
|
|
|
1166
1280
|
for (const [seriesIndex, series] of study.series.entries()) {
|
|
1167
1281
|
const seriesUid = groupUids.series(studyOrdinal, seriesIndex);
|
|
1168
1282
|
let instanceNumber = 0;
|
|
1169
|
-
for (const
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
instanceNumber
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
}
|
|
1179
|
-
|
|
1180
|
-
|
|
1283
|
+
for (const { baseSpec, instanceTags } of seriesFiles(series)) {
|
|
1284
|
+
instanceNumber++;
|
|
1285
|
+
const tags = {
|
|
1286
|
+
InstanceNumber: instanceNumber,
|
|
1287
|
+
...study.tags,
|
|
1288
|
+
...series.tags,
|
|
1289
|
+
...instanceTags
|
|
1290
|
+
};
|
|
1291
|
+
yield {
|
|
1292
|
+
fileSpec: { ...baseSpec, tags },
|
|
1293
|
+
index: globalIndex,
|
|
1294
|
+
uidOverride: { study: studyUid, series: seriesUid },
|
|
1295
|
+
context: {
|
|
1181
1296
|
index: globalIndex,
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
)
|
|
1196
|
-
};
|
|
1197
|
-
globalIndex++;
|
|
1198
|
-
}
|
|
1297
|
+
studyIndex: studyOrdinal,
|
|
1298
|
+
seriesIndex,
|
|
1299
|
+
instanceNumber
|
|
1300
|
+
},
|
|
1301
|
+
...computeNames(
|
|
1302
|
+
baseSpec.type,
|
|
1303
|
+
globalIndex,
|
|
1304
|
+
padWidth,
|
|
1305
|
+
hierarchical ? { studyOrdinal, seriesIndex, instanceNumber } : void 0,
|
|
1306
|
+
quirks
|
|
1307
|
+
)
|
|
1308
|
+
};
|
|
1309
|
+
globalIndex++;
|
|
1199
1310
|
}
|
|
1200
1311
|
}
|
|
1201
1312
|
studyOrdinal++;
|
|
@@ -1444,8 +1555,117 @@ function extractTags(record, keepKeywords) {
|
|
|
1444
1555
|
}
|
|
1445
1556
|
return found ? tags : void 0;
|
|
1446
1557
|
}
|
|
1558
|
+
function sizeKbOf(bytes) {
|
|
1559
|
+
return Math.max(1, Math.round(bytes / 1024));
|
|
1560
|
+
}
|
|
1561
|
+
function bucketBytes(bytes, tolerance) {
|
|
1562
|
+
if (tolerance <= 0 || bytes < 1) return bytes;
|
|
1563
|
+
const factor = 1 + tolerance;
|
|
1564
|
+
const bucket = Math.floor(Math.log(bytes) / Math.log(factor));
|
|
1565
|
+
return Math.max(1, Math.round(factor ** (bucket + 0.5)));
|
|
1566
|
+
}
|
|
1567
|
+
function collapseKeyOf(r) {
|
|
1568
|
+
return JSON.stringify([
|
|
1569
|
+
r.large,
|
|
1570
|
+
r.modality ?? null,
|
|
1571
|
+
r.large ? r.bytes : sizeKbOf(r.bytes),
|
|
1572
|
+
r.tags ?? null
|
|
1573
|
+
]);
|
|
1574
|
+
}
|
|
1575
|
+
function accumulate(byKey, r) {
|
|
1576
|
+
const key = collapseKeyOf(r);
|
|
1577
|
+
const existing = byKey.get(key);
|
|
1578
|
+
if (existing) {
|
|
1579
|
+
existing.count++;
|
|
1580
|
+
} else {
|
|
1581
|
+
byKey.set(key, {
|
|
1582
|
+
modality: r.modality,
|
|
1583
|
+
large: r.large,
|
|
1584
|
+
bytes: r.bytes,
|
|
1585
|
+
tags: r.tags,
|
|
1586
|
+
count: 1
|
|
1587
|
+
});
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
function entryOf(acc) {
|
|
1591
|
+
return {
|
|
1592
|
+
...acc.modality !== void 0 ? { modality: acc.modality } : {},
|
|
1593
|
+
...acc.large ? { type: "large-image", targetBytes: acc.bytes } : { type: "valid-image", targetSizeKb: sizeKbOf(acc.bytes) },
|
|
1594
|
+
...acc.tags !== void 0 ? { tags: acc.tags } : {},
|
|
1595
|
+
...acc.count > 1 ? { count: acc.count } : {}
|
|
1596
|
+
};
|
|
1597
|
+
}
|
|
1598
|
+
function collapseEntries(records) {
|
|
1599
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
1600
|
+
for (const r of records) accumulate(byKey, r);
|
|
1601
|
+
return [...byKey.values()].map(entryOf);
|
|
1602
|
+
}
|
|
1603
|
+
function tagValuesEqual(a, b) {
|
|
1604
|
+
if (a === b) return true;
|
|
1605
|
+
if (typeof a === "object" && a !== null && typeof b === "object" && b !== null)
|
|
1606
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
1607
|
+
return false;
|
|
1608
|
+
}
|
|
1609
|
+
function tryInstancesForm(records) {
|
|
1610
|
+
const count = records.length;
|
|
1611
|
+
if (count === 0) return null;
|
|
1612
|
+
const large = records[0]?.large ?? false;
|
|
1613
|
+
const modality = records[0]?.modality;
|
|
1614
|
+
const columns = /* @__PURE__ */ new Map();
|
|
1615
|
+
for (let i = 0; i < count; i++) {
|
|
1616
|
+
const r = records[i];
|
|
1617
|
+
if (r.large !== large || r.modality !== modality) return null;
|
|
1618
|
+
if (!r.tags) continue;
|
|
1619
|
+
for (const [k, v] of Object.entries(r.tags)) {
|
|
1620
|
+
let col = columns.get(k);
|
|
1621
|
+
if (!col) {
|
|
1622
|
+
col = { values: new Array(count), present: 0 };
|
|
1623
|
+
columns.set(k, col);
|
|
1624
|
+
}
|
|
1625
|
+
col.values[i] = v;
|
|
1626
|
+
col.present++;
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
if (columns.size === 0) return null;
|
|
1630
|
+
const sharedTags = {};
|
|
1631
|
+
const perInstanceTags = {};
|
|
1632
|
+
for (const [k, col] of columns) {
|
|
1633
|
+
if (col.present !== count) return null;
|
|
1634
|
+
const first = col.values[0];
|
|
1635
|
+
if (col.values.every((v) => tagValuesEqual(v, first))) {
|
|
1636
|
+
sharedTags[k] = first;
|
|
1637
|
+
} else {
|
|
1638
|
+
perInstanceTags[k] = col.values;
|
|
1639
|
+
}
|
|
1640
|
+
}
|
|
1641
|
+
if (Object.keys(perInstanceTags).length === 0) return null;
|
|
1642
|
+
const instances = {
|
|
1643
|
+
count,
|
|
1644
|
+
...large ? { targetBytes: records.map((r) => r.bytes) } : { targetSizeKb: records.map((r) => sizeKbOf(r.bytes)) },
|
|
1645
|
+
...modality !== void 0 ? { modality } : {},
|
|
1646
|
+
// perInstanceTags is non-empty here (we returned null above otherwise).
|
|
1647
|
+
tags: perInstanceTags
|
|
1648
|
+
};
|
|
1649
|
+
return {
|
|
1650
|
+
instances,
|
|
1651
|
+
...Object.keys(sharedTags).length ? { tags: sharedTags } : {}
|
|
1652
|
+
};
|
|
1653
|
+
}
|
|
1654
|
+
function buildSeriesSpec(records) {
|
|
1655
|
+
return tryInstancesForm(records) ?? { entries: collapseEntries(records) };
|
|
1656
|
+
}
|
|
1657
|
+
function seriesSpecFromAccum(accum) {
|
|
1658
|
+
return Array.isArray(accum) ? buildSeriesSpec(accum) : { entries: [...accum.values()].map(entryOf) };
|
|
1659
|
+
}
|
|
1447
1660
|
function describeDirectory(dir, options = {}) {
|
|
1448
1661
|
const keepKeywords = options.keepTags ? resolveKeepTags(options.keepTags) : [];
|
|
1662
|
+
const keepTags = keepKeywords.length > 0;
|
|
1663
|
+
const tolerance = options.sizeTolerance ?? 0;
|
|
1664
|
+
if (!Number.isFinite(tolerance) || tolerance < 0) {
|
|
1665
|
+
throw new Error(
|
|
1666
|
+
`describe: sizeTolerance must be a non-negative fraction; got ${JSON.stringify(options.sizeTolerance)}`
|
|
1667
|
+
);
|
|
1668
|
+
}
|
|
1449
1669
|
const studies = /* @__PURE__ */ new Map();
|
|
1450
1670
|
const stats = { dicomFiles: 0, skipped: 0, unknownModality: 0 };
|
|
1451
1671
|
for (const path of [...walkFiles(dir)].sort()) {
|
|
@@ -1477,8 +1697,9 @@ function describeDirectory(dir, options = {}) {
|
|
|
1477
1697
|
stats.unknownModality++;
|
|
1478
1698
|
}
|
|
1479
1699
|
}
|
|
1480
|
-
const
|
|
1481
|
-
const
|
|
1700
|
+
const tags = keepTags ? extractTags(record, keepKeywords) : void 0;
|
|
1701
|
+
const bytes = large ? size : Math.min(bucketBytes(size, tolerance), MAX_PIXEL_BYTES);
|
|
1702
|
+
const fileRecord = { large, bytes, modality, tags };
|
|
1482
1703
|
let study = studies.get(studyUid);
|
|
1483
1704
|
if (!study) {
|
|
1484
1705
|
study = /* @__PURE__ */ new Map();
|
|
@@ -1486,37 +1707,18 @@ function describeDirectory(dir, options = {}) {
|
|
|
1486
1707
|
}
|
|
1487
1708
|
let series = study.get(seriesUid);
|
|
1488
1709
|
if (!series) {
|
|
1489
|
-
series = /* @__PURE__ */ new Map();
|
|
1710
|
+
series = keepTags ? [] : /* @__PURE__ */ new Map();
|
|
1490
1711
|
study.set(seriesUid, series);
|
|
1491
1712
|
}
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
modality ?? null,
|
|
1495
|
-
large ? size : sizeKb,
|
|
1496
|
-
tags ?? null
|
|
1497
|
-
]);
|
|
1498
|
-
const existing = series.get(collapseKey);
|
|
1499
|
-
if (existing) {
|
|
1500
|
-
existing.count++;
|
|
1713
|
+
if (Array.isArray(series)) {
|
|
1714
|
+
series.push(fileRecord);
|
|
1501
1715
|
} else {
|
|
1502
|
-
series
|
|
1716
|
+
accumulate(series, fileRecord);
|
|
1503
1717
|
}
|
|
1504
1718
|
}
|
|
1505
|
-
const studySpecs = [...studies.values()].map((study) => {
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
...acc.modality !== void 0 ? { modality: acc.modality } : {},
|
|
1509
|
-
...acc.large ? { type: "large-image", targetBytes: acc.bytes } : {
|
|
1510
|
-
type: "valid-image",
|
|
1511
|
-
targetSizeKb: Math.max(1, Math.round(acc.bytes / 1024))
|
|
1512
|
-
},
|
|
1513
|
-
...acc.tags !== void 0 ? { tags: acc.tags } : {},
|
|
1514
|
-
...acc.count > 1 ? { count: acc.count } : {}
|
|
1515
|
-
}));
|
|
1516
|
-
return { entries };
|
|
1517
|
-
});
|
|
1518
|
-
return { series: seriesSpecs };
|
|
1519
|
-
});
|
|
1719
|
+
const studySpecs = [...studies.values()].map((study) => ({
|
|
1720
|
+
series: [...study.values()].map(seriesSpecFromAccum)
|
|
1721
|
+
}));
|
|
1520
1722
|
if (studySpecs.length === 0) {
|
|
1521
1723
|
throw new Error(`describe: no DICOM series found in ${dir}`);
|
|
1522
1724
|
}
|
|
@@ -224,27 +224,30 @@ function validateEntry(entry, path) {
|
|
|
224
224
|
return e;
|
|
225
225
|
}
|
|
226
226
|
var MODALITY_TAG = "00080060";
|
|
227
|
+
function validateTagValue(key, value, path) {
|
|
228
|
+
const isModalityKey = key === "Modality" || key.toUpperCase() === MODALITY_TAG;
|
|
229
|
+
if (isModalityKey && typeof value === "string" && Object.hasOwn(VALID_MODALITIES, value)) {
|
|
230
|
+
throw new Error(
|
|
231
|
+
`${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`
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
if (typeof value === "string") {
|
|
235
|
+
for (const name of findTemplateTokens(value)) {
|
|
236
|
+
if (!TEMPLATE_VOCAB.includes(name)) {
|
|
237
|
+
throw new Error(
|
|
238
|
+
`${path}.${key}: unknown placeholder "{${name}}" \u2014 allowed: ${TEMPLATE_VOCAB.map((v) => `{${v}}`).join(", ")}`
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
227
244
|
function validateTags(tags, path) {
|
|
228
245
|
if (typeof tags !== "object" || tags === null || Array.isArray(tags)) {
|
|
229
246
|
throw new Error(`${path}: must be an object`);
|
|
230
247
|
}
|
|
231
248
|
for (const [key, value] of Object.entries(tags)) {
|
|
232
249
|
validateTagKey(key, path);
|
|
233
|
-
|
|
234
|
-
if (isModalityKey && typeof value === "string" && Object.hasOwn(VALID_MODALITIES, value)) {
|
|
235
|
-
throw new Error(
|
|
236
|
-
`${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`
|
|
237
|
-
);
|
|
238
|
-
}
|
|
239
|
-
if (typeof value === "string") {
|
|
240
|
-
for (const name of findTemplateTokens(value)) {
|
|
241
|
-
if (!TEMPLATE_VOCAB.includes(name)) {
|
|
242
|
-
throw new Error(
|
|
243
|
-
`${path}.${key}: unknown placeholder "{${name}}" \u2014 allowed: ${TEMPLATE_VOCAB.map((v) => `{${v}}`).join(", ")}`
|
|
244
|
-
);
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
}
|
|
250
|
+
validateTagValue(key, value, path);
|
|
248
251
|
}
|
|
249
252
|
}
|
|
250
253
|
function validateRange(value, path, options = {}) {
|
|
@@ -213,26 +213,100 @@ function validateEntry(entry, path) {
|
|
|
213
213
|
return e;
|
|
214
214
|
}
|
|
215
215
|
var MODALITY_TAG = "00080060";
|
|
216
|
+
function validateTagValue(key, value, path) {
|
|
217
|
+
const isModalityKey = key === "Modality" || key.toUpperCase() === MODALITY_TAG;
|
|
218
|
+
if (isModalityKey && typeof value === "string" && Object.hasOwn(VALID_MODALITIES, value)) {
|
|
219
|
+
throw new Error(
|
|
220
|
+
`${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`
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
if (typeof value === "string") {
|
|
224
|
+
for (const name of findTemplateTokens(value)) {
|
|
225
|
+
if (!TEMPLATE_VOCAB.includes(name)) {
|
|
226
|
+
throw new Error(
|
|
227
|
+
`${path}.${key}: unknown placeholder "{${name}}" \u2014 allowed: ${TEMPLATE_VOCAB.map((v) => `{${v}}`).join(", ")}`
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
216
233
|
function validateTags(tags, path) {
|
|
217
234
|
if (typeof tags !== "object" || tags === null || Array.isArray(tags)) {
|
|
218
235
|
throw new Error(`${path}: must be an object`);
|
|
219
236
|
}
|
|
220
237
|
for (const [key, value] of Object.entries(tags)) {
|
|
221
238
|
validateTagKey(key, path);
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
239
|
+
validateTagValue(key, value, path);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
function validateArrayOfLength(value, count, path) {
|
|
243
|
+
if (!Array.isArray(value)) {
|
|
244
|
+
throw new Error(`${path}: must be an array`);
|
|
245
|
+
}
|
|
246
|
+
if (value.length !== count) {
|
|
247
|
+
throw new Error(
|
|
248
|
+
`${path}: length (${value.length}) must equal count (${count})`
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
return value;
|
|
252
|
+
}
|
|
253
|
+
function validateInstances(instances, path) {
|
|
254
|
+
if (typeof instances !== "object" || instances === null || Array.isArray(instances)) {
|
|
255
|
+
throw new Error(`${path}: must be an object`);
|
|
256
|
+
}
|
|
257
|
+
const inst = instances;
|
|
258
|
+
validatePositiveInt(inst.count, `${path}.count`);
|
|
259
|
+
const count = inst.count;
|
|
260
|
+
const hasKb = "targetSizeKb" in inst;
|
|
261
|
+
const hasBytes = "targetBytes" in inst;
|
|
262
|
+
if (hasKb === hasBytes) {
|
|
263
|
+
throw new Error(
|
|
264
|
+
`${path}: exactly one of "targetSizeKb" or "targetBytes" is required`
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
if (hasKb) {
|
|
268
|
+
const sizes = validateArrayOfLength(
|
|
269
|
+
inst.targetSizeKb,
|
|
270
|
+
count,
|
|
271
|
+
`${path}.targetSizeKb`
|
|
272
|
+
);
|
|
273
|
+
sizes.forEach((kb, i) => {
|
|
274
|
+
validatePositiveInt(kb, `${path}.targetSizeKb[${i}]`);
|
|
275
|
+
validateSizeKbLimit(kb, `${path}.targetSizeKb[${i}]`);
|
|
276
|
+
});
|
|
277
|
+
} else {
|
|
278
|
+
const sizes = validateArrayOfLength(
|
|
279
|
+
inst.targetBytes,
|
|
280
|
+
count,
|
|
281
|
+
`${path}.targetBytes`
|
|
282
|
+
);
|
|
283
|
+
sizes.forEach((b, i) => {
|
|
284
|
+
validateLargeTargetBytes(b, `${path}.targetBytes[${i}]`);
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
if ("modality" in inst) {
|
|
288
|
+
if (Array.isArray(inst.modality)) {
|
|
289
|
+
validateArrayOfLength(inst.modality, count, `${path}.modality`).forEach(
|
|
290
|
+
(m, i) => {
|
|
291
|
+
validateEnum(m, VALID_MODALITIES, `${path}.modality[${i}]`);
|
|
292
|
+
}
|
|
226
293
|
);
|
|
294
|
+
} else {
|
|
295
|
+
validateEnum(inst.modality, VALID_MODALITIES, `${path}.modality`);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
if ("tags" in inst) {
|
|
299
|
+
const tags = inst.tags;
|
|
300
|
+
if (typeof tags !== "object" || tags === null || Array.isArray(tags)) {
|
|
301
|
+
throw new Error(`${path}.tags: must be an object`);
|
|
227
302
|
}
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
);
|
|
303
|
+
for (const [key, arr] of Object.entries(tags)) {
|
|
304
|
+
validateTagKey(key, `${path}.tags`);
|
|
305
|
+
validateArrayOfLength(arr, count, `${path}.tags.${key}`).forEach(
|
|
306
|
+
(value, i) => {
|
|
307
|
+
validateTagValue(key, value, `${path}.tags[${i}]`);
|
|
234
308
|
}
|
|
235
|
-
|
|
309
|
+
);
|
|
236
310
|
}
|
|
237
311
|
}
|
|
238
312
|
}
|
|
@@ -241,16 +315,27 @@ function validateSeries(series, path) {
|
|
|
241
315
|
throw new Error(`${path}: must be an object`);
|
|
242
316
|
}
|
|
243
317
|
const s = series;
|
|
244
|
-
|
|
245
|
-
|
|
318
|
+
const hasEntries = "entries" in s;
|
|
319
|
+
const hasInstances = "instances" in s;
|
|
320
|
+
if (hasEntries === hasInstances) {
|
|
321
|
+
throw new Error(
|
|
322
|
+
`${path}: must have exactly one of "entries" or "instances"`
|
|
323
|
+
);
|
|
246
324
|
}
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
);
|
|
325
|
+
if (hasEntries) {
|
|
326
|
+
if (!Array.isArray(s.entries) || s.entries.length === 0) {
|
|
327
|
+
throw new Error(`${path}.entries: must be a non-empty array`);
|
|
328
|
+
}
|
|
329
|
+
for (let i = 0; i < s.entries.length; i++) {
|
|
330
|
+
const e = validateEntry(s.entries[i], `${path}.entries[${i}]`);
|
|
331
|
+
if (!TYPE_CAPABILITIES[e.type].image) {
|
|
332
|
+
throw new Error(
|
|
333
|
+
`${path}.entries[${i}].type: "${e.type}" cannot be used inside a series \u2014 only image-generating types can be grouped`
|
|
334
|
+
);
|
|
335
|
+
}
|
|
253
336
|
}
|
|
337
|
+
} else {
|
|
338
|
+
validateInstances(s.instances, `${path}.instances`);
|
|
254
339
|
}
|
|
255
340
|
if ("tags" in s) validateTags(s.tags, `${path}.tags`);
|
|
256
341
|
return s;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { DatasetSpec } from '../schema/types.js';
|
|
2
2
|
export type DescribeOptions = {
|
|
3
3
|
keepTags?: string[];
|
|
4
|
+
sizeTolerance?: number;
|
|
4
5
|
};
|
|
5
6
|
export type DescribeResult = {
|
|
6
7
|
spec: DatasetSpec;
|
|
@@ -11,4 +12,5 @@ export type DescribeResult = {
|
|
|
11
12
|
};
|
|
12
13
|
};
|
|
13
14
|
export declare function readHeaderOnly(path: string): Record<string, unknown> | null;
|
|
15
|
+
export declare function tagValuesEqual(a: unknown, b: unknown): boolean;
|
|
14
16
|
export declare function describeDirectory(dir: string, options?: DescribeOptions): DescribeResult;
|
package/dist/types/index.d.ts
CHANGED
|
@@ -4,7 +4,7 @@ export { defaultPublicCasesPath, loadCaseById, loadCasesFromJson, loadDefaultCas
|
|
|
4
4
|
export { caseCachePath, fetchPublicCaseToCache, verifySha256, } from './public-fixtures/fetch.js';
|
|
5
5
|
export { MAX_PIXEL_BYTES, MAX_STREAM_BYTES } from './schema/limits.js';
|
|
6
6
|
export { resolveParametricSpec } from './schema/parametric.js';
|
|
7
|
-
export type { DatasetLayout, DatasetSpec, DicomdirSpec, DicomTagOverrides, FakeSignatureSpec, FileSpec, ImageSpec, InvalidUidImageSpec, LargeFileSpec, Modality, NonDicomSpec, ParametricEdgeCase, ParametricSpec, ParametricStudies, PathQuirk, Range, SeriesEntrySpec, SeriesSpec, StudySpec, TransferSyntax, ValidImageSpec, VendorWarningsImageSpec, ViolationClass, } from './schema/types.js';
|
|
7
|
+
export type { DatasetLayout, DatasetSpec, DicomdirSpec, DicomTagOverrides, FakeSignatureSpec, FileSpec, ImageSpec, InvalidUidImageSpec, LargeFileSpec, Modality, NonDicomSpec, ParametricEdgeCase, ParametricSpec, ParametricStudies, PathQuirk, Range, SeriesEntrySpec, SeriesInstances, SeriesSpec, StudySpec, TransferSyntax, ValidImageSpec, VendorWarningsImageSpec, ViolationClass, } from './schema/types.js';
|
|
8
8
|
export { validateDatasetSpec, validateParametricSpec, } from './schema/validate.js';
|
|
9
9
|
export { type LargeFileEncoding, type LargeFileResult, type LargeImageSpec, type StreamLargeImageOptions, streamLargeImage, writeLargeImageFile, } from './syntheticFixtures/streamWrite.js';
|
|
10
10
|
export { resolveTagTemplates, type TagContext, TEMPLATE_VOCAB, } from './syntheticFixtures/tagTemplate.js';
|
|
@@ -51,8 +51,16 @@ export type EntrySpec = FileSpec & {
|
|
|
51
51
|
export type SeriesEntrySpec = (ImageSpec | LargeFileSpec) & {
|
|
52
52
|
count?: number;
|
|
53
53
|
};
|
|
54
|
+
export type SeriesInstances = {
|
|
55
|
+
count: number;
|
|
56
|
+
targetSizeKb?: number[];
|
|
57
|
+
targetBytes?: number[];
|
|
58
|
+
modality?: Modality | Modality[];
|
|
59
|
+
tags?: Record<string, unknown[]>;
|
|
60
|
+
};
|
|
54
61
|
export type SeriesSpec = {
|
|
55
|
-
entries
|
|
62
|
+
entries?: SeriesEntrySpec[];
|
|
63
|
+
instances?: SeriesInstances;
|
|
56
64
|
tags?: DicomTagOverrides;
|
|
57
65
|
};
|
|
58
66
|
export type StudySpec = {
|