Skip to main content

qualia_core_db/medical/
dicom.rs

1//! DICOM Part 10 metadata parsing and anatomy overlay spec generation.
2//!
3//! File ingest path — heap allocation is permitted here (not a hot-path evaluator).
4
5use 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
35/// Inline datatype tag for mmap blob byte-offset pointers (resolver bits 60–62 = 0b100).
36pub 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/// Pixel payload location inside a Part 10 file (not copied — Core 3 streams to blob store).
97#[derive(Debug, Clone, Copy, PartialEq, Eq)]
98pub struct DicomPixelSlice {
99    pub offset: usize,
100    pub length: usize,
101}
102
103/// Result of split-ingestion parse: semantic metadata + raw pixel slice bounds.
104#[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    /// Compressed pixel data (JPEG / JPEG 2000 / RLE); dataset tags remain explicit VR.
159    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
342/// Skip nested elements inside an undefined-length sequence item until `(FFFE,E00D)`.
343fn 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
429/// End offset of encapsulated Pixel Data fragment sequence (after item delimiter).
430fn 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/// Encode a Core-3 blob-store byte offset into an inline Object field pointer.
480#[inline]
481pub fn encode_blob_pointer(byte_offset: u64) -> u64 {
482    (byte_offset & INLINE_VALUE_MASK) | INLINE_TAG_BLOB_POINTER
483}
484
485/// Decode a blob pointer from an Object field; returns `None` if tag mismatch.
486#[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/// Pack rows/cols/pixel-byte-length into the metadata lane for volume queries.
496#[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
570/// Parse DICOM Part 10 metadata from an on-disk byte buffer (pixel data optional).
571pub 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
583/// Split-ingestion entry: metadata tags + pixel slice bounds (no heap copy of pixels).
584pub 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
600/// Parse DICOM metadata from a file path.
601pub 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
885/// Default relative path for gitignored private DICOM slices (local dev only).
886pub const DEFAULT_LOCAL_DICOM_DIR: &str = "app-development/DICOM-20250126T150544Z-001";
887
888/// Resolve private local DICOM root: `QUALIA_DICOM_FIXTURE_DIR` env, then workspace-relative default.
889pub 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
920/// Returns true when the byte slice looks like DICOM Part 10 (preamble + `DICM`).
921pub fn is_dicom_part10(bytes: &[u8]) -> bool {
922    bytes.len() >= 132 && &bytes[128..132] == b"DICM"
923}
924
925/// True when the buffer contains extractable Pixel Data for split-ingest.
926pub fn dicom_has_pixel_data(data: &[u8]) -> bool {
927    split_dicom_payload(data).is_ok()
928}
929
930/// Collect up to `max_files` DICOM Part-10 paths under `root` (cold-path helper; may allocate).
931pub 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
937/// Image slices only (Pixel Data present), preferring `IM*` filenames typical of PACS export.
938pub 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    /// Minimal Part-10 bytes for unit tests (explicit VR, single 2×2 pixel frame).
991    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    /// Auto-skips when private fixtures are absent (CI); runs when `app-development/DICOM-*` exists.
1101    #[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}