dicom-synth 1.11.0 → 1.13.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 +27 -2
- package/dist/esm/collection/writer.js +44 -65
- package/dist/esm/describe/describe.js +114 -8
- package/dist/esm/index.js +163 -68
- package/dist/esm/schema/parametric.js +13 -1
- package/dist/esm/schema/validate.js +11 -0
- 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 +2 -1
- package/dist/types/schema/types.d.ts +2 -0
- package/dist/types/syntheticFixtures/streamWrite.d.ts +6 -2
- package/dist/types/syntheticFixtures/uid.d.ts +4 -2
- package/package.json +1 -1
|
@@ -6,12 +6,14 @@ 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>] [--gzip]\n' +
|
|
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' +
|
|
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' +
|
|
13
13
|
'\n' +
|
|
14
|
-
'
|
|
14
|
+
'UIDs are preserved by default as hash-derived synthetic UIDs, keeping\n' +
|
|
15
|
+
'cross-file links (FrameOfReferenceUID etc.) without emitting any original\n' +
|
|
16
|
+
'UID. Other tags are not copied unless named.\n' +
|
|
15
17
|
'--keep-tags copies the named tags (DICOM keywords or 8-hex tags)\n' +
|
|
16
18
|
"verbatim; if a kept tag holds PHI, handling it is the caller's\n" +
|
|
17
19
|
"responsibility, not this tool's.\n" +
|
|
@@ -19,6 +21,11 @@ function usage() {
|
|
|
19
21
|
'line, # comments); merged with any --keep-tags.\n' +
|
|
20
22
|
'--size-tolerance folds near-identical file sizes (fraction, e.g. 0.05\n' +
|
|
21
23
|
'= ±5%) into one entry; default exact.\n' +
|
|
24
|
+
'--no-preserve-uids emits shape only with freshly minted UIDs (no link\n' +
|
|
25
|
+
'preservation).\n' +
|
|
26
|
+
'--uid-salt fixes the hashing salt (hex) for a specific namespace; when\n' +
|
|
27
|
+
"omitted the salt derives from the dataset's own study UIDs (reproducible,\n" +
|
|
28
|
+
'and disjoint across datasets). The salt is reported on stderr.\n' +
|
|
22
29
|
'--gzip (or a .gz --out suffix) writes the spec gzip-compressed.',
|
|
23
30
|
)
|
|
24
31
|
process.exit(1)
|
|
@@ -38,6 +45,8 @@ if (args.length === 0) usage()
|
|
|
38
45
|
let dir
|
|
39
46
|
let outPath
|
|
40
47
|
let sizeTolerance
|
|
48
|
+
let preserveUids = true
|
|
49
|
+
let uidSalt
|
|
41
50
|
let gzip = false
|
|
42
51
|
const keepTags = []
|
|
43
52
|
|
|
@@ -64,6 +73,10 @@ for (let i = 0; i < args.length; i++) {
|
|
|
64
73
|
console.error('--size-tolerance must be a non-negative number')
|
|
65
74
|
process.exit(1)
|
|
66
75
|
}
|
|
76
|
+
} else if (args[i] === '--no-preserve-uids') {
|
|
77
|
+
preserveUids = false
|
|
78
|
+
} else if (args[i] === '--uid-salt' && args[i + 1]) {
|
|
79
|
+
uidSalt = args[++i]
|
|
67
80
|
} else if (args[i] === '--gzip') {
|
|
68
81
|
gzip = true
|
|
69
82
|
} else if (args[i].startsWith('--')) {
|
|
@@ -79,11 +92,18 @@ for (let i = 0; i < args.length; i++) {
|
|
|
79
92
|
|
|
80
93
|
if (dir === undefined) usage()
|
|
81
94
|
|
|
95
|
+
if (uidSalt !== undefined && !preserveUids) {
|
|
96
|
+
console.error('--uid-salt cannot be combined with --no-preserve-uids')
|
|
97
|
+
process.exit(1)
|
|
98
|
+
}
|
|
99
|
+
|
|
82
100
|
let result
|
|
83
101
|
try {
|
|
84
102
|
result = describeDirectory(dir, {
|
|
85
103
|
...(keepTags.length ? { keepTags } : {}),
|
|
86
104
|
...(sizeTolerance !== undefined ? { sizeTolerance } : {}),
|
|
105
|
+
preserveUids,
|
|
106
|
+
...(uidSalt !== undefined ? { uidSalt } : {}),
|
|
87
107
|
})
|
|
88
108
|
} catch (err) {
|
|
89
109
|
console.error(`Error: ${err.message}`)
|
|
@@ -103,6 +123,11 @@ const { dicomFiles, skipped, unknownModality } = result.stats
|
|
|
103
123
|
console.error(
|
|
104
124
|
`Described ${dicomFiles} DICOM file(s); skipped ${skipped} non-DICOM/ungroupable file(s)`,
|
|
105
125
|
)
|
|
126
|
+
if (result.spec.uidSalt) {
|
|
127
|
+
console.error(
|
|
128
|
+
` UIDs hash-derived with salt ${result.spec.uidSalt} (reuse via --uid-salt to reproduce)`,
|
|
129
|
+
)
|
|
130
|
+
}
|
|
106
131
|
if (unknownModality > 0) {
|
|
107
132
|
console.error(
|
|
108
133
|
` ${unknownModality} file(s) had an unsupported Modality — emitted as default (CT)`,
|
|
@@ -269,68 +269,37 @@ import { closeSync, mkdirSync as mkdirSync2, openSync, writeSync } from "node:fs
|
|
|
269
269
|
import { resolve as resolve2 } from "node:path";
|
|
270
270
|
|
|
271
271
|
// src/syntheticFixtures/uid.ts
|
|
272
|
-
import { randomBytes } from "node:crypto";
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
return ()
|
|
277
|
-
s = Math.imul(1664525, s) + 1013904223 >>> 0;
|
|
278
|
-
return s;
|
|
279
|
-
};
|
|
280
|
-
}
|
|
281
|
-
function formatUid(a, b, role) {
|
|
282
|
-
return `2.25.${a}.${b}.${ROLE_CODE[role]}`;
|
|
272
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
273
|
+
function hashUid(salt, key) {
|
|
274
|
+
const digest = createHash("sha256").update(salt).update(" ").update(key).digest();
|
|
275
|
+
const n = BigInt(`0x${digest.subarray(0, 16).toString("hex")}`);
|
|
276
|
+
return `2.25.${n.toString()}`;
|
|
283
277
|
}
|
|
284
|
-
function randomUid(
|
|
285
|
-
const
|
|
286
|
-
return
|
|
278
|
+
function randomUid() {
|
|
279
|
+
const n = BigInt(`0x${randomBytes(16).toString("hex")}`);
|
|
280
|
+
return `2.25.${n.toString()}`;
|
|
287
281
|
}
|
|
288
|
-
function
|
|
289
|
-
return
|
|
282
|
+
function seedToSalt(seed) {
|
|
283
|
+
return seed === void 0 ? void 0 : `seed:${seed}`;
|
|
290
284
|
}
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
}
|
|
295
|
-
function makeGroupUidGenerator(seed) {
|
|
296
|
-
if (seed === void 0) {
|
|
297
|
-
return {
|
|
298
|
-
study: () => randomUid("study"),
|
|
299
|
-
series: () => randomUid("series")
|
|
300
|
-
};
|
|
285
|
+
function makeGroupUidGenerator(salt) {
|
|
286
|
+
if (salt === void 0) {
|
|
287
|
+
return { study: () => randomUid(), series: () => randomUid() };
|
|
301
288
|
}
|
|
302
289
|
return {
|
|
303
|
-
study: (studyIndex) =>
|
|
304
|
-
|
|
305
|
-
"study"
|
|
306
|
-
),
|
|
307
|
-
series: (studyIndex, seriesIndex) => seededUid(
|
|
308
|
-
seededStream(
|
|
309
|
-
seed,
|
|
310
|
-
GROUP_STREAM_OFFSET,
|
|
311
|
-
studyIndex + 1,
|
|
312
|
-
seriesIndex + 1
|
|
313
|
-
),
|
|
314
|
-
"series"
|
|
315
|
-
)
|
|
290
|
+
study: (studyIndex) => hashUid(salt, `study:${studyIndex}`),
|
|
291
|
+
series: (studyIndex, seriesIndex) => hashUid(salt, `series:${studyIndex}:${seriesIndex}`)
|
|
316
292
|
};
|
|
317
293
|
}
|
|
318
|
-
function makeUidGenerator(
|
|
319
|
-
if (
|
|
320
|
-
return () => ({
|
|
321
|
-
study: randomUid("study"),
|
|
322
|
-
series: randomUid("series"),
|
|
323
|
-
sop: randomUid("sop")
|
|
324
|
-
});
|
|
294
|
+
function makeUidGenerator(salt) {
|
|
295
|
+
if (salt === void 0) {
|
|
296
|
+
return () => ({ study: randomUid(), series: randomUid(), sop: randomUid() });
|
|
325
297
|
}
|
|
326
|
-
return (fileIndex) => {
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
sop: seededUid(next, "sop")
|
|
332
|
-
};
|
|
333
|
-
};
|
|
298
|
+
return (fileIndex) => ({
|
|
299
|
+
study: hashUid(salt, `study:flat:${fileIndex}`),
|
|
300
|
+
series: hashUid(salt, `series:flat:${fileIndex}`),
|
|
301
|
+
sop: hashUid(salt, `sop:${fileIndex}`)
|
|
302
|
+
});
|
|
334
303
|
}
|
|
335
304
|
|
|
336
305
|
// src/syntheticFixtures/streamWrite.ts
|
|
@@ -443,7 +412,7 @@ function streamLargeImage(outPath, options) {
|
|
|
443
412
|
fragmentBytes = FRAGMENT_MAX_BYTES
|
|
444
413
|
} = options;
|
|
445
414
|
const modality = options.modality ?? "CT";
|
|
446
|
-
const uid = options.uid ?? makeUidGenerator(options.seed)(0);
|
|
415
|
+
const uid = options.uid ?? makeUidGenerator(options.uidSalt ?? seedToSalt(options.seed))(0);
|
|
447
416
|
const identity = { modality, uid, tags: options.tags };
|
|
448
417
|
mkdirSync2(resolve2(outPath, ".."), { recursive: true });
|
|
449
418
|
const zeroChunk = Buffer.alloc(Math.max(1, chunkBytes));
|
|
@@ -622,13 +591,16 @@ function* seriesFiles(series) {
|
|
|
622
591
|
}
|
|
623
592
|
}
|
|
624
593
|
}
|
|
625
|
-
function resolveUid(
|
|
626
|
-
return { ...makeUidGenerator(
|
|
594
|
+
function resolveUid(salt, index, override) {
|
|
595
|
+
return { ...makeUidGenerator(salt)(index), ...override };
|
|
596
|
+
}
|
|
597
|
+
function effectiveSalt(spec) {
|
|
598
|
+
return spec.uidSalt ?? seedToSalt(spec.seed);
|
|
627
599
|
}
|
|
628
600
|
async function generateFile(spec, options) {
|
|
629
601
|
const index = options?.index ?? 0;
|
|
630
602
|
const padWidth = options?.padWidth ?? 3;
|
|
631
|
-
const uid = resolveUid(options
|
|
603
|
+
const uid = resolveUid(effectiveSalt(options ?? {}), index, options?.uid);
|
|
632
604
|
const resolvedSpec = "tags" in spec && spec.tags ? {
|
|
633
605
|
...spec,
|
|
634
606
|
tags: resolveTagTemplates(spec.tags, { index, ...options?.context })
|
|
@@ -700,6 +672,9 @@ function hierarchicalName(studyOrdinal, seriesIndex, instanceNumber) {
|
|
|
700
672
|
function withResolvedSeed(spec) {
|
|
701
673
|
return spec.seed === void 0 ? { ...spec, seed: randomBytes2(4).readUInt32BE(0) } : spec;
|
|
702
674
|
}
|
|
675
|
+
function withResolvedSalt(spec) {
|
|
676
|
+
return spec.uidSalt === void 0 ? { ...spec, uidSalt: randomBytes2(16).toString("hex") } : spec;
|
|
677
|
+
}
|
|
703
678
|
function computeNames(type, index, padWidth, grouped, quirks) {
|
|
704
679
|
let names;
|
|
705
680
|
if (grouped) {
|
|
@@ -740,7 +715,7 @@ function* planCollection(spec) {
|
|
|
740
715
|
globalIndex++;
|
|
741
716
|
}
|
|
742
717
|
}
|
|
743
|
-
const groupUids = makeGroupUidGenerator(spec
|
|
718
|
+
const groupUids = makeGroupUidGenerator(effectiveSalt(spec));
|
|
744
719
|
let studyOrdinal = 0;
|
|
745
720
|
for (const study of studies) {
|
|
746
721
|
const copies = study.count ?? 1;
|
|
@@ -782,21 +757,23 @@ function* planCollection(spec) {
|
|
|
782
757
|
}
|
|
783
758
|
}
|
|
784
759
|
}
|
|
785
|
-
async function materialisePlan(plan,
|
|
760
|
+
async function materialisePlan(plan, salt) {
|
|
786
761
|
const file = await generateFile(plan.fileSpec, {
|
|
787
762
|
index: plan.index,
|
|
788
|
-
|
|
763
|
+
uidSalt: salt,
|
|
789
764
|
uid: plan.uidOverride,
|
|
790
765
|
context: plan.context
|
|
791
766
|
});
|
|
792
767
|
return { ...file, filename: plan.filename, relativePath: plan.relativePath };
|
|
793
768
|
}
|
|
794
769
|
async function* generateCollectionFromSpec(spec) {
|
|
770
|
+
const salt = effectiveSalt(spec);
|
|
795
771
|
for (const plan of planCollection(spec)) {
|
|
796
|
-
yield await materialisePlan(plan,
|
|
772
|
+
yield await materialisePlan(plan, salt);
|
|
797
773
|
}
|
|
798
774
|
}
|
|
799
775
|
async function* previewCollection(spec) {
|
|
776
|
+
const salt = effectiveSalt(spec);
|
|
800
777
|
for (const plan of planCollection(spec)) {
|
|
801
778
|
if (plan.fileSpec.type === "large-image") {
|
|
802
779
|
yield {
|
|
@@ -806,7 +783,7 @@ async function* previewCollection(spec) {
|
|
|
806
783
|
approxBytes: plan.fileSpec.targetBytes
|
|
807
784
|
};
|
|
808
785
|
} else {
|
|
809
|
-
const file = await materialisePlan(plan,
|
|
786
|
+
const file = await materialisePlan(plan, salt);
|
|
810
787
|
yield {
|
|
811
788
|
relativePath: plan.relativePath,
|
|
812
789
|
type: plan.fileSpec.type,
|
|
@@ -820,6 +797,7 @@ async function writeCollectionFromSpec(spec, outDir) {
|
|
|
820
797
|
const root = resolve3(outDir);
|
|
821
798
|
mkdirSync3(root, { recursive: true });
|
|
822
799
|
const manifest = [];
|
|
800
|
+
const salt = effectiveSalt(spec);
|
|
823
801
|
const createdDirs = /* @__PURE__ */ new Set([root]);
|
|
824
802
|
const ensureDir = (filePath) => {
|
|
825
803
|
const dir = dirname(filePath);
|
|
@@ -838,7 +816,7 @@ async function writeCollectionFromSpec(spec, outDir) {
|
|
|
838
816
|
`large-image targetBytes (${large.targetBytes}) exceeds the 50 GB limit`
|
|
839
817
|
);
|
|
840
818
|
}
|
|
841
|
-
const uid = resolveUid(
|
|
819
|
+
const uid = resolveUid(salt, plan.index, plan.uidOverride);
|
|
842
820
|
streamLargeImage(filePath, {
|
|
843
821
|
targetBytes: large.targetBytes,
|
|
844
822
|
modality: large.modality,
|
|
@@ -846,7 +824,7 @@ async function writeCollectionFromSpec(spec, outDir) {
|
|
|
846
824
|
tags: resolveTagTemplates(large.tags, plan.context)
|
|
847
825
|
});
|
|
848
826
|
} else {
|
|
849
|
-
const file = await materialisePlan(plan,
|
|
827
|
+
const file = await materialisePlan(plan, salt);
|
|
850
828
|
await writeFile(filePath, file.buffer);
|
|
851
829
|
}
|
|
852
830
|
manifest.push({
|
|
@@ -861,6 +839,7 @@ export {
|
|
|
861
839
|
generateCollectionFromSpec,
|
|
862
840
|
generateFile,
|
|
863
841
|
previewCollection,
|
|
842
|
+
withResolvedSalt,
|
|
864
843
|
withResolvedSeed,
|
|
865
844
|
writeCollectionFromSpec
|
|
866
845
|
};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// src/describe/describe.ts
|
|
2
|
+
import { createHash as createHash2 } from "node:crypto";
|
|
2
3
|
import {
|
|
3
4
|
closeSync,
|
|
4
5
|
openSync,
|
|
@@ -105,6 +106,13 @@ function validateSeed(value, path) {
|
|
|
105
106
|
);
|
|
106
107
|
}
|
|
107
108
|
}
|
|
109
|
+
function validateUidSalt(value, path) {
|
|
110
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
111
|
+
throw new Error(
|
|
112
|
+
`${path}: must be a non-empty string; got ${JSON.stringify(value)}`
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
108
116
|
function validateSizeKbLimit(kb, path) {
|
|
109
117
|
if (kb * 1024 > MAX_PIXEL_BYTES) {
|
|
110
118
|
throw new Error(`${path}: exceeds the 512 MB limit`);
|
|
@@ -406,6 +414,7 @@ function validateDatasetSpec(raw) {
|
|
|
406
414
|
(study, i) => validateStudy(study, `studies[${i}]`)
|
|
407
415
|
);
|
|
408
416
|
if ("seed" in r) validateSeed(r.seed, "DatasetSpec.seed");
|
|
417
|
+
if ("uidSalt" in r) validateUidSalt(r.uidSalt, "DatasetSpec.uidSalt");
|
|
409
418
|
if ("layout" in r) {
|
|
410
419
|
validateEnum(r.layout, VALID_LAYOUTS, "DatasetSpec.layout");
|
|
411
420
|
}
|
|
@@ -421,11 +430,20 @@ function validateDatasetSpec(raw) {
|
|
|
421
430
|
...entries.length > 0 ? { entries } : {},
|
|
422
431
|
...studies.length > 0 ? { studies } : {},
|
|
423
432
|
...r.seed !== void 0 ? { seed: r.seed } : {},
|
|
433
|
+
...r.uidSalt !== void 0 ? { uidSalt: r.uidSalt } : {},
|
|
424
434
|
...r.layout !== void 0 ? { layout: r.layout } : {},
|
|
425
435
|
...r.pathQuirks !== void 0 ? { pathQuirks: r.pathQuirks } : {}
|
|
426
436
|
};
|
|
427
437
|
}
|
|
428
438
|
|
|
439
|
+
// src/syntheticFixtures/uid.ts
|
|
440
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
441
|
+
function hashUid(salt, key) {
|
|
442
|
+
const digest = createHash("sha256").update(salt).update(" ").update(key).digest();
|
|
443
|
+
const n = BigInt(`0x${digest.subarray(0, 16).toString("hex")}`);
|
|
444
|
+
return `2.25.${n.toString()}`;
|
|
445
|
+
}
|
|
446
|
+
|
|
429
447
|
// src/describe/describe.ts
|
|
430
448
|
var dcmjsAny = dcmjs;
|
|
431
449
|
var HEX_TAG_RE2 = /^[0-9a-fA-F]{8}$/;
|
|
@@ -583,6 +601,53 @@ function extractTags(record, keepKeywords) {
|
|
|
583
601
|
}
|
|
584
602
|
return found ? tags : void 0;
|
|
585
603
|
}
|
|
604
|
+
var DICOM_ORG_ROOT = "1.2.840.10008.";
|
|
605
|
+
var UID_SWEEP_EXCLUDE = /* @__PURE__ */ new Set([
|
|
606
|
+
"StudyInstanceUID",
|
|
607
|
+
"SeriesInstanceUID",
|
|
608
|
+
"SOPClassUID"
|
|
609
|
+
]);
|
|
610
|
+
function vrOf(keyword) {
|
|
611
|
+
const nm = dcmjsAny.data.DicomMetaDictionary.nameMap;
|
|
612
|
+
return (nm?.[keyword] ?? nm?.[`RETIRED_${keyword}`])?.vr;
|
|
613
|
+
}
|
|
614
|
+
function hashUidVia(map, salt, original) {
|
|
615
|
+
let synthetic = map.get(original);
|
|
616
|
+
if (synthetic === void 0) {
|
|
617
|
+
synthetic = hashUid(salt, original);
|
|
618
|
+
map.set(original, synthetic);
|
|
619
|
+
}
|
|
620
|
+
return synthetic;
|
|
621
|
+
}
|
|
622
|
+
function collectUidOriginals(record) {
|
|
623
|
+
const uids = {};
|
|
624
|
+
let found = false;
|
|
625
|
+
for (const [keyword, value] of Object.entries(record)) {
|
|
626
|
+
if (UID_SWEEP_EXCLUDE.has(keyword)) continue;
|
|
627
|
+
if (typeof value !== "string" || value.startsWith(DICOM_ORG_ROOT)) continue;
|
|
628
|
+
if (vrOf(keyword) !== "UI") continue;
|
|
629
|
+
uids[keyword] = value;
|
|
630
|
+
found = true;
|
|
631
|
+
}
|
|
632
|
+
return found ? uids : void 0;
|
|
633
|
+
}
|
|
634
|
+
function hashUidTags(uids, salt, map) {
|
|
635
|
+
const tags = {};
|
|
636
|
+
for (const [keyword, original] of Object.entries(uids)) {
|
|
637
|
+
tags[keyword] = hashUidVia(map, salt, original);
|
|
638
|
+
}
|
|
639
|
+
return tags;
|
|
640
|
+
}
|
|
641
|
+
function deriveSalt(studyUids) {
|
|
642
|
+
const h = createHash2("sha256");
|
|
643
|
+
for (const uid of [...studyUids].sort()) h.update(uid).update("\n");
|
|
644
|
+
return h.digest("hex");
|
|
645
|
+
}
|
|
646
|
+
function mergeTags(a, b) {
|
|
647
|
+
if (!a) return b;
|
|
648
|
+
if (!b) return a;
|
|
649
|
+
return { ...a, ...b };
|
|
650
|
+
}
|
|
586
651
|
function sizeKbOf(bytes) {
|
|
587
652
|
return Math.max(1, Math.round(bytes / 1024));
|
|
588
653
|
}
|
|
@@ -687,7 +752,9 @@ function seriesSpecFromAccum(accum) {
|
|
|
687
752
|
}
|
|
688
753
|
function describeDirectory(dir, options = {}) {
|
|
689
754
|
const keepKeywords = options.keepTags ? resolveKeepTags(options.keepTags) : [];
|
|
690
|
-
const
|
|
755
|
+
const preserveUids = options.preserveUids ?? true;
|
|
756
|
+
const uidMap = /* @__PURE__ */ new Map();
|
|
757
|
+
const useRecords = keepKeywords.length > 0 || preserveUids;
|
|
691
758
|
const tolerance = options.sizeTolerance ?? 0;
|
|
692
759
|
if (!Number.isFinite(tolerance) || tolerance < 0) {
|
|
693
760
|
throw new Error(
|
|
@@ -725,9 +792,16 @@ function describeDirectory(dir, options = {}) {
|
|
|
725
792
|
stats.unknownModality++;
|
|
726
793
|
}
|
|
727
794
|
}
|
|
728
|
-
const
|
|
795
|
+
const keptTags = keepKeywords.length ? extractTags(record, keepKeywords) : void 0;
|
|
796
|
+
const uidOriginals = preserveUids ? collectUidOriginals(record) : void 0;
|
|
729
797
|
const bytes = large ? size : Math.min(bucketBytes(size, tolerance), MAX_PIXEL_BYTES);
|
|
730
|
-
const fileRecord = {
|
|
798
|
+
const fileRecord = {
|
|
799
|
+
large,
|
|
800
|
+
bytes,
|
|
801
|
+
modality,
|
|
802
|
+
tags: keptTags,
|
|
803
|
+
uidOriginals
|
|
804
|
+
};
|
|
731
805
|
let study = studies.get(studyUid);
|
|
732
806
|
if (!study) {
|
|
733
807
|
study = /* @__PURE__ */ new Map();
|
|
@@ -735,7 +809,7 @@ function describeDirectory(dir, options = {}) {
|
|
|
735
809
|
}
|
|
736
810
|
let series = study.get(seriesUid);
|
|
737
811
|
if (!series) {
|
|
738
|
-
series =
|
|
812
|
+
series = useRecords ? [] : /* @__PURE__ */ new Map();
|
|
739
813
|
study.set(seriesUid, series);
|
|
740
814
|
}
|
|
741
815
|
if (Array.isArray(series)) {
|
|
@@ -744,13 +818,45 @@ function describeDirectory(dir, options = {}) {
|
|
|
744
818
|
accumulate(series, fileRecord);
|
|
745
819
|
}
|
|
746
820
|
}
|
|
747
|
-
const
|
|
748
|
-
|
|
749
|
-
|
|
821
|
+
const salt = preserveUids ? options.uidSalt ?? deriveSalt([...studies.keys()]) : void 0;
|
|
822
|
+
if (salt !== void 0) {
|
|
823
|
+
for (const study of studies.values()) {
|
|
824
|
+
for (const series of study.values()) {
|
|
825
|
+
if (!Array.isArray(series)) continue;
|
|
826
|
+
for (const rec of series) {
|
|
827
|
+
if (rec.uidOriginals) {
|
|
828
|
+
rec.tags = mergeTags(
|
|
829
|
+
rec.tags,
|
|
830
|
+
hashUidTags(rec.uidOriginals, salt, uidMap)
|
|
831
|
+
);
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
const withGroupUid = (spec2, seriesUid) => salt === void 0 ? spec2 : {
|
|
838
|
+
...spec2,
|
|
839
|
+
tags: {
|
|
840
|
+
...spec2.tags,
|
|
841
|
+
SeriesInstanceUID: hashUidVia(uidMap, salt, seriesUid)
|
|
842
|
+
}
|
|
843
|
+
};
|
|
844
|
+
const studySpecs = [...studies.entries()].map(
|
|
845
|
+
([studyUid, study]) => ({
|
|
846
|
+
...salt !== void 0 ? { tags: { StudyInstanceUID: hashUidVia(uidMap, salt, studyUid) } } : {},
|
|
847
|
+
series: [...study.entries()].map(
|
|
848
|
+
([seriesUid, accum]) => withGroupUid(seriesSpecFromAccum(accum), seriesUid)
|
|
849
|
+
)
|
|
850
|
+
})
|
|
851
|
+
);
|
|
750
852
|
if (studySpecs.length === 0) {
|
|
751
853
|
throw new Error(`describe: no DICOM series found in ${dir}`);
|
|
752
854
|
}
|
|
753
|
-
const spec = {
|
|
855
|
+
const spec = {
|
|
856
|
+
studies: studySpecs,
|
|
857
|
+
layout: "hierarchical",
|
|
858
|
+
...salt !== void 0 ? { uidSalt: salt } : {}
|
|
859
|
+
};
|
|
754
860
|
validateDatasetSpec(spec);
|
|
755
861
|
return { spec, stats };
|
|
756
862
|
}
|
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`);
|
|
@@ -455,6 +462,7 @@ function validateDatasetSpec(raw) {
|
|
|
455
462
|
(study, i) => validateStudy(study, `studies[${i}]`)
|
|
456
463
|
);
|
|
457
464
|
if ("seed" in r) validateSeed(r.seed, "DatasetSpec.seed");
|
|
465
|
+
if ("uidSalt" in r) validateUidSalt(r.uidSalt, "DatasetSpec.uidSalt");
|
|
458
466
|
if ("layout" in r) {
|
|
459
467
|
validateEnum(r.layout, VALID_LAYOUTS, "DatasetSpec.layout");
|
|
460
468
|
}
|
|
@@ -470,6 +478,7 @@ function validateDatasetSpec(raw) {
|
|
|
470
478
|
...entries.length > 0 ? { entries } : {},
|
|
471
479
|
...studies.length > 0 ? { studies } : {},
|
|
472
480
|
...r.seed !== void 0 ? { seed: r.seed } : {},
|
|
481
|
+
...r.uidSalt !== void 0 ? { uidSalt: r.uidSalt } : {},
|
|
473
482
|
...r.layout !== void 0 ? { layout: r.layout } : {},
|
|
474
483
|
...r.pathQuirks !== void 0 ? { pathQuirks: r.pathQuirks } : {}
|
|
475
484
|
};
|
|
@@ -585,6 +594,7 @@ function validateParametricSpec(raw) {
|
|
|
585
594
|
}
|
|
586
595
|
}
|
|
587
596
|
if ("seed" in r) validateSeed(r.seed, "ParametricSpec.seed");
|
|
597
|
+
if ("uidSalt" in r) validateUidSalt(r.uidSalt, "ParametricSpec.uidSalt");
|
|
588
598
|
if ("layout" in r) {
|
|
589
599
|
validateEnum(r.layout, VALID_LAYOUTS, "ParametricSpec.layout");
|
|
590
600
|
}
|
|
@@ -592,6 +602,7 @@ function validateParametricSpec(raw) {
|
|
|
592
602
|
studies,
|
|
593
603
|
...edgeCases.length > 0 ? { edgeCases } : {},
|
|
594
604
|
...r.seed !== void 0 ? { seed: r.seed } : {},
|
|
605
|
+
...r.uidSalt !== void 0 ? { uidSalt: r.uidSalt } : {},
|
|
595
606
|
...r.layout !== void 0 ? { layout: r.layout } : {}
|
|
596
607
|
};
|
|
597
608
|
}
|
|
@@ -779,8 +790,19 @@ import { closeSync, mkdirSync as mkdirSync2, openSync, writeSync } from "node:fs
|
|
|
779
790
|
import { resolve as resolve2 } from "node:path";
|
|
780
791
|
|
|
781
792
|
// src/syntheticFixtures/uid.ts
|
|
782
|
-
import { randomBytes } from "node:crypto";
|
|
783
|
-
|
|
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
|
+
}
|
|
784
806
|
function lcg(seed) {
|
|
785
807
|
let s = seed >>> 0;
|
|
786
808
|
return () => {
|
|
@@ -788,60 +810,28 @@ function lcg(seed) {
|
|
|
788
810
|
return s;
|
|
789
811
|
};
|
|
790
812
|
}
|
|
791
|
-
function formatUid(a, b, role) {
|
|
792
|
-
return `2.25.${a}.${b}.${ROLE_CODE[role]}`;
|
|
793
|
-
}
|
|
794
|
-
function randomUid(role) {
|
|
795
|
-
const buf = randomBytes(8);
|
|
796
|
-
return formatUid(buf.readUInt32BE(0), buf.readUInt32BE(4), role);
|
|
797
|
-
}
|
|
798
|
-
function seededUid(next, role) {
|
|
799
|
-
return formatUid(next(), next(), role);
|
|
800
|
-
}
|
|
801
|
-
var GROUP_STREAM_OFFSET = 2654435769;
|
|
802
813
|
var PARAMETRIC_STREAM_OFFSET = 2246822507;
|
|
803
814
|
function seededStream(seed, offset, a, b) {
|
|
804
815
|
return lcg(seed * 999983 + offset + a * 1000003 + b * 7919 >>> 0);
|
|
805
816
|
}
|
|
806
|
-
function makeGroupUidGenerator(
|
|
807
|
-
if (
|
|
808
|
-
return {
|
|
809
|
-
study: () => randomUid("study"),
|
|
810
|
-
series: () => randomUid("series")
|
|
811
|
-
};
|
|
817
|
+
function makeGroupUidGenerator(salt) {
|
|
818
|
+
if (salt === void 0) {
|
|
819
|
+
return { study: () => randomUid(), series: () => randomUid() };
|
|
812
820
|
}
|
|
813
821
|
return {
|
|
814
|
-
study: (studyIndex) =>
|
|
815
|
-
|
|
816
|
-
"study"
|
|
817
|
-
),
|
|
818
|
-
series: (studyIndex, seriesIndex) => seededUid(
|
|
819
|
-
seededStream(
|
|
820
|
-
seed,
|
|
821
|
-
GROUP_STREAM_OFFSET,
|
|
822
|
-
studyIndex + 1,
|
|
823
|
-
seriesIndex + 1
|
|
824
|
-
),
|
|
825
|
-
"series"
|
|
826
|
-
)
|
|
822
|
+
study: (studyIndex) => hashUid(salt, `study:${studyIndex}`),
|
|
823
|
+
series: (studyIndex, seriesIndex) => hashUid(salt, `series:${studyIndex}:${seriesIndex}`)
|
|
827
824
|
};
|
|
828
825
|
}
|
|
829
|
-
function makeUidGenerator(
|
|
830
|
-
if (
|
|
831
|
-
return () => ({
|
|
832
|
-
study: randomUid("study"),
|
|
833
|
-
series: randomUid("series"),
|
|
834
|
-
sop: randomUid("sop")
|
|
835
|
-
});
|
|
826
|
+
function makeUidGenerator(salt) {
|
|
827
|
+
if (salt === void 0) {
|
|
828
|
+
return () => ({ study: randomUid(), series: randomUid(), sop: randomUid() });
|
|
836
829
|
}
|
|
837
|
-
return (fileIndex) => {
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
sop: seededUid(next, "sop")
|
|
843
|
-
};
|
|
844
|
-
};
|
|
830
|
+
return (fileIndex) => ({
|
|
831
|
+
study: hashUid(salt, `study:flat:${fileIndex}`),
|
|
832
|
+
series: hashUid(salt, `series:flat:${fileIndex}`),
|
|
833
|
+
sop: hashUid(salt, `sop:${fileIndex}`)
|
|
834
|
+
});
|
|
845
835
|
}
|
|
846
836
|
|
|
847
837
|
// src/syntheticFixtures/streamWrite.ts
|
|
@@ -954,7 +944,7 @@ function streamLargeImage(outPath, options) {
|
|
|
954
944
|
fragmentBytes = FRAGMENT_MAX_BYTES
|
|
955
945
|
} = options;
|
|
956
946
|
const modality = options.modality ?? "CT";
|
|
957
|
-
const uid = options.uid ?? makeUidGenerator(options.seed)(0);
|
|
947
|
+
const uid = options.uid ?? makeUidGenerator(options.uidSalt ?? seedToSalt(options.seed))(0);
|
|
958
948
|
const identity = { modality, uid, tags: options.tags };
|
|
959
949
|
mkdirSync2(resolve2(outPath, ".."), { recursive: true });
|
|
960
950
|
const zeroChunk = Buffer.alloc(Math.max(1, chunkBytes));
|
|
@@ -1006,6 +996,7 @@ function writeLargeImageFile(spec, outPath, options = {}) {
|
|
|
1006
996
|
return streamLargeImage(outPath, {
|
|
1007
997
|
targetBytes,
|
|
1008
998
|
modality: spec.modality,
|
|
999
|
+
uidSalt: spec.uidSalt,
|
|
1009
1000
|
seed: spec.seed,
|
|
1010
1001
|
chunkBytes: options.chunkBytes
|
|
1011
1002
|
});
|
|
@@ -1153,13 +1144,16 @@ function* seriesFiles(series) {
|
|
|
1153
1144
|
}
|
|
1154
1145
|
}
|
|
1155
1146
|
}
|
|
1156
|
-
function resolveUid(
|
|
1157
|
-
return { ...makeUidGenerator(
|
|
1147
|
+
function resolveUid(salt, index, override) {
|
|
1148
|
+
return { ...makeUidGenerator(salt)(index), ...override };
|
|
1149
|
+
}
|
|
1150
|
+
function effectiveSalt(spec) {
|
|
1151
|
+
return spec.uidSalt ?? seedToSalt(spec.seed);
|
|
1158
1152
|
}
|
|
1159
1153
|
async function generateFile(spec, options) {
|
|
1160
1154
|
const index = options?.index ?? 0;
|
|
1161
1155
|
const padWidth = options?.padWidth ?? 3;
|
|
1162
|
-
const uid = resolveUid(options
|
|
1156
|
+
const uid = resolveUid(effectiveSalt(options ?? {}), index, options?.uid);
|
|
1163
1157
|
const resolvedSpec = "tags" in spec && spec.tags ? {
|
|
1164
1158
|
...spec,
|
|
1165
1159
|
tags: resolveTagTemplates(spec.tags, { index, ...options?.context })
|
|
@@ -1231,6 +1225,9 @@ function hierarchicalName(studyOrdinal, seriesIndex, instanceNumber) {
|
|
|
1231
1225
|
function withResolvedSeed(spec) {
|
|
1232
1226
|
return spec.seed === void 0 ? { ...spec, seed: randomBytes2(4).readUInt32BE(0) } : spec;
|
|
1233
1227
|
}
|
|
1228
|
+
function withResolvedSalt(spec) {
|
|
1229
|
+
return spec.uidSalt === void 0 ? { ...spec, uidSalt: randomBytes2(16).toString("hex") } : spec;
|
|
1230
|
+
}
|
|
1234
1231
|
function computeNames(type, index, padWidth, grouped, quirks) {
|
|
1235
1232
|
let names;
|
|
1236
1233
|
if (grouped) {
|
|
@@ -1271,7 +1268,7 @@ function* planCollection(spec) {
|
|
|
1271
1268
|
globalIndex++;
|
|
1272
1269
|
}
|
|
1273
1270
|
}
|
|
1274
|
-
const groupUids = makeGroupUidGenerator(spec
|
|
1271
|
+
const groupUids = makeGroupUidGenerator(effectiveSalt(spec));
|
|
1275
1272
|
let studyOrdinal = 0;
|
|
1276
1273
|
for (const study of studies) {
|
|
1277
1274
|
const copies = study.count ?? 1;
|
|
@@ -1313,21 +1310,23 @@ function* planCollection(spec) {
|
|
|
1313
1310
|
}
|
|
1314
1311
|
}
|
|
1315
1312
|
}
|
|
1316
|
-
async function materialisePlan(plan,
|
|
1313
|
+
async function materialisePlan(plan, salt) {
|
|
1317
1314
|
const file = await generateFile(plan.fileSpec, {
|
|
1318
1315
|
index: plan.index,
|
|
1319
|
-
|
|
1316
|
+
uidSalt: salt,
|
|
1320
1317
|
uid: plan.uidOverride,
|
|
1321
1318
|
context: plan.context
|
|
1322
1319
|
});
|
|
1323
1320
|
return { ...file, filename: plan.filename, relativePath: plan.relativePath };
|
|
1324
1321
|
}
|
|
1325
1322
|
async function* generateCollectionFromSpec(spec) {
|
|
1323
|
+
const salt = effectiveSalt(spec);
|
|
1326
1324
|
for (const plan of planCollection(spec)) {
|
|
1327
|
-
yield await materialisePlan(plan,
|
|
1325
|
+
yield await materialisePlan(plan, salt);
|
|
1328
1326
|
}
|
|
1329
1327
|
}
|
|
1330
1328
|
async function* previewCollection(spec) {
|
|
1329
|
+
const salt = effectiveSalt(spec);
|
|
1331
1330
|
for (const plan of planCollection(spec)) {
|
|
1332
1331
|
if (plan.fileSpec.type === "large-image") {
|
|
1333
1332
|
yield {
|
|
@@ -1337,7 +1336,7 @@ async function* previewCollection(spec) {
|
|
|
1337
1336
|
approxBytes: plan.fileSpec.targetBytes
|
|
1338
1337
|
};
|
|
1339
1338
|
} else {
|
|
1340
|
-
const file = await materialisePlan(plan,
|
|
1339
|
+
const file = await materialisePlan(plan, salt);
|
|
1341
1340
|
yield {
|
|
1342
1341
|
relativePath: plan.relativePath,
|
|
1343
1342
|
type: plan.fileSpec.type,
|
|
@@ -1351,6 +1350,7 @@ async function writeCollectionFromSpec(spec, outDir) {
|
|
|
1351
1350
|
const root = resolve3(outDir);
|
|
1352
1351
|
mkdirSync3(root, { recursive: true });
|
|
1353
1352
|
const manifest = [];
|
|
1353
|
+
const salt = effectiveSalt(spec);
|
|
1354
1354
|
const createdDirs = /* @__PURE__ */ new Set([root]);
|
|
1355
1355
|
const ensureDir = (filePath) => {
|
|
1356
1356
|
const dir = dirname(filePath);
|
|
@@ -1369,7 +1369,7 @@ async function writeCollectionFromSpec(spec, outDir) {
|
|
|
1369
1369
|
`large-image targetBytes (${large.targetBytes}) exceeds the 50 GB limit`
|
|
1370
1370
|
);
|
|
1371
1371
|
}
|
|
1372
|
-
const uid = resolveUid(
|
|
1372
|
+
const uid = resolveUid(salt, plan.index, plan.uidOverride);
|
|
1373
1373
|
streamLargeImage(filePath, {
|
|
1374
1374
|
targetBytes: large.targetBytes,
|
|
1375
1375
|
modality: large.modality,
|
|
@@ -1377,7 +1377,7 @@ async function writeCollectionFromSpec(spec, outDir) {
|
|
|
1377
1377
|
tags: resolveTagTemplates(large.tags, plan.context)
|
|
1378
1378
|
});
|
|
1379
1379
|
} else {
|
|
1380
|
-
const file = await materialisePlan(plan,
|
|
1380
|
+
const file = await materialisePlan(plan, salt);
|
|
1381
1381
|
await writeFile(filePath, file.buffer);
|
|
1382
1382
|
}
|
|
1383
1383
|
manifest.push({
|
|
@@ -1390,6 +1390,7 @@ async function writeCollectionFromSpec(spec, outDir) {
|
|
|
1390
1390
|
}
|
|
1391
1391
|
|
|
1392
1392
|
// src/describe/describe.ts
|
|
1393
|
+
import { createHash as createHash2 } from "node:crypto";
|
|
1393
1394
|
import {
|
|
1394
1395
|
closeSync as closeSync2,
|
|
1395
1396
|
openSync as openSync2,
|
|
@@ -1555,6 +1556,53 @@ function extractTags(record, keepKeywords) {
|
|
|
1555
1556
|
}
|
|
1556
1557
|
return found ? tags : void 0;
|
|
1557
1558
|
}
|
|
1559
|
+
var DICOM_ORG_ROOT = "1.2.840.10008.";
|
|
1560
|
+
var UID_SWEEP_EXCLUDE = /* @__PURE__ */ new Set([
|
|
1561
|
+
"StudyInstanceUID",
|
|
1562
|
+
"SeriesInstanceUID",
|
|
1563
|
+
"SOPClassUID"
|
|
1564
|
+
]);
|
|
1565
|
+
function vrOf(keyword) {
|
|
1566
|
+
const nm = dcmjsAny2.data.DicomMetaDictionary.nameMap;
|
|
1567
|
+
return (nm?.[keyword] ?? nm?.[`RETIRED_${keyword}`])?.vr;
|
|
1568
|
+
}
|
|
1569
|
+
function hashUidVia(map, salt, original) {
|
|
1570
|
+
let synthetic = map.get(original);
|
|
1571
|
+
if (synthetic === void 0) {
|
|
1572
|
+
synthetic = hashUid(salt, original);
|
|
1573
|
+
map.set(original, synthetic);
|
|
1574
|
+
}
|
|
1575
|
+
return synthetic;
|
|
1576
|
+
}
|
|
1577
|
+
function collectUidOriginals(record) {
|
|
1578
|
+
const uids = {};
|
|
1579
|
+
let found = false;
|
|
1580
|
+
for (const [keyword, value] of Object.entries(record)) {
|
|
1581
|
+
if (UID_SWEEP_EXCLUDE.has(keyword)) continue;
|
|
1582
|
+
if (typeof value !== "string" || value.startsWith(DICOM_ORG_ROOT)) continue;
|
|
1583
|
+
if (vrOf(keyword) !== "UI") continue;
|
|
1584
|
+
uids[keyword] = value;
|
|
1585
|
+
found = true;
|
|
1586
|
+
}
|
|
1587
|
+
return found ? uids : void 0;
|
|
1588
|
+
}
|
|
1589
|
+
function hashUidTags(uids, salt, map) {
|
|
1590
|
+
const tags = {};
|
|
1591
|
+
for (const [keyword, original] of Object.entries(uids)) {
|
|
1592
|
+
tags[keyword] = hashUidVia(map, salt, original);
|
|
1593
|
+
}
|
|
1594
|
+
return tags;
|
|
1595
|
+
}
|
|
1596
|
+
function deriveSalt(studyUids) {
|
|
1597
|
+
const h = createHash2("sha256");
|
|
1598
|
+
for (const uid of [...studyUids].sort()) h.update(uid).update("\n");
|
|
1599
|
+
return h.digest("hex");
|
|
1600
|
+
}
|
|
1601
|
+
function mergeTags(a, b) {
|
|
1602
|
+
if (!a) return b;
|
|
1603
|
+
if (!b) return a;
|
|
1604
|
+
return { ...a, ...b };
|
|
1605
|
+
}
|
|
1558
1606
|
function sizeKbOf(bytes) {
|
|
1559
1607
|
return Math.max(1, Math.round(bytes / 1024));
|
|
1560
1608
|
}
|
|
@@ -1659,7 +1707,9 @@ function seriesSpecFromAccum(accum) {
|
|
|
1659
1707
|
}
|
|
1660
1708
|
function describeDirectory(dir, options = {}) {
|
|
1661
1709
|
const keepKeywords = options.keepTags ? resolveKeepTags(options.keepTags) : [];
|
|
1662
|
-
const
|
|
1710
|
+
const preserveUids = options.preserveUids ?? true;
|
|
1711
|
+
const uidMap = /* @__PURE__ */ new Map();
|
|
1712
|
+
const useRecords = keepKeywords.length > 0 || preserveUids;
|
|
1663
1713
|
const tolerance = options.sizeTolerance ?? 0;
|
|
1664
1714
|
if (!Number.isFinite(tolerance) || tolerance < 0) {
|
|
1665
1715
|
throw new Error(
|
|
@@ -1697,9 +1747,16 @@ function describeDirectory(dir, options = {}) {
|
|
|
1697
1747
|
stats.unknownModality++;
|
|
1698
1748
|
}
|
|
1699
1749
|
}
|
|
1700
|
-
const
|
|
1750
|
+
const keptTags = keepKeywords.length ? extractTags(record, keepKeywords) : void 0;
|
|
1751
|
+
const uidOriginals = preserveUids ? collectUidOriginals(record) : void 0;
|
|
1701
1752
|
const bytes = large ? size : Math.min(bucketBytes(size, tolerance), MAX_PIXEL_BYTES);
|
|
1702
|
-
const fileRecord = {
|
|
1753
|
+
const fileRecord = {
|
|
1754
|
+
large,
|
|
1755
|
+
bytes,
|
|
1756
|
+
modality,
|
|
1757
|
+
tags: keptTags,
|
|
1758
|
+
uidOriginals
|
|
1759
|
+
};
|
|
1703
1760
|
let study = studies.get(studyUid);
|
|
1704
1761
|
if (!study) {
|
|
1705
1762
|
study = /* @__PURE__ */ new Map();
|
|
@@ -1707,7 +1764,7 @@ function describeDirectory(dir, options = {}) {
|
|
|
1707
1764
|
}
|
|
1708
1765
|
let series = study.get(seriesUid);
|
|
1709
1766
|
if (!series) {
|
|
1710
|
-
series =
|
|
1767
|
+
series = useRecords ? [] : /* @__PURE__ */ new Map();
|
|
1711
1768
|
study.set(seriesUid, series);
|
|
1712
1769
|
}
|
|
1713
1770
|
if (Array.isArray(series)) {
|
|
@@ -1716,13 +1773,45 @@ function describeDirectory(dir, options = {}) {
|
|
|
1716
1773
|
accumulate(series, fileRecord);
|
|
1717
1774
|
}
|
|
1718
1775
|
}
|
|
1719
|
-
const
|
|
1720
|
-
|
|
1721
|
-
|
|
1776
|
+
const salt = preserveUids ? options.uidSalt ?? deriveSalt([...studies.keys()]) : void 0;
|
|
1777
|
+
if (salt !== void 0) {
|
|
1778
|
+
for (const study of studies.values()) {
|
|
1779
|
+
for (const series of study.values()) {
|
|
1780
|
+
if (!Array.isArray(series)) continue;
|
|
1781
|
+
for (const rec of series) {
|
|
1782
|
+
if (rec.uidOriginals) {
|
|
1783
|
+
rec.tags = mergeTags(
|
|
1784
|
+
rec.tags,
|
|
1785
|
+
hashUidTags(rec.uidOriginals, salt, uidMap)
|
|
1786
|
+
);
|
|
1787
|
+
}
|
|
1788
|
+
}
|
|
1789
|
+
}
|
|
1790
|
+
}
|
|
1791
|
+
}
|
|
1792
|
+
const withGroupUid = (spec2, seriesUid) => salt === void 0 ? spec2 : {
|
|
1793
|
+
...spec2,
|
|
1794
|
+
tags: {
|
|
1795
|
+
...spec2.tags,
|
|
1796
|
+
SeriesInstanceUID: hashUidVia(uidMap, salt, seriesUid)
|
|
1797
|
+
}
|
|
1798
|
+
};
|
|
1799
|
+
const studySpecs = [...studies.entries()].map(
|
|
1800
|
+
([studyUid, study]) => ({
|
|
1801
|
+
...salt !== void 0 ? { tags: { StudyInstanceUID: hashUidVia(uidMap, salt, studyUid) } } : {},
|
|
1802
|
+
series: [...study.entries()].map(
|
|
1803
|
+
([seriesUid, accum]) => withGroupUid(seriesSpecFromAccum(accum), seriesUid)
|
|
1804
|
+
)
|
|
1805
|
+
})
|
|
1806
|
+
);
|
|
1722
1807
|
if (studySpecs.length === 0) {
|
|
1723
1808
|
throw new Error(`describe: no DICOM series found in ${dir}`);
|
|
1724
1809
|
}
|
|
1725
|
-
const spec = {
|
|
1810
|
+
const spec = {
|
|
1811
|
+
studies: studySpecs,
|
|
1812
|
+
layout: "hierarchical",
|
|
1813
|
+
...salt !== void 0 ? { uidSalt: salt } : {}
|
|
1814
|
+
};
|
|
1726
1815
|
validateDatasetSpec(spec);
|
|
1727
1816
|
return { spec, stats };
|
|
1728
1817
|
}
|
|
@@ -1759,7 +1848,7 @@ function loadCaseById(casesJsonPath, id) {
|
|
|
1759
1848
|
}
|
|
1760
1849
|
|
|
1761
1850
|
// src/public-fixtures/fetch.ts
|
|
1762
|
-
import { createHash } from "node:crypto";
|
|
1851
|
+
import { createHash as createHash3 } from "node:crypto";
|
|
1763
1852
|
import { existsSync, mkdirSync as mkdirSync4, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "node:fs";
|
|
1764
1853
|
import { homedir } from "node:os";
|
|
1765
1854
|
import { join as join3 } from "node:path";
|
|
@@ -1768,7 +1857,7 @@ function caseCachePath(sha256, root = DEFAULT_CACHE_ROOT) {
|
|
|
1768
1857
|
return join3(root, sha256, "file.dcm");
|
|
1769
1858
|
}
|
|
1770
1859
|
function verifySha256(buffer, expected) {
|
|
1771
|
-
const h =
|
|
1860
|
+
const h = createHash3("sha256").update(buffer).digest("hex");
|
|
1772
1861
|
if (h !== expected) {
|
|
1773
1862
|
throw new Error(
|
|
1774
1863
|
`SHA256 mismatch: expected ${expected}, got ${h}. Upstream may have changed; bump metadata after review.`
|
|
@@ -1900,6 +1989,9 @@ function resolveParametricSpec(spec) {
|
|
|
1900
1989
|
...entries.length > 0 ? { entries } : {},
|
|
1901
1990
|
studies,
|
|
1902
1991
|
seed,
|
|
1992
|
+
// Pass an explicit salt through; when absent, UIDs derive from `seed` so the
|
|
1993
|
+
// resolved spec stays deterministic ("same seed → identical resolved spec").
|
|
1994
|
+
...validated.uidSalt !== void 0 ? { uidSalt: validated.uidSalt } : {},
|
|
1903
1995
|
...validated.layout !== void 0 ? { layout: validated.layout } : {}
|
|
1904
1996
|
};
|
|
1905
1997
|
}
|
|
@@ -1913,16 +2005,19 @@ export {
|
|
|
1913
2005
|
fetchPublicCaseToCache,
|
|
1914
2006
|
generateCollectionFromSpec,
|
|
1915
2007
|
generateFile,
|
|
2008
|
+
hashUid,
|
|
1916
2009
|
loadCaseById,
|
|
1917
2010
|
loadCasesFromJson,
|
|
1918
2011
|
loadDefaultCases,
|
|
1919
2012
|
previewCollection,
|
|
1920
2013
|
resolveParametricSpec,
|
|
1921
2014
|
resolveTagTemplates,
|
|
2015
|
+
seedToSalt,
|
|
1922
2016
|
streamLargeImage,
|
|
1923
2017
|
validateDatasetSpec,
|
|
1924
2018
|
validateParametricSpec,
|
|
1925
2019
|
verifySha256,
|
|
2020
|
+
withResolvedSalt,
|
|
1926
2021
|
withResolvedSeed,
|
|
1927
2022
|
writeCollectionFromSpec,
|
|
1928
2023
|
writeLargeImageFile
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { randomBytes as randomBytes2 } from "node:crypto";
|
|
3
3
|
|
|
4
4
|
// src/syntheticFixtures/uid.ts
|
|
5
|
-
import { randomBytes } from "node:crypto";
|
|
5
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
6
6
|
function lcg(seed) {
|
|
7
7
|
let s = seed >>> 0;
|
|
8
8
|
return () => {
|
|
@@ -90,6 +90,13 @@ function validateSeed(value, path) {
|
|
|
90
90
|
);
|
|
91
91
|
}
|
|
92
92
|
}
|
|
93
|
+
function validateUidSalt(value, path) {
|
|
94
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
95
|
+
throw new Error(
|
|
96
|
+
`${path}: must be a non-empty string; got ${JSON.stringify(value)}`
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
93
100
|
function validateSizeKbLimit(kb, path) {
|
|
94
101
|
if (kb * 1024 > MAX_PIXEL_BYTES) {
|
|
95
102
|
throw new Error(`${path}: exceeds the 512 MB limit`);
|
|
@@ -361,6 +368,7 @@ function validateParametricSpec(raw) {
|
|
|
361
368
|
}
|
|
362
369
|
}
|
|
363
370
|
if ("seed" in r) validateSeed(r.seed, "ParametricSpec.seed");
|
|
371
|
+
if ("uidSalt" in r) validateUidSalt(r.uidSalt, "ParametricSpec.uidSalt");
|
|
364
372
|
if ("layout" in r) {
|
|
365
373
|
validateEnum(r.layout, VALID_LAYOUTS, "ParametricSpec.layout");
|
|
366
374
|
}
|
|
@@ -368,6 +376,7 @@ function validateParametricSpec(raw) {
|
|
|
368
376
|
studies,
|
|
369
377
|
...edgeCases.length > 0 ? { edgeCases } : {},
|
|
370
378
|
...r.seed !== void 0 ? { seed: r.seed } : {},
|
|
379
|
+
...r.uidSalt !== void 0 ? { uidSalt: r.uidSalt } : {},
|
|
371
380
|
...r.layout !== void 0 ? { layout: r.layout } : {}
|
|
372
381
|
};
|
|
373
382
|
}
|
|
@@ -471,6 +480,9 @@ function resolveParametricSpec(spec) {
|
|
|
471
480
|
...entries.length > 0 ? { entries } : {},
|
|
472
481
|
studies,
|
|
473
482
|
seed,
|
|
483
|
+
// Pass an explicit salt through; when absent, UIDs derive from `seed` so the
|
|
484
|
+
// resolved spec stays deterministic ("same seed → identical resolved spec").
|
|
485
|
+
...validated.uidSalt !== void 0 ? { uidSalt: validated.uidSalt } : {},
|
|
474
486
|
...validated.layout !== void 0 ? { layout: validated.layout } : {}
|
|
475
487
|
};
|
|
476
488
|
}
|
|
@@ -79,6 +79,13 @@ function validateSeed(value, path) {
|
|
|
79
79
|
);
|
|
80
80
|
}
|
|
81
81
|
}
|
|
82
|
+
function validateUidSalt(value, path) {
|
|
83
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
84
|
+
throw new Error(
|
|
85
|
+
`${path}: must be a non-empty string; got ${JSON.stringify(value)}`
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
82
89
|
function validateSizeKbLimit(kb, path) {
|
|
83
90
|
if (kb * 1024 > MAX_PIXEL_BYTES) {
|
|
84
91
|
throw new Error(`${path}: exceeds the 512 MB limit`);
|
|
@@ -380,6 +387,7 @@ function validateDatasetSpec(raw) {
|
|
|
380
387
|
(study, i) => validateStudy(study, `studies[${i}]`)
|
|
381
388
|
);
|
|
382
389
|
if ("seed" in r) validateSeed(r.seed, "DatasetSpec.seed");
|
|
390
|
+
if ("uidSalt" in r) validateUidSalt(r.uidSalt, "DatasetSpec.uidSalt");
|
|
383
391
|
if ("layout" in r) {
|
|
384
392
|
validateEnum(r.layout, VALID_LAYOUTS, "DatasetSpec.layout");
|
|
385
393
|
}
|
|
@@ -395,6 +403,7 @@ function validateDatasetSpec(raw) {
|
|
|
395
403
|
...entries.length > 0 ? { entries } : {},
|
|
396
404
|
...studies.length > 0 ? { studies } : {},
|
|
397
405
|
...r.seed !== void 0 ? { seed: r.seed } : {},
|
|
406
|
+
...r.uidSalt !== void 0 ? { uidSalt: r.uidSalt } : {},
|
|
398
407
|
...r.layout !== void 0 ? { layout: r.layout } : {},
|
|
399
408
|
...r.pathQuirks !== void 0 ? { pathQuirks: r.pathQuirks } : {}
|
|
400
409
|
};
|
|
@@ -510,6 +519,7 @@ function validateParametricSpec(raw) {
|
|
|
510
519
|
}
|
|
511
520
|
}
|
|
512
521
|
if ("seed" in r) validateSeed(r.seed, "ParametricSpec.seed");
|
|
522
|
+
if ("uidSalt" in r) validateUidSalt(r.uidSalt, "ParametricSpec.uidSalt");
|
|
513
523
|
if ("layout" in r) {
|
|
514
524
|
validateEnum(r.layout, VALID_LAYOUTS, "ParametricSpec.layout");
|
|
515
525
|
}
|
|
@@ -517,6 +527,7 @@ function validateParametricSpec(raw) {
|
|
|
517
527
|
studies,
|
|
518
528
|
...edgeCases.length > 0 ? { edgeCases } : {},
|
|
519
529
|
...r.seed !== void 0 ? { seed: r.seed } : {},
|
|
530
|
+
...r.uidSalt !== void 0 ? { uidSalt: r.uidSalt } : {},
|
|
520
531
|
...r.layout !== void 0 ? { layout: r.layout } : {}
|
|
521
532
|
};
|
|
522
533
|
}
|
|
@@ -144,44 +144,28 @@ function serializeDict(meta, dataset) {
|
|
|
144
144
|
}
|
|
145
145
|
|
|
146
146
|
// src/syntheticFixtures/uid.ts
|
|
147
|
-
import { randomBytes } from "node:crypto";
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
return ()
|
|
152
|
-
s = Math.imul(1664525, s) + 1013904223 >>> 0;
|
|
153
|
-
return s;
|
|
154
|
-
};
|
|
155
|
-
}
|
|
156
|
-
function formatUid(a, b, role) {
|
|
157
|
-
return `2.25.${a}.${b}.${ROLE_CODE[role]}`;
|
|
147
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
148
|
+
function hashUid(salt, key) {
|
|
149
|
+
const digest = createHash("sha256").update(salt).update(" ").update(key).digest();
|
|
150
|
+
const n = BigInt(`0x${digest.subarray(0, 16).toString("hex")}`);
|
|
151
|
+
return `2.25.${n.toString()}`;
|
|
158
152
|
}
|
|
159
|
-
function randomUid(
|
|
160
|
-
const
|
|
161
|
-
return
|
|
153
|
+
function randomUid() {
|
|
154
|
+
const n = BigInt(`0x${randomBytes(16).toString("hex")}`);
|
|
155
|
+
return `2.25.${n.toString()}`;
|
|
162
156
|
}
|
|
163
|
-
function
|
|
164
|
-
return
|
|
157
|
+
function seedToSalt(seed) {
|
|
158
|
+
return seed === void 0 ? void 0 : `seed:${seed}`;
|
|
165
159
|
}
|
|
166
|
-
function
|
|
167
|
-
|
|
168
|
-
}
|
|
169
|
-
function makeUidGenerator(seed) {
|
|
170
|
-
if (seed === void 0) {
|
|
171
|
-
return () => ({
|
|
172
|
-
study: randomUid("study"),
|
|
173
|
-
series: randomUid("series"),
|
|
174
|
-
sop: randomUid("sop")
|
|
175
|
-
});
|
|
160
|
+
function makeUidGenerator(salt) {
|
|
161
|
+
if (salt === void 0) {
|
|
162
|
+
return () => ({ study: randomUid(), series: randomUid(), sop: randomUid() });
|
|
176
163
|
}
|
|
177
|
-
return (fileIndex) => {
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
sop: seededUid(next, "sop")
|
|
183
|
-
};
|
|
184
|
-
};
|
|
164
|
+
return (fileIndex) => ({
|
|
165
|
+
study: hashUid(salt, `study:flat:${fileIndex}`),
|
|
166
|
+
series: hashUid(salt, `series:flat:${fileIndex}`),
|
|
167
|
+
sop: hashUid(salt, `sop:${fileIndex}`)
|
|
168
|
+
});
|
|
185
169
|
}
|
|
186
170
|
|
|
187
171
|
// src/syntheticFixtures/streamWrite.ts
|
|
@@ -294,7 +278,7 @@ function streamLargeImage(outPath, options) {
|
|
|
294
278
|
fragmentBytes = FRAGMENT_MAX_BYTES
|
|
295
279
|
} = options;
|
|
296
280
|
const modality = options.modality ?? "CT";
|
|
297
|
-
const uid = options.uid ?? makeUidGenerator(options.seed)(0);
|
|
281
|
+
const uid = options.uid ?? makeUidGenerator(options.uidSalt ?? seedToSalt(options.seed))(0);
|
|
298
282
|
const identity = { modality, uid, tags: options.tags };
|
|
299
283
|
mkdirSync2(resolve2(outPath, ".."), { recursive: true });
|
|
300
284
|
const zeroChunk = Buffer.alloc(Math.max(1, chunkBytes));
|
|
@@ -346,6 +330,7 @@ function writeLargeImageFile(spec, outPath, options = {}) {
|
|
|
346
330
|
return streamLargeImage(outPath, {
|
|
347
331
|
targetBytes,
|
|
348
332
|
modality: spec.modality,
|
|
333
|
+
uidSalt: spec.uidSalt,
|
|
349
334
|
seed: spec.seed,
|
|
350
335
|
chunkBytes: options.chunkBytes
|
|
351
336
|
});
|
|
@@ -1,6 +1,17 @@
|
|
|
1
1
|
// src/syntheticFixtures/uid.ts
|
|
2
|
-
import { randomBytes } from "node:crypto";
|
|
3
|
-
|
|
2
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
3
|
+
function hashUid(salt, key) {
|
|
4
|
+
const digest = createHash("sha256").update(salt).update(" ").update(key).digest();
|
|
5
|
+
const n = BigInt(`0x${digest.subarray(0, 16).toString("hex")}`);
|
|
6
|
+
return `2.25.${n.toString()}`;
|
|
7
|
+
}
|
|
8
|
+
function randomUid() {
|
|
9
|
+
const n = BigInt(`0x${randomBytes(16).toString("hex")}`);
|
|
10
|
+
return `2.25.${n.toString()}`;
|
|
11
|
+
}
|
|
12
|
+
function seedToSalt(seed) {
|
|
13
|
+
return seed === void 0 ? void 0 : `seed:${seed}`;
|
|
14
|
+
}
|
|
4
15
|
function lcg(seed) {
|
|
5
16
|
let s = seed >>> 0;
|
|
6
17
|
return () => {
|
|
@@ -8,64 +19,34 @@ function lcg(seed) {
|
|
|
8
19
|
return s;
|
|
9
20
|
};
|
|
10
21
|
}
|
|
11
|
-
function formatUid(a, b, role) {
|
|
12
|
-
return `2.25.${a}.${b}.${ROLE_CODE[role]}`;
|
|
13
|
-
}
|
|
14
|
-
function randomUid(role) {
|
|
15
|
-
const buf = randomBytes(8);
|
|
16
|
-
return formatUid(buf.readUInt32BE(0), buf.readUInt32BE(4), role);
|
|
17
|
-
}
|
|
18
|
-
function seededUid(next, role) {
|
|
19
|
-
return formatUid(next(), next(), role);
|
|
20
|
-
}
|
|
21
|
-
var GROUP_STREAM_OFFSET = 2654435769;
|
|
22
22
|
var PARAMETRIC_STREAM_OFFSET = 2246822507;
|
|
23
23
|
function seededStream(seed, offset, a, b) {
|
|
24
24
|
return lcg(seed * 999983 + offset + a * 1000003 + b * 7919 >>> 0);
|
|
25
25
|
}
|
|
26
|
-
function makeGroupUidGenerator(
|
|
27
|
-
if (
|
|
28
|
-
return {
|
|
29
|
-
study: () => randomUid("study"),
|
|
30
|
-
series: () => randomUid("series")
|
|
31
|
-
};
|
|
26
|
+
function makeGroupUidGenerator(salt) {
|
|
27
|
+
if (salt === void 0) {
|
|
28
|
+
return { study: () => randomUid(), series: () => randomUid() };
|
|
32
29
|
}
|
|
33
30
|
return {
|
|
34
|
-
study: (studyIndex) =>
|
|
35
|
-
|
|
36
|
-
"study"
|
|
37
|
-
),
|
|
38
|
-
series: (studyIndex, seriesIndex) => seededUid(
|
|
39
|
-
seededStream(
|
|
40
|
-
seed,
|
|
41
|
-
GROUP_STREAM_OFFSET,
|
|
42
|
-
studyIndex + 1,
|
|
43
|
-
seriesIndex + 1
|
|
44
|
-
),
|
|
45
|
-
"series"
|
|
46
|
-
)
|
|
31
|
+
study: (studyIndex) => hashUid(salt, `study:${studyIndex}`),
|
|
32
|
+
series: (studyIndex, seriesIndex) => hashUid(salt, `series:${studyIndex}:${seriesIndex}`)
|
|
47
33
|
};
|
|
48
34
|
}
|
|
49
|
-
function makeUidGenerator(
|
|
50
|
-
if (
|
|
51
|
-
return () => ({
|
|
52
|
-
study: randomUid("study"),
|
|
53
|
-
series: randomUid("series"),
|
|
54
|
-
sop: randomUid("sop")
|
|
55
|
-
});
|
|
35
|
+
function makeUidGenerator(salt) {
|
|
36
|
+
if (salt === void 0) {
|
|
37
|
+
return () => ({ study: randomUid(), series: randomUid(), sop: randomUid() });
|
|
56
38
|
}
|
|
57
|
-
return (fileIndex) => {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
sop: seededUid(next, "sop")
|
|
63
|
-
};
|
|
64
|
-
};
|
|
39
|
+
return (fileIndex) => ({
|
|
40
|
+
study: hashUid(salt, `study:flat:${fileIndex}`),
|
|
41
|
+
series: hashUid(salt, `series:flat:${fileIndex}`),
|
|
42
|
+
sop: hashUid(salt, `sop:${fileIndex}`)
|
|
43
|
+
});
|
|
65
44
|
}
|
|
66
45
|
export {
|
|
67
46
|
PARAMETRIC_STREAM_OFFSET,
|
|
47
|
+
hashUid,
|
|
68
48
|
makeGroupUidGenerator,
|
|
69
49
|
makeUidGenerator,
|
|
50
|
+
seedToSalt,
|
|
70
51
|
seededStream
|
|
71
52
|
};
|
|
@@ -16,11 +16,13 @@ export type CollectionManifest = Array<{
|
|
|
16
16
|
export declare function generateFile(spec: FileSpec, options?: {
|
|
17
17
|
index?: number;
|
|
18
18
|
seed?: number;
|
|
19
|
+
uidSalt?: string;
|
|
19
20
|
padWidth?: number;
|
|
20
21
|
uid?: Partial<UidSet>;
|
|
21
22
|
context?: Partial<TagContext>;
|
|
22
23
|
}): Promise<GeneratedFile>;
|
|
23
24
|
export declare function withResolvedSeed(spec: DatasetSpec): DatasetSpec;
|
|
25
|
+
export declare function withResolvedSalt(spec: DatasetSpec): DatasetSpec;
|
|
24
26
|
export declare function generateCollectionFromSpec(spec: DatasetSpec): AsyncGenerator<GeneratedFile>;
|
|
25
27
|
export type CollectionPreviewItem = {
|
|
26
28
|
relativePath: string;
|
package/dist/types/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { type CollectionManifest, type CollectionPreviewItem, type GeneratedFile, generateCollectionFromSpec, generateFile, previewCollection, withResolvedSeed, writeCollectionFromSpec, } from './collection/writer.js';
|
|
1
|
+
export { type CollectionManifest, type CollectionPreviewItem, type GeneratedFile, generateCollectionFromSpec, generateFile, previewCollection, withResolvedSalt, withResolvedSeed, writeCollectionFromSpec, } from './collection/writer.js';
|
|
2
2
|
export { type DescribeOptions, type DescribeResult, describeDirectory, } from './describe/describe.js';
|
|
3
3
|
export { defaultPublicCasesPath, loadCaseById, loadCasesFromJson, loadDefaultCases, type PublicCaseRecord, type PublicCaseSource, } from './public-fixtures/catalog.js';
|
|
4
4
|
export { caseCachePath, fetchPublicCaseToCache, verifySha256, } from './public-fixtures/fetch.js';
|
|
@@ -8,3 +8,4 @@ export type { DatasetLayout, DatasetSpec, DicomdirSpec, DicomTagOverrides, FakeS
|
|
|
8
8
|
export { validateDatasetSpec, validateParametricSpec, } from './schema/validate.js';
|
|
9
9
|
export { type LargeFileEncoding, type LargeFileResult, type LargeImageSpec, type StreamLargeImageOptions, streamLargeImage, writeLargeImageFile, } from './syntheticFixtures/streamWrite.js';
|
|
10
10
|
export { resolveTagTemplates, type TagContext, TEMPLATE_VOCAB, } from './syntheticFixtures/tagTemplate.js';
|
|
11
|
+
export { hashUid, seedToSalt } from './syntheticFixtures/uid.js';
|
|
@@ -74,6 +74,7 @@ export type DatasetSpec = {
|
|
|
74
74
|
entries?: EntrySpec[];
|
|
75
75
|
studies?: StudySpec[];
|
|
76
76
|
seed?: number;
|
|
77
|
+
uidSalt?: string;
|
|
77
78
|
layout?: DatasetLayout;
|
|
78
79
|
pathQuirks?: PathQuirk[];
|
|
79
80
|
};
|
|
@@ -96,6 +97,7 @@ export type ParametricEdgeCase = EntrySpec & {
|
|
|
96
97
|
};
|
|
97
98
|
export type ParametricSpec = {
|
|
98
99
|
seed?: number;
|
|
100
|
+
uidSalt?: string;
|
|
99
101
|
studies: ParametricStudies;
|
|
100
102
|
edgeCases?: ParametricEdgeCase[];
|
|
101
103
|
layout?: DatasetLayout;
|
|
@@ -4,7 +4,9 @@ export type LargeImageSpec = {
|
|
|
4
4
|
/** Approximate total file size in bytes (must exceed the 512 MB cap). */
|
|
5
5
|
targetBytes: number;
|
|
6
6
|
modality?: Modality;
|
|
7
|
-
/**
|
|
7
|
+
/** UID namespace (hash-derived UIDs); random when both salt and seed omitted. */
|
|
8
|
+
uidSalt?: string;
|
|
9
|
+
/** Bridged to a salt when uidSalt is omitted; fixes the instance UIDs. */
|
|
8
10
|
seed?: number;
|
|
9
11
|
};
|
|
10
12
|
export type LargeFileEncoding = 'native' | 'encapsulated';
|
|
@@ -18,7 +20,9 @@ export type StreamLargeImageOptions = {
|
|
|
18
20
|
/** Approximate total file size in bytes (no floor — small values are valid). */
|
|
19
21
|
targetBytes: number;
|
|
20
22
|
modality?: Modality;
|
|
21
|
-
/**
|
|
23
|
+
/** UID namespace (hash-derived UIDs); random when both salt and seed omitted. */
|
|
24
|
+
uidSalt?: string;
|
|
25
|
+
/** Bridged to a salt when uidSalt is omitted; fixes the instance UIDs. */
|
|
22
26
|
seed?: number;
|
|
23
27
|
/** Explicit UID set — used as-is (e.g. collection threading shared study/series UIDs). */
|
|
24
28
|
uid?: UidSet;
|
|
@@ -3,12 +3,14 @@ export type UidSet = {
|
|
|
3
3
|
series: string;
|
|
4
4
|
sop: string;
|
|
5
5
|
};
|
|
6
|
+
export declare function hashUid(salt: string, key: string): string;
|
|
7
|
+
export declare function seedToSalt(seed: number | undefined): string | undefined;
|
|
6
8
|
export declare const PARAMETRIC_STREAM_OFFSET = 2246822507;
|
|
7
9
|
export declare function seededStream(seed: number, offset: number, a: number, b: number): () => number;
|
|
8
10
|
export type GroupUidGenerator = {
|
|
9
11
|
study: (studyIndex: number) => string;
|
|
10
12
|
series: (studyIndex: number, seriesIndex: number) => string;
|
|
11
13
|
};
|
|
12
|
-
export declare function makeGroupUidGenerator(
|
|
14
|
+
export declare function makeGroupUidGenerator(salt?: string): GroupUidGenerator;
|
|
13
15
|
export type UidGenerator = (fileIndex: number) => UidSet;
|
|
14
|
-
export declare function makeUidGenerator(
|
|
16
|
+
export declare function makeUidGenerator(salt?: string): UidGenerator;
|