Data formats

DICOM

DICOM medical image files read by hand, explicit and implicit VR: tags by name, patient, study and series fields, identifying data stripped by a good part of the basic profile and the file written back, and uncompressed pixels to PNG with rescale and window applied.

Version 1.0.0 · 6 functions · 3 crates

#healthcare#medical-imaging#png#privacy

Functions

What use dicom.qwer gives a program, with the types Quillo sees. To take only some, or under other names: use dicom_tags, dicom_tag from dicom (Quillo 0.6).

dicom_tags(path: str) → anyor an error

All the top-level tags as a map, by name (or (gggg,eeee) when this does not know them): { PatientName: { family, given, ... }, Rows: 16, ... }, sequences as lists of maps, pixel data as its size.

dicom_tag(path: str, name: str) → anyor an error

One tag's value by name, PatientID, or number, (0010,0020) or 00100020. Null when the file lacks it; an error for a name nobody knows.

dicom_elements(path: str) → listor an error

Each element in order: [{ tag, name, vr, value }], for a viewer of a file's header.

dicom_summary(path: str) → anyor an error

The fields a program wants: { patient: { name, id, birth_date, sex, age }, study: { uid, date, time, description, accession }, series: { uid, number, modality, description }, image: { sop_class, instance_uid, number, rows, columns, frames, bits_allocated, bits_stored, photometric, pixel_spacing }, transfer_syntax, compressed }.

dicom_strip_phi(path: str, out: str, options: any) → anyor an error

Takes the identifying data out of a file, by a good part of PS3.15's basic profile, and writes it to out: names, ids, dates, addresses and institutions blanked or removed, also inside sequences; private tags gone; UIDs replaced by ones hashed with options.salt, so a study's files stay together; PatientIdentityRemoved set. options.keep names tags to leave. options.patient_id sets a pseudonym. Gives { blanked, removed, uids, private_tags, warnings }: the image itself is left as it is, so text burned into its pixels stays, and a warning says so unless the file says none is there.

dicom_png(path: str, out: str, options: any) → anyor an error

Writes one frame as a PNG at out: 8 bits gray, or RGB. options: frame (from 0), window_center and window_width (the file's, else the pixels' full range). RescaleSlope and RescaleIntercept are applied first, MONOCHROME1 inverted. Only uncompressed files: a JPEG one is an error naming its syntax. Gives { path, width, height, frame, frames, window_center, window_width }.

Get it

$ quillo install dicom               # dicom.qwer into this folder$ quillo install dicom --example     # and its example, dicom.qwe

Or with curl:

$ curl -O https://quillolang.org/plugins/files/dicom.qwer$ curl -O https://quillolang.org/plugins/files/dicom.qwe$ curl -O https://quillolang.org/plugins/files/dicom_sample.dcm$ curl -O https://quillolang.org/plugins/files/dicom_sample_implicit.dcm

Then, in your program, all of it or only what you call:

main.qwe
use dicom.qweruse dicom_tags, dicom_tag from dicom      // Quillo 0.6: only these

It uses these crates, which cargo fetches the first time the plugin is built:

[dependencies]
serde_json = { version = "1", features = ["preserve_order"] }png = "0.17"sha2 = "0.10"

Example

dicom.qwe
// A radiology file on its way to a research share: what it holds, its identifying data taken out,// and its image as a PNG. dicom_sample.dcm and dicom_sample_implicit.dcm are synthetic: a 16 by// 16 ramp and made-up people, written from the standard's rules by a script.////   quillo run dicom.qwe      then open http://localhost:8080/summary, /tags, /strip, /image.png, /window and /refuseduse "dicom.qwer"let SAMPLE = "dicom_sample.dcm"let IMPLICIT = "dicom_sample_implicit.dcm"let CLEAN = ".quillo/dicom_stripped.dcm"route GET /summary = dicom_summary(SAMPLE)?route GET /tags = {    name: dicom_tag(SAMPLE, "PatientName")?,    by_number: dicom_tag(SAMPLE, "(0010,0020)")?,    spacing: dicom_tag(SAMPLE, "PixelSpacing")?,    image_type: dicom_tag(SAMPLE, "ImageType")?,    nested: dicom_tag(SAMPLE, "ReferencedImageSequence")?,    sequence: dicom_tag(SAMPLE, "RequestAttributesSequence")?,    pixels: dicom_tag(SAMPLE, "PixelData")?,    private_tag: dicom_tag(SAMPLE, "(0029,1001)")?,    absent: dicom_tag(SAMPLE, "PatientWeight")?,    implicit_reads_alike: dicom_tags(IMPLICIT)?.PatientName == dicom_tags(SAMPLE)?.PatientName,    implicit: dicom_summary(IMPLICIT)?.transfer_syntax.name,    header: slice(dicom_elements(SAMPLE)?, 0, 2),}route GET /strip {    let done = dicom_strip_phi(SAMPLE, CLEAN, { salt: "project-7", patient_id: "RESEARCH-001", keep: ["StudyDate"] })?    let t = dicom_tags(CLEAN)?    let before = dicom_summary(SAMPLE)?    let after = dicom_summary(CLEAN)?    {        done: done,        name: t.PatientName.text, id: t.PatientID, kept_date: t.StudyDate, birth: t.PatientBirthDate,        gone: [t.PatientAddress, t.InstitutionName, t.DeviceSerialNumber, t.PatientComments, t["(0029,1001)"]],        nested: t.RequestAttributesSequence,        identity_removed: t.PatientIdentityRemoved,        new_uid: after.study.uid != before.study.uid,        uid_prefix: slice(after.study.uid, 0, 5),        same_again: dicom_strip_phi(SAMPLE, CLEAN + "2", { salt: "project-7" }).uids == done.uids,        still_reads: after.image.rows,        same_image: dicom_png(CLEAN, ".quillo/dicom_stripped.png", {})?.highest == dicom_png(SAMPLE, ".quillo/dicom_sample.png", {})?.highest,    }}route GET /image.png {    dicom_png(SAMPLE, ".quillo/dicom_view.png", {})?    file(".quillo/dicom_view.png")}route GET /window = {    file_window: dicom_png(SAMPLE, ".quillo/dicom_w.png", {})?,    narrow: dicom_png(SAMPLE, ".quillo/dicom_w.png", { window_center: 1000.0, window_width: 400.0 })?.window_width,    bad_frame: dicom_png(SAMPLE, ".quillo/dicom_w.png", { frame: 3 }).error,    bad_width: dicom_png(SAMPLE, ".quillo/dicom_w.png", { window_width: 0.0 }).error,}route GET /refused = {    missing: dicom_summary("no-such.dcm").error != null,    not_dicom: dicom_summary("dicom.qwe").error,    unknown_tag: dicom_tag(SAMPLE, "ShoeSize").error,}
$ quillo run dicom.qwe$ curl 'localhost:8080/summary'$ curl 'localhost:8080/tags'$ curl 'localhost:8080/strip'$ curl 'localhost:8080/image.png'$ curl 'localhost:8080/window'$ curl 'localhost:8080/refused'

Source

dicom.qwer
//! DICOM medical image files read by hand: tags by name, patient and study fields, PHI stripped and the file written back, and uncompressed pixel data to PNG with window and rescale applied.//! version: 1.0.0//!//! [dependencies]//! serde_json = { version = "1", features = ["preserve_order"] }//! png = "0.17"//! sha2 = "0.10"use serde_json::{json, Map, Value};use sha2::{Digest, Sha256};const MAX_DEPTH: usize = 32;const PIXEL_DATA: u32 = 0x7FE0_0010;const ITEM: u32 = 0xFFFE_E000;const ITEM_END: u32 = 0xFFFE_E00D;const SEQ_END: u32 = 0xFFFE_E0DD;const UNDEFINED: u32 = 0xFFFF_FFFF;/// The tags in common use, from PS3.6: tag, VR, name.const DICT: &[(u32, &str, &str)] = &[    (0x0002_0000, "UL", "FileMetaInformationGroupLength"), (0x0002_0001, "OB", "FileMetaInformationVersion"), (0x0002_0002, "UI", "MediaStorageSOPClassUID"),    (0x0002_0003, "UI", "MediaStorageSOPInstanceUID"), (0x0002_0010, "UI", "TransferSyntaxUID"), (0x0002_0012, "UI", "ImplementationClassUID"), (0x0002_0013, "SH", "ImplementationVersionName"),    (0x0008_0005, "CS", "SpecificCharacterSet"), (0x0008_0008, "CS", "ImageType"), (0x0008_0012, "DA", "InstanceCreationDate"), (0x0008_0013, "TM", "InstanceCreationTime"),    (0x0008_0016, "UI", "SOPClassUID"), (0x0008_0018, "UI", "SOPInstanceUID"), (0x0008_0020, "DA", "StudyDate"), (0x0008_0021, "DA", "SeriesDate"), (0x0008_0022, "DA", "AcquisitionDate"),    (0x0008_0023, "DA", "ContentDate"), (0x0008_002A, "DT", "AcquisitionDateTime"), (0x0008_0030, "TM", "StudyTime"), (0x0008_0031, "TM", "SeriesTime"), (0x0008_0032, "TM", "AcquisitionTime"), (0x0008_0033, "TM", "ContentTime"),    (0x0008_0050, "SH", "AccessionNumber"), (0x0008_0060, "CS", "Modality"), (0x0008_0070, "LO", "Manufacturer"), (0x0008_0080, "LO", "InstitutionName"), (0x0008_0081, "ST", "InstitutionAddress"),    (0x0008_0090, "PN", "ReferringPhysicianName"), (0x0008_0092, "ST", "ReferringPhysicianAddress"), (0x0008_0094, "SH", "ReferringPhysicianTelephoneNumbers"), (0x0008_1010, "SH", "StationName"),    (0x0008_1030, "LO", "StudyDescription"), (0x0008_103E, "LO", "SeriesDescription"), (0x0008_1040, "LO", "InstitutionalDepartmentName"), (0x0008_1048, "PN", "PhysiciansOfRecord"),    (0x0008_1050, "PN", "PerformingPhysicianName"), (0x0008_1060, "PN", "NameOfPhysiciansReadingStudy"), (0x0008_1070, "PN", "OperatorsName"), (0x0008_1090, "LO", "ManufacturerModelName"),    (0x0008_1110, "SQ", "ReferencedStudySequence"), (0x0008_1120, "SQ", "ReferencedPatientSequence"), (0x0008_1140, "SQ", "ReferencedImageSequence"), (0x0008_1150, "UI", "ReferencedSOPClassUID"), (0x0008_1155, "UI", "ReferencedSOPInstanceUID"),    (0x0010_0010, "PN", "PatientName"), (0x0010_0020, "LO", "PatientID"), (0x0010_0021, "LO", "IssuerOfPatientID"), (0x0010_0030, "DA", "PatientBirthDate"), (0x0010_0032, "TM", "PatientBirthTime"), (0x0010_0040, "CS", "PatientSex"),    (0x0010_1000, "LO", "OtherPatientIDs"), (0x0010_1001, "PN", "OtherPatientNames"), (0x0010_1005, "PN", "PatientBirthName"), (0x0010_1010, "AS", "PatientAge"), (0x0010_1020, "DS", "PatientSize"), (0x0010_1030, "DS", "PatientWeight"),    (0x0010_1040, "LO", "PatientAddress"), (0x0010_1060, "PN", "PatientMotherBirthName"), (0x0010_2154, "SH", "PatientTelephoneNumbers"), (0x0010_2160, "SH", "EthnicGroup"), (0x0010_2180, "SH", "Occupation"),    (0x0010_21B0, "LT", "AdditionalPatientHistory"), (0x0010_4000, "LT", "PatientComments"), (0x0012_0062, "CS", "PatientIdentityRemoved"), (0x0012_0063, "LO", "DeidentificationMethod"),    (0x0018_0015, "CS", "BodyPartExamined"), (0x0018_0050, "DS", "SliceThickness"), (0x0018_0060, "DS", "KVP"), (0x0018_1000, "LO", "DeviceSerialNumber"), (0x0018_1020, "LO", "SoftwareVersions"), (0x0018_1030, "LO", "ProtocolName"),    (0x0020_000D, "UI", "StudyInstanceUID"), (0x0020_000E, "UI", "SeriesInstanceUID"), (0x0020_0010, "SH", "StudyID"), (0x0020_0011, "IS", "SeriesNumber"), (0x0020_0012, "IS", "AcquisitionNumber"), (0x0020_0013, "IS", "InstanceNumber"),    (0x0020_0032, "DS", "ImagePositionPatient"), (0x0020_0037, "DS", "ImageOrientationPatient"), (0x0020_0052, "UI", "FrameOfReferenceUID"), (0x0020_1041, "DS", "SliceLocation"), (0x0020_4000, "LT", "ImageComments"),    (0x0028_0002, "US", "SamplesPerPixel"), (0x0028_0004, "CS", "PhotometricInterpretation"), (0x0028_0006, "US", "PlanarConfiguration"), (0x0028_0008, "IS", "NumberOfFrames"), (0x0028_0010, "US", "Rows"), (0x0028_0011, "US", "Columns"),    (0x0028_0030, "DS", "PixelSpacing"), (0x0028_0100, "US", "BitsAllocated"), (0x0028_0101, "US", "BitsStored"), (0x0028_0102, "US", "HighBit"), (0x0028_0103, "US", "PixelRepresentation"), (0x0028_0301, "CS", "BurnedInAnnotation"),    (0x0028_1050, "DS", "WindowCenter"), (0x0028_1051, "DS", "WindowWidth"), (0x0028_1052, "DS", "RescaleIntercept"), (0x0028_1053, "DS", "RescaleSlope"), (0x0028_1054, "LO", "RescaleType"),    (0x0032_1032, "PN", "RequestingPhysician"), (0x0032_1060, "LO", "RequestedProcedureDescription"), (0x0040_0006, "PN", "ScheduledPerformingPhysicianName"), (0x0040_0244, "DA", "PerformedProcedureStepStartDate"),    (0x0040_0254, "LO", "PerformedProcedureStepDescription"), (0x0040_0275, "SQ", "RequestAttributesSequence"), (0x0040_A730, "SQ", "ContentSequence"), (0x7FE0_0010, "OW", "PixelData"),];/// Emptied, as PS3.15's basic profile empties type 2 attributes, which must stay.const BLANK: &[u32] = &[0x0008_0020, 0x0008_0030, 0x0008_0050, 0x0008_0090, 0x0010_0010, 0x0010_0020, 0x0010_0030, 0x0010_0040, 0x0020_0010];/// Removed outright.const REMOVE: &[u32] = &[    0x0008_0012, 0x0008_0013, 0x0008_0021, 0x0008_0022, 0x0008_0023, 0x0008_002A, 0x0008_0031, 0x0008_0032, 0x0008_0033, 0x0008_0080, 0x0008_0081, 0x0008_0092, 0x0008_0094, 0x0008_1010, 0x0008_1040, 0x0008_1048, 0x0008_1050, 0x0008_1060, 0x0008_1070,    0x0010_0021, 0x0010_0032, 0x0010_1000, 0x0010_1001, 0x0010_1005, 0x0010_1010, 0x0010_1020, 0x0010_1030, 0x0010_1040, 0x0010_1060, 0x0010_2154, 0x0010_2160, 0x0010_2180, 0x0010_21B0, 0x0010_4000,    0x0018_1000, 0x0020_4000, 0x0032_1032, 0x0032_1060, 0x0040_0006, 0x0040_0244, 0x0040_0254, 0x0008_1120,];/// UIDs that tie a file to its origin: replaced by ones derived from them, so a study's files stay together.const UIDS: &[u32] = &[0x0002_0003, 0x0008_0018, 0x0008_1155, 0x0020_000D, 0x0020_000E, 0x0020_0052];#[derive(Clone)]enum Body {    Bytes(Vec<u8>),    Seq(Vec<Vec<Elem>>),    Fragments(Vec<Vec<u8>>),}#[derive(Clone)]struct Elem {    tag: u32,    vr: [u8; 2],    body: Body,}#[derive(Clone, Copy, PartialEq)]struct Syntax {    explicit: bool,    big: bool,}struct File {    preamble: Vec<u8>,    meta: Vec<Elem>,    syntax: Syntax,    ts: String,    data: Vec<Elem>,}fn long_form(vr: &[u8; 2]) -> bool {    matches!(vr, b"OB" | b"OD" | b"OF" | b"OL" | b"OV" | b"OW" | b"SQ" | b"UC" | b"UN" | b"UR" | b"UT" | b"SV" | b"UV")}fn tag_text(t: u32) -> String {    format!("({:04X},{:04X})", t >> 16, t & 0xFFFF)}fn dict_vr(t: u32) -> [u8; 2] {    match DICT.iter().find(|d| d.0 == t) {        Some(d) => [d.1.as_bytes()[0], d.1.as_bytes()[1]],        None if t & 0xFFFF == 0 => *b"UL",        None => *b"UN",    }}struct Reader<'a> {    d: &'a [u8],    p: usize,    s: Syntax,}impl Reader<'_> {    fn take(&mut self, n: usize) -> Result<&[u8], String> {        let end = self.p.checked_add(n).filter(|e| *e <= self.d.len()).ok_or(format!("the file ends at byte {}, inside an element that needs {} more", self.d.len(), n))?;        let s = &self.d[self.p..end];        self.p = end;        Ok(s)    }    fn u16(&mut self) -> Result<u16, String> {        let big = self.s.big;        let b = self.take(2)?;        Ok(if big { u16::from_be_bytes([b[0], b[1]]) } else { u16::from_le_bytes([b[0], b[1]]) })    }    fn u32(&mut self) -> Result<u32, String> {        let big = self.s.big;        let b = self.take(4)?;        let a = [b[0], b[1], b[2], b[3]];        Ok(if big { u32::from_be_bytes(a) } else { u32::from_le_bytes(a) })    }    fn tag(&mut self) -> Result<u32, String> {        Ok((self.u16()? as u32) << 16 | self.u16()? as u32)    }    /// Items up to the end of a sequence.    fn items(&mut self, len: u32, depth: usize) -> Result<Vec<Vec<Elem>>, String> {        if depth > MAX_DEPTH {            return Err("sequences are nested too deep".into());        }        let end = if len == UNDEFINED { None } else { Some(self.p.checked_add(len as usize).filter(|e| *e <= self.d.len()).ok_or("a sequence runs off the end of the file")?) };        let mut out = vec![];        loop {            if end.is_some_and(|e| self.p >= e) {                break;            }            let t = self.tag()?;            let n = self.u32()?;            match t {                SEQ_END => break,                ITEM => {                    let stop = if n == UNDEFINED { None } else { Some(self.p.checked_add(n as usize).filter(|e| *e <= self.d.len()).ok_or("an item runs off the end of the file")?) };                    out.push(self.elements(stop, depth + 1, stop.is_none())?);                }                o => return Err(format!("{} where an item should be, at byte {}", tag_text(o), self.p)),            }        }        Ok(out)    }    fn fragments(&mut self) -> Result<Vec<Vec<u8>>, String> {        let mut out = vec![];        loop {            let t = self.tag()?;            let n = self.u32()?;            match t {                SEQ_END => return Ok(out),                ITEM => out.push(self.take(n as usize)?.to_vec()),                o => return Err(format!("{} among the image's fragments", tag_text(o))),            }        }    }    fn elements(&mut self, stop: Option<usize>, depth: usize, in_item: bool) -> Result<Vec<Elem>, String> {        let mut out = vec![];        while stop.map_or(self.p < self.d.len(), |e| self.p < e) {            let t = self.tag()?;            if t == ITEM_END && in_item {                self.u32()?;                return Ok(out);            }            if t == 0xFFFC_FFFC {                self.p = self.d.len();                return Ok(out); // trailing padding            }            let (vr, len) = if self.s.explicit {                let b = self.take(2)?;                let vr = [b[0], b[1]];                if !vr.iter().all(u8::is_ascii_uppercase) {                    return Err(format!("{}: `{}` is no VR, at byte {}: the file is damaged or its transfer syntax wrong", tag_text(t), String::from_utf8_lossy(&vr), self.p - 2));                }                if long_form(&vr) {                    self.take(2)?;                    (vr, self.u32()?)                } else {                    (vr, self.u16()? as u32)                }            } else {                (dict_vr(t), self.u32()?)            };            let body = if vr == *b"SQ" || (len == UNDEFINED && t != PIXEL_DATA) {                let was = self.s;                // A UN of undefined length holds implicit VR little endian.                if vr == *b"UN" {                    self.s = Syntax { explicit: false, big: false };                }                let b = Body::Seq(self.items(len, depth)?);                self.s = was;                b            } else if len == UNDEFINED {                Body::Fragments(self.fragments()?)            } else {                Body::Bytes(self.take(len as usize).map_err(|e| format!("{}: {}", tag_text(t), e))?.to_vec())            };            out.push(Elem { tag: t, vr: if vr == *b"UN" && matches!(body, Body::Seq(_)) { *b"SQ" } else { vr }, body });        }        Ok(out)    }}fn syntax_of(uid: &str) -> Result<Syntax, String> {    Ok(match uid {        "1.2.840.10008.1.2" => Syntax { explicit: false, big: false },        "1.2.840.10008.1.2.2" => Syntax { explicit: true, big: true },        "1.2.840.10008.1.2.1.99" => return Err("deflated transfer syntax: inflate the file first".into()),        _ => Syntax { explicit: true, big: false }, // explicit little endian, and every encapsulated one    })}fn syntax_name(uid: &str) -> &str {    match uid {        "1.2.840.10008.1.2" => "Implicit VR Little Endian",        "1.2.840.10008.1.2.1" => "Explicit VR Little Endian",        "1.2.840.10008.1.2.2" => "Explicit VR Big Endian",        "1.2.840.10008.1.2.4.50" => "JPEG Baseline",        "1.2.840.10008.1.2.4.51" => "JPEG Extended",        "1.2.840.10008.1.2.4.57" | "1.2.840.10008.1.2.4.70" => "JPEG Lossless",        "1.2.840.10008.1.2.4.80" | "1.2.840.10008.1.2.4.81" => "JPEG-LS",        "1.2.840.10008.1.2.4.90" | "1.2.840.10008.1.2.4.91" => "JPEG 2000",        "1.2.840.10008.1.2.5" => "RLE Lossless",        _ => "unknown",    }}fn parse_bytes(b: &[u8], name: &str) -> Result<File, String> {    let has_preamble = b.len() >= 132 && &b[128..132] == b"DICM";    let (preamble, mut meta, mut start) = (if has_preamble { b[..128].to_vec() } else { vec![0; 128] }, vec![], if has_preamble { 132 } else { 0 });    let mut uid = "1.2.840.10008.1.2".to_string();    if has_preamble {        let mut r = Reader { d: b, p: 132, s: Syntax { explicit: true, big: false } };        while r.p + 8 <= b.len() {            let at = r.p;            let group = u16::from_le_bytes([b[at], b[at + 1]]);            if group != 2 {                break;            }            let t = r.tag()?;            let v = r.take(2)?;            let vr = [v[0], v[1]];            let len = if long_form(&vr) { r.take(2)?; r.u32()? } else { r.u16()? as u32 };            let body = r.take(len as usize)?.to_vec();            if t == 0x0002_0010 {                uid = String::from_utf8_lossy(&body).trim_end_matches(['\0', ' ']).to_string();            }            meta.push(Elem { tag: t, vr, body: Body::Bytes(body) });        }        start = r.p;    } else {        // No preamble: an old file, or not DICOM. Explicit if a VR stands where it would.        if b.len() < 8 || b[1] != 0 || b[0] % 2 == 1 || b[0] > 0x28 || b[0] == 0 {            return Err(format!("{} is not a DICOM file: no DICM mark at byte 128", name));        }        if b[4..6].iter().all(u8::is_ascii_uppercase) {            uid = "1.2.840.10008.1.2.1".into();        }    }    let s = syntax_of(&uid)?;    let mut r = Reader { d: b, p: start, s };    let data = r.elements(None, 0, false).map_err(|e| format!("{}: {}", name, e))?;    Ok(File { preamble, meta, syntax: s, ts: uid, data })}fn open(path: &str) -> Result<File, String> {    let b = std::fs::read(path).map_err(|e| format!("{}: {}", path, e))?;    parse_bytes(&b, path)}fn text(b: &[u8], utf8: bool) -> String {    let t = if utf8 { String::from_utf8_lossy(b).into_owned() } else { b.iter().map(|c| *c as char).collect() };    t.trim_end_matches(['\0', ' ']).trim_start_matches(' ').to_string()}fn numbers<const N: usize>(b: &[u8], s: Syntax, f: impl Fn([u8; N], bool) -> Value) -> Value {    let v: Vec<Value> = b.chunks_exact(N).map(|c| f(c.try_into().unwrap(), s.big)).collect();    if v.len() == 1 { v.into_iter().next().unwrap() } else { Value::Array(v) }}fn person(x: &str) -> Value {    let alpha = x.split('=').next().unwrap_or("");    let p: Vec<&str> = alpha.split('^').collect();    let g = |i: usize| p.get(i).copied().unwrap_or("");    json!({ "family": g(0), "given": g(1), "middle": g(2), "prefix": g(3), "suffix": g(4), "text": alpha.replace('^', " ").trim() })}fn value_of(e: &Elem, s: Syntax, utf8: bool) -> Value {    let b = match &e.body {        Body::Seq(items) => return Value::Array(items.iter().map(|i| named(i, s, utf8)).collect()),        Body::Fragments(f) => return json!(format!("<{} compressed fragments>", f.len())),        Body::Bytes(b) => b,    };    match &e.vr {        b"US" => numbers::<2>(b, s, |c, big| json!(if big { u16::from_be_bytes(c) } else { u16::from_le_bytes(c) })),        b"SS" => numbers::<2>(b, s, |c, big| json!(if big { i16::from_be_bytes(c) } else { i16::from_le_bytes(c) })),        b"UL" => numbers::<4>(b, s, |c, big| json!(if big { u32::from_be_bytes(c) } else { u32::from_le_bytes(c) })),        b"SL" => numbers::<4>(b, s, |c, big| json!(if big { i32::from_be_bytes(c) } else { i32::from_le_bytes(c) })),        b"FL" => numbers::<4>(b, s, |c, big| json!(if big { f32::from_be_bytes(c) } else { f32::from_le_bytes(c) })),        b"FD" => numbers::<8>(b, s, |c, big| json!(if big { f64::from_be_bytes(c) } else { f64::from_le_bytes(c) })),        b"AT" => json!(b.chunks_exact(4).map(|c| { let g = u16::from_le_bytes([c[0], c[1]]) as u32; let el = u16::from_le_bytes([c[2], c[3]]) as u32; tag_text(g << 16 | el) }).collect::<Vec<_>>()),        b"OB" | b"OW" | b"OF" | b"OD" | b"OL" | b"OV" | b"UN" => json!(format!("<{} bytes>", b.len())),        b"PN" => person(&text(b, utf8)),        b"DS" | b"IS" => {            let t = text(b, utf8);            let nums: Vec<Value> = t.split('\\').map(|x| { let x = x.trim(); x.parse::<i64>().map(|i| json!(i)).or_else(|_| x.parse::<f64>().map(|f| json!(f))).unwrap_or_else(|_| json!(x)) }).collect();            if t.is_empty() { json!("") } else if nums.len() == 1 { nums.into_iter().next().unwrap() } else { Value::Array(nums) }        }        b"LT" | b"ST" | b"UT" => json!(text(b, utf8)),        _ => {            let t = text(b, utf8);            if t.contains('\\') { json!(t.split('\\').collect::<Vec<_>>()) } else { json!(t) }        }    }}fn name_of(t: u32) -> String {    DICT.iter().find(|d| d.0 == t).map_or_else(|| tag_text(t), |d| d.2.to_string())}fn named(list: &[Elem], s: Syntax, utf8: bool) -> Value {    let mut m = Map::new();    for e in list {        m.insert(name_of(e.tag), value_of(e, s, utf8));    }    Value::Object(m)}fn utf8_of(f: &File) -> bool {    f.data.iter().find(|e| e.tag == 0x0008_0005).and_then(|e| if let Body::Bytes(b) = &e.body { Some(text(b, false).contains("ISO_IR 192")) } else { None }).unwrap_or(false)}fn get<'a>(f: &'a File, t: u32) -> Option<&'a Elem> {    f.data.iter().find(|e| e.tag == t)}fn field(f: &File, t: u32) -> Value {    get(f, t).map_or(Value::Null, |e| value_of(e, f.syntax, utf8_of(f)))}fn number(f: &File, t: u32) -> Option<f64> {    match field(f, t) {        Value::Array(a) => a.first().and_then(Value::as_f64),        o => o.as_f64(),    }}/// All the top-level tags as a map, by name (or `(gggg,eeee)` when this does not know them): `{ PatientName: { family, given, ... }, Rows: 16, ... }`, sequences as lists of maps, pixel data as its size.pub fn dicom_tags(path: &str) -> Result<Value, String> {    let f = open(path)?;    Ok(named(&f.data, f.syntax, utf8_of(&f)))}fn tag_of(name: &str) -> Option<u32> {    let t = name.trim_matches(['(', ')']).replace(',', "");    if t.len() == 8 {        if let Ok(v) = u32::from_str_radix(&t, 16) {            return Some(v);        }    }    DICT.iter().find(|d| d.2.eq_ignore_ascii_case(name)).map(|d| d.0)}/// One tag's value by name, `PatientID`, or number, `(0010,0020)` or `00100020`. Null when the file lacks it; an error for a name nobody knows.pub fn dicom_tag(path: &str, name: &str) -> Result<Value, String> {    let t = tag_of(name).ok_or(format!("`{}` is no tag this knows: give it as (gggg,eeee)", name))?;    Ok(field(&open(path)?, t))}/// Each element in order: `[{ tag, name, vr, value }]`, for a viewer of a file's header.pub fn dicom_elements(path: &str) -> Result<Vec<Value>, String> {    let f = open(path)?;    let u = utf8_of(&f);    Ok(f.meta.iter().map(|e| (e, Syntax { explicit: true, big: false })).chain(f.data.iter().map(|e| (e, f.syntax))).map(|(e, s)| json!({ "tag": tag_text(e.tag), "name": name_of(e.tag), "vr": String::from_utf8_lossy(&e.vr), "value": value_of(e, s, u) })).collect())}/// The fields a program wants: `{ patient: { name, id, birth_date, sex, age }, study: { uid, date, time, description, accession }, series: { uid, number, modality, description }, image: { sop_class, instance_uid, number, rows, columns, frames, bits_allocated, bits_stored, photometric, pixel_spacing }, transfer_syntax, compressed }`.pub fn dicom_summary(path: &str) -> Result<Value, String> {    let f = open(path)?;    let g = |t| field(&f, t);    let compressed = matches!(get(&f, PIXEL_DATA).map(|e| &e.body), Some(Body::Fragments(_)));    Ok(json!({        "patient": { "name": g(0x0010_0010)["text"], "id": g(0x0010_0020), "birth_date": g(0x0010_0030), "sex": g(0x0010_0040), "age": g(0x0010_1010) },        "study": { "uid": g(0x0020_000D), "date": g(0x0008_0020), "time": g(0x0008_0030), "description": g(0x0008_1030), "accession": g(0x0008_0050) },        "series": { "uid": g(0x0020_000E), "number": g(0x0020_0011), "modality": g(0x0008_0060), "description": g(0x0008_103E) },        "image": { "sop_class": g(0x0008_0016), "instance_uid": g(0x0008_0018), "number": g(0x0020_0013), "rows": g(0x0028_0010), "columns": g(0x0028_0011), "frames": number(&f, 0x0028_0008).unwrap_or(1.0) as i64,            "bits_allocated": g(0x0028_0100), "bits_stored": g(0x0028_0101), "photometric": g(0x0028_0004), "pixel_spacing": g(0x0028_0030) },        "transfer_syntax": { "uid": f.ts, "name": syntax_name(&f.ts) }, "compressed": compressed,    }))}fn put_u16(o: &mut Vec<u8>, x: u16, big: bool) {    o.extend_from_slice(&if big { x.to_be_bytes() } else { x.to_le_bytes() });}fn put_u32(o: &mut Vec<u8>, x: u32, big: bool) {    o.extend_from_slice(&if big { x.to_be_bytes() } else { x.to_le_bytes() });}fn put_tag(o: &mut Vec<u8>, t: u32, big: bool) {    put_u16(o, (t >> 16) as u16, big);    put_u16(o, t as u16, big);}fn write_elem(o: &mut Vec<u8>, e: &Elem, s: Syntax) {    put_tag(o, e.tag, s.big);    let head = |o: &mut Vec<u8>, len: u32, vr: [u8; 2]| {        if s.explicit {            o.extend_from_slice(&vr);            if long_form(&vr) {                o.extend_from_slice(&[0, 0]);                put_u32(o, len, s.big);            } else {                put_u16(o, len as u16, s.big);            }        } else {            put_u32(o, len, s.big);        }    };    match &e.body {        Body::Bytes(b) => {            head(o, b.len() as u32, e.vr);            o.extend_from_slice(b);        }        Body::Seq(items) => {            let mut inner = vec![];            for i in items {                let mut one = vec![];                for x in i {                    write_elem(&mut one, x, s);                }                put_tag(&mut inner, ITEM, s.big);                put_u32(&mut inner, one.len() as u32, s.big);                inner.extend_from_slice(&one);            }            head(o, inner.len() as u32, *b"SQ");            o.extend_from_slice(&inner);        }        Body::Fragments(frags) => {            head(o, UNDEFINED, e.vr);            for f in frags {                put_tag(o, ITEM, s.big);                put_u32(o, f.len() as u32, s.big);                o.extend_from_slice(f);            }            put_tag(o, SEQ_END, s.big);            put_u32(o, 0, s.big);        }    }}fn serialize(f: &File) -> Vec<u8> {    let le = Syntax { explicit: true, big: false };    let mut meta = vec![];    for e in f.meta.iter().filter(|e| e.tag != 0x0002_0000) {        write_elem(&mut meta, e, le);    }    let mut o = f.preamble.clone();    o.extend_from_slice(b"DICM");    write_elem(&mut o, &Elem { tag: 0x0002_0000, vr: *b"UL", body: Body::Bytes((meta.len() as u32).to_le_bytes().to_vec()) }, le);    o.extend_from_slice(&meta);    for e in &f.data {        write_elem(&mut o, e, f.syntax);    }    o}fn new_uid(old: &[u8], salt: &str) -> Vec<u8> {    let t = text(old, false);    let h = Sha256::new().chain_update(salt.as_bytes()).chain_update(t.as_bytes()).finalize();    let n = u128::from_be_bytes(h[..16].try_into().unwrap()) >> 1;    let mut u = format!("2.25.{}", n).into_bytes();    if u.len() % 2 == 1 {        u.push(0);    }    u}#[derive(Default)]struct Stripped {    blanked: Vec<String>,    removed: Vec<String>,    uids: usize,    private: usize,}fn strip(list: &mut Vec<Elem>, keep: &[u32], salt: &str, r: &mut Stripped) {    list.retain(|e| {        if (e.tag >> 16) % 2 == 1 {            r.private += 1;            return false;        }        if REMOVE.contains(&e.tag) && !keep.contains(&e.tag) {            r.removed.push(name_of(e.tag));            return false;        }        true    });    for e in list.iter_mut() {        if keep.contains(&e.tag) {            continue;        }        if BLANK.contains(&e.tag) {            r.blanked.push(name_of(e.tag));            e.body = Body::Bytes(vec![]);        } else if UIDS.contains(&e.tag) {            if let Body::Bytes(b) = &e.body {                r.uids += 1;                e.body = Body::Bytes(new_uid(b, salt));            }        } else if let Body::Seq(items) = &mut e.body {            for i in items {                strip(i, keep, salt, r);            }        }    }}/// Takes the identifying data out of a file, by a good part of PS3.15's basic profile, and writes it to `out`: names, ids, dates, addresses and institutions blanked or removed, also inside sequences; private tags gone; UIDs replaced by ones hashed with `options.salt`, so a study's files stay together; `PatientIdentityRemoved` set. `options.keep` names tags to leave. `options.patient_id` sets a pseudonym. Gives `{ blanked, removed, uids, private_tags, warnings }`: the image itself is left as it is, so text burned into its pixels stays, and a warning says so unless the file says none is there.pub fn dicom_strip_phi(path: &str, out: &str, options: serde_json::Value) -> Result<Value, String> {    let mut f = open(path)?;    let salt = options["salt"].as_str().unwrap_or("").to_string();    let mut keep = vec![];    for k in options["keep"].as_array().into_iter().flatten() {        let n = k.as_str().unwrap_or("");        keep.push(tag_of(n).ok_or(format!("keep: `{}` is no tag this knows", n))?);    }    let mut r = Stripped::default();    strip(&mut f.data, &keep, &salt, &mut r);    for e in &mut f.meta {        if e.tag == 0x0002_0003 {            if let Body::Bytes(b) = &e.body {                e.body = Body::Bytes(new_uid(b, &salt));            }        }    }    if let Some(p) = options["patient_id"].as_str() {        let mut b = p.as_bytes().to_vec();        if b.len() % 2 == 1 {            b.push(b' ');        }        if let Some(e) = f.data.iter_mut().find(|e| e.tag == 0x0010_0020) {            e.body = Body::Bytes(b);        }    }    let mark = [(0x0012_0062, *b"CS", b"YES ".to_vec()), (0x0012_0063, *b"LO", b"QUILLO BASIC PROFILE SUBSET ".to_vec())];    for (t, vr, v) in mark {        f.data.retain(|e| e.tag != t);        f.data.push(Elem { tag: t, vr, body: Body::Bytes(v) });    }    f.data.sort_by_key(|e| e.tag);    f.preamble = vec![0; 128];    let burned = field(&f, 0x0028_0301);    let warnings = if burned == json!("NO") { vec![] } else { vec!["the image may have text burned into its pixels: BurnedInAnnotation does not say NO".to_string()] };    std::fs::write(out, serialize(&f)).map_err(|e| format!("{}: {}", out, e))?;    Ok(json!({ "blanked": r.blanked, "removed": r.removed, "uids": r.uids, "private_tags": r.private, "warnings": warnings }))}fn write_png(out: &str, cols: usize, rows: usize, kind: png::ColorType, image: &[u8]) -> Result<(), String> {    let w = std::fs::File::create(out).map_err(|e| format!("{}: {}", out, e))?;    let mut enc = png::Encoder::new(std::io::BufWriter::new(w), cols as u32, rows as u32);    enc.set_color(kind);    enc.set_depth(png::BitDepth::Eight);    enc.write_header().and_then(|mut h| h.write_image_data(image)).map_err(|e| format!("{}: {}", out, e))}/// Writes one frame as a PNG at `out`: 8 bits gray, or RGB. `options`: `frame` (from 0), `window_center` and `window_width` (the file's, else the pixels' full range). `RescaleSlope` and `RescaleIntercept` are applied first, MONOCHROME1 inverted. Only uncompressed files: a JPEG one is an error naming its syntax. Gives `{ path, width, height, frame, frames, window_center, window_width }`.pub fn dicom_png(path: &str, out: &str, options: serde_json::Value) -> Result<Value, String> {    let f = open(path)?;    let e = get(&f, PIXEL_DATA).ok_or("the file has no pixel data")?;    let data = match &e.body {        Body::Bytes(b) => b,        Body::Fragments(_) => return Err(format!("the pixels are compressed ({}), and this reads uncompressed images only", syntax_name(&f.ts))),        Body::Seq(_) => return Err("the pixel data is a sequence".into()),    };    let num = |t: u32, what: &str| number(&f, t).ok_or(format!("the file has no {}", what));    let (rows, cols) = (num(0x0028_0010, "Rows")? as usize, num(0x0028_0011, "Columns")? as usize);    let samples = number(&f, 0x0028_0002).unwrap_or(1.0) as usize;    let bits = number(&f, 0x0028_0100).unwrap_or(16.0) as usize;    let stored = number(&f, 0x0028_0101).unwrap_or(bits as f64) as usize;    let signed = number(&f, 0x0028_0103).unwrap_or(0.0) as i64 == 1;    let frames = number(&f, 0x0028_0008).unwrap_or(1.0).max(1.0) as usize;    let frame = options["frame"].as_u64().unwrap_or(0) as usize;    let photometric = field(&f, 0x0028_0004).as_str().unwrap_or("MONOCHROME2").to_string();    if frame >= frames {        return Err(format!("frame {} of {}: frames count from 0", frame, frames));    }    if rows == 0 || cols == 0 || rows.saturating_mul(cols) > 1 << 28 {        return Err(format!("{} by {} is no image size this draws", cols, rows));    }    if bits != 8 && bits != 16 {        return Err(format!("{} bits allocated: 8 and 16 are read", bits));    }    let size = rows * cols * samples * bits / 8;    let piece = data.get(frame * size..(frame + 1) * size).ok_or(format!("the pixel data holds {} bytes; frame {} needs {}", data.len(), frame, (frame + 1) * size))?;    let image: Vec<u8>;    if samples == 3 {        if photometric != "RGB" || bits != 8 {            return Err(format!("{} with 3 samples at {} bits is not read: 8-bit RGB is", photometric, bits));        }        let planar = number(&f, 0x0028_0006).unwrap_or(0.0) as i64 == 1;        image = if planar {            let n = rows * cols;            (0..n).flat_map(|i| [piece[i], piece[n + i], piece[2 * n + i]]).collect()        } else {            piece.to_vec()        };    } else if samples == 1 && photometric.starts_with("MONOCHROME") {        let mask: u32 = if stored >= 32 { u32::MAX } else { (1u32 << stored) - 1 };        let raw: Vec<f64> = if bits == 8 {            piece.iter().map(|b| *b as f64).collect()        } else {            piece.chunks_exact(2).map(|c| {                let v = if f.syntax.big { u16::from_be_bytes([c[0], c[1]]) } else { u16::from_le_bytes([c[0], c[1]]) } as u32 & mask;                if signed && stored > 0 && v >> (stored - 1) & 1 == 1 { v as f64 - (1u64 << stored) as f64 } else { v as f64 }            }).collect()        };        let slope = number(&f, 0x0028_1053).unwrap_or(1.0);        let intercept = number(&f, 0x0028_1052).unwrap_or(0.0);        let scaled: Vec<f64> = raw.iter().map(|v| v * slope + intercept).collect();        let (lo, hi) = scaled.iter().fold((f64::MAX, f64::MIN), |(a, b), v| (a.min(*v), b.max(*v)));        let center = options["window_center"].as_f64().or(number(&f, 0x0028_1050)).unwrap_or((lo + hi) / 2.0);        let width = options["window_width"].as_f64().or(number(&f, 0x0028_1051)).unwrap_or((hi - lo).max(1.0));        if width <= 0.0 {            return Err(format!("window width {} must be above zero", width));        }        let invert = photometric == "MONOCHROME1";        image = scaled.iter().map(|v| {            // PS3.3 C.11.2.1.2.1, the linear function.            let t = ((v - (center - 0.5)) / (width - 1.0).max(1.0) + 0.5).clamp(0.0, 1.0);            let p = (t * 255.0).round() as u8;            if invert { 255 - p } else { p }        }).collect::<Vec<u8>>();        write_png(out, cols, rows, png::ColorType::Grayscale, &image)?;        return Ok(json!({ "path": out, "width": cols, "height": rows, "frame": frame, "frames": frames, "window_center": center, "window_width": width, "lowest": lo, "highest": hi }));    } else {        return Err(format!("{} with {} samples is not read: gray and 8-bit RGB are", photometric, samples));    }    write_png(out, cols, rows, png::ColorType::Rgb, &image)?;    Ok(json!({ "path": out, "width": cols, "height": rows, "frame": frame, "frames": frames }))}
Thread
59Useful? Like it, or add to its thread below.

Thread 0 posts

On DICOM: questions, tips, corrections, and code that uses it.

No posts yet. The first one opens the thread.