jpegli/encode/
encoder_types.rs

1//! Core types for the v2 encoder API.
2
3/// Quality/compression setting.
4///
5/// All variants map to internal quality through empirical lookup tables.
6/// Results vary by image - these are rough approximations, not guarantees.
7#[derive(Clone, Copy, Debug, PartialEq)]
8#[non_exhaustive]
9pub enum Quality {
10    /// Approximate jpegli quality scale (this is a fork, not exact jpegli).
11    /// Range: 0.0–100.0, where ~90 is visually lossless for most images.
12    ApproxJpegli(f32),
13
14    /// Approximate mozjpeg quality behavior.
15    /// Range: 0–100. Maps to quality producing similar file sizes.
16    ApproxMozjpeg(u8),
17
18    /// Approximate SSIMULACRA2 score target.
19    /// Range: 0–100 (higher = better). 90+ is roughly visually lossless.
20    ApproxSsim2(f32),
21
22    /// Approximate Butteraugli distance target.
23    /// Range: 0.0+ (lower = better). <1.0 excellent, <3.0 good.
24    ApproxButteraugli(f32),
25}
26
27impl Default for Quality {
28    fn default() -> Self {
29        Quality::ApproxJpegli(90.0)
30    }
31}
32
33impl From<f32> for Quality {
34    fn from(q: f32) -> Self {
35        Quality::ApproxJpegli(q)
36    }
37}
38
39impl From<u8> for Quality {
40    fn from(q: u8) -> Self {
41        Quality::ApproxJpegli(q as f32)
42    }
43}
44
45impl From<i32> for Quality {
46    fn from(q: i32) -> Self {
47        Quality::ApproxJpegli(q as f32)
48    }
49}
50
51impl Quality {
52    /// Convert to internal quality value (0.0-100.0 scale).
53    #[must_use]
54    pub fn to_internal(&self) -> f32 {
55        match self {
56            Quality::ApproxJpegli(q) => *q,
57            Quality::ApproxMozjpeg(q) => mozjpeg_to_internal(*q),
58            Quality::ApproxSsim2(score) => ssim2_to_internal(*score),
59            Quality::ApproxButteraugli(dist) => butteraugli_to_internal(*dist),
60        }
61    }
62
63    /// Convert to butteraugli distance.
64    ///
65    /// Uses the exact same formula as C++ jpegli's `jpegli_quality_to_distance`.
66    #[must_use]
67    pub fn to_distance(&self) -> f32 {
68        // If already butteraugli distance, return it directly
69        if let Quality::ApproxButteraugli(d) = self {
70            return *d;
71        }
72        // Exact C++ jpegli formula from lib/jpegli/encode.cc:jpegli_quality_to_distance
73        let q = self.to_internal();
74        if q >= 100.0 {
75            0.01
76        } else if q >= 30.0 {
77            0.1 + (100.0 - q) * 0.09
78        } else {
79            // Quadratic for very low quality
80            53.0 / 3000.0 * q * q - 23.0 / 20.0 * q + 25.0
81        }
82    }
83}
84
85// Calibrated mozjpeg→jpegli quality mapping (4:4:4, DSSIM metric)
86// From corpus testing on CID22-512 and Kodak datasets
87const MOZJPEG_TO_JPEGLI: [(u8, u8); 10] = [
88    (30, 28),
89    (40, 37),
90    (50, 47),
91    (60, 55),
92    (70, 65),
93    (75, 71),
94    (80, 77),
95    (85, 83),
96    (90, 89),
97    (95, 94),
98];
99
100fn mozjpeg_to_internal(q: u8) -> f32 {
101    if q >= 100 {
102        return 100.0;
103    }
104    if q <= 30 {
105        // Extrapolate below table range
106        return (q as f32 / 30.0) * 28.0;
107    }
108
109    // Find bracketing entries and interpolate
110    let mut lower = (30u8, 28u8);
111    let mut upper = (95u8, 94u8);
112
113    for &(moz_q, jpegli_q) in &MOZJPEG_TO_JPEGLI {
114        if moz_q <= q && moz_q > lower.0 {
115            lower = (moz_q, jpegli_q);
116        }
117        if moz_q >= q && moz_q < upper.0 {
118            upper = (moz_q, jpegli_q);
119        }
120    }
121
122    if lower.0 == upper.0 {
123        return lower.1 as f32;
124    }
125
126    // Linear interpolation
127    let t = (q - lower.0) as f32 / (upper.0 - lower.0) as f32;
128    lower.1 as f32 + t * (upper.1 as f32 - lower.1 as f32)
129}
130
131// Calibrated SSIMULACRA2→jpegli quality mapping (4:4:4)
132// SSIM2 scores: higher is better, 100 = identical
133const SSIM2_TO_JPEGLI: [(u8, u8); 8] = [
134    (70, 55), // Low quality
135    (75, 65),
136    (80, 73),
137    (85, 80),
138    (88, 85),
139    (90, 88),
140    (93, 92),
141    (95, 95),
142];
143
144fn ssim2_to_internal(score: f32) -> f32 {
145    if score >= 100.0 {
146        return 100.0;
147    }
148    if score <= 70.0 {
149        return (score / 70.0) * 55.0;
150    }
151
152    let q = score as u8;
153    let mut lower = (70u8, 55u8);
154    let mut upper = (95u8, 95u8);
155
156    for &(ssim_score, jpegli_q) in &SSIM2_TO_JPEGLI {
157        if ssim_score <= q && ssim_score > lower.0 {
158            lower = (ssim_score, jpegli_q);
159        }
160        if ssim_score >= q && ssim_score < upper.0 {
161            upper = (ssim_score, jpegli_q);
162        }
163    }
164
165    if lower.0 == upper.0 {
166        return lower.1 as f32;
167    }
168
169    let t = (score - lower.0 as f32) / (upper.0 - lower.0) as f32;
170    lower.1 as f32 + t * (upper.1 as f32 - lower.1 as f32)
171}
172
173// Calibrated butteraugli→jpegli quality mapping
174// Butteraugli: lower is better, 0 = identical, <1 excellent, <3 good
175const BUTTERAUGLI_TO_JPEGLI: [(f32, f32); 7] = [
176    (0.3, 96.0),
177    (0.5, 93.0),
178    (1.0, 88.0),
179    (1.5, 82.0),
180    (2.0, 76.0),
181    (3.0, 68.0),
182    (5.0, 55.0),
183];
184
185fn butteraugli_to_internal(dist: f32) -> f32 {
186    if dist <= 0.0 {
187        return 100.0;
188    }
189    if dist <= 0.3 {
190        return 96.0 + (0.3 - dist) / 0.3 * 4.0;
191    }
192    if dist >= 5.0 {
193        return 55.0 - (dist - 5.0) * 3.0;
194    }
195
196    let mut lower = (0.3f32, 96.0f32);
197    let mut upper = (5.0f32, 55.0f32);
198
199    for &(ba_dist, jpegli_q) in &BUTTERAUGLI_TO_JPEGLI {
200        if ba_dist <= dist && ba_dist > lower.0 {
201            lower = (ba_dist, jpegli_q);
202        }
203        if ba_dist >= dist && ba_dist < upper.0 {
204            upper = (ba_dist, jpegli_q);
205        }
206    }
207
208    if (lower.0 - upper.0).abs() < 0.001 {
209        return lower.1;
210    }
211
212    let t = (dist - lower.0) / (upper.0 - lower.0);
213    lower.1 + t * (upper.1 - lower.1)
214}
215
216/// Output color space with bundled subsampling options.
217#[derive(Clone, Copy, Debug, PartialEq, Eq)]
218#[non_exhaustive]
219pub enum ColorMode {
220    /// Standard YCbCr with configurable chroma subsampling.
221    YCbCr { subsampling: ChromaSubsampling },
222
223    /// XYB perceptual color space (jpegli-specific).
224    /// Computed internally from linear RGB input.
225    Xyb { subsampling: XybSubsampling },
226
227    /// Single-channel grayscale.
228    Grayscale,
229}
230
231impl Default for ColorMode {
232    fn default() -> Self {
233        ColorMode::YCbCr {
234            subsampling: ChromaSubsampling::None, // 4:4:4 - no subsampling
235        }
236    }
237}
238
239/// YCbCr chroma subsampling (spatial resolution).
240#[derive(Clone, Copy, Debug, PartialEq, Eq)]
241#[non_exhaustive]
242pub enum ChromaSubsampling {
243    /// 4:4:4 - No subsampling (full chroma resolution, best quality, largest files)
244    None,
245    /// 4:2:2 - Half horizontal resolution
246    HalfHorizontal,
247    /// 4:2:0 - Quarter resolution (half each direction, most common)
248    Quarter,
249    /// 4:4:0 - Half vertical resolution
250    HalfVertical,
251}
252
253impl ChromaSubsampling {
254    /// Horizontal subsampling factor (1 or 2).
255    #[must_use]
256    pub const fn h_factor(&self) -> u8 {
257        match self {
258            ChromaSubsampling::None | ChromaSubsampling::HalfVertical => 1,
259            ChromaSubsampling::HalfHorizontal | ChromaSubsampling::Quarter => 2,
260        }
261    }
262
263    /// Vertical subsampling factor (1 or 2).
264    #[must_use]
265    pub const fn v_factor(&self) -> u8 {
266        match self {
267            ChromaSubsampling::None | ChromaSubsampling::HalfHorizontal => 1,
268            ChromaSubsampling::HalfVertical | ChromaSubsampling::Quarter => 2,
269        }
270    }
271
272    /// Returns the horizontal sampling factor for luma.
273    ///
274    /// This is the luma block count in horizontal direction per MCU.
275    /// Returns 1 for 4:4:4/4:4:0, returns 2 for 4:2:0/4:2:2.
276    #[must_use]
277    pub const fn h_samp_factor_luma(self) -> u8 {
278        self.h_factor()
279    }
280
281    /// Returns the vertical sampling factor for luma.
282    ///
283    /// This is the luma block count in vertical direction per MCU.
284    /// Returns 1 for 4:4:4/4:2:2, returns 2 for 4:2:0/4:4:0.
285    #[must_use]
286    pub const fn v_samp_factor_luma(self) -> u8 {
287        self.v_factor()
288    }
289
290    /// Returns the MCU (Minimum Coded Unit) size for this subsampling mode.
291    ///
292    /// - 8 for 4:4:4 (no subsampling)
293    /// - 16 for modes with 2x sampling (4:2:0, 4:2:2, 4:4:0)
294    #[must_use]
295    pub const fn mcu_size(self) -> usize {
296        match self {
297            ChromaSubsampling::None => 8,
298            ChromaSubsampling::Quarter
299            | ChromaSubsampling::HalfHorizontal
300            | ChromaSubsampling::HalfVertical => 16,
301        }
302    }
303}
304
305/// XYB component subsampling.
306///
307/// Unlike YCbCr where only luma is full, XYB keeps X and Y full
308/// even in subsampled mode - only B is reduced.
309#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
310#[non_exhaustive]
311pub enum XybSubsampling {
312    /// X, Y, B all at full resolution (1x1, 1x1, 1x1)
313    Full,
314    /// X, Y full, B at quarter resolution (1x1, 1x1, 2x2)
315    #[default]
316    BQuarter,
317}
318
319/// Chroma downsampling algorithm for RGB->YCbCr conversion.
320///
321/// **Only applies to RGB/RGBX input.** Ignored for grayscale, YCbCr, and planar input.
322#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
323#[non_exhaustive]
324pub enum DownsamplingMethod {
325    /// Simple box filter averaging (fast, matches C++ jpegli default)
326    #[default]
327    Box,
328    /// Gamma-aware averaging (better color accuracy at edges)
329    GammaAware,
330    /// Iterative optimization (SharpYUV-style, best quality, ~3x slower)
331    GammaAwareIterative,
332}
333
334impl DownsamplingMethod {
335    /// Returns true if this method uses gamma-aware downsampling.
336    #[must_use]
337    pub const fn uses_gamma_aware(self) -> bool {
338        matches!(self, Self::GammaAware | Self::GammaAwareIterative)
339    }
340}
341
342/// Pixel data layout for raw byte input.
343///
344/// Describes channel order, bit depth, and color space interpretation.
345/// Use with `encode_from_bytes()` when working with raw buffers.
346///
347/// For rgb crate types, use `encode_from_rgb()` which infers layout.
348#[derive(Clone, Copy, Debug, PartialEq, Eq)]
349#[non_exhaustive]
350pub enum PixelLayout {
351    // === 8-bit sRGB (gamma-encoded) ===
352    /// RGB, 3 bytes/pixel, sRGB gamma
353    Rgb8Srgb,
354    /// BGR, 3 bytes/pixel, sRGB gamma (Windows/GDI order)
355    Bgr8Srgb,
356    /// RGBX, 4 bytes/pixel, sRGB gamma (4th byte ignored)
357    Rgbx8Srgb,
358    /// BGRX, 4 bytes/pixel, sRGB gamma (4th byte ignored)
359    Bgrx8Srgb,
360    /// Grayscale, 1 byte/pixel, sRGB gamma
361    Gray8Srgb,
362
363    // === 16-bit linear ===
364    /// RGB, 6 bytes/pixel, linear light (0-65535)
365    Rgb16Linear,
366    /// RGBX, 8 bytes/pixel, linear light (4th channel ignored)
367    Rgbx16Linear,
368    /// Grayscale, 2 bytes/pixel, linear light
369    Gray16Linear,
370
371    // === 32-bit float linear ===
372    /// RGB, 12 bytes/pixel, linear light (0.0-1.0)
373    RgbF32Linear,
374    /// RGBX, 16 bytes/pixel, linear light (4th channel ignored)
375    RgbxF32Linear,
376    /// Grayscale, 4 bytes/pixel, linear light
377    GrayF32Linear,
378
379    // === Pre-converted YCbCr (skip RGB->YCbCr conversion) ===
380    /// YCbCr interleaved, 3 bytes/pixel, u8
381    YCbCr8,
382    /// YCbCr interleaved, 12 bytes/pixel, f32
383    YCbCrF32,
384}
385
386impl PixelLayout {
387    /// Bytes per pixel for this layout.
388    #[must_use]
389    pub const fn bytes_per_pixel(&self) -> usize {
390        match self {
391            Self::Gray8Srgb => 1,
392            Self::Gray16Linear => 2,
393            Self::Rgb8Srgb | Self::Bgr8Srgb | Self::YCbCr8 => 3,
394            Self::Rgbx8Srgb | Self::Bgrx8Srgb | Self::GrayF32Linear => 4,
395            Self::Rgb16Linear => 6,
396            Self::Rgbx16Linear => 8,
397            Self::RgbF32Linear | Self::YCbCrF32 => 12,
398            Self::RgbxF32Linear => 16,
399        }
400    }
401
402    /// Number of channels (including ignored channels).
403    #[must_use]
404    pub const fn channels(&self) -> usize {
405        match self {
406            Self::Gray8Srgb | Self::Gray16Linear | Self::GrayF32Linear => 1,
407            Self::Rgb8Srgb
408            | Self::Bgr8Srgb
409            | Self::Rgb16Linear
410            | Self::RgbF32Linear
411            | Self::YCbCr8
412            | Self::YCbCrF32 => 3,
413            Self::Rgbx8Srgb | Self::Bgrx8Srgb | Self::Rgbx16Linear | Self::RgbxF32Linear => 4,
414        }
415    }
416
417    /// Whether this is a grayscale format.
418    #[must_use]
419    pub const fn is_grayscale(&self) -> bool {
420        matches!(
421            self,
422            Self::Gray8Srgb | Self::Gray16Linear | Self::GrayF32Linear
423        )
424    }
425
426    /// Whether this is pre-converted YCbCr.
427    #[must_use]
428    pub const fn is_ycbcr(&self) -> bool {
429        matches!(self, Self::YCbCr8 | Self::YCbCrF32)
430    }
431
432    /// Whether this uses BGR channel order.
433    #[must_use]
434    pub const fn is_bgr(&self) -> bool {
435        matches!(self, Self::Bgr8Srgb | Self::Bgrx8Srgb)
436    }
437
438    /// Whether this is a float format (linear color space).
439    #[must_use]
440    pub const fn is_float(&self) -> bool {
441        matches!(
442            self,
443            Self::RgbF32Linear | Self::RgbxF32Linear | Self::GrayF32Linear | Self::YCbCrF32
444        )
445    }
446
447    /// Whether this is a 16-bit format (linear color space).
448    #[must_use]
449    pub const fn is_16bit(&self) -> bool {
450        matches!(
451            self,
452            Self::Rgb16Linear | Self::Rgbx16Linear | Self::Gray16Linear
453        )
454    }
455}
456
457/// Planar YCbCr data for a strip of rows.
458///
459/// Each plane has its own stride. All planes are f32.
460#[derive(Clone, Copy, Debug)]
461pub struct YCbCrPlanes<'a> {
462    pub y: &'a [f32],
463    pub y_stride: usize,
464    pub cb: &'a [f32],
465    pub cb_stride: usize,
466    pub cr: &'a [f32],
467    pub cr_stride: usize,
468}
469
470#[cfg(test)]
471mod tests {
472    use super::*;
473
474    #[test]
475    fn test_quality_default() {
476        let q = Quality::default();
477        assert!(matches!(q, Quality::ApproxJpegli(90.0)));
478    }
479
480    #[test]
481    fn test_quality_from() {
482        let q: Quality = 85.0.into();
483        assert!(matches!(q, Quality::ApproxJpegli(85.0)));
484
485        let q: Quality = 75u8.into();
486        assert!(matches!(q, Quality::ApproxJpegli(75.0)));
487    }
488
489    #[test]
490    fn test_pixel_layout_bytes() {
491        assert_eq!(PixelLayout::Rgb8Srgb.bytes_per_pixel(), 3);
492        assert_eq!(PixelLayout::Rgbx8Srgb.bytes_per_pixel(), 4);
493        assert_eq!(PixelLayout::RgbF32Linear.bytes_per_pixel(), 12);
494        assert_eq!(PixelLayout::Gray8Srgb.bytes_per_pixel(), 1);
495    }
496
497    #[test]
498    fn test_chroma_subsampling_factors() {
499        assert_eq!(ChromaSubsampling::None.h_factor(), 1);
500        assert_eq!(ChromaSubsampling::None.v_factor(), 1);
501        assert_eq!(ChromaSubsampling::Quarter.h_factor(), 2);
502        assert_eq!(ChromaSubsampling::Quarter.v_factor(), 2);
503        assert_eq!(ChromaSubsampling::HalfHorizontal.h_factor(), 2);
504        assert_eq!(ChromaSubsampling::HalfHorizontal.v_factor(), 1);
505    }
506}
507
508// =============================================================================
509// Parallel Encoding Configuration
510// =============================================================================
511
512/// Parallel encoding strategy.
513///
514/// Controls how the encoder uses multiple threads for improved throughput.
515/// Parallel encoding uses JPEG restart markers to enable independent encoding
516/// of image segments, which are then concatenated.
517///
518/// # Restart Marker Behavior
519///
520/// Parallel encoding requires restart markers between segments. When enabled:
521/// - If `restart_interval` is 0 or too small, it will be **increased** to an
522///   optimal value based on thread count and image size
523/// - If `restart_interval` is already set to a reasonable value, it will be
524///   preserved (parallel encoding respects user-specified intervals)
525///
526/// Restart markers add ~2 bytes per interval but enable:
527/// - Parallel encoding/decoding
528/// - Error recovery in corrupted streams
529/// - Random access to image regions
530///
531/// # Performance
532///
533/// Parallel encoding is most beneficial for larger images (512x512+):
534/// - 2 threads: ~1.2-1.6x speedup
535/// - 4 threads: ~1.3-1.7x speedup
536/// - Diminishing returns beyond 4 threads for typical images
537///
538/// Small images (<256x256) may see no benefit or slight overhead.
539#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
540#[non_exhaustive]
541#[cfg(feature = "parallel")]
542pub enum ParallelEncoding {
543    /// Automatically configure parallel encoding.
544    ///
545    /// Uses available CPU cores and selects an optimal restart interval
546    /// based on image dimensions. The restart interval will be increased
547    /// if needed, but never decreased below the user-specified value.
548    #[default]
549    Auto,
550}