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
|
|
@ -4,40 +4,10 @@ button.osd.circular {
|
||||||
min-height: 42px;
|
min-height: 42px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.image-view {
|
|
||||||
background-color: @theme_base_color;
|
|
||||||
}
|
|
||||||
|
|
||||||
flap.fullscreen-widget dimming, shadow, border, outline {
|
flap.fullscreen-widget dimming, shadow, border, outline {
|
||||||
background: none;
|
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 {
|
row.property label.title {
|
||||||
opacity: 0.55;
|
opacity: 0.55;
|
||||||
font-size: smaller;
|
font-size: smaller;
|
||||||
|
|
|
||||||
|
|
@ -88,7 +88,9 @@ impl Tile {
|
||||||
|
|
||||||
// TODO: do not clip outer bounderies of the image
|
// TODO: do not clip outer bounderies of the image
|
||||||
snapshot.push_clip(&area);
|
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.append_scaled_texture(&self.texture, options.scaling_filter, &area_with_bleed);
|
||||||
snapshot.pop();
|
snapshot.pop();
|
||||||
}
|
}
|
||||||
|
|
@ -173,6 +175,7 @@ impl Default for TiledImage {
|
||||||
pub struct RenderOptions {
|
pub struct RenderOptions {
|
||||||
pub scaling_filter: gsk::ScalingFilter,
|
pub scaling_filter: gsk::ScalingFilter,
|
||||||
pub scale_factor: i32,
|
pub scale_factor: i32,
|
||||||
|
pub background_color: Option<gdk::RGBA>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TiledImage {
|
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 {
|
pub trait FrameBufferExt {
|
||||||
fn push(&self, tile: Tile);
|
fn push(&self, tile: Tile);
|
||||||
fn push_frame(&self, tile: Tile, dimensions: Coordinates, delay: Duration);
|
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),
|
Rotate(f64),
|
||||||
Scale,
|
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::path::{Path, PathBuf};
|
||||||
use std::sync::Arc;
|
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
|
/// Milliseconds
|
||||||
const ZOOM_ANIMATION_DURATION: u32 = 200;
|
const ZOOM_ANIMATION_DURATION: u32 = 200;
|
||||||
/// Milliseconds
|
/// Milliseconds
|
||||||
|
|
@ -94,6 +114,7 @@ mod imp {
|
||||||
pub(super) frame_buffer: Arc<ArcSwap<tiling::FrameBuffer>>,
|
pub(super) frame_buffer: Arc<ArcSwap<tiling::FrameBuffer>>,
|
||||||
pub(super) decoder: RefCell<Option<Arc<Decoder>>>,
|
pub(super) decoder: RefCell<Option<Arc<Decoder>>>,
|
||||||
pub(super) format: RefCell<Option<decoder::ImageFormat>>,
|
pub(super) format: RefCell<Option<decoder::ImageFormat>>,
|
||||||
|
pub(super) background_color: RefCell<Option<gdk::RGBA>>,
|
||||||
|
|
||||||
/// Set to true when image is ready for displaying
|
/// Set to true when image is ready for displaying
|
||||||
pub(super) is_loaded: Cell<bool>,
|
pub(super) is_loaded: Cell<bool>,
|
||||||
|
|
@ -590,12 +611,19 @@ mod imp {
|
||||||
let render_options = tiling::RenderOptions {
|
let render_options = tiling::RenderOptions {
|
||||||
scaling_filter,
|
scaling_filter,
|
||||||
scale_factor: widget.scale_factor(),
|
scale_factor: widget.scale_factor(),
|
||||||
|
background_color: Some(widget.background_color()),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Operations on snapshots are coordinate transformations
|
// Operations on snapshots are coordinate transformations
|
||||||
// It might help to read the following code from bottom to top
|
// It might help to read the following code from bottom to top
|
||||||
snapshot.save();
|
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
|
// Apply the scrolling position to the image
|
||||||
if let Some(adj) = self.hadjustment.borrow().as_ref() {
|
if let Some(adj) = self.hadjustment.borrow().as_ref() {
|
||||||
let x = -(adj.value() - (adj.upper() - display_width) / 2.);
|
let x = -(adj.value() - (adj.upper() - display_width) / 2.);
|
||||||
|
|
@ -771,7 +799,7 @@ impl LpImage {
|
||||||
}
|
}
|
||||||
DecoderUpdate::Redraw => {
|
DecoderUpdate::Redraw => {
|
||||||
if !self.is_loaded() {
|
if !self.is_loaded() {
|
||||||
self.imp().is_loaded.set(true);
|
imp.is_loaded.set(true);
|
||||||
self.notify("is-loaded");
|
self.notify("is-loaded");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -779,9 +807,12 @@ impl LpImage {
|
||||||
imp.frame_buffer.rcu(|tiles| {
|
imp.frame_buffer.rcu(|tiles| {
|
||||||
let mut new_tiles = (**tiles).clone();
|
let mut new_tiles = (**tiles).clone();
|
||||||
// TODO: Use an area larger than the viewport
|
// 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
|
new_tiles
|
||||||
});
|
});
|
||||||
|
if self.imp().background_color.borrow().is_none() {
|
||||||
|
self.set_background_color(self.background_color_guess());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
DecoderUpdate::Error(err) => {
|
DecoderUpdate::Error(err) => {
|
||||||
self.set_error(err);
|
self.set_error(err);
|
||||||
|
|
@ -947,6 +978,7 @@ impl LpImage {
|
||||||
let render_options = tiling::RenderOptions {
|
let render_options = tiling::RenderOptions {
|
||||||
scaling_filter: gsk::ScalingFilter::Trilinear,
|
scaling_filter: gsk::ScalingFilter::Trilinear,
|
||||||
scale_factor: self.scale_factor(),
|
scale_factor: self.scale_factor(),
|
||||||
|
background_color: Some(self.background_color()),
|
||||||
};
|
};
|
||||||
|
|
||||||
let snapshot = gtk::Snapshot::new();
|
let snapshot = gtk::Snapshot::new();
|
||||||
|
|
@ -1588,4 +1620,98 @@ impl LpImage {
|
||||||
let scale = self.scale_factor() as f32;
|
let scale = self.scale_factor() as f32;
|
||||||
(number * scale).round() / scale
|
(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