dicom-synth 1.1.0 → 1.3.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 +115 -39
- package/dist/esm/collection/writer.js +178 -34
- package/dist/esm/index.js +290 -82
- package/dist/esm/schema/validate.js +110 -46
- package/dist/esm/syntheticFixtures/generator.js +73 -27
- package/dist/esm/syntheticFixtures/uid.js +29 -1
- package/dist/types/collection/writer.d.ts +3 -0
- package/dist/types/index.d.ts +1 -1
- package/dist/types/schema/types.d.ts +29 -11
- package/dist/types/syntheticFixtures/uid.d.ts +5 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@ Schema-driven **synthetic DICOM fixture generation** and **public fixture** fetc
|
|
|
4
4
|
|
|
5
5
|
## Disclaimer
|
|
6
6
|
|
|
7
|
-
Still in early release. APIs may change without notice.
|
|
7
|
+
⚠️ Still in early release. APIs may change without notice. Version 2+ will be the first stable release ⚠️
|
|
8
8
|
|
|
9
9
|
## Install
|
|
10
10
|
|
|
@@ -24,16 +24,16 @@ Generation is a three-layer API. Each layer is a thin wrapper over the one below
|
|
|
24
24
|
```ts
|
|
25
25
|
import { generateFile } from 'dicom-synth'
|
|
26
26
|
|
|
27
|
-
const { buffer, filename, type, index } = await generateFile({ type: 'valid-
|
|
27
|
+
const { buffer, filename, type, index } = await generateFile({ type: 'valid-image' })
|
|
28
28
|
// buffer: Buffer ready to write or pass to a parser
|
|
29
|
-
// filename: 'valid-
|
|
29
|
+
// filename: 'valid-image-000.dcm'
|
|
30
30
|
```
|
|
31
31
|
|
|
32
32
|
With options:
|
|
33
33
|
|
|
34
34
|
```ts
|
|
35
35
|
const { buffer } = await generateFile(
|
|
36
|
-
{ type: 'valid-
|
|
36
|
+
{ type: 'valid-image', tags: { PatientID: 'P001' }, violations: ['uid-too-long'] },
|
|
37
37
|
{ seed: 42, index: 7 },
|
|
38
38
|
)
|
|
39
39
|
```
|
|
@@ -44,7 +44,7 @@ Write a single file to disk:
|
|
|
44
44
|
import { writeFileSync } from 'node:fs'
|
|
45
45
|
import { generateFile } from 'dicom-synth'
|
|
46
46
|
|
|
47
|
-
const { buffer, filename } = await generateFile({ type: 'valid-
|
|
47
|
+
const { buffer, filename } = await generateFile({ type: 'valid-image' })
|
|
48
48
|
writeFileSync(`./out/${filename}`, buffer)
|
|
49
49
|
```
|
|
50
50
|
|
|
@@ -55,8 +55,8 @@ import { generateCollectionFromSpec } from 'dicom-synth'
|
|
|
55
55
|
|
|
56
56
|
for await (const file of generateCollectionFromSpec({
|
|
57
57
|
entries: [
|
|
58
|
-
{ type: 'valid-
|
|
59
|
-
{ type: 'invalid-uid-
|
|
58
|
+
{ type: 'valid-image', count: 10 },
|
|
59
|
+
{ type: 'invalid-uid-image', count: 3 },
|
|
60
60
|
],
|
|
61
61
|
seed: 42,
|
|
62
62
|
})) {
|
|
@@ -72,7 +72,7 @@ Files are yielded one at a time — safe to pipe without buffering the entire co
|
|
|
72
72
|
import { writeCollectionFromSpec } from 'dicom-synth'
|
|
73
73
|
|
|
74
74
|
const manifest = await writeCollectionFromSpec(
|
|
75
|
-
{ entries: [{ type: 'valid-
|
|
75
|
+
{ entries: [{ type: 'valid-image', count: 5 }], seed: 42 },
|
|
76
76
|
'./fixtures/generated',
|
|
77
77
|
)
|
|
78
78
|
// manifest: Array<{ path: string; type: string; index: number }>
|
|
@@ -95,7 +95,7 @@ import { writeCollectionFromSpec } from 'dicom-synth'
|
|
|
95
95
|
const dir = await mkdtemp(join(tmpdir(), 'synth-'))
|
|
96
96
|
try {
|
|
97
97
|
await writeCollectionFromSpec(
|
|
98
|
-
{ entries: [{ type: 'valid-
|
|
98
|
+
{ entries: [{ type: 'valid-image', count: 3 }], seed: 42 },
|
|
99
99
|
dir,
|
|
100
100
|
)
|
|
101
101
|
// run pipeline against dir
|
|
@@ -148,7 +148,7 @@ Use layer 2 when passing generated files directly to a parser or pipeline withou
|
|
|
148
148
|
import { generateCollectionFromSpec, type GeneratedFile } from 'dicom-synth'
|
|
149
149
|
|
|
150
150
|
const files: GeneratedFile[] = []
|
|
151
|
-
for await (const file of generateCollectionFromSpec({ entries: [{ type: 'valid-
|
|
151
|
+
for await (const file of generateCollectionFromSpec({ entries: [{ type: 'valid-image', count: 5 }] })) {
|
|
152
152
|
files.push(file)
|
|
153
153
|
}
|
|
154
154
|
```
|
|
@@ -163,11 +163,15 @@ for await (const file of generateCollectionFromSpec({ entries: [{ type: 'valid-c
|
|
|
163
163
|
import type { DatasetSpec } from 'dicom-synth'
|
|
164
164
|
|
|
165
165
|
type DatasetSpec = {
|
|
166
|
-
entries
|
|
167
|
-
|
|
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
|
|
168
170
|
}
|
|
169
171
|
```
|
|
170
172
|
|
|
173
|
+
At least one of `entries` or `studies` must be present and non-empty; both may be used together.
|
|
174
|
+
|
|
171
175
|
`seed` makes UID generation fully deterministic across runs. Omit it for random UIDs.
|
|
172
176
|
|
|
173
177
|
### `EntrySpec`
|
|
@@ -175,7 +179,7 @@ type DatasetSpec = {
|
|
|
175
179
|
An `EntrySpec` is a `FileSpec` plus an optional `count` field (how many times to generate it):
|
|
176
180
|
|
|
177
181
|
```json
|
|
178
|
-
{ "type": "valid-
|
|
182
|
+
{ "type": "valid-image", "count": 10 }
|
|
179
183
|
```
|
|
180
184
|
|
|
181
185
|
`count` defaults to 1 and must be a positive integer. It is a collection-layer concern — `generateFile` does not accept `count`.
|
|
@@ -184,21 +188,21 @@ An `EntrySpec` is a `FileSpec` plus an optional `count` field (how many times to
|
|
|
184
188
|
|
|
185
189
|
| Type | Description | Conformance |
|
|
186
190
|
|---|---|---|
|
|
187
|
-
| `valid-
|
|
188
|
-
| `invalid-uid-
|
|
189
|
-
| `vendor-warnings-
|
|
190
|
-
| `large-
|
|
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
|
+
| `large-image` | Image with configurable pixel dimensions; `rows` and `columns` required | strict |
|
|
191
195
|
| `fake-signature` | 200-byte buffer with `XXXX` at preamble offset — no DICM magic | edge |
|
|
192
196
|
| `non-dicom` | Arbitrary text buffer; no extension in filename | edge |
|
|
193
197
|
| `dicomdir` | Minimal DICOMDIR file | strict |
|
|
194
198
|
|
|
195
|
-
**`large-
|
|
199
|
+
**`large-image` fields:**
|
|
196
200
|
|
|
197
201
|
```json
|
|
198
|
-
{ "type": "large-
|
|
202
|
+
{ "type": "large-image", "rows": 512, "columns": 512, "frames": 100 }
|
|
199
203
|
```
|
|
200
204
|
|
|
201
|
-
`frames` defaults to 1. A 512×512×100
|
|
205
|
+
`frames` defaults to 1. A 512×512×100 image produces a buffer of ~52 MB.
|
|
202
206
|
|
|
203
207
|
**`non-dicom` fields:**
|
|
204
208
|
|
|
@@ -208,13 +212,75 @@ An `EntrySpec` is a `FileSpec` plus an optional `count` field (how many times to
|
|
|
208
212
|
|
|
209
213
|
`content` defaults to `"not dicom"`.
|
|
210
214
|
|
|
215
|
+
### Modality presets (`modality`)
|
|
216
|
+
|
|
217
|
+
Available on `valid-image`, `invalid-uid-image`, `vendor-warnings-image`, and `large-image`. Defaults to `CT`.
|
|
218
|
+
|
|
219
|
+
```json
|
|
220
|
+
{ "type": "valid-image", "modality": "PT" }
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
Each preset sets the matching SOP Class UID (dataset and meta header), the `Modality` tag, and minimal modality-specific type-1 attributes:
|
|
224
|
+
|
|
225
|
+
| Modality | SOP Class | Extra attributes |
|
|
226
|
+
|---|---|---|
|
|
227
|
+
| `CT` (default) | CT Image Storage `…1.1.2` | — |
|
|
228
|
+
| `PT` | PET Image Storage `…1.1.128` | `Units`, `DecayCorrection`, `CorrectedImage` |
|
|
229
|
+
| `MR` | MR Image Storage `…1.1.4` | `ScanningSequence`, `SequenceVariant` |
|
|
230
|
+
| `CR` | CR Image Storage `…1.1.1` | — |
|
|
231
|
+
|
|
232
|
+
Presets target shape/variance fidelity for pipeline testing, not clinical fidelity. Tag overrides win over preset attributes.
|
|
233
|
+
|
|
234
|
+
```ts
|
|
235
|
+
import type { Modality } from 'dicom-synth'
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
### Study/series grouping (`studies`)
|
|
239
|
+
|
|
240
|
+
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.
|
|
241
|
+
|
|
242
|
+
```json
|
|
243
|
+
{
|
|
244
|
+
"studies": [
|
|
245
|
+
{
|
|
246
|
+
"tags": { "PatientID": "P001" },
|
|
247
|
+
"series": [
|
|
248
|
+
{ "entries": [{ "type": "valid-image", "modality": "CT", "count": 50 }] },
|
|
249
|
+
{ "entries": [{ "type": "valid-image", "modality": "PT", "count": 120 }] }
|
|
250
|
+
]
|
|
251
|
+
},
|
|
252
|
+
{ "series": [{ "entries": [{ "type": "valid-image" }] }], "count": 3 }
|
|
253
|
+
],
|
|
254
|
+
"seed": 42,
|
|
255
|
+
"layout": "hierarchical"
|
|
256
|
+
}
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
- `StudySpec.count` generates N copies of the whole study, each with fresh UIDs.
|
|
260
|
+
- `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.
|
|
261
|
+
- Only image-generating types can appear in a series (`fake-signature`, `non-dicom`, and `dicomdir` are rejected).
|
|
262
|
+
- With a `seed`, group UIDs are deterministic; without one they are random but still shared within each group.
|
|
263
|
+
|
|
264
|
+
```ts
|
|
265
|
+
import type { StudySpec, SeriesSpec, SeriesEntrySpec } from 'dicom-synth'
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
### Layout (`layout`)
|
|
269
|
+
|
|
270
|
+
| Value | Effect |
|
|
271
|
+
|---|---|
|
|
272
|
+
| `flat` (default) | All files in the output root, named `<type>-<index>[.dcm]` |
|
|
273
|
+
| `hierarchical` | Grouped files written as `study-001/series-001/00001.dcm`; flat `entries` stay in the root |
|
|
274
|
+
|
|
275
|
+
`GeneratedFile.relativePath` carries the layout-aware path for in-process consumers; `filename` is always the basename.
|
|
276
|
+
|
|
211
277
|
### Tag overrides (`tags`)
|
|
212
278
|
|
|
213
|
-
Available on `valid-
|
|
279
|
+
Available on `valid-image`, `invalid-uid-image`, `vendor-warnings-image`, and `large-image`.
|
|
214
280
|
|
|
215
281
|
```json
|
|
216
282
|
{
|
|
217
|
-
"type": "valid-
|
|
283
|
+
"type": "valid-image",
|
|
218
284
|
"tags": {
|
|
219
285
|
"PatientID": "P001",
|
|
220
286
|
"PatientName": "DOE^JANE",
|
|
@@ -231,7 +297,7 @@ import type { DicomTagOverrides } from 'dicom-synth'
|
|
|
231
297
|
|
|
232
298
|
### Transfer syntax (`transferSyntax`)
|
|
233
299
|
|
|
234
|
-
Available on `valid-
|
|
300
|
+
Available on `valid-image`, `invalid-uid-image`, `vendor-warnings-image`, and `large-image`.
|
|
235
301
|
|
|
236
302
|
| Value | `TransferSyntaxUID` in meta header |
|
|
237
303
|
|---|---|
|
|
@@ -241,7 +307,7 @@ Available on `valid-ct`, `invalid-uid-ct`, `vendor-warnings-ct`, and `large-ct`.
|
|
|
241
307
|
|
|
242
308
|
### Violation injection (`violations`)
|
|
243
309
|
|
|
244
|
-
Available on `valid-
|
|
310
|
+
Available on `valid-image`, `invalid-uid-image`, `vendor-warnings-image`, and `large-image`. Violations are applied as post-processing transforms to an otherwise valid buffer.
|
|
245
311
|
|
|
246
312
|
| Violation | Effect | Detectable by dciodvfy |
|
|
247
313
|
|---|---|---|
|
|
@@ -255,7 +321,7 @@ Available on `valid-ct`, `invalid-uid-ct`, `vendor-warnings-ct`, and `large-ct`.
|
|
|
255
321
|
Tag-level violations are applied before byte-level violations. Byte-level violations may produce a buffer that cannot be re-parsed.
|
|
256
322
|
|
|
257
323
|
```json
|
|
258
|
-
{ "type": "valid-
|
|
324
|
+
{ "type": "valid-image", "violations": ["uid-too-long", "missing-meta-header"] }
|
|
259
325
|
```
|
|
260
326
|
|
|
261
327
|
```ts
|
|
@@ -277,16 +343,25 @@ The validator throws with a human-readable message on any structural error.
|
|
|
277
343
|
```json
|
|
278
344
|
{
|
|
279
345
|
"entries": [
|
|
280
|
-
{ "type": "valid-
|
|
281
|
-
{ "type": "invalid-uid-
|
|
282
|
-
{ "type": "vendor-warnings-
|
|
283
|
-
{ "type": "large-
|
|
284
|
-
{ "type": "valid-
|
|
346
|
+
{ "type": "valid-image", "count": 10, "tags": { "PatientID": "P001" }, "transferSyntax": "explicit-vr-little-endian" },
|
|
347
|
+
{ "type": "invalid-uid-image", "count": 3 },
|
|
348
|
+
{ "type": "vendor-warnings-image", "count": 2 },
|
|
349
|
+
{ "type": "large-image", "count": 1, "rows": 512, "columns": 512, "frames": 100 },
|
|
350
|
+
{ "type": "valid-image", "count": 1, "violations": ["uid-too-long", "missing-meta-header"] },
|
|
285
351
|
{ "type": "fake-signature", "count": 5 },
|
|
286
352
|
{ "type": "non-dicom", "count": 2, "content": "garbage" },
|
|
287
353
|
{ "type": "dicomdir", "count": 1 }
|
|
288
354
|
],
|
|
289
|
-
"
|
|
355
|
+
"studies": [
|
|
356
|
+
{
|
|
357
|
+
"series": [
|
|
358
|
+
{ "entries": [{ "type": "valid-image", "modality": "CT", "count": 50 }] },
|
|
359
|
+
{ "entries": [{ "type": "valid-image", "modality": "PT", "count": 120 }] }
|
|
360
|
+
]
|
|
361
|
+
}
|
|
362
|
+
],
|
|
363
|
+
"seed": 42,
|
|
364
|
+
"layout": "hierarchical"
|
|
290
365
|
}
|
|
291
366
|
```
|
|
292
367
|
|
|
@@ -299,20 +374,20 @@ The validator throws with a human-readable message on any structural error.
|
|
|
299
374
|
dicom-synth-generate --schema dataset.json --out ./fixtures/generated
|
|
300
375
|
|
|
301
376
|
# Inline schema
|
|
302
|
-
dicom-synth-generate --schema-inline '{"entries":[{"type":"valid-
|
|
377
|
+
dicom-synth-generate --schema-inline '{"entries":[{"type":"valid-image","count":5}]}' --out ./out
|
|
303
378
|
|
|
304
379
|
# Default output directory is ./fixtures/generated
|
|
305
380
|
dicom-synth-generate --schema dataset.json
|
|
306
381
|
```
|
|
307
382
|
|
|
308
|
-
The default schema (`examples/default.json`) generates one each of `valid-
|
|
383
|
+
The default schema (`examples/default.json`) generates one each of `valid-image`, `invalid-uid-image`, and `vendor-warnings-image`:
|
|
309
384
|
|
|
310
385
|
```bash
|
|
311
386
|
dicom-synth-generate --schema examples/default.json --out /tmp/out
|
|
312
387
|
# Wrote 3 file(s) to /tmp/out
|
|
313
|
-
# valid-
|
|
314
|
-
# invalid-uid-
|
|
315
|
-
# vendor-warnings-
|
|
388
|
+
# valid-image: 1
|
|
389
|
+
# invalid-uid-image: 1
|
|
390
|
+
# vendor-warnings-image: 1
|
|
316
391
|
```
|
|
317
392
|
|
|
318
393
|
Schema validation errors exit with code 1 and a descriptive message.
|
|
@@ -391,7 +466,7 @@ pnpm fetch:public-case -- pydicom-CT-small
|
|
|
391
466
|
| `src/public-fixtures/fetch.ts` | Fetch, SHA-256 verify, content-addressed cache |
|
|
392
467
|
| `bin/dicom-synth-generate.mjs` | Published generate CLI (requires `pnpm build`) |
|
|
393
468
|
| `bin/dicom-synth-fetch.mjs` | Published fetch CLI (requires `pnpm build`) |
|
|
394
|
-
| `examples/default.json` | Default `DatasetSpec` — one each of `valid-
|
|
469
|
+
| `examples/default.json` | Default `DatasetSpec` — one each of `valid-image`, `invalid-uid-image`, `vendor-warnings-image` |
|
|
395
470
|
|
|
396
471
|
---
|
|
397
472
|
|
|
@@ -417,9 +492,10 @@ Git hooks: see [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
|
417
492
|
|
|
418
493
|
## Future development
|
|
419
494
|
|
|
420
|
-
- **
|
|
495
|
+
- **Size targeting** — generate a file of approximately N KB via a `targetSizeKb` field
|
|
496
|
+
- **Parametric dataset designer** — range-based spec (`{min, max}` for counts/sizes) that resolves deterministically into a concrete `DatasetSpec`
|
|
497
|
+
- **Dry-run mode** — emit the manifest (paths, types, grouping) without writing files
|
|
421
498
|
- **Compressed transfer syntaxes** — JPEG-LS, JPEG 2000, RLE
|
|
422
|
-
- **Modality presets** — MR, PET, CR profiles beyond CT
|
|
423
499
|
- **Private fixture catalogues** — same SHA-256 fetch/cache pattern for credentials-backed sources (e.g. S3)
|
|
424
500
|
|
|
425
501
|
---
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// src/collection/writer.ts
|
|
2
2
|
import { mkdirSync as mkdirSync2 } from "node:fs";
|
|
3
3
|
import { writeFile } from "node:fs/promises";
|
|
4
|
-
import { resolve as resolve2 } from "node:path";
|
|
4
|
+
import { dirname, resolve as resolve2 } from "node:path";
|
|
5
5
|
|
|
6
6
|
// src/loadDcmjs.ts
|
|
7
7
|
import dcmjsDefaultImport, * as dcmjsNamespace from "dcmjs";
|
|
@@ -49,7 +49,35 @@ var MAX_PIXEL_BYTES = 512 * 1024 * 1024;
|
|
|
49
49
|
var HEX_TAG_RE = /^[0-9a-fA-F]{8}$/;
|
|
50
50
|
|
|
51
51
|
// src/syntheticFixtures/generator.ts
|
|
52
|
-
var
|
|
52
|
+
var MODALITY_PRESETS = {
|
|
53
|
+
CT: {
|
|
54
|
+
sopClassUid: "1.2.840.10008.5.1.4.1.1.2",
|
|
55
|
+
// CT Image Storage
|
|
56
|
+
attributes: {}
|
|
57
|
+
},
|
|
58
|
+
PT: {
|
|
59
|
+
sopClassUid: "1.2.840.10008.5.1.4.1.1.128",
|
|
60
|
+
// PET Image Storage
|
|
61
|
+
attributes: {
|
|
62
|
+
Units: "BQML",
|
|
63
|
+
DecayCorrection: "NONE",
|
|
64
|
+
CorrectedImage: ["ATTN", "DECY"]
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
MR: {
|
|
68
|
+
sopClassUid: "1.2.840.10008.5.1.4.1.1.4",
|
|
69
|
+
// MR Image Storage
|
|
70
|
+
attributes: {
|
|
71
|
+
ScanningSequence: "SE",
|
|
72
|
+
SequenceVariant: "NONE"
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
CR: {
|
|
76
|
+
sopClassUid: "1.2.840.10008.5.1.4.1.1.1",
|
|
77
|
+
// CR Image Storage
|
|
78
|
+
attributes: {}
|
|
79
|
+
}
|
|
80
|
+
};
|
|
53
81
|
var TRANSFER_SYNTAX_UID = {
|
|
54
82
|
"explicit-vr-little-endian": "1.2.840.10008.1.2.1",
|
|
55
83
|
"implicit-vr-little-endian": "1.2.840.10008.1.2"
|
|
@@ -82,22 +110,23 @@ function applyTagOverrides(dataset, tags) {
|
|
|
82
110
|
}
|
|
83
111
|
}
|
|
84
112
|
}
|
|
85
|
-
function buildMeta(uid, transferSyntax = "explicit-vr-little-endian") {
|
|
113
|
+
function buildMeta(uid, modality, transferSyntax = "explicit-vr-little-endian") {
|
|
86
114
|
return {
|
|
87
115
|
FileMetaInformationVersion: new Uint8Array([0, 1]).buffer,
|
|
88
|
-
MediaStorageSOPClassUID:
|
|
116
|
+
MediaStorageSOPClassUID: MODALITY_PRESETS[modality].sopClassUid,
|
|
89
117
|
MediaStorageSOPInstanceUID: uid.sop,
|
|
90
118
|
TransferSyntaxUID: TRANSFER_SYNTAX_UID[transferSyntax],
|
|
91
119
|
ImplementationClassUID: "1.2.3.4",
|
|
92
120
|
ImplementationVersionName: "SYNTH"
|
|
93
121
|
};
|
|
94
122
|
}
|
|
95
|
-
function
|
|
123
|
+
function buildBaseImageDataset(uid, modality) {
|
|
124
|
+
const preset = MODALITY_PRESETS[modality];
|
|
96
125
|
return {
|
|
97
126
|
PatientName: "SYNTH^SUBJECT",
|
|
98
127
|
PatientID: "SYNTH_PID",
|
|
99
|
-
Modality:
|
|
100
|
-
SOPClassUID:
|
|
128
|
+
Modality: modality,
|
|
129
|
+
SOPClassUID: preset.sopClassUid,
|
|
101
130
|
SOPInstanceUID: uid.sop,
|
|
102
131
|
SeriesInstanceUID: uid.series,
|
|
103
132
|
StudyInstanceUID: uid.study,
|
|
@@ -114,7 +143,8 @@ function buildBaseCtDataset(uid) {
|
|
|
114
143
|
PixelData: new Uint8Array([0, 0]).buffer,
|
|
115
144
|
// Declares PixelData's VR explicitly — without it dcmjs logs
|
|
116
145
|
// "No value representation given for PixelData" and falls back to OW anyway.
|
|
117
|
-
_vrMap: { PixelData: "OW" }
|
|
146
|
+
_vrMap: { PixelData: "OW" },
|
|
147
|
+
...preset.attributes
|
|
118
148
|
};
|
|
119
149
|
}
|
|
120
150
|
function serializeDict(meta, dataset) {
|
|
@@ -128,36 +158,36 @@ function serializeDict(meta, dataset) {
|
|
|
128
158
|
);
|
|
129
159
|
return Buffer.from(dicomDict.write({ allowInvalidVRLength: true }));
|
|
130
160
|
}
|
|
131
|
-
function
|
|
132
|
-
const dataset =
|
|
161
|
+
function buildValidImageBuffer(uid, modality, tags, transferSyntax) {
|
|
162
|
+
const dataset = buildBaseImageDataset(uid, modality);
|
|
133
163
|
applyTagOverrides(dataset, tags);
|
|
134
|
-
return serializeDict(buildMeta(uid, transferSyntax), dataset);
|
|
164
|
+
return serializeDict(buildMeta(uid, modality, transferSyntax), dataset);
|
|
135
165
|
}
|
|
136
|
-
function
|
|
166
|
+
function buildInvalidUidImageBuffer(uid, modality, tags, transferSyntax) {
|
|
137
167
|
const invalidUid = {
|
|
138
168
|
study: `2.25.invalid.study.${uid.study.slice(-6)}`,
|
|
139
169
|
series: `2.25.invalid.series.${uid.series.slice(-6)}`,
|
|
140
170
|
sop: `2.25.invalid.sop.${uid.sop.slice(-6)}`
|
|
141
171
|
};
|
|
142
|
-
const dataset =
|
|
172
|
+
const dataset = buildBaseImageDataset(invalidUid, modality);
|
|
143
173
|
applyTagOverrides(dataset, tags);
|
|
144
|
-
return serializeDict(buildMeta(invalidUid, transferSyntax), dataset);
|
|
174
|
+
return serializeDict(buildMeta(invalidUid, modality, transferSyntax), dataset);
|
|
145
175
|
}
|
|
146
|
-
function
|
|
147
|
-
const dataset =
|
|
176
|
+
function buildVendorWarningsImageBuffer(uid, modality, tags, transferSyntax) {
|
|
177
|
+
const dataset = buildBaseImageDataset(uid, modality);
|
|
148
178
|
dataset.Laterality = "";
|
|
149
179
|
dataset.PatientWeight = "0";
|
|
150
180
|
applyTagOverrides(dataset, tags);
|
|
151
|
-
return serializeDict(buildMeta(uid, transferSyntax), dataset);
|
|
181
|
+
return serializeDict(buildMeta(uid, modality, transferSyntax), dataset);
|
|
152
182
|
}
|
|
153
|
-
function
|
|
154
|
-
const dataset =
|
|
183
|
+
function buildLargeImageBuffer(uid, modality, rows, columns, frames, tags, transferSyntax) {
|
|
184
|
+
const dataset = buildBaseImageDataset(uid, modality);
|
|
155
185
|
dataset.Rows = rows;
|
|
156
186
|
dataset.Columns = columns;
|
|
157
187
|
dataset.NumberOfFrames = frames;
|
|
158
188
|
dataset.PixelData = new Uint8Array(rows * columns * frames * 2).buffer;
|
|
159
189
|
applyTagOverrides(dataset, tags);
|
|
160
|
-
return serializeDict(buildMeta(uid, transferSyntax), dataset);
|
|
190
|
+
return serializeDict(buildMeta(uid, modality, transferSyntax), dataset);
|
|
161
191
|
}
|
|
162
192
|
function buildFakeSignatureBuffer() {
|
|
163
193
|
const buf = Buffer.alloc(200, 0);
|
|
@@ -169,15 +199,31 @@ function buildNonDicomBuffer(content = "not dicom") {
|
|
|
169
199
|
}
|
|
170
200
|
function buildBufferForSpec(spec, uid) {
|
|
171
201
|
switch (spec.type) {
|
|
172
|
-
case "valid-
|
|
173
|
-
return
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
202
|
+
case "valid-image":
|
|
203
|
+
return buildValidImageBuffer(
|
|
204
|
+
uid,
|
|
205
|
+
spec.modality ?? "CT",
|
|
206
|
+
spec.tags,
|
|
207
|
+
spec.transferSyntax
|
|
208
|
+
);
|
|
209
|
+
case "invalid-uid-image":
|
|
210
|
+
return buildInvalidUidImageBuffer(
|
|
211
|
+
uid,
|
|
212
|
+
spec.modality ?? "CT",
|
|
213
|
+
spec.tags,
|
|
214
|
+
spec.transferSyntax
|
|
215
|
+
);
|
|
216
|
+
case "vendor-warnings-image":
|
|
217
|
+
return buildVendorWarningsImageBuffer(
|
|
180
218
|
uid,
|
|
219
|
+
spec.modality ?? "CT",
|
|
220
|
+
spec.tags,
|
|
221
|
+
spec.transferSyntax
|
|
222
|
+
);
|
|
223
|
+
case "large-image":
|
|
224
|
+
return buildLargeImageBuffer(
|
|
225
|
+
uid,
|
|
226
|
+
spec.modality ?? "CT",
|
|
181
227
|
spec.rows,
|
|
182
228
|
spec.columns,
|
|
183
229
|
spec.frames ?? 1,
|
|
@@ -213,6 +259,33 @@ function randomUid(role) {
|
|
|
213
259
|
function seededUid(next, role) {
|
|
214
260
|
return formatUid(next(), next(), role);
|
|
215
261
|
}
|
|
262
|
+
var GROUP_STREAM_OFFSET = 2654435769;
|
|
263
|
+
function seededStream(seed, offset, a, b) {
|
|
264
|
+
return lcg(seed * 999983 + offset + a * 1000003 + b * 7919 >>> 0);
|
|
265
|
+
}
|
|
266
|
+
function makeGroupUidGenerator(seed) {
|
|
267
|
+
if (seed === void 0) {
|
|
268
|
+
return {
|
|
269
|
+
study: () => randomUid("study"),
|
|
270
|
+
series: () => randomUid("series")
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
return {
|
|
274
|
+
study: (studyIndex) => seededUid(
|
|
275
|
+
seededStream(seed, GROUP_STREAM_OFFSET, studyIndex + 1, 0),
|
|
276
|
+
"study"
|
|
277
|
+
),
|
|
278
|
+
series: (studyIndex, seriesIndex) => seededUid(
|
|
279
|
+
seededStream(
|
|
280
|
+
seed,
|
|
281
|
+
GROUP_STREAM_OFFSET,
|
|
282
|
+
studyIndex + 1,
|
|
283
|
+
seriesIndex + 1
|
|
284
|
+
),
|
|
285
|
+
"series"
|
|
286
|
+
)
|
|
287
|
+
};
|
|
288
|
+
}
|
|
216
289
|
function makeUidGenerator(seed) {
|
|
217
290
|
if (seed === void 0) {
|
|
218
291
|
return () => ({
|
|
@@ -222,7 +295,7 @@ function makeUidGenerator(seed) {
|
|
|
222
295
|
});
|
|
223
296
|
}
|
|
224
297
|
return (fileIndex) => {
|
|
225
|
-
const next =
|
|
298
|
+
const next = seededStream(seed, 0, fileIndex, 0);
|
|
226
299
|
return {
|
|
227
300
|
study: seededUid(next, "study"),
|
|
228
301
|
series: seededUid(next, "series"),
|
|
@@ -335,28 +408,50 @@ function filename(type, index, padWidth) {
|
|
|
335
408
|
const base = `${type}-${padded}`;
|
|
336
409
|
return NO_EXTENSION_TYPES.has(type) ? base : `${base}.dcm`;
|
|
337
410
|
}
|
|
411
|
+
function entryCount(entries) {
|
|
412
|
+
return entries.reduce((sum, e) => sum + (e.count ?? 1), 0);
|
|
413
|
+
}
|
|
414
|
+
function studyFileCount(studies) {
|
|
415
|
+
return studies.reduce(
|
|
416
|
+
(sum, study) => sum + (study.count ?? 1) * study.series.reduce((s, series) => s + entryCount(series.entries), 0),
|
|
417
|
+
0
|
|
418
|
+
);
|
|
419
|
+
}
|
|
338
420
|
async function generateFile(spec, options) {
|
|
339
421
|
const index = options?.index ?? 0;
|
|
340
422
|
const padWidth = options?.padWidth ?? 3;
|
|
341
423
|
const uidGen = makeUidGenerator(options?.seed);
|
|
342
|
-
const uid = uidGen(index);
|
|
424
|
+
const uid = { ...uidGen(index), ...options?.uid };
|
|
343
425
|
let buffer = buildBufferForSpec(spec, uid);
|
|
344
426
|
const violations = "violations" in spec ? spec.violations : void 0;
|
|
345
427
|
if (violations?.length) {
|
|
346
428
|
buffer = applyViolations(buffer, violations);
|
|
347
429
|
}
|
|
430
|
+
const name = filename(spec.type, index, padWidth);
|
|
348
431
|
return {
|
|
349
|
-
filename:
|
|
432
|
+
filename: name,
|
|
433
|
+
relativePath: name,
|
|
350
434
|
buffer,
|
|
351
435
|
type: spec.type,
|
|
352
436
|
index
|
|
353
437
|
};
|
|
354
438
|
}
|
|
439
|
+
function hierarchicalName(studyOrdinal, seriesIndex, instanceNumber) {
|
|
440
|
+
const pad3 = (n) => String(n).padStart(3, "0");
|
|
441
|
+
const name = `${String(instanceNumber).padStart(5, "0")}.dcm`;
|
|
442
|
+
return {
|
|
443
|
+
filename: name,
|
|
444
|
+
relativePath: `study-${pad3(studyOrdinal + 1)}/series-${pad3(seriesIndex + 1)}/${name}`
|
|
445
|
+
};
|
|
446
|
+
}
|
|
355
447
|
async function* generateCollectionFromSpec(spec) {
|
|
356
|
-
const
|
|
448
|
+
const flatEntries = spec.entries ?? [];
|
|
449
|
+
const studies = spec.studies ?? [];
|
|
450
|
+
const totalFiles = entryCount(flatEntries) + studyFileCount(studies);
|
|
357
451
|
const padWidth = String(totalFiles).length;
|
|
452
|
+
const hierarchical = spec.layout === "hierarchical";
|
|
358
453
|
let globalIndex = 0;
|
|
359
|
-
for (const entry of
|
|
454
|
+
for (const entry of flatEntries) {
|
|
360
455
|
const { count = 1, ...fileSpec } = entry;
|
|
361
456
|
for (let i = 0; i < count; i++) {
|
|
362
457
|
yield generateFile(fileSpec, {
|
|
@@ -367,13 +462,62 @@ async function* generateCollectionFromSpec(spec) {
|
|
|
367
462
|
globalIndex++;
|
|
368
463
|
}
|
|
369
464
|
}
|
|
465
|
+
const groupUids = makeGroupUidGenerator(spec.seed);
|
|
466
|
+
let studyOrdinal = 0;
|
|
467
|
+
for (const study of studies) {
|
|
468
|
+
const copies = study.count ?? 1;
|
|
469
|
+
for (let copy = 0; copy < copies; copy++) {
|
|
470
|
+
const studyUid = groupUids.study(studyOrdinal);
|
|
471
|
+
for (const [seriesIndex, series] of study.series.entries()) {
|
|
472
|
+
const seriesUid = groupUids.series(studyOrdinal, seriesIndex);
|
|
473
|
+
let instanceNumber = 0;
|
|
474
|
+
for (const entry of series.entries) {
|
|
475
|
+
const { count = 1, ...fileSpec } = entry;
|
|
476
|
+
for (let i = 0; i < count; i++) {
|
|
477
|
+
instanceNumber++;
|
|
478
|
+
const tags = {
|
|
479
|
+
InstanceNumber: instanceNumber,
|
|
480
|
+
...study.tags,
|
|
481
|
+
...series.tags,
|
|
482
|
+
...fileSpec.tags
|
|
483
|
+
};
|
|
484
|
+
const file = await generateFile(
|
|
485
|
+
{ ...fileSpec, tags },
|
|
486
|
+
{
|
|
487
|
+
index: globalIndex,
|
|
488
|
+
seed: spec.seed,
|
|
489
|
+
padWidth,
|
|
490
|
+
uid: { study: studyUid, series: seriesUid }
|
|
491
|
+
}
|
|
492
|
+
);
|
|
493
|
+
yield hierarchical ? {
|
|
494
|
+
...file,
|
|
495
|
+
...hierarchicalName(
|
|
496
|
+
studyOrdinal,
|
|
497
|
+
seriesIndex,
|
|
498
|
+
instanceNumber
|
|
499
|
+
)
|
|
500
|
+
} : file;
|
|
501
|
+
globalIndex++;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
studyOrdinal++;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
370
508
|
}
|
|
371
509
|
async function writeCollectionFromSpec(spec, outDir) {
|
|
372
510
|
const root = resolve2(outDir);
|
|
373
511
|
mkdirSync2(root, { recursive: true });
|
|
374
512
|
const manifest = [];
|
|
513
|
+
const createdDirs = /* @__PURE__ */ new Set([root]);
|
|
375
514
|
for await (const file of generateCollectionFromSpec(spec)) {
|
|
376
|
-
const filePath = resolve2(root, file.
|
|
515
|
+
const filePath = resolve2(root, file.relativePath);
|
|
516
|
+
const dir = dirname(filePath);
|
|
517
|
+
if (!createdDirs.has(dir)) {
|
|
518
|
+
mkdirSync2(dir, { recursive: true });
|
|
519
|
+
createdDirs.add(dir);
|
|
520
|
+
}
|
|
377
521
|
await writeFile(filePath, file.buffer);
|
|
378
522
|
manifest.push({ path: filePath, type: file.type, index: file.index });
|
|
379
523
|
}
|