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/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
- const isModalityKey = key === "Modality" || key.toUpperCase() === MODALITY_TAG;
298
- if (isModalityKey && typeof value === "string" && Object.hasOwn(VALID_MODALITIES, value)) {
299
- throw new Error(
300
- `${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`
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
- if (typeof value === "string") {
304
- for (const name of findTemplateTokens(value)) {
305
- if (!TEMPLATE_VOCAB.includes(name)) {
306
- throw new Error(
307
- `${path}.${key}: unknown placeholder "{${name}}" \u2014 allowed: ${TEMPLATE_VOCAB.map((v) => `{${v}}`).join(", ")}`
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
- if (!Array.isArray(s.entries) || s.entries.length === 0) {
320
- throw new Error(`${path}.entries: must be a non-empty array`);
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
- for (let i = 0; i < s.entries.length; i++) {
323
- const e = validateEntry(s.entries[i], `${path}.entries[${i}]`);
324
- if (!TYPE_CAPABILITIES[e.type].image) {
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
- var ROLE_CODE = { study: 1, series: 2, sop: 3 };
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(seed) {
722
- if (seed === void 0) {
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) => seededUid(
730
- seededStream(seed, GROUP_STREAM_OFFSET, studyIndex + 1, 0),
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(seed) {
745
- if (seed === void 0) {
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
- const next = seededStream(seed, 0, fileIndex, 0);
754
- return {
755
- study: seededUid(next, "study"),
756
- series: seededUid(next, "series"),
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 + entryCount(series.entries), 0),
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 resolveUid(seed, index, override) {
1043
- return { ...makeUidGenerator(seed)(index), ...override };
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?.seed, index, options?.uid);
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.seed);
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 entry of series.entries) {
1170
- const { count = 1, ...fileSpec } = entry;
1171
- for (let i = 0; i < count; i++) {
1172
- instanceNumber++;
1173
- const tags = {
1174
- InstanceNumber: instanceNumber,
1175
- ...study.tags,
1176
- ...series.tags,
1177
- ...fileSpec.tags
1178
- };
1179
- yield {
1180
- fileSpec: { ...fileSpec, tags },
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
- uidOverride: { study: studyUid, series: seriesUid },
1183
- context: {
1184
- index: globalIndex,
1185
- studyIndex: studyOrdinal,
1186
- seriesIndex,
1187
- instanceNumber
1188
- },
1189
- ...computeNames(
1190
- fileSpec.type,
1191
- globalIndex,
1192
- padWidth,
1193
- hierarchical ? { studyOrdinal, seriesIndex, instanceNumber } : void 0,
1194
- quirks
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, seed) {
1313
+ async function materialisePlan(plan, salt) {
1206
1314
  const file = await generateFile(plan.fileSpec, {
1207
1315
  index: plan.index,
1208
- seed,
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, spec.seed);
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, spec.seed);
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(spec.seed, plan.index, plan.uidOverride);
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, spec.seed);
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 sizeKb = Math.max(1, Math.round(size / 1024));
1481
- const tags = keepKeywords.length ? extractTags(record, keepKeywords) : void 0;
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
- const collapseKey = JSON.stringify([
1493
- large,
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.set(collapseKey, { modality, large, bytes: size, tags, count: 1 });
1716
+ accumulate(series, fileRecord);
1503
1717
  }
1504
1718
  }
1505
- const studySpecs = [...studies.values()].map((study) => {
1506
- const seriesSpecs = [...study.values()].map((series) => {
1507
- const entries = [...series.values()].map((acc) => ({
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 = createHash("sha256").update(buffer).digest("hex");
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