centralised image loading logic
All checks were successful
Python CI / test (push) Successful in 1m17s
Lint / lint (push) Successful in 1m32s
Tests / test (3.10) (push) Successful in 1m9s
Tests / test (3.11) (push) Successful in 1m8s
Tests / test (3.9) (push) Successful in 1m7s

This commit is contained in:
Duncan Tourolle 2025-11-27 21:33:53 +01:00
parent 45268cdfe4
commit d7786ede80
5 changed files with 91 additions and 78 deletions

View File

@ -32,6 +32,47 @@ class LoadPriority(Enum):
URGENT = 3 # User is actively interacting with URGENT = 3 # User is actively interacting with
def get_image_dimensions(
image_path: str,
max_size: Optional[int] = None
) -> Optional[Tuple[int, int]]:
"""
Extract image dimensions without loading the full image.
Uses PIL's lazy loading to read only the header, making this a fast
operation suitable for UI code that needs dimensions before async loading.
Args:
image_path: Path to the image file (absolute or relative)
max_size: Optional maximum dimension - if provided, dimensions are
scaled down proportionally to fit within this limit
Returns:
Tuple of (width, height) or None if the image cannot be read
Example:
# Get raw dimensions
dims = get_image_dimensions("/path/to/image.jpg")
# Get dimensions scaled to fit within 300px
dims = get_image_dimensions("/path/to/image.jpg", max_size=300)
"""
try:
with Image.open(image_path) as img:
width, height = img.size
if max_size and (width > max_size or height > max_size):
scale = min(max_size / width, max_size / height)
width = int(width * scale)
height = int(height * scale)
return (width, height)
except Exception as e:
logger.warning(f"Could not extract dimensions for {image_path}: {e}")
return None
@dataclass(order=True) @dataclass(order=True)
class LoadRequest: class LoadRequest:
"""Request to load and process an image.""" """Request to load and process an image."""

View File

