dicom-seg-js 0.1.0 → 0.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/CHANGELOG.md CHANGED
@@ -1,5 +1,39 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.2.0] - 2026-08-30
4
+
5
+ Additive — `^1.0.0` peer unchanged.
6
+
7
+ ### Added
8
+
9
+ - **`LABELMAP` (PS3.3 Sup 243) read and write.** `readSeg` accepts a LABELMAP SEG
10
+ (one 8- or 16-bit integer-per-pixel frame per plane, pixel value = `SegmentNumber`,
11
+ `0` = background); `seg.type === "LABELMAP"` and `seg.mask(n)` returns the voxels whose
12
+ label is `n`. `writeSeg({ segmentationType: "LABELMAP", segments: [{ number, mask }] })`
13
+ writes one — 8-bit unless a segment number exceeds 255. LABELMAP is a partition:
14
+ overlapping input masks throw the new `LabelmapOverlapError`. `seg.field()` throws
15
+ `SegmentationTypeMismatchError` on a LABELMAP.
16
+ - **`writeSeg({ frameCoverage: "sparse" })`** — omit a frame whose slice is all-background.
17
+ Default stays `"full"` (one frame per segment × plane, exact `writeSeg` → `readSeg`
18
+ identity). Under `"sparse"` the reader reconstructs the grid only across planes that have
19
+ a frame; an all-empty segment throws `RangeError`.
20
+ - `Segmentation.numberOfFrames` — the stored frame count (`< segments × planes` for a
21
+ sparse SEG; one per plane for LABELMAP).
22
+
23
+ ### Changed
24
+
25
+ - `UnsupportedSegmentationTypeError` is no longer raised for LABELMAP (now supported); it
26
+ is reserved for an unknown/vendor `SegmentationType`. An absent `SegmentationType` is a
27
+ `MalformedSegmentationError`.
28
+ - `mask()` now serves BINARY *and* LABELMAP; `field()`'s type-mismatch message names the
29
+ actual type.
30
+
31
+ ## [0.1.1] - 2026-08-30
32
+
33
+ No API change. Peer dependency `rt-geometry-js` bumped `^0.1.2` → `^1.0.0` — the shared
34
+ core was promoted to a stable major (identical code to its 0.1.2, see
35
+ `rt-geometry-js/CONTRACT.md`).
36
+
3
37
  ## [0.1.0] - 2026-08-29
4
38
 
5
39
  First release. DICOM Segmentation (SEG) **read and write** — `BINARY` masks and
