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.
- package/bin/dicom-synth-describe.mjs +23 -5
- package/dist/esm/collection/writer.js +100 -95
- package/dist/esm/describe/describe.js +234 -48
- package/dist/esm/index.js +345 -137
- package/dist/esm/schema/parametric.js +31 -16
- package/dist/esm/schema/validate.js +115 -19
- package/dist/esm/syntheticFixtures/streamWrite.js +20 -35
- package/dist/esm/syntheticFixtures/uid.js +28 -47
- package/dist/types/collection/writer.d.ts +2 -0
- package/dist/types/describe/describe.d.ts +2 -0
- package/dist/types/index.d.ts +3 -2
- package/dist/types/schema/types.d.ts +11 -1
- package/dist/types/syntheticFixtures/streamWrite.d.ts +6 -2
- package/dist/types/syntheticFixtures/uid.d.ts +4 -2
- package/package.json +1 -1
package/dist/esm/index.js
CHANGED
|
@@ -154,6 +154,13 @@ function validateSeed(value, path) {
|
|
|
154
154
|
);
|
|
155
155
|
}
|
|
156
156
|
}
|
|
157
|
+
function validateUidSalt(value, path) {
|
|
158
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
159
|
+
throw new Error(
|
|
160
|
+
`${path}: must be a non-empty string; got ${JSON.stringify(value)}`
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
157
164
|
function validateSizeKbLimit(kb, path) {
|
|
158
165
|
if (kb * 1024 > MAX_PIXEL_BYTES) {
|
|
159
166
|
throw new Error(`${path}: exceeds the 512 MB limit`);
|
|
@@ -288,26 +295,100 @@ function validateEntry(entry, path) {
|
|
|
288
295
|
return e;
|
|
289
296
|
}
|
|
290
297
|
var MODALITY_TAG = "00080060";
|
|
298
|
+
function validateTagValue(key, value, path) {
|
|
299
|
+
const isModalityKey = key === "Modality" || key.toUpperCase() === MODALITY_TAG;
|
|
300
|
+
if (isModalityKey && typeof value === "string" && Object.hasOwn(VALID_MODALITIES, value)) {
|
|
301
|
+
throw new Error(
|
|
302
|
+
`${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`
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
if (typeof value === "string") {
|
|
306
|
+
for (const name of findTemplateTokens(value)) {
|
|
307
|
+
if (!TEMPLATE_VOCAB.includes(name)) {
|
|
308
|
+
throw new Error(
|
|
309
|
+
`${path}.${key}: unknown placeholder "{${name}}" \u2014 allowed: ${TEMPLATE_VOCAB.map((v) => `{${v}}`).join(", ")}`
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
291
315
|
function validateTags(tags, path) {
|
|
292
316
|
if (typeof tags !== "object" || tags === null || Array.isArray(tags)) {
|
|
293
317
|
throw new Error(`${path}: must be an object`);
|
|
294
318
|
}
|
|
295
319
|
for (const [key, value] of Object.entries(tags)) {
|
|
296
320
|
validateTagKey(key, path);
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
321
|
+
validateTagValue(key, value, path);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
function validateArrayOfLength(value, count, path) {
|
|
325
|
+
if (!Array.isArray(value)) {
|
|
326
|
+
throw new Error(`${path}: must be an array`);
|
|
327
|
+
}
|
|
328
|
+
if (value.length !== count) {
|
|
329
|
+
throw new Error(
|
|
330
|
+
`${path}: length (${value.length}) must equal count (${count})`
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
return value;
|
|
334
|
+
}
|
|
335
|
+
function validateInstances(instances, path) {
|
|
336
|
+
if (typeof instances !== "object" || instances === null || Array.isArray(instances)) {
|
|
337
|
+
throw new Error(`${path}: must be an object`);
|
|
338
|
+
}
|
|
339
|
+
const inst = instances;
|
|
340
|
+
validatePositiveInt(inst.count, `${path}.count`);
|
|
341
|
+
const count = inst.count;
|
|
342
|
+
const hasKb = "targetSizeKb" in inst;
|
|
343
|
+
const hasBytes = "targetBytes" in inst;
|
|
344
|
+
if (hasKb === hasBytes) {
|
|
345
|
+
throw new Error(
|
|
346
|
+
`${path}: exactly one of "targetSizeKb" or "targetBytes" is required`
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
if (hasKb) {
|
|
350
|
+
const sizes = validateArrayOfLength(
|
|
351
|
+
inst.targetSizeKb,
|
|
352
|
+
count,
|
|
353
|
+
`${path}.targetSizeKb`
|
|
354
|
+
);
|
|
355
|
+
sizes.forEach((kb, i) => {
|
|
356
|
+
validatePositiveInt(kb, `${path}.targetSizeKb[${i}]`);
|
|
357
|
+
validateSizeKbLimit(kb, `${path}.targetSizeKb[${i}]`);
|
|
358
|
+
});
|
|
359
|
+
} else {
|
|
360
|
+
const sizes = validateArrayOfLength(
|
|
361
|
+
inst.targetBytes,
|
|
362
|
+
count,
|
|
363
|
+
`${path}.targetBytes`
|
|
364
|
+
);
|
|
365
|
+
sizes.forEach((b, i) => {
|
|
366
|
+
validateLargeTargetBytes(b, `${path}.targetBytes[${i}]`);
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
if ("modality" in inst) {
|
|
370
|
+
if (Array.isArray(inst.modality)) {
|
|
371
|
+
validateArrayOfLength(inst.modality, count, `${path}.modality`).forEach(
|
|
372
|
+
(m, i) => {
|
|
373
|
+
validateEnum(m, VALID_MODALITIES, `${path}.modality[${i}]`);
|
|
374
|
+
}
|
|
301
375
|
);
|
|
376
|
+
} else {
|
|
377
|
+
validateEnum(inst.modality, VALID_MODALITIES, `${path}.modality`);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
if ("tags" in inst) {
|
|
381
|
+
const tags = inst.tags;
|
|
382
|
+
if (typeof tags !== "object" || tags === null || Array.isArray(tags)) {
|
|
383
|
+
throw new Error(`${path}.tags: must be an object`);
|
|
302
384
|
}
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
);
|
|
385
|
+
for (const [key, arr] of Object.entries(tags)) {
|
|
386
|
+
validateTagKey(key, `${path}.tags`);
|
|
387
|
+
validateArrayOfLength(arr, count, `${path}.tags.${key}`).forEach(
|
|
388
|
+
(value, i) => {
|
|
389
|
+
validateTagValue(key, value, `${path}.tags[${i}]`);
|
|
309
390
|
}
|
|
310
|
-
|
|
391
|
+
);
|
|
311
392
|
}
|
|
312
393
|
}
|
|
313
394
|
}
|
|
@@ -316,16 +397,27 @@ function validateSeries(series, path) {
|
|
|
316
397
|
throw new Error(`${path}: must be an object`);
|
|
317
398
|
}
|
|
318
399
|
const s = series;
|
|
319
|
-
|
|
320
|
-
|
|
400
|
+
const hasEntries = "entries" in s;
|
|
401
|
+
const hasInstances = "instances" in s;
|
|
402
|
+
if (hasEntries === hasInstances) {
|
|
403
|
+
throw new Error(
|
|
404
|
+
`${path}: must have exactly one of "entries" or "instances"`
|
|
405
|
+
);
|
|
321
406
|
}
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
throw new Error(
|
|
326
|
-
`${path}.entries[${i}].type: "${e.type}" cannot be used inside a series \u2014 only image-generating types can be grouped`
|
|
327
|
-
);
|
|
407
|
+
if (hasEntries) {
|
|
408
|
+
if (!Array.isArray(s.entries) || s.entries.length === 0) {
|
|
409
|
+
throw new Error(`${path}.entries: must be a non-empty array`);
|
|
328
410
|
}
|
|
411
|
+
for (let i = 0; i < s.entries.length; i++) {
|
|
412
|
+
const e = validateEntry(s.entries[i], `${path}.entries[${i}]`);
|
|
413
|
+
if (!TYPE_CAPABILITIES[e.type].image) {
|
|
414
|
+
throw new Error(
|
|
415
|
+
`${path}.entries[${i}].type: "${e.type}" cannot be used inside a series \u2014 only image-generating types can be grouped`
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
} else {
|
|
420
|
+
validateInstances(s.instances, `${path}.instances`);
|
|
329
421
|
}
|
|
330
422
|
if ("tags" in s) validateTags(s.tags, `${path}.tags`);
|
|
331
423
|
return s;
|
|
@@ -370,6 +462,7 @@ function validateDatasetSpec(raw) {
|
|
|
370
462
|
(study, i) => validateStudy(study, `studies[${i}]`)
|
|
371
463
|
);
|
|
372
464
|
if ("seed" in r) validateSeed(r.seed, "DatasetSpec.seed");
|
|
465
|
+
if ("uidSalt" in r) validateUidSalt(r.uidSalt, "DatasetSpec.uidSalt");
|
|
373
466
|
if ("layout" in r) {
|
|
374
467
|
validateEnum(r.layout, VALID_LAYOUTS, "DatasetSpec.layout");
|
|
375
468
|
}
|
|
@@ -385,6 +478,7 @@ function validateDatasetSpec(raw) {
|
|
|
385
478
|
...entries.length > 0 ? { entries } : {},
|
|
386
479
|
...studies.length > 0 ? { studies } : {},
|
|
387
480
|
...r.seed !== void 0 ? { seed: r.seed } : {},
|
|
481
|
+
...r.uidSalt !== void 0 ? { uidSalt: r.uidSalt } : {},
|
|
388
482
|
...r.layout !== void 0 ? { layout: r.layout } : {},
|
|
389
483
|
...r.pathQuirks !== void 0 ? { pathQuirks: r.pathQuirks } : {}
|
|
390
484
|
};
|
|
@@ -500,6 +594,7 @@ function validateParametricSpec(raw) {
|
|
|
500
594
|
}
|
|
501
595
|
}
|
|
502
596
|
if ("seed" in r) validateSeed(r.seed, "ParametricSpec.seed");
|
|
597
|
+
if ("uidSalt" in r) validateUidSalt(r.uidSalt, "ParametricSpec.uidSalt");
|
|
503
598
|
if ("layout" in r) {
|
|
504
599
|
validateEnum(r.layout, VALID_LAYOUTS, "ParametricSpec.layout");
|
|
505
600
|
}
|
|
@@ -507,6 +602,7 @@ function validateParametricSpec(raw) {
|
|
|
507
602
|
studies,
|
|
508
603
|
...edgeCases.length > 0 ? { edgeCases } : {},
|
|
509
604
|
...r.seed !== void 0 ? { seed: r.seed } : {},
|
|
605
|
+
...r.uidSalt !== void 0 ? { uidSalt: r.uidSalt } : {},
|
|
510
606
|
...r.layout !== void 0 ? { layout: r.layout } : {}
|
|
511
607
|
};
|
|
512
608
|
}
|
|
@@ -694,8 +790,19 @@ import { closeSync, mkdirSync as mkdirSync2, openSync, writeSync } from "node:fs
|
|
|
694
790
|
import { resolve as resolve2 } from "node:path";
|
|
695
791
|
|
|
696
792
|
// src/syntheticFixtures/uid.ts
|
|
697
|
-
import { randomBytes } from "node:crypto";
|
|
698
|
-
|
|
793
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
794
|
+
function hashUid(salt, key) {
|
|
795
|
+
const digest = createHash("sha256").update(salt).update(" ").update(key).digest();
|
|
796
|
+
const n = BigInt(`0x${digest.subarray(0, 16).toString("hex")}`);
|
|
797
|
+
return `2.25.${n.toString()}`;
|
|
798
|
+
}
|
|
799
|
+
function randomUid() {
|
|
800
|
+
const n = BigInt(`0x${randomBytes(16).toString("hex")}`);
|
|
801
|
+
return `2.25.${n.toString()}`;
|
|
802
|
+
}
|
|
803
|
+
function seedToSalt(seed) {
|
|
804
|
+
return seed === void 0 ? void 0 : `seed:${seed}`;
|
|
805
|
+
}
|
|
699
806
|
function lcg(seed) {
|
|
700
807
|
let s = seed >>> 0;
|
|
701
808
|
return () => {
|
|
@@ -703,60 +810,28 @@ function lcg(seed) {
|
|
|
703
810
|
return s;
|
|
704
811
|
};
|
|
705
812
|
}
|
|
706
|
-
function formatUid(a, b, role) {
|
|
707
|
-
return `2.25.${a}.${b}.${ROLE_CODE[role]}`;
|
|
708
|
-
}
|
|
709
|
-
function randomUid(role) {
|
|
710
|
-
const buf = randomBytes(8);
|
|
711
|
-
return formatUid(buf.readUInt32BE(0), buf.readUInt32BE(4), role);
|
|
712
|
-
}
|
|
713
|
-
function seededUid(next, role) {
|
|
714
|
-
return formatUid(next(), next(), role);
|
|
715
|
-
}
|
|
716
|
-
var GROUP_STREAM_OFFSET = 2654435769;
|
|
717
813
|
var PARAMETRIC_STREAM_OFFSET = 2246822507;
|
|
718
814
|
function seededStream(seed, offset, a, b) {
|
|
719
815
|
return lcg(seed * 999983 + offset + a * 1000003 + b * 7919 >>> 0);
|
|
720
816
|
}
|
|
721
|
-
function makeGroupUidGenerator(
|
|
722
|
-
if (
|
|
723
|
-
return {
|
|
724
|
-
study: () => randomUid("study"),
|
|
725
|
-
series: () => randomUid("series")
|
|
726
|
-
};
|
|
817
|
+
function makeGroupUidGenerator(salt) {
|
|
818
|
+
if (salt === void 0) {
|
|
819
|
+
return { study: () => randomUid(), series: () => randomUid() };
|
|
727
820
|
}
|
|
728
821
|
return {
|
|
729
|
-
study: (studyIndex) =>
|
|
730
|
-
|
|
731
|
-
"study"
|
|
732
|
-
),
|
|
733
|
-
series: (studyIndex, seriesIndex) => seededUid(
|
|
734
|
-
seededStream(
|
|
735
|
-
seed,
|
|
736
|
-
GROUP_STREAM_OFFSET,
|
|
737
|
-
studyIndex + 1,
|
|
738
|
-
seriesIndex + 1
|
|
739
|
-
),
|
|
740
|
-
"series"
|
|
741
|
-
)
|
|
822
|
+
study: (studyIndex) => hashUid(salt, `study:${studyIndex}`),
|
|
823
|
+
series: (studyIndex, seriesIndex) => hashUid(salt, `series:${studyIndex}:${seriesIndex}`)
|
|
742
824
|
};
|
|
743
825
|
}
|
|
744
|
-
function makeUidGenerator(
|
|
745
|
-
if (
|
|
746
|
-
return () => ({
|
|
747
|
-
study: randomUid("study"),
|
|
748
|
-
series: randomUid("series"),
|
|
749
|
-
sop: randomUid("sop")
|
|
750
|
-
});
|
|
826
|
+
function makeUidGenerator(salt) {
|
|
827
|
+
if (salt === void 0) {
|
|
828
|
+
return () => ({ study: randomUid(), series: randomUid(), sop: randomUid() });
|
|
751
829
|
}
|
|
752
|
-
return (fileIndex) => {
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
sop: seededUid(next, "sop")
|
|
758
|
-
};
|
|
759
|
-
};
|
|
830
|
+
return (fileIndex) => ({
|
|
831
|
+
study: hashUid(salt, `study:flat:${fileIndex}`),
|
|
832
|
+
series: hashUid(salt, `series:flat:${fileIndex}`),
|
|
833
|
+
sop: hashUid(salt, `sop:${fileIndex}`)
|
|
834
|
+
});
|
|
760
835
|
}
|
|
761
836
|
|
|
762
837
|
// src/syntheticFixtures/streamWrite.ts
|
|
@@ -869,7 +944,7 @@ function streamLargeImage(outPath, options) {
|
|
|
869
944
|
fragmentBytes = FRAGMENT_MAX_BYTES
|
|
870
945
|
} = options;
|
|
871
946
|
const modality = options.modality ?? "CT";
|
|
872
|
-
const uid = options.uid ?? makeUidGenerator(options.seed)(0);
|
|
947
|
+
const uid = options.uid ?? makeUidGenerator(options.uidSalt ?? seedToSalt(options.seed))(0);
|
|
873
948
|
const identity = { modality, uid, tags: options.tags };
|
|
874
949
|
mkdirSync2(resolve2(outPath, ".."), { recursive: true });
|
|
875
950
|
const zeroChunk = Buffer.alloc(Math.max(1, chunkBytes));
|
|
@@ -921,6 +996,7 @@ function writeLargeImageFile(spec, outPath, options = {}) {
|
|
|
921
996
|
return streamLargeImage(outPath, {
|
|
922
997
|
targetBytes,
|
|
923
998
|
modality: spec.modality,
|
|
999
|
+
uidSalt: spec.uidSalt,
|
|
924
1000
|
seed: spec.seed,
|
|
925
1001
|
chunkBytes: options.chunkBytes
|
|
926
1002
|
});
|
|
@@ -1033,19 +1109,51 @@ function filename(type, index, padWidth) {
|
|
|
1033
1109
|
function entryCount(entries) {
|
|
1034
1110
|
return entries.reduce((sum, e) => sum + (e.count ?? 1), 0);
|
|
1035
1111
|
}
|
|
1112
|
+
function seriesFileCount(series) {
|
|
1113
|
+
return series.instances ? series.instances.count : entryCount(series.entries ?? []);
|
|
1114
|
+
}
|
|
1036
1115
|
function studyFileCount(studies) {
|
|
1037
1116
|
return studies.reduce(
|
|
1038
|
-
(sum, study) => sum + (study.count ?? 1) * study.series.reduce((s, series) => s +
|
|
1117
|
+
(sum, study) => sum + (study.count ?? 1) * study.series.reduce((s, series) => s + seriesFileCount(series), 0),
|
|
1039
1118
|
0
|
|
1040
1119
|
);
|
|
1041
1120
|
}
|
|
1042
|
-
function
|
|
1043
|
-
|
|
1121
|
+
function* seriesFiles(series) {
|
|
1122
|
+
if (series.instances) {
|
|
1123
|
+
const inst = series.instances;
|
|
1124
|
+
for (let i = 0; i < inst.count; i++) {
|
|
1125
|
+
const modality = Array.isArray(inst.modality) ? inst.modality[i] : inst.modality;
|
|
1126
|
+
const baseSpec = {
|
|
1127
|
+
...inst.targetBytes ? { type: "large-image", targetBytes: inst.targetBytes[i] } : { type: "valid-image", targetSizeKb: inst.targetSizeKb?.[i] },
|
|
1128
|
+
...modality !== void 0 ? { modality } : {}
|
|
1129
|
+
};
|
|
1130
|
+
const instanceTags = {};
|
|
1131
|
+
if (inst.tags) {
|
|
1132
|
+
for (const [key, values] of Object.entries(inst.tags)) {
|
|
1133
|
+
instanceTags[key] = values[i];
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
yield { baseSpec, instanceTags };
|
|
1137
|
+
}
|
|
1138
|
+
return;
|
|
1139
|
+
}
|
|
1140
|
+
for (const entry of series.entries ?? []) {
|
|
1141
|
+
const { count = 1, tags, ...baseSpec } = entry;
|
|
1142
|
+
for (let i = 0; i < count; i++) {
|
|
1143
|
+
yield { baseSpec, instanceTags: tags ?? {} };
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
function resolveUid(salt, index, override) {
|
|
1148
|
+
return { ...makeUidGenerator(salt)(index), ...override };
|
|
1149
|
+
}
|
|
1150
|
+
function effectiveSalt(spec) {
|
|
1151
|
+
return spec.uidSalt ?? seedToSalt(spec.seed);
|
|
1044
1152
|
}
|
|
1045
1153
|
async function generateFile(spec, options) {
|
|
1046
1154
|
const index = options?.index ?? 0;
|
|
1047
1155
|
const padWidth = options?.padWidth ?? 3;
|
|
1048
|
-
const uid = resolveUid(options
|
|
1156
|
+
const uid = resolveUid(effectiveSalt(options ?? {}), index, options?.uid);
|
|
1049
1157
|
const resolvedSpec = "tags" in spec && spec.tags ? {
|
|
1050
1158
|
...spec,
|
|
1051
1159
|
tags: resolveTagTemplates(spec.tags, { index, ...options?.context })
|
|
@@ -1117,6 +1225,9 @@ function hierarchicalName(studyOrdinal, seriesIndex, instanceNumber) {
|
|
|
1117
1225
|
function withResolvedSeed(spec) {
|
|
1118
1226
|
return spec.seed === void 0 ? { ...spec, seed: randomBytes2(4).readUInt32BE(0) } : spec;
|
|
1119
1227
|
}
|
|
1228
|
+
function withResolvedSalt(spec) {
|
|
1229
|
+
return spec.uidSalt === void 0 ? { ...spec, uidSalt: randomBytes2(16).toString("hex") } : spec;
|
|
1230
|
+
}
|
|
1120
1231
|
function computeNames(type, index, padWidth, grouped, quirks) {
|
|
1121
1232
|
let names;
|
|
1122
1233
|
if (grouped) {
|
|
@@ -1157,7 +1268,7 @@ function* planCollection(spec) {
|
|
|
1157
1268
|
globalIndex++;
|
|
1158
1269
|
}
|
|
1159
1270
|
}
|
|
1160
|
-
const groupUids = makeGroupUidGenerator(spec
|
|
1271
|
+
const groupUids = makeGroupUidGenerator(effectiveSalt(spec));
|
|
1161
1272
|
let studyOrdinal = 0;
|
|
1162
1273
|
for (const study of studies) {
|
|
1163
1274
|
const copies = study.count ?? 1;
|
|
@@ -1166,57 +1277,56 @@ function* planCollection(spec) {
|
|
|
1166
1277
|
for (const [seriesIndex, series] of study.series.entries()) {
|
|
1167
1278
|
const seriesUid = groupUids.series(studyOrdinal, seriesIndex);
|
|
1168
1279
|
let instanceNumber = 0;
|
|
1169
|
-
for (const
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
instanceNumber
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
}
|
|
1179
|
-
|
|
1180
|
-
|
|
1280
|
+
for (const { baseSpec, instanceTags } of seriesFiles(series)) {
|
|
1281
|
+
instanceNumber++;
|
|
1282
|
+
const tags = {
|
|
1283
|
+
InstanceNumber: instanceNumber,
|
|
1284
|
+
...study.tags,
|
|
1285
|
+
...series.tags,
|
|
1286
|
+
...instanceTags
|
|
1287
|
+
};
|
|
1288
|
+
yield {
|
|
1289
|
+
fileSpec: { ...baseSpec, tags },
|
|
1290
|
+
index: globalIndex,
|
|
1291
|
+
uidOverride: { study: studyUid, series: seriesUid },
|
|
1292
|
+
context: {
|
|
1181
1293
|
index: globalIndex,
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
)
|
|
1196
|
-
};
|
|
1197
|
-
globalIndex++;
|
|
1198
|
-
}
|
|
1294
|
+
studyIndex: studyOrdinal,
|
|
1295
|
+
seriesIndex,
|
|
1296
|
+
instanceNumber
|
|
1297
|
+
},
|
|
1298
|
+
...computeNames(
|
|
1299
|
+
baseSpec.type,
|
|
1300
|
+
globalIndex,
|
|
1301
|
+
padWidth,
|
|
1302
|
+
hierarchical ? { studyOrdinal, seriesIndex, instanceNumber } : void 0,
|
|
1303
|
+
quirks
|
|
1304
|
+
)
|
|
1305
|
+
};
|
|
1306
|
+
globalIndex++;
|
|
1199
1307
|
}
|
|
1200
1308
|
}
|
|
1201
1309
|
studyOrdinal++;
|
|
1202
1310
|
}
|
|
1203
1311
|
}
|
|
1204
1312
|
}
|
|
1205
|
-
async function materialisePlan(plan,
|
|
1313
|
+
async function materialisePlan(plan, salt) {
|
|
1206
1314
|
const file = await generateFile(plan.fileSpec, {
|
|
1207
1315
|
index: plan.index,
|
|
1208
|
-
|
|
1316
|
+
uidSalt: salt,
|
|
1209
1317
|
uid: plan.uidOverride,
|
|
1210
1318
|
context: plan.context
|
|
1211
1319
|
});
|
|
1212
1320
|
return { ...file, filename: plan.filename, relativePath: plan.relativePath };
|
|
1213
1321
|
}
|
|
1214
1322
|
async function* generateCollectionFromSpec(spec) {
|
|
1323
|
+
const salt = effectiveSalt(spec);
|
|
1215
1324
|
for (const plan of planCollection(spec)) {
|
|
1216
|
-
yield await materialisePlan(plan,
|
|
1325
|
+
yield await materialisePlan(plan, salt);
|
|
1217
1326
|
}
|
|
1218
1327
|
}
|
|
1219
1328
|
async function* previewCollection(spec) {
|
|
1329
|
+
const salt = effectiveSalt(spec);
|
|
1220
1330
|
for (const plan of planCollection(spec)) {
|
|
1221
1331
|
if (plan.fileSpec.type === "large-image") {
|
|
1222
1332
|
yield {
|
|
@@ -1226,7 +1336,7 @@ async function* previewCollection(spec) {
|
|
|
1226
1336
|
approxBytes: plan.fileSpec.targetBytes
|
|
1227
1337
|
};
|
|
1228
1338
|
} else {
|
|
1229
|
-
const file = await materialisePlan(plan,
|
|
1339
|
+
const file = await materialisePlan(plan, salt);
|
|
1230
1340
|
yield {
|
|
1231
1341
|
relativePath: plan.relativePath,
|
|
1232
1342
|
type: plan.fileSpec.type,
|
|
@@ -1240,6 +1350,7 @@ async function writeCollectionFromSpec(spec, outDir) {
|
|
|
1240
1350
|
const root = resolve3(outDir);
|
|
1241
1351
|
mkdirSync3(root, { recursive: true });
|
|
1242
1352
|
const manifest = [];
|
|
1353
|
+
const salt = effectiveSalt(spec);
|
|
1243
1354
|
const createdDirs = /* @__PURE__ */ new Set([root]);
|
|
1244
1355
|
const ensureDir = (filePath) => {
|
|
1245
1356
|
const dir = dirname(filePath);
|
|
@@ -1258,7 +1369,7 @@ async function writeCollectionFromSpec(spec, outDir) {
|
|
|
1258
1369
|
`large-image targetBytes (${large.targetBytes}) exceeds the 50 GB limit`
|
|
1259
1370
|
);
|
|
1260
1371
|
}
|
|
1261
|
-
const uid = resolveUid(
|
|
1372
|
+
const uid = resolveUid(salt, plan.index, plan.uidOverride);
|
|
1262
1373
|
streamLargeImage(filePath, {
|
|
1263
1374
|
targetBytes: large.targetBytes,
|
|
1264
1375
|
modality: large.modality,
|
|
@@ -1266,7 +1377,7 @@ async function writeCollectionFromSpec(spec, outDir) {
|
|
|
1266
1377
|
tags: resolveTagTemplates(large.tags, plan.context)
|
|
1267
1378
|
});
|
|
1268
1379
|
} else {
|
|
1269
|
-
const file = await materialisePlan(plan,
|
|
1380
|
+
const file = await materialisePlan(plan, salt);
|
|
1270
1381
|
await writeFile(filePath, file.buffer);
|
|
1271
1382
|
}
|
|
1272
1383
|
manifest.push({
|
|
@@ -1444,8 +1555,117 @@ function extractTags(record, keepKeywords) {
|
|
|
1444
1555
|
}
|
|
1445
1556
|
return found ? tags : void 0;
|
|
1446
1557
|
}
|
|
1558
|
+
function sizeKbOf(bytes) {
|
|
1559
|
+
return Math.max(1, Math.round(bytes / 1024));
|
|
1560
|
+
}
|
|
1561
|
+
function bucketBytes(bytes, tolerance) {
|
|
1562
|
+
if (tolerance <= 0 || bytes < 1) return bytes;
|
|
1563
|
+
const factor = 1 + tolerance;
|
|
1564
|
+
const bucket = Math.floor(Math.log(bytes) / Math.log(factor));
|
|
1565
|
+
return Math.max(1, Math.round(factor ** (bucket + 0.5)));
|
|
1566
|
+
}
|
|
1567
|
+
function collapseKeyOf(r) {
|
|
1568
|
+
return JSON.stringify([
|
|
1569
|
+
r.large,
|
|
1570
|
+
r.modality ?? null,
|
|
1571
|
+
r.large ? r.bytes : sizeKbOf(r.bytes),
|
|
1572
|
+
r.tags ?? null
|
|
1573
|
+
]);
|
|
1574
|
+
}
|
|
1575
|
+
function accumulate(byKey, r) {
|
|
1576
|
+
const key = collapseKeyOf(r);
|
|
1577
|
+
const existing = byKey.get(key);
|
|
1578
|
+
if (existing) {
|
|
1579
|
+
existing.count++;
|
|
1580
|
+
} else {
|
|
1581
|
+
byKey.set(key, {
|
|
1582
|
+
modality: r.modality,
|
|
1583
|
+
large: r.large,
|
|
1584
|
+
bytes: r.bytes,
|
|
1585
|
+
tags: r.tags,
|
|
1586
|
+
count: 1
|
|
1587
|
+
});
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
function entryOf(acc) {
|
|
1591
|
+
return {
|
|
1592
|
+
...acc.modality !== void 0 ? { modality: acc.modality } : {},
|
|
1593
|
+
...acc.large ? { type: "large-image", targetBytes: acc.bytes } : { type: "valid-image", targetSizeKb: sizeKbOf(acc.bytes) },
|
|
1594
|
+
...acc.tags !== void 0 ? { tags: acc.tags } : {},
|
|
1595
|
+
...acc.count > 1 ? { count: acc.count } : {}
|
|
1596
|
+
};
|
|
1597
|
+
}
|
|
1598
|
+
function collapseEntries(records) {
|
|
1599
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
1600
|
+
for (const r of records) accumulate(byKey, r);
|
|
1601
|
+
return [...byKey.values()].map(entryOf);
|
|
1602
|
+
}
|
|
1603
|
+
function tagValuesEqual(a, b) {
|
|
1604
|
+
if (a === b) return true;
|
|
1605
|
+
if (typeof a === "object" && a !== null && typeof b === "object" && b !== null)
|
|
1606
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
1607
|
+
return false;
|
|
1608
|
+
}
|
|
1609
|
+
function tryInstancesForm(records) {
|
|
1610
|
+
const count = records.length;
|
|
1611
|
+
if (count === 0) return null;
|
|
1612
|
+
const large = records[0]?.large ?? false;
|
|
1613
|
+
const modality = records[0]?.modality;
|
|
1614
|
+
const columns = /* @__PURE__ */ new Map();
|
|
1615
|
+
for (let i = 0; i < count; i++) {
|
|
1616
|
+
const r = records[i];
|
|
1617
|
+
if (r.large !== large || r.modality !== modality) return null;
|
|
1618
|
+
if (!r.tags) continue;
|
|
1619
|
+
for (const [k, v] of Object.entries(r.tags)) {
|
|
1620
|
+
let col = columns.get(k);
|
|
1621
|
+
if (!col) {
|
|
1622
|
+
col = { values: new Array(count), present: 0 };
|
|
1623
|
+
columns.set(k, col);
|
|
1624
|
+
}
|
|
1625
|
+
col.values[i] = v;
|
|
1626
|
+
col.present++;
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
if (columns.size === 0) return null;
|
|
1630
|
+
const sharedTags = {};
|
|
1631
|
+
const perInstanceTags = {};
|
|
1632
|
+
for (const [k, col] of columns) {
|
|
1633
|
+
if (col.present !== count) return null;
|
|
1634
|
+
const first = col.values[0];
|
|
1635
|
+
if (col.values.every((v) => tagValuesEqual(v, first))) {
|
|
1636
|
+
sharedTags[k] = first;
|
|
1637
|
+
} else {
|
|
1638
|
+
perInstanceTags[k] = col.values;
|
|
1639
|
+
}
|
|
1640
|
+
}
|
|
1641
|
+
if (Object.keys(perInstanceTags).length === 0) return null;
|
|
1642
|
+
const instances = {
|
|
1643
|
+
count,
|
|
1644
|
+
...large ? { targetBytes: records.map((r) => r.bytes) } : { targetSizeKb: records.map((r) => sizeKbOf(r.bytes)) },
|
|
1645
|
+
...modality !== void 0 ? { modality } : {},
|
|
1646
|
+
// perInstanceTags is non-empty here (we returned null above otherwise).
|
|
1647
|
+
tags: perInstanceTags
|
|
1648
|
+
};
|
|
1649
|
+
return {
|
|
1650
|
+
instances,
|
|
1651
|
+
...Object.keys(sharedTags).length ? { tags: sharedTags } : {}
|
|
1652
|
+
};
|
|
1653
|
+
}
|
|
1654
|
+
function buildSeriesSpec(records) {
|
|
1655
|
+
return tryInstancesForm(records) ?? { entries: collapseEntries(records) };
|
|
1656
|
+
}
|
|
1657
|
+
function seriesSpecFromAccum(accum) {
|
|
1658
|
+
return Array.isArray(accum) ? buildSeriesSpec(accum) : { entries: [...accum.values()].map(entryOf) };
|
|
1659
|
+
}
|
|
1447
1660
|
function describeDirectory(dir, options = {}) {
|
|
1448
1661
|
const keepKeywords = options.keepTags ? resolveKeepTags(options.keepTags) : [];
|
|
1662
|
+
const keepTags = keepKeywords.length > 0;
|
|
1663
|
+
const tolerance = options.sizeTolerance ?? 0;
|
|
1664
|
+
if (!Number.isFinite(tolerance) || tolerance < 0) {
|
|
1665
|
+
throw new Error(
|
|
1666
|
+
`describe: sizeTolerance must be a non-negative fraction; got ${JSON.stringify(options.sizeTolerance)}`
|
|
1667
|
+
);
|
|
1668
|
+
}
|
|
1449
1669
|
const studies = /* @__PURE__ */ new Map();
|
|
1450
1670
|
const stats = { dicomFiles: 0, skipped: 0, unknownModality: 0 };
|
|
1451
1671
|
for (const path of [...walkFiles(dir)].sort()) {
|
|
@@ -1477,8 +1697,9 @@ function describeDirectory(dir, options = {}) {
|
|
|
1477
1697
|
stats.unknownModality++;
|
|
1478
1698
|
}
|
|
1479
1699
|
}
|
|
1480
|
-
const
|
|
1481
|
-
const
|
|
1700
|
+
const tags = keepTags ? extractTags(record, keepKeywords) : void 0;
|
|
1701
|
+
const bytes = large ? size : Math.min(bucketBytes(size, tolerance), MAX_PIXEL_BYTES);
|
|
1702
|
+
const fileRecord = { large, bytes, modality, tags };
|
|
1482
1703
|
let study = studies.get(studyUid);
|
|
1483
1704
|
if (!study) {
|
|
1484
1705
|
study = /* @__PURE__ */ new Map();
|
|
@@ -1486,37 +1707,18 @@ function describeDirectory(dir, options = {}) {
|
|
|
1486
1707
|
}
|
|
1487
1708
|
let series = study.get(seriesUid);
|
|
1488
1709
|
if (!series) {
|
|
1489
|
-
series = /* @__PURE__ */ new Map();
|
|
1710
|
+
series = keepTags ? [] : /* @__PURE__ */ new Map();
|
|
1490
1711
|
study.set(seriesUid, series);
|
|
1491
1712
|
}
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
modality ?? null,
|
|
1495
|
-
large ? size : sizeKb,
|
|
1496
|
-
tags ?? null
|
|
1497
|
-
]);
|
|
1498
|
-
const existing = series.get(collapseKey);
|
|
1499
|
-
if (existing) {
|
|
1500
|
-
existing.count++;
|
|
1713
|
+
if (Array.isArray(series)) {
|
|
1714
|
+
series.push(fileRecord);
|
|
1501
1715
|
} else {
|
|
1502
|
-
series
|
|
1716
|
+
accumulate(series, fileRecord);
|
|
1503
1717
|
}
|
|
1504
1718
|
}
|
|
1505
|
-
const studySpecs = [...studies.values()].map((study) => {
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
...acc.modality !== void 0 ? { modality: acc.modality } : {},
|
|
1509
|
-
...acc.large ? { type: "large-image", targetBytes: acc.bytes } : {
|
|
1510
|
-
type: "valid-image",
|
|
1511
|
-
targetSizeKb: Math.max(1, Math.round(acc.bytes / 1024))
|
|
1512
|
-
},
|
|
1513
|
-
...acc.tags !== void 0 ? { tags: acc.tags } : {},
|
|
1514
|
-
...acc.count > 1 ? { count: acc.count } : {}
|
|
1515
|
-
}));
|
|
1516
|
-
return { entries };
|
|
1517
|
-
});
|
|
1518
|
-
return { series: seriesSpecs };
|
|
1519
|
-
});
|
|
1719
|
+
const studySpecs = [...studies.values()].map((study) => ({
|
|
1720
|
+
series: [...study.values()].map(seriesSpecFromAccum)
|
|
1721
|
+
}));
|
|
1520
1722
|
if (studySpecs.length === 0) {
|
|
1521
1723
|
throw new Error(`describe: no DICOM series found in ${dir}`);
|
|
1522
1724
|
}
|
|
@@ -1557,7 +1759,7 @@ function loadCaseById(casesJsonPath, id) {
|
|
|
1557
1759
|
}
|
|
1558
1760
|
|
|
1559
1761
|
// src/public-fixtures/fetch.ts
|
|
1560
|
-
import { createHash } from "node:crypto";
|
|
1762
|
+
import { createHash as createHash2 } from "node:crypto";
|
|
1561
1763
|
import { existsSync, mkdirSync as mkdirSync4, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "node:fs";
|
|
1562
1764
|
import { homedir } from "node:os";
|
|
1563
1765
|
import { join as join3 } from "node:path";
|
|
@@ -1566,7 +1768,7 @@ function caseCachePath(sha256, root = DEFAULT_CACHE_ROOT) {
|
|
|
1566
1768
|
return join3(root, sha256, "file.dcm");
|
|
1567
1769
|
}
|
|
1568
1770
|
function verifySha256(buffer, expected) {
|
|
1569
|
-
const h =
|
|
1771
|
+
const h = createHash2("sha256").update(buffer).digest("hex");
|
|
1570
1772
|
if (h !== expected) {
|
|
1571
1773
|
throw new Error(
|
|
1572
1774
|
`SHA256 mismatch: expected ${expected}, got ${h}. Upstream may have changed; bump metadata after review.`
|
|
@@ -1698,6 +1900,9 @@ function resolveParametricSpec(spec) {
|
|
|
1698
1900
|
...entries.length > 0 ? { entries } : {},
|
|
1699
1901
|
studies,
|
|
1700
1902
|
seed,
|
|
1903
|
+
// Pass an explicit salt through; when absent, UIDs derive from `seed` so the
|
|
1904
|
+
// resolved spec stays deterministic ("same seed → identical resolved spec").
|
|
1905
|
+
...validated.uidSalt !== void 0 ? { uidSalt: validated.uidSalt } : {},
|
|
1701
1906
|
...validated.layout !== void 0 ? { layout: validated.layout } : {}
|
|
1702
1907
|
};
|
|
1703
1908
|
}
|
|
@@ -1711,16 +1916,19 @@ export {
|
|
|
1711
1916
|
fetchPublicCaseToCache,
|
|
1712
1917
|
generateCollectionFromSpec,
|
|
1713
1918
|
generateFile,
|
|
1919
|
+
hashUid,
|
|
1714
1920
|
loadCaseById,
|
|
1715
1921
|
loadCasesFromJson,
|
|
1716
1922
|
loadDefaultCases,
|
|
1717
1923
|
previewCollection,
|
|
1718
1924
|
resolveParametricSpec,
|
|
1719
1925
|
resolveTagTemplates,
|
|
1926
|
+
seedToSalt,
|
|
1720
1927
|
streamLargeImage,
|
|
1721
1928
|
validateDatasetSpec,
|
|
1722
1929
|
validateParametricSpec,
|
|
1723
1930
|
verifySha256,
|
|
1931
|
+
withResolvedSalt,
|
|
1724
1932
|
withResolvedSeed,
|
|
1725
1933
|
writeCollectionFromSpec,
|
|
1726
1934
|
writeLargeImageFile
|