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: <https://gitlab.gnome.org/Incubator/loupe/-/merge_requests/151>
This commit is contained in:
parent
2e237d41e8
commit
7bf9baa581
4 changed files with 159 additions and 33 deletions
|
|
@ -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<gdk::RGBA>,
|
||||
}
|
||||
|
||||
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);
|
||||
|
|
|
|||
25
src/util.rs
25
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 <https://www.w3.org/WAI/WCAG21/Understanding/contrast-minimum.html#dfn-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 <https://www.w3.org/WAI/WCAG21/Understanding/contrast-minimum.html#dfn-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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<gdk::RGBA> =
|
||||
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<gdk::RGBA> =
|
||||
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<ArcSwap<tiling::FrameBuffer>>,
|
||||
pub(super) decoder: RefCell<Option<Arc<Decoder>>>,
|
||||
pub(super) format: RefCell<Option<decoder::ImageFormat>>,
|
||||
pub(super) background_color: RefCell<Option<gdk::RGBA>>,
|
||||
|
||||
/// Set to true when image is ready for displaying
|
||||
pub(super) is_loaded: Cell<bool>,
|
||||
|
|
@ -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<gdk::RGBA>) {
|
||||
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<gdk::RGBA> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue