Skip to content

pygame.font.Font constructor fails to properly open all file streams of type typing.BinaryIO (1677) #930

Open
@GalacticEmperor1

Description

@GalacticEmperor1

Issue №1677 opened by MyreMylar at 2020-04-27 11:28:04

Context:

I am experimenting with generalised improvements to the speed and future proofing of loading resources in the Pygame GUI package.

From a fair bit of reading over the slightly muddled state of parallel programming in python, and after profiling a few other options - I determined that using a few python Threads from threading import Thread was likely a reasonable way to get some speed up when loading a long-ish list of files (five threads seems to be the optimum number on my dev machine).

My initial explorations were mainly done using pygame.Surfaces where, when loading about 20 of them, I saw roughly a 3x speed up when using 5 Threads over simple sequential loading - without any obvious (to me) negative side effects.

Problem:

While this multi-threaded loading approach works fine for pygame.Surfaces, it doesn't appear to work for pygame.font.Font objects, the other main resource used in Pygame GUI projects.

Desired outcome

pygame.font.Font objects are loadable on Threads other than the main thread - or somebody has an idea how I could achieve the same broad goal under the current system.

Example code

import pygame

from typing import Union, Any, Dict

from collections import namedtuple
from threading import Thread, Lock
from importlib.resources import open_binary

from queue import Queue

from fps_counter import FPSCounter


PackageResource = namedtuple('PackageResource', ('package', 'resource'))


class FontResource:
    def __init__(self, font_id: str, size: int, style: Dict[str, bool],
                 location: Union[PackageResource, str]):
        self.font_id = font_id
        self.size = size
        self.style = style
        self.location = location
        self.loaded_resource = None


class ImageResource:
    def __init__(self, image_id: str, location: Union[PackageResource, str]):
        self.image_id = image_id
        self.location = location
        self.loaded_resource = None


def complete_image_load(image_resource: ImageResource) -> ImageResource:
    with open_binary(image_resource.location.package,
                     image_resource.location.resource) as open_resource:
        image_resource.loaded_resource = pygame.image.load(open_resource)
        return image_resource


def complete_font_load(font_resource: FontResource) -> FontResource:
    with open_binary(font_resource.location.package,
                     font_resource.location.resource) as open_resource:
        font_resource.loaded_resource = pygame.font.Font(open_resource, font_resource.size)
        font_resource.loaded_resource.set_bold(font_resource.style['bold'])
        font_resource.loaded_resource.set_italic(font_resource.style['italic'])
        return font_resource


def complete_load(resource_loc: Union[FontResource, ImageResource]) -> Any:
    if isinstance(resource_loc, FontResource):
        return complete_font_load(resource_loc)
    elif isinstance(resource_loc, ImageResource):
        return complete_image_load(resource_loc)


class ClosableQueue(Queue):
    SENTINEL = object()

    def close(self):
        self.put(self.SENTINEL)

    def __iter__(self):
        while True:
            item = self.get()
            try:
                if item is self.SENTINEL:
                    return  #  Cause the thread to exit
                yield item
            finally:
                self.task_done()


class StoppableOutputWorker(Thread):
    def __init__(self, func, in_queue, out_list):
        super().__init__()
        self.func = func
        self.in_queue = in_queue
        self.out_list = out_list
        self.lock = Lock()

    def run(self):
        for item in self.in_queue:
            result = self.func(item)
            with self.lock:
                self.out_list.append(result)


def start_output_threads(count, *args):
    threads = [StoppableOutputWorker(*args) for _ in range(count)]
    for thread in threads:
        thread.start()
    return threads


def stop_threads(closable_queue, threads):
    for _ in threads:
        closable_queue.close()

    closable_queue.join()

    for thread in threads:
        thread.join()


class Camera:
    def __init__(self):
        self.dimensions = (800, 600)


def draw_images(window_surface, loaded_resources):
    x_pos = 0
    y_pos = 0
    for loaded_resource in loaded_resources:
        if isinstance(loaded_resource, ImageResource):
            window_surface.blit(loaded_resource.loaded_resource, (x_pos, y_pos))
            x_pos += 160
            if x_pos >= 800:
                x_pos = 0
                y_pos += 120
        #  Uncomment below to see error
        #  elif isinstance(loaded_resource.loaded_resource, pygame.font.Font):
        #      text_surf = loaded_resource.loaded_resource.render('Hello world',
        #                                                         False, pygame.Color('# FFFFFFFF'))
        #      window_surface.blit(text_surf, (x_pos, y_pos))
        #      x_pos += 160
        #      if x_pos >= 800:
        #          x_pos = 0
        #          y_pos += 120


