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
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 errorAll 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 errorOne 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 errorEach element in order: [{ tag, name, vr, value }], for a viewer of a file's header.
dicom_summary(path: str) → anyor an errorThe 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 errorTakes 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 errorWrites 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.qweOr 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.dcmThen, in your program, all of it or only what you call:
use dicom.qweruse dicom_tags, dicom_tag from dicom // Quillo 0.6: only theseIt uses these crates, which cargo fetches the first time the plugin is built:
serde_json = { version = "1", features = ["preserve_order"] }png = "0.17"sha2 = "0.10"Example
// 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 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 }))}Related
fhir.qwer · 1.0.0HL7 FHIR R4 JSON: resources checked for required fields, bound codes and date formats, Bundles read and built, references resolved across a Bundle, FHIRPath-lite field access, and search parameters with paging, sorting and includes for a FHIR server.3 views · 7 downloadsHL7 hl7.qwer · 1.0.0HL7 v2 messages from hospitals and labs: segments, fields, components and repetitions read, values picked by path, and ACKs made.7 views · 8 downloadsThread 0 posts
On DICOM: questions, tips, corrections, and code that uses it.
No posts yet. The first one opens the thread.