From 1b0e6f9ed163b999dd67d37fff575c8eff22d352 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Tue, 11 Nov 2025 17:13:12 +0100 Subject: [PATCH] fixed bug with images being displayed squashed when dimensions exceeded render size --- pyPhotoAlbum/async_backend.py | 14 ++- pyPhotoAlbum/models.py | 213 +++++++++++++++++++--------------- 2 files changed, 129 insertions(+), 98 deletions(-) diff --git a/pyPhotoAlbum/async_backend.py b/pyPhotoAlbum/async_backend.py index 005b3aa..1298609 100644 --- a/pyPhotoAlbum/async_backend.py +++ b/pyPhotoAlbum/async_backend.py @@ -354,12 +354,16 @@ class AsyncImageLoader(QObject): if img.mode != 'RGBA': img = img.convert('RGBA') - # Downsample if target size specified + # Downsample if target size specified (preserving aspect ratio) if target_size: - current_size = img.size - if current_size[0] > target_size[0] or current_size[1] > target_size[1]: - img = img.resize(target_size, Image.Resampling.LANCZOS) - logger.debug(f"Downsampled {path}: {current_size} -> {target_size}") + max_size = target_size[0] # Assume square target (2048, 2048) + if img.width > max_size or img.height > max_size: + # Calculate scale to fit within max_size while preserving aspect ratio + scale = min(max_size / img.width, max_size / img.height) + new_width = int(img.width * scale) + new_height = int(img.height * scale) + img = img.resize((new_width, new_height), Image.Resampling.LANCZOS) + logger.debug(f"Downsampled {path}: {img.size} -> ({new_width}, {new_height})") return img diff --git a/pyPhotoAlbum/models.py b/pyPhotoAlbum/models.py index a41e430..e8c8944 100644 --- a/pyPhotoAlbum/models.py +++ b/pyPhotoAlbum/models.py @@ -6,6 +6,7 @@ from abc import ABC, abstractmethod from typing import Tuple, Optional, Dict, Any, List import json import os +from PIL import Image # Global configuration for asset path resolution _asset_search_paths: List[str] = [] @@ -57,15 +58,55 @@ class BaseLayoutElement(ABC): class ImageData(BaseLayoutElement): """Class to store image data and properties""" - def __init__(self, image_path: str = "", crop_info: Optional[Tuple] = None, **kwargs): + def __init__(self, image_path: str = "", crop_info: Optional[Tuple] = None, + image_dimensions: Optional[Tuple[int, int]] = None, **kwargs): super().__init__(**kwargs) self.image_path = image_path self.crop_info = crop_info or (0, 0, 1, 1) # Default: no crop + # Metadata: Store image dimensions for aspect ratio calculations before full load + # This allows correct rendering even while async loading is in progress + self.image_dimensions = image_dimensions # (width, height) or None + + # If dimensions not provided and we have a path, try to extract them quickly + if not self.image_dimensions and self.image_path: + self._extract_dimensions_metadata() + # Async loading state self._async_loading = False self._async_load_requested = False + def _extract_dimensions_metadata(self): + """ + Extract image dimensions without loading the full image. + Uses PIL's lazy loading to just read the header. + """ + try: + # Resolve path + image_path = self.image_path + if not os.path.isabs(self.image_path): + project_folder, search_paths = get_asset_search_paths() + if project_folder and os.path.exists(os.path.join(project_folder, self.image_path)): + image_path = os.path.join(project_folder, self.image_path) + + if os.path.exists(image_path): + # Use PIL to just read dimensions (fast, doesn't load pixel data) + with Image.open(image_path) as img: + width, height = img.width, img.height + + # Apply same downsampling logic as the old sync code (max 2048px) + max_size = 2048 + if width > max_size or height > max_size: + scale = min(max_size / width, max_size / height) + width = int(width * scale) + height = int(height * scale) + + self.image_dimensions = (width, height) + print(f"ImageData: Extracted dimensions {self.image_dimensions} for {self.image_path}") + except Exception as e: + print(f"ImageData: Could not extract dimensions for {self.image_path}: {e}") + self.image_dimensions = None + def render(self): """Render the image using OpenGL""" from OpenGL.GL import (glBegin, glEnd, glVertex2f, glColor3f, glColor4f, GL_QUADS, GL_LINE_LOOP, @@ -126,97 +167,71 @@ class ImageData(BaseLayoutElement): print(f"ImageData: Could not resolve path: {self.image_path}") print(f" Tried paths: {possible_paths[:3]}") # Print first 3 to avoid clutter - if image_full_path and os.path.exists(image_full_path): - # Check if we already have a cached texture - if not hasattr(self, '_texture_id') or not hasattr(self, '_texture_path') or self._texture_path != image_full_path: - try: - # Load image - img = Image.open(image_full_path) - img = img.convert('RGBA') - - # Scale down large images for performance (max 2048px on longest side) - max_size = 2048 - if img.width > max_size or img.height > max_size: - scale = min(max_size / img.width, max_size / img.height) - new_width = int(img.width * scale) - new_height = int(img.height * scale) - img = img.resize((new_width, new_height), Image.Resampling.LANCZOS) - - img_data = img.tobytes() - - # Delete old texture if it exists - if hasattr(self, '_texture_id') and self._texture_id: - glDeleteTextures([self._texture_id]) - - # Create texture - texture_id = glGenTextures(1) - glBindTexture(GL_TEXTURE_2D, texture_id) - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR) - glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR) - glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, img.width, img.height, 0, GL_RGBA, GL_UNSIGNED_BYTE, img_data) - - # Cache texture ID and path - self._texture_id = texture_id - self._texture_path = image_full_path - self._img_width = img.width - self._img_height = img.height - - except Exception as e: - print(f"Error loading image {image_full_path}: {e}") - print(f" Original path: {self.image_path}") - self._texture_id = None - - # Use cached texture - if hasattr(self, '_texture_id') and self._texture_id: - texture_id = self._texture_id - - # Get crop info - crop_x_min, crop_y_min, crop_x_max, crop_y_max = self.crop_info - - # Calculate aspect ratios for center crop - img_aspect = self._img_width / self._img_height - target_aspect = w / h - - # Calculate texture coordinates for center crop - if img_aspect > target_aspect: - # Image is wider - crop horizontally - scale = target_aspect / img_aspect - tx_offset = (1.0 - scale) / 2.0 - tx_min_base = tx_offset - tx_max_base = 1.0 - tx_offset - ty_min_base = 0.0 - ty_max_base = 1.0 - else: - # Image is taller - crop vertically - scale = img_aspect / target_aspect - ty_offset = (1.0 - scale) / 2.0 - tx_min_base = 0.0 - tx_max_base = 1.0 - ty_min_base = ty_offset - ty_max_base = 1.0 - ty_offset - - # Apply additional crop from crop_info (for spanning elements) - tx_range = tx_max_base - tx_min_base - ty_range = ty_max_base - ty_min_base - - tx_min = tx_min_base + crop_x_min * tx_range - tx_max = tx_min_base + crop_x_max * tx_range - ty_min = ty_min_base + crop_y_min * ty_range - ty_max = ty_min_base + crop_y_max * ty_range - - # Enable texturing and draw with crop - glEnable(GL_TEXTURE_2D) - glBindTexture(GL_TEXTURE_2D, texture_id) - glColor4f(1.0, 1.0, 1.0, 1.0) # White color to show texture as-is - - glBegin(GL_QUADS) - glTexCoord2f(tx_min, ty_min); glVertex2f(x, y) - glTexCoord2f(tx_max, ty_min); glVertex2f(x + w, y) - glTexCoord2f(tx_max, ty_max); glVertex2f(x + w, y + h) - glTexCoord2f(tx_min, ty_max); glVertex2f(x, y + h) - glEnd() - - glDisable(GL_TEXTURE_2D) + # NOTE: Async loading is now handled by page_layout.py calling request_image_load() + # This sync path should only be reached if async loading is not available + # The actual image will be loaded in the background and the texture created + # via _on_async_image_loaded() callback when ready + + # Use cached texture if available + if hasattr(self, '_texture_id') and self._texture_id: + texture_id = self._texture_id + + # Get image dimensions (from loaded texture or metadata) + if hasattr(self, '_img_width') and hasattr(self, '_img_height'): + img_width, img_height = self._img_width, self._img_height + elif self.image_dimensions: + img_width, img_height = self.image_dimensions + else: + # No dimensions available, render without aspect ratio correction + img_width, img_height = int(w), int(h) + + # Get crop info + crop_x_min, crop_y_min, crop_x_max, crop_y_max = self.crop_info + + # Calculate aspect ratios for center crop + img_aspect = img_width / img_height + target_aspect = w / h + + # Calculate texture coordinates for center crop + if img_aspect > target_aspect: + # Image is wider - crop horizontally + scale = target_aspect / img_aspect + tx_offset = (1.0 - scale) / 2.0 + tx_min_base = tx_offset + tx_max_base = 1.0 - tx_offset + ty_min_base = 0.0 + ty_max_base = 1.0 + else: + # Image is taller - crop vertically + scale = img_aspect / target_aspect + ty_offset = (1.0 - scale) / 2.0 + tx_min_base = 0.0 + tx_max_base = 1.0 + ty_min_base = ty_offset + ty_max_base = 1.0 - ty_offset + + # Apply additional crop from crop_info (for spanning elements) + tx_range = tx_max_base - tx_min_base + ty_range = ty_max_base - ty_min_base + + tx_min = tx_min_base + crop_x_min * tx_range + tx_max = tx_min_base + crop_x_max * tx_range + ty_min = ty_min_base + crop_y_min * ty_range + ty_max = ty_min_base + crop_y_max * ty_range + + # Enable texturing and draw with crop + glEnable(GL_TEXTURE_2D) + glBindTexture(GL_TEXTURE_2D, texture_id) + glColor4f(1.0, 1.0, 1.0, 1.0) # White color to show texture as-is + + glBegin(GL_QUADS) + glTexCoord2f(tx_min, ty_min); glVertex2f(x, y) + glTexCoord2f(tx_max, ty_min); glVertex2f(x + w, y) + glTexCoord2f(tx_max, ty_max); glVertex2f(x + w, y + h) + glTexCoord2f(tx_min, ty_max); glVertex2f(x, y + h) + glEnd() + + glDisable(GL_TEXTURE_2D) # If no image or loading failed, draw placeholder if not texture_id: @@ -243,7 +258,7 @@ class ImageData(BaseLayoutElement): def serialize(self) -> Dict[str, Any]: """Serialize image data to dictionary""" - return { + data = { "type": "image", "position": self.position, "size": self.size, @@ -252,6 +267,10 @@ class ImageData(BaseLayoutElement): "image_path": self.image_path, "crop_info": self.crop_info } + # Include image dimensions metadata if available + if self.image_dimensions: + data["image_dimensions"] = self.image_dimensions + return data def deserialize(self, data: Dict[str, Any]): """Deserialize from dictionary""" @@ -261,6 +280,10 @@ class ImageData(BaseLayoutElement): self.z_index = data.get("z_index", 0) self.image_path = data.get("image_path", "") self.crop_info = tuple(data.get("crop_info", (0, 0, 1, 1))) + # Load image dimensions metadata if available + self.image_dimensions = data.get("image_dimensions", None) + if self.image_dimensions: + self.image_dimensions = tuple(self.image_dimensions) def _on_async_image_loaded(self, pil_image): """ @@ -295,6 +318,10 @@ class ImageData(BaseLayoutElement): self._img_height = pil_image.height self._async_loading = False + # Update metadata for future renders + if not self.image_dimensions: + self.image_dimensions = (pil_image.width, pil_image.height) + print(f"ImageData: Async loaded texture for {self.image_path}") except Exception as e: