wsi_streamer/tile/
encoder.rs

1//! Tile encoder with JPEG and JPEG 2000 support.
2//!
3//! This module handles decoding source tiles (JPEG or JPEG 2000) and
4//! re-encoding them as JPEG at a specified quality level.
5//!
6//! # Design Decisions
7//!
8//! - **Always decode/encode**: For simplicity and correctness, tiles are always
9//!   decoded from source format and re-encoded as JPEG. No passthrough optimization.
10//!
11//! - **No resizing**: Tiles are served at their native size. The tile coordinates
12//!   specify tile indices, not pixel coordinates.
13//!
14//! - **Quality control**: JPEG quality is configurable per request, allowing
15//!   clients to trade off file size vs image quality.
16//!
17//! - **Format detection**: Source format is auto-detected from magic bytes,
18//!   supporting both JPEG (FFD8) and JPEG 2000 (FF4F or JP2 container).
19
20use bytes::Bytes;
21use image::codecs::jpeg::JpegEncoder;
22use image::{DynamicImage, ImageReader};
23use jpeg2k::Image as J2kImage;
24use std::io::Cursor;
25
26use crate::error::TileError;
27
28// =============================================================================
29// Format Detection
30// =============================================================================
31
32/// Detected tile format based on magic bytes.
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34enum TileFormat {
35    /// JPEG format (FFD8 magic)
36    Jpeg,
37    /// JPEG 2000 codestream or JP2 container
38    Jpeg2000,
39    /// Unknown format
40    Unknown,
41}
42
43/// Detect the format of tile data from its magic bytes.
44///
45/// # Arguments
46/// * `data` - Raw tile bytes
47///
48/// # Returns
49/// The detected format, or `Unknown` if not recognized.
50fn detect_tile_format(data: &[u8]) -> TileFormat {
51    if data.len() < 2 {
52        return TileFormat::Unknown;
53    }
54
55    // JPEG: SOI marker (FF D8)
56    if data[0] == 0xFF && data[1] == 0xD8 {
57        return TileFormat::Jpeg;
58    }
59
60    // JPEG 2000 codestream: SOC + SIZ markers (FF 4F FF 51)
61    if data.len() >= 4 && data[0..4] == [0xFF, 0x4F, 0xFF, 0x51] {
62        return TileFormat::Jpeg2000;
63    }
64
65    // JPEG 2000 JP2 container: signature box
66    // Box length (4 bytes) + "jP  " signature
67    if data.len() >= 12 && &data[4..8] == b"jP  " {
68        return TileFormat::Jpeg2000;
69    }
70
71    TileFormat::Unknown
72}
73
74/// Decode JPEG 2000 data to a DynamicImage.
75///
76/// # Arguments
77/// * `data` - Raw JPEG 2000 bytes (codestream or JP2 container)
78///
79/// # Returns
80/// Decoded image, or error if decoding fails.
81fn decode_jpeg2000(data: &[u8]) -> Result<DynamicImage, TileError> {
82    let j2k_image = J2kImage::from_bytes(data).map_err(|e| TileError::DecodeError {
83        message: format!("JPEG 2000 decode error: {}", e),
84    })?;
85
86    // First, try the standard TryFrom conversion which handles color space
87    // conversion properly for most images. Wrap in catch_unwind to handle
88    // the rare panic case in the jpeg2k crate.
89    let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
90        DynamicImage::try_from(&j2k_image)
91    }));
92
93    match result {
94        Ok(Ok(img)) => Ok(img),
95        Ok(Err(e)) => Err(TileError::DecodeError {
96            message: format!("JPEG 2000 to DynamicImage conversion error: {}", e),
97        }),
98        Err(_) => {
99            // TryFrom panicked - fall back to manual conversion using components
100            decode_jpeg2000_manual(&j2k_image)
101        }
102    }
103}
104
105/// Manual JPEG 2000 decoding fallback for when TryFrom panics.
106///
107/// This handles YCbCr 4:2:0 subsampled images by manually upsampling
108/// and converting to RGB.
109fn decode_jpeg2000_manual(j2k_image: &J2kImage) -> Result<DynamicImage, TileError> {
110    let num_components = j2k_image.num_components();
111    let components = j2k_image.components();
112
113    if components.is_empty() {
114        return Err(TileError::DecodeError {
115            message: "JPEG 2000 image has no components".to_string(),
116        });
117    }
118
119    match num_components {
120        1 => {
121            // Grayscale - use first component directly
122            let comp = &components[0];
123            let comp_data = comp.data();
124            let comp_width = comp.width();
125            let comp_height = comp.height();
126
127            // Convert i32 to u8
128            let pixels: Vec<u8> = comp_data.iter().map(|&v| v.clamp(0, 255) as u8).collect();
129
130            image::GrayImage::from_raw(comp_width, comp_height, pixels)
131                .map(DynamicImage::ImageLuma8)
132                .ok_or_else(|| TileError::DecodeError {
133                    message: format!(
134                        "Failed to create grayscale image from components: {}x{}",
135                        comp_width, comp_height
136                    ),
137                })
138        }
139        3 => {
140            // RGB or YCbCr - check for subsampling and handle accordingly
141            let y_comp = &components[0];
142            let cb_comp = &components[1];
143            let cr_comp = &components[2];
144
145            // Check if chroma is subsampled
146            let y_width = y_comp.width();
147            let y_height = y_comp.height();
148            let cb_width = cb_comp.width();
149            let cb_height = cb_comp.height();
150
151            if cb_width == y_width && cb_height == y_height {
152                // No subsampling - direct RGB conversion
153                let y_data = y_comp.data();
154                let cb_data = cb_comp.data();
155                let cr_data = cr_comp.data();
156
157                let mut pixels = Vec::with_capacity((y_width * y_height * 3) as usize);
158                for i in 0..(y_width * y_height) as usize {
159                    pixels.push(y_data[i].clamp(0, 255) as u8);
160                    pixels.push(cb_data[i].clamp(0, 255) as u8);
161                    pixels.push(cr_data[i].clamp(0, 255) as u8);
162                }
163
164                image::RgbImage::from_raw(y_width, y_height, pixels)
165                    .map(DynamicImage::ImageRgb8)
166                    .ok_or_else(|| TileError::DecodeError {
167                        message: format!(
168                            "Failed to create RGB image from components: {}x{}",
169                            y_width, y_height
170                        ),
171                    })
172            } else {
173                // Chroma subsampling detected - need to upsample and convert YCbCr to RGB
174                let y_data = y_comp.data();
175                let cb_data = cb_comp.data();
176                let cr_data = cr_comp.data();
177
178                let mut pixels = Vec::with_capacity((y_width * y_height * 3) as usize);
179
180                for y_row in 0..y_height {
181                    for y_col in 0..y_width {
182                        let y_idx = (y_row * y_width + y_col) as usize;
183
184                        // Map Y coordinate to subsampled Cb/Cr coordinate
185                        let cb_col = (y_col * cb_width) / y_width;
186                        let cb_row = (y_row * cb_height) / y_height;
187                        let cb_idx = (cb_row * cb_width + cb_col) as usize;
188
189                        let y_val = y_data[y_idx] as f32;
190                        let cb_val = cb_data.get(cb_idx).copied().unwrap_or(128) as f32 - 128.0;
191                        let cr_val = cr_data.get(cb_idx).copied().unwrap_or(128) as f32 - 128.0;
192
193                        // YCbCr to RGB conversion (ITU-R BT.601)
194                        let r = (y_val + 1.402 * cr_val).clamp(0.0, 255.0) as u8;
195                        let g =
196                            (y_val - 0.344136 * cb_val - 0.714136 * cr_val).clamp(0.0, 255.0) as u8;
197                        let b = (y_val + 1.772 * cb_val).clamp(0.0, 255.0) as u8;
198
199                        pixels.push(r);
200                        pixels.push(g);
201                        pixels.push(b);
202                    }
203                }
204
205                image::RgbImage::from_raw(y_width, y_height, pixels)
206                    .map(DynamicImage::ImageRgb8)
207                    .ok_or_else(|| TileError::DecodeError {
208                        message: format!(
209                            "Failed to create RGB image from YCbCr components: {}x{}",
210                            y_width, y_height
211                        ),
212                    })
213            }
214        }
215        _ => Err(TileError::DecodeError {
216            message: format!(
217                "Unsupported JPEG 2000 component count: {} (expected 1 or 3)",
218                num_components
219            ),
220        }),
221    }
222}
223
224/// Default JPEG quality (1-100).
225pub const DEFAULT_JPEG_QUALITY: u8 = 80;
226
227/// Minimum allowed JPEG quality.
228pub const MIN_JPEG_QUALITY: u8 = 1;
229
230/// Maximum allowed JPEG quality.
231pub const MAX_JPEG_QUALITY: u8 = 100;
232
233// =============================================================================
234// JPEG Encoder
235// =============================================================================
236
237/// JPEG tile encoder for decoding and re-encoding tiles.
238///
239/// This encoder takes raw JPEG data from slide tiles, decodes it to pixels,
240/// and re-encodes it at the requested quality level.
241///
242/// # Example
243///
244/// ```ignore
245/// use wsi_streamer::tile::JpegTileEncoder;
246/// use bytes::Bytes;
247///
248/// let encoder = JpegTileEncoder::new();
249///
250/// // Source JPEG data from slide
251/// let source_jpeg: Bytes = /* ... */;
252///
253/// // Re-encode at quality 85
254/// let output = encoder.encode(&source_jpeg, 85)?;
255/// ```
256#[derive(Debug, Clone, Default)]
257pub struct JpegTileEncoder {
258    // Currently stateless, but struct allows future extension
259    // (e.g., shared thread pool, encoder settings)
260}
261
262impl JpegTileEncoder {
263    /// Create a new JPEG tile encoder.
264    pub fn new() -> Self {
265        Self {}
266    }
267
268    /// Decode source tile and re-encode at the specified quality.
269    ///
270    /// This method auto-detects the source format (JPEG or JPEG 2000) and
271    /// decodes accordingly. Output is always JPEG.
272    ///
273    /// # Arguments
274    ///
275    /// * `source` - Raw tile data (JPEG or JPEG 2000)
276    /// * `quality` - Output JPEG quality (1-100)
277    ///
278    /// # Returns
279    ///
280    /// Encoded JPEG data at the requested quality.
281    ///
282    /// # Errors
283    ///
284    /// Returns an error if:
285    /// - The source data format is not recognized
286    /// - Decoding fails
287    /// - Encoding fails
288    pub fn encode(&self, source: &[u8], quality: u8) -> Result<Bytes, TileError> {
289        // Clamp quality to valid range
290        let quality = quality.clamp(MIN_JPEG_QUALITY, MAX_JPEG_QUALITY);
291
292        // Detect source format and decode
293        let format = detect_tile_format(source);
294
295        let img = match format {
296            TileFormat::Jpeg => {
297                let cursor = Cursor::new(source);
298                let reader = ImageReader::with_format(cursor, image::ImageFormat::Jpeg);
299                reader.decode().map_err(|e| TileError::DecodeError {
300                    message: format!("JPEG decode error: {}", e),
301                })?
302            }
303            TileFormat::Jpeg2000 => decode_jpeg2000(source)?,
304            TileFormat::Unknown => {
305                return Err(TileError::DecodeError {
306                    message: "Unknown tile format: expected JPEG or JPEG 2000".to_string(),
307                });
308            }
309        };
310
311        // Encode to JPEG at requested quality
312        let mut output = Vec::new();
313        let mut encoder = JpegEncoder::new_with_quality(&mut output, quality);
314
315        encoder
316            .encode_image(&img)
317            .map_err(|e| TileError::EncodeError {
318                message: e.to_string(),
319            })?;
320
321        Ok(Bytes::from(output))
322    }
323
324    /// Decode source JPEG and re-encode at the default quality.
325    ///
326    /// This is a convenience method equivalent to `encode(source, DEFAULT_JPEG_QUALITY)`.
327    pub fn encode_default(&self, source: &[u8]) -> Result<Bytes, TileError> {
328        self.encode(source, DEFAULT_JPEG_QUALITY)
329    }
330
331    /// Get image dimensions without fully decoding (when possible).
332    ///
333    /// This is useful for validation or metadata queries.
334    ///
335    /// Note: For JPEG 2000, this currently requires a full decode.
336    ///
337    /// # Returns
338    ///
339    /// `(width, height)` in pixels.
340    pub fn dimensions(&self, source: &[u8]) -> Result<(u32, u32), TileError> {
341        let format = detect_tile_format(source);
342
343        match format {
344            TileFormat::Jpeg => {
345                let cursor = Cursor::new(source);
346                let reader = ImageReader::with_format(cursor, image::ImageFormat::Jpeg);
347                reader
348                    .into_dimensions()
349                    .map_err(|e| TileError::DecodeError {
350                        message: format!("JPEG dimensions error: {}", e),
351                    })
352            }
353            TileFormat::Jpeg2000 => {
354                // jpeg2k requires full decode to get dimensions
355                let j2k = J2kImage::from_bytes(source).map_err(|e| TileError::DecodeError {
356                    message: format!("JPEG 2000 decode error: {}", e),
357                })?;
358                Ok((j2k.width(), j2k.height()))
359            }
360            TileFormat::Unknown => Err(TileError::DecodeError {
361                message: "Unknown tile format: expected JPEG or JPEG 2000".to_string(),
362            }),
363        }
364    }
365}
366
367// =============================================================================
368// Utility Functions
369// =============================================================================
370
371/// Validate JPEG quality parameter.
372///
373/// Returns `true` if quality is in the valid range (1-100).
374#[inline]
375pub fn is_valid_quality(quality: u8) -> bool {
376    (MIN_JPEG_QUALITY..=MAX_JPEG_QUALITY).contains(&quality)
377}
378
379/// Clamp quality to valid range.
380///
381/// Values below 1 become 1, values above 100 become 100.
382#[inline]
383pub fn clamp_quality(quality: u8) -> u8 {
384    quality.clamp(MIN_JPEG_QUALITY, MAX_JPEG_QUALITY)
385}
386
387// =============================================================================
388// Tests
389// =============================================================================
390
391#[cfg(test)]
392mod tests {
393    use super::*;
394
395    fn create_test_jpeg() -> Vec<u8> {
396        // Create a simple 8x8 gray image and encode it
397        use image::{GrayImage, Luma};
398
399        let img = GrayImage::from_fn(8, 8, |x, y| {
400            let val = ((x + y) * 16) as u8;
401            Luma([val])
402        });
403
404        let mut buf = Vec::new();
405        let mut encoder = JpegEncoder::new_with_quality(&mut buf, 90);
406        encoder.encode_image(&img).unwrap();
407        buf
408    }
409
410    #[test]
411    fn test_encoder_creation() {
412        let encoder = JpegTileEncoder::new();
413        // Just verify the encoder can be created without panicking
414        let _ = &encoder;
415    }
416
417    #[test]
418    fn test_encode_valid_jpeg() {
419        let encoder = JpegTileEncoder::new();
420        let source = create_test_jpeg();
421
422        let result = encoder.encode(&source, 80);
423        assert!(result.is_ok());
424
425        let output = result.unwrap();
426        // Output should be valid JPEG (starts with FFD8)
427        assert!(output.len() >= 2);
428        assert_eq!(output[0], 0xFF);
429        assert_eq!(output[1], 0xD8);
430    }
431
432    #[test]
433    fn test_encode_different_qualities() {
434        let encoder = JpegTileEncoder::new();
435        let source = create_test_jpeg();
436
437        let low_quality = encoder.encode(&source, 10).unwrap();
438        let high_quality = encoder.encode(&source, 95).unwrap();
439
440        // Higher quality should generally produce larger files
441        // (though not guaranteed for all images)
442        assert!(!low_quality.is_empty());
443        assert!(!high_quality.is_empty());
444    }
445
446    #[test]
447    fn test_encode_default() {
448        let encoder = JpegTileEncoder::new();
449        let source = create_test_jpeg();
450
451        let result = encoder.encode_default(&source);
452        assert!(result.is_ok());
453    }
454
455    #[test]
456    fn test_encode_invalid_data() {
457        let encoder = JpegTileEncoder::new();
458        let invalid = vec![0x00, 0x01, 0x02, 0x03];
459
460        let result = encoder.encode(&invalid, 80);
461        assert!(result.is_err());
462
463        match result {
464            Err(TileError::DecodeError { .. }) => {}
465            _ => panic!("Expected DecodeError"),
466        }
467    }
468
469    #[test]
470    fn test_encode_empty_data() {
471        let encoder = JpegTileEncoder::new();
472
473        let result = encoder.encode(&[], 80);
474        assert!(result.is_err());
475    }
476
477    #[test]
478    fn test_quality_clamping() {
479        let encoder = JpegTileEncoder::new();
480        let source = create_test_jpeg();
481
482        // Quality 0 should be clamped to 1
483        let result = encoder.encode(&source, 0);
484        assert!(result.is_ok());
485
486        // Quality 255 should be clamped to 100
487        let result = encoder.encode(&source, 255);
488        assert!(result.is_ok());
489    }
490
491    #[test]
492    fn test_dimensions() {
493        let encoder = JpegTileEncoder::new();
494        let source = create_test_jpeg();
495
496        let (width, height) = encoder.dimensions(&source).unwrap();
497        assert_eq!(width, 8);
498        assert_eq!(height, 8);
499    }
500
501    #[test]
502    fn test_dimensions_invalid() {
503        let encoder = JpegTileEncoder::new();
504        let invalid = vec![0x00, 0x01, 0x02];
505
506        let result = encoder.dimensions(&invalid);
507        assert!(result.is_err());
508    }
509
510    #[test]
511    fn test_is_valid_quality() {
512        assert!(!is_valid_quality(0));
513        assert!(is_valid_quality(1));
514        assert!(is_valid_quality(50));
515        assert!(is_valid_quality(100));
516        assert!(!is_valid_quality(101));
517    }
518
519    #[test]
520    fn test_clamp_quality() {
521        assert_eq!(clamp_quality(0), 1);
522        assert_eq!(clamp_quality(1), 1);
523        assert_eq!(clamp_quality(50), 50);
524        assert_eq!(clamp_quality(100), 100);
525        assert_eq!(clamp_quality(150), 100);
526        assert_eq!(clamp_quality(255), 100);
527    }
528
529    #[test]
530    fn test_output_is_valid_jpeg() {
531        let encoder = JpegTileEncoder::new();
532        let source = create_test_jpeg();
533
534        let output = encoder.encode(&source, 80).unwrap();
535
536        // Verify JPEG markers
537        assert_eq!(output[0], 0xFF); // SOI marker
538        assert_eq!(output[1], 0xD8);
539        assert_eq!(output[output.len() - 2], 0xFF); // EOI marker
540        assert_eq!(output[output.len() - 1], 0xD9);
541
542        // Verify we can decode the output
543        let result = encoder.dimensions(&output);
544        assert!(result.is_ok());
545    }
546
547    // -------------------------------------------------------------------------
548    // Format Detection Tests
549    // -------------------------------------------------------------------------
550
551    #[test]
552    fn test_detect_jpeg_format() {
553        let jpeg = [0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10];
554        assert_eq!(detect_tile_format(&jpeg), TileFormat::Jpeg);
555    }
556
557    #[test]
558    fn test_detect_j2k_codestream_format() {
559        // JPEG 2000 codestream: SOC + SIZ markers
560        let j2k = [0xFF, 0x4F, 0xFF, 0x51, 0x00, 0x00];
561        assert_eq!(detect_tile_format(&j2k), TileFormat::Jpeg2000);
562    }
563
564    #[test]
565    fn test_detect_jp2_container_format() {
566        // JP2 container with signature box
567        let jp2 = [
568            0x00, 0x00, 0x00, 0x0C, // Box length
569            0x6A, 0x50, 0x20, 0x20, // "jP  " signature
570            0x0D, 0x0A, 0x87, 0x0A, // Additional signature bytes
571        ];
572        assert_eq!(detect_tile_format(&jp2), TileFormat::Jpeg2000);
573    }
574
575    #[test]
576    fn test_detect_unknown_format() {
577        let unknown = [0x00, 0x00, 0x00, 0x00];
578        assert_eq!(detect_tile_format(&unknown), TileFormat::Unknown);
579    }
580
581    #[test]
582    fn test_detect_empty_data() {
583        assert_eq!(detect_tile_format(&[]), TileFormat::Unknown);
584    }
585
586    #[test]
587    fn test_detect_short_data() {
588        assert_eq!(detect_tile_format(&[0xFF]), TileFormat::Unknown);
589    }
590
591    #[test]
592    fn test_encode_unknown_format_returns_error() {
593        let encoder = JpegTileEncoder::new();
594        let unknown = vec![0x00, 0x01, 0x02, 0x03];
595
596        let result = encoder.encode(&unknown, 80);
597        assert!(result.is_err());
598
599        match result {
600            Err(TileError::DecodeError { message }) => {
601                assert!(message.contains("Unknown tile format"));
602            }
603            _ => panic!("Expected DecodeError with format message"),
604        }
605    }
606}