fixed bug with images being displayed squashed when dimensions exceeded render size

This commit is contained in:
Duncan Tourolle 2025-11-11 17:13:12 +01:00
parent 7d8d2d42f8
commit 1b0e6f9ed1
2 changed files with 129 additions and 98 deletions

View File

@ -354,12 +354,16 @@ class AsyncImageLoader(QObject):
if img.mode != 'RGBA': if img.mode != 'RGBA':
img = img.convert('RGBA') img = img.convert('RGBA')
# Downsample if target size specified # Downsample if target size specified (preserving aspect ratio)
if target_size: if target_size:
current_size = img.size max_size = target_size[0] # Assume square target (2048, 2048)
if current_size[0] > target_size[0] or current_size[1] > target_size[1]: if img.width > max_size or img.height > max_size:
img = img.resize(target_size, Image.Resampling.LANCZOS) # Calculate scale to fit within max_size while preserving aspect ratio
logger.debug(f"Downsampled {path}: {current_size} -> {target_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)
logger.debug(f"Downsampled {path}: {img.size} -> ({new_width}, {new_height})")
return img return img

View File

@ -6,6 +6,7 @@ from abc import ABC, abstractmethod
from typing import Tuple, Optional, Dict, Any, List from typing import Tuple, Optional, Dict, Any, List
import json import json
import os import os
from PIL import Image
# Global configuration for asset path resolution # Global configuration for asset path resolution
_asset_search_paths: List[str] = [] _asset_search_paths: List[str] = []
@ -57,15 +58,55 @@ class BaseLayoutElement(ABC):
class ImageData(BaseLayoutElement): class ImageData(BaseLayoutElement):
"""Class to store image data and properties""" """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) super().__init__(**kwargs)
self.image_path = image_path self.image_path = image_path
self.crop_info = crop_info or (0, 0, 1, 1) # Default: no crop 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 # Async loading state
self._async_loading = False self._async_loading = False
self._async_load_requested = 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): def render(self):
"""Render the image using OpenGL""" """Render the image using OpenGL"""
from OpenGL.GL import (glBegin, glEnd, glVertex2f, glColor3f, glColor4f, GL_QUADS, GL_LINE_LOOP, from OpenGL.GL import (glBegin, glEnd, glVertex2f, glColor3f, glColor4f, GL_QUADS, GL_LINE_LOOP,
@ -126,55 +167,29 @@ class ImageData(BaseLayoutElement):
print(f"ImageData: Could not resolve path: {self.image_path}") print(f"ImageData: Could not resolve path: {self.image_path}")
print(f" Tried paths: {possible_paths[:3]}") # Print first 3 to avoid clutter print(f" Tried paths: {possible_paths[:3]}") # Print first 3 to avoid clutter
if image_full_path and os.path.exists(image_full_path): # NOTE: Async loading is now handled by page_layout.py calling request_image_load()
# Check if we already have a cached texture # This sync path should only be reached if async loading is not available
if not hasattr(self, '_texture_id') or not hasattr(self, '_texture_path') or self._texture_path != image_full_path: # The actual image will be loaded in the background and the texture created
try: # via _on_async_image_loaded() callback when ready
# Load image
img = Image.open(image_full_path)
img = img.convert('RGBA')
# Scale down large images for performance (max 2048px on longest side) # Use cached texture if available
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: if hasattr(self, '_texture_id') and self._texture_id:
texture_id = 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 # Get crop info
crop_x_min, crop_y_min, crop_x_max, crop_y_max = self.crop_info crop_x_min, crop_y_min, crop_x_max, crop_y_max = self.crop_info
# Calculate aspect ratios for center crop # Calculate aspect ratios for center crop
img_aspect = self._img_width / self._img_height img_aspect = img_width / img_height
target_aspect = w / h target_aspect = w / h
# Calculate texture coordinates for center crop # Calculate texture coordinates for center crop
@ -243,7 +258,7 @@ class ImageData(BaseLayoutElement):
def serialize(self) -> Dict[str, Any]: def serialize(self) -> Dict[str, Any]:
"""Serialize image data to dictionary""" """Serialize image data to dictionary"""
return { data = {
"type": "image", "type": "image",
"position": self.position, "position": self.position,
"size": self.size, "size": self.size,
@ -252,6 +267,10 @@ class ImageData(BaseLayoutElement):
"image_path": self.image_path, "image_path": self.image_path,
"crop_info": self.crop_info "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]): def deserialize(self, data: Dict[str, Any]):
"""Deserialize from dictionary""" """Deserialize from dictionary"""
@ -261,6 +280,10 @@ class ImageData(BaseLayoutElement):
self.z_index = data.get("z_index", 0) self.z_index = data.get("z_index", 0)
self.image_path = data.get("image_path", "") self.image_path = data.get("image_path", "")
self.crop_info = tuple(data.get("crop_info", (0, 0, 1, 1))) 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): def _on_async_image_loaded(self, pil_image):
""" """
@ -295,6 +318,10 @@ class ImageData(BaseLayoutElement):
self._img_height = pil_image.height self._img_height = pil_image.height
self._async_loading = False 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}") print(f"ImageData: Async loaded texture for {self.image_path}")
except Exception as e: except Exception as e: