dicom-synth 1.4.0 → 1.6.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/dist/esm/index.js CHANGED
@@ -85,6 +85,25 @@ function validatePositiveInt(value, path) {
85
85
  );
86
86
  }
87
87
  }
88
+ function validateSeed(value, path) {
89
+ if (typeof value !== "number" || !Number.isFinite(value)) {
90
+ throw new Error(
91
+ `${path}: must be a finite number; got ${JSON.stringify(value)}`
92
+ );
93
+ }
94
+ }
95
+ function validateSizeKbLimit(kb, path) {
96
+ if (kb * 1024 > MAX_PIXEL_BYTES) {
97
+ throw new Error(`${path}: exceeds the 512 MB limit`);
98
+ }
99
+ }
100
+ function validateEnum(value, valid, path) {
101
+ if (typeof value !== "string" || !Object.hasOwn(valid, value)) {
102
+ throw new Error(
103
+ `${path}: must be one of ${Object.keys(valid).join(", ")}; got ${JSON.stringify(value)}`
104
+ );
105
+ }
106
+ }
88
107
  function validateTagKey(key, path) {
89
108
  if (!HEX_TAG_RE.test(key) && !KEYWORD_RE.test(key)) {
90
109
  throw new Error(
@@ -97,11 +116,7 @@ function validateEntry(entry, path) {
97
116
  throw new Error(`${path}: must be an object`);
98
117
  }
99
118
  const e = entry;
100
- if (typeof e.type !== "string" || !Object.hasOwn(TYPE_CAPABILITIES, e.type)) {
101
- throw new Error(
102
- `${path}.type: must be one of ${Object.keys(TYPE_CAPABILITIES).join(", ")}; got ${JSON.stringify(e.type)}`
103
- );
104
- }
119
+ validateEnum(e.type, TYPE_CAPABILITIES, `${path}.type`);
105
120
  const type = e.type;
106
121
  const caps = TYPE_CAPABILITIES[type];
107
122
  if ("count" in e) validatePositiveInt(e.count, `${path}.count`);
@@ -119,18 +134,14 @@ function validateEntry(entry, path) {
119
134
  throw new Error(`${path}.${field}: not supported for type "${type}"`);
120
135
  }
121
136
  if ("transferSyntax" in e) {
122
- if (!Object.hasOwn(VALID_TRANSFER_SYNTAXES, e.transferSyntax)) {
123
- throw new Error(
124
- `${path}.transferSyntax: must be one of ${Object.keys(VALID_TRANSFER_SYNTAXES).join(", ")}`
125
- );
126
- }
137
+ validateEnum(
138
+ e.transferSyntax,
139
+ VALID_TRANSFER_SYNTAXES,
140
+ `${path}.transferSyntax`
141
+ );
127
142
  }
128
143
  if ("modality" in e) {
129
- if (typeof e.modality !== "string" || !Object.hasOwn(VALID_MODALITIES, e.modality)) {
130
- throw new Error(
131
- `${path}.modality: must be one of ${Object.keys(VALID_MODALITIES).join(", ")}; got ${JSON.stringify(e.modality)}`
132
- );
133
- }
144
+ validateEnum(e.modality, VALID_MODALITIES, `${path}.modality`);
134
145
  }
135
146
  if ("tags" in e) validateTags(e.tags, `${path}.tags`);
136
147
  if ("violations" in e) {
@@ -138,11 +149,7 @@ function validateEntry(entry, path) {
138
149
  throw new Error(`${path}.violations: must be an array`);
139
150
  }
140
151
  for (const [i, v] of e.violations.entries()) {
141
- if (typeof v !== "string" || !Object.hasOwn(VALID_VIOLATIONS, v)) {
142
- throw new Error(
143
- `${path}.violations[${i}]: must be one of ${Object.keys(VALID_VIOLATIONS).join(", ")}; got ${JSON.stringify(v)}`
144
- );
145
- }
152
+ validateEnum(v, VALID_VIOLATIONS, `${path}.violations[${i}]`);
146
153
  }
147
154
  }
148
155
  const hasDims = "rows" in e || "columns" in e || "frames" in e;
@@ -153,9 +160,7 @@ function validateEntry(entry, path) {
153
160
  );
154
161
  }
155
162
  validatePositiveInt(e.targetSizeKb, `${path}.targetSizeKb`);
156
- if (e.targetSizeKb * 1024 > MAX_PIXEL_BYTES) {
157
- throw new Error(`${path}.targetSizeKb: exceeds the 512 MB limit`);
158
- }
163
+ validateSizeKbLimit(e.targetSizeKb, `${path}.targetSizeKb`);
159
164
  } else if (hasDims) {
160
165
  if (!("rows" in e) || !("columns" in e)) {
161
166
  throw new Error(`${path}: rows and columns must be provided together`);
@@ -240,23 +245,104 @@ function validateDatasetSpec(raw) {
240
245
  const studies = rawStudies.map(
241
246
  (study, i) => validateStudy(study, `studies[${i}]`)
242
247
  );
243
- if ("seed" in r) {
244
- if (typeof r.seed !== "number" || !Number.isFinite(r.seed)) {
248
+ if ("seed" in r) validateSeed(r.seed, "DatasetSpec.seed");
249
+ if ("layout" in r) {
250
+ validateEnum(r.layout, VALID_LAYOUTS, "DatasetSpec.layout");
251
+ }
252
+ return {
253
+ ...entries.length > 0 ? { entries } : {},
254
+ ...studies.length > 0 ? { studies } : {},
255
+ ...r.seed !== void 0 ? { seed: r.seed } : {},
256
+ ...r.layout !== void 0 ? { layout: r.layout } : {}
257
+ };
258
+ }
259
+ function validateRange(value, path) {
260
+ if (typeof value === "number") {
261
+ validatePositiveInt(value, path);
262
+ return value;
263
+ }
264
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
265
+ throw new Error(
266
+ `${path}: must be a positive integer or { min, max }; got ${JSON.stringify(value)}`
267
+ );
268
+ }
269
+ const r = value;
270
+ validatePositiveInt(r.min, `${path}.min`);
271
+ validatePositiveInt(r.max, `${path}.max`);
272
+ if (r.min > r.max) {
273
+ throw new Error(`${path}: min (${r.min}) must be \u2264 max (${r.max})`);
274
+ }
275
+ return value;
276
+ }
277
+ function rangeMax(range) {
278
+ return typeof range === "number" ? range : range.max;
279
+ }
280
+ function validateParametricStudies(studies, path) {
281
+ if (typeof studies !== "object" || studies === null || Array.isArray(studies)) {
282
+ throw new Error(`${path}: must be an object`);
283
+ }
284
+ const s = studies;
285
+ for (const field of ["count", "seriesPerStudy", "filesPerSeries"]) {
286
+ if (!(field in s)) throw new Error(`${path}.${field}: is required`);
287
+ validateRange(s[field], `${path}.${field}`);
288
+ }
289
+ if ("fileSizeKb" in s) {
290
+ validateRange(s.fileSizeKb, `${path}.fileSizeKb`);
291
+ validateSizeKbLimit(rangeMax(s.fileSizeKb), `${path}.fileSizeKb`);
292
+ }
293
+ if ("modalities" in s) {
294
+ if (!Array.isArray(s.modalities) || s.modalities.length === 0) {
295
+ throw new Error(`${path}.modalities: must be a non-empty array`);
296
+ }
297
+ for (const [i, m] of s.modalities.entries()) {
298
+ validateEnum(m, VALID_MODALITIES, `${path}.modalities[${i}]`);
299
+ }
300
+ }
301
+ if ("tags" in s) validateTags(s.tags, `${path}.tags`);
302
+ return s;
303
+ }
304
+ function validateEdgeCase(edgeCase, path) {
305
+ const e = validateEntry(edgeCase, path);
306
+ if ("frequency" in e) {
307
+ if ("count" in e) {
245
308
  throw new Error(
246
- `DatasetSpec.seed: must be a finite number; got ${JSON.stringify(r.seed)}`
309
+ `${path}: frequency cannot be combined with count \u2014 frequency draws the count at resolution time`
247
310
  );
248
311
  }
249
- }
250
- if ("layout" in r) {
251
- if (typeof r.layout !== "string" || !Object.hasOwn(VALID_LAYOUTS, r.layout)) {
312
+ const f = e.frequency;
313
+ if (typeof f !== "number" || !(f >= 0 && f <= 1)) {
252
314
  throw new Error(
253
- `DatasetSpec.layout: must be one of ${Object.keys(VALID_LAYOUTS).join(", ")}; got ${JSON.stringify(r.layout)}`
315
+ `${path}.frequency: must be a number between 0 and 1; got ${JSON.stringify(f)}`
254
316
  );
255
317
  }
256
318
  }
319
+ return e;
320
+ }
321
+ function validateParametricSpec(raw) {
322
+ if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
323
+ throw new Error("ParametricSpec: must be a JSON object");
324
+ }
325
+ const r = raw;
326
+ if (!("studies" in r)) {
327
+ throw new Error("ParametricSpec.studies: is required");
328
+ }
329
+ const studies = validateParametricStudies(r.studies, "studies");
330
+ const edgeCases = [];
331
+ if ("edgeCases" in r) {
332
+ if (!Array.isArray(r.edgeCases)) {
333
+ throw new Error("ParametricSpec.edgeCases: must be an array");
334
+ }
335
+ for (const [i, e] of r.edgeCases.entries()) {
336
+ edgeCases.push(validateEdgeCase(e, `edgeCases[${i}]`));
337
+ }
338
+ }
339
+ if ("seed" in r) validateSeed(r.seed, "ParametricSpec.seed");
340
+ if ("layout" in r) {
341
+ validateEnum(r.layout, VALID_LAYOUTS, "ParametricSpec.layout");
342
+ }
257
343
  return {
258
- ...entries.length > 0 ? { entries } : {},
259
- ...studies.length > 0 ? { studies } : {},
344
+ studies,
345
+ ...edgeCases.length > 0 ? { edgeCases } : {},
260
346
  ...r.seed !== void 0 ? { seed: r.seed } : {},
261
347
  ...r.layout !== void 0 ? { layout: r.layout } : {}
262
348
  };
@@ -457,6 +543,7 @@ function seededUid(next, role) {
457
543
  return formatUid(next(), next(), role);
458
544
  }
459
545
  var GROUP_STREAM_OFFSET = 2654435769;
546
+ var PARAMETRIC_STREAM_OFFSET = 2246822507;
460
547
  function seededStream(seed, offset, a, b) {
461
548
  return lcg(seed * 999983 + offset + a * 1000003 + b * 7919 >>> 0);
462
549
  }
@@ -721,11 +808,153 @@ async function writeCollectionFromSpec(spec, outDir) {
721
808
  return manifest;
722
809
  }
723
810
 
811
+ // src/describe/describe.ts
812
+ import { readdirSync, readFileSync } from "node:fs";
813
+ import { join } from "node:path";
814
+ var dcmjsAny2 = dcmjs;
815
+ var HEX_TAG_RE2 = /^[0-9a-fA-F]{8}$/;
816
+ var SUPPORTED_MODALITIES = /* @__PURE__ */ new Set(["CT", "PT", "MR", "CR"]);
817
+ function buildHexToKeyword() {
818
+ const map = /* @__PURE__ */ new Map();
819
+ const nm = dcmjsAny2.data.DicomMetaDictionary.nameMap;
820
+ if (!nm) return map;
821
+ for (const [keyword, info] of Object.entries(nm)) {
822
+ if (info?.tag) {
823
+ const hex = info.tag.replace(/[(,)]/g, "").toUpperCase();
824
+ if (hex.length === 8) map.set(hex, keyword);
825
+ }
826
+ }
827
+ return map;
828
+ }
829
+ function resolveKeepTags(keepTags) {
830
+ const nameMap = dcmjsAny2.data.DicomMetaDictionary.nameMap;
831
+ let hexToKeyword;
832
+ return keepTags.map((tag) => {
833
+ if (HEX_TAG_RE2.test(tag)) {
834
+ hexToKeyword ?? (hexToKeyword = buildHexToKeyword());
835
+ const keyword = hexToKeyword.get(tag.toUpperCase());
836
+ if (!keyword) {
837
+ throw new Error(`describe: unknown hex tag "${tag}"`);
838
+ }
839
+ return keyword;
840
+ }
841
+ if (nameMap && !Object.hasOwn(nameMap, tag)) {
842
+ throw new Error(`describe: unknown tag keyword "${tag}"`);
843
+ }
844
+ return tag;
845
+ });
846
+ }
847
+ function* walkFiles(dir) {
848
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
849
+ const path = join(dir, entry.name);
850
+ if (entry.isDirectory()) {
851
+ yield* walkFiles(path);
852
+ } else if (entry.isFile()) {
853
+ yield path;
854
+ }
855
+ }
856
+ }
857
+ function parseFile(path) {
858
+ try {
859
+ const buf = readFileSync(path);
860
+ const ab = buf.buffer.slice(
861
+ buf.byteOffset,
862
+ buf.byteOffset + buf.byteLength
863
+ );
864
+ const parsed = dcmjsAny2.data.DicomMessage.readFile(ab);
865
+ const record = dcmjsAny2.data.DicomMetaDictionary.naturalizeDataset(
866
+ parsed.dict
867
+ );
868
+ return { record, size: buf.byteLength };
869
+ } catch {
870
+ return null;
871
+ }
872
+ }
873
+ function extractTags(record, keepKeywords) {
874
+ const tags = {};
875
+ let found = false;
876
+ for (const keyword of keepKeywords) {
877
+ const value = record[keyword];
878
+ if (value !== void 0) {
879
+ tags[keyword] = value;
880
+ found = true;
881
+ }
882
+ }
883
+ return found ? tags : void 0;
884
+ }
885
+ function describeDirectory(dir, options = {}) {
886
+ const keepKeywords = options.keepTags ? resolveKeepTags(options.keepTags) : [];
887
+ const studies = /* @__PURE__ */ new Map();
888
+ const stats = { dicomFiles: 0, skipped: 0, unknownModality: 0 };
889
+ for (const path of [...walkFiles(dir)].sort()) {
890
+ const parsed = parseFile(path);
891
+ const studyUid = parsed?.record.StudyInstanceUID;
892
+ const seriesUid = parsed?.record.SeriesInstanceUID;
893
+ if (!parsed || typeof studyUid !== "string" || typeof seriesUid !== "string") {
894
+ stats.skipped++;
895
+ continue;
896
+ }
897
+ const { record } = parsed;
898
+ stats.dicomFiles++;
899
+ let modality;
900
+ if (typeof record.Modality === "string") {
901
+ if (SUPPORTED_MODALITIES.has(record.Modality)) {
902
+ modality = record.Modality;
903
+ } else {
904
+ stats.unknownModality++;
905
+ }
906
+ }
907
+ const sizeKb = Math.max(1, Math.round(parsed.size / 1024));
908
+ const tags = keepKeywords.length ? extractTags(record, keepKeywords) : void 0;
909
+ let study = studies.get(studyUid);
910
+ if (!study) {
911
+ study = /* @__PURE__ */ new Map();
912
+ studies.set(studyUid, study);
913
+ }
914
+ let series = study.get(seriesUid);
915
+ if (!series) {
916
+ series = /* @__PURE__ */ new Map();
917
+ study.set(seriesUid, series);
918
+ }
919
+ const collapseKey = JSON.stringify([modality ?? null, sizeKb, tags ?? null]);
920
+ const existing = series.get(collapseKey);
921
+ if (existing) {
922
+ existing.count++;
923
+ } else {
924
+ series.set(collapseKey, {
925
+ modality,
926
+ targetSizeKb: sizeKb,
927
+ tags,
928
+ count: 1
929
+ });
930
+ }
931
+ }
932
+ const studySpecs = [...studies.values()].map((study) => {
933
+ const seriesSpecs = [...study.values()].map((series) => {
934
+ const entries = [...series.values()].map((acc) => ({
935
+ type: "valid-image",
936
+ ...acc.modality !== void 0 ? { modality: acc.modality } : {},
937
+ targetSizeKb: acc.targetSizeKb,
938
+ ...acc.tags !== void 0 ? { tags: acc.tags } : {},
939
+ ...acc.count > 1 ? { count: acc.count } : {}
940
+ }));
941
+ return { entries };
942
+ });
943
+ return { series: seriesSpecs };
944
+ });
945
+ if (studySpecs.length === 0) {
946
+ throw new Error(`describe: no DICOM series found in ${dir}`);
947
+ }
948
+ const spec = { studies: studySpecs, layout: "hierarchical" };
949
+ validateDatasetSpec(spec);
950
+ return { spec, stats };
951
+ }
952
+
724
953
  // src/public-fixtures/catalog.ts
725
- import { readFileSync } from "node:fs";
726
- import { dirname as dirname2, join } from "node:path";
954
+ import { readFileSync as readFileSync2 } from "node:fs";
955
+ import { dirname as dirname2, join as join2 } from "node:path";
727
956
  import { fileURLToPath } from "node:url";
728
- var bundledCatalogPath = join(
957
+ var bundledCatalogPath = join2(
729
958
  dirname2(fileURLToPath(import.meta.url)),
730
959
  "../../data/public-cases.json"
731
960
  );
@@ -736,7 +965,7 @@ function loadDefaultCases() {
736
965
  return loadCasesFromJson(defaultPublicCasesPath());
737
966
  }
738
967
  function loadCasesFromJson(casesJsonPath) {
739
- const j = JSON.parse(readFileSync(casesJsonPath, "utf8"));
968
+ const j = JSON.parse(readFileSync2(casesJsonPath, "utf8"));
740
969
  if (!Array.isArray(j.cases)) {
741
970
  throw new Error(
742
971
  `Invalid catalog in ${casesJsonPath}: expected "cases" array`
@@ -754,12 +983,12 @@ function loadCaseById(casesJsonPath, id) {
754
983
 
755
984
  // src/public-fixtures/fetch.ts
756
985
  import { createHash } from "node:crypto";
757
- import { existsSync, mkdirSync as mkdirSync3, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
986
+ import { existsSync, mkdirSync as mkdirSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "node:fs";
758
987
  import { homedir } from "node:os";
759
- import { join as join2 } from "node:path";
760
- var DEFAULT_CACHE_ROOT = join2(homedir(), ".cache", "dicom-synth-testcases");
988
+ import { join as join3 } from "node:path";
989
+ var DEFAULT_CACHE_ROOT = join3(homedir(), ".cache", "dicom-synth-testcases");
761
990
  function caseCachePath(sha256, root = DEFAULT_CACHE_ROOT) {
762
- return join2(root, sha256, "file.dcm");
991
+ return join3(root, sha256, "file.dcm");
763
992
  }
764
993
  function verifySha256(buffer, expected) {
765
994
  const h = createHash("sha256").update(buffer).digest("hex");
@@ -777,11 +1006,11 @@ async function fetchPublicCaseToCache(record, cacheRoot = DEFAULT_CACHE_ROOT) {
777
1006
  }
778
1007
  const dest = caseCachePath(record.sha256, cacheRoot);
779
1008
  if (existsSync(dest)) {
780
- const buf2 = readFileSync2(dest);
1009
+ const buf2 = readFileSync3(dest);
781
1010
  verifySha256(buf2, record.sha256);
782
1011
  return dest;
783
1012
  }
784
- mkdirSync3(join2(cacheRoot, record.sha256), { recursive: true });
1013
+ mkdirSync3(join3(cacheRoot, record.sha256), { recursive: true });
785
1014
  const res = await fetch(record.source.url);
786
1015
  if (!res.ok) {
787
1016
  throw new Error(
@@ -793,16 +1022,93 @@ async function fetchPublicCaseToCache(record, cacheRoot = DEFAULT_CACHE_ROOT) {
793
1022
  writeFileSync2(dest, buf);
794
1023
  return dest;
795
1024
  }
1025
+
1026
+ // src/schema/parametric.ts
1027
+ import { randomBytes as randomBytes2 } from "node:crypto";
1028
+ function sample(next, range) {
1029
+ if (typeof range === "number") return range;
1030
+ return range.min + next() % (range.max - range.min + 1);
1031
+ }
1032
+ function pick(next, items) {
1033
+ return items[next() % items.length];
1034
+ }
1035
+ function fraction(next) {
1036
+ return next() / 4294967296;
1037
+ }
1038
+ function resolveParametricSpec(spec) {
1039
+ const validated = validateParametricSpec(spec);
1040
+ const seed = validated.seed ?? randomBytes2(4).readUInt32BE(0);
1041
+ const next = seededStream(seed, PARAMETRIC_STREAM_OFFSET, 0, 0);
1042
+ const params = validated.studies;
1043
+ const studies = [];
1044
+ let totalFiles = 0;
1045
+ const studyCount = sample(next, params.count);
1046
+ for (let st = 0; st < studyCount; st++) {
1047
+ const seriesCount = sample(next, params.seriesPerStudy);
1048
+ const series = [];
1049
+ for (let se = 0; se < seriesCount; se++) {
1050
+ const fileCount = sample(next, params.filesPerSeries);
1051
+ totalFiles += fileCount;
1052
+ const modality = params.modalities ? pick(next, params.modalities) : void 0;
1053
+ const base = {
1054
+ type: "valid-image",
1055
+ ...modality !== void 0 ? { modality } : {}
1056
+ };
1057
+ const entries2 = [];
1058
+ if (params.fileSizeKb !== void 0 && typeof params.fileSizeKb !== "number") {
1059
+ for (let f = 0; f < fileCount; f++) {
1060
+ entries2.push({
1061
+ ...base,
1062
+ targetSizeKb: sample(next, params.fileSizeKb)
1063
+ });
1064
+ }
1065
+ } else {
1066
+ entries2.push({
1067
+ ...base,
1068
+ ...params.fileSizeKb !== void 0 ? { targetSizeKb: params.fileSizeKb } : {},
1069
+ ...fileCount > 1 ? { count: fileCount } : {}
1070
+ });
1071
+ }
1072
+ series.push({ entries: entries2 });
1073
+ }
1074
+ studies.push({
1075
+ series,
1076
+ ...params.tags !== void 0 ? { tags: params.tags } : {}
1077
+ });
1078
+ }
1079
+ const entries = [];
1080
+ for (const edgeCase of validated.edgeCases ?? []) {
1081
+ const { frequency, ...entry } = edgeCase;
1082
+ if (frequency === void 0) {
1083
+ entries.push(entry);
1084
+ continue;
1085
+ }
1086
+ let drawn = 0;
1087
+ for (let i = 0; i < totalFiles; i++) {
1088
+ if (fraction(next) < frequency) drawn++;
1089
+ }
1090
+ if (drawn > 0) entries.push({ ...entry, count: drawn });
1091
+ }
1092
+ return {
1093
+ ...entries.length > 0 ? { entries } : {},
1094
+ studies,
1095
+ seed,
1096
+ ...validated.layout !== void 0 ? { layout: validated.layout } : {}
1097
+ };
1098
+ }
796
1099
  export {
797
1100
  caseCachePath,
798
1101
  defaultPublicCasesPath,
1102
+ describeDirectory,
799
1103
  fetchPublicCaseToCache,
800
1104
  generateCollectionFromSpec,
801
1105
  generateFile,
802
1106
  loadCaseById,
803
1107
  loadCasesFromJson,
804
1108
  loadDefaultCases,
1109
+ resolveParametricSpec,
805
1110
  validateDatasetSpec,
1111
+ validateParametricSpec,
806
1112
  verifySha256,
807
1113
  writeCollectionFromSpec
808
1114
  };