diff --git a/Cargo.lock b/Cargo.lock index cb5b918..9573c1d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1106,6 +1106,7 @@ dependencies = [ "config", "dicom", "dicom-json", + "dicom-pixeldata", "futures", "mime", "multer", diff --git a/Cargo.toml b/Cargo.toml index 8fe12d9..130a44b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,11 @@ version = "0.3.0-beta.1" description = "A robust DICOMweb server with swappable backend" edition = "2021" rust-version = "1.74.0" -categories = ["multimedia", "network-programming", "web-programming::http-server"] +categories = [ + "multimedia", + "network-programming", + "web-programming::http-server", +] keywords = ["dicom", "dicomweb", "healthcare", "medical"] repository = "https://github.com/UMEssen/DICOM-RST" license = "MIT" @@ -20,6 +24,7 @@ s3 = [] # DICOM processing dicom = "0.8.0" dicom-json = "0.8.0" +dicom-pixeldata = { version = "0.8.0", features = ["image"] } sentry = { version = "0.35.0", features = ["tracing"] } # Serialization diff --git a/src/api/wado/routes.rs b/src/api/wado/routes.rs index 36b9357..159efdf 100644 --- a/src/api/wado/routes.rs +++ b/src/api/wado/routes.rs @@ -1,4 +1,4 @@ -use crate::api::wado::RetrieveInstanceRequest; +use crate::api::wado::{RenderedRequest, RetrieveInstanceRequest}; use crate::backend::dimse::wado::DicomMultipartStream; use crate::backend::ServiceProvider; use crate::types::UI; @@ -96,6 +96,36 @@ async fn instance_resource( } } +async fn rendered_resource( + provider: ServiceProvider, + request: RenderedRequest, +) -> impl IntoResponse { + if let Some(wado) = provider.wado { + let response = wado.render(request).await; + + match response { + Ok(response) => { + let mut image = response.image; + + Response::builder() + .header(CONTENT_TYPE, "image/jpeg") + .body(Body::empty()) + .unwrap() + } + Err(err) => { + error!("{err:?}"); + (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()).into_response() + } + } + } else { + ( + StatusCode::SERVICE_UNAVAILABLE, + "WADO-RS endpoint is disabled", + ) + .into_response() + } +} + #[instrument(skip_all)] async fn study_instances( provider: ServiceProvider, @@ -132,20 +162,27 @@ async fn instance_metadata() -> impl IntoResponse { StatusCode::NOT_IMPLEMENTED } -async fn rendered_study() -> impl IntoResponse { - StatusCode::NOT_IMPLEMENTED +#[instrument(skip_all)] +async fn rendered_study(provider: ServiceProvider, request: RenderedRequest) -> impl IntoResponse { + rendered_resource(provider, request).await } -async fn rendered_series() -> impl IntoResponse { - StatusCode::NOT_IMPLEMENTED +#[instrument(skip_all)] +async fn rendered_series(provider: ServiceProvider, request: RenderedRequest) -> impl IntoResponse { + rendered_resource(provider, request).await } -async fn rendered_instance() -> impl IntoResponse { - StatusCode::NOT_IMPLEMENTED +#[instrument(skip_all)] +async fn rendered_instance( + provider: ServiceProvider, + request: RenderedRequest, +) -> impl IntoResponse { + rendered_resource(provider, request).await } -async fn rendered_frames() -> impl IntoResponse { - StatusCode::NOT_IMPLEMENTED +#[instrument(skip_all)] +async fn rendered_frames(provider: ServiceProvider, request: RenderedRequest) -> impl IntoResponse { + rendered_resource(provider, request).await } async fn study_thumbnail() -> impl IntoResponse { diff --git a/src/api/wado/service.rs b/src/api/wado/service.rs index 1195116..a419d35 100644 --- a/src/api/wado/service.rs +++ b/src/api/wado/service.rs @@ -7,9 +7,11 @@ use axum::extract::{FromRef, FromRequestParts, Path, Query}; use axum::http::request::Parts; use axum::response::{IntoResponse, Response}; use dicom::object::{FileDicomObject, InMemDicomObject}; +use dicom_pixeldata::image::DynamicImage; use futures::stream::BoxStream; -use serde::{Deserialize, Serialize}; -use std::fmt::Debug; +use serde::de::{Error, Visitor}; +use serde::{Deserialize, Deserializer, Serialize}; +use std::fmt::{Debug, Formatter}; use std::num::ParseIntError; use std::str::FromStr; use std::sync::Arc; @@ -21,6 +23,8 @@ pub trait WadoService: Send + Sync { &self, request: RetrieveInstanceRequest, ) -> Result; + + async fn render(&self, request: RenderedRequest) -> Result; } #[derive(Debug, Error)] @@ -30,6 +34,7 @@ pub enum RetrieveError { } pub type RetrieveInstanceRequest = RetrieveRequest; +pub type RenderedRequest = RetrieveRequest; pub struct RetrieveRequest { pub query: ResourceQuery, @@ -64,10 +69,42 @@ where } } +#[async_trait] +impl FromRequestParts for RenderedRequest +where + AppState: FromRef, + S: Send + Sync, +{ + type Rejection = Response; + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + let Path(query): Path = Path::from_request_parts(parts, state) + .await + .map_err(PathRejection::into_response)?; + + let Query(parameters): Query = + Query::from_request_parts(parts, state) + .await + .map_err(QueryRejection::into_response)?; + + Ok(Self { + query, + parameters, + // TODO: currently unused + headers: RequestHeaderFields::default(), + }) + } +} + pub struct InstanceResponse { pub stream: BoxStream<'static, Result>, MoveError>>, } +pub struct RenderedResponse { + pub image: DynamicImage, + pub headers: ResponseHeaderFields, +} + #[derive(Debug, Deserialize)] pub struct ResourceQuery { #[serde(rename = "aet")] @@ -108,7 +145,7 @@ pub struct MetadataQueryParameters { pub charset: Option, } -#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Deserialize)] pub struct ImageQuality(u8); impl ImageQuality { @@ -159,6 +196,7 @@ pub enum ImageAnnotation { /// Controls the windowing of the images or video as defined in Section C.8.11.3.1.5 in PS3.3. /// /// +#[derive(Debug, Deserialize, PartialEq)] pub struct Window { /// Decimal number containing the window-center value. pub center: f64, @@ -168,7 +206,46 @@ pub struct Window { pub function: VoiLutFunction, } +/// Custom deserialization visitor for repeated `includefield` query parameters. +/// It collects all `includefield` parameters in [`crate::dicomweb::qido::IncludeField::List`]. +/// If at least one `includefield` parameter has the value `all`, +/// [`crate::dicomweb::qido::IncludeField::All`] is returned instead. +struct WindowVisitor; + +impl<'a> Visitor<'a> for WindowVisitor { + type Value = Option; + + fn expecting(&self, formatter: &mut Formatter) -> std::fmt::Result { + write!(formatter, "a value of <{{attribute}}* | all>") + } + + fn visit_str(self, v: &str) -> Result + where + E: Error, + { + let values = v.split(',').collect::>(); + if values.len() != 3 { + return Err(E::custom("expected 3 comma-separated values")); + } + + Ok(Some(Window { + center: values[0].parse().map_err(E::custom)?, + width: values[1].parse().map_err(E::custom)?, + function: values[2].parse().map_err(E::custom)?, + })) + } +} + +/// See [`WindowVisitor`]. +fn deserialize_window<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + deserializer.deserialize_any(WindowVisitor) +} + /// +#[derive(Debug, Deserialize, PartialEq)] pub enum VoiLutFunction { /// Linear, @@ -184,6 +261,25 @@ impl Default for VoiLutFunction { } } +#[derive(Debug, Error)] +pub enum ParseVoiLutFunctionError { + #[error("Unknown VOI LUT function: {function}")] + UnknownFunction { function: String }, +} + +impl FromStr for VoiLutFunction { + type Err = ParseVoiLutFunctionError; + + fn from_str(s: &str) -> Result { + match s { + "LINEAR" => Ok(Self::Linear), + "LINEAR_EXACT" => Ok(Self::LinearExact), + "SIGMOID" => Ok(Self::Sigmoid), + _ => Err(ParseVoiLutFunctionError::UnknownFunction { function: s.into() }), + } + } +} + /// Specifies the inclusion of an ICC Profile in the rendered images. /// /// @@ -218,13 +314,14 @@ impl ImageAnnotation { } } -#[derive(Debug, Default)] +#[derive(Debug, Default, Deserialize, PartialEq)] pub struct RenderedQueryParameters { pub accept: Option, pub annotation: Option, pub quality: Option, pub viewport: Option, - pub window: Option, + #[serde(deserialize_with = "deserialize_window")] + pub window: Option, pub iccprofile: Option, } @@ -236,6 +333,9 @@ pub struct ThumbnailQueryParameters { #[cfg(test)] mod tests { + use axum::extract::Query; + use axum::http::Uri; + use super::*; #[test] @@ -259,4 +359,26 @@ mod tests { ImageQuality::new(0).unwrap() ); } + + #[test] + fn parse_rendered_query_params() { + let uri = Uri::from_static("http://test?window=100,200,SIGMOID"); + let Query(params) = Query::::try_from_uri(&uri).unwrap(); + + assert_eq!( + params, + RenderedQueryParameters { + accept: None, + annotation: None, + quality: None, + viewport: None, + window: Some(Window { + center: 100.0, + width: 200.0, + function: VoiLutFunction::Sigmoid, + }), + iccprofile: None, + } + ); + } } diff --git a/src/backend/dimse/wado.rs b/src/backend/dimse/wado.rs index f16e2bf..4faea02 100644 --- a/src/backend/dimse/wado.rs +++ b/src/backend/dimse/wado.rs @@ -1,4 +1,7 @@ -use crate::api::wado::{InstanceResponse, RetrieveError, RetrieveInstanceRequest, WadoService}; +use crate::api::wado::{ + InstanceResponse, RenderedRequest, RenderedResponse, RetrieveError, RetrieveInstanceRequest, + WadoService, +}; use crate::backend::dimse::association; use crate::backend::dimse::cmove::movescu::{MoveError, MoveServiceClassUser}; use crate::backend::dimse::cmove::{ @@ -14,6 +17,7 @@ use async_trait::async_trait; use dicom::core::VR; use dicom::dictionary_std::tags; use dicom::object::{FileDicomObject, InMemDicomObject}; +use dicom_pixeldata::{ConvertOptions, PixelDecoder, VoiLutOption, WindowLevel}; use futures::stream::BoxStream; use futures::{Stream, StreamExt}; use pin_project::pin_project; @@ -65,6 +69,74 @@ impl WadoService for DimseWadoService { stream: stream.boxed(), }) } + + async fn render(&self, request: RenderedRequest) -> Result { + if self.config.receivers.len() > 1 { + warn!("Multiple receivers are not supported yet."); + } + + let storescp_aet = self + .config + .receivers + .first() // TODO + .ok_or_else(|| RetrieveError::Backend { + source: anyhow::Error::new(DimseRetrieveError::MissingReceiver { + aet: request.query.aet.clone(), + }), + })?; + + let mut stream = self + .retrieve_instances( + &request.query.aet, + storescp_aet, + Self::create_identifier(Some(&request.query.study_instance_uid), None, None), + ) + .await; + + while let Some(result) = stream.next().await { + match result { + Ok(dicom_file) => { + trace!( + "Rendering {}", + dicom_file.meta().media_storage_sop_instance_uid() + ); + + let pixel_data = + dicom_file + .decode_pixel_data() + .map_err(|e| RetrieveError::Backend { + source: anyhow::anyhow!("Failed to decode pixel data"), + })?; + + // Convert the pixel data to an image + let options = match &request.parameters.window { + Some(windowing) => ConvertOptions::new() + .with_voi_lut(VoiLutOption::Custom(WindowLevel { + center: windowing.center, + width: windowing.width, + })) + .force_8bit(), + None => ConvertOptions::default().force_8bit(), + }; + let image = pixel_data + .to_dynamic_image_with_options(0, &options) + .map_err(|e| { + error!("Failed to convert pixel data to image: {}", e); + RetrieveError::Backend { + source: anyhow::anyhow!("Failed to decode pixel data"), + } + })?; + } + Err(err) => { + error!("{:?}", err); + } + } + } + + Err(RetrieveError::Backend { + source: anyhow::anyhow!("No renderable instance found"), + }) + } } #[derive(Debug, Error)] diff --git a/src/backend/s3/wado.rs b/src/backend/s3/wado.rs index af5dca3..68a2742 100644 --- a/src/backend/s3/wado.rs +++ b/src/backend/s3/wado.rs @@ -1,4 +1,7 @@ -use crate::api::wado::{InstanceResponse, RetrieveError, RetrieveInstanceRequest, WadoService}; +use crate::api::wado::{ + InstanceResponse, RenderedRequest, RenderedResponse, RetrieveError, RetrieveInstanceRequest, + WadoService, +}; use crate::backend::dimse::cmove::movescu::MoveError; use crate::config::{S3Config, S3EndpointStyle}; use async_trait::async_trait; @@ -115,4 +118,8 @@ impl WadoService for S3WadoService { stream: stream.boxed(), }) } + + async fn render(&self, _request: RenderedRequest) -> Result { + unimplemented!() + } }