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.
package/CHANGELOG.md ADDED
@@ -0,0 +1,72 @@
1
+ # Changelog
2
+
3
+ ## [0.1.0] - 2026-08-29
4
+
5
+ First release. DICOM Segmentation (SEG) **read and write** — `BINARY` masks and
6
+ `FRACTIONAL` probability/occupancy fields — built on `rt-geometry-js` `^0.1.2` (peer
7
+ dependency). Part of the `dicom-imaging-toolkit-packages` monorepo (roadmap v2, Phase F,
8
+ PRs 2–4).
9
+
10
+ **Validated voxel-exact against `highdicom`** on real TCIA SEG files (2- and 6-segment
11
+ BINARY, one FRACTIONAL): 728 / 728 per-slice checksums identical. See `VALIDATION.md`.
12
+
13
+ ### Added
14
+
15
+ - `readSeg(bytes)` / `Segmentation.fromDicom(bytes)` — parse one SEG object:
16
+ - `BINARY` and `FRACTIONAL` (`LABELMAP` → `UnsupportedSegmentationTypeError`, planned
17
+ for 0.2.0).
18
+ - One `GridGeometry` built from the Per-Frame / Shared Functional Groups, spanning every
19
+ distinct frame position; planes sorted along the normal.
20
+ - `NotSegmentationError` for a non-SEG SOP class; `MalformedSegmentationError` for an
21
+ unassemblable object (missing `SegmentSequence` / shared `PixelMeasures` /
22
+ `PlaneOrientation`, per-frame group count ≠ `NumberOfFrames`, a frame referencing an
23
+ undeclared `SegmentNumber`, short `PixelData`).
24
+ - `seg.segments()` — number, label, `SegmentAlgorithmType` / `Name`, coded
25
+ `SegmentedPropertyCategory` / `Type` / `TypeModifier`, `TrackingID` / `TrackingUID`,
26
+ and the per-segment stored frame count.
27
+ - `seg.mask(n)` — `Mask3D` for a BINARY segment (bit-unpacked; the non-conformant
28
+ byte-aligned-per-frame variant is detected and handled with a diagnostic).
29
+ - `seg.field(n)` — `ScalarField3D` for a FRACTIONAL segment, rescaled to `[0, 1]` by
30
+ `MaximumFractionalValue`. `seg.rawField(n)` keeps the stored integers.
31
+ - `seg.fractionalType` — `PROBABILITY` / `OCCUPANCY`, **never defaulted** (absent →
32
+ `undefined` + a `FRACTIONAL_TYPE_ABSENT` diagnostic; roadmap §7.1).
33
+ - `FRACTIONAL_VALUES_LOOK_BINARY` diagnostic — a FRACTIONAL field with ≥ 98% of its
34
+ non-zero values pinned at `MaximumFractionalValue` is a binary mask stored as FRACTIONAL,
35
+ not a graded field (roadmap §7.1). Fires on a real ISPY1 "OCCUPANCY" file.
36
+ - `seg.segmentsOverlap` — `SegmentsOverlap` surfaced; `YES` also raises a diagnostic.
37
+ - `seg.support(n)` — `Mask3D` of a segment's footprint (the mask for BINARY; non-zero
38
+ voxels for FRACTIONAL), for use as the `mask` argument to the honest metrics.
39
+ - `seg.sampleConfidence(n, point)` — interpolated confidence at a physical point (§7.3),
40
+ the same call as `dose.sample()` against a different field.
41
+ - `mask()` on FRACTIONAL / `field()` on BINARY throw `SegmentationTypeMismatchError` — no
42
+ implicit threshold.
43
+ - The full `rt-geometry-js` surface is re-exported, so `meanValue` /
44
+ `volumeAboveThreshold` / `thresholdSensitivity` (from 0.1.2) are available from a single
45
+ import. No "accuracy" / "% correct" metric anywhere (§7.2).
46
+ - `writeSeg({ segmentationType, segments, ... })` — a `Mask3D` per BINARY segment or a
47
+ `ScalarField3D` per FRACTIONAL segment, all on one shared `GridGeometry`:
48
+ - **`fractionalType` is required** for FRACTIONAL — no default (roadmap §7.1); omitting
49
+ it is a `TypeError`.
50
+ - `maximumFractionalValue` (default 255) scales `[0, 1]` field values on write;
51
+ `fieldScale: "raw"` takes `[0, maximumFractionalValue]` integers instead.
52
+ - Every segment must share one grid (`GridMismatchError` otherwise). One frame per
53
+ `(segment, plane)` over the full grid, so `writeSeg` → `readSeg` is an exact round
54
+ trip. `SegmentsOverlap` defaults to `NO` (one segment) / `UNDEFINED` (more).
55
+ - Coded category / type / type-modifier, `TrackingID` / `TrackingUID`, algorithm
56
+ type/name all round-trip.
57
+
58
+ ### Performance
59
+
60
+ - BINARY continuous-bitstream unpack is memoised per parse (was re-unpacking the whole
61
+ stream once per frame — O(frames²) on a large SEG). A 546-frame / 6-segment file now
62
+ reconstructs in ~2 s.
63
+
64
+ ### Known / deferred
65
+
66
+ - **Sparse writing** (omitting all-zero frames, as most real files do) — 0.2.0. Reading
67
+ sparse SEGs is fully supported now.
68
+ - **LABELMAP** deferred to 0.2.0 (little real-world test data yet).
69
+ - Validation covers BINARY (2- and 6-segment) and one FRACTIONAL file; no genuinely graded
70
+ `PROBABILITY` field or `SegmentsOverlap YES` file in the sample yet (`VALIDATION.md`).
71
+ - Per-frame `PlaneOrientationSequence` that varies from the shared one is flagged but not
72
+ honoured (the shared orientation is used).
package/README.md ADDED
@@ -0,0 +1,119 @@
1
+ # dicom-seg-js
2
+
3
+ DICOM **Segmentation (SEG)** reading and writing for JavaScript/TypeScript, built on
4
+ [`rt-geometry-js`](https://www.npmjs.com/package/rt-geometry-js). Part of the
5
+ [DICOM imaging toolkit](https://github.com/adeelbarki/dicom-imaging-toolkit-packages).
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`);
10
+ `npm install dicom-seg-js rt-geometry-js`.
11
+
12
+ **Validated against real DICOM files**, not just its own fixtures — `dicom-seg-js`'s
13
+ reconstruction is **voxel-exact vs `highdicom`** on real TCIA SEG files (C4KC-KiTS 2-segment
14
+ BINARY, NSCLC-Radiomics 6-segment BINARY, ISPY1 FRACTIONAL): 728 / 728 per-slice checksums
15
+ identical. See
16
+ [VALIDATION.md](https://github.com/adeelbarki/dicom-imaging-toolkit-packages/blob/main/packages/dicom-seg/VALIDATION.md)
17
+ (this link works from both GitHub and the npm page).
18
+
19
+ **Standard pinned (for doc references):** DICOM PS3.3 **2026c**.
20
+
21
+ > **FRACTIONAL values are per-voxel model *confidence*, not accuracy.** Averaging them does
22
+ > not give you an accuracy figure, and most model outputs are uncalibrated (a softmax 0.9
23
+ > is not "90% of such voxels are correct"). This library exposes honest quantities only —
24
+ > `meanValue`, `volumeAboveThreshold`, `thresholdSensitivity` (from `rt-geometry-js`) — and
25
+ > no "accuracy" or "% correct" number anywhere. See
26
+ > [`docs/FRACTIONAL-SEG.md`](docs/FRACTIONAL-SEG.md).
27
+
28
+ ## Install
29
+
30
+ ```sh
31
+ npm install dicom-seg-js rt-geometry-js
32
+ ```
33
+
34
+ ## Use
35
+
36
+ ```ts
37
+ import { readSeg, meanValue, volumeAboveThreshold } from "dicom-seg-js";
38
+
39
+ const seg = readSeg(segBytes); // ArrayBuffer -> parsed Segmentation
40
+ seg.type; // "BINARY" | "FRACTIONAL"
41
+ seg.segments(); // [{ number, label, category, propertyType, algorithmType, ... }]
42
+ seg.geometry; // the SEG's own GridGeometry (from the Functional Groups)
43
+ seg.segmentsOverlap; // "YES" | "NO" | "UNDEFINED"
44
+ seg.diagnostics; // non-fatal issues found while parsing
45
+
46
+ // BINARY:
47
+ const mask = seg.mask(1); // Mask3D on seg.geometry
48
+
49
+ // FRACTIONAL:
50
+ seg.fractionalType; // "PROBABILITY" | "OCCUPANCY" | undefined (never assumed)
51
+ const field = seg.field(1); // ScalarField3D, rescaled to 0..1 by MaximumFractionalValue
52
+ const raw = seg.rawField(1); // the stored integers, unscaled
53
+ const support = seg.support(1); // Mask3D of voxels with non-zero confidence
54
+
55
+ meanValue(field, support); // mean confidence over the marked region
56
+ volumeAboveThreshold(field, support, 0.7); // mm³ the model is >= 70% confident about
57
+ seg.sampleConfidence(1, [x, y, z]); // interpolated confidence at a physical point (§7.3)
58
+ ```
59
+
60
+ `mask()` on a FRACTIONAL SEG and `field()` on a BINARY one both throw
61
+ `SegmentationTypeMismatchError` — there is no safe default threshold to turn a probability
62
+ field into a mask, so the caller must pick one.
63
+
64
+ ## Write
65
+
66
+ ```ts
67
+ import { writeSeg } from "dicom-seg-js";
68
+
69
+ // BINARY — one Mask3D per segment, all on one GridGeometry
70
+ const bytes = writeSeg({
71
+ segmentationType: "BINARY",
72
+ segments: [
73
+ { number: 1, label: "Liver", mask: liverMask,
74
+ category: { value: "T-D0050", scheme: "SRT", meaning: "Tissue" },
75
+ propertyType: { value: "T-62000", scheme: "SRT", meaning: "Liver" } },
76
+ { number: 2, label: "Tumor", mask: tumorMask },
77
+ ],
78
+ });
79
+
80
+ // FRACTIONAL — fractionalType is REQUIRED, there is no default (see FRACTIONAL-SEG.md §1)
81
+ const probBytes = writeSeg({
82
+ segmentationType: "FRACTIONAL",
83
+ fractionalType: "PROBABILITY",
84
+ maximumFractionalValue: 255, // optional, default 255
85
+ segments: [{ number: 1, label: "Tumor", field: probField }], // values in 0..1
86
+ });
87
+ ```
88
+
89
+ `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
94
+ `[0, maximumFractionalValue]` rather than `[0, 1]`.
95
+
96
+ ## What it reads
97
+
98
+ | Element | Handling |
99
+ |---|---|
100
+ | `SegmentationType` (0062,0001) | `BINARY` → `mask(n)`; `FRACTIONAL` → `field(n)` / `rawField(n)`. `LABELMAP` → `UnsupportedSegmentationTypeError` (0.2.0) |
101
+ | `SegmentSequence` (0062,0002) | number, label, algorithm type/name, coded `SegmentedPropertyCategory` / `Type` / `TypeModifier`, `TrackingID` / `TrackingUID` |
102
+ | `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
+ | `MaximumFractionalValue` (0062,000E) | used to rescale `field(n)` to 0..1; `rawField(n)` keeps the integers. Absent → assumed 255 + a diagnostic |
104
+ | `SegmentsOverlap` (0062,0013) | surfaced on `seg.segmentsOverlap`; `YES` also raises a diagnostic |
105
+ | 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 |
107
+
108
+ The SEG's grid is **not** required to match any source image series. To compare a segment
109
+ against a CT or a dose grid, resample it — `resampleMask` / `resampleField` from
110
+ `rt-geometry-js` are re-exported here.
111
+
112
+ 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`).
116
+
117
+ ## License
118
+
119
+ [MIT](../../LICENSE)
@@ -0,0 +1,117 @@
1
+ import { type Diagnostic, type GridGeometry, type GridTolerance, type Mask3D, type ScalarField3D, type Vec3 } from "rt-geometry-js";
2
+ import type { CodedConcept, FractionalType, SegmentInfo, SegmentationType, SegmentsOverlap } from "../types.js";
3
+ export interface FrameRef {
4
+ readonly segmentNumber: number;
5
+ readonly planeIndex: number;
6
+ /** Position of this frame in the PixelData frame stream. */
7
+ readonly frameIndex: number;
8
+ }
9
+ export interface ParsedSeg {
10
+ readonly segmentationType: SegmentationType;
11
+ readonly fractionalType: FractionalType | undefined;
12
+ readonly maximumFractionalValue: number | undefined;
13
+ readonly segmentsOverlap: SegmentsOverlap;
14
+ readonly geometry: GridGeometry;
15
+ readonly frameOfReferenceUID: string | undefined;
16
+ readonly contentLabel: string | undefined;
17
+ readonly rows: number;
18
+ readonly columns: number;
19
+ readonly numberOfFrames: number;
20
+ readonly segments: readonly SegmentInfo[];
21
+ readonly frames: readonly FrameRef[];
22
+ /** Raw PixelData bytes (bit-packed for BINARY, one byte per pixel for FRACTIONAL). */
23
+ readonly pixelData: Uint8Array;
24
+ /** True when BINARY frames are individually padded to a byte boundary rather than
25
+ * packed as one continuous bitstream (a non-conformant but real variant). */
26
+ readonly binaryFramesByteAligned: boolean;
27
+ readonly diagnostics: readonly Diagnostic[];
28
+ }
29
+ /** Parse one SEG object's bytes. Throws NotSegmentationError / MalformedSegmentationError /
30
+ * UnsupportedSegmentationTypeError. */
31
+ export declare function readSegDataset(bytes: ArrayBuffer): ParsedSeg;
32
+ /** Unpacked 0/1 bits for BINARY frame `frameIndex` (length rows·columns). */
33
+ export declare function binaryFrame(parsed: ParsedSeg, frameIndex: number): Uint8Array;
34
+ /** Raw 8-bit values for FRACTIONAL frame `frameIndex` (length rows·columns). */
35
+ export declare function fractionalFrame(parsed: ParsedSeg, frameIndex: number): Uint8Array;
36
+ export interface EncodeSegSegment {
37
+ readonly number: number;
38
+ readonly label?: string;
39
+ readonly algorithmType?: string;
40
+ readonly algorithmName?: string;
41
+ readonly category?: CodedConcept;
42
+ readonly propertyType?: CodedConcept;
43
+ readonly propertyTypeModifier?: CodedConcept;
44
+ readonly trackingId?: string;
45
+ readonly trackingUid?: string;
46
+ }
47
+ export interface WriteSegFrame {
48
+ readonly segmentNumber: number;
49
+ readonly position: Vec3;
50
+ /** BINARY: 0/1 per pixel. FRACTIONAL: 0..maximumFractionalValue per pixel. Length rows·columns. */
51
+ readonly pixels: ArrayLike<number>;
52
+ }
53
+ export interface EncodeSegOptions {
54
+ readonly rows: number;
55
+ readonly columns: number;
56
+ readonly segmentationType: SegmentationType;
57
+ readonly rowDirection?: Vec3;
58
+ readonly columnDirection?: Vec3;
59
+ readonly pixelSpacing?: readonly [number, number];
60
+ readonly sliceThickness?: number;
61
+ readonly frameOfReferenceUID?: string;
62
+ readonly segmentsOverlap?: SegmentsOverlap;
63
+ readonly fractionalType?: FractionalType;
64
+ readonly maximumFractionalValue?: number;
65
+ readonly segments: readonly EncodeSegSegment[];
66
+ readonly frames: readonly WriteSegFrame[];
67
+ /** Override to exercise NotSegmentationError. */
68
+ readonly sopClassUID?: string;
69
+ readonly modality?: string;
70
+ /** Test-only: emit LABELMAP as the type. */
71
+ readonly forceType?: string;
72
+ /** Test-only: drop SegmentationFractionalType even for FRACTIONAL. */
73
+ readonly omitFractionalType?: boolean;
74
+ /** Test-only: drop MaximumFractionalValue even for FRACTIONAL. */
75
+ readonly omitMaximumFractionalValue?: boolean;
76
+ }
77
+ export declare function encodeSegFrames(options: EncodeSegOptions): ArrayBuffer;
78
+ export interface WriteSegSegment {
79
+ readonly number: number;
80
+ readonly label: string;
81
+ readonly algorithmType?: string;
82
+ readonly algorithmName?: string;
83
+ readonly category?: CodedConcept;
84
+ readonly propertyType?: CodedConcept;
85
+ readonly propertyTypeModifier?: CodedConcept;
86
+ readonly trackingId?: string;
87
+ readonly trackingUid?: string;
88
+ /** BINARY — the segment mask. */
89
+ readonly mask?: Mask3D;
90
+ /** FRACTIONAL — the segment field (0..1 unless `fieldScale: "raw"`). */
91
+ readonly field?: ScalarField3D;
92
+ }
93
+ export interface WriteSegOptions {
94
+ readonly segmentationType: SegmentationType;
95
+ /**
96
+ * FRACTIONAL only, and **required** — there is no default. PROBABILITY and OCCUPANCY are
97
+ * different quantities (roadmap §7.1 / FRACTIONAL-SEG.md §1); the writer must be told
98
+ * which one the values are.
99
+ */
100
+ readonly fractionalType?: FractionalType;
101
+ /** FRACTIONAL only — the stored integer that means 1.0. Default 255 (8-bit ceiling). */
102
+ readonly maximumFractionalValue?: number;
103
+ /**
104
+ * How `segment.field` values are read. `"unit"` (default): they are in `[0, 1]` and get
105
+ * multiplied by `maximumFractionalValue`. `"raw"`: they are already integers in
106
+ * `[0, maximumFractionalValue]`.
107
+ */
108
+ readonly fieldScale?: "unit" | "raw";
109
+ readonly segments: readonly WriteSegSegment[];
110
+ /** Default `"NO"` for one segment, `"UNDEFINED"` for more. */
111
+ readonly segmentsOverlap?: SegmentsOverlap;
112
+ /** Default: the shared geometry's `frameOfReferenceUID`, else a fresh UID. */
113
+ readonly frameOfReferenceUID?: string;
114
+ readonly contentLabel?: string;
115
+ readonly tolerance?: GridTolerance;
116
+ }
117
+ export declare function writeSeg(options: WriteSegOptions): ArrayBuffer;