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,97 +167,71 @@ 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) # Use cached texture if available
img = img.convert('RGBA') if hasattr(self, '_texture_id') and self._texture_id:
texture_id = self._texture_id
# Scale down large images for performance (max 2048px on longest side)
max_size = 2048 # Get image dimensions (from loaded texture or metadata)
if img.width > max_size or img.height > max_size: if hasattr(self, '_img_width') and hasattr(self, '_img_height'):
scale = min(max_size / img.width, max_size / img.height) img_width, img_height = self._img_width, self._img_height
new_width = int(img.width * scale) elif self.image_dimensions:
new_height = int(img.height * scale) img_width, img_height = self.image_dimensions
img = img.resize((new_width, new_height), Image.Resampling.LANCZOS) else:
# No dimensions available, render without aspect ratio correction
img_data = img.tobytes() img_width, img_height = int(w), int(h)
# Delete old texture if it exists # Get crop info
if hasattr(self, '_texture_id') and self._texture_id: crop_x_min, crop_y_min, crop_x_max, crop_y_max = self.crop_info
glDeleteTextures([self._texture_id])
# Calculate aspect ratios for center crop
# Create texture img_aspect = img_width / img_height
texture_id = glGenTextures(1) target_aspect = w / h
glBindTexture(GL_TEXTURE_2D, texture_id)
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR) # Calculate texture coordinates for center crop
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR) if img_aspect > target_aspect:
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, img.width, img.height, 0, GL_RGBA, GL_UNSIGNED_BYTE, img_data) # Image is wider - crop horizontally
scale = target_aspect / img_aspect
# Cache texture ID and path tx_offset = (1.0 - scale) / 2.0
self._texture_id = texture_id tx_min_base = tx_offset
self._texture_path = image_full_path tx_max_base = 1.0 - tx_offset
self._img_width = img.width ty_min_base = 0.0
self._img_height = img.height ty_max_base = 1.0
else:
except Exception as e: # Image is taller - crop vertically
print(f"Error loading image {image_full_path}: {e}") scale = img_aspect / target_aspect
print(f" Original path: {self.image_path}") ty_offset = (1.0 - scale) / 2.0
self._texture_id = None tx_min_base = 0.0
tx_max_base = 1.0
# Use cached texture ty_min_base = ty_offset
if hasattr(self, '_texture_id') and self._texture_id: ty_max_base = 1.0 - ty_offset
texture_id = self._texture_id
# Apply additional crop from crop_info (for spanning elements)
# Get crop info tx_range = tx_max_base - tx_min_base
crop_x_min, crop_y_min, crop_x_max, crop_y_max = self.crop_info ty_range = ty_max_base - ty_min_base
# Calculate aspect ratios for center crop tx_min = tx_min_base + crop_x_min * tx_range
img_aspect = self._img_width / self._img_height tx_max = tx_min_base + crop_x_max * tx_range
target_aspect = w / h ty_min = ty_min_base + crop_y_min * ty_range
ty_max = ty_min_base + crop_y_max * ty_range
# Calculate texture coordinates for center crop
if img_aspect > target_aspect: # Enable texturing and draw with crop
# Image is wider - crop horizontally glEnable(GL_TEXTURE_2D)
scale = target_aspect / img_aspect glBindTexture(GL_TEXTURE_2D, texture_id)
tx_offset = (1.0 - scale) / 2.0 glColor4f(1.0, 1.0, 1.0, 1.0) # White color to show texture as-is
tx_min_base = tx_offset
tx_max_base = 1.0 - tx_offset glBegin(GL_QUADS)
ty_min_base = 0.0 glTexCoord2f(tx_min, ty_min); glVertex2f(x, y)
ty_max_base = 1.0 glTexCoord2f(tx_max, ty_min); glVertex2f(x + w, y)
else: glTexCoord2f(tx_max, ty_max); glVertex2f(x + w, y + h)
# Image is taller - crop vertically glTexCoord2f(tx_min, ty_max); glVertex2f(x, y + h)
scale = img_aspect / target_aspect glEnd()
ty_offset = (1.0 - scale) / 2.0
tx_min_base = 0.0 glDisable(GL_TEXTURE_2D)
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 no image or loading failed, draw placeholder
if not texture_id: if not texture_id:
@ -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: