Skip to main content

wsi_dicom/
options.rs

1use clap::ValueEnum;
2use serde::{Deserialize, Serialize};
3
4use crate::Error;
5
6/// Runtime preference for JPEG 2000 Lossless encode backends.
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ValueEnum)]
8#[non_exhaustive]
9pub enum EncodeBackendPreference {
10    /// Let the crate choose the safest measured backend for the request.
11    Auto,
12    /// Always use CPU encoding.
13    #[value(name = "cpu")]
14    CpuOnly,
15    /// Prefer a device backend, but fall back to CPU when unavailable.
16    PreferDevice,
17    /// Require a device backend and fail when it cannot be used.
18    RequireDevice,
19}
20
21impl EncodeBackendPreference {
22    pub(crate) fn requires_device(self) -> bool {
23        matches!(self, Self::RequireDevice)
24    }
25
26    pub(crate) fn cpu_batch_safe(self) -> bool {
27        match self {
28            Self::CpuOnly => true,
29            Self::Auto => !cfg!(any(feature = "metal", feature = "cuda")),
30            Self::PreferDevice | Self::RequireDevice => false,
31        }
32    }
33
34    pub(crate) fn to_j2k(self) -> j2k::EncodeBackendPreference {
35        match self {
36            Self::Auto => j2k::EncodeBackendPreference::Auto,
37            Self::CpuOnly => j2k::EncodeBackendPreference::CpuOnly,
38            Self::PreferDevice => j2k::EncodeBackendPreference::Auto,
39            Self::RequireDevice => j2k::EncodeBackendPreference::RequireDevice,
40        }
41    }
42}
43
44/// Runtime validation policy for newly encoded compressed frame bytes.
45#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ValueEnum)]
46#[non_exhaustive]
47pub enum CodecValidation {
48    /// Do not run an encode-time validation decode.
49    Disabled,
50    /// Decode encoded frames during export to catch codec regressions.
51    RoundTrip,
52}
53
54impl CodecValidation {
55    pub(crate) fn to_j2k_validation(self) -> j2k::J2kEncodeValidation {
56        match self {
57            Self::Disabled => j2k::J2kEncodeValidation::External,
58            Self::RoundTrip => j2k::J2kEncodeValidation::CpuRoundTrip,
59        }
60    }
61}
62
63/// Policy for DICOM Optical Path ICC profile handling when source color
64/// metadata is unavailable.
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ValueEnum)]
66#[non_exhaustive]
67pub enum IccProfilePolicy {
68    /// Require a real source or embedded JPEG ICC profile.
69    Strict,
70    /// Preserve source ICC when available; otherwise embed a synthesized sRGB
71    /// ICC profile and report it as an assumption.
72    FallbackSrgb,
73    /// Preserve source ICC when available; otherwise embed a synthesized
74    /// Display P3 ICC profile and report it as an assumption.
75    FallbackDisplayP3,
76    /// Preserve source ICC when available; otherwise omit the ICC Profile
77    /// attribute.
78    OmitIfMissing,
79}
80
81/// Policy for identifiers generated when the caller does not supply them.
82#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ValueEnum)]
83#[serde(rename_all = "kebab-case")]
84#[non_exhaustive]
85pub enum UidPolicy {
86    /// Generate a fresh identity namespace for every export invocation.
87    Fresh,
88    /// Derive repeatable identifiers from source content, metadata, and export options.
89    Deterministic,
90}
91
92/// DICOM transfer syntax choices for exported VL Whole Slide Microscopy files.
93#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, ValueEnum)]
94#[serde(rename_all = "kebab-case")]
95#[non_exhaustive]
96pub enum TransferSyntax {
97    /// JPEG Baseline 8-bit transfer syntax.
98    JpegBaseline8Bit = 0,
99    /// JPEG 2000 Image Compression transfer syntax.
100    Jpeg2000 = 1,
101    /// JPEG 2000 Image Compression Lossless Only transfer syntax.
102    Jpeg2000Lossless = 2,
103    /// High-Throughput JPEG 2000 Image Compression transfer syntax.
104    Htj2k = 6,
105    /// High-Throughput JPEG 2000 Image Compression Lossless Only transfer syntax.
106    Htj2kLossless = 3,
107    /// High-Throughput JPEG 2000 with RPCL Options Image Compression Lossless Only transfer syntax.
108    Htj2kLosslessRpcl = 4,
109    /// Explicit VR Little Endian transfer syntax for uncompressed input fixtures.
110    #[value(skip)]
111    ExplicitVrLittleEndian = 5,
112}
113
114/// User-facing export presets for common conversion goals.
115#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, ValueEnum)]
116#[serde(rename_all = "kebab-case")]
117#[non_exhaustive]
118pub enum ExportPreset {
119    /// Reviewer-focused output that preserves the default lossless HTJ2K target.
120    LosslessReview,
121    /// Fast JPEG Baseline output that preserves compatible native JPEG geometry.
122    FastJpeg,
123}
124
125impl ExportPreset {
126    /// Return export options for this preset with caller-supplied geometry and quality knobs.
127    pub fn options(self, tile_size: u32, jpeg_quality: u8) -> ExportOptions {
128        match self {
129            Self::LosslessReview => ExportOptions {
130                tile_size,
131                jpeg_quality,
132                ..ExportOptions::lossless_review()
133            },
134            Self::FastJpeg => ExportOptions::fast_jpeg(tile_size, jpeg_quality),
135        }
136    }
137}
138
139impl TransferSyntax {
140    /// All transfer syntax variants supported by this crate.
141    pub const ALL: [Self; 7] = [
142        Self::JpegBaseline8Bit,
143        Self::Jpeg2000,
144        Self::Jpeg2000Lossless,
145        Self::Htj2k,
146        Self::Htj2kLossless,
147        Self::Htj2kLosslessRpcl,
148        Self::ExplicitVrLittleEndian,
149    ];
150
151    /// Return the DICOM transfer syntax UID.
152    pub fn uid(self) -> &'static str {
153        match self {
154            Self::JpegBaseline8Bit => "1.2.840.10008.1.2.4.50",
155            Self::Jpeg2000 => "1.2.840.10008.1.2.4.91",
156            Self::Jpeg2000Lossless => "1.2.840.10008.1.2.4.90",
157            Self::Htj2k => "1.2.840.10008.1.2.4.203",
158            Self::Htj2kLossless => "1.2.840.10008.1.2.4.201",
159            Self::Htj2kLosslessRpcl => "1.2.840.10008.1.2.4.202",
160            Self::ExplicitVrLittleEndian => "1.2.840.10008.1.2.1",
161        }
162    }
163
164    pub(crate) fn is_j2k_family(self) -> bool {
165        matches!(
166            self,
167            Self::Jpeg2000
168                | Self::Jpeg2000Lossless
169                | Self::Htj2k
170                | Self::Htj2kLossless
171                | Self::Htj2kLosslessRpcl
172        )
173    }
174
175    pub(crate) fn is_lossless_j2k_family(self) -> bool {
176        matches!(
177            self,
178            Self::Jpeg2000Lossless | Self::Htj2kLossless | Self::Htj2kLosslessRpcl
179        )
180    }
181
182    pub(crate) fn is_jpeg2000_passthrough_only(self) -> bool {
183        self == Self::Jpeg2000
184    }
185}
186
187/// Direct JPEG-to-HTJ2K coefficient path used for HTJ2K export from JPEG tiles.
188#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, ValueEnum)]
189#[serde(rename_all = "kebab-case")]
190#[non_exhaustive]
191pub enum JpegDirectHtj2kProfile {
192    /// Reversible 5/3 transform for HTJ2K lossless transfer syntaxes.
193    #[value(name = "53")]
194    Lossless53,
195    /// Backwards-compatible alias for the balanced irreversible 9/7 profile.
196    #[value(name = "97", alias = "lossy97")]
197    Lossy97,
198    /// Near-lossless irreversible 9/7 profile, quantization scale 2.
199    #[value(name = "lossy97-near", alias = "97-near")]
200    Lossy97Near,
201    /// Balanced irreversible 9/7 profile, quantization scale 5.
202    #[value(name = "lossy97-balanced", alias = "97-balanced")]
203    Lossy97Balanced,
204    /// Aggressive irreversible 9/7 profile, quantization scale 10.
205    #[value(name = "lossy97-aggressive", alias = "97-aggressive")]
206    Lossy97Aggressive,
207    /// Preview-oriented irreversible 9/7 profile, quantization scale 20.
208    #[value(name = "lossy97-preview", alias = "97-preview")]
209    Lossy97Preview,
210    /// Thumbnail-oriented irreversible 9/7 profile, quantization scale 50.
211    #[value(name = "lossy97-thumbnail", alias = "97-thumbnail")]
212    Lossy97Thumbnail,
213}
214
215impl JpegDirectHtj2kProfile {
216    /// Return the profile normally paired with the requested transfer syntax.
217    pub fn default_for_transfer_syntax(transfer_syntax: TransferSyntax) -> Self {
218        match transfer_syntax {
219            TransferSyntax::Htj2k => Self::Lossy97,
220            _ => Self::Lossless53,
221        }
222    }
223
224    /// Whether this profile uses the irreversible 9/7 transform.
225    pub const fn is_lossy_97(self) -> bool {
226        matches!(
227            self,
228            Self::Lossy97
229                | Self::Lossy97Near
230                | Self::Lossy97Balanced
231                | Self::Lossy97Aggressive
232                | Self::Lossy97Preview
233                | Self::Lossy97Thumbnail
234        )
235    }
236
237    /// Quantization scale used by irreversible 9/7 profiles.
238    pub const fn irreversible_quantization_scale(self) -> Option<f32> {
239        match self {
240            Self::Lossless53 => None,
241            Self::Lossy97Near => Some(2.0),
242            Self::Lossy97 | Self::Lossy97Balanced => Some(5.0),
243            Self::Lossy97Aggressive => Some(10.0),
244            Self::Lossy97Preview => Some(20.0),
245            Self::Lossy97Thumbnail => Some(50.0),
246        }
247    }
248}
249
250/// Options controlling how a source WSI should be converted into DICOM.
251#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
252#[serde(default)]
253#[non_exhaustive]
254pub struct ExportOptions {
255    /// Target DICOM tile size in pixels for generated frames.
256    pub tile_size: u32,
257    /// Whether generated DICOM files may replace existing files.
258    pub overwrite: bool,
259    /// Maximum prepared uncompressed frame buffer size in bytes.
260    pub max_prepared_frame_bytes: u64,
261    /// Requested output transfer syntax.
262    pub transfer_syntax: TransferSyntax,
263    /// Direct JPEG-to-HTJ2K profile used when eligible.
264    pub jpeg_direct_htj2k_profile: JpegDirectHtj2kProfile,
265    /// JPEG quality used for JPEG Baseline fallback encoding.
266    pub jpeg_quality: u8,
267    /// ICC profile policy for missing source color metadata.
268    pub icc_profile_policy: IccProfilePolicy,
269    /// Policy for generated Study, Series, SOP, and related DICOM UIDs.
270    pub uid_policy: UidPolicy,
271    /// Runtime encoder backend preference.
272    pub encode_backend: EncodeBackendPreference,
273    /// Runtime codec validation policy.
274    pub codec_validation: CodecValidation,
275    /// Whether source tile decode may use a device backend when available.
276    pub source_device_decode: bool,
277    /// Optional maximum JPEG 2000 decomposition level override.
278    pub j2k_decomposition_levels: Option<u8>,
279    /// Optional cap on concurrently in-flight GPU encode tiles.
280    pub gpu_encode_inflight_tiles: Option<usize>,
281    /// Optional GPU encode memory budget in MiB.
282    pub gpu_encode_memory_mib: Option<u64>,
283    /// Optional GPU pipeline depth.
284    pub gpu_pipeline_depth: Option<usize>,
285    /// Optional maximum rows per GPU row batch.
286    pub gpu_row_batch_rows: Option<usize>,
287    /// Optional target tile count per GPU row batch.
288    pub gpu_row_batch_target_tiles: Option<usize>,
289}
290
291impl Default for ExportOptions {
292    fn default() -> Self {
293        Self {
294            tile_size: 512,
295            overwrite: false,
296            max_prepared_frame_bytes: 256 * 1024 * 1024,
297            transfer_syntax: TransferSyntax::Htj2kLosslessRpcl,
298            jpeg_direct_htj2k_profile: JpegDirectHtj2kProfile::Lossless53,
299            jpeg_quality: 90,
300            icc_profile_policy: IccProfilePolicy::FallbackSrgb,
301            uid_policy: UidPolicy::Fresh,
302            encode_backend: EncodeBackendPreference::Auto,
303            codec_validation: CodecValidation::Disabled,
304            source_device_decode: false,
305            j2k_decomposition_levels: None,
306            gpu_encode_inflight_tiles: None,
307            gpu_encode_memory_mib: None,
308            gpu_pipeline_depth: None,
309            gpu_row_batch_rows: None,
310            gpu_row_batch_target_tiles: None,
311        }
312    }
313}
314
315impl ExportOptions {
316    /// Return reviewer-focused lossless export options.
317    pub fn lossless_review() -> Self {
318        Self::default()
319    }
320
321    /// Return fast JPEG Baseline export options for speed-oriented comparisons.
322    pub fn fast_jpeg(tile_size: u32, jpeg_quality: u8) -> Self {
323        Self {
324            tile_size,
325            transfer_syntax: TransferSyntax::JpegBaseline8Bit,
326            jpeg_quality,
327            ..Self::default()
328        }
329    }
330}
331
332impl ExportOptions {
333    /// Validate option combinations before running an export.
334    pub fn validate(&self) -> Result<(), Error> {
335        if self.tile_size == 0 {
336            return Err(Error::InvalidOptions {
337                reason: "tile_size must be greater than zero".into(),
338            });
339        }
340        if self.max_prepared_frame_bytes == 0 {
341            return Err(Error::InvalidOptions {
342                reason: "max_prepared_frame_bytes must be greater than zero".into(),
343            });
344        }
345        if !(1..=100).contains(&self.jpeg_quality) {
346            return Err(Error::InvalidOptions {
347                reason: "jpeg_quality must be in the range 1..=100".into(),
348            });
349        }
350        let profile = self.jpeg_direct_htj2k_profile;
351        if self.transfer_syntax == TransferSyntax::Htj2k {
352            if profile == JpegDirectHtj2kProfile::Lossless53 {
353                return Err(Error::InvalidOptions {
354                    reason: "HTJ2K transfer syntax 1.2.840.10008.1.2.4.203 requires an irreversible 9/7 jpeg_direct_htj2k_profile; use an HTJ2K Lossless transfer syntax for 5/3".into(),
355                });
356            }
357        } else if profile.is_lossy_97() {
358            return Err(Error::InvalidOptions {
359                reason: format!(
360                    "jpeg_direct_htj2k_profile={profile:?} requires transfer_syntax=Htj2k"
361                ),
362            });
363        }
364        if self.gpu_encode_inflight_tiles == Some(0) {
365            return Err(Error::InvalidOptions {
366                reason: "gpu_encode_inflight_tiles must be greater than zero when provided".into(),
367            });
368        }
369        if self.gpu_encode_memory_mib == Some(0) {
370            return Err(Error::InvalidOptions {
371                reason: "gpu_encode_memory_mib must be greater than zero when provided".into(),
372            });
373        }
374        if self.gpu_pipeline_depth == Some(0) {
375            return Err(Error::InvalidOptions {
376                reason: "gpu_pipeline_depth must be greater than zero when provided".into(),
377            });
378        }
379        if self.gpu_row_batch_rows == Some(0) {
380            return Err(Error::InvalidOptions {
381                reason: "gpu_row_batch_rows must be greater than zero when provided".into(),
382            });
383        }
384        if self.gpu_row_batch_target_tiles == Some(0) {
385            return Err(Error::InvalidOptions {
386                reason: "gpu_row_batch_target_tiles must be greater than zero when provided".into(),
387            });
388        }
389        if let Some(memory_mib) = self.gpu_encode_memory_mib {
390            let _ = usize::try_from(memory_mib)
391                .ok()
392                .and_then(|mib| mib.checked_mul(1024 * 1024))
393                .ok_or_else(|| Error::InvalidOptions {
394                    reason: "gpu_encode_memory_mib exceeds platform addressable memory".into(),
395                })?;
396        }
397        Ok(())
398    }
399}
400
401#[cfg(test)]
402mod tests {
403    use super::*;
404
405    #[test]
406    fn encode_backend_requires_device_only_for_strict_device_preference() {
407        assert!(!EncodeBackendPreference::Auto.requires_device());
408        assert!(!EncodeBackendPreference::CpuOnly.requires_device());
409        assert!(!EncodeBackendPreference::PreferDevice.requires_device());
410        assert!(EncodeBackendPreference::RequireDevice.requires_device());
411    }
412
413    #[test]
414    fn encode_backend_cpu_batch_safety_matches_backend_features() {
415        assert!(EncodeBackendPreference::CpuOnly.cpu_batch_safe());
416        assert_eq!(
417            EncodeBackendPreference::Auto.cpu_batch_safe(),
418            !cfg!(any(feature = "metal", feature = "cuda"))
419        );
420        assert!(!EncodeBackendPreference::PreferDevice.cpu_batch_safe());
421        assert!(!EncodeBackendPreference::RequireDevice.cpu_batch_safe());
422    }
423
424    #[test]
425    fn jpeg_direct_htj2k_profiles_expose_97_quality_scales() {
426        assert_eq!(
427            JpegDirectHtj2kProfile::Lossless53.irreversible_quantization_scale(),
428            None
429        );
430        assert_eq!(
431            JpegDirectHtj2kProfile::Lossy97Near.irreversible_quantization_scale(),
432            Some(2.0)
433        );
434        assert_eq!(
435            JpegDirectHtj2kProfile::Lossy97.irreversible_quantization_scale(),
436            Some(5.0)
437        );
438        assert_eq!(
439            JpegDirectHtj2kProfile::Lossy97Balanced.irreversible_quantization_scale(),
440            Some(5.0)
441        );
442        assert_eq!(
443            JpegDirectHtj2kProfile::Lossy97Aggressive.irreversible_quantization_scale(),
444            Some(10.0)
445        );
446        assert_eq!(
447            JpegDirectHtj2kProfile::Lossy97Preview.irreversible_quantization_scale(),
448            Some(20.0)
449        );
450        assert_eq!(
451            JpegDirectHtj2kProfile::Lossy97Thumbnail.irreversible_quantization_scale(),
452            Some(50.0)
453        );
454    }
455
456    #[test]
457    fn validation_accepts_all_97_profiles_only_with_general_htj2k() {
458        for profile in [
459            JpegDirectHtj2kProfile::Lossy97Near,
460            JpegDirectHtj2kProfile::Lossy97,
461            JpegDirectHtj2kProfile::Lossy97Balanced,
462            JpegDirectHtj2kProfile::Lossy97Aggressive,
463            JpegDirectHtj2kProfile::Lossy97Preview,
464            JpegDirectHtj2kProfile::Lossy97Thumbnail,
465        ] {
466            ExportOptions {
467                transfer_syntax: TransferSyntax::Htj2k,
468                jpeg_direct_htj2k_profile: profile,
469                ..ExportOptions::default()
470            }
471            .validate()
472            .unwrap();
473
474            assert!(ExportOptions {
475                transfer_syntax: TransferSyntax::Htj2kLosslessRpcl,
476                jpeg_direct_htj2k_profile: profile,
477                ..ExportOptions::default()
478            }
479            .validate()
480            .is_err());
481        }
482
483        assert!(ExportOptions {
484            transfer_syntax: TransferSyntax::Htj2k,
485            jpeg_direct_htj2k_profile: JpegDirectHtj2kProfile::Lossless53,
486            ..ExportOptions::default()
487        }
488        .validate()
489        .is_err());
490    }
491
492    #[test]
493    fn export_options_round_trip_through_json_and_validate() {
494        let options = ExportOptions {
495            transfer_syntax: TransferSyntax::Htj2k,
496            jpeg_direct_htj2k_profile: JpegDirectHtj2kProfile::Lossy97Balanced,
497            ..ExportOptions::default()
498        };
499
500        let json = serde_json::to_string(&options).expect("serialize options");
501        assert!(json.contains("htj2k"));
502        assert!(json.contains("lossy97-balanced"));
503
504        let decoded: ExportOptions = serde_json::from_str(&json).expect("deserialize options");
505        decoded.validate().expect("valid export options");
506
507        assert_eq!(decoded.transfer_syntax, TransferSyntax::Htj2k);
508        assert_eq!(
509            decoded.jpeg_direct_htj2k_profile,
510            JpegDirectHtj2kProfile::Lossy97Balanced
511        );
512    }
513
514    #[test]
515    fn export_options_validation_rejects_invalid_options() {
516        let options = ExportOptions {
517            jpeg_quality: 0,
518            ..ExportOptions::default()
519        };
520
521        let err = options.validate().expect_err("invalid quality");
522        assert!(err.to_string().contains("jpeg_quality"));
523    }
524
525    #[test]
526    fn export_options_preserve_every_field_through_json() {
527        let options = ExportOptions {
528            tile_size: 384,
529            overwrite: true,
530            max_prepared_frame_bytes: 128 * 1024 * 1024,
531            transfer_syntax: TransferSyntax::Htj2k,
532            jpeg_direct_htj2k_profile: JpegDirectHtj2kProfile::Lossy97Aggressive,
533            jpeg_quality: 77,
534            icc_profile_policy: IccProfilePolicy::OmitIfMissing,
535            uid_policy: UidPolicy::Deterministic,
536            encode_backend: EncodeBackendPreference::PreferDevice,
537            codec_validation: CodecValidation::RoundTrip,
538            source_device_decode: true,
539            j2k_decomposition_levels: Some(4),
540            gpu_encode_inflight_tiles: Some(8),
541            gpu_encode_memory_mib: Some(4096),
542            gpu_pipeline_depth: Some(3),
543            gpu_row_batch_rows: Some(6),
544            gpu_row_batch_target_tiles: Some(96),
545        };
546
547        let json = serde_json::to_string(&options).expect("serialize options");
548        let round_tripped: ExportOptions =
549            serde_json::from_str(&json).expect("deserialize options");
550        round_tripped
551            .validate()
552            .expect("valid options should validate");
553
554        assert_eq!(round_tripped, options);
555    }
556
557    #[test]
558    fn export_options_deserialize_missing_fields_from_defaults() {
559        let options: ExportOptions =
560            serde_json::from_str(r#"{"transfer_syntax":"jpeg-baseline8-bit"}"#)
561                .expect("partial persisted options should use defaults");
562
563        assert_eq!(options.transfer_syntax, TransferSyntax::JpegBaseline8Bit);
564        assert_eq!(options.tile_size, ExportOptions::default().tile_size);
565        assert_eq!(options.jpeg_quality, ExportOptions::default().jpeg_quality);
566        assert_eq!(
567            options.jpeg_direct_htj2k_profile,
568            ExportOptions::default().jpeg_direct_htj2k_profile
569        );
570    }
571
572    #[test]
573    fn export_option_presets_select_expected_transfer_syntaxes() {
574        let lossless = ExportOptions::lossless_review();
575        assert_eq!(lossless.transfer_syntax, TransferSyntax::Htj2kLosslessRpcl);
576        assert_eq!(lossless.tile_size, 512);
577        assert_eq!(lossless.jpeg_quality, 90);
578        let preset_lossless = ExportPreset::LosslessReview.options(384, 85);
579        assert_eq!(
580            preset_lossless.transfer_syntax,
581            TransferSyntax::Htj2kLosslessRpcl
582        );
583        assert_eq!(preset_lossless.tile_size, 384);
584        assert_eq!(preset_lossless.jpeg_quality, 85);
585
586        let fast = ExportOptions::fast_jpeg(256, 80);
587        assert_eq!(fast.transfer_syntax, TransferSyntax::JpegBaseline8Bit);
588        assert_eq!(fast.tile_size, 256);
589        assert_eq!(fast.jpeg_quality, 80);
590        assert_eq!(
591            ExportPreset::FastJpeg.options(256, 80),
592            ExportOptions::fast_jpeg(256, 80)
593        );
594    }
595
596    #[test]
597    fn transfer_syntax_all_contains_each_uid_once() {
598        let mut uids = std::collections::BTreeSet::new();
599        for transfer_syntax in TransferSyntax::ALL {
600            assert!(
601                uids.insert(transfer_syntax.uid()),
602                "duplicate transfer syntax UID {}",
603                transfer_syntax.uid()
604            );
605        }
606        assert_eq!(uids.len(), 7);
607    }
608
609    #[test]
610    fn dicom_export_preset_serializes_as_kebab_case() {
611        assert_eq!(
612            serde_json::to_string(&ExportPreset::LosslessReview).unwrap(),
613            "\"lossless-review\""
614        );
615        assert_eq!(
616            serde_json::to_string(&ExportPreset::FastJpeg).unwrap(),
617            "\"fast-jpeg\""
618        );
619    }
620}