Home-Assistant-custom-components-Xiaomi-Cloud-Map-Extractor icon indicating copy to clipboard operation
Home-Assistant-custom-components-Xiaomi-Cloud-Map-Extractor copied to clipboard

Improve speed and memory usage

Open Teka101 opened this issue 4 years ago • 2 comments

Hello,

Here a little test about using cairo instead of PIL in python.

For benchmarking it, i use the same file with scale factor at 10:

  • PIL / original code: memory peak usage at 2mbytes for a total duration of 3,45s
  • cairo: memory peak usage at 32kbytes for a total duration of 0,31s

The patch provided is tester under python / windows, but it should work for raspberry pi

Limit:

  • cairo only support save png to filesystem (so you can save it in /tmp and get raw data from file)
  • using font file is untested (but it should be ok)

Patch:

diff --git a/custom_components/xiaomi_cloud_map_extractor/image_handler.py b/custom_components/xiaomi_cloud_map_extractor/image_handler.py
index fdc1ac4..48a95e6 100644
--- a/custom_components/xiaomi_cloud_map_extractor/image_handler.py
+++ b/custom_components/xiaomi_cloud_map_extractor/image_handler.py
@@ -1,6 +1,6 @@
 import logging
-from typing import Callable
-from PIL import Image, ImageDraw, ImageFont
+import cairo
+import math
 
 from .const import *
 
@@ -63,29 +63,29 @@ class ImageHandler:
         trim_bottom = int(image_config[CONF_TRIM][CONF_BOTTOM] * height / 100)
         trimmed_height = height - trim_top - trim_bottom
         trimmed_width = width - trim_left - trim_right
-        image = Image.new('RGBA', (trimmed_width, trimmed_height))
-        if width == 0 or height == 0:
-            return ImageHandler.create_empty_map(colors)
-        pixels = image.load()
+        image = cairo.ImageSurface(cairo.FORMAT_ARGB32, int(trimmed_width * scale), int(trimmed_height * scale))
+        ctx = cairo.Context(image)
+        ctx.scale(scale, scale)
         for img_y in range(trimmed_height):
             for img_x in range(trimmed_width):
                 pixel_type = raw_data[img_x + trim_left + width * (img_y + trim_bottom)]
                 x = img_x
                 y = trimmed_height - img_y - 1
+                c = None
                 if pixel_type == ImageHandler.MAP_OUTSIDE:
-                    pixels[x, y] = ImageHandler.__get_color__(COLOR_MAP_OUTSIDE, colors)
+                    c = ImageHandler.__get_color__(COLOR_MAP_OUTSIDE, colors)
                 elif pixel_type == ImageHandler.MAP_WALL:
-                    pixels[x, y] = ImageHandler.__get_color__(COLOR_MAP_WALL, colors)
+                    c = ImageHandler.__get_color__(COLOR_MAP_WALL, colors)
                 elif pixel_type == ImageHandler.MAP_INSIDE:
-                    pixels[x, y] = ImageHandler.__get_color__(COLOR_MAP_INSIDE, colors)
+                    c = ImageHandler.__get_color__(COLOR_MAP_INSIDE, colors)
                 elif pixel_type == ImageHandler.MAP_SCAN:
-                    pixels[x, y] = ImageHandler.__get_color__(COLOR_SCAN, colors)
+                    c = ImageHandler.__get_color__(COLOR_SCAN, colors)
                 else:
                     obstacle = pixel_type & 0x07
                     if obstacle == 0:
-                        pixels[x, y] = ImageHandler.__get_color__(COLOR_GREY_WALL, colors)
+                        c = ImageHandler.__get_color__(COLOR_GREY_WALL, colors)
                     elif obstacle == 1:
-                        pixels[x, y] = ImageHandler.__get_color__(COLOR_MAP_WALL_V2, colors)
+                        c = ImageHandler.__get_color__(COLOR_MAP_WALL_V2, colors)
                     elif obstacle == 7:
                         room_number = (pixel_type & 0xFF) >> 3
                         room_x = img_x + trim_left
@@ -98,26 +98,14 @@ class ImageHandler:
                                                   max(rooms[room_number][2], room_x),
                                                   max(rooms[room_number][3], room_y))
                         default = ImageHandler.ROOM_COLORS[room_number >> 1]
-                        pixels[x, y] = ImageHandler.__get_color__(f"{COLOR_ROOM_PREFIX}{room_number}", colors, default)
+                        c = ImageHandler.__get_color__(f"{COLOR_ROOM_PREFIX}{room_number}", colors, default)
                     else:
