dicom-seg-js 0.1.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.
@@ -0,0 +1,508 @@
1
+ import { createRequire } from "node:module";
2
+ import { createDiagnostic, createGridGeometry, cross, DEFAULT_TOLERANCE, dot, normalize, sub, GridMismatchError, } from "rt-geometry-js";
3
+ import { MalformedSegmentationError, NotSegmentationError, UnsupportedSegmentationTypeError, } from "../errors.js";
4
+ // THE ONLY dcmjs importer in dicom-seg-js — mirrors rtstruct-js / rtdose-js port.ts.
5
+ // dcmjs's ESM build is CJS-with-export-syntax and trips Node's resolver; require() hits
6
+ // the genuinely-CJS condition instead.
7
+ const dcmjs = createRequire(import.meta.url)("dcmjs");
8
+ const { DicomMessage, DicomMetaDictionary, DicomDict, BitArray } = dcmjs.data;
9
+ const SEG_STORAGE_SOP_CLASS_UID = "1.2.840.10008.5.1.4.1.1.66.4";
10
+ const TRANSFER_SYNTAX_UID = "1.2.840.10008.1.2.1"; // Explicit VR Little Endian
11
+ const TAG_PIXEL_DATA = "7FE00010";
12
+ function at(arr, index) {
13
+ const value = arr[index];
14
+ if (value === undefined)
15
+ throw new RangeError(`index ${index} out of range (length ${arr.length})`);
16
+ return value;
17
+ }
18
+ function naturalize(bytes) {
19
+ const dicomData = DicomMessage.readFile(bytes);
20
+ return DicomMetaDictionary.naturalizeDataset(dicomData.dict);
21
+ }
22
+ function asArray(value) {
23
+ if (Array.isArray(value))
24
+ return value;
25
+ if (value === undefined || value === null)
26
+ return [];
27
+ return [value];
28
+ }
29
+ function asNumberArray(value) {
30
+ if (Array.isArray(value))
31
+ return value.map(Number);
32
+ if (typeof value === "number")
33
+ return [value];
34
+ if (typeof value === "string") {
35
+ return value.split("\\").map((s) => Number(s.trim())).filter((n) => !Number.isNaN(n));
36
+ }
37
+ return undefined;
38
+ }
39
+ function readCode(seq) {
40
+ const item = asArray(seq)[0];
41
+ if (!item)
42
+ return undefined;
43
+ const value = item["CodeValue"];
44
+ const scheme = item["CodingSchemeDesignator"];
45
+ const meaning = item["CodeMeaning"];
46
+ if (value === undefined && meaning === undefined)
47
+ return undefined;
48
+ return { value: value ?? "", scheme: scheme ?? "", meaning: meaning ?? "" };
49
+ }
50
+ /** Parse one SEG object's bytes. Throws NotSegmentationError / MalformedSegmentationError /
51
+ * UnsupportedSegmentationTypeError. */
52
+ export function readSegDataset(bytes) {
53
+ const ds = naturalize(bytes);
54
+ const diagnostics = [];
55
+ const sopClassUID = ds["SOPClassUID"];
56
+ const modality = ds["Modality"];
57
+ if (sopClassUID !== undefined && sopClassUID !== SEG_STORAGE_SOP_CLASS_UID && modality !== "SEG") {
58
+ throw new NotSegmentationError(`SOPClassUID ${sopClassUID} is not Segmentation Storage (${SEG_STORAGE_SOP_CLASS_UID}) and Modality is ${modality ?? "absent"}`);
59
+ }
60
+ const rawType = (ds["SegmentationType"] ?? "").toUpperCase();
61
+ if (rawType === "LABELMAP") {
62
+ throw new UnsupportedSegmentationTypeError("LABELMAP segmentation (PS3.3 Sup 243) is not supported in dicom-seg-js 0.1.0 — planned for 0.2.0");
63
+ }
64
+ if (rawType !== "BINARY" && rawType !== "FRACTIONAL") {
65
+ throw new MalformedSegmentationError(`SegmentationType is ${JSON.stringify(rawType || "absent")}, expected BINARY or FRACTIONAL`);
66
+ }
67
+ const segmentationType = rawType;
68
+ const rows = ds["Rows"];
69
+ const columns = ds["Columns"];
70
+ const numberOfFrames = ds["NumberOfFrames"] ?? 0;
71
+ if (!Number.isInteger(rows) || rows <= 0)
72
+ throw new MalformedSegmentationError("missing or invalid Rows");
73
+ if (!Number.isInteger(columns) || columns <= 0)
74
+ throw new MalformedSegmentationError("missing or invalid Columns");
75
+ if (!Number.isInteger(numberOfFrames) || numberOfFrames <= 0) {
76
+ throw new MalformedSegmentationError(`missing or invalid NumberOfFrames (${numberOfFrames})`);
77
+ }
78
+ // --- shared functional groups: orientation + pixel spacing ---
79
+ const shared = asArray(ds["SharedFunctionalGroupsSequence"])[0];
80
+ const sharedOrientation = asNumberArray(asArray(shared?.["PlaneOrientationSequence"])[0]?.["ImageOrientationPatient"]);
81
+ const pixelMeasures = asArray(shared?.["PixelMeasuresSequence"])[0];
82
+ const pixelSpacing = asNumberArray(pixelMeasures?.["PixelSpacing"]);
83
+ if (!sharedOrientation || sharedOrientation.length !== 6) {
84
+ throw new MalformedSegmentationError("SharedFunctionalGroupsSequence is missing PlaneOrientationSequence/ImageOrientationPatient");
85
+ }
86
+ if (!pixelSpacing || pixelSpacing.length !== 2) {
87
+ throw new MalformedSegmentationError("SharedFunctionalGroupsSequence is missing PixelMeasuresSequence/PixelSpacing");
88
+ }
89
+ const rowDirection = [at(sharedOrientation, 0), at(sharedOrientation, 1), at(sharedOrientation, 2)];
90
+ const columnDirection = [at(sharedOrientation, 3), at(sharedOrientation, 4), at(sharedOrientation, 5)];
91
+ const normal = normalize(cross(rowDirection, columnDirection));
92
+ // --- per-frame functional groups ---
93
+ const perFrame = asArray(ds["PerFrameFunctionalGroupsSequence"]);
94
+ if (perFrame.length !== numberOfFrames) {
95
+ throw new MalformedSegmentationError(`PerFrameFunctionalGroupsSequence has ${perFrame.length} items but NumberOfFrames is ${numberOfFrames}`);
96
+ }
97
+ const rawFrames = perFrame.map((fg, frameIndex) => {
98
+ const segId = asArray(fg["SegmentIdentificationSequence"])[0];
99
+ const segmentNumber = Number(segId?.["ReferencedSegmentNumber"]);
100
+ const ipp = asNumberArray(asArray(fg["PlanePositionSequence"])[0]?.["ImagePositionPatient"]);
101
+ if (!Number.isInteger(segmentNumber) || segmentNumber <= 0) {
102
+ throw new MalformedSegmentationError(`frame ${frameIndex}: missing SegmentIdentificationSequence/ReferencedSegmentNumber`);
103
+ }
104
+ if (!ipp || ipp.length !== 3) {
105
+ throw new MalformedSegmentationError(`frame ${frameIndex}: missing PlanePositionSequence/ImagePositionPatient`);
106
+ }
107
+ const perFrameOrientation = asNumberArray(asArray(fg["PlaneOrientationSequence"])[0]?.["ImageOrientationPatient"]);
108
+ if (perFrameOrientation && perFrameOrientation.some((v, i) => Math.abs(v - sharedOrientation[i]) > 1e-6)) {
109
+ diagnostics.push(createDiagnostic("PER_FRAME_ORIENTATION_VARIES", "warning", `frame ${frameIndex} declares a PlaneOrientationSequence that differs from the shared one; the shared orientation is used`));
110
+ }
111
+ return { segmentNumber, position: [at(ipp, 0), at(ipp, 1), at(ipp, 2)], frameIndex };
112
+ });
113
+ // --- segments ---
114
+ const segSeq = asArray(ds["SegmentSequence"]);
115
+ if (segSeq.length === 0)
116
+ throw new MalformedSegmentationError("SegmentSequence is absent or empty");
117
+ const declaredNumbers = new Set();
118
+ const framesBySegment = new Map();
119
+ for (const f of rawFrames)
120
+ framesBySegment.set(f.segmentNumber, (framesBySegment.get(f.segmentNumber) ?? 0) + 1);
121
+ const segments = segSeq.map((s) => {
122
+ const number = Number(s["SegmentNumber"]);
123
+ if (!Number.isInteger(number) || number <= 0) {
124
+ throw new MalformedSegmentationError(`SegmentSequence item has an invalid SegmentNumber (${s["SegmentNumber"]})`);
125
+ }
126
+ declaredNumbers.add(number);
127
+ return {
128
+ number,
129
+ label: s["SegmentLabel"] ?? "",
130
+ algorithmType: s["SegmentAlgorithmType"] ?? "",
131
+ algorithmName: s["SegmentAlgorithmName"] || undefined,
132
+ category: readCode(s["SegmentedPropertyCategoryCodeSequence"]),
133
+ propertyType: readCode(s["SegmentedPropertyTypeCodeSequence"]),
134
+ propertyTypeModifier: readCode(s["SegmentedPropertyTypeModifierCodeSequence"]),
135
+ trackingId: s["TrackingID"] || undefined,
136
+ trackingUid: s["TrackingUID"] || undefined,
137
+ frameCount: framesBySegment.get(number) ?? 0,
138
+ };
139
+ });
140
+ for (const f of rawFrames) {
141
+ if (!declaredNumbers.has(f.segmentNumber)) {
142
+ throw new MalformedSegmentationError(`frame ${f.frameIndex} references SegmentNumber ${f.segmentNumber}, which has no SegmentSequence entry`);
143
+ }
144
+ }
145
+ // --- geometry: distinct plane positions across every frame ---
146
+ const uniquePositions = [];
147
+ const tol = DEFAULT_TOLERANCE.positionMm;
148
+ for (const f of rawFrames) {
149
+ if (!uniquePositions.some((p) => Math.abs(dot(sub(p, f.position), normal)) <= tol
150
+ && Math.hypot(...sub(p, f.position)) <= tol)) {
151
+ uniquePositions.push(f.position);
152
+ }
153
+ }
154
+ const geometry = createGridGeometry({
155
+ rows: rows,
156
+ columns: columns,
157
+ rowDirection,
158
+ columnDirection,
159
+ pixelSpacing: [at(pixelSpacing, 0), at(pixelSpacing, 1)],
160
+ planePositions: uniquePositions,
161
+ frameOfReferenceUID: ds["FrameOfReferenceUID"],
162
+ });
163
+ // map each frame's IPP -> the sorted plane index
164
+ const planeProjections = geometry.planes.map((p) => dot(p.position, normal));
165
+ const planeIndexOf = (position) => {
166
+ const s = dot(position, normal);
167
+ let best = 0;
168
+ let bestDist = Infinity;
169
+ for (let i = 0; i < planeProjections.length; i++) {
170
+ const d = Math.abs(planeProjections[i] - s);
171
+ if (d < bestDist) {
172
+ bestDist = d;
173
+ best = i;
174
+ }
175
+ }
176
+ return best;
177
+ };
178
+ const frames = rawFrames.map((f) => ({
179
+ segmentNumber: f.segmentNumber,
180
+ planeIndex: planeIndexOf(f.position),
181
+ frameIndex: f.frameIndex,
182
+ }));
183
+ // --- pixel data ---
184
+ const pdBuffers = ds["PixelData"];
185
+ const first = Array.isArray(pdBuffers) ? pdBuffers[0] : pdBuffers;
186
+ if (!(first instanceof ArrayBuffer)) {
187
+ throw new MalformedSegmentationError("PixelData (7FE0,0010) is absent or not decodable as raw bytes");
188
+ }
189
+ const pixelData = new Uint8Array(first);
190
+ const rc = rows * columns;
191
+ let binaryFramesByteAligned = false;
192
+ if (segmentationType === "BINARY") {
193
+ const continuousBytes = Math.ceil((numberOfFrames * rc) / 8);
194
+ const perFrameBytes = numberOfFrames * Math.ceil(rc / 8);
195
+ if (pixelData.length >= continuousBytes) {
196
+ binaryFramesByteAligned = false;
197
+ }
198
+ else if (pixelData.length >= perFrameBytes && perFrameBytes !== continuousBytes) {
199
+ binaryFramesByteAligned = true;
200
+ diagnostics.push(createDiagnostic("BINARY_FRAMES_BYTE_ALIGNED", "info", "BINARY frames are individually byte-padded rather than packed as one continuous bitstream"));
201
+ }
202
+ else {
203
+ throw new MalformedSegmentationError(`PixelData is ${pixelData.length} bytes; a BINARY SEG of ${numberOfFrames} frames × ${rc} px needs ${continuousBytes}`);
204
+ }
205
+ }
206
+ else {
207
+ if (pixelData.length < numberOfFrames * rc) {
208
+ throw new MalformedSegmentationError(`PixelData is ${pixelData.length} bytes; a FRACTIONAL SEG of ${numberOfFrames} frames × ${rc} px needs ${numberOfFrames * rc}`);
209
+ }
210
+ }
211
+ // --- fractional-specific ---
212
+ let fractionalType;
213
+ let maximumFractionalValue;
214
+ if (segmentationType === "FRACTIONAL") {
215
+ const rawFrac = (ds["SegmentationFractionalType"] ?? "").toUpperCase();
216
+ if (rawFrac === "PROBABILITY" || rawFrac === "OCCUPANCY") {
217
+ fractionalType = rawFrac;
218
+ }
219
+ else {
220
+ diagnostics.push(createDiagnostic("FRACTIONAL_TYPE_ABSENT", "warning", "FRACTIONAL SEG did not declare SegmentationFractionalType (0062,0010); PROBABILITY vs OCCUPANCY is unknown"));
221
+ }
222
+ const maxRaw = ds["MaximumFractionalValue"];
223
+ if (Number.isFinite(maxRaw) && maxRaw > 0) {
224
+ maximumFractionalValue = maxRaw;
225
+ }
226
+ else {
227
+ maximumFractionalValue = 255;
228
+ diagnostics.push(createDiagnostic("MISSING_MAX_FRACTIONAL_VALUE", "warning", `MaximumFractionalValue (0062,000E) is ${maxRaw === undefined ? "absent" : `invalid (${maxRaw})`}; assuming 255`));
229
+ }
230
+ // Bimodal check (roadmap §7.1): a FRACTIONAL field whose non-zero values are almost all
231
+ // pinned at the max is a thresholded / binary mask stored as FRACTIONAL, not a graded
232
+ // probability or occupancy. One pass over the 8-bit pixel bytes.
233
+ const max = maximumFractionalValue;
234
+ const nearMax = max - Math.max(1, Math.floor(max * 0.02));
235
+ let nonZero = 0;
236
+ let atExtreme = 0;
237
+ for (let i = 0; i < pixelData.length; i++) {
238
+ const v = pixelData[i];
239
+ if (v === 0)
240
+ continue;
241
+ nonZero++;
242
+ if (v >= nearMax)
243
+ atExtreme++;
244
+ }
245
+ if (nonZero > 0 && atExtreme / nonZero >= 0.98) {
246
+ diagnostics.push(createDiagnostic("FRACTIONAL_VALUES_LOOK_BINARY", "warning", `${((atExtreme / nonZero) * 100).toFixed(1)}% of non-zero values are at (or within 2% of) MaximumFractionalValue ` +
247
+ `(${max}) — this ${fractionalType ?? "FRACTIONAL"} field is effectively a binary mask stored as FRACTIONAL, ` +
248
+ `not a graded field`));
249
+ }
250
+ }
251
+ const segmentsOverlap = (ds["SegmentsOverlap"] ?? "UNDEFINED").toUpperCase();
252
+ if (segmentsOverlap === "YES") {
253
+ diagnostics.push(createDiagnostic("SEGMENTS_OVERLAP", "info", "SegmentsOverlap is YES — segments may share voxels; do not assume a partition"));
254
+ }
255
+ return {
256
+ segmentationType,
257
+ fractionalType,
258
+ maximumFractionalValue,
259
+ segmentsOverlap,
260
+ geometry,
261
+ frameOfReferenceUID: ds["FrameOfReferenceUID"],
262
+ contentLabel: ds["ContentLabel"] || undefined,
263
+ rows: rows,
264
+ columns: columns,
265
+ numberOfFrames,
266
+ segments,
267
+ frames,
268
+ pixelData,
269
+ binaryFramesByteAligned,
270
+ diagnostics,
271
+ };
272
+ }
273
+ // The full continuous bitstream, unpacked once per ParsedSeg (BitArray.unpack returns
274
+ // one byte per bit, so this is ~8× the PixelData size — still cheap next to re-unpacking
275
+ // the whole stream for every frame, which is O(frames²) work on a large SEG).
276
+ const continuousBitsCache = new WeakMap();
277
+ /** Unpacked 0/1 bits for BINARY frame `frameIndex` (length rows·columns). */
278
+ export function binaryFrame(parsed, frameIndex) {
279
+ const rc = parsed.rows * parsed.columns;
280
+ const out = new Uint8Array(rc);
281
+ if (parsed.binaryFramesByteAligned) {
282
+ const frameBytes = Math.ceil(rc / 8);
283
+ const offset = frameIndex * frameBytes;
284
+ const bits = BitArray.unpack(parsed.pixelData.subarray(offset, offset + frameBytes));
285
+ for (let i = 0; i < rc; i++)
286
+ out[i] = bits[i] ? 1 : 0;
287
+ return out;
288
+ }
289
+ let allBits = continuousBitsCache.get(parsed);
290
+ if (!allBits) {
291
+ allBits = BitArray.unpack(parsed.pixelData);
292
+ continuousBitsCache.set(parsed, allBits);
293
+ }
294
+ const base = frameIndex * rc;
295
+ for (let i = 0; i < rc; i++)
296
+ out[i] = allBits[base + i] ? 1 : 0;
297
+ return out;
298
+ }
299
+ /** Raw 8-bit values for FRACTIONAL frame `frameIndex` (length rows·columns). */
300
+ export function fractionalFrame(parsed, frameIndex) {
301
+ const rc = parsed.rows * parsed.columns;
302
+ const offset = frameIndex * rc;
303
+ return parsed.pixelData.subarray(offset, offset + rc);
304
+ }
305
+ export function encodeSegFrames(options) {
306
+ const rows = options.rows;
307
+ const columns = options.columns;
308
+ const rc = rows * columns;
309
+ const rowDirection = options.rowDirection ?? [1, 0, 0];
310
+ const columnDirection = options.columnDirection ?? [0, 1, 0];
311
+ const isBinary = options.segmentationType === "BINARY";
312
+ const perFrameGroups = options.frames.map((f) => ({
313
+ FrameContentSequence: [{ StackID: "1", InStackPositionNumber: 1, DimensionIndexValues: [f.segmentNumber, 1] }],
314
+ PlanePositionSequence: [{ ImagePositionPatient: [...f.position] }],
315
+ SegmentIdentificationSequence: [{ ReferencedSegmentNumber: f.segmentNumber }],
316
+ }));
317
+ let pixelBuffer;
318
+ if (isBinary) {
319
+ const bits = [];
320
+ for (const f of options.frames)
321
+ for (let i = 0; i < rc; i++)
322
+ bits.push(f.pixels[i] ? 1 : 0);
323
+ pixelBuffer = BitArray.pack(bits).buffer.slice(0);
324
+ }
325
+ else {
326
+ const bytes = new Uint8Array(options.frames.length * rc);
327
+ options.frames.forEach((f, fi) => {
328
+ for (let i = 0; i < rc; i++)
329
+ bytes[fi * rc + i] = Math.max(0, Math.min(255, Math.round(f.pixels[i] ?? 0)));
330
+ });
331
+ pixelBuffer = bytes.buffer.slice(0);
332
+ }
333
+ const dataset = {
334
+ _meta: {},
335
+ SOPClassUID: options.sopClassUID ?? SEG_STORAGE_SOP_CLASS_UID,
336
+ SOPInstanceUID: DicomMetaDictionary.uid(),
337
+ Modality: options.modality ?? "SEG",
338
+ SamplesPerPixel: 1,
339
+ PhotometricInterpretation: "MONOCHROME2",
340
+ Rows: rows,
341
+ Columns: columns,
342
+ NumberOfFrames: options.frames.length,
343
+ BitsAllocated: isBinary ? 1 : 8,
344
+ BitsStored: isBinary ? 1 : 8,
345
+ HighBit: isBinary ? 0 : 7,
346
+ PixelRepresentation: 0,
347
+ SegmentationType: options.forceType ?? options.segmentationType,
348
+ SegmentsOverlap: options.segmentsOverlap ?? "NO",
349
+ FrameOfReferenceUID: options.frameOfReferenceUID ?? DicomMetaDictionary.uid(),
350
+ ContentLabel: "SEG",
351
+ SegmentSequence: options.segments.map((s) => {
352
+ const code = (c) => ({
353
+ CodeValue: c.value,
354
+ CodingSchemeDesignator: c.scheme,
355
+ CodeMeaning: c.meaning,
356
+ });
357
+ const item = {
358
+ SegmentNumber: s.number,
359
+ SegmentLabel: s.label ?? `segment-${s.number}`,
360
+ SegmentAlgorithmType: s.algorithmType ?? "AUTOMATIC",
361
+ SegmentAlgorithmName: s.algorithmName ?? "dicom-seg-js",
362
+ SegmentedPropertyCategoryCodeSequence: [
363
+ s.category ? code(s.category) : { CodeValue: "T-D0050", CodingSchemeDesignator: "SRT", CodeMeaning: "Tissue" },
364
+ ],
365
+ SegmentedPropertyTypeCodeSequence: [
366
+ s.propertyType ? code(s.propertyType) : { CodeValue: "T-62000", CodingSchemeDesignator: "SRT", CodeMeaning: "Liver" },
367
+ ],
368
+ };
369
+ if (s.propertyTypeModifier)
370
+ item["SegmentedPropertyTypeModifierCodeSequence"] = [code(s.propertyTypeModifier)];
371
+ if (s.trackingId !== undefined)
372
+ item["TrackingID"] = s.trackingId;
373
+ if (s.trackingUid !== undefined)
374
+ item["TrackingUID"] = s.trackingUid;
375
+ return item;
376
+ }),
377
+ SharedFunctionalGroupsSequence: [
378
+ {
379
+ PlaneOrientationSequence: [{ ImageOrientationPatient: [...rowDirection, ...columnDirection] }],
380
+ PixelMeasuresSequence: [
381
+ {
382
+ PixelSpacing: [...(options.pixelSpacing ?? [1, 1])],
383
+ SliceThickness: options.sliceThickness ?? 1,
384
+ SpacingBetweenSlices: options.sliceThickness ?? 1,
385
+ },
386
+ ],
387
+ },
388
+ ],
389
+ PerFrameFunctionalGroupsSequence: perFrameGroups,
390
+ };
391
+ if (!isBinary) {
392
+ if (!options.omitFractionalType)
393
+ dataset["SegmentationFractionalType"] = options.fractionalType ?? "PROBABILITY";
394
+ if (!options.omitMaximumFractionalValue)
395
+ dataset["MaximumFractionalValue"] = options.maximumFractionalValue ?? 255;
396
+ }
397
+ const meta = {
398
+ MediaStorageSOPClassUID: dataset["SOPClassUID"],
399
+ MediaStorageSOPInstanceUID: dataset["SOPInstanceUID"],
400
+ ImplementationClassUID: DicomMetaDictionary.uid(),
401
+ ImplementationVersionName: "dicom-seg-js",
402
+ TransferSyntaxUID: TRANSFER_SYNTAX_UID,
403
+ };
404
+ const dicomDict = new DicomDict(DicomMetaDictionary.denaturalizeDataset(meta));
405
+ dicomDict.dict = DicomMetaDictionary.denaturalizeDataset(dataset);
406
+ dicomDict.dict[TAG_PIXEL_DATA] = { vr: "OB", Value: [pixelBuffer] };
407
+ return dicomDict.write();
408
+ }
409
+ export function writeSeg(options) {
410
+ const isBinary = options.segmentationType === "BINARY";
411
+ if (options.segmentationType !== "BINARY" && options.segmentationType !== "FRACTIONAL") {
412
+ throw new TypeError(`writeSeg: segmentationType must be "BINARY" or "FRACTIONAL", got ${JSON.stringify(options.segmentationType)}`);
413
+ }
414
+ if (!isBinary && options.fractionalType === undefined) {
415
+ throw new TypeError('writeSeg: a FRACTIONAL segmentation requires an explicit fractionalType ("PROBABILITY" or "OCCUPANCY") — ' +
416
+ "there is no default (FRACTIONAL-SEG.md §1)");
417
+ }
418
+ if (options.segments.length === 0)
419
+ throw new RangeError("writeSeg: at least one segment is required");
420
+ const numbers = new Set();
421
+ for (const s of options.segments) {
422
+ if (!Number.isInteger(s.number) || s.number <= 0) {
423
+ throw new RangeError(`writeSeg: segment number must be a positive integer, got ${s.number}`);
424
+ }
425
+ if (numbers.has(s.number))
426
+ throw new RangeError(`writeSeg: duplicate segment number ${s.number}`);
427
+ numbers.add(s.number);
428
+ }
429
+ const maxFractional = options.maximumFractionalValue ?? 255;
430
+ if (!isBinary && (!Number.isInteger(maxFractional) || maxFractional < 1 || maxFractional > 255)) {
431
+ throw new RangeError(`writeSeg: maximumFractionalValue must be an integer in [1, 255], got ${maxFractional}`);
432
+ }
433
+ const rawScale = options.fieldScale === "raw";
434
+ const structures = options.segments.map((s) => {
435
+ const structure = isBinary ? s.mask : s.field;
436
+ if (!structure) {
437
+ throw new TypeError(`writeSeg: segment ${s.number} is missing its ${isBinary ? "mask" : "field"}`);
438
+ }
439
+ return structure;
440
+ });
441
+ const geometry = structures[0].geometry;
442
+ for (let i = 1; i < structures.length; i++) {
443
+ if (!structures[i].geometry.equals(geometry, options.tolerance)) {
444
+ throw new GridMismatchError(`writeSeg: segment ${options.segments[i].number}'s ${isBinary ? "mask" : "field"} is on a different grid than segment ${options.segments[0].number}'s — every segment must share one GridGeometry`);
445
+ }
446
+ }
447
+ const columns = geometry.columns;
448
+ const rows = geometry.rows;
449
+ const rc = columns * rows;
450
+ const planeCount = geometry.planes.length;
451
+ const planePositions = geometry.planes.map((p) => p.position);
452
+ const sliceThickness = planeCount >= 2 ? geometry.planeThicknessMm(0) : 1;
453
+ // One frame per (segment, plane) across the whole shared geometry — so
454
+ // writeSeg → readSeg is an exact identity on the grid, empty planes included.
455
+ // (Real files usually omit all-zero frames; reading handles that, sparse
456
+ // *writing* is a later feature.)
457
+ const frames = [];
458
+ options.segments.forEach((s, si) => {
459
+ const structure = structures[si];
460
+ for (let k = 0; k < planeCount; k++) {
461
+ const slice = structure.getSliceBuffer(k);
462
+ const pixels = new Uint8Array(rc);
463
+ for (let i = 0; i < rc; i++) {
464
+ const v = slice[i];
465
+ if (isBinary) {
466
+ pixels[i] = v !== 0 ? 1 : 0;
467
+ }
468
+ else {
469
+ let stored = Math.round(rawScale ? v : v * maxFractional);
470
+ if (stored < 0)
471
+ stored = 0;
472
+ if (stored > maxFractional)
473
+ stored = maxFractional;
474
+ pixels[i] = stored;
475
+ }
476
+ }
477
+ frames.push({ segmentNumber: s.number, position: planePositions[k], pixels });
478
+ }
479
+ });
480
+ const segmentsOverlap = options.segmentsOverlap ?? (options.segments.length === 1 ? "NO" : "UNDEFINED");
481
+ const encodeOptions = {
482
+ rows,
483
+ columns,
484
+ segmentationType: options.segmentationType,
485
+ rowDirection: geometry.rowDirection,
486
+ columnDirection: geometry.columnDirection,
487
+ pixelSpacing: geometry.pixelSpacing,
488
+ sliceThickness,
489
+ segmentsOverlap,
490
+ segments: options.segments.map((s) => ({
491
+ number: s.number,
492
+ label: s.label,
493
+ algorithmType: s.algorithmType ?? "AUTOMATIC",
494
+ algorithmName: s.algorithmName ?? "dicom-seg-js",
495
+ ...(s.category ? { category: s.category } : {}),
496
+ ...(s.propertyType ? { propertyType: s.propertyType } : {}),
497
+ ...(s.propertyTypeModifier ? { propertyTypeModifier: s.propertyTypeModifier } : {}),
498
+ ...(s.trackingId !== undefined ? { trackingId: s.trackingId } : {}),
499
+ ...(s.trackingUid !== undefined ? { trackingUid: s.trackingUid } : {}),
500
+ })),
501
+ frames,
502
+ ...(options.frameOfReferenceUID ?? geometry.frameOfReferenceUID
503
+ ? { frameOfReferenceUID: (options.frameOfReferenceUID ?? geometry.frameOfReferenceUID) }
504
+ : {}),
505
+ ...(isBinary ? {} : { fractionalType: options.fractionalType, maximumFractionalValue: maxFractional }),
506
+ };
507
+ return encodeSegFrames(encodeOptions);
508
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * dicom-seg-js-specific errors. Geometry-core errors (ResourceLimitError,
3
+ * GridMismatchError, NonParallelPlanesError, …) live in rt-geometry-js and are re-exported
4
+ * from this package's entry point.
5
+ */
6
+ /**
7
+ * The supplied bytes are not a DICOM Segmentation. `SOPClassUID` (0008,0016) is present and
8
+ * is not Segmentation Storage (`1.2.840.10008.5.1.4.1.1.66.4`), and `Modality` (0008,0060)
9
+ * is not `"SEG"`.
10
+ */
11
+ export declare class NotSegmentationError extends Error {
12
+ constructor(message: string);
13
+ }
14
+ /**
15
+ * The segmentation cannot be assembled: a required element is missing
16
+ * (`SegmentSequence`, the shared `PixelMeasuresSequence` / `PlaneOrientationSequence`,
17
+ * `Rows`/`Columns`), the per-frame functional-group count disagrees with `NumberOfFrames`,
18
+ * a frame references an undeclared `SegmentNumber`, or the decoded `PixelData` is too
19
+ * short for the frame count.
20
+ */
21
+ export declare class MalformedSegmentationError extends Error {
22
+ constructor(message: string);
23
+ }
24
+ /**
25
+ * `SegmentationType` (0062,0001) is `LABELMAP`. dicom-seg-js 0.1.0 reads `BINARY` and
26
+ * `FRACTIONAL` only; LABELMAP (PS3.3 Supplement 243) support is planned for 0.2.0.
27
+ */
28
+ export declare class UnsupportedSegmentationTypeError extends Error {
29
+ constructor(message: string);
30
+ }
31
+ /**
32
+ * `mask()` was called on a FRACTIONAL segmentation, or `field()` on a BINARY one. The two
33
+ * are not interchangeable and there is no safe default threshold to turn a probability
34
+ * field into a mask (roadmap §7.1) — the caller must pick one explicitly.
35
+ */
36
+ export declare class SegmentationTypeMismatchError extends Error {
37
+ constructor(message: string);
38
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,50 @@
1
+ /**
2
+ * dicom-seg-js-specific errors. Geometry-core errors (ResourceLimitError,
3
+ * GridMismatchError, NonParallelPlanesError, …) live in rt-geometry-js and are re-exported
4
+ * from this package's entry point.
5
+ */
6
+ /**
7
+ * The supplied bytes are not a DICOM Segmentation. `SOPClassUID` (0008,0016) is present and
8
+ * is not Segmentation Storage (`1.2.840.10008.5.1.4.1.1.66.4`), and `Modality` (0008,0060)
9
+ * is not `"SEG"`.
10
+ */
11
+ export class NotSegmentationError extends Error {
12
+ constructor(message) {
13
+ super(`NotSegmentationError: ${message}`);
14
+ this.name = "NotSegmentationError";
15
+ }
16
+ }
17
+ /**
18
+ * The segmentation cannot be assembled: a required element is missing
19
+ * (`SegmentSequence`, the shared `PixelMeasuresSequence` / `PlaneOrientationSequence`,
20
+ * `Rows`/`Columns`), the per-frame functional-group count disagrees with `NumberOfFrames`,
21
+ * a frame references an undeclared `SegmentNumber`, or the decoded `PixelData` is too
22
+ * short for the frame count.
23
+ */
24
+ export class MalformedSegmentationError extends Error {
25
+ constructor(message) {
26
+ super(`MalformedSegmentationError: ${message}`);
27
+ this.name = "MalformedSegmentationError";
28
+ }
29
+ }
30
+ /**
31
+ * `SegmentationType` (0062,0001) is `LABELMAP`. dicom-seg-js 0.1.0 reads `BINARY` and
32
+ * `FRACTIONAL` only; LABELMAP (PS3.3 Supplement 243) support is planned for 0.2.0.
33
+ */
34
+ export class UnsupportedSegmentationTypeError extends Error {
35
+ constructor(message) {
36
+ super(`UnsupportedSegmentationTypeError: ${message}`);
37
+ this.name = "UnsupportedSegmentationTypeError";
38
+ }
39
+ }
40
+ /**
41
+ * `mask()` was called on a FRACTIONAL segmentation, or `field()` on a BINARY one. The two
42
+ * are not interchangeable and there is no safe default threshold to turn a probability
43
+ * field into a mask (roadmap §7.1) — the caller must pick one explicitly.
44
+ */
45
+ export class SegmentationTypeMismatchError extends Error {
46
+ constructor(message) {
47
+ super(`SegmentationTypeMismatchError: ${message}`);
48
+ this.name = "SegmentationTypeMismatchError";
49
+ }
50
+ }