1use serde::{Deserialize, Serialize};
6use std::collections::BTreeMap;
7use std::fs;
8use std::path::{Path, PathBuf};
9
10pub const TS_IMPLICIT_VR_LITTLE_ENDIAN: &str = "1.2.840.10008.1.2";
11pub const TS_EXPLICIT_VR_LITTLE_ENDIAN: &str = "1.2.840.10008.1.2.1";
12pub const TS_JPEG2000_LOSSLESS: &str = "1.2.840.10008.1.2.4.90";
13pub const TS_JPEG2000: &str = "1.2.840.10008.1.2.4.91";
14pub const TS_JPEG_BASELINE: &str = "1.2.840.10008.1.2.4.50";
15pub const TS_RLE_LOSSLESS: &str = "1.2.840.10008.1.2.5";
16
17const TAG_MODALITY: u32 = tag(0x0008, 0x0060);
18const TAG_STUDY_DESCRIPTION: u32 = tag(0x0008, 0x1030);
19const TAG_SERIES_DESCRIPTION: u32 = tag(0x0008, 0x103E);
20const TAG_BODY_PART_EXAMINED: u32 = tag(0x0018, 0x0015);
21const TAG_SERIES_INSTANCE_UID: u32 = tag(0x0020, 0x000E);
22const TAG_INSTANCE_NUMBER: u32 = tag(0x0020, 0x0013);
23const TAG_ROWS: u32 = tag(0x0028, 0x0010);
24const TAG_COLUMNS: u32 = tag(0x0028, 0x0011);
25const TAG_WINDOW_CENTER: u32 = tag(0x0028, 0x1050);
26const TAG_WINDOW_WIDTH: u32 = tag(0x0028, 0x1051);
27const TAG_PHOTOMETRIC_INTERPRETATION: u32 = tag(0x0028, 0x0004);
28const TAG_BITS_ALLOCATED: u32 = tag(0x0028, 0x0100);
29const TAG_PIXEL_REPRESENTATION: u32 = tag(0x0028, 0x0103);
30const TAG_PATIENT_ID: u32 = tag(0x0010, 0x0020);
31const TAG_STUDY_DATE: u32 = tag(0x0008, 0x0020);
32const TAG_PIXEL_DATA: u32 = tag(0x7FE0, 0x0010);
33const TAG_TRANSFER_SYNTAX_UID: u32 = tag(0x0002, 0x0010);
34
35pub const INLINE_TAG_BLOB_POINTER: u64 = 0b100u64 << 60;
37const INLINE_TAG_MASK: u64 = 0b111u64 << 60;
38const INLINE_VALUE_MASK: u64 = 0x0FFF_FFFF_FFFF_FFFF;
39
40const ITEM_DELIMITER: u32 = tag(0xFFFE, 0xE000);
41const ITEM_END_DELIMITER: u32 = tag(0xFFFE, 0xE00D);
42const SEQ_DELIMITER: u32 = tag(0xFFFE, 0xE0DD);
43
44const fn tag(group: u16, element: u16) -> u32 {
45 ((group as u32) << 16) | element as u32
46}
47
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub enum DicomError {
50 TooShort,
51 MissingDicmMagic,
52 UnexpectedEof,
53 InvalidVr,
54 UnsupportedTransferSyntax(String),
55 Io(String),
56}
57
58impl std::fmt::Display for DicomError {
59 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
60 match self {
61 Self::TooShort => write!(f, "DICOM file too short"),
62 Self::MissingDicmMagic => write!(f, "DICOM magic (DICM) not found"),
63 Self::UnexpectedEof => write!(f, "unexpected end of DICOM stream"),
64 Self::InvalidVr => write!(f, "invalid DICOM VR"),
65 Self::UnsupportedTransferSyntax(ts) => {
66 write!(f, "unsupported DICOM transfer syntax: {ts}")
67 }
68 Self::Io(msg) => write!(f, "DICOM IO error: {msg}"),
69 }
70 }
71}
72
73impl std::error::Error for DicomError {}
74
75#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
76pub struct DicomMetadata {
77 pub modality: String,
78 pub body_part_examined: String,
79 pub study_description: String,
80 pub series_description: String,
81 pub protocol_name: String,
82 pub series_instance_uid: String,
83 pub instance_number: i32,
84 pub rows: u16,
85 pub columns: u16,
86 pub window_center: Option<f64>,
87 pub window_width: Option<f64>,
88 pub photometric_interpretation: String,
89 pub transfer_syntax_uid: String,
90 pub patient_id: String,
91 pub study_date: String,
92 pub bits_allocated: u16,
93 pub pixel_representation: u16,
94}
95
96#[derive(Debug, Clone, Copy, PartialEq, Eq)]
98pub struct DicomPixelSlice {
99 pub offset: usize,
100 pub length: usize,
101}
102
103#[derive(Debug, Clone, PartialEq)]
105pub struct DicomSplitPayload {
106 pub meta: DicomMetadata,
107 pub pixels: DicomPixelSlice,
108}
109
110#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
111pub struct DicomPlacement {
112 #[serde(rename = "offsetX")]
113 pub offset_x: f32,
114 #[serde(rename = "offsetY")]
115 pub offset_y: f32,
116 #[serde(rename = "offsetZ")]
117 pub offset_z: f32,
118 pub scale: f32,
119 #[serde(rename = "rotationY")]
120 pub rotation_y: f32,
121}
122
123#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
124pub struct DicomOverlaySpec {
125 pub version: String,
126 pub organ: Option<String>,
127 pub opacity: f32,
128 pub visible: bool,
129 pub placement: DicomPlacement,
130 #[serde(rename = "seriesInstanceUID", skip_serializing_if = "Option::is_none")]
131 pub series_instance_uid: Option<String>,
132 #[serde(skip_serializing_if = "Option::is_none")]
133 pub modality: Option<String>,
134 #[serde(rename = "bodyPartExamined", skip_serializing_if = "Option::is_none")]
135 pub body_part_examined: Option<String>,
136 #[serde(skip_serializing_if = "Option::is_none")]
137 pub source: Option<String>,
138}
139
140#[derive(Debug, Deserialize)]
141pub struct DicomOrganMapFile {
142 #[serde(default)]
143 pub tag_matchers: Vec<DicomTagMatcher>,
144}
145
146#[derive(Debug, Deserialize)]
147pub struct DicomTagMatcher {
148 #[serde(default)]
149 pub tokens: Vec<String>,
150 #[serde(default)]
151 pub organ: Option<String>,
152}
153
154#[derive(Debug, Clone, Copy, PartialEq, Eq)]
155enum TransferSyntax {
156 ImplicitVrLittleEndian,
157 ExplicitVrLittleEndian,
158 EncapsulatedExplicitVr,
160}
161
162fn find_dataset_offset(data: &[u8]) -> Result<usize, DicomError> {
163 if data.len() < 132 {
164 return Err(DicomError::TooShort);
165 }
166 if &data[128..132] == b"DICM" {
167 return Ok(132);
168 }
169 for start in 0..data.len().saturating_sub(4) {
170 if &data[start..start + 4] == b"DICM" {
171 return Ok(start + 4);
172 }
173 }
174 Err(DicomError::MissingDicmMagic)
175}
176
177fn read_u16_le(data: &[u8], offset: usize) -> Result<u16, DicomError> {
178 let end = offset + 2;
179 if end > data.len() {
180 return Err(DicomError::UnexpectedEof);
181 }
182 Ok(u16::from_le_bytes([data[offset], data[offset + 1]]))
183}
184
185fn read_u32_le(data: &[u8], offset: usize) -> Result<u32, DicomError> {
186 let end = offset + 4;
187 if end > data.len() {
188 return Err(DicomError::UnexpectedEof);
189 }
190 Ok(u32::from_le_bytes([
191 data[offset],
192 data[offset + 1],
193 data[offset + 2],
194 data[offset + 3],
195 ]))
196}
197
198fn vr_is_long(vr: &[u8; 2]) -> bool {
199 matches!(
200 vr,
201 b"OB" | b"OW" | b"OF" | b"SQ" | b"UT" | b"UN" | b"SV" | b"UV"
202 )
203}
204
205fn decode_dicom_string(bytes: &[u8]) -> String {
206 let trimmed = bytes
207 .iter()
208 .copied()
209 .take_while(|&b| b != 0)
210 .collect::<Vec<u8>>();
211 String::from_utf8_lossy(&trimmed).trim().to_string()
212}
213
214fn decode_numeric_string(bytes: &[u8]) -> Option<f64> {
215 let text = decode_dicom_string(bytes);
216 text.split('\\')
217 .next()
218 .and_then(|part| part.trim().parse::<f64>().ok())
219}
220
221fn decode_i32(bytes: &[u8]) -> i32 {
222 decode_numeric_string(bytes).map(|v| v as i32).unwrap_or(0)
223}
224
225fn decode_u16(bytes: &[u8]) -> u16 {
226 decode_numeric_string(bytes)
227 .map(|v| v as u16)
228 .unwrap_or_else(|| {
229 if bytes.len() >= 2 {
230 u16::from_le_bytes([bytes[0], bytes[1]])
231 } else {
232 0
233 }
234 })
235}
236
237struct ElementHeader {
238 tag: u32,
239 vr: Option<[u8; 2]>,
240 length: usize,
241 header_size: usize,
242}
243
244fn read_element_header(
245 data: &[u8],
246 offset: usize,
247 syntax: TransferSyntax,
248 meta_group: bool,
249) -> Result<Option<ElementHeader>, DicomError> {
250 if offset + 8 > data.len() {
251 return Ok(None);
252 }
253
254 let group = read_u16_le(data, offset)?;
255 let element = read_u16_le(data, offset + 2)?;
256 let tag = tag(group, element);
257
258 if tag == ITEM_DELIMITER || tag == ITEM_END_DELIMITER || tag == SEQ_DELIMITER {
259 let length = read_u32_le(data, offset + 4)? as usize;
260 return Ok(Some(ElementHeader {
261 tag,
262 vr: None,
263 length,
264 header_size: 8,
265 }));
266 }
267
268 let use_explicit = meta_group
269 || matches!(
270 syntax,
271 TransferSyntax::ExplicitVrLittleEndian | TransferSyntax::EncapsulatedExplicitVr
272 );
273 if use_explicit {
274 let vr = [data[offset + 4], data[offset + 5]];
275 if !vr[0].is_ascii_uppercase() || !vr[1].is_ascii_uppercase() {
276 return Err(DicomError::InvalidVr);
277 }
278 let (length, header_size) = if vr_is_long(&vr) {
279 let length = read_u32_le(data, offset + 8)? as usize;
280 (length, 12)
281 } else {
282 let length = read_u16_le(data, offset + 6)? as usize;
283 (length, 8)
284 };
285 Ok(Some(ElementHeader {
286 tag,
287 vr: Some(vr),
288 length,
289 header_size,
290 }))
291 } else {
292 let length = read_u32_le(data, offset + 4)? as usize;
293 Ok(Some(ElementHeader {
294 tag,
295 vr: None,
296 length,
297 header_size: 8,
298 }))
299 }
300}
301
302fn skip_element(
303 data: &[u8],
304 offset: usize,
305 header: &ElementHeader,
306 syntax: TransferSyntax,
307 meta_group: bool,
308) -> Result<usize, DicomError> {
309 if header.tag == ITEM_DELIMITER {
310 if header.length == 0xFFFF_FFFF {
311 return skip_undefined_length_item(
312 data,
313 offset + header.header_size,
314 syntax,
315 meta_group,
316 );
317 }
318 return Ok(offset + header.header_size + header.length);
319 }
320 if header.tag == SEQ_DELIMITER || header.tag == ITEM_END_DELIMITER {
321 return Ok(offset + header.header_size);
322 }
323
324 if header.vr == Some(*b"SQ") || (header.vr.is_none() && header.length == 0xFFFF_FFFF) {
325 return skip_sequence(data, offset + header.header_size, syntax, meta_group);
326 }
327
328 if header.length == 0xFFFF_FFFF {
329 return Ok(data.len());
330 }
331
332 let end = offset
333 .checked_add(header.header_size)
334 .and_then(|v| v.checked_add(header.length))
335 .ok_or(DicomError::UnexpectedEof)?;
336 if end > data.len() {
337 return Err(DicomError::UnexpectedEof);
338 }
339 Ok(end)
340}
341
342fn skip_undefined_length_item(
344 data: &[u8],
345 mut offset: usize,
346 syntax: TransferSyntax,
347 meta_group: bool,
348) -> Result<usize, DicomError> {
349 loop {
350 if offset + 8 > data.len() {
351 return Ok(offset);
352 }
353 let Some(header) = read_element_header(data, offset, syntax, meta_group)? else {
354 return Ok(offset);
355 };
356 if header.tag == ITEM_END_DELIMITER {
357 return Ok(offset + header.header_size);
358 }
359 offset = skip_element(data, offset, &header, syntax, meta_group)?;
360 }
361}
362
363fn skip_sequence(
364 data: &[u8],
365 mut offset: usize,
366 syntax: TransferSyntax,
367 meta_group: bool,
368) -> Result<usize, DicomError> {
369 loop {
370 if offset + 8 > data.len() {
371 return Ok(offset);
372 }
373 let Some(header) = read_element_header(data, offset, syntax, meta_group)? else {
374 return Ok(offset);
375 };
376 if header.tag == SEQ_DELIMITER {
377 return Ok(offset + header.header_size);
378 }
379 offset = skip_element(data, offset, &header, syntax, meta_group)?;
380 }
381}
382
383fn parse_meta_information(data: &[u8], offset: usize) -> Result<(String, usize), DicomError> {
384 let mut transfer_syntax = TS_EXPLICIT_VR_LITTLE_ENDIAN.to_string();
385 let mut cursor = offset;
386
387 loop {
388 let Some(header) =
389 read_element_header(data, cursor, TransferSyntax::ExplicitVrLittleEndian, true)?
390 else {
391 break;
392 };
393 let value_offset = cursor + header.header_size;
394 if header.tag == TAG_TRANSFER_SYNTAX_UID && header.length > 0 {
395 let end = value_offset + header.length;
396 if end > data.len() {
397 return Err(DicomError::UnexpectedEof);
398 }
399 transfer_syntax = decode_dicom_string(&data[value_offset..end]);
400 }
401 if (header.tag >> 16) as u16 > 0x0002 {
402 break;
403 }
404 cursor = skip_element(
405 data,
406 cursor,
407 &header,
408 TransferSyntax::ExplicitVrLittleEndian,
409 true,
410 )?;
411 }
412
413 Ok((transfer_syntax, cursor))
414}
415
416fn transfer_syntax_from_uid(uid: &str) -> Result<TransferSyntax, DicomError> {
417 match uid {
418 TS_IMPLICIT_VR_LITTLE_ENDIAN => Ok(TransferSyntax::ImplicitVrLittleEndian),
419 TS_EXPLICIT_VR_LITTLE_ENDIAN => Ok(TransferSyntax::ExplicitVrLittleEndian),
420 TS_JPEG2000_LOSSLESS
421 | TS_JPEG2000
422 | TS_JPEG_BASELINE
423 | "1.2.840.10008.1.2.4.51"
424 | TS_RLE_LOSSLESS => Ok(TransferSyntax::EncapsulatedExplicitVr),
425 other => Err(DicomError::UnsupportedTransferSyntax(other.to_string())),
426 }
427}
428
429fn encapsulated_pixel_end(data: &[u8], start: usize) -> Result<usize, DicomError> {
431 let mut cursor = start;
432 loop {
433 if cursor + 8 > data.len() {
434 return Err(DicomError::UnexpectedEof);
435 }
436 let group = read_u16_le(data, cursor)?;
437 let element = read_u16_le(data, cursor + 2)?;
438 let tag = tag(group, element);
439 let length = read_u32_le(data, cursor + 4)? as usize;
440 if tag == SEQ_DELIMITER {
441 return Ok(cursor + 8);
442 }
443 if tag != ITEM_DELIMITER {
444 return Err(DicomError::InvalidVr);
445 }
446 cursor = cursor
447 .checked_add(8)
448 .and_then(|c| c.checked_add(length))
449 .ok_or(DicomError::UnexpectedEof)?;
450 if cursor > data.len() {
451 return Err(DicomError::UnexpectedEof);
452 }
453 }
454}
455
456fn apply_value(meta: &mut DicomMetadata, tag: u32, value: &[u8]) {
457 match tag {
458 TAG_MODALITY => meta.modality = decode_dicom_string(value),
459 TAG_STUDY_DESCRIPTION => meta.study_description = decode_dicom_string(value),
460 TAG_SERIES_DESCRIPTION => meta.series_description = decode_dicom_string(value),
461 TAG_BODY_PART_EXAMINED => meta.body_part_examined = decode_dicom_string(value),
462 TAG_SERIES_INSTANCE_UID => meta.series_instance_uid = decode_dicom_string(value),
463 TAG_INSTANCE_NUMBER => meta.instance_number = decode_i32(value),
464 TAG_ROWS => meta.rows = decode_u16(value),
465 TAG_COLUMNS => meta.columns = decode_u16(value),
466 TAG_WINDOW_CENTER => meta.window_center = decode_numeric_string(value),
467 TAG_WINDOW_WIDTH => meta.window_width = decode_numeric_string(value),
468 TAG_PHOTOMETRIC_INTERPRETATION => {
469 meta.photometric_interpretation = decode_dicom_string(value)
470 }
471 TAG_BITS_ALLOCATED => meta.bits_allocated = decode_u16(value),
472 TAG_PIXEL_REPRESENTATION => meta.pixel_representation = decode_u16(value),
473 TAG_PATIENT_ID => meta.patient_id = decode_dicom_string(value),
474 TAG_STUDY_DATE => meta.study_date = decode_dicom_string(value),
475 _ => {}
476 }
477}
478
479#[inline]
481pub fn encode_blob_pointer(byte_offset: u64) -> u64 {
482 (byte_offset & INLINE_VALUE_MASK) | INLINE_TAG_BLOB_POINTER
483}
484
485#[inline]
487pub fn decode_blob_pointer(field: u64) -> Option<u64> {
488 if (field & INLINE_TAG_MASK) == INLINE_TAG_BLOB_POINTER {
489 Some(field & INLINE_VALUE_MASK)
490 } else {
491 None
492 }
493}
494
495#[inline]
497pub fn pack_volume_metadata(rows: u16, cols: u16, byte_length: u32) -> u64 {
498 ((rows as u64) << 48) | ((cols as u64) << 32) | (byte_length as u64)
499}
500
501#[inline]
502pub fn unpack_volume_metadata(metadata: u64) -> (u16, u16, u32) {
503 let rows = ((metadata >> 48) & 0xFFFF) as u16;
504 let cols = ((metadata >> 32) & 0xFFFF) as u16;
505 let byte_length = (metadata & 0xFFFF_FFFF) as u32;
506 (rows, cols, byte_length)
507}
508
509fn walk_dataset(
510 data: &[u8],
511 mut cursor: usize,
512 syntax: TransferSyntax,
513 meta: &mut DicomMetadata,
514 capture_pixels: bool,
515) -> Result<Option<DicomPixelSlice>, DicomError> {
516 let mut pixels = None;
517
518 while cursor + 8 <= data.len() {
519 let Some(header) = read_element_header(data, cursor, syntax, false)? else {
520 break;
521 };
522
523 if header.tag == TAG_PIXEL_DATA {
524 if capture_pixels {
525 let value_offset = cursor + header.header_size;
526 if header.length == 0xFFFF_FFFF {
527 let end = encapsulated_pixel_end(data, value_offset)?;
528 pixels = Some(DicomPixelSlice {
529 offset: value_offset,
530 length: end.saturating_sub(value_offset),
531 });
532 } else {
533 let end = value_offset
534 .checked_add(header.length)
535 .ok_or(DicomError::UnexpectedEof)?;
536 if end <= data.len() {
537 pixels = Some(DicomPixelSlice {
538 offset: value_offset,
539 length: header.length,
540 });
541 }
542 }
543 }
544 break;
545 }
546
547 if header.tag == ITEM_DELIMITER
548 || header.tag == ITEM_END_DELIMITER
549 || header.tag == SEQ_DELIMITER
550 {
551 cursor = skip_element(data, cursor, &header, syntax, false)?;
552 continue;
553 }
554
555 let value_offset = cursor + header.header_size;
556 if header.length != 0xFFFF_FFFF && value_offset + header.length <= data.len() {
557 apply_value(
558 meta,
559 header.tag,
560 &data[value_offset..value_offset + header.length],
561 );
562 }
563
564 cursor = skip_element(data, cursor, &header, syntax, false)?;
565 }
566
567 Ok(pixels)
568}
569
570pub fn parse_dicom_metadata_bytes(data: &[u8]) -> Result<DicomMetadata, DicomError> {
572 let offset = find_dataset_offset(data)?;
573 let (transfer_syntax_uid, cursor) = parse_meta_information(data, offset)?;
574 let syntax = transfer_syntax_from_uid(&transfer_syntax_uid)?;
575 let mut meta = DicomMetadata {
576 transfer_syntax_uid,
577 ..Default::default()
578 };
579 walk_dataset(data, cursor, syntax, &mut meta, false)?;
580 Ok(meta)
581}
582
583pub fn split_dicom_payload(data: &[u8]) -> Result<DicomSplitPayload, DicomError> {
585 let offset = find_dataset_offset(data)?;
586 let (transfer_syntax_uid, cursor) = parse_meta_information(data, offset)?;
587 let syntax = transfer_syntax_from_uid(&transfer_syntax_uid)?;
588
589 let mut meta = DicomMetadata {
590 transfer_syntax_uid,
591 ..Default::default()
592 };
593
594 let pixels = walk_dataset(data, cursor, syntax, &mut meta, true)?
595 .ok_or(DicomError::Io("DICOM has no Pixel Data (7FE0,0010)".into()))?;
596
597 Ok(DicomSplitPayload { meta, pixels })
598}
599
600pub fn parse_dicom_file(path: &Path) -> Result<DicomMetadata, DicomError> {
602 let bytes = fs::read(path).map_err(|e| DicomError::Io(e.to_string()))?;
603 parse_dicom_metadata_bytes(&bytes)
604}
605
606pub fn normalize_dicom_token(value: &str) -> String {
607 value
608 .to_lowercase()
609 .chars()
610 .map(|c| if c.is_ascii_alphanumeric() { c } else { ' ' })
611 .collect::<String>()
612 .split_whitespace()
613 .collect::<Vec<_>>()
614 .join(" ")
615}
616
617pub fn default_organ_matchers() -> Vec<DicomTagMatcher> {
618 vec![
619 DicomTagMatcher {
620 tokens: vec![
621 "heart".into(),
622 "cardiac".into(),
623 "coronary".into(),
624 "aorta".into(),
625 ],
626 organ: Some("Heart".into()),
627 },
628 DicomTagMatcher {
629 tokens: vec![
630 "lung".into(),
631 "pulmonary".into(),
632 "chest ct".into(),
633 "thorax".into(),
634 ],
635 organ: Some("Lung".into()),
636 },
637 DicomTagMatcher {
638 tokens: vec!["liver".into(), "hepatic".into()],
639 organ: Some("Liver".into()),
640 },
641 DicomTagMatcher {
642 tokens: vec![
643 "brain".into(),
644 "cerebral".into(),
645 "cranial".into(),
646 "head".into(),
647 ],
648 organ: Some("Brain (Allen)".into()),
649 },
650 DicomTagMatcher {
651 tokens: vec!["kidney".into(), "renal".into()],
652 organ: Some("Kidney (Left)".into()),
653 },
654 DicomTagMatcher {
655 tokens: vec!["pancrea".into(), "pancreatic".into()],
656 organ: Some("Pancreas".into()),
657 },
658 DicomTagMatcher {
659 tokens: vec!["spleen".into(), "splenic".into()],
660 organ: Some("Spleen".into()),
661 },
662 DicomTagMatcher {
663 tokens: vec!["intestin".into(), "bowel".into(), "abdomen".into()],
664 organ: Some("Small Intestine".into()),
665 },
666 DicomTagMatcher {
667 tokens: vec!["prostate".into()],
668 organ: Some("Prostate".into()),
669 },
670 DicomTagMatcher {
671 tokens: vec!["uterus".into(), "uterine".into()],
672 organ: Some("Uterus".into()),
673 },
674 DicomTagMatcher {
675 tokens: vec!["ovary".into(), "ovarian".into()],
676 organ: Some("Ovary (Left)".into()),
677 },
678 ]
679}
680
681pub fn infer_organ_from_metadata(
682 meta: &DicomMetadata,
683 matchers: &[DicomTagMatcher],
684) -> Option<String> {
685 let haystack = normalize_dicom_token(
686 &[
687 meta.body_part_examined.as_str(),
688 meta.series_description.as_str(),
689 meta.study_description.as_str(),
690 meta.protocol_name.as_str(),
691 ]
692 .iter()
693 .filter(|s| !s.is_empty())
694 .copied()
695 .collect::<Vec<_>>()
696 .join(" "),
697 );
698
699 infer_organ_from_haystack(&haystack, matchers)
700}
701
702pub fn infer_organ_from_haystack(haystack: &str, matchers: &[DicomTagMatcher]) -> Option<String> {
703 let normalized = normalize_dicom_token(haystack);
704 for matcher in matchers {
705 for token in &matcher.tokens {
706 let token_norm = normalize_dicom_token(token);
707 if !token_norm.is_empty() && normalized.contains(&token_norm) {
708 return matcher.organ.clone();
709 }
710 }
711 }
712 None
713}
714
715pub fn default_placement_for_organ(organ: &str) -> DicomPlacement {
716 let table: BTreeMap<&str, DicomPlacement> = BTreeMap::from([
717 (
718 "Heart",
719 DicomPlacement {
720 offset_x: 0.0,
721 offset_y: 0.05,
722 offset_z: 0.32,
723 scale: 0.9,
724 rotation_y: 0.0,
725 },
726 ),
727 (
728 "Lung",
729 DicomPlacement {
730 offset_x: 0.0,
731 offset_y: 0.08,
732 offset_z: 0.3,
733 scale: 1.05,
734 rotation_y: 0.0,
735 },
736 ),
737 (
738 "Liver",
739 DicomPlacement {
740 offset_x: 0.12,
741 offset_y: -0.02,
742 offset_z: 0.22,
743 scale: 0.95,
744 rotation_y: -0.35,
745 },
746 ),
747 (
748 "Brain (Allen)",
749 DicomPlacement {
750 offset_x: 0.0,
751 offset_y: 0.18,
752 offset_z: 0.12,
753 scale: 1.0,
754 rotation_y: 0.0,
755 },
756 ),
757 (
758 "Kidney (Left)",
759 DicomPlacement {
760 offset_x: -0.14,
761 offset_y: -0.06,
762 offset_z: 0.18,
763 scale: 0.75,
764 rotation_y: 0.2,
765 },
766 ),
767 ]);
768
769 table.get(organ).copied().unwrap_or(DicomPlacement {
770 offset_x: 0.0,
771 offset_y: 0.0,
772 offset_z: 0.28,
773 scale: 0.85,
774 rotation_y: 0.0,
775 })
776}
777
778pub fn build_overlay_spec(
779 meta: &DicomMetadata,
780 organ: Option<String>,
781 source: &str,
782) -> DicomOverlaySpec {
783 let organ_label = organ.or_else(|| infer_organ_from_metadata(meta, &default_organ_matchers()));
784 let placement = organ_label
785 .as_deref()
786 .map(default_placement_for_organ)
787 .unwrap_or_else(|| default_placement_for_organ("Heart"));
788
789 DicomOverlaySpec {
790 version: "1.0.0".to_string(),
791 organ: organ_label,
792 opacity: 0.72,
793 visible: true,
794 placement,
795 series_instance_uid: if meta.series_instance_uid.is_empty() {
796 None
797 } else {
798 Some(meta.series_instance_uid.clone())
799 },
800 modality: if meta.modality.is_empty() {
801 None
802 } else {
803 Some(meta.modality.clone())
804 },
805 body_part_examined: if meta.body_part_examined.is_empty() {
806 None
807 } else {
808 Some(meta.body_part_examined.clone())
809 },
810 source: Some(source.to_string()),
811 }
812}
813
814pub fn overlay_spec_from_file(path: &Path) -> Result<DicomOverlaySpec, DicomError> {
815 let meta = parse_dicom_file(path)?;
816 Ok(build_overlay_spec(
817 &meta,
818 None,
819 &format!("dicom-file:{}", path.display()),
820 ))
821}
822
823pub fn overlay_spec_json_from_file(path: &Path) -> Result<String, String> {
824 let spec = overlay_spec_from_file(path).map_err(|e| e.to_string())?;
825 serde_json::to_string(&spec).map_err(|e| e.to_string())
826}
827
828pub fn metadata_json_from_file(path: &Path) -> Result<String, String> {
829 let meta = parse_dicom_file(path).map_err(|e| e.to_string())?;
830 serde_json::to_string(&meta).map_err(|e| e.to_string())
831}
832
833const IMAGING_CHAT_MARKERS: &[&str] = &[
834 "dicom",
835 "ct scan",
836 " ct ",
837 "mri",
838 "x-ray",
839 "xray",
840 "radiograph",
841 "ultrasound",
842 "pet scan",
843 "mammogram",
844 "imaging study",
845 "chest x",
846 "slice thickness",
847 "window level",
848 "hounsfield",
849];
850
851pub fn chat_mentions_imaging(text: &str) -> bool {
852 let haystack = format!(" {} ", text.to_lowercase());
853 IMAGING_CHAT_MARKERS
854 .iter()
855 .any(|marker| haystack.contains(marker))
856}
857
858pub fn infer_overlay_spec_from_text(
859 text: &str,
860 matchers: &[DicomTagMatcher],
861) -> Option<DicomOverlaySpec> {
862 if !chat_mentions_imaging(text) {
863 return None;
864 }
865
866 let organ = infer_organ_from_haystack(text, matchers);
867 let placement = organ
868 .as_deref()
869 .map(default_placement_for_organ)
870 .unwrap_or_else(|| default_placement_for_organ("Heart"));
871
872 Some(DicomOverlaySpec {
873 version: "1.0.0".to_string(),
874 organ,
875 opacity: 0.72,
876 visible: true,
877 placement,
878 series_instance_uid: None,
879 modality: None,
880 body_part_examined: None,
881 source: Some("chat-inferred".to_string()),
882 })
883}
884
885pub const DEFAULT_LOCAL_DICOM_DIR: &str = "app-development/DICOM-20250126T150544Z-001";
887
888pub fn resolve_local_dicom_dir() -> Option<PathBuf> {
890 if let Ok(dir) = std::env::var("QUALIA_DICOM_FIXTURE_DIR") {
891 let path = PathBuf::from(dir);
892 if path.is_dir() {
893 return path.canonicalize().ok().or(Some(path));
894 }
895 }
896
897 let mut candidates: Vec<PathBuf> = Vec::new();
898 candidates.push(PathBuf::from(DEFAULT_LOCAL_DICOM_DIR));
899 if let Ok(manifest) = std::env::var("CARGO_MANIFEST_DIR") {
900 candidates.push(
901 PathBuf::from(manifest)
902 .join("..")
903 .join("..")
904 .join(DEFAULT_LOCAL_DICOM_DIR),
905 );
906 }
907 if let Ok(cwd) = std::env::current_dir() {
908 candidates.push(cwd.join(DEFAULT_LOCAL_DICOM_DIR));
909 candidates.push(cwd.join("..").join("..").join(DEFAULT_LOCAL_DICOM_DIR));
910 }
911
912 for path in candidates {
913 if path.is_dir() {
914 return path.canonicalize().ok().or(Some(path));
915 }
916 }
917 None
918}
919
920pub fn is_dicom_part10(bytes: &[u8]) -> bool {
922 bytes.len() >= 132 && &bytes[128..132] == b"DICM"
923}
924
925pub fn dicom_has_pixel_data(data: &[u8]) -> bool {
927 split_dicom_payload(data).is_ok()
928}
929
930pub fn collect_dicom_paths_under(root: &Path, max_files: usize) -> Vec<PathBuf> {
932 let mut out = Vec::new();
933 collect_dicom_paths_recursive(root, max_files, &mut out);
934 out
935}
936
937pub fn collect_dicom_image_paths_under(root: &Path, max_files: usize) -> Vec<PathBuf> {
939 let mut all = Vec::new();
940 collect_dicom_paths_recursive(root, max_files.saturating_mul(8).max(32), &mut all);
941 let mut images: Vec<PathBuf> = all
942 .into_iter()
943 .filter(|path| {
944 fs::read(path)
945 .ok()
946 .map(|bytes| dicom_has_pixel_data(&bytes))
947 .unwrap_or(false)
948 })
949 .collect();
950 images.sort_by_key(|p| {
951 let name = p.file_name().and_then(|s| s.to_str()).unwrap_or("");
952 (
953 !(name.starts_with("IM") || name.starts_with("im")),
954 name.to_string(),
955 )
956 });
957 images.truncate(max_files);
958 images
959}
960
961fn collect_dicom_paths_recursive(dir: &Path, max_files: usize, out: &mut Vec<PathBuf>) {
962 if out.len() >= max_files {
963 return;
964 }
965 let entries = match fs::read_dir(dir) {
966 Ok(e) => e,
967 Err(_) => return,
968 };
969 for entry in entries.flatten() {
970 if out.len() >= max_files {
971 break;
972 }
973 let path = entry.path();
974 if path.is_dir() {
975 collect_dicom_paths_recursive(&path, max_files, out);
976 } else if path.is_file() {
977 if let Ok(bytes) = fs::read(&path) {
978 if is_dicom_part10(&bytes) {
979 out.push(path);
980 }
981 }
982 }
983 }
984}
985
986#[cfg(test)]
987pub(crate) mod test_fixtures {
988 use super::*;
989
990 pub fn test_fixture_split_bytes() -> Vec<u8> {
992 let mut bytes = build_explicit_meta_file(TS_EXPLICIT_VR_LITTLE_ENDIAN);
993 push_explicit_lo(TAG_MODALITY, "CT", &mut bytes);
994 push_explicit_lo(TAG_BODY_PART_EXAMINED, "CHEST", &mut bytes);
995 push_explicit_lo(TAG_SERIES_DESCRIPTION, "CORONARY CTA", &mut bytes);
996 push_explicit_us(TAG_ROWS, 2, &mut bytes);
997 push_explicit_us(TAG_COLUMNS, 2, &mut bytes);
998 push_explicit_string(TAG_SERIES_INSTANCE_UID, "1.2.3", &mut bytes);
999 let pixels = [10u8, 20, 30, 40];
1000 bytes.extend_from_slice(&[0xE0, 0x7F, 0x10, 0x00, b'O', b'B', 0x00, 0x00]);
1001 bytes.extend_from_slice(&(pixels.len() as u32).to_le_bytes());
1002 bytes.extend_from_slice(&pixels);
1003 bytes
1004 }
1005
1006 fn build_explicit_meta_file(transfer_syntax: &str) -> Vec<u8> {
1007 let mut out = vec![0u8; 128];
1008 out.extend_from_slice(b"DICM");
1009 push_explicit_string(TAG_TRANSFER_SYNTAX_UID, transfer_syntax, &mut out);
1010 out
1011 }
1012
1013 fn push_explicit_string(tag: u32, value: &str, out: &mut Vec<u8>) {
1014 let group = (tag >> 16) as u16;
1015 let element = tag as u16;
1016 let bytes = value.as_bytes();
1017 out.extend_from_slice(&group.to_le_bytes());
1018 out.extend_from_slice(&element.to_le_bytes());
1019 out.extend_from_slice(b"UI");
1020 out.extend_from_slice(&(bytes.len() as u16).to_le_bytes());
1021 out.extend_from_slice(bytes);
1022 }
1023
1024 fn push_explicit_lo(tag: u32, value: &str, out: &mut Vec<u8>) {
1025 let group = (tag >> 16) as u16;
1026 let element = tag as u16;
1027 let bytes = value.as_bytes();
1028 out.extend_from_slice(&group.to_le_bytes());
1029 out.extend_from_slice(&element.to_le_bytes());
1030 out.extend_from_slice(b"LO");
1031 out.extend_from_slice(&(bytes.len() as u16).to_le_bytes());
1032 out.extend_from_slice(bytes);
1033 }
1034
1035 fn push_explicit_us(tag: u32, value: u16, out: &mut Vec<u8>) {
1036 let group = (tag >> 16) as u16;
1037 let element = tag as u16;
1038 out.extend_from_slice(&group.to_le_bytes());
1039 out.extend_from_slice(&element.to_le_bytes());
1040 out.extend_from_slice(b"US");
1041 out.extend_from_slice(&2u16.to_le_bytes());
1042 out.extend_from_slice(&value.to_le_bytes());
1043 }
1044}
1045
1046#[cfg(test)]
1047pub use test_fixtures::test_fixture_split_bytes;
1048
1049#[cfg(test)]
1050mod tests {
1051 use super::*;
1052
1053 #[test]
1054 fn infer_organ_from_cardiac_ct_description() {
1055 let meta = DicomMetadata {
1056 modality: "CT".into(),
1057 body_part_examined: "CHEST".into(),
1058 series_description: "Coronary CTA".into(),
1059 ..Default::default()
1060 };
1061 let organ = infer_organ_from_metadata(&meta, &default_organ_matchers());
1062 assert_eq!(organ.as_deref(), Some("Heart"));
1063 }
1064
1065 #[test]
1066 fn chat_inference_requires_imaging_marker() {
1067 let spec = infer_overlay_spec_from_text("patient has diabetes", &default_organ_matchers());
1068 assert!(spec.is_none());
1069 }
1070
1071 #[test]
1072 fn chat_inference_maps_mri_brain() {
1073 let text = "Review the brain MRI slices for demyelination.";
1074 let spec = infer_overlay_spec_from_text(text, &default_organ_matchers()).unwrap();
1075 assert_eq!(spec.organ.as_deref(), Some("Brain (Allen)"));
1076 assert_eq!(spec.source.as_deref(), Some("chat-inferred"));
1077 }
1078
1079 #[test]
1080 fn parse_explicit_vr_dataset_tags() {
1081 let meta = parse_dicom_metadata_bytes(&super::test_fixture_split_bytes()).expect("parse");
1082 assert_eq!(meta.modality, "CT");
1083 assert_eq!(meta.body_part_examined, "CHEST");
1084 assert_eq!(meta.rows, 2);
1085 assert_eq!(meta.columns, 2);
1086 assert_eq!(
1087 infer_organ_from_metadata(&meta, &default_organ_matchers()).as_deref(),
1088 Some("Heart")
1089 );
1090 }
1091
1092 #[test]
1093 fn placement_defaults_exist_for_common_organs() {
1094 let heart = default_placement_for_organ("Heart");
1095 assert!(heart.scale > 0.0);
1096 let unknown = default_placement_for_organ("Unknown Organ");
1097 assert!(unknown.scale > 0.0);
1098 }
1099
1100 #[test]
1102 fn local_private_dicom_metadata_and_split() {
1103 let Some(root) = super::resolve_local_dicom_dir() else {
1104 eprintln!("skip: set QUALIA_DICOM_FIXTURE_DIR or add {DEFAULT_LOCAL_DICOM_DIR}");
1105 return;
1106 };
1107 let all = super::collect_dicom_paths_under(&root, 8);
1108 assert!(
1109 !all.is_empty(),
1110 "no Part-10 DICOM files under {}",
1111 root.display()
1112 );
1113 for path in &all {
1114 let meta = super::parse_dicom_file(path).expect("metadata parse");
1115 assert!(
1116 !meta.modality.is_empty(),
1117 "modality missing in {}",
1118 path.display()
1119 );
1120 }
1121
1122 let images = super::collect_dicom_image_paths_under(&root, 4);
1123 assert!(
1124 !images.is_empty(),
1125 "no image slices with Pixel Data under {}",
1126 root.display()
1127 );
1128 for path in &images {
1129 let bytes = std::fs::read(path).unwrap();
1130 let split = super::split_dicom_payload(&bytes).expect("split ingest");
1131 assert!(
1132 split.pixels.length > 0,
1133 "pixel payload empty in {}",
1134 path.display()
1135 );
1136 assert!(split.meta.rows > 0 && split.meta.columns > 0);
1137 }
1138 }
1139}