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.
@@ -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.keys(tags)) {
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
- if (!Array.isArray(s.entries) || s.entries.length === 0) {
236
- throw new Error(`${path}.entries: must be a non-empty array`);
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
- for (let i = 0; i < s.entries.length; i++) {
239
- const e = validateEntry(s.entries[i], `${path}.entries[${i}]`);
240
- if (!TYPE_CAPABILITIES[e.type].image) {
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
- return keepTags.map((tag) => {
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 keyword = hexToKeyword.get(tag.toUpperCase());
332
- if (!keyword) {
460
+ const mapped = hexToKeyword.get(tag.toUpperCase());
461
+ if (!mapped) {
333
462
  throw new Error(`describe: unknown hex tag "${tag}"`);
334
463
  }
335
- return keyword;
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 && !Object.hasOwn(nameMap, tag)) {
338
- throw new Error(`describe: unknown tag keyword "${tag}"`);
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
- return tag;
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 !== void 0) {
434
- tags[keyword] = value;
435
- found = true;
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 sizeKb = Math.max(1, Math.round(size / 1024));
474
- const tags = keepKeywords.length ? extractTags(record, keepKeywords) : void 0;
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
- const collapseKey = JSON.stringify([
486
- large,
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.set(collapseKey, { modality, large, bytes: size, tags, count: 1 });
744
+ accumulate(series, fileRecord);
496
745
  }
497
746
  }
498
- const studySpecs = [...studies.values()].map((study) => {
499
- const seriesSpecs = [...study.values()].map((series) => {
500
- const entries = [...series.values()].map((acc) => ({
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
  };