dicom-synth 1.5.0 → 1.6.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 +50 -1
- package/bin/dicom-synth-describe.mjs +73 -0
- package/dist/esm/describe/describe.js +378 -0
- package/dist/esm/index.js +153 -10
- package/dist/types/describe/describe.d.ts +13 -0
- package/dist/types/index.d.ts +1 -0
- package/package.json +4 -3
package/README.md
CHANGED
|
@@ -417,6 +417,35 @@ See `examples/parametric.json` for a JSON example and `examples/convert-data-sha
|
|
|
417
417
|
|
|
418
418
|
---
|
|
419
419
|
|
|
420
|
+
## Describe tool
|
|
421
|
+
|
|
422
|
+
`describeDirectory` scans an existing DICOM tree and emits a concrete `DatasetSpec` reproducing its shape — studies/series grouped by their UIDs, per-file size as `targetSizeKb`, and modality. This closes the loop: real tree → spec → regenerate.
|
|
423
|
+
|
|
424
|
+
```ts
|
|
425
|
+
import { describeDirectory, writeCollectionFromSpec } from 'dicom-synth'
|
|
426
|
+
|
|
427
|
+
const { spec, stats } = describeDirectory('./real-dataset')
|
|
428
|
+
// stats: { dicomFiles, skipped, unknownModality }
|
|
429
|
+
await writeCollectionFromSpec(spec, './reproduced')
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
- **Shape only by default** — no tags are copied; the regenerated dataset carries fresh UIDs and synthetic identifiers, so it cannot leak PHI from the source.
|
|
433
|
+
- **Opt-in tag preservation** — `keepTags` copies the named tags verbatim (some datasets are valuable for testing *precisely because* of their problematic tags):
|
|
434
|
+
|
|
435
|
+
```ts
|
|
436
|
+
describeDirectory('./real-dataset', { keepTags: ['Manufacturer', '00080060'] })
|
|
437
|
+
```
|
|
438
|
+
|
|
439
|
+
⚠️ Tags are copied exactly as found. If a kept tag contains PHI, handling that PHI is the **caller's responsibility** — the tool extracts only what you name.
|
|
440
|
+
- Files in a series collapse into one counted entry only when modality, size, and kept tags all match, so tag/size variation is preserved rather than flattened.
|
|
441
|
+
- Non-DICOM, unparseable, or ungroupable files (missing study/series UIDs) are skipped and counted. An unsupported `Modality` is emitted as the default (CT) and counted.
|
|
442
|
+
|
|
443
|
+
```ts
|
|
444
|
+
import type { DescribeOptions, DescribeResult } from 'dicom-synth'
|
|
445
|
+
```
|
|
446
|
+
|
|
447
|
+
---
|
|
448
|
+
|
|
420
449
|
## CLI
|
|
421
450
|
|
|
422
451
|
```bash
|
|
@@ -455,6 +484,24 @@ dicom-synth-generate --schema examples/default.json --out /tmp/out
|
|
|
455
484
|
|
|
456
485
|
Schema validation errors exit with code 1 and a descriptive message.
|
|
457
486
|
|
|
487
|
+
### Describe an existing tree
|
|
488
|
+
|
|
489
|
+
```bash
|
|
490
|
+
# Shape only (PHI-safe) — prints the DatasetSpec to stdout
|
|
491
|
+
dicom-synth-describe ./real-dataset
|
|
492
|
+
|
|
493
|
+
# Save to a file, and keep selected tags (caller owns any PHI in them)
|
|
494
|
+
dicom-synth-describe ./real-dataset --out spec.json --keep-tags Manufacturer,00080060
|
|
495
|
+
```
|
|
496
|
+
|
|
497
|
+
| Flag | Effect |
|
|
498
|
+
|---|---|
|
|
499
|
+
| `<dir>` | DICOM tree to scan (required) |
|
|
500
|
+
| `--out <path>` | Write the `DatasetSpec` JSON (default: stdout) |
|
|
501
|
+
| `--keep-tags <a,b,...>` | Copy the named tags (keywords or 8-hex) verbatim; omit for shape only |
|
|
502
|
+
|
|
503
|
+
Scan stats (DICOM/skipped/unsupported-modality counts) are written to stderr.
|
|
504
|
+
|
|
458
505
|
---
|
|
459
506
|
|
|
460
507
|
## Public fixtures
|
|
@@ -522,6 +569,7 @@ pnpm fetch:public-case -- pydicom-CT-small
|
|
|
522
569
|
| `src/schema/types.ts` | All public TypeScript types (`FileSpec`, `DatasetSpec`, `ViolationClass`, etc.) |
|
|
523
570
|
| `src/schema/validate.ts` | `validateDatasetSpec` / `validateParametricSpec` — validate raw JSON values against the schema |
|
|
524
571
|
| `src/schema/parametric.ts` | `resolveParametricSpec` — seeded `ParametricSpec` → `DatasetSpec` resolution |
|
|
572
|
+
| `src/describe/describe.ts` | `describeDirectory` — scan a DICOM tree → `DatasetSpec` |
|
|
525
573
|
| `src/syntheticFixtures/generator.ts` | Internal buffer builders keyed on `FileSpec` type |
|
|
526
574
|
| `src/syntheticFixtures/uid.ts` | Seeded and random UID generation |
|
|
527
575
|
| `src/syntheticFixtures/violations.ts` | Post-processing violation injection |
|
|
@@ -530,6 +578,7 @@ pnpm fetch:public-case -- pydicom-CT-small
|
|
|
530
578
|
| `src/public-fixtures/fetch.ts` | Fetch, SHA-256 verify, content-addressed cache |
|
|
531
579
|
| `bin/dicom-synth-generate.mjs` | Published generate CLI (requires `pnpm build`) |
|
|
532
580
|
| `bin/dicom-synth-fetch.mjs` | Published fetch CLI (requires `pnpm build`) |
|
|
581
|
+
| `bin/dicom-synth-describe.mjs` | Published describe CLI (requires `pnpm build`) |
|
|
533
582
|
| `examples/default.json` | Default `DatasetSpec` — one each of `valid-image`, `invalid-uid-image`, `vendor-warnings-image` |
|
|
534
583
|
| `examples/parametric.json` | Example `ParametricSpec` for the `--parametric` CLI path |
|
|
535
584
|
| `examples/convert-data-shape.mjs` | Example converter: tree-shape JSON → `DatasetSpec` |
|
|
@@ -558,8 +607,8 @@ Git hooks: see [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
|
558
607
|
|
|
559
608
|
## Future development
|
|
560
609
|
|
|
561
|
-
- **Describe tool** — scan an existing DICOM tree and emit a matching `DatasetSpec` (counts, grouping, sizes, modalities; no PHI)
|
|
562
610
|
- **Dimension edge-case recipes** — documented presets for geometry pathologies (1×65535 strips, high frame counts)
|
|
611
|
+
- **Per-file tag variance templating** — patterned overrides like `"PatientID": "P{index}"` for shape replication
|
|
563
612
|
- **Compressed transfer syntaxes** — JPEG-LS, JPEG 2000, RLE
|
|
564
613
|
- **Private fixture catalogues** — same SHA-256 fetch/cache pattern for credentials-backed sources (e.g. S3)
|
|
565
614
|
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { writeFileSync } from 'node:fs'
|
|
3
|
+
import { resolve } from 'node:path'
|
|
4
|
+
import { describeDirectory } from '../dist/esm/index.js'
|
|
5
|
+
|
|
6
|
+
function usage() {
|
|
7
|
+
console.error(
|
|
8
|
+
'Usage: dicom-synth-describe <dir> [--out <spec.json>] [--keep-tags <a,b,...>]\n' +
|
|
9
|
+
'\n' +
|
|
10
|
+
'Scans a DICOM tree and emits a DatasetSpec describing its shape\n' +
|
|
11
|
+
'(studies/series, per-file size, modality).\n' +
|
|
12
|
+
'\n' +
|
|
13
|
+
'By default only the shape is reproduced — no tags are copied.\n' +
|
|
14
|
+
'--keep-tags copies the named tags (DICOM keywords or 8-hex tags)\n' +
|
|
15
|
+
"verbatim; if a kept tag holds PHI, handling it is the caller's\n" +
|
|
16
|
+
"responsibility, not this tool's.",
|
|
17
|
+
)
|
|
18
|
+
process.exit(1)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const args = process.argv.slice(2)
|
|
22
|
+
if (args.length === 0) usage()
|
|
23
|
+
|
|
24
|
+
let dir
|
|
25
|
+
let outPath
|
|
26
|
+
let keepTags
|
|
27
|
+
|
|
28
|
+
for (let i = 0; i < args.length; i++) {
|
|
29
|
+
if (args[i] === '--out' && args[i + 1]) {
|
|
30
|
+
outPath = resolve(args[++i])
|
|
31
|
+
} else if (args[i] === '--keep-tags' && args[i + 1]) {
|
|
32
|
+
keepTags = args[++i]
|
|
33
|
+
.split(',')
|
|
34
|
+
.map((t) => t.trim())
|
|
35
|
+
.filter(Boolean)
|
|
36
|
+
} else if (args[i].startsWith('--')) {
|
|
37
|
+
console.error(`Unknown argument: ${args[i]}`)
|
|
38
|
+
usage()
|
|
39
|
+
} else if (dir === undefined) {
|
|
40
|
+
dir = resolve(args[i])
|
|
41
|
+
} else {
|
|
42
|
+
console.error(`Unexpected argument: ${args[i]}`)
|
|
43
|
+
usage()
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (dir === undefined) usage()
|
|
48
|
+
|
|
49
|
+
let result
|
|
50
|
+
try {
|
|
51
|
+
result = describeDirectory(dir, keepTags ? { keepTags } : {})
|
|
52
|
+
} catch (err) {
|
|
53
|
+
console.error(`Error: ${err.message}`)
|
|
54
|
+
process.exit(1)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const json = `${JSON.stringify(result.spec, null, 2)}\n`
|
|
58
|
+
if (outPath) {
|
|
59
|
+
writeFileSync(outPath, json)
|
|
60
|
+
console.error(`Wrote DatasetSpec to ${outPath}`)
|
|
61
|
+
} else {
|
|
62
|
+
process.stdout.write(json)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const { dicomFiles, skipped, unknownModality } = result.stats
|
|
66
|
+
console.error(
|
|
67
|
+
`Described ${dicomFiles} DICOM file(s); skipped ${skipped} non-DICOM/ungroupable file(s)`,
|
|
68
|
+
)
|
|
69
|
+
if (unknownModality > 0) {
|
|
70
|
+
console.error(
|
|
71
|
+
` ${unknownModality} file(s) had an unsupported Modality — emitted as default (CT)`,
|
|
72
|
+
)
|
|
73
|
+
}
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
// src/describe/describe.ts
|
|
2
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
// src/loadDcmjs.ts
|
|
6
|
+
import dcmjsDefaultImport, * as dcmjsNamespace from "dcmjs";
|
|
7
|
+
function loadDcmjs() {
|
|
8
|
+
if (dcmjsNamespace.data?.DicomDict) {
|
|
9
|
+
return dcmjsNamespace;
|
|
10
|
+
}
|
|
11
|
+
const d = dcmjsDefaultImport;
|
|
12
|
+
if (d?.data?.DicomDict) {
|
|
13
|
+
return d;
|
|
14
|
+
}
|
|
15
|
+
if (d?.default?.data?.DicomDict) {
|
|
16
|
+
return d.default;
|
|
17
|
+
}
|
|
18
|
+
throw new Error(
|
|
19
|
+
"dcmjs failed to load (missing data.DicomDict). Install dcmjs >= 0.29."
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
var dcmjs = loadDcmjs();
|
|
23
|
+
|
|
24
|
+
// src/schema/validate.ts
|
|
25
|
+
var TYPE_CAPABILITIES = {
|
|
26
|
+
"valid-image": { image: true },
|
|
27
|
+
"invalid-uid-image": { image: true },
|
|
28
|
+
"vendor-warnings-image": { image: true },
|
|
29
|
+
"fake-signature": { image: false },
|
|
30
|
+
"non-dicom": { image: false },
|
|
31
|
+
dicomdir: { image: false }
|
|
32
|
+
};
|
|
33
|
+
var VALID_MODALITIES = {
|
|
34
|
+
CT: true,
|
|
35
|
+
PT: true,
|
|
36
|
+
MR: true,
|
|
37
|
+
CR: true
|
|
38
|
+
};
|
|
39
|
+
var VALID_LAYOUTS = {
|
|
40
|
+
flat: true,
|
|
41
|
+
hierarchical: true
|
|
42
|
+
};
|
|
43
|
+
var VALID_TRANSFER_SYNTAXES = {
|
|
44
|
+
"explicit-vr-little-endian": true,
|
|
45
|
+
"implicit-vr-little-endian": true
|
|
46
|
+
};
|
|
47
|
+
var MAX_PIXEL_BYTES = 512 * 1024 * 1024;
|
|
48
|
+
var VALID_VIOLATIONS = {
|
|
49
|
+
"uid-too-long": true,
|
|
50
|
+
"non-conformant-uid": true,
|
|
51
|
+
"missing-meta-header": true,
|
|
52
|
+
"malformed-sq-delimiter": true,
|
|
53
|
+
"vr-max-length-exceeded": true,
|
|
54
|
+
"missing-type1-tag": true
|
|
55
|
+
};
|
|
56
|
+
var HEX_TAG_RE = /^[0-9a-fA-F]{8}$/;
|
|
57
|
+
var KEYWORD_RE = /^[A-Z][A-Za-z0-9]*$/;
|
|
58
|
+
function validatePositiveInt(value, path) {
|
|
59
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < 1) {
|
|
60
|
+
throw new Error(
|
|
61
|
+
`${path}: must be a positive integer; got ${JSON.stringify(value)}`
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
function validateSeed(value, path) {
|
|
66
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
67
|
+
throw new Error(
|
|
68
|
+
`${path}: must be a finite number; got ${JSON.stringify(value)}`
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
function validateSizeKbLimit(kb, path) {
|
|
73
|
+
if (kb * 1024 > MAX_PIXEL_BYTES) {
|
|
74
|
+
throw new Error(`${path}: exceeds the 512 MB limit`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
function validateEnum(value, valid, path) {
|
|
78
|
+
if (typeof value !== "string" || !Object.hasOwn(valid, value)) {
|
|
79
|
+
throw new Error(
|
|
80
|
+
`${path}: must be one of ${Object.keys(valid).join(", ")}; got ${JSON.stringify(value)}`
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
function validateTagKey(key, path) {
|
|
85
|
+
if (!HEX_TAG_RE.test(key) && !KEYWORD_RE.test(key)) {
|
|
86
|
+
throw new Error(
|
|
87
|
+
`${path}: invalid tag key "${key}" \u2014 must be a DICOM keyword name (e.g. "Modality") or an 8-hex-char tag (e.g. "00080060")`
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
function validateEntry(entry, path) {
|
|
92
|
+
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
|
|
93
|
+
throw new Error(`${path}: must be an object`);
|
|
94
|
+
}
|
|
95
|
+
const e = entry;
|
|
96
|
+
validateEnum(e.type, TYPE_CAPABILITIES, `${path}.type`);
|
|
97
|
+
const type = e.type;
|
|
98
|
+
const caps = TYPE_CAPABILITIES[type];
|
|
99
|
+
if ("count" in e) validatePositiveInt(e.count, `${path}.count`);
|
|
100
|
+
for (const field of [
|
|
101
|
+
"modality",
|
|
102
|
+
"rows",
|
|
103
|
+
"columns",
|
|
104
|
+
"frames",
|
|
105
|
+
"targetSizeKb",
|
|
106
|
+
"tags",
|
|
107
|
+
"violations",
|
|
108
|
+
"transferSyntax"
|
|
109
|
+
]) {
|
|
110
|
+
if (!caps.image && field in e)
|
|
111
|
+
throw new Error(`${path}.${field}: not supported for type "${type}"`);
|
|
112
|
+
}
|
|
113
|
+
if ("transferSyntax" in e) {
|
|
114
|
+
validateEnum(
|
|
115
|
+
e.transferSyntax,
|
|
116
|
+
VALID_TRANSFER_SYNTAXES,
|
|
117
|
+
`${path}.transferSyntax`
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
if ("modality" in e) {
|
|
121
|
+
validateEnum(e.modality, VALID_MODALITIES, `${path}.modality`);
|
|
122
|
+
}
|
|
123
|
+
if ("tags" in e) validateTags(e.tags, `${path}.tags`);
|
|
124
|
+
if ("violations" in e) {
|
|
125
|
+
if (!Array.isArray(e.violations)) {
|
|
126
|
+
throw new Error(`${path}.violations: must be an array`);
|
|
127
|
+
}
|
|
128
|
+
for (const [i, v] of e.violations.entries()) {
|
|
129
|
+
validateEnum(v, VALID_VIOLATIONS, `${path}.violations[${i}]`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
const hasDims = "rows" in e || "columns" in e || "frames" in e;
|
|
133
|
+
if ("targetSizeKb" in e) {
|
|
134
|
+
if (hasDims) {
|
|
135
|
+
throw new Error(
|
|
136
|
+
`${path}: targetSizeKb cannot be combined with rows/columns/frames \u2014 use one sizing mechanism`
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
validatePositiveInt(e.targetSizeKb, `${path}.targetSizeKb`);
|
|
140
|
+
validateSizeKbLimit(e.targetSizeKb, `${path}.targetSizeKb`);
|
|
141
|
+
} else if (hasDims) {
|
|
142
|
+
if (!("rows" in e) || !("columns" in e)) {
|
|
143
|
+
throw new Error(`${path}: rows and columns must be provided together`);
|
|
144
|
+
}
|
|
145
|
+
validatePositiveInt(e.rows, `${path}.rows`);
|
|
146
|
+
validatePositiveInt(e.columns, `${path}.columns`);
|
|
147
|
+
if ("frames" in e) validatePositiveInt(e.frames, `${path}.frames`);
|
|
148
|
+
const rows = e.rows;
|
|
149
|
+
const columns = e.columns;
|
|
150
|
+
const frames = typeof e.frames === "number" ? e.frames : 1;
|
|
151
|
+
if (rows * columns * frames * 2 > MAX_PIXEL_BYTES) {
|
|
152
|
+
throw new Error(
|
|
153
|
+
`${path}: pixel data (${rows}\xD7${columns}\xD7${frames}\xD72 bytes) exceeds the 512 MB limit`
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return e;
|
|
158
|
+
}
|
|
159
|
+
function validateTags(tags, path) {
|
|
160
|
+
if (typeof tags !== "object" || tags === null || Array.isArray(tags)) {
|
|
161
|
+
throw new Error(`${path}: must be an object`);
|
|
162
|
+
}
|
|
163
|
+
for (const key of Object.keys(tags)) {
|
|
164
|
+
validateTagKey(key, path);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
function validateSeries(series, path) {
|
|
168
|
+
if (typeof series !== "object" || series === null || Array.isArray(series)) {
|
|
169
|
+
throw new Error(`${path}: must be an object`);
|
|
170
|
+
}
|
|
171
|
+
const s = series;
|
|
172
|
+
if (!Array.isArray(s.entries) || s.entries.length === 0) {
|
|
173
|
+
throw new Error(`${path}.entries: must be a non-empty array`);
|
|
174
|
+
}
|
|
175
|
+
for (let i = 0; i < s.entries.length; i++) {
|
|
176
|
+
const e = validateEntry(s.entries[i], `${path}.entries[${i}]`);
|
|
177
|
+
if (!TYPE_CAPABILITIES[e.type].image) {
|
|
178
|
+
throw new Error(
|
|
179
|
+
`${path}.entries[${i}].type: "${e.type}" cannot be used inside a series \u2014 only image-generating types can be grouped`
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
if ("tags" in s) validateTags(s.tags, `${path}.tags`);
|
|
184
|
+
return s;
|
|
185
|
+
}
|
|
186
|
+
function validateStudy(study, path) {
|
|
187
|
+
if (typeof study !== "object" || study === null || Array.isArray(study)) {
|
|
188
|
+
throw new Error(`${path}: must be an object`);
|
|
189
|
+
}
|
|
190
|
+
const st = study;
|
|
191
|
+
if (!Array.isArray(st.series) || st.series.length === 0) {
|
|
192
|
+
throw new Error(`${path}.series: must be a non-empty array`);
|
|
193
|
+
}
|
|
194
|
+
for (let i = 0; i < st.series.length; i++) {
|
|
195
|
+
validateSeries(st.series[i], `${path}.series[${i}]`);
|
|
196
|
+
}
|
|
197
|
+
if ("count" in st) validatePositiveInt(st.count, `${path}.count`);
|
|
198
|
+
if ("tags" in st) validateTags(st.tags, `${path}.tags`);
|
|
199
|
+
return st;
|
|
200
|
+
}
|
|
201
|
+
function validateDatasetSpec(raw) {
|
|
202
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
203
|
+
throw new Error("DatasetSpec: must be a JSON object");
|
|
204
|
+
}
|
|
205
|
+
const r = raw;
|
|
206
|
+
if ("entries" in r && !Array.isArray(r.entries)) {
|
|
207
|
+
throw new Error("DatasetSpec.entries: must be an array");
|
|
208
|
+
}
|
|
209
|
+
if ("studies" in r && !Array.isArray(r.studies)) {
|
|
210
|
+
throw new Error("DatasetSpec.studies: must be an array");
|
|
211
|
+
}
|
|
212
|
+
const rawEntries = r.entries ?? [];
|
|
213
|
+
const rawStudies = r.studies ?? [];
|
|
214
|
+
if (rawEntries.length === 0 && rawStudies.length === 0) {
|
|
215
|
+
throw new Error(
|
|
216
|
+
'DatasetSpec: must contain at least one of "entries" or "studies" (non-empty)'
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
const entries = rawEntries.map(
|
|
220
|
+
(entry, i) => validateEntry(entry, `entries[${i}]`)
|
|
221
|
+
);
|
|
222
|
+
const studies = rawStudies.map(
|
|
223
|
+
(study, i) => validateStudy(study, `studies[${i}]`)
|
|
224
|
+
);
|
|
225
|
+
if ("seed" in r) validateSeed(r.seed, "DatasetSpec.seed");
|
|
226
|
+
if ("layout" in r) {
|
|
227
|
+
validateEnum(r.layout, VALID_LAYOUTS, "DatasetSpec.layout");
|
|
228
|
+
}
|
|
229
|
+
return {
|
|
230
|
+
...entries.length > 0 ? { entries } : {},
|
|
231
|
+
...studies.length > 0 ? { studies } : {},
|
|
232
|
+
...r.seed !== void 0 ? { seed: r.seed } : {},
|
|
233
|
+
...r.layout !== void 0 ? { layout: r.layout } : {}
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// src/describe/describe.ts
|
|
238
|
+
var dcmjsAny = dcmjs;
|
|
239
|
+
var HEX_TAG_RE2 = /^[0-9a-fA-F]{8}$/;
|
|
240
|
+
var SUPPORTED_MODALITIES = /* @__PURE__ */ new Set(["CT", "PT", "MR", "CR"]);
|
|
241
|
+
function buildHexToKeyword() {
|
|
242
|
+
const map = /* @__PURE__ */ new Map();
|
|
243
|
+
const nm = dcmjsAny.data.DicomMetaDictionary.nameMap;
|
|
244
|
+
if (!nm) return map;
|
|
245
|
+
for (const [keyword, info] of Object.entries(nm)) {
|
|
246
|
+
if (info?.tag) {
|
|
247
|
+
const hex = info.tag.replace(/[(,)]/g, "").toUpperCase();
|
|
248
|
+
if (hex.length === 8) map.set(hex, keyword);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return map;
|
|
252
|
+
}
|
|
253
|
+
function resolveKeepTags(keepTags) {
|
|
254
|
+
const nameMap = dcmjsAny.data.DicomMetaDictionary.nameMap;
|
|
255
|
+
let hexToKeyword;
|
|
256
|
+
return keepTags.map((tag) => {
|
|
257
|
+
if (HEX_TAG_RE2.test(tag)) {
|
|
258
|
+
hexToKeyword ?? (hexToKeyword = buildHexToKeyword());
|
|
259
|
+
const keyword = hexToKeyword.get(tag.toUpperCase());
|
|
260
|
+
if (!keyword) {
|
|
261
|
+
throw new Error(`describe: unknown hex tag "${tag}"`);
|
|
262
|
+
}
|
|
263
|
+
return keyword;
|
|
264
|
+
}
|
|
265
|
+
if (nameMap && !Object.hasOwn(nameMap, tag)) {
|
|
266
|
+
throw new Error(`describe: unknown tag keyword "${tag}"`);
|
|
267
|
+
}
|
|
268
|
+
return tag;
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
function* walkFiles(dir) {
|
|
272
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
273
|
+
const path = join(dir, entry.name);
|
|
274
|
+
if (entry.isDirectory()) {
|
|
275
|
+
yield* walkFiles(path);
|
|
276
|
+
} else if (entry.isFile()) {
|
|
277
|
+
yield path;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
function parseFile(path) {
|
|
282
|
+
try {
|
|
283
|
+
const buf = readFileSync(path);
|
|
284
|
+
const ab = buf.buffer.slice(
|
|
285
|
+
buf.byteOffset,
|
|
286
|
+
buf.byteOffset + buf.byteLength
|
|
287
|
+
);
|
|
288
|
+
const parsed = dcmjsAny.data.DicomMessage.readFile(ab);
|
|
289
|
+
const record = dcmjsAny.data.DicomMetaDictionary.naturalizeDataset(
|
|
290
|
+
parsed.dict
|
|
291
|
+
);
|
|
292
|
+
return { record, size: buf.byteLength };
|
|
293
|
+
} catch {
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
function extractTags(record, keepKeywords) {
|
|
298
|
+
const tags = {};
|
|
299
|
+
let found = false;
|
|
300
|
+
for (const keyword of keepKeywords) {
|
|
301
|
+
const value = record[keyword];
|
|
302
|
+
if (value !== void 0) {
|
|
303
|
+
tags[keyword] = value;
|
|
304
|
+
found = true;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
return found ? tags : void 0;
|
|
308
|
+
}
|
|
309
|
+
function describeDirectory(dir, options = {}) {
|
|
310
|
+
const keepKeywords = options.keepTags ? resolveKeepTags(options.keepTags) : [];
|
|
311
|
+
const studies = /* @__PURE__ */ new Map();
|
|
312
|
+
const stats = { dicomFiles: 0, skipped: 0, unknownModality: 0 };
|
|
313
|
+
for (const path of [...walkFiles(dir)].sort()) {
|
|
314
|
+
const parsed = parseFile(path);
|
|
315
|
+
const studyUid = parsed?.record.StudyInstanceUID;
|
|
316
|
+
const seriesUid = parsed?.record.SeriesInstanceUID;
|
|
317
|
+
if (!parsed || typeof studyUid !== "string" || typeof seriesUid !== "string") {
|
|
318
|
+
stats.skipped++;
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
const { record } = parsed;
|
|
322
|
+
stats.dicomFiles++;
|
|
323
|
+
let modality;
|
|
324
|
+
if (typeof record.Modality === "string") {
|
|
325
|
+
if (SUPPORTED_MODALITIES.has(record.Modality)) {
|
|
326
|
+
modality = record.Modality;
|
|
327
|
+
} else {
|
|
328
|
+
stats.unknownModality++;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
const sizeKb = Math.max(1, Math.round(parsed.size / 1024));
|
|
332
|
+
const tags = keepKeywords.length ? extractTags(record, keepKeywords) : void 0;
|
|
333
|
+
let study = studies.get(studyUid);
|
|
334
|
+
if (!study) {
|
|
335
|
+
study = /* @__PURE__ */ new Map();
|
|
336
|
+
studies.set(studyUid, study);
|
|
337
|
+
}
|
|
338
|
+
let series = study.get(seriesUid);
|
|
339
|
+
if (!series) {
|
|
340
|
+
series = /* @__PURE__ */ new Map();
|
|
341
|
+
study.set(seriesUid, series);
|
|
342
|
+
}
|
|
343
|
+
const collapseKey = JSON.stringify([modality ?? null, sizeKb, tags ?? null]);
|
|
344
|
+
const existing = series.get(collapseKey);
|
|
345
|
+
if (existing) {
|
|
346
|
+
existing.count++;
|
|
347
|
+
} else {
|
|
348
|
+
series.set(collapseKey, {
|
|
349
|
+
modality,
|
|
350
|
+
targetSizeKb: sizeKb,
|
|
351
|
+
tags,
|
|
352
|
+
count: 1
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
const studySpecs = [...studies.values()].map((study) => {
|
|
357
|
+
const seriesSpecs = [...study.values()].map((series) => {
|
|
358
|
+
const entries = [...series.values()].map((acc) => ({
|
|
359
|
+
type: "valid-image",
|
|
360
|
+
...acc.modality !== void 0 ? { modality: acc.modality } : {},
|
|
361
|
+
targetSizeKb: acc.targetSizeKb,
|
|
362
|
+
...acc.tags !== void 0 ? { tags: acc.tags } : {},
|
|
363
|
+
...acc.count > 1 ? { count: acc.count } : {}
|
|
364
|
+
}));
|
|
365
|
+
return { entries };
|
|
366
|
+
});
|
|
367
|
+
return { series: seriesSpecs };
|
|
368
|
+
});
|
|
369
|
+
if (studySpecs.length === 0) {
|
|
370
|
+
throw new Error(`describe: no DICOM series found in ${dir}`);
|
|
371
|
+
}
|
|
372
|
+
const spec = { studies: studySpecs, layout: "hierarchical" };
|
|
373
|
+
validateDatasetSpec(spec);
|
|
374
|
+
return { spec, stats };
|
|
375
|
+
}
|
|
376
|
+
export {
|
|
377
|
+
describeDirectory
|
|
378
|
+
};
|
package/dist/esm/index.js
CHANGED
|
@@ -808,11 +808,153 @@ async function writeCollectionFromSpec(spec, outDir) {
|
|
|
808
808
|
return manifest;
|
|
809
809
|
}
|
|
810
810
|
|
|
811
|
+
// src/describe/describe.ts
|
|
812
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
813
|
+
import { join } from "node:path";
|
|
814
|
+
var dcmjsAny2 = dcmjs;
|
|
815
|
+
var HEX_TAG_RE2 = /^[0-9a-fA-F]{8}$/;
|
|
816
|
+
var SUPPORTED_MODALITIES = /* @__PURE__ */ new Set(["CT", "PT", "MR", "CR"]);
|
|
817
|
+
function buildHexToKeyword() {
|
|
818
|
+
const map = /* @__PURE__ */ new Map();
|
|
819
|
+
const nm = dcmjsAny2.data.DicomMetaDictionary.nameMap;
|
|
820
|
+
if (!nm) return map;
|
|
821
|
+
for (const [keyword, info] of Object.entries(nm)) {
|
|
822
|
+
if (info?.tag) {
|
|
823
|
+
const hex = info.tag.replace(/[(,)]/g, "").toUpperCase();
|
|
824
|
+
if (hex.length === 8) map.set(hex, keyword);
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
return map;
|
|
828
|
+
}
|
|
829
|
+
function resolveKeepTags(keepTags) {
|
|
830
|
+
const nameMap = dcmjsAny2.data.DicomMetaDictionary.nameMap;
|
|
831
|
+
let hexToKeyword;
|
|
832
|
+
return keepTags.map((tag) => {
|
|
833
|
+
if (HEX_TAG_RE2.test(tag)) {
|
|
834
|
+
hexToKeyword ?? (hexToKeyword = buildHexToKeyword());
|
|
835
|
+
const keyword = hexToKeyword.get(tag.toUpperCase());
|
|
836
|
+
if (!keyword) {
|
|
837
|
+
throw new Error(`describe: unknown hex tag "${tag}"`);
|
|
838
|
+
}
|
|
839
|
+
return keyword;
|
|
840
|
+
}
|
|
841
|
+
if (nameMap && !Object.hasOwn(nameMap, tag)) {
|
|
842
|
+
throw new Error(`describe: unknown tag keyword "${tag}"`);
|
|
843
|
+
}
|
|
844
|
+
return tag;
|
|
845
|
+
});
|
|
846
|
+
}
|
|
847
|
+
function* walkFiles(dir) {
|
|
848
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
849
|
+
const path = join(dir, entry.name);
|
|
850
|
+
if (entry.isDirectory()) {
|
|
851
|
+
yield* walkFiles(path);
|
|
852
|
+
} else if (entry.isFile()) {
|
|
853
|
+
yield path;
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
function parseFile(path) {
|
|
858
|
+
try {
|
|
859
|
+
const buf = readFileSync(path);
|
|
860
|
+
const ab = buf.buffer.slice(
|
|
861
|
+
buf.byteOffset,
|
|
862
|
+
buf.byteOffset + buf.byteLength
|
|
863
|
+
);
|
|
864
|
+
const parsed = dcmjsAny2.data.DicomMessage.readFile(ab);
|
|
865
|
+
const record = dcmjsAny2.data.DicomMetaDictionary.naturalizeDataset(
|
|
866
|
+
parsed.dict
|
|
867
|
+
);
|
|
868
|
+
return { record, size: buf.byteLength };
|
|
869
|
+
} catch {
|
|
870
|
+
return null;
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
function extractTags(record, keepKeywords) {
|
|
874
|
+
const tags = {};
|
|
875
|
+
let found = false;
|
|
876
|
+
for (const keyword of keepKeywords) {
|
|
877
|
+
const value = record[keyword];
|
|
878
|
+
if (value !== void 0) {
|
|
879
|
+
tags[keyword] = value;
|
|
880
|
+
found = true;
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
return found ? tags : void 0;
|
|
884
|
+
}
|
|
885
|
+
function describeDirectory(dir, options = {}) {
|
|
886
|
+
const keepKeywords = options.keepTags ? resolveKeepTags(options.keepTags) : [];
|
|
887
|
+
const studies = /* @__PURE__ */ new Map();
|
|
888
|
+
const stats = { dicomFiles: 0, skipped: 0, unknownModality: 0 };
|
|
889
|
+
for (const path of [...walkFiles(dir)].sort()) {
|
|
890
|
+
const parsed = parseFile(path);
|
|
891
|
+
const studyUid = parsed?.record.StudyInstanceUID;
|
|
892
|
+
const seriesUid = parsed?.record.SeriesInstanceUID;
|
|
893
|
+
if (!parsed || typeof studyUid !== "string" || typeof seriesUid !== "string") {
|
|
894
|
+
stats.skipped++;
|
|
895
|
+
continue;
|
|
896
|
+
}
|
|
897
|
+
const { record } = parsed;
|
|
898
|
+
stats.dicomFiles++;
|
|
899
|
+
let modality;
|
|
900
|
+
if (typeof record.Modality === "string") {
|
|
901
|
+
if (SUPPORTED_MODALITIES.has(record.Modality)) {
|
|
902
|
+
modality = record.Modality;
|
|
903
|
+
} else {
|
|
904
|
+
stats.unknownModality++;
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
const sizeKb = Math.max(1, Math.round(parsed.size / 1024));
|
|
908
|
+
const tags = keepKeywords.length ? extractTags(record, keepKeywords) : void 0;
|
|
909
|
+
let study = studies.get(studyUid);
|
|
910
|
+
if (!study) {
|
|
911
|
+
study = /* @__PURE__ */ new Map();
|
|
912
|
+
studies.set(studyUid, study);
|
|
913
|
+
}
|
|
914
|
+
let series = study.get(seriesUid);
|
|
915
|
+
if (!series) {
|
|
916
|
+
series = /* @__PURE__ */ new Map();
|
|
917
|
+
study.set(seriesUid, series);
|
|
918
|
+
}
|
|
919
|
+
const collapseKey = JSON.stringify([modality ?? null, sizeKb, tags ?? null]);
|
|
920
|
+
const existing = series.get(collapseKey);
|
|
921
|
+
if (existing) {
|
|
922
|
+
existing.count++;
|
|
923
|
+
} else {
|
|
924
|
+
series.set(collapseKey, {
|
|
925
|
+
modality,
|
|
926
|
+
targetSizeKb: sizeKb,
|
|
927
|
+
tags,
|
|
928
|
+
count: 1
|
|
929
|
+
});
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
const studySpecs = [...studies.values()].map((study) => {
|
|
933
|
+
const seriesSpecs = [...study.values()].map((series) => {
|
|
934
|
+
const entries = [...series.values()].map((acc) => ({
|
|
935
|
+
type: "valid-image",
|
|
936
|
+
...acc.modality !== void 0 ? { modality: acc.modality } : {},
|
|
937
|
+
targetSizeKb: acc.targetSizeKb,
|
|
938
|
+
...acc.tags !== void 0 ? { tags: acc.tags } : {},
|
|
939
|
+
...acc.count > 1 ? { count: acc.count } : {}
|
|
940
|
+
}));
|
|
941
|
+
return { entries };
|
|
942
|
+
});
|
|
943
|
+
return { series: seriesSpecs };
|
|
944
|
+
});
|
|
945
|
+
if (studySpecs.length === 0) {
|
|
946
|
+
throw new Error(`describe: no DICOM series found in ${dir}`);
|
|
947
|
+
}
|
|
948
|
+
const spec = { studies: studySpecs, layout: "hierarchical" };
|
|
949
|
+
validateDatasetSpec(spec);
|
|
950
|
+
return { spec, stats };
|
|
951
|
+
}
|
|
952
|
+
|
|
811
953
|
// src/public-fixtures/catalog.ts
|
|
812
|
-
import { readFileSync } from "node:fs";
|
|
813
|
-
import { dirname as dirname2, join } from "node:path";
|
|
954
|
+
import { readFileSync as readFileSync2 } from "node:fs";
|
|
955
|
+
import { dirname as dirname2, join as join2 } from "node:path";
|
|
814
956
|
import { fileURLToPath } from "node:url";
|
|
815
|
-
var bundledCatalogPath =
|
|
957
|
+
var bundledCatalogPath = join2(
|
|
816
958
|
dirname2(fileURLToPath(import.meta.url)),
|
|
817
959
|
"../../data/public-cases.json"
|
|
818
960
|
);
|
|
@@ -823,7 +965,7 @@ function loadDefaultCases() {
|
|
|
823
965
|
return loadCasesFromJson(defaultPublicCasesPath());
|
|
824
966
|
}
|
|
825
967
|
function loadCasesFromJson(casesJsonPath) {
|
|
826
|
-
const j = JSON.parse(
|
|
968
|
+
const j = JSON.parse(readFileSync2(casesJsonPath, "utf8"));
|
|
827
969
|
if (!Array.isArray(j.cases)) {
|
|
828
970
|
throw new Error(
|
|
829
971
|
`Invalid catalog in ${casesJsonPath}: expected "cases" array`
|
|
@@ -841,12 +983,12 @@ function loadCaseById(casesJsonPath, id) {
|
|
|
841
983
|
|
|
842
984
|
// src/public-fixtures/fetch.ts
|
|
843
985
|
import { createHash } from "node:crypto";
|
|
844
|
-
import { existsSync, mkdirSync as mkdirSync3, readFileSync as
|
|
986
|
+
import { existsSync, mkdirSync as mkdirSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "node:fs";
|
|
845
987
|
import { homedir } from "node:os";
|
|
846
|
-
import { join as
|
|
847
|
-
var DEFAULT_CACHE_ROOT =
|
|
988
|
+
import { join as join3 } from "node:path";
|
|
989
|
+
var DEFAULT_CACHE_ROOT = join3(homedir(), ".cache", "dicom-synth-testcases");
|
|
848
990
|
function caseCachePath(sha256, root = DEFAULT_CACHE_ROOT) {
|
|
849
|
-
return
|
|
991
|
+
return join3(root, sha256, "file.dcm");
|
|
850
992
|
}
|
|
851
993
|
function verifySha256(buffer, expected) {
|
|
852
994
|
const h = createHash("sha256").update(buffer).digest("hex");
|
|
@@ -864,11 +1006,11 @@ async function fetchPublicCaseToCache(record, cacheRoot = DEFAULT_CACHE_ROOT) {
|
|
|
864
1006
|
}
|
|
865
1007
|
const dest = caseCachePath(record.sha256, cacheRoot);
|
|
866
1008
|
if (existsSync(dest)) {
|
|
867
|
-
const buf2 =
|
|
1009
|
+
const buf2 = readFileSync3(dest);
|
|
868
1010
|
verifySha256(buf2, record.sha256);
|
|
869
1011
|
return dest;
|
|
870
1012
|
}
|
|
871
|
-
mkdirSync3(
|
|
1013
|
+
mkdirSync3(join3(cacheRoot, record.sha256), { recursive: true });
|
|
872
1014
|
const res = await fetch(record.source.url);
|
|
873
1015
|
if (!res.ok) {
|
|
874
1016
|
throw new Error(
|
|
@@ -957,6 +1099,7 @@ function resolveParametricSpec(spec) {
|
|
|
957
1099
|
export {
|
|
958
1100
|
caseCachePath,
|
|
959
1101
|
defaultPublicCasesPath,
|
|
1102
|
+
describeDirectory,
|
|
960
1103
|
fetchPublicCaseToCache,
|
|
961
1104
|
generateCollectionFromSpec,
|
|
962
1105
|
generateFile,
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { DatasetSpec } from '../schema/types.js';
|
|
2
|
+
export type DescribeOptions = {
|
|
3
|
+
keepTags?: string[];
|
|
4
|
+
};
|
|
5
|
+
export type DescribeResult = {
|
|
6
|
+
spec: DatasetSpec;
|
|
7
|
+
stats: {
|
|
8
|
+
dicomFiles: number;
|
|
9
|
+
skipped: number;
|
|
10
|
+
unknownModality: number;
|
|
11
|
+
};
|
|
12
|
+
};
|
|
13
|
+
export declare function describeDirectory(dir: string, options?: DescribeOptions): DescribeResult;
|
package/dist/types/index.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export { type CollectionManifest, type GeneratedFile, generateCollectionFromSpec, generateFile, writeCollectionFromSpec, } from './collection/writer.js';
|
|
2
|
+
export { type DescribeOptions, type DescribeResult, describeDirectory, } from './describe/describe.js';
|
|
2
3
|
export { defaultPublicCasesPath, loadCaseById, loadCasesFromJson, loadDefaultCases, type PublicCaseRecord, type PublicCaseSource, } from './public-fixtures/catalog.js';
|
|
3
4
|
export { caseCachePath, fetchPublicCaseToCache, verifySha256, } from './public-fixtures/fetch.js';
|
|
4
5
|
export { resolveParametricSpec } from './schema/parametric.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dicom-synth",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.0",
|
|
4
4
|
"description": "Toolkit for synthetic DICOM fixtures and public fixture fetch/cache.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -23,7 +23,8 @@
|
|
|
23
23
|
"types": "dist/types/index.d.ts",
|
|
24
24
|
"bin": {
|
|
25
25
|
"dicom-synth-generate": "./bin/dicom-synth-generate.mjs",
|
|
26
|
-
"dicom-synth-fetch": "./bin/dicom-synth-fetch.mjs"
|
|
26
|
+
"dicom-synth-fetch": "./bin/dicom-synth-fetch.mjs",
|
|
27
|
+
"dicom-synth-describe": "./bin/dicom-synth-describe.mjs"
|
|
27
28
|
},
|
|
28
29
|
"exports": {
|
|
29
30
|
".": {
|
|
@@ -82,7 +83,7 @@
|
|
|
82
83
|
"@typescript-eslint/eslint-plugin": "^8.46.1",
|
|
83
84
|
"@typescript-eslint/parser": "^8.46.1",
|
|
84
85
|
"dcmjs": "^0.51.1",
|
|
85
|
-
"esbuild": "^0.
|
|
86
|
+
"esbuild": "^0.28.1",
|
|
86
87
|
"eslint": "^9.38.0",
|
|
87
88
|
"globals": "^16.4.0",
|
|
88
89
|
"husky": "^9.1.7",
|