dicom-synth 1.0.1 → 1.1.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
@@ -1,10 +1,10 @@
1
1
  # dicom-synth
2
2
 
3
- Toolkit for **synthetic DICOM fixtures** and **public fixture** fetch/cache. Portable and independent of any consumer (e.g. [dicom-curate](https://github.com/clintools/dicom-curate)).
3
+ Schema-driven **synthetic DICOM fixture generation** and **public fixture** fetch/cache. Portable and independent of any consumer (e.g. [dicom-curate](https://github.com/clintools/dicom-curate)).
4
4
 
5
5
  ## Disclaimer
6
6
 
7
- Pre-1.0.0 APIs may change without notice.
7
+ Still in early release. APIs may change without notice.
8
8
 
9
9
  ## Install
10
10
 
@@ -13,94 +13,387 @@ pnpm add dicom-synth
13
13
  pnpm add dcmjs # peer dependency (^0.51.1)
14
14
  ```
15
15
 
16
- ## Synthetic fixtures (in memory)
16
+ ---
17
+
18
+ ## Synthetic fixtures
19
+
20
+ Generation is a three-layer API. Each layer is a thin wrapper over the one below.
21
+
22
+ ### Layer 1 — single file, no I/O
23
+
24
+ ```ts
25
+ import { generateFile } from 'dicom-synth'
26
+
27
+ const { buffer, filename, type, index } = await generateFile({ type: 'valid-ct' })
28
+ // buffer: Buffer ready to write or pass to a parser
29
+ // filename: 'valid-ct-000.dcm'
30
+ ```
31
+
32
+ With options:
33
+
34
+ ```ts
35
+ const { buffer } = await generateFile(
36
+ { type: 'valid-ct', tags: { PatientID: 'P001' }, violations: ['uid-too-long'] },
37
+ { seed: 42, index: 7 },
38
+ )
39
+ ```
40
+
41
+ Write a single file to disk:
42
+
43
+ ```ts
44
+ import { writeFileSync } from 'node:fs'
45
+ import { generateFile } from 'dicom-synth'
46
+
47
+ const { buffer, filename } = await generateFile({ type: 'valid-ct' })
48
+ writeFileSync(`./out/${filename}`, buffer)
49
+ ```
50
+
51
+ ### Layer 2 — collection stream, no I/O
52
+
53
+ ```ts
54
+ import { generateCollectionFromSpec } from 'dicom-synth'
55
+
56
+ for await (const file of generateCollectionFromSpec({
57
+ entries: [
58
+ { type: 'valid-ct', count: 10 },
59
+ { type: 'invalid-uid-ct', count: 3 },
60
+ ],
61
+ seed: 42,
62
+ })) {
63
+ console.log(file.filename, file.buffer.length)
64
+ }
65
+ ```
66
+
67
+ Files are yielded one at a time — safe to pipe without buffering the entire collection.
68
+
69
+ ### Layer 3 — disk writer
70
+
71
+ ```ts
72
+ import { writeCollectionFromSpec } from 'dicom-synth'
73
+
74
+ const manifest = await writeCollectionFromSpec(
75
+ { entries: [{ type: 'valid-ct', count: 5 }], seed: 42 },
76
+ './fixtures/generated',
77
+ )
78
+ // manifest: Array<{ path: string; type: string; index: number }>
79
+ ```
80
+
81
+ ---
82
+
83
+ ## Usage patterns
84
+
85
+ ### Ephemeral fixtures in tests (CI)
86
+
87
+ Generate into a temp directory per test run — nothing committed, works in any CI environment:
17
88
 
18
89
  ```ts
19
- import { buildSyntheticCtBuffer, SYNTHETIC_FIXTURES } from 'dicom-synth'
90
+ import { mkdtemp, rm } from 'node:fs/promises'
91
+ import { tmpdir } from 'node:os'
92
+ import { join } from 'node:path'
93
+ import { writeCollectionFromSpec } from 'dicom-synth'
20
94
 
21
- const buf = buildSyntheticCtBuffer('valid-uid')
22
- // use buf in tests or write to disk
95
+ const dir = await mkdtemp(join(tmpdir(), 'synth-'))
96
+ try {
97
+ await writeCollectionFromSpec(
98
+ { entries: [{ type: 'valid-ct', count: 3 }], seed: 42 },
99
+ dir,
100
+ )
101
+ // run pipeline against dir
102
+ } finally {
103
+ await rm(dir, { recursive: true, force: true })
104
+ }
23
105
  ```
24
106
 
25
- Variants (see `SYNTHETIC_FIXTURES` in `src/syntheticFixtures/generator.ts`):
107
+ Use a fixed `seed` in CI so UID values are identical across runs and any diff is meaningful.
26
108
 
27
- | Variant | Purpose |
28
- |---------|---------|
29
- | `minimal-invalid-uid` | Invalid UID characters (many dciodvfy errors) |
30
- | `valid-uid` | Numeric UIDs only (missing-module noise) |
31
- | `vendor-warnings` | Numeric UIDs plus empty Laterality and zero PatientWeight |
109
+ ### Static generation (commit schema, not binaries)
32
110
 
33
- ## Write synthetic fixtures to disk
111
+ Commit a `dataset.json` schema to your repo. Add a script to regenerate fixtures on demand:
34
112
 
35
- Published CLI:
113
+ ```json
114
+ // package.json
115
+ {
116
+ "scripts": {
117
+ "fixtures:generate": "dicom-synth-generate --schema dataset.json --out fixtures/dicom"
118
+ }
119
+ }
120
+ ```
36
121
 
37
122
  ```bash
38
- pnpm dlx dicom-synth@latest dicom-synth-write ./tmp/fixtures
123
+ pnpm fixtures:generate # regenerate
39
124
  ```
40
125
 
41
- Local development:
126
+ Commit `dataset.json` and `.gitignore` the output directory. Fixtures are regenerated locally or in a dedicated CI job — never stored as binaries in version control.
127
+
128
+ ### Calling the CLI from a consumer project
129
+
130
+ After installing `dicom-synth`, the `dicom-synth-generate` binary is available via your package manager:
42
131
 
43
132
  ```bash
44
- pnpm write:synthetic -- ./out/fixtures
133
+ # pnpm
134
+ pnpm exec dicom-synth-generate --schema dataset.json --out ./out
135
+
136
+ # npm / npx
137
+ npx dicom-synth-generate --schema dataset.json --out ./out
138
+
139
+ # one-off without installing
140
+ pnpm --package=dicom-synth dlx dicom-synth-generate --schema dataset.json --out ./out
141
+ ```
142
+
143
+ ### In-process pipeline (no disk I/O)
144
+
145
+ Use layer 2 when passing generated files directly to a parser or pipeline without writing to disk:
146
+
147
+ ```ts
148
+ import { generateCollectionFromSpec, type GeneratedFile } from 'dicom-synth'
149
+
150
+ const files: GeneratedFile[] = []
151
+ for await (const file of generateCollectionFromSpec({ entries: [{ type: 'valid-ct', count: 5 }] })) {
152
+ files.push(file)
153
+ }
154
+ ```
155
+
156
+ ---
157
+
158
+ ## Schema reference
159
+
160
+ ### `DatasetSpec`
161
+
162
+ ```ts
163
+ import type { DatasetSpec } from 'dicom-synth'
164
+
165
+ type DatasetSpec = {
166
+ entries: EntrySpec[] // one or more entry specs; must not be empty
167
+ seed?: number // optional: fixed seed for deterministic UID generation
168
+ }
169
+ ```
170
+
171
+ `seed` makes UID generation fully deterministic across runs. Omit it for random UIDs.
172
+
173
+ ### `EntrySpec`
174
+
175
+ An `EntrySpec` is a `FileSpec` plus an optional `count` field (how many times to generate it):
176
+
177
+ ```json
178
+ { "type": "valid-ct", "count": 10 }
45
179
  ```
46
180
 
181
+ `count` defaults to 1 and must be a positive integer. It is a collection-layer concern — `generateFile` does not accept `count`.
182
+
183
+ ### `FileSpec` type catalogue
184
+
185
+ | Type | Description | Conformance |
186
+ |---|---|---|
187
+ | `valid-ct` | Standards-valid CT image; numeric UIDs, complete meta header | strict |
188
+ | `invalid-uid-ct` | CT with non-numeric UIDs (letters in UID components) | edge |
189
+ | `vendor-warnings-ct` | CT with empty `Laterality` and `PatientWeight = 0` — produces dciodvfy warnings | edge |
190
+ | `large-ct` | CT with configurable pixel dimensions; `rows` and `columns` required | strict |
191
+ | `fake-signature` | 200-byte buffer with `XXXX` at preamble offset — no DICM magic | edge |
192
+ | `non-dicom` | Arbitrary text buffer; no extension in filename | edge |
193
+ | `dicomdir` | Minimal DICOMDIR file | strict |
194
+
195
+ **`large-ct` fields:**
196
+
197
+ ```json
198
+ { "type": "large-ct", "rows": 512, "columns": 512, "frames": 100 }
199
+ ```
200
+
201
+ `frames` defaults to 1. A 512×512×100 CT produces a buffer of ~52 MB.
202
+
203
+ **`non-dicom` fields:**
204
+
205
+ ```json
206
+ { "type": "non-dicom", "content": "not a dicom file" }
207
+ ```
208
+
209
+ `content` defaults to `"not dicom"`.
210
+
211
+ ### Tag overrides (`tags`)
212
+
213
+ Available on `valid-ct`, `invalid-uid-ct`, `vendor-warnings-ct`, and `large-ct`.
214
+
215
+ ```json
216
+ {
217
+ "type": "valid-ct",
218
+ "tags": {
219
+ "PatientID": "P001",
220
+ "PatientName": "DOE^JANE",
221
+ "00081030": "Research Protocol A"
222
+ }
223
+ }
224
+ ```
225
+
226
+ Keys are either DICOM keyword names (PascalCase, e.g. `"Modality"`) or 8-hex-character tag strings (e.g. `"00080060"`). Unknown hex tags produce a console warning and are skipped.
227
+
228
+ ```ts
229
+ import type { DicomTagOverrides } from 'dicom-synth'
230
+ ```
231
+
232
+ ### Transfer syntax (`transferSyntax`)
233
+
234
+ Available on `valid-ct`, `invalid-uid-ct`, `vendor-warnings-ct`, and `large-ct`.
235
+
236
+ | Value | `TransferSyntaxUID` in meta header |
237
+ |---|---|
238
+ | `explicit-vr-little-endian` (default) | `1.2.840.10008.1.2.1` |
239
+ | `implicit-vr-little-endian` | `1.2.840.10008.1.2` |
240
+
241
+
242
+ ### Violation injection (`violations`)
243
+
244
+ Available on `valid-ct`, `invalid-uid-ct`, `vendor-warnings-ct`, and `large-ct`. Violations are applied as post-processing transforms to an otherwise valid buffer.
245
+
246
+ | Violation | Effect | Detectable by dciodvfy |
247
+ |---|---|---|
248
+ | `uid-too-long` | Sets `SOPInstanceUID` to a 65-character value (limit is 64) | yes |
249
+ | `non-conformant-uid` | Sets `SOPInstanceUID` to a UID with a leading-zero component | yes |
250
+ | `vr-max-length-exceeded` | Sets `StudyDescription` to a 65-character value (LO limit is 64) | yes |
251
+ | `missing-type1-tag` | Removes `SOPClassUID` (Type 1 mandatory attribute) | yes |
252
+ | `missing-meta-header` | Strips the 128-byte preamble and DICM prefix | yes |
253
+ | `malformed-sq-delimiter` | Appends a sequence delimiter tag with a non-zero length field | yes |
254
+
255
+ Tag-level violations are applied before byte-level violations. Byte-level violations may produce a buffer that cannot be re-parsed.
256
+
257
+ ```json
258
+ { "type": "valid-ct", "violations": ["uid-too-long", "missing-meta-header"] }
259
+ ```
260
+
261
+ ```ts
262
+ import type { ViolationClass } from 'dicom-synth'
263
+ ```
264
+
265
+ ### Schema validation
266
+
267
+ ```ts
268
+ import { validateDatasetSpec } from 'dicom-synth'
269
+
270
+ const spec = validateDatasetSpec(JSON.parse(rawJson)) // throws on invalid input
271
+ ```
272
+
273
+ The validator throws with a human-readable message on any structural error.
274
+
275
+ ### Full schema example
276
+
277
+ ```json
278
+ {
279
+ "entries": [
280
+ { "type": "valid-ct", "count": 10, "tags": { "PatientID": "P001" }, "transferSyntax": "explicit-vr-little-endian" },
281
+ { "type": "invalid-uid-ct", "count": 3 },
282
+ { "type": "vendor-warnings-ct", "count": 2 },
283
+ { "type": "large-ct", "count": 1, "rows": 512, "columns": 512, "frames": 100 },
284
+ { "type": "valid-ct", "count": 1, "violations": ["uid-too-long", "missing-meta-header"] },
285
+ { "type": "fake-signature", "count": 5 },
286
+ { "type": "non-dicom", "count": 2, "content": "garbage" },
287
+ { "type": "dicomdir", "count": 1 }
288
+ ],
289
+ "seed": 42
290
+ }
291
+ ```
292
+
293
+ ---
294
+
295
+ ## CLI
296
+
297
+ ```bash
298
+ # From a schema file
299
+ dicom-synth-generate --schema dataset.json --out ./fixtures/generated
300
+
301
+ # Inline schema
302
+ dicom-synth-generate --schema-inline '{"entries":[{"type":"valid-ct","count":5}]}' --out ./out
303
+
304
+ # Default output directory is ./fixtures/generated
305
+ dicom-synth-generate --schema dataset.json
306
+ ```
307
+
308
+ The default schema (`examples/default.json`) generates one each of `valid-ct`, `invalid-uid-ct`, and `vendor-warnings-ct`:
309
+
310
+ ```bash
311
+ dicom-synth-generate --schema examples/default.json --out /tmp/out
312
+ # Wrote 3 file(s) to /tmp/out
313
+ # valid-ct: 1
314
+ # invalid-uid-ct: 1
315
+ # vendor-warnings-ct: 1
316
+ ```
317
+
318
+ Schema validation errors exit with code 1 and a descriptive message.
319
+
320
+ ---
321
+
47
322
  ## Public fixtures
48
323
 
49
- Catalog metadata lives in `data/public-cases.json` (also importable as `dicom-synth/public-cases.json`). Fetched binaries are cached under `~/.cache/dicom-synth-testcases/<sha256>/file.dcm`.
324
+ Catalog metadata lives in `data/public-cases.json`. Fetched binaries are cached at `~/.cache/dicom-synth-testcases/<sha256>/file.dcm`.
325
+
326
+ ### Load and fetch
50
327
 
51
328
  ```ts
52
329
  import {
330
+ loadDefaultCases,
331
+ loadCaseById,
332
+ loadCasesFromJson,
53
333
  defaultPublicCasesPath,
54
334
  fetchPublicCaseToCache,
55
- loadCaseById,
335
+ caseCachePath,
56
336
  } from 'dicom-synth'
57
337
 
338
+ // Load all bundled cases
339
+ const cases = loadDefaultCases()
340
+
341
+ // Load one bundled case by id
58
342
  const record = loadCaseById(defaultPublicCasesPath(), 'pydicom-CT-small')
343
+
344
+ // Load a custom catalogue
345
+ const custom = loadCasesFromJson('./my-cases.json')
346
+
347
+ // Fetch to local cache, returns the file path
59
348
  const path = await fetchPublicCaseToCache(record)
349
+
350
+ // Resolve the cache path for a known SHA-256 without fetching
351
+ const cached = caseCachePath(record.sha256)
60
352
  ```
61
353
 
62
- Published CLI:
354
+ ### CI caching
63
355
 
64
- ```bash
65
- pnpm dlx dicom-synth@latest dicom-synth-fetch pydicom-CT-small
356
+ The fetch cache is keyed by SHA-256 so cached files are safe to restore across CI runs. To avoid re-downloading on every run, cache the default cache root:
357
+
358
+ ```yaml
359
+ # GitHub Actions example
360
+ - uses: actions/cache@v4
361
+ with:
362
+ path: ~/.cache/dicom-synth-testcases
363
+ key: dicom-public-fixtures-${{ hashFiles('node_modules/dicom-synth/dist/data/public-cases.json') }}
66
364
  ```
67
365
 
68
- Local development:
366
+ ### Published CLI
69
367
 
70
368
  ```bash
71
- pnpm fetch:public-case -- pydicom-CT-small
369
+ pnpm --package=dicom-synth@latest dlx dicom-synth-fetch pydicom-CT-small
72
370
  ```
73
371
 
74
- ## Use in tests (ephemeral files)
372
+ ### Local development
75
373
 
76
- ```ts
77
- import { mkdtemp, rm, writeFile } from 'node:fs/promises'
78
- import { tmpdir } from 'node:os'
79
- import { join } from 'node:path'
80
- import { buildSyntheticCtBuffer } from 'dicom-synth'
81
-
82
- const dir = await mkdtemp(join(tmpdir(), 'synth-'))
83
- try {
84
- await writeFile(join(dir, 'test.dcm'), buildSyntheticCtBuffer('valid-uid'))
85
- // run pipeline against dir
86
- } finally {
87
- await rm(dir, { recursive: true, force: true })
88
- }
374
+ ```bash
375
+ pnpm fetch:public-case -- pydicom-CT-small
89
376
  ```
90
377
 
91
- The same pattern works in CI when `dicom-synth` and `dcmjs` are installed; you do not need to commit `.dcm` binaries in the consumer repo.
378
+ ---
92
379
 
93
380
  ## Source layout
94
381
 
95
382
  | Path | Responsibility |
96
- |------|----------------|
97
- | `src/syntheticFixtures/generator.ts` | Deterministic synthetic CT generation |
383
+ |---|---|
384
+ | `src/schema/types.ts` | All public TypeScript types (`FileSpec`, `DatasetSpec`, `ViolationClass`, etc.) |
385
+ | `src/schema/validate.ts` | `validateDatasetSpec` — validates a raw JSON value against the schema |
386
+ | `src/syntheticFixtures/generator.ts` | Internal buffer builders keyed on `FileSpec` type |
387
+ | `src/syntheticFixtures/uid.ts` | Seeded and random UID generation |
388
+ | `src/syntheticFixtures/violations.ts` | Post-processing violation injection |
389
+ | `src/collection/writer.ts` | Three-layer API — `generateFile`, `generateCollectionFromSpec`, `writeCollectionFromSpec` |
98
390
  | `src/public-fixtures/catalog.ts` | Load bundled public case catalogue |
99
391
  | `src/public-fixtures/fetch.ts` | Fetch, SHA-256 verify, content-addressed cache |
100
- | `scripts/write-synthetic-fixtures.ts` | Dev CLI for synthetic output |
101
- | `scripts/fetch-public-case.ts` | Dev CLI for public fetch |
102
- | `bin/dicom-synth-write.mjs` | Published write CLI (requires `pnpm build`) |
392
+ | `bin/dicom-synth-generate.mjs` | Published generate CLI (requires `pnpm build`) |
103
393
  | `bin/dicom-synth-fetch.mjs` | Published fetch CLI (requires `pnpm build`) |
394
+ | `examples/default.json` | Default `DatasetSpec` — one each of `valid-ct`, `invalid-uid-ct`, `vendor-warnings-ct` |
395
+
396
+ ---
104
397
 
105
398
  ## Development
106
399
 
@@ -112,24 +405,24 @@ pnpm code:quality
112
405
  ```
113
406
 
114
407
  | Script | Purpose |
115
- |--------|---------|
116
- | `pnpm write:synthetic` | Write all synthetic fixtures to a directory |
408
+ |---|---|
409
+ | `pnpm write:synthetic` | Write `examples/default.json` to `fixtures/generated` |
117
410
  | `pnpm fetch:public-case` | Fetch one public case by id |
118
- | `pnpm hooks:pre-commit` | Reproduce commit hook |
119
- | `pnpm hooks:pre-push` | Reproduce push hook |
411
+ | `pnpm hooks:pre-commit` | Reproduce commit hook locally |
412
+ | `pnpm hooks:pre-push` | Reproduce push hook locally |
120
413
 
121
414
  Git hooks: see [CONTRIBUTING.md](CONTRIBUTING.md).
122
415
 
416
+ ---
417
+
123
418
  ## Future development
124
419
 
125
- The current release targets stable, deterministic fixtures for conformance basic conformance testing. Planned extensions include:
420
+ - **Multi-series study layouts** shared `StudyInstanceUID` across files via a `studies` top-level key; no breaking schema change needed
421
+ - **Compressed transfer syntaxes** — JPEG-LS, JPEG 2000, RLE
422
+ - **Modality presets** — MR, PET, CR profiles beyond CT
423
+ - **Private fixture catalogues** — same SHA-256 fetch/cache pattern for credentials-backed sources (e.g. S3)
126
424
 
127
- - **Parameterised synthetic generation** — options for instance count, dimensions, frames, transfer syntax, and modality/profile presets.
128
- - **Stress and scale** — very large pixel payloads, high file counts, and multi-series study layouts for performance and memory testing.
129
- - **Seeded variation** — reproducible pseudo-random datasets (fixed seed) for broader coverage without losing determinism in CI.
130
- - **Conformance modes** — `strict` (standards-valid stress data) vs `edge` (intentional faults for parser and validator hardening).
131
- - **Private fixture catalogues** — same SHA-256 fetch/cache pattern as public fixtures, for credentials-backed sources (e.g. S3).
132
- - **Export helpers** — optional upload or packaging of generated datasets for remote CI or shared test stores.
425
+ ---
133
426
 
134
427
  ## License
135
428
 
@@ -0,0 +1,85 @@
1
+ #!/usr/bin/env node
2
+ import { readFileSync } from 'node:fs'
3
+ import { resolve } from 'node:path'
4
+ import {
5
+ validateDatasetSpec,
6
+ writeCollectionFromSpec,
7
+ } from '../dist/esm/index.js'
8
+
9
+ function usage() {
10
+ console.error(
11
+ 'Usage: dicom-synth-generate --schema <path> [--out <dir>]\n' +
12
+ ' dicom-synth-generate --schema-inline <json> [--out <dir>]',
13
+ )
14
+ process.exit(1)
15
+ }
16
+
17
+ const args = process.argv.slice(2)
18
+
19
+ if (args.length === 0) usage()
20
+
21
+ let rawSpec
22
+ let outDir = resolve('./fixtures/generated')
23
+
24
+ for (let i = 0; i < args.length; i++) {
25
+ if (
26
+ (args[i] === '--schema' || args[i] === '--schema-inline') &&
27
+ args[i + 1]
28
+ ) {
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
+ }
50
+ } else if (args[i] === '--out' && args[i + 1]) {
51
+ outDir = resolve(args[++i])
52
+ } else {
53
+ console.error(`Unknown argument: ${args[i]}`)
54
+ usage()
55
+ }
56
+ }
57
+
58
+ if (rawSpec === undefined) usage()
59
+
60
+ let spec
61
+ try {
62
+ spec = validateDatasetSpec(rawSpec)
63
+ } catch (err) {
64
+ console.error(`Schema validation error: ${err.message}`)
65
+ process.exit(1)
66
+ }
67
+
68
+ let manifest
69
+ try {
70
+ manifest = await writeCollectionFromSpec(spec, outDir)
71
+ } catch (err) {
72
+ console.error(`Error writing collection: ${err.message}`)
73
+ process.exit(1)
74
+ }
75
+
76
+ // Summary by type
77
+ const counts = {}
78
+ for (const entry of manifest) {
79
+ counts[entry.type] = (counts[entry.type] ?? 0) + 1
80
+ }
81
+
82
+ console.log(`Wrote ${manifest.length} file(s) to ${outDir}`)
83
+ for (const [type, count] of Object.entries(counts)) {
84
+ console.log(` ${type}: ${count}`)
85
+ }