Skip to main content

wsi_dicom/
api.rs

1//! Builder-first Rust API for DICOM export.
2
3use std::path::PathBuf;
4
5use crate::{
6    default_transfer_syntax_for_source, export_dicom, CodecValidation,
7    DefaultTransferSyntaxRequest, EncodeBackendPreference, Error, ExportOptions, ExportReport,
8    ExportRequest, IccProfilePolicy, JpegDirectHtj2kProfile, MetadataSource, TransferSyntax,
9};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12enum TransferSyntaxSelection {
13    SourceAware,
14    Explicit,
15}
16
17/// Builder for exporting one wsi-rs-readable whole-slide image to DICOM VL WSI.
18#[derive(Debug, Clone)]
19#[must_use = "Export is a builder; call run() to execute the export"]
20pub struct Export {
21    source_path: PathBuf,
22    output_dir: Option<PathBuf>,
23    options: ExportOptions,
24    metadata: Option<MetadataSource>,
25    level_filter: Option<u32>,
26    transfer_syntax: TransferSyntaxSelection,
27}
28
29impl Export {
30    /// Start an export builder from a source slide path.
31    ///
32    /// The builder resolves a source-aware transfer syntax in `build_request`
33    /// or `run` until `with_options` or `transfer_syntax` makes the transfer
34    /// syntax explicit. Direct `export_dicom` calls use the request options as
35    /// supplied and do not perform this source-aware resolution step.
36    #[must_use = "call run() or build_request() on the returned Export builder"]
37    pub fn from_slide(source_path: impl Into<PathBuf>) -> Self {
38        Self {
39            source_path: source_path.into(),
40            output_dir: None,
41            options: ExportOptions::default(),
42            metadata: None,
43            level_filter: None,
44            transfer_syntax: TransferSyntaxSelection::SourceAware,
45        }
46    }
47
48    /// Set the output directory for generated DICOM instances.
49    #[must_use = "builder methods return the updated Export"]
50    pub fn to_directory(mut self, output_dir: impl Into<PathBuf>) -> Self {
51        self.output_dir = Some(output_dir.into());
52        self
53    }
54
55    /// Replace export options and treat their transfer syntax as explicit.
56    #[must_use = "builder methods return the updated Export"]
57    pub fn with_options(mut self, options: ExportOptions) -> Self {
58        self.options = options;
59        self.transfer_syntax = TransferSyntaxSelection::Explicit;
60        self
61    }
62
63    /// Replace metadata used for DICOM conformance fields.
64    #[must_use = "builder methods return the updated Export"]
65    pub fn with_metadata(mut self, metadata: MetadataSource) -> Self {
66        self.metadata = Some(metadata);
67        self
68    }
69
70    /// Use deterministic non-clinical placeholder metadata for research exports.
71    #[must_use = "builder methods return the updated Export"]
72    pub fn with_research_placeholder_metadata(mut self) -> Self {
73        self.metadata = Some(MetadataSource::ResearchPlaceholder);
74        self
75    }
76
77    /// Export only one source pyramid level.
78    #[must_use = "builder methods return the updated Export"]
79    pub fn level(mut self, level: u32) -> Self {
80        self.level_filter = Some(level);
81        self
82    }
83
84    /// Explicitly set the output transfer syntax.
85    #[must_use = "builder methods return the updated Export"]
86    pub fn transfer_syntax(mut self, transfer_syntax: TransferSyntax) -> Self {
87        self.options.transfer_syntax = transfer_syntax;
88        self.options.jpeg_direct_htj2k_profile =
89            JpegDirectHtj2kProfile::default_for_transfer_syntax(transfer_syntax);
90        self.transfer_syntax = TransferSyntaxSelection::Explicit;
91        self
92    }
93
94    /// Select the direct JPEG-to-HTJ2K coefficient path for HTJ2K exports.
95    #[must_use = "builder methods return the updated Export"]
96    pub fn jpeg_direct_htj2k_profile(mut self, profile: JpegDirectHtj2kProfile) -> Self {
97        self.options.jpeg_direct_htj2k_profile = profile;
98        self
99    }
100
101    /// Set the DICOM frame tile size in pixels.
102    #[must_use = "builder methods return the updated Export"]
103    pub fn tile_size(mut self, tile_size: u32) -> Self {
104        self.options.tile_size = tile_size;
105        self
106    }
107
108    /// Set JPEG Baseline fallback quality.
109    #[must_use = "builder methods return the updated Export"]
110    pub fn jpeg_quality(mut self, jpeg_quality: u8) -> Self {
111        self.options.jpeg_quality = jpeg_quality;
112        self
113    }
114
115    /// Set the ICC profile policy for missing source color metadata.
116    #[must_use = "builder methods return the updated Export"]
117    pub fn icc_profile_policy(mut self, policy: IccProfilePolicy) -> Self {
118        self.options.icc_profile_policy = policy;
119        self
120    }
121
122    /// Set the runtime encode backend preference.
123    #[must_use = "builder methods return the updated Export"]
124    pub fn encode_backend(mut self, backend: EncodeBackendPreference) -> Self {
125        self.options.encode_backend = backend;
126        self
127    }
128
129    /// Set the runtime codec validation policy.
130    #[must_use = "builder methods return the updated Export"]
131    pub fn codec_validation(mut self, validation: CodecValidation) -> Self {
132        self.options.codec_validation = validation;
133        self
134    }
135
136    /// Allow or disable device-backed source tile decode.
137    #[must_use = "builder methods return the updated Export"]
138    pub fn source_device_decode(mut self, source_device_decode: bool) -> Self {
139        self.options.source_device_decode = source_device_decode;
140        self
141    }
142
143    /// Override JPEG 2000 decomposition levels.
144    #[must_use = "builder methods return the updated Export"]
145    pub fn j2k_decomposition_levels(mut self, levels: Option<u8>) -> Self {
146        self.options.j2k_decomposition_levels = levels;
147        self
148    }
149
150    /// Set the optional GPU encode in-flight tile cap.
151    #[must_use = "builder methods return the updated Export"]
152    pub fn gpu_encode_inflight_tiles(mut self, tiles: Option<usize>) -> Self {
153        self.options.gpu_encode_inflight_tiles = tiles;
154        self
155    }
156
157    /// Set the optional GPU encode memory budget in MiB.
158    #[must_use = "builder methods return the updated Export"]
159    pub fn gpu_encode_memory_mib(mut self, memory_mib: Option<u64>) -> Self {
160        self.options.gpu_encode_memory_mib = memory_mib;
161        self
162    }
163
164    /// Set the optional GPU pipeline depth.
165    #[must_use = "builder methods return the updated Export"]
166    pub fn gpu_pipeline_depth(mut self, depth: Option<usize>) -> Self {
167        self.options.gpu_pipeline_depth = depth;
168        self
169    }
170
171    /// Set the optional maximum rows per GPU row batch.
172    #[must_use = "builder methods return the updated Export"]
173    pub fn gpu_row_batch_rows(mut self, rows: Option<usize>) -> Self {
174        self.options.gpu_row_batch_rows = rows;
175        self
176    }
177
178    /// Set the optional target tile count per GPU row batch.
179    #[must_use = "builder methods return the updated Export"]
180    pub fn gpu_row_batch_target_tiles(mut self, tiles: Option<usize>) -> Self {
181        self.options.gpu_row_batch_target_tiles = tiles;
182        self
183    }
184
185    /// Resolve the transfer syntax from the source at run time.
186    #[must_use = "builder methods return the updated Export"]
187    pub fn source_aware_transfer_syntax(mut self) -> Self {
188        self.transfer_syntax = TransferSyntaxSelection::SourceAware;
189        self
190    }
191
192    /// Convert the builder into an export request, resolving source-aware defaults.
193    pub fn build_request(mut self) -> Result<ExportRequest, Error> {
194        let output_dir = self
195            .output_dir
196            .take()
197            .ok_or_else(|| Error::InvalidOptions {
198                reason: "output directory must be configured with to_directory".into(),
199            })?;
200        if self.transfer_syntax == TransferSyntaxSelection::SourceAware {
201            self.options.transfer_syntax =
202                default_transfer_syntax_for_source(DefaultTransferSyntaxRequest {
203                    source_path: self.source_path.clone(),
204                    tile_size: self.options.tile_size,
205                    level_filter: self.level_filter,
206                    max_levels: None,
207                })?;
208            self.options.jpeg_direct_htj2k_profile =
209                JpegDirectHtj2kProfile::default_for_transfer_syntax(self.options.transfer_syntax);
210        }
211        let metadata = self.metadata.take().ok_or_else(|| Error::Metadata {
212            reason: "export metadata must be provided with with_metadata or with_research_placeholder_metadata".into(),
213        })?;
214        self.options.validate()?;
215        Ok(ExportRequest {
216            source_path: self.source_path,
217            output_dir,
218            options: self.options,
219            metadata,
220            level_filter: self.level_filter,
221        })
222    }
223
224    /// Run the export and return a report.
225    pub fn run(self) -> Result<ExportReport, Error> {
226        export_dicom(self.build_request()?)
227    }
228}
229
230#[cfg(test)]
231mod tests {
232    use crate::{
233        CodecValidation, EncodeBackendPreference, Export, IccProfilePolicy, JpegDirectHtj2kProfile,
234        MetadataSource, TransferSyntax,
235    };
236
237    #[test]
238    fn htj2k_transfer_syntax_defaults_to_97_profile() {
239        let request = Export::from_slide("source.ndpi")
240            .to_directory("dicom-out")
241            .with_research_placeholder_metadata()
242            .transfer_syntax(TransferSyntax::Htj2k)
243            .build_request()
244            .unwrap();
245
246        assert_eq!(request.options.transfer_syntax, TransferSyntax::Htj2k);
247        assert_eq!(
248            request.options.jpeg_direct_htj2k_profile,
249            JpegDirectHtj2kProfile::Lossy97
250        );
251    }
252
253    #[test]
254    fn builder_option_setters_flow_into_request() {
255        let request = Export::from_slide("source.ndpi")
256            .to_directory("dicom-out")
257            .with_metadata(MetadataSource::ResearchPlaceholder)
258            .transfer_syntax(TransferSyntax::Htj2kLossless)
259            .tile_size(256)
260            .jpeg_quality(80)
261            .icc_profile_policy(IccProfilePolicy::OmitIfMissing)
262            .encode_backend(EncodeBackendPreference::CpuOnly)
263            .codec_validation(CodecValidation::RoundTrip)
264            .source_device_decode(true)
265            .j2k_decomposition_levels(Some(3))
266            .gpu_encode_inflight_tiles(Some(8))
267            .gpu_encode_memory_mib(Some(4096))
268            .gpu_pipeline_depth(Some(3))
269            .gpu_row_batch_rows(Some(6))
270            .gpu_row_batch_target_tiles(Some(96))
271            .build_request()
272            .unwrap();
273
274        assert_eq!(request.options.tile_size, 256);
275        assert_eq!(request.options.jpeg_quality, 80);
276        assert_eq!(
277            request.options.icc_profile_policy,
278            IccProfilePolicy::OmitIfMissing
279        );
280        assert_eq!(
281            request.options.encode_backend,
282            EncodeBackendPreference::CpuOnly
283        );
284        assert_eq!(request.options.codec_validation, CodecValidation::RoundTrip);
285        assert!(request.options.source_device_decode);
286        assert_eq!(request.options.j2k_decomposition_levels, Some(3));
287        assert_eq!(request.options.gpu_encode_inflight_tiles, Some(8));
288        assert_eq!(request.options.gpu_encode_memory_mib, Some(4096));
289        assert_eq!(request.options.gpu_pipeline_depth, Some(3));
290        assert_eq!(request.options.gpu_row_batch_rows, Some(6));
291        assert_eq!(request.options.gpu_row_batch_target_tiles, Some(96));
292    }
293
294    #[test]
295    fn builder_requires_explicit_metadata() {
296        let err = Export::from_slide("source.ndpi")
297            .to_directory("dicom-out")
298            .transfer_syntax(TransferSyntax::Htj2kLossless)
299            .build_request()
300            .expect_err("metadata policy must be explicit");
301
302        assert!(err.to_string().contains("metadata"));
303    }
304}