-                        pixels[x, y] = ImageHandler.__get_color__(COLOR_UNKNOWN, colors)
-        if image_config["scale"] != 1 and width != 0 and height != 0:
-            image = image.resize((int(trimmed_width * scale), int(trimmed_height * scale)), resample=Image.NEAREST)
-        return image, rooms
-
-    @staticmethod
-    def create_empty_map(colors):
-        color = ImageHandler.__get_color__(COLOR_MAP_OUTSIDE, colors)
-        image = Image.new('RGBA', (100, 100), color=color)
-        if sum(color[0:3]) > 382:
-            text_color = (0, 0, 0)
-        else:
-            text_color = (255, 255, 255)
-        draw = ImageDraw.Draw(image, "RGBA")
-        text = "NO MAP"
-        w, h = draw.textsize(text)
-        draw.text((50 - w / 2, 50 - h / 2), text, fill=text_color)
-        return image, {}
+                        c = ImageHandler.__get_color__(COLOR_UNKNOWN, colors)
+                ctx.set_source_rgba(*c)
+                ctx.rectangle(x, y, 1, 1)
+                ctx.fill()
+        ctx.scale(1 / scale, 1 / scale)
+        return [image, ctx], rooms
 
     @staticmethod
     def get_room_at_pixel(raw_data: bytes, width, x, y):
@@ -154,10 +142,15 @@ class ImageHandler:
 
     @staticmethod
     def draw_walls(image, walls, colors):
-        draw = ImageDraw.Draw(image.data, 'RGBA')
+        ctx = image.data[1]
+        color = ImageHandler.__get_color__(COLOR_VIRTUAL_WALLS, colors)
+
+        ctx.set_source_rgba(*color)
         for wall in walls:
-            draw.line(wall.to_img(image.dimensions).as_list(),
-                      ImageHandler.__get_color__(COLOR_VIRTUAL_WALLS, colors), width=2)
+            s = wall.to_img(image.dimensions).as_list()
+            ctx.move_to(s[0], s[1])
+            ctx.line_to(s[2], s[3])
+            ctx.stroke()
 
     @staticmethod
     def draw_zones(image, zones, colors):
@@ -185,88 +178,93 @@ class ImageHandler:
     @staticmethod
     def rotate(image):
         if image.dimensions.rotation == 90:
-            image.data = image.data.transpose(Image.ROTATE_90)
+            image.data[1].rotate(90)
         if image.dimensions.rotation == 180:
-            image.data = image.data.transpose(Image.ROTATE_180)
+            image.data[1].rotate(180)
         if image.dimensions.rotation == 270:
-            image.data = image.data.transpose(Image.ROTATE_270)
+            image.data[1].rotate(270)
 
     @staticmethod
     def draw_texts(image, texts):
         for text_config in texts:
-            x = text_config[CONF_X] * image.data.size[0] / 100
-            y = text_config[CONF_Y] * image.data.size[1] / 100
+            x = text_config[CONF_X] * image.data[0].get_width() / 100
+            y = text_config[CONF_Y] * image.data[0].get_height() / 100
             ImageHandler.__draw_text__(image, text_config[CONF_TEXT], x, y, text_config[CONF_COLOR],
                                        text_config[CONF_FONT], text_config[CONF_FONT_SIZE])
 
     @staticmethod
     def __draw_circle__(image, center, r, outline, fill):
-        def draw_func(draw: ImageDraw):
-            point = center.to_img(image.dimensions)
-            coords = [point.x - r, point.y - r, point.x + r, point.y + r]
-            draw.ellipse(coords, outline=outline, fill=fill)
+        ctx = image.data[1]
+        point = center.to_img(image.dimensions)
 
-        ImageHandler.__draw_on_new_layer__(image, draw_func)
+        ctx.set_source_rgba(*fill)
+        ctx.arc(point.x, point.y, r, 0, 2 * math.pi)
+        ctx.fill()
+
+        ctx.set_source_rgba(*outline)
+        ctx.arc(point.x, point.y, r, 0, 2 * math.pi)
+        ctx.stroke()
 
     @staticmethod
     def __draw_areas__(image, areas, fill, outline):
         if len(areas) == 0:
             return
 
