diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0c1387dae..5080124dc 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -10,20 +10,11 @@ repos: - id: check-symlinks - id: check-added-large-files -- repo: https://github.com/myint/docformatter - rev: v1.4 - hooks: - - id: docformatter - -- repo: https://github.com/myint/rstcheck - rev: '3f92957478422df87bd730abde66f089cc1ee19b' +- repo: https://gitlab.com/PyCQA/flake8 + rev: 3.9.0 hooks: - - id: rstcheck - args: [ - "--report", "warning", - "--ignore-roles", "class", - "--ignore-directives", "autoclass,automodule", - ] + - id: flake8 + args: ["--max-line-length=150"] - repo: https://github.com/pre-commit/mirrors-autopep8 rev: v1.5.6 @@ -48,8 +39,17 @@ repos: "--ignore", "E226,E24,W50,W690,E402" ] -- repo: https://github.com/PyCQA/flake8 - rev: 3.9.0 +- repo: https://github.com/myint/docformatter + rev: v1.4 hooks: - - id: flake8 - args: ["--max-line-length=150"] + - id: docformatter + +- repo: https://github.com/myint/rstcheck + rev: '3f92957478422df87bd730abde66f089cc1ee19b' + hooks: + - id: rstcheck + args: [ + "--report", "warning", + "--ignore-roles", "class", + "--ignore-directives", "autoclass,automodule", + ] diff --git a/pitop/camera/camera.py b/pitop/camera/camera.py index 78048d431..b65a8be03 100644 --- a/pitop/camera/camera.py +++ b/pitop/camera/camera.py @@ -4,7 +4,10 @@ FrameHandler, CameraTypes) from .core.capture_actions import CaptureActions -from pitop.core import ImageFunctions +from pitop.core.functions import ( + image_format_check, + image_convert, +) from pitop.core.mixins import ( Stateful, Recreatable, @@ -95,7 +98,7 @@ def format(self): @format.setter def format(self, format_value): - ImageFunctions.image_format_check(format_value) + image_format_check(format_value) self._format = format_value.lower() @classmethod @@ -251,7 +254,7 @@ def __get_processed_current_frame(self): image = self.__frame_handler.frame if self.format.lower() == "opencv": - image = ImageFunctions.convert(image, format="opencv") + image = image_convert(image, format="opencv") return image diff --git a/pitop/camera/core/cameras/file_system_camera.py b/pitop/camera/core/cameras/file_system_camera.py index df4a036d9..bd80cf99b 100644 --- a/pitop/camera/core/cameras/file_system_camera.py +++ b/pitop/camera/core/cameras/file_system_camera.py @@ -1,11 +1,11 @@ -from pitop.core import ImageFunctions +from pitop.core.functions import get_pil_image_from_path import os class FsImage: def __init__(self, path: str): self.path = path - self.data = ImageFunctions.get_pil_image_from_path(self.path) + self.data = get_pil_image_from_path(self.path) if self.data is None: raise AttributeError(f"Couldn't load image {path}") diff --git a/pitop/camera/core/capture_actions/generic_action.py b/pitop/camera/core/capture_actions/generic_action.py index 53ad924a3..df98498d6 100644 --- a/pitop/camera/core/capture_actions/generic_action.py +++ b/pitop/camera/core/capture_actions/generic_action.py @@ -1,6 +1,6 @@ from .capture_action_base import CaptureActionBase -from pitop.core import ImageFunctions +from pitop.core.functions import image_convert from concurrent.futures import ThreadPoolExecutor from inspect import signature @@ -29,7 +29,7 @@ def __del__(self): def process(self, frame): if isinstance(self.__format, str) and self.__format.lower() == 'opencv': - frame = ImageFunctions.convert(frame, format="opencv") + frame = image_convert(frame, format="opencv") if self.__elapsed_frames % self.__frame_interval == 0: if self.callback_has_argument: diff --git a/pitop/core/ImageFunctions.py b/pitop/core/ImageFunctions.py deleted file mode 100644 index c978c9865..000000000 --- a/pitop/core/ImageFunctions.py +++ /dev/null @@ -1,65 +0,0 @@ -from PIL import Image -from numpy import ( - asarray, - ndarray, -) -from urllib.request import urlopen - -from pitopcommon.formatting import is_url - - -def image_format_check(format): - assert isinstance(format, str) - assert format.lower() in ("pil", "opencv") - - -def convert(image, format="PIL"): - - try: - from cv2 import ( - cvtColor, - COLOR_BGR2RGB, - COLOR_RGB2BGR, - ) - except (ImportError, ModuleNotFoundError): - raise ModuleNotFoundError( - "OpenCV Python library is not installed. You can install it by running 'sudo apt install python3-opencv libatlas-base-dev'.") from None - - image_format_check(format) - format = format.lower() - - # Image type is already correct - return image - if any([ - isinstance(image, Image.Image) and format == "pil", - isinstance(image, ndarray) and format == "opencv" - ]): - return image - elif isinstance(image, Image.Image) and format == "opencv": - # Convert PIL to OpenCV - cv_image = asarray(image) - if image.mode == "RGB": - cv_image = cvtColor(cv_image, COLOR_RGB2BGR) - return cv_image - elif isinstance(image, ndarray) and format == "pil": - # Convert OpenCV to PIL - if len(image.shape) > 2 and image.shape[2] == 3: - # If incoming image has 3 channels, convert from BGR to RGB - image = cvtColor(image, COLOR_BGR2RGB) - return Image.fromarray(image) - - -def get_pil_image_from_path(file_path_or_url): - if is_url(file_path_or_url): - image_path = urlopen(file_path_or_url) - else: - image_path = file_path_or_url - - image = Image.open(image_path) - - # Verify on deep copy to avoid needing to close and - # re-open after verifying... - test_image = image.copy() - # Raise exception if there's an issue with the image - test_image.verify() - - return image diff --git a/pitop/core/functions.py b/pitop/core/functions.py new file mode 100644 index 000000000..82619b3c9 --- /dev/null +++ b/pitop/core/functions.py @@ -0,0 +1,125 @@ +from numpy import ( + asarray, + ndarray, +) +from PIL import Image, ImageFont +from re import split +from urllib.request import urlopen + +from pitopcommon.formatting import is_url + + +def get_text_size(font_filename, font_size, text): + return ImageFont.truetype(font_filename, font_size).getsize(text) + + +def get_word_wrapped_text(text, font_filename, font_size, max_width): + # Push and pop each line to stack + output_text = list() + + # TODO: add height support + remaining = max_width + + # Split up text based on all whitespace + for field in split(r'(\s+)', text): + # Get text size + field_width, field_height = get_text_size(font_filename, font_size, str(field)) + if field_width > remaining: + # Update remaining width + remaining = max_width - field_width + + # Not enough space to add to current line - start new one + output_text.append(field) + else: + # Update remaining width + remaining = remaining - field_width + + # Is enough space + if not output_text: + # First time - just append + output_text.append(field) + else: + # Pop latest line from list + # Append the field with a space + # Add back to list + output_text.append(output_text.pop() + f" {field}") + + return "\n".join(output_text) + + +def get_font_size(text, font_filename, word_wrap, max_width=None, max_height=None): + if max_width is None and max_height is None: + raise ValueError('You need to pass max_width or max_height') + + font_size = 1 + text_width, text_height = get_text_size(font_filename, font_size, text) + + if (max_width is not None and text_width > max_width) or \ + (max_height is not None and text_height > max_height): + raise ValueError("Text can't be filled in only (%dpx, %dpx)" % (text_width, text_height)) + + # Increase font size until width or height exceeds box + # TODO: add word wrapping + while True: + if (max_width is not None and text_width >= max_width) or \ + (max_height is not None and text_height >= max_height): + return font_size - 1 + font_size += 1 + text_width, text_height = get_text_size(font_filename, font_size, text) + + +def image_format_check(format): + assert isinstance(format, str) + assert format.lower() in ("pil", "opencv") + + +def image_convert(image, format="PIL"): + + try: + from cv2 import ( + cvtColor, + COLOR_BGR2RGB, + COLOR_RGB2BGR, + ) + except (ImportError, ModuleNotFoundError): + raise ModuleNotFoundError( + "OpenCV Python library is not installed. You can install it by running 'sudo apt install python3-opencv libatlas-base-dev'.") from None + + image_format_check(format) + format = format.lower() + + # Image type is already correct - return image + if any([ + isinstance(image, Image.Image) and format == "pil", + isinstance(image, ndarray) and format == "opencv" + ]): + return image + elif isinstance(image, Image.Image) and format == "opencv": + # Convert PIL to OpenCV + cv_image = asarray(image) + if image.mode == "RGB": + cv_image = cvtColor(cv_image, COLOR_RGB2BGR) + return cv_image + elif isinstance(image, ndarray) and format == "pil": + # Convert OpenCV to PIL + if len(image.shape) > 2 and image.shape[2] == 3: + # If incoming image has 3 channels, convert from BGR to RGB + image = cvtColor(image, COLOR_BGR2RGB) + return Image.fromarray(image) + + +def get_pil_image_from_path(file_path_or_url): + if is_url(file_path_or_url): + image_path = urlopen(file_path_or_url) + else: + image_path = file_path_or_url + + image = Image.open(image_path) + + # Verify on deep copy to avoid needing to close and + # re-open after verifying... + test_image = image.copy() + # Raise exception if there's an issue with the image + test_image.verify() + + return image diff --git a/pitop/miniscreen/oled/oled.py b/pitop/miniscreen/oled/oled.py index c094f714f..718ec232b 100644 --- a/pitop/miniscreen/oled/oled.py +++ b/pitop/miniscreen/oled/oled.py @@ -1,4 +1,8 @@ -from pitop.core import ImageFunctions +from pitop.core.functions import ( + get_pil_image_from_path, + # get_word_wrapped_text, + get_font_size, +) from .core import ( Canvas, FPS_Regulator, @@ -259,7 +263,7 @@ def display_image_file(self, file_path_or_url, xy=None, invert=False): :param bool invert: Set to True to flip the on/off state of each pixel in the image """ self.display_image( - ImageFunctions.get_pil_image_from_path(file_path_or_url), + get_pil_image_from_path(file_path_or_url), xy=xy, invert=invert, ) @@ -284,9 +288,18 @@ def display_image(self, image, xy=None, invert=False): invert=invert, ) - def display_text(self, text, xy=None, font_size=None, invert=False): - """Renders a single line of text to the screen at a given position and - size. + def display_text( + self, + text, + xy=None, + font_size=None, + invert=False, + word_wrap=True, + align="justify", + max_width=None, + max_height=None + ): + """Renders text to the screen at a given position and size. The display's positional properties (e.g. `top_left`, `top_right`) can be used to assist with specifying the `xy` position parameter. @@ -298,105 +311,57 @@ def display_text(self, text, xy=None, font_size=None, invert=False): :param int font_size: The font size in pixels. If not provided or passed as `None`, the default font size will be used :param bool invert: Set to True to flip the on/off state of each pixel in the image + :param word_wrap: Add newlines to text that is too wide for the display """ - if xy is None: - xy = self.top_left - if font_size is None: - font_size = 30 - - # Create empty image image = self.__empty_image - # 'Draw' text to empty image, using desired font size - ImageDraw.Draw(image).text( - xy, - str(text), - font=ImageFont.truetype( - self.__font_path(), - size=font_size - ), - fill=1, - spacing=0, - align="left" - ) - - # Display image - self.display_image(image, invert=invert) - - def display_multiline_text(self, text, xy=None, font_size=None): - """Renders multi-lined text to the screen at a given position and size. - Text that is too long for the screen will automatically wrap to the - next line. + x, y = xy - The display's positional properties (e.g. `top_left`, `top_right`) can be used to assist with - specifying the `xy` position parameter. + # Limit to bottom right of the image if not specified + if max_width is None: + max_width = image.width - x - :param string text: The text to render - :param tuple xy: The position on the screen to render the image. If not - provided or passed as `None` the image will be drawn in the top-left of - the screen. - :param int font_size: The font size in pixels. If not provided or passed as - `None`, the default font size will be used - """ - if xy is None: - xy = self.top_left + if max_height is None: + max_height = image.height - y + # Dynamic font size if not specified if font_size is None: - font_size = 30 - # Create empty image - image = self.__empty_image - - # Create font - font = ImageFont.truetype( - self.__font_path(), - size=font_size - ) + # if word_wrap: + # text = get_word_wrapped_text( + # text, + # self.__font_path, + # font_size, + # xy, + # image + # ) + + font_size = get_font_size( + text, + self.__font_path, + word_wrap, + max_width, + max_height + ) - def format_multiline_text(text): - def get_text_size(text): - return ImageDraw.Draw(self.__empty_image).textsize( - text=str(text), - font=font, - spacing=0, - ) + if xy is None: + xy = self.top_left - remaining = self.width - space_width, _ = get_text_size(" ") - # use this list as a stack, push/popping each line - output_text = [] - # split on whitespace... - for word in text.split(None): - word_width, _ = get_text_size(word) - if word_width + space_width > remaining: - output_text.append(word) - remaining = self.width - word_width - else: - if not output_text: - output_text.append(word) - else: - output = output_text.pop() - output += " %s" % word - output_text.append(output) - remaining = remaining - (word_width + space_width) - return "\n".join(output_text) - - # Format text - text = format_multiline_text(text) - - # 'Draw' text to empty image, using desired font size - ImageDraw.Draw(image).multiline_text( - xy, - str(text), - font=font, + ImageDraw.Draw(image).text( + (x, y), + text, + font=ImageFont.truetype( + self.__font_path, + font_size + ), fill=1, spacing=0, - align="left" + align=align ) # Display image - self.display_image(image) + self.display_image(image, invert=invert) def __display(self, image_to_display, force=False, invert=False): self.stop_animated_image() @@ -423,7 +388,7 @@ def play_animated_image_file(self, file_path_or_url, background=False, loop=Fals :param bool loop: Set whether the image animation should start again when it has finished """ - image = ImageFunctions.get_pil_image_from_path(file_path_or_url) + image = get_pil_image_from_path(file_path_or_url) self.play_animated_image(image, background, loop) def play_animated_image(self, image, background=False, loop=False): @@ -527,6 +492,24 @@ def bottom_right(self): ####################### # Deprecation support # ####################### + def display_multiline_text(self, text, xy=None, font_size=None): + """Renders multi-lined text to the screen at a given position and size. + + .. warning:: + This method is deprecated and will be deleted on the next major release of the SDK. + + The display's positional properties (e.g. `top_left`, `top_right`) can be used to assist with + specifying the `xy` position parameter. + + :param string text: The text to render + :param tuple xy: The position on the screen to render the image. If not + provided or passed as `None` the image will be drawn in the top-left of + the screen. + :param int font_size: The font size in pixels. If not provided or passed as + `None`, the default font size will be used + """ + self.display_text(text, xy=None, font_size=None, word_wrap=True) + def display(self, force=False): """Displays what is on the current canvas to the screen as a single frame. @@ -655,6 +638,7 @@ def __cleanup(self): if self.__file_monitor_thread is not None and self.__file_monitor_thread.is_alive(): self.__file_monitor_thread.join(0) + @property def __font_path(self): primary_font_path = "/usr/share/fonts/opentype/FSMePro/FSMePro-Light.otf" fallback_font_path = "/usr/share/fonts/truetype/droid/DroidSansFallbackFull.ttf" diff --git a/pitop/processing/algorithms/line_detect.py b/pitop/processing/algorithms/line_detect.py index f1748b4c3..56fae45f7 100644 --- a/pitop/processing/algorithms/line_detect.py +++ b/pitop/processing/algorithms/line_detect.py @@ -7,7 +7,7 @@ import_opencv, scale_frame, ) -from pitop.core import ImageFunctions +from pitop.core.functions import image_convert def calculate_blue_limits(): @@ -32,7 +32,7 @@ def calculate_blue_limits(): def process_frame_for_line(frame, image_format="PIL", scale_factor=0.5): cv2 = import_opencv() - cv_frame = ImageFunctions.convert(frame, format="OpenCV") + cv_frame = image_convert(frame, format="OpenCV") resized_frame = scale_frame(cv_frame, scale=scale_factor) hsv_lower, hsv_upper = calculate_blue_limits() @@ -54,7 +54,7 @@ def process_frame_for_line(frame, image_format="PIL", scale_factor=0.5): robot_view_img = robot_view(resized_frame, image_mask, line_contour, scaled_image_centroid) if image_format.lower() != 'opencv': - robot_view_img = ImageFunctions.convert(robot_view_img, format="PIL") + robot_view_img = image_convert(robot_view_img, format="PIL") class dotdict(dict): """dot.notation access to dictionary attributes."""