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
package/dist/esm/index.js
CHANGED
|
@@ -49,6 +49,50 @@ function buildDicomdirBuffer() {
|
|
|
49
49
|
return buf.subarray(0, off);
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
// src/syntheticFixtures/tagTemplate.ts
|
|
53
|
+
var TEMPLATE_VOCAB = [
|
|
54
|
+
"index",
|
|
55
|
+
"studyIndex",
|
|
56
|
+
"seriesIndex",
|
|
57
|
+
"instanceNumber"
|
|
58
|
+
];
|
|
59
|
+
var TEMPLATE_PART_RE = /\{\{|\}\}|\{([a-zA-Z]+)\}/g;
|
|
60
|
+
function escapeTagTemplate(value) {
|
|
61
|
+
return value.replace(/\{/g, "{{").replace(/\}/g, "}}");
|
|
62
|
+
}
|
|
63
|
+
function findTemplateTokens(value) {
|
|
64
|
+
const names = [];
|
|
65
|
+
for (const m of value.matchAll(TEMPLATE_PART_RE)) {
|
|
66
|
+
if (m[1] !== void 0) names.push(m[1]);
|
|
67
|
+
}
|
|
68
|
+
return names;
|
|
69
|
+
}
|
|
70
|
+
function resolveTagTemplates(tags, context) {
|
|
71
|
+
if (!tags) return tags;
|
|
72
|
+
const resolved = {};
|
|
73
|
+
for (const [key, value] of Object.entries(tags)) {
|
|
74
|
+
resolved[key] = typeof value === "string" ? resolveString(value, context) : value;
|
|
75
|
+
}
|
|
76
|
+
return resolved;
|
|
77
|
+
}
|
|
78
|
+
function resolveString(value, context) {
|
|
79
|
+
return value.replace(TEMPLATE_PART_RE, (match, name) => {
|
|
80
|
+
if (name === void 0) return match === "{{" ? "{" : "}";
|
|
81
|
+
if (!TEMPLATE_VOCAB.includes(name)) {
|
|
82
|
+
throw new Error(
|
|
83
|
+
`tag template: unknown placeholder "{${name}}" \u2014 allowed: ${TEMPLATE_VOCAB.map((v) => `{${v}}`).join(", ")}`
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
const resolved = context[name];
|
|
87
|
+
if (resolved === void 0) {
|
|
88
|
+
throw new Error(
|
|
89
|
+
`tag template: placeholder "{${name}}" is not available here \u2014 it only applies inside grouped studies`
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
return String(resolved);
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
52
96
|
// src/schema/validate.ts
|
|
53
97
|
var TYPE_CAPABILITIES = {
|
|
54
98
|
"valid-image": { image: true },
|
|
@@ -243,12 +287,102 @@ function validateEntry(entry, path) {
|
|
|
243
287
|
}
|
|
244
288
|
return e;
|
|
245
289
|
}
|
|
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
|
+
}
|
|
246
308
|
function validateTags(tags, path) {
|
|
247
309
|
if (typeof tags !== "object" || tags === null || Array.isArray(tags)) {
|
|
248
310
|
throw new Error(`${path}: must be an object`);
|
|
249
311
|
}
|
|
250
|
-
for (const key of Object.
|
|
312
|
+
for (const [key, value] of Object.entries(tags)) {
|
|
251
313
|
validateTagKey(key, path);
|
|
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
|
+
}
|
|
368
|
+
);
|
|
369
|
+
} else {
|
|
370
|
+
validateEnum(inst.modality, VALID_MODALITIES, `${path}.modality`);
|
|
371
|
+
}
|
|
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}]`);
|
|
383
|
+
}
|
|
384
|
+
);
|
|
385
|
+
}
|
|
252
386
|
}
|
|
253
387
|
}
|
|
254
388
|
function validateSeries(series, path) {
|
|
@@ -256,16 +390,27 @@ function validateSeries(series, path) {
|
|
|
256
390
|
throw new Error(`${path}: must be an object`);
|
|
257
391
|
}
|
|
258
392
|
const s = series;
|
|
259
|
-
|
|
260
|
-
|
|
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
|
+
);
|
|
261
399
|
}
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
throw new Error(
|
|
266
|
-
`${path}.entries[${i}].type: "${e.type}" cannot be used inside a series \u2014 only image-generating types can be grouped`
|
|
267
|
-
);
|
|
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`);
|
|
268
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
|
+
}
|
|
411
|
+
}
|
|
412
|
+
} else {
|
|
413
|
+
validateInstances(s.instances, `${path}.instances`);
|
|
269
414
|
}
|
|
270
415
|
if ("tags" in s) validateTags(s.tags, `${path}.tags`);
|
|
271
416
|
return s;
|
|
@@ -973,12 +1118,41 @@ function filename(type, index, padWidth) {
|
|
|
973
1118
|
function entryCount(entries) {
|
|
974
1119
|
return entries.reduce((sum, e) => sum + (e.count ?? 1), 0);
|
|
975
1120
|
}
|
|
1121
|
+
function seriesFileCount(series) {
|
|
1122
|
+
return series.instances ? series.instances.count : entryCount(series.entries ?? []);
|
|
1123
|
+
}
|
|
976
1124
|
function studyFileCount(studies) {
|
|
977
1125
|
return studies.reduce(
|
|
978
|
-
(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),
|
|
979
1127
|
0
|
|
980
1128
|
);
|
|
981
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
|
+
}
|
|
982
1156
|
function resolveUid(seed, index, override) {
|
|
983
1157
|
return { ...makeUidGenerator(seed)(index), ...override };
|
|
984
1158
|
}
|
|
@@ -986,7 +1160,11 @@ async function generateFile(spec, options) {
|
|
|
986
1160
|
const index = options?.index ?? 0;
|
|
987
1161
|
const padWidth = options?.padWidth ?? 3;
|
|
988
1162
|
const uid = resolveUid(options?.seed, index, options?.uid);
|
|
989
|
-
|
|
1163
|
+
const resolvedSpec = "tags" in spec && spec.tags ? {
|
|
1164
|
+
...spec,
|
|
1165
|
+
tags: resolveTagTemplates(spec.tags, { index, ...options?.context })
|
|
1166
|
+
} : spec;
|
|
1167
|
+
let buffer = buildBufferForSpec(resolvedSpec, uid);
|
|
990
1168
|
const violations = "violations" in spec ? spec.violations : void 0;
|
|
991
1169
|
if (violations?.length) {
|
|
992
1170
|
buffer = applyViolations(buffer, violations);
|
|
@@ -1081,6 +1259,7 @@ function* planCollection(spec) {
|
|
|
1081
1259
|
yield {
|
|
1082
1260
|
fileSpec,
|
|
1083
1261
|
index: globalIndex,
|
|
1262
|
+
context: { index: globalIndex },
|
|
1084
1263
|
...computeNames(
|
|
1085
1264
|
fileSpec.type,
|
|
1086
1265
|
globalIndex,
|
|
@@ -1101,30 +1280,33 @@ function* planCollection(spec) {
|
|
|
1101
1280
|
for (const [seriesIndex, series] of study.series.entries()) {
|
|
1102
1281
|
const seriesUid = groupUids.series(studyOrdinal, seriesIndex);
|
|
1103
1282
|
let instanceNumber = 0;
|
|
1104
|
-
for (const
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
instanceNumber
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
}
|
|
1114
|
-
|
|
1115
|
-
|
|
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: {
|
|
1116
1296
|
index: globalIndex,
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
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++;
|
|
1128
1310
|
}
|
|
1129
1311
|
}
|
|
1130
1312
|
studyOrdinal++;
|
|
@@ -1135,7 +1317,8 @@ async function materialisePlan(plan, seed) {
|
|
|
1135
1317
|
const file = await generateFile(plan.fileSpec, {
|
|
1136
1318
|
index: plan.index,
|
|
1137
1319
|
seed,
|
|
1138
|
-
uid: plan.uidOverride
|
|
1320
|
+
uid: plan.uidOverride,
|
|
1321
|
+
context: plan.context
|
|
1139
1322
|
});
|
|
1140
1323
|
return { ...file, filename: plan.filename, relativePath: plan.relativePath };
|
|
1141
1324
|
}
|
|
@@ -1191,7 +1374,7 @@ async function writeCollectionFromSpec(spec, outDir) {
|
|
|
1191
1374
|
targetBytes: large.targetBytes,
|
|
1192
1375
|
modality: large.modality,
|
|
1193
1376
|
uid,
|
|
1194
|
-
tags: large.tags
|
|
1377
|
+
tags: resolveTagTemplates(large.tags, plan.context)
|
|
1195
1378
|
});
|
|
1196
1379
|
} else {
|
|
1197
1380
|
const file = await materialisePlan(plan, spec.seed);
|
|
@@ -1219,6 +1402,13 @@ import { join } from "node:path";
|
|
|
1219
1402
|
var dcmjsAny2 = dcmjs;
|
|
1220
1403
|
var HEX_TAG_RE2 = /^[0-9a-fA-F]{8}$/;
|
|
1221
1404
|
var SUPPORTED_MODALITIES = /* @__PURE__ */ new Set(["CT", "PT", "MR", "CR"]);
|
|
1405
|
+
var RESERVED_KEEP_KEYWORDS = /* @__PURE__ */ new Set([
|
|
1406
|
+
"Units",
|
|
1407
|
+
"DecayCorrection",
|
|
1408
|
+
"CorrectedImage",
|
|
1409
|
+
"ScanningSequence",
|
|
1410
|
+
"SequenceVariant"
|
|
1411
|
+
]);
|
|
1222
1412
|
function buildHexToKeyword() {
|
|
1223
1413
|
const map = /* @__PURE__ */ new Map();
|
|
1224
1414
|
const nm = dcmjsAny2.data.DicomMetaDictionary.nameMap;
|
|
@@ -1234,20 +1424,37 @@ function buildHexToKeyword() {
|
|
|
1234
1424
|
function resolveKeepTags(keepTags) {
|
|
1235
1425
|
const nameMap = dcmjsAny2.data.DicomMetaDictionary.nameMap;
|
|
1236
1426
|
let hexToKeyword;
|
|
1237
|
-
|
|
1427
|
+
const kept = [];
|
|
1428
|
+
for (const tag of keepTags) {
|
|
1429
|
+
let keyword;
|
|
1238
1430
|
if (HEX_TAG_RE2.test(tag)) {
|
|
1239
1431
|
hexToKeyword ?? (hexToKeyword = buildHexToKeyword());
|
|
1240
|
-
const
|
|
1241
|
-
if (!
|
|
1432
|
+
const mapped = hexToKeyword.get(tag.toUpperCase());
|
|
1433
|
+
if (!mapped) {
|
|
1242
1434
|
throw new Error(`describe: unknown hex tag "${tag}"`);
|
|
1243
1435
|
}
|
|
1244
|
-
|
|
1436
|
+
keyword = mapped;
|
|
1437
|
+
} else {
|
|
1438
|
+
if (nameMap && !Object.hasOwn(nameMap, tag)) {
|
|
1439
|
+
throw new Error(`describe: unknown tag keyword "${tag}"`);
|
|
1440
|
+
}
|
|
1441
|
+
keyword = tag;
|
|
1245
1442
|
}
|
|
1246
|
-
if (
|
|
1247
|
-
|
|
1443
|
+
if (RESERVED_KEEP_KEYWORDS.has(keyword)) {
|
|
1444
|
+
console.warn(
|
|
1445
|
+
`describe: ignoring keep-tag "${keyword}" \u2014 modality-coupled tags are reproduced from the modality field`
|
|
1446
|
+
);
|
|
1447
|
+
continue;
|
|
1248
1448
|
}
|
|
1249
|
-
|
|
1250
|
-
|
|
1449
|
+
if (nameMap?.[keyword]?.vr === "UI") {
|
|
1450
|
+
console.warn(
|
|
1451
|
+
`describe: ignoring keep-tag "${keyword}" \u2014 UID-valued tags are not kept verbatim`
|
|
1452
|
+
);
|
|
1453
|
+
continue;
|
|
1454
|
+
}
|
|
1455
|
+
kept.push(keyword);
|
|
1456
|
+
}
|
|
1457
|
+
return kept;
|
|
1251
1458
|
}
|
|
1252
1459
|
function* walkFiles(dir) {
|
|
1253
1460
|
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
@@ -1339,15 +1546,126 @@ function extractTags(record, keepKeywords) {
|
|
|
1339
1546
|
let found = false;
|
|
1340
1547
|
for (const keyword of keepKeywords) {
|
|
1341
1548
|
const value = record[keyword];
|
|
1342
|
-
if (value
|
|
1343
|
-
|
|
1344
|
-
|
|
1549
|
+
if (value === void 0) continue;
|
|
1550
|
+
if (keyword === "Modality" && typeof value === "string" && SUPPORTED_MODALITIES.has(value)) {
|
|
1551
|
+
continue;
|
|
1345
1552
|
}
|
|
1553
|
+
tags[keyword] = typeof value === "string" ? escapeTagTemplate(value) : value;
|
|
1554
|
+
found = true;
|
|
1346
1555
|
}
|
|
1347
1556
|
return found ? tags : void 0;
|
|
1348
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
|
+
}
|
|
1349
1660
|
function describeDirectory(dir, options = {}) {
|
|
1350
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
|
+
}
|
|
1351
1669
|
const studies = /* @__PURE__ */ new Map();
|
|
1352
1670
|
const stats = { dicomFiles: 0, skipped: 0, unknownModality: 0 };
|
|
1353
1671
|
for (const path of [...walkFiles(dir)].sort()) {
|
|
@@ -1379,8 +1697,9 @@ function describeDirectory(dir, options = {}) {
|
|
|
1379
1697
|
stats.unknownModality++;
|
|
1380
1698
|
}
|
|
1381
1699
|
}
|
|
1382
|
-
const
|
|
1383
|
-
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 };
|
|
1384
1703
|
let study = studies.get(studyUid);
|
|
1385
1704
|
if (!study) {
|
|
1386
1705
|
study = /* @__PURE__ */ new Map();
|
|
@@ -1388,37 +1707,18 @@ function describeDirectory(dir, options = {}) {
|
|
|
1388
1707
|
}
|
|
1389
1708
|
let series = study.get(seriesUid);
|
|
1390
1709
|
if (!series) {
|
|
1391
|
-
series = /* @__PURE__ */ new Map();
|
|
1710
|
+
series = keepTags ? [] : /* @__PURE__ */ new Map();
|
|
1392
1711
|
study.set(seriesUid, series);
|
|
1393
1712
|
}
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
modality ?? null,
|
|
1397
|
-
large ? size : sizeKb,
|
|
1398
|
-
tags ?? null
|
|
1399
|
-
]);
|
|
1400
|
-
const existing = series.get(collapseKey);
|
|
1401
|
-
if (existing) {
|
|
1402
|
-
existing.count++;
|
|
1713
|
+
if (Array.isArray(series)) {
|
|
1714
|
+
series.push(fileRecord);
|
|
1403
1715
|
} else {
|
|
1404
|
-
series
|
|
1716
|
+
accumulate(series, fileRecord);
|
|
1405
1717
|
}
|
|
1406
1718
|
}
|
|
1407
|
-
const studySpecs = [...studies.values()].map((study) => {
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
...acc.modality !== void 0 ? { modality: acc.modality } : {},
|
|
1411
|
-
...acc.large ? { type: "large-image", targetBytes: acc.bytes } : {
|
|
1412
|
-
type: "valid-image",
|
|
1413
|
-
targetSizeKb: Math.max(1, Math.round(acc.bytes / 1024))
|
|
1414
|
-
},
|
|
1415
|
-
...acc.tags !== void 0 ? { tags: acc.tags } : {},
|
|
1416
|
-
...acc.count > 1 ? { count: acc.count } : {}
|
|
1417
|
-
}));
|
|
1418
|
-
return { entries };
|
|
1419
|
-
});
|
|
1420
|
-
return { series: seriesSpecs };
|
|
1421
|
-
});
|
|
1719
|
+
const studySpecs = [...studies.values()].map((study) => ({
|
|
1720
|
+
series: [...study.values()].map(seriesSpecFromAccum)
|
|
1721
|
+
}));
|
|
1422
1722
|
if (studySpecs.length === 0) {
|
|
1423
1723
|
throw new Error(`describe: no DICOM series found in ${dir}`);
|
|
1424
1724
|
}
|
|
@@ -1606,6 +1906,7 @@ function resolveParametricSpec(spec) {
|
|
|
1606
1906
|
export {
|
|
1607
1907
|
MAX_PIXEL_BYTES,
|
|
1608
1908
|
MAX_STREAM_BYTES,
|
|
1909
|
+
TEMPLATE_VOCAB,
|
|
1609
1910
|
caseCachePath,
|
|
1610
1911
|
defaultPublicCasesPath,
|
|
1611
1912
|
describeDirectory,
|
|
@@ -1617,6 +1918,7 @@ export {
|
|
|
1617
1918
|
loadDefaultCases,
|
|
1618
1919
|
previewCollection,
|
|
1619
1920
|
resolveParametricSpec,
|
|
1921
|
+
resolveTagTemplates,
|
|
1620
1922
|
streamLargeImage,
|
|
1621
1923
|
validateDatasetSpec,
|
|
1622
1924
|
validateParametricSpec,
|
|
@@ -15,6 +15,22 @@ function seededStream(seed, offset, a, b) {
|
|
|
15
15
|
return lcg(seed * 999983 + offset + a * 1000003 + b * 7919 >>> 0);
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
// src/syntheticFixtures/tagTemplate.ts
|
|
19
|
+
var TEMPLATE_VOCAB = [
|
|
20
|
+
"index",
|
|
21
|
+
"studyIndex",
|
|
22
|
+
"seriesIndex",
|
|
23
|
+
"instanceNumber"
|
|
24
|
+
];
|
|
25
|
+
var TEMPLATE_PART_RE = /\{\{|\}\}|\{([a-zA-Z]+)\}/g;
|
|
26
|
+
function findTemplateTokens(value) {
|
|
27
|
+
const names = [];
|
|
28
|
+
for (const m of value.matchAll(TEMPLATE_PART_RE)) {
|
|
29
|
+
if (m[1] !== void 0) names.push(m[1]);
|
|
30
|
+
}
|
|
31
|
+
return names;
|
|
32
|
+
}
|
|
33
|
+
|
|
18
34
|
// src/schema/limits.ts
|
|
19
35
|
var MAX_PIXEL_BYTES = 512 * 1024 * 1024;
|
|
20
36
|
var MAX_STREAM_BYTES = 50 * 1024 * 1024 * 1024;
|
|
@@ -207,12 +223,31 @@ function validateEntry(entry, path) {
|
|
|
207
223
|
}
|
|
208
224
|
return e;
|
|
209
225
|
}
|
|
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
|
+
}
|
|
210
244
|
function validateTags(tags, path) {
|
|
211
245
|
if (typeof tags !== "object" || tags === null || Array.isArray(tags)) {
|
|
212
246
|
throw new Error(`${path}: must be an object`);
|
|
213
247
|
}
|
|
214
|
-
for (const key of Object.
|
|
248
|
+
for (const [key, value] of Object.entries(tags)) {
|
|
215
249
|
validateTagKey(key, path);
|
|
250
|
+
validateTagValue(key, value, path);
|
|
216
251
|
}
|
|
217
252
|
}
|
|
218
253
|
function validateRange(value, path, options = {}) {
|