-        def draw_func(draw: ImageDraw):
-            for area in areas:
-                draw.polygon(area.to_img(image.dimensions).as_list(), fill, outline)
+        ctx = image.data[1]
+        for area in areas:
+            p = area.to_img(image.dimensions).as_list()
+
+            ctx.set_source_rgba(*fill)
+            ctx.move_to(p[0], p[1])
+            ctx.line_to(p[2], p[3])
+            ctx.line_to(p[4], p[5])
+            ctx.line_to(p[6], p[7])
+            ctx.close_path()
+            ctx.fill()
 
-        ImageHandler.__draw_on_new_layer__(image, draw_func)
+            ctx.set_source_rgba(*outline)
+            ctx.move_to(p[0], p[1])
+            ctx.line_to(p[2], p[3])
+            ctx.line_to(p[4], p[5])
+            ctx.line_to(p[6], p[7])
+            ctx.close_path()
+            ctx.stroke()
 
     @staticmethod
     def __draw_path__(image, path, color, scale):
         if len(path.path) < 2:
             return
-
-        def draw_func(draw: ImageDraw):
-            s = path.path[0].to_img(image.dimensions)
-            for point in path.path[1:]:
-                e = point.to_img(image.dimensions)
-                draw.line([s.x * scale, s.y * scale, e.x * scale, e.y * scale], width=int(scale), fill=color)
-                s = e
-
-        ImageHandler.__draw_on_new_layer__(image, draw_func, scale)
+        ctx = image.data[1]
+        ctx.set_line_join(cairo.LINE_JOIN_BEVEL)
+        ctx.set_source_rgba(*color)
+        s = path.path[0].to_img(image.dimensions)
+        ctx.move_to(s.x, s.y)
+        for point in path.path[1:]:
+            e = point.to_img(image.dimensions)
+            ctx.line_to(e.x, e.y)
+        ctx.stroke()
 
     @staticmethod
     def __draw_text__(image, text, x, y, color, font_file=None, font_size=None):
-        def draw_func(draw: ImageDraw):
-            font = ImageFont.load_default()
-            try:
-                if font_file is not None and font_size > 0:
-                    font = ImageFont.truetype(font_file, font_size)
-            except OSError:
-                _LOGGER.warning("Unable to find font file: %s", font_file)
-            except ImportError:
-                _LOGGER.warning("Unable to open font: %s", font_file)
-            finally:
-                w, h = draw.textsize(text, font)
-                draw.text((x - w / 2, y - h / 2), text, font=font, fill=color)
-
-        ImageHandler.__draw_on_new_layer__(image, draw_func)
+        ctx = image.data[1]
+        ctx.set_source_rgba(*color)
+        if font_file:
+            ctx.select_font_face(font_file)
+        if font_size:
+            ctx.set_font_size(font_size)
+        (_, _, w, h, _, _) = ctx.text_extents(text)
+        ctx.move_to(x - w / 2, y - h / 2)
+        ctx.show_text(text)
 
     @staticmethod
     def __get_color__(name, colors, default_name=None):
+        c = None
         if name in colors:
-            return colors[name]
-        if default_name is None:
-            return ImageHandler.COLORS[name]
-        return ImageHandler.COLORS[default_name]
-
-    @staticmethod
-    def __draw_on_new_layer__(image, draw_function: Callable, scale=1):
-        if scale == 1:
-            size = image.data.size
-        else:
-            size = [int(image.data.size[0] * scale), int(image.data.size[1] * scale)]
-        layer = Image.new("RGBA", size, (255, 255, 255, 0))
-        draw = ImageDraw.Draw(layer, "RGBA")
-        draw_function(draw)
-        if scale != 1:
-            layer = layer.resize(image.data.size, resample=Image.BOX)
-        image.data = Image.alpha_composite(image.data, layer)
+            c = colors[name]
+        if default_name is None and c is None:
+            c = ImageHandler.COLORS[name]
+        if c is None:
+            c = ImageHandler.COLORS[default_name]
+        if len(c) == 3:
+            c = [ c[0], c[1], c[2], 255 ]
+        return list(map(lambda x: x / 255, c))

test-pil-x10 test-cairo-x10

Teka101 avatar Apr 13 '21 12:04 Teka101

Hi! Thank you very much, these numbers look great! I'm a bit afraid about saving PNG to filesystem, it might not be supported for all HA installations.

I also need some time to process this change, especially I haven't event finished the previous one yet 😉

PiotrMachowski avatar Apr 13 '21 13:04 PiotrMachowski

Using /tmp to save image is safe (no write to sdcard) because the directory is always writeable for anyone.

And this patch needs a little more tests ;)

Teka101 avatar Apr 13 '21 14:04 Teka101