Skip to main content

wsi_dicom/
request.rs

1//! Request types accepted by the public export, profile, coverage, and encode APIs.
2
3use std::path::PathBuf;
4use std::time::Duration;
5
6use crate::{
7    CodecValidation, EncodeBackendPreference, Error, ExportOptions, MetadataSource, TransferSyntax,
8};
9
10/// A validated request to export one vendor WSI into one DICOM output directory.
11#[derive(Debug, Clone, PartialEq)]
12#[non_exhaustive]
13pub struct ExportRequest {
14    /// Path to the source slide readable by `wsi-rs`.
15    pub source_path: PathBuf,
16    /// Directory where generated DICOM instances are written.
17    pub output_dir: PathBuf,
18    /// Export routing, encoding, ICC, and backend options.
19    pub options: ExportOptions,
20    /// Metadata policy used to populate required DICOM identifying fields.
21    pub metadata: MetadataSource,
22    /// Optional single source pyramid level to export.
23    pub level_filter: Option<u32>,
24}
25
26impl ExportRequest {
27    /// Build an export request with explicit metadata.
28    pub fn new(
29        source_path: PathBuf,
30        output_dir: PathBuf,
31        options: ExportOptions,
32        metadata: MetadataSource,
33    ) -> Result<Self, Error> {
34        options.validate()?;
35        Ok(Self {
36            source_path,
37            output_dir,
38            options,
39            metadata,
40            level_filter: None,
41        })
42    }
43
44    /// Validate request options before export.
45    pub fn validate(&self) -> Result<(), Error> {
46        self.options.validate()
47    }
48}
49
50/// Borrowed interleaved samples and geometry for DICOM J2K/HTJ2K frame encoding.
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52#[non_exhaustive]
53pub struct FrameSamples<'a> {
54    /// Interleaved pixel bytes for the frame.
55    pub data: &'a [u8],
56    /// Frame width in pixels.
57    pub width: u32,
58    /// Frame height in pixels.
59    pub height: u32,
60    /// Samples per pixel; currently 1 for grayscale or 3 for RGB-like data.
61    pub components: u8,
62    /// Stored bits per sample.
63    pub bit_depth: u8,
64    /// Whether sample values are signed.
65    pub signed: bool,
66}
67
68impl<'a> FrameSamples<'a> {
69    /// Validate and describe an interleaved frame buffer.
70    pub fn new(
71        data: &'a [u8],
72        width: u32,
73        height: u32,
74        components: u8,
75        bit_depth: u8,
76        signed: bool,
77    ) -> Result<Self, Error> {
78        let samples = Self {
79            data,
80            width,
81            height,
82            components,
83            bit_depth,
84            signed,
85        };
86        samples.to_j2k()?;
87        Ok(samples)
88    }
89
90    pub(crate) fn to_j2k(self) -> Result<j2k::J2kLosslessSamples<'a>, Error> {
91        j2k::J2kLosslessSamples::new(
92            self.data,
93            self.width,
94            self.height,
95            u16::from(self.components),
96            self.bit_depth,
97            self.signed,
98        )
99        .map_err(|err| Error::UnsupportedPixelData {
100            reason: err.to_string(),
101        })
102    }
103}
104
105/// Request to encode one already-composed tile into DICOM-ready J2K/HTJ2K frame bytes.
106#[derive(Debug, Clone, Copy)]
107#[non_exhaustive]
108pub struct J2kFrameEncodeRequest<'a> {
109    /// Interleaved samples and geometry for the frame.
110    pub samples: FrameSamples<'a>,
111    /// DICOM transfer syntax to encode.
112    pub transfer_syntax: TransferSyntax,
113    /// Runtime backend preference for the encoder.
114    pub encode_backend: EncodeBackendPreference,
115    /// Optional per-frame codec validation policy.
116    pub codec_validation: CodecValidation,
117}
118
119impl<'a> J2kFrameEncodeRequest<'a> {
120    /// Build a single-frame encode request.
121    pub fn new(
122        samples: FrameSamples<'a>,
123        transfer_syntax: TransferSyntax,
124        encode_backend: EncodeBackendPreference,
125        codec_validation: CodecValidation,
126    ) -> Self {
127        Self {
128            samples,
129            transfer_syntax,
130            encode_backend,
131            codec_validation,
132        }
133    }
134}
135
136/// Request to profile frame routing for one level without writing DICOM.
137#[derive(Debug, Clone, PartialEq)]
138#[non_exhaustive]
139pub struct RouteProfileRequest {
140    /// Path to the source slide readable by `wsi-rs`.
141    pub source_path: PathBuf,
142    /// Export options used for route planning.
143    pub options: ExportOptions,
144    /// Whether to resolve the transfer syntax from source compression before profiling.
145    pub source_aware_transfer_syntax: bool,
146    /// Source pyramid level to profile.
147    pub level: u32,
148    /// Maximum frames to sample.
149    pub max_frames: u64,
150}
151
152impl RouteProfileRequest {
153    /// Build a route profile request for one source level.
154    pub fn new(source_path: PathBuf, options: ExportOptions, level: u32, max_frames: u64) -> Self {
155        Self {
156            source_path,
157            options,
158            source_aware_transfer_syntax: true,
159            level,
160            max_frames,
161        }
162    }
163
164    /// Set whether profiling should resolve transfer syntax from source compression.
165    pub fn with_source_aware_transfer_syntax(mut self, source_aware: bool) -> Self {
166        self.source_aware_transfer_syntax = source_aware;
167        self
168    }
169}
170
171/// Request for source-aware default transfer syntax selection.
172#[derive(Debug, Clone, PartialEq, Eq)]
173#[non_exhaustive]
174pub struct DefaultTransferSyntaxRequest {
175    /// Path to the source slide readable by `wsi-rs`.
176    pub source_path: PathBuf,
177    /// Requested fallback DICOM tile size.
178    pub tile_size: u32,
179    /// Optional single source pyramid level to inspect.
180    pub level_filter: Option<u32>,
181    /// Optional cap on source levels inspected.
182    pub max_levels: Option<u32>,
183}
184
185impl DefaultTransferSyntaxRequest {
186    /// Build a default transfer syntax request for all source levels.
187    pub fn new(source_path: PathBuf, tile_size: u32) -> Self {
188        Self {
189            source_path,
190            tile_size,
191            level_filter: None,
192            max_levels: None,
193        }
194    }
195}
196
197/// Target for route coverage profiling.
198#[derive(Debug, Clone, PartialEq, Eq)]
199#[non_exhaustive]
200pub enum RouteCoverageTarget {
201    /// Profile one source slide.
202    Source(PathBuf),
203    /// Profile all supported source files under a root directory.
204    Corpus(PathBuf),
205}
206
207/// Progress reporting destination for route coverage work.
208#[derive(Debug, Clone, Copy, PartialEq, Eq)]
209#[non_exhaustive]
210pub enum RouteProgressSink {
211    /// Emit progress lines to standard error.
212    Stderr,
213}
214
215/// Request to sample route coverage without writing DICOM.
216#[derive(Debug, Clone, PartialEq)]
217#[non_exhaustive]
218pub struct RouteCoverageRequest {
219    /// Source or corpus target to profile.
220    pub target: RouteCoverageTarget,
221    /// Export options used for route planning.
222    pub options: ExportOptions,
223    /// Whether to resolve the transfer syntax from source compression before profiling.
224    pub source_aware_transfer_syntax: bool,
225    /// Maximum frames sampled per level; `u64::MAX` requests full coverage.
226    pub max_frames_per_level: u64,
227    /// Optional cap on source levels inspected.
228    pub max_levels: Option<u32>,
229    /// Optional per-level elapsed time budget.
230    pub max_level_elapsed: Option<Duration>,
231    /// Optional progress sink.
232    pub progress: Option<RouteProgressSink>,
233    /// Maximum source files considered for corpus coverage.
234    pub max_sources: usize,
235    /// Maximum directory depth walked for corpus coverage.
236    pub max_depth: usize,
237}
238
239impl RouteCoverageRequest {
240    /// Build a route coverage request that samples one frame per level.
241    pub fn new(source_path: PathBuf, options: ExportOptions) -> Self {
242        Self {
243            target: RouteCoverageTarget::Source(source_path),
244            options,
245            source_aware_transfer_syntax: true,
246            max_frames_per_level: 1,
247            max_levels: None,
248            max_level_elapsed: None,
249            progress: None,
250            max_sources: 100_000,
251            max_depth: 64,
252        }
253    }
254
255    /// Build a corpus coverage request that samples one frame per level.
256    pub fn new_corpus(source_root: PathBuf, options: ExportOptions) -> Self {
257        Self {
258            target: RouteCoverageTarget::Corpus(source_root),
259            options,
260            source_aware_transfer_syntax: true,
261            max_frames_per_level: 1,
262            max_levels: None,
263            max_level_elapsed: None,
264            progress: None,
265            max_sources: 100_000,
266            max_depth: 64,
267        }
268    }
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274
275    #[test]
276    fn request_constructors_preserve_defaults_and_validate_options() {
277        let source = PathBuf::from("slide.svs");
278        let output = PathBuf::from("dicom-out");
279        let options = ExportOptions::default();
280
281        let export = ExportRequest::new(
282            source.clone(),
283            output.clone(),
284            options.clone(),
285            MetadataSource::ResearchPlaceholder,
286        )
287        .unwrap();
288        assert_eq!(export.source_path, source);
289        assert_eq!(export.output_dir, output);
290        assert_eq!(export.metadata, MetadataSource::ResearchPlaceholder);
291        assert_eq!(export.level_filter, None);
292        export.validate().unwrap();
293
294        let err = ExportRequest::new(
295            PathBuf::from("slide.svs"),
296            PathBuf::from("dicom-out"),
297            ExportOptions {
298                tile_size: 0,
299                ..ExportOptions::default()
300            },
301            MetadataSource::ResearchPlaceholder,
302        )
303        .expect_err("zero tile size should be rejected");
304        assert!(err.to_string().contains("tile_size"));
305
306        let profile = RouteProfileRequest::new(PathBuf::from("slide.svs"), options.clone(), 2, 16);
307        assert!(profile.source_aware_transfer_syntax);
308        assert_eq!(profile.level, 2);
309        assert_eq!(profile.max_frames, 16);
310
311        let default_transfer = DefaultTransferSyntaxRequest::new(PathBuf::from("slide.svs"), 512);
312        assert_eq!(default_transfer.tile_size, 512);
313        assert_eq!(default_transfer.level_filter, None);
314        assert_eq!(default_transfer.max_levels, None);
315
316        let coverage = RouteCoverageRequest::new(PathBuf::from("slide.svs"), options.clone());
317        assert_eq!(
318            coverage.target,
319            RouteCoverageTarget::Source(PathBuf::from("slide.svs"))
320        );
321        assert!(coverage.source_aware_transfer_syntax);
322        assert_eq!(coverage.max_frames_per_level, 1);
323        assert_eq!(coverage.max_levels, None);
324        assert_eq!(coverage.max_level_elapsed, None);
325        assert_eq!(coverage.progress, None);
326        assert_eq!(coverage.max_sources, 100_000);
327        assert_eq!(coverage.max_depth, 64);
328
329        let corpus = RouteCoverageRequest::new_corpus(PathBuf::from("slides"), options);
330        assert_eq!(
331            corpus.target,
332            RouteCoverageTarget::Corpus(PathBuf::from("slides"))
333        );
334        assert!(corpus.source_aware_transfer_syntax);
335        assert_eq!(corpus.max_frames_per_level, 1);
336        assert_eq!(corpus.max_levels, None);
337        assert_eq!(corpus.max_level_elapsed, None);
338        assert_eq!(corpus.progress, None);
339        assert_eq!(corpus.max_sources, 100_000);
340        assert_eq!(corpus.max_depth, 64);
341    }
342
343    #[test]
344    fn frame_encode_request_constructors_validate_sample_geometry() {
345        let pixels = [0_u8; 2 * 2 * 3];
346        let samples = FrameSamples::new(&pixels, 2, 2, 3, 8, false).unwrap();
347        assert_eq!(samples.width, 2);
348        assert_eq!(samples.height, 2);
349        assert_eq!(samples.components, 3);
350
351        let request = J2kFrameEncodeRequest::new(
352            samples,
353            TransferSyntax::Htj2kLosslessRpcl,
354            EncodeBackendPreference::CpuOnly,
355            CodecValidation::RoundTrip,
356        );
357        assert_eq!(request.transfer_syntax, TransferSyntax::Htj2kLosslessRpcl);
358        assert_eq!(request.encode_backend, EncodeBackendPreference::CpuOnly);
359        assert_eq!(request.codec_validation, CodecValidation::RoundTrip);
360
361        let err = FrameSamples::new(&pixels[..3], 2, 2, 3, 8, false)
362            .expect_err("short frame data should be rejected");
363        assert!(err.to_string().contains("pixel"));
364    }
365}