package/README.md CHANGED
@@ -4,9 +4,11 @@ DICOM **Segmentation (SEG)** reading and writing for JavaScript/TypeScript, buil
4
4
  [`rt-geometry-js`](https://www.npmjs.com/package/rt-geometry-js). Part of the
5
5
  [DICOM imaging toolkit](https://github.com/adeelbarki/dicom-imaging-toolkit-packages).
6
6
 
7
- **Status:** 0.1.0 first release. Read **and write** `BINARY` masks and `FRACTIONAL`
8
- probability/occupancy fields. `LABELMAP` (PS3.3 Sup 243) lands in 0.2.0. Requires the peer
9
- dependency [`rt-geometry-js`](https://www.npmjs.com/package/rt-geometry-js) (`^0.1.2`);
7
+ **Status:** published — [`dicom-seg-js`](https://www.npmjs.com/package/dicom-seg-js) 0.2.0 on npm. Reads **and writes** `BINARY` masks, `FRACTIONAL`
8
+ probability/occupancy fields, and **`LABELMAP`** (PS3.3 Sup 243) one label per pixel,
9
+ `seg.mask(n)` returns the voxels whose label is `n`. `writeSeg` also takes
10
+ `frameCoverage: "sparse"` to omit all-background frames. Requires the peer
11
+ dependency [`rt-geometry-js`](https://www.npmjs.com/package/rt-geometry-js) (`^1.0.0`);
10
12
  `npm install dicom-seg-js rt-geometry-js`.
11
13
 
12
14
  **Validated against real DICOM files**, not just its own fixtures — `dicom-seg-js`'s
@@ -87,32 +89,41 @@ const probBytes = writeSeg({
87
89
  ```
88
90
 
89
91
  `writeSeg` derives the SEG grid from the first segment's mask/field; every other segment
90
- must be on the same grid (`GridMismatchError` otherwise). One frame is written per
91
- `(segment, plane)` across the full grid, so `writeSeg` → `readSeg` is an exact round trip
92
- (sparse writing, which omits all-zero frames, is a 0.2.0 feature). Pass
93
- `fieldScale: "raw"` when your `field` values are already integers in
92
+ must be on the same grid (`GridMismatchError` otherwise). By default (`frameCoverage:
93
+ "full"`) one frame is written per `(segment, plane)` across the whole grid, so `writeSeg` →
94
+ `readSeg` is an exact round trip. `frameCoverage: "sparse"` omits a frame whose slice is
95
+ all-background smaller output, matching how real files are stored, but the reader can
96
+ only reconstruct the grid across planes that have a frame (empty-plane extent is not
97
+ preserved). Pass `fieldScale: "raw"` when your `field` values are already integers in
94
98
  `[0, maximumFractionalValue]` rather than `[0, 1]`.
95
99
 
100
+ **LABELMAP** (`segmentationType: "LABELMAP"`) writes one integer-per-pixel frame per plane,
101
+ each pixel a `SegmentNumber` (`0` = background); 8-bit unless a segment number exceeds 255,
102
+ then 16-bit. It is a **partition** — overlapping input masks raise `LabelmapOverlapError`.
103
+ LABELMAP is multi-*segment*, a different axis from FRACTIONAL's multi-*confidence*; there
104
+ is no `seg.field()` for it.
105
+
96
106
  ## What it reads
97
107
 
98
108
  | Element | Handling |
99
109
  |---|---|
100
- | `SegmentationType` (0062,0001) | `BINARY` → `mask(n)`; `FRACTIONAL` → `field(n)` / `rawField(n)`. `LABELMAP` → `UnsupportedSegmentationTypeError` (0.2.0) |
110
+ | `SegmentationType` (0062,0001) | `BINARY` → `mask(n)`; `FRACTIONAL` → `field(n)` / `rawField(n)`; `LABELMAP` → `mask(n)` (voxels whose label is `n`). An unknown value → `UnsupportedSegmentationTypeError` |
101
111
  | `SegmentSequence` (0062,0002) | number, label, algorithm type/name, coded `SegmentedPropertyCategory` / `Type` / `TypeModifier`, `TrackingID` / `TrackingUID` |
102
112
  | `SegmentationFractionalType` (0062,0010) | surfaced on `seg.fractionalType`; **never defaulted** — absent is `undefined` + a diagnostic. A field whose non-zero values are ≥ 98% at the max raises `FRACTIONAL_VALUES_LOOK_BINARY` (a binary mask stored as FRACTIONAL) |
103
113
  | `MaximumFractionalValue` (0062,000E) | used to rescale `field(n)` to 0..1; `rawField(n)` keeps the integers. Absent → assumed 255 + a diagnostic |
104
114
  | `SegmentsOverlap` (0062,0013) | surfaced on `seg.segmentsOverlap`; `YES` also raises a diagnostic |
105
115
  | Per-Frame / Shared Functional Groups | plane positions, orientation, pixel spacing → one `GridGeometry` spanning every frame position |
106
- | `PixelData` | BINARY bit-unpacked (continuous bitstream; the byte-aligned-per-frame variant is detected and handled with a diagnostic). FRACTIONAL 8-bit |
116
+ | `PixelData` | BINARY bit-unpacked (continuous bitstream; the byte-aligned-per-frame variant is detected and handled with a diagnostic). FRACTIONAL 8-bit. LABELMAP 8- or 16-bit little-endian integers |
107
117
 
108
118
  The SEG's grid is **not** required to match any source image series. To compare a segment
109
119
  against a CT or a dose grid, resample it — `resampleMask` / `resampleField` from
110
120
  `rt-geometry-js` are re-exported here.
111
121
 
112
122
  Parsing raises `NotSegmentationError` for a non-SEG SOP class,
113
- `UnsupportedSegmentationTypeError` for LABELMAP, and `MalformedSegmentationError` when the
114
- object can't be assembled (missing `SegmentSequence` / shared functional groups, per-frame
115
- count ≠ `NumberOfFrames`, a frame referencing an undeclared segment, short `PixelData`).
123
+ `UnsupportedSegmentationTypeError` for an unknown `SegmentationType`, and
124
+ `MalformedSegmentationError` when the object can't be assembled (missing `SegmentSequence`
125
+ / shared functional groups, per-frame count ≠ `NumberOfFrames`, a frame referencing an
126
+ undeclared segment, short `PixelData`, LABELMAP `BitsAllocated` not 8/16).
116
127
 
117
128
  ## License
118
129
 
@@ -19,11 +19,14 @@ export interface ParsedSeg {
19
19
  readonly numberOfFrames: number;
20
20
  readonly segments: readonly SegmentInfo[];
21
21
  readonly frames: readonly FrameRef[];
22
- /** Raw PixelData bytes (bit-packed for BINARY, one byte per pixel for FRACTIONAL). */
22
+ /** Raw PixelData bytes (bit-packed for BINARY, one byte per pixel for FRACTIONAL,
23
+ * 8- or 16-bit integers per pixel for LABELMAP). */
23
24
  readonly pixelData: Uint8Array;
24
25
  /** True when BINARY frames are individually padded to a byte boundary rather than
25
26
  * packed as one continuous bitstream (a non-conformant but real variant). */
26
27
  readonly binaryFramesByteAligned: boolean;
28
+ /** LABELMAP only — bits per label pixel (8 or 16). */
29
+ readonly labelmapBitsAllocated: 8 | 16 | undefined;
27
30
  readonly diagnostics: readonly Diagnostic[];
28
31
  }
29
32
  /** Parse one SEG object's bytes. Throws NotSegmentationError / MalformedSegmentationError /
@@ -33,6 +36,9 @@ export declare function readSegDataset(bytes: ArrayBuffer): ParsedSeg;
33
36
  export declare function binaryFrame(parsed: ParsedSeg, frameIndex: number): Uint8Array;
34
37
  /** Raw 8-bit values for FRACTIONAL frame `frameIndex` (length rows·columns). */
35
38
  export declare function fractionalFrame(parsed: ParsedSeg, frameIndex: number): Uint8Array;
39
+ /** Label values for LABELMAP frame `frameIndex` (length rows·columns; each entry a
40
+ * SegmentNumber, 0 = background). Handles 8- and 16-bit little-endian pixel data. */
41
+ export declare function labelmapFrame(parsed: ParsedSeg, frameIndex: number): Uint8Array | Uint16Array;
36
42
  export interface EncodeSegSegment {
37
43
  readonly number: number;
38
44
  readonly label?: string;
@@ -45,9 +51,12 @@ export interface EncodeSegSegment {
45
51
  readonly trackingUid?: string;
46
52
  }
47
53
  export interface WriteSegFrame {
54
+ /** BINARY / FRACTIONAL: the segment this frame belongs to. LABELMAP: unused (0) — one
55
+ * frame per plane carries every label. */
48
56
  readonly segmentNumber: number;
49
57
  readonly position: Vec3;
50
- /** BINARY: 0/1 per pixel. FRACTIONAL: 0..maximumFractionalValue per pixel. Length rows·columns. */
58
+ /** BINARY: 0/1 per pixel. FRACTIONAL: 0..maximumFractionalValue per pixel. LABELMAP: a
59
+ * SegmentNumber (0 = background) per pixel. Length rows·columns. */
51
60
  readonly pixels: ArrayLike<number>;
52
61
  }
53
62
  export interface EncodeSegOptions {
@@ -62,6 +71,8 @@ export interface EncodeSegOptions {
62
71
  readonly segmentsOverlap?: SegmentsOverlap;
63
72
  readonly fractionalType?: FractionalType;
64
73
  readonly maximumFractionalValue?: number;
74
+ /** LABELMAP only — bits per label pixel. Default 8. */
75
+ readonly labelmapBitsAllocated?: 8 | 16;
65
76
  readonly segments: readonly EncodeSegSegment[];
66
77
  readonly frames: readonly WriteSegFrame[];
67
78
  /** Override to exercise NotSegmentationError. */
@@ -107,8 +118,16 @@ export interface WriteSegOptions {
107
118
  */
108
119
  readonly fieldScale?: "unit" | "raw";
109
120
  readonly segments: readonly WriteSegSegment[];
110
- /** Default `"NO"` for one segment, `"UNDEFINED"` for more. */
121
+ /** Default `"NO"` for one segment, `"UNDEFINED"` for more. Forced to `"NO"` for LABELMAP
122
+ * (a partition). */
111
123
  readonly segmentsOverlap?: SegmentsOverlap;
124
+ /**
125
+ * `"full"` (default) — one frame per (segment, plane) over the whole grid, so
126
+ * `writeSeg` → `readSeg` is an exact identity, empty planes included. `"sparse"` — omit a
127
+ * frame whose slice is entirely background (BINARY/FRACTIONAL: all-zero; LABELMAP:
128
+ * all-zero after labelling). Matches how real files are stored; `readSeg` reads either.
129
+ */
130
+ readonly frameCoverage?: "full" | "sparse";
112
131
  /** Default: the shared geometry's `frameOfReferenceUID`, else a fresh UID. */
113
132
  readonly frameOfReferenceUID?: string;
114
133
  readonly contentLabel?: string;
@@ -1,6 +1,6 @@
1
1
  import { createRequire } from "node:module";
2
2
  import { createDiagnostic, createGridGeometry, cross, DEFAULT_TOLERANCE, dot, normalize, sub, GridMismatchError, } from "rt-geometry-js";
3
- import { MalformedSegmentationError, NotSegmentationError, UnsupportedSegmentationTypeError, } from "../errors.js";
3
+ import { LabelmapOverlapError, MalformedSegmentationError, NotSegmentationError, UnsupportedSegmentationTypeError, } from "../errors.js";
4
4
  // THE ONLY dcmjs importer in dicom-seg-js — mirrors rtstruct-js / rtdose-js port.ts.
5
5
  // dcmjs's ESM build is CJS-with-export-syntax and trips Node's resolver; require() hits
6
6
  // the genuinely-CJS condition instead.
@@ -58,13 +58,14 @@ export function readSegDataset(bytes) {
58
58
  throw new NotSegmentationError(`SOPClassUID ${sopClassUID} is not Segmentation Storage (${SEG_STORAGE_SOP_CLASS_UID}) and Modality is ${modality ?? "absent"}`);
59
59
  }
60
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`);
61
+ if (rawType !== "BINARY" && rawType !== "FRACTIONAL" && rawType !== "LABELMAP") {
62
+ if (rawType === "") {
63
+ throw new MalformedSegmentationError("SegmentationType (0062,0001) is absent, expected BINARY, FRACTIONAL, or LABELMAP");
64
+ }
65
+ throw new UnsupportedSegmentationTypeError(`SegmentationType is ${JSON.stringify(rawType)} dicom-seg-js handles BINARY, FRACTIONAL, and LABELMAP`);
66
66
  }
67
67
  const segmentationType = rawType;
68
+ const isLabelmap = segmentationType === "LABELMAP";
68
69
  const rows = ds["Rows"];
69
70
  const columns = ds["Columns"];
70
71
  const numberOfFrames = ds["NumberOfFrames"] ?? 0;
@@ -96,9 +97,12 @@ export function readSegDataset(bytes) {
96
97
  }
97
98
  const rawFrames = perFrame.map((fg, frameIndex) => {
98
99
  const segId = asArray(fg["SegmentIdentificationSequence"])[0];
99
- const segmentNumber = Number(segId?.["ReferencedSegmentNumber"]);
100
+ const rawSegNum = Number(segId?.["ReferencedSegmentNumber"]);
101
+ // LABELMAP frames carry every segment via the pixel value, so a per-frame
102
+ // SegmentIdentificationSequence is optional; 0 stands for "all labels".
103
+ const segmentNumber = isLabelmap ? (Number.isInteger(rawSegNum) && rawSegNum > 0 ? rawSegNum : 0) : rawSegNum;
100
104
  const ipp = asNumberArray(asArray(fg["PlanePositionSequence"])[0]?.["ImagePositionPatient"]);
101
- if (!Number.isInteger(segmentNumber) || segmentNumber <= 0) {
105
+ if (!isLabelmap && (!Number.isInteger(segmentNumber) || segmentNumber <= 0)) {
102
106
  throw new MalformedSegmentationError(`frame ${frameIndex}: missing SegmentIdentificationSequence/ReferencedSegmentNumber`);
103
107
  }
104
108
  if (!ipp || ipp.length !== 3) {
@@ -134,12 +138,16 @@ export function readSegDataset(bytes) {
134
138
  propertyTypeModifier: readCode(s["SegmentedPropertyTypeModifierCodeSequence"]),
135
139
  trackingId: s["TrackingID"] || undefined,
136
140
  trackingUid: s["TrackingUID"] || undefined,
137
- frameCount: framesBySegment.get(number) ?? 0,
141
+ // LABELMAP stores one frame per plane carrying every label, so a per-segment count
142
+ // isn't meaningful before the pixels are scanned — report the plane count.
143
+ frameCount: isLabelmap ? perFrame.length : framesBySegment.get(number) ?? 0,
138
144
  };
139
145
  });
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`);
146
+ if (!isLabelmap) {
147
+ for (const f of rawFrames) {
148
+ if (!declaredNumbers.has(f.segmentNumber)) {
149
+ throw new MalformedSegmentationError(`frame ${f.frameIndex} references SegmentNumber ${f.segmentNumber}, which has no SegmentSequence entry`);
150
+ }
143
151
  }
144
152
  }
145
153
  // --- geometry: distinct plane positions across every frame ---
@@ -189,7 +197,19 @@ export function readSegDataset(bytes) {
189
197
  const pixelData = new Uint8Array(first);
190
198
  const rc = rows * columns;
191
199
  let binaryFramesByteAligned = false;
192
- if (segmentationType === "BINARY") {
200
+ let labelmapBitsAllocated;
201
+ if (isLabelmap) {
202
+ const ba = Number(ds["BitsAllocated"]);
203
+ if (ba !== 8 && ba !== 16) {
204
+ throw new MalformedSegmentationError(`LABELMAP BitsAllocated is ${ds["BitsAllocated"]}, expected 8 or 16`);
205
+ }
206
+ labelmapBitsAllocated = ba;
207
+ const need = numberOfFrames * rc * (ba / 8);
208
+ if (pixelData.length < need) {
209
+ throw new MalformedSegmentationError(`PixelData is ${pixelData.length} bytes; a LABELMAP SEG of ${numberOfFrames} frames × ${rc} px × ${ba / 8} B needs ${need}`);
210
+ }
211
+ }
212
+ else if (segmentationType === "BINARY") {
193
213
  const continuousBytes = Math.ceil((numberOfFrames * rc) / 8);
194
214
  const perFrameBytes = numberOfFrames * Math.ceil(rc / 8);
195
215
  if (pixelData.length >= continuousBytes) {
@@ -267,6 +287,7 @@ export function readSegDataset(bytes) {
267
287
  frames,
268
288
  pixelData,
269
289
  binaryFramesByteAligned,
290
+ labelmapBitsAllocated,
270
291
  diagnostics,
271
292
  };
272
293
  }
@@ -302,6 +323,23 @@ export function fractionalFrame(parsed, frameIndex) {
302
323
  const offset = frameIndex * rc;
303
324
  return parsed.pixelData.subarray(offset, offset + rc);
304
325
  }
326
+ /** Label values for LABELMAP frame `frameIndex` (length rows·columns; each entry a
327
+ * SegmentNumber, 0 = background). Handles 8- and 16-bit little-endian pixel data. */
328
+ export function labelmapFrame(parsed, frameIndex) {
329
+ const rc = parsed.rows * parsed.columns;
330
+ if (parsed.labelmapBitsAllocated === 16) {
331
+ const byteOffset = parsed.pixelData.byteOffset + frameIndex * rc * 2;
332
+ // A copy, so callers can't accidentally mutate the shared PixelData, and to sidestep
333
+ // any alignment constraint on the underlying buffer.
334
+ const out = new Uint16Array(rc);
335
+ const view = new DataView(parsed.pixelData.buffer, byteOffset, rc * 2);
336
+ for (let i = 0; i < rc; i++)
337
+ out[i] = view.getUint16(i * 2, true);
338
+ return out;
339
+ }
340
+ const offset = frameIndex * rc;
341
+ return parsed.pixelData.subarray(offset, offset + rc);
342
+ }
305
343
  export function encodeSegFrames(options) {
306
344
  const rows = options.rows;
307
345
  const columns = options.columns;
@@ -309,10 +347,13 @@ export function encodeSegFrames(options) {
309
347
  const rowDirection = options.rowDirection ?? [1, 0, 0];
310
348
  const columnDirection = options.columnDirection ?? [0, 1, 0];
311
349
  const isBinary = options.segmentationType === "BINARY";
350
+ const isLabelmap = options.segmentationType === "LABELMAP";
351
+ const labelmapBits = options.labelmapBitsAllocated ?? 8;
312
352
  const perFrameGroups = options.frames.map((f) => ({
313
- FrameContentSequence: [{ StackID: "1", InStackPositionNumber: 1, DimensionIndexValues: [f.segmentNumber, 1] }],
353
+ FrameContentSequence: [{ StackID: "1", InStackPositionNumber: 1, DimensionIndexValues: [f.segmentNumber || 1, 1] }],
314
354
  PlanePositionSequence: [{ ImagePositionPatient: [...f.position] }],
315
- SegmentIdentificationSequence: [{ ReferencedSegmentNumber: f.segmentNumber }],
355
+ // LABELMAP carries the segment in the pixel value — no per-frame SegmentIdentificationSequence.
356
+ ...(isLabelmap ? {} : { SegmentIdentificationSequence: [{ ReferencedSegmentNumber: f.segmentNumber }] }),
316
357
  }));
317
358
  let pixelBuffer;
318
359
  if (isBinary) {
@@ -322,6 +363,25 @@ export function encodeSegFrames(options) {
322
363
  bits.push(f.pixels[i] ? 1 : 0);
323
364
  pixelBuffer = BitArray.pack(bits).buffer.slice(0);
324
365
  }
366
+ else if (isLabelmap) {
367
+ const n = options.frames.length * rc;
368
+ if (labelmapBits === 16) {
369
+ const words = new Uint16Array(n);
370
+ options.frames.forEach((f, fi) => {
371
+ for (let i = 0; i < rc; i++)
372
+ words[fi * rc + i] = Math.max(0, Math.round(f.pixels[i] ?? 0)) & 0xffff;
373
+ });
374
+ pixelBuffer = words.buffer.slice(0);
375
+ }
376
+ else {
377
+ const bytes = new Uint8Array(n);
378
+ options.frames.forEach((f, fi) => {
379
+ for (let i = 0; i < rc; i++)
380
+ bytes[fi * rc + i] = Math.max(0, Math.min(255, Math.round(f.pixels[i] ?? 0)));
381
+ });
382
+ pixelBuffer = bytes.buffer.slice(0);
383
+ }
384
+ }
325
385
  else {
326
386
  const bytes = new Uint8Array(options.frames.length * rc);
327
387
  options.frames.forEach((f, fi) => {
@@ -340,9 +400,9 @@ export function encodeSegFrames(options) {
340
400
  Rows: rows,
341
401
  Columns: columns,
342
402
  NumberOfFrames: options.frames.length,
343
- BitsAllocated: isBinary ? 1 : 8,
344
- BitsStored: isBinary ? 1 : 8,
345
- HighBit: isBinary ? 0 : 7,
403
+ BitsAllocated: isBinary ? 1 : isLabelmap ? labelmapBits : 8,
404
+ BitsStored: isBinary ? 1 : isLabelmap ? labelmapBits : 8,
405
+ HighBit: isBinary ? 0 : isLabelmap ? labelmapBits - 1 : 7,
346
406
  PixelRepresentation: 0,
347
407
  SegmentationType: options.forceType ?? options.segmentationType,
348
408
  SegmentsOverlap: options.segmentsOverlap ?? "NO",
@@ -388,7 +448,7 @@ export function encodeSegFrames(options) {
388
448
  ],
389
449
  PerFrameFunctionalGroupsSequence: perFrameGroups,
390
450
  };
391
- if (!isBinary) {
451
+ if (!isBinary && !isLabelmap) {
392
452
  if (!options.omitFractionalType)
393
453
  dataset["SegmentationFractionalType"] = options.fractionalType ?? "PROBABILITY";
394
454
  if (!options.omitMaximumFractionalValue)
@@ -403,20 +463,23 @@ export function encodeSegFrames(options) {
403
463
  };
404
464
  const dicomDict = new DicomDict(DicomMetaDictionary.denaturalizeDataset(meta));
405
465
  dicomDict.dict = DicomMetaDictionary.denaturalizeDataset(dataset);
406
- dicomDict.dict[TAG_PIXEL_DATA] = { vr: "OB", Value: [pixelBuffer] };
466
+ dicomDict.dict[TAG_PIXEL_DATA] = { vr: isLabelmap && labelmapBits === 16 ? "OW" : "OB", Value: [pixelBuffer] };
407
467
  return dicomDict.write();
408
468
  }
409
469
  export function writeSeg(options) {
410
470
  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)}`);
471
+ const isLabelmap = options.segmentationType === "LABELMAP";
472
+ const isFractional = options.segmentationType === "FRACTIONAL";
473
+ if (!isBinary && !isFractional && !isLabelmap) {
474
+ throw new TypeError(`writeSeg: segmentationType must be "BINARY", "FRACTIONAL", or "LABELMAP", got ${JSON.stringify(options.segmentationType)}`);
413
475
  }
414
- if (!isBinary && options.fractionalType === undefined) {
476
+ if (isFractional && options.fractionalType === undefined) {
415
477
  throw new TypeError('writeSeg: a FRACTIONAL segmentation requires an explicit fractionalType ("PROBABILITY" or "OCCUPANCY") — ' +
416
478
  "there is no default (FRACTIONAL-SEG.md §1)");
417
479
  }
418
480
  if (options.segments.length === 0)
419
481
  throw new RangeError("writeSeg: at least one segment is required");
482
+ const sparse = options.frameCoverage === "sparse";
420
483
  const numbers = new Set();
421
484
  for (const s of options.segments) {
422
485
  if (!Number.isInteger(s.number) || s.number <= 0) {
@@ -427,21 +490,22 @@ export function writeSeg(options) {
427
490
  numbers.add(s.number);
428
491
  }
429
492
  const maxFractional = options.maximumFractionalValue ?? 255;
430
- if (!isBinary && (!Number.isInteger(maxFractional) || maxFractional < 1 || maxFractional > 255)) {
493
+ if (isFractional && (!Number.isInteger(maxFractional) || maxFractional < 1 || maxFractional > 255)) {
431
494
  throw new RangeError(`writeSeg: maximumFractionalValue must be an integer in [1, 255], got ${maxFractional}`);
432
495
  }
433
496
  const rawScale = options.fieldScale === "raw";
497
+ const wantsMask = isBinary || isLabelmap;
434
498
  const structures = options.segments.map((s) => {
435
- const structure = isBinary ? s.mask : s.field;
499
+ const structure = wantsMask ? s.mask : s.field;
436
500
  if (!structure) {
437
- throw new TypeError(`writeSeg: segment ${s.number} is missing its ${isBinary ? "mask" : "field"}`);
501
+ throw new TypeError(`writeSeg: segment ${s.number} is missing its ${wantsMask ? "mask" : "field"}`);
438
502
  }
439
503
  return structure;
440
504
  });
441
505
  const geometry = structures[0].geometry;
442
506
  for (let i = 1; i < structures.length; i++) {
443
507
  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`);
508
+ throw new GridMismatchError(`writeSeg: segment ${options.segments[i].number}'s ${wantsMask ? "mask" : "field"} is on a different grid than segment ${options.segments[0].number}'s — every segment must share one GridGeometry`);
445
509
  }
446
510
  }
447
511
  const columns = geometry.columns;
@@ -450,34 +514,70 @@ export function writeSeg(options) {
450
514
  const planeCount = geometry.planes.length;
451
515
  const planePositions = geometry.planes.map((p) => p.position);
452
516
  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.)
517
+ // `"full"` (default): one frame per (segment, plane) over the whole grid, so
518
+ // writeSeg → readSeg is an exact identity. `"sparse"`: skip an all-background slice.
457
519
  const frames = [];
458
- options.segments.forEach((s, si) => {
459
- const structure = structures[si];
520
+ let labelmapBits = 8;
521
+ if (isLabelmap) {
522
+ const maxSeg = Math.max(...options.segments.map((s) => s.number));
523
+ labelmapBits = maxSeg > 255 ? 16 : 8;
460
524
  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;
525
+ const pixels = maxSeg > 255 ? new Uint16Array(rc) : new Uint8Array(rc);
526
+ let anySet = false;
527
+ for (let si = 0; si < options.segments.length; si++) {
528
+ const s = options.segments[si];
529
+ const slice = structures[si].getSliceBuffer(k);
530
+ for (let i = 0; i < rc; i++) {
531
+ if (slice[i] === 0)
532
+ continue;
533
+ if (pixels[i] !== 0) {
534
+ throw new LabelmapOverlapError(`segments ${pixels[i]} and ${s.number} both set the voxel at plane ${k}, index ${i} — ` +
535
+ "LABELMAP stores one label per pixel. Use segmentationType \"BINARY\" for overlapping segments.");
536
+ }
537
+ pixels[i] = s.number;
538
+ anySet = true;
475
539
  }
476
540
  }
477
- frames.push({ segmentNumber: s.number, position: planePositions[k], pixels });
541
+ if (sparse && !anySet)
542
+ continue;
543
+ frames.push({ segmentNumber: 0, position: planePositions[k], pixels });
478
544
  }
479
- });
480
- const segmentsOverlap = options.segmentsOverlap ?? (options.segments.length === 1 ? "NO" : "UNDEFINED");
545
+ }
546
+ else {
547
+ options.segments.forEach((s, si) => {
548
+ const structure = structures[si];
549
+ for (let k = 0; k < planeCount; k++) {
550
+ const slice = structure.getSliceBuffer(k);
551
+ const pixels = new Uint8Array(rc);
552
+ let anySet = false;
553
+ for (let i = 0; i < rc; i++) {
554
+ const v = slice[i];
555
+ if (isBinary) {
556
+ pixels[i] = v !== 0 ? 1 : 0;
557
+ }
558
+ else {
559
+ let stored = Math.round(rawScale ? v : v * maxFractional);
560
+ if (stored < 0)
561
+ stored = 0;
562
+ if (stored > maxFractional)
563
+ stored = maxFractional;
564
+ pixels[i] = stored;
565
+ }
566
+ if (pixels[i] !== 0)
567
+ anySet = true;
568
+ }
569
+ if (sparse && !anySet)
570
+ continue;
571
+ frames.push({ segmentNumber: s.number, position: planePositions[k], pixels });
572
+ }
573
+ });
574
+ }
575
+ if (frames.length === 0) {
576
+ throw new RangeError("writeSeg: frameCoverage \"sparse\" produced no frames — every segment is empty");
577
+ }
578
+ const segmentsOverlap = isLabelmap
579
+ ? "NO"
580
+ : options.segmentsOverlap ?? (options.segments.length === 1 ? "NO" : "UNDEFINED");
481
581
  const encodeOptions = {
482
582
  rows,
483
583
  columns,
@@ -502,7 +602,8 @@ export function writeSeg(options) {
502
602
  ...(options.frameOfReferenceUID ?? geometry.frameOfReferenceUID
503
603
  ? { frameOfReferenceUID: (options.frameOfReferenceUID ?? geometry.frameOfReferenceUID) }
504
604
  : {}),
505
- ...(isBinary ? {} : { fractionalType: options.fractionalType, maximumFractionalValue: maxFractional }),
605
+ ...(isFractional ? { fractionalType: options.fractionalType, maximumFractionalValue: maxFractional } : {}),
606
+ ...(isLabelmap ? { labelmapBitsAllocated: labelmapBits } : {}),
506
607
  };
507
608
  return encodeSegFrames(encodeOptions);
508
609
  }
package/dist/errors.d.ts CHANGED
@@ -22,16 +22,26 @@ export declare class MalformedSegmentationError extends Error {
22
22
  constructor(message: string);
23
23
  }
24
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.
25
+ * `SegmentationType` (0062,0001) is a value dicom-seg-js does not handle. As of 0.2.0
26
+ * `BINARY`, `FRACTIONAL`, and `LABELMAP` (PS3.3 Supplement 243) are all supported, so this
27
+ * is now raised only for an unknown/vendor value.
27
28
  */
28
29
  export declare class UnsupportedSegmentationTypeError extends Error {
29
30
  constructor(message: string);
30
31
  }
31
32
  /**
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.
33
+ * A `writeSeg({ segmentationType: "LABELMAP" })` call was given segments whose masks
34
+ * overlap. LABELMAP is a partition each pixel stores exactly one `SegmentNumber` so
35
+ * overlapping input cannot be represented. Use `BINARY` (with `SegmentsOverlap`) instead.
36
+ */
37
+ export declare class LabelmapOverlapError extends Error {
38
+ constructor(message: string);
39
+ }
40
+ /**
41
+ * `mask()` was called on a FRACTIONAL segmentation, `field()` on a BINARY or LABELMAP one,
42
+ * or `field()` on LABELMAP. The forms are not interchangeable and there is no safe default
43
+ * threshold to turn a probability field into a mask (roadmap §7.1) — the caller must pick
44
+ * one explicitly.
35
45
  */
36
46
  export declare class SegmentationTypeMismatchError extends Error {
37
47
  constructor(message: string);
package/dist/errors.js CHANGED
@@ -28,8 +28,9 @@ export class MalformedSegmentationError extends Error {
28
28
  }
29
29
  }
30
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.
31
+ * `SegmentationType` (0062,0001) is a value dicom-seg-js does not handle. As of 0.2.0
32
+ * `BINARY`, `FRACTIONAL`, and `LABELMAP` (PS3.3 Supplement 243) are all supported, so this
33
+ * is now raised only for an unknown/vendor value.
33
34
  */
34
35
  export class UnsupportedSegmentationTypeError extends Error {
35
36
  constructor(message) {
@@ -38,9 +39,21 @@ export class UnsupportedSegmentationTypeError extends Error {
38
39
  }
39
40
  }
40
41
  /**
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.
42
+ * A `writeSeg({ segmentationType: "LABELMAP" })` call was given segments whose masks
43
+ * overlap. LABELMAP is a partition each pixel stores exactly one `SegmentNumber` so
44
+ * overlapping input cannot be represented. Use `BINARY` (with `SegmentsOverlap`) instead.
45
+ */
46
+ export class LabelmapOverlapError extends Error {
47
+ constructor(message) {
48
+ super(`LabelmapOverlapError: ${message}`);
49
+ this.name = "LabelmapOverlapError";
50
+ }
51
+ }
52
+ /**
53
+ * `mask()` was called on a FRACTIONAL segmentation, `field()` on a BINARY or LABELMAP one,
54
+ * or `field()` on LABELMAP. The forms are not interchangeable and there is no safe default
55
+ * threshold to turn a probability field into a mask (roadmap §7.1) — the caller must pick
56
+ * one explicitly.
44
57
  */
45
58
  export class SegmentationTypeMismatchError extends Error {
46
59
  constructor(message) {
package/dist/index.d.ts CHANGED
@@ -39,6 +39,9 @@ export declare class Segmentation {
39
39
  readonly geometry: GridGeometry;
40
40
  readonly frameOfReferenceUID: string | undefined;
41
41
  readonly contentLabel: string | undefined;
42
+ /** `NumberOfFrames` (0028,0008) — total frames stored. A sparse SEG has fewer than
43
+ * `segments × planes`; a LABELMAP has one per plane. */
44
+ readonly numberOfFrames: number;
42
45
  readonly diagnostics: readonly Diagnostic[];
43
46
  private readonly parsed;
44
47
  private readonly maskCache;
@@ -52,7 +55,8 @@ export declare class Segmentation {
52
55
  private assertSegment;
53
56
  private get sliceSize();
54
57
  /**
55
- * The boolean mask for a **BINARY** segment. Throws {@link SegmentationTypeMismatchError}
58
+ * The boolean mask for a segment. **BINARY** — the segment's frames. **LABELMAP** the
59
+ * voxels whose label equals `segmentNumber`. Throws {@link SegmentationTypeMismatchError}
56
60
  * on a FRACTIONAL SEG — threshold `field(n)` yourself, there is no safe default cut.
57
61
  */
58
62
  mask(segmentNumber: number): Mask3D;
package/dist/index.js CHANGED
@@ -14,7 +14,7 @@
14
14
  * segment to a resample or a histogram from one import.
15
15
  */
16
16
  import { createScalarField, maskFromDense, sampleFieldAt, } from "rt-geometry-js";
17
- import { binaryFrame, fractionalFrame, readSegDataset } from "./dicom/port.js";
17
+ import { binaryFrame, fractionalFrame, labelmapFrame, readSegDataset } from "./dicom/port.js";
18
18
  import { SegmentationTypeMismatchError } from "./errors.js";
19
19
  export * from "rt-geometry-js";
20
20
  export * from "./types.js";
@@ -41,6 +41,9 @@ export class Segmentation {
41
41
  geometry;
42
42
  frameOfReferenceUID;
43
43
  contentLabel;
44
+ /** `NumberOfFrames` (0028,0008) — total frames stored. A sparse SEG has fewer than
45
+ * `segments × planes`; a LABELMAP has one per plane. */
46
+ numberOfFrames;
44
47
  diagnostics;
45
48
  parsed;
46
49
  maskCache = new Map();
@@ -55,6 +58,7 @@ export class Segmentation {
55
58
  this.geometry = parsed.geometry;
56
59
  this.frameOfReferenceUID = parsed.frameOfReferenceUID;
57
60
  this.contentLabel = parsed.contentLabel;
61
+ this.numberOfFrames = parsed.numberOfFrames;
58
62
  this.diagnostics = parsed.diagnostics;
59
63
  }
60
64
  static fromDicom(bytes) {
@@ -76,27 +80,39 @@ export class Segmentation {
76
80
  return this.parsed.rows * this.parsed.columns;
77
81
  }
78
82
  /**
79
- * The boolean mask for a **BINARY** segment. Throws {@link SegmentationTypeMismatchError}
83
+ * The boolean mask for a segment. **BINARY** — the segment's frames. **LABELMAP** the
84
+ * voxels whose label equals `segmentNumber`. Throws {@link SegmentationTypeMismatchError}
80
85
  * on a FRACTIONAL SEG — threshold `field(n)` yourself, there is no safe default cut.
81
86
  */
82
87
  mask(segmentNumber) {
83
88
  this.assertSegment(segmentNumber);
84
- if (this.type !== "BINARY") {
85
- throw new SegmentationTypeMismatchError(`mask() is for BINARY segmentations; this is FRACTIONAL — use field(${segmentNumber}) and apply your own threshold`);
89
+ if (this.type === "FRACTIONAL") {
90
+ throw new SegmentationTypeMismatchError(`mask() is for BINARY / LABELMAP segmentations; this is FRACTIONAL — use field(${segmentNumber}) and apply your own threshold`);
86
91
  }
87
92
  const cached = this.maskCache.get(segmentNumber);
88
93
  if (cached)
89
94
  return cached;
90
95
  const rc = this.sliceSize;
91
96
  const data = new Uint8Array(this.parsed.geometry.planes.length * rc);
92
- for (const fr of this.parsed.frames) {
93
- if (fr.segmentNumber !== segmentNumber)
94
- continue;
95
- const bits = binaryFrame(this.parsed, fr.frameIndex);
96
- const base = fr.planeIndex * rc;
97
- for (let i = 0; i < rc; i++)
98
- if (bits[i])
99
- data[base + i] = 1;
97
+ if (this.type === "LABELMAP") {
98
+ for (const fr of this.parsed.frames) {
99
+ const labels = labelmapFrame(this.parsed, fr.frameIndex);
100
+ const base = fr.planeIndex * rc;
101
+ for (let i = 0; i < rc; i++)
102
+ if (labels[i] === segmentNumber)
103
+ data[base + i] = 1;
104
+ }
105
+ }
106
+ else {
107
+ for (const fr of this.parsed.frames) {
108
+ if (fr.segmentNumber !== segmentNumber)
109
+ continue;
110
+ const bits = binaryFrame(this.parsed, fr.frameIndex);
111
+ const base = fr.planeIndex * rc;
112
+ for (let i = 0; i < rc; i++)
113
+ if (bits[i])
114
+ data[base + i] = 1;
115
+ }
100
116
  }
101
117
  const mask = maskFromDense(this.parsed.geometry, data);
102
118
  this.maskCache.set(segmentNumber, mask);
@@ -117,7 +133,7 @@ export class Segmentation {
117
133
  fractionalField(segmentNumber, rescale, cache) {
118
134
  this.assertSegment(segmentNumber);
119
135
  if (this.type !== "FRACTIONAL") {
120
- throw new SegmentationTypeMismatchError(`field() is for FRACTIONAL segmentations; this is BINARY — use mask(${segmentNumber})`);
136
+ throw new SegmentationTypeMismatchError(`field() is for FRACTIONAL segmentations; this is ${this.type} — use mask(${segmentNumber})`);
121
137
  }
122
138
  const cached = cache.get(segmentNumber);
123
139
  if (cached)
@@ -144,7 +160,7 @@ export class Segmentation {
144
160
  * region the model marked at all".
145
161
  */
146
162
  support(segmentNumber) {
147
- if (this.type === "BINARY")
163
+ if (this.type === "BINARY" || this.type === "LABELMAP")
148
164
  return this.mask(segmentNumber);
149
165
  const raw = this.rawField(segmentNumber);
150
166
  const [columns, rows, planes] = raw.dimensions;
package/dist/types.d.ts CHANGED
@@ -1,8 +1,16 @@
1
1
  /**
2
- * Segmentation type. dicom-seg-js 0.1.0 handles `BINARY` and `FRACTIONAL`; `LABELMAP`
3
- * (PS3.3 Sup 243) throws {@link UnsupportedSegmentationTypeError} until 0.2.0.
2
+ * `SegmentationType` (0062,0001).
3
+ *
4
+ * - `BINARY` — 1 bit per pixel, one frame per (segment, plane).
5
+ * - `FRACTIONAL` — 8 bits per pixel, `0..MaximumFractionalValue`, one frame per
6
+ * (segment, plane). {@link FractionalType} says whether the value is a probability or an
7
+ * occupancy.
8
+ * - `LABELMAP` (PS3.3 Sup 243) — one frame per **plane**, each pixel an integer equal to
9
+ * the `SegmentNumber` it belongs to (`0` = background). A partition: a voxel has at most
10
+ * one label. `seg.mask(n)` returns the voxels whose label is `n`; there is no
11
+ * `seg.field()`.
4
12
  */
5
- export type SegmentationType = "BINARY" | "FRACTIONAL";
13
+ export type SegmentationType = "BINARY" | "FRACTIONAL" | "LABELMAP";
6
14
  /**
7
15
  * `SegmentationFractionalType` (0062,0010). PROBABILITY = "the probability that the
8
16
  * segmented property occupies the voxel"; OCCUPANCY = "the fraction of the voxel volume
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "dicom-seg-js",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
- "description": "DICOM Segmentation (SEG) reading — BINARY masks and FRACTIONAL probability/occupancy fields — built on rt-geometry-js.",
5
+ "description": "DICOM Segmentation (SEG) reading and writing — BINARY masks and FRACTIONAL probability/occupancy fields — built on rt-geometry-js.",
6
6
  "keywords": [
7
7
  "dicom",
8
8
  "segmentation",
@@ -48,16 +48,17 @@
48
48
  "check:deps": "node ../../scripts/check-dependency-rule.mjs",
49
49
  "build": "tsc -p tsconfig.build.json",
50
50
  "prebuild": "npm run build --workspace rt-geometry-js",
51
- "prepublishOnly": "npm run build && npm test"
51
+ "prepublishOnly": "npm run build && npm test",
52
+ "bench": "vitest bench --run"
52
53
  },
53
54
  "peerDependencies": {
54
- "rt-geometry-js": "^0.1.2"
55
+ "rt-geometry-js": "^1.0.0"
55
56
  },
56
57
  "devDependencies": {
57
58
  "@types/node": "^26.2.0",
58
- "rt-geometry-js": "^0.1.2",
59
+ "rt-geometry-js": "^1.0.0",
59
60
  "typescript": "^5.6.3",
60
- "vitest": "^2.1.4"
61
+ "vitest": "^3.2.7"
61
62
  },
62
63
  "dependencies": {
63
64
  "dcmjs": "^0.52.0"