dicom-synth 1.8.0 → 1.9.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-generate.mjs +90 -5
- package/dist/esm/collection/writer.js +267 -39
- package/dist/esm/describe/describe.js +159 -29
- package/dist/esm/index.js +519 -74
- package/dist/esm/schema/limits.js +7 -0
- package/dist/esm/schema/parametric.js +123 -6
- package/dist/esm/schema/validate.js +93 -6
- package/dist/esm/syntheticFixtures/generator.js +13 -2
- package/dist/esm/syntheticFixtures/streamWrite.js +356 -0
- package/dist/types/collection/writer.d.ts +7 -0
- package/dist/types/describe/describe.d.ts +1 -0
- package/dist/types/index.d.ts +4 -2
- package/dist/types/schema/limits.d.ts +2 -0
- package/dist/types/schema/types.d.ts +10 -2
- package/dist/types/syntheticFixtures/generator.d.ts +5 -1
- package/dist/types/syntheticFixtures/streamWrite.d.ts +49 -0
- package/package.json +1 -1
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
// src/describe/describe.ts
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
closeSync,
|
|
4
|
+
openSync,
|
|
5
|
+
readdirSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
readSync,
|
|
8
|
+
statSync
|
|
9
|
+
} from "node:fs";
|
|
3
10
|
import { join } from "node:path";
|
|
4
11
|
|
|
5
12
|
// src/loadDcmjs.ts
|
|
@@ -21,11 +28,16 @@ function loadDcmjs() {
|
|
|
21
28
|
}
|
|
22
29
|
var dcmjs = loadDcmjs();
|
|
23
30
|
|
|
31
|
+
// src/schema/limits.ts
|
|
32
|
+
var MAX_PIXEL_BYTES = 512 * 1024 * 1024;
|
|
33
|
+
var MAX_STREAM_BYTES = 50 * 1024 * 1024 * 1024;
|
|
34
|
+
|
|
24
35
|
// src/schema/validate.ts
|
|
25
36
|
var TYPE_CAPABILITIES = {
|
|
26
37
|
"valid-image": { image: true },
|
|
27
38
|
"invalid-uid-image": { image: true },
|
|
28
39
|
"vendor-warnings-image": { image: true },
|
|
40
|
+
"large-image": { image: true },
|
|
29
41
|
"fake-signature": { image: false },
|
|
30
42
|
"non-dicom": { image: false },
|
|
31
43
|
dicomdir: { image: false }
|
|
@@ -50,7 +62,6 @@ var VALID_TRANSFER_SYNTAXES = {
|
|
|
50
62
|
"explicit-vr-little-endian": true,
|
|
51
63
|
"implicit-vr-little-endian": true
|
|
52
64
|
};
|
|
53
|
-
var MAX_PIXEL_BYTES = 512 * 1024 * 1024;
|
|
54
65
|
var VALID_VIOLATIONS = {
|
|
55
66
|
"uid-too-long": true,
|
|
56
67
|
"non-conformant-uid": true,
|
|
@@ -80,6 +91,43 @@ function validateSizeKbLimit(kb, path) {
|
|
|
80
91
|
throw new Error(`${path}: exceeds the 512 MB limit`);
|
|
81
92
|
}
|
|
82
93
|
}
|
|
94
|
+
function validateLargeTargetBytes(value, path) {
|
|
95
|
+
if (typeof value !== "number" || !Number.isInteger(value)) {
|
|
96
|
+
throw new Error(`${path}: must be an integer; got ${JSON.stringify(value)}`);
|
|
97
|
+
}
|
|
98
|
+
if (value <= MAX_PIXEL_BYTES) {
|
|
99
|
+
throw new Error(
|
|
100
|
+
`${path}: must exceed 512 MB \u2014 use targetSizeKb for smaller files`
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
if (value > MAX_STREAM_BYTES) {
|
|
104
|
+
throw new Error(`${path}: exceeds the 50 GB limit`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
function validateLargeImageEntry(e, path) {
|
|
108
|
+
for (const field of [
|
|
109
|
+
"rows",
|
|
110
|
+
"columns",
|
|
111
|
+
"frames",
|
|
112
|
+
"targetSizeKb",
|
|
113
|
+
"violations",
|
|
114
|
+
"transferSyntax"
|
|
115
|
+
]) {
|
|
116
|
+
if (field in e) {
|
|
117
|
+
throw new Error(
|
|
118
|
+
`${path}.${field}: not supported for type "large-image" \u2014 use targetBytes`
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (!("targetBytes" in e)) {
|
|
123
|
+
throw new Error(`${path}.targetBytes: is required for type "large-image"`);
|
|
124
|
+
}
|
|
125
|
+
validateLargeTargetBytes(e.targetBytes, `${path}.targetBytes`);
|
|
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
|
+
}
|
|
83
131
|
function validateEnum(value, valid, path) {
|
|
84
132
|
if (typeof value !== "string" || !Object.hasOwn(valid, value)) {
|
|
85
133
|
throw new Error(
|
|
@@ -103,6 +151,15 @@ function validateEntry(entry, path) {
|
|
|
103
151
|
const type = e.type;
|
|
104
152
|
const caps = TYPE_CAPABILITIES[type];
|
|
105
153
|
if ("count" in e) validatePositiveInt(e.count, `${path}.count`);
|
|
154
|
+
if (type === "large-image") {
|
|
155
|
+
validateLargeImageEntry(e, path);
|
|
156
|
+
return e;
|
|
157
|
+
}
|
|
158
|
+
if ("targetBytes" in e) {
|
|
159
|
+
throw new Error(
|
|
160
|
+
`${path}.targetBytes: only supported for type "large-image"`
|
|
161
|
+
);
|
|
162
|
+
}
|
|
106
163
|
for (const field of [
|
|
107
164
|
"modality",
|
|
108
165
|
"rows",
|
|
@@ -293,18 +350,77 @@ function* walkFiles(dir) {
|
|
|
293
350
|
}
|
|
294
351
|
}
|
|
295
352
|
}
|
|
296
|
-
function
|
|
353
|
+
function toArrayBuffer(buf) {
|
|
354
|
+
return buf.buffer.slice(
|
|
355
|
+
buf.byteOffset,
|
|
356
|
+
buf.byteOffset + buf.byteLength
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
function naturalize(ab) {
|
|
360
|
+
const parsed = dcmjsAny.data.DicomMessage.readFile(ab);
|
|
361
|
+
return dcmjsAny.data.DicomMetaDictionary.naturalizeDataset(parsed.dict);
|
|
362
|
+
}
|
|
363
|
+
function parseFull(path) {
|
|
297
364
|
try {
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
365
|
+
return naturalize(toArrayBuffer(readFileSync(path)));
|
|
366
|
+
} catch {
|
|
367
|
+
return null;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
var PIXEL_DATA_OW = Buffer.from([224, 127, 16, 0, 79, 87]);
|
|
371
|
+
var PIXEL_DATA_OB = Buffer.from([224, 127, 16, 0, 79, 66]);
|
|
372
|
+
var EMPTY_OW_PIXEL_DATA = Buffer.from([
|
|
373
|
+
224,
|
|
374
|
+
127,
|
|
375
|
+
16,
|
|
376
|
+
0,
|
|
377
|
+
79,
|
|
378
|
+
87,
|
|
379
|
+
0,
|
|
380
|
+
0,
|
|
381
|
+
0,
|
|
382
|
+
0,
|
|
383
|
+
0,
|
|
384
|
+
0
|
|
385
|
+
]);
|
|
386
|
+
var HEADER_READ_BYTES = 2 * 1024 * 1024;
|
|
387
|
+
function findPixelDataOffset(prefix) {
|
|
388
|
+
let best = -1;
|
|
389
|
+
for (const pattern of [PIXEL_DATA_OW, PIXEL_DATA_OB]) {
|
|
390
|
+
for (let from = 0; ; ) {
|
|
391
|
+
const i = prefix.indexOf(pattern, from);
|
|
392
|
+
if (i < 0) break;
|
|
393
|
+
if (prefix[i + 6] === 0 && prefix[i + 7] === 0) {
|
|
394
|
+
if (best < 0 || i < best) best = i;
|
|
395
|
+
break;
|
|
396
|
+
}
|
|
397
|
+
from = i + 1;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
return best;
|
|
401
|
+
}
|
|
402
|
+
function readHeaderOnly(path) {
|
|
403
|
+
let prefix;
|
|
404
|
+
try {
|
|
405
|
+
const fd = openSync(path, "r");
|
|
406
|
+
try {
|
|
407
|
+
const buf = Buffer.alloc(HEADER_READ_BYTES);
|
|
408
|
+
const n = readSync(fd, buf, 0, HEADER_READ_BYTES, 0);
|
|
409
|
+
prefix = buf.subarray(0, n);
|
|
410
|
+
} finally {
|
|
411
|
+
closeSync(fd);
|
|
412
|
+
}
|
|
413
|
+
} catch {
|
|
414
|
+
return null;
|
|
415
|
+
}
|
|
416
|
+
const tagIdx = findPixelDataOffset(prefix);
|
|
417
|
+
if (tagIdx < 0) return null;
|
|
418
|
+
const headerOnly = Buffer.concat([
|
|
419
|
+
prefix.subarray(0, tagIdx),
|
|
420
|
+
EMPTY_OW_PIXEL_DATA
|
|
421
|
+
]);
|
|
422
|
+
try {
|
|
423
|
+
return naturalize(toArrayBuffer(headerOnly));
|
|
308
424
|
} catch {
|
|
309
425
|
return null;
|
|
310
426
|
}
|
|
@@ -326,14 +442,25 @@ function describeDirectory(dir, options = {}) {
|
|
|
326
442
|
const studies = /* @__PURE__ */ new Map();
|
|
327
443
|
const stats = { dicomFiles: 0, skipped: 0, unknownModality: 0 };
|
|
328
444
|
for (const path of [...walkFiles(dir)].sort()) {
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
445
|
+
let size;
|
|
446
|
+
try {
|
|
447
|
+
size = statSync(path).size;
|
|
448
|
+
} catch {
|
|
449
|
+
stats.skipped++;
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
452
|
+
if (size > MAX_STREAM_BYTES) {
|
|
453
|
+
stats.skipped++;
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
456
|
+
const large = size > MAX_PIXEL_BYTES;
|
|
457
|
+
const record = large ? readHeaderOnly(path) : parseFull(path);
|
|
458
|
+
const studyUid = record?.StudyInstanceUID;
|
|
459
|
+
const seriesUid = record?.SeriesInstanceUID;
|
|
460
|
+
if (!record || typeof studyUid !== "string" || typeof seriesUid !== "string") {
|
|
333
461
|
stats.skipped++;
|
|
334
462
|
continue;
|
|
335
463
|
}
|
|
336
|
-
const { record } = parsed;
|
|
337
464
|
stats.dicomFiles++;
|
|
338
465
|
let modality;
|
|
339
466
|
if (typeof record.Modality === "string") {
|
|
@@ -343,7 +470,7 @@ function describeDirectory(dir, options = {}) {
|
|
|
343
470
|
stats.unknownModality++;
|
|
344
471
|
}
|
|
345
472
|
}
|
|
346
|
-
const sizeKb = Math.max(1, Math.round(
|
|
473
|
+
const sizeKb = Math.max(1, Math.round(size / 1024));
|
|
347
474
|
const tags = keepKeywords.length ? extractTags(record, keepKeywords) : void 0;
|
|
348
475
|
let study = studies.get(studyUid);
|
|
349
476
|
if (!study) {
|
|
@@ -355,25 +482,27 @@ function describeDirectory(dir, options = {}) {
|
|
|
355
482
|
series = /* @__PURE__ */ new Map();
|
|
356
483
|
study.set(seriesUid, series);
|
|
357
484
|
}
|
|
358
|
-
const collapseKey = JSON.stringify([
|
|
485
|
+
const collapseKey = JSON.stringify([
|
|
486
|
+
large,
|
|
487
|
+
modality ?? null,
|
|
488
|
+
large ? size : sizeKb,
|
|
489
|
+
tags ?? null
|
|
490
|
+
]);
|
|
359
491
|
const existing = series.get(collapseKey);
|
|
360
492
|
if (existing) {
|
|
361
493
|
existing.count++;
|
|
362
494
|
} else {
|
|
363
|
-
series.set(collapseKey, {
|
|
364
|
-
modality,
|
|
365
|
-
targetSizeKb: sizeKb,
|
|
366
|
-
tags,
|
|
367
|
-
count: 1
|
|
368
|
-
});
|
|
495
|
+
series.set(collapseKey, { modality, large, bytes: size, tags, count: 1 });
|
|
369
496
|
}
|
|
370
497
|
}
|
|
371
498
|
const studySpecs = [...studies.values()].map((study) => {
|
|
372
499
|
const seriesSpecs = [...study.values()].map((series) => {
|
|
373
500
|
const entries = [...series.values()].map((acc) => ({
|
|
374
|
-
type: "valid-image",
|
|
375
501
|
...acc.modality !== void 0 ? { modality: acc.modality } : {},
|
|
376
|
-
|
|
502
|
+
...acc.large ? { type: "large-image", targetBytes: acc.bytes } : {
|
|
503
|
+
type: "valid-image",
|
|
504
|
+
targetSizeKb: Math.max(1, Math.round(acc.bytes / 1024))
|
|
505
|
+
},
|
|
377
506
|
...acc.tags !== void 0 ? { tags: acc.tags } : {},
|
|
378
507
|
...acc.count > 1 ? { count: acc.count } : {}
|
|
379
508
|
}));
|
|
@@ -389,5 +518,6 @@ function describeDirectory(dir, options = {}) {
|
|
|
389
518
|
return { spec, stats };
|
|
390
519
|
}
|
|
391
520
|
export {
|
|
392
|
-
describeDirectory
|
|
521
|
+
describeDirectory,
|
|
522
|
+
readHeaderOnly
|
|
393
523
|
};
|