dicom-synth 1.4.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 +104 -4
- package/bin/dicom-synth-describe.mjs +73 -0
- package/bin/dicom-synth-generate.mjs +100 -40
- package/dist/esm/describe/describe.js +378 -0
- package/dist/esm/index.js +348 -42
- package/dist/esm/schema/parametric.js +327 -0
- package/dist/esm/schema/validate.js +120 -33
- package/dist/esm/syntheticFixtures/uid.js +4 -1
- package/dist/types/describe/describe.d.ts +13 -0
- package/dist/types/index.d.ts +4 -2
- package/dist/types/schema/parametric.d.ts +2 -0
- package/dist/types/schema/types.d.ts +21 -0
- package/dist/types/schema/validate.d.ts +2 -1
- package/dist/types/syntheticFixtures/uid.d.ts +2 -0
- package/package.json +4 -3
|
@@ -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
|
+
};
|