Skip to main content

ad_plugins_rs/
file_jpeg.rs

1use std::path::{Path, PathBuf};
2
3use ad_core_rs::color::{NDColorMode, convert_rgb_layout};
4use ad_core_rs::error::{ADError, ADResult};
5use ad_core_rs::ndarray::{NDArray, NDDataBuffer, NDDataType, NDDimension};
6use ad_core_rs::ndarray_pool::NDArrayPool;
7use ad_core_rs::plugin::file_base::{NDFileMode, NDFileWriter};
8use ad_core_rs::plugin::file_controller::FilePluginController;
9use ad_core_rs::plugin::runtime::{
10    NDPluginProcess, ParamChangeResult, PluginParamSnapshot, ProcessResult,
11};
12
13use jpeg_encoder::{ColorType as JpegColorType, Encoder as JpegEncoder};
14
15/// JPEG file writer using `jpeg-encoder` for encoding and `jpeg-decoder` for decoding.
16pub struct JpegWriter {
17    current_path: Option<PathBuf>,
18    pub(crate) quality: u8,
19}
20
21impl JpegWriter {
22    pub fn new(quality: u8) -> Self {
23        Self {
24            current_path: None,
25            quality,
26        }
27    }
28
29    pub fn set_quality(&mut self, quality: u8) {
30        self.quality = quality;
31    }
32}
33
34impl NDFileWriter for JpegWriter {
35    fn open_file(&mut self, path: &Path, _mode: NDFileMode, array: &NDArray) -> ADResult<()> {
36        let dt = array.data.data_type();
37        if dt != NDDataType::UInt8 && dt != NDDataType::Int8 {
38            return Err(ADError::UnsupportedConversion(
39                "JPEG only supports UInt8/Int8 data".into(),
40            ));
41        }
42        self.current_path = Some(path.to_path_buf());
43        Ok(())
44    }
45
46    fn write_file(&mut self, array: &NDArray) -> ADResult<()> {
47        let path = self
48            .current_path
49            .as_ref()
50            .ok_or_else(|| ADError::UnsupportedConversion("no file open".into()))?;
51
52        // Detect color mode and convert RGB2/RGB3 to RGB1 if needed
53        let color_mode = array
54            .attributes
55            .get("ColorMode")
56            .and_then(|attr| attr.value.as_i64())
57            .map(|v| NDColorMode::from_i32(v as i32))
58            .unwrap_or_else(|| {
59                if array.dims.len() == 3 {
60                    let d0 = array.dims[0].size;
61                    let d1 = array.dims[1].size;
62                    let d2 = array.dims[2].size;
63                    if d0 == 3 {
64                        NDColorMode::RGB1
65                    } else if d1 == 3 {
66                        NDColorMode::RGB2
67                    } else if d2 == 3 {
68                        NDColorMode::RGB3
69                    } else {
70                        NDColorMode::Mono
71                    }
72                } else {
73                    NDColorMode::Mono
74                }
75            });
76
77        let is_rgb = matches!(
78            color_mode,
79            NDColorMode::RGB1 | NDColorMode::RGB2 | NDColorMode::RGB3
80        );
81        let src = if is_rgb && color_mode != NDColorMode::RGB1 {
82            &convert_rgb_layout(array, color_mode, NDColorMode::RGB1)?
83        } else {
84            array
85        };
86
87        let info = src.info();
88        let width = info.x_size;
89        let height = info.y_size;
90
91        let data: Vec<u8> = match &src.data {
92            NDDataBuffer::U8(v) => v.clone(),
93            NDDataBuffer::I8(v) => v.iter().map(|&b| b as u8).collect(),
94            _ => {
95                return Err(ADError::UnsupportedConversion(
96                    "JPEG only supports UInt8/Int8".into(),
97                ));
98            }
99        };
100
101        let color_type = if info.color_size == 3 {
102            JpegColorType::Rgb
103        } else {
104            JpegColorType::Luma
105        };
106
107        let mut buf = Vec::new();
108        let encoder = JpegEncoder::new(&mut buf, self.quality);
109        encoder
110            .encode(&data, width as u16, height as u16, color_type)
111            .map_err(|e| ADError::UnsupportedConversion(format!("JPEG encode error: {}", e)))?;
112
113        std::fs::write(path, &buf)?;
114        Ok(())
115    }
116
117    fn read_file(&mut self) -> ADResult<NDArray> {
118        let path = self
119            .current_path
120            .as_ref()
121            .ok_or_else(|| ADError::UnsupportedConversion("no file open".into()))?;
122
123        let file_data = std::fs::read(path)?;
124        let mut decoder = jpeg_decoder::Decoder::new(&file_data[..]);
125        let pixels = decoder
126            .decode()
127            .map_err(|e| ADError::UnsupportedConversion(format!("JPEG decode error: {}", e)))?;
128        let info = decoder.info().unwrap();
129
130        let (width, height) = (info.width as usize, info.height as usize);
131
132        let dims = match info.pixel_format {
133            jpeg_decoder::PixelFormat::L8 => {
134                vec![NDDimension::new(width), NDDimension::new(height)]
135            }
136            jpeg_decoder::PixelFormat::RGB24 => {
137                vec![
138                    NDDimension::new(3),
139                    NDDimension::new(width),
140                    NDDimension::new(height),
141                ]
142            }
143            _ => {
144                return Err(ADError::UnsupportedConversion(
145                    "unsupported JPEG pixel format".into(),
146                ));
147            }
148        };
149
150        let mut arr = NDArray::new(dims, NDDataType::UInt8);
151        arr.data = NDDataBuffer::U8(pixels);
152        Ok(arr)
153    }
154
155    fn close_file(&mut self) -> ADResult<()> {
156        self.current_path = None;
157        Ok(())
158    }
159
160    fn supports_multiple_arrays(&self) -> bool {
161        false
162    }
163}
164
165/// JPEG file processor wrapping FilePluginController<JpegWriter>.
166pub struct JpegFileProcessor {
167    ctrl: FilePluginController<JpegWriter>,
168    jpeg_quality_idx: Option<usize>,
169}
170
171impl JpegFileProcessor {
172    pub fn new(quality: u8) -> Self {
173        Self {
174            ctrl: FilePluginController::new(JpegWriter::new(quality)),
175            jpeg_quality_idx: None,
176        }
177    }
178}
179
180impl Default for JpegFileProcessor {
181    fn default() -> Self {
182        Self::new(50)
183    }
184}
185
186impl NDPluginProcess for JpegFileProcessor {
187    fn process_array(&mut self, array: &NDArray, _pool: &NDArrayPool) -> ProcessResult {
188        self.ctrl.process_array(array)
189    }
190
191    fn plugin_type(&self) -> &str {
192        "NDFileJPEG"
193    }
194
195    /// C `NDPluginFile.cpp:948` (base of every file writer) sets
196    /// `NDArrayCallbacks = 0`: file plugins write to disk, not downstream.
197    fn does_array_callbacks(&self) -> bool {
198        false
199    }
200
201    fn register_params(
202        &mut self,
203        base: &mut asyn_rs::port::PortDriverBase,
204    ) -> asyn_rs::error::AsynResult<()> {
205        self.ctrl.register_params(base)?;
206        use asyn_rs::param::ParamType;
207        let idx = base.create_param("JPEG_QUALITY", ParamType::Int32)?;
208        // Seed the readback PV with the actual encoder default (C++ NDFileJPEG.cpp:327
209        // sets NDFileJPEGQuality default to 50). Without this the PV reads 0 while the
210        // encoder uses its constructed default, so PV and effective quality disagree.
211        base.set_int32_param(idx, 0, i32::from(self.ctrl.writer.quality))?;
212        self.jpeg_quality_idx = Some(idx);
213        Ok(())
214    }
215
216    fn on_param_change(
217        &mut self,
218        reason: usize,
219        params: &PluginParamSnapshot,
220    ) -> ParamChangeResult {
221        // JPEG-specific: quality change
222        if Some(reason) == self.jpeg_quality_idx {
223            let q = params.value.as_i32().clamp(1, 100) as u8;
224            self.ctrl.writer.set_quality(q);
225            return ParamChangeResult::empty();
226        }
227        self.ctrl.on_param_change(reason, params)
228    }
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234    use ad_core_rs::ndarray::{NDDataBuffer, NDDimension};
235    use std::sync::atomic::{AtomicU32, Ordering};
236
237    static TEST_COUNTER: AtomicU32 = AtomicU32::new(0);
238
239    fn temp_path(prefix: &str) -> PathBuf {
240        let n = TEST_COUNTER.fetch_add(1, Ordering::Relaxed);
241        std::env::temp_dir().join(format!("adcore_test_{}_{}.jpg", prefix, n))
242    }
243
244    #[test]
245    fn test_write_u8() {
246        let path = temp_path("jpeg");
247        let mut writer = JpegWriter::new(90);
248
249        let mut arr = NDArray::new(
250            vec![NDDimension::new(8), NDDimension::new(8)],
251            NDDataType::UInt8,
252        );
253        if let NDDataBuffer::U8(ref mut v) = arr.data {
254            for i in 0..64 {
255                v[i] = (i * 4) as u8;
256            }
257        }
258
259        writer.open_file(&path, NDFileMode::Single, &arr).unwrap();
260        writer.write_file(&arr).unwrap();
261        writer.close_file().unwrap();
262
263        let data = std::fs::read(&path).unwrap();
264        // Check JPEG SOI marker
265        assert_eq!(&data[0..2], &[0xFF, 0xD8]);
266        // Check JPEG EOI marker at end
267        assert_eq!(&data[data.len() - 2..], &[0xFF, 0xD9]);
268
269        std::fs::remove_file(&path).ok();
270    }
271
272    #[test]
273    fn test_rejects_non_u8() {
274        let path = temp_path("jpeg_u16");
275        let mut writer = JpegWriter::new(90);
276
277        let arr = NDArray::new(
278            vec![NDDimension::new(4), NDDimension::new(4)],
279            NDDataType::UInt16,
280        );
281
282        let result = writer.open_file(&path, NDFileMode::Single, &arr);
283        assert!(result.is_err());
284    }
285
286    #[test]
287    fn test_quality_affects_size() {
288        let path_high = temp_path("jpeg_hi");
289        let path_low = temp_path("jpeg_lo");
290
291        let mut arr = NDArray::new(
292            vec![NDDimension::new(32), NDDimension::new(32)],
293            NDDataType::UInt8,
294        );
295        if let NDDataBuffer::U8(ref mut v) = arr.data {
296            for i in 0..v.len() {
297                v[i] = (i % 256) as u8;
298            }
299        }
300
301        let mut writer_high = JpegWriter::new(95);
302        writer_high
303            .open_file(&path_high, NDFileMode::Single, &arr)
304            .unwrap();
305        writer_high.write_file(&arr).unwrap();
306        writer_high.close_file().unwrap();
307
308        let mut writer_low = JpegWriter::new(10);
309        writer_low
310            .open_file(&path_low, NDFileMode::Single, &arr)
311            .unwrap();
312        writer_low.write_file(&arr).unwrap();
313        writer_low.close_file().unwrap();
314
315        let size_high = std::fs::metadata(&path_high).unwrap().len();
316        let size_low = std::fs::metadata(&path_low).unwrap().len();
317        assert!(
318            size_high > size_low,
319            "high quality ({}) should be larger than low quality ({})",
320            size_high,
321            size_low
322        );
323
324        std::fs::remove_file(&path_high).ok();
325        std::fs::remove_file(&path_low).ok();
326    }
327
328    #[test]
329    fn test_default_quality_is_50() {
330        // C++ NDFileJPEG.cpp:327 default quality is 50.
331        assert_eq!(JpegFileProcessor::default().ctrl.writer.quality, 50);
332        assert_eq!(JpegWriter::new(50).quality, 50);
333    }
334
335    #[test]
336    fn test_register_params_seeds_quality_pv() {
337        use asyn_rs::port::{PortDriverBase, PortFlags};
338        let mut base = PortDriverBase::new("jpeg_param_test", 1, PortFlags::default());
339        let mut proc = JpegFileProcessor::new(50);
340        proc.register_params(&mut base).unwrap();
341        let idx = proc.jpeg_quality_idx.unwrap();
342        // Readback PV must equal the encoder's effective quality, not 0.
343        assert_eq!(base.get_int32_param(idx, 0).unwrap(), 50);
344    }
345
346    #[test]
347    fn test_roundtrip_luma() {
348        let path = temp_path("jpeg_rt");
349        let mut writer = JpegWriter::new(100);
350
351        let mut arr = NDArray::new(
352            vec![NDDimension::new(8), NDDimension::new(8)],
353            NDDataType::UInt8,
354        );
355        if let NDDataBuffer::U8(ref mut v) = arr.data {
356            // Use uniform value so JPEG compression is lossless at quality 100
357            for i in 0..64 {
358                v[i] = 128;
359            }
360        }
361
362        writer.open_file(&path, NDFileMode::Single, &arr).unwrap();
363        writer.write_file(&arr).unwrap();
364
365        let read_back = writer.read_file().unwrap();
366        assert_eq!(read_back.data.data_type(), NDDataType::UInt8);
367        if let NDDataBuffer::U8(ref v) = read_back.data {
368            // With uniform input at max quality, decoded values should be close
369            for &px in v.iter() {
370                assert!(
371                    (px as i16 - 128).unsigned_abs() < 5,
372                    "pixel {} too far from 128",
373                    px
374                );
375            }
376        }
377
378        writer.close_file().unwrap();
379        std::fs::remove_file(&path).ok();
380    }
381
382    /// Write an RGB-mode array to JPEG and return the decoded array's dims.
383    fn jpeg_roundtrip_dims(prefix: &str, mode: NDColorMode, dims: Vec<usize>) -> Vec<usize> {
384        use ad_core_rs::attributes::{NDAttrSource, NDAttrValue, NDAttribute};
385        let path = temp_path(prefix);
386        let mut writer = JpegWriter::new(95);
387
388        let mut arr = NDArray::new(
389            dims.iter().map(|&d| NDDimension::new(d)).collect(),
390            NDDataType::UInt8,
391        );
392        arr.attributes.add(NDAttribute::new_static(
393            "ColorMode",
394            "Color mode",
395            NDAttrSource::Driver,
396            NDAttrValue::Int32(mode as i32),
397        ));
398        if let NDDataBuffer::U8(ref mut v) = arr.data {
399            for (i, x) in v.iter_mut().enumerate() {
400                *x = (i % 256) as u8;
401            }
402        }
403
404        writer.open_file(&path, NDFileMode::Single, &arr).unwrap();
405        writer.write_file(&arr).unwrap();
406        let read_back = writer.read_file().unwrap();
407        writer.close_file().unwrap();
408        std::fs::remove_file(&path).ok();
409        read_back.dims.iter().map(|d| d.size).collect()
410    }
411
412    #[test]
413    fn test_adp12_rgb2_jpeg_written_as_rgb_not_grayscale() {
414        // RGB2 [x=5, c=3, y=4]: C writes width=5, height=4, 3 JCS_RGB
415        // components (NDFileJPEG.cpp:67-78). The Rust converts to RGB1 first;
416        // before the fix the stale ColorMode=RGB2 attribute made info() read
417        // the RGB1 dims as width=3, color=5 -> a 3x4 grayscale JPEG. Decoded
418        // dims must be RGB24 [3, 5, 4], not grayscale [3, 4].
419        let dims = jpeg_roundtrip_dims("jpeg_rgb2", NDColorMode::RGB2, vec![5, 3, 4]);
420        assert_eq!(dims, vec![3, 5, 4]);
421    }
422
423    #[test]
424    fn test_adp12_rgb3_jpeg_written_as_rgb_not_grayscale() {
425        // RGB3 [x=5, y=4, c=3]: C writes width=5, height=4, 3 components
426        // (NDFileJPEG.cpp:72-78). Decoded dims must be RGB24 [3, 5, 4].
427        let dims = jpeg_roundtrip_dims("jpeg_rgb3", NDColorMode::RGB3, vec![5, 4, 3]);
428        assert_eq!(dims, vec![3, 5, 4]);
429    }
430}