if __name__ == '__main__':
    pygame.init()
    screen = pygame.display.set_mode((800, 600))    
    background = pygame.Surface((800, 600))
    background.fill(pygame.Color("# 000000"))
    camera = Camera()
    clock = pygame.time.Clock()
    load_timer = pygame.time.Clock()

    time_1 = load_timer.tick()

    font_list = [FontResource(font_id='agency_bold_14',
                              size=14,
                              style={'bold': True, 'italic': False},
                              location=PackageResource('fonts', 'AGENCYR.TTF')),
                 FontResource(font_id='verdana_regular_12',
                              size=12,
                              style={'bold': False, 'italic': False},
                              location=PackageResource('fonts', 'verdana.ttf')),
                 FontResource(font_id='verdana_regular_16',
                              size=16,
                              style={'bold': False, 'italic': False},
                              location=PackageResource('fonts', 'verdana.ttf')),
                 ImageResource(image_id='space_1',
                               location=PackageResource('images', 'space_1.jpg')),
                 ImageResource(image_id='space_2',
                               location=PackageResource('images', 'space_2.jpg')),
                 ImageResource(image_id='space_3',
                               location=PackageResource('images', 'space_3.jpg')),
                 ImageResource(image_id='space_4',
                               location=PackageResource('images', 'space_4.jpg')),
                 ImageResource(image_id='space_5',
                               location=PackageResource('images', 'space_5.jpg')),
                 ImageResource(image_id='space_6',
                               location=PackageResource('images', 'space_6.jpg')),
                 ImageResource(image_id='space_7',
                               location=PackageResource('images', 'space_7.jpg')),
                 ImageResource(image_id='space_8',
                               location=PackageResource('images', 'space_8.jpg')),
                 ImageResource(image_id='space_9',
                               location=PackageResource('images', 'space_9.jpg')),
                 ImageResource(image_id='space_10',
                               location=PackageResource('images', 'space_10.jpg'))
                 ]

    to_load_queue = ClosableQueue()
    done_loading_list = []

    for resource in font_list:
        to_load_queue.put(resource)

    resources_length = to_load_queue.qsize()
    loading_threads = start_output_threads(5, complete_load, to_load_queue, done_loading_list)

    loading_finished = False

    running = True
    while running:
        time_delta = clock.tick(60) / 1000.0
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                running = False

        if not loading_finished and len(done_loading_list) == resources_length:
            stop_threads(to_load_queue, loading_threads)

            time_2 = load_timer.tick()
            print("total time:" + str((time_2 - time_1) / 1000.0) + ' seconds')
            print(len(done_loading_list), 'items finished')
            loading_finished = True

        screen.blit(background, (0, 0))
        draw_images(screen, done_loading_list)
        pygame.display.flip()

Using example images downloaded from here:
https://spacetelescope.org/images/archive/top100/

Possibly confusing things:

  • I'm using importlib.resources for this example because apparently it is the wave of the future. Making use of it requires that your resource directories have a dunder init.py file inside of them.
  • importlib.resources requires python 3.7+, though there is a backport you could also try. Or you could switch the load functions to use old style file paths.
  • There is no error unless you uncomment the block of code which tries to use the loaded font objects - then you get pygame.error: Text has zero width as if the font module had been shut down (which I'm assuming is what is happening?).

Platform: Windows
Versions tested: 1.9.6, 2.0.0.dev6

Related Docs: https://www.pygame.org/docs/ref/font.html# pygame.font.Font

To Do to close:

  • Produce simple test case showing error.
  • Investigate why image loading and font loading appear to behave differently.
  • Fix in c code if possible.

Comments

# # MyreMylar commented at 2020-04-28 15:59:20

I've found a solution to this a day later. Apparently if you switch the font loading from:

def complete_font_load(font_resource: FontResource) -> FontResource:
    with open_binary(font_resource.location.package,
                     font_resource.location.resource) as open_resource:
        font_resource.loaded_resource = pygame.font.Font(open_resource, font_resource.size)
        font_resource.loaded_resource.set_bold(font_resource.style['bold'])
        font_resource.loaded_resource.set_italic(font_resource.style['italic'])
        return font_resource

Over to:

def complete_font_load(font_resource: FontResource) -> FontResource:
    font_resource.loaded_resource = pygame.font.Font(io.BytesIO(read_binary(font_resource.location.package,
                                                                            font_resource.location.resource)),
                                                     font_resource.size)
    font_resource.loaded_resource.set_bold(font_resource.style['bold'])
    font_resource.loaded_resource.set_italic(font_resource.style['italic'])
    return font_resource

It seems to work fine. This is using the read_binary() function from importlib.resources.

I'm thinking the issue is nothing to do with multi-threading and it's actually that the font loading is unable to handle a file object/stream of type:

typing.BinaryIO

versus the normal:

io.BytesIO

while the pygame.image.load() image loader is able to handle both types.

Not sure if this is that important - the silent handling of it only to error later is a bit weird though. Will update the title.


# # MyreMylar commented at 2022-04-07 12:10:43

This still happens in 2.1.3.

Metadata

Metadata

Assignees

No one assigned

    Labels

    fontpygame.font

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions