dicom-synth 1.2.0 → 1.4.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 +132 -42
- package/dist/esm/collection/writer.js +77 -48
- package/dist/esm/index.js +125 -66
- package/dist/esm/schema/validate.js +48 -18
- package/dist/esm/syntheticFixtures/generator.js +77 -48
- package/dist/types/index.d.ts +1 -1
- package/dist/types/schema/types.d.ts +14 -14
- package/package.json +2 -2
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,37 +188,112 @@ 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-ct` | CT with configurable pixel dimensions; `rows` and `columns` required | strict |
|
|
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 |
|
|
191
194
|
| `fake-signature` | 200-byte buffer with `XXXX` at preamble offset — no DICM magic | edge |
|
|
192
195
|
| `non-dicom` | Arbitrary text buffer; no extension in filename | edge |
|
|
193
196
|
| `dicomdir` | Minimal DICOMDIR file | strict |
|
|
194
197
|
|
|
195
|
-
**`
|
|
198
|
+
**`non-dicom` fields:**
|
|
196
199
|
|
|
197
200
|
```json
|
|
198
|
-
{ "type": "
|
|
201
|
+
{ "type": "non-dicom", "content": "not a dicom file" }
|
|
199
202
|
```
|
|
200
203
|
|
|
201
|
-
`
|
|
204
|
+
`content` defaults to `"not dicom"`.
|
|
202
205
|
|
|
203
|
-
|
|
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):
|
|
204
211
|
|
|
205
212
|
```json
|
|
206
|
-
{ "type": "
|
|
213
|
+
{ "type": "valid-image", "rows": 512, "columns": 512, "frames": 100 }
|
|
207
214
|
```
|
|
208
215
|
|
|
209
|
-
`
|
|
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.
|
|
210
289
|
|
|
211
290
|
### Tag overrides (`tags`)
|
|
212
291
|
|
|
213
|
-
Available on
|
|
292
|
+
Available on all image types.
|
|
214
293
|
|
|
215
294
|
```json
|
|
216
295
|
{
|
|
217
|
-
"type": "valid-
|
|
296
|
+
"type": "valid-image",
|
|
218
297
|
"tags": {
|
|
219
298
|
"PatientID": "P001",
|
|
220
299
|
"PatientName": "DOE^JANE",
|
|
@@ -231,7 +310,7 @@ import type { DicomTagOverrides } from 'dicom-synth'
|
|
|
231
310
|
|
|
232
311
|
### Transfer syntax (`transferSyntax`)
|
|
233
312
|
|
|
234
|
-
Available on
|
|
313
|
+
Available on all image types.
|
|
235
314
|
|
|
236
315
|
| Value | `TransferSyntaxUID` in meta header |
|
|
237
316
|
|---|---|
|
|
@@ -241,7 +320,7 @@ Available on `valid-ct`, `invalid-uid-ct`, `vendor-warnings-ct`, and `large-ct`.
|
|
|
241
320
|
|
|
242
321
|
### Violation injection (`violations`)
|
|
243
322
|
|
|
244
|
-
Available on
|
|
323
|
+
Available on all image types. Violations are applied as post-processing transforms to an otherwise valid buffer.
|
|
245
324
|
|
|
246
325
|
| Violation | Effect | Detectable by dciodvfy |
|
|
247
326
|
|---|---|---|
|
|
@@ -255,7 +334,7 @@ Available on `valid-ct`, `invalid-uid-ct`, `vendor-warnings-ct`, and `large-ct`.
|
|
|
255
334
|
Tag-level violations are applied before byte-level violations. Byte-level violations may produce a buffer that cannot be re-parsed.
|
|
256
335
|
|
|
257
336
|
```json
|
|
258
|
-
{ "type": "valid-
|
|
337
|
+
{ "type": "valid-image", "violations": ["uid-too-long", "missing-meta-header"] }
|
|
259
338
|
```
|
|
260
339
|
|
|
261
340
|
```ts
|
|
@@ -277,16 +356,26 @@ The validator throws with a human-readable message on any structural error.
|
|
|
277
356
|
```json
|
|
278
357
|
{
|
|
279
358
|
"entries": [
|
|
280
|
-
{ "type": "valid-
|
|
281
|
-
{ "type": "invalid-uid-
|
|
282
|
-
{ "type": "vendor-warnings-
|
|
283
|
-
{ "type": "
|
|
284
|
-
{ "type": "valid-
|
|
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"] },
|
|
285
365
|
{ "type": "fake-signature", "count": 5 },
|
|
286
366
|
{ "type": "non-dicom", "count": 2, "content": "garbage" },
|
|
287
367
|
{ "type": "dicomdir", "count": 1 }
|
|
288
368
|
],
|
|
289
|
-
"
|
|
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"
|
|
290
379
|
}
|
|
291
380
|
```
|
|
292
381
|
|
|
@@ -299,20 +388,20 @@ The validator throws with a human-readable message on any structural error.
|
|
|
299
388
|
dicom-synth-generate --schema dataset.json --out ./fixtures/generated
|
|
300
389
|
|
|
301
390
|
# Inline schema
|
|
302
|
-
dicom-synth-generate --schema-inline '{"entries":[{"type":"valid-
|
|
391
|
+
dicom-synth-generate --schema-inline '{"entries":[{"type":"valid-image","count":5}]}' --out ./out
|
|
303
392
|
|
|
304
393
|
# Default output directory is ./fixtures/generated
|
|
305
394
|
dicom-synth-generate --schema dataset.json
|
|
306
395
|
```
|
|
307
396
|
|
|
308
|
-
The default schema (`examples/default.json`) generates one each of `valid-
|
|
397
|
+
The default schema (`examples/default.json`) generates one each of `valid-image`, `invalid-uid-image`, and `vendor-warnings-image`:
|
|
309
398
|
|
|
310
399
|
```bash
|
|
311
400
|
dicom-synth-generate --schema examples/default.json --out /tmp/out
|
|
312
401
|
# Wrote 3 file(s) to /tmp/out
|
|
313
|
-
# valid-
|
|
314
|
-
# invalid-uid-
|
|
315
|
-
# vendor-warnings-
|
|
402
|
+
# valid-image: 1
|
|
403
|
+
# invalid-uid-image: 1
|
|
404
|
+
# vendor-warnings-image: 1
|
|
316
405
|
```
|
|
317
406
|
|
|
318
407
|
Schema validation errors exit with code 1 and a descriptive message.
|
|
@@ -391,7 +480,7 @@ pnpm fetch:public-case -- pydicom-CT-small
|
|
|
391
480
|
| `src/public-fixtures/fetch.ts` | Fetch, SHA-256 verify, content-addressed cache |
|
|
392
481
|
| `bin/dicom-synth-generate.mjs` | Published generate CLI (requires `pnpm build`) |
|
|
393
482
|
| `bin/dicom-synth-fetch.mjs` | Published fetch CLI (requires `pnpm build`) |
|
|
394
|
-
| `examples/default.json` | Default `DatasetSpec` — one each of `valid-
|
|
483
|
+
| `examples/default.json` | Default `DatasetSpec` — one each of `valid-image`, `invalid-uid-image`, `vendor-warnings-image` |
|
|
395
484
|
|
|
396
485
|
---
|
|
397
486
|
|
|
@@ -417,9 +506,10 @@ Git hooks: see [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
|
417
506
|
|
|
418
507
|
## Future development
|
|
419
508
|
|
|
420
|
-
- **
|
|
509
|
+
- **Size targeting** — generate a file of approximately N KB via a `targetSizeKb` field
|
|
510
|
+
- **Parametric dataset designer** — range-based spec (`{min, max}` for counts/sizes) that resolves deterministically into a concrete `DatasetSpec`
|
|
511
|
+
- **Dry-run mode** — emit the manifest (paths, types, grouping) without writing files
|
|
421
512
|
- **Compressed transfer syntaxes** — JPEG-LS, JPEG 2000, RLE
|
|
422
|
-
- **Modality presets** — MR, PET, CR profiles beyond CT
|
|
423
513
|
- **Private fixture catalogues** — same SHA-256 fetch/cache pattern for credentials-backed sources (e.g. S3)
|
|
424
514
|
|
|
425
515
|
---
|
|
@@ -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,46 @@ function serializeDict(meta, dataset) {
|
|
|
128
158
|
);
|
|
129
159
|
return Buffer.from(dicomDict.write({ allowInvalidVRLength: true }));
|
|
130
160
|
}
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
161
|
+
var MAX_DIMENSION = 65535;
|
|
162
|
+
function applySizing(dataset, meta, spec) {
|
|
163
|
+
if (spec.targetSizeKb !== void 0) {
|
|
164
|
+
const clampDim = (n) => Math.min(MAX_DIMENSION, Math.max(1, Math.round(n)));
|
|
165
|
+
const minimalPixelBytes = dataset.PixelData.byteLength;
|
|
166
|
+
const overhead = serializeDict(meta, dataset).length - minimalPixelBytes;
|
|
167
|
+
const cells = Math.max(
|
|
168
|
+
1,
|
|
169
|
+
Math.round((spec.targetSizeKb * 1024 - overhead) / 2)
|
|
170
|
+
);
|
|
171
|
+
const rows = clampDim(Math.sqrt(cells));
|
|
172
|
+
const columns = clampDim(cells / rows);
|
|
173
|
+
dataset.Rows = rows;
|
|
174
|
+
dataset.Columns = columns;
|
|
175
|
+
dataset.PixelData = new ArrayBuffer(rows * columns * 2);
|
|
176
|
+
} else if (spec.rows !== void 0 && spec.columns !== void 0) {
|
|
177
|
+
dataset.Rows = spec.rows;
|
|
178
|
+
dataset.Columns = spec.columns;
|
|
179
|
+
if (spec.frames !== void 0) dataset.NumberOfFrames = spec.frames;
|
|
180
|
+
dataset.PixelData = new ArrayBuffer(
|
|
181
|
+
spec.rows * spec.columns * (spec.frames ?? 1) * 2
|
|
182
|
+
);
|
|
183
|
+
}
|
|
135
184
|
}
|
|
136
|
-
function
|
|
137
|
-
const
|
|
185
|
+
function buildImageBuffer(spec, uid) {
|
|
186
|
+
const modality = spec.modality ?? "CT";
|
|
187
|
+
const effectiveUid = spec.type === "invalid-uid-image" ? {
|
|
138
188
|
study: `2.25.invalid.study.${uid.study.slice(-6)}`,
|
|
139
189
|
series: `2.25.invalid.series.${uid.series.slice(-6)}`,
|
|
140
190
|
sop: `2.25.invalid.sop.${uid.sop.slice(-6)}`
|
|
141
|
-
};
|
|
142
|
-
const dataset =
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
dataset
|
|
150
|
-
|
|
151
|
-
return serializeDict(buildMeta(uid, transferSyntax), dataset);
|
|
152
|
-
}
|
|
153
|
-
function buildLargeCtBuffer(uid, rows, columns, frames, tags, transferSyntax) {
|
|
154
|
-
const dataset = buildBaseCtDataset(uid);
|
|
155
|
-
dataset.Rows = rows;
|
|
156
|
-
dataset.Columns = columns;
|
|
157
|
-
dataset.NumberOfFrames = frames;
|
|
158
|
-
dataset.PixelData = new Uint8Array(rows * columns * frames * 2).buffer;
|
|
159
|
-
applyTagOverrides(dataset, tags);
|
|
160
|
-
return serializeDict(buildMeta(uid, transferSyntax), dataset);
|
|
191
|
+
} : uid;
|
|
192
|
+
const dataset = buildBaseImageDataset(effectiveUid, modality);
|
|
193
|
+
if (spec.type === "vendor-warnings-image") {
|
|
194
|
+
dataset.Laterality = "";
|
|
195
|
+
dataset.PatientWeight = "0";
|
|
196
|
+
}
|
|
197
|
+
applyTagOverrides(dataset, spec.tags);
|
|
198
|
+
const meta = buildMeta(effectiveUid, modality, spec.transferSyntax);
|
|
199
|
+
applySizing(dataset, meta, spec);
|
|
200
|
+
return serializeDict(meta, dataset);
|
|
161
201
|
}
|
|
162
202
|
function buildFakeSignatureBuffer() {
|
|
163
203
|
const buf = Buffer.alloc(200, 0);
|
|
@@ -169,21 +209,10 @@ function buildNonDicomBuffer(content = "not dicom") {
|
|
|
169
209
|
}
|
|
170
210
|
function buildBufferForSpec(spec, uid) {
|
|
171
211
|
switch (spec.type) {
|
|
172
|
-
case "valid-
|
|
173
|
-
|
|
174
|
-
case "
|
|
175
|
-
return
|
|
176
|
-
case "vendor-warnings-ct":
|
|
177
|
-
return buildVendorCtBuffer(uid, spec.tags, spec.transferSyntax);
|
|
178
|
-
case "large-ct":
|
|
179
|
-
return buildLargeCtBuffer(
|
|
180
|
-
uid,
|
|
181
|
-
spec.rows,
|
|
182
|
-
spec.columns,
|
|
183
|
-
spec.frames ?? 1,
|
|
184
|
-
spec.tags,
|
|
185
|
-
spec.transferSyntax
|
|
186
|
-
);
|
|
212
|
+
case "valid-image":
|
|
213
|
+
case "invalid-uid-image":
|
|
214
|
+
case "vendor-warnings-image":
|
|
215
|
+
return buildImageBuffer(spec, uid);
|
|
187
216
|
case "fake-signature":
|
|
188
217
|
return buildFakeSignatureBuffer();
|
|
189
218
|
case "non-dicom":
|
package/dist/esm/index.js
CHANGED
|
@@ -46,13 +46,18 @@ function buildDicomdirBuffer() {
|
|
|
46
46
|
|
|
47
47
|
// src/schema/validate.ts
|
|
48
48
|
var TYPE_CAPABILITIES = {
|
|
49
|
-
"valid-
|
|
50
|
-
"invalid-uid-
|
|
51
|
-
"vendor-warnings-
|
|
52
|
-
"
|
|
53
|
-
"
|
|
54
|
-
|
|
55
|
-
|
|
49
|
+
"valid-image": { image: true },
|
|
50
|
+
"invalid-uid-image": { image: true },
|
|
51
|
+
"vendor-warnings-image": { image: true },
|
|
52
|
+
"fake-signature": { image: false },
|
|
53
|
+
"non-dicom": { image: false },
|
|
54
|
+
dicomdir: { image: false }
|
|
55
|
+
};
|
|
56
|
+
var VALID_MODALITIES = {
|
|
57
|
+
CT: true,
|
|
58
|
+
PT: true,
|
|
59
|
+
MR: true,
|
|
60
|
+
CR: true
|
|
56
61
|
};
|
|
57
62
|
var VALID_LAYOUTS = {
|
|
58
63
|
flat: true,
|
|
@@ -100,22 +105,33 @@ function validateEntry(entry, path) {
|
|
|
100
105
|
const type = e.type;
|
|
101
106
|
const caps = TYPE_CAPABILITIES[type];
|
|
102
107
|
if ("count" in e) validatePositiveInt(e.count, `${path}.count`);
|
|
108
|
+
for (const field of [
|
|
109
|
+
"modality",
|
|
110
|
+
"rows",
|
|
111
|
+
"columns",
|
|
112
|
+
"frames",
|
|
113
|
+
"targetSizeKb",
|
|
114
|
+
"tags",
|
|
115
|
+
"violations",
|
|
116
|
+
"transferSyntax"
|
|
117
|
+
]) {
|
|
118
|
+
if (!caps.image && field in e)
|
|
119
|
+
throw new Error(`${path}.${field}: not supported for type "${type}"`);
|
|
120
|
+
}
|
|
103
121
|
if ("transferSyntax" in e) {
|
|
104
|
-
if (!
|
|
122
|
+
if (!Object.hasOwn(VALID_TRANSFER_SYNTAXES, e.transferSyntax)) {
|
|
105
123
|
throw new Error(
|
|
106
|
-
`${path}.transferSyntax:
|
|
124
|
+
`${path}.transferSyntax: must be one of ${Object.keys(VALID_TRANSFER_SYNTAXES).join(", ")}`
|
|
107
125
|
);
|
|
108
126
|
}
|
|
109
|
-
|
|
127
|
+
}
|
|
128
|
+
if ("modality" in e) {
|
|
129
|
+
if (typeof e.modality !== "string" || !Object.hasOwn(VALID_MODALITIES, e.modality)) {
|
|
110
130
|
throw new Error(
|
|
111
|
-
`${path}.
|
|
131
|
+
`${path}.modality: must be one of ${Object.keys(VALID_MODALITIES).join(", ")}; got ${JSON.stringify(e.modality)}`
|
|
112
132
|
);
|
|
113
133
|
}
|
|
114
134
|
}
|
|
115
|
-
if (!caps.tags && "tags" in e)
|
|
116
|
-
throw new Error(`${path}.tags: not supported for type "${type}"`);
|
|
117
|
-
if (!caps.violations && "violations" in e)
|
|
118
|
-
throw new Error(`${path}.violations: not supported for type "${type}"`);
|
|
119
135
|
if ("tags" in e) validateTags(e.tags, `${path}.tags`);
|
|
120
136
|
if ("violations" in e) {
|
|
121
137
|
if (!Array.isArray(e.violations)) {
|
|
@@ -129,7 +145,21 @@ function validateEntry(entry, path) {
|
|
|
129
145
|
}
|
|
130
146
|
}
|
|
131
147
|
}
|
|
132
|
-
|
|
148
|
+
const hasDims = "rows" in e || "columns" in e || "frames" in e;
|
|
149
|
+
if ("targetSizeKb" in e) {
|
|
150
|
+
if (hasDims) {
|
|
151
|
+
throw new Error(
|
|
152
|
+
`${path}: targetSizeKb cannot be combined with rows/columns/frames \u2014 use one sizing mechanism`
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
validatePositiveInt(e.targetSizeKb, `${path}.targetSizeKb`);
|
|
156
|
+
if (e.targetSizeKb * 1024 > MAX_PIXEL_BYTES) {
|
|
157
|
+
throw new Error(`${path}.targetSizeKb: exceeds the 512 MB limit`);
|
|
158
|
+
}
|
|
159
|
+
} else if (hasDims) {
|
|
160
|
+
if (!("rows" in e) || !("columns" in e)) {
|
|
161
|
+
throw new Error(`${path}: rows and columns must be provided together`);
|
|
162
|
+
}
|
|
133
163
|
validatePositiveInt(e.rows, `${path}.rows`);
|
|
134
164
|
validatePositiveInt(e.columns, `${path}.columns`);
|
|
135
165
|
if ("frames" in e) validatePositiveInt(e.frames, `${path}.frames`);
|
|
@@ -162,9 +192,9 @@ function validateSeries(series, path) {
|
|
|
162
192
|
}
|
|
163
193
|
for (let i = 0; i < s.entries.length; i++) {
|
|
164
194
|
const e = validateEntry(s.entries[i], `${path}.entries[${i}]`);
|
|
165
|
-
if (!TYPE_CAPABILITIES[e.type].
|
|
195
|
+
if (!TYPE_CAPABILITIES[e.type].image) {
|
|
166
196
|
throw new Error(
|
|
167
|
-
`${path}.entries[${i}].type: "${e.type}" cannot be used inside a series \u2014 only types
|
|
197
|
+
`${path}.entries[${i}].type: "${e.type}" cannot be used inside a series \u2014 only image-generating types can be grouped`
|
|
168
198
|
);
|
|
169
199
|
}
|
|
170
200
|
}
|
|
@@ -233,7 +263,35 @@ function validateDatasetSpec(raw) {
|
|
|
233
263
|
}
|
|
234
264
|
|
|
235
265
|
// src/syntheticFixtures/generator.ts
|
|
236
|
-
var
|
|
266
|
+
var MODALITY_PRESETS = {
|
|
267
|
+
CT: {
|
|
268
|
+
sopClassUid: "1.2.840.10008.5.1.4.1.1.2",
|
|
269
|
+
// CT Image Storage
|
|
270
|
+
attributes: {}
|
|
271
|
+
},
|
|
272
|
+
PT: {
|
|
273
|
+
sopClassUid: "1.2.840.10008.5.1.4.1.1.128",
|
|
274
|
+
// PET Image Storage
|
|
275
|
+
attributes: {
|
|
276
|
+
Units: "BQML",
|
|
277
|
+
DecayCorrection: "NONE",
|
|
278
|
+
CorrectedImage: ["ATTN", "DECY"]
|
|
279
|
+
}
|
|
280
|
+
},
|
|
281
|
+
MR: {
|
|
282
|
+
sopClassUid: "1.2.840.10008.5.1.4.1.1.4",
|
|
283
|
+
// MR Image Storage
|
|
284
|
+
attributes: {
|
|
285
|
+
ScanningSequence: "SE",
|
|
286
|
+
SequenceVariant: "NONE"
|
|
287
|
+
}
|
|
288
|
+
},
|
|
289
|
+
CR: {
|
|
290
|
+
sopClassUid: "1.2.840.10008.5.1.4.1.1.1",
|
|
291
|
+
// CR Image Storage
|
|
292
|
+
attributes: {}
|
|
293
|
+
}
|
|
294
|
+
};
|
|
237
295
|
var TRANSFER_SYNTAX_UID = {
|
|
238
296
|
"explicit-vr-little-endian": "1.2.840.10008.1.2.1",
|
|
239
297
|
"implicit-vr-little-endian": "1.2.840.10008.1.2"
|
|
@@ -266,22 +324,23 @@ function applyTagOverrides(dataset, tags) {
|
|
|
266
324
|
}
|
|
267
325
|
}
|
|
268
326
|
}
|
|
269
|
-
function buildMeta(uid, transferSyntax = "explicit-vr-little-endian") {
|
|
327
|
+
function buildMeta(uid, modality, transferSyntax = "explicit-vr-little-endian") {
|
|
270
328
|
return {
|
|
271
329
|
FileMetaInformationVersion: new Uint8Array([0, 1]).buffer,
|
|
272
|
-
MediaStorageSOPClassUID:
|
|
330
|
+
MediaStorageSOPClassUID: MODALITY_PRESETS[modality].sopClassUid,
|
|
273
331
|
MediaStorageSOPInstanceUID: uid.sop,
|
|
274
332
|
TransferSyntaxUID: TRANSFER_SYNTAX_UID[transferSyntax],
|
|
275
333
|
ImplementationClassUID: "1.2.3.4",
|
|
276
334
|
ImplementationVersionName: "SYNTH"
|
|
277
335
|
};
|
|
278
336
|
}
|
|
279
|
-
function
|
|
337
|
+
function buildBaseImageDataset(uid, modality) {
|
|
338
|
+
const preset = MODALITY_PRESETS[modality];
|
|
280
339
|
return {
|
|
281
340
|
PatientName: "SYNTH^SUBJECT",
|
|
282
341
|
PatientID: "SYNTH_PID",
|
|
283
|
-
Modality:
|
|
284
|
-
SOPClassUID:
|
|
342
|
+
Modality: modality,
|
|
343
|
+
SOPClassUID: preset.sopClassUid,
|
|
285
344
|
SOPInstanceUID: uid.sop,
|
|
286
345
|
SeriesInstanceUID: uid.series,
|
|
287
346
|
StudyInstanceUID: uid.study,
|
|
@@ -298,7 +357,8 @@ function buildBaseCtDataset(uid) {
|
|
|
298
357
|
PixelData: new Uint8Array([0, 0]).buffer,
|
|
299
358
|
// Declares PixelData's VR explicitly — without it dcmjs logs
|
|
300
359
|
// "No value representation given for PixelData" and falls back to OW anyway.
|
|
301
|
-
_vrMap: { PixelData: "OW" }
|
|
360
|
+
_vrMap: { PixelData: "OW" },
|
|
361
|
+
...preset.attributes
|
|
302
362
|
};
|
|
303
363
|
}
|
|
304
364
|
function serializeDict(meta, dataset) {
|
|
@@ -312,36 +372,46 @@ function serializeDict(meta, dataset) {
|
|
|
312
372
|
);
|
|
313
373
|
return Buffer.from(dicomDict.write({ allowInvalidVRLength: true }));
|
|
314
374
|
}
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
375
|
+
var MAX_DIMENSION = 65535;
|
|
376
|
+
function applySizing(dataset, meta, spec) {
|
|
377
|
+
if (spec.targetSizeKb !== void 0) {
|
|
378
|
+
const clampDim = (n) => Math.min(MAX_DIMENSION, Math.max(1, Math.round(n)));
|
|
379
|
+
const minimalPixelBytes = dataset.PixelData.byteLength;
|
|
380
|
+
const overhead = serializeDict(meta, dataset).length - minimalPixelBytes;
|
|
381
|
+
const cells = Math.max(
|
|
382
|
+
1,
|
|
383
|
+
Math.round((spec.targetSizeKb * 1024 - overhead) / 2)
|
|
384
|
+
);
|
|
385
|
+
const rows = clampDim(Math.sqrt(cells));
|
|
386
|
+
const columns = clampDim(cells / rows);
|
|
387
|
+
dataset.Rows = rows;
|
|
388
|
+
dataset.Columns = columns;
|
|
389
|
+
dataset.PixelData = new ArrayBuffer(rows * columns * 2);
|
|
390
|
+
} else if (spec.rows !== void 0 && spec.columns !== void 0) {
|
|
391
|
+
dataset.Rows = spec.rows;
|
|
392
|
+
dataset.Columns = spec.columns;
|
|
393
|
+
if (spec.frames !== void 0) dataset.NumberOfFrames = spec.frames;
|
|
394
|
+
dataset.PixelData = new ArrayBuffer(
|
|
395
|
+
spec.rows * spec.columns * (spec.frames ?? 1) * 2
|
|
396
|
+
);
|
|
397
|
+
}
|
|
319
398
|
}
|
|
320
|
-
function
|
|
321
|
-
const
|
|
399
|
+
function buildImageBuffer(spec, uid) {
|
|
400
|
+
const modality = spec.modality ?? "CT";
|
|
401
|
+
const effectiveUid = spec.type === "invalid-uid-image" ? {
|
|
322
402
|
study: `2.25.invalid.study.${uid.study.slice(-6)}`,
|
|
323
403
|
series: `2.25.invalid.series.${uid.series.slice(-6)}`,
|
|
324
404
|
sop: `2.25.invalid.sop.${uid.sop.slice(-6)}`
|
|
325
|
-
};
|
|
326
|
-
const dataset =
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
dataset
|
|
334
|
-
|
|
335
|
-
return serializeDict(buildMeta(uid, transferSyntax), dataset);
|
|
336
|
-
}
|
|
337
|
-
function buildLargeCtBuffer(uid, rows, columns, frames, tags, transferSyntax) {
|
|
338
|
-
const dataset = buildBaseCtDataset(uid);
|
|
339
|
-
dataset.Rows = rows;
|
|
340
|
-
dataset.Columns = columns;
|
|
341
|
-
dataset.NumberOfFrames = frames;
|
|
342
|
-
dataset.PixelData = new Uint8Array(rows * columns * frames * 2).buffer;
|
|
343
|
-
applyTagOverrides(dataset, tags);
|
|
344
|
-
return serializeDict(buildMeta(uid, transferSyntax), dataset);
|
|
405
|
+
} : uid;
|
|
406
|
+
const dataset = buildBaseImageDataset(effectiveUid, modality);
|
|
407
|
+
if (spec.type === "vendor-warnings-image") {
|
|
408
|
+
dataset.Laterality = "";
|
|
409
|
+
dataset.PatientWeight = "0";
|
|
410
|
+
}
|
|
411
|
+
applyTagOverrides(dataset, spec.tags);
|
|
412
|
+
const meta = buildMeta(effectiveUid, modality, spec.transferSyntax);
|
|
413
|
+
applySizing(dataset, meta, spec);
|
|
414
|
+
return serializeDict(meta, dataset);
|
|
345
415
|
}
|
|
346
416
|
function buildFakeSignatureBuffer() {
|
|
347
417
|
const buf = Buffer.alloc(200, 0);
|
|
@@ -353,21 +423,10 @@ function buildNonDicomBuffer(content = "not dicom") {
|
|
|
353
423
|
}
|
|
354
424
|
function buildBufferForSpec(spec, uid) {
|
|
355
425
|
switch (spec.type) {
|
|
356
|
-
case "valid-
|
|
357
|
-
|
|
358
|
-
case "
|
|
359
|
-
return
|
|
360
|
-
case "vendor-warnings-ct":
|
|
361
|
-
return buildVendorCtBuffer(uid, spec.tags, spec.transferSyntax);
|
|
362
|
-
case "large-ct":
|
|
363
|
-
return buildLargeCtBuffer(
|
|
364
|
-
uid,
|
|
365
|
-
spec.rows,
|
|
366
|
-
spec.columns,
|
|
367
|
-
spec.frames ?? 1,
|
|
368
|
-
spec.tags,
|
|
369
|
-
spec.transferSyntax
|
|
370
|
-
);
|
|
426
|
+
case "valid-image":
|
|
427
|
+
case "invalid-uid-image":
|
|
428
|
+
case "vendor-warnings-image":
|
|
429
|
+
return buildImageBuffer(spec, uid);
|
|
371
430
|
case "fake-signature":
|
|
372
431
|
return buildFakeSignatureBuffer();
|
|
373
432
|
case "non-dicom":
|
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
// src/schema/validate.ts
|
|
2
2
|
var TYPE_CAPABILITIES = {
|
|
3
|
-
"valid-
|
|
4
|
-
"invalid-uid-
|
|
5
|
-
"vendor-warnings-
|
|
6
|
-
"
|
|
7
|
-
"
|
|
8
|
-
|
|
9
|
-
|
|
3
|
+
"valid-image": { image: true },
|
|
4
|
+
"invalid-uid-image": { image: true },
|
|
5
|
+
"vendor-warnings-image": { image: true },
|
|
6
|
+
"fake-signature": { image: false },
|
|
7
|
+
"non-dicom": { image: false },
|
|
8
|
+
dicomdir: { image: false }
|
|
9
|
+
};
|
|
10
|
+
var VALID_MODALITIES = {
|
|
11
|
+
CT: true,
|
|
12
|
+
PT: true,
|
|
13
|
+
MR: true,
|
|
14
|
+
CR: true
|
|
10
15
|
};
|
|
11
16
|
var VALID_LAYOUTS = {
|
|
12
17
|
flat: true,
|
|
@@ -54,22 +59,33 @@ function validateEntry(entry, path) {
|
|
|
54
59
|
const type = e.type;
|
|
55
60
|
const caps = TYPE_CAPABILITIES[type];
|
|
56
61
|
if ("count" in e) validatePositiveInt(e.count, `${path}.count`);
|
|
62
|
+
for (const field of [
|
|
63
|
+
"modality",
|
|
64
|
+
"rows",
|
|
65
|
+
"columns",
|
|
66
|
+
"frames",
|
|
67
|
+
"targetSizeKb",
|
|
68
|
+
"tags",
|
|
69
|
+
"violations",
|
|
70
|
+
"transferSyntax"
|
|
71
|
+
]) {
|
|
72
|
+
if (!caps.image && field in e)
|
|
73
|
+
throw new Error(`${path}.${field}: not supported for type "${type}"`);
|
|
74
|
+
}
|
|
57
75
|
if ("transferSyntax" in e) {
|
|
58
|
-
if (!
|
|
76
|
+
if (!Object.hasOwn(VALID_TRANSFER_SYNTAXES, e.transferSyntax)) {
|
|
59
77
|
throw new Error(
|
|
60
|
-
`${path}.transferSyntax:
|
|
78
|
+
`${path}.transferSyntax: must be one of ${Object.keys(VALID_TRANSFER_SYNTAXES).join(", ")}`
|
|
61
79
|
);
|
|
62
80
|
}
|
|
63
|
-
|
|
81
|
+
}
|
|
82
|
+
if ("modality" in e) {
|
|
83
|
+
if (typeof e.modality !== "string" || !Object.hasOwn(VALID_MODALITIES, e.modality)) {
|
|
64
84
|
throw new Error(
|
|
65
|
-
`${path}.
|
|
85
|
+
`${path}.modality: must be one of ${Object.keys(VALID_MODALITIES).join(", ")}; got ${JSON.stringify(e.modality)}`
|
|
66
86
|
);
|
|
67
87
|
}
|
|
68
88
|
}
|
|
69
|
-
if (!caps.tags && "tags" in e)
|
|
70
|
-
throw new Error(`${path}.tags: not supported for type "${type}"`);
|
|
71
|
-
if (!caps.violations && "violations" in e)
|
|
72
|
-
throw new Error(`${path}.violations: not supported for type "${type}"`);
|
|
73
89
|
if ("tags" in e) validateTags(e.tags, `${path}.tags`);
|
|
74
90
|
if ("violations" in e) {
|
|
75
91
|
if (!Array.isArray(e.violations)) {
|
|
@@ -83,7 +99,21 @@ function validateEntry(entry, path) {
|
|
|
83
99
|
}
|
|
84
100
|
}
|
|
85
101
|
}
|
|
86
|
-
|
|
102
|
+
const hasDims = "rows" in e || "columns" in e || "frames" in e;
|
|
103
|
+
if ("targetSizeKb" in e) {
|
|
104
|
+
if (hasDims) {
|
|
105
|
+
throw new Error(
|
|
106
|
+
`${path}: targetSizeKb cannot be combined with rows/columns/frames \u2014 use one sizing mechanism`
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
validatePositiveInt(e.targetSizeKb, `${path}.targetSizeKb`);
|
|
110
|
+
if (e.targetSizeKb * 1024 > MAX_PIXEL_BYTES) {
|
|
111
|
+
throw new Error(`${path}.targetSizeKb: exceeds the 512 MB limit`);
|
|
112
|
+
}
|
|
113
|
+
} else if (hasDims) {
|
|
114
|
+
if (!("rows" in e) || !("columns" in e)) {
|
|
115
|
+
throw new Error(`${path}: rows and columns must be provided together`);
|
|
116
|
+
}
|
|
87
117
|
validatePositiveInt(e.rows, `${path}.rows`);
|
|
88
118
|
validatePositiveInt(e.columns, `${path}.columns`);
|
|
89
119
|
if ("frames" in e) validatePositiveInt(e.frames, `${path}.frames`);
|
|
@@ -116,9 +146,9 @@ function validateSeries(series, path) {
|
|
|
116
146
|
}
|
|
117
147
|
for (let i = 0; i < s.entries.length; i++) {
|
|
118
148
|
const e = validateEntry(s.entries[i], `${path}.entries[${i}]`);
|
|
119
|
-
if (!TYPE_CAPABILITIES[e.type].
|
|
149
|
+
if (!TYPE_CAPABILITIES[e.type].image) {
|
|
120
150
|
throw new Error(
|
|
121
|
-
`${path}.entries[${i}].type: "${e.type}" cannot be used inside a series \u2014 only types
|
|
151
|
+
`${path}.entries[${i}].type: "${e.type}" cannot be used inside a series \u2014 only image-generating types can be grouped`
|
|
122
152
|
);
|
|
123
153
|
}
|
|
124
154
|
}
|
|
@@ -44,7 +44,35 @@ var MAX_PIXEL_BYTES = 512 * 1024 * 1024;
|
|
|
44
44
|
var HEX_TAG_RE = /^[0-9a-fA-F]{8}$/;
|
|
45
45
|
|
|
46
46
|
// src/syntheticFixtures/generator.ts
|
|
47
|
-
var
|
|
47
|
+
var MODALITY_PRESETS = {
|
|
48
|
+
CT: {
|
|
49
|
+
sopClassUid: "1.2.840.10008.5.1.4.1.1.2",
|
|
50
|
+
// CT Image Storage
|
|
51
|
+
attributes: {}
|
|
52
|
+
},
|
|
53
|
+
PT: {
|
|
54
|
+
sopClassUid: "1.2.840.10008.5.1.4.1.1.128",
|
|
55
|
+
// PET Image Storage
|
|
56
|
+
attributes: {
|
|
57
|
+
Units: "BQML",
|
|
58
|
+
DecayCorrection: "NONE",
|
|
59
|
+
CorrectedImage: ["ATTN", "DECY"]
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
MR: {
|
|
63
|
+
sopClassUid: "1.2.840.10008.5.1.4.1.1.4",
|
|
64
|
+
// MR Image Storage
|
|
65
|
+
attributes: {
|
|
66
|
+
ScanningSequence: "SE",
|
|
67
|
+
SequenceVariant: "NONE"
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
CR: {
|
|
71
|
+
sopClassUid: "1.2.840.10008.5.1.4.1.1.1",
|
|
72
|
+
// CR Image Storage
|
|
73
|
+
attributes: {}
|
|
74
|
+
}
|
|
75
|
+
};
|
|
48
76
|
var TRANSFER_SYNTAX_UID = {
|
|
49
77
|
"explicit-vr-little-endian": "1.2.840.10008.1.2.1",
|
|
50
78
|
"implicit-vr-little-endian": "1.2.840.10008.1.2"
|
|
@@ -77,22 +105,23 @@ function applyTagOverrides(dataset, tags) {
|
|
|
77
105
|
}
|
|
78
106
|
}
|
|
79
107
|
}
|
|
80
|
-
function buildMeta(uid, transferSyntax = "explicit-vr-little-endian") {
|
|
108
|
+
function buildMeta(uid, modality, transferSyntax = "explicit-vr-little-endian") {
|
|
81
109
|
return {
|
|
82
110
|
FileMetaInformationVersion: new Uint8Array([0, 1]).buffer,
|
|
83
|
-
MediaStorageSOPClassUID:
|
|
111
|
+
MediaStorageSOPClassUID: MODALITY_PRESETS[modality].sopClassUid,
|
|
84
112
|
MediaStorageSOPInstanceUID: uid.sop,
|
|
85
113
|
TransferSyntaxUID: TRANSFER_SYNTAX_UID[transferSyntax],
|
|
86
114
|
ImplementationClassUID: "1.2.3.4",
|
|
87
115
|
ImplementationVersionName: "SYNTH"
|
|
88
116
|
};
|
|
89
117
|
}
|
|
90
|
-
function
|
|
118
|
+
function buildBaseImageDataset(uid, modality) {
|
|
119
|
+
const preset = MODALITY_PRESETS[modality];
|
|
91
120
|
return {
|
|
92
121
|
PatientName: "SYNTH^SUBJECT",
|
|
93
122
|
PatientID: "SYNTH_PID",
|
|
94
|
-
Modality:
|
|
95
|
-
SOPClassUID:
|
|
123
|
+
Modality: modality,
|
|
124
|
+
SOPClassUID: preset.sopClassUid,
|
|
96
125
|
SOPInstanceUID: uid.sop,
|
|
97
126
|
SeriesInstanceUID: uid.series,
|
|
98
127
|
StudyInstanceUID: uid.study,
|
|
@@ -109,7 +138,8 @@ function buildBaseCtDataset(uid) {
|
|
|
109
138
|
PixelData: new Uint8Array([0, 0]).buffer,
|
|
110
139
|
// Declares PixelData's VR explicitly — without it dcmjs logs
|
|
111
140
|
// "No value representation given for PixelData" and falls back to OW anyway.
|
|
112
|
-
_vrMap: { PixelData: "OW" }
|
|
141
|
+
_vrMap: { PixelData: "OW" },
|
|
142
|
+
...preset.attributes
|
|
113
143
|
};
|
|
114
144
|
}
|
|
115
145
|
function serializeDict(meta, dataset) {
|
|
@@ -123,36 +153,46 @@ function serializeDict(meta, dataset) {
|
|
|
123
153
|
);
|
|
124
154
|
return Buffer.from(dicomDict.write({ allowInvalidVRLength: true }));
|
|
125
155
|
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
156
|
+
var MAX_DIMENSION = 65535;
|
|
157
|
+
function applySizing(dataset, meta, spec) {
|
|
158
|
+
if (spec.targetSizeKb !== void 0) {
|
|
159
|
+
const clampDim = (n) => Math.min(MAX_DIMENSION, Math.max(1, Math.round(n)));
|
|
160
|
+
const minimalPixelBytes = dataset.PixelData.byteLength;
|
|
161
|
+
const overhead = serializeDict(meta, dataset).length - minimalPixelBytes;
|
|
162
|
+
const cells = Math.max(
|
|
163
|
+
1,
|
|
164
|
+
Math.round((spec.targetSizeKb * 1024 - overhead) / 2)
|
|
165
|
+
);
|
|
166
|
+
const rows = clampDim(Math.sqrt(cells));
|
|
167
|
+
const columns = clampDim(cells / rows);
|
|
168
|
+
dataset.Rows = rows;
|
|
169
|
+
dataset.Columns = columns;
|
|
170
|
+
dataset.PixelData = new ArrayBuffer(rows * columns * 2);
|
|
171
|
+
} else if (spec.rows !== void 0 && spec.columns !== void 0) {
|
|
172
|
+
dataset.Rows = spec.rows;
|
|
173
|
+
dataset.Columns = spec.columns;
|
|
174
|
+
if (spec.frames !== void 0) dataset.NumberOfFrames = spec.frames;
|
|
175
|
+
dataset.PixelData = new ArrayBuffer(
|
|
176
|
+
spec.rows * spec.columns * (spec.frames ?? 1) * 2
|
|
177
|
+
);
|
|
178
|
+
}
|
|
130
179
|
}
|
|
131
|
-
function
|
|
132
|
-
const
|
|
180
|
+
function buildImageBuffer(spec, uid) {
|
|
181
|
+
const modality = spec.modality ?? "CT";
|
|
182
|
+
const effectiveUid = spec.type === "invalid-uid-image" ? {
|
|
133
183
|
study: `2.25.invalid.study.${uid.study.slice(-6)}`,
|
|
134
184
|
series: `2.25.invalid.series.${uid.series.slice(-6)}`,
|
|
135
185
|
sop: `2.25.invalid.sop.${uid.sop.slice(-6)}`
|
|
136
|
-
};
|
|
137
|
-
const dataset =
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
dataset
|
|
145
|
-
|
|
146
|
-
return serializeDict(buildMeta(uid, transferSyntax), dataset);
|
|
147
|
-
}
|
|
148
|
-
function buildLargeCtBuffer(uid, rows, columns, frames, tags, transferSyntax) {
|
|
149
|
-
const dataset = buildBaseCtDataset(uid);
|
|
150
|
-
dataset.Rows = rows;
|
|
151
|
-
dataset.Columns = columns;
|
|
152
|
-
dataset.NumberOfFrames = frames;
|
|
153
|
-
dataset.PixelData = new Uint8Array(rows * columns * frames * 2).buffer;
|
|
154
|
-
applyTagOverrides(dataset, tags);
|
|
155
|
-
return serializeDict(buildMeta(uid, transferSyntax), dataset);
|
|
186
|
+
} : uid;
|
|
187
|
+
const dataset = buildBaseImageDataset(effectiveUid, modality);
|
|
188
|
+
if (spec.type === "vendor-warnings-image") {
|
|
189
|
+
dataset.Laterality = "";
|
|
190
|
+
dataset.PatientWeight = "0";
|
|
191
|
+
}
|
|
192
|
+
applyTagOverrides(dataset, spec.tags);
|
|
193
|
+
const meta = buildMeta(effectiveUid, modality, spec.transferSyntax);
|
|
194
|
+
applySizing(dataset, meta, spec);
|
|
195
|
+
return serializeDict(meta, dataset);
|
|
156
196
|
}
|
|
157
197
|
function buildFakeSignatureBuffer() {
|
|
158
198
|
const buf = Buffer.alloc(200, 0);
|
|
@@ -164,21 +204,10 @@ function buildNonDicomBuffer(content = "not dicom") {
|
|
|
164
204
|
}
|
|
165
205
|
function buildBufferForSpec(spec, uid) {
|
|
166
206
|
switch (spec.type) {
|
|
167
|
-
case "valid-
|
|
168
|
-
|
|
169
|
-
case "
|
|
170
|
-
return
|
|
171
|
-
case "vendor-warnings-ct":
|
|
172
|
-
return buildVendorCtBuffer(uid, spec.tags, spec.transferSyntax);
|
|
173
|
-
case "large-ct":
|
|
174
|
-
return buildLargeCtBuffer(
|
|
175
|
-
uid,
|
|
176
|
-
spec.rows,
|
|
177
|
-
spec.columns,
|
|
178
|
-
spec.frames ?? 1,
|
|
179
|
-
spec.tags,
|
|
180
|
-
spec.transferSyntax
|
|
181
|
-
);
|
|
207
|
+
case "valid-image":
|
|
208
|
+
case "invalid-uid-image":
|
|
209
|
+
case "vendor-warnings-image":
|
|
210
|
+
return buildImageBuffer(spec, uid);
|
|
182
211
|
case "fake-signature":
|
|
183
212
|
return buildFakeSignatureBuffer();
|
|
184
213
|
case "non-dicom":
|
package/dist/types/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { type CollectionManifest, type GeneratedFile, generateCollectionFromSpec, generateFile, writeCollectionFromSpec, } from './collection/writer.js';
|
|
2
2
|
export { defaultPublicCasesPath, loadCaseById, loadCasesFromJson, loadDefaultCases, type PublicCaseRecord, type PublicCaseSource, } from './public-fixtures/catalog.js';
|
|
3
3
|
export { caseCachePath, fetchPublicCaseToCache, verifySha256, } from './public-fixtures/fetch.js';
|
|
4
|
-
export type { DatasetLayout, DatasetSpec, DicomdirSpec, DicomTagOverrides, FakeSignatureSpec, FileSpec, ImageSpec,
|
|
4
|
+
export type { DatasetLayout, DatasetSpec, DicomdirSpec, DicomTagOverrides, FakeSignatureSpec, FileSpec, ImageSpec, InvalidUidImageSpec, Modality, NonDicomSpec, SeriesEntrySpec, SeriesSpec, StudySpec, TransferSyntax, ValidImageSpec, VendorWarningsImageSpec, ViolationClass, } from './schema/types.js';
|
|
5
5
|
export { validateDatasetSpec } from './schema/validate.js';
|
|
@@ -7,25 +7,25 @@ export type DicomTagOverrides = {
|
|
|
7
7
|
[tag: string]: unknown;
|
|
8
8
|
};
|
|
9
9
|
export type ViolationClass = 'uid-too-long' | 'non-conformant-uid' | 'missing-meta-header' | 'malformed-sq-delimiter' | 'vr-max-length-exceeded' | 'missing-type1-tag';
|
|
10
|
-
type
|
|
10
|
+
export type Modality = 'CT' | 'PT' | 'MR' | 'CR';
|
|
11
|
+
type BaseImageSpec = {
|
|
12
|
+
modality?: Modality;
|
|
13
|
+
rows?: number;
|
|
14
|
+
columns?: number;
|
|
15
|
+
frames?: number;
|
|
16
|
+
targetSizeKb?: number;
|
|
11
17
|
tags?: DicomTagOverrides;
|
|
12
18
|
transferSyntax?: TransferSyntax;
|
|
13
19
|
violations?: ViolationClass[];
|
|
14
20
|
};
|
|
15
|
-
export type
|
|
16
|
-
type: 'valid-
|
|
17
|
-
};
|
|
18
|
-
export type InvalidUidCtSpec = BaseCtSpec & {
|
|
19
|
-
type: 'invalid-uid-ct';
|
|
21
|
+
export type ValidImageSpec = BaseImageSpec & {
|
|
22
|
+
type: 'valid-image';
|
|
20
23
|
};
|
|
21
|
-
export type
|
|
22
|
-
type: '
|
|
24
|
+
export type InvalidUidImageSpec = BaseImageSpec & {
|
|
25
|
+
type: 'invalid-uid-image';
|
|
23
26
|
};
|
|
24
|
-
export type
|
|
25
|
-
type: '
|
|
26
|
-
rows: number;
|
|
27
|
-
columns: number;
|
|
28
|
-
frames?: number;
|
|
27
|
+
export type VendorWarningsImageSpec = BaseImageSpec & {
|
|
28
|
+
type: 'vendor-warnings-image';
|
|
29
29
|
};
|
|
30
30
|
export type FakeSignatureSpec = {
|
|
31
31
|
type: 'fake-signature';
|
|
@@ -37,7 +37,7 @@ export type NonDicomSpec = {
|
|
|
37
37
|
export type DicomdirSpec = {
|
|
38
38
|
type: 'dicomdir';
|
|
39
39
|
};
|
|
40
|
-
export type ImageSpec =
|
|
40
|
+
export type ImageSpec = ValidImageSpec | InvalidUidImageSpec | VendorWarningsImageSpec;
|
|
41
41
|
export type FileSpec = ImageSpec | FakeSignatureSpec | NonDicomSpec | DicomdirSpec;
|
|
42
42
|
export type EntrySpec = FileSpec & {
|
|
43
43
|
count?: number;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dicom-synth",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.0",
|
|
4
4
|
"description": "Toolkit for synthetic DICOM fixtures and public fixture fetch/cache.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -90,6 +90,6 @@
|
|
|
90
90
|
"semantic-release": "^25.0.3",
|
|
91
91
|
"tsx": "^4.19.3",
|
|
92
92
|
"typescript": "^5.8.3",
|
|
93
|
-
"vitest": "^3.2.
|
|
93
|
+
"vitest": "^3.2.6"
|
|
94
94
|
}
|
|
95
95
|
}
|