dicom-synth 1.3.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 CHANGED
@@ -191,30 +191,43 @@ An `EntrySpec` is a `FileSpec` plus an optional `count` field (how many times to
191
191
  | `valid-image` | Standards-valid image (CT by default — see `modality`); numeric UIDs, complete meta header | strict |
192
192
  | `invalid-uid-image` | Image with non-numeric UIDs (letters in UID components) | edge |
193
193
  | `vendor-warnings-image` | Image with empty `Laterality` and `PatientWeight = 0` — produces dciodvfy warnings | edge |
194
- | `large-image` | Image with configurable pixel dimensions; `rows` and `columns` required | strict |
195
194
  | `fake-signature` | 200-byte buffer with `XXXX` at preamble offset — no DICM magic | edge |
196
195
  | `non-dicom` | Arbitrary text buffer; no extension in filename | edge |
197
196
  | `dicomdir` | Minimal DICOMDIR file | strict |
198
197
 
199
- **`large-image` fields:**
198
+ **`non-dicom` fields:**
200
199
 
201
200
  ```json
202
- { "type": "large-image", "rows": 512, "columns": 512, "frames": 100 }
201
+ { "type": "non-dicom", "content": "not a dicom file" }
203
202
  ```
204
203
 
205
- `frames` defaults to 1. A 512×512×100 image produces a buffer of ~52 MB.
204
+ `content` defaults to `"not dicom"`.
206
205
 
207
- **`non-dicom` fields:**
206
+ ### Sizing (`rows`/`columns`/`frames` or `targetSizeKb`)
207
+
208
+ Available on all image types. Two mutually exclusive mechanisms; omitting both produces a minimal 1×1 image.
209
+
210
+ **Explicit pixel dimensions** — for geometry-shaped cases (large frames, strips, multi-frame):
208
211
 
209
212
  ```json
210
- { "type": "non-dicom", "content": "not a dicom file" }
213
+ { "type": "valid-image", "rows": 512, "columns": 512, "frames": 100 }
211
214
  ```
212
215
 
213
- `content` defaults to `"not dicom"`.
216
+ `rows` and `columns` must be provided together; `frames` defaults to 1. A 512×512×100 image produces a buffer of ~52 MB.
217
+
218
+ **Target file size** — for byte-shaped cases (upload limits, memory ceilings):
219
+
220
+ ```json
221
+ { "type": "valid-image", "targetSizeKb": 250000 }
222
+ ```
223
+
224
+ Pads `PixelData` so the file lands within ±2% or ±4 KB of the target (whichever is larger), accounting for tag and transfer-syntax overhead. `Rows`/`Columns` stay consistent with the pixel data, so the file remains conformant.
225
+
226
+ Both mechanisms are capped at 512 MB of pixel data to prevent accidental OOM. A target smaller than the file's fixed overhead (roughly 1 KB) produces the minimal possible file, slightly above target. When sizing fields are set they control `Rows`, `Columns`, and `PixelData` — tag overrides of those keywords do not apply.
214
227
 
215
228
  ### Modality presets (`modality`)
216
229
 
217
- Available on `valid-image`, `invalid-uid-image`, `vendor-warnings-image`, and `large-image`. Defaults to `CT`.
230
+ Available on all image types. Defaults to `CT`.
218
231
 
219
232
  ```json
220
233
  { "type": "valid-image", "modality": "PT" }
@@ -276,7 +289,7 @@ import type { StudySpec, SeriesSpec, SeriesEntrySpec } from 'dicom-synth'
276
289
 
277
290
  ### Tag overrides (`tags`)
278
291
 
279
- Available on `valid-image`, `invalid-uid-image`, `vendor-warnings-image`, and `large-image`.
292
+ Available on all image types.
280
293
 
281
294
  ```json
282
295
  {
@@ -297,7 +310,7 @@ import type { DicomTagOverrides } from 'dicom-synth'
297
310
 
298
311
  ### Transfer syntax (`transferSyntax`)
299
312
 
300
- Available on `valid-image`, `invalid-uid-image`, `vendor-warnings-image`, and `large-image`.
313
+ Available on all image types.
301
314
 
302
315
  | Value | `TransferSyntaxUID` in meta header |
303
316
  |---|---|
@@ -307,7 +320,7 @@ Available on `valid-image`, `invalid-uid-image`, `vendor-warnings-image`, and `l
307
320
 
308
321
  ### Violation injection (`violations`)
309
322
 
310
- Available on `valid-image`, `invalid-uid-image`, `vendor-warnings-image`, and `large-image`. Violations are applied as post-processing transforms to an otherwise valid buffer.
323
+ Available on all image types. Violations are applied as post-processing transforms to an otherwise valid buffer.
311
324
 
312
325
  | Violation | Effect | Detectable by dciodvfy |
313
326
  |---|---|---|
@@ -346,7 +359,8 @@ The validator throws with a human-readable message on any structural error.
346
359
  { "type": "valid-image", "count": 10, "tags": { "PatientID": "P001" }, "transferSyntax": "explicit-vr-little-endian" },
347
360
  { "type": "invalid-uid-image", "count": 3 },
348
361
  { "type": "vendor-warnings-image", "count": 2 },
349
- { "type": "large-image", "count": 1, "rows": 512, "columns": 512, "frames": 100 },
362
+ { "type": "valid-image", "count": 1, "rows": 512, "columns": 512, "frames": 100 },
363
+ { "type": "valid-image", "count": 1, "targetSizeKb": 250000 },
350
364
  { "type": "valid-image", "count": 1, "violations": ["uid-too-long", "missing-meta-header"] },
351
365
  { "type": "fake-signature", "count": 5 },
352
366
  { "type": "non-dicom", "count": 2, "content": "garbage" },
@@ -367,6 +381,42 @@ The validator throws with a human-readable message on any structural error.
367
381
 
368
382
  ---
369
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
+
370
420
  ## CLI
371
421
 
372
422
  ```bash
@@ -378,8 +428,21 @@ dicom-synth-generate --schema-inline '{"entries":[{"type":"valid-image","count":
378
428
 
379
429
  # Default output directory is ./fixtures/generated
380
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
381
437
  ```
382
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
+
383
446
  The default schema (`examples/default.json`) generates one each of `valid-image`, `invalid-uid-image`, and `vendor-warnings-image`:
384
447
 
385
448
  ```bash
@@ -457,7 +520,8 @@ pnpm fetch:public-case -- pydicom-CT-small
457
520
  | Path | Responsibility |
458
521
  |---|---|
459
522
  | `src/schema/types.ts` | All public TypeScript types (`FileSpec`, `DatasetSpec`, `ViolationClass`, etc.) |
460
- | `src/schema/validate.ts` | `validateDatasetSpec` — validates a raw JSON value against the schema |
523
+ | `src/schema/validate.ts` | `validateDatasetSpec` / `validateParametricSpec` validate raw JSON values against the schema |
524
+ | `src/schema/parametric.ts` | `resolveParametricSpec` — seeded `ParametricSpec` → `DatasetSpec` resolution |
461
525
  | `src/syntheticFixtures/generator.ts` | Internal buffer builders keyed on `FileSpec` type |
462
526
  | `src/syntheticFixtures/uid.ts` | Seeded and random UID generation |
463
527
  | `src/syntheticFixtures/violations.ts` | Post-processing violation injection |
@@ -467,6 +531,8 @@ pnpm fetch:public-case -- pydicom-CT-small
467
531
  | `bin/dicom-synth-generate.mjs` | Published generate CLI (requires `pnpm build`) |
468
532
  | `bin/dicom-synth-fetch.mjs` | Published fetch CLI (requires `pnpm build`) |
469
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` |
470
536
 
471
537
  ---
472
538
 
@@ -492,9 +558,8 @@ Git hooks: see [CONTRIBUTING.md](CONTRIBUTING.md).
492
558
 
493
559
  ## Future development
494
560
 
495
- - **Size targeting** — generate a file of approximately N KB via a `targetSizeKb` field
496
- - **Parametric dataset designer** — range-based spec (`{min, max}` for counts/sizes) that resolves deterministically into a concrete `DatasetSpec`
497
- - **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)
498
563
  - **Compressed transfer syntaxes** — JPEG-LS, JPEG 2000, RLE
499
564
  - **Private fixture catalogues** — same SHA-256 fetch/cache pattern for credentials-backed sources (e.g. S3)
500
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' || args[i] === '--schema-inline') &&
57
+ (args[i] === '--schema' ||
58
+ args[i] === '--schema-inline' ||
59
+ args[i] === '--parametric') &&
27
60
  args[i + 1]
28
61
  ) {
29
- if (rawSpec !== undefined) {
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
- let spec
61
- try {
62
- spec = validateDatasetSpec(rawSpec)
63
- } catch (err) {
64
- console.error(`Schema validation error: ${err.message}`)
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 manifest
88
+ let spec
69
89
  try {
70
- manifest = await writeCollectionFromSpec(spec, outDir)
90
+ spec =
91
+ specSource === '--parametric'
92
+ ? resolveParametricSpec(rawSpec)
93
+ : validateDatasetSpec(rawSpec)
71
94
  } catch (err) {
72
- console.error(`Error writing collection: ${err.message}`)
95
+ console.error(`Schema validation error: ${err.message}`)
73
96
  process.exit(1)
74
97
  }
75
98
 
76
- // Summary by type
77
- const counts = {}
78
- for (const entry of manifest) {
79
- counts[entry.type] = (counts[entry.type] ?? 0) + 1
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
- console.log(`Wrote ${manifest.length} file(s) to ${outDir}`)
83
- for (const [type, count] of Object.entries(counts)) {
84
- console.log(` ${type}: ${count}`)
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
  }
@@ -158,36 +158,46 @@ function serializeDict(meta, dataset) {
158
158
  );
159
159
  return Buffer.from(dicomDict.write({ allowInvalidVRLength: true }));
160
160
  }
161
- function buildValidImageBuffer(uid, modality, tags, transferSyntax) {
162
- const dataset = buildBaseImageDataset(uid, modality);
163
- applyTagOverrides(dataset, tags);
164
- return serializeDict(buildMeta(uid, modality, transferSyntax), dataset);
161
+ var MAX_DIMENSION = 65535;
162
+ function applySizing(dataset, meta, spec) {
163
+ if (spec.targetSizeKb !== void 0) {
164
+ const clampDim = (n) => Math.min(MAX_DIMENSION, Math.max(1, Math.round(n)));
165
+ const minimalPixelBytes = dataset.PixelData.byteLength;
166
+ const overhead = serializeDict(meta, dataset).length - minimalPixelBytes;
167
+ const cells = Math.max(
168
+ 1,
169
+ Math.round((spec.targetSizeKb * 1024 - overhead) / 2)
170
+ );
171
+ const rows = clampDim(Math.sqrt(cells));
172
+ const columns = clampDim(cells / rows);
173
+ dataset.Rows = rows;
174
+ dataset.Columns = columns;
175
+ dataset.PixelData = new ArrayBuffer(rows * columns * 2);
176
+ } else if (spec.rows !== void 0 && spec.columns !== void 0) {
177
+ dataset.Rows = spec.rows;
178
+ dataset.Columns = spec.columns;
179
+ if (spec.frames !== void 0) dataset.NumberOfFrames = spec.frames;
180
+ dataset.PixelData = new ArrayBuffer(
181
+ spec.rows * spec.columns * (spec.frames ?? 1) * 2
182
+ );
183
+ }
165
184
  }
166
- function buildInvalidUidImageBuffer(uid, modality, tags, transferSyntax) {
167
- const invalidUid = {
185
+ function buildImageBuffer(spec, uid) {
186
+ const modality = spec.modality ?? "CT";
187
+ const effectiveUid = spec.type === "invalid-uid-image" ? {
168
188
  study: `2.25.invalid.study.${uid.study.slice(-6)}`,
169
189
  series: `2.25.invalid.series.${uid.series.slice(-6)}`,
170
190
  sop: `2.25.invalid.sop.${uid.sop.slice(-6)}`
171
- };
172
- const dataset = buildBaseImageDataset(invalidUid, modality);
173
- applyTagOverrides(dataset, tags);
174
- return serializeDict(buildMeta(invalidUid, modality, transferSyntax), dataset);
175
- }
176
- function buildVendorWarningsImageBuffer(uid, modality, tags, transferSyntax) {
177
- const dataset = buildBaseImageDataset(uid, modality);
178
- dataset.Laterality = "";
179
- dataset.PatientWeight = "0";
180
- applyTagOverrides(dataset, tags);
181
- return serializeDict(buildMeta(uid, modality, transferSyntax), dataset);
182
- }
183
- function buildLargeImageBuffer(uid, modality, rows, columns, frames, tags, transferSyntax) {
184
- const dataset = buildBaseImageDataset(uid, modality);
185
- dataset.Rows = rows;
186
- dataset.Columns = columns;
187
- dataset.NumberOfFrames = frames;
188
- dataset.PixelData = new Uint8Array(rows * columns * frames * 2).buffer;
189
- applyTagOverrides(dataset, tags);
190
- return serializeDict(buildMeta(uid, modality, transferSyntax), dataset);
191
+ } : uid;
192
+ const dataset = buildBaseImageDataset(effectiveUid, modality);
193
+ if (spec.type === "vendor-warnings-image") {
194
+ dataset.Laterality = "";
195
+ dataset.PatientWeight = "0";
196
+ }
197
+ applyTagOverrides(dataset, spec.tags);
198
+ const meta = buildMeta(effectiveUid, modality, spec.transferSyntax);
199
+ applySizing(dataset, meta, spec);
200
+ return serializeDict(meta, dataset);
191
201
  }
192
202
  function buildFakeSignatureBuffer() {
193
203
  const buf = Buffer.alloc(200, 0);
@@ -200,36 +210,9 @@ function buildNonDicomBuffer(content = "not dicom") {
200
210
  function buildBufferForSpec(spec, uid) {
201
211
  switch (spec.type) {
202
212
  case "valid-image":
203
- return buildValidImageBuffer(
204
- uid,
205
- spec.modality ?? "CT",
206
- spec.tags,
207
- spec.transferSyntax
208
- );
209
213
  case "invalid-uid-image":
210
- return buildInvalidUidImageBuffer(
211
- uid,
212
- spec.modality ?? "CT",
213
- spec.tags,
214
- spec.transferSyntax
215
- );
216
214
  case "vendor-warnings-image":
217
- return buildVendorWarningsImageBuffer(
218
- uid,
219
- spec.modality ?? "CT",
220
- spec.tags,
221
- spec.transferSyntax
222
- );
223
- case "large-image":
224
- return buildLargeImageBuffer(
225
- uid,
226
- spec.modality ?? "CT",
227
- spec.rows,
228
- spec.columns,
229
- spec.frames ?? 1,
230
- spec.tags,
231
- spec.transferSyntax
232
- );
215
+ return buildImageBuffer(spec, uid);
233
216
  case "fake-signature":
234
217
  return buildFakeSignatureBuffer();
235
218
  case "non-dicom":