mirror of https://github.com/seebye/ueberzug
You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
495 lines
16 KiB
Python
495 lines
16 KiB
Python
import abc
|
|
import queue
|
|
import weakref
|
|
import os
|
|
import threading
|
|
import concurrent.futures
|
|
import enum
|
|
|
|
import ueberzug.thread as thread
|
|
import ueberzug.pattern as pattern
|
|
|
|
|
|
INDEX_ALPHA_CHANNEL = 3
|
|
|
|
|
|
def load_image(path, upper_bound_size):
|
|
"""Loads the image and converts it
|
|
if it doesn't use the RGB or RGBX mode.
|
|
|
|
Args:
|
|
path (str): the path of the image file
|
|
upper_bound_size (tuple of (width: int, height: int)):
|
|
the maximal size to load data for
|
|
|
|
Returns:
|
|
tuple of (PIL.Image, bool): rgb image, downscaled
|
|
|
|
Raises:
|
|
OSError: for unsupported formats
|
|
"""
|
|
import PIL.Image
|
|
image = PIL.Image.open(path)
|
|
original_size = image.width, image.height
|
|
downscaled = False
|
|
mask = None
|
|
|
|
if upper_bound_size:
|
|
upper_bound_size = tuple(
|
|
min(size for size in size_pair if size > 0)
|
|
for size_pair in zip(upper_bound_size, original_size))
|
|
image.draft(None, upper_bound_size)
|
|
downscaled = (image.width, image.height) < original_size
|
|
|
|
image.load()
|
|
|
|
if (image.format == 'PNG'
|
|
and image.mode in ('L', 'P')
|
|
and 'transparency' in image.info):
|
|
# Prevent pillow to print the warning
|
|
# 'Palette images with Transparency expressed in bytes should be
|
|
# converted to RGBA images'
|
|
image = image.convert('RGBA')
|
|
|
|
if image.mode == 'RGBA':
|
|
mask = image.split()[INDEX_ALPHA_CHANNEL]
|
|
|
|
if image.mode not in ('RGB', 'RGBX'):
|
|
image_rgb = PIL.Image.new(
|
|
"RGB", image.size, color=(255, 255, 255))
|
|
image_rgb.paste(image, mask=mask)
|
|
image = image_rgb
|
|
|
|
return image, downscaled
|
|
|
|
|
|
class ImageHolder:
|
|
"""Holds the reference of an image.
|
|
It serves as bridge between image loader and image user.
|
|
"""
|
|
def __init__(self, path, image=None):
|
|
self.path = path
|
|
self.image = image
|
|
self.waiter = threading.Condition()
|
|
|
|
def reveal_image(self, image):
|
|
"""Assigns an image to this holder and
|
|
notifies waiting image users about it.
|
|
|
|
Args:
|
|
image (PIL.Image): the loaded image
|
|
"""
|
|
with self.waiter:
|
|
self.image = image
|
|
self.waiter.notify_all()
|
|
|
|
def await_image(self):
|
|
"""Waits till an image loader assigns the image
|
|
if it's not already happened.
|
|
|
|
Returns:
|
|
PIL.Image: the image assigned to this holder
|
|
"""
|
|
if self.image is None:
|
|
with self.waiter:
|
|
if self.image is None:
|
|
self.waiter.wait()
|
|
return self.image
|
|
|
|
|
|
class PostLoadImageProcessor(metaclass=abc.ABCMeta):
|
|
"""Describes the structure used to define callbacks which
|
|
will be invoked after loading an image.
|
|
"""
|
|
@abc.abstractmethod
|
|
def on_loaded(self, image):
|
|
"""Postprocessor of an loaded image.
|
|
The returned image will be assigned to the image holder.
|
|
|
|
Args:
|
|
image (PIL.Image): the loaded image
|
|
|
|
Returns:
|
|
PIL.Image:
|
|
the image which will be assigned
|
|
to the image holder of this loading process
|
|
"""
|
|
raise NotImplementedError()
|
|
|
|
|
|
class CoverPostLoadImageProcessor(PostLoadImageProcessor):
|
|
"""Implementation of PostLoadImageProcessor
|
|
which resizes an image (if possible -> needs to be bigger)
|
|
such that it covers only just a given resolution.
|
|
"""
|
|
def __init__(self, width, height):
|
|
self.width = width
|
|
self.height = height
|
|
|
|
def on_loaded(self, image):
|
|
import PIL.Image
|
|
resize_ratio = max(min(1, self.width / image.width),
|
|
min(1, self.height / image.height))
|
|
|
|
if resize_ratio != 1:
|
|
image = image.resize(
|
|
(int(resize_ratio * image.width),
|
|
int(resize_ratio * image.height)),
|
|
PIL.Image.ANTIALIAS)
|
|
|
|
return image
|
|
|
|
|
|
class ImageLoader(metaclass=abc.ABCMeta):
|
|
"""Describes the structure used to define image loading strategies.
|
|
|
|
Defines a general interface used to implement different ways
|
|
of loading images.
|
|
E.g. loading images asynchron
|
|
"""
|
|
@pattern.LazyConstant
|
|
def PLACEHOLDER():
|
|
"""PIL.Image: fallback image for occuring errors"""
|
|
# pylint: disable=no-method-argument,invalid-name
|
|
import PIL.Image
|
|
return PIL.Image.new('RGB', (1, 1))
|
|
|
|
@staticmethod
|
|
@abc.abstractmethod
|
|
def get_loader_name():
|
|
"""Returns the constant name which is associated to this loader."""
|
|
raise NotImplementedError()
|
|
|
|
def __init__(self):
|
|
self.error_handler = None
|
|
|
|
@abc.abstractmethod
|
|
def load(self, path, upper_bound_size, post_load_processor=None):
|
|
"""Starts the image loading procedure for the passed path.
|
|
How and when an image get's loaded depends on the implementation
|
|
of the used ImageLoader class.
|
|
|
|
Args:
|
|
path (str): the path to the image which should be loaded
|
|
upper_bound_size (tuple of (width: int, height: int)):
|
|
the maximal size to load data for
|
|
post_load_processor (PostLoadImageProcessor):
|
|
allows to apply changes to the recently loaded image
|
|
|
|
Returns:
|
|
ImageHolder: which the image will be assigned to
|
|
"""
|
|
raise NotImplementedError()
|
|
|
|
def register_error_handler(self, error_handler):
|
|
"""Set's the error handler to the passed function.
|
|
An error handler will be called with exceptions which were
|
|
raised during loading an image.
|
|
|
|
Args:
|
|
error_handler (Function(Exception)):
|
|
the function which should be called
|
|
to handle an error
|
|
"""
|
|
self.error_handler = error_handler
|
|
|
|
def process_error(self, exception):
|
|
"""Processes an exception.
|
|
Calls the error_handler with the exception
|
|
if there's any.
|
|
|
|
Args:
|
|
exception (Exception): the occurred error
|
|
"""
|
|
if (self.error_handler is not None and
|
|
exception is not None):
|
|
self.error_handler(exception)
|
|
|
|
def __enter__(self):
|
|
pass
|
|
|
|
def __exit__(self, *_):
|
|
"""Finalises the image loader."""
|
|
pass
|
|
|
|
|
|
class SynchronousImageLoader(ImageLoader):
|
|
"""Implementation of ImageLoader
|
|
which loads images right away in the same thread
|
|
it was requested to load the image.
|
|
"""
|
|
@staticmethod
|
|
def get_loader_name():
|
|
return "synchronous"
|
|
|
|
def load(self, path, upper_bound_size, post_load_processor=None):
|
|
image = None
|
|
|
|
try:
|
|
image, _ = load_image(path, None)
|
|
except OSError as exception:
|
|
self.process_error(exception)
|
|
|
|
if image and post_load_processor:
|
|
image = post_load_processor.on_loaded(image)
|
|
|
|
return ImageHolder(path, image or self.PLACEHOLDER)
|
|
|
|
|
|
class AsynchronousImageLoader(ImageLoader):
|
|
"""Extension of ImageLoader
|
|
which adds basic functionality
|
|
needed to implement asynchron image loading.
|
|
"""
|
|
@enum.unique
|
|
class Priority(enum.Enum):
|
|
"""Enum which defines the possible priorities
|
|
of queue entries.
|
|
"""
|
|
HIGH = enum.auto()
|
|
LOW = enum.auto()
|
|
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.__queue = queue.Queue()
|
|
self.__queue_low_priority = queue.Queue()
|
|
self.__waiter_low_priority = threading.Condition()
|
|
|
|
def _enqueue(self, queue, image_holder, upper_bound_size, post_load_processor):
|
|
"""Enqueues the image holder weakly referenced.
|
|
|
|
Args:
|
|
queue (queue.Queue): the queue to operate on
|
|
image_holder (ImageHolder):
|
|
the image holder for which an image should be loaded
|
|
upper_bound_size (tuple of (width: int, height: int)):
|
|
the maximal size to load data for
|
|
post_load_processor (PostLoadImageProcessor):
|
|
allows to apply changes to the recently loaded image
|
|
"""
|
|
queue.put((
|
|
weakref.ref(image_holder), upper_bound_size, post_load_processor))
|
|
|
|
def _dequeue(self, queue):
|
|
"""Removes queue entries till an alive reference was found.
|
|
The referenced image holder will be returned in this case.
|
|
Otherwise if there wasn't found any alive reference
|
|
None will be returned.
|
|
|
|
Args:
|
|
queue (queue.Queue): the queue to operate on
|
|
|
|
Returns:
|
|
tuple of (ImageHolder, tuple of (width: int, height: int),
|
|
PostLoadImageProcessor):
|
|
an queued image holder or None, upper bound size or None,
|
|
the post load image processor or None
|
|
"""
|
|
holder_reference = None
|
|
image_holder = None
|
|
upper_bound_size = None
|
|
post_load_processor = None
|
|
|
|
while not queue.empty():
|
|
holder_reference, upper_bound_size, post_load_processor = \
|
|
queue.get_nowait()
|
|
image_holder = holder_reference and holder_reference()
|
|
if (holder_reference is None or
|
|
image_holder is not None):
|
|
break
|
|
|
|
return image_holder, upper_bound_size, post_load_processor
|
|
|
|
@abc.abstractmethod
|
|
def _schedule(self, function, priority):
|
|
"""Schedules the execution of a function.
|
|
Functions should be executed in different thread pools
|
|
based on their priority otherwise you can wait for a death lock.
|
|
|
|
Args:
|
|
function (Function): the function which should be executed
|
|
priority (AsynchronImageLoader.Priority):
|
|
the priority of the execution of this function
|
|
"""
|
|
raise NotImplementedError()
|
|
|
|
def _load_image(self, path, upper_bound_size, post_load_processor):
|
|
"""Wrapper for calling load_image.
|
|
Behaves like calling it directly,
|
|
but allows e.g. executing the function in other processes.
|
|
"""
|
|
image, *other_data = load_image(path, upper_bound_size)
|
|
|
|
if image and post_load_processor:
|
|
image = post_load_processor.on_loaded(image)
|
|
|
|
return (image, *other_data)
|
|
|
|
def load(self, path, upper_bound_size, post_load_processor=None):
|
|
holder = ImageHolder(path)
|
|
self._enqueue(
|
|
self.__queue, holder, upper_bound_size, post_load_processor)
|
|
self._schedule(self.__process_high_priority_entry,
|
|
self.Priority.HIGH)
|
|
return holder
|
|
|
|
def __wait_for_main_work(self):
|
|
"""Waits till all queued high priority entries were processed."""
|
|
if not self.__queue.empty():
|
|
with self.__waiter_low_priority:
|
|
if not self.__queue.empty():
|
|
self.__waiter_low_priority.wait()
|
|
|
|
def __notify_main_work_done(self):
|
|
"""Notifies waiting threads that
|
|
all queued high priority entries were processed.
|
|
"""
|
|
if self.__queue.empty():
|
|
with self.__waiter_low_priority:
|
|
if self.__queue.empty():
|
|
self.__waiter_low_priority.notify_all()
|
|
|
|
def __process_high_priority_entry(self):
|
|
"""Processes a single queued high priority entry."""
|
|
self.__process_queue(self.__queue)
|
|
self.__notify_main_work_done()
|
|
|
|
def __process_low_priority_entry(self):
|
|
"""Processes a single queued low priority entry."""
|
|
self.__wait_for_main_work()
|
|
self.__process_queue(self.__queue_low_priority)
|
|
|
|
def __process_queue(self, queue):
|
|
"""Processes a single queued entry.
|
|
|
|
Args:
|
|
queue (queue.Queue): the queue to operate on
|
|
"""
|
|
image = None
|
|
image_holder, upper_bound_size, post_load_processor = \
|
|
self._dequeue(queue)
|
|
if image_holder is None:
|
|
return
|
|
|
|
try:
|
|
image, downscaled = self._load_image(
|
|
image_holder.path, upper_bound_size, post_load_processor)
|
|
if upper_bound_size and downscaled:
|
|
self._enqueue(
|
|
self.__queue_low_priority,
|
|
image_holder, None, post_load_processor)
|
|
self._schedule(self.__process_low_priority_entry,
|
|
self.Priority.LOW)
|
|
except OSError as exception:
|
|
self.process_error(exception)
|
|
finally:
|
|
image_holder.reveal_image(image or self.PLACEHOLDER)
|
|
|
|
|
|
# * Pythons GIL limits the usefulness of threads.
|
|
# So in order to use all cpu cores (assumed GIL isn't released)
|
|
# you need to use multiple processes.
|
|
# * Pillows load method will read & decode the image.
|
|
# So it does the I/O and CPU work.
|
|
# Decoding seems to be the bottleneck for large images.
|
|
# * Using multiple processes comes with it's own bottleneck
|
|
# (transfering the data between the processes).
|
|
#
|
|
# => Using multiple processes seems to be faster for small images.
|
|
# Using threads seems to be faster for large images.
|
|
class ThreadImageLoader(AsynchronousImageLoader):
|
|
"""Implementation of AsynchronImageLoader
|
|
which loads images in multiple threads.
|
|
"""
|
|
@staticmethod
|
|
def get_loader_name():
|
|
return "thread"
|
|
|
|
def __init__(self):
|
|
super().__init__()
|
|
threads = os.cpu_count()
|
|
threads_low_priority = max(1, threads // 2)
|
|
self.__executor = thread.DaemonThreadPoolExecutor(
|
|
max_workers=threads)
|
|
self.__executor_low_priority = thread.DaemonThreadPoolExecutor(
|
|
max_workers=threads_low_priority)
|
|
self.threads = threads + threads_low_priority
|
|
|
|
def __exit__(self, *_):
|
|
self.__executor_low_priority.shutdown()
|
|
self.__executor.shutdown()
|
|
|
|
def _schedule(self, function, priority):
|
|
executor = self.__executor
|
|
if priority == self.Priority.LOW:
|
|
executor = self.__executor_low_priority
|
|
executor.submit(function) \
|
|
.add_done_callback(
|
|
lambda future: self.process_error(future.exception()))
|
|
|
|
|
|
class ProcessImageLoader(ThreadImageLoader):
|
|
"""Implementation of AsynchronImageLoader
|
|
which loads images in multiple processes.
|
|
Therefore it allows to utilise all cpu cores
|
|
for decoding an image.
|
|
"""
|
|
@staticmethod
|
|
def get_loader_name():
|
|
return "process"
|
|
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.__executor_loader = concurrent.futures.ProcessPoolExecutor(
|
|
max_workers=self.threads)
|
|
# ProcessPoolExecutor won't work
|
|
# when used first in ThreadPoolExecutor
|
|
self.__executor_loader \
|
|
.submit(id, id) \
|
|
.result()
|
|
|
|
def __exit__(self, *args):
|
|
super().__exit__(*args)
|
|
self.__executor_loader.shutdown()
|
|
|
|
@staticmethod
|
|
def _load_image_extern(path, upper_bound_size, post_load_processor):
|
|
"""This function is a wrapper for the image loading function
|
|
as sometimes pillow restores decoded images
|
|
received from other processes wrongly.
|
|
E.g. a PNG is reported as webp (-> crash on using an image function)
|
|
So this function is a workaround which prevents these crashs to happen.
|
|
"""
|
|
image, *other_data = load_image(path, upper_bound_size)
|
|
|
|
if image and post_load_processor:
|
|
image = post_load_processor.on_loaded(image)
|
|
|
|
return (image.mode, image.size, image.tobytes(), *other_data)
|
|
|
|
def _load_image(self, path, upper_bound_size, post_load_processor=None):
|
|
import PIL.Image
|
|
future = self.__executor_loader.submit(
|
|
ProcessImageLoader._load_image_extern,
|
|
path, upper_bound_size, post_load_processor)
|
|
mode, size, data, downscaled = future.result()
|
|
return PIL.Image.frombytes(mode, size, data), downscaled
|
|
|
|
|
|
@enum.unique
|
|
class ImageLoaderOption(str, enum.Enum):
|
|
"""Enum which lists the useable ImageLoader classes."""
|
|
SYNCHRONOUS = SynchronousImageLoader
|
|
THREAD = ThreadImageLoader
|
|
PROCESS = ProcessImageLoader
|
|
|
|
def __new__(cls, loader_class):
|
|
inst = str.__new__(cls)
|
|
# Based on an official example
|
|
# https://docs.python.org/3/library/enum.html#using-a-custom-new
|
|
# So.. stfu pylint
|
|
# pylint: disable=protected-access
|
|
inst._value_ = loader_class.get_loader_name()
|
|
inst.loader_class = loader_class
|
|
return inst
|