dicom-synth 1.4.0 → 1.6.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
@@ -381,6 +381,71 @@ 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
+
420
+ ## Describe tool
421
+
422
+ `describeDirectory` scans an existing DICOM tree and emits a concrete `DatasetSpec` reproducing its shape — studies/series grouped by their UIDs, per-file size as `targetSizeKb`, and modality. This closes the loop: real tree → spec → regenerate.
423
+
424
+ ```ts
425
+ import { describeDirectory, writeCollectionFromSpec } from 'dicom-synth'
426
+
427
+ const { spec, stats } = describeDirectory('./real-dataset')
428
+ // stats: { dicomFiles, skipped, unknownModality }
429
+ await writeCollectionFromSpec(spec, './reproduced')
430
+ ```
431
+
432
+ - **Shape only by default** — no tags are copied; the regenerated dataset carries fresh UIDs and synthetic identifiers, so it cannot leak PHI from the source.
433
+ - **Opt-in tag preservation** — `keepTags` copies the named tags verbatim (some datasets are valuable for testing *precisely because* of their problematic tags):
434
+
435
+ ```ts
436
+ describeDirectory('./real-dataset', { keepTags: ['Manufacturer', '00080060'] })
437
+ ```
438
+
439
+ ⚠️ Tags are copied exactly as found. If a kept tag contains PHI, handling that PHI is the **caller's responsibility** — the tool extracts only what you name.
440
+ - Files in a series collapse into one counted entry only when modality, size, and kept tags all match, so tag/size variation is preserved rather than flattened.
441
+ - Non-DICOM, unparseable, or ungroupable files (missing study/series UIDs) are skipped and counted. An unsupported `Modality` is emitted as the default (CT) and counted.
442
+
443
+ ```ts
444
+ import type { DescribeOptions, DescribeResult } from 'dicom-synth'
445
+ ```
446
+
447
+ ---
448
+
384
449
  ## CLI
385
450
 
386
451
  ```bash
@@ -392,8 +457,21 @@ dicom-synth-generate --schema-inline '{"entries":[{"type":"valid-image","count":
392
457
 
393
458
  # Default output directory is ./fixtures/generated
394
459
  dicom-synth-generate --schema dataset.json
460
+
461
+ # Resolve and generate from a parametric spec; save the resolved DatasetSpec
462
+ dicom-synth-generate --parametric examples/parametric.json --out ./out --emit-spec resolved.json
463
+
464
+ # Preview the manifest (paths, types, sizes) without writing anything
465
+ dicom-synth-generate --parametric examples/parametric.json --dry-run
395
466
  ```
396
467
 
468
+ | Flag | Effect |
469
+ |---|---|
470
+ | `--schema <path>` / `--schema-inline <json>` / `--parametric <path>` | Input spec (mutually exclusive) |
471
+ | `--out <dir>` | Output directory (default `./fixtures/generated`) |
472
+ | `--emit-spec <path>` | Write the resolved `DatasetSpec` JSON (requires `--parametric`) |
473
+ | `--dry-run` | Generate in memory and print the manifest; nothing is written |
474
+
397
475
  The default schema (`examples/default.json`) generates one each of `valid-image`, `invalid-uid-image`, and `vendor-warnings-image`:
398
476
 
