dicom-synth 1.0.1 → 1.2.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 +348 -55
- package/bin/dicom-synth-generate.mjs +85 -0
- package/dist/esm/collection/writer.js +484 -0
- package/dist/esm/index.js +649 -179
- package/dist/esm/schema/types.js +0 -0
- package/dist/esm/schema/validate.js +191 -0
- package/dist/esm/syntheticFixtures/generator.js +140 -103
- package/dist/esm/syntheticFixtures/uid.js +68 -0
- package/dist/esm/syntheticFixtures/violations.js +115 -0
- package/dist/types/collection/writer.d.ts +22 -0
- package/dist/types/index.d.ts +5 -5
- package/dist/types/schema/types.d.ts +64 -0
- package/dist/types/schema/validate.d.ts +3 -0
- package/dist/types/syntheticFixtures/generator.d.ts +3 -27
- package/dist/types/syntheticFixtures/uid.d.ts +12 -0
- package/dist/types/syntheticFixtures/violations.d.ts +2 -0
- package/package.json +3 -3
- package/bin/dicom-synth-write.mjs +0 -9
- package/dist/esm/negativeCases/index.js +0 -17
- package/dist/types/negativeCases/index.d.ts +0 -11
|
File without changes
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// src/schema/validate.ts
|
|
2
|
+
var TYPE_CAPABILITIES = {
|
|
3
|
+
"valid-ct": { tags: true, violations: true, transferSyntax: true },
|
|
4
|
+
"invalid-uid-ct": { tags: true, violations: true, transferSyntax: true },
|
|
5
|
+
"vendor-warnings-ct": { tags: true, violations: true, transferSyntax: true },
|
|
6
|
+
"large-ct": { tags: true, violations: true, transferSyntax: true },
|
|
7
|
+
"fake-signature": { tags: false, violations: false, transferSyntax: false },
|
|
8
|
+
"non-dicom": { tags: false, violations: false, transferSyntax: false },
|
|
9
|
+
dicomdir: { tags: false, violations: false, transferSyntax: false }
|
|
10
|
+
};
|
|
11
|
+
var VALID_LAYOUTS = {
|
|
12
|
+
flat: true,
|
|
13
|
+
hierarchical: true
|
|
14
|
+
};
|
|
15
|
+
var VALID_TRANSFER_SYNTAXES = {
|
|
16
|
+
"explicit-vr-little-endian": true,
|
|
17
|
+
"implicit-vr-little-endian": true
|
|
18
|
+
};
|
|
19
|
+
var MAX_PIXEL_BYTES = 512 * 1024 * 1024;
|
|
20
|
+
var VALID_VIOLATIONS = {
|
|
21
|
+
"uid-too-long": true,
|
|
22
|
+
"non-conformant-uid": true,
|
|
23
|
+
"missing-meta-header": true,
|
|
24
|
+
"malformed-sq-delimiter": true,
|
|
25
|
+
"vr-max-length-exceeded": true,
|
|
26
|
+
"missing-type1-tag": true
|
|
27
|
+
};
|
|
28
|
+
var HEX_TAG_RE = /^[0-9a-fA-F]{8}$/;
|
|
29
|
+
var KEYWORD_RE = /^[A-Z][A-Za-z0-9]*$/;
|
|
30
|
+
function validatePositiveInt(value, path) {
|
|
31
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < 1) {
|
|
32
|
+
throw new Error(
|
|
33
|
+
`${path}: must be a positive integer; got ${JSON.stringify(value)}`
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function validateTagKey(key, path) {
|
|
38
|
+
if (!HEX_TAG_RE.test(key) && !KEYWORD_RE.test(key)) {
|
|
39
|
+
throw new Error(
|
|
40
|
+
`${path}: invalid tag key "${key}" \u2014 must be a DICOM keyword name (e.g. "Modality") or an 8-hex-char tag (e.g. "00080060")`
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function validateEntry(entry, path) {
|
|
45
|
+
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
|
|
46
|
+
throw new Error(`${path}: must be an object`);
|
|
47
|
+
}
|
|
48
|
+
const e = entry;
|
|
49
|
+
if (typeof e.type !== "string" || !Object.hasOwn(TYPE_CAPABILITIES, e.type)) {
|
|
50
|
+
throw new Error(
|
|
51
|
+
`${path}.type: must be one of ${Object.keys(TYPE_CAPABILITIES).join(", ")}; got ${JSON.stringify(e.type)}`
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
const type = e.type;
|
|
55
|
+
const caps = TYPE_CAPABILITIES[type];
|
|
56
|
+
if ("count" in e) validatePositiveInt(e.count, `${path}.count`);
|
|
57
|
+
if ("transferSyntax" in e) {
|
|
58
|
+
if (!caps.transferSyntax) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
`${path}.transferSyntax: not supported for type "${type}"`
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
if (!Object.hasOwn(VALID_TRANSFER_SYNTAXES, e.transferSyntax)) {
|
|
64
|
+
throw new Error(
|
|
65
|
+
`${path}.transferSyntax: must be one of ${Object.keys(VALID_TRANSFER_SYNTAXES).join(", ")}`
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (!caps.tags && "tags" in e)
|
|
70
|
+
throw new Error(`${path}.tags: not supported for type "${type}"`);
|
|
71
|
+
if (!caps.violations && "violations" in e)
|
|
72
|
+
throw new Error(`${path}.violations: not supported for type "${type}"`);
|
|
73
|
+
if ("tags" in e) validateTags(e.tags, `${path}.tags`);
|
|
74
|
+
if ("violations" in e) {
|
|
75
|
+
if (!Array.isArray(e.violations)) {
|
|
76
|
+
throw new Error(`${path}.violations: must be an array`);
|
|
77
|
+
}
|
|
78
|
+
for (const [i, v] of e.violations.entries()) {
|
|
79
|
+
if (typeof v !== "string" || !Object.hasOwn(VALID_VIOLATIONS, v)) {
|
|
80
|
+
throw new Error(
|
|
81
|
+
`${path}.violations[${i}]: must be one of ${Object.keys(VALID_VIOLATIONS).join(", ")}; got ${JSON.stringify(v)}`
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (type === "large-ct") {
|
|
87
|
+
validatePositiveInt(e.rows, `${path}.rows`);
|
|
88
|
+
validatePositiveInt(e.columns, `${path}.columns`);
|
|
89
|
+
if ("frames" in e) validatePositiveInt(e.frames, `${path}.frames`);
|
|
90
|
+
const rows = e.rows;
|
|
91
|
+
const columns = e.columns;
|
|
92
|
+
const frames = typeof e.frames === "number" ? e.frames : 1;
|
|
93
|
+
if (rows * columns * frames * 2 > MAX_PIXEL_BYTES) {
|
|
94
|
+
throw new Error(
|
|
95
|
+
`${path}: pixel data (${rows}\xD7${columns}\xD7${frames}\xD72 bytes) exceeds the 512 MB limit`
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return e;
|
|
100
|
+
}
|
|
101
|
+
function validateTags(tags, path) {
|
|
102
|
+
if (typeof tags !== "object" || tags === null || Array.isArray(tags)) {
|
|
103
|
+
throw new Error(`${path}: must be an object`);
|
|
104
|
+
}
|
|
105
|
+
for (const key of Object.keys(tags)) {
|
|
106
|
+
validateTagKey(key, path);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
function validateSeries(series, path) {
|
|
110
|
+
if (typeof series !== "object" || series === null || Array.isArray(series)) {
|
|
111
|
+
throw new Error(`${path}: must be an object`);
|
|
112
|
+
}
|
|
113
|
+
const s = series;
|
|
114
|
+
if (!Array.isArray(s.entries) || s.entries.length === 0) {
|
|
115
|
+
throw new Error(`${path}.entries: must be a non-empty array`);
|
|
116
|
+
}
|
|
117
|
+
for (let i = 0; i < s.entries.length; i++) {
|
|
118
|
+
const e = validateEntry(s.entries[i], `${path}.entries[${i}]`);
|
|
119
|
+
if (!TYPE_CAPABILITIES[e.type].tags) {
|
|
120
|
+
throw new Error(
|
|
121
|
+
`${path}.entries[${i}].type: "${e.type}" cannot be used inside a series \u2014 only types that support tags can be grouped`
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if ("tags" in s) validateTags(s.tags, `${path}.tags`);
|
|
126
|
+
return s;
|
|
127
|
+
}
|
|
128
|
+
function validateStudy(study, path) {
|
|
129
|
+
if (typeof study !== "object" || study === null || Array.isArray(study)) {
|
|
130
|
+
throw new Error(`${path}: must be an object`);
|
|
131
|
+
}
|
|
132
|
+
const st = study;
|
|
133
|
+
if (!Array.isArray(st.series) || st.series.length === 0) {
|
|
134
|
+
throw new Error(`${path}.series: must be a non-empty array`);
|
|
135
|
+
}
|
|
136
|
+
for (let i = 0; i < st.series.length; i++) {
|
|
137
|
+
validateSeries(st.series[i], `${path}.series[${i}]`);
|
|
138
|
+
}
|
|
139
|
+
if ("count" in st) validatePositiveInt(st.count, `${path}.count`);
|
|
140
|
+
if ("tags" in st) validateTags(st.tags, `${path}.tags`);
|
|
141
|
+
return st;
|
|
142
|
+
}
|
|
143
|
+
function validateDatasetSpec(raw) {
|
|
144
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
145
|
+
throw new Error("DatasetSpec: must be a JSON object");
|
|
146
|
+
}
|
|
147
|
+
const r = raw;
|
|
148
|
+
if ("entries" in r && !Array.isArray(r.entries)) {
|
|
149
|
+
throw new Error("DatasetSpec.entries: must be an array");
|
|
150
|
+
}
|
|
151
|
+
if ("studies" in r && !Array.isArray(r.studies)) {
|
|
152
|
+
throw new Error("DatasetSpec.studies: must be an array");
|
|
153
|
+
}
|
|
154
|
+
const rawEntries = r.entries ?? [];
|
|
155
|
+
const rawStudies = r.studies ?? [];
|
|
156
|
+
if (rawEntries.length === 0 && rawStudies.length === 0) {
|
|
157
|
+
throw new Error(
|
|
158
|
+
'DatasetSpec: must contain at least one of "entries" or "studies" (non-empty)'
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
const entries = rawEntries.map(
|
|
162
|
+
(entry, i) => validateEntry(entry, `entries[${i}]`)
|
|
163
|
+
);
|
|
164
|
+
const studies = rawStudies.map(
|
|
165
|
+
(study, i) => validateStudy(study, `studies[${i}]`)
|
|
166
|
+
);
|
|
167
|
+
if ("seed" in r) {
|
|
168
|
+
if (typeof r.seed !== "number" || !Number.isFinite(r.seed)) {
|
|
169
|
+
throw new Error(
|
|
170
|
+
`DatasetSpec.seed: must be a finite number; got ${JSON.stringify(r.seed)}`
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
if ("layout" in r) {
|
|
175
|
+
if (typeof r.layout !== "string" || !Object.hasOwn(VALID_LAYOUTS, r.layout)) {
|
|
176
|
+
throw new Error(
|
|
177
|
+
`DatasetSpec.layout: must be one of ${Object.keys(VALID_LAYOUTS).join(", ")}; got ${JSON.stringify(r.layout)}`
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return {
|
|
182
|
+
...entries.length > 0 ? { entries } : {},
|
|
183
|
+
...studies.length > 0 ? { studies } : {},
|
|
184
|
+
...r.seed !== void 0 ? { seed: r.seed } : {},
|
|
185
|
+
...r.layout !== void 0 ? { layout: r.layout } : {}
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
export {
|
|
189
|
+
HEX_TAG_RE,
|
|
190
|
+
validateDatasetSpec
|
|
191
|
+
};
|
|
@@ -1,7 +1,3 @@
|
|
|
1
|
-
// src/syntheticFixtures/generator.ts
|
|
2
|
-
import { mkdirSync, writeFileSync } from "node:fs";
|
|
3
|
-
import { resolve } from "node:path";
|
|
4
|
-
|
|
5
1
|
// src/loadDcmjs.ts
|
|
6
2
|
import dcmjsDefaultImport, * as dcmjsNamespace from "dcmjs";
|
|
7
3
|
function loadDcmjs() {
|
|
@@ -21,38 +17,85 @@ function loadDcmjs() {
|
|
|
21
17
|
}
|
|
22
18
|
var dcmjs = loadDcmjs();
|
|
23
19
|
|
|
20
|
+
// src/nonStandardDicom/dicomdir.ts
|
|
21
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
22
|
+
import { resolve } from "node:path";
|
|
23
|
+
var DICOMDIR_SOP_CLASS_UID = "1.2.840.10008.1.3.10";
|
|
24
|
+
function buildDicomdirBuffer() {
|
|
25
|
+
const uid = DICOMDIR_SOP_CLASS_UID;
|
|
26
|
+
const buf = Buffer.alloc(256, 0);
|
|
27
|
+
buf.write("DICM", 128, 4, "ascii");
|
|
28
|
+
let off = 132;
|
|
29
|
+
buf.writeUInt16LE(2, off);
|
|
30
|
+
off += 2;
|
|
31
|
+
buf.writeUInt16LE(2, off);
|
|
32
|
+
off += 2;
|
|
33
|
+
buf.write("UI", off, 2, "ascii");
|
|
34
|
+
off += 2;
|
|
35
|
+
buf.writeUInt16LE(uid.length, off);
|
|
36
|
+
off += 2;
|
|
37
|
+
buf.write(uid, off, uid.length, "ascii");
|
|
38
|
+
off += uid.length;
|
|
39
|
+
return buf.subarray(0, off);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// src/schema/validate.ts
|
|
43
|
+
var MAX_PIXEL_BYTES = 512 * 1024 * 1024;
|
|
44
|
+
var HEX_TAG_RE = /^[0-9a-fA-F]{8}$/;
|
|
45
|
+
|
|
24
46
|
// src/syntheticFixtures/generator.ts
|
|
25
47
|
var CT_SOP_CLASS = "1.2.840.10008.5.1.4.1.1.2";
|
|
26
|
-
var
|
|
27
|
-
"
|
|
28
|
-
"
|
|
29
|
-
"vendor-warnings": 2
|
|
48
|
+
var TRANSFER_SYNTAX_UID = {
|
|
49
|
+
"explicit-vr-little-endian": "1.2.840.10008.1.2.1",
|
|
50
|
+
"implicit-vr-little-endian": "1.2.840.10008.1.2"
|
|
30
51
|
};
|
|
31
|
-
function
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
52
|
+
function buildTagToKeyword() {
|
|
53
|
+
const map = /* @__PURE__ */ new Map();
|
|
54
|
+
const nm = dcmjs.data?.DicomMetaDictionary?.nameMap;
|
|
55
|
+
if (!nm) return map;
|
|
56
|
+
for (const [keyword, info] of Object.entries(nm)) {
|
|
57
|
+
if (info?.tag) {
|
|
58
|
+
const hex = info.tag.replace(/[(,)]/g, "").toUpperCase();
|
|
59
|
+
if (hex.length === 8) map.set(hex, keyword);
|
|
60
|
+
}
|
|
35
61
|
}
|
|
36
|
-
|
|
37
|
-
"1000000000000000000000000000000000001",
|
|
38
|
-
"1000000000000000000000000000000000002",
|
|
39
|
-
"1000000000000000000000000000000000003"
|
|
40
|
-
];
|
|
41
|
-
const roleSuffix = role === "study" ? 1 : role === "series" ? 2 : 3;
|
|
42
|
-
return `2.25.${uidRoots[i]}.${roleSuffix}`;
|
|
62
|
+
return map;
|
|
43
63
|
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
const
|
|
48
|
-
|
|
64
|
+
var tagToKeyword = buildTagToKeyword();
|
|
65
|
+
function applyTagOverrides(dataset, tags) {
|
|
66
|
+
if (!tags) return;
|
|
67
|
+
for (const [key, value] of Object.entries(tags)) {
|
|
68
|
+
if (HEX_TAG_RE.test(key)) {
|
|
69
|
+
const keyword = tagToKeyword.get(key.toUpperCase());
|
|
70
|
+
if (keyword) {
|
|
71
|
+
dataset[keyword] = value;
|
|
72
|
+
} else {
|
|
73
|
+
console.warn(`dicom-synth: unknown hex tag "${key}" \u2014 skipping`);
|
|
74
|
+
}
|
|
75
|
+
} else {
|
|
76
|
+
dataset[key] = value;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
function buildMeta(uid, transferSyntax = "explicit-vr-little-endian") {
|
|
81
|
+
return {
|
|
82
|
+
FileMetaInformationVersion: new Uint8Array([0, 1]).buffer,
|
|
83
|
+
MediaStorageSOPClassUID: CT_SOP_CLASS,
|
|
84
|
+
MediaStorageSOPInstanceUID: uid.sop,
|
|
85
|
+
TransferSyntaxUID: TRANSFER_SYNTAX_UID[transferSyntax],
|
|
86
|
+
ImplementationClassUID: "1.2.3.4",
|
|
87
|
+
ImplementationVersionName: "SYNTH"
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
function buildBaseCtDataset(uid) {
|
|
91
|
+
return {
|
|
49
92
|
PatientName: "SYNTH^SUBJECT",
|
|
50
93
|
PatientID: "SYNTH_PID",
|
|
51
94
|
Modality: "CT",
|
|
52
95
|
SOPClassUID: CT_SOP_CLASS,
|
|
53
|
-
SOPInstanceUID:
|
|
54
|
-
SeriesInstanceUID:
|
|
55
|
-
StudyInstanceUID:
|
|
96
|
+
SOPInstanceUID: uid.sop,
|
|
97
|
+
SeriesInstanceUID: uid.series,
|
|
98
|
+
StudyInstanceUID: uid.study,
|
|
56
99
|
StudyDescription: "Synthetic study",
|
|
57
100
|
SeriesDescription: "Synthetic series",
|
|
58
101
|
Rows: 1,
|
|
@@ -63,93 +106,87 @@ function naturalDataset(variant) {
|
|
|
63
106
|
SamplesPerPixel: 1,
|
|
64
107
|
PhotometricInterpretation: "MONOCHROME2",
|
|
65
108
|
PixelRepresentation: 0,
|
|
66
|
-
PixelData: new Uint8Array([0, 0]).buffer
|
|
109
|
+
PixelData: new Uint8Array([0, 0]).buffer,
|
|
110
|
+
// Declares PixelData's VR explicitly — without it dcmjs logs
|
|
111
|
+
// "No value representation given for PixelData" and falls back to OW anyway.
|
|
112
|
+
_vrMap: { PixelData: "OW" }
|
|
67
113
|
};
|
|
68
|
-
if (variant === "vendor-warnings") {
|
|
69
|
-
dataset.Laterality = "";
|
|
70
|
-
dataset.PatientWeight = "0";
|
|
71
|
-
}
|
|
72
|
-
return dataset;
|
|
73
114
|
}
|
|
74
|
-
function
|
|
75
|
-
const meta = {
|
|
76
|
-
FileMetaInformationVersion: new Uint8Array([0, 1]).buffer,
|
|
77
|
-
MediaStorageSOPClassUID: CT_SOP_CLASS,
|
|
78
|
-
MediaStorageSOPInstanceUID: uidFor(variant, "sop"),
|
|
79
|
-
TransferSyntaxUID: "1.2.840.10008.1.2.1",
|
|
80
|
-
ImplementationClassUID: "1.2.3.4",
|
|
81
|
-
ImplementationVersionName: "SYNTH"
|
|
82
|
-
};
|
|
115
|
+
function serializeDict(meta, dataset) {
|
|
83
116
|
const dicomDict = new dcmjs.data.DicomDict(
|
|
84
|
-
dcmjs.data.DicomMetaDictionary.denaturalizeDataset(
|
|
117
|
+
dcmjs.data.DicomMetaDictionary.denaturalizeDataset(
|
|
118
|
+
meta
|
|
119
|
+
)
|
|
85
120
|
);
|
|
86
121
|
dicomDict.dict = dcmjs.data.DicomMetaDictionary.denaturalizeDataset(
|
|
87
|
-
|
|
122
|
+
dataset
|
|
88
123
|
);
|
|
89
|
-
return dicomDict;
|
|
124
|
+
return Buffer.from(dicomDict.write({ allowInvalidVRLength: true }));
|
|
90
125
|
}
|
|
91
|
-
function
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
);
|
|
126
|
+
function buildValidCtBuffer(uid, tags, transferSyntax) {
|
|
127
|
+
const dataset = buildBaseCtDataset(uid);
|
|
128
|
+
applyTagOverrides(dataset, tags);
|
|
129
|
+
return serializeDict(buildMeta(uid, transferSyntax), dataset);
|
|
95
130
|
}
|
|
96
|
-
|
|
97
|
-
{
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
},
|
|
102
|
-
{
|
|
103
|
-
filename: "valid-uid-ct-0.dcm",
|
|
104
|
-
variant: "valid-uid",
|
|
105
|
-
description: "Minimal CT with numeric UIDs only (missing-module noise)"
|
|
106
|
-
},
|
|
107
|
-
{
|
|
108
|
-
filename: "vendor-warnings-ct-0.dcm",
|
|
109
|
-
variant: "vendor-warnings",
|
|
110
|
-
description: "Valid UIDs plus empty Laterality and zero PatientWeight"
|
|
111
|
-
}
|
|
112
|
-
];
|
|
113
|
-
function writeMinimalDicomFile(filePath, tags) {
|
|
114
|
-
const patientId = tags?.patientId ?? "TEST-PATIENT-001";
|
|
115
|
-
const patientName = tags?.patientName ?? "TEST^PATIENT";
|
|
116
|
-
const sopUID = tags?.sopInstanceUid ?? "1.2.3.4.5.6.7.8.9.0.1";
|
|
117
|
-
const dataset = {
|
|
118
|
-
PatientName: patientName,
|
|
119
|
-
PatientID: patientId,
|
|
120
|
-
Modality: "CT",
|
|
121
|
-
SOPClassUID: CT_SOP_CLASS,
|
|
122
|
-
SOPInstanceUID: sopUID,
|
|
123
|
-
SeriesInstanceUID: "1.2.3.4.5.6.7.8",
|
|
124
|
-
StudyInstanceUID: "1.2.3.4.5.6.7",
|
|
125
|
-
SeriesNumber: "1",
|
|
126
|
-
Rows: 1,
|
|
127
|
-
Columns: 1,
|
|
128
|
-
BitsAllocated: 16,
|
|
129
|
-
PixelRepresentation: 0,
|
|
130
|
-
PixelData: new Uint8Array([0, 0]).buffer
|
|
131
|
+
function buildInvalidUidCtBuffer(uid, tags, transferSyntax) {
|
|
132
|
+
const invalidUid = {
|
|
133
|
+
study: `2.25.invalid.study.${uid.study.slice(-6)}`,
|
|
134
|
+
series: `2.25.invalid.series.${uid.series.slice(-6)}`,
|
|
135
|
+
sop: `2.25.invalid.sop.${uid.sop.slice(-6)}`
|
|
131
136
|
};
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
+
const dataset = buildBaseCtDataset(invalidUid);
|
|
138
|
+
applyTagOverrides(dataset, tags);
|
|
139
|
+
return serializeDict(buildMeta(invalidUid, transferSyntax), dataset);
|
|
140
|
+
}
|
|
141
|
+
function buildVendorCtBuffer(uid, tags, transferSyntax) {
|
|
142
|
+
const dataset = buildBaseCtDataset(uid);
|
|
143
|
+
dataset.Laterality = "";
|
|
144
|
+
dataset.PatientWeight = "0";
|
|
145
|
+
applyTagOverrides(dataset, tags);
|
|
146
|
+
return serializeDict(buildMeta(uid, transferSyntax), dataset);
|
|
147
|
+
}
|
|
148
|
+
function buildLargeCtBuffer(uid, rows, columns, frames, tags, transferSyntax) {
|
|
149
|
+
const dataset = buildBaseCtDataset(uid);
|
|
150
|
+
dataset.Rows = rows;
|
|
151
|
+
dataset.Columns = columns;
|
|
152
|
+
dataset.NumberOfFrames = frames;
|
|
153
|
+
dataset.PixelData = new Uint8Array(rows * columns * frames * 2).buffer;
|
|
154
|
+
applyTagOverrides(dataset, tags);
|
|
155
|
+
return serializeDict(buildMeta(uid, transferSyntax), dataset);
|
|
156
|
+
}
|
|
157
|
+
function buildFakeSignatureBuffer() {
|
|
158
|
+
const buf = Buffer.alloc(200, 0);
|
|
159
|
+
buf.write("XXXX", 128, 4, "ascii");
|
|
160
|
+
return buf;
|
|
161
|
+
}
|
|
162
|
+
function buildNonDicomBuffer(content = "not dicom") {
|
|
163
|
+
return Buffer.from(content, "utf8");
|
|
137
164
|
}
|
|
138
|
-
function
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
165
|
+
function buildBufferForSpec(spec, uid) {
|
|
166
|
+
switch (spec.type) {
|
|
167
|
+
case "valid-ct":
|
|
168
|
+
return buildValidCtBuffer(uid, spec.tags, spec.transferSyntax);
|
|
169
|
+
case "invalid-uid-ct":
|
|
170
|
+
return buildInvalidUidCtBuffer(uid, spec.tags, spec.transferSyntax);
|
|
171
|
+
case "vendor-warnings-ct":
|
|
172
|
+
return buildVendorCtBuffer(uid, spec.tags, spec.transferSyntax);
|
|
173
|
+
case "large-ct":
|
|
174
|
+
return buildLargeCtBuffer(
|
|
175
|
+
uid,
|
|
176
|
+
spec.rows,
|
|
177
|
+
spec.columns,
|
|
178
|
+
spec.frames ?? 1,
|
|
179
|
+
spec.tags,
|
|
180
|
+
spec.transferSyntax
|
|
181
|
+
);
|
|
182
|
+
case "fake-signature":
|
|
183
|
+
return buildFakeSignatureBuffer();
|
|
184
|
+
case "non-dicom":
|
|
185
|
+
return buildNonDicomBuffer(spec.content);
|
|
186
|
+
case "dicomdir":
|
|
187
|
+
return buildDicomdirBuffer();
|
|
146
188
|
}
|
|
147
|
-
return written;
|
|
148
189
|
}
|
|
149
190
|
export {
|
|
150
|
-
|
|
151
|
-
buildDicomDict,
|
|
152
|
-
buildSyntheticCtBuffer,
|
|
153
|
-
writeMinimalDicomFile,
|
|
154
|
-
writeSyntheticFixturesToDir
|
|
191
|
+
buildBufferForSpec
|
|
155
192
|
};
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// src/syntheticFixtures/uid.ts
|
|
2
|
+
import { randomBytes } from "node:crypto";
|
|
3
|
+
var ROLE_CODE = { study: 1, series: 2, sop: 3 };
|
|
4
|
+
function lcg(seed) {
|
|
5
|
+
let s = seed >>> 0;
|
|
6
|
+
return () => {
|
|
7
|
+
s = Math.imul(1664525, s) + 1013904223 >>> 0;
|
|
8
|
+
return s;
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
function formatUid(a, b, role) {
|
|
12
|
+
return `2.25.${a}.${b}.${ROLE_CODE[role]}`;
|
|
13
|
+
}
|
|
14
|
+
function randomUid(role) {
|
|
15
|
+
const buf = randomBytes(8);
|
|
16
|
+
return formatUid(buf.readUInt32BE(0), buf.readUInt32BE(4), role);
|
|
17
|
+
}
|
|
18
|
+
function seededUid(next, role) {
|
|
19
|
+
return formatUid(next(), next(), role);
|
|
20
|
+
}
|
|
21
|
+
var GROUP_STREAM_OFFSET = 2654435769;
|
|
22
|
+
function seededStream(seed, offset, a, b) {
|
|
23
|
+
return lcg(seed * 999983 + offset + a * 1000003 + b * 7919 >>> 0);
|
|
24
|
+
}
|
|
25
|
+
function makeGroupUidGenerator(seed) {
|
|
26
|
+
if (seed === void 0) {
|
|
27
|
+
return {
|
|
28
|
+
study: () => randomUid("study"),
|
|
29
|
+
series: () => randomUid("series")
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
study: (studyIndex) => seededUid(
|
|
34
|
+
seededStream(seed, GROUP_STREAM_OFFSET, studyIndex + 1, 0),
|
|
35
|
+
"study"
|
|
36
|
+
),
|
|
37
|
+
series: (studyIndex, seriesIndex) => seededUid(
|
|
38
|
+
seededStream(
|
|
39
|
+
seed,
|
|
40
|
+
GROUP_STREAM_OFFSET,
|
|
41
|
+
studyIndex + 1,
|
|
42
|
+
seriesIndex + 1
|
|
43
|
+
),
|
|
44
|
+
"series"
|
|
45
|
+
)
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
function makeUidGenerator(seed) {
|
|
49
|
+
if (seed === void 0) {
|
|
50
|
+
return () => ({
|
|
51
|
+
study: randomUid("study"),
|
|
52
|
+
series: randomUid("series"),
|
|
53
|
+
sop: randomUid("sop")
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
return (fileIndex) => {
|
|
57
|
+
const next = seededStream(seed, 0, fileIndex, 0);
|
|
58
|
+
return {
|
|
59
|
+
study: seededUid(next, "study"),
|
|
60
|
+
series: seededUid(next, "series"),
|
|
61
|
+
sop: seededUid(next, "sop")
|
|
62
|
+
};
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
export {
|
|
66
|
+
makeGroupUidGenerator,
|
|
67
|
+
makeUidGenerator
|
|
68
|
+
};
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// src/loadDcmjs.ts
|
|
2
|
+
import dcmjsDefaultImport, * as dcmjsNamespace from "dcmjs";
|
|
3
|
+
function loadDcmjs() {
|
|
4
|
+
if (dcmjsNamespace.data?.DicomDict) {
|
|
5
|
+
return dcmjsNamespace;
|
|
6
|
+
}
|
|
7
|
+
const d = dcmjsDefaultImport;
|
|
8
|
+
if (d?.data?.DicomDict) {
|
|
9
|
+
return d;
|
|
10
|
+
}
|
|
11
|
+
if (d?.default?.data?.DicomDict) {
|
|
12
|
+
return d.default;
|
|
13
|
+
}
|
|
14
|
+
throw new Error(
|
|
15
|
+
"dcmjs failed to load (missing data.DicomDict). Install dcmjs >= 0.29."
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
var dcmjs = loadDcmjs();
|
|
19
|
+
|
|
20
|
+
// src/syntheticFixtures/violations.ts
|
|
21
|
+
var dcmjsAny = dcmjs;
|
|
22
|
+
function parseBuffer(buffer) {
|
|
23
|
+
const ab = buffer.buffer.slice(
|
|
24
|
+
buffer.byteOffset,
|
|
25
|
+
buffer.byteOffset + buffer.byteLength
|
|
26
|
+
);
|
|
27
|
+
return dcmjsAny.data.DicomMessage.readFile(ab);
|
|
28
|
+
}
|
|
29
|
+
function reserialize(parsed) {
|
|
30
|
+
return Buffer.from(parsed.write({ allowInvalidVRLength: true }));
|
|
31
|
+
}
|
|
32
|
+
var OVERLONG_UID = `2.25.${"0".repeat(60)}`;
|
|
33
|
+
var VIOLATION_LEVEL = {
|
|
34
|
+
"uid-too-long": "tag",
|
|
35
|
+
"non-conformant-uid": "tag",
|
|
36
|
+
"vr-max-length-exceeded": "tag",
|
|
37
|
+
"missing-type1-tag": "tag",
|
|
38
|
+
"missing-meta-header": "byte",
|
|
39
|
+
"malformed-sq-delimiter": "byte"
|
|
40
|
+
};
|
|
41
|
+
function applyTagLevel(buffer, violations) {
|
|
42
|
+
if (violations.length === 0) return buffer;
|
|
43
|
+
let parsed;
|
|
44
|
+
try {
|
|
45
|
+
parsed = parseBuffer(buffer);
|
|
46
|
+
} catch {
|
|
47
|
+
console.warn(
|
|
48
|
+
"dicom-synth: could not parse buffer for tag-level violations \u2014 skipping"
|
|
49
|
+
);
|
|
50
|
+
return buffer;
|
|
51
|
+
}
|
|
52
|
+
const natural = dcmjsAny.data.DicomMetaDictionary.naturalizeDataset(
|
|
53
|
+
parsed.dict
|
|
54
|
+
);
|
|
55
|
+
for (const v of violations) {
|
|
56
|
+
switch (v) {
|
|
57
|
+
case "uid-too-long":
|
|
58
|
+
break;
|
|
59
|
+
case "non-conformant-uid":
|
|
60
|
+
natural.SOPInstanceUID = "2.25.01.234.567";
|
|
61
|
+
break;
|
|
62
|
+
case "vr-max-length-exceeded":
|
|
63
|
+
natural.StudyDescription = "X".repeat(65);
|
|
64
|
+
break;
|
|
65
|
+
case "missing-type1-tag":
|
|
66
|
+
delete natural.SOPClassUID;
|
|
67
|
+
break;
|
|
68
|
+
default:
|
|
69
|
+
throw new Error(
|
|
70
|
+
`dicom-synth: unhandled tag-level violation "${v}"`
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
parsed.dict = dcmjsAny.data.DicomMetaDictionary.denaturalizeDataset(natural);
|
|
75
|
+
if (violations.includes("uid-too-long")) {
|
|
76
|
+
;
|
|
77
|
+
parsed.dict["00080018"] = {
|
|
78
|
+
vr: "UI",
|
|
79
|
+
Value: [OVERLONG_UID]
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
return reserialize(parsed);
|
|
83
|
+
}
|
|
84
|
+
function applyByteLevel(buffer, violations) {
|
|
85
|
+
let result = buffer;
|
|
86
|
+
for (const v of violations) {
|
|
87
|
+
switch (v) {
|
|
88
|
+
case "missing-meta-header":
|
|
89
|
+
result = result.subarray(132);
|
|
90
|
+
break;
|
|
91
|
+
case "malformed-sq-delimiter": {
|
|
92
|
+
const delimiter = Buffer.alloc(8);
|
|
93
|
+
delimiter.writeUInt16LE(65534, 0);
|
|
94
|
+
delimiter.writeUInt16LE(57565, 2);
|
|
95
|
+
delimiter.writeUInt32LE(4, 4);
|
|
96
|
+
result = Buffer.concat([result, delimiter]);
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
default:
|
|
100
|
+
throw new Error(
|
|
101
|
+
`dicom-synth: unhandled byte-level violation "${v}"`
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return result;
|
|
106
|
+
}
|
|
107
|
+
function applyViolations(buffer, violations) {
|
|
108
|
+
if (violations.length === 0) return buffer;
|
|
109
|
+
const tagViolations = violations.filter((v) => VIOLATION_LEVEL[v] === "tag");
|
|
110
|
+
const byteViolations = violations.filter((v) => VIOLATION_LEVEL[v] === "byte");
|
|
111
|
+
return applyByteLevel(applyTagLevel(buffer, tagViolations), byteViolations);
|
|
112
|
+
}
|
|
113
|
+
export {
|
|
114
|
+
applyViolations
|
|
115
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { DatasetSpec, FileSpec } from '../schema/types.js';
|
|
2
|
+
import type { UidSet } from '../syntheticFixtures/uid.js';
|
|
3
|
+
export type GeneratedFile = {
|
|
4
|
+
filename: string;
|
|
5
|
+
relativePath: string;
|
|
6
|
+
buffer: Buffer;
|
|
7
|
+
type: FileSpec['type'];
|
|
8
|
+
index: number;
|
|
9
|
+
};
|
|
10
|
+
export type CollectionManifest = Array<{
|
|
11
|
+
path: string;
|
|
12
|
+
type: FileSpec['type'];
|
|
13
|
+
index: number;
|
|
14
|
+
}>;
|
|
15
|
+
export declare function generateFile(spec: FileSpec, options?: {
|
|
16
|
+
index?: number;
|
|
17
|
+
seed?: number;
|
|
18
|
+
padWidth?: number;
|
|
19
|
+
uid?: Partial<UidSet>;
|
|
20
|
+
}): Promise<GeneratedFile>;
|
|
21
|
+
export declare function generateCollectionFromSpec(spec: DatasetSpec): AsyncGenerator<GeneratedFile>;
|
|
22
|
+
export declare function writeCollectionFromSpec(spec: DatasetSpec, outDir: string): Promise<CollectionManifest>;
|