From ea15a280f8a10cf3ed425c7d9c95481750af64ba Mon Sep 17 00:00:00 2001 From: Isotr0py Date: Sat, 23 Nov 2024 22:51:16 +0800 Subject: [PATCH] :crab: (Rust): Support `I;16` and `F` pixel values decoding (#73) --- .github/workflows/test.yml | 2 +- pyproject.toml | 5 +- requirements-dev.txt | 1 - src/decode.rs | 134 +++++++++++++++++++++++++---------- test/images/sample_float.jxl | Bin 0 -> 745 bytes test/images/sample_float.ppm | Bin 0 -> 8014 bytes test/images/sample_grey.jxl | Bin 0 -> 808 bytes test/images/sample_grey.png | Bin 0 -> 3208 bytes test/test_plugin.py | 36 ++++++++-- 9 files changed, 133 insertions(+), 45 deletions(-) delete mode 100644 requirements-dev.txt create mode 100644 test/images/sample_float.jxl create mode 100644 test/images/sample_float.ppm create mode 100644 test/images/sample_grey.jxl create mode 100644 test/images/sample_grey.png diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e411947..2f1720b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -60,7 +60,7 @@ jobs: - name: Test with pytest run: | source venv/bin/activate - pip install -r requirements-dev.txt + pip install -e .[dev] pytest test/ --junitxml=junit/test-results-${{ matrix.python-version }}.xml - name: Upload pytest test results diff --git a/pyproject.toml b/pyproject.toml index 0694353..e3d93b0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,10 +21,13 @@ dependencies = [ "Pillow", ] +[project.optional-dependencies] +dev = ["numpy", "pytest"] + [project.urls] "Homepage" = "https://github.com/Isotr0py/pillow-jpegxl-plugin" "Bug Tracker" = "https://github.com/Isotr0py/pillow-jpegxl-plugin/issues" "Releases" = "https://github.com/Isotr0py/pillow-jpegxl-plugin/releases" [tool.maturin] -features = ["pyo3/extension-module"] +features = ["pyo3/extension-module", "vendored"] diff --git a/requirements-dev.txt b/requirements-dev.txt deleted file mode 100644 index e079f8a..0000000 --- a/requirements-dev.txt +++ /dev/null @@ -1 +0,0 @@ -pytest diff --git a/src/decode.rs b/src/decode.rs index 56ca34c..4b5eb4f 100644 --- a/src/decode.rs +++ b/src/decode.rs @@ -1,6 +1,6 @@ use std::borrow::Cow; -use pyo3::exceptions::PyRuntimeError; +use pyo3::exceptions::{PyNotImplementedError, PyRuntimeError, PyValueError}; use pyo3::prelude::*; use jpegxl_rs::decode::{Data, Metadata, Pixels}; @@ -24,9 +24,13 @@ struct ImageInfo { } impl ImageInfo { - fn from(item: Metadata) -> ImageInfo { + fn from(item: Metadata, pixel: &Data) -> ImageInfo { + let pixel_type = match &pixel { + Data::Pixels(pixels) => Some(pixels), + Data::Jpeg(_) => None, + }; ImageInfo { - mode: Self::mode(item.num_color_channels, item.has_alpha_channel), + mode: Self::mode(item.num_color_channels, item.has_alpha_channel, pixel_type).unwrap(), width: item.width, height: item.height, num_channels: item.num_color_channels, @@ -34,39 +38,30 @@ impl ImageInfo { } } - fn mode(num_channels: u32, has_alpha_channel: bool) -> String { - match (num_channels, has_alpha_channel) { + fn mode( + num_channels: u32, + has_alpha_channel: bool, + pixel_type: Option<&Pixels>, + ) -> PyResult { + let mode = match (num_channels, has_alpha_channel) { (1, false) => "L".to_string(), (1, true) => "LA".to_string(), (3, false) => "RGB".to_string(), (3, true) => "RGBA".to_string(), - _ => panic!("Unsupported number of channels"), - } - } -} - -pub fn convert_pixels(pixels: Pixels) -> Vec { - let mut result = Vec::new(); - match pixels { - Pixels::Uint8(pixels) => { - for pixel in pixels { - result.push(pixel); - } - } - Pixels::Uint16(pixels) => { - for pixel in pixels { - result.push((pixel >> 8) as u8); - result.push(pixel as u8); + _ => return Err(PyNotImplementedError::new_err("Unsupported color mode")), + }; + if let Some(Pixels::Uint16(_)) = pixel_type { + if mode == "L" { + return Ok("I;16".to_string()); } } - Pixels::Float(pixels) => { - for pixel in pixels { - result.push((pixel * 255.0) as u8); + if let Some(Pixels::Float(_)) = pixel_type { + if mode == "L" { + return Ok("F".to_string()); } } - Pixels::Float16(_) => panic!("Float16 is not supported yet"), + Ok(mode) } - result } #[pyclass(module = "pillow_jxl")] @@ -96,6 +91,75 @@ impl Decoder { } } +impl Decoder { + fn pixels_to_bytes_8bit(&self, pixels: Pixels) -> PyResult> { + // Convert pixels to bytes with 8-bit casting + let mut result = Vec::new(); + match pixels { + Pixels::Uint8(pixels) => { + return Ok(pixels); + } + Pixels::Uint16(pixels) => { + for pixel in pixels { + result.push((pixel >> 8) as u8); + } + } + Pixels::Float(pixels) => { + for pixel in pixels { + result.push((pixel * 255.0) as u8); + } + } + Pixels::Float16(_) => { + return Err(PyNotImplementedError::new_err( + "Float16 is not supported yet", + )) + } + } + Ok(result) + } + + fn pixels_to_bytes(&self, pixels: Pixels) -> PyResult> { + // Convert pixels to bytes without casting + let mut result = Vec::new(); + match pixels { + Pixels::Uint8(pixels) => { + return Ok(pixels); + } + Pixels::Uint16(pixels) => { + for pixel in pixels { + let pix_bytes = pixel.to_ne_bytes(); + for byte in pix_bytes.iter() { + result.push(*byte); + } + } + } + Pixels::Float(pixels) => { + for pixel in pixels { + let pix_bytes = pixel.to_ne_bytes(); + for byte in pix_bytes.iter() { + result.push(*byte); + } + } + } + Pixels::Float16(_) => { + return Err(PyNotImplementedError::new_err( + "Float16 is not supported yet", + )) + } + } + Ok(result) + } + + fn convert_pil_pixels(&self, pixels: Pixels, num_channels: u32) -> PyResult> { + let result = match num_channels { + 1 => self.pixels_to_bytes(pixels)?, + 3 => self.pixels_to_bytes_8bit(pixels)?, + _ => return Err(PyValueError::new_err("image color channels must be 1 or 3")), + }; + Ok(result) + } +} + impl Decoder { fn call_inner(&self, data: &[u8]) -> PyResult<(bool, ImageInfo, Cow<'_, [u8]>, Cow<'_, [u8]>)> { let parallel_runner = ThreadsRunner::new( @@ -113,20 +177,16 @@ impl Decoder { .build() .map_err(to_pyjxlerror)?; let (info, img) = decoder.reconstruct(&data).map_err(to_pyjxlerror)?; - let (jpeg, img) = match img { - Data::Jpeg(x) => (true, x), - Data::Pixels(x) => (false, convert_pixels(x)), - }; let icc_profile: Vec = match &info.icc_profile { Some(x) => x.to_vec(), None => Vec::new(), }; - Ok(( - jpeg, - ImageInfo::from(info), - Cow::Owned(img), - Cow::Owned(icc_profile), - )) + let img_info = ImageInfo::from(info, &img); + let (jpeg, img) = match img { + Data::Jpeg(x) => (true, x), + Data::Pixels(x) => (false, self.convert_pil_pixels(x, img_info.num_channels)?), + }; + Ok((jpeg, img_info, Cow::Owned(img), Cow::Owned(icc_profile))) } } diff --git a/test/images/sample_float.jxl b/test/images/sample_float.jxl new file mode 100644 index 0000000000000000000000000000000000000000..abc3d94cde084a58bbd9713c7a3ffbd7e9d875d5 GIT binary patch literal 745 zcmV-q9ShsCsw>kMtRZD9#x zv%N4#m;mTR0hqWTGD?OlHiGp50ErV^F2wN*ETU8-xCT8be{=K}+{@d6JAX6(KJX)Z zfI#2h2)LBt&G2R(-CIlN=K=%wri*L<;4lu!B6(n18yvuAKOl~UFIe*B@nG~p45=Xu zBojnWjAo}MHWgiw{OTI;zU~Epgc96&c)m!kyAKbk!x&d}Np(EmA{^wPVu*q zwPSyWc}2@5DX64>&V0#sAdT|}%ty&4dMrk%$i%e^Sgexo+7qge(l0)~;GAGwcLV}K z#G<$=^}@G18y$@Jh@leX7LE{5o%~7)U&qWmvc!zKQw~)S9-77Mm$&Pl_(?fJ>gg(4^6atB=Pyc(R+gdSh(~ceUKSy_ bsu^*C_>RRZ`~LaYfX^V^eO=@A$EZMivwvh( literal 0 HcmV?d00001 diff --git a/test/images/sample_float.ppm b/test/images/sample_float.ppm new file mode 100644 index 0000000000000000000000000000000000000000..88784321c7dc7a8223283b362c8791e7e57d5507 GIT binary patch literal 8014 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 literal 0 HcmV?d00001 diff --git a/test/images/sample_grey.png b/test/images/sample_grey.png new file mode 100644 index 0000000000000000000000000000000000000000..ebf799781a2303fda23df4ea5d910cd5b1515d71 GIT binary patch literal 3208 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