dicom-synth 1.15.0 → 1.16.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.
@@ -6,7 +6,7 @@ import { describeDirectory } from '../dist/esm/index.js'
6
6
 
7
7
  function usage() {
8
8
  console.error(
9
- 'Usage: dicom-synth-describe <dir> [--out <spec.json[.gz]>] [--keep-tags <a,b,...>] [--keep-tags-file <path>] [--size-tolerance <fraction>] [--no-preserve-uids] [--uid-salt <hex>] [--gzip]\n' +
9
+ 'Usage: dicom-synth-describe <dir> [--out <spec.json[.gz]>] [--tree] [--keep-tags <a,b,...>] [--keep-tags-file <path>] [--size-tolerance <fraction>] [--no-preserve-uids] [--uid-salt <hex>] [--gzip]\n' +
10
10
  '\n' +
11
11
  'Scans a DICOM tree and emits a DatasetSpec describing its shape\n' +
12
12
  '(studies/series, per-file size, modality).\n' +
@@ -14,6 +14,9 @@ function usage() {
14
14
  'UIDs are preserved by default as hash-derived synthetic UIDs, keeping\n' +
15
15
  'cross-file links (FrameOfReferenceUID etc.) without emitting any original\n' +
16
16
  'UID. Other tags are not copied unless named.\n' +
17
+ '--tree captures the real directory structure as a generic tree spec\n' +
18
+ '(anonymised names, sized non-DICOM placeholders included) instead of\n' +
19
+ 'the compact studies/series form. Requires UID preservation.\n' +
17
20
  '--keep-tags copies the named tags (DICOM keywords or 8-hex tags)\n' +
18
21
  "verbatim; if a kept tag holds PHI, handling it is the caller's\n" +
19
22
  "responsibility, not this tool's.\n" +
@@ -48,6 +51,7 @@ let sizeTolerance
48
51
  let preserveUids = true
49
52
  let uidSalt
50
53
  let gzip = false
54
+ let captureTree = false
51
55
  const keepTags = []
52
56
 
53
57
  for (let i = 0; i < args.length; i++) {
@@ -75,6 +79,8 @@ for (let i = 0; i < args.length; i++) {
75
79
  }
76
80
  } else if (args[i] === '--no-preserve-uids') {
77
81
  preserveUids = false
82
+ } else if (args[i] === '--tree') {
83
+ captureTree = true
78
84
  } else if (args[i] === '--uid-salt' && args[i + 1]) {
79
85
  uidSalt = args[++i]
80
86
  } else if (args[i] === '--gzip') {
@@ -104,6 +110,7 @@ try {
104
110
  ...(sizeTolerance !== undefined ? { sizeTolerance } : {}),
105
111
  preserveUids,
106
112
  ...(uidSalt !== undefined ? { uidSalt } : {}),
113
+ ...(captureTree ? { captureTree: true } : {}),
107
114
  })
108
115
  } catch (err) {
109
116
  console.error(`Error: ${err.message}`)
@@ -119,10 +126,15 @@ if (outPath) {
119
126
  process.stdout.write(compress ? gzipSync(json) : json)
120
127
  }
121
128
 
122
- const { dicomFiles, skipped, unknownModality } = result.stats
129
+ const { dicomFiles, skipped, unknownModality, nonDicomFiles } = result.stats
123
130
  console.error(
124
131
  `Described ${dicomFiles} DICOM file(s); skipped ${skipped} non-DICOM/ungroupable file(s)`,
125
132
  )
133
+ if (nonDicomFiles > 0) {
134
+ console.error(
135
+ ` ${nonDicomFiles} non-DICOM file(s) captured as sized placeholders`,
136
+ )
137
+ }
126
138
  if (result.spec.uidSalt) {
127
139
  console.error(
128
140
  ` UIDs hash-derived with salt ${result.spec.uidSalt} (reuse via --uid-salt to reproduce)`,
@@ -56,6 +56,9 @@ function isImageType(type) {
56
56
  return TYPE_CAPABILITIES[type].image;
57
57
  }
58
58
  var HEX_TAG_RE = /^[0-9a-fA-F]{8}$/;
59
+ function positionalName(prefix, ordinal) {
60
+ return `${prefix}-${String(ordinal).padStart(3, "0")}`;
61
+ }
59
62
 
60
63
  // src/loadDcmjs.ts
61
64
  import dcmjsDefaultImport, * as dcmjsNamespace from "dcmjs";
@@ -160,6 +163,14 @@ function applyTagOverrides(dataset, tags) {
160
163
  }
161
164
  }
162
165
  }
166
+ function syncMetaWithDataset(meta, dataset) {
167
+ if (typeof dataset.SOPInstanceUID === "string") {
168
+ meta.MediaStorageSOPInstanceUID = dataset.SOPInstanceUID;
169
+ }
170
+ if (typeof dataset.SOPClassUID === "string") {
171
+ meta.MediaStorageSOPClassUID = dataset.SOPClassUID;
172
+ }
173
+ }
163
174
  function buildMeta(uid, modality, transferSyntax = "explicit-vr-little-endian") {
164
175
  return {
165
176
  FileMetaInformationVersion: new Uint8Array([0, 1]).buffer,
@@ -246,6 +257,7 @@ function buildImageBuffer(spec, uid) {
246
257
  }
247
258
  applyTagOverrides(dataset, spec.tags);
248
259
  const meta = buildMeta(effectiveUid, modality, spec.transferSyntax);
260
+ syncMetaWithDataset(meta, dataset);
249
261
  applySizing(dataset, meta, spec);
250
262
  return serializeDict(meta, dataset);
251
263
  }
@@ -355,6 +367,7 @@ function writeNative(outPath, params, dims, zeroChunk) {
355
367
  const meta = buildMeta(uid, modality);
356
368
  const dataset = buildBaseImageDataset(uid, modality);
357
369
  applyTagOverrides(dataset, tags);
370
+ syncMetaWithDataset(meta, dataset);
358
371
  dataset.Rows = dims.rows;
359
372
  dataset.Columns = dims.columns;
360
373
  const minimalPixelBytes = dataset.PixelData.byteLength;
@@ -388,6 +401,7 @@ function writeEncapsulated(outPath, params, pixelBytes, fragmentBytes, zeroChunk
388
401
  };
389
402
  const dataset = buildBaseImageDataset(uid, modality);
390
403
  applyTagOverrides(dataset, tags);
404
+ syncMetaWithDataset(meta, dataset);
391
405
  dataset.PixelData = [new Uint8Array([]).buffer, new Uint8Array([0, 0]).buffer];
392
406
  dataset._vrMap = { PixelData: "OB" };
393
407
  const probe = serializeDict(meta, dataset);
@@ -723,7 +737,7 @@ function* planTree(nodes, scope, env) {
723
737
  for (const node of nodes) {
724
738
  if (isDirNode(node)) {
725
739
  dirOrdinal++;
726
- const dirName = node.name ?? `dir-${String(dirOrdinal).padStart(3, "0")}`;
740
+ const dirName = node.name ?? positionalName("dir", dirOrdinal);
727
741
  const child = {
728
742
  ...scope,
729
743
  dirs: [...scope.dirs, dirName],
@@ -8,7 +8,7 @@ import {
8
8
  readSync,
9
9
  statSync
10
10
  } from "node:fs";
11
- import { join } from "node:path";
11
+ import { basename, extname, join, relative, sep } from "node:path";
12
12
 
13
13
  // src/loadDcmjs.ts
14
14
  import dcmjsDefaultImport, * as dcmjsNamespace from "dcmjs";
@@ -431,6 +431,9 @@ function validateStudy(study, path) {
431
431
  if ("tags" in st) validateTags(st.tags, `${path}.tags`);
432
432
  return st;
433
433
  }
434
+ function positionalName(prefix, ordinal) {
435
+ return `${prefix}-${String(ordinal).padStart(3, "0")}`;
436
+ }
434
437
  var VALID_TREE_ROLES = {
435
438
  study: true,
436
439
  series: true
@@ -511,7 +514,7 @@ function validateTreeChildren(children, basePath, inStudy, inSeries) {
511
514
  let name = "name" in n ? n.name : void 0;
512
515
  if ("children" in n) {
513
516
  dirOrdinal++;
514
- name ?? (name = `dir-${String(dirOrdinal).padStart(3, "0")}`);
517
+ name ?? (name = positionalName("dir", dirOrdinal));
515
518
  }
516
519
  if (name === void 0) return;
517
520
  const prior = claimed.get(name);
@@ -850,6 +853,37 @@ function mergeTags(a, b) {
850
853
  if (!b) return a;
851
854
  return { ...a, ...b };
852
855
  }
856
+ function scanFile(path) {
857
+ let size;
858
+ try {
859
+ size = statSync(path).size;
860
+ } catch {
861
+ return null;
862
+ }
863
+ if (size > MAX_STREAM_BYTES) return null;
864
+ const large = size > MAX_PIXEL_BYTES;
865
+ return { size, large, record: large ? readHeaderOnly(path) : parseFull(path) };
866
+ }
867
+ function presetModalityOf(record) {
868
+ if (typeof record.Modality !== "string") return void 0;
869
+ return SUPPORTED_MODALITIES.has(record.Modality) ? record.Modality : "unsupported";
870
+ }
871
+ function hashRecordUids(rec, salt, uidMap, sweptKeepTags) {
872
+ if (!rec.uidOriginals) return;
873
+ if (rec.tags) {
874
+ for (const keyword of Object.keys(rec.uidOriginals)) {
875
+ if (keyword in rec.tags) sweptKeepTags.add(keyword);
876
+ }
877
+ }
878
+ rec.tags = mergeTags(rec.tags, hashUidTags(rec.uidOriginals, salt, uidMap));
879
+ }
880
+ function warnSweptKeepTags(sweptKeepTags) {
881
+ for (const keyword of sweptKeepTags) {
882
+ console.warn(
883
+ `describe: keep-tag "${keyword}" contains UID references \u2014 kept as its hashed UID skeleton, not verbatim (disable with preserveUids: false)`
884
+ );
885
+ }
886
+ }
853
887
  function sizeKbOf(bytes) {
854
888
  return Math.max(1, Math.round(bytes / 1024));
855
889
  }
@@ -952,9 +986,128 @@ function buildSeriesSpec(records) {
952
986
  function seriesSpecFromAccum(accum) {
953
987
  return Array.isArray(accum) ? buildSeriesSpec(accum) : { entries: [...accum.values()].map(entryOf) };
954
988
  }
989
+ function dirAccumFor(root, dirs) {
990
+ let node = root;
991
+ for (const name of dirs) {
992
+ let child = node.dirs.get(name);
993
+ if (!child) {
994
+ child = { dirs: /* @__PURE__ */ new Map(), files: [] };
995
+ node.dirs.set(name, child);
996
+ }
997
+ node = child;
998
+ }
999
+ return node;
1000
+ }
1001
+ var SAFE_EXTENSION_RE = /^\.[A-Za-z0-9]{1,8}$/;
1002
+ function safeExtension(path) {
1003
+ const ext = extname(basename(path));
1004
+ return SAFE_EXTENSION_RE.test(ext) ? ext : "";
1005
+ }
1006
+ function emitTree(acc) {
1007
+ const nodes = [];
1008
+ let otherOrdinal = 0;
1009
+ for (const leaf of acc.files) {
1010
+ if (leaf.kind === "other") {
1011
+ otherOrdinal++;
1012
+ const name = `${positionalName("file", otherOrdinal)}${leaf.ext}`;
1013
+ nodes.push(
1014
+ leaf.bytes > 0 ? { type: "non-dicom", name, targetBytes: leaf.bytes } : { type: "non-dicom", name, content: "" }
1015
+ );
1016
+ } else {
1017
+ nodes.push(entryOf({ ...leaf.rec, count: 1 }));
1018
+ }
1019
+ }
1020
+ for (const sub of acc.dirs.values()) {
1021
+ nodes.push({ children: emitTree(sub) });
1022
+ }
1023
+ return nodes;
1024
+ }
1025
+ function describeTree(dir, options, keepKeywords) {
1026
+ const root = { dirs: /* @__PURE__ */ new Map(), files: [] };
1027
+ const stats = {
1028
+ dicomFiles: 0,
1029
+ skipped: 0,
1030
+ unknownModality: 0,
1031
+ nonDicomFiles: 0
1032
+ };
1033
+ const studyUids = /* @__PURE__ */ new Set();
1034
+ const dicomLeaves = [];
1035
+ for (const path of [...walkFiles(dir)].sort()) {
1036
+ const scanned = scanFile(path);
1037
+ if (!scanned) {
1038
+ stats.skipped++;
1039
+ continue;
1040
+ }
1041
+ const { size, large, record } = scanned;
1042
+ const studyUid = record?.StudyInstanceUID;
1043
+ const seriesUid = record?.SeriesInstanceUID;
1044
+ const relDirs = relative(dir, path).split(sep).slice(0, -1);
1045
+ if (!record || typeof studyUid !== "string" || typeof seriesUid !== "string") {
1046
+ if (large) {
1047
+ stats.skipped++;
1048
+ continue;
1049
+ }
1050
+ stats.nonDicomFiles++;
1051
+ dirAccumFor(root, relDirs).files.push({
1052
+ kind: "other",
1053
+ bytes: size,
1054
+ ext: safeExtension(path)
1055
+ });
1056
+ continue;
1057
+ }
1058
+ stats.dicomFiles++;
1059
+ studyUids.add(studyUid);
1060
+ const preset = presetModalityOf(record);
1061
+ if (preset === "unsupported") stats.unknownModality++;
1062
+ const modality = preset === "unsupported" ? void 0 : preset;
1063
+ const instanceNumber = typeof record.InstanceNumber === "number" ? { InstanceNumber: record.InstanceNumber } : typeof record.InstanceNumber === "string" ? { InstanceNumber: escapeTagTemplate(record.InstanceNumber) } : void 0;
1064
+ const leaf = {
1065
+ kind: "dicom",
1066
+ rec: {
1067
+ large,
1068
+ bytes: size,
1069
+ modality,
1070
+ tags: mergeTags(
1071
+ instanceNumber,
1072
+ keepKeywords.length ? extractTags(record, keepKeywords) : void 0
1073
+ ),
1074
+ uidOriginals: collectUidOriginals(record)
1075
+ },
1076
+ studyUid,
1077
+ seriesUid
1078
+ };
1079
+ dirAccumFor(root, relDirs).files.push(leaf);
1080
+ dicomLeaves.push(leaf);
1081
+ }
1082
+ if (root.files.length === 0 && root.dirs.size === 0) {
1083
+ throw new Error(`describe: no files found in ${dir}`);
1084
+ }
1085
+ const salt = options.uidSalt ?? deriveSalt([...studyUids]);
1086
+ const uidMap = /* @__PURE__ */ new Map();
1087
+ const sweptKeepTags = /* @__PURE__ */ new Set();
1088
+ for (const { rec, studyUid, seriesUid } of dicomLeaves) {
1089
+ hashRecordUids(rec, salt, uidMap, sweptKeepTags);
1090
+ rec.tags = mergeTags(rec.tags, {
1091
+ StudyInstanceUID: hashUidVia(uidMap, salt, studyUid),
1092
+ SeriesInstanceUID: hashUidVia(uidMap, salt, seriesUid)
1093
+ });
1094
+ }
1095
+ warnSweptKeepTags(sweptKeepTags);
1096
+ const spec = { tree: emitTree(root), uidSalt: salt };
1097
+ validateDatasetSpec(spec);
1098
+ return { spec, stats };
1099
+ }
955
1100
  function describeDirectory(dir, options = {}) {
956
1101
  const keepKeywords = options.keepTags ? resolveKeepTags(options.keepTags) : [];
957
1102
  const preserveUids = options.preserveUids ?? true;
1103
+ if (options.captureTree) {
1104
+ if (!preserveUids) {
1105
+ throw new Error(
1106
+ "describe: captureTree requires preserveUids \u2014 the captured tree carries its grouping as hashed UID tags"
1107
+ );
1108
+ }
1109
+ return describeTree(dir, options, keepKeywords);
1110
+ }
958
1111
  const uidMap = /* @__PURE__ */ new Map();
959
1112
  const useRecords = keepKeywords.length > 0 || preserveUids;
960
1113
  const tolerance = options.sizeTolerance ?? 0;
@@ -964,21 +1117,19 @@ function describeDirectory(dir, options = {}) {
964
1117
  );
965
1118
  }
966
1119
  const studies = /* @__PURE__ */ new Map();
967
- const stats = { dicomFiles: 0, skipped: 0, unknownModality: 0 };
1120
+ const stats = {
1121
+ dicomFiles: 0,
1122
+ skipped: 0,
1123
+ unknownModality: 0,
1124
+ nonDicomFiles: 0
1125
+ };
968
1126
  for (const path of [...walkFiles(dir)].sort()) {
969
- let size;
970
- try {
971
- size = statSync(path).size;
972
- } catch {
1127
+ const scanned = scanFile(path);
1128
+ if (!scanned) {
973
1129
  stats.skipped++;
974
1130
  continue;
975
1131
  }
976
- if (size > MAX_STREAM_BYTES) {
977
- stats.skipped++;
978
- continue;
979
- }
980
- const large = size > MAX_PIXEL_BYTES;
981
- const record = large ? readHeaderOnly(path) : parseFull(path);
1132
+ const { size, large, record } = scanned;
982
1133
  const studyUid = record?.StudyInstanceUID;
983
1134
  const seriesUid = record?.SeriesInstanceUID;
984
1135
  if (!record || typeof studyUid !== "string" || typeof seriesUid !== "string") {
@@ -986,14 +1137,9 @@ function describeDirectory(dir, options = {}) {
986
1137
  continue;
987
1138
  }
988
1139
  stats.dicomFiles++;
989
- let modality;
990
- if (typeof record.Modality === "string") {
991
- if (SUPPORTED_MODALITIES.has(record.Modality)) {
992
- modality = record.Modality;
993
- } else {
994
- stats.unknownModality++;
995
- }
996
- }
1140
+ const preset = presetModalityOf(record);
1141
+ if (preset === "unsupported") stats.unknownModality++;
1142
+ const modality = preset === "unsupported" ? void 0 : preset;
997
1143
  const keptTags = keepKeywords.length ? extractTags(record, keepKeywords) : void 0;
998
1144
  const uidOriginals = preserveUids ? collectUidOriginals(record) : void 0;
999
1145
  const bytes = large ? size : Math.min(bucketBytes(size, tolerance), MAX_PIXEL_BYTES);
@@ -1027,25 +1173,11 @@ function describeDirectory(dir, options = {}) {
1027
1173
  for (const series of study.values()) {
1028
1174
  if (!Array.isArray(series)) continue;
1029
1175
  for (const rec of series) {
1030
- if (rec.uidOriginals) {
1031
- if (rec.tags) {
1032
- for (const keyword of Object.keys(rec.uidOriginals)) {
1033
- if (keyword in rec.tags) sweptKeepTags.add(keyword);
1034
- }
1035
- }
1036
- rec.tags = mergeTags(
1037
- rec.tags,
1038
- hashUidTags(rec.uidOriginals, salt, uidMap)
1039
- );
1040
- }
1176
+ hashRecordUids(rec, salt, uidMap, sweptKeepTags);
1041
1177
  }
1042
1178
  }
1043
1179
  }
1044
- for (const keyword of sweptKeepTags) {
1045
- console.warn(
1046
- `describe: keep-tag "${keyword}" contains UID references \u2014 kept as its hashed UID skeleton, not verbatim (disable with preserveUids: false)`
1047
- );
1048
- }
1180
+ warnSweptKeepTags(sweptKeepTags);
1049
1181
  }
1050
1182
  const withGroupUid = (spec2, seriesUid) => salt === void 0 ? spec2 : {
1051
1183
  ...spec2,
package/dist/esm/index.js CHANGED
@@ -441,6 +441,9 @@ function validateStudy(study, path) {
441
441
  if ("tags" in st) validateTags(st.tags, `${path}.tags`);
442
442
  return st;
443
443
  }
444
+ function positionalName(prefix, ordinal) {
445
+ return `${prefix}-${String(ordinal).padStart(3, "0")}`;
446
+ }
444
447
  var VALID_TREE_ROLES = {
445
448
  study: true,
446
449
  series: true
@@ -521,7 +524,7 @@ function validateTreeChildren(children, basePath, inStudy, inSeries) {
521
524
  let name = "name" in n ? n.name : void 0;
522
525
  if ("children" in n) {
523
526
  dirOrdinal++;
524
- name ?? (name = `dir-${String(dirOrdinal).padStart(3, "0")}`);
527
+ name ?? (name = positionalName("dir", dirOrdinal));
525
528
  }
526
529
  if (name === void 0) return;
527
530
  const prior = claimed.get(name);
@@ -814,6 +817,14 @@ function applyTagOverrides(dataset, tags) {
814
817
  }
815
818
  }
816
819
  }
820
+ function syncMetaWithDataset(meta, dataset) {
821
+ if (typeof dataset.SOPInstanceUID === "string") {
822
+ meta.MediaStorageSOPInstanceUID = dataset.SOPInstanceUID;
823
+ }
824
+ if (typeof dataset.SOPClassUID === "string") {
825
+ meta.MediaStorageSOPClassUID = dataset.SOPClassUID;
826
+ }
827
+ }
817
828
  function buildMeta(uid, modality, transferSyntax = "explicit-vr-little-endian") {
818
829
  return {
819
830
  FileMetaInformationVersion: new Uint8Array([0, 1]).buffer,
@@ -900,6 +911,7 @@ function buildImageBuffer(spec, uid) {
900
911
  }
901
912
  applyTagOverrides(dataset, spec.tags);
902
913
  const meta = buildMeta(effectiveUid, modality, spec.transferSyntax);
914
+ syncMetaWithDataset(meta, dataset);
903
915
  applySizing(dataset, meta, spec);
904
916
  return serializeDict(meta, dataset);
905
917
  }
@@ -1020,6 +1032,7 @@ function writeNative(outPath, params, dims, zeroChunk) {
1020
1032
  const meta = buildMeta(uid, modality);
1021
1033
  const dataset = buildBaseImageDataset(uid, modality);
1022
1034
  applyTagOverrides(dataset, tags);
1035
+ syncMetaWithDataset(meta, dataset);
1023
1036
  dataset.Rows = dims.rows;
1024
1037
  dataset.Columns = dims.columns;
1025
1038
  const minimalPixelBytes = dataset.PixelData.byteLength;
@@ -1053,6 +1066,7 @@ function writeEncapsulated(outPath, params, pixelBytes, fragmentBytes, zeroChunk
1053
1066
  };
1054
1067
  const dataset = buildBaseImageDataset(uid, modality);
1055
1068
  applyTagOverrides(dataset, tags);
1069
+ syncMetaWithDataset(meta, dataset);
1056
1070
  dataset.PixelData = [new Uint8Array([]).buffer, new Uint8Array([0, 0]).buffer];
1057
1071
  dataset._vrMap = { PixelData: "OB" };
1058
1072
  const probe = serializeDict(meta, dataset);
@@ -1409,7 +1423,7 @@ function* planTree(nodes, scope, env) {
1409
1423
  for (const node of nodes) {
1410
1424
  if (isDirNode(node)) {
1411
1425
  dirOrdinal++;
1412
- const dirName = node.name ?? `dir-${String(dirOrdinal).padStart(3, "0")}`;
1426
+ const dirName = node.name ?? positionalName("dir", dirOrdinal);
1413
1427
  const child = {
1414
1428
  ...scope,
1415
1429
  dirs: [...scope.dirs, dirName],
@@ -1640,7 +1654,7 @@ import {
1640
1654
  readSync,
1641
1655
  statSync
1642
1656
  } from "node:fs";
1643
- import { join } from "node:path";
1657
+ import { basename, extname, join, relative, sep } from "node:path";
1644
1658
  var dcmjsAny2 = dcmjs;
1645
1659
  var HEX_TAG_RE2 = /^[0-9a-fA-F]{8}$/;
1646
1660
  var SUPPORTED_MODALITIES = /* @__PURE__ */ new Set(["CT", "PT", "MR", "CR"]);
@@ -1904,6 +1918,37 @@ function mergeTags(a, b) {
1904
1918
  if (!b) return a;
1905
1919
  return { ...a, ...b };
1906
1920
  }
1921
+ function scanFile(path) {
1922
+ let size;
1923
+ try {
1924
+ size = statSync(path).size;
1925
+ } catch {
1926
+ return null;
1927
+ }
1928
+ if (size > MAX_STREAM_BYTES) return null;
1929
+ const large = size > MAX_PIXEL_BYTES;
1930
+ return { size, large, record: large ? readHeaderOnly(path) : parseFull(path) };
1931
+ }
1932
+ function presetModalityOf(record) {
1933
+ if (typeof record.Modality !== "string") return void 0;
1934
+ return SUPPORTED_MODALITIES.has(record.Modality) ? record.Modality : "unsupported";
1935
+ }
1936
+ function hashRecordUids(rec, salt, uidMap, sweptKeepTags) {
1937
+ if (!rec.uidOriginals) return;
1938
+ if (rec.tags) {
1939
+ for (const keyword of Object.keys(rec.uidOriginals)) {
1940
+ if (keyword in rec.tags) sweptKeepTags.add(keyword);
1941
+ }
1942
+ }
1943
+ rec.tags = mergeTags(rec.tags, hashUidTags(rec.uidOriginals, salt, uidMap));
1944
+ }
1945
+ function warnSweptKeepTags(sweptKeepTags) {
1946
+ for (const keyword of sweptKeepTags) {
1947
+ console.warn(
1948
+ `describe: keep-tag "${keyword}" contains UID references \u2014 kept as its hashed UID skeleton, not verbatim (disable with preserveUids: false)`
1949
+ );
1950
+ }
1951
+ }
1907
1952
  function sizeKbOf(bytes) {
1908
1953
  return Math.max(1, Math.round(bytes / 1024));
1909
1954
  }
@@ -2006,9 +2051,128 @@ function buildSeriesSpec(records) {
2006
2051
  function seriesSpecFromAccum(accum) {
2007
2052
  return Array.isArray(accum) ? buildSeriesSpec(accum) : { entries: [...accum.values()].map(entryOf) };
2008
2053
  }
2054
+ function dirAccumFor(root, dirs) {
2055
+ let node = root;
2056
+ for (const name of dirs) {
2057
+ let child = node.dirs.get(name);
2058
+ if (!child) {
2059
+ child = { dirs: /* @__PURE__ */ new Map(), files: [] };
2060
+ node.dirs.set(name, child);
2061
+ }
2062
+ node = child;
2063
+ }
2064
+ return node;
2065
+ }
2066
+ var SAFE_EXTENSION_RE = /^\.[A-Za-z0-9]{1,8}$/;
2067
+ function safeExtension(path) {
2068
+ const ext = extname(basename(path));
2069
+ return SAFE_EXTENSION_RE.test(ext) ? ext : "";
2070
+ }
2071
+ function emitTree(acc) {
2072
+ const nodes = [];
2073
+ let otherOrdinal = 0;
2074
+ for (const leaf of acc.files) {
2075
+ if (leaf.kind === "other") {
2076
+ otherOrdinal++;
2077
+ const name = `${positionalName("file", otherOrdinal)}${leaf.ext}`;
2078
+ nodes.push(
2079
+ leaf.bytes > 0 ? { type: "non-dicom", name, targetBytes: leaf.bytes } : { type: "non-dicom", name, content: "" }
2080
+ );
2081
+ } else {
2082
+ nodes.push(entryOf({ ...leaf.rec, count: 1 }));
2083
+ }
2084
+ }
2085
+ for (const sub of acc.dirs.values()) {
2086
+ nodes.push({ children: emitTree(sub) });
2087
+ }
2088
+ return nodes;
2089
+ }
2090
+ function describeTree(dir, options, keepKeywords) {
2091
+ const root = { dirs: /* @__PURE__ */ new Map(), files: [] };
2092
+ const stats = {
2093
+ dicomFiles: 0,
2094
+ skipped: 0,
2095
+ unknownModality: 0,
2096
+ nonDicomFiles: 0
2097
+ };
2098
+ const studyUids = /* @__PURE__ */ new Set();
2099
+ const dicomLeaves = [];
2100
+ for (const path of [...walkFiles(dir)].sort()) {
2101
+ const scanned = scanFile(path);
2102
+ if (!scanned) {
2103
+ stats.skipped++;
2104
+ continue;
2105
+ }
2106
+ const { size, large, record } = scanned;
2107
+ const studyUid = record?.StudyInstanceUID;
2108
+ const seriesUid = record?.SeriesInstanceUID;
2109
+ const relDirs = relative(dir, path).split(sep).slice(0, -1);
2110
+ if (!record || typeof studyUid !== "string" || typeof seriesUid !== "string") {
2111
+ if (large) {
2112
+ stats.skipped++;
2113
+ continue;
2114
+ }
2115
+ stats.nonDicomFiles++;
2116
+ dirAccumFor(root, relDirs).files.push({
2117
+ kind: "other",
2118
+ bytes: size,
2119
+ ext: safeExtension(path)
2120
+ });
2121
+ continue;
2122
+ }
2123
+ stats.dicomFiles++;
2124
+ studyUids.add(studyUid);
2125
+ const preset = presetModalityOf(record);
2126
+ if (preset === "unsupported") stats.unknownModality++;
2127
+ const modality = preset === "unsupported" ? void 0 : preset;
2128
+ const instanceNumber = typeof record.InstanceNumber === "number" ? { InstanceNumber: record.InstanceNumber } : typeof record.InstanceNumber === "string" ? { InstanceNumber: escapeTagTemplate(record.InstanceNumber) } : void 0;
2129
+ const leaf = {
2130
+ kind: "dicom",
2131
+ rec: {
2132
+ large,
2133
+ bytes: size,
2134
+ modality,
2135
+ tags: mergeTags(
2136
+ instanceNumber,
2137
+ keepKeywords.length ? extractTags(record, keepKeywords) : void 0
2138
+ ),
2139
+ uidOriginals: collectUidOriginals(record)
2140
+ },
2141
+ studyUid,
2142
+ seriesUid
2143
+ };
2144
+ dirAccumFor(root, relDirs).files.push(leaf);
2145
+ dicomLeaves.push(leaf);
2146
+ }
2147
+ if (root.files.length === 0 && root.dirs.size === 0) {
2148
+ throw new Error(`describe: no files found in ${dir}`);
2149
+ }
2150
+ const salt = options.uidSalt ?? deriveSalt([...studyUids]);
2151
+ const uidMap = /* @__PURE__ */ new Map();
2152
+ const sweptKeepTags = /* @__PURE__ */ new Set();
2153
+ for (const { rec, studyUid, seriesUid } of dicomLeaves) {
2154
+ hashRecordUids(rec, salt, uidMap, sweptKeepTags);
2155
+ rec.tags = mergeTags(rec.tags, {
2156
+ StudyInstanceUID: hashUidVia(uidMap, salt, studyUid),
2157
+ SeriesInstanceUID: hashUidVia(uidMap, salt, seriesUid)
2158
+ });
2159
+ }
2160
+ warnSweptKeepTags(sweptKeepTags);
2161
+ const spec = { tree: emitTree(root), uidSalt: salt };
2162
+ validateDatasetSpec(spec);
2163
+ return { spec, stats };
2164
+ }
2009
2165
  function describeDirectory(dir, options = {}) {
2010
2166
  const keepKeywords = options.keepTags ? resolveKeepTags(options.keepTags) : [];
2011
2167
  const preserveUids = options.preserveUids ?? true;
2168
+ if (options.captureTree) {
2169
+ if (!preserveUids) {
2170
+ throw new Error(
2171
+ "describe: captureTree requires preserveUids \u2014 the captured tree carries its grouping as hashed UID tags"
2172
+ );
2173
+ }
2174
+ return describeTree(dir, options, keepKeywords);
2175
+ }
2012
2176
  const uidMap = /* @__PURE__ */ new Map();
2013
2177
  const useRecords = keepKeywords.length > 0 || preserveUids;
2014
2178
  const tolerance = options.sizeTolerance ?? 0;
@@ -2018,21 +2182,19 @@ function describeDirectory(dir, options = {}) {
2018
2182
  );
2019
2183
  }
2020
2184
  const studies = /* @__PURE__ */ new Map();
2021
- const stats = { dicomFiles: 0, skipped: 0, unknownModality: 0 };
2185
+ const stats = {
2186
+ dicomFiles: 0,
2187
+ skipped: 0,
2188
+ unknownModality: 0,
2189
+ nonDicomFiles: 0
2190
+ };
2022
2191
  for (const path of [...walkFiles(dir)].sort()) {
2023
- let size;
2024
- try {
2025
- size = statSync(path).size;
2026
- } catch {
2027
- stats.skipped++;
2028
- continue;
2029
- }
2030
- if (size > MAX_STREAM_BYTES) {
2192
+ const scanned = scanFile(path);
2193
+ if (!scanned) {
2031
2194
  stats.skipped++;
2032
2195
  continue;
2033
2196
  }
2034
- const large = size > MAX_PIXEL_BYTES;
2035
- const record = large ? readHeaderOnly(path) : parseFull(path);
2197
+ const { size, large, record } = scanned;
2036
2198
  const studyUid = record?.StudyInstanceUID;
2037
2199
  const seriesUid = record?.SeriesInstanceUID;
2038
2200
  if (!record || typeof studyUid !== "string" || typeof seriesUid !== "string") {
@@ -2040,14 +2202,9 @@ function describeDirectory(dir, options = {}) {
2040
2202
  continue;
2041
2203
  }
2042
2204
  stats.dicomFiles++;
2043
- let modality;
2044
- if (typeof record.Modality === "string") {
2045
- if (SUPPORTED_MODALITIES.has(record.Modality)) {
2046
- modality = record.Modality;
2047
- } else {
2048
- stats.unknownModality++;
2049
- }
2050
- }
2205
+ const preset = presetModalityOf(record);
2206
+ if (preset === "unsupported") stats.unknownModality++;
2207
+ const modality = preset === "unsupported" ? void 0 : preset;
2051
2208
  const keptTags = keepKeywords.length ? extractTags(record, keepKeywords) : void 0;
2052
2209
  const uidOriginals = preserveUids ? collectUidOriginals(record) : void 0;
2053
2210
  const bytes = large ? size : Math.min(bucketBytes(size, tolerance), MAX_PIXEL_BYTES);
@@ -2081,25 +2238,11 @@ function describeDirectory(dir, options = {}) {
2081
2238
  for (const series of study.values()) {
2082
2239
  if (!Array.isArray(series)) continue;
2083
2240
  for (const rec of series) {
2084
- if (rec.uidOriginals) {
2085
- if (rec.tags) {
2086
- for (const keyword of Object.keys(rec.uidOriginals)) {
2087
- if (keyword in rec.tags) sweptKeepTags.add(keyword);
2088
- }
2089
- }
2090
- rec.tags = mergeTags(
2091
- rec.tags,
2092
- hashUidTags(rec.uidOriginals, salt, uidMap)
2093
- );
2094
- }
2241
+ hashRecordUids(rec, salt, uidMap, sweptKeepTags);
2095
2242
  }
2096
2243
  }
2097
2244
  }
2098
- for (const keyword of sweptKeepTags) {
2099
- console.warn(
2100
- `describe: keep-tag "${keyword}" contains UID references \u2014 kept as its hashed UID skeleton, not verbatim (disable with preserveUids: false)`
2101
- );
2102
- }
2245
+ warnSweptKeepTags(sweptKeepTags);
2103
2246
  }
2104
2247
  const withGroupUid = (spec2, seriesUid) => salt === void 0 ? spec2 : {
2105
2248
  ...spec2,
@@ -407,6 +407,9 @@ function validateStudy(study, path) {
407
407
  if ("tags" in st) validateTags(st.tags, `${path}.tags`);
408
408
  return st;
409
409
  }
410
+ function positionalName(prefix, ordinal) {
411
+ return `${prefix}-${String(ordinal).padStart(3, "0")}`;
412
+ }
410
413
  var VALID_TREE_ROLES = {
411
414
  study: true,
412
415
  series: true
@@ -487,7 +490,7 @@ function validateTreeChildren(children, basePath, inStudy, inSeries) {
487
490
  let name = "name" in n ? n.name : void 0;
488
491
  if ("children" in n) {
489
492
  dirOrdinal++;
490
- name ?? (name = `dir-${String(dirOrdinal).padStart(3, "0")}`);
493
+ name ?? (name = positionalName("dir", dirOrdinal));
491
494
  }
492
495
  if (name === void 0) return;
493
496
  const prior = claimed.get(name);
@@ -679,6 +682,7 @@ function validateParametricSpec(raw) {
679
682
  export {
680
683
  HEX_TAG_RE,
681
684
  isImageType,
685
+ positionalName,
682
686
  validateDatasetSpec,
683
687
  validateParametricSpec
684
688
  };
@@ -108,6 +108,14 @@ function applyTagOverrides(dataset, tags) {
108
108
  }
109
109
  }
110
110
  }
111
+ function syncMetaWithDataset(meta, dataset) {
112
+ if (typeof dataset.SOPInstanceUID === "string") {
113
+ meta.MediaStorageSOPInstanceUID = dataset.SOPInstanceUID;
114
+ }
115
+ if (typeof dataset.SOPClassUID === "string") {
116
+ meta.MediaStorageSOPClassUID = dataset.SOPClassUID;
117
+ }
118
+ }
111
119
  function buildMeta(uid, modality, transferSyntax = "explicit-vr-little-endian") {
112
120
  return {
113
121
  FileMetaInformationVersion: new Uint8Array([0, 1]).buffer,
@@ -194,6 +202,7 @@ function buildImageBuffer(spec, uid) {
194
202
  }
195
203
  applyTagOverrides(dataset, spec.tags);
196
204
  const meta = buildMeta(effectiveUid, modality, spec.transferSyntax);
205
+ syncMetaWithDataset(meta, dataset);
197
206
  applySizing(dataset, meta, spec);
198
207
  return serializeDict(meta, dataset);
199
208
  }
@@ -234,5 +243,6 @@ export {
234
243
  buildBufferForSpec,
235
244
  buildMeta,
236
245
  serializeDict,
237
- sizedNonDicomBytes
246
+ sizedNonDicomBytes,
247
+ syncMetaWithDataset
238
248
  };
@@ -94,6 +94,14 @@ function applyTagOverrides(dataset, tags) {
94
94
  }
95
95
  }
96
96
  }
97
+ function syncMetaWithDataset(meta, dataset) {
98
+ if (typeof dataset.SOPInstanceUID === "string") {
99
+ meta.MediaStorageSOPInstanceUID = dataset.SOPInstanceUID;
100
+ }
101
+ if (typeof dataset.SOPClassUID === "string") {
102
+ meta.MediaStorageSOPClassUID = dataset.SOPClassUID;
103
+ }
104
+ }
97
105
  function buildMeta(uid, modality, transferSyntax = "explicit-vr-little-endian") {
98
106
  return {
99
107
  FileMetaInformationVersion: new Uint8Array([0, 1]).buffer,
@@ -204,6 +212,7 @@ function writeNative(outPath, params, dims, zeroChunk) {
204
212
  const meta = buildMeta(uid, modality);
205
213
  const dataset = buildBaseImageDataset(uid, modality);
206
214
  applyTagOverrides(dataset, tags);
215
+ syncMetaWithDataset(meta, dataset);
207
216
  dataset.Rows = dims.rows;
208
217
  dataset.Columns = dims.columns;
209
218
  const minimalPixelBytes = dataset.PixelData.byteLength;
@@ -237,6 +246,7 @@ function writeEncapsulated(outPath, params, pixelBytes, fragmentBytes, zeroChunk
237
246
  };
238
247
  const dataset = buildBaseImageDataset(uid, modality);
239
248
  applyTagOverrides(dataset, tags);
249
+ syncMetaWithDataset(meta, dataset);
240
250
  dataset.PixelData = [new Uint8Array([]).buffer, new Uint8Array([0, 0]).buffer];
241
251
  dataset._vrMap = { PixelData: "OB" };
242
252
  const probe = serializeDict(meta, dataset);
@@ -4,6 +4,7 @@ export type DescribeOptions = {
4
4
  sizeTolerance?: number;
5
5
  preserveUids?: boolean;
6
6
  uidSalt?: string;
7
+ captureTree?: boolean;
7
8
  };
8
9
  export type DescribeResult = {
9
10
  spec: DatasetSpec;
@@ -11,6 +12,7 @@ export type DescribeResult = {
11
12
  dicomFiles: number;
12
13
  skipped: number;
13
14
  unknownModality: number;
15
+ nonDicomFiles: number;
14
16
  };
15
17
  };
16
18
  export declare function readHeaderOnly(path: string): Record<string, unknown> | null;
@@ -1,5 +1,6 @@
1
1
  import type { DatasetSpec, FileSpec, ParametricSpec } from './types.js';
2
2
  export declare function isImageType(type: FileSpec['type']): boolean;
3
3
  export declare const HEX_TAG_RE: RegExp;
4
+ export declare function positionalName(prefix: string, ordinal: number): string;
4
5
  export declare function validateDatasetSpec(raw: unknown): DatasetSpec;
5
6
  export declare function validateParametricSpec(raw: unknown): ParametricSpec;
@@ -1,6 +1,7 @@
1
1
  import type { DicomTagOverrides, FileSpec, Modality, NonDicomSpec, TransferSyntax } from '../schema/types.js';
2
2
  import type { UidSet } from './uid.js';
3
3
  export declare function applyTagOverrides(dataset: Record<string, unknown>, tags: DicomTagOverrides | undefined): void;
4
+ export declare function syncMetaWithDataset(meta: Record<string, unknown>, dataset: Record<string, unknown>): void;
4
5
  export declare function buildMeta(uid: UidSet, modality: Modality, transferSyntax?: TransferSyntax): Record<string, unknown>;
5
6
  export declare function buildBaseImageDataset(uid: UidSet, modality: Modality): Record<string, unknown>;
6
7
  export declare function serializeDict(meta: Record<string, unknown>, dataset: Record<string, unknown>): Buffer;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dicom-synth",
3
- "version": "1.15.0",
3
+ "version": "1.16.0",
4
4
  "description": "Toolkit for synthetic DICOM fixtures and public fixture fetch/cache.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",