implement adaptive sampling for optimal image quality of large images in gallery view while keeping the memory foodprint minimal and UI blocks from GPU buffer uploads minimal and as short as possible
This commit is contained in:
parent
76c56d5d3b
commit
bf7b9c192c
2 changed files with 279 additions and 41 deletions
|
|
@ -30,6 +30,59 @@ pub const MB_TO_BYTES: u64 = 1024 * 1024;
|
|||
/// The image crate's memory limits use decimal MB, not binary MB.
|
||||
pub const DECIMAL_MB_TO_BYTES: u64 = 1000 * 1000;
|
||||
|
||||
/// Scale factor for HiDPI displays - decode at higher resolution than display size
|
||||
/// for better quality on high-DPI screens. 1.5x provides good balance between
|
||||
/// quality and memory usage and also prevets re-decoding on small windows size adjustments.
|
||||
const DISPLAY_SCALE_FACTOR: f32 = 1.5;
|
||||
|
||||
/// Calculate optimal target dimensions for decoding based on display size.
|
||||
/// Returns None if no resizing is needed (image is smaller than display).
|
||||
///
|
||||
/// This helps reduce memory usage by decoding large images at a resolution
|
||||
/// appropriate for the display, rather than always using full resolution.
|
||||
pub fn calculate_target_dimensions(
|
||||
image_width: u32,
|
||||
image_height: u32,
|
||||
display_width: u32,
|
||||
display_height: u32,
|
||||
) -> Option<(u32, u32)> {
|
||||
let target_width = (display_width as f32 * DISPLAY_SCALE_FACTOR) as u32;
|
||||
let target_height = (display_height as f32 * DISPLAY_SCALE_FACTOR) as u32;
|
||||
|
||||
if image_width <= target_width && image_height <= target_height {
|
||||
return None;
|
||||
}
|
||||
|
||||
let image_aspect = image_width as f32 / image_height as f32;
|
||||
let target_aspect = target_width as f32 / target_height as f32;
|
||||
|
||||
let (new_width, new_height) = if image_aspect > target_aspect {
|
||||
let w = target_width;
|
||||
let h = (target_width as f32 / image_aspect) as u32;
|
||||
(w, h)
|
||||
} else {
|
||||
let h = target_height;
|
||||
let w = (target_height as f32 * image_aspect) as u32;
|
||||
(w, h)
|
||||
};
|
||||
|
||||
let new_width = new_width.max(1);
|
||||
let new_height = new_height.max(1);
|
||||
|
||||
log::info!(
|
||||
"Calculated target dimensions: {}x{} -> {}x{} (display: {}x{}, scale: {}x)",
|
||||
image_width,
|
||||
image_height,
|
||||
new_width,
|
||||
new_height,
|
||||
display_width,
|
||||
display_height,
|
||||
DISPLAY_SCALE_FACTOR
|
||||
);
|
||||
|
||||
Some((new_width, new_height))
|
||||
}
|
||||
|
||||
/// Check if an image's dimensions would exceed the available memory budget.
|
||||
/// Returns true if the image is too large to decode.
|
||||
pub fn exceeds_memory_limit(width: u32, height: u32, memory_limit_mb: u64) -> bool {
|
||||
|
|
@ -208,7 +261,10 @@ pub fn check_memory_available(width: u32, height: u32) -> (bool, Option<String>)
|
|||
/// This function is used for gallery mode where full-resolution images need to be loaded.
|
||||
/// It uses the full memory budget (GALLERY_MEMORY_LIMIT_MB) since only one image
|
||||
/// decodes at a time in gallery mode.
|
||||
pub async fn decode_large_image(path: PathBuf) -> Option<(PathBuf, u32, u32, Vec<u8>)> {
|
||||
pub async fn decode_large_image(
|
||||
path: PathBuf,
|
||||
target_dimensions: Option<(u32, u32)>,
|
||||
) -> Option<(PathBuf, u32, u32, Vec<u8>)> {
|
||||
// Decode image in blocking thread pool (CPU-intensive work should not block)
|
||||
tokio::task::spawn_blocking(move || {
|
||||
log::info!("Starting async decode of {}", path.display());
|
||||
|
|
@ -227,16 +283,46 @@ pub async fn decode_large_image(path: PathBuf) -> Option<(PathBuf, u32, u32, Vec
|
|||
match reader.decode() {
|
||||
Ok(img) => {
|
||||
let rgba = img.into_rgba8();
|
||||
let width = rgba.width();
|
||||
let height = rgba.height();
|
||||
let pixels = rgba.into_raw();
|
||||
let orig_width = rgba.width();
|
||||
let orig_height = rgba.height();
|
||||
|
||||
log::info!(
|
||||
"Decoded {}x{} image: {}",
|
||||
width,
|
||||
height,
|
||||
path.display()
|
||||
);
|
||||
// Resize if target dimensions provided
|
||||
let (final_img, width, height) = if let Some((target_w, target_h)) = target_dimensions {
|
||||
log::info!(
|
||||
"Resizing {}x{} -> {}x{} for memory optimization: {}",
|
||||
orig_width, orig_height, target_w, target_h,
|
||||
path.display()
|
||||
);
|
||||
|
||||
// Use Lanczos3 for high-quality downsampling
|
||||
let resized = image::imageops::resize(
|
||||
&rgba,
|
||||
target_w,
|
||||
target_h,
|
||||
image::imageops::FilterType::Lanczos3,
|
||||
);
|
||||
|
||||
let resized_w = resized.width();
|
||||
let resized_h = resized.height();
|
||||
|
||||
log::info!(
|
||||
"Resize complete: {}x{} image now uses ~{} MB instead of ~{} MB",
|
||||
resized_w, resized_h,
|
||||
(resized_w as u64 * resized_h as u64 * 4) / MB_TO_BYTES,
|
||||
(orig_width as u64 * orig_height as u64 * 4) / MB_TO_BYTES
|
||||
);
|
||||
|
||||
(resized, resized_w, resized_h)
|
||||
} else {
|
||||
log::info!(
|
||||
"Decoded {}x{} image at full resolution: {}",
|
||||
orig_width, orig_height,
|
||||
path.display()
|
||||
);
|
||||
(rgba, orig_width, orig_height)
|
||||
};
|
||||
|
||||
let pixels = final_img.into_raw();
|
||||
Some((path, width, height, pixels))
|
||||
}
|
||||
Err(e) => {
|
||||
|
|
@ -269,8 +355,14 @@ pub struct LargeImageManager {
|
|||
decoding_images: HashSet<PathBuf>,
|
||||
/// Cache of decoded image handles
|
||||
decoded_images: HashMap<PathBuf, widget::image::Handle>,
|
||||
/// Display dimensions used for each decoded image (for resize detection)
|
||||
decoded_display_sizes: HashMap<PathBuf, (u32, u32)>,
|
||||
/// Errors encountered during decoding
|
||||
decode_errors: HashMap<PathBuf, String>,
|
||||
/// Generation counter for each decode to support cancellation.
|
||||
/// When a new decode is started for the same path, the generation is incremented.
|
||||
/// Only decodes matching the current generation are accepted when they complete.
|
||||
decode_generations: HashMap<PathBuf, u64>,
|
||||
}
|
||||
|
||||
impl LargeImageManager {
|
||||
|
|
@ -290,9 +382,40 @@ impl LargeImageManager {
|
|||
self.decode_errors.get(path)
|
||||
}
|
||||
|
||||
pub fn store_decoded(&mut self, path: PathBuf, handle: widget::image::Handle) {
|
||||
/// Store a decoded image if the generation matches (not superseded by newer decode).
|
||||
/// Returns true if stored, false if rejected due to generation mismatch.
|
||||
pub fn store_decoded_with_generation(
|
||||
&mut self,
|
||||
path: PathBuf,
|
||||
handle: widget::image::Handle,
|
||||
display_size: Option<(u32, u32)>,
|
||||
generation: u64,
|
||||
) -> bool {
|
||||
// Check if this decode is still current (not superseded by a newer one)
|
||||
if let Some(¤t_gen) = self.decode_generations.get(&path) {
|
||||
if generation != current_gen {
|
||||
log::info!(
|
||||
"Discarding outdated decode for {} (generation {} != current {})",
|
||||
path.display(),
|
||||
generation,
|
||||
current_gen
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"Storing decoded image for {} (generation {})",
|
||||
path.display(),
|
||||
generation
|
||||
);
|
||||
|
||||
self.decoded_images.insert(path.clone(), handle);
|
||||
if let Some(size) = display_size {
|
||||
self.decoded_display_sizes.insert(path.clone(), size);
|
||||
}
|
||||
self.decoding_images.remove(&path);
|
||||
true
|
||||
}
|
||||
|
||||
pub fn store_error(&mut self, path: PathBuf, error: String) {
|
||||
|
|
@ -320,28 +443,115 @@ impl LargeImageManager {
|
|||
self.decoded_images.is_empty()
|
||||
}
|
||||
|
||||
/// Attempt to decode a large image, checking memory availability first.
|
||||
/// Returns true if decode was initiated, false if skipped due to insufficient memory.
|
||||
pub fn try_decode(&mut self, path: &PathBuf) -> bool {
|
||||
self.clear_error(path);
|
||||
|
||||
// Check if already decoded or decoding
|
||||
if self.get_decoded(path).is_some() || self.is_decoding(path) {
|
||||
/// Check if an image should be re-decoded due to display size increase.
|
||||
/// Returns true only if the display size has INCREASED by more than 20% in either dimension.
|
||||
/// Does NOT re-decode for smaller sizes (GPU can efficiently downscale).
|
||||
pub fn needs_redecode_for_size(
|
||||
&self,
|
||||
path: &Path,
|
||||
new_display_size: Option<(u32, u32)>,
|
||||
) -> bool {
|
||||
let Some(new_size) = new_display_size else {
|
||||
return false;
|
||||
};
|
||||
|
||||
let Some(&old_size) = self.decoded_display_sizes.get(path) else {
|
||||
return false;
|
||||
};
|
||||
|
||||
const REDECODE_THRESHOLD: f32 = 0.2;
|
||||
|
||||
let width_increase = (new_size.0 as f32 / old_size.0 as f32) - 1.0;
|
||||
let height_increase = (new_size.1 as f32 / old_size.1 as f32) - 1.0;
|
||||
|
||||
let needs_redecode =
|
||||
width_increase > REDECODE_THRESHOLD || height_increase > REDECODE_THRESHOLD;
|
||||
|
||||
if needs_redecode {
|
||||
log::info!(
|
||||
"Display size increased significantly for {}: {}x{} -> {}x{} (increase: {:.1}% width, {:.1}% height) - re-decoding at higher resolution",
|
||||
path.display(),
|
||||
old_size.0,
|
||||
old_size.1,
|
||||
new_size.0,
|
||||
new_size.1,
|
||||
width_increase * 100.0,
|
||||
height_increase * 100.0
|
||||
);
|
||||
} else if width_increase < -REDECODE_THRESHOLD || height_increase < -REDECODE_THRESHOLD {
|
||||
log::debug!(
|
||||
"Display size decreased for {}: {}x{} -> {}x{} (decrease: {:.1}% width, {:.1}% height) - keeping existing higher resolution",
|
||||
path.display(),
|
||||
old_size.0,
|
||||
old_size.1,
|
||||
new_size.0,
|
||||
new_size.1,
|
||||
width_increase * 100.0,
|
||||
height_increase * 100.0
|
||||
);
|
||||
}
|
||||
|
||||
needs_redecode
|
||||
}
|
||||
|
||||
/// Attempt to decode a large image, checking memory availability first.
|
||||
/// Returns (should_decode, target_dimensions, generation) tuple.
|
||||
pub fn try_decode(
|
||||
&mut self,
|
||||
path: &PathBuf,
|
||||
display_dimensions: Option<(u32, u32)>,
|
||||
) -> (bool, Option<(u32, u32)>, u64) {
|
||||
self.clear_error(path);
|
||||
let is_currently_decoding = self.is_decoding(path);
|
||||
let needs_redecode = self.needs_redecode_for_size(path, display_dimensions);
|
||||
|
||||
if is_currently_decoding && !needs_redecode {
|
||||
// Get current generation for the ongoing decode
|
||||
let generation = self.decode_generations.get(path).copied().unwrap_or(0);
|
||||
return (false, None, generation);
|
||||
}
|
||||
|
||||
if self.get_decoded(path).is_some() && !needs_redecode && !is_currently_decoding {
|
||||
let generation = self.decode_generations.get(path).copied().unwrap_or(0);
|
||||
return (false, None, generation);
|
||||
}
|
||||
|
||||
let Some((width, height)) = get_image_dimensions(path) else {
|
||||
self.store_error(path.clone(), "Failed to read image dimensions".to_string());
|
||||
return false;
|
||||
return (false, None, 0);
|
||||
};
|
||||
|
||||
if !self.ensure_memory_available(path, width, height) {
|
||||
return false;
|
||||
let target_dimensions = if let Some((display_w, display_h)) = display_dimensions {
|
||||
calculate_target_dimensions(width, height, display_w, display_h)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Check memory for target size (if resizing) or full size
|
||||
let (check_w, check_h) = target_dimensions.unwrap_or((width, height));
|
||||
if !self.ensure_memory_available(path, check_w, check_h) {
|
||||
return (false, None, 0);
|
||||
}
|
||||
|
||||
// Increment generation counter (cancels any previous decode)
|
||||
let generation = self
|
||||
.decode_generations
|
||||
.entry(path.clone())
|
||||
.and_modify(|g| *g += 1)
|
||||
.or_insert(1);
|
||||
let generation = *generation;
|
||||
|
||||
if is_currently_decoding {
|
||||
log::info!(
|
||||
"Cancelling previous decode for {} and starting new one (generation {})",
|
||||
path.display(),
|
||||
generation
|
||||
);
|
||||
}
|
||||
|
||||
// Mark as decoding
|
||||
self.decoding_images.insert(path.clone());
|
||||
true
|
||||
(true, target_dimensions, generation)
|
||||
}
|
||||
|
||||
/// Check if sufficient memory is available, clearing cache if needed.
|
||||
|
|
|
|||
66
src/tab.rs
66
src/tab.rs
|
|
@ -1624,7 +1624,7 @@ pub enum Message {
|
|||
HighlightDeactivate(usize),
|
||||
HighlightActivate(usize),
|
||||
DirectorySize(PathBuf, DirSize),
|
||||
ImageDecoded(PathBuf, u32, u32, Vec<u8>),
|
||||
ImageDecoded(PathBuf, u32, u32, Vec<u8>, Option<(u32, u32)>, u64), // path, width, height, pixels, display_size, generation
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
|
||||
|
|
@ -2986,16 +2986,35 @@ impl Tab {
|
|||
// Clone path to avoid borrow checker issues
|
||||
let path = path.to_path_buf();
|
||||
|
||||
// Try to decode the image using LargeImageManager
|
||||
if self.large_image_manager.try_decode(&path) {
|
||||
// Get display size for adaptive resolution
|
||||
let display_dimensions = self
|
||||
.size_opt
|
||||
.get()
|
||||
.map(|size| (size.width as u32, size.height as u32));
|
||||
|
||||
// Try to decode the image using LargeImageManager with adaptive resolution
|
||||
let (should_decode, target_dimensions, generation) = self
|
||||
.large_image_manager
|
||||
.try_decode(&path, display_dimensions);
|
||||
if should_decode {
|
||||
vec![Command::Iced(
|
||||
cosmic::iced::Task::perform(decode_large_image(path), |result| {
|
||||
result
|
||||
.map(|(path, width, height, pixels)| {
|
||||
Message::ImageDecoded(path, width, height, pixels)
|
||||
})
|
||||
.unwrap_or_else(|| Message::AutoScroll(None))
|
||||
})
|
||||
cosmic::iced::Task::perform(
|
||||
decode_large_image(path, target_dimensions),
|
||||
move |result| {
|
||||
result
|
||||
.map(|(path, width, height, pixels)| {
|
||||
Message::ImageDecoded(
|
||||
path,
|
||||
width,
|
||||
height,
|
||||
pixels,
|
||||
display_dimensions,
|
||||
generation,
|
||||
)
|
||||
})
|
||||
.unwrap_or_else(|| Message::AutoScroll(None))
|
||||
},
|
||||
)
|
||||
.into(),
|
||||
)]
|
||||
} else {
|
||||
|
|
@ -3955,12 +3974,17 @@ impl Tab {
|
|||
}
|
||||
}
|
||||
}
|
||||
Message::ImageDecoded(path, width, height, pixels) => {
|
||||
Message::ImageDecoded(path, width, height, pixels, display_size, generation) => {
|
||||
// Create handle from pre-decoded RGBA data (fast!)
|
||||
let handle = widget::image::Handle::from_rgba(width, height, pixels);
|
||||
|
||||
// Store decoded image handle and remove from decoding set
|
||||
self.large_image_manager.store_decoded(path, handle);
|
||||
// Store decoded image handle if generation still matches (not superseded)
|
||||
self.large_image_manager.store_decoded_with_generation(
|
||||
path,
|
||||
handle,
|
||||
display_size,
|
||||
generation,
|
||||
);
|
||||
}
|
||||
Message::ToggleSort(heading_option) => {
|
||||
if !matches!(self.location, Location::Search(..)) {
|
||||
|
|
@ -4324,15 +4348,19 @@ impl Tab {
|
|||
if let Some(error_msg) = self.large_image_manager.get_error(path) {
|
||||
error_msg_opt = Some(error_msg.clone());
|
||||
handle.clone()
|
||||
} else if self.large_image_manager.is_decoding(path) {
|
||||
// Currently decoding (initial or re-decode) --> show cached/thumbnail with loading indicator
|
||||
is_loading = true;
|
||||
// Use decoded handle if available (re-decode), otherwise thumbnail (initial decode)
|
||||
self.large_image_manager
|
||||
.get_decoded(path)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| handle.clone())
|
||||
} else if let Some(decoded_handle) =
|
||||
self.large_image_manager.get_decoded(path)
|
||||
{
|
||||
// Full resolution ready --> use it
|
||||
// Decoded and not currently decoding --> use it
|
||||
decoded_handle.clone()
|
||||
} else if self.large_image_manager.is_decoding(path) {
|
||||
// Currently decoding --> show thumbnail with loading indicator
|
||||
is_loading = true;
|
||||
handle.clone()
|
||||
} else if let Some((w, h)) = original_dims {
|
||||
// Check if image needs tiling
|
||||
if should_use_tiling(*w, *h) {
|
||||
|
|
@ -4361,7 +4389,7 @@ impl Tab {
|
|||
} else if is_loading {
|
||||
widget::column()
|
||||
.push(widget::image(image_handle))
|
||||
.push(widget::text("Loading full resolution...").size(14))
|
||||
.push(widget::text("Loading higher resolution...").size(14))
|
||||
.padding(space_xs)
|
||||
.align_x(cosmic::iced::Alignment::Center)
|
||||
.into()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue