@cosyte/dicom 0.0.4 → 0.0.6
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 +495 -182
- package/README.md +32 -21
- package/dist/index.cjs +275 -142
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +40 -11
- package/dist/index.d.ts +40 -11
- package/dist/index.mjs +276 -143
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
<picture>
|
|
2
|
+
<source media="(prefers-color-scheme: dark)" srcset="https://cosyte.com/tile/cosyte-lockup-tile-on-dark-1200x300.png">
|
|
3
|
+
<img alt="Cosyte: a plus mark set in two overlapping rounded squares, one solid and one outlined, beside the Cosyte wordmark" src="https://cosyte.com/tile/cosyte-lockup-tile-on-light-1200x300.png">
|
|
4
|
+
</picture>
|
|
5
|
+
|
|
1
6
|
# @cosyte/dicom
|
|
2
7
|
|
|
3
8
|
> Read a real-world, vendor-quirky DICOM Part 10 file and pull the metadata you need in one line, without having read the DICOM standard.
|
|
@@ -26,7 +31,7 @@ import { parseDicom } from "@cosyte/dicom";
|
|
|
26
31
|
|
|
27
32
|
const ds = parseDicom(await readFile("study.dcm"));
|
|
28
33
|
|
|
29
|
-
ds.get("
|
|
34
|
+
ds.get("00100010")?.value; // (0010,0010) Patient's Name: structured PN, never flattened
|
|
30
35
|
ds.study.instanceUid; // "1.2.840.…": the global study anchor
|
|
31
36
|
ds.image.rescaleSlope; // number | undefined: undefined means "absent", never 1
|
|
32
37
|
```
|
|
@@ -38,11 +43,11 @@ That's the pitch: no config, no schema upload, no spec lookup. The parser accept
|
|
|
38
43
|
## Features
|
|
39
44
|
|
|
40
45
|
- **One-line metadata extraction**: `ds.patient`, `ds.study`, `ds.series`, `ds.image`: typed, fail-safe views over the safety-critical attributes. No `(group,element)` tags to memorise.
|
|
41
|
-
- **Two access patterns**: named views, or structural `ds.get("
|
|
46
|
+
- **Two access patterns**: named views, or structural `ds.get("00100010")` by 8-character `(group,element)` tag (resolve a keyword to its tag with `Dictionary.byKeyword`), plus `ds.elements()` to walk everything.
|
|
42
47
|
- **Lazy typed value decode**: `element.value` decodes raw bytes into a discriminated `DicomValue` across all 34 VRs (numbers, `bigint`s, person names, dates/times, sequences, raw `binary`), honoring `(0008,0005)` Specific Character Set through nested items.
|
|
43
|
-
- **Real-world tolerance, Postel's Law**: a lenient reader emits
|
|
48
|
+
- **Real-world tolerance, Postel's Law**: a lenient reader emits 25 stable warning codes for what it tolerated; only 4 truly-structural conditions are fatal. The serializer always writes spec-clean Part 10.
|
|
44
49
|
- **Source/vendor profile system**: `defineProfile()` + 5 built-ins (`ge`, `siemens`, `philips`, `strict`, `lenient`) that only ever _tighten or annotate_ a parse, resolving vendor private tags by the file's live Private Creator string, never a wrong decode.
|
|
45
|
-
- **Metadata-level de-identification**: `deidentify()` applies the PS3.15 Annex E Basic Profile + the nine metadata Options, returning a fresh dataset and
|
|
50
|
+
- **Metadata-level de-identification**: `deidentify()` applies the PS3.15 Annex E Basic Profile + the nine metadata Options, returning a fresh dataset and an audit report that is value-free apart from the source UIDs in `report.uidMap`.
|
|
46
51
|
- **Spec-clean serializer**: `serializeDicom(ds)` round-trips a dataset back to Part 10 bytes in its source transfer syntax (no transcode), with correct File Meta group length, even-length padding, byte-exact sequence passthrough, and lossless File Meta: non-modeled `(0002,xxxx)` elements are preserved and re-emitted in tag order.
|
|
47
52
|
- **Strict TypeScript, dual ESM + CJS, Node ≥ 22**: `noUncheckedIndexedAccess`, no `any`, JSDoc + `@example` on every public export feeding your editor's IntelliSense. Zero runtime dependencies today.
|
|
48
53
|
|
|
@@ -87,15 +92,20 @@ s.instanceUid; // "1.2.840.…" Study Instance UID (0020,000D)
|
|
|
87
92
|
s.accessionNumber; // ties the study to the HIS order (0008,0050)
|
|
88
93
|
```
|
|
89
94
|
|
|
90
|
-
### By
|
|
95
|
+
### By tag
|
|
91
96
|
|
|
92
|
-
`get`
|
|
97
|
+
`get`, `has` and `getAll` take the **8-character `(group,element)` tag** (case-insensitive) and only that: `"00080060"`, not `"Modality"` and not `"(0008,0060)"`. A keyword resolves to its tag through the dictionary first. `getAll` is the always-array complement of `get` (a dataset holds at most one element per tag, so it returns 0 or 1), and `elements()` walks everything.
|
|
93
98
|
|
|
94
99
|
```ts
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
ds.
|
|
100
|
+
import { Dictionary } from "@cosyte/dicom";
|
|
101
|
+
|
|
102
|
+
ds.get("00080060"); // Modality (0008,0060)
|
|
103
|
+
ds.has("7FE00010"); // boolean: is Pixel Data present
|
|
98
104
|
ds.elements(); // readonly Element[]: walk everything
|
|
105
|
+
|
|
106
|
+
// Prefer keywords? Resolve one to its tag, then get by tag.
|
|
107
|
+
const tag = Dictionary.byKeyword("Modality")?.tag; // "00080060"
|
|
108
|
+
ds.get(tag ?? "");
|
|
99
109
|
```
|
|
100
110
|
|
|
101
111
|
### Typed values
|
|
@@ -103,10 +113,10 @@ ds.elements(); // readonly Element[]: walk everything
|
|
|
103
113
|
`get` returns an `Element`; its `.value` lazily decodes the raw bytes into a discriminated `DicomValue` and caches the result.
|
|
104
114
|
|
|
105
115
|
```ts
|
|
106
|
-
const rows = ds.get("
|
|
116
|
+
const rows = ds.get("00280010")?.value; // Rows, a US
|
|
107
117
|
if (rows?.kind === "numbers") rows.values[0]; // 512
|
|
108
118
|
|
|
109
|
-
const name = ds.get("
|
|
119
|
+
const name = ds.get("00100010")?.value; // Patient's Name, a PN
|
|
110
120
|
if (name?.kind === "personName") name.values[0]?.alphabetic.givenName; // "Jane"
|
|
111
121
|
```
|
|
112
122
|
|
|
@@ -141,7 +151,7 @@ async function indexFile(path: string) {
|
|
|
141
151
|
}
|
|
142
152
|
```
|
|
143
153
|
|
|
144
|
-
|
|
154
|
+
A quirky object is tolerated rather than rejected, and absent fields come back `undefined`. Check `ds.warnings` to log what was tolerated. A folder walk **does** still need a `try`/`catch`, because all four Tier-3 conditions throw and a real archive meets all four: `UNSUPPORTED_TRANSFER_SYNTAX` for a pixel-compressed object, which this parser does not read; `INVALID_FILE_META` for a truncated or partly-copied file; `NOT_DICOM_PART_10` for whatever non-DICOM file wandered into the folder; and `EMPTY_INPUT` for a zero-byte one. They all throw the one class, so catch `DicomParseError` per file and skip.
|
|
145
155
|
|
|
146
156
|
### Build routing keys
|
|
147
157
|
|
|
@@ -173,7 +183,7 @@ img.photometricInterpretation; // (0028,0004) never defaulted to MONOCHROME2
|
|
|
173
183
|
img.pixelSpacing; // (0028,0030) patient-plane mm, distinct from imagerPixelSpacing
|
|
174
184
|
```
|
|
175
185
|
|
|
176
|
-
> **Vendor note.** Philips writes private rescale tags `(2005,1409/140A/140B)` that shadow the standard `(0028,1052/1053)`; using the standard tags alone can yield non-quantitative values. This parser **preserves** the private tags so you can prefer them. Reach them with `ds.get("
|
|
186
|
+
> **Vendor note.** Philips writes private rescale tags `(2005,1409/140A/140B)` that shadow the standard `(0028,1052/1053)`; using the standard tags alone can yield non-quantitative values. This parser **preserves** the private tags so you can prefer them. Reach them with `ds.get("20051409")` (optionally under `profiles.philips`).
|
|
177
187
|
|
|
178
188
|
For Enhanced multi-frame objects, `image.frame(i)` resolves each frame's functional-group macros Per-Frame-else-Shared (PS3.3 C.7.6.16). It throws a `DicomValueError` (carrying only structural facts, never PHI) for an out-of-range frame or a required geometry macro missing from both groups.
|
|
179
189
|
|
|
@@ -187,7 +197,7 @@ if (img.isEnhancedMultiFrame) {
|
|
|
187
197
|
|
|
188
198
|
### De-identify before sharing
|
|
189
199
|
|
|
190
|
-
`deidentify()` applies the PS3.15 Annex E Basic Application Level Confidentiality Profile (replacing, emptying, or removing every attribute the standard lists as identifying) and returns a fresh dataset plus a value-free report.
|
|
200
|
+
`deidentify()` applies the PS3.15 Annex E Basic Application Level Confidentiality Profile (replacing, emptying, or removing every attribute the standard lists as identifying) and returns a fresh dataset plus a report that is value-free apart from the source UIDs in `report.uidMap`.
|
|
191
201
|
|
|
192
202
|
```ts
|
|
193
203
|
import { parseDicom, deidentify, serializeDicom } from "@cosyte/dicom";
|
|
@@ -262,7 +272,7 @@ const ds = parseDicom(buf, { profile: profiles.siemens });
|
|
|
262
272
|
|
|
263
273
|
A profile bundles three things that only ever **tighten or annotate** a parse, never loosen it past the lenient default:
|
|
264
274
|
|
|
265
|
-
- **Private-dictionary overlay**: resolves the Implicit VR of vendor private data elements by the file's _live_ Private Creator string (e.g. `"SIEMENS CSA HEADER"`), keyed canonically as `"GGGGxxLL"` (PS3.5 §7.8.1), never a hard-coded block number. (This is why Agfa IMPAX re-assigning blocks still resolves.) An unknown creator degrades to `UN` plus a `DICOM_PRIVATE_CREATOR_UNKNOWN` warning.
|
|
275
|
+
- **Private-dictionary overlay**: resolves the Implicit VR of vendor private data elements by the file's _live_ Private Creator string (e.g. `"SIEMENS CSA HEADER"`), keyed canonically as `"GGGGxxLL"` (PS3.5 §7.8.1), never a hard-coded block number. (This is why Agfa IMPAX re-assigning blocks still resolves.) An unknown creator degrades to `UN` plus a `DICOM_PRIVATE_CREATOR_UNKNOWN` warning. The lookup is scoped to one Data Set, and every Sequence Item is its own (PS3.5 §7.5.1, §7.8.1): a block claimed at the root does not resolve an element inside an item, and an element whose block was never claimed in its own Data Set reads `UN` plus `DICOM_PRIVATE_TAG_NO_CREATOR` rather than borrowing a neighbour's VR. Declare the creator in each item that writes private data.
|
|
266
276
|
- **Escalations**: Tier-2 warning codes promoted to a thrown `DicomParseError` (a stricter posture for known-unsafe deviations).
|
|
267
277
|
- **Suppressions**: benign, high-volume warning codes silenced for a known-quirky source.
|
|
268
278
|
|
|
@@ -293,7 +303,7 @@ At an RSNA-era interoperability test, ~80% of real-world patient CDs failed stri
|
|
|
293
303
|
| 2 | Warning | Recoverable deviation | `DICOM_MISSING_PREAMBLE` |
|
|
294
304
|
| 3 | Fatal (always) | Unrecoverable structural error | `NOT_DICOM_PART_10` |
|
|
295
305
|
|
|
296
|
-
Tier-2 warnings are plain data on `ds.warnings`. Each carries a stable string `code`, a
|
|
306
|
+
Tier-2 warnings are plain data on `ds.warnings`. Each carries a stable string `code`, a `message` looked up from a frozen registry (never composed from the document), and a `position` with the byte offset where it occurred, so you can react programmatically:
|
|
297
307
|
|
|
298
308
|
```ts
|
|
299
309
|
import { parseDicom, WARNING_CODES } from "@cosyte/dicom";
|
|
@@ -306,7 +316,7 @@ for (const w of ds.warnings) {
|
|
|
306
316
|
}
|
|
307
317
|
```
|
|
308
318
|
|
|
309
|
-
The
|
|
319
|
+
The 25 Tier-2 codes (`DICOM_MISSING_PREAMBLE`, `DICOM_FILE_META_GROUP_LENGTH_MISMATCH`, `DICOM_UN_PARSED_AS_SQ`, `DICOM_ODD_LENGTH_VALUE_PADDED`, `DICOM_PRIVATE_CREATOR_UNKNOWN`, `DICOM_VR_MISMATCH`, `DICOM_DA_LEGACY_FORMAT`, … ) live in [`src/parser/warnings.ts`](./src/parser/warnings.ts). Narrow on `w.code === WARNING_CODES.…` for typo-free comparisons, or pass `{ onWarning }` to `parseDicom` to stream them.
|
|
310
320
|
|
|
311
321
|
The 4 Tier-3 fatal codes (`NOT_DICOM_PART_10`, `INVALID_FILE_META`, `UNSUPPORTED_TRANSFER_SYNTAX`, `EMPTY_INPUT`) always throw a `DicomParseError`; they represent input the parser cannot meaningfully recover.
|
|
312
322
|
|
|
@@ -314,11 +324,11 @@ The 4 Tier-3 fatal codes (`NOT_DICOM_PART_10`, `INVALID_FILE_META`, `UNSUPPORTED
|
|
|
314
324
|
|
|
315
325
|
## Error Handling
|
|
316
326
|
|
|
317
|
-
The library throws
|
|
327
|
+
The library throws five typed errors, all exported from the package barrel. Warnings are data rather than throws unless you ask otherwise: a profile's `escalate` list promotes only the codes it names.
|
|
318
328
|
|
|
319
329
|
### `DicomParseError`
|
|
320
330
|
|
|
321
|
-
Thrown by `parseDicom` on one of the 4 Tier-3 fatal codes. Carries the byte position and a
|
|
331
|
+
Thrown by `parseDicom` on one of the 4 Tier-3 fatal codes. Carries the byte position, a registry-composed message, and a 16-byte hex `snippet` of the source. The snippet is raw input: treat it as PHI and redact it at your own boundary.
|
|
322
332
|
|
|
323
333
|
```ts
|
|
324
334
|
import { parseDicom, DicomParseError, FATAL_CODES } from "@cosyte/dicom";
|
|
@@ -350,13 +360,14 @@ Thrown by `serializeDicom` for `MISSING_TRANSFER_SYNTAX` (the dataset names no t
|
|
|
350
360
|
|
|
351
361
|
`@cosyte/dicom` is metadata-first by design. Even at v1-complete, do **not** rely on it for:
|
|
352
362
|
|
|
353
|
-
- **Pixel data.** No decode/decompression
|
|
363
|
+
- **Pixel data.** No decode/decompression, no rendering, no measurements: Pixel Data is exposed as raw bytes. And v1 does not read a **compressed object at all**, not even structurally: a transfer syntax outside the four listed below is the fatal `UNSUPPORTED_TRANSFER_SYNTAX`, so JPEG / JPEG-LS / JPEG2000 / RLE / HTJ2K objects do not parse. → `@cosyte/dicom-pixel`.
|
|
354
364
|
- **Burned-in PHI.** v1 **warns** it cannot remove burned-in annotation; a "de-identified" output is **metadata-de-identified only**.
|
|
365
|
+
- **A sequence the parser could not open.** `deidentify()` recurses only into a sequence whose items the parser materialized, so anything nested inside one it could not read is neither cleaned nor listed in the report. Two shapes reach that state and **only one of them warns**: a defined-length Implicit VR LE value whose dictionary-resolved `SQ` is not an item stream raises `DICOM_SQ_NOT_DESCENDED`, while an undefined-length `UN` value the CP-246 descent could not read as a sequence is **silent** (it keeps `vr === "UN"` and raises nothing). So `ds.warnings` is worth reading but is not sufficient: the reliable test is `el.items === undefined` on an `SQ` or `UN` element you are trusting the report about.
|
|
355
366
|
- **Networking & web.** No DIMSE (C-STORE/FIND/MOVE, MWL, MPPS); no DICOMweb (QIDO/WADO/STOW). → `@cosyte/dicom-net`, `@cosyte/dicomweb`.
|
|
356
367
|
- **Transcoding.** No transfer-syntax conversion. The serializer re-emits in the dataset's source syntax only.
|
|
357
368
|
- **Terminology resolution.** Coded values are surfaced (designator + canonical source) but not validated against SNOMED/LOINC/etc.
|
|
358
369
|
|
|
359
|
-
Supported transfer syntaxes
|
|
370
|
+
Supported transfer syntaxes, and **exactly** these four (**pixels never decoded** in any of them): Implicit VR LE `1.2.840.10008.1.2`, Explicit VR LE `…1.2.1`, Deflated Explicit VR LE `…1.2.1.99`, Explicit VR BE `…1.2.2` (retired, legacy-only). Any other UID, which includes every pixel-compressed syntax (JPEG, JPEG-LS, JPEG2000, RLE, HTJ2K), is rejected by `parseDicom` with the fatal `UNSUPPORTED_TRANSFER_SYNTAX` rather than read structurally. Deflated is the one compressed syntax in the supported set: it deflates the whole dataset stream rather than the pixels, and it is inflated on parse.
|
|
360
371
|
|
|
361
372
|
---
|
|
362
373
|
|