Skip to content

Commit

Permalink
feat(rendered): Support rendered resources
Browse files Browse the repository at this point in the history
  • Loading branch information
feliwir committed Jan 7, 2025
1 parent 2de782d commit 076e5fc
Show file tree
Hide file tree
Showing 6 changed files with 261 additions and 17 deletions.
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 6 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand Down
55 changes: 46 additions & 9 deletions src/api/wado/routes.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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;

Check warning

Code scanning / clippy

variable does not need to be mutable Warning

variable does not need to be mutable

Check warning

Code scanning / clippy

variable does not need to be mutable Warning

variable does not need to be mutable

Check warning

Code scanning / clippy

unused variable: image Warning

unused variable: image

Check warning

Code scanning / clippy

unused variable: image Warning

unused variable: 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,
Expand Down Expand Up @@ -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 {
Expand Down
132 changes: 127 additions & 5 deletions src/api/wado/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -21,6 +23,8 @@ pub trait WadoService: Send + Sync {
&self,
request: RetrieveInstanceRequest,
) -> Result<InstanceResponse, RetrieveError>;

async fn render(&self, request: RenderedRequest) -> Result<RenderedResponse, RetrieveError>;
}

#[derive(Debug, Error)]
Expand All @@ -30,6 +34,7 @@ pub enum RetrieveError {
}

pub type RetrieveInstanceRequest = RetrieveRequest<InstanceQueryParameters>;
pub type RenderedRequest = RetrieveRequest<RenderedQueryParameters>;

pub struct RetrieveRequest<Q: QueryParameters> {
pub query: ResourceQuery,
Expand Down Expand Up @@ -64,10 +69,42 @@ where
}
}

#[async_trait]
impl<S> FromRequestParts<S> for RenderedRequest
where
AppState: FromRef<S>,
S: Send + Sync,
{
type Rejection = Response;

async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let Path(query): Path<ResourceQuery> = Path::from_request_parts(parts, state)
.await
.map_err(PathRejection::into_response)?;

let Query(parameters): Query<RenderedQueryParameters> =
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<Arc<FileDicomObject<InMemDicomObject>>, MoveError>>,
}

pub struct RenderedResponse {

Check warning

Code scanning / clippy

field headers is never read Warning

field headers is never read

Check warning

Code scanning / clippy

field headers is never read Warning

field headers is never read
pub image: DynamicImage,
pub headers: ResponseHeaderFields,

Check warning

Code scanning / clippy

field headers is never read Warning

field headers is never read

Check warning

Code scanning / clippy

field headers is never read Warning

field headers is never read
}

#[derive(Debug, Deserialize)]
pub struct ResourceQuery {
#[serde(rename = "aet")]
Expand Down Expand Up @@ -108,7 +145,7 @@ pub struct MetadataQueryParameters {
pub charset: Option<String>,
}

#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)]
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Deserialize)]
pub struct ImageQuality(u8);

impl ImageQuality {
Expand Down Expand Up @@ -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.
///
/// <https://dicom.nema.org/medical/dicom/current/output/chtml/part18/sect_8.3.5.html#sect_8.3.5.1.4>
#[derive(Debug, Deserialize, PartialEq)]
pub struct Window {
/// Decimal number containing the window-center value.
pub center: f64,
Expand All @@ -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 {

Check warning

Code scanning / clippy

the following explicit lifetimes could be elided: 'a Warning

the following explicit lifetimes could be elided: 'a

Check warning

Code scanning / clippy

the following explicit lifetimes could be elided: 'a Warning

the following explicit lifetimes could be elided: 'a

Check warning

Code scanning / clippy

the following explicit lifetimes could be elided: 'a Warning

the following explicit lifetimes could be elided: 'a

Check warning

Code scanning / clippy

the following explicit lifetimes could be elided: 'a Warning

the following explicit lifetimes could be elided: 'a
type Value = Option<Window>;

fn expecting(&self, formatter: &mut Formatter) -> std::fmt::Result {
write!(formatter, "a value of <{{attribute}}* | all>")
}

fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
where
E: Error,
{
let values = v.split(',').collect::<Vec<&str>>();
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<Option<Window>, D::Error>
where
D: Deserializer<'de>,
{
deserializer.deserialize_any(WindowVisitor)
}

/// <https://dicom.nema.org/medical/dicom/current/output/chtml/part03/sect_C.11.2.html#sect_C.11.2.1.3>
#[derive(Debug, Deserialize, PartialEq)]

Check warning

Code scanning / clippy

you are deriving PartialEq and can implement Eq Warning

you are deriving PartialEq and can implement Eq

Check warning

Code scanning / clippy

you are deriving PartialEq and can implement Eq Warning

you are deriving PartialEq and can implement Eq
pub enum VoiLutFunction {
/// <https://dicom.nema.org/medical/dicom/current/output/chtml/part03/sect_C.11.2.html#sect_C.11.2.1.2.1>
Linear,
Expand All @@ -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<Self, Self::Err> {
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.
///
/// <https://dicom.nema.org/medical/dicom/current/output/chtml/part18/sect_8.3.5.html#sect_8.3.5.1.5>
Expand Down Expand Up @@ -218,13 +314,14 @@ impl ImageAnnotation {
}
}

#[derive(Debug, Default)]
#[derive(Debug, Default, Deserialize, PartialEq)]
pub struct RenderedQueryParameters {
pub accept: Option<String>,
pub annotation: Option<String>,
pub quality: Option<ImageQuality>,
pub viewport: Option<String>,
pub window: Option<String>,
#[serde(deserialize_with = "deserialize_window")]
pub window: Option<Window>,
pub iccprofile: Option<String>,
}

Expand All @@ -236,6 +333,9 @@ pub struct ThumbnailQueryParameters {

#[cfg(test)]
mod tests {
use axum::extract::Query;
use axum::http::Uri;

use super::*;

#[test]
Expand All @@ -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::<RenderedQueryParameters>::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,
}
);
}
}
Loading

0 comments on commit 076e5fc

Please sign in to comment.