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 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-ct' })
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-ct-000.dcm'
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-ct', tags: { PatientID: 'P001' }, violations: ['uid-too-long'] },
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-ct' })
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-ct', count: 10 },
59
- { type: 'invalid-uid-ct', count: 3 },
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-ct', count: 5 }], seed: 42 },
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-ct', count: 3 }], seed: 42 },
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-ct', count: 5 }] })) {
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: EntrySpec[] // one or more entry specs; must not be empty
167
- seed?: number // optional: fixed seed for deterministic UID generation
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-ct", "count": 10 }
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-ct` | Standards-valid CT image; numeric UIDs, complete meta header | strict |
188
- | `invalid-uid-ct` | CT with non-numeric UIDs (letters in UID components) | edge |
189
- | `vendor-warnings-ct` | CT with empty `Laterality` and `PatientWeight = 0` — produces dciodvfy warnings | edge |
190
- | `large-ct` | CT with configurable pixel dimensions; `rows` and `columns` required | strict |
191
+ | `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-ct` fields:**
199
+ **`large-image` fields:**
196
200
 
197
201
  ```json
198
- { "type": "large-ct", "rows": 512, "columns": 512, "frames": 100 }
202
+ { "type": "large-image", "rows": 512, "columns": 512, "frames": 100 }
199
203
  ```
200
204
 
201
- `frames` defaults to 1. A 512×512×100 CT produces a buffer of ~52 MB.
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-ct`, `invalid-uid-ct`, `vendor-warnings-ct`, and `large-ct`.
279
+ Available on `valid-image`, `invalid-uid-image`, `vendor-warnings-image`, and `large-image`.
214
280
 
215
281
  ```json
216
282
  {
217
- "type": "valid-ct",
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-ct`, `invalid-uid-ct`, `vendor-warnings-ct`, and `large-ct`.
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-ct`, `invalid-uid-ct`, `vendor-warnings-ct`, and `large-ct`. Violations are applied as post-processing transforms to an otherwise valid buffer.
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-ct", "violations": ["uid-too-long", "missing-meta-header"] }
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-ct", "count": 10, "tags": { "PatientID": "P001" }, "transferSyntax": "explicit-vr-little-endian" },
281
- { "type": "invalid-uid-ct", "count": 3 },
282
- { "type": "vendor-warnings-ct", "count": 2 },
283
- { "type": "large-ct", "count": 1, "rows": 512, "columns": 512, "frames": 100 },
284
- { "type": "valid-ct", "count": 1, "violations": ["uid-too-long", "missing-meta-header"] },
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
- "seed": 42
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-ct","count":5}]}' --out ./out
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-ct`, `invalid-uid-ct`, and `vendor-warnings-ct`:
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-ct: 1
314
- # invalid-uid-ct: 1
315
- # vendor-warnings-ct: 1
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-ct`, `invalid-uid-ct`, `vendor-warnings-ct` |
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
- - **Multi-series study layouts** — shared `StudyInstanceUID` across files via a `studies` top-level key; no breaking schema change needed
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 CT_SOP_CLASS = "1.2.840.10008.5.1.4.1.1.2";
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: CT_SOP_CLASS,
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 buildBaseCtDataset(uid) {
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: "CT",
100
- SOPClassUID: CT_SOP_CLASS,
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 buildValidCtBuffer(uid, tags, transferSyntax) {
132
- const dataset = buildBaseCtDataset(uid);
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 buildInvalidUidCtBuffer(uid, tags, transferSyntax) {
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 = buildBaseCtDataset(invalidUid);
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 buildVendorCtBuffer(uid, tags, transferSyntax) {
147
- const dataset = buildBaseCtDataset(uid);
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 buildLargeCtBuffer(uid, rows, columns, frames, tags, transferSyntax) {
154
- const dataset = buildBaseCtDataset(uid);
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-ct":
173
- return buildValidCtBuffer(uid, spec.tags, spec.transferSyntax);
174
- case "invalid-uid-ct":
175
- return buildInvalidUidCtBuffer(uid, spec.tags, spec.transferSyntax);
176
- case "vendor-warnings-ct":
177
- return buildVendorCtBuffer(uid, spec.tags, spec.transferSyntax);
178
- case "large-ct":
179
- return buildLargeCtBuffer(
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 = lcg(seed * 999983 + fileIndex * 1000003 >>> 0);
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: filename(spec.type, index, padWidth),
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 totalFiles = spec.entries.reduce((sum, e) => sum + (e.count ?? 1), 0);
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 spec.entries) {
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.filename);
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
  }