Skip to main content

otf_pixels_ops/
convert.rs

1//! [`ConvertFormat`] — change an image's pixel format.
2//!
3//! Depth (8-bit, 16-bit, float) and layout (grey, grey with alpha, RGB,
4//! RGBA) both convert. Depth rescales to the full range of the target, with
5//! rounding to nearest; grey widens to RGB by repetition and RGB narrows to
6//! grey by BT.601 luma, sharp's `greyscale`; alpha is added opaque, or
7//! dropped without compositing (use `flatten` to composite against a colour
8//! first).
9
10use otf_pixels_core::{
11    ChannelLayout, ImageDescriptor, Op, PixelFormat, PixelsError, Region, Result, SampleKind, Tile,
12    TileMut,
13};
14use std::sync::Arc;
15
16/// Convert to a different [`PixelFormat`].
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub struct ConvertFormat {
19    to: PixelFormat,
20}
21
22impl ConvertFormat {
23    /// Convert whatever comes in to `to`.
24    #[must_use]
25    pub const fn to(to: PixelFormat) -> Self {
26        Self { to }
27    }
28}
29
30/// One sample at `index` of a pixel, as 0..=1.
31fn read(pixel: &[u8], kind: SampleKind, index: usize) -> f32 {
32    match kind {
33        SampleKind::U8 => f32::from(pixel.get(index).copied().unwrap_or(0)) / 255.0,
34        SampleKind::U16 => {
35            let at = index * 2;
36            let v = u16::from_ne_bytes([
37                pixel.get(at).copied().unwrap_or(0),
38                pixel.get(at + 1).copied().unwrap_or(0),
39            ]);
40            f32::from(v) / 65535.0
41        }
42        SampleKind::F32 => {
43            let at = index * 4;
44            let mut b = [0_u8; 4];
45            if let Some(s) = pixel.get(at..at + 4) {
46                b.copy_from_slice(s);
47            }
48            f32::from_ne_bytes(b)
49        }
50    }
51}
52
53fn write(pixel: &mut [u8], kind: SampleKind, index: usize, value: f32) {
54    match kind {
55        SampleKind::U8 => {
56            if let Some(slot) = pixel.get_mut(index) {
57                *slot = (value.clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
58            }
59        }
60        SampleKind::U16 => {
61            let v = (value.clamp(0.0, 1.0) * 65535.0 + 0.5) as u16;
62            if let Some(slot) = pixel.get_mut(index * 2..index * 2 + 2) {
63                slot.copy_from_slice(&v.to_ne_bytes());
64            }
65        }
66        SampleKind::F32 => {
67            if let Some(slot) = pixel.get_mut(index * 4..index * 4 + 4) {
68                slot.copy_from_slice(&value.to_ne_bytes());
69            }
70        }
71    }
72}
73
74/// A pixel as RGBA in 0..=1.
75fn to_rgba(pixel: &[u8], format: PixelFormat) -> [f32; 4] {
76    let kind = format.sample_kind();
77    let at = |i| read(pixel, kind, i);
78    match format.layout() {
79        ChannelLayout::Gray => [at(0), at(0), at(0), 1.0],
80        ChannelLayout::GrayAlpha => [at(0), at(0), at(0), at(1)],
81        ChannelLayout::Rgb => [at(0), at(1), at(2), 1.0],
82        ChannelLayout::Rgba => [at(0), at(1), at(2), at(3)],
83    }
84}
85
86fn from_rgba(rgba: [f32; 4], pixel: &mut [u8], format: PixelFormat) {
87    let kind = format.sample_kind();
88    let [r, g, b, a] = rgba;
89    let luma = || 0.299 * r + 0.587 * g + 0.114 * b;
90    match format.layout() {
91        ChannelLayout::Gray => write(pixel, kind, 0, luma()),
92        ChannelLayout::GrayAlpha => {
93            write(pixel, kind, 0, luma());
94            write(pixel, kind, 1, a);
95        }
96        ChannelLayout::Rgb => {
97            for (i, v) in [r, g, b].into_iter().enumerate() {
98                write(pixel, kind, i, v);
99            }
100        }
101        ChannelLayout::Rgba => {
102            for (i, v) in [r, g, b, a].into_iter().enumerate() {
103                write(pixel, kind, i, v);
104            }
105        }
106    }
107}
108
109/// 16-bit to 8-bit exactly as the rounding in `write` would, without floats:
110/// the conversion every narrowing for output goes through.
111fn narrow_16_to_8(v: u16) -> u8 {
112    ((u32::from(v) * 255 + 32_767) / 65_535) as u8
113}
114
115impl Op for ConvertFormat {
116    fn name(&self) -> &'static str {
117        "convert_format"
118    }
119
120    /// Pointwise: resolution means nothing to it.
121    fn rescaled(&self) -> Option<Arc<dyn Op>> {
122        Some(Arc::new(*self))
123    }
124
125    fn output_descriptor(&self, inputs: &[ImageDescriptor]) -> Result<ImageDescriptor> {
126        let [input] = inputs else {
127            return Err(PixelsError::graph("convert_format takes exactly one input"));
128        };
129        ImageDescriptor::new(input.width, input.height, self.to)
130    }
131
132    fn input_regions(&self, output: Region, _inputs: &[ImageDescriptor]) -> Result<Vec<Region>> {
133        Ok(vec![output])
134    }
135
136    fn compute(&self, inputs: &[Tile<'_>], output: &mut TileMut<'_>) -> Result<()> {
137        let [input] = inputs else {
138            return Err(PixelsError::graph("convert_format takes exactly one input"));
139        };
140        let (from, to) = (input.pixel(), self.to);
141        let (in_bytes, out_bytes) = (from.bytes_per_pixel(), to.bytes_per_pixel());
142        let region = output.region();
143        let same_layout = from.layout() == to.layout();
144        for y in region.y..region.y + region.height {
145            let (Some(source), Some(target)) = (input.row(y), output.row_mut(y)) else {
146                continue;
147            };
148            if from == to {
149                let len = source.len().min(target.len());
150                if let (Some(t), Some(s)) = (target.get_mut(..len), source.get(..len)) {
151                    t.copy_from_slice(s);
152                }
153                continue;
154            }
155            if same_layout
156                && from.sample_kind() == SampleKind::U16
157                && to.sample_kind() == SampleKind::U8
158            {
159                // The hot narrowing for output, integer only.
160                for (pair, slot) in source.chunks_exact(2).zip(target.iter_mut()) {
161                    if let &[lo, hi] = pair {
162                        *slot = narrow_16_to_8(u16::from_ne_bytes([lo, hi]));
163                    }
164                }
165                continue;
166            }
167            for (from_px, to_px) in source
168                .chunks_exact(in_bytes)
169                .zip(target.chunks_exact_mut(out_bytes))
170            {
171                from_rgba(to_rgba(from_px, from), to_px, to);
172            }
173        }
174        Ok(())
175    }
176}
177
178#[cfg(test)]
179#[allow(
180    clippy::unwrap_used,
181    clippy::indexing_slicing,
182    reason = "tests operate on known-good values"
183)]
184mod tests {
185    use super::*;
186    use otf_pixels_core::TileBuf;
187
188    fn convert(from: PixelFormat, bytes: Vec<u8>, to: PixelFormat) -> Vec<u8> {
189        let pixels = bytes.len() / from.bytes_per_pixel();
190        let input = ImageDescriptor::new(pixels as u32, 1, from).unwrap();
191        let op = ConvertFormat::to(to);
192        let out = op.output_descriptor(&[input]).unwrap();
193        let source = TileBuf::from_vec(input.region(), from, bytes).unwrap();
194        let mut target = TileBuf::for_image(&out).unwrap();
195        op.compute(
196            &[source.as_tile().unwrap()],
197            &mut target.as_tile_mut().unwrap(),
198        )
199        .unwrap();
200        target.into_bytes()
201    }
202
203    fn wide(values: &[u16]) -> Vec<u8> {
204        values.iter().flat_map(|v| v.to_ne_bytes()).collect()
205    }
206
207    #[test]
208    fn depth_rescales_with_rounding() {
209        assert_eq!(
210            convert(
211                PixelFormat::Gray16,
212                wide(&[0, 128, 32_896, 65_535]),
213                PixelFormat::Gray8
214            ),
215            vec![0, 0, 128, 255]
216        );
217        // Every 8-bit value survives a round trip through 16 bits.
218        let all: Vec<u8> = (0..=255).collect();
219        let up = convert(PixelFormat::Gray8, all.clone(), PixelFormat::Gray16);
220        assert_eq!(up[2..4], 257_u16.to_ne_bytes());
221        assert_eq!(convert(PixelFormat::Gray16, up, PixelFormat::Gray8), all);
222        // Floats clamp to the range.
223        let floats: Vec<u8> = [-0.5_f32, 0.5, 2.0]
224            .iter()
225            .flat_map(|v| v.to_ne_bytes())
226            .collect();
227        assert_eq!(
228            convert(PixelFormat::RgbF32, floats, PixelFormat::Rgb8),
229            vec![0, 128, 255]
230        );
231    }
232
233    #[test]
234    fn layouts_widen_narrow_and_add_or_drop_alpha() {
235        assert_eq!(
236            convert(PixelFormat::Gray8, vec![7], PixelFormat::Rgba8),
237            vec![7, 7, 7, 255]
238        );
239        assert_eq!(
240            convert(PixelFormat::Rgb8, vec![255, 0, 0], PixelFormat::Gray8),
241            vec![76]
242        );
243        assert_eq!(
244            convert(PixelFormat::Rgba8, vec![1, 2, 3, 4], PixelFormat::Rgb8),
245            vec![1, 2, 3]
246        );
247        assert_eq!(
248            convert(PixelFormat::GrayA8, vec![9, 100], PixelFormat::Rgba16),
249            wide(&[9 * 257, 9 * 257, 9 * 257, 100 * 257])
250        );
251        assert_eq!(
252            convert(
253                PixelFormat::Rgba16,
254                wide(&[65_535, 0, 0, 32_768]),
255                PixelFormat::GrayA8
256            ),
257            vec![76, 128]
258        );
259    }
260}