dicom-synth 1.16.0 → 1.18.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/bin/dicom-synth-describe.mjs +12 -1
- package/dist/esm/collection/writer.js +68 -32
- package/dist/esm/describe/describe.js +247 -22
- package/dist/esm/index.js +312 -54
- package/dist/esm/schema/parametric.js +108 -2
- package/dist/esm/schema/validate.js +110 -2
- package/dist/esm/syntheticFixtures/generator.js +50 -21
- package/dist/esm/syntheticFixtures/streamWrite.js +47 -18
- package/dist/esm/syntheticFixtures/violations.js +9 -3
- package/dist/types/describe/describe.d.ts +12 -1
- package/dist/types/index.d.ts +1 -1
- package/dist/types/schema/types.d.ts +4 -0
- package/dist/types/schema/validate.d.ts +5 -0
- package/dist/types/syntheticFixtures/generator.d.ts +12 -4
- package/dist/types/syntheticFixtures/violations.d.ts +2 -1
- package/package.json +3 -1
|
@@ -6,7 +6,7 @@ import { describeDirectory } from '../dist/esm/index.js'
|
|
|
6
6
|
|
|
7
7
|
function usage() {
|
|
8
8
|
console.error(
|
|
9
|
-
'Usage: dicom-synth-describe <dir> [--out <spec.json[.gz]>] [--tree] [--keep-tags <a,b,...>] [--keep-tags-file <path>] [--size-tolerance <fraction>] [--no-preserve-uids] [--uid-salt <hex>] [--gzip]\n' +
|
|
9
|
+
'Usage: dicom-synth-describe <dir> [--out <spec.json[.gz]>] [--tree] [--keep-tags <a,b,...>] [--keep-tags-file <path>] [--keep-private-tags] [--size-tolerance <fraction>] [--no-preserve-uids] [--uid-salt <hex>] [--gzip]\n' +
|
|
10
10
|
'\n' +
|
|
11
11
|
'Scans a DICOM tree and emits a DatasetSpec describing its shape\n' +
|
|
12
12
|
'(studies/series, per-file size, modality).\n' +
|
|
@@ -22,6 +22,13 @@ function usage() {
|
|
|
22
22
|
"responsibility, not this tool's.\n" +
|
|
23
23
|
'--keep-tags-file reads the same allow-list from a file (one tag per\n' +
|
|
24
24
|
'line, # comments); merged with any --keep-tags.\n' +
|
|
25
|
+
'--keep-private-tags captures ALL private (odd-group) elements as-is as\n' +
|
|
26
|
+
'raw { vr, value } tag forms — creators, scalars, nested private\n' +
|
|
27
|
+
'sequences. Private blocks routinely carry PHI/device detail: handling\n' +
|
|
28
|
+
"that is the caller's responsibility. UID-valued leaves are still\n" +
|
|
29
|
+
'hashed unless --no-preserve-uids. Individual private tags can instead\n' +
|
|
30
|
+
'be named (8-hex) in --keep-tags. Binary/unparseable payloads are\n' +
|
|
31
|
+
'skipped and counted.\n' +
|
|
25
32
|
'--size-tolerance folds near-identical file sizes (fraction, e.g. 0.05\n' +
|
|
26
33
|
'= ±5%) into one entry; default exact.\n' +
|
|
27
34
|
'--no-preserve-uids emits shape only with freshly minted UIDs (no link\n' +
|
|
@@ -52,6 +59,7 @@ let preserveUids = true
|
|
|
52
59
|
let uidSalt
|
|
53
60
|
let gzip = false
|
|
54
61
|
let captureTree = false
|
|
62
|
+
let keepPrivateTags = false
|
|
55
63
|
const keepTags = []
|
|
56
64
|
|
|
57
65
|
for (let i = 0; i < args.length; i++) {
|
|
@@ -77,6 +85,8 @@ for (let i = 0; i < args.length; i++) {
|
|
|
77
85
|
console.error('--size-tolerance must be a non-negative number')
|
|
78
86
|
process.exit(1)
|
|
79
87
|
}
|
|
88
|
+
} else if (args[i] === '--keep-private-tags') {
|
|
89
|
+
keepPrivateTags = true
|
|
80
90
|
} else if (args[i] === '--no-preserve-uids') {
|
|
81
91
|
preserveUids = false
|
|
82
92
|
} else if (args[i] === '--tree') {
|
|
@@ -107,6 +117,7 @@ let result
|
|
|
107
117
|
try {
|
|
108
118
|
result = describeDirectory(dir, {
|
|
109
119
|
...(keepTags.length ? { keepTags } : {}),
|
|
120
|
+
...(keepPrivateTags ? { keepPrivateTags: true } : {}),
|
|
110
121
|
...(sizeTolerance !== undefined ? { sizeTolerance } : {}),
|
|
111
122
|
preserveUids,
|
|
112
123
|
...(uidSalt !== undefined ? { uidSalt } : {}),
|
|
@@ -56,6 +56,9 @@ function isImageType(type) {
|
|
|
56
56
|
return TYPE_CAPABILITIES[type].image;
|
|
57
57
|
}
|
|
58
58
|
var HEX_TAG_RE = /^[0-9a-fA-F]{8}$/;
|
|
59
|
+
function isRawTagValue(value) {
|
|
60
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) && typeof value.vr === "string";
|
|
61
|
+
}
|
|
59
62
|
function positionalName(prefix, ordinal) {
|
|
60
63
|
return `${prefix}-${String(ordinal).padStart(3, "0")}`;
|
|
61
64
|
}
|
|
@@ -148,27 +151,50 @@ function buildTagToKeyword() {
|
|
|
148
151
|
return map;
|
|
149
152
|
}
|
|
150
153
|
var tagToKeyword = buildTagToKeyword();
|
|
154
|
+
function toRawElement(raw) {
|
|
155
|
+
const value = raw.vr === "SQ" ? raw.value.map(
|
|
156
|
+
(item) => Object.fromEntries(
|
|
157
|
+
Object.entries(item).map(([tag, nested]) => [
|
|
158
|
+
tag.toUpperCase(),
|
|
159
|
+
toRawElement(nested)
|
|
160
|
+
])
|
|
161
|
+
)
|
|
162
|
+
) : raw.value;
|
|
163
|
+
return { vr: raw.vr, Value: Array.isArray(value) ? value : [value] };
|
|
164
|
+
}
|
|
151
165
|
function applyTagOverrides(dataset, tags) {
|
|
152
|
-
if (!tags) return;
|
|
166
|
+
if (!tags) return void 0;
|
|
167
|
+
let raw;
|
|
153
168
|
for (const [key, value] of Object.entries(tags)) {
|
|
154
|
-
if (
|
|
169
|
+
if (isRawTagValue(value)) {
|
|
170
|
+
raw ?? (raw = {});
|
|
171
|
+
raw[key.toUpperCase()] = toRawElement(value);
|
|
172
|
+
} else if (HEX_TAG_RE.test(key)) {
|
|
155
173
|
const keyword = tagToKeyword.get(key.toUpperCase());
|
|
156
|
-
if (keyword) {
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
174
|
+
if (!keyword) {
|
|
175
|
+
throw new Error(
|
|
176
|
+
`dicom-synth: unknown hex tag "${key}" \u2014 dictionary tags take plain values; private/unknown tags require the raw { vr, value } form`
|
|
177
|
+
);
|
|
160
178
|
}
|
|
179
|
+
dataset[keyword] = value;
|
|
161
180
|
} else {
|
|
162
181
|
dataset[key] = value;
|
|
163
182
|
}
|
|
164
183
|
}
|
|
184
|
+
return raw;
|
|
165
185
|
}
|
|
166
|
-
function
|
|
167
|
-
|
|
168
|
-
|
|
186
|
+
function rawScalarString(rawElements, tag) {
|
|
187
|
+
const value = rawElements?.[tag]?.Value;
|
|
188
|
+
return value?.length === 1 && typeof value[0] === "string" ? value[0] : void 0;
|
|
189
|
+
}
|
|
190
|
+
function syncMetaWithDataset(meta, dataset, rawElements) {
|
|
191
|
+
const sopInstance = rawScalarString(rawElements, "00080018") ?? (typeof dataset.SOPInstanceUID === "string" ? dataset.SOPInstanceUID : void 0);
|
|
192
|
+
if (sopInstance !== void 0) {
|
|
193
|
+
meta.MediaStorageSOPInstanceUID = sopInstance;
|
|
169
194
|
}
|
|
170
|
-
|
|
171
|
-
|
|
195
|
+
const sopClass = rawScalarString(rawElements, "00080016") ?? (typeof dataset.SOPClassUID === "string" ? dataset.SOPClassUID : void 0);
|
|
196
|
+
if (sopClass !== void 0) {
|
|
197
|
+
meta.MediaStorageSOPClassUID = sopClass;
|
|
172
198
|
}
|
|
173
199
|
}
|
|
174
200
|
function buildMeta(uid, modality, transferSyntax = "explicit-vr-little-endian") {
|
|
@@ -208,7 +234,7 @@ function buildBaseImageDataset(uid, modality) {
|
|
|
208
234
|
...preset.attributes
|
|
209
235
|
};
|
|
210
236
|
}
|
|
211
|
-
function serializeDict(meta, dataset) {
|
|
237
|
+
function serializeDict(meta, dataset, rawElements) {
|
|
212
238
|
const dicomDict = new dcmjs.data.DicomDict(
|
|
213
239
|
dcmjs.data.DicomMetaDictionary.denaturalizeDataset(
|
|
214
240
|
meta
|
|
@@ -217,14 +243,17 @@ function serializeDict(meta, dataset) {
|
|
|
217
243
|
dicomDict.dict = dcmjs.data.DicomMetaDictionary.denaturalizeDataset(
|
|
218
244
|
dataset
|
|
219
245
|
);
|
|
246
|
+
if (rawElements) {
|
|
247
|
+
Object.assign(dicomDict.dict, rawElements);
|
|
248
|
+
}
|
|
220
249
|
return Buffer.from(dicomDict.write({ allowInvalidVRLength: true }));
|
|
221
250
|
}
|
|
222
251
|
var MAX_DIMENSION = 65535;
|
|
223
|
-
function applySizing(dataset, meta, spec) {
|
|
252
|
+
function applySizing(dataset, meta, spec, rawElements) {
|
|
224
253
|
if (spec.targetSizeKb !== void 0) {
|
|
225
254
|
const clampDim = (n) => Math.min(MAX_DIMENSION, Math.max(1, Math.round(n)));
|
|
226
255
|
const minimalPixelBytes = dataset.PixelData.byteLength;
|
|
227
|
-
const overhead = serializeDict(meta, dataset).length - minimalPixelBytes;
|
|
256
|
+
const overhead = serializeDict(meta, dataset, rawElements).length - minimalPixelBytes;
|
|
228
257
|
const cells = Math.max(
|
|
229
258
|
1,
|
|
230
259
|
Math.round((spec.targetSizeKb * 1024 - overhead) / 2)
|
|
@@ -255,11 +284,11 @@ function buildImageBuffer(spec, uid) {
|
|
|
255
284
|
dataset.Laterality = "";
|
|
256
285
|
dataset.PatientWeight = "0";
|
|
257
286
|
}
|
|
258
|
-
applyTagOverrides(dataset, spec.tags);
|
|
287
|
+
const rawElements = applyTagOverrides(dataset, spec.tags);
|
|
259
288
|
const meta = buildMeta(effectiveUid, modality, spec.transferSyntax);
|
|
260
|
-
syncMetaWithDataset(meta, dataset);
|
|
261
|
-
applySizing(dataset, meta, spec);
|
|
262
|
-
return serializeDict(meta, dataset);
|
|
289
|
+
syncMetaWithDataset(meta, dataset, rawElements);
|
|
290
|
+
applySizing(dataset, meta, spec, rawElements);
|
|
291
|
+
return { buffer: serializeDict(meta, dataset, rawElements), rawElements };
|
|
263
292
|
}
|
|
264
293
|
function buildFakeSignatureBuffer() {
|
|
265
294
|
const buf = Buffer.alloc(200, 0);
|
|
@@ -281,11 +310,11 @@ function buildBufferForSpec(spec, uid) {
|
|
|
281
310
|
case "vendor-warnings-image":
|
|
282
311
|
return buildImageBuffer(spec, uid);
|
|
283
312
|
case "fake-signature":
|
|
284
|
-
return buildFakeSignatureBuffer();
|
|
313
|
+
return { buffer: buildFakeSignatureBuffer() };
|
|
285
314
|
case "non-dicom":
|
|
286
|
-
return buildNonDicomBuffer(spec);
|
|
315
|
+
return { buffer: buildNonDicomBuffer(spec) };
|
|
287
316
|
case "dicomdir":
|
|
288
|
-
return buildDicomdirBuffer();
|
|
317
|
+
return { buffer: buildDicomdirBuffer() };
|
|
289
318
|
case "large-image":
|
|
290
319
|
throw new Error(
|
|
291
320
|
"large-image cannot be generated in memory \u2014 use writeCollectionFromSpec (disk-only streaming)"
|
|
@@ -366,12 +395,12 @@ function writeNative(outPath, params, dims, zeroChunk) {
|
|
|
366
395
|
const { modality, uid, tags } = params;
|
|
367
396
|
const meta = buildMeta(uid, modality);
|
|
368
397
|
const dataset = buildBaseImageDataset(uid, modality);
|
|
369
|
-
applyTagOverrides(dataset, tags);
|
|
370
|
-
syncMetaWithDataset(meta, dataset);
|
|
398
|
+
const rawElements = applyTagOverrides(dataset, tags);
|
|
399
|
+
syncMetaWithDataset(meta, dataset, rawElements);
|
|
371
400
|
dataset.Rows = dims.rows;
|
|
372
401
|
dataset.Columns = dims.columns;
|
|
373
402
|
const minimalPixelBytes = dataset.PixelData.byteLength;
|
|
374
|
-
const probe = serializeDict(meta, dataset);
|
|
403
|
+
const probe = serializeDict(meta, dataset, rawElements);
|
|
375
404
|
const pixelElement = Buffer.alloc(12);
|
|
376
405
|
PIXEL_DATA_OW_HEADER.copy(pixelElement);
|
|
377
406
|
pixelElement.writeUInt32LE(minimalPixelBytes, 8);
|
|
@@ -400,11 +429,11 @@ function writeEncapsulated(outPath, params, pixelBytes, fragmentBytes, zeroChunk
|
|
|
400
429
|
TransferSyntaxUID: RLE_TRANSFER_SYNTAX_UID
|
|
401
430
|
};
|
|
402
431
|
const dataset = buildBaseImageDataset(uid, modality);
|
|
403
|
-
applyTagOverrides(dataset, tags);
|
|
404
|
-
syncMetaWithDataset(meta, dataset);
|
|
432
|
+
const rawElements = applyTagOverrides(dataset, tags);
|
|
433
|
+
syncMetaWithDataset(meta, dataset, rawElements);
|
|
405
434
|
dataset.PixelData = [new Uint8Array([]).buffer, new Uint8Array([0, 0]).buffer];
|
|
406
435
|
dataset._vrMap = { PixelData: "OB" };
|
|
407
|
-
const probe = serializeDict(meta, dataset);
|
|
436
|
+
const probe = serializeDict(meta, dataset, rawElements);
|
|
408
437
|
const idx = probe.indexOf(PIXEL_DATA_OB_HEADER);
|
|
409
438
|
if (idx < 0 || probe.readUInt32LE(idx + 8) !== 4294967295) {
|
|
410
439
|
throw new Error("failed to locate encapsulated PixelData element header");
|
|
@@ -501,7 +530,7 @@ var VIOLATION_LEVEL = {
|
|
|
501
530
|
"missing-meta-header": "byte",
|
|
502
531
|
"malformed-sq-delimiter": "byte"
|
|
503
532
|
};
|
|
504
|
-
function applyTagLevel(buffer, violations) {
|
|
533
|
+
function applyTagLevel(buffer, violations, rawElements) {
|
|
505
534
|
if (violations.length === 0) return buffer;
|
|
506
535
|
let parsed;
|
|
507
536
|
try {
|
|
@@ -535,6 +564,9 @@ function applyTagLevel(buffer, violations) {
|
|
|
535
564
|
}
|
|
536
565
|
}
|
|
537
566
|
parsed.dict = dcmjsAny.data.DicomMetaDictionary.denaturalizeDataset(natural);
|
|
567
|
+
if (rawElements) {
|
|
568
|
+
Object.assign(parsed.dict, rawElements);
|
|
569
|
+
}
|
|
538
570
|
if (violations.includes("uid-too-long")) {
|
|
539
571
|
;
|
|
540
572
|
parsed.dict["00080018"] = {
|
|
@@ -567,11 +599,14 @@ function applyByteLevel(buffer, violations) {
|
|
|
567
599
|
}
|
|
568
600
|
return result;
|
|
569
601
|
}
|
|
570
|
-
function applyViolations(buffer, violations) {
|
|
602
|
+
function applyViolations(buffer, violations, rawElements) {
|
|
571
603
|
if (violations.length === 0) return buffer;
|
|
572
604
|
const tagViolations = violations.filter((v) => VIOLATION_LEVEL[v] === "tag");
|
|
573
605
|
const byteViolations = violations.filter((v) => VIOLATION_LEVEL[v] === "byte");
|
|
574
|
-
return applyByteLevel(
|
|
606
|
+
return applyByteLevel(
|
|
607
|
+
applyTagLevel(buffer, tagViolations, rawElements),
|
|
608
|
+
byteViolations
|
|
609
|
+
);
|
|
575
610
|
}
|
|
576
611
|
|
|
577
612
|
// src/collection/writer.ts
|
|
@@ -645,10 +680,11 @@ async function generateFile(spec, options) {
|
|
|
645
680
|
...spec,
|
|
646
681
|
tags: resolveTagTemplates(spec.tags, { index, ...options?.context })
|
|
647
682
|
} : spec;
|
|
648
|
-
|
|
683
|
+
const built = buildBufferForSpec(resolvedSpec, uid);
|
|
684
|
+
let buffer = built.buffer;
|
|
649
685
|
const violations = "violations" in spec ? spec.violations : void 0;
|
|
650
686
|
if (violations?.length) {
|
|
651
|
-
buffer = applyViolations(buffer, violations);
|
|
687
|
+
buffer = applyViolations(buffer, violations, built.rawElements);
|
|
652
688
|
}
|
|
653
689
|
const name = filename(spec.type, index, padWidth);
|
|
654
690
|
return {
|