dicom-synth 1.0.0 → 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 +348 -55
- package/bin/dicom-synth-generate.mjs +85 -0
- package/dist/esm/collection/writer.js +386 -0
- package/dist/esm/index.js +498 -177
- package/dist/esm/schema/types.js +0 -0
- package/dist/esm/schema/validate.js +140 -0
- package/dist/esm/syntheticFixtures/generator.js +140 -103
- package/dist/esm/syntheticFixtures/uid.js +40 -0
- package/dist/esm/syntheticFixtures/violations.js +115 -0
- package/dist/types/collection/writer.d.ts +19 -0
- package/dist/types/index.d.ts +5 -5
- package/dist/types/schema/types.d.ts +48 -0
- package/dist/types/schema/validate.d.ts +3 -0
- package/dist/types/syntheticFixtures/generator.d.ts +3 -27
- package/dist/types/syntheticFixtures/uid.d.ts +7 -0
- package/dist/types/syntheticFixtures/violations.d.ts +2 -0
- package/package.json +3 -3
- package/bin/dicom-synth-write.mjs +0 -9
- package/dist/esm/negativeCases/index.js +0 -17
- package/dist/types/negativeCases/index.d.ts +0 -11
package/README.md
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
# dicom-synth
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
22
|
-
|
|
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
|
-
|
|
107
|
+
Use a fixed `seed` in CI so UID values are identical across runs and any diff is meaningful.
|
|
26
108
|
|
|
27
|
-
|
|
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
|
-
|
|
111
|
+
Commit a `dataset.json` schema to your repo. Add a script to regenerate fixtures on demand:
|
|
34
112
|
|
|
35
|
-
|
|
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
|
|
123
|
+
pnpm fixtures:generate # regenerate
|
|
39
124
|
```
|
|
40
125
|
|
|
41
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
354
|
+
### CI caching
|
|
63
355
|
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
366
|
+
### Published CLI
|
|
69
367
|
|
|
70
368
|
```bash
|
|
71
|
-
pnpm
|
|
369
|
+
pnpm --package=dicom-synth@latest dlx dicom-synth-fetch pydicom-CT-small
|
|
72
370
|
```
|
|
73
371
|
|
|
74
|
-
|
|
372
|
+
### Local development
|
|
75
373
|
|
|
76
|
-
```
|
|
77
|
-
|
|
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
|
-
|
|
378
|
+
---
|
|
92
379
|
|
|
93
380
|
## Source layout
|
|
94
381
|
|
|
95
382
|
| Path | Responsibility |
|
|
96
|
-
|
|
97
|
-
| `src/
|
|
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
|
-
| `
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|