399
477
  ```bash
@@ -406,6 +484,24 @@ dicom-synth-generate --schema examples/default.json --out /tmp/out
406
484
 
407
485
  Schema validation errors exit with code 1 and a descriptive message.
408
486
 
487
+ ### Describe an existing tree
488
+
489
+ ```bash
490
+ # Shape only (PHI-safe) — prints the DatasetSpec to stdout
491
+ dicom-synth-describe ./real-dataset
492
+
493
+ # Save to a file, and keep selected tags (caller owns any PHI in them)
494
+ dicom-synth-describe ./real-dataset --out spec.json --keep-tags Manufacturer,00080060
495
+ ```
496
+
497
+ | Flag | Effect |
498
+ |---|---|
499
+ | `<dir>` | DICOM tree to scan (required) |
500
+ | `--out <path>` | Write the `DatasetSpec` JSON (default: stdout) |
501
+ | `--keep-tags <a,b,...>` | Copy the named tags (keywords or 8-hex) verbatim; omit for shape only |
502
+
503
+ Scan stats (DICOM/skipped/unsupported-modality counts) are written to stderr.
504
+
409
505
  ---
410
506
 
411
507
  ## Public fixtures
@@ -471,7 +567,9 @@ pnpm fetch:public-case -- pydicom-CT-small
471
567
  | Path | Responsibility |
472
568
  |---|---|
473
569
  | `src/schema/types.ts` | All public TypeScript types (`FileSpec`, `DatasetSpec`, `ViolationClass`, etc.) |
474
- | `src/schema/validate.ts` | `validateDatasetSpec` — validates a raw JSON value against the schema |
570
+ | `src/schema/validate.ts` | `validateDatasetSpec` / `validateParametricSpec` validate raw JSON values against the schema |
571
+ | `src/schema/parametric.ts` | `resolveParametricSpec` — seeded `ParametricSpec` → `DatasetSpec` resolution |
572
+ | `src/describe/describe.ts` | `describeDirectory` — scan a DICOM tree → `DatasetSpec` |
475
573
  | `src/syntheticFixtures/generator.ts` | Internal buffer builders keyed on `FileSpec` type |
476
574
  | `src/syntheticFixtures/uid.ts` | Seeded and random UID generation |
477
575
  | `src/syntheticFixtures/violations.ts` | Post-processing violation injection |
@@ -480,7 +578,10 @@ pnpm fetch:public-case -- pydicom-CT-small
480
578
  | `src/public-fixtures/fetch.ts` | Fetch, SHA-256 verify, content-addressed cache |
481
579
  | `bin/dicom-synth-generate.mjs` | Published generate CLI (requires `pnpm build`) |
482
580
  | `bin/dicom-synth-fetch.mjs` | Published fetch CLI (requires `pnpm build`) |
581
+ | `bin/dicom-synth-describe.mjs` | Published describe CLI (requires `pnpm build`) |
483
582
  | `examples/default.json` | Default `DatasetSpec` — one each of `valid-image`, `invalid-uid-image`, `vendor-warnings-image` |
583
+ | `examples/parametric.json` | Example `ParametricSpec` for the `--parametric` CLI path |
584
+ | `examples/convert-data-shape.mjs` | Example converter: tree-shape JSON → `DatasetSpec` |
484
585
 
485
586
  ---
486
587
 
@@ -506,9 +607,8 @@ Git hooks: see [CONTRIBUTING.md](CONTRIBUTING.md).
506
607
 
507
608
  ## Future development
508
609
 
509
- - **Size targeting** — generate a file of approximately N KB via a `targetSizeKb` field
510
- - **Parametric dataset designer** — range-based spec (`{min, max}` for counts/sizes) that resolves deterministically into a concrete `DatasetSpec`
511
- - **Dry-run mode** — emit the manifest (paths, types, grouping) without writing files
610
+ - **Dimension edge-case recipes** — documented presets for geometry pathologies (1×65535 strips, high frame counts)
611
+ - **Per-file tag variance templating** — patterned overrides like `"PatientID": "P{index}"` for shape replication
512
612
  - **Compressed transfer syntaxes** — JPEG-LS, JPEG 2000, RLE
513
613
  - **Private fixture catalogues** — same SHA-256 fetch/cache pattern for credentials-backed sources (e.g. S3)
514
614
 
@@ -0,0 +1,73 @@
1
+ #!/usr/bin/env node
2
+ import { writeFileSync } from 'node:fs'
3
+ import { resolve } from 'node:path'
4
+ import { describeDirectory } from '../dist/esm/index.js'
5
+
6
+ function usage() {
7
+ console.error(
8
+ 'Usage: dicom-synth-describe <dir> [--out <spec.json>] [--keep-tags <a,b,...>]\n' +
9
+ '\n' +
10
+ 'Scans a DICOM tree and emits a DatasetSpec describing its shape\n' +
11
+ '(studies/series, per-file size, modality).\n' +
12
+ '\n' +
13
+ 'By default only the shape is reproduced — no tags are copied.\n' +
14
+ '--keep-tags copies the named tags (DICOM keywords or 8-hex tags)\n' +
15
+ "verbatim; if a kept tag holds PHI, handling it is the caller's\n" +
16
+ "responsibility, not this tool's.",
17
+ )
18
+ process.exit(1)
19
+ }
20
+
21
+ const args = process.argv.slice(2)
22
+ if (args.length === 0) usage()
23
+
24
+ let dir
25
+ let outPath
26
+ let keepTags
27
+
28
+ for (let i = 0; i < args.length; i++) {
29
+ if (args[i] === '--out' && args[i + 1]) {
30
+ outPath = resolve(args[++i])
31
+ } else if (args[i] === '--keep-tags' && args[i + 1]) {
32
+ keepTags = args[++i]
33
+ .split(',')
34
+ .map((t) => t.trim())
35
+ .filter(Boolean)
36
+ } else if (args[i].startsWith('--')) {
37
+ console.error(`Unknown argument: ${args[i]}`)
38
+ usage()
39
+ } else if (dir === undefined) {
40
+ dir = resolve(args[i])
41
+ } else {
42
+ console.error(`Unexpected argument: ${args[i]}`)
43
+ usage()
44
+ }
45
+ }
46
+
47
+ if (dir === undefined) usage()
48
+
49
+ let result
50
+ try {
51
+ result = describeDirectory(dir, keepTags ? { keepTags } : {})
52
+ } catch (err) {
53
+ console.error(`Error: ${err.message}`)
54
+ process.exit(1)
55
+ }
56
+
57
+ const json = `${JSON.stringify(result.spec, null, 2)}\n`
58
+ if (outPath) {
59
+ writeFileSync(outPath, json)
60
+ console.error(`Wrote DatasetSpec to ${outPath}`)
61
+ } else {
62
+ process.stdout.write(json)
63
+ }
64
+
65
+ const { dicomFiles, skipped, unknownModality } = result.stats
66
+ console.error(
67
+ `Described ${dicomFiles} DICOM file(s); skipped ${skipped} non-DICOM/ungroupable file(s)`,
68
+ )
69
+ if (unknownModality > 0) {
70
+ console.error(
71
+ ` ${unknownModality} file(s) had an unsupported Modality — emitted as default (CT)`,
72
+ )
73
+ }
@@ -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
  }