dicom-synth 1.4.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +55 -4
- package/bin/dicom-synth-generate.mjs +100 -40
- package/dist/esm/index.js +195 -32
- package/dist/esm/schema/parametric.js +327 -0
- package/dist/esm/schema/validate.js +120 -33
- package/dist/esm/syntheticFixtures/uid.js +4 -1
- package/dist/types/index.d.ts +3 -2
- package/dist/types/schema/parametric.d.ts +2 -0
- package/dist/types/schema/types.d.ts +21 -0
- package/dist/types/schema/validate.d.ts +2 -1
- package/dist/types/syntheticFixtures/uid.d.ts +2 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -381,6 +381,42 @@ The validator throws with a human-readable message on any structural error.
|
|
|
381
381
|
|
|
382
382
|
---
|
|
383
383
|
|
|
384
|
+
## Parametric designer
|
|
385
|
+
|
|
386
|
+
A `ParametricSpec` describes a dataset by ranges instead of explicit entries. `resolveParametricSpec` resolves it — deterministically for a given seed — into a concrete `DatasetSpec` that can be inspected, saved, and re-run.
|
|
387
|
+
|
|
388
|
+
```ts
|
|
389
|
+
import { resolveParametricSpec, writeCollectionFromSpec } from 'dicom-synth'
|
|
390
|
+
|
|
391
|
+
const resolved = resolveParametricSpec({
|
|
392
|
+
seed: 42,
|
|
393
|
+
studies: {
|
|
394
|
+
count: { min: 2, max: 4 }, // ranges are inclusive
|
|
395
|
+
seriesPerStudy: { min: 1, max: 3 },
|
|
396
|
+
filesPerSeries: { min: 5, max: 20 },
|
|
397
|
+
fileSizeKb: { min: 100, max: 600 }, // sampled per file
|
|
398
|
+
modalities: ['CT', 'PT'], // one drawn per series
|
|
399
|
+
},
|
|
400
|
+
edgeCases: [{ type: 'invalid-uid-image', frequency: 0.05 }],
|
|
401
|
+
layout: 'hierarchical',
|
|
402
|
+
})
|
|
403
|
+
await writeCollectionFromSpec(resolved, './out')
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
- Every field of `studies` accepts a fixed number or an inclusive `{ min, max }` range (`Range`).
|
|
407
|
+
- `fileSizeKb` maps to `targetSizeKb`; a fixed value collapses each series into one counted entry, a range samples per file.
|
|
408
|
+
- `edgeCases` are `EntrySpec`s appended to the flat `entries` of the resolved spec. With `frequency` (0–1), the count is drawn per file across the whole dataset — the resolved spec records the actual draw. Without it, the entry is appended as-is (use `count` for absolute numbers). At small dataset sizes a low frequency may draw zero occurrences.
|
|
409
|
+
- The seed (drawn randomly when omitted) is embedded in the resolved spec, so resolution is always reproducible.
|
|
410
|
+
|
|
411
|
+
```ts
|
|
412
|
+
import type { ParametricSpec, ParametricStudies, ParametricEdgeCase, Range } from 'dicom-synth'
|
|
413
|
+
import { validateParametricSpec } from 'dicom-synth'
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
See `examples/parametric.json` for a JSON example and `examples/convert-data-shape.mjs` for converting an exported tree-shape JSON into a `DatasetSpec` (skips non-DICOM files).
|
|
417
|
+
|
|
418
|
+
---
|
|
419
|
+
|
|
384
420
|
## CLI
|
|
385
421
|
|
|
386
422
|
```bash
|
|
@@ -392,8 +428,21 @@ dicom-synth-generate --schema-inline '{"entries":[{"type":"valid-image","count":
|
|
|
392
428
|
|
|
393
429
|
# Default output directory is ./fixtures/generated
|
|
394
430
|
dicom-synth-generate --schema dataset.json
|
|
431
|
+
|
|
432
|
+
# Resolve and generate from a parametric spec; save the resolved DatasetSpec
|
|
433
|
+
dicom-synth-generate --parametric examples/parametric.json --out ./out --emit-spec resolved.json
|
|
434
|
+
|
|
435
|
+
# Preview the manifest (paths, types, sizes) without writing anything
|
|
436
|
+
dicom-synth-generate --parametric examples/parametric.json --dry-run
|
|
395
437
|
```
|
|
396
438
|
|
|
439
|
+
| Flag | Effect |
|
|
440
|
+
|---|---|
|
|
441
|
+
| `--schema <path>` / `--schema-inline <json>` / `--parametric <path>` | Input spec (mutually exclusive) |
|
|
442
|
+
| `--out <dir>` | Output directory (default `./fixtures/generated`) |
|
|
443
|
+
| `--emit-spec <path>` | Write the resolved `DatasetSpec` JSON (requires `--parametric`) |
|
|
444
|
+
| `--dry-run` | Generate in memory and print the manifest; nothing is written |
|
|
445
|
+
|
|
397
446
|
The default schema (`examples/default.json`) generates one each of `valid-image`, `invalid-uid-image`, and `vendor-warnings-image`:
|
|
398
447
|
|
|
399
448
|
```bash
|
|
@@ -471,7 +520,8 @@ pnpm fetch:public-case -- pydicom-CT-small
|
|
|
471
520
|
| Path | Responsibility |
|
|
472
521
|
|---|---|
|
|
473
522
|
| `src/schema/types.ts` | All public TypeScript types (`FileSpec`, `DatasetSpec`, `ViolationClass`, etc.) |
|
|
474
|
-
| `src/schema/validate.ts` | `validateDatasetSpec` —
|
|
523
|
+
| `src/schema/validate.ts` | `validateDatasetSpec` / `validateParametricSpec` — validate raw JSON values against the schema |
|
|
524
|
+
| `src/schema/parametric.ts` | `resolveParametricSpec` — seeded `ParametricSpec` → `DatasetSpec` resolution |
|
|
475
525
|
| `src/syntheticFixtures/generator.ts` | Internal buffer builders keyed on `FileSpec` type |
|
|
476
526
|
| `src/syntheticFixtures/uid.ts` | Seeded and random UID generation |
|
|
477
527
|
| `src/syntheticFixtures/violations.ts` | Post-processing violation injection |
|
|
@@ -481,6 +531,8 @@ pnpm fetch:public-case -- pydicom-CT-small
|
|
|
481
531
|
| `bin/dicom-synth-generate.mjs` | Published generate CLI (requires `pnpm build`) |
|
|
482
532
|
| `bin/dicom-synth-fetch.mjs` | Published fetch CLI (requires `pnpm build`) |
|
|
483
533
|
| `examples/default.json` | Default `DatasetSpec` — one each of `valid-image`, `invalid-uid-image`, `vendor-warnings-image` |
|
|
534
|
+
| `examples/parametric.json` | Example `ParametricSpec` for the `--parametric` CLI path |
|
|
535
|
+
| `examples/convert-data-shape.mjs` | Example converter: tree-shape JSON → `DatasetSpec` |
|
|
484
536
|
|
|
485
537
|
---
|
|
486
538
|
|
|
@@ -506,9 +558,8 @@ Git hooks: see [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
|
506
558
|
|
|
507
559
|
## Future development
|
|
508
560
|
|
|
509
|
-
- **
|
|
510
|
-
- **
|
|
511
|
-
- **Dry-run mode** — emit the manifest (paths, types, grouping) without writing files
|
|
561
|
+
- **Describe tool** — scan an existing DICOM tree and emit a matching `DatasetSpec` (counts, grouping, sizes, modalities; no PHI)
|
|
562
|
+
- **Dimension edge-case recipes** — documented presets for geometry pathologies (1×65535 strips, high frame counts)
|
|
512
563
|
- **Compressed transfer syntaxes** — JPEG-LS, JPEG 2000, RLE
|
|
513
564
|
- **Private fixture catalogues** — same SHA-256 fetch/cache pattern for credentials-backed sources (e.g. S3)
|
|
514
565
|
|
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { readFileSync, writeFileSync } from 'node:fs'
|
|
3
3
|
import { resolve } from 'node:path'
|
|
4
4
|
import {
|
|
5
|
+
generateCollectionFromSpec,
|
|
6
|
+
resolveParametricSpec,
|
|
5
7
|
validateDatasetSpec,
|
|
6
8
|
writeCollectionFromSpec,
|
|
7
9
|
} from '../dist/esm/index.js'
|
|
8
10
|
|
|
9
11
|
function usage() {
|
|
10
12
|
console.error(
|
|
11
|
-
'Usage: dicom-synth-generate --schema <path> [--out <dir>]\n' +
|
|
12
|
-
' dicom-synth-generate --schema-inline <json> [--out <dir>]'
|
|
13
|
+
'Usage: dicom-synth-generate --schema <path> [--out <dir>] [--dry-run]\n' +
|
|
14
|
+
' dicom-synth-generate --schema-inline <json> [--out <dir>] [--dry-run]\n' +
|
|
15
|
+
' dicom-synth-generate --parametric <path> [--out <dir>] [--emit-spec <path>] [--dry-run]',
|
|
13
16
|
)
|
|
14
17
|
process.exit(1)
|
|
15
18
|
}
|
|
@@ -19,36 +22,50 @@ const args = process.argv.slice(2)
|
|
|
19
22
|
if (args.length === 0) usage()
|
|
20
23
|
|
|
21
24
|
let rawSpec
|
|
25
|
+
let specSource
|
|
22
26
|
let outDir = resolve('./fixtures/generated')
|
|
27
|
+
let emitSpecPath
|
|
28
|
+
let dryRun = false
|
|
29
|
+
|
|
30
|
+
function readSpecArg(flag, value) {
|
|
31
|
+
if (rawSpec !== undefined) {
|
|
32
|
+
console.error(
|
|
33
|
+
'Error: --schema, --schema-inline and --parametric are mutually exclusive',
|
|
34
|
+
)
|
|
35
|
+
process.exit(1)
|
|
36
|
+
}
|
|
37
|
+
specSource = flag
|
|
38
|
+
if (flag === '--schema-inline') {
|
|
39
|
+
try {
|
|
40
|
+
rawSpec = JSON.parse(value)
|
|
41
|
+
} catch (err) {
|
|
42
|
+
console.error(`Error parsing inline schema: ${err.message}`)
|
|
43
|
+
process.exit(1)
|
|
44
|
+
}
|
|
45
|
+
} else {
|
|
46
|
+
try {
|
|
47
|
+
rawSpec = JSON.parse(readFileSync(resolve(value), 'utf8'))
|
|
48
|
+
} catch (err) {
|
|
49
|
+
console.error(`Error reading ${flag.slice(2)} file: ${err.message}`)
|
|
50
|
+
process.exit(1)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
23
54
|
|
|
24
55
|
for (let i = 0; i < args.length; i++) {
|
|
25
56
|
if (
|
|
26
|
-
(args[i] === '--schema' ||
|
|
57
|
+
(args[i] === '--schema' ||
|
|
58
|
+
args[i] === '--schema-inline' ||
|
|
59
|
+
args[i] === '--parametric') &&
|
|
27
60
|
args[i + 1]
|
|
28
61
|
) {
|
|
29
|
-
|
|
30
|
-
console.error(
|
|
31
|
-
'Error: --schema and --schema-inline are mutually exclusive',
|
|
32
|
-
)
|
|
33
|
-
process.exit(1)
|
|
34
|
-
}
|
|
35
|
-
if (args[i] === '--schema') {
|
|
36
|
-
try {
|
|
37
|
-
rawSpec = JSON.parse(readFileSync(resolve(args[++i]), 'utf8'))
|
|
38
|
-
} catch (err) {
|
|
39
|
-
console.error(`Error reading schema file: ${err.message}`)
|
|
40
|
-
process.exit(1)
|
|
41
|
-
}
|
|
42
|
-
} else {
|
|
43
|
-
try {
|
|
44
|
-
rawSpec = JSON.parse(args[++i])
|
|
45
|
-
} catch (err) {
|
|
46
|
-
console.error(`Error parsing inline schema: ${err.message}`)
|
|
47
|
-
process.exit(1)
|
|
48
|
-
}
|
|
49
|
-
}
|
|
62
|
+
readSpecArg(args[i], args[++i])
|
|
50
63
|
} else if (args[i] === '--out' && args[i + 1]) {
|
|
51
64
|
outDir = resolve(args[++i])
|
|
65
|
+
} else if (args[i] === '--emit-spec' && args[i + 1]) {
|
|
66
|
+
emitSpecPath = resolve(args[++i])
|
|
67
|
+
} else if (args[i] === '--dry-run') {
|
|
68
|
+
dryRun = true
|
|
52
69
|
} else {
|
|
53
70
|
console.error(`Unknown argument: ${args[i]}`)
|
|
54
71
|
usage()
|
|
@@ -57,29 +74,72 @@ for (let i = 0; i < args.length; i++) {
|
|
|
57
74
|
|
|
58
75
|
if (rawSpec === undefined) usage()
|
|
59
76
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
}
|
|
64
|
-
|
|
77
|
+
function printTypeCounts(counts) {
|
|
78
|
+
for (const [type, count] of Object.entries(counts)) {
|
|
79
|
+
console.log(` ${type}: ${count}`)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (emitSpecPath !== undefined && specSource !== '--parametric') {
|
|
84
|
+
console.error('Error: --emit-spec requires --parametric')
|
|
65
85
|
process.exit(1)
|
|
66
86
|
}
|
|
67
87
|
|
|
68
|
-
let
|
|
88
|
+
let spec
|
|
69
89
|
try {
|
|
70
|
-
|
|
90
|
+
spec =
|
|
91
|
+
specSource === '--parametric'
|
|
92
|
+
? resolveParametricSpec(rawSpec)
|
|
93
|
+
: validateDatasetSpec(rawSpec)
|
|
71
94
|
} catch (err) {
|
|
72
|
-
console.error(`
|
|
95
|
+
console.error(`Schema validation error: ${err.message}`)
|
|
73
96
|
process.exit(1)
|
|
74
97
|
}
|
|
75
98
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
99
|
+
if (emitSpecPath !== undefined) {
|
|
100
|
+
try {
|
|
101
|
+
writeFileSync(emitSpecPath, `${JSON.stringify(spec, null, 2)}\n`)
|
|
102
|
+
console.log(`Wrote resolved spec to ${emitSpecPath}`)
|
|
103
|
+
} catch (err) {
|
|
104
|
+
console.error(`Error writing resolved spec: ${err.message}`)
|
|
105
|
+
process.exit(1)
|
|
106
|
+
}
|
|
80
107
|
}
|
|
81
108
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
109
|
+
if (dryRun) {
|
|
110
|
+
const counts = {}
|
|
111
|
+
let fileCount = 0
|
|
112
|
+
let byteCount = 0
|
|
113
|
+
try {
|
|
114
|
+
for await (const file of generateCollectionFromSpec(spec)) {
|
|
115
|
+
console.log(
|
|
116
|
+
`${file.relativePath} (${file.type}, ${(file.buffer.length / 1024).toFixed(1)} KB)`,
|
|
117
|
+
)
|
|
118
|
+
counts[file.type] = (counts[file.type] ?? 0) + 1
|
|
119
|
+
fileCount++
|
|
120
|
+
byteCount += file.buffer.length
|
|
121
|
+
}
|
|
122
|
+
} catch (err) {
|
|
123
|
+
console.error(`Error generating collection: ${err.message}`)
|
|
124
|
+
process.exit(1)
|
|
125
|
+
}
|
|
126
|
+
console.log(
|
|
127
|
+
`Dry run: ${fileCount} file(s), ${(byteCount / 1024).toFixed(1)} KB total — nothing written`,
|
|
128
|
+
)
|
|
129
|
+
printTypeCounts(counts)
|
|
130
|
+
} else {
|
|
131
|
+
let manifest
|
|
132
|
+
try {
|
|
133
|
+
manifest = await writeCollectionFromSpec(spec, outDir)
|
|
134
|
+
} catch (err) {
|
|
135
|
+
console.error(`Error writing collection: ${err.message}`)
|
|
136
|
+
process.exit(1)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const counts = {}
|
|
140
|
+
for (const entry of manifest) {
|
|
141
|
+
counts[entry.type] = (counts[entry.type] ?? 0) + 1
|
|
142
|
+
}
|
|
143
|
+
console.log(`Wrote ${manifest.length} file(s) to ${outDir}`)
|
|
144
|
+
printTypeCounts(counts)
|
|
85
145
|
}
|
package/dist/esm/index.js
CHANGED
|
@@ -85,6 +85,25 @@ function validatePositiveInt(value, path) {
|
|
|
85
85
|
);
|
|
86
86
|
}
|
|
87
87
|
}
|
|
88
|
+
function validateSeed(value, path) {
|
|
89
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
90
|
+
throw new Error(
|
|
91
|
+
`${path}: must be a finite number; got ${JSON.stringify(value)}`
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
function validateSizeKbLimit(kb, path) {
|
|
96
|
+
if (kb * 1024 > MAX_PIXEL_BYTES) {
|
|
97
|
+
throw new Error(`${path}: exceeds the 512 MB limit`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
function validateEnum(value, valid, path) {
|
|
101
|
+
if (typeof value !== "string" || !Object.hasOwn(valid, value)) {
|
|
102
|
+
throw new Error(
|
|
103
|
+
`${path}: must be one of ${Object.keys(valid).join(", ")}; got ${JSON.stringify(value)}`
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
88
107
|
function validateTagKey(key, path) {
|
|
89
108
|
if (!HEX_TAG_RE.test(key) && !KEYWORD_RE.test(key)) {
|
|
90
109
|
throw new Error(
|
|
@@ -97,11 +116,7 @@ function validateEntry(entry, path) {
|
|
|
97
116
|
throw new Error(`${path}: must be an object`);
|
|
98
117
|
}
|
|
99
118
|
const e = entry;
|
|
100
|
-
|
|
101
|
-
throw new Error(
|
|
102
|
-
`${path}.type: must be one of ${Object.keys(TYPE_CAPABILITIES).join(", ")}; got ${JSON.stringify(e.type)}`
|
|
103
|
-
);
|
|
104
|
-
}
|
|
119
|
+
validateEnum(e.type, TYPE_CAPABILITIES, `${path}.type`);
|
|
105
120
|
const type = e.type;
|
|
106
121
|
const caps = TYPE_CAPABILITIES[type];
|
|
107
122
|
if ("count" in e) validatePositiveInt(e.count, `${path}.count`);
|
|
@@ -119,18 +134,14 @@ function validateEntry(entry, path) {
|
|
|
119
134
|
throw new Error(`${path}.${field}: not supported for type "${type}"`);
|
|
120
135
|
}
|
|
121
136
|
if ("transferSyntax" in e) {
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
137
|
+
validateEnum(
|
|
138
|
+
e.transferSyntax,
|
|
139
|
+
VALID_TRANSFER_SYNTAXES,
|
|
140
|
+
`${path}.transferSyntax`
|
|
141
|
+
);
|
|
127
142
|
}
|
|
128
143
|
if ("modality" in e) {
|
|
129
|
-
|
|
130
|
-
throw new Error(
|
|
131
|
-
`${path}.modality: must be one of ${Object.keys(VALID_MODALITIES).join(", ")}; got ${JSON.stringify(e.modality)}`
|
|
132
|
-
);
|
|
133
|
-
}
|
|
144
|
+
validateEnum(e.modality, VALID_MODALITIES, `${path}.modality`);
|
|
134
145
|
}
|
|
135
146
|
if ("tags" in e) validateTags(e.tags, `${path}.tags`);
|
|
136
147
|
if ("violations" in e) {
|
|
@@ -138,11 +149,7 @@ function validateEntry(entry, path) {
|
|
|
138
149
|
throw new Error(`${path}.violations: must be an array`);
|
|
139
150
|
}
|
|
140
151
|
for (const [i, v] of e.violations.entries()) {
|
|
141
|
-
|
|
142
|
-
throw new Error(
|
|
143
|
-
`${path}.violations[${i}]: must be one of ${Object.keys(VALID_VIOLATIONS).join(", ")}; got ${JSON.stringify(v)}`
|
|
144
|
-
);
|
|
145
|
-
}
|
|
152
|
+
validateEnum(v, VALID_VIOLATIONS, `${path}.violations[${i}]`);
|
|
146
153
|
}
|
|
147
154
|
}
|
|
148
155
|
const hasDims = "rows" in e || "columns" in e || "frames" in e;
|
|
@@ -153,9 +160,7 @@ function validateEntry(entry, path) {
|
|
|
153
160
|
);
|
|
154
161
|
}
|
|
155
162
|
validatePositiveInt(e.targetSizeKb, `${path}.targetSizeKb`);
|
|
156
|
-
|
|
157
|
-
throw new Error(`${path}.targetSizeKb: exceeds the 512 MB limit`);
|
|
158
|
-
}
|
|
163
|
+
validateSizeKbLimit(e.targetSizeKb, `${path}.targetSizeKb`);
|
|
159
164
|
} else if (hasDims) {
|
|
160
165
|
if (!("rows" in e) || !("columns" in e)) {
|
|
161
166
|
throw new Error(`${path}: rows and columns must be provided together`);
|
|
@@ -240,23 +245,104 @@ function validateDatasetSpec(raw) {
|
|
|
240
245
|
const studies = rawStudies.map(
|
|
241
246
|
(study, i) => validateStudy(study, `studies[${i}]`)
|
|
242
247
|
);
|
|
243
|
-
if ("seed" in r)
|
|
244
|
-
|
|
248
|
+
if ("seed" in r) validateSeed(r.seed, "DatasetSpec.seed");
|
|
249
|
+
if ("layout" in r) {
|
|
250
|
+
validateEnum(r.layout, VALID_LAYOUTS, "DatasetSpec.layout");
|
|
251
|
+
}
|
|
252
|
+
return {
|
|
253
|
+
...entries.length > 0 ? { entries } : {},
|
|
254
|
+
...studies.length > 0 ? { studies } : {},
|
|
255
|
+
...r.seed !== void 0 ? { seed: r.seed } : {},
|
|
256
|
+
...r.layout !== void 0 ? { layout: r.layout } : {}
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
function validateRange(value, path) {
|
|
260
|
+
if (typeof value === "number") {
|
|
261
|
+
validatePositiveInt(value, path);
|
|
262
|
+
return value;
|
|
263
|
+
}
|
|
264
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
265
|
+
throw new Error(
|
|
266
|
+
`${path}: must be a positive integer or { min, max }; got ${JSON.stringify(value)}`
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
const r = value;
|
|
270
|
+
validatePositiveInt(r.min, `${path}.min`);
|
|
271
|
+
validatePositiveInt(r.max, `${path}.max`);
|
|
272
|
+
if (r.min > r.max) {
|
|
273
|
+
throw new Error(`${path}: min (${r.min}) must be \u2264 max (${r.max})`);
|
|
274
|
+
}
|
|
275
|
+
return value;
|
|
276
|
+
}
|
|
277
|
+
function rangeMax(range) {
|
|
278
|
+
return typeof range === "number" ? range : range.max;
|
|
279
|
+
}
|
|
280
|
+
function validateParametricStudies(studies, path) {
|
|
281
|
+
if (typeof studies !== "object" || studies === null || Array.isArray(studies)) {
|
|
282
|
+
throw new Error(`${path}: must be an object`);
|
|
283
|
+
}
|
|
284
|
+
const s = studies;
|
|
285
|
+
for (const field of ["count", "seriesPerStudy", "filesPerSeries"]) {
|
|
286
|
+
if (!(field in s)) throw new Error(`${path}.${field}: is required`);
|
|
287
|
+
validateRange(s[field], `${path}.${field}`);
|
|
288
|
+
}
|
|
289
|
+
if ("fileSizeKb" in s) {
|
|
290
|
+
validateRange(s.fileSizeKb, `${path}.fileSizeKb`);
|
|
291
|
+
validateSizeKbLimit(rangeMax(s.fileSizeKb), `${path}.fileSizeKb`);
|
|
292
|
+
}
|
|
293
|
+
if ("modalities" in s) {
|
|
294
|
+
if (!Array.isArray(s.modalities) || s.modalities.length === 0) {
|
|
295
|
+
throw new Error(`${path}.modalities: must be a non-empty array`);
|
|
296
|
+
}
|
|
297
|
+
for (const [i, m] of s.modalities.entries()) {
|
|
298
|
+
validateEnum(m, VALID_MODALITIES, `${path}.modalities[${i}]`);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
if ("tags" in s) validateTags(s.tags, `${path}.tags`);
|
|
302
|
+
return s;
|
|
303
|
+
}
|
|
304
|
+
function validateEdgeCase(edgeCase, path) {
|
|
305
|
+
const e = validateEntry(edgeCase, path);
|
|
306
|
+
if ("frequency" in e) {
|
|
307
|
+
if ("count" in e) {
|
|
245
308
|
throw new Error(
|
|
246
|
-
|
|
309
|
+
`${path}: frequency cannot be combined with count \u2014 frequency draws the count at resolution time`
|
|
247
310
|
);
|
|
248
311
|
}
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
if (typeof r.layout !== "string" || !Object.hasOwn(VALID_LAYOUTS, r.layout)) {
|
|
312
|
+
const f = e.frequency;
|
|
313
|
+
if (typeof f !== "number" || !(f >= 0 && f <= 1)) {
|
|
252
314
|
throw new Error(
|
|
253
|
-
|
|
315
|
+
`${path}.frequency: must be a number between 0 and 1; got ${JSON.stringify(f)}`
|
|
254
316
|
);
|
|
255
317
|
}
|
|
256
318
|
}
|
|
319
|
+
return e;
|
|
320
|
+
}
|
|
321
|
+
function validateParametricSpec(raw) {
|
|
322
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
323
|
+
throw new Error("ParametricSpec: must be a JSON object");
|
|
324
|
+
}
|
|
325
|
+
const r = raw;
|
|
326
|
+
if (!("studies" in r)) {
|
|
327
|
+
throw new Error("ParametricSpec.studies: is required");
|
|
328
|
+
}
|
|
329
|
+
const studies = validateParametricStudies(r.studies, "studies");
|
|
330
|
+
const edgeCases = [];
|
|
331
|
+
if ("edgeCases" in r) {
|
|
332
|
+
if (!Array.isArray(r.edgeCases)) {
|
|
333
|
+
throw new Error("ParametricSpec.edgeCases: must be an array");
|
|
334
|
+
}
|
|
335
|
+
for (const [i, e] of r.edgeCases.entries()) {
|
|
336
|
+
edgeCases.push(validateEdgeCase(e, `edgeCases[${i}]`));
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
if ("seed" in r) validateSeed(r.seed, "ParametricSpec.seed");
|
|
340
|
+
if ("layout" in r) {
|
|
341
|
+
validateEnum(r.layout, VALID_LAYOUTS, "ParametricSpec.layout");
|
|
342
|
+
}
|
|
257
343
|
return {
|
|
258
|
-
|
|
259
|
-
...
|
|
344
|
+
studies,
|
|
345
|
+
...edgeCases.length > 0 ? { edgeCases } : {},
|
|
260
346
|
...r.seed !== void 0 ? { seed: r.seed } : {},
|
|
261
347
|
...r.layout !== void 0 ? { layout: r.layout } : {}
|
|
262
348
|
};
|
|
@@ -457,6 +543,7 @@ function seededUid(next, role) {
|
|
|
457
543
|
return formatUid(next(), next(), role);
|
|
458
544
|
}
|
|
459
545
|
var GROUP_STREAM_OFFSET = 2654435769;
|
|
546
|
+
var PARAMETRIC_STREAM_OFFSET = 2246822507;
|
|
460
547
|
function seededStream(seed, offset, a, b) {
|
|
461
548
|
return lcg(seed * 999983 + offset + a * 1000003 + b * 7919 >>> 0);
|
|
462
549
|
}
|
|
@@ -793,6 +880,80 @@ async function fetchPublicCaseToCache(record, cacheRoot = DEFAULT_CACHE_ROOT) {
|
|
|
793
880
|
writeFileSync2(dest, buf);
|
|
794
881
|
return dest;
|
|
795
882
|
}
|
|
883
|
+
|
|
884
|
+
// src/schema/parametric.ts
|
|
885
|
+
import { randomBytes as randomBytes2 } from "node:crypto";
|
|
886
|
+
function sample(next, range) {
|
|
887
|
+
if (typeof range === "number") return range;
|
|
888
|
+
return range.min + next() % (range.max - range.min + 1);
|
|
889
|
+
}
|
|
890
|
+
function pick(next, items) {
|
|
891
|
+
return items[next() % items.length];
|
|
892
|
+
}
|
|
893
|
+
function fraction(next) {
|
|
894
|
+
return next() / 4294967296;
|
|
895
|
+
}
|
|
896
|
+
function resolveParametricSpec(spec) {
|
|
897
|
+
const validated = validateParametricSpec(spec);
|
|
898
|
+
const seed = validated.seed ?? randomBytes2(4).readUInt32BE(0);
|
|
899
|
+
const next = seededStream(seed, PARAMETRIC_STREAM_OFFSET, 0, 0);
|
|
900
|
+
const params = validated.studies;
|
|
901
|
+
const studies = [];
|
|
902
|
+
let totalFiles = 0;
|
|
903
|
+
const studyCount = sample(next, params.count);
|
|
904
|
+
for (let st = 0; st < studyCount; st++) {
|
|
905
|
+
const seriesCount = sample(next, params.seriesPerStudy);
|
|
906
|
+
const series = [];
|
|
907
|
+
for (let se = 0; se < seriesCount; se++) {
|
|
908
|
+
const fileCount = sample(next, params.filesPerSeries);
|
|
909
|
+
totalFiles += fileCount;
|
|
910
|
+
const modality = params.modalities ? pick(next, params.modalities) : void 0;
|
|
911
|
+
const base = {
|
|
912
|
+
type: "valid-image",
|
|
913
|
+
...modality !== void 0 ? { modality } : {}
|
|
914
|
+
};
|
|
915
|
+
const entries2 = [];
|
|
916
|
+
if (params.fileSizeKb !== void 0 && typeof params.fileSizeKb !== "number") {
|
|
917
|
+
for (let f = 0; f < fileCount; f++) {
|
|
918
|
+
entries2.push({
|
|
919
|
+
...base,
|
|
920
|
+
targetSizeKb: sample(next, params.fileSizeKb)
|
|
921
|
+
});
|
|
922
|
+
}
|
|
923
|
+
} else {
|
|
924
|
+
entries2.push({
|
|
925
|
+
...base,
|
|
926
|
+
...params.fileSizeKb !== void 0 ? { targetSizeKb: params.fileSizeKb } : {},
|
|
927
|
+
...fileCount > 1 ? { count: fileCount } : {}
|
|
928
|
+
});
|
|
929
|
+
}
|
|
930
|
+
series.push({ entries: entries2 });
|
|
931
|
+
}
|
|
932
|
+
studies.push({
|
|
933
|
+
series,
|
|
934
|
+
...params.tags !== void 0 ? { tags: params.tags } : {}
|
|
935
|
+
});
|
|
936
|
+
}
|
|
937
|
+
const entries = [];
|
|
938
|
+
for (const edgeCase of validated.edgeCases ?? []) {
|
|
939
|
+
const { frequency, ...entry } = edgeCase;
|
|
940
|
+
if (frequency === void 0) {
|
|
941
|
+
entries.push(entry);
|
|
942
|
+
continue;
|
|
943
|
+
}
|
|
944
|
+
let drawn = 0;
|
|
945
|
+
for (let i = 0; i < totalFiles; i++) {
|
|
946
|
+
if (fraction(next) < frequency) drawn++;
|
|
947
|
+
}
|
|
948
|
+
if (drawn > 0) entries.push({ ...entry, count: drawn });
|
|
949
|
+
}
|
|
950
|
+
return {
|
|
951
|
+
...entries.length > 0 ? { entries } : {},
|
|
952
|
+
studies,
|
|
953
|
+
seed,
|
|
954
|
+
...validated.layout !== void 0 ? { layout: validated.layout } : {}
|
|
955
|
+
};
|
|
956
|
+
}
|
|
796
957
|
export {
|
|
797
958
|
caseCachePath,
|
|
798
959
|
defaultPublicCasesPath,
|
|
@@ -802,7 +963,9 @@ export {
|
|
|
802
963
|
loadCaseById,
|
|
803
964
|
loadCasesFromJson,
|
|
804
965
|
loadDefaultCases,
|
|
966
|
+
resolveParametricSpec,
|
|
805
967
|
validateDatasetSpec,
|
|
968
|
+
validateParametricSpec,
|
|
806
969
|
verifySha256,
|
|
807
970
|
writeCollectionFromSpec
|
|
808
971
|
};
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
// src/schema/parametric.ts
|
|
2
|
+
import { randomBytes as randomBytes2 } from "node:crypto";
|
|
3
|
+
|
|
4
|
+
// src/syntheticFixtures/uid.ts
|
|
5
|
+
import { randomBytes } from "node:crypto";
|
|
6
|
+
function lcg(seed) {
|
|
7
|
+
let s = seed >>> 0;
|
|
8
|
+
return () => {
|
|
9
|
+
s = Math.imul(1664525, s) + 1013904223 >>> 0;
|
|
10
|
+
return s;
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
var PARAMETRIC_STREAM_OFFSET = 2246822507;
|
|
14
|
+
function seededStream(seed, offset, a, b) {
|
|
15
|
+
return lcg(seed * 999983 + offset + a * 1000003 + b * 7919 >>> 0);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// src/schema/validate.ts
|
|
19
|
+
var TYPE_CAPABILITIES = {
|
|
20
|
+
"valid-image": { image: true },
|
|
21
|
+
"invalid-uid-image": { image: true },
|
|
22
|
+
"vendor-warnings-image": { image: true },
|
|
23
|
+
"fake-signature": { image: false },
|
|
24
|
+
"non-dicom": { image: false },
|
|
25
|
+
dicomdir: { image: false }
|
|
26
|
+
};
|
|
27
|
+
var VALID_MODALITIES = {
|
|
28
|
+
CT: true,
|
|
29
|
+
PT: true,
|
|
30
|
+
MR: true,
|
|
31
|
+
CR: true
|
|
32
|
+
};
|
|
33
|
+
var VALID_LAYOUTS = {
|
|
34
|
+
flat: true,
|
|
35
|
+
hierarchical: true
|
|
36
|
+
};
|
|
37
|
+
var VALID_TRANSFER_SYNTAXES = {
|
|
38
|
+
"explicit-vr-little-endian": true,
|
|
39
|
+
"implicit-vr-little-endian": true
|
|
40
|
+
};
|
|
41
|
+
var MAX_PIXEL_BYTES = 512 * 1024 * 1024;
|
|
42
|
+
var VALID_VIOLATIONS = {
|
|
43
|
+
"uid-too-long": true,
|
|
44
|
+
"non-conformant-uid": true,
|
|
45
|
+
"missing-meta-header": true,
|
|
46
|
+
"malformed-sq-delimiter": true,
|
|
47
|
+
"vr-max-length-exceeded": true,
|
|
48
|
+
"missing-type1-tag": true
|
|
49
|
+
};
|
|
50
|
+
var HEX_TAG_RE = /^[0-9a-fA-F]{8}$/;
|
|
51
|
+
var KEYWORD_RE = /^[A-Z][A-Za-z0-9]*$/;
|
|
52
|
+
function validatePositiveInt(value, path) {
|
|
53
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < 1) {
|
|
54
|
+
throw new Error(
|
|
55
|
+
`${path}: must be a positive integer; got ${JSON.stringify(value)}`
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
function validateSeed(value, path) {
|
|
60
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
61
|
+
throw new Error(
|
|
62
|
+
`${path}: must be a finite number; got ${JSON.stringify(value)}`
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
function validateSizeKbLimit(kb, path) {
|
|
67
|
+
if (kb * 1024 > MAX_PIXEL_BYTES) {
|
|
68
|
+
throw new Error(`${path}: exceeds the 512 MB limit`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
function validateEnum(value, valid, path) {
|
|
72
|
+
if (typeof value !== "string" || !Object.hasOwn(valid, value)) {
|
|
73
|
+
throw new Error(
|
|
74
|
+
`${path}: must be one of ${Object.keys(valid).join(", ")}; got ${JSON.stringify(value)}`
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
function validateTagKey(key, path) {
|
|
79
|
+
if (!HEX_TAG_RE.test(key) && !KEYWORD_RE.test(key)) {
|
|
80
|
+
throw new Error(
|
|
81
|
+
`${path}: invalid tag key "${key}" \u2014 must be a DICOM keyword name (e.g. "Modality") or an 8-hex-char tag (e.g. "00080060")`
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
function validateEntry(entry, path) {
|
|
86
|
+
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
|
|
87
|
+
throw new Error(`${path}: must be an object`);
|
|
88
|
+
}
|
|
89
|
+
const e = entry;
|
|
90
|
+
validateEnum(e.type, TYPE_CAPABILITIES, `${path}.type`);
|
|
91
|
+
const type = e.type;
|
|
92
|
+
const caps = TYPE_CAPABILITIES[type];
|
|
93
|
+
if ("count" in e) validatePositiveInt(e.count, `${path}.count`);
|
|
94
|
+
for (const field of [
|
|
95
|
+
"modality",
|
|
96
|
+
"rows",
|
|
97
|
+
"columns",
|
|
98
|
+
"frames",
|
|
99
|
+
"targetSizeKb",
|
|
100
|
+
"tags",
|
|
101
|
+
"violations",
|
|
102
|
+
"transferSyntax"
|
|
103
|
+
]) {
|
|
104
|
+
if (!caps.image && field in e)
|
|
105
|
+
throw new Error(`${path}.${field}: not supported for type "${type}"`);
|
|
106
|
+
}
|
|
107
|
+
if ("transferSyntax" in e) {
|
|
108
|
+
validateEnum(
|
|
109
|
+
e.transferSyntax,
|
|
110
|
+
VALID_TRANSFER_SYNTAXES,
|
|
111
|
+
`${path}.transferSyntax`
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
if ("modality" in e) {
|
|
115
|
+
validateEnum(e.modality, VALID_MODALITIES, `${path}.modality`);
|
|
116
|
+
}
|
|
117
|
+
if ("tags" in e) validateTags(e.tags, `${path}.tags`);
|
|
118
|
+
if ("violations" in e) {
|
|
119
|
+
if (!Array.isArray(e.violations)) {
|
|
120
|
+
throw new Error(`${path}.violations: must be an array`);
|
|
121
|
+
}
|
|
122
|
+
for (const [i, v] of e.violations.entries()) {
|
|
123
|
+
validateEnum(v, VALID_VIOLATIONS, `${path}.violations[${i}]`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
const hasDims = "rows" in e || "columns" in e || "frames" in e;
|
|
127
|
+
if ("targetSizeKb" in e) {
|
|
128
|
+
if (hasDims) {
|
|
129
|
+
throw new Error(
|
|
130
|
+
`${path}: targetSizeKb cannot be combined with rows/columns/frames \u2014 use one sizing mechanism`
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
validatePositiveInt(e.targetSizeKb, `${path}.targetSizeKb`);
|
|
134
|
+
validateSizeKbLimit(e.targetSizeKb, `${path}.targetSizeKb`);
|
|
135
|
+
} else if (hasDims) {
|
|
136
|
+
if (!("rows" in e) || !("columns" in e)) {
|
|
137
|
+
throw new Error(`${path}: rows and columns must be provided together`);
|
|
138
|
+
}
|
|
139
|
+
validatePositiveInt(e.rows, `${path}.rows`);
|
|
140
|
+
validatePositiveInt(e.columns, `${path}.columns`);
|
|
141
|
+
if ("frames" in e) validatePositiveInt(e.frames, `${path}.frames`);
|
|
142
|
+
const rows = e.rows;
|
|
143
|
+
const columns = e.columns;
|
|
144
|
+
const frames = typeof e.frames === "number" ? e.frames : 1;
|
|
145
|
+
if (rows * columns * frames * 2 > MAX_PIXEL_BYTES) {
|
|
146
|
+
throw new Error(
|
|
147
|
+
`${path}: pixel data (${rows}\xD7${columns}\xD7${frames}\xD72 bytes) exceeds the 512 MB limit`
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return e;
|
|
152
|
+
}
|
|
153
|
+
function validateTags(tags, path) {
|
|
154
|
+
if (typeof tags !== "object" || tags === null || Array.isArray(tags)) {
|
|
155
|
+
throw new Error(`${path}: must be an object`);
|
|
156
|
+
}
|
|
157
|
+
for (const key of Object.keys(tags)) {
|
|
158
|
+
validateTagKey(key, path);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
function validateRange(value, path) {
|
|
162
|
+
if (typeof value === "number") {
|
|
163
|
+
validatePositiveInt(value, path);
|
|
164
|
+
return value;
|
|
165
|
+
}
|
|
166
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
167
|
+
throw new Error(
|
|
168
|
+
`${path}: must be a positive integer or { min, max }; got ${JSON.stringify(value)}`
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
const r = value;
|
|
172
|
+
validatePositiveInt(r.min, `${path}.min`);
|
|
173
|
+
validatePositiveInt(r.max, `${path}.max`);
|
|
174
|
+
if (r.min > r.max) {
|
|
175
|
+
throw new Error(`${path}: min (${r.min}) must be \u2264 max (${r.max})`);
|
|
176
|
+
}
|
|
177
|
+
return value;
|
|
178
|
+
}
|
|
179
|
+
function rangeMax(range) {
|
|
180
|
+
return typeof range === "number" ? range : range.max;
|
|
181
|
+
}
|
|
182
|
+
function validateParametricStudies(studies, path) {
|
|
183
|
+
if (typeof studies !== "object" || studies === null || Array.isArray(studies)) {
|
|
184
|
+
throw new Error(`${path}: must be an object`);
|
|
185
|
+
}
|
|
186
|
+
const s = studies;
|
|
187
|
+
for (const field of ["count", "seriesPerStudy", "filesPerSeries"]) {
|
|
188
|
+
if (!(field in s)) throw new Error(`${path}.${field}: is required`);
|
|
189
|
+
validateRange(s[field], `${path}.${field}`);
|
|
190
|
+
}
|
|
191
|
+
if ("fileSizeKb" in s) {
|
|
192
|
+
validateRange(s.fileSizeKb, `${path}.fileSizeKb`);
|
|
193
|
+
validateSizeKbLimit(rangeMax(s.fileSizeKb), `${path}.fileSizeKb`);
|
|
194
|
+
}
|
|
195
|
+
if ("modalities" in s) {
|
|
196
|
+
if (!Array.isArray(s.modalities) || s.modalities.length === 0) {
|
|
197
|
+
throw new Error(`${path}.modalities: must be a non-empty array`);
|
|
198
|
+
}
|
|
199
|
+
for (const [i, m] of s.modalities.entries()) {
|
|
200
|
+
validateEnum(m, VALID_MODALITIES, `${path}.modalities[${i}]`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
if ("tags" in s) validateTags(s.tags, `${path}.tags`);
|
|
204
|
+
return s;
|
|
205
|
+
}
|
|
206
|
+
function validateEdgeCase(edgeCase, path) {
|
|
207
|
+
const e = validateEntry(edgeCase, path);
|
|
208
|
+
if ("frequency" in e) {
|
|
209
|
+
if ("count" in e) {
|
|
210
|
+
throw new Error(
|
|
211
|
+
`${path}: frequency cannot be combined with count \u2014 frequency draws the count at resolution time`
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
const f = e.frequency;
|
|
215
|
+
if (typeof f !== "number" || !(f >= 0 && f <= 1)) {
|
|
216
|
+
throw new Error(
|
|
217
|
+
`${path}.frequency: must be a number between 0 and 1; got ${JSON.stringify(f)}`
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return e;
|
|
222
|
+
}
|
|
223
|
+
function validateParametricSpec(raw) {
|
|
224
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
225
|
+
throw new Error("ParametricSpec: must be a JSON object");
|
|
226
|
+
}
|
|
227
|
+
const r = raw;
|
|
228
|
+
if (!("studies" in r)) {
|
|
229
|
+
throw new Error("ParametricSpec.studies: is required");
|
|
230
|
+
}
|
|
231
|
+
const studies = validateParametricStudies(r.studies, "studies");
|
|
232
|
+
const edgeCases = [];
|
|
233
|
+
if ("edgeCases" in r) {
|
|
234
|
+
if (!Array.isArray(r.edgeCases)) {
|
|
235
|
+
throw new Error("ParametricSpec.edgeCases: must be an array");
|
|
236
|
+
}
|
|
237
|
+
for (const [i, e] of r.edgeCases.entries()) {
|
|
238
|
+
edgeCases.push(validateEdgeCase(e, `edgeCases[${i}]`));
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
if ("seed" in r) validateSeed(r.seed, "ParametricSpec.seed");
|
|
242
|
+
if ("layout" in r) {
|
|
243
|
+
validateEnum(r.layout, VALID_LAYOUTS, "ParametricSpec.layout");
|
|
244
|
+
}
|
|
245
|
+
return {
|
|
246
|
+
studies,
|
|
247
|
+
...edgeCases.length > 0 ? { edgeCases } : {},
|
|
248
|
+
...r.seed !== void 0 ? { seed: r.seed } : {},
|
|
249
|
+
...r.layout !== void 0 ? { layout: r.layout } : {}
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// src/schema/parametric.ts
|
|
254
|
+
function sample(next, range) {
|
|
255
|
+
if (typeof range === "number") return range;
|
|
256
|
+
return range.min + next() % (range.max - range.min + 1);
|
|
257
|
+
}
|
|
258
|
+
function pick(next, items) {
|
|
259
|
+
return items[next() % items.length];
|
|
260
|
+
}
|
|
261
|
+
function fraction(next) {
|
|
262
|
+
return next() / 4294967296;
|
|
263
|
+
}
|
|
264
|
+
function resolveParametricSpec(spec) {
|
|
265
|
+
const validated = validateParametricSpec(spec);
|
|
266
|
+
const seed = validated.seed ?? randomBytes2(4).readUInt32BE(0);
|
|
267
|
+
const next = seededStream(seed, PARAMETRIC_STREAM_OFFSET, 0, 0);
|
|
268
|
+
const params = validated.studies;
|
|
269
|
+
const studies = [];
|
|
270
|
+
let totalFiles = 0;
|
|
271
|
+
const studyCount = sample(next, params.count);
|
|
272
|
+
for (let st = 0; st < studyCount; st++) {
|
|
273
|
+
const seriesCount = sample(next, params.seriesPerStudy);
|
|
274
|
+
const series = [];
|
|
275
|
+
for (let se = 0; se < seriesCount; se++) {
|
|
276
|
+
const fileCount = sample(next, params.filesPerSeries);
|
|
277
|
+
totalFiles += fileCount;
|
|
278
|
+
const modality = params.modalities ? pick(next, params.modalities) : void 0;
|
|
279
|
+
const base = {
|
|
280
|
+
type: "valid-image",
|
|
281
|
+
...modality !== void 0 ? { modality } : {}
|
|
282
|
+
};
|
|
283
|
+
const entries2 = [];
|
|
284
|
+
if (params.fileSizeKb !== void 0 && typeof params.fileSizeKb !== "number") {
|
|
285
|
+
for (let f = 0; f < fileCount; f++) {
|
|
286
|
+
entries2.push({
|
|
287
|
+
...base,
|
|
288
|
+
targetSizeKb: sample(next, params.fileSizeKb)
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
} else {
|
|
292
|
+
entries2.push({
|
|
293
|
+
...base,
|
|
294
|
+
...params.fileSizeKb !== void 0 ? { targetSizeKb: params.fileSizeKb } : {},
|
|
295
|
+
...fileCount > 1 ? { count: fileCount } : {}
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
series.push({ entries: entries2 });
|
|
299
|
+
}
|
|
300
|
+
studies.push({
|
|
301
|
+
series,
|
|
302
|
+
...params.tags !== void 0 ? { tags: params.tags } : {}
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
const entries = [];
|
|
306
|
+
for (const edgeCase of validated.edgeCases ?? []) {
|
|
307
|
+
const { frequency, ...entry } = edgeCase;
|
|
308
|
+
if (frequency === void 0) {
|
|
309
|
+
entries.push(entry);
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
let drawn = 0;
|
|
313
|
+
for (let i = 0; i < totalFiles; i++) {
|
|
314
|
+
if (fraction(next) < frequency) drawn++;
|
|
315
|
+
}
|
|
316
|
+
if (drawn > 0) entries.push({ ...entry, count: drawn });
|
|
317
|
+
}
|
|
318
|
+
return {
|
|
319
|
+
...entries.length > 0 ? { entries } : {},
|
|
320
|
+
studies,
|
|
321
|
+
seed,
|
|
322
|
+
...validated.layout !== void 0 ? { layout: validated.layout } : {}
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
export {
|
|
326
|
+
resolveParametricSpec
|
|
327
|
+
};
|
|
@@ -39,6 +39,25 @@ function validatePositiveInt(value, path) {
|
|
|
39
39
|
);
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
|
+
function validateSeed(value, path) {
|
|
43
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
44
|
+
throw new Error(
|
|
45
|
+
`${path}: must be a finite number; got ${JSON.stringify(value)}`
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function validateSizeKbLimit(kb, path) {
|
|
50
|
+
if (kb * 1024 > MAX_PIXEL_BYTES) {
|
|
51
|
+
throw new Error(`${path}: exceeds the 512 MB limit`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function validateEnum(value, valid, path) {
|
|
55
|
+
if (typeof value !== "string" || !Object.hasOwn(valid, value)) {
|
|
56
|
+
throw new Error(
|
|
57
|
+
`${path}: must be one of ${Object.keys(valid).join(", ")}; got ${JSON.stringify(value)}`
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
42
61
|
function validateTagKey(key, path) {
|
|
43
62
|
if (!HEX_TAG_RE.test(key) && !KEYWORD_RE.test(key)) {
|
|
44
63
|
throw new Error(
|
|
@@ -51,11 +70,7 @@ function validateEntry(entry, path) {
|
|
|
51
70
|
throw new Error(`${path}: must be an object`);
|
|
52
71
|
}
|
|
53
72
|
const e = entry;
|
|
54
|
-
|
|
55
|
-
throw new Error(
|
|
56
|
-
`${path}.type: must be one of ${Object.keys(TYPE_CAPABILITIES).join(", ")}; got ${JSON.stringify(e.type)}`
|
|
57
|
-
);
|
|
58
|
-
}
|
|
73
|
+
validateEnum(e.type, TYPE_CAPABILITIES, `${path}.type`);
|
|
59
74
|
const type = e.type;
|
|
60
75
|
const caps = TYPE_CAPABILITIES[type];
|
|
61
76
|
if ("count" in e) validatePositiveInt(e.count, `${path}.count`);
|
|
@@ -73,18 +88,14 @@ function validateEntry(entry, path) {
|
|
|
73
88
|
throw new Error(`${path}.${field}: not supported for type "${type}"`);
|
|
74
89
|
}
|
|
75
90
|
if ("transferSyntax" in e) {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
91
|
+
validateEnum(
|
|
92
|
+
e.transferSyntax,
|
|
93
|
+
VALID_TRANSFER_SYNTAXES,
|
|
94
|
+
`${path}.transferSyntax`
|
|
95
|
+
);
|
|
81
96
|
}
|
|
82
97
|
if ("modality" in e) {
|
|
83
|
-
|
|
84
|
-
throw new Error(
|
|
85
|
-
`${path}.modality: must be one of ${Object.keys(VALID_MODALITIES).join(", ")}; got ${JSON.stringify(e.modality)}`
|
|
86
|
-
);
|
|
87
|
-
}
|
|
98
|
+
validateEnum(e.modality, VALID_MODALITIES, `${path}.modality`);
|
|
88
99
|
}
|
|
89
100
|
if ("tags" in e) validateTags(e.tags, `${path}.tags`);
|
|
90
101
|
if ("violations" in e) {
|
|
@@ -92,11 +103,7 @@ function validateEntry(entry, path) {
|
|
|
92
103
|
throw new Error(`${path}.violations: must be an array`);
|
|
93
104
|
}
|
|
94
105
|
for (const [i, v] of e.violations.entries()) {
|
|
95
|
-
|
|
96
|
-
throw new Error(
|
|
97
|
-
`${path}.violations[${i}]: must be one of ${Object.keys(VALID_VIOLATIONS).join(", ")}; got ${JSON.stringify(v)}`
|
|
98
|
-
);
|
|
99
|
-
}
|
|
106
|
+
validateEnum(v, VALID_VIOLATIONS, `${path}.violations[${i}]`);
|
|
100
107
|
}
|
|
101
108
|
}
|
|
102
109
|
const hasDims = "rows" in e || "columns" in e || "frames" in e;
|
|
@@ -107,9 +114,7 @@ function validateEntry(entry, path) {
|
|
|
107
114
|
);
|
|
108
115
|
}
|
|
109
116
|
validatePositiveInt(e.targetSizeKb, `${path}.targetSizeKb`);
|
|
110
|
-
|
|
111
|
-
throw new Error(`${path}.targetSizeKb: exceeds the 512 MB limit`);
|
|
112
|
-
}
|
|
117
|
+
validateSizeKbLimit(e.targetSizeKb, `${path}.targetSizeKb`);
|
|
113
118
|
} else if (hasDims) {
|
|
114
119
|
if (!("rows" in e) || !("columns" in e)) {
|
|
115
120
|
throw new Error(`${path}: rows and columns must be provided together`);
|
|
@@ -194,28 +199,110 @@ function validateDatasetSpec(raw) {
|
|
|
194
199
|
const studies = rawStudies.map(
|
|
195
200
|
(study, i) => validateStudy(study, `studies[${i}]`)
|
|
196
201
|
);
|
|
197
|
-
if ("seed" in r)
|
|
198
|
-
|
|
202
|
+
if ("seed" in r) validateSeed(r.seed, "DatasetSpec.seed");
|
|
203
|
+
if ("layout" in r) {
|
|
204
|
+
validateEnum(r.layout, VALID_LAYOUTS, "DatasetSpec.layout");
|
|
205
|
+
}
|
|
206
|
+
return {
|
|
207
|
+
...entries.length > 0 ? { entries } : {},
|
|
208
|
+
...studies.length > 0 ? { studies } : {},
|
|
209
|
+
...r.seed !== void 0 ? { seed: r.seed } : {},
|
|
210
|
+
...r.layout !== void 0 ? { layout: r.layout } : {}
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
function validateRange(value, path) {
|
|
214
|
+
if (typeof value === "number") {
|
|
215
|
+
validatePositiveInt(value, path);
|
|
216
|
+
return value;
|
|
217
|
+
}
|
|
218
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
219
|
+
throw new Error(
|
|
220
|
+
`${path}: must be a positive integer or { min, max }; got ${JSON.stringify(value)}`
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
const r = value;
|
|
224
|
+
validatePositiveInt(r.min, `${path}.min`);
|
|
225
|
+
validatePositiveInt(r.max, `${path}.max`);
|
|
226
|
+
if (r.min > r.max) {
|
|
227
|
+
throw new Error(`${path}: min (${r.min}) must be \u2264 max (${r.max})`);
|
|
228
|
+
}
|
|
229
|
+
return value;
|
|
230
|
+
}
|
|
231
|
+
function rangeMax(range) {
|
|
232
|
+
return typeof range === "number" ? range : range.max;
|
|
233
|
+
}
|
|
234
|
+
function validateParametricStudies(studies, path) {
|
|
235
|
+
if (typeof studies !== "object" || studies === null || Array.isArray(studies)) {
|
|
236
|
+
throw new Error(`${path}: must be an object`);
|
|
237
|
+
}
|
|
238
|
+
const s = studies;
|
|
239
|
+
for (const field of ["count", "seriesPerStudy", "filesPerSeries"]) {
|
|
240
|
+
if (!(field in s)) throw new Error(`${path}.${field}: is required`);
|
|
241
|
+
validateRange(s[field], `${path}.${field}`);
|
|
242
|
+
}
|
|
243
|
+
if ("fileSizeKb" in s) {
|
|
244
|
+
validateRange(s.fileSizeKb, `${path}.fileSizeKb`);
|
|
245
|
+
validateSizeKbLimit(rangeMax(s.fileSizeKb), `${path}.fileSizeKb`);
|
|
246
|
+
}
|
|
247
|
+
if ("modalities" in s) {
|
|
248
|
+
if (!Array.isArray(s.modalities) || s.modalities.length === 0) {
|
|
249
|
+
throw new Error(`${path}.modalities: must be a non-empty array`);
|
|
250
|
+
}
|
|
251
|
+
for (const [i, m] of s.modalities.entries()) {
|
|
252
|
+
validateEnum(m, VALID_MODALITIES, `${path}.modalities[${i}]`);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
if ("tags" in s) validateTags(s.tags, `${path}.tags`);
|
|
256
|
+
return s;
|
|
257
|
+
}
|
|
258
|
+
function validateEdgeCase(edgeCase, path) {
|
|
259
|
+
const e = validateEntry(edgeCase, path);
|
|
260
|
+
if ("frequency" in e) {
|
|
261
|
+
if ("count" in e) {
|
|
199
262
|
throw new Error(
|
|
200
|
-
|
|
263
|
+
`${path}: frequency cannot be combined with count \u2014 frequency draws the count at resolution time`
|
|
201
264
|
);
|
|
202
265
|
}
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
if (typeof r.layout !== "string" || !Object.hasOwn(VALID_LAYOUTS, r.layout)) {
|
|
266
|
+
const f = e.frequency;
|
|
267
|
+
if (typeof f !== "number" || !(f >= 0 && f <= 1)) {
|
|
206
268
|
throw new Error(
|
|
207
|
-
|
|
269
|
+
`${path}.frequency: must be a number between 0 and 1; got ${JSON.stringify(f)}`
|
|
208
270
|
);
|
|
209
271
|
}
|
|
210
272
|
}
|
|
273
|
+
return e;
|
|
274
|
+
}
|
|
275
|
+
function validateParametricSpec(raw) {
|
|
276
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
277
|
+
throw new Error("ParametricSpec: must be a JSON object");
|
|
278
|
+
}
|
|
279
|
+
const r = raw;
|
|
280
|
+
if (!("studies" in r)) {
|
|
281
|
+
throw new Error("ParametricSpec.studies: is required");
|
|
282
|
+
}
|
|
283
|
+
const studies = validateParametricStudies(r.studies, "studies");
|
|
284
|
+
const edgeCases = [];
|
|
285
|
+
if ("edgeCases" in r) {
|
|
286
|
+
if (!Array.isArray(r.edgeCases)) {
|
|
287
|
+
throw new Error("ParametricSpec.edgeCases: must be an array");
|
|
288
|
+
}
|
|
289
|
+
for (const [i, e] of r.edgeCases.entries()) {
|
|
290
|
+
edgeCases.push(validateEdgeCase(e, `edgeCases[${i}]`));
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
if ("seed" in r) validateSeed(r.seed, "ParametricSpec.seed");
|
|
294
|
+
if ("layout" in r) {
|
|
295
|
+
validateEnum(r.layout, VALID_LAYOUTS, "ParametricSpec.layout");
|
|
296
|
+
}
|
|
211
297
|
return {
|
|
212
|
-
|
|
213
|
-
...
|
|
298
|
+
studies,
|
|
299
|
+
...edgeCases.length > 0 ? { edgeCases } : {},
|
|
214
300
|
...r.seed !== void 0 ? { seed: r.seed } : {},
|
|
215
301
|
...r.layout !== void 0 ? { layout: r.layout } : {}
|
|
216
302
|
};
|
|
217
303
|
}
|
|
218
304
|
export {
|
|
219
305
|
HEX_TAG_RE,
|
|
220
|
-
validateDatasetSpec
|
|
306
|
+
validateDatasetSpec,
|
|
307
|
+
validateParametricSpec
|
|
221
308
|
};
|
|
@@ -19,6 +19,7 @@ function seededUid(next, role) {
|
|
|
19
19
|
return formatUid(next(), next(), role);
|
|
20
20
|
}
|
|
21
21
|
var GROUP_STREAM_OFFSET = 2654435769;
|
|
22
|
+
var PARAMETRIC_STREAM_OFFSET = 2246822507;
|
|
22
23
|
function seededStream(seed, offset, a, b) {
|
|
23
24
|
return lcg(seed * 999983 + offset + a * 1000003 + b * 7919 >>> 0);
|
|
24
25
|
}
|
|
@@ -63,6 +64,8 @@ function makeUidGenerator(seed) {
|
|
|
63
64
|
};
|
|
64
65
|
}
|
|
65
66
|
export {
|
|
67
|
+
PARAMETRIC_STREAM_OFFSET,
|
|
66
68
|
makeGroupUidGenerator,
|
|
67
|
-
makeUidGenerator
|
|
69
|
+
makeUidGenerator,
|
|
70
|
+
seededStream
|
|
68
71
|
};
|
package/dist/types/index.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { type CollectionManifest, type GeneratedFile, generateCollectionFromSpec, generateFile, writeCollectionFromSpec, } from './collection/writer.js';
|
|
2
2
|
export { defaultPublicCasesPath, loadCaseById, loadCasesFromJson, loadDefaultCases, type PublicCaseRecord, type PublicCaseSource, } from './public-fixtures/catalog.js';
|
|
3
3
|
export { caseCachePath, fetchPublicCaseToCache, verifySha256, } from './public-fixtures/fetch.js';
|
|
4
|
-
export
|
|
5
|
-
export {
|
|
4
|
+
export { resolveParametricSpec } from './schema/parametric.js';
|
|
5
|
+
export type { DatasetLayout, DatasetSpec, DicomdirSpec, DicomTagOverrides, FakeSignatureSpec, FileSpec, ImageSpec, InvalidUidImageSpec, Modality, NonDicomSpec, ParametricEdgeCase, ParametricSpec, ParametricStudies, Range, SeriesEntrySpec, SeriesSpec, StudySpec, TransferSyntax, ValidImageSpec, VendorWarningsImageSpec, ViolationClass, } from './schema/types.js';
|
|
6
|
+
export { validateDatasetSpec, validateParametricSpec, } from './schema/validate.js';
|
|
@@ -61,4 +61,25 @@ export type DatasetSpec = {
|
|
|
61
61
|
seed?: number;
|
|
62
62
|
layout?: DatasetLayout;
|
|
63
63
|
};
|
|
64
|
+
export type Range = number | {
|
|
65
|
+
min: number;
|
|
66
|
+
max: number;
|
|
67
|
+
};
|
|
68
|
+
export type ParametricStudies = {
|
|
69
|
+
count: Range;
|
|
70
|
+
seriesPerStudy: Range;
|
|
71
|
+
filesPerSeries: Range;
|
|
72
|
+
fileSizeKb?: Range;
|
|
73
|
+
modalities?: Modality[];
|
|
74
|
+
tags?: DicomTagOverrides;
|
|
75
|
+
};
|
|
76
|
+
export type ParametricEdgeCase = EntrySpec & {
|
|
77
|
+
frequency?: number;
|
|
78
|
+
};
|
|
79
|
+
export type ParametricSpec = {
|
|
80
|
+
seed?: number;
|
|
81
|
+
studies: ParametricStudies;
|
|
82
|
+
edgeCases?: ParametricEdgeCase[];
|
|
83
|
+
layout?: DatasetLayout;
|
|
84
|
+
};
|
|
64
85
|
export {};
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
-
import type { DatasetSpec } from './types.js';
|
|
1
|
+
import type { DatasetSpec, ParametricSpec } from './types.js';
|
|
2
2
|
export declare const HEX_TAG_RE: RegExp;
|
|
3
3
|
export declare function validateDatasetSpec(raw: unknown): DatasetSpec;
|
|
4
|
+
export declare function validateParametricSpec(raw: unknown): ParametricSpec;
|
|
@@ -3,6 +3,8 @@ export type UidSet = {
|
|
|
3
3
|
series: string;
|
|
4
4
|
sop: string;
|
|
5
5
|
};
|
|
6
|
+
export declare const PARAMETRIC_STREAM_OFFSET = 2246822507;
|
|
7
|
+
export declare function seededStream(seed: number, offset: number, a: number, b: number): () => number;
|
|
6
8
|
export type GroupUidGenerator = {
|
|
7
9
|
study: (studyIndex: number) => string;
|
|
8
10
|
series: (studyIndex: number, seriesIndex: number) => string;
|