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.
@@ -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
- const isModalityKey = key === "Modality" || key.toUpperCase() === MODALITY_TAG;
249
- if (isModalityKey && typeof value === "string" && Object.hasOwn(VALID_MODALITIES, value)) {
250
- throw new Error(
251
- `${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`
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
- if (typeof value === "string") {
255
- for (const name of findTemplateTokens(value)) {
256
- if (!TEMPLATE_VOCAB.includes(name)) {
257
- throw new Error(
258
- `${path}.${key}: unknown placeholder "{${name}}" \u2014 allowed: ${TEMPLATE_VOCAB.map((v) => `{${v}}`).join(", ")}`
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
- if (!Array.isArray(s.entries) || s.entries.length === 0) {
271
- throw new Error(`${path}.entries: must be a non-empty array`);
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
- for (let i = 0; i < s.entries.length; i++) {
274
- const e = validateEntry(s.entries[i], `${path}.entries[${i}]`);
275
- if (!TYPE_CAPABILITIES[e.type].image) {
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 sizeKb = Math.max(1, Math.round(size / 1024));
535
- const tags = keepKeywords.length ? extractTags(record, keepKeywords) : void 0;
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
- const collapseKey = JSON.stringify([
547
- large,
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.set(collapseKey, { modality, large, bytes: size, tags, count: 1 });
753
+ accumulate(series, fileRecord);
557
754
  }
558
755
  }
559
- const studySpecs = [...studies.values()].map((study) => {
560
- const seriesSpecs = [...study.values()].map((series) => {
561
- const entries = [...series.values()].map((acc) => ({
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
  };