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 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 = join(
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(readFileSync(casesJsonPath, "utf8"));
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 readFileSync2, writeFileSync as writeFileSync2 } from "node:fs";
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 join2 } from "node:path";
847
- var DEFAULT_CACHE_ROOT = join2(homedir(), ".cache", "dicom-synth-testcases");
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 join2(root, sha256, "file.dcm");
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 = readFileSync2(dest);
1009
+ const buf2 = readFileSync3(dest);
868
1010
  verifySha256(buf2, record.sha256);
869
1011
  return dest;
870
1012
  }
871
- mkdirSync3(join2(cacheRoot, record.sha256), { recursive: true });
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;
@@ -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.5.0",
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.25.9",
86
+ "esbuild": "^0.28.1",
86
87
  "eslint": "^9.38.0",
87
88
  "globals": "^16.4.0",
88
89
  "husky": "^9.1.7",