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':
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

View File

@ -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: