Added caching for thumbnail images

This commit is contained in:
Felipe Polo-Wood 2013-11-07 15:13:15 -05:00
parent 3c7102abcd
commit 6c3253c7c0
6 changed files with 89 additions and 38 deletions

View File

@ -36,6 +36,7 @@ import logging
import os import os
import time import time
import queue import queue
import re
from PyQt4 import QtCore from PyQt4 import QtCore
@ -105,7 +106,7 @@ class Image(object):
""" """
secondary_priority = 0 secondary_priority = 0
def __init__(self, path, source, background): def __init__(self, path, source, background, dimensions=''):
""" """
Create an image for the :class:`ImageManager`'s cache. Create an image for the :class:`ImageManager`'s cache.
@ -127,6 +128,15 @@ class Image(object):
self.source = source self.source = source
self.background = background self.background = background
self.timestamp = 0 self.timestamp = 0
match = re.search('(\d+)x(\d+)', dimensions)
if match:
# let's make sure that the dimensions are within reason
self.width = sorted([10, int(match.group(1)), 1000])[1]
self.height = sorted([10, int(match.group(2)), 1000])[1]
else:
# -1 means use the default dimension in ImageManager
self.width = -1
self.height = -1
# FIXME: We assume that the path exist. The caller has to take care that it exists! # FIXME: We assume that the path exist. The caller has to take care that it exists!
if os.path.exists(path): if os.path.exists(path):
self.timestamp = os.stat(path).st_mtime self.timestamp = os.stat(path).st_mtime
@ -217,13 +227,13 @@ class ImageManager(QtCore.QObject):
image.background = background image.background = background
self._reset_image(image) self._reset_image(image)
def update_image_border(self, path, source, background): def update_image_border(self, path, source, background, dimensions=''):
""" """
Border has changed so update the image affected. Border has changed so update the image affected.
""" """
log.debug('update_image_border') log.debug('update_image_border')
# Mark the image as dirty for a rebuild by setting the image and byte stream to None. # Mark the image as dirty for a rebuild by setting the image and byte stream to None.
image = self._cache[(path, source)] image = self._cache[(path, source, dimensions)]
if image.source == source: if image.source == source:
image.background = background image.background = background
self._reset_image(image) self._reset_image(image)
@ -244,12 +254,12 @@ class ImageManager(QtCore.QObject):
if not self.image_thread.isRunning(): if not self.image_thread.isRunning():
self.image_thread.start() self.image_thread.start()
def get_image(self, path, source): def get_image(self, path, source, dimensions=''):
""" """
Return the ``QImage`` from the cache. If not present wait for the background thread to process it. Return the ``QImage`` from the cache. If not present wait for the background thread to process it.
""" """
log.debug('getImage %s' % path) log.debug('getImage %s' % path)
image = self._cache[(path, source)] image = self._cache[(path, source, dimensions)]
if image.image is None: if image.image is None:
self._conversion_queue.modify_priority(image, Priority.High) self._conversion_queue.modify_priority(image, Priority.High)
# make sure we are running and if not give it a kick # make sure we are running and if not give it a kick
@ -264,12 +274,12 @@ class ImageManager(QtCore.QObject):
self._conversion_queue.modify_priority(image, Priority.Low) self._conversion_queue.modify_priority(image, Priority.Low)
return image.image return image.image
def get_image_bytes(self, path, source): def get_image_bytes(self, path, source, dimensions=''):
""" """
Returns the byte string for an image. If not present wait for the background thread to process it. Returns the byte string for an image. If not present wait for the background thread to process it.
""" """
log.debug('get_image_bytes %s' % path) log.debug('get_image_bytes %s' % path)
image = self._cache[(path, source)] image = self._cache[(path, source, dimensions)]
if image.image_bytes is None: if image.image_bytes is None:
self._conversion_queue.modify_priority(image, Priority.Urgent) self._conversion_queue.modify_priority(image, Priority.Urgent)
# make sure we are running and if not give it a kick # make sure we are running and if not give it a kick
@ -279,14 +289,14 @@ class ImageManager(QtCore.QObject):
time.sleep(0.1) time.sleep(0.1)
return image.image_bytes return image.image_bytes
def add_image(self, path, source, background): def add_image(self, path, source, background, dimensions=''):
""" """
Add image to cache if it is not already there. Add image to cache if it is not already there.
""" """
log.debug('add_image %s' % path) log.debug('add_image %s' % path)
if not (path, source) in self._cache: if not (path, source, dimensions) in self._cache:
image = Image(path, source, background) image = Image(path, source, background, dimensions)
self._cache[(path, source)] = image self._cache[(path, source, dimensions)] = image
self._conversion_queue.put((image.priority, image.secondary_priority, image)) self._conversion_queue.put((image.priority, image.secondary_priority, image))
# Check if the there are any images with the same path and check if the timestamp has changed. # Check if the there are any images with the same path and check if the timestamp has changed.
for image in list(self._cache.values()): for image in list(self._cache.values()):
@ -315,7 +325,10 @@ class ImageManager(QtCore.QObject):
image = self._conversion_queue.get()[2] image = self._conversion_queue.get()[2]
# Generate the QImage for the image. # Generate the QImage for the image.
if image.image is None: if image.image is None:
image.image = resize_image(image.path, self.width, self.height, image.background) # Let's see if the image was requested with specific dimensions
width = self.width if image.width == -1 else image.width
height = self.height if image.height == -1 else image.height
image.image = resize_image(image.path, width, height, image.background)
# Set the priority to Lowest and stop here as we need to process more important images first. # Set the priority to Lowest and stop here as we need to process more important images first.
if image.priority == Priority.Normal: if image.priority == Priority.Normal:
self._conversion_queue.modify_priority(image, Priority.Lowest) self._conversion_queue.modify_priority(image, Priority.Lowest)

View File

@ -452,8 +452,8 @@ class ServiceItem(object):
if path: if path:
self.has_original_files = False self.has_original_files = False
self.add_from_command(path, text_image['title'], self.add_from_command(path, text_image['title'],
text_image['image'], text_image['display_title'], text_image['image'], text_image.get('display_title',''),
text_image['notes']) text_image.get('notes', ''))
else: else:
self.add_from_command(text_image['path'], self.add_from_command(text_image['path'],
text_image['title'], text_image['image']) text_image['title'], text_image['image'])

View File

@ -402,31 +402,25 @@ class HttpRouter(object):
log.debug('serve thumbnail %s/thumbnails%s/%s' % (controller_name, log.debug('serve thumbnail %s/thumbnails%s/%s' % (controller_name,
dimensions, file_name)) dimensions, file_name))
supported_controllers = ['presentations'] supported_controllers = ['presentations']
if not dimensions:
dimensions = ''
content = '' content = ''
if controller_name and file_name: if controller_name and file_name:
if controller_name in supported_controllers: if controller_name in supported_controllers:
full_path = urllib.parse.unquote(file_name) full_path = urllib.parse.unquote(file_name)
if not '..' in full_path: # no hacking please if not '..' in full_path: # no hacking please
width = 80
height = 80
if dimensions:
match = re.search('(\d+)x(\d+)',
dimensions)
if match:
width = int(match.group(1))
height = int(match.group(2))
# let's make sure that the dimensions are within reason
width = min(width,1000)
width = max(width,10)
height = min(height,1000)
height = max(height,10)
full_path = os.path.normpath(os.path.join( full_path = os.path.normpath(os.path.join(
AppLocation.get_section_data_path(controller_name), AppLocation.get_section_data_path(controller_name),
'thumbnails/' + full_path)) 'thumbnails/' + full_path))
if os.path.exists(full_path): if os.path.exists(full_path):
path, just_file_name = os.path.split(full_path)
image_manager = Registry().get('image_manager')
image_manager.add_image(full_path, just_file_name, None,
dimensions)
ext = self.send_appropriate_header(full_path) ext = self.send_appropriate_header(full_path)
content = image_to_byte(resize_image(full_path, width, content = image_to_byte(
height),False) image_manager.get_image(full_path,
just_file_name, dimensions), False)
if len(content)==0: if len(content)==0:
content = self.do_not_found() content = self.do_not_found()
return content return content

View File

@ -61,16 +61,17 @@ class TestImageManager(TestCase):
Test the Image Manager setup basic functionality Test the Image Manager setup basic functionality
""" """
# GIVEN: the an image add to the image manager # GIVEN: the an image add to the image manager
self.image_manager.add_image(TEST_PATH, 'church.jpg', None) full_path = os.path.normpath(os.path.join(TEST_PATH, 'church.jpg'))
self.image_manager.add_image(full_path, 'church.jpg', None)
# WHEN the image is retrieved # WHEN the image is retrieved
image = self.image_manager.get_image(TEST_PATH, 'church.jpg') image = self.image_manager.get_image(full_path, 'church.jpg')
# THEN returned record is a type of image # THEN returned record is a type of image
self.assertEqual(isinstance(image, QtGui.QImage), True, 'The returned object should be a QImage') self.assertEqual(isinstance(image, QtGui.QImage), True, 'The returned object should be a QImage')
# WHEN: The image bytes are requested. # WHEN: The image bytes are requested.
byte_array = self.image_manager.get_image_bytes(TEST_PATH, 'church.jpg') byte_array = self.image_manager.get_image_bytes(full_path, 'church.jpg')
# THEN: Type should be a str. # THEN: Type should be a str.
self.assertEqual(isinstance(byte_array, str), True, 'The returned object should be a str') self.assertEqual(isinstance(byte_array, str), True, 'The returned object should be a str')
@ -80,3 +81,40 @@ class TestImageManager(TestCase):
with self.assertRaises(KeyError) as context: with self.assertRaises(KeyError) as context:
self.image_manager.get_image(TEST_PATH, 'church1.jpg') self.image_manager.get_image(TEST_PATH, 'church1.jpg')
self.assertNotEquals(context.exception, '', 'KeyError exception should have been thrown for missing image') self.assertNotEquals(context.exception, '', 'KeyError exception should have been thrown for missing image')
def different_dimension_image_test(self):
"""
Test the Image Manager with dimensions
"""
# GIVEN: add an image with specific dimensions
full_path = os.path.normpath(os.path.join(TEST_PATH, 'church.jpg'))
self.image_manager.add_image(full_path, 'church.jpg', None, '80x80')
# WHEN: the image is retrieved
image = self.image_manager.get_image(full_path, 'church.jpg', '80x80')
# THEN: The return should be of type image
self.assertEqual(isinstance(image, QtGui.QImage), True,
'The returned object should be a QImage')
#print(len(self.image_manager._cache))
# WHEN: adding the same image with different dimensions
self.image_manager.add_image(full_path, 'church.jpg', None, '100x100')
# THEN: the cache should contain two pictures
self.assertEqual(len(self.image_manager._cache), 2,
'Image manager should consider two dimensions of the same picture as different')
# WHEN: adding the same image with first dimensions
self.image_manager.add_image(full_path, 'church.jpg', None, '80x80')
# THEN: the cache should still contain only two pictures
self.assertEqual(len(self.image_manager._cache), 2,
'Same dimensions should not be added again')
# WHEN: calling with correct image, but wrong dimensions
with self.assertRaises(KeyError) as context:
self.image_manager.get_image(full_path, 'church.jpg', '120x120')
self.assertNotEquals(context.exception, '',
'KeyError exception should have been thrown for missing dimension')

View File

@ -62,7 +62,7 @@ class TestRemoteTab(TestCase):
""" """
Create the UI Create the UI
""" """
fd, self.ini_file = mkstemp('.ini') self.fd, self.ini_file = mkstemp('.ini')
Settings().set_filename(self.ini_file) Settings().set_filename(self.ini_file)
self.application = QtGui.QApplication.instance() self.application = QtGui.QApplication.instance()
Settings().extend_default_settings(__default_settings__) Settings().extend_default_settings(__default_settings__)
@ -76,6 +76,7 @@ class TestRemoteTab(TestCase):
del self.application del self.application
del self.parent del self.parent
del self.form del self.form
os.close(self.fd)
os.unlink(self.ini_file) os.unlink(self.ini_file)
def get_ip_address_default_test(self): def get_ip_address_default_test(self):

View File

@ -36,6 +36,7 @@ from tempfile import mkstemp
from PyQt4 import QtGui from PyQt4 import QtGui
from openlp.core.lib import Registry
from openlp.core.common import Settings from openlp.core.common import Settings
from openlp.plugins.remotes.lib.httpserver import HttpRouter from openlp.plugins.remotes.lib.httpserver import HttpRouter
from mock import MagicMock, patch, mock_open from mock import MagicMock, patch, mock_open
@ -183,6 +184,9 @@ class TestRouter(TestCase):
self.router.send_header = MagicMock() self.router.send_header = MagicMock()
self.router.end_headers = MagicMock() self.router.end_headers = MagicMock()
self.router.wfile = MagicMock() self.router.wfile = MagicMock()
mocked_image_manager = MagicMock()
Registry.create()
Registry().register('image_manager',mocked_image_manager)
file_name = 'another%20test/slide1.png' file_name = 'another%20test/slide1.png'
full_path = os.path.normpath(os.path.join('thumbnails',file_name)) full_path = os.path.normpath(os.path.join('thumbnails',file_name))
width = 120 width = 120
@ -191,8 +195,6 @@ class TestRouter(TestCase):
patch('builtins.open', mock_open(read_data='123')), \ patch('builtins.open', mock_open(read_data='123')), \
patch('openlp.plugins.remotes.lib.httprouter.AppLocation') \ patch('openlp.plugins.remotes.lib.httprouter.AppLocation') \
as mocked_location, \ as mocked_location, \
patch('openlp.plugins.remotes.lib.httprouter.resize_image') \
as mocked_resize, \
patch('openlp.plugins.remotes.lib.httprouter.image_to_byte')\ patch('openlp.plugins.remotes.lib.httprouter.image_to_byte')\
as mocked_image_to_byte: as mocked_image_to_byte:
mocked_exists.return_value = True mocked_exists.return_value = True
@ -205,8 +207,11 @@ class TestRouter(TestCase):
# THEN: a file should be returned # THEN: a file should be returned
self.assertEqual(self.router.send_header.call_count, 1, self.assertEqual(self.router.send_header.call_count, 1,
'One header') 'One header')
self.assertEqual(result, '123', 'The content should match \'123\'')
mocked_exists.assert_called_with(urllib.parse.unquote(full_path)) mocked_exists.assert_called_with(urllib.parse.unquote(full_path))
self.assertEqual(mocked_image_to_byte.call_count, 1, 'Called once') self.assertEqual(mocked_image_to_byte.call_count, 1, 'Called once')
mocked_resize.assert_called_once_with( mocked_image_manager.assert_called_any(
urllib.parse.unquote(full_path), width, height) os.path.normpath('thumbnails\\another test'), 'slide1.png',
None, '120x90')
mocked_image_manager.assert_called_any(
os.path.normpath('thumbnails\\another test'),'slide1.png',
'120x90')