dicom-synth 1.5.0 → 1.7.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 +77 -1
- package/bin/dicom-synth-describe.mjs +73 -0
- package/dist/esm/collection/writer.js +61 -13
- package/dist/esm/describe/describe.js +393 -0
- package/dist/esm/index.js +230 -24
- package/dist/esm/schema/validate.js +16 -1
- package/dist/types/describe/describe.d.ts +13 -0
- package/dist/types/index.d.ts +2 -1
- package/dist/types/schema/types.d.ts +2 -0
- package/package.json +4 -3
package/README.md
CHANGED
|
@@ -287,6 +287,33 @@ import type { StudySpec, SeriesSpec, SeriesEntrySpec } from 'dicom-synth'
|
|
|
287
287
|
|
|
288
288
|
`GeneratedFile.relativePath` carries the layout-aware path for in-process consumers; `filename` is always the basename.
|
|
289
289
|
|
|
290
|
+
### Path quirks (`pathQuirks`)
|
|
291
|
+
|
|
292
|
+
Decorate written paths with edge-case names to exercise consumer file handling (e.g. the Chrome File System Access API). Quirks apply to the relative paths the layout already produces — DICOM byte content is unchanged.
|
|
293
|
+
|
|
294
|
+
```json
|
|
295
|
+
{
|
|
296
|
+
"studies": [{ "series": [{ "entries": [{ "type": "valid-image", "count": 3 }] }] }],
|
|
297
|
+
"layout": "hierarchical",
|
|
298
|
+
"pathQuirks": ["trailing-dot", "unicode"]
|
|
299
|
+
}
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
| Quirk | Effect |
|
|
303
|
+
|---|---|
|
|
304
|
+
| `trailing-dot` | Appends `.` to each directory component (`study-001./series-001./…`); on flat output, to the filename |
|
|
305
|
+
| `unicode` | Inserts a non-ASCII character into directory and file names |
|
|
306
|
+
| `deep-nesting` | Injects extra nested directory levels before the file |
|
|
307
|
+
| `long-name` | Pads the filename stem and the deepest directory component toward the 255-byte limit |
|
|
308
|
+
|
|
309
|
+
Decorations are stable per path component, so a series' files still share one directory. Quirks compose; the result is deterministic and independent of the order they're listed in.
|
|
310
|
+
|
|
311
|
+
> **Cross-platform:** these names are writable on POSIX (Linux/macOS, where CI runs) but Windows rewrites or rejects several (trailing dots, reserved names, long paths). The intent is to *feed* a downstream consumer (e.g. a browser), not to round-trip on Windows.
|
|
312
|
+
|
|
313
|
+
```ts
|
|
314
|
+
import type { PathQuirk } from 'dicom-synth'
|
|
315
|
+
```
|
|
316
|
+
|
|
290
317
|
### Tag overrides (`tags`)
|
|
291
318
|
|
|
292
319
|
Available on all image types.
|
|
@@ -417,6 +444,35 @@ See `examples/parametric.json` for a JSON example and `examples/convert-data-sha
|
|
|
417
444
|
|
|
418
445
|
---
|
|
419
446
|
|
|
447
|
+
## Describe tool
|
|
448
|
+
|
|
449
|
+
`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.
|
|
450
|
+
|
|
451
|
+
```ts
|
|
452
|
+
import { describeDirectory, writeCollectionFromSpec } from 'dicom-synth'
|
|
453
|
+
|
|
454
|
+
const { spec, stats } = describeDirectory('./real-dataset')
|
|
455
|
+
// stats: { dicomFiles, skipped, unknownModality }
|
|
456
|
+
await writeCollectionFromSpec(spec, './reproduced')
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
- **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.
|
|
460
|
+
- **Opt-in tag preservation** — `keepTags` copies the named tags verbatim (some datasets are valuable for testing *precisely because* of their problematic tags):
|
|
461
|
+
|
|
462
|
+
```ts
|
|
463
|
+
describeDirectory('./real-dataset', { keepTags: ['Manufacturer', '00080060'] })
|
|
464
|
+
```
|
|
465
|
+
|
|
466
|
+
⚠️ 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.
|
|
467
|
+
- 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.
|
|
468
|
+
- 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.
|
|
469
|
+
|
|
470
|
+
```ts
|
|
471
|
+
import type { DescribeOptions, DescribeResult } from 'dicom-synth'
|
|
472
|
+
```
|
|
473
|
+
|
|
474
|
+
---
|
|
475
|
+
|
|
420
476
|
## CLI
|
|
421
477
|
|
|
422
478
|
```bash
|
|
@@ -455,6 +511,24 @@ dicom-synth-generate --schema examples/default.json --out /tmp/out
|
|
|
455
511
|
|
|
456
512
|
Schema validation errors exit with code 1 and a descriptive message.
|
|
457
513
|
|
|
514
|
+
### Describe an existing tree
|
|
515
|
+
|
|
516
|
+
```bash
|
|
517
|
+
# Shape only (PHI-safe) — prints the DatasetSpec to stdout
|
|
518
|
+
dicom-synth-describe ./real-dataset
|
|
519
|
+
|
|
520
|
+
# Save to a file, and keep selected tags (caller owns any PHI in them)
|
|
521
|
+
dicom-synth-describe ./real-dataset --out spec.json --keep-tags Manufacturer,00080060
|
|
522
|
+
```
|
|
523
|
+
|
|
524
|
+
| Flag | Effect |
|
|
525
|
+
|---|---|
|
|
526
|
+
| `<dir>` | DICOM tree to scan (required) |
|
|
527
|
+
| `--out <path>` | Write the `DatasetSpec` JSON (default: stdout) |
|
|
528
|
+
| `--keep-tags <a,b,...>` | Copy the named tags (keywords or 8-hex) verbatim; omit for shape only |
|
|
529
|
+
|
|
530
|
+
Scan stats (DICOM/skipped/unsupported-modality counts) are written to stderr.
|
|
531
|
+
|
|
458
532
|
---
|
|
459
533
|
|
|
460
534
|
## Public fixtures
|
|
@@ -522,6 +596,7 @@ pnpm fetch:public-case -- pydicom-CT-small
|
|
|
522
596
|
| `src/schema/types.ts` | All public TypeScript types (`FileSpec`, `DatasetSpec`, `ViolationClass`, etc.) |
|
|
523
597
|
| `src/schema/validate.ts` | `validateDatasetSpec` / `validateParametricSpec` — validate raw JSON values against the schema |
|
|
524
598
|
| `src/schema/parametric.ts` | `resolveParametricSpec` — seeded `ParametricSpec` → `DatasetSpec` resolution |
|
|
599
|
+
| `src/describe/describe.ts` | `describeDirectory` — scan a DICOM tree → `DatasetSpec` |
|
|
525
600
|
| `src/syntheticFixtures/generator.ts` | Internal buffer builders keyed on `FileSpec` type |
|
|
526
601
|
| `src/syntheticFixtures/uid.ts` | Seeded and random UID generation |
|
|
527
602
|
| `src/syntheticFixtures/violations.ts` | Post-processing violation injection |
|
|
@@ -530,6 +605,7 @@ pnpm fetch:public-case -- pydicom-CT-small
|
|
|
530
605
|
| `src/public-fixtures/fetch.ts` | Fetch, SHA-256 verify, content-addressed cache |
|
|
531
606
|
| `bin/dicom-synth-generate.mjs` | Published generate CLI (requires `pnpm build`) |
|
|
532
607
|
| `bin/dicom-synth-fetch.mjs` | Published fetch CLI (requires `pnpm build`) |
|
|
608
|
+
| `bin/dicom-synth-describe.mjs` | Published describe CLI (requires `pnpm build`) |
|
|
533
609
|
| `examples/default.json` | Default `DatasetSpec` — one each of `valid-image`, `invalid-uid-image`, `vendor-warnings-image` |
|
|
534
610
|
| `examples/parametric.json` | Example `ParametricSpec` for the `--parametric` CLI path |
|
|
535
611
|
| `examples/convert-data-shape.mjs` | Example converter: tree-shape JSON → `DatasetSpec` |
|
|
@@ -558,8 +634,8 @@ Git hooks: see [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
|
558
634
|
|
|
559
635
|
## Future development
|
|
560
636
|
|
|
561
|
-
- **Describe tool** — scan an existing DICOM tree and emit a matching `DatasetSpec` (counts, grouping, sizes, modalities; no PHI)
|
|
562
637
|
- **Dimension edge-case recipes** — documented presets for geometry pathologies (1×65535 strips, high frame counts)
|
|
638
|
+
- **Per-file tag variance templating** — patterned overrides like `"PatientID": "P{index}"` for shape replication
|
|
563
639
|
- **Compressed transfer syntaxes** — JPEG-LS, JPEG 2000, RLE
|
|
564
640
|
- **Private fixture catalogues** — same SHA-256 fetch/cache pattern for credentials-backed sources (e.g. S3)
|
|
565
641
|
|
|
@@ -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
|
+
}
|
|
@@ -419,6 +419,48 @@ async function generateFile(spec, options) {
|
|
|
419
419
|
index
|
|
420
420
|
};
|
|
421
421
|
}
|
|
422
|
+
function hashString(s) {
|
|
423
|
+
let h = 2166136261;
|
|
424
|
+
for (let i = 0; i < s.length; i++) {
|
|
425
|
+
h ^= s.charCodeAt(i);
|
|
426
|
+
h = Math.imul(h, 16777619);
|
|
427
|
+
}
|
|
428
|
+
return h >>> 0;
|
|
429
|
+
}
|
|
430
|
+
var UNICODE_TOKENS = ["\xE9", "\xFC", "\xF1", "\u65E5", "\u2122", "\u03A9"];
|
|
431
|
+
var NEST_DEPTH = 8;
|
|
432
|
+
var LONG_NAME_TARGET = 200;
|
|
433
|
+
function applyPathQuirks(relativePath, quirks) {
|
|
434
|
+
const segments = relativePath.split("/");
|
|
435
|
+
let dirs = segments.slice(0, -1);
|
|
436
|
+
const leaf = segments[segments.length - 1];
|
|
437
|
+
const dot = leaf.lastIndexOf(".");
|
|
438
|
+
let stem = dot > 0 ? leaf.slice(0, dot) : leaf;
|
|
439
|
+
const ext = dot > 0 ? leaf.slice(dot) : "";
|
|
440
|
+
const unicode = (s) => `${s}${UNICODE_TOKENS[hashString(s) % UNICODE_TOKENS.length]}`;
|
|
441
|
+
const has = (q) => quirks.includes(q);
|
|
442
|
+
if (has("deep-nesting")) {
|
|
443
|
+
dirs = [...dirs, ...Array.from({ length: NEST_DEPTH }, (_, i) => `d${i}`)];
|
|
444
|
+
}
|
|
445
|
+
if (has("unicode")) {
|
|
446
|
+
dirs = dirs.map(unicode);
|
|
447
|
+
stem = unicode(stem);
|
|
448
|
+
}
|
|
449
|
+
if (has("long-name")) {
|
|
450
|
+
const pad = (s) => s.length < LONG_NAME_TARGET ? s + "x".repeat(LONG_NAME_TARGET - s.length) : s;
|
|
451
|
+
stem = pad(stem);
|
|
452
|
+
if (dirs.length > 0) {
|
|
453
|
+
const last = dirs.length - 1;
|
|
454
|
+
dirs = dirs.map((d, i) => i === last ? pad(d) : d);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
let newLeaf = stem + ext;
|
|
458
|
+
if (has("trailing-dot")) {
|
|
459
|
+
if (dirs.length > 0) dirs = dirs.map((d) => `${d}.`);
|
|
460
|
+
else newLeaf += ".";
|
|
461
|
+
}
|
|
462
|
+
return { relativePath: [...dirs, newLeaf].join("/"), filename: newLeaf };
|
|
463
|
+
}
|
|
422
464
|
function hierarchicalName(studyOrdinal, seriesIndex, instanceNumber) {
|
|
423
465
|
const pad3 = (n) => String(n).padStart(3, "0");
|
|
424
466
|
const name = `${String(instanceNumber).padStart(5, "0")}.dcm`;
|
|
@@ -433,15 +475,19 @@ async function* generateCollectionFromSpec(spec) {
|
|
|
433
475
|
const totalFiles = entryCount(flatEntries) + studyFileCount(studies);
|
|
434
476
|
const padWidth = String(totalFiles).length;
|
|
435
477
|
const hierarchical = spec.layout === "hierarchical";
|
|
478
|
+
const quirks = spec.pathQuirks ?? [];
|
|
479
|
+
const decorate = (file) => quirks.length === 0 ? file : { ...file, ...applyPathQuirks(file.relativePath, quirks) };
|
|
436
480
|
let globalIndex = 0;
|
|
437
481
|
for (const entry of flatEntries) {
|
|
438
482
|
const { count = 1, ...fileSpec } = entry;
|
|
439
483
|
for (let i = 0; i < count; i++) {
|
|
440
|
-
yield
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
484
|
+
yield decorate(
|
|
485
|
+
await generateFile(fileSpec, {
|
|
486
|
+
index: globalIndex,
|
|
487
|
+
seed: spec.seed,
|
|
488
|
+
padWidth
|
|
489
|
+
})
|
|
490
|
+
);
|
|
445
491
|
globalIndex++;
|
|
446
492
|
}
|
|
447
493
|
}
|
|
@@ -473,14 +519,16 @@ async function* generateCollectionFromSpec(spec) {
|
|
|
473
519
|
uid: { study: studyUid, series: seriesUid }
|
|
474
520
|
}
|
|
475
521
|
);
|
|
476
|
-
yield
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
522
|
+
yield decorate(
|
|
523
|
+
hierarchical ? {
|
|
524
|
+
...file,
|
|
525
|
+
...hierarchicalName(
|
|
526
|
+
studyOrdinal,
|
|
527
|
+
seriesIndex,
|
|
528
|
+
instanceNumber
|
|
529
|
+
)
|
|
530
|
+
} : file
|
|
531
|
+
);
|
|
484
532
|
globalIndex++;
|
|
485
533
|
}
|
|
486
534
|
}
|
|
@@ -0,0 +1,393 @@
|
|
|
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_PATH_QUIRKS = {
|
|
44
|
+
"trailing-dot": true,
|
|
45
|
+
unicode: true,
|
|
46
|
+
"deep-nesting": true,
|
|
47
|
+
"long-name": true
|
|
48
|
+
};
|
|
49
|
+
var VALID_TRANSFER_SYNTAXES = {
|
|
50
|
+
"explicit-vr-little-endian": true,
|
|
51
|
+
"implicit-vr-little-endian": true
|
|
52
|
+
};
|
|
53
|
+
var MAX_PIXEL_BYTES = 512 * 1024 * 1024;
|
|
54
|
+
var VALID_VIOLATIONS = {
|
|
55
|
+
"uid-too-long": true,
|
|
56
|
+
"non-conformant-uid": true,
|
|
57
|
+
"missing-meta-header": true,
|
|
58
|
+
"malformed-sq-delimiter": true,
|
|
59
|
+
"vr-max-length-exceeded": true,
|
|
60
|
+
"missing-type1-tag": true
|
|
61
|
+
};
|
|
62
|
+
var HEX_TAG_RE = /^[0-9a-fA-F]{8}$/;
|
|
63
|
+
var KEYWORD_RE = /^[A-Z][A-Za-z0-9]*$/;
|
|
64
|
+
function validatePositiveInt(value, path) {
|
|
65
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < 1) {
|
|
66
|
+
throw new Error(
|
|
67
|
+
`${path}: must be a positive integer; got ${JSON.stringify(value)}`
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
function validateSeed(value, path) {
|
|
72
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
73
|
+
throw new Error(
|
|
74
|
+
`${path}: must be a finite number; got ${JSON.stringify(value)}`
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
function validateSizeKbLimit(kb, path) {
|
|
79
|
+
if (kb * 1024 > MAX_PIXEL_BYTES) {
|
|
80
|
+
throw new Error(`${path}: exceeds the 512 MB limit`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
function validateEnum(value, valid, path) {
|
|
84
|
+
if (typeof value !== "string" || !Object.hasOwn(valid, value)) {
|
|
85
|
+
throw new Error(
|
|
86
|
+
`${path}: must be one of ${Object.keys(valid).join(", ")}; got ${JSON.stringify(value)}`
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
function validateTagKey(key, path) {
|
|
91
|
+
if (!HEX_TAG_RE.test(key) && !KEYWORD_RE.test(key)) {
|
|
92
|
+
throw new Error(
|
|
93
|
+
`${path}: invalid tag key "${key}" \u2014 must be a DICOM keyword name (e.g. "Modality") or an 8-hex-char tag (e.g. "00080060")`
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
function validateEntry(entry, path) {
|
|
98
|
+
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
|
|
99
|
+
throw new Error(`${path}: must be an object`);
|
|
100
|
+
}
|
|
101
|
+
const e = entry;
|
|
102
|
+
validateEnum(e.type, TYPE_CAPABILITIES, `${path}.type`);
|
|
103
|
+
const type = e.type;
|
|
104
|
+
const caps = TYPE_CAPABILITIES[type];
|
|
105
|
+
if ("count" in e) validatePositiveInt(e.count, `${path}.count`);
|
|
106
|
+
for (const field of [
|
|
107
|
+
"modality",
|
|
108
|
+
"rows",
|
|
109
|
+
"columns",
|
|
110
|
+
"frames",
|
|
111
|
+
"targetSizeKb",
|
|
112
|
+
"tags",
|
|
113
|
+
"violations",
|
|
114
|
+
"transferSyntax"
|
|
115
|
+
]) {
|
|
116
|
+
if (!caps.image && field in e)
|
|
117
|
+
throw new Error(`${path}.${field}: not supported for type "${type}"`);
|
|
118
|
+
}
|
|
119
|
+
if ("transferSyntax" in e) {
|
|
120
|
+
validateEnum(
|
|
121
|
+
e.transferSyntax,
|
|
122
|
+
VALID_TRANSFER_SYNTAXES,
|
|
123
|
+
`${path}.transferSyntax`
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
if ("modality" in e) {
|
|
127
|
+
validateEnum(e.modality, VALID_MODALITIES, `${path}.modality`);
|
|
128
|
+
}
|
|
129
|
+
if ("tags" in e) validateTags(e.tags, `${path}.tags`);
|
|
130
|
+
if ("violations" in e) {
|
|
131
|
+
if (!Array.isArray(e.violations)) {
|
|
132
|
+
throw new Error(`${path}.violations: must be an array`);
|
|
133
|
+
}
|
|
134
|
+
for (const [i, v] of e.violations.entries()) {
|
|
135
|
+
validateEnum(v, VALID_VIOLATIONS, `${path}.violations[${i}]`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
const hasDims = "rows" in e || "columns" in e || "frames" in e;
|
|
139
|
+
if ("targetSizeKb" in e) {
|
|
140
|
+
if (hasDims) {
|
|
141
|
+
throw new Error(
|
|
142
|
+
`${path}: targetSizeKb cannot be combined with rows/columns/frames \u2014 use one sizing mechanism`
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
validatePositiveInt(e.targetSizeKb, `${path}.targetSizeKb`);
|
|
146
|
+
validateSizeKbLimit(e.targetSizeKb, `${path}.targetSizeKb`);
|
|
147
|
+
} else if (hasDims) {
|
|
148
|
+
if (!("rows" in e) || !("columns" in e)) {
|
|
149
|
+
throw new Error(`${path}: rows and columns must be provided together`);
|
|
150
|
+
}
|
|
151
|
+
validatePositiveInt(e.rows, `${path}.rows`);
|
|
152
|
+
validatePositiveInt(e.columns, `${path}.columns`);
|
|
153
|
+
if ("frames" in e) validatePositiveInt(e.frames, `${path}.frames`);
|
|
154
|
+
const rows = e.rows;
|
|
155
|
+
const columns = e.columns;
|
|
156
|
+
const frames = typeof e.frames === "number" ? e.frames : 1;
|
|
157
|
+
if (rows * columns * frames * 2 > MAX_PIXEL_BYTES) {
|
|
158
|
+
throw new Error(
|
|
159
|
+
`${path}: pixel data (${rows}\xD7${columns}\xD7${frames}\xD72 bytes) exceeds the 512 MB limit`
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return e;
|
|
164
|
+
}
|
|
165
|
+
function validateTags(tags, path) {
|
|
166
|
+
if (typeof tags !== "object" || tags === null || Array.isArray(tags)) {
|
|
167
|
+
throw new Error(`${path}: must be an object`);
|
|
168
|
+
}
|
|
169
|
+
for (const key of Object.keys(tags)) {
|
|
170
|
+
validateTagKey(key, path);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
function validateSeries(series, path) {
|
|
174
|
+
if (typeof series !== "object" || series === null || Array.isArray(series)) {
|
|
175
|
+
throw new Error(`${path}: must be an object`);
|
|
176
|
+
}
|
|
177
|
+
const s = series;
|
|
178
|
+
if (!Array.isArray(s.entries) || s.entries.length === 0) {
|
|
179
|
+
throw new Error(`${path}.entries: must be a non-empty array`);
|
|
180
|
+
}
|
|
181
|
+
for (let i = 0; i < s.entries.length; i++) {
|
|
182
|
+
const e = validateEntry(s.entries[i], `${path}.entries[${i}]`);
|
|
183
|
+
if (!TYPE_CAPABILITIES[e.type].image) {
|
|
184
|
+
throw new Error(
|
|
185
|
+
`${path}.entries[${i}].type: "${e.type}" cannot be used inside a series \u2014 only image-generating types can be grouped`
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
if ("tags" in s) validateTags(s.tags, `${path}.tags`);
|
|
190
|
+
return s;
|
|
191
|
+
}
|
|
192
|
+
function validateStudy(study, path) {
|
|
193
|
+
if (typeof study !== "object" || study === null || Array.isArray(study)) {
|
|
194
|
+
throw new Error(`${path}: must be an object`);
|
|
195
|
+
}
|
|
196
|
+
const st = study;
|
|
197
|
+
if (!Array.isArray(st.series) || st.series.length === 0) {
|
|
198
|
+
throw new Error(`${path}.series: must be a non-empty array`);
|
|
199
|
+
}
|
|
200
|
+
for (let i = 0; i < st.series.length; i++) {
|
|
201
|
+
validateSeries(st.series[i], `${path}.series[${i}]`);
|
|
202
|
+
}
|
|
203
|
+
if ("count" in st) validatePositiveInt(st.count, `${path}.count`);
|
|
204
|
+
if ("tags" in st) validateTags(st.tags, `${path}.tags`);
|
|
205
|
+
return st;
|
|
206
|
+
}
|
|
207
|
+
function validateDatasetSpec(raw) {
|
|
208
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
209
|
+
throw new Error("DatasetSpec: must be a JSON object");
|
|
210
|
+
}
|
|
211
|
+
const r = raw;
|
|
212
|
+
if ("entries" in r && !Array.isArray(r.entries)) {
|
|
213
|
+
throw new Error("DatasetSpec.entries: must be an array");
|
|
214
|
+
}
|
|
215
|
+
if ("studies" in r && !Array.isArray(r.studies)) {
|
|
216
|
+
throw new Error("DatasetSpec.studies: must be an array");
|
|
217
|
+
}
|
|
218
|
+
const rawEntries = r.entries ?? [];
|
|
219
|
+
const rawStudies = r.studies ?? [];
|
|
220
|
+
if (rawEntries.length === 0 && rawStudies.length === 0) {
|
|
221
|
+
throw new Error(
|
|
222
|
+
'DatasetSpec: must contain at least one of "entries" or "studies" (non-empty)'
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
const entries = rawEntries.map(
|
|
226
|
+
(entry, i) => validateEntry(entry, `entries[${i}]`)
|
|
227
|
+
);
|
|
228
|
+
const studies = rawStudies.map(
|
|
229
|
+
(study, i) => validateStudy(study, `studies[${i}]`)
|
|
230
|
+
);
|
|
231
|
+
if ("seed" in r) validateSeed(r.seed, "DatasetSpec.seed");
|
|
232
|
+
if ("layout" in r) {
|
|
233
|
+
validateEnum(r.layout, VALID_LAYOUTS, "DatasetSpec.layout");
|
|
234
|
+
}
|
|
235
|
+
if ("pathQuirks" in r) {
|
|
236
|
+
if (!Array.isArray(r.pathQuirks)) {
|
|
237
|
+
throw new Error("DatasetSpec.pathQuirks: must be an array");
|
|
238
|
+
}
|
|
239
|
+
for (const [i, q] of r.pathQuirks.entries()) {
|
|
240
|
+
validateEnum(q, VALID_PATH_QUIRKS, `DatasetSpec.pathQuirks[${i}]`);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return {
|
|
244
|
+
...entries.length > 0 ? { entries } : {},
|
|
245
|
+
...studies.length > 0 ? { studies } : {},
|
|
246
|
+
...r.seed !== void 0 ? { seed: r.seed } : {},
|
|
247
|
+
...r.layout !== void 0 ? { layout: r.layout } : {},
|
|
248
|
+
...r.pathQuirks !== void 0 ? { pathQuirks: r.pathQuirks } : {}
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// src/describe/describe.ts
|
|
253
|
+
var dcmjsAny = dcmjs;
|
|
254
|
+
var HEX_TAG_RE2 = /^[0-9a-fA-F]{8}$/;
|
|
255
|
+
var SUPPORTED_MODALITIES = /* @__PURE__ */ new Set(["CT", "PT", "MR", "CR"]);
|
|
256
|
+
function buildHexToKeyword() {
|
|
257
|
+
const map = /* @__PURE__ */ new Map();
|
|
258
|
+
const nm = dcmjsAny.data.DicomMetaDictionary.nameMap;
|
|
259
|
+
if (!nm) return map;
|
|
260
|
+
for (const [keyword, info] of Object.entries(nm)) {
|
|
261
|
+
if (info?.tag) {
|
|
262
|
+
const hex = info.tag.replace(/[(,)]/g, "").toUpperCase();
|
|
263
|
+
if (hex.length === 8) map.set(hex, keyword);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return map;
|
|
267
|
+
}
|
|
268
|
+
function resolveKeepTags(keepTags) {
|
|
269
|
+
const nameMap = dcmjsAny.data.DicomMetaDictionary.nameMap;
|
|
270
|
+
let hexToKeyword;
|
|
271
|
+
return keepTags.map((tag) => {
|
|
272
|
+
if (HEX_TAG_RE2.test(tag)) {
|
|
273
|
+
hexToKeyword ?? (hexToKeyword = buildHexToKeyword());
|
|
274
|
+
const keyword = hexToKeyword.get(tag.toUpperCase());
|
|
275
|
+
if (!keyword) {
|
|
276
|
+
throw new Error(`describe: unknown hex tag "${tag}"`);
|
|
277
|
+
}
|
|
278
|
+
return keyword;
|
|
279
|
+
}
|
|
280
|
+
if (nameMap && !Object.hasOwn(nameMap, tag)) {
|
|
281
|
+
throw new Error(`describe: unknown tag keyword "${tag}"`);
|
|
282
|
+
}
|
|
283
|
+
return tag;
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
function* walkFiles(dir) {
|
|
287
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
288
|
+
const path = join(dir, entry.name);
|
|
289
|
+
if (entry.isDirectory()) {
|
|
290
|
+
yield* walkFiles(path);
|
|
291
|
+
} else if (entry.isFile()) {
|
|
292
|
+
yield path;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
function parseFile(path) {
|
|
297
|
+
try {
|
|
298
|
+
const buf = readFileSync(path);
|
|
299
|
+
const ab = buf.buffer.slice(
|
|
300
|
+
buf.byteOffset,
|
|
301
|
+
buf.byteOffset + buf.byteLength
|
|
302
|
+
);
|
|
303
|
+
const parsed = dcmjsAny.data.DicomMessage.readFile(ab);
|
|
304
|
+
const record = dcmjsAny.data.DicomMetaDictionary.naturalizeDataset(
|
|
305
|
+
parsed.dict
|
|
306
|
+
);
|
|
307
|
+
return { record, size: buf.byteLength };
|
|
308
|
+
} catch {
|
|
309
|
+
return null;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
function extractTags(record, keepKeywords) {
|
|
313
|
+
const tags = {};
|
|
314
|
+
let found = false;
|
|
315
|
+
for (const keyword of keepKeywords) {
|
|
316
|
+
const value = record[keyword];
|
|
317
|
+
if (value !== void 0) {
|
|
318
|
+
tags[keyword] = value;
|
|
319
|
+
found = true;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
return found ? tags : void 0;
|
|
323
|
+
}
|
|
324
|
+
function describeDirectory(dir, options = {}) {
|
|
325
|
+
const keepKeywords = options.keepTags ? resolveKeepTags(options.keepTags) : [];
|
|
326
|
+
const studies = /* @__PURE__ */ new Map();
|
|
327
|
+
const stats = { dicomFiles: 0, skipped: 0, unknownModality: 0 };
|
|
328
|
+
for (const path of [...walkFiles(dir)].sort()) {
|
|
329
|
+
const parsed = parseFile(path);
|
|
330
|
+
const studyUid = parsed?.record.StudyInstanceUID;
|
|
331
|
+
const seriesUid = parsed?.record.SeriesInstanceUID;
|
|
332
|
+
if (!parsed || typeof studyUid !== "string" || typeof seriesUid !== "string") {
|
|
333
|
+
stats.skipped++;
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
const { record } = parsed;
|
|
337
|
+
stats.dicomFiles++;
|
|
338
|
+
let modality;
|
|
339
|
+
if (typeof record.Modality === "string") {
|
|
340
|
+
if (SUPPORTED_MODALITIES.has(record.Modality)) {
|
|
341
|
+
modality = record.Modality;
|
|
342
|
+
} else {
|
|
343
|
+
stats.unknownModality++;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
const sizeKb = Math.max(1, Math.round(parsed.size / 1024));
|
|
347
|
+
const tags = keepKeywords.length ? extractTags(record, keepKeywords) : void 0;
|
|
348
|
+
let study = studies.get(studyUid);
|
|
349
|
+
if (!study) {
|
|
350
|
+
study = /* @__PURE__ */ new Map();
|
|
351
|
+
studies.set(studyUid, study);
|
|
352
|
+
}
|
|
353
|
+
let series = study.get(seriesUid);
|
|
354
|
+
if (!series) {
|
|
355
|
+
series = /* @__PURE__ */ new Map();
|
|
356
|
+
study.set(seriesUid, series);
|
|
357
|
+
}
|
|
358
|
+
const collapseKey = JSON.stringify([modality ?? null, sizeKb, tags ?? null]);
|
|
359
|
+
const existing = series.get(collapseKey);
|
|
360
|
+
if (existing) {
|
|
361
|
+
existing.count++;
|
|
362
|
+
} else {
|
|
363
|
+
series.set(collapseKey, {
|
|
364
|
+
modality,
|
|
365
|
+
targetSizeKb: sizeKb,
|
|
366
|
+
tags,
|
|
367
|
+
count: 1
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
const studySpecs = [...studies.values()].map((study) => {
|
|
372
|
+
const seriesSpecs = [...study.values()].map((series) => {
|
|
373
|
+
const entries = [...series.values()].map((acc) => ({
|
|
374
|
+
type: "valid-image",
|
|
375
|
+
...acc.modality !== void 0 ? { modality: acc.modality } : {},
|
|
376
|
+
targetSizeKb: acc.targetSizeKb,
|
|
377
|
+
...acc.tags !== void 0 ? { tags: acc.tags } : {},
|
|
378
|
+
...acc.count > 1 ? { count: acc.count } : {}
|
|
379
|
+
}));
|
|
380
|
+
return { entries };
|
|
381
|
+
});
|
|
382
|
+
return { series: seriesSpecs };
|
|
383
|
+
});
|
|
384
|
+
if (studySpecs.length === 0) {
|
|
385
|
+
throw new Error(`describe: no DICOM series found in ${dir}`);
|
|
386
|
+
}
|
|
387
|
+
const spec = { studies: studySpecs, layout: "hierarchical" };
|
|
388
|
+
validateDatasetSpec(spec);
|
|
389
|
+
return { spec, stats };
|
|
390
|
+
}
|
|
391
|
+
export {
|
|
392
|
+
describeDirectory
|
|
393
|
+
};
|
package/dist/esm/index.js
CHANGED
|
@@ -63,6 +63,12 @@ var VALID_LAYOUTS = {
|
|
|
63
63
|
flat: true,
|
|
64
64
|
hierarchical: true
|
|
65
65
|
};
|
|
66
|
+
var VALID_PATH_QUIRKS = {
|
|
67
|
+
"trailing-dot": true,
|
|
68
|
+
unicode: true,
|
|
69
|
+
"deep-nesting": true,
|
|
70
|
+
"long-name": true
|
|
71
|
+
};
|
|
66
72
|
var VALID_TRANSFER_SYNTAXES = {
|
|
67
73
|
"explicit-vr-little-endian": true,
|
|
68
74
|
"implicit-vr-little-endian": true
|
|
@@ -249,11 +255,20 @@ function validateDatasetSpec(raw) {
|
|
|
249
255
|
if ("layout" in r) {
|
|
250
256
|
validateEnum(r.layout, VALID_LAYOUTS, "DatasetSpec.layout");
|
|
251
257
|
}
|
|
258
|
+
if ("pathQuirks" in r) {
|
|
259
|
+
if (!Array.isArray(r.pathQuirks)) {
|
|
260
|
+
throw new Error("DatasetSpec.pathQuirks: must be an array");
|
|
261
|
+
}
|
|
262
|
+
for (const [i, q] of r.pathQuirks.entries()) {
|
|
263
|
+
validateEnum(q, VALID_PATH_QUIRKS, `DatasetSpec.pathQuirks[${i}]`);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
252
266
|
return {
|
|
253
267
|
...entries.length > 0 ? { entries } : {},
|
|
254
268
|
...studies.length > 0 ? { studies } : {},
|
|
255
269
|
...r.seed !== void 0 ? { seed: r.seed } : {},
|
|
256
|
-
...r.layout !== void 0 ? { layout: r.layout } : {}
|
|
270
|
+
...r.layout !== void 0 ? { layout: r.layout } : {},
|
|
271
|
+
...r.pathQuirks !== void 0 ? { pathQuirks: r.pathQuirks } : {}
|
|
257
272
|
};
|
|
258
273
|
}
|
|
259
274
|
function validateRange(value, path) {
|
|
@@ -720,6 +735,48 @@ async function generateFile(spec, options) {
|
|
|
720
735
|
index
|
|
721
736
|
};
|
|
722
737
|
}
|
|
738
|
+
function hashString(s) {
|
|
739
|
+
let h = 2166136261;
|
|
740
|
+
for (let i = 0; i < s.length; i++) {
|
|
741
|
+
h ^= s.charCodeAt(i);
|
|
742
|
+
h = Math.imul(h, 16777619);
|
|
743
|
+
}
|
|
744
|
+
return h >>> 0;
|
|
745
|
+
}
|
|
746
|
+
var UNICODE_TOKENS = ["\xE9", "\xFC", "\xF1", "\u65E5", "\u2122", "\u03A9"];
|
|
747
|
+
var NEST_DEPTH = 8;
|
|
748
|
+
var LONG_NAME_TARGET = 200;
|
|
749
|
+
function applyPathQuirks(relativePath, quirks) {
|
|
750
|
+
const segments = relativePath.split("/");
|
|
751
|
+
let dirs = segments.slice(0, -1);
|
|
752
|
+
const leaf = segments[segments.length - 1];
|
|
753
|
+
const dot = leaf.lastIndexOf(".");
|
|
754
|
+
let stem = dot > 0 ? leaf.slice(0, dot) : leaf;
|
|
755
|
+
const ext = dot > 0 ? leaf.slice(dot) : "";
|
|
756
|
+
const unicode = (s) => `${s}${UNICODE_TOKENS[hashString(s) % UNICODE_TOKENS.length]}`;
|
|
757
|
+
const has = (q) => quirks.includes(q);
|
|
758
|
+
if (has("deep-nesting")) {
|
|
759
|
+
dirs = [...dirs, ...Array.from({ length: NEST_DEPTH }, (_, i) => `d${i}`)];
|
|
760
|
+
}
|
|
761
|
+
if (has("unicode")) {
|
|
762
|
+
dirs = dirs.map(unicode);
|
|
763
|
+
stem = unicode(stem);
|
|
764
|
+
}
|
|
765
|
+
if (has("long-name")) {
|
|
766
|
+
const pad = (s) => s.length < LONG_NAME_TARGET ? s + "x".repeat(LONG_NAME_TARGET - s.length) : s;
|
|
767
|
+
stem = pad(stem);
|
|
768
|
+
if (dirs.length > 0) {
|
|
769
|
+
const last = dirs.length - 1;
|
|
770
|
+
dirs = dirs.map((d, i) => i === last ? pad(d) : d);
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
let newLeaf = stem + ext;
|
|
774
|
+
if (has("trailing-dot")) {
|
|
775
|
+
if (dirs.length > 0) dirs = dirs.map((d) => `${d}.`);
|
|
776
|
+
else newLeaf += ".";
|
|
777
|
+
}
|
|
778
|
+
return { relativePath: [...dirs, newLeaf].join("/"), filename: newLeaf };
|
|
779
|
+
}
|
|
723
780
|
function hierarchicalName(studyOrdinal, seriesIndex, instanceNumber) {
|
|
724
781
|
const pad3 = (n) => String(n).padStart(3, "0");
|
|
725
782
|
const name = `${String(instanceNumber).padStart(5, "0")}.dcm`;
|
|
@@ -734,15 +791,19 @@ async function* generateCollectionFromSpec(spec) {
|
|
|
734
791
|
const totalFiles = entryCount(flatEntries) + studyFileCount(studies);
|
|
735
792
|
const padWidth = String(totalFiles).length;
|
|
736
793
|
const hierarchical = spec.layout === "hierarchical";
|
|
794
|
+
const quirks = spec.pathQuirks ?? [];
|
|
795
|
+
const decorate = (file) => quirks.length === 0 ? file : { ...file, ...applyPathQuirks(file.relativePath, quirks) };
|
|
737
796
|
let globalIndex = 0;
|
|
738
797
|
for (const entry of flatEntries) {
|
|
739
798
|
const { count = 1, ...fileSpec } = entry;
|
|
740
799
|
for (let i = 0; i < count; i++) {
|
|
741
|
-
yield
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
800
|
+
yield decorate(
|
|
801
|
+
await generateFile(fileSpec, {
|
|
802
|
+
index: globalIndex,
|
|
803
|
+
seed: spec.seed,
|
|
804
|
+
padWidth
|
|
805
|
+
})
|
|
806
|
+
);
|
|
746
807
|
globalIndex++;
|
|
747
808
|
}
|
|
748
809
|
}
|
|
@@ -774,14 +835,16 @@ async function* generateCollectionFromSpec(spec) {
|
|
|
774
835
|
uid: { study: studyUid, series: seriesUid }
|
|
775
836
|
}
|
|
776
837
|
);
|
|
777
|
-
yield
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
838
|
+
yield decorate(
|
|
839
|
+
hierarchical ? {
|
|
840
|
+
...file,
|
|
841
|
+
...hierarchicalName(
|
|
842
|
+
studyOrdinal,
|
|
843
|
+
seriesIndex,
|
|
844
|
+
instanceNumber
|
|
845
|
+
)
|
|
846
|
+
} : file
|
|
847
|
+
);
|
|
785
848
|
globalIndex++;
|
|
786
849
|
}
|
|
787
850
|
}
|
|
@@ -808,11 +871,153 @@ async function writeCollectionFromSpec(spec, outDir) {
|
|
|
808
871
|
return manifest;
|
|
809
872
|
}
|
|
810
873
|
|
|
874
|
+
// src/describe/describe.ts
|
|
875
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
876
|
+
import { join } from "node:path";
|
|
877
|
+
var dcmjsAny2 = dcmjs;
|
|
878
|
+
var HEX_TAG_RE2 = /^[0-9a-fA-F]{8}$/;
|
|
879
|
+
var SUPPORTED_MODALITIES = /* @__PURE__ */ new Set(["CT", "PT", "MR", "CR"]);
|
|
880
|
+
function buildHexToKeyword() {
|
|
881
|
+
const map = /* @__PURE__ */ new Map();
|
|
882
|
+
const nm = dcmjsAny2.data.DicomMetaDictionary.nameMap;
|
|
883
|
+
if (!nm) return map;
|
|
884
|
+
for (const [keyword, info] of Object.entries(nm)) {
|
|
885
|
+
if (info?.tag) {
|
|
886
|
+
const hex = info.tag.replace(/[(,)]/g, "").toUpperCase();
|
|
887
|
+
if (hex.length === 8) map.set(hex, keyword);
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
return map;
|
|
891
|
+
}
|
|
892
|
+
function resolveKeepTags(keepTags) {
|
|
893
|
+
const nameMap = dcmjsAny2.data.DicomMetaDictionary.nameMap;
|
|
894
|
+
let hexToKeyword;
|
|
895
|
+
return keepTags.map((tag) => {
|
|
896
|
+
if (HEX_TAG_RE2.test(tag)) {
|
|
897
|
+
hexToKeyword ?? (hexToKeyword = buildHexToKeyword());
|
|
898
|
+
const keyword = hexToKeyword.get(tag.toUpperCase());
|
|
899
|
+
if (!keyword) {
|
|
900
|
+
throw new Error(`describe: unknown hex tag "${tag}"`);
|
|
901
|
+
}
|
|
902
|
+
return keyword;
|
|
903
|
+
}
|
|
904
|
+
if (nameMap && !Object.hasOwn(nameMap, tag)) {
|
|
905
|
+
throw new Error(`describe: unknown tag keyword "${tag}"`);
|
|
906
|
+
}
|
|
907
|
+
return tag;
|
|
908
|
+
});
|
|
909
|
+
}
|
|
910
|
+
function* walkFiles(dir) {
|
|
911
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
912
|
+
const path = join(dir, entry.name);
|
|
913
|
+
if (entry.isDirectory()) {
|
|
914
|
+
yield* walkFiles(path);
|
|
915
|
+
} else if (entry.isFile()) {
|
|
916
|
+
yield path;
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
function parseFile(path) {
|
|
921
|
+
try {
|
|
922
|
+
const buf = readFileSync(path);
|
|
923
|
+
const ab = buf.buffer.slice(
|
|
924
|
+
buf.byteOffset,
|
|
925
|
+
buf.byteOffset + buf.byteLength
|
|
926
|
+
);
|
|
927
|
+
const parsed = dcmjsAny2.data.DicomMessage.readFile(ab);
|
|
928
|
+
const record = dcmjsAny2.data.DicomMetaDictionary.naturalizeDataset(
|
|
929
|
+
parsed.dict
|
|
930
|
+
);
|
|
931
|
+
return { record, size: buf.byteLength };
|
|
932
|
+
} catch {
|
|
933
|
+
return null;
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
function extractTags(record, keepKeywords) {
|
|
937
|
+
const tags = {};
|
|
938
|
+
let found = false;
|
|
939
|
+
for (const keyword of keepKeywords) {
|
|
940
|
+
const value = record[keyword];
|
|
941
|
+
if (value !== void 0) {
|
|
942
|
+
tags[keyword] = value;
|
|
943
|
+
found = true;
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
return found ? tags : void 0;
|
|
947
|
+
}
|
|
948
|
+
function describeDirectory(dir, options = {}) {
|
|
949
|
+
const keepKeywords = options.keepTags ? resolveKeepTags(options.keepTags) : [];
|
|
950
|
+
const studies = /* @__PURE__ */ new Map();
|
|
951
|
+
const stats = { dicomFiles: 0, skipped: 0, unknownModality: 0 };
|
|
952
|
+
for (const path of [...walkFiles(dir)].sort()) {
|
|
953
|
+
const parsed = parseFile(path);
|
|
954
|
+
const studyUid = parsed?.record.StudyInstanceUID;
|
|
955
|
+
const seriesUid = parsed?.record.SeriesInstanceUID;
|
|
956
|
+
if (!parsed || typeof studyUid !== "string" || typeof seriesUid !== "string") {
|
|
957
|
+
stats.skipped++;
|
|
958
|
+
continue;
|
|
959
|
+
}
|
|
960
|
+
const { record } = parsed;
|
|
961
|
+
stats.dicomFiles++;
|
|
962
|
+
let modality;
|
|
963
|
+
if (typeof record.Modality === "string") {
|
|
964
|
+
if (SUPPORTED_MODALITIES.has(record.Modality)) {
|
|
965
|
+
modality = record.Modality;
|
|
966
|
+
} else {
|
|
967
|
+
stats.unknownModality++;
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
const sizeKb = Math.max(1, Math.round(parsed.size / 1024));
|
|
971
|
+
const tags = keepKeywords.length ? extractTags(record, keepKeywords) : void 0;
|
|
972
|
+
let study = studies.get(studyUid);
|
|
973
|
+
if (!study) {
|
|
974
|
+
study = /* @__PURE__ */ new Map();
|
|
975
|
+
studies.set(studyUid, study);
|
|
976
|
+
}
|
|
977
|
+
let series = study.get(seriesUid);
|
|
978
|
+
if (!series) {
|
|
979
|
+
series = /* @__PURE__ */ new Map();
|
|
980
|
+
study.set(seriesUid, series);
|
|
981
|
+
}
|
|
982
|
+
const collapseKey = JSON.stringify([modality ?? null, sizeKb, tags ?? null]);
|
|
983
|
+
const existing = series.get(collapseKey);
|
|
984
|
+
if (existing) {
|
|
985
|
+
existing.count++;
|
|
986
|
+
} else {
|
|
987
|
+
series.set(collapseKey, {
|
|
988
|
+
modality,
|
|
989
|
+
targetSizeKb: sizeKb,
|
|
990
|
+
tags,
|
|
991
|
+
count: 1
|
|
992
|
+
});
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
const studySpecs = [...studies.values()].map((study) => {
|
|
996
|
+
const seriesSpecs = [...study.values()].map((series) => {
|
|
997
|
+
const entries = [...series.values()].map((acc) => ({
|
|
998
|
+
type: "valid-image",
|
|
999
|
+
...acc.modality !== void 0 ? { modality: acc.modality } : {},
|
|
1000
|
+
targetSizeKb: acc.targetSizeKb,
|
|
1001
|
+
...acc.tags !== void 0 ? { tags: acc.tags } : {},
|
|
1002
|
+
...acc.count > 1 ? { count: acc.count } : {}
|
|
1003
|
+
}));
|
|
1004
|
+
return { entries };
|
|
1005
|
+
});
|
|
1006
|
+
return { series: seriesSpecs };
|
|
1007
|
+
});
|
|
1008
|
+
if (studySpecs.length === 0) {
|
|
1009
|
+
throw new Error(`describe: no DICOM series found in ${dir}`);
|
|
1010
|
+
}
|
|
1011
|
+
const spec = { studies: studySpecs, layout: "hierarchical" };
|
|
1012
|
+
validateDatasetSpec(spec);
|
|
1013
|
+
return { spec, stats };
|
|
1014
|
+
}
|
|
1015
|
+
|
|
811
1016
|
// src/public-fixtures/catalog.ts
|
|
812
|
-
import { readFileSync } from "node:fs";
|
|
813
|
-
import { dirname as dirname2, join } from "node:path";
|
|
1017
|
+
import { readFileSync as readFileSync2 } from "node:fs";
|
|
1018
|
+
import { dirname as dirname2, join as join2 } from "node:path";
|
|
814
1019
|
import { fileURLToPath } from "node:url";
|
|
815
|
-
var bundledCatalogPath =
|
|
1020
|
+
var bundledCatalogPath = join2(
|
|
816
1021
|
dirname2(fileURLToPath(import.meta.url)),
|
|
817
1022
|
"../../data/public-cases.json"
|
|
818
1023
|
);
|
|
@@ -823,7 +1028,7 @@ function loadDefaultCases() {
|
|
|
823
1028
|
return loadCasesFromJson(defaultPublicCasesPath());
|
|
824
1029
|
}
|
|
825
1030
|
function loadCasesFromJson(casesJsonPath) {
|
|
826
|
-
const j = JSON.parse(
|
|
1031
|
+
const j = JSON.parse(readFileSync2(casesJsonPath, "utf8"));
|
|
827
1032
|
if (!Array.isArray(j.cases)) {
|
|
828
1033
|
throw new Error(
|
|
829
1034
|
`Invalid catalog in ${casesJsonPath}: expected "cases" array`
|
|
@@ -841,12 +1046,12 @@ function loadCaseById(casesJsonPath, id) {
|
|
|
841
1046
|
|
|
842
1047
|
// src/public-fixtures/fetch.ts
|
|
843
1048
|
import { createHash } from "node:crypto";
|
|
844
|
-
import { existsSync, mkdirSync as mkdirSync3, readFileSync as
|
|
1049
|
+
import { existsSync, mkdirSync as mkdirSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "node:fs";
|
|
845
1050
|
import { homedir } from "node:os";
|
|
846
|
-
import { join as
|
|
847
|
-
var DEFAULT_CACHE_ROOT =
|
|
1051
|
+
import { join as join3 } from "node:path";
|
|
1052
|
+
var DEFAULT_CACHE_ROOT = join3(homedir(), ".cache", "dicom-synth-testcases");
|
|
848
1053
|
function caseCachePath(sha256, root = DEFAULT_CACHE_ROOT) {
|
|
849
|
-
return
|
|
1054
|
+
return join3(root, sha256, "file.dcm");
|
|
850
1055
|
}
|
|
851
1056
|
function verifySha256(buffer, expected) {
|
|
852
1057
|
const h = createHash("sha256").update(buffer).digest("hex");
|
|
@@ -864,11 +1069,11 @@ async function fetchPublicCaseToCache(record, cacheRoot = DEFAULT_CACHE_ROOT) {
|
|
|
864
1069
|
}
|
|
865
1070
|
const dest = caseCachePath(record.sha256, cacheRoot);
|
|
866
1071
|
if (existsSync(dest)) {
|
|
867
|
-
const buf2 =
|
|
1072
|
+
const buf2 = readFileSync3(dest);
|
|
868
1073
|
verifySha256(buf2, record.sha256);
|
|
869
1074
|
return dest;
|
|
870
1075
|
}
|
|
871
|
-
mkdirSync3(
|
|
1076
|
+
mkdirSync3(join3(cacheRoot, record.sha256), { recursive: true });
|
|
872
1077
|
const res = await fetch(record.source.url);
|
|
873
1078
|
if (!res.ok) {
|
|
874
1079
|
throw new Error(
|
|
@@ -957,6 +1162,7 @@ function resolveParametricSpec(spec) {
|
|
|
957
1162
|
export {
|
|
958
1163
|
caseCachePath,
|
|
959
1164
|
defaultPublicCasesPath,
|
|
1165
|
+
describeDirectory,
|
|
960
1166
|
fetchPublicCaseToCache,
|
|
961
1167
|
generateCollectionFromSpec,
|
|
962
1168
|
generateFile,
|
|
@@ -17,6 +17,12 @@ var VALID_LAYOUTS = {
|
|
|
17
17
|
flat: true,
|
|
18
18
|
hierarchical: true
|
|
19
19
|
};
|
|
20
|
+
var VALID_PATH_QUIRKS = {
|
|
21
|
+
"trailing-dot": true,
|
|
22
|
+
unicode: true,
|
|
23
|
+
"deep-nesting": true,
|
|
24
|
+
"long-name": true
|
|
25
|
+
};
|
|
20
26
|
var VALID_TRANSFER_SYNTAXES = {
|
|
21
27
|
"explicit-vr-little-endian": true,
|
|
22
28
|
"implicit-vr-little-endian": true
|
|
@@ -203,11 +209,20 @@ function validateDatasetSpec(raw) {
|
|
|
203
209
|
if ("layout" in r) {
|
|
204
210
|
validateEnum(r.layout, VALID_LAYOUTS, "DatasetSpec.layout");
|
|
205
211
|
}
|
|
212
|
+
if ("pathQuirks" in r) {
|
|
213
|
+
if (!Array.isArray(r.pathQuirks)) {
|
|
214
|
+
throw new Error("DatasetSpec.pathQuirks: must be an array");
|
|
215
|
+
}
|
|
216
|
+
for (const [i, q] of r.pathQuirks.entries()) {
|
|
217
|
+
validateEnum(q, VALID_PATH_QUIRKS, `DatasetSpec.pathQuirks[${i}]`);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
206
220
|
return {
|
|
207
221
|
...entries.length > 0 ? { entries } : {},
|
|
208
222
|
...studies.length > 0 ? { studies } : {},
|
|
209
223
|
...r.seed !== void 0 ? { seed: r.seed } : {},
|
|
210
|
-
...r.layout !== void 0 ? { layout: r.layout } : {}
|
|
224
|
+
...r.layout !== void 0 ? { layout: r.layout } : {},
|
|
225
|
+
...r.pathQuirks !== void 0 ? { pathQuirks: r.pathQuirks } : {}
|
|
211
226
|
};
|
|
212
227
|
}
|
|
213
228
|
function validateRange(value, path) {
|
|
@@ -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,6 +1,7 @@
|
|
|
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';
|
|
5
|
-
export type { DatasetLayout, DatasetSpec, DicomdirSpec, DicomTagOverrides, FakeSignatureSpec, FileSpec, ImageSpec, InvalidUidImageSpec, Modality, NonDicomSpec, ParametricEdgeCase, ParametricSpec, ParametricStudies, Range, SeriesEntrySpec, SeriesSpec, StudySpec, TransferSyntax, ValidImageSpec, VendorWarningsImageSpec, ViolationClass, } from './schema/types.js';
|
|
6
|
+
export type { DatasetLayout, DatasetSpec, DicomdirSpec, DicomTagOverrides, FakeSignatureSpec, FileSpec, ImageSpec, InvalidUidImageSpec, Modality, NonDicomSpec, ParametricEdgeCase, ParametricSpec, ParametricStudies, PathQuirk, Range, SeriesEntrySpec, SeriesSpec, StudySpec, TransferSyntax, ValidImageSpec, VendorWarningsImageSpec, ViolationClass, } from './schema/types.js';
|
|
6
7
|
export { validateDatasetSpec, validateParametricSpec, } from './schema/validate.js';
|
|
@@ -55,11 +55,13 @@ export type StudySpec = {
|
|
|
55
55
|
count?: number;
|
|
56
56
|
};
|
|
57
57
|
export type DatasetLayout = 'flat' | 'hierarchical';
|
|
58
|
+
export type PathQuirk = 'trailing-dot' | 'unicode' | 'deep-nesting' | 'long-name';
|
|
58
59
|
export type DatasetSpec = {
|
|
59
60
|
entries?: EntrySpec[];
|
|
60
61
|
studies?: StudySpec[];
|
|
61
62
|
seed?: number;
|
|
62
63
|
layout?: DatasetLayout;
|
|
64
|
+
pathQuirks?: PathQuirk[];
|
|
63
65
|
};
|
|
64
66
|
export type Range = number | {
|
|
65
67
|
min: number;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dicom-synth",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.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",
|