dicom-synth 1.6.0 → 1.8.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 +17 -574
- package/bin/dicom-synth-generate.mjs +17 -8
- package/dist/esm/collection/writer.js +66 -13
- package/dist/esm/describe/describe.js +16 -1
- package/dist/esm/index.js +84 -16
- package/dist/esm/schema/validate.js +16 -1
- package/dist/types/collection/writer.d.ts +1 -0
- package/dist/types/index.d.ts +2 -2
- package/dist/types/schema/types.d.ts +2 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -13,607 +13,50 @@ pnpm add dicom-synth
|
|
|
13
13
|
pnpm add dcmjs # peer dependency (^0.51.1)
|
|
14
14
|
```
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
## Quick start
|
|
17
17
|
|
|
18
|
-
|
|
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-image' })
|
|
28
|
-
// buffer: Buffer ready to write or pass to a parser
|
|
29
|
-
// filename: 'valid-image-000.dcm'
|
|
30
|
-
```
|
|
31
|
-
|
|
32
|
-
With options:
|
|
33
|
-
|
|
34
|
-
```ts
|
|
35
|
-
const { buffer } = await generateFile(
|
|
36
|
-
{ type: 'valid-image', 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-image' })
|
|
48
|
-
writeFileSync(`./out/${filename}`, buffer)
|
|
49
|
-
```
|
|
50
|
-
|
|
51
|
-
### Layer 2 — collection stream, no I/O
|
|
18
|
+
In-process — generate a deterministic collection and use it without touching disk:
|
|
52
19
|
|
|
53
20
|
```ts
|
|
54
21
|
import { generateCollectionFromSpec } from 'dicom-synth'
|
|
55
22
|
|
|
56
23
|
for await (const file of generateCollectionFromSpec({
|
|
57
|
-
entries: [
|
|
58
|
-
{ type: 'valid-image', count: 10 },
|
|
59
|
-
{ type: 'invalid-uid-image', count: 3 },
|
|
60
|
-
],
|
|
24
|
+
entries: [{ type: 'valid-image', count: 5 }],
|
|
61
25
|
seed: 42,
|
|
62
26
|
})) {
|
|
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-image', 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:
|
|
88
|
-
|
|
89
|
-
```ts
|
|
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'
|
|
94
|
-
|
|
95
|
-
const dir = await mkdtemp(join(tmpdir(), 'synth-'))
|
|
96
|
-
try {
|
|
97
|
-
await writeCollectionFromSpec(
|
|
98
|
-
{ entries: [{ type: 'valid-image', count: 3 }], seed: 42 },
|
|
99
|
-
dir,
|
|
100
|
-
)
|
|
101
|
-
// run pipeline against dir
|
|
102
|
-
} finally {
|
|
103
|
-
await rm(dir, { recursive: true, force: true })
|
|
104
|
-
}
|
|
105
|
-
```
|
|
106
|
-
|
|
107
|
-
Use a fixed `seed` in CI so UID values are identical across runs and any diff is meaningful.
|
|
108
|
-
|
|
109
|
-
### Static generation (commit schema, not binaries)
|
|
110
|
-
|
|
111
|
-
Commit a `dataset.json` schema to your repo. Add a script to regenerate fixtures on demand:
|
|
112
|
-
|
|
113
|
-
```json
|
|
114
|
-
// package.json
|
|
115
|
-
{
|
|
116
|
-
"scripts": {
|
|
117
|
-
"fixtures:generate": "dicom-synth-generate --schema dataset.json --out fixtures/dicom"
|
|
118
|
-
}
|
|
27
|
+
console.log(file.filename, file.buffer.length) // Buffer ready to parse or pipe
|
|
119
28
|
}
|
|
120
29
|
```
|
|
121
30
|
|
|
122
|
-
|
|
123
|
-
pnpm fixtures:generate # regenerate
|
|
124
|
-
```
|
|
125
|
-
|
|
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:
|
|
31
|
+
Or from the command line — write fixtures to a directory:
|
|
131
32
|
|
|
132
33
|
```bash
|
|
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-image', 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[] // flat entries — each file gets its own UIDs
|
|
167
|
-
studies?: StudySpec[] // grouped entries — shared study/series UIDs
|
|
168
|
-
seed?: number // optional: fixed seed for deterministic UID generation
|
|
169
|
-
layout?: 'flat' | 'hierarchical' // optional: directory layout for grouped files
|
|
170
|
-
}
|
|
171
|
-
```
|
|
172
|
-
|
|
173
|
-
At least one of `entries` or `studies` must be present and non-empty; both may be used together.
|
|
174
|
-
|
|
175
|
-
`seed` makes UID generation fully deterministic across runs. Omit it for random UIDs.
|
|
176
|
-
|
|
177
|
-
### `EntrySpec`
|
|
178
|
-
|
|
179
|
-
An `EntrySpec` is a `FileSpec` plus an optional `count` field (how many times to generate it):
|
|
180
|
-
|
|
181
|
-
```json
|
|
182
|
-
{ "type": "valid-image", "count": 10 }
|
|
183
|
-
```
|
|
184
|
-
|
|
185
|
-
`count` defaults to 1 and must be a positive integer. It is a collection-layer concern — `generateFile` does not accept `count`.
|
|
186
|
-
|
|
187
|
-
### `FileSpec` type catalogue
|
|
188
|
-
|
|
189
|
-
| Type | Description | Conformance |
|
|
190
|
-
|---|---|---|
|
|
191
|
-
| `valid-image` | Standards-valid image (CT by default — see `modality`); numeric UIDs, complete meta header | strict |
|
|
192
|
-
| `invalid-uid-image` | Image with non-numeric UIDs (letters in UID components) | edge |
|
|
193
|
-
| `vendor-warnings-image` | Image with empty `Laterality` and `PatientWeight = 0` — produces dciodvfy warnings | edge |
|
|
194
|
-
| `fake-signature` | 200-byte buffer with `XXXX` at preamble offset — no DICM magic | edge |
|
|
195
|
-
| `non-dicom` | Arbitrary text buffer; no extension in filename | edge |
|
|
196
|
-
| `dicomdir` | Minimal DICOMDIR file | strict |
|
|
197
|
-
|
|
198
|
-
**`non-dicom` fields:**
|
|
199
|
-
|
|
200
|
-
```json
|
|
201
|
-
{ "type": "non-dicom", "content": "not a dicom file" }
|
|
202
|
-
```
|
|
203
|
-
|
|
204
|
-
`content` defaults to `"not dicom"`.
|
|
205
|
-
|
|
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):
|
|
211
|
-
|
|
212
|
-
```json
|
|
213
|
-
{ "type": "valid-image", "rows": 512, "columns": 512, "frames": 100 }
|
|
214
|
-
```
|
|
215
|
-
|
|
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.
|
|
227
|
-
|
|
228
|
-
### Modality presets (`modality`)
|
|
229
|
-
|
|
230
|
-
Available on all image types. Defaults to `CT`.
|
|
231
|
-
|
|
232
|
-
```json
|
|
233
|
-
{ "type": "valid-image", "modality": "PT" }
|
|
234
|
-
```
|
|
235
|
-
|
|
236
|
-
Each preset sets the matching SOP Class UID (dataset and meta header), the `Modality` tag, and minimal modality-specific type-1 attributes:
|
|
237
|
-
|
|
238
|
-
| Modality | SOP Class | Extra attributes |
|
|
239
|
-
|---|---|---|
|
|
240
|
-
| `CT` (default) | CT Image Storage `…1.1.2` | — |
|
|
241
|
-
| `PT` | PET Image Storage `…1.1.128` | `Units`, `DecayCorrection`, `CorrectedImage` |
|
|
242
|
-
| `MR` | MR Image Storage `…1.1.4` | `ScanningSequence`, `SequenceVariant` |
|
|
243
|
-
| `CR` | CR Image Storage `…1.1.1` | — |
|
|
244
|
-
|
|
245
|
-
Presets target shape/variance fidelity for pipeline testing, not clinical fidelity. Tag overrides win over preset attributes.
|
|
246
|
-
|
|
247
|
-
```ts
|
|
248
|
-
import type { Modality } from 'dicom-synth'
|
|
249
|
-
```
|
|
250
|
-
|
|
251
|
-
### Study/series grouping (`studies`)
|
|
252
|
-
|
|
253
|
-
Group files so they share `StudyInstanceUID` (per study) and `SeriesInstanceUID` (per series). `InstanceNumber` increments from 1 within each series. SOP Instance UIDs remain unique per file.
|
|
254
|
-
|
|
255
|
-
```json
|
|
256
|
-
{
|
|
257
|
-
"studies": [
|
|
258
|
-
{
|
|
259
|
-
"tags": { "PatientID": "P001" },
|
|
260
|
-
"series": [
|
|
261
|
-
{ "entries": [{ "type": "valid-image", "modality": "CT", "count": 50 }] },
|
|
262
|
-
{ "entries": [{ "type": "valid-image", "modality": "PT", "count": 120 }] }
|
|
263
|
-
]
|
|
264
|
-
},
|
|
265
|
-
{ "series": [{ "entries": [{ "type": "valid-image" }] }], "count": 3 }
|
|
266
|
-
],
|
|
267
|
-
"seed": 42,
|
|
268
|
-
"layout": "hierarchical"
|
|
269
|
-
}
|
|
270
|
-
```
|
|
271
|
-
|
|
272
|
-
- `StudySpec.count` generates N copies of the whole study, each with fresh UIDs.
|
|
273
|
-
- `tags` may be set at study, series, and entry level — merged into each file, with entry tags winning over series tags, which win over study tags.
|
|
274
|
-
- Only image-generating types can appear in a series (`fake-signature`, `non-dicom`, and `dicomdir` are rejected).
|
|
275
|
-
- With a `seed`, group UIDs are deterministic; without one they are random but still shared within each group.
|
|
276
|
-
|
|
277
|
-
```ts
|
|
278
|
-
import type { StudySpec, SeriesSpec, SeriesEntrySpec } from 'dicom-synth'
|
|
279
|
-
```
|
|
280
|
-
|
|
281
|
-
### Layout (`layout`)
|
|
282
|
-
|
|
283
|
-
| Value | Effect |
|
|
284
|
-
|---|---|
|
|
285
|
-
| `flat` (default) | All files in the output root, named `<type>-<index>[.dcm]` |
|
|
286
|
-
| `hierarchical` | Grouped files written as `study-001/series-001/00001.dcm`; flat `entries` stay in the root |
|
|
287
|
-
|
|
288
|
-
`GeneratedFile.relativePath` carries the layout-aware path for in-process consumers; `filename` is always the basename.
|
|
289
|
-
|
|
290
|
-
### Tag overrides (`tags`)
|
|
291
|
-
|
|
292
|
-
Available on all image types.
|
|
293
|
-
|
|
294
|
-
```json
|
|
295
|
-
{
|
|
296
|
-
"type": "valid-image",
|
|
297
|
-
"tags": {
|
|
298
|
-
"PatientID": "P001",
|
|
299
|
-
"PatientName": "DOE^JANE",
|
|
300
|
-
"00081030": "Research Protocol A"
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
|
-
```
|
|
304
|
-
|
|
305
|
-
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.
|
|
306
|
-
|
|
307
|
-
```ts
|
|
308
|
-
import type { DicomTagOverrides } from 'dicom-synth'
|
|
309
|
-
```
|
|
310
|
-
|
|
311
|
-
### Transfer syntax (`transferSyntax`)
|
|
312
|
-
|
|
313
|
-
Available on all image types.
|
|
314
|
-
|
|
315
|
-
| Value | `TransferSyntaxUID` in meta header |
|
|
316
|
-
|---|---|
|
|
317
|
-
| `explicit-vr-little-endian` (default) | `1.2.840.10008.1.2.1` |
|
|
318
|
-
| `implicit-vr-little-endian` | `1.2.840.10008.1.2` |
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
### Violation injection (`violations`)
|
|
322
|
-
|
|
323
|
-
Available on all image types. Violations are applied as post-processing transforms to an otherwise valid buffer.
|
|
324
|
-
|
|
325
|
-
| Violation | Effect | Detectable by dciodvfy |
|
|
326
|
-
|---|---|---|
|
|
327
|
-
| `uid-too-long` | Sets `SOPInstanceUID` to a 65-character value (limit is 64) | yes |
|
|
328
|
-
| `non-conformant-uid` | Sets `SOPInstanceUID` to a UID with a leading-zero component | yes |
|
|
329
|
-
| `vr-max-length-exceeded` | Sets `StudyDescription` to a 65-character value (LO limit is 64) | yes |
|
|
330
|
-
| `missing-type1-tag` | Removes `SOPClassUID` (Type 1 mandatory attribute) | yes |
|
|
331
|
-
| `missing-meta-header` | Strips the 128-byte preamble and DICM prefix | yes |
|
|
332
|
-
| `malformed-sq-delimiter` | Appends a sequence delimiter tag with a non-zero length field | yes |
|
|
333
|
-
|
|
334
|
-
Tag-level violations are applied before byte-level violations. Byte-level violations may produce a buffer that cannot be re-parsed.
|
|
335
|
-
|
|
336
|
-
```json
|
|
337
|
-
{ "type": "valid-image", "violations": ["uid-too-long", "missing-meta-header"] }
|
|
338
|
-
```
|
|
339
|
-
|
|
340
|
-
```ts
|
|
341
|
-
import type { ViolationClass } from 'dicom-synth'
|
|
342
|
-
```
|
|
343
|
-
|
|
344
|
-
### Schema validation
|
|
345
|
-
|
|
346
|
-
```ts
|
|
347
|
-
import { validateDatasetSpec } from 'dicom-synth'
|
|
348
|
-
|
|
349
|
-
const spec = validateDatasetSpec(JSON.parse(rawJson)) // throws on invalid input
|
|
350
|
-
```
|
|
351
|
-
|
|
352
|
-
The validator throws with a human-readable message on any structural error.
|
|
353
|
-
|
|
354
|
-
### Full schema example
|
|
355
|
-
|
|
356
|
-
```json
|
|
357
|
-
{
|
|
358
|
-
"entries": [
|
|
359
|
-
{ "type": "valid-image", "count": 10, "tags": { "PatientID": "P001" }, "transferSyntax": "explicit-vr-little-endian" },
|
|
360
|
-
{ "type": "invalid-uid-image", "count": 3 },
|
|
361
|
-
{ "type": "vendor-warnings-image", "count": 2 },
|
|
362
|
-
{ "type": "valid-image", "count": 1, "rows": 512, "columns": 512, "frames": 100 },
|
|
363
|
-
{ "type": "valid-image", "count": 1, "targetSizeKb": 250000 },
|
|
364
|
-
{ "type": "valid-image", "count": 1, "violations": ["uid-too-long", "missing-meta-header"] },
|
|
365
|
-
{ "type": "fake-signature", "count": 5 },
|
|
366
|
-
{ "type": "non-dicom", "count": 2, "content": "garbage" },
|
|
367
|
-
{ "type": "dicomdir", "count": 1 }
|
|
368
|
-
],
|
|
369
|
-
"studies": [
|
|
370
|
-
{
|
|
371
|
-
"series": [
|
|
372
|
-
{ "entries": [{ "type": "valid-image", "modality": "CT", "count": 50 }] },
|
|
373
|
-
{ "entries": [{ "type": "valid-image", "modality": "PT", "count": 120 }] }
|
|
374
|
-
]
|
|
375
|
-
}
|
|
376
|
-
],
|
|
377
|
-
"seed": 42,
|
|
378
|
-
"layout": "hierarchical"
|
|
379
|
-
}
|
|
380
|
-
```
|
|
381
|
-
|
|
382
|
-
---
|
|
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
|
-
|
|
449
|
-
## CLI
|
|
450
|
-
|
|
451
|
-
```bash
|
|
452
|
-
# From a schema file
|
|
453
|
-
dicom-synth-generate --schema dataset.json --out ./fixtures/generated
|
|
454
|
-
|
|
455
|
-
# Inline schema
|
|
456
34
|
dicom-synth-generate --schema-inline '{"entries":[{"type":"valid-image","count":5}]}' --out ./out
|
|
457
|
-
|
|
458
|
-
# Default output directory is ./fixtures/generated
|
|
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
|
|
466
|
-
```
|
|
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
|
-
|
|
475
|
-
The default schema (`examples/default.json`) generates one each of `valid-image`, `invalid-uid-image`, and `vendor-warnings-image`:
|
|
476
|
-
|
|
477
|
-
```bash
|
|
478
|
-
dicom-synth-generate --schema examples/default.json --out /tmp/out
|
|
479
|
-
# Wrote 3 file(s) to /tmp/out
|
|
480
|
-
# valid-image: 1
|
|
481
|
-
# invalid-uid-image: 1
|
|
482
|
-
# vendor-warnings-image: 1
|
|
483
35
|
```
|
|
484
36
|
|
|
485
|
-
|
|
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
|
|
37
|
+
The full input shape (image types, sizing, modalities, grouping, violations, …) is the [schema reference](./docs/schema-reference.md).
|
|
492
38
|
|
|
493
|
-
|
|
494
|
-
dicom-synth-describe ./real-dataset --out spec.json --keep-tags Manufacturer,00080060
|
|
495
|
-
```
|
|
39
|
+
## Documentation
|
|
496
40
|
|
|
497
|
-
|
|
|
41
|
+
| Guide | Covers |
|
|
498
42
|
|---|---|
|
|
499
|
-
|
|
|
500
|
-
|
|
|
501
|
-
|
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
## Public fixtures
|
|
508
|
-
|
|
509
|
-
Catalog metadata lives in `data/public-cases.json`. Fetched binaries are cached at `~/.cache/dicom-synth-testcases/<sha256>/file.dcm`.
|
|
510
|
-
|
|
511
|
-
### Load and fetch
|
|
512
|
-
|
|
513
|
-
```ts
|
|
514
|
-
import {
|
|
515
|
-
loadDefaultCases,
|
|
516
|
-
loadCaseById,
|
|
517
|
-
loadCasesFromJson,
|
|
518
|
-
defaultPublicCasesPath,
|
|
519
|
-
fetchPublicCaseToCache,
|
|
520
|
-
caseCachePath,
|
|
521
|
-
} from 'dicom-synth'
|
|
522
|
-
|
|
523
|
-
// Load all bundled cases
|
|
524
|
-
const cases = loadDefaultCases()
|
|
525
|
-
|
|
526
|
-
// Load one bundled case by id
|
|
527
|
-
const record = loadCaseById(defaultPublicCasesPath(), 'pydicom-CT-small')
|
|
528
|
-
|
|
529
|
-
// Load a custom catalogue
|
|
530
|
-
const custom = loadCasesFromJson('./my-cases.json')
|
|
531
|
-
|
|
532
|
-
// Fetch to local cache, returns the file path
|
|
533
|
-
const path = await fetchPublicCaseToCache(record)
|
|
534
|
-
|
|
535
|
-
// Resolve the cache path for a known SHA-256 without fetching
|
|
536
|
-
const cached = caseCachePath(record.sha256)
|
|
537
|
-
```
|
|
538
|
-
|
|
539
|
-
### CI caching
|
|
540
|
-
|
|
541
|
-
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:
|
|
542
|
-
|
|
543
|
-
```yaml
|
|
544
|
-
# GitHub Actions example
|
|
545
|
-
- uses: actions/cache@v4
|
|
546
|
-
with:
|
|
547
|
-
path: ~/.cache/dicom-synth-testcases
|
|
548
|
-
key: dicom-public-fixtures-${{ hashFiles('node_modules/dicom-synth/dist/data/public-cases.json') }}
|
|
549
|
-
```
|
|
550
|
-
|
|
551
|
-
### Published CLI
|
|
552
|
-
|
|
553
|
-
```bash
|
|
554
|
-
pnpm --package=dicom-synth@latest dlx dicom-synth-fetch pydicom-CT-small
|
|
555
|
-
```
|
|
556
|
-
|
|
557
|
-
### Local development
|
|
558
|
-
|
|
559
|
-
```bash
|
|
560
|
-
pnpm fetch:public-case -- pydicom-CT-small
|
|
561
|
-
```
|
|
562
|
-
|
|
563
|
-
---
|
|
564
|
-
|
|
565
|
-
## Source layout
|
|
566
|
-
|
|
567
|
-
| Path | Responsibility |
|
|
568
|
-
|---|---|
|
|
569
|
-
| `src/schema/types.ts` | All public TypeScript types (`FileSpec`, `DatasetSpec`, `ViolationClass`, etc.) |
|
|
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` |
|
|
573
|
-
| `src/syntheticFixtures/generator.ts` | Internal buffer builders keyed on `FileSpec` type |
|
|
574
|
-
| `src/syntheticFixtures/uid.ts` | Seeded and random UID generation |
|
|
575
|
-
| `src/syntheticFixtures/violations.ts` | Post-processing violation injection |
|
|
576
|
-
| `src/collection/writer.ts` | Three-layer API — `generateFile`, `generateCollectionFromSpec`, `writeCollectionFromSpec` |
|
|
577
|
-
| `src/public-fixtures/catalog.ts` | Load bundled public case catalogue |
|
|
578
|
-
| `src/public-fixtures/fetch.ts` | Fetch, SHA-256 verify, content-addressed cache |
|
|
579
|
-
| `bin/dicom-synth-generate.mjs` | Published generate CLI (requires `pnpm build`) |
|
|
580
|
-
| `bin/dicom-synth-fetch.mjs` | Published fetch CLI (requires `pnpm build`) |
|
|
581
|
-
| `bin/dicom-synth-describe.mjs` | Published describe CLI (requires `pnpm build`) |
|
|
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` |
|
|
585
|
-
|
|
586
|
-
---
|
|
587
|
-
|
|
588
|
-
## Development
|
|
589
|
-
|
|
590
|
-
```bash
|
|
591
|
-
pnpm install
|
|
592
|
-
pnpm build
|
|
593
|
-
pnpm test
|
|
594
|
-
pnpm code:quality
|
|
595
|
-
```
|
|
596
|
-
|
|
597
|
-
| Script | Purpose |
|
|
598
|
-
|---|---|
|
|
599
|
-
| `pnpm write:synthetic` | Write `examples/default.json` to `fixtures/generated` |
|
|
600
|
-
| `pnpm fetch:public-case` | Fetch one public case by id |
|
|
601
|
-
| `pnpm hooks:pre-commit` | Reproduce commit hook locally |
|
|
602
|
-
| `pnpm hooks:pre-push` | Reproduce push hook locally |
|
|
603
|
-
|
|
604
|
-
Git hooks: see [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
605
|
-
|
|
606
|
-
---
|
|
43
|
+
| [Synthetic fixtures](./docs/synthetic-fixtures.md) | The three-layer generation API (`generateFile`, `generateCollectionFromSpec`, `writeCollectionFromSpec`) and usage patterns |
|
|
44
|
+
| [Schema reference](./docs/schema-reference.md) | `DatasetSpec` and every field — types, sizing, modality, grouping, layout, path quirks, tags, transfer syntax, violations, validation |
|
|
45
|
+
| [Parametric designer](./docs/parametric.md) | Range-based `ParametricSpec` that resolves deterministically into a `DatasetSpec` |
|
|
46
|
+
| [Describe tool](./docs/describe.md) | Scan a real DICOM tree → `DatasetSpec` (shape-only by default; opt-in tag preservation) |
|
|
47
|
+
| [CLI reference](./docs/cli.md) | `dicom-synth-generate`, `dicom-synth-describe`, `dicom-synth-fetch` |
|
|
48
|
+
| [Public fixtures](./docs/public-fixtures.md) | Catalog-backed fetch/cache with SHA-256 verification |
|
|
49
|
+
| [Development](./docs/development.md) | Scripts, hooks, and source layout |
|
|
50
|
+
| [Examples](./examples/README.md) | Runnable example specs and the shape converter |
|
|
607
51
|
|
|
608
52
|
## Future development
|
|
609
53
|
|
|
54
|
+
- **Very large (streamed) files** — synthesise single DICOMs beyond the 512 MB in-memory cap via a disk-only streaming writer
|
|
610
55
|
- **Dimension edge-case recipes** — documented presets for geometry pathologies (1×65535 strips, high frame counts)
|
|
611
56
|
- **Per-file tag variance templating** — patterned overrides like `"PatientID": "P{index}"` for shape replication
|
|
612
57
|
- **Compressed transfer syntaxes** — JPEG-LS, JPEG 2000, RLE
|
|
613
58
|
- **Private fixture catalogues** — same SHA-256 fetch/cache pattern for credentials-backed sources (e.g. S3)
|
|
614
59
|
|
|
615
|
-
---
|
|
616
|
-
|
|
617
60
|
## License
|
|
618
61
|
|
|
619
62
|
Apache-2.0
|
|
@@ -5,14 +5,18 @@ import {
|
|
|
5
5
|
generateCollectionFromSpec,
|
|
6
6
|
resolveParametricSpec,
|
|
7
7
|
validateDatasetSpec,
|
|
8
|
+
withResolvedSeed,
|
|
8
9
|
writeCollectionFromSpec,
|
|
9
10
|
} from '../dist/esm/index.js'
|
|
10
11
|
|
|
11
12
|
function usage() {
|
|
12
13
|
console.error(
|
|
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]'
|
|
14
|
+
'Usage: dicom-synth-generate --schema <path> [--out <dir>] [--emit-spec <path>] [--dry-run]\n' +
|
|
15
|
+
' dicom-synth-generate --schema-inline <json> [--out <dir>] [--emit-spec <path>] [--dry-run]\n' +
|
|
16
|
+
' dicom-synth-generate --parametric <path> [--out <dir>] [--emit-spec <path>] [--dry-run]\n' +
|
|
17
|
+
'\n' +
|
|
18
|
+
'A seed is drawn when the spec omits one; pass --emit-spec to capture the\n' +
|
|
19
|
+
'effective spec (seed included) so the dataset can be re-created.',
|
|
16
20
|
)
|
|
17
21
|
process.exit(1)
|
|
18
22
|
}
|
|
@@ -80,22 +84,27 @@ function printTypeCounts(counts) {
|
|
|
80
84
|
}
|
|
81
85
|
}
|
|
82
86
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
process.exit(1)
|
|
86
|
-
}
|
|
87
|
+
const seedProvided =
|
|
88
|
+
rawSpec !== null && typeof rawSpec === 'object' && 'seed' in rawSpec
|
|
87
89
|
|
|
88
90
|
let spec
|
|
89
91
|
try {
|
|
90
92
|
spec =
|
|
91
93
|
specSource === '--parametric'
|
|
92
94
|
? resolveParametricSpec(rawSpec)
|
|
93
|
-
: validateDatasetSpec(rawSpec)
|
|
95
|
+
: withResolvedSeed(validateDatasetSpec(rawSpec))
|
|
94
96
|
} catch (err) {
|
|
95
97
|
console.error(`Schema validation error: ${err.message}`)
|
|
96
98
|
process.exit(1)
|
|
97
99
|
}
|
|
98
100
|
|
|
101
|
+
// Surface the drawn seed so a run is reproducible even without --emit-spec.
|
|
102
|
+
if (!seedProvided) {
|
|
103
|
+
console.error(
|
|
104
|
+
`Generated with seed ${spec.seed} (pass --emit-spec to capture)`,
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
|
|
99
108
|
if (emitSpecPath !== undefined) {
|
|
100
109
|
try {
|
|
101
110
|
writeFileSync(emitSpecPath, `${JSON.stringify(spec, null, 2)}\n`)
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// src/collection/writer.ts
|
|
2
|
+
import { randomBytes as randomBytes2 } from "node:crypto";
|
|
2
3
|
import { mkdirSync as mkdirSync2 } from "node:fs";
|
|
3
4
|
import { writeFile } from "node:fs/promises";
|
|
4
5
|
import { dirname, resolve as resolve2 } from "node:path";
|
|
@@ -419,6 +420,48 @@ async function generateFile(spec, options) {
|
|
|
419
420
|
index
|
|
420
421
|
};
|
|
421
422
|
}
|
|
423
|
+
function hashString(s) {
|
|
424
|
+
let h = 2166136261;
|
|
425
|
+
for (let i = 0; i < s.length; i++) {
|
|
426
|
+
h ^= s.charCodeAt(i);
|
|
427
|
+
h = Math.imul(h, 16777619);
|
|
428
|
+
}
|
|
429
|
+
return h >>> 0;
|
|
430
|
+
}
|
|
431
|
+
var UNICODE_TOKENS = ["\xE9", "\xFC", "\xF1", "\u65E5", "\u2122", "\u03A9"];
|
|
432
|
+
var NEST_DEPTH = 8;
|
|
433
|
+
var LONG_NAME_TARGET = 200;
|
|
434
|
+
function applyPathQuirks(relativePath, quirks) {
|
|
435
|
+
const segments = relativePath.split("/");
|
|
436
|
+
let dirs = segments.slice(0, -1);
|
|
437
|
+
const leaf = segments[segments.length - 1];
|
|
438
|
+
const dot = leaf.lastIndexOf(".");
|
|
439
|
+
let stem = dot > 0 ? leaf.slice(0, dot) : leaf;
|
|
440
|
+
const ext = dot > 0 ? leaf.slice(dot) : "";
|
|
441
|
+
const unicode = (s) => `${s}${UNICODE_TOKENS[hashString(s) % UNICODE_TOKENS.length]}`;
|
|
442
|
+
const has = (q) => quirks.includes(q);
|
|
443
|
+
if (has("deep-nesting")) {
|
|
444
|
+
dirs = [...dirs, ...Array.from({ length: NEST_DEPTH }, (_, i) => `d${i}`)];
|
|
445
|
+
}
|
|
446
|
+
if (has("unicode")) {
|
|
447
|
+
dirs = dirs.map(unicode);
|
|
448
|
+
stem = unicode(stem);
|
|
449
|
+
}
|
|
450
|
+
if (has("long-name")) {
|
|
451
|
+
const pad = (s) => s.length < LONG_NAME_TARGET ? s + "x".repeat(LONG_NAME_TARGET - s.length) : s;
|
|
452
|
+
stem = pad(stem);
|
|
453
|
+
if (dirs.length > 0) {
|
|
454
|
+
const last = dirs.length - 1;
|
|
455
|
+
dirs = dirs.map((d, i) => i === last ? pad(d) : d);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
let newLeaf = stem + ext;
|
|
459
|
+
if (has("trailing-dot")) {
|
|
460
|
+
if (dirs.length > 0) dirs = dirs.map((d) => `${d}.`);
|
|
461
|
+
else newLeaf += ".";
|
|
462
|
+
}
|
|
463
|
+
return { relativePath: [...dirs, newLeaf].join("/"), filename: newLeaf };
|
|
464
|
+
}
|
|
422
465
|
function hierarchicalName(studyOrdinal, seriesIndex, instanceNumber) {
|
|
423
466
|
const pad3 = (n) => String(n).padStart(3, "0");
|
|
424
467
|
const name = `${String(instanceNumber).padStart(5, "0")}.dcm`;
|
|
@@ -427,21 +470,28 @@ function hierarchicalName(studyOrdinal, seriesIndex, instanceNumber) {
|
|
|
427
470
|
relativePath: `study-${pad3(studyOrdinal + 1)}/series-${pad3(seriesIndex + 1)}/${name}`
|
|
428
471
|
};
|
|
429
472
|
}
|
|
473
|
+
function withResolvedSeed(spec) {
|
|
474
|
+
return spec.seed === void 0 ? { ...spec, seed: randomBytes2(4).readUInt32BE(0) } : spec;
|
|
475
|
+
}
|
|
430
476
|
async function* generateCollectionFromSpec(spec) {
|
|
431
477
|
const flatEntries = spec.entries ?? [];
|
|
432
478
|
const studies = spec.studies ?? [];
|
|
433
479
|
const totalFiles = entryCount(flatEntries) + studyFileCount(studies);
|
|
434
480
|
const padWidth = String(totalFiles).length;
|
|
435
481
|
const hierarchical = spec.layout === "hierarchical";
|
|
482
|
+
const quirks = spec.pathQuirks ?? [];
|
|
483
|
+
const decorate = (file) => quirks.length === 0 ? file : { ...file, ...applyPathQuirks(file.relativePath, quirks) };
|
|
436
484
|
let globalIndex = 0;
|
|
437
485
|
for (const entry of flatEntries) {
|
|
438
486
|
const { count = 1, ...fileSpec } = entry;
|
|
439
487
|
for (let i = 0; i < count; i++) {
|
|
440
|
-
yield
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
488
|
+
yield decorate(
|
|
489
|
+
await generateFile(fileSpec, {
|
|
490
|
+
index: globalIndex,
|
|
491
|
+
seed: spec.seed,
|
|
492
|
+
padWidth
|
|
493
|
+
})
|
|
494
|
+
);
|
|
445
495
|
globalIndex++;
|
|
446
496
|
}
|
|
447
497
|
}
|
|
@@ -473,14 +523,16 @@ async function* generateCollectionFromSpec(spec) {
|
|
|
473
523
|
uid: { study: studyUid, series: seriesUid }
|
|
474
524
|
}
|
|
475
525
|
);
|
|
476
|
-
yield
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
526
|
+
yield decorate(
|
|
527
|
+
hierarchical ? {
|
|
528
|
+
...file,
|
|
529
|
+
...hierarchicalName(
|
|
530
|
+
studyOrdinal,
|
|
531
|
+
seriesIndex,
|
|
532
|
+
instanceNumber
|
|
533
|
+
)
|
|
534
|
+
} : file
|
|
535
|
+
);
|
|
484
536
|
globalIndex++;
|
|
485
537
|
}
|
|
486
538
|
}
|
|
@@ -509,5 +561,6 @@ async function writeCollectionFromSpec(spec, outDir) {
|
|
|
509
561
|
export {
|
|
510
562
|
generateCollectionFromSpec,
|
|
511
563
|
generateFile,
|
|
564
|
+
withResolvedSeed,
|
|
512
565
|
writeCollectionFromSpec
|
|
513
566
|
};
|
|
@@ -40,6 +40,12 @@ var VALID_LAYOUTS = {
|
|
|
40
40
|
flat: true,
|
|
41
41
|
hierarchical: true
|
|
42
42
|
};
|
|
43
|
+
var VALID_PATH_QUIRKS = {
|
|
44
|
+
"trailing-dot": true,
|
|
45
|
+
unicode: true,
|
|
46
|
+
"deep-nesting": true,
|
|
47
|
+
"long-name": true
|
|
48
|
+
};
|
|
43
49
|
var VALID_TRANSFER_SYNTAXES = {
|
|
44
50
|
"explicit-vr-little-endian": true,
|
|
45
51
|
"implicit-vr-little-endian": true
|
|
@@ -226,11 +232,20 @@ function validateDatasetSpec(raw) {
|
|
|
226
232
|
if ("layout" in r) {
|
|
227
233
|
validateEnum(r.layout, VALID_LAYOUTS, "DatasetSpec.layout");
|
|
228
234
|
}
|
|
235
|
+
if ("pathQuirks" in r) {
|
|
236
|
+
if (!Array.isArray(r.pathQuirks)) {
|
|
237
|
+
throw new Error("DatasetSpec.pathQuirks: must be an array");
|
|
238
|
+
}
|
|
239
|
+
for (const [i, q] of r.pathQuirks.entries()) {
|
|
240
|
+
validateEnum(q, VALID_PATH_QUIRKS, `DatasetSpec.pathQuirks[${i}]`);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
229
243
|
return {
|
|
230
244
|
...entries.length > 0 ? { entries } : {},
|
|
231
245
|
...studies.length > 0 ? { studies } : {},
|
|
232
246
|
...r.seed !== void 0 ? { seed: r.seed } : {},
|
|
233
|
-
...r.layout !== void 0 ? { layout: r.layout } : {}
|
|
247
|
+
...r.layout !== void 0 ? { layout: r.layout } : {},
|
|
248
|
+
...r.pathQuirks !== void 0 ? { pathQuirks: r.pathQuirks } : {}
|
|
234
249
|
};
|
|
235
250
|
}
|
|
236
251
|
|
package/dist/esm/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// src/collection/writer.ts
|
|
2
|
+
import { randomBytes as randomBytes2 } from "node:crypto";
|
|
2
3
|
import { mkdirSync as mkdirSync2 } from "node:fs";
|
|
3
4
|
import { writeFile } from "node:fs/promises";
|
|
4
5
|
import { dirname, resolve as resolve2 } from "node:path";
|
|
@@ -63,6 +64,12 @@ var VALID_LAYOUTS = {
|
|
|
63
64
|
flat: true,
|
|
64
65
|
hierarchical: true
|
|
65
66
|
};
|
|
67
|
+
var VALID_PATH_QUIRKS = {
|
|
68
|
+
"trailing-dot": true,
|
|
69
|
+
unicode: true,
|
|
70
|
+
"deep-nesting": true,
|
|
71
|
+
"long-name": true
|
|
72
|
+
};
|
|
66
73
|
var VALID_TRANSFER_SYNTAXES = {
|
|
67
74
|
"explicit-vr-little-endian": true,
|
|
68
75
|
"implicit-vr-little-endian": true
|
|
@@ -249,11 +256,20 @@ function validateDatasetSpec(raw) {
|
|
|
249
256
|
if ("layout" in r) {
|
|
250
257
|
validateEnum(r.layout, VALID_LAYOUTS, "DatasetSpec.layout");
|
|
251
258
|
}
|
|
259
|
+
if ("pathQuirks" in r) {
|
|
260
|
+
if (!Array.isArray(r.pathQuirks)) {
|
|
261
|
+
throw new Error("DatasetSpec.pathQuirks: must be an array");
|
|
262
|
+
}
|
|
263
|
+
for (const [i, q] of r.pathQuirks.entries()) {
|
|
264
|
+
validateEnum(q, VALID_PATH_QUIRKS, `DatasetSpec.pathQuirks[${i}]`);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
252
267
|
return {
|
|
253
268
|
...entries.length > 0 ? { entries } : {},
|
|
254
269
|
...studies.length > 0 ? { studies } : {},
|
|
255
270
|
...r.seed !== void 0 ? { seed: r.seed } : {},
|
|
256
|
-
...r.layout !== void 0 ? { layout: r.layout } : {}
|
|
271
|
+
...r.layout !== void 0 ? { layout: r.layout } : {},
|
|
272
|
+
...r.pathQuirks !== void 0 ? { pathQuirks: r.pathQuirks } : {}
|
|
257
273
|
};
|
|
258
274
|
}
|
|
259
275
|
function validateRange(value, path) {
|
|
@@ -720,6 +736,48 @@ async function generateFile(spec, options) {
|
|
|
720
736
|
index
|
|
721
737
|
};
|
|
722
738
|
}
|
|
739
|
+
function hashString(s) {
|
|
740
|
+
let h = 2166136261;
|
|
741
|
+
for (let i = 0; i < s.length; i++) {
|
|
742
|
+
h ^= s.charCodeAt(i);
|
|
743
|
+
h = Math.imul(h, 16777619);
|
|
744
|
+
}
|
|
745
|
+
return h >>> 0;
|
|
746
|
+
}
|
|
747
|
+
var UNICODE_TOKENS = ["\xE9", "\xFC", "\xF1", "\u65E5", "\u2122", "\u03A9"];
|
|
748
|
+
var NEST_DEPTH = 8;
|
|
749
|
+
var LONG_NAME_TARGET = 200;
|
|
750
|
+
function applyPathQuirks(relativePath, quirks) {
|
|
751
|
+
const segments = relativePath.split("/");
|
|
752
|
+
let dirs = segments.slice(0, -1);
|
|
753
|
+
const leaf = segments[segments.length - 1];
|
|
754
|
+
const dot = leaf.lastIndexOf(".");
|
|
755
|
+
let stem = dot > 0 ? leaf.slice(0, dot) : leaf;
|
|
756
|
+
const ext = dot > 0 ? leaf.slice(dot) : "";
|
|
757
|
+
const unicode = (s) => `${s}${UNICODE_TOKENS[hashString(s) % UNICODE_TOKENS.length]}`;
|
|
758
|
+
const has = (q) => quirks.includes(q);
|
|
759
|
+
if (has("deep-nesting")) {
|
|
760
|
+
dirs = [...dirs, ...Array.from({ length: NEST_DEPTH }, (_, i) => `d${i}`)];
|
|
761
|
+
}
|
|
762
|
+
if (has("unicode")) {
|
|
763
|
+
dirs = dirs.map(unicode);
|
|
764
|
+
stem = unicode(stem);
|
|
765
|
+
}
|
|
766
|
+
if (has("long-name")) {
|
|
767
|
+
const pad = (s) => s.length < LONG_NAME_TARGET ? s + "x".repeat(LONG_NAME_TARGET - s.length) : s;
|
|
768
|
+
stem = pad(stem);
|
|
769
|
+
if (dirs.length > 0) {
|
|
770
|
+
const last = dirs.length - 1;
|
|
771
|
+
dirs = dirs.map((d, i) => i === last ? pad(d) : d);
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
let newLeaf = stem + ext;
|
|
775
|
+
if (has("trailing-dot")) {
|
|
776
|
+
if (dirs.length > 0) dirs = dirs.map((d) => `${d}.`);
|
|
777
|
+
else newLeaf += ".";
|
|
778
|
+
}
|
|
779
|
+
return { relativePath: [...dirs, newLeaf].join("/"), filename: newLeaf };
|
|
780
|
+
}
|
|
723
781
|
function hierarchicalName(studyOrdinal, seriesIndex, instanceNumber) {
|
|
724
782
|
const pad3 = (n) => String(n).padStart(3, "0");
|
|
725
783
|
const name = `${String(instanceNumber).padStart(5, "0")}.dcm`;
|
|
@@ -728,21 +786,28 @@ function hierarchicalName(studyOrdinal, seriesIndex, instanceNumber) {
|
|
|
728
786
|
relativePath: `study-${pad3(studyOrdinal + 1)}/series-${pad3(seriesIndex + 1)}/${name}`
|
|
729
787
|
};
|
|
730
788
|
}
|
|
789
|
+
function withResolvedSeed(spec) {
|
|
790
|
+
return spec.seed === void 0 ? { ...spec, seed: randomBytes2(4).readUInt32BE(0) } : spec;
|
|
791
|
+
}
|
|
731
792
|
async function* generateCollectionFromSpec(spec) {
|
|
732
793
|
const flatEntries = spec.entries ?? [];
|
|
733
794
|
const studies = spec.studies ?? [];
|
|
734
795
|
const totalFiles = entryCount(flatEntries) + studyFileCount(studies);
|
|
735
796
|
const padWidth = String(totalFiles).length;
|
|
736
797
|
const hierarchical = spec.layout === "hierarchical";
|
|
798
|
+
const quirks = spec.pathQuirks ?? [];
|
|
799
|
+
const decorate = (file) => quirks.length === 0 ? file : { ...file, ...applyPathQuirks(file.relativePath, quirks) };
|
|
737
800
|
let globalIndex = 0;
|
|
738
801
|
for (const entry of flatEntries) {
|
|
739
802
|
const { count = 1, ...fileSpec } = entry;
|
|
740
803
|
for (let i = 0; i < count; i++) {
|
|
741
|
-
yield
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
804
|
+
yield decorate(
|
|
805
|
+
await generateFile(fileSpec, {
|
|
806
|
+
index: globalIndex,
|
|
807
|
+
seed: spec.seed,
|
|
808
|
+
padWidth
|
|
809
|
+
})
|
|
810
|
+
);
|
|
746
811
|
globalIndex++;
|
|
747
812
|
}
|
|
748
813
|
}
|
|
@@ -774,14 +839,16 @@ async function* generateCollectionFromSpec(spec) {
|
|
|
774
839
|
uid: { study: studyUid, series: seriesUid }
|
|
775
840
|
}
|
|
776
841
|
);
|
|
777
|
-
yield
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
842
|
+
yield decorate(
|
|
843
|
+
hierarchical ? {
|
|
844
|
+
...file,
|
|
845
|
+
...hierarchicalName(
|
|
846
|
+
studyOrdinal,
|
|
847
|
+
seriesIndex,
|
|
848
|
+
instanceNumber
|
|
849
|
+
)
|
|
850
|
+
} : file
|
|
851
|
+
);
|
|
785
852
|
globalIndex++;
|
|
786
853
|
}
|
|
787
854
|
}
|
|
@@ -1024,7 +1091,7 @@ async function fetchPublicCaseToCache(record, cacheRoot = DEFAULT_CACHE_ROOT) {
|
|
|
1024
1091
|
}
|
|
1025
1092
|
|
|
1026
1093
|
// src/schema/parametric.ts
|
|
1027
|
-
import { randomBytes as
|
|
1094
|
+
import { randomBytes as randomBytes3 } from "node:crypto";
|
|
1028
1095
|
function sample(next, range) {
|
|
1029
1096
|
if (typeof range === "number") return range;
|
|
1030
1097
|
return range.min + next() % (range.max - range.min + 1);
|
|
@@ -1037,7 +1104,7 @@ function fraction(next) {
|
|
|
1037
1104
|
}
|
|
1038
1105
|
function resolveParametricSpec(spec) {
|
|
1039
1106
|
const validated = validateParametricSpec(spec);
|
|
1040
|
-
const seed = validated.seed ??
|
|
1107
|
+
const seed = validated.seed ?? randomBytes3(4).readUInt32BE(0);
|
|
1041
1108
|
const next = seededStream(seed, PARAMETRIC_STREAM_OFFSET, 0, 0);
|
|
1042
1109
|
const params = validated.studies;
|
|
1043
1110
|
const studies = [];
|
|
@@ -1110,5 +1177,6 @@ export {
|
|
|
1110
1177
|
validateDatasetSpec,
|
|
1111
1178
|
validateParametricSpec,
|
|
1112
1179
|
verifySha256,
|
|
1180
|
+
withResolvedSeed,
|
|
1113
1181
|
writeCollectionFromSpec
|
|
1114
1182
|
};
|
|
@@ -17,6 +17,12 @@ var VALID_LAYOUTS = {
|
|
|
17
17
|
flat: true,
|
|
18
18
|
hierarchical: true
|
|
19
19
|
};
|
|
20
|
+
var VALID_PATH_QUIRKS = {
|
|
21
|
+
"trailing-dot": true,
|
|
22
|
+
unicode: true,
|
|
23
|
+
"deep-nesting": true,
|
|
24
|
+
"long-name": true
|
|
25
|
+
};
|
|
20
26
|
var VALID_TRANSFER_SYNTAXES = {
|
|
21
27
|
"explicit-vr-little-endian": true,
|
|
22
28
|
"implicit-vr-little-endian": true
|
|
@@ -203,11 +209,20 @@ function validateDatasetSpec(raw) {
|
|
|
203
209
|
if ("layout" in r) {
|
|
204
210
|
validateEnum(r.layout, VALID_LAYOUTS, "DatasetSpec.layout");
|
|
205
211
|
}
|
|
212
|
+
if ("pathQuirks" in r) {
|
|
213
|
+
if (!Array.isArray(r.pathQuirks)) {
|
|
214
|
+
throw new Error("DatasetSpec.pathQuirks: must be an array");
|
|
215
|
+
}
|
|
216
|
+
for (const [i, q] of r.pathQuirks.entries()) {
|
|
217
|
+
validateEnum(q, VALID_PATH_QUIRKS, `DatasetSpec.pathQuirks[${i}]`);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
206
220
|
return {
|
|
207
221
|
...entries.length > 0 ? { entries } : {},
|
|
208
222
|
...studies.length > 0 ? { studies } : {},
|
|
209
223
|
...r.seed !== void 0 ? { seed: r.seed } : {},
|
|
210
|
-
...r.layout !== void 0 ? { layout: r.layout } : {}
|
|
224
|
+
...r.layout !== void 0 ? { layout: r.layout } : {},
|
|
225
|
+
...r.pathQuirks !== void 0 ? { pathQuirks: r.pathQuirks } : {}
|
|
211
226
|
};
|
|
212
227
|
}
|
|
213
228
|
function validateRange(value, path) {
|
|
@@ -18,5 +18,6 @@ export declare function generateFile(spec: FileSpec, options?: {
|
|
|
18
18
|
padWidth?: number;
|
|
19
19
|
uid?: Partial<UidSet>;
|
|
20
20
|
}): Promise<GeneratedFile>;
|
|
21
|
+
export declare function withResolvedSeed(spec: DatasetSpec): DatasetSpec;
|
|
21
22
|
export declare function generateCollectionFromSpec(spec: DatasetSpec): AsyncGenerator<GeneratedFile>;
|
|
22
23
|
export declare function writeCollectionFromSpec(spec: DatasetSpec, outDir: string): Promise<CollectionManifest>;
|
package/dist/types/index.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
export { type CollectionManifest, type GeneratedFile, generateCollectionFromSpec, generateFile, writeCollectionFromSpec, } from './collection/writer.js';
|
|
1
|
+
export { type CollectionManifest, type GeneratedFile, generateCollectionFromSpec, generateFile, withResolvedSeed, writeCollectionFromSpec, } from './collection/writer.js';
|
|
2
2
|
export { type DescribeOptions, type DescribeResult, describeDirectory, } from './describe/describe.js';
|
|
3
3
|
export { defaultPublicCasesPath, loadCaseById, loadCasesFromJson, loadDefaultCases, type PublicCaseRecord, type PublicCaseSource, } from './public-fixtures/catalog.js';
|
|
4
4
|
export { caseCachePath, fetchPublicCaseToCache, verifySha256, } from './public-fixtures/fetch.js';
|
|
5
5
|
export { resolveParametricSpec } from './schema/parametric.js';
|
|
6
|
-
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 type { DatasetLayout, DatasetSpec, DicomdirSpec, DicomTagOverrides, FakeSignatureSpec, FileSpec, ImageSpec, InvalidUidImageSpec, Modality, NonDicomSpec, ParametricEdgeCase, ParametricSpec, ParametricStudies, PathQuirk, Range, SeriesEntrySpec, SeriesSpec, StudySpec, TransferSyntax, ValidImageSpec, VendorWarningsImageSpec, ViolationClass, } from './schema/types.js';
|
|
7
7
|
export { validateDatasetSpec, validateParametricSpec, } from './schema/validate.js';
|
|
@@ -55,11 +55,13 @@ export type StudySpec = {
|
|
|
55
55
|
count?: number;
|
|
56
56
|
};
|
|
57
57
|
export type DatasetLayout = 'flat' | 'hierarchical';
|
|
58
|
+
export type PathQuirk = 'trailing-dot' | 'unicode' | 'deep-nesting' | 'long-name';
|
|
58
59
|
export type DatasetSpec = {
|
|
59
60
|
entries?: EntrySpec[];
|
|
60
61
|
studies?: StudySpec[];
|
|
61
62
|
seed?: number;
|
|
62
63
|
layout?: DatasetLayout;
|
|
64
|
+
pathQuirks?: PathQuirk[];
|
|
63
65
|
};
|
|
64
66
|
export type Range = number | {
|
|
65
67
|
min: number;
|