Skip to main content

ad_plugins_rs/
codec.rs

1use std::sync::Arc;
2
3use ad_core_rs::attributes::{NDAttrSource, NDAttrValue, NDAttribute};
4use ad_core_rs::codec::{Codec, CodecName};
5use ad_core_rs::ndarray::{NDArray, NDDataBuffer, NDDataType, NDDimension};
6use ad_core_rs::ndarray_pool::NDArrayPool;
7use ad_core_rs::plugin::runtime::{NDPluginProcess, ParamUpdate, ProcessResult};
8
9use lz4_flex::{compress_prepend_size, decompress_size_prepended};
10use rust_hdf5::format::messages::filter::{
11    FILTER_BLOSC, Filter, FilterPipeline, apply_filters, reverse_filters,
12};
13
14/// Attribute name used to store the original NDDataType ordinal before compression.
15const ATTR_ORIGINAL_DATA_TYPE: &str = "CODEC_ORIGINAL_DATA_TYPE";
16
17/// Reconstruct an `NDDataBuffer` from raw bytes and a target data type.
18///
19/// The byte slice is reinterpreted as the target type using native endianness.
20/// Returns `None` if the byte count is not a multiple of the element size.
21fn buffer_from_bytes(bytes: &[u8], data_type: NDDataType) -> Option<NDDataBuffer> {
22    let elem_size = data_type.element_size();
23    if bytes.len() % elem_size != 0 {
24        return None;
25    }
26    let count = bytes.len() / elem_size;
27
28    Some(match data_type {
29        NDDataType::Int8 => {
30            let mut v = vec![0i8; count];
31            // SAFETY: i8 and u8 have the same size/alignment
32            unsafe {
33                std::ptr::copy_nonoverlapping(
34                    bytes.as_ptr(),
35                    v.as_mut_ptr() as *mut u8,
36                    bytes.len(),
37                );
38            }
39            NDDataBuffer::I8(v)
40        }
41        NDDataType::UInt8 => NDDataBuffer::U8(bytes.to_vec()),
42        NDDataType::Int16 => {
43            let mut v = vec![0i16; count];
44            unsafe {
45                std::ptr::copy_nonoverlapping(
46                    bytes.as_ptr(),
47                    v.as_mut_ptr() as *mut u8,
48                    bytes.len(),
49                );
50            }
51            NDDataBuffer::I16(v)
52        }
53        NDDataType::UInt16 => {
54            let mut v = vec![0u16; count];
55            unsafe {
56                std::ptr::copy_nonoverlapping(
57                    bytes.as_ptr(),
58                    v.as_mut_ptr() as *mut u8,
59                    bytes.len(),
60                );
61            }
62            NDDataBuffer::U16(v)
63        }
64        NDDataType::Int32 => {
65            let mut v = vec![0i32; count];
66            unsafe {
67                std::ptr::copy_nonoverlapping(
68                    bytes.as_ptr(),
69                    v.as_mut_ptr() as *mut u8,
70                    bytes.len(),
71                );
72            }
73            NDDataBuffer::I32(v)
74        }
75        NDDataType::UInt32 => {
76            let mut v = vec![0u32; count];
77            unsafe {
78                std::ptr::copy_nonoverlapping(
79                    bytes.as_ptr(),
80                    v.as_mut_ptr() as *mut u8,
81                    bytes.len(),
82                );
83            }
84            NDDataBuffer::U32(v)
85        }
86        NDDataType::Int64 => {
87            let mut v = vec![0i64; count];
88            unsafe {
89                std::ptr::copy_nonoverlapping(
90                    bytes.as_ptr(),
91                    v.as_mut_ptr() as *mut u8,
92                    bytes.len(),
93                );
94            }
95            NDDataBuffer::I64(v)
96        }
97        NDDataType::UInt64 => {
98            let mut v = vec![0u64; count];
99            unsafe {
100                std::ptr::copy_nonoverlapping(
101                    bytes.as_ptr(),
102                    v.as_mut_ptr() as *mut u8,
103                    bytes.len(),
104                );
105            }
106            NDDataBuffer::U64(v)
107        }
108        NDDataType::Float32 => {
109            let mut v = vec![0f32; count];
110            unsafe {
111                std::ptr::copy_nonoverlapping(
112                    bytes.as_ptr(),
113                    v.as_mut_ptr() as *mut u8,
114                    bytes.len(),
115                );
116            }
117            NDDataBuffer::F32(v)
118        }
119        NDDataType::Float64 => {
120            let mut v = vec![0f64; count];
121            unsafe {
122                std::ptr::copy_nonoverlapping(
123                    bytes.as_ptr(),
124                    v.as_mut_ptr() as *mut u8,
125                    bytes.len(),
126                );
127            }
128            NDDataBuffer::F64(v)
129        }
130    })
131}
132
133/// Compress an NDArray using LZ4.
134///
135/// The raw bytes of the data buffer are compressed with LZ4 (block mode, size-prepended).
136/// The original data type ordinal is stored as an attribute so decompression can
137/// reconstruct the correct typed buffer.
138pub fn compress_lz4(src: &NDArray) -> NDArray {
139    let raw = src.data.as_u8_slice();
140    let original_data_type = src.data.data_type();
141    let original_size = raw.len();
142    let compressed = compress_prepend_size(raw);
143    let compressed_size = compressed.len();
144
145    let mut arr = src.clone();
146    arr.data = NDDataBuffer::U8(compressed);
147    arr.codec = Some(Codec {
148        name: CodecName::LZ4,
149        compressed_size,
150    });
151
152    // Store original data type so decompression can reconstruct the buffer.
153    arr.attributes.add(NDAttribute {
154        name: ATTR_ORIGINAL_DATA_TYPE.into(),
155        description: "Original NDDataType ordinal before codec compression".into(),
156        source: NDAttrSource::Driver,
157        value: NDAttrValue::UInt8(original_data_type as u8),
158    });
159
160    tracing::debug!(
161        original_size,
162        compressed_size,
163        ratio = original_size as f64 / compressed_size.max(1) as f64,
164        "LZ4 compress"
165    );
166
167    arr
168}
169
170/// Decompress an LZ4-compressed NDArray.
171///
172/// Returns `None` if the codec is not LZ4 or decompression fails.
173/// The original typed buffer is reconstructed using the stored data type attribute.
174pub fn decompress_lz4(src: &NDArray) -> Option<NDArray> {
175    if src.codec.as_ref().map(|c| c.name) != Some(CodecName::LZ4) {
176        return None;
177    }
178    let compressed = src.data.as_u8_slice();
179    let decompressed = decompress_size_prepended(compressed).ok()?;
180
181    // Recover original data type from attribute.
182    let original_type = src
183        .attributes
184        .get(ATTR_ORIGINAL_DATA_TYPE)
185        .and_then(|a| a.value.as_i64())
186        .and_then(|ord| NDDataType::from_ordinal(ord as u8))
187        .unwrap_or(NDDataType::UInt8);
188
189    let buffer = buffer_from_bytes(&decompressed, original_type)?;
190
191    let mut arr = src.clone();
192    arr.data = buffer;
193    arr.codec = None;
194    arr.attributes.remove(ATTR_ORIGINAL_DATA_TYPE);
195
196    Some(arr)
197}
198
199/// Compress an NDArray to JPEG.
200///
201/// Only supports UInt8 data. Handles:
202/// - 2D arrays (mono/grayscale)
203/// - 3D arrays with dims\[0\]=3 (RGB1 interleaved)
204///
205/// Returns `None` if the data type is not UInt8 or the layout is unsupported.
206pub fn compress_jpeg(src: &NDArray, quality: u8) -> Option<NDArray> {
207    if src.data.data_type() != NDDataType::UInt8 {
208        return None;
209    }
210
211    let raw = src.data.as_u8_slice();
212    let info = src.info();
213
214    let (width, height, color_type) = match src.dims.len() {
215        2 => {
216            // Mono: dims = [x, y]
217            (
218                info.x_size as u16,
219                info.y_size as u16,
220                jpeg_encoder::ColorType::Luma,
221            )
222        }
223        3 if src.dims[0].size == 3 => {
224            // RGB1: dims = [3, x, y], pixel-interleaved
225            (
226                info.x_size as u16,
227                info.y_size as u16,
228                jpeg_encoder::ColorType::Rgb,
229            )
230        }
231        _ => return None,
232    };
233
234    let mut jpeg_buf = Vec::new();
235    let encoder = jpeg_encoder::Encoder::new(&mut jpeg_buf, quality);
236    if encoder.encode(raw, width, height, color_type).is_err() {
237        return None;
238    }
239
240    let compressed_size = jpeg_buf.len();
241    let original_size = raw.len();
242
243    let mut arr = src.clone();
244    arr.data = NDDataBuffer::U8(jpeg_buf);
245    arr.codec = Some(Codec {
246        name: CodecName::JPEG,
247        compressed_size,
248    });
249
250    tracing::debug!(
251        original_size,
252        compressed_size,
253        ratio = original_size as f64 / compressed_size.max(1) as f64,
254        "JPEG compress (quality={})",
255        quality,
256    );
257
258    Some(arr)
259}
260
261/// Decompress a JPEG-compressed NDArray.
262///
263/// Uses jpeg-decoder to decode the JPEG data back to pixel data.
264/// Reconstructs proper dimensions and color layout (mono or RGB1).
265///
266/// Returns `None` if the codec is not JPEG or decoding fails.
267pub fn decompress_jpeg(src: &NDArray) -> Option<NDArray> {
268    if src.codec.as_ref().map(|c| c.name) != Some(CodecName::JPEG) {
269        return None;
270    }
271
272    let compressed = src.data.as_u8_slice();
273    let mut decoder = jpeg_decoder::Decoder::new(compressed);
274    let pixels = decoder.decode().ok()?;
275    let metadata = decoder.info()?;
276
277    let width = metadata.width as usize;
278    let height = metadata.height as usize;
279
280    let dims = match metadata.pixel_format {
281        jpeg_decoder::PixelFormat::L8 => {
282            // Grayscale
283            vec![NDDimension::new(width), NDDimension::new(height)]
284        }
285        jpeg_decoder::PixelFormat::RGB24 => {
286            // RGB1 interleaved
287            vec![
288                NDDimension::new(3),
289                NDDimension::new(width),
290                NDDimension::new(height),
291            ]
292        }
293        _ => return None,
294    };
295
296    let mut arr = src.clone();
297    arr.dims = dims;
298    arr.data = NDDataBuffer::U8(pixels);
299    arr.codec = None;
300
301    Some(arr)
302}
303
304/// Blosc compression settings.
305#[derive(Debug, Clone, Copy)]
306pub struct BloscConfig {
307    /// Sub-compressor: 0=BloscLZ, 1=LZ4, 2=LZ4HC, 3=Snappy, 4=Zlib, 5=Zstd
308    pub compressor: u32,
309    /// Compression level (0-9).
310    pub clevel: u32,
311    /// Shuffle mode: 0=None, 1=ByteShuffle, 2=BitShuffle.
312    pub shuffle: u32,
313}
314
315impl Default for BloscConfig {
316    fn default() -> Self {
317        Self {
318            compressor: 0,
319            clevel: 3,
320            shuffle: 0,
321        }
322    }
323}
324
325/// Compress an NDArray using Blosc via rust-hdf5's filter pipeline.
326pub fn compress_blosc(src: &NDArray, config: &BloscConfig) -> NDArray {
327    let raw = src.data.as_u8_slice();
328    let element_size = src.data.data_type().element_size();
329
330    let pipeline = FilterPipeline {
331        filters: vec![Filter {
332            id: FILTER_BLOSC,
333            flags: 0,
334            cd_values: vec![
335                2,                   // filter version
336                2,                   // blosc version
337                element_size as u32, // type size
338                raw.len() as u32,    // chunk size
339                config.shuffle,      // shuffle
340                config.compressor,   // compressor
341                config.clevel,       // level
342            ],
343        }],
344    };
345
346    let compressed = match apply_filters(&pipeline, raw) {
347        Ok(data) => data,
348        Err(_) => return src.clone(),
349    };
350
351    let compressed_size = compressed.len();
352    let mut arr = src.clone();
353    arr.attributes.add(NDAttribute {
354        name: ATTR_ORIGINAL_DATA_TYPE.to_string(),
355        description: String::new(),
356        source: NDAttrSource::Driver,
357        value: NDAttrValue::Int64(src.data.data_type() as u8 as i64),
358    });
359    arr.data = NDDataBuffer::U8(compressed);
360    arr.codec = Some(Codec {
361        name: CodecName::Blosc,
362        compressed_size,
363    });
364    arr
365}
366
367/// Decompress a Blosc-compressed NDArray via rust-hdf5's filter pipeline.
368pub fn decompress_blosc(src: &NDArray) -> Option<NDArray> {
369    if src.codec.as_ref().map(|c| c.name) != Some(CodecName::Blosc) {
370        return None;
371    }
372
373    let compressed = src.data.as_u8_slice();
374
375    // Blosc header contains enough info for decompression
376    let pipeline = FilterPipeline {
377        filters: vec![Filter {
378            id: FILTER_BLOSC,
379            flags: 0,
380            cd_values: vec![],
381        }],
382    };
383
384    let decompressed = reverse_filters(&pipeline, compressed).ok()?;
385
386    let original_type = src
387        .attributes
388        .get(ATTR_ORIGINAL_DATA_TYPE)
389        .and_then(|a| a.value.as_i64())
390        .and_then(|ord| NDDataType::from_ordinal(ord as u8))
391        .unwrap_or(NDDataType::UInt8);
392
393    let buffer = buffer_from_bytes(&decompressed, original_type)?;
394
395    let mut arr = src.clone();
396    arr.data = buffer;
397    arr.codec = None;
398    arr.attributes.remove(ATTR_ORIGINAL_DATA_TYPE);
399    Some(arr)
400}
401
402/// Codec operation mode.
403#[derive(Debug, Clone, Copy, PartialEq, Eq)]
404pub enum CodecMode {
405    /// Compress using the specified codec. `quality` is used for JPEG (1-100).
406    Compress { codec: CodecName, quality: u8 },
407    /// Decompress: auto-detect codec from the array's codec field.
408    Decompress,
409}
410
411/// Pure codec processing logic.
412///
413/// Reports compression ratio after each operation via `compression_ratio()`.
414#[derive(Default)]
415struct CodecParamIndices {
416    mode: Option<usize>,
417    compressor: Option<usize>,
418    comp_factor: Option<usize>,
419    jpeg_quality: Option<usize>,
420    blosc_compressor: Option<usize>,
421    blosc_clevel: Option<usize>,
422    blosc_shuffle: Option<usize>,
423    blosc_numthreads: Option<usize>,
424    codec_status: Option<usize>,
425    codec_error: Option<usize>,
426}
427
428pub struct CodecProcessor {
429    mode: CodecMode,
430    compression_ratio: f64,
431    jpeg_quality: u8,
432    blosc_config: BloscConfig,
433    params: CodecParamIndices,
434}
435
436impl CodecProcessor {
437    pub fn new(mode: CodecMode) -> Self {
438        let quality = match mode {
439            CodecMode::Compress { quality, .. } => quality,
440            _ => 85,
441        };
442        Self {
443            mode,
444            compression_ratio: 1.0,
445            jpeg_quality: quality,
446            blosc_config: BloscConfig::default(),
447            params: CodecParamIndices::default(),
448        }
449    }
450
451    /// Last computed compression ratio (original_size / compressed_size).
452    /// Returns 1.0 if no compression has been performed yet or on decompression.
453    pub fn compression_ratio(&self) -> f64 {
454        self.compression_ratio
455    }
456}
457
458impl NDPluginProcess for CodecProcessor {
459    fn process_array(&mut self, array: &NDArray, _pool: &NDArrayPool) -> ProcessResult {
460        let original_bytes = array.data.as_u8_slice().len();
461
462        let result = match self.mode {
463            CodecMode::Compress {
464                codec: CodecName::LZ4,
465                ..
466            } => Some(compress_lz4(array)),
467            CodecMode::Compress {
468                codec: CodecName::JPEG,
469                ..
470            } => compress_jpeg(array, self.jpeg_quality),
471            CodecMode::Compress {
472                codec: CodecName::Blosc,
473                ..
474            } => Some(compress_blosc(array, &self.blosc_config)),
475            CodecMode::Compress { .. } => None,
476            CodecMode::Decompress => match array.codec.as_ref().map(|c| c.name) {
477                Some(CodecName::LZ4) => decompress_lz4(array),
478                Some(CodecName::JPEG) => decompress_jpeg(array),
479                Some(CodecName::Blosc) => decompress_blosc(array),
480                _ => None,
481            },
482        };
483
484        let mut updates = Vec::new();
485
486        match result {
487            Some(ref out) => {
488                let output_bytes = out.data.as_u8_slice().len();
489                match self.mode {
490                    CodecMode::Compress { .. } => {
491                        self.compression_ratio = original_bytes as f64 / output_bytes.max(1) as f64;
492                    }
493                    CodecMode::Decompress => {
494                        self.compression_ratio = output_bytes as f64 / original_bytes.max(1) as f64;
495                    }
496                }
497                if let Some(idx) = self.params.comp_factor {
498                    updates.push(ParamUpdate::float64(idx, self.compression_ratio));
499                }
500                if let Some(idx) = self.params.codec_status {
501                    updates.push(ParamUpdate::int32(idx, 0)); // Success
502                }
503                if let Some(idx) = self.params.codec_error {
504                    updates.push(ParamUpdate::Octet {
505                        reason: idx,
506                        addr: 0,
507                        value: String::new(),
508                    });
509                }
510                let mut r = ProcessResult::arrays(vec![Arc::new(out.clone())]);
511                r.param_updates = updates;
512                r
513            }
514            None => {
515                self.compression_ratio = 1.0;
516                if let Some(idx) = self.params.comp_factor {
517                    updates.push(ParamUpdate::float64(idx, 0.0));
518                }
519                if let Some(idx) = self.params.codec_status {
520                    updates.push(ParamUpdate::int32(idx, 1)); // Error
521                }
522                if let Some(idx) = self.params.codec_error {
523                    updates.push(ParamUpdate::Octet {
524                        reason: idx,
525                        addr: 0,
526                        value: "codec operation failed or unsupported".to_string(),
527                    });
528                }
529                ProcessResult::sink(updates)
530            }
531        }
532    }
533
534    fn plugin_type(&self) -> &str {
535        "NDPluginCodec"
536    }
537
538    fn register_params(
539        &mut self,
540        base: &mut asyn_rs::port::PortDriverBase,
541    ) -> asyn_rs::error::AsynResult<()> {
542        use asyn_rs::param::ParamType;
543        base.create_param("MODE", ParamType::Int32)?;
544        base.create_param("COMPRESSOR", ParamType::Int32)?;
545        base.create_param("COMP_FACTOR", ParamType::Float64)?;
546        base.create_param("JPEG_QUALITY", ParamType::Int32)?;
547        base.create_param("BLOSC_COMPRESSOR", ParamType::Int32)?;
548        base.create_param("BLOSC_CLEVEL", ParamType::Int32)?;
549        base.create_param("BLOSC_SHUFFLE", ParamType::Int32)?;
550        base.create_param("BLOSC_NUMTHREADS", ParamType::Int32)?;
551        base.create_param("CODEC_STATUS", ParamType::Int32)?;
552        base.create_param("CODEC_ERROR", ParamType::Octet)?;
553
554        self.params.mode = base.find_param("MODE");
555        self.params.compressor = base.find_param("COMPRESSOR");
556        self.params.comp_factor = base.find_param("COMP_FACTOR");
557        self.params.jpeg_quality = base.find_param("JPEG_QUALITY");
558        self.params.blosc_compressor = base.find_param("BLOSC_COMPRESSOR");
559        self.params.blosc_clevel = base.find_param("BLOSC_CLEVEL");
560        self.params.blosc_shuffle = base.find_param("BLOSC_SHUFFLE");
561        self.params.blosc_numthreads = base.find_param("BLOSC_NUMTHREADS");
562        self.params.codec_status = base.find_param("CODEC_STATUS");
563        self.params.codec_error = base.find_param("CODEC_ERROR");
564        Ok(())
565    }
566
567    fn on_param_change(
568        &mut self,
569        reason: usize,
570        params: &ad_core_rs::plugin::runtime::PluginParamSnapshot,
571    ) -> ad_core_rs::plugin::runtime::ParamChangeResult {
572        if Some(reason) == self.params.mode {
573            let v = params.value.as_i32();
574            if v == 0 {
575                // Compress — keep current codec
576                let codec = match self.mode {
577                    CodecMode::Compress { codec, .. } => codec,
578                    _ => CodecName::LZ4,
579                };
580                self.mode = CodecMode::Compress {
581                    codec,
582                    quality: self.jpeg_quality,
583                };
584            } else {
585                self.mode = CodecMode::Decompress;
586            }
587        } else if Some(reason) == self.params.compressor {
588            // 0=None, 1=LZ4, 2=JPEG, 3=Blosc
589            let codec = match params.value.as_i32() {
590                1 => CodecName::LZ4,
591                2 => CodecName::JPEG,
592                3 => CodecName::Blosc,
593                _ => CodecName::LZ4,
594            };
595            if let CodecMode::Compress { .. } = self.mode {
596                self.mode = CodecMode::Compress {
597                    codec,
598                    quality: self.jpeg_quality,
599                };
600            }
601        } else if Some(reason) == self.params.jpeg_quality {
602            self.jpeg_quality = params.value.as_i32().clamp(1, 100) as u8;
603            if let CodecMode::Compress { codec, .. } = self.mode {
604                self.mode = CodecMode::Compress {
605                    codec,
606                    quality: self.jpeg_quality,
607                };
608            }
609        } else if Some(reason) == self.params.blosc_compressor {
610            self.blosc_config.compressor = params.value.as_i32().max(0) as u32;
611        } else if Some(reason) == self.params.blosc_clevel {
612            self.blosc_config.clevel = params.value.as_i32().clamp(0, 9) as u32;
613        } else if Some(reason) == self.params.blosc_shuffle {
614            self.blosc_config.shuffle = params.value.as_i32().max(0) as u32;
615        }
616
617        ad_core_rs::plugin::runtime::ParamChangeResult::updates(vec![])
618    }
619}
620
621#[cfg(test)]
622mod tests {
623    use super::*;
624
625    fn make_u8_array(width: usize, height: usize) -> NDArray {
626        let mut arr = NDArray::new(
627            vec![NDDimension::new(width), NDDimension::new(height)],
628            NDDataType::UInt8,
629        );
630        if let NDDataBuffer::U8(ref mut v) = arr.data {
631            for i in 0..v.len() {
632                v[i] = (i % 256) as u8;
633            }
634        }
635        arr
636    }
637
638    fn make_rgb_array(width: usize, height: usize) -> NDArray {
639        let mut arr = NDArray::new(
640            vec![
641                NDDimension::new(3),
642                NDDimension::new(width),
643                NDDimension::new(height),
644            ],
645            NDDataType::UInt8,
646        );
647        if let NDDataBuffer::U8(ref mut v) = arr.data {
648            for i in 0..v.len() {
649                v[i] = (i % 256) as u8;
650            }
651        }
652        arr
653    }
654
655    // ---- LZ4 tests ----
656
657    #[test]
658    fn test_lz4_roundtrip_u8() {
659        let arr = make_u8_array(4, 4);
660        let original_data = arr.data.as_u8_slice().to_vec();
661
662        let compressed = compress_lz4(&arr);
663        assert_eq!(compressed.codec.as_ref().unwrap().name, CodecName::LZ4);
664        // Data buffer should now be the compressed bytes
665        assert_ne!(compressed.data.as_u8_slice(), original_data.as_slice());
666
667        let decompressed = decompress_lz4(&compressed).unwrap();
668        assert!(decompressed.codec.is_none());
669        assert_eq!(decompressed.data.data_type(), NDDataType::UInt8);
670        assert_eq!(decompressed.data.as_u8_slice(), original_data.as_slice());
671    }
672
673    #[test]
674    fn test_lz4_roundtrip_u16() {
675        let mut arr = NDArray::new(
676            vec![NDDimension::new(8), NDDimension::new(8)],
677            NDDataType::UInt16,
678        );
679        if let NDDataBuffer::U16(ref mut v) = arr.data {
680            for i in 0..v.len() {
681                v[i] = (i * 100) as u16;
682            }
683        }
684        let original_bytes = arr.data.as_u8_slice().to_vec();
685
686        let compressed = compress_lz4(&arr);
687        assert_eq!(compressed.codec.as_ref().unwrap().name, CodecName::LZ4);
688        // The original data type attribute should be set
689        let dt_attr = compressed.attributes.get(ATTR_ORIGINAL_DATA_TYPE).unwrap();
690        assert_eq!(dt_attr.value, NDAttrValue::UInt8(NDDataType::UInt16 as u8));
691
692        let decompressed = decompress_lz4(&compressed).unwrap();
693        assert!(decompressed.codec.is_none());
694        assert_eq!(decompressed.data.data_type(), NDDataType::UInt16);
695        assert_eq!(decompressed.data.as_u8_slice(), original_bytes.as_slice());
696        // Attribute should be cleaned up
697        assert!(
698            decompressed
699                .attributes
700                .get(ATTR_ORIGINAL_DATA_TYPE)
701                .is_none()
702        );
703    }
704
705    #[test]
706    fn test_lz4_roundtrip_f64() {
707        let mut arr = NDArray::new(vec![NDDimension::new(16)], NDDataType::Float64);
708        if let NDDataBuffer::F64(ref mut v) = arr.data {
709            for i in 0..v.len() {
710                v[i] = i as f64 * 1.5;
711            }
712        }
713        let original_bytes = arr.data.as_u8_slice().to_vec();
714
715        let compressed = compress_lz4(&arr);
716        let decompressed = decompress_lz4(&compressed).unwrap();
717        assert_eq!(decompressed.data.data_type(), NDDataType::Float64);
718        assert_eq!(decompressed.data.as_u8_slice(), original_bytes.as_slice());
719    }
720
721    #[test]
722    fn test_lz4_compresses_repetitive_data() {
723        // Highly repetitive data should compress well
724        let mut arr = NDArray::new(
725            vec![NDDimension::new(256), NDDimension::new(256)],
726            NDDataType::UInt8,
727        );
728        // All zeros = very compressible
729        if let NDDataBuffer::U8(ref mut v) = arr.data {
730            for x in v.iter_mut() {
731                *x = 0;
732            }
733        }
734        let original_size = arr.data.as_u8_slice().len();
735
736        let compressed = compress_lz4(&arr);
737        let compressed_size = compressed.codec.as_ref().unwrap().compressed_size;
738        assert!(
739            compressed_size < original_size,
740            "compressed ({}) should be smaller than original ({})",
741            compressed_size,
742            original_size,
743        );
744    }
745
746    #[test]
747    fn test_lz4_preserves_metadata() {
748        let mut arr = make_u8_array(4, 4);
749        arr.unique_id = 42;
750
751        let compressed = compress_lz4(&arr);
752        assert_eq!(compressed.unique_id, 42);
753        assert_eq!(compressed.dims.len(), 2);
754        assert_eq!(compressed.dims[0].size, 4);
755        assert_eq!(compressed.dims[1].size, 4);
756    }
757
758    // ---- JPEG tests ----
759
760    #[test]
761    fn test_jpeg_compress_mono() {
762        let arr = make_u8_array(16, 16);
763        let compressed = compress_jpeg(&arr, 90).unwrap();
764        assert_eq!(compressed.codec.as_ref().unwrap().name, CodecName::JPEG);
765        // Compressed data should be valid JPEG (starts with SOI marker)
766        let data = compressed.data.as_u8_slice();
767        assert_eq!(&data[0..2], &[0xFF, 0xD8]);
768    }
769
770    #[test]
771    fn test_jpeg_compress_rgb() {
772        let arr = make_rgb_array(16, 16);
773        let compressed = compress_jpeg(&arr, 90).unwrap();
774        assert_eq!(compressed.codec.as_ref().unwrap().name, CodecName::JPEG);
775        let data = compressed.data.as_u8_slice();
776        assert_eq!(&data[0..2], &[0xFF, 0xD8]);
777    }
778
779    #[test]
780    fn test_jpeg_roundtrip_mono() {
781        let arr = make_u8_array(16, 16);
782        let compressed = compress_jpeg(&arr, 100).unwrap();
783        let decompressed = decompress_jpeg(&compressed).unwrap();
784        assert!(decompressed.codec.is_none());
785        assert_eq!(decompressed.dims.len(), 2);
786        assert_eq!(decompressed.dims[0].size, 16); // width
787        assert_eq!(decompressed.dims[1].size, 16); // height
788        assert_eq!(decompressed.data.data_type(), NDDataType::UInt8);
789        // JPEG is lossy, so data won't be identical, but dimensions match
790        assert_eq!(decompressed.data.len(), 16 * 16);
791    }
792
793    #[test]
794    fn test_jpeg_roundtrip_rgb() {
795        let arr = make_rgb_array(16, 16);
796        let compressed = compress_jpeg(&arr, 100).unwrap();
797        let decompressed = decompress_jpeg(&compressed).unwrap();
798        assert!(decompressed.codec.is_none());
799        assert_eq!(decompressed.dims.len(), 3);
800        assert_eq!(decompressed.dims[0].size, 3); // color
801        assert_eq!(decompressed.dims[1].size, 16); // width
802        assert_eq!(decompressed.dims[2].size, 16); // height
803        assert_eq!(decompressed.data.len(), 3 * 16 * 16);
804    }
805
806    #[test]
807    fn test_jpeg_rejects_non_u8() {
808        let arr = NDArray::new(
809            vec![NDDimension::new(8), NDDimension::new(8)],
810            NDDataType::UInt16,
811        );
812        assert!(compress_jpeg(&arr, 90).is_none());
813    }
814
815    #[test]
816    fn test_jpeg_rejects_1d() {
817        let arr = NDArray::new(vec![NDDimension::new(64)], NDDataType::UInt8);
818        assert!(compress_jpeg(&arr, 90).is_none());
819    }
820
821    #[test]
822    fn test_jpeg_quality_affects_size() {
823        let arr = make_u8_array(64, 64);
824        let high = compress_jpeg(&arr, 95).unwrap();
825        let low = compress_jpeg(&arr, 10).unwrap();
826        let high_size = high.codec.as_ref().unwrap().compressed_size;
827        let low_size = low.codec.as_ref().unwrap().compressed_size;
828        assert!(
829            high_size > low_size,
830            "high quality ({}) should produce larger output than low quality ({})",
831            high_size,
832            low_size,
833        );
834    }
835
836    // ---- Decompress wrong codec ----
837
838    #[test]
839    fn test_decompress_wrong_codec() {
840        let arr = make_u8_array(4, 4);
841        assert!(decompress_lz4(&arr).is_none());
842        assert!(decompress_jpeg(&arr).is_none());
843    }
844
845    // ---- CodecProcessor tests ----
846
847    #[test]
848    fn test_processor_lz4_compress() {
849        let pool = NDArrayPool::new(1_000_000);
850        let mut proc = CodecProcessor::new(CodecMode::Compress {
851            codec: CodecName::LZ4,
852            quality: 0,
853        });
854        let arr = make_u8_array(32, 32);
855        let result = proc.process_array(&arr, &pool);
856        assert_eq!(result.output_arrays.len(), 1);
857        assert_eq!(
858            result.output_arrays[0].codec.as_ref().unwrap().name,
859            CodecName::LZ4
860        );
861        assert!(proc.compression_ratio() >= 1.0);
862    }
863
864    #[test]
865    fn test_processor_jpeg_compress() {
866        let pool = NDArrayPool::new(1_000_000);
867        let mut proc = CodecProcessor::new(CodecMode::Compress {
868            codec: CodecName::JPEG,
869            quality: 80,
870        });
871        let arr = make_u8_array(16, 16);
872        let result = proc.process_array(&arr, &pool);
873        assert_eq!(result.output_arrays.len(), 1);
874        assert_eq!(
875            result.output_arrays[0].codec.as_ref().unwrap().name,
876            CodecName::JPEG
877        );
878    }
879
880    #[test]
881    fn test_processor_decompress_auto_lz4() {
882        let pool = NDArrayPool::new(1_000_000);
883        let arr = make_u8_array(16, 16);
884        let compressed = compress_lz4(&arr);
885
886        let mut proc = CodecProcessor::new(CodecMode::Decompress);
887        let result = proc.process_array(&compressed, &pool);
888        assert_eq!(result.output_arrays.len(), 1);
889        assert!(result.output_arrays[0].codec.is_none());
890        assert_eq!(
891            result.output_arrays[0].data.as_u8_slice(),
892            arr.data.as_u8_slice()
893        );
894        assert!(proc.compression_ratio() > 0.0);
895    }
896
897    #[test]
898    fn test_processor_decompress_auto_jpeg() {
899        let pool = NDArrayPool::new(1_000_000);
900        let arr = make_u8_array(16, 16);
901        let compressed = compress_jpeg(&arr, 90).unwrap();
902
903        let mut proc = CodecProcessor::new(CodecMode::Decompress);
904        let result = proc.process_array(&compressed, &pool);
905        assert_eq!(result.output_arrays.len(), 1);
906        assert!(result.output_arrays[0].codec.is_none());
907    }
908
909    #[test]
910    fn test_processor_decompress_no_codec() {
911        let pool = NDArrayPool::new(1_000_000);
912        let arr = make_u8_array(8, 8);
913        let mut proc = CodecProcessor::new(CodecMode::Decompress);
914        let result = proc.process_array(&arr, &pool);
915        assert!(result.output_arrays.is_empty());
916        assert_eq!(proc.compression_ratio(), 1.0);
917    }
918
919    #[test]
920    fn test_processor_compression_ratio() {
921        let pool = NDArrayPool::new(1_000_000);
922        // Create highly compressible data (all zeros)
923        let mut arr = NDArray::new(
924            vec![NDDimension::new(128), NDDimension::new(128)],
925            NDDataType::UInt8,
926        );
927        if let NDDataBuffer::U8(ref mut v) = arr.data {
928            for x in v.iter_mut() {
929                *x = 0;
930            }
931        }
932
933        let mut proc = CodecProcessor::new(CodecMode::Compress {
934            codec: CodecName::LZ4,
935            quality: 0,
936        });
937        let _ = proc.process_array(&arr, &pool);
938        let ratio = proc.compression_ratio();
939        assert!(
940            ratio > 2.0,
941            "all-zeros 128x128 should compress at least 2x, got {}",
942            ratio,
943        );
944    }
945
946    #[test]
947    fn test_processor_plugin_type() {
948        let proc = CodecProcessor::new(CodecMode::Decompress);
949        assert_eq!(proc.plugin_type(), "NDPluginCodec");
950    }
951
952    // ---- buffer_from_bytes tests ----
953
954    #[test]
955    fn test_buffer_from_bytes_u8() {
956        let data = vec![1u8, 2, 3, 4];
957        let buf = buffer_from_bytes(&data, NDDataType::UInt8).unwrap();
958        assert_eq!(buf.data_type(), NDDataType::UInt8);
959        assert_eq!(buf.len(), 4);
960        assert_eq!(buf.as_u8_slice(), &[1, 2, 3, 4]);
961    }
962
963    #[test]
964    fn test_buffer_from_bytes_u16() {
965        let original = vec![1000u16, 2000, 3000];
966        let bytes: Vec<u8> = original.iter().flat_map(|v| v.to_ne_bytes()).collect();
967        let buf = buffer_from_bytes(&bytes, NDDataType::UInt16).unwrap();
968        assert_eq!(buf.data_type(), NDDataType::UInt16);
969        assert_eq!(buf.len(), 3);
970        if let NDDataBuffer::U16(v) = buf {
971            assert_eq!(v, original);
972        } else {
973            panic!("wrong buffer type");
974        }
975    }
976
977    #[test]
978    fn test_buffer_from_bytes_bad_alignment() {
979        // 3 bytes can't form a u16 array
980        let data = vec![0u8; 3];
981        assert!(buffer_from_bytes(&data, NDDataType::UInt16).is_none());
982    }
983
984    #[test]
985    fn test_buffer_from_bytes_f64_roundtrip() {
986        let original = vec![1.5f64, -2.7, 3.14159];
987        let bytes: Vec<u8> = original.iter().flat_map(|v| v.to_ne_bytes()).collect();
988        let buf = buffer_from_bytes(&bytes, NDDataType::Float64).unwrap();
989        if let NDDataBuffer::F64(v) = buf {
990            assert_eq!(v, original);
991        } else {
992            panic!("wrong buffer type");
993        }
994    }
995}