From 7bf9baa581f1bb1b6bb2b5e1414a262d68c2a8bb Mon Sep 17 00:00:00 2001 From: Sophie Herold Date: Sun, 2 Apr 2023 22:22:00 +0200 Subject: [PATCH] image: Ensure contrast for transparent images Determine if the image is transparent and has low contrast with default background color. If so, use a different background color. Closes #31 Part-of: --- data/style.css | 30 ---------- src/decoder/tiling.rs | 7 ++- src/util.rs | 25 ++++++++ src/widgets/image.rs | 130 +++++++++++++++++++++++++++++++++++++++++- 4 files changed, 159 insertions(+), 33 deletions(-) diff --git a/data/style.css b/data/style.css index 33374b6..8c8445b 100644 --- a/data/style.css +++ b/data/style.css @@ -4,40 +4,10 @@ button.osd.circular { min-height: 42px; } -.image-view { - background-color: @theme_base_color; -} - flap.fullscreen-widget dimming, shadow, border, outline { background: none; } -.checkerboard { - background-color: #7f7f7f; - background-image: linear-gradient( - 45deg, - #cccccc 25%, - transparent 25% - ), - linear-gradient( - -45deg, - #cccccc 25%, - transparent 25% - ), - linear-gradient( - 45deg, - transparent 75%, - #cccccc 75% - ), - linear-gradient( - -45deg, - transparent 75%, - #cccccc 75% - ); - background-size: 20px 20px; - background-position: 0 0, 0 10px, 10px -10px, -10px 0px; -} - row.property label.title { opacity: 0.55; font-size: smaller; diff --git a/src/decoder/tiling.rs b/src/decoder/tiling.rs index 549019e..62ebd01 100644 --- a/src/decoder/tiling.rs +++ b/src/decoder/tiling.rs @@ -88,7 +88,9 @@ impl Tile { // TODO: do not clip outer bounderies of the image snapshot.push_clip(&area); - snapshot.append_color(&gdk::RGBA::new(0.118, 0.118, 0.118, 1.), &area); + if let Some(background_color) = &options.background_color { + snapshot.append_color(background_color, &area); + } snapshot.append_scaled_texture(&self.texture, options.scaling_filter, &area_with_bleed); snapshot.pop(); } @@ -173,6 +175,7 @@ impl Default for TiledImage { pub struct RenderOptions { pub scaling_filter: gsk::ScalingFilter, pub scale_factor: i32, + pub background_color: Option, } impl TiledImage { @@ -402,6 +405,8 @@ impl RectExt for graphene::Rect { } } +/// We always store [`FrameBuffer`]s in an [`ArcSwap`]. +/// This trait adds convenience functions for this. pub trait FrameBufferExt { fn push(&self, tile: Tile); fn push_frame(&self, tile: Tile, dimensions: Coordinates, delay: Duration); diff --git a/src/util.rs b/src/util.rs index fb033eb..bc2a93d 100644 --- a/src/util.rs +++ b/src/util.rs @@ -173,3 +173,28 @@ pub enum Gesture { Rotate(f64), Scale, } + +fn srgb_linear(v: f32) -> f32 { + if v <= 0.04045 { + v / 12.92 + } else { + ((v + 0.055) / 1.055).powf(2.4) + } +} + +/// Relative luminance +fn relative_luminance(color: &gdk::RGBA) -> f32 { + 0.2126 * srgb_linear(color.red()) + + 0.7152 * srgb_linear(color.green()) + + 0.0722 * srgb_linear(color.blue()) +} + +/// Contrast ratio +pub fn contrast_ratio(color1: &gdk::RGBA, color2: &gdk::RGBA) -> f32 { + let la = relative_luminance(color1); + let lb = relative_luminance(color2); + let l1 = f32::max(la, lb); + let l2 = f32::min(la, lb); + + (l1 + 0.05) / (l2 + 0.05) +} diff --git a/src/widgets/image.rs b/src/widgets/image.rs index 1e38ae4..c6b70bc 100644 --- a/src/widgets/image.rs +++ b/src/widgets/image.rs @@ -46,6 +46,26 @@ use std::cell::{Cell, RefCell}; use std::path::{Path, PathBuf}; use std::sync::Arc; +/// Default background color around images and behind transparent images +/// `#242424` +static BACKGROUND_COLOR_DEFAULT: Lazy = + Lazy::new(|| gdk::RGBA::new(36. / 255., 36. / 255., 36. / 255., 1.)); +/// Background color if the default does not give enough contrast for transparent images +/// `#e8e7e6` +static BACKGROUND_COLOR_ALTERNATE: Lazy = + Lazy::new(|| gdk::RGBA::new(232. / 255., 231. / 255., 230. / 255., 1.)); + +/// Consider pixels with less than 70% opacity as being transparent +static BACKGROUND_GUESS_TRANSPRAENT_PIXEL_THRESHOLD: u8 = (0.70 * 255.) as u8; +/// Consider 3.5:1 contrast and worse to be bad contrast for a pixel +static BACKGROUND_GUESS_LOW_CONTRAST_RATIO: f32 = 3.5; +/// Consider images with more than 10% transparent pixels as transparent +static BACKGROUND_GUESS_TRANSPRAENT_IMAGE_THRESHOLD: f64 = 0.10; +/// Consider transparent images with more than 90% pixels bad contrast as bad contrast +/// +/// Bad contrast image will use the `BACKGROUND_COLOR_ALTERNATE`. +static BACKGROUND_GUESS_LOW_CONTRAST_TRHESHOLD: f64 = 0.90; + /// Milliseconds const ZOOM_ANIMATION_DURATION: u32 = 200; /// Milliseconds @@ -94,6 +114,7 @@ mod imp { pub(super) frame_buffer: Arc>, pub(super) decoder: RefCell>>, pub(super) format: RefCell>, + pub(super) background_color: RefCell>, /// Set to true when image is ready for displaying pub(super) is_loaded: Cell, @@ -590,12 +611,19 @@ mod imp { let render_options = tiling::RenderOptions { scaling_filter, scale_factor: widget.scale_factor(), + background_color: Some(widget.background_color()), }; // Operations on snapshots are coordinate transformations // It might help to read the following code from bottom to top snapshot.save(); + // Add background + snapshot.append_color( + &widget.background_color(), + &graphene::Rect::new(0., 0., widget_width as f32, widget_height as f32), + ); + // Apply the scrolling position to the image if let Some(adj) = self.hadjustment.borrow().as_ref() { let x = -(adj.value() - (adj.upper() - display_width) / 2.); @@ -771,7 +799,7 @@ impl LpImage { } DecoderUpdate::Redraw => { if !self.is_loaded() { - self.imp().is_loaded.set(true); + imp.is_loaded.set(true); self.notify("is-loaded"); } @@ -779,9 +807,12 @@ impl LpImage { imp.frame_buffer.rcu(|tiles| { let mut new_tiles = (**tiles).clone(); // TODO: Use an area larger than the viewport - new_tiles.cleanup(self.imp().zoom_target.get(), self.viewport()); + new_tiles.cleanup(imp.zoom_target.get(), self.viewport()); new_tiles }); + if self.imp().background_color.borrow().is_none() { + self.set_background_color(self.background_color_guess()); + } } DecoderUpdate::Error(err) => { self.set_error(err); @@ -947,6 +978,7 @@ impl LpImage { let render_options = tiling::RenderOptions { scaling_filter: gsk::ScalingFilter::Trilinear, scale_factor: self.scale_factor(), + background_color: Some(self.background_color()), }; let snapshot = gtk::Snapshot::new(); @@ -1588,4 +1620,98 @@ impl LpImage { let scale = self.scale_factor() as f32; (number * scale).round() / scale } + + /// Returns the background color that should be used with this image + /// + /// Returns the default color if no one has been guessed yet + pub fn background_color(&self) -> gdk::RGBA { + (*self.imp().background_color.borrow()).unwrap_or(*BACKGROUND_COLOR_DEFAULT) + } + + pub fn set_background_color(&self, color: Option) { + self.imp().background_color.replace(color); + } + + /// Returns a background color that should give suitable contrast with transparent images + /// + /// For non-transparent images this always returns `BACKGROUND_COLOR_DEFAULT` + pub fn background_color_guess(&self) -> Option { + let (width, height) = self.original_dimensions(); + let max_size = i32::max(width, height); + + // Only use max 200px size scaled image for analysis + let zoom = f64::min(1., 200. / max_size as f64); + + let snapshot = gtk::Snapshot::new(); + let render_options = tiling::RenderOptions { + scaling_filter: gsk::ScalingFilter::Nearest, + background_color: None, + scale_factor: 1, + }; + self.imp() + .frame_buffer + .load() + .add_to_snapshot(&snapshot, zoom, &render_options); + + let node = snapshot.to_node()?; + + let renderer = self.root()?.renderer(); + if !renderer.is_realized() { + return None; + } + + let texture = renderer.render_texture(node, None); + + let mut downloader = gdk::TextureDownloader::new(&texture); + downloader.set_format(gdk::MemoryFormat::R8g8b8a8); + let (bytes, stride) = downloader.download_bytes(); + + let mut bytes_iter = bytes.iter(); + let mut transparent = 0; + let mut bad_contrast = 0; + 'img: loop { + for _ in 0..dbg!(texture.width()) { + let Some(r) = bytes_iter.next() else { break 'img; }; + let Some(g) = bytes_iter.next() else { break 'img; }; + let Some(b) = bytes_iter.next() else { break 'img; }; + let Some(a) = bytes_iter.next() else { break 'img; }; + + // 70% transparency + if dbg!(*a) < BACKGROUND_GUESS_TRANSPRAENT_PIXEL_THRESHOLD { + transparent += 1; + } else { + let fg = gdk::RGBA::new( + *r as f32 / 255., + *g as f32 / 255., + *b as f32 / 255., + *a as f32 / 255., + ); + let contrast = crate::util::contrast_ratio(&BACKGROUND_COLOR_DEFAULT, &fg); + + if contrast < BACKGROUND_GUESS_LOW_CONTRAST_RATIO { + bad_contrast += 1; + } + } + } + + let advance_by = stride - 4 * texture.width() as usize; + + if dbg!(advance_by) > 0 { + bytes_iter.nth(advance_by - 1); + } + } + + let n_pixels = texture.width() * texture.height(); + + let part_transparent = transparent as f64 / n_pixels as f64; + let part_bad_contrast = bad_contrast as f64 / (n_pixels as f64 - transparent as f64); + + if dbg!(part_transparent) > BACKGROUND_GUESS_TRANSPRAENT_IMAGE_THRESHOLD + && dbg!(part_bad_contrast) > BACKGROUND_GUESS_LOW_CONTRAST_TRHESHOLD + { + Some(*BACKGROUND_COLOR_ALTERNATE) + } else { + Some(*BACKGROUND_COLOR_DEFAULT) + } + } }