Skip to main content

otf_pixels/
metadata.rs

1//! What a source file says about itself, and what its pixels measure: the
2//! facade's answers to sharp's `metadata()` and `stats()`.
3
4#![allow(
5    clippy::indexing_slicing,
6    reason = "sample indices are bounded by the descriptor's own dimensions"
7)]
8
9use std::sync::Arc;
10
11use otf_pixels_core::{
12    Animation, Background, ChannelLayout, Comment, Density, Format, ImageDescriptor, Orientation,
13    PixelFormat, SampleKind, SourceInfo,
14};
15
16/// What a source file says about itself, read from its headers (sharp's
17/// `metadata()`). Nothing here costs a pixel decode.
18#[derive(Debug, Clone, PartialEq)]
19#[non_exhaustive]
20pub struct SourceMetadata {
21    /// The file's format.
22    pub format: Format,
23    /// The file's size in bytes, when it was opened from a path.
24    pub size: Option<u64>,
25    /// Width as stored, before any orientation is applied.
26    pub width: u32,
27    /// Height as stored.
28    pub height: u32,
29    /// The pixel format the decoder produces.
30    pub pixel: PixelFormat,
31    /// Channels in that pixel format, alpha included.
32    pub channels: usize,
33    /// Whether there is an alpha channel.
34    pub has_alpha: bool,
35    /// Bits per stored sample, which may be fewer than the decoder's output
36    /// (a 4-bit PNG, a 10-bit AVIF).
37    pub bits_per_sample: Option<u8>,
38    /// How the image must be turned to display upright; EXIF's 1–8 as
39    /// [`Orientation::exif`] gives it.
40    pub orientation: Orientation,
41    /// Pages in the document, at least 1 (multi-page TIFF).
42    pub pages: u32,
43    /// The animation's frames, loop count and delays, for more than one
44    /// frame.
45    pub animation: Option<Animation>,
46    /// The stated resolution, if any.
47    pub density: Option<Density>,
48    /// Progressive JPEG, or interlaced PNG or GIF.
49    pub progressive: bool,
50    /// Samples index a palette.
51    pub palette: bool,
52    /// Chroma subsampling as sharp spells it (`"4:2:0"`, `"4:4:4"`, ...).
53    pub chroma_subsampling: Option<&'static str>,
54    /// The background colour the file names (PNG `bKGD`, GIF).
55    pub background: Option<Background>,
56    /// The compression scheme where a format has several.
57    pub compression: Option<&'static str>,
58    /// Text entries (PNG text chunks, JPEG and GIF comments).
59    pub comments: Vec<Comment>,
60    /// The embedded ICC profile.
61    pub icc: Option<Arc<[u8]>>,
62    /// The EXIF block, TIFF-structured, as the file stores it.
63    pub exif: Option<Arc<[u8]>>,
64    /// The XMP packet.
65    pub xmp: Option<Arc<[u8]>>,
66}
67
68impl SourceMetadata {
69    #[allow(
70        clippy::too_many_arguments,
71        reason = "everything a decoder reports, once"
72    )]
73    pub(crate) fn new(
74        format: Format,
75        stored: ImageDescriptor,
76        orientation: Orientation,
77        pages: u32,
78        animation: Option<Animation>,
79        icc: Option<&[u8]>,
80        exif: Option<&[u8]>,
81        xmp: Option<Arc<[u8]>>,
82        info: SourceInfo,
83    ) -> Self {
84        Self {
85            format,
86            size: None,
87            width: stored.width,
88            height: stored.height,
89            pixel: stored.pixel,
90            channels: stored.pixel.channels(),
91            has_alpha: stored.pixel.has_alpha(),
92            bits_per_sample: info.bits_per_sample,
93            orientation,
94            pages,
95            animation,
96            density: info.density,
97            progressive: info.progressive,
98            palette: info.palette,
99            chroma_subsampling: info.chroma_subsampling,
100            background: info.background,
101            compression: info.compression,
102            comments: info.comments,
103            icc: icc.map(Arc::from),
104            exif: exif.map(Arc::from),
105            xmp,
106        }
107    }
108
109    /// Whether the file carries an ICC profile (sharp's `hasProfile`).
110    #[must_use]
111    pub const fn has_profile(&self) -> bool {
112        self.icc.is_some()
113    }
114
115    /// Width once turned upright, as sharp's `autoOrient` reports it.
116    #[must_use]
117    pub const fn oriented_width(&self) -> u32 {
118        if self.orientation.transposes() {
119            self.height
120        } else {
121            self.width
122        }
123    }
124
125    /// Height once turned upright.
126    #[must_use]
127    pub const fn oriented_height(&self) -> u32 {
128        if self.orientation.transposes() {
129            self.width
130        } else {
131            self.height
132        }
133    }
134}
135
136/// One channel's statistics.
137#[derive(Debug, Clone, Copy, PartialEq)]
138#[non_exhaustive]
139pub struct ChannelStats {
140    /// The smallest sample.
141    pub min: f64,
142    /// The largest sample.
143    pub max: f64,
144    /// The sum of every sample.
145    pub sum: f64,
146    /// The sum of every sample squared.
147    pub squares_sum: f64,
148    /// The mean.
149    pub mean: f64,
150    /// The sample standard deviation.
151    pub stdev: f64,
152    /// Where the minimum first occurs, in raster order.
153    pub min_x: u32,
154    /// Its row.
155    pub min_y: u32,
156    /// Where the maximum first occurs.
157    pub max_x: u32,
158    /// Its row.
159    pub max_y: u32,
160}
161
162/// Pixel statistics of an image (sharp's `stats()`), in its own sample
163/// scale: 0–255 for 8-bit, 0–65535 for 16-bit, 0–1 for float.
164#[derive(Debug, Clone, PartialEq)]
165#[non_exhaustive]
166pub struct Stats {
167    /// Each channel, alpha included, in order.
168    pub channels: Vec<ChannelStats>,
169    /// No alpha channel, or every alpha sample opaque.
170    pub is_opaque: bool,
171    /// Shannon entropy, in bits, of the greyscale histogram.
172    pub entropy: f64,
173    /// The standard deviation of the greyscale Laplacian: higher is sharper.
174    pub sharpness: f64,
175    /// The most common sRGB colour, by a 16-level-per-channel histogram,
176    /// each channel the centre of its bin.
177    pub dominant: [u8; 3],
178}
179
180/// Linear light from an sRGB-encoded sample in 0..=1.
181fn to_linear(v: f64) -> f64 {
182    if v <= 0.040_45 {
183        v / 12.92
184    } else {
185        ((v + 0.055) / 1.055).powf(2.4)
186    }
187}
188
189/// sRGB encoding of linear light in 0..=1.
190fn from_linear(v: f64) -> f64 {
191    if v <= 0.003_130_8 {
192        v * 12.92
193    } else {
194        1.055 * v.powf(1.0 / 2.4) - 0.055
195    }
196}
197
198impl Stats {
199    /// Measure `pixels`, packed as `descriptor` lays them out.
200    #[must_use]
201    pub fn of(descriptor: &ImageDescriptor, pixels: &[u8]) -> Self {
202        let pixel = descriptor.pixel;
203        let channels = pixel.channels();
204        let (width, height) = (descriptor.width as usize, descriptor.height as usize);
205        let n = width * height;
206        let kind = pixel.sample_kind();
207        let full = match kind {
208            SampleKind::U8 => 255.0,
209            SampleKind::U16 => 65_535.0,
210            _ => 1.0,
211        };
212        let sample = |i: usize| -> f64 {
213            match kind {
214                SampleKind::U8 => f64::from(pixels[i]),
215                SampleKind::U16 => {
216                    f64::from(u16::from_ne_bytes([pixels[2 * i], pixels[2 * i + 1]]))
217                }
218                _ => f64::from(f32::from_ne_bytes([
219                    pixels[4 * i],
220                    pixels[4 * i + 1],
221                    pixels[4 * i + 2],
222                    pixels[4 * i + 3],
223                ])),
224            }
225        };
226        let mut stats = Vec::with_capacity(channels);
227        for c in 0..channels {
228            let (mut min, mut max, mut sum, mut squares) =
229                (f64::INFINITY, f64::NEG_INFINITY, 0.0, 0.0);
230            let (mut min_at, mut max_at) = (0, 0);
231            for p in 0..n {
232                let v = sample(p * channels + c);
233                if v < min {
234                    min = v;
235                    min_at = p;
236                }
237                if v > max {
238                    max = v;
239                    max_at = p;
240                }
241                sum += v;
242                squares += v * v;
243            }
244            let count = n as f64;
245            let mean = if n > 0 { sum / count } else { 0.0 };
246            let variance = if n > 1 {
247                ((squares - sum * sum / count) / (count - 1.0)).max(0.0)
248            } else {
249                0.0
250            };
251            let at = |p: usize| ((p % width.max(1)) as u32, (p / width.max(1)) as u32);
252            let ((min_x, min_y), (max_x, max_y)) = (at(min_at), at(max_at));
253            stats.push(ChannelStats {
254                min: if n > 0 { min } else { 0.0 },
255                max: if n > 0 { max } else { 0.0 },
256                sum,
257                squares_sum: squares,
258                mean,
259                stdev: variance.sqrt(),
260                min_x,
261                min_y,
262                max_x,
263                max_y,
264            });
265        }
266        let layout = pixel.layout();
267        let alpha = layout.has_alpha().then(|| channels - 1);
268        let is_opaque = alpha.is_none_or(|a| stats[a].min >= full);
269        let colour = layout != ChannelLayout::Gray && layout != ChannelLayout::GrayAlpha;
270
271        // Greyscale as sharp takes it: luminance of linear light, sRGB
272        // encoded again, at the image's own depth (float as 16-bit).
273        let levels: usize = if kind == SampleKind::U8 { 256 } else { 65_536 };
274        let top = (levels - 1) as f64;
275        let grey: Vec<u32> = (0..n)
276            .map(|p| {
277                let v = if colour {
278                    let ch =
279                        |c: usize| to_linear((sample(p * channels + c) / full).clamp(0.0, 1.0));
280                    from_linear(0.2126 * ch(0) + 0.7152 * ch(1) + 0.0722 * ch(2))
281                } else {
282                    (sample(p * channels) / full).clamp(0.0, 1.0)
283                };
284                (v * top).round() as u32
285            })
286            .collect();
287        let mut histogram = vec![0_u64; levels];
288        for &g in &grey {
289            histogram[g as usize] += 1;
290        }
291        let entropy = histogram
292            .iter()
293            .filter(|&&h| h > 0)
294            .map(|&h| {
295                let p = h as f64 / n as f64;
296                -p * p.log2()
297            })
298            .sum();
299
300        // The Laplacian, scaled by 9 and clipped to the depth as libvips's
301        // integer convolution does, edges extended.
302        let sharpness = if n > 1 {
303            let at = |x: isize, y: isize| -> f64 {
304                let x = x.clamp(0, width as isize - 1) as usize;
305                let y = y.clamp(0, height as isize - 1) as usize;
306                f64::from(grey[y * width + x])
307            };
308            let (mut s, mut s2) = (0.0, 0.0);
309            for y in 0..height as isize {
310                for x in 0..width as isize {
311                    let l =
312                        at(x, y - 1) + at(x - 1, y) + at(x + 1, y) + at(x, y + 1) - 4.0 * at(x, y);
313                    let v = (l / 9.0).round().clamp(0.0, top);
314                    s += v;
315                    s2 += v * v;
316                }
317            }
318            let count = n as f64;
319            ((s2 - s * s / count) / (count - 1.0)).max(0.0).sqrt()
320        } else {
321            0.0
322        };
323
324        // Dominant colour over a 16x16x16 histogram of 8-bit sRGB.
325        let mut bins = vec![0_u32; 4096];
326        for p in 0..n {
327            let byte = |c: usize| {
328                ((sample(p * channels + c) / full).clamp(0.0, 1.0) * 255.0).round() as usize
329            };
330            let (r, g, b) = if colour {
331                (byte(0), byte(1), byte(2))
332            } else {
333                (byte(0), byte(0), byte(0))
334            };
335            bins[(r >> 4) * 256 + (g >> 4) * 16 + (b >> 4)] += 1;
336        }
337        let best = bins
338            .iter()
339            .enumerate()
340            .max_by(|a, b| a.1.cmp(b.1).then(b.0.cmp(&a.0)))
341            .map_or(0, |(i, _)| i);
342        let centre = |bin: usize| (bin * 16 + 8) as u8;
343        let dominant = [
344            centre(best / 256),
345            centre(best / 16 % 16),
346            centre(best % 16),
347        ];
348
349        Self {
350            channels: stats,
351            is_opaque,
352            entropy,
353            sharpness,
354            dominant,
355        }
356    }
357}
358
359#[cfg(test)]
360#[allow(
361    clippy::unwrap_used,
362    clippy::float_cmp,
363    reason = "tests operate on known-good values"
364)]
365mod tests {
366    use super::*;
367
368    #[test]
369    fn channel_statistics_follow_their_definitions() {
370        let d = ImageDescriptor::new(2, 2, PixelFormat::Rgba8).unwrap();
371        let px = [10, 0, 0, 255, 20, 0, 0, 255, 30, 0, 0, 255, 40, 200, 0, 128];
372        let s = Stats::of(&d, &px);
373        let r = s.channels[0];
374        assert_eq!((r.min, r.max, r.sum, r.mean), (10.0, 40.0, 100.0, 25.0));
375        assert_eq!(r.squares_sum, 100.0 + 400.0 + 900.0 + 1600.0);
376        assert!((r.stdev - (500.0_f64 / 3.0).sqrt()).abs() < 1e-9);
377        assert_eq!((r.min_x, r.min_y, r.max_x, r.max_y), (0, 0, 1, 1));
378        assert!(!s.is_opaque);
379        assert_eq!(s.channels.len(), 4);
380    }
381
382    #[test]
383    fn a_flat_image_has_no_entropy_and_no_sharpness() {
384        let d = ImageDescriptor::new(8, 8, PixelFormat::Rgb8).unwrap();
385        let px = [200_u8, 30, 90].repeat(64);
386        let s = Stats::of(&d, &px);
387        assert_eq!(s.entropy, 0.0);
388        assert_eq!(s.sharpness, 0.0);
389        assert!(s.is_opaque);
390        assert_eq!(s.dominant, [200, 24, 88]);
391    }
392
393    #[test]
394    fn a_two_level_image_has_one_bit_of_entropy_and_edges_are_sharp() {
395        let d = ImageDescriptor::new(8, 8, PixelFormat::Gray8).unwrap();
396        let px: Vec<u8> = (0..64).map(|i| if (i % 8) < 4 { 0 } else { 255 }).collect();
397        let s = Stats::of(&d, &px);
398        assert!((s.entropy - 1.0).abs() < 1e-9);
399        assert!(s.sharpness > 0.0);
400    }
401}