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/README.md +104 -4
- package/bin/dicom-synth-describe.mjs +73 -0
- package/bin/dicom-synth-generate.mjs +100 -40
- package/dist/esm/describe/describe.js +378 -0
- package/dist/esm/index.js +348 -42
- package/dist/esm/schema/parametric.js +327 -0
- package/dist/esm/schema/validate.js +120 -33
- package/dist/esm/syntheticFixtures/uid.js +4 -1
- package/dist/types/describe/describe.d.ts +13 -0
- package/dist/types/index.d.ts +4 -2
- package/dist/types/schema/parametric.d.ts +2 -0
- package/dist/types/schema/types.d.ts +21 -0
- package/dist/types/schema/validate.d.ts +2 -1
- package/dist/types/syntheticFixtures/uid.d.ts +2 -0
- package/package.json +4 -3
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
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
137
|
+
validateEnum(
|
|
138
|
+
e.transferSyntax,
|
|
139
|
+
VALID_TRANSFER_SYNTAXES,
|
|
140
|
+
`${path}.transferSyntax`
|
|
141
|
+
);
|
|
127
142
|
}
|
|
128
143
|
if ("modality" in e) {
|
|
129
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
309
|
+
`${path}: frequency cannot be combined with count \u2014 frequency draws the count at resolution time`
|
|
247
310
|
);
|
|
248
311
|
}
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
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
|
-
|
|
259
|
-
...
|
|
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 =
|
|
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(
|
|
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
|
|
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
|
|
760
|
-
var DEFAULT_CACHE_ROOT =
|
|
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
|
|
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 =
|
|
1009
|
+
const buf2 = readFileSync3(dest);
|
|
781
1010
|
verifySha256(buf2, record.sha256);
|
|
782
1011
|
return dest;
|
|
783
1012
|
}
|
|
784
|
-
mkdirSync3(
|
|
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
|
};
|