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};
14use parking_lot::Mutex;
15
16/// JPEG file writer using `jpeg-encoder` for encoding and `jpeg-decoder` for decoding.
17pub struct JpegWriter {
18    current_path: Option<PathBuf>,
19    pub(crate) quality: u8,
20}
21
22impl JpegWriter {
23    pub fn new(quality: u8) -> Self {
24        Self {
25            current_path: None,
26            quality,
27        }
28    }
29
30    pub fn set_quality(&mut self, quality: u8) {
31        self.quality = quality;
32    }
33}
34
35impl NDFileWriter for JpegWriter {
36    fn open_file(&mut self, path: &Path, _mode: NDFileMode, array: &NDArray) -> ADResult<()> {
37        let dt = array.data.data_type();
38        if dt != NDDataType::UInt8 && dt != NDDataType::Int8 {
39            return Err(ADError::UnsupportedConversion(
40                "JPEG only supports UInt8/Int8 data".into(),
41            ));
42        }
43        self.current_path = Some(path.to_path_buf());
44        Ok(())
45    }
46
47    fn write_file(&mut self, array: &NDArray) -> ADResult<()> {
48        let path = self
49            .current_path
50            .as_ref()
51            .ok_or_else(|| ADError::UnsupportedConversion("no file open".into()))?;
52
53        // The ColorMode *attribute* is the only source of truth, defaulting to
54        // Mono when it is absent — C `int colorMode = NDColorModeMono;`
55        // (NDFileJPEG.cpp:29) overwritten only by the attribute (:52-53).
56        // `info().color_mode` is that rule's single owner.
57        let color_mode = array.info().color_mode;
58
59        // C's `openFile` structure chain (NDFileJPEG.cpp:55-84). Each 3-D branch
60        // requires the *attribute* to name the layout, so a 3-D array with no
61        // ColorMode attribute (colorMode stays Mono) matches nothing and C returns
62        // asynError (:79-84). Inferring the layout from the dimensions instead
63        // made such an array look like RGB1 and write a file.
64        //
65        // C also takes `this->colorMode` from the branch it took, not from the
66        // attribute: the 2-D branch forces Mono (:60). And unlike
67        // NDFileTIFF.cpp:180 there is no ndims == 1 branch here — a 1-D array is an error.
68        let color_mode = match array.dims.as_slice() {
69            [_, _] => NDColorMode::Mono,
70            [c, _, _] if c.size == 3 && color_mode == NDColorMode::RGB1 => NDColorMode::RGB1,
71            [_, c, _] if c.size == 3 && color_mode == NDColorMode::RGB2 => NDColorMode::RGB2,
72            [_, _, c] if c.size == 3 && color_mode == NDColorMode::RGB3 => NDColorMode::RGB3,
73            _ => {
74                return Err(ADError::InvalidDimensions(
75                    "unsupported array structure".into(),
76                ));
77            }
78        };
79
80        let is_rgb = matches!(
81            color_mode,
82            NDColorMode::RGB1 | NDColorMode::RGB2 | NDColorMode::RGB3
83        );
84        let src = if is_rgb && color_mode != NDColorMode::RGB1 {
85            &convert_rgb_layout(array, color_mode, NDColorMode::RGB1)?
86        } else {
87            array
88        };
89
90        let info = src.info();
91        let width = info.x_size;
92        let height = info.y_size;
93
94        let data: Vec<u8> = match &src.data {
95            NDDataBuffer::U8(v) => v.clone(),
96            NDDataBuffer::I8(v) => v.iter().map(|&b| b as u8).collect(),
97            _ => {
98                return Err(ADError::UnsupportedConversion(
99                    "JPEG only supports UInt8/Int8".into(),
100                ));
101            }
102        };
103
104        let color_type = if info.color_size == 3 {
105            JpegColorType::Rgb
106        } else {
107            JpegColorType::Luma
108        };
109
110        let mut buf = Vec::new();
111        let encoder = JpegEncoder::new(&mut buf, self.quality);
112        encoder
113            .encode(&data, width as u16, height as u16, color_type)
114            .map_err(|e| ADError::UnsupportedConversion(format!("JPEG encode error: {}", e)))?;
115
116        std::fs::write(path, &buf)?;
117        Ok(())
118    }
119
120    fn read_file(&mut self) -> ADResult<NDArray> {
121        let path = self
122            .current_path
123            .as_ref()
124            .ok_or_else(|| ADError::UnsupportedConversion("no file open".into()))?;
125
126        let file_data = std::fs::read(path)?;
127        let mut decoder = jpeg_decoder::Decoder::new(&file_data[..]);
128        let pixels = decoder
129            .decode()
130            .map_err(|e| ADError::UnsupportedConversion(format!("JPEG decode error: {}", e)))?;
131        let info = decoder.info().unwrap();
132
133        let (width, height) = (info.width as usize, info.height as usize);
134
135        let dims = match info.pixel_format {
136            jpeg_decoder::PixelFormat::L8 => {
137                vec![NDDimension::new(width), NDDimension::new(height)]
138            }
139            jpeg_decoder::PixelFormat::RGB24 => {
140                vec![
141                    NDDimension::new(3),
142                    NDDimension::new(width),
143                    NDDimension::new(height),
144                ]
145            }
146            _ => {
147                return Err(ADError::UnsupportedConversion(
148                    "unsupported JPEG pixel format".into(),
149                ));
150            }
151        };
152
153        let mut arr = NDArray::new(dims, NDDataType::UInt8);
154        arr.data = NDDataBuffer::U8(pixels);
155        Ok(arr)
156    }
157
158    fn close_file(&mut self) -> ADResult<()> {
159        self.current_path = None;
160        Ok(())
161    }
162
163    fn supports_multiple_arrays(&self) -> bool {
164        false
165    }
166}
167
168/// JPEG file processor wrapping `FilePluginController<JpegWriter>`.
169pub struct JpegFileProcessor {
170    ctrl: Mutex<FilePluginController<JpegWriter>>,
171    jpeg_quality_idx: Option<usize>,
172}
173
174impl JpegFileProcessor {
175    pub fn new(quality: u8) -> Self {
176        Self {
177            ctrl: Mutex::new(FilePluginController::new(JpegWriter::new(quality))),
178            jpeg_quality_idx: None,
179        }
180    }
181}
182
183impl Default for JpegFileProcessor {
184    fn default() -> Self {
185        Self::new(50)
186    }
187}
188
189impl NDPluginProcess for JpegFileProcessor {
190    fn process_array(&self, array: &NDArray, _pool: &NDArrayPool) -> ProcessResult {
191        self.ctrl.lock().process_array(array)
192    }
193
194    fn plugin_type(&self) -> &str {
195        "NDFileJPEG"
196    }
197
198    /// C `NDPluginFile.cpp:948` (base of every file writer) sets
199    /// `NDArrayCallbacks = 0`: file plugins write to disk, not downstream.
200    fn does_array_callbacks(&self) -> bool {
201        false
202    }
203
204    fn register_params(
205        &mut self,
206        base: &mut asyn_rs::port::PortDriverBase,
207    ) -> asyn_rs::error::AsynResult<()> {
208        self.ctrl.lock().register_params(base)?;
209        use asyn_rs::param::ParamType;
210        let idx = base.create_param("JPEG_QUALITY", ParamType::Int32)?;
211        // Seed the readback PV with the actual encoder default (C++ NDFileJPEG.cpp:327
212        // sets NDFileJPEGQuality default to 50). Without this the PV reads 0 while the
213        // encoder uses its constructed default, so PV and effective quality disagree.
214        base.set_int32_param(idx, 0, i32::from(self.ctrl.lock().writer.quality))?;
215        self.jpeg_quality_idx = Some(idx);
216        Ok(())
217    }
218
219    fn on_param_change(&self, reason: usize, params: &PluginParamSnapshot) -> ParamChangeResult {
220        // JPEG-specific: quality change
221        if Some(reason) == self.jpeg_quality_idx {
222            let q = params.value.as_i32().clamp(1, 100) as u8;
223            self.ctrl.lock().writer.set_quality(q);
224            return ParamChangeResult::empty();
225        }
226        self.ctrl.lock().on_param_change(reason, params)
227    }
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233    use ad_core_rs::ndarray::{NDDataBuffer, NDDimension};
234    use std::sync::atomic::{AtomicU32, Ordering};
235
236    static TEST_COUNTER: AtomicU32 = AtomicU32::new(0);
237
238    fn temp_path(prefix: &str) -> PathBuf {
239        let n = TEST_COUNTER.fetch_add(1, Ordering::Relaxed);
240        std::env::temp_dir().join(format!(
241            "adcore_test_{}_{}_{}.jpg",
242            std::process::id(),
243            prefix,
244            n
245        ))
246    }
247
248    /// R8-75, JPEG writer: same defect as the cited TIFF site. C defaults
249    /// `colorMode` to Mono (NDFileJPEG.cpp:29), overwrites it only from the
250    /// attribute (:52-53), and each 3-D branch requires the attribute (:61, :67,
251    /// :73) — so a 3-D array without ColorMode returns asynError (:79-84). The
252    /// port inferred RGB1 from the dims. A 1-D array is an error here too: C has
253    /// no ndims == 1 branch in this writer.
254    #[test]
255    fn test_r8_75_3d_without_colormode_attribute_is_an_error() {
256        use ad_core_rs::attributes::{NDAttrSource, NDAttrValue, NDAttribute};
257        use ad_core_rs::color::NDColorMode;
258
259        let rgb1_dims = || {
260            vec![
261                NDDimension::new(3),
262                NDDimension::new(4),
263                NDDimension::new(4),
264            ]
265        };
266
267        let arr = NDArray::new(rgb1_dims(), NDDataType::UInt8);
268        let path = temp_path("jpeg_3d_no_colormode");
269        let mut writer = JpegWriter::new(90);
270        writer
271            .open_file(&path, NDFileMode::Single, &arr)
272            .expect("open");
273        let err = writer.write_file(&arr).unwrap_err();
274        assert!(
275            matches!(err, ADError::InvalidDimensions(_)),
276            "3-D without ColorMode must be rejected, got {err:?}"
277        );
278        // ADP-95(b): C rejects in `openFile` (NDFileJPEG.cpp:79-84), before any write, so no file
279        // exists. The port decides one call later but must leave the same
280        // absence — a caller branching on the error must not find a file.
281        assert!(
282            !path.exists(),
283            "a rejected array must leave no file: {}",
284            path.display()
285        );
286        std::fs::remove_file(&path).ok();
287
288        // C has no ndims == 1 branch (unlike NDFileTIFF.cpp:180).
289        let arr = NDArray::new(vec![NDDimension::new(16)], NDDataType::UInt8);
290        let path = temp_path("jpeg_1d");
291        let mut writer = JpegWriter::new(90);
292        writer
293            .open_file(&path, NDFileMode::Single, &arr)
294            .expect("open");
295        assert!(matches!(
296            writer.write_file(&arr).unwrap_err(),
297            ADError::InvalidDimensions(_)
298        ));
299        std::fs::remove_file(&path).ok();
300
301        // Positive control: WITH ColorMode=RGB1 the same array writes.
302        let mut arr = NDArray::new(rgb1_dims(), NDDataType::UInt8);
303        arr.attributes.add(NDAttribute::new_static(
304            "ColorMode",
305            "",
306            NDAttrSource::Driver,
307            NDAttrValue::Int32(NDColorMode::RGB1 as i32),
308        ));
309        let path = temp_path("jpeg_3d_rgb1");
310        let mut writer = JpegWriter::new(90);
311        writer
312            .open_file(&path, NDFileMode::Single, &arr)
313            .expect("open");
314        writer
315            .write_file(&arr)
316            .expect("3-D WITH ColorMode=RGB1 must still write");
317        assert!(path.exists());
318        std::fs::remove_file(&path).ok();
319    }
320
321    #[test]
322    fn test_write_u8() {
323        let path = temp_path("jpeg");
324        let mut writer = JpegWriter::new(90);
325
326        let mut arr = NDArray::new(
327            vec![NDDimension::new(8), NDDimension::new(8)],
328            NDDataType::UInt8,
329        );
330        if let NDDataBuffer::U8(ref mut v) = arr.data {
331            for i in 0..64 {
332                v[i] = (i * 4) as u8;
333            }
334        }
335
336        writer.open_file(&path, NDFileMode::Single, &arr).unwrap();
337        writer.write_file(&arr).unwrap();
338        writer.close_file().unwrap();
339
340        let data = std::fs::read(&path).unwrap();
341        // Check JPEG SOI marker
342        assert_eq!(&data[0..2], &[0xFF, 0xD8]);
343        // Check JPEG EOI marker at end
344        assert_eq!(&data[data.len() - 2..], &[0xFF, 0xD9]);
345
346        std::fs::remove_file(&path).ok();
347    }
348
349    #[test]
350    fn test_rejects_non_u8() {
351        let path = temp_path("jpeg_u16");
352        let mut writer = JpegWriter::new(90);
353
354        let arr = NDArray::new(
355            vec![NDDimension::new(4), NDDimension::new(4)],
356            NDDataType::UInt16,
357        );
358
359        let result = writer.open_file(&path, NDFileMode::Single, &arr);
360        assert!(result.is_err());
361    }
362
363    #[test]
364    fn test_quality_affects_size() {
365        let path_high = temp_path("jpeg_hi");
366        let path_low = temp_path("jpeg_lo");
367
368        let mut arr = NDArray::new(
369            vec![NDDimension::new(32), NDDimension::new(32)],
370            NDDataType::UInt8,
371        );
372        if let NDDataBuffer::U8(ref mut v) = arr.data {
373            for i in 0..v.len() {
374                v[i] = (i % 256) as u8;
375            }
376        }
377
378        let mut writer_high = JpegWriter::new(95);
379        writer_high
380            .open_file(&path_high, NDFileMode::Single, &arr)
381            .unwrap();
382        writer_high.write_file(&arr).unwrap();
383        writer_high.close_file().unwrap();
384
385        let mut writer_low = JpegWriter::new(10);
386        writer_low
387            .open_file(&path_low, NDFileMode::Single, &arr)
388            .unwrap();
389        writer_low.write_file(&arr).unwrap();
390        writer_low.close_file().unwrap();
391
392        let size_high = std::fs::metadata(&path_high).unwrap().len();
393        let size_low = std::fs::metadata(&path_low).unwrap().len();
394        assert!(
395            size_high > size_low,
396            "high quality ({}) should be larger than low quality ({})",
397            size_high,
398            size_low
399        );
400
401        std::fs::remove_file(&path_high).ok();
402        std::fs::remove_file(&path_low).ok();
403    }
404
405    #[test]
406    fn test_default_quality_is_50() {
407        // C++ NDFileJPEG.cpp:327 default quality is 50.
408        assert_eq!(JpegFileProcessor::default().ctrl.lock().writer.quality, 50);
409        assert_eq!(JpegWriter::new(50).quality, 50);
410    }
411
412    #[test]
413    fn test_register_params_seeds_quality_pv() {
414        use asyn_rs::port::{PortDriverBase, PortFlags};
415        let mut base = PortDriverBase::new("jpeg_param_test", 1, PortFlags::default());
416        let mut proc = JpegFileProcessor::new(50);
417        proc.register_params(&mut base).unwrap();
418        let idx = proc.jpeg_quality_idx.unwrap();
419        // Readback PV must equal the encoder's effective quality, not 0.
420        assert_eq!(base.get_int32_param(idx, 0).unwrap(), 50);
421    }
422
423    #[test]
424    fn test_roundtrip_luma() {
425        let path = temp_path("jpeg_rt");
426        let mut writer = JpegWriter::new(100);
427
428        let mut arr = NDArray::new(
429            vec![NDDimension::new(8), NDDimension::new(8)],
430            NDDataType::UInt8,
431        );
432        if let NDDataBuffer::U8(ref mut v) = arr.data {
433            // Use uniform value so JPEG compression is lossless at quality 100
434            for i in 0..64 {
435                v[i] = 128;
436            }
437        }
438
439        writer.open_file(&path, NDFileMode::Single, &arr).unwrap();
440        writer.write_file(&arr).unwrap();
441
442        let read_back = writer.read_file().unwrap();
443        assert_eq!(read_back.data.data_type(), NDDataType::UInt8);
444        if let NDDataBuffer::U8(ref v) = read_back.data {
445            // With uniform input at max quality, decoded values should be close
446            for &px in v.iter() {
447                assert!(
448                    (px as i16 - 128).unsigned_abs() < 5,
449                    "pixel {} too far from 128",
450                    px
451                );
452            }
453        }
454
455        writer.close_file().unwrap();
456        std::fs::remove_file(&path).ok();
457    }
458
459    /// Write an RGB-mode array to JPEG and return the decoded array's dims.
460    fn jpeg_roundtrip_dims(prefix: &str, mode: NDColorMode, dims: Vec<usize>) -> Vec<usize> {
461        use ad_core_rs::attributes::{NDAttrSource, NDAttrValue, NDAttribute};
462        let path = temp_path(prefix);
463        let mut writer = JpegWriter::new(95);
464
465        let mut arr = NDArray::new(
466            dims.iter().map(|&d| NDDimension::new(d)).collect(),
467            NDDataType::UInt8,
468        );
469        arr.attributes.add(NDAttribute::new_static(
470            "ColorMode",
471            "Color mode",
472            NDAttrSource::Driver,
473            NDAttrValue::Int32(mode as i32),
474        ));
475        if let NDDataBuffer::U8(ref mut v) = arr.data {
476            for (i, x) in v.iter_mut().enumerate() {
477                *x = (i % 256) as u8;
478            }
479        }
480
481        writer.open_file(&path, NDFileMode::Single, &arr).unwrap();
482        writer.write_file(&arr).unwrap();
483        let read_back = writer.read_file().unwrap();
484        writer.close_file().unwrap();
485        std::fs::remove_file(&path).ok();
486        read_back.dims.iter().map(|d| d.size).collect()
487    }
488
489    #[test]
490    fn test_adp12_rgb2_jpeg_written_as_rgb_not_grayscale() {
491        // RGB2 [x=5, c=3, y=4]: C writes width=5, height=4, 3 JCS_RGB
492        // components (NDFileJPEG.cpp:67-78). The Rust converts to RGB1 first;
493        // before the fix the stale ColorMode=RGB2 attribute made info() read
494        // the RGB1 dims as width=3, color=5 -> a 3x4 grayscale JPEG. Decoded
495        // dims must be RGB24 [3, 5, 4], not grayscale [3, 4].
496        let dims = jpeg_roundtrip_dims("jpeg_rgb2", NDColorMode::RGB2, vec![5, 3, 4]);
497        assert_eq!(dims, vec![3, 5, 4]);
498    }
499
500    #[test]
501    fn test_adp12_rgb3_jpeg_written_as_rgb_not_grayscale() {
502        // RGB3 [x=5, y=4, c=3]: C writes width=5, height=4, 3 components
503        // (NDFileJPEG.cpp:72-78). Decoded dims must be RGB24 [3, 5, 4].
504        let dims = jpeg_roundtrip_dims("jpeg_rgb3", NDColorMode::RGB3, vec![5, 4, 3]);
505        assert_eq!(dims, vec![3, 5, 4]);
506    }
507}