1use clap::ValueEnum;
2use serde::{Deserialize, Serialize};
3
4use crate::Error;
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ValueEnum)]
8#[non_exhaustive]
9pub enum EncodeBackendPreference {
10 Auto,
12 #[value(name = "cpu")]
14 CpuOnly,
15 PreferDevice,
17 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ValueEnum)]
46#[non_exhaustive]
47pub enum CodecValidation {
48 Disabled,
50 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ValueEnum)]
66#[non_exhaustive]
67pub enum IccProfilePolicy {
68 Strict,
70 FallbackSrgb,
73 FallbackDisplayP3,
76 OmitIfMissing,
79}
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ValueEnum)]
83#[serde(rename_all = "kebab-case")]
84#[non_exhaustive]
85pub enum UidPolicy {
86 Fresh,
88 Deterministic,
90}
91
92#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, ValueEnum)]
94#[serde(rename_all = "kebab-case")]
95#[non_exhaustive]
96pub enum TransferSyntax {
97 JpegBaseline8Bit = 0,
99 Jpeg2000 = 1,
101 Jpeg2000Lossless = 2,
103 Htj2k = 6,
105 Htj2kLossless = 3,
107 Htj2kLosslessRpcl = 4,
109 #[value(skip)]
111 ExplicitVrLittleEndian = 5,
112}
113
114#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, ValueEnum)]
116#[serde(rename_all = "kebab-case")]
117#[non_exhaustive]
118pub enum ExportPreset {
119 LosslessReview,
121 FastJpeg,
123}
124
125impl ExportPreset {
126 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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, ValueEnum)]
189#[serde(rename_all = "kebab-case")]
190#[non_exhaustive]
191pub enum JpegDirectHtj2kProfile {
192 #[value(name = "53")]
194 Lossless53,
195 #[value(name = "97", alias = "lossy97")]
197 Lossy97,
198 #[value(name = "lossy97-near", alias = "97-near")]
200 Lossy97Near,
201 #[value(name = "lossy97-balanced", alias = "97-balanced")]
203 Lossy97Balanced,
204 #[value(name = "lossy97-aggressive", alias = "97-aggressive")]
206 Lossy97Aggressive,
207 #[value(name = "lossy97-preview", alias = "97-preview")]
209 Lossy97Preview,
210 #[value(name = "lossy97-thumbnail", alias = "97-thumbnail")]
212 Lossy97Thumbnail,
213}
214
215impl JpegDirectHtj2kProfile {
216 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 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 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
252#[serde(default)]
253#[non_exhaustive]
254pub struct ExportOptions {
255 pub tile_size: u32,
257 pub overwrite: bool,
259 pub max_prepared_frame_bytes: u64,
261 pub transfer_syntax: TransferSyntax,
263 pub jpeg_direct_htj2k_profile: JpegDirectHtj2kProfile,
265 pub jpeg_quality: u8,
267 pub icc_profile_policy: IccProfilePolicy,
269 pub uid_policy: UidPolicy,
271 pub encode_backend: EncodeBackendPreference,
273 pub codec_validation: CodecValidation,
275 pub source_device_decode: bool,
277 pub j2k_decomposition_levels: Option<u8>,
279 pub gpu_encode_inflight_tiles: Option<usize>,
281 pub gpu_encode_memory_mib: Option<u64>,
283 pub gpu_pipeline_depth: Option<usize>,
285 pub gpu_row_batch_rows: Option<usize>,
287 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 pub fn lossless_review() -> Self {
318 Self::default()
319 }
320
321 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 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}