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 +72 -0
- package/README.md +119 -0
- package/dist/dicom/port.d.ts +117 -0
- package/dist/dicom/port.js +508 -0
- package/dist/errors.d.ts +38 -0
- package/dist/errors.js +50 -0
- package/dist/index.d.ts +87 -0
- package/dist/index.js +177 -0
- package/dist/types.d.ts +42 -0
- package/dist/types.js +1 -0
- package/docs/FRACTIONAL-SEG.md +105 -0
- package/package.json +65 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dicom-seg-js — DICOM Segmentation (SEG) reading, built on rt-geometry-js.
|
|
3
|
+
*
|
|
4
|
+
* 0.1.0: BINARY masks and FRACTIONAL probability/occupancy fields. LABELMAP (PS3.3
|
|
5
|
+
* Sup 243) is planned for 0.2.0. Writing arrives in a later PR.
|
|
6
|
+
*
|
|
7
|
+
* FRACTIONAL values are per-voxel model confidence, not accuracy — see
|
|
8
|
+
* `docs/FRACTIONAL-SEG.md`. This library exposes honest quantities only
|
|
9
|
+
* (`meanValue` / `volumeAboveThreshold` / `thresholdSensitivity` from rt-geometry-js) and
|
|
10
|
+
* never an "accuracy" or "% correct" number.
|
|
11
|
+
*
|
|
12
|
+
* The whole rt-geometry-js surface (GridGeometry, Mask3D, ScalarField3D, resampling,
|
|
13
|
+
* histogram/metrics, geometry errors) is re-exported so a caller can go straight from a
|
|
14
|
+
* segment to a resample or a histogram from one import.
|
|
15
|
+
*/
|
|
16
|
+
import { type Diagnostic, type GridGeometry, type InterpMethod, type Mask3D, type ScalarField3D, type Vec3 } from "rt-geometry-js";
|
|
17
|
+
import type { FractionalType, SegmentInfo, SegmentationType, SegmentsOverlap } from "./types.js";
|
|
18
|
+
export * from "rt-geometry-js";
|
|
19
|
+
export * from "./types.js";
|
|
20
|
+
export * from "./errors.js";
|
|
21
|
+
export { writeSeg } from "./dicom/port.js";
|
|
22
|
+
export type { WriteSegOptions, WriteSegSegment } from "./dicom/port.js";
|
|
23
|
+
/**
|
|
24
|
+
* A parsed DICOM Segmentation and the per-segment masks / fields over it.
|
|
25
|
+
*
|
|
26
|
+
* BINARY → `mask(n)`; FRACTIONAL → `field(n)` (rescaled to 0..1) and `rawField(n)` (the
|
|
27
|
+
* stored integers). The two are not interchangeable — calling the wrong one throws
|
|
28
|
+
* {@link SegmentationTypeMismatchError} rather than guessing a threshold (roadmap §7.1).
|
|
29
|
+
*/
|
|
30
|
+
export declare class Segmentation {
|
|
31
|
+
readonly type: SegmentationType;
|
|
32
|
+
/** FRACTIONAL only — PROBABILITY vs OCCUPANCY, or undefined if the SEG didn't declare it. */
|
|
33
|
+
readonly fractionalType: FractionalType | undefined;
|
|
34
|
+
/** FRACTIONAL only — the stored value that means 1.0 (usually 255). */
|
|
35
|
+
readonly maximumFractionalValue: number | undefined;
|
|
36
|
+
readonly segmentsOverlap: SegmentsOverlap;
|
|
37
|
+
/** The SEG's own sampling grid, built from the Per-Frame / Shared Functional Groups.
|
|
38
|
+
* Not required to match any source image series — resample to cross grids. */
|
|
39
|
+
readonly geometry: GridGeometry;
|
|
40
|
+
readonly frameOfReferenceUID: string | undefined;
|
|
41
|
+
readonly contentLabel: string | undefined;
|
|
42
|
+
readonly diagnostics: readonly Diagnostic[];
|
|
43
|
+
private readonly parsed;
|
|
44
|
+
private readonly maskCache;
|
|
45
|
+
private readonly fieldCache;
|
|
46
|
+
private readonly rawFieldCache;
|
|
47
|
+
private constructor();
|
|
48
|
+
static fromDicom(bytes: ArrayBuffer): Segmentation;
|
|
49
|
+
/** Every segment, in `SegmentSequence` order. */
|
|
50
|
+
segments(): readonly SegmentInfo[];
|
|
51
|
+
hasSegment(segmentNumber: number): boolean;
|
|
52
|
+
private assertSegment;
|
|
53
|
+
private get sliceSize();
|
|
54
|
+
/**
|
|
55
|
+
* The boolean mask for a **BINARY** segment. Throws {@link SegmentationTypeMismatchError}
|
|
56
|
+
* on a FRACTIONAL SEG — threshold `field(n)` yourself, there is no safe default cut.
|
|
57
|
+
*/
|
|
58
|
+
mask(segmentNumber: number): Mask3D;
|
|
59
|
+
/**
|
|
60
|
+
* The confidence/occupancy field for a **FRACTIONAL** segment, rescaled to `[0, 1]` by
|
|
61
|
+
* `MaximumFractionalValue`. Throws {@link SegmentationTypeMismatchError} on a BINARY SEG
|
|
62
|
+
* — use `mask(n)`.
|
|
63
|
+
*/
|
|
64
|
+
field(segmentNumber: number): ScalarField3D;
|
|
65
|
+
/** The raw stored integers (0..`maximumFractionalValue`) for a FRACTIONAL segment, unscaled. */
|
|
66
|
+
rawField(segmentNumber: number): ScalarField3D;
|
|
67
|
+
private fractionalField;
|
|
68
|
+
/**
|
|
69
|
+
* The footprint of a segment as a `Mask3D`: for BINARY, the mask itself; for FRACTIONAL,
|
|
70
|
+
* the voxels with a non-zero stored value. Handy as the `mask` argument to
|
|
71
|
+
* `meanValue` / `volumeAboveThreshold` / `thresholdSensitivity` — "confidence over the
|
|
72
|
+
* region the model marked at all".
|
|
73
|
+
*/
|
|
74
|
+
support(segmentNumber: number): Mask3D;
|
|
75
|
+
/**
|
|
76
|
+
* Interpolated confidence at a physical point for a FRACTIONAL segment (0 outside the
|
|
77
|
+
* grid). The confidence-under-cursor tooltip from §7.3 — the same call as
|
|
78
|
+
* `dose.sample()` against a different field. Trilinear by default.
|
|
79
|
+
*/
|
|
80
|
+
sampleConfidence(segmentNumber: number, point: Vec3, opts?: {
|
|
81
|
+
method?: InterpMethod;
|
|
82
|
+
}): number;
|
|
83
|
+
}
|
|
84
|
+
/** Parse one DICOM SEG object's bytes. Throws `NotSegmentationError` /
|
|
85
|
+
* `MalformedSegmentationError` / `UnsupportedSegmentationTypeError`. */
|
|
86
|
+
export declare function readSeg(bytes: ArrayBuffer): Segmentation;
|
|
87
|
+
export type { ParsedSeg } from "./dicom/port.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dicom-seg-js — DICOM Segmentation (SEG) reading, built on rt-geometry-js.
|
|
3
|
+
*
|
|
4
|
+
* 0.1.0: BINARY masks and FRACTIONAL probability/occupancy fields. LABELMAP (PS3.3
|
|
5
|
+
* Sup 243) is planned for 0.2.0. Writing arrives in a later PR.
|
|
6
|
+
*
|
|
7
|
+
* FRACTIONAL values are per-voxel model confidence, not accuracy — see
|
|
8
|
+
* `docs/FRACTIONAL-SEG.md`. This library exposes honest quantities only
|
|
9
|
+
* (`meanValue` / `volumeAboveThreshold` / `thresholdSensitivity` from rt-geometry-js) and
|
|
10
|
+
* never an "accuracy" or "% correct" number.
|
|
11
|
+
*
|
|
12
|
+
* The whole rt-geometry-js surface (GridGeometry, Mask3D, ScalarField3D, resampling,
|
|
13
|
+
* histogram/metrics, geometry errors) is re-exported so a caller can go straight from a
|
|
14
|
+
* segment to a resample or a histogram from one import.
|
|
15
|
+
*/
|
|
16
|
+
import { createScalarField, maskFromDense, sampleFieldAt, } from "rt-geometry-js";
|
|
17
|
+
import { binaryFrame, fractionalFrame, readSegDataset } from "./dicom/port.js";
|
|
18
|
+
import { SegmentationTypeMismatchError } from "./errors.js";
|
|
19
|
+
export * from "rt-geometry-js";
|
|
20
|
+
export * from "./types.js";
|
|
21
|
+
export * from "./errors.js";
|
|
22
|
+
// Write a conformant SEG from a Mask3D per BINARY segment / a ScalarField3D per FRACTIONAL
|
|
23
|
+
// segment. The low-level frame encoder (encodeSegFrames) stays internal.
|
|
24
|
+
export { writeSeg } from "./dicom/port.js";
|
|
25
|
+
/**
|
|
26
|
+
* A parsed DICOM Segmentation and the per-segment masks / fields over it.
|
|
27
|
+
*
|
|
28
|
+
* BINARY → `mask(n)`; FRACTIONAL → `field(n)` (rescaled to 0..1) and `rawField(n)` (the
|
|
29
|
+
* stored integers). The two are not interchangeable — calling the wrong one throws
|
|
30
|
+
* {@link SegmentationTypeMismatchError} rather than guessing a threshold (roadmap §7.1).
|
|
31
|
+
*/
|
|
32
|
+
export class Segmentation {
|
|
33
|
+
type;
|
|
34
|
+
/** FRACTIONAL only — PROBABILITY vs OCCUPANCY, or undefined if the SEG didn't declare it. */
|
|
35
|
+
fractionalType;
|
|
36
|
+
/** FRACTIONAL only — the stored value that means 1.0 (usually 255). */
|
|
37
|
+
maximumFractionalValue;
|
|
38
|
+
segmentsOverlap;
|
|
39
|
+
/** The SEG's own sampling grid, built from the Per-Frame / Shared Functional Groups.
|
|
40
|
+
* Not required to match any source image series — resample to cross grids. */
|
|
41
|
+
geometry;
|
|
42
|
+
frameOfReferenceUID;
|
|
43
|
+
contentLabel;
|
|
44
|
+
diagnostics;
|
|
45
|
+
parsed;
|
|
46
|
+
maskCache = new Map();
|
|
47
|
+
fieldCache = new Map();
|
|
48
|
+
rawFieldCache = new Map();
|
|
49
|
+
constructor(parsed) {
|
|
50
|
+
this.parsed = parsed;
|
|
51
|
+
this.type = parsed.segmentationType;
|
|
52
|
+
this.fractionalType = parsed.fractionalType;
|
|
53
|
+
this.maximumFractionalValue = parsed.maximumFractionalValue;
|
|
54
|
+
this.segmentsOverlap = parsed.segmentsOverlap;
|
|
55
|
+
this.geometry = parsed.geometry;
|
|
56
|
+
this.frameOfReferenceUID = parsed.frameOfReferenceUID;
|
|
57
|
+
this.contentLabel = parsed.contentLabel;
|
|
58
|
+
this.diagnostics = parsed.diagnostics;
|
|
59
|
+
}
|
|
60
|
+
static fromDicom(bytes) {
|
|
61
|
+
return new Segmentation(readSegDataset(bytes));
|
|
62
|
+
}
|
|
63
|
+
/** Every segment, in `SegmentSequence` order. */
|
|
64
|
+
segments() {
|
|
65
|
+
return this.parsed.segments;
|
|
66
|
+
}
|
|
67
|
+
hasSegment(segmentNumber) {
|
|
68
|
+
return this.parsed.segments.some((s) => s.number === segmentNumber);
|
|
69
|
+
}
|
|
70
|
+
assertSegment(segmentNumber) {
|
|
71
|
+
if (!this.hasSegment(segmentNumber)) {
|
|
72
|
+
throw new RangeError(`no segment with SegmentNumber ${segmentNumber} (present: ${this.parsed.segments.map((s) => s.number).join(", ")})`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
get sliceSize() {
|
|
76
|
+
return this.parsed.rows * this.parsed.columns;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* The boolean mask for a **BINARY** segment. Throws {@link SegmentationTypeMismatchError}
|
|
80
|
+
* on a FRACTIONAL SEG — threshold `field(n)` yourself, there is no safe default cut.
|
|
81
|
+
*/
|
|
82
|
+
mask(segmentNumber) {
|
|
83
|
+
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`);
|
|
86
|
+
}
|
|
87
|
+
const cached = this.maskCache.get(segmentNumber);
|
|
88
|
+
if (cached)
|
|
89
|
+
return cached;
|
|
90
|
+
const rc = this.sliceSize;
|
|
91
|
+
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;
|
|
100
|
+
}
|
|
101
|
+
const mask = maskFromDense(this.parsed.geometry, data);
|
|
102
|
+
this.maskCache.set(segmentNumber, mask);
|
|
103
|
+
return mask;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* The confidence/occupancy field for a **FRACTIONAL** segment, rescaled to `[0, 1]` by
|
|
107
|
+
* `MaximumFractionalValue`. Throws {@link SegmentationTypeMismatchError} on a BINARY SEG
|
|
108
|
+
* — use `mask(n)`.
|
|
109
|
+
*/
|
|
110
|
+
field(segmentNumber) {
|
|
111
|
+
return this.fractionalField(segmentNumber, true, this.fieldCache);
|
|
112
|
+
}
|
|
113
|
+
/** The raw stored integers (0..`maximumFractionalValue`) for a FRACTIONAL segment, unscaled. */
|
|
114
|
+
rawField(segmentNumber) {
|
|
115
|
+
return this.fractionalField(segmentNumber, false, this.rawFieldCache);
|
|
116
|
+
}
|
|
117
|
+
fractionalField(segmentNumber, rescale, cache) {
|
|
118
|
+
this.assertSegment(segmentNumber);
|
|
119
|
+
if (this.type !== "FRACTIONAL") {
|
|
120
|
+
throw new SegmentationTypeMismatchError(`field() is for FRACTIONAL segmentations; this is BINARY — use mask(${segmentNumber})`);
|
|
121
|
+
}
|
|
122
|
+
const cached = cache.get(segmentNumber);
|
|
123
|
+
if (cached)
|
|
124
|
+
return cached;
|
|
125
|
+
const rc = this.sliceSize;
|
|
126
|
+
const buffer = new Float32Array(this.parsed.geometry.planes.length * rc);
|
|
127
|
+
const divisor = rescale ? (this.maximumFractionalValue ?? 255) : 1;
|
|
128
|
+
for (const fr of this.parsed.frames) {
|
|
129
|
+
if (fr.segmentNumber !== segmentNumber)
|
|
130
|
+
continue;
|
|
131
|
+
const bytes = fractionalFrame(this.parsed, fr.frameIndex);
|
|
132
|
+
const base = fr.planeIndex * rc;
|
|
133
|
+
for (let i = 0; i < rc; i++)
|
|
134
|
+
buffer[base + i] = bytes[i] / divisor;
|
|
135
|
+
}
|
|
136
|
+
const field = createScalarField(this.parsed.geometry, buffer);
|
|
137
|
+
cache.set(segmentNumber, field);
|
|
138
|
+
return field;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* The footprint of a segment as a `Mask3D`: for BINARY, the mask itself; for FRACTIONAL,
|
|
142
|
+
* the voxels with a non-zero stored value. Handy as the `mask` argument to
|
|
143
|
+
* `meanValue` / `volumeAboveThreshold` / `thresholdSensitivity` — "confidence over the
|
|
144
|
+
* region the model marked at all".
|
|
145
|
+
*/
|
|
146
|
+
support(segmentNumber) {
|
|
147
|
+
if (this.type === "BINARY")
|
|
148
|
+
return this.mask(segmentNumber);
|
|
149
|
+
const raw = this.rawField(segmentNumber);
|
|
150
|
+
const [columns, rows, planes] = raw.dimensions;
|
|
151
|
+
const data = new Uint8Array(columns * rows * planes);
|
|
152
|
+
for (let k = 0; k < planes; k++) {
|
|
153
|
+
const slice = raw.getSliceBuffer(k);
|
|
154
|
+
const base = k * columns * rows;
|
|
155
|
+
for (let i = 0; i < slice.length; i++)
|
|
156
|
+
if (slice[i] > 0)
|
|
157
|
+
data[base + i] = 1;
|
|
158
|
+
}
|
|
159
|
+
return maskFromDense(this.geometry, data);
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Interpolated confidence at a physical point for a FRACTIONAL segment (0 outside the
|
|
163
|
+
* grid). The confidence-under-cursor tooltip from §7.3 — the same call as
|
|
164
|
+
* `dose.sample()` against a different field. Trilinear by default.
|
|
165
|
+
*/
|
|
166
|
+
sampleConfidence(segmentNumber, point, opts = {}) {
|
|
167
|
+
return sampleFieldAt(this.field(segmentNumber), point, {
|
|
168
|
+
method: opts.method ?? "trilinear",
|
|
169
|
+
outOfBounds: 0,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
/** Parse one DICOM SEG object's bytes. Throws `NotSegmentationError` /
|
|
174
|
+
* `MalformedSegmentationError` / `UnsupportedSegmentationTypeError`. */
|
|
175
|
+
export function readSeg(bytes) {
|
|
176
|
+
return Segmentation.fromDicom(bytes);
|
|
177
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
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.
|
|
4
|
+
*/
|
|
5
|
+
export type SegmentationType = "BINARY" | "FRACTIONAL";
|
|
6
|
+
/**
|
|
7
|
+
* `SegmentationFractionalType` (0062,0010). PROBABILITY = "the probability that the
|
|
8
|
+
* segmented property occupies the voxel"; OCCUPANCY = "the fraction of the voxel volume
|
|
9
|
+
* the property occupies". A stored 0.5 means very different things under each — never
|
|
10
|
+
* assume one (roadmap §7.1). Undefined when the SEG did not declare it.
|
|
11
|
+
*/
|
|
12
|
+
export type FractionalType = "PROBABILITY" | "OCCUPANCY";
|
|
13
|
+
/** `SegmentsOverlap` (0062,0013) — whether segments may share voxels. */
|
|
14
|
+
export type SegmentsOverlap = "YES" | "NO" | "UNDEFINED";
|
|
15
|
+
/** A DICOM coded concept — `CodeValue` + `CodingSchemeDesignator` + `CodeMeaning`. */
|
|
16
|
+
export interface CodedConcept {
|
|
17
|
+
readonly value: string;
|
|
18
|
+
readonly scheme: string;
|
|
19
|
+
readonly meaning: string;
|
|
20
|
+
}
|
|
21
|
+
/** One entry of `SegmentSequence` (0062,0002). */
|
|
22
|
+
export interface SegmentInfo {
|
|
23
|
+
/** `SegmentNumber` (0062,0004) — 1-based, unique within the SEG. The key for `mask()` / `field()`. */
|
|
24
|
+
readonly number: number;
|
|
25
|
+
/** `SegmentLabel` (0062,0005). */
|
|
26
|
+
readonly label: string;
|
|
27
|
+
/** `SegmentAlgorithmType` (0062,0008) — `AUTOMATIC` | `SEMIAUTOMATIC` | `MANUAL`. */
|
|
28
|
+
readonly algorithmType: string;
|
|
29
|
+
/** `SegmentAlgorithmName` (0062,0009), when present. */
|
|
30
|
+
readonly algorithmName: string | undefined;
|
|
31
|
+
/** `SegmentedPropertyCategoryCodeSequence` (0062,0003) — e.g. Tissue, Anatomical Structure. */
|
|
32
|
+
readonly category: CodedConcept | undefined;
|
|
33
|
+
/** `SegmentedPropertyTypeCodeSequence` (0062,000F) — e.g. Liver, Tumor. */
|
|
34
|
+
readonly propertyType: CodedConcept | undefined;
|
|
35
|
+
/** `SegmentedPropertyTypeModifierCodeSequence` (0062,0011), when present — e.g. Left. */
|
|
36
|
+
readonly propertyTypeModifier: CodedConcept | undefined;
|
|
37
|
+
/** `TrackingID` (0062,0020) / `TrackingUID` (0062,0021), when present. */
|
|
38
|
+
readonly trackingId: string | undefined;
|
|
39
|
+
readonly trackingUid: string | undefined;
|
|
40
|
+
/** Number of frames actually stored for this segment (sparse SEGs omit all-empty frames). */
|
|
41
|
+
readonly frameCount: number;
|
|
42
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# FRACTIONAL segmentations
|
|
2
|
+
|
|
3
|
+
A FRACTIONAL SEG stores one 8-bit value per voxel per segment. `dicom-seg-js` rescales it
|
|
4
|
+
to `[0, 1]` with `MaximumFractionalValue` (`field(n)`) and also gives you the raw integers
|
|
5
|
+
(`rawField(n)`). What that number *means* is the subject of this document.
|
|
6
|
+
|
|
7
|
+
## 1. PROBABILITY and OCCUPANCY are different quantities
|
|
8
|
+
|
|
9
|
+
`SegmentationFractionalType` (0062,0010) declares one of two meanings:
|
|
10
|
+
|
|
11
|
+
| | 0.5 means |
|
|
12
|
+
|---|---|
|
|
13
|
+
| **PROBABILITY** | "there is a 50% chance the segmented property is present at this voxel" |
|
|
14
|
+
| **OCCUPANCY** | "the segmented property fills 50% of this voxel's volume" |
|
|
15
|
+
|
|
16
|
+
They are not interchangeable. A partial-volume boundary voxel that is genuinely half tumour
|
|
17
|
+
has OCCUPANCY 0.5 and (if the model is certain) PROBABILITY ≈ 1.0. A voxel the model is
|
|
18
|
+
unsure about has PROBABILITY 0.5 and OCCUPANCY that is either 0 or 1 (the tumour is either
|
|
19
|
+
there or not — the model just doesn't know which).
|
|
20
|
+
|
|
21
|
+
`dicom-seg-js` therefore:
|
|
22
|
+
|
|
23
|
+
- exposes `seg.fractionalType` and **never defaults it** — an absent
|
|
24
|
+
`SegmentationFractionalType` reads back as `undefined` with a `FRACTIONAL_TYPE_ABSENT`
|
|
25
|
+
diagnostic, not as PROBABILITY;
|
|
26
|
+
- (planned) emits a diagnostic when the value distribution contradicts the declared type —
|
|
27
|
+
e.g. an OCCUPANCY field that is bimodal at 0 and `MaximumFractionalValue` with almost
|
|
28
|
+
nothing in between, which is a thresholded probability mask mislabelled.
|
|
29
|
+
|
|
30
|
+
If your pipeline needs one specific meaning, check `seg.fractionalType` and reject the
|
|
31
|
+
file when it is `undefined` or wrong. Do not let the library pick for you, because it
|
|
32
|
+
can't.
|
|
33
|
+
|
|
34
|
+
## 2. Confidence is not accuracy
|
|
35
|
+
|
|
36
|
+
A per-voxel PROBABILITY is the model's **confidence** at that location. It is not the
|
|
37
|
+
accuracy of the segmentation:
|
|
38
|
+
|
|
39
|
+
- **Accuracy needs ground truth**, which does not exist at inference time. Averaging
|
|
40
|
+
confidence over a segment tells you how sure the model was, not how often it was right.
|
|
41
|
+
- **Most model outputs are uncalibrated.** A softmax value of 0.9 does not mean 90% of
|
|
42
|
+
voxels with that value are correct; networks are typically overconfident, and the
|
|
43
|
+
mapping from score to empirical frequency is model- and dataset-specific. Calibration
|
|
44
|
+
(temperature scaling, isotonic regression, …) is a separate step that the SEG file does
|
|
45
|
+
not record.
|
|
46
|
+
|
|
47
|
+
So `dicom-seg-js` exposes honest quantities only, via the re-exported `rt-geometry-js`
|
|
48
|
+
functions:
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
import { readSeg, meanValue, volumeAboveThreshold, thresholdSensitivity } from "dicom-seg-js";
|
|
52
|
+
|
|
53
|
+
const seg = readSeg(bytes);
|
|
54
|
+
const field = seg.field(1);
|
|
55
|
+
const support = seg.support(1); // voxels the model marked at all
|
|
56
|
+
|
|
57
|
+
meanValue(field, support); // mean confidence over that region
|
|
58
|
+
volumeAboveThreshold(field, support, 0.7); // mm³ at confidence >= 0.7
|
|
59
|
+
thresholdSensitivity(field, support, [0.3, 0.5, 0.7, 0.9]);
|
|
60
|
+
// how much the segmented volume moves as the confidence cut moves — a flat curve
|
|
61
|
+
// means the threshold barely matters, a steep one means it dominates the result
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
There is deliberately **no** `accuracy()`, `percentCorrect()`, `reliability()`, or single
|
|
65
|
+
summary "quality" number anywhere in the API.
|
|
66
|
+
|
|
67
|
+
## 3. Displaying confidence
|
|
68
|
+
|
|
69
|
+
`seg.sampleConfidence(n, point)` gives an interpolated value for a cursor tooltip (§7.3) —
|
|
70
|
+
the same call as `dose.sample()` on a different field.
|
|
71
|
+
|
|
72
|
+
Presenting that value as "**87% confidence**" implies the model is calibrated, which it
|
|
73
|
+
usually is not. Unless you have calibrated the model yourself, prefer relative
|
|
74
|
+
presentation: a heatmap, or high/medium/low banding. This is a UI obligation on the
|
|
75
|
+
consumer, not something the library can enforce.
|
|
76
|
+
|
|
77
|
+
## 4. Validation
|
|
78
|
+
|
|
79
|
+
`dicom-seg-js`'s reconstruction was checked against
|
|
80
|
+
[`highdicom`](https://github.com/ImagingDataCommons/highdicom) 0.28 on real SEG files from
|
|
81
|
+
TCIA. Full method and the harness are in
|
|
82
|
+
[`../VALIDATION.md`](../VALIDATION.md) / [`../scripts/validation/`](../scripts/validation/);
|
|
83
|
+
the headline:
|
|
84
|
+
|
|
85
|
+
| File (TCIA) | Type | Segments | Result |
|
|
86
|
+
|---|---|---|---|
|
|
87
|
+
| C4KC-KiTS `KiTS-00007` | BINARY | 2 (Kidney, Mass) | **voxel-exact** — 122/122 slice checksums identical |
|
|
88
|
+
| NSCLC-Radiomics `LUNG1-005` | BINARY | 6 (Esophagus, GTV, Heart, L/R Lung, Cord) | **voxel-exact** — 546/546 |
|
|
89
|
+
| ISPY1 `ISPY1_1004` | FRACTIONAL / OCCUPANCY | 1 (PE Tumor) | **voxel-exact** — 60/60; raw value sum 22 876 305 matched exactly |
|
|
90
|
+
|
|
91
|
+
728 `(segment, plane)` slices, every one byte-for-byte identical to highdicom's
|
|
92
|
+
reconstruction (BINARY bit-unpacking included).
|
|
93
|
+
|
|
94
|
+
### Fractional types in the wild
|
|
95
|
+
|
|
96
|
+
Of the TCIA collections that publish DICOM SEG, most are `BINARY`. `FRACTIONAL` appears
|
|
97
|
+
mainly in the breast-MRI collections (ISPY1/ISPY2, ACRIN-6698). In the sample checked, the
|
|
98
|
+
declared fractional type was **`OCCUPANCY`** — but the `ISPY1_1004` file's non-zero values
|
|
99
|
+
are **all exactly 255**: it is a binary mask stored as FRACTIONAL, not a graded occupancy
|
|
100
|
+
field. `dicom-seg-js` emits a `FRACTIONAL_VALUES_LOOK_BINARY` diagnostic for exactly this
|
|
101
|
+
case (≥ 98% of non-zero values pinned at `MaximumFractionalValue`). No genuinely graded
|
|
102
|
+
`PROBABILITY` field turned up in the sample; that is consistent with FRACTIONAL SEG being
|
|
103
|
+
rare and usually a thresholded export rather than a raw model head. Treat a FRACTIONAL SEG
|
|
104
|
+
as graded only after checking its value distribution (`thresholdSensitivity`, or the
|
|
105
|
+
diagnostic).
|
package/package.json
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dicom-seg-js",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "DICOM Segmentation (SEG) reading — BINARY masks and FRACTIONAL probability/occupancy fields — built on rt-geometry-js.",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"dicom",
|
|
8
|
+
"segmentation",
|
|
9
|
+
"dicom-seg",
|
|
10
|
+
"seg",
|
|
11
|
+
"medical-imaging",
|
|
12
|
+
"mask",
|
|
13
|
+
"fractional",
|
|
14
|
+
"dcmjs"
|
|
15
|
+
],
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"author": "Adeel Khan",
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/adeelbarki/dicom-imaging-toolkit-packages.git",
|
|
21
|
+
"directory": "packages/dicom-seg"
|
|
22
|
+
},
|
|
23
|
+
"homepage": "https://github.com/adeelbarki/dicom-imaging-toolkit-packages/tree/main/packages/dicom-seg#readme",
|
|
24
|
+
"bugs": {
|
|
25
|
+
"url": "https://github.com/adeelbarki/dicom-imaging-toolkit-packages/issues"
|
|
26
|
+
},
|
|
27
|
+
"main": "./dist/index.js",
|
|
28
|
+
"types": "./dist/index.d.ts",
|
|
29
|
+
"exports": {
|
|
30
|
+
".": {
|
|
31
|
+
"types": "./dist/index.d.ts",
|
|
32
|
+
"import": "./dist/index.js"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"files": [
|
|
36
|
+
"dist",
|
|
37
|
+
"CHANGELOG.md",
|
|
38
|
+
"docs/FRACTIONAL-SEG.md"
|
|
39
|
+
],
|
|
40
|
+
"engines": {
|
|
41
|
+
"node": ">=20"
|
|
42
|
+
},
|
|
43
|
+
"scripts": {
|
|
44
|
+
"pretest": "npm run check:deps",
|
|
45
|
+
"test": "vitest run",
|
|
46
|
+
"test:watch": "vitest",
|
|
47
|
+
"typecheck": "tsc --noEmit",
|
|
48
|
+
"check:deps": "node ../../scripts/check-dependency-rule.mjs",
|
|
49
|
+
"build": "tsc -p tsconfig.build.json",
|
|
50
|
+
"prebuild": "npm run build --workspace rt-geometry-js",
|
|
51
|
+
"prepublishOnly": "npm run build && npm test"
|
|
52
|
+
},
|
|
53
|
+
"peerDependencies": {
|
|
54
|
+
"rt-geometry-js": "^0.1.2"
|
|
55
|
+
},
|
|
56
|
+
"devDependencies": {
|
|
57
|
+
"@types/node": "^26.2.0",
|
|
58
|
+
"rt-geometry-js": "^0.1.2",
|
|
59
|
+
"typescript": "^5.6.3",
|
|
60
|
+
"vitest": "^2.1.4"
|
|
61
|
+
},
|
|
62
|
+
"dependencies": {
|
|
63
|
+
"dcmjs": "^0.52.0"
|
|
64
|
+
}
|
|
65
|
+
}
|