@ -128,21 +128,15 @@ class AssetDropMixin:
print(f"Error importing dropped image: {e}") print(f"Error importing dropped image: {e}")
def _calculate_image_dimensions(self, image_path): def _calculate_image_dimensions(self, image_path):
"""Calculate scaled image dimensions for new image""" """Calculate scaled image dimensions for new image using centralized utility."""
try: from pyPhotoAlbum.async_backend import get_image_dimensions
from PIL import Image
img = Image.open(image_path)
img_width, img_height = img.size
max_size = 300 # Use centralized utility (max 300px for UI display)
if img_width > max_size or img_height > max_size: dimensions = get_image_dimensions(image_path, max_size=300)
scale = min(max_size / img_width, max_size / img_height) if dimensions:
img_width = int(img_width * scale) return dimensions
img_height = int(img_height * scale)
return img_width, img_height # Fallback dimensions if image cannot be read
except Exception as e:
print(f"Error loading image dimensions: {e}")
return 200, 150 return 200, 150
def _add_new_image_to_page(self, asset_path, target_page, page_index, def _add_new_image_to_page(self, asset_path, target_page, page_index,

View File

@ -3,10 +3,10 @@ Element operations mixin for pyPhotoAlbum
""" """
from PyQt6.QtWidgets import QFileDialog from PyQt6.QtWidgets import QFileDialog
from PIL import Image
from pyPhotoAlbum.decorators import ribbon_action from pyPhotoAlbum.decorators import ribbon_action
from pyPhotoAlbum.models import ImageData, TextBoxData, PlaceholderData from pyPhotoAlbum.models import ImageData, TextBoxData, PlaceholderData
from pyPhotoAlbum.commands import AddElementCommand from pyPhotoAlbum.commands import AddElementCommand
from pyPhotoAlbum.async_backend import get_image_dimensions
class ElementOperationsMixin: class ElementOperationsMixin:
@ -43,17 +43,14 @@ class ElementOperationsMixin:
# Import asset to project # Import asset to project
asset_path = self.project.asset_manager.import_asset(file_path) asset_path = self.project.asset_manager.import_asset(file_path)
# Load image from imported asset (not from original source) # Get dimensions using centralized utility (max 300px for UI display)
full_asset_path = os.path.join(self.project.folder_path, asset_path) full_asset_path = os.path.join(self.project.folder_path, asset_path)
img = Image.open(full_asset_path) dimensions = get_image_dimensions(full_asset_path, max_size=300)
img_width, img_height = img.size if dimensions:
img_width, img_height = dimensions
# Scale to reasonable size (max 300px) else:
max_size = 300 # Fallback dimensions if image cannot be read
if img_width > max_size or img_height > max_size: img_width, img_height = 200, 150
scale = min(max_size / img_width, max_size / img_height)
img_width = int(img_width * scale)
img_height = int(img_height * scale)
# Create image element at center of page # Create image element at center of page
page_width_mm = current_page.layout.size[0] page_width_mm = current_page.layout.size[0]

View File

@ -158,27 +158,16 @@ class ImageData(BaseLayoutElement):
def _extract_dimensions_metadata(self): def _extract_dimensions_metadata(self):
""" """
Extract image dimensions without loading the full image. Extract image dimensions without loading the full image.
Uses PIL's lazy loading to just read the header. Uses the centralized get_image_dimensions() utility.
""" """
try: from pyPhotoAlbum.async_backend import get_image_dimensions
image_path = self.resolve_image_path() image_path = self.resolve_image_path()
if image_path: if image_path:
# Use PIL to just read dimensions (fast, doesn't load pixel data) # Use centralized utility (max 2048px for texture loading)
with Image.open(image_path) as img: self.image_dimensions = get_image_dimensions(image_path, max_size=2048)
width, height = img.width, img.height if self.image_dimensions:
# 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}") 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"""

View File

@ -10,8 +10,6 @@ from pyPhotoAlbum.models import ImageData, TextBoxData, PlaceholderData
from pyPhotoAlbum.project import Project, Page from pyPhotoAlbum.project import Project, Page
from pyPhotoAlbum.page_layout import PageLayout from pyPhotoAlbum.page_layout import PageLayout
from pyPhotoAlbum.commands import CommandHistory from pyPhotoAlbum.commands import CommandHistory
from PIL import Image
import io
# Create test window with ElementOperationsMixin # Create test window with ElementOperationsMixin
@ -69,8 +67,8 @@ class TestAddImage:
"""Test add_image method""" """Test add_image method"""
@patch('pyPhotoAlbum.mixins.operations.element_ops.QFileDialog.getOpenFileName') @patch('pyPhotoAlbum.mixins.operations.element_ops.QFileDialog.getOpenFileName')
@patch('pyPhotoAlbum.mixins.operations.element_ops.Image.open') @patch('pyPhotoAlbum.mixins.operations.element_ops.get_image_dimensions')
def test_add_image_success(self, mock_image_open, mock_file_dialog, qtbot): def test_add_image_success(self, mock_get_dims, mock_file_dialog, qtbot):
"""Test successfully adding an image""" """Test successfully adding an image"""
window = TestElementWindow() window = TestElementWindow()
qtbot.addWidget(window) qtbot.addWidget(window)
@ -85,10 +83,8 @@ class TestAddImage:
# Mock file dialog # Mock file dialog
mock_file_dialog.return_value = ("/path/to/image.jpg", "Image Files (*.jpg)") mock_file_dialog.return_value = ("/path/to/image.jpg", "Image Files (*.jpg)")
# Mock PIL Image # Mock get_image_dimensions (returns scaled dimensions)
mock_img = Mock() mock_get_dims.return_value = (300, 225) # 800x600 scaled to max 300
mock_img.size = (800, 600)
mock_image_open.return_value = mock_img
# Mock asset manager # Mock asset manager
window.project.asset_manager.import_asset.return_value = "assets/image.jpg" window.project.asset_manager.import_asset.return_value = "assets/image.jpg"
@ -139,8 +135,8 @@ class TestAddImage:
assert not window._update_view_called assert not window._update_view_called
@patch('pyPhotoAlbum.mixins.operations.element_ops.QFileDialog.getOpenFileName') @patch('pyPhotoAlbum.mixins.operations.element_ops.QFileDialog.getOpenFileName')
@patch('pyPhotoAlbum.mixins.operations.element_ops.Image.open') @patch('pyPhotoAlbum.mixins.operations.element_ops.get_image_dimensions')
def test_add_image_scales_large_image(self, mock_image_open, mock_file_dialog, qtbot): def test_add_image_scales_large_image(self, mock_get_dims, mock_file_dialog, qtbot):
"""Test that large images are scaled down""" """Test that large images are scaled down"""
window = TestElementWindow() window = TestElementWindow()
qtbot.addWidget(window) qtbot.addWidget(window)
@ -153,22 +149,20 @@ class TestAddImage:
mock_file_dialog.return_value = ("/path/to/large.jpg", "Image Files (*.jpg)") mock_file_dialog.return_value = ("/path/to/large.jpg", "Image Files (*.jpg)")
# Mock very large image # Mock get_image_dimensions returning scaled dimensions (3000x2000 -> 300x200)
mock_img = Mock() mock_get_dims.return_value = (300, 200)
mock_img.size = (3000, 2000) # Much larger than max_size=300
mock_image_open.return_value = mock_img
window.project.asset_manager.import_asset.return_value = "assets/large.jpg" window.project.asset_manager.import_asset.return_value = "assets/large.jpg"
window.add_image() window.add_image()
# Image should be added (scaled down internally) # Image should be added (scaled down by get_image_dimensions)
assert window._update_view_called assert window._update_view_called
@patch('pyPhotoAlbum.mixins.operations.element_ops.QFileDialog.getOpenFileName') @patch('pyPhotoAlbum.mixins.operations.element_ops.QFileDialog.getOpenFileName')
@patch('pyPhotoAlbum.mixins.operations.element_ops.Image.open') @patch('pyPhotoAlbum.mixins.operations.element_ops.get_image_dimensions')
def test_add_image_error_handling(self, mock_image_open, mock_file_dialog, qtbot): def test_add_image_fallback_dimensions(self, mock_get_dims, mock_file_dialog, qtbot):
"""Test error handling when adding image fails""" """Test fallback dimensions when get_image_dimensions returns None"""
window = TestElementWindow() window = TestElementWindow()
qtbot.addWidget(window) qtbot.addWidget(window)
@ -180,14 +174,16 @@ class TestAddImage:
mock_file_dialog.return_value = ("/path/to/broken.jpg", "Image Files (*.jpg)") mock_file_dialog.return_value = ("/path/to/broken.jpg", "Image Files (*.jpg)")
# Mock error # Mock get_image_dimensions returning None (image unreadable)
mock_image_open.side_effect = Exception("Cannot open image") mock_get_dims.return_value = None
window.project.asset_manager.import_asset.return_value = "assets/broken.jpg"
window.add_image() window.add_image()
# Should show error # Should still add image with fallback dimensions (200x150)
assert window._error_message is not None assert window._update_view_called
assert "failed to add image" in window._error_message.lower() assert window.project.history.can_undo()
class TestAddText: class TestAddText:
@ -294,8 +290,8 @@ class TestElementOperationsIntegration:
"""Test integration between element operations""" """Test integration between element operations"""
@patch('pyPhotoAlbum.mixins.operations.element_ops.QFileDialog.getOpenFileName') @patch('pyPhotoAlbum.mixins.operations.element_ops.QFileDialog.getOpenFileName')
@patch('pyPhotoAlbum.mixins.operations.element_ops.Image.open') @patch('pyPhotoAlbum.mixins.operations.element_ops.get_image_dimensions')
def test_add_multiple_elements(self, mock_image_open, mock_file_dialog, qtbot): def test_add_multiple_elements(self, mock_get_dims, mock_file_dialog, qtbot):
"""Test adding multiple different element types""" """Test adding multiple different element types"""
window = TestElementWindow() window = TestElementWindow()
qtbot.addWidget(window) qtbot.addWidget(window)
@ -317,9 +313,7 @@ class TestElementOperationsIntegration:
# Add image # Add image
mock_file_dialog.return_value = ("/test.jpg", "Image Files") mock_file_dialog.return_value = ("/test.jpg", "Image Files")
mock_img = Mock() mock_get_dims.return_value = (100, 100)
mock_img.size = (100, 100)
mock_image_open.return_value = mock_img
window.project.asset_manager.import_asset.return_value = "assets/test.jpg" window.project.asset_manager.import_asset.return_value = "assets/test.jpg"
window.add_image() window.add_image()
@ -328,8 +322,8 @@ class TestElementOperationsIntegration:
assert window._update_view_called assert window._update_view_called
@patch('pyPhotoAlbum.mixins.operations.element_ops.QFileDialog.getOpenFileName') @patch('pyPhotoAlbum.mixins.operations.element_ops.QFileDialog.getOpenFileName')
@patch('pyPhotoAlbum.mixins.operations.element_ops.Image.open') @patch('pyPhotoAlbum.mixins.operations.element_ops.get_image_dimensions')
def test_add_image_with_undo(self, mock_image_open, mock_file_dialog, qtbot): def test_add_image_with_undo(self, mock_get_dims, mock_file_dialog, qtbot):
"""Test that adding image can be undone""" """Test that adding image can be undone"""
window = TestElementWindow() window = TestElementWindow()
qtbot.addWidget(window) qtbot.addWidget(window)
@ -341,9 +335,7 @@ class TestElementOperationsIntegration:
window._current_page = page window._current_page = page
mock_file_dialog.return_value = ("/test.jpg", "Image Files") mock_file_dialog.return_value = ("/test.jpg", "Image Files")
mock_img = Mock() mock_get_dims.return_value = (200, 150)
mock_img.size = (200, 150)
mock_image_open.return_value = mock_img
window.project.asset_manager.import_asset.return_value = "assets/test.jpg" window.project.asset_manager.import_asset.return_value = "assets/test.jpg"
# Should have no commands initially # Should have no commands initially