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