async ImageBuilder, restructure: cache in image_rendering, prelude, cached ImageBuilder

This commit is contained in:
Fl1tzi 2024-02-28 00:39:00 +01:00
parent 4c1c75e7d8
commit 9690f1f976
No known key found for this signature in database
GPG key ID: 06B333727810C686
5 changed files with 207 additions and 134 deletions

View file

@ -1,11 +1,15 @@
// multiple functions for rendering various images/buttons
//
use crate::GLOBAL_FONT;
use async_trait::async_trait;
use image::imageops;
use image::{io::Reader, DynamicImage, ImageBuffer, Rgb, RgbImage};
use imageproc::drawing::draw_text_mut;
use rusttype::Scale;
use std::{io, path::Path};
use std::{
io,
path::{Path, PathBuf},
};
use tracing::trace;
/// Retrieve an image from a path
@ -17,6 +21,137 @@ pub fn retrieve_image(path: &Path) -> io::Result<DynamicImage> {
.expect("Unable to decode image"))
}
pub mod cache {
use super::retrieve_image;
use base64::engine::{general_purpose, Engine};
use dirs::cache_dir;
use image::imageops::FilterType;
use image::io::Reader as ImageReader;
use image::DynamicImage;
use ring::digest;
use std::path::PathBuf;
use tracing::trace;
/// Loads an image from the system or retrieves it from the cache. If
/// the provided image is not already in the cache it will be inserted.
#[allow(dead_code)]
pub async fn load_image(path: PathBuf, resolution: (usize, usize)) -> Option<DynamicImage> {
// hash the image
let mut image = tokio::task::spawn_blocking(move || retrieve_image(&path))
.await
.unwrap()
.ok()?;
let image_hash = hash_image(image.as_bytes());
if let Some(image) = get_image_from_cache(&image_hash, resolution) {
trace!("Cached image is available");
return Some(image);
}
// TODO prevent multiple buttons from resizing the same image at the same time (performance
// improvement)
let image = tokio::task::spawn_blocking(move || {
trace!("Resizing image");
image = image.resize_exact(
resolution.0 as u32,
resolution.1 as u32,
FilterType::Lanczos3,
);
trace!("Resizing finished");
let mut path = match cache_dir() {
Some(dir) => dir,
None => return None, // System does not provide cache
};
path.push("microdeck");
path.push(image_cache_file_name(&image_hash, resolution));
image.save(path).ok()?;
Some(image)
})
.await
.unwrap()?;
Some(image.into())
}
/// Does the same thing as [load_image] but the images aspect ratio is preserved to fit in the specified resolution. Also see [image::DynamicImage::resize_to_fill]
// TODO: Duplicated code from load_image
pub async fn load_image_fill(
path: PathBuf,
resolution: (usize, usize),
) -> Option<DynamicImage> {
// hash the image
let mut image = tokio::task::spawn_blocking(move || retrieve_image(&path))
.await
.unwrap()
.ok()?;
let image_hash = hash_image(image.as_bytes());
if let Some(image) = get_image_from_cache(&image_hash, resolution) {
trace!("Cached image is available");
return Some(image);
}
// TODO prevent multiple buttons from resizing the same image at the same time (performance
// improvement)
let image = tokio::task::spawn_blocking(move || {
trace!("Resizing image");
image = image.resize_to_fill(
resolution.0 as u32,
resolution.1 as u32,
FilterType::Lanczos3,
);
trace!("Resizing finished");
let mut path = match cache_dir() {
Some(dir) => dir,
None => return None, // System does not provide cache
};
path.push("microdeck");
path.push(image_cache_file_name(&image_hash, resolution));
image.save(path).ok()?;
Some(image)
})
.await
.unwrap()?;
Some(image.into())
}
/// File name for a cached image
///
/// `<hash>-<height>x<width>`
pub fn image_cache_file_name(image_hash: &str, resolution: (usize, usize)) -> String {
format!("{}-{}x{}.png", image_hash, resolution.0, resolution.1)
}
pub fn hash_image(data: &[u8]) -> String {
let mut context = digest::Context::new(&digest::SHA256);
context.update(data);
let hash = context.finish();
general_purpose::STANDARD.encode(hash)
}
/// Try to retrieve an image from the cache. Will return None if
/// the image was not cached yet (or is not accessible)
/// or if the system does not provide a [dirs::cache_dir].
#[allow(dead_code)]
pub fn get_image_from_cache(
image_hash: &str,
resolution: (usize, usize),
) -> Option<DynamicImage> {
let mut path = match cache_dir() {
Some(dir) => dir,
None => return None, // System does not provide cache
};
path.push("microdeck");
path.push(image_cache_file_name(image_hash, resolution));
Some(ImageReader::open(path).ok()?.decode().ok()?)
}
}
/// A red image which should represent an missing image or error
#[allow(dead_code)]
pub fn create_error_image() -> DynamicImage {
@ -29,8 +164,9 @@ pub fn create_error_image() -> DynamicImage {
DynamicImage::ImageRgb8(error_img)
}
#[async_trait]
trait Component {
fn render(&self) -> DynamicImage;
async fn render(self) -> DynamicImage;
}
/// The ImageBuilder is an easy way to build images.
@ -49,7 +185,7 @@ pub struct ImageBuilder {
font_size: f32,
text: Option<String>,
text_color: [u8; 3],
image: Option<DynamicImage>,
image: Option<PathBuf>,
}
impl Default for ImageBuilder {
@ -104,13 +240,13 @@ impl ImageBuilder {
}
#[allow(dead_code)]
pub fn set_image(mut self, image: DynamicImage) -> Self {
pub fn set_image(mut self, image: PathBuf) -> Self {
self.image = Some(image);
self
}
#[allow(dead_code)]
pub fn build(self) -> DynamicImage {
pub async fn build(self) -> DynamicImage {
// cannot use "if let" here, because variables would be moved
if self.text.is_some() && self.image.is_some() {
let c = ImageTextComponent {
@ -122,7 +258,7 @@ impl ImageBuilder {
text_color: self.text_color,
text: self.text.unwrap(),
};
return c.render();
return c.render().await;
} else if let Some(text) = self.text {
let c = TextComponent {
height: self.height,
@ -131,7 +267,7 @@ impl ImageBuilder {
text_color: self.text_color,
text,
};
return c.render();
return c.render().await;
} else if let Some(image) = self.image {
let c = ImageComponent {
height: self.height,
@ -139,7 +275,7 @@ impl ImageBuilder {
scale: self.scale,
image,
};
return c.render();
return c.render().await;
} else {
return create_error_image();
}
@ -151,17 +287,18 @@ struct ImageComponent {
height: usize,
width: usize,
scale: f32,
image: DynamicImage,
image: PathBuf,
}
#[async_trait]
impl Component for ImageComponent {
fn render(&self) -> DynamicImage {
async fn render(self) -> DynamicImage {
let new_h = (self.height as f32 * (self.scale * 0.01)) as u32;
let new_w = (self.width as f32 * (self.scale * 0.01)) as u32;
let image = self
.image
.resize_to_fill(new_w, new_h, image::imageops::FilterType::Nearest);
let image = cache::load_image_fill(self.image, (new_h as usize, new_w as usize))
.await
.expect("Image not available");
let mut base_image = RgbImage::new(self.height as u32, self.width as u32);
@ -187,14 +324,20 @@ struct TextComponent {
text: String,
}
#[async_trait]
impl Component for TextComponent {
fn render(&self) -> DynamicImage {
let mut image = RgbImage::new(self.width as u32, self.height as u32);
async fn render(self) -> DynamicImage {
let image = RgbImage::new(self.width as u32, self.height as u32);
let font_scale = Scale::uniform(self.font_size);
let text = wrap_text(self.height as u32, font_scale, &self.text);
let text_color = Rgb(self.text_color);
draw_text_on_image(&text, &mut image, Rgb(self.text_color), font_scale);
let image = tokio::task::spawn_blocking(move || {
draw_text_on_image(text, image, text_color, font_scale)
})
.await
.unwrap();
image::DynamicImage::ImageRgb8(image)
}
@ -204,27 +347,33 @@ impl Component for TextComponent {
struct ImageTextComponent {
height: usize,
width: usize,
image: DynamicImage,
image: PathBuf,
scale: f32,
font_size: f32,
text_color: [u8; 3],
text: String,
}
#[async_trait]
impl Component for ImageTextComponent {
fn render(&self) -> DynamicImage {
async fn render(self) -> DynamicImage {
let new_h = (self.height as f32 * (self.scale * 0.01)) as u32;
let new_w = (self.width as f32 * (self.scale * 0.01)) as u32;
let image = self
.image
.resize_to_fill(new_w, new_h, image::imageops::FilterType::Nearest);
let image = cache::load_image_fill(self.image, (new_h as usize, new_w as usize))
.await
.expect("Image not available");
let mut base_image = RgbImage::new(self.height as u32, self.width as u32);
let base_image = RgbImage::new(self.height as u32, self.width as u32);
let font_scale = Scale::uniform(self.font_size);
let text = wrap_text(self.height as u32, font_scale, &self.text);
draw_text_on_image(&text, &mut base_image, Rgb(self.text_color), font_scale);
let text_color = Rgb(self.text_color);
let mut base_image = tokio::task::spawn_blocking(move || {
draw_text_on_image(text, base_image, text_color, font_scale)
})
.await
.unwrap();
// position at the middle
let free_space = self.width - image.width() as usize;
// TODO: allow padding to be manually set
@ -239,7 +388,13 @@ impl Component for ImageTextComponent {
}
}
fn draw_text_on_image(text: &String, image: &mut RgbImage, color: Rgb<u8>, font_scale: Scale) {
fn draw_text_on_image(
text: String,
image: RgbImage,
color: Rgb<u8>,
font_scale: Scale,
) -> RgbImage {
let mut image = image;
let font = &GLOBAL_FONT.get().unwrap();
let v_metrics = font.v_metrics(font_scale);
@ -247,9 +402,10 @@ fn draw_text_on_image(text: &String, image: &mut RgbImage, color: Rgb<u8>, font_
let mut y_pos = 0;
for line in text.split('\n') {
draw_text_mut(image, color, 0, y_pos, font_scale, font, &line);
draw_text_mut(&mut image, color, 0, y_pos, font_scale, font, &line);
y_pos += line_height
}
image
}
/// This functions adds '\n' to the line endings. It does not wrap

View file

@ -10,25 +10,27 @@ use self::space::Space;
// other things
use crate::config::{Button, ButtonConfigError};
use crate::image_rendering::{retrieve_image, ImageBuilder};
use ::image::imageops::FilterType;
use ::image::io::Reader as ImageReader;
use crate::image_rendering::ImageBuilder;
use ::image::DynamicImage;
use async_trait::async_trait;
use base64::engine::{general_purpose, Engine};
pub use deck_driver as streamdeck;
use dirs::cache_dir;
use futures_util::Future;
use once_cell::sync::Lazy;
use ring::digest;
use std::collections::HashMap;
use std::{error::Error, path::PathBuf, pin::Pin, sync::Arc};
use std::{error::Error, pin::Pin, sync::Arc};
use streamdeck::info::ImageFormat;
use streamdeck::info::Kind;
use streamdeck::AsyncStreamDeck;
use streamdeck::StreamDeckError;
use tokio::sync::mpsc;
use tracing::{debug, error, trace};
use tracing::{debug, error};
pub mod prelude {
pub use super::{ChannelReceiver, DeviceAccess, HostEvent, Module, ModuleObject, ReturnError};
pub use crate::config::{Button, ButtonConfigError};
pub use crate::image_rendering::{cache::*, ImageBuilder};
pub use image::DynamicImage;
}
pub static MODULE_REGISTRY: Lazy<ModuleRegistry> = Lazy::new(|| ModuleRegistry::default());
@ -112,84 +114,13 @@ pub async fn start_module(
.set_text(format!("E: {}", e))
.set_font_size(12.0)
.set_text_color([255, 0, 0])
.build();
.build()
.await;
da.write_img(image).await.unwrap();
}
}
}
/// Loads an image from the system or retrieves it from the cache. If
/// the provided image is not already in the cache it will be inserted.
#[allow(dead_code)]
pub async fn load_image(path: PathBuf, resolution: (usize, usize)) -> Option<DynamicImage> {
// hash the image
let mut image = tokio::task::spawn_blocking(move || retrieve_image(&path))
.await
.unwrap()
.ok()?;
let image_hash = hash_image(image.as_bytes());
if let Some(image) = get_image_from_cache(&image_hash, resolution) {
trace!("Cached image is available");
return Some(image);
}
// TODO prevent multiple buttons from resizing the same image at the same time (performance
// improvement)
let image = tokio::task::spawn_blocking(move || {
trace!("Resizing image");
image = image.resize_exact(
resolution.0 as u32,
resolution.1 as u32,
FilterType::Lanczos3,
);
trace!("Resizing finished");
let mut path = match cache_dir() {
Some(dir) => dir,
None => return None, // System does not provide cache
};
path.push("microdeck");
path.push(image_cache_file_name(&image_hash, resolution));
image.save(path).ok()?;
Some(image)
})
.await
.unwrap()?;
Some(image.into())
}
/// File name for a cached image
///
/// `<hash>-<height>x<width>`
pub fn image_cache_file_name(image_hash: &str, resolution: (usize, usize)) -> String {
format!("{}-{}x{}.png", image_hash, resolution.0, resolution.1)
}
pub fn hash_image(data: &[u8]) -> String {
let mut context = digest::Context::new(&digest::SHA256);
context.update(data);
let hash = context.finish();
general_purpose::STANDARD.encode(hash)
}
/// Try to retrieve an image from the cache. Will return None if
/// the image was not cached yet (or is not accessible)
/// or if the system does not provide a [dirs::cache_dir].
#[allow(dead_code)]
pub fn get_image_from_cache(image_hash: &str, resolution: (usize, usize)) -> Option<DynamicImage> {
let mut path = match cache_dir() {
Some(dir) => dir,
None => return None, // System does not provide cache
};
path.push("microdeck");
path.push(image_cache_file_name(image_hash, resolution));
Some(ImageReader::open(path).ok()?.decode().ok()?)
}
/// Wrapper to provide easier access to the Deck
pub struct DeviceAccess {
streamdeck: Arc<AsyncStreamDeck>,

View file

@ -1,10 +1,6 @@
use std::sync::Arc;
use crate::config::Button;
use super::{
ButtonConfigError, ChannelReceiver, DeviceAccess, HostEvent, Module, ModuleObject, ReturnError,
};
use super::prelude::*;
use crate::image_rendering::wrap_text;
use crate::GLOBAL_FONT;

View file

@ -1,12 +1,4 @@
use super::load_image;
use super::Button;
use super::ButtonConfigError;
use super::ChannelReceiver;
use super::DeviceAccess;
use super::Module;
use super::ModuleObject;
use super::ReturnError;
use crate::image_rendering::ImageBuilder;
use super::prelude::*;
use async_trait::async_trait;
use std::{path::PathBuf, sync::Arc};
@ -38,9 +30,10 @@ impl Module for Image {
) -> Result<(), ReturnError> {
let (h, w) = streamdeck.resolution();
let img = ImageBuilder::new(h, w)
.set_image(load_image(self.path.clone(), (h, w)).await.unwrap())
.set_image(self.path.clone())
.set_image_scale(self.scale)
.build();
.build()
.await;
streamdeck.write_img(img).await.unwrap();
Ok(())
}

View file

@ -1,24 +1,21 @@
use super::Button;
use super::ButtonConfigError;
use super::ChannelReceiver;
use super::DeviceAccess;
use super::Module;
use super::ModuleObject;
use super::ReturnError;
use crate::image_rendering::{create_error_image, ImageBuilder};
use super::prelude::*;
use async_trait::async_trait;
use std::path::PathBuf;
use std::sync::Arc;
/// module to represent the switching of a space (just visual)
pub struct Space {
name: String,
path: PathBuf,
}
#[async_trait]
impl Module for Space {
async fn new(config: Arc<Button>) -> Result<ModuleObject, ButtonConfigError> {
let name = config.parse_module("name", "Unknown".to_string()).res()?;
Ok(Box::new(Space { name }))
let path = config.parse_module("path", "".to_string()).required()?;
let path = PathBuf::from(path);
Ok(Box::new(Space { name, path }))
}
async fn run(
@ -27,13 +24,13 @@ impl Module for Space {
_button_receiver: ChannelReceiver,
) -> Result<(), ReturnError> {
// let icon = load_image(&config).unwrap();
let icon = create_error_image();
let res = streamdeck.resolution();
let image = ImageBuilder::new(res.0, res.1)
.set_image(icon)
.set_image(self.path.clone())
.set_text(self.name.clone())
.build();
.build()
.await;
streamdeck.write_img(image).await.unwrap();